Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ Framework integration points are now instance-scoped (#198), and the Ktor integr
- Shared Spring Boot auto-configurations in storm-spring (`st.orm.spring.boot`): `StormTransactionAutoConfiguration` (Spring-aware provider beans), `StormValidationAutoConfiguration` (startup schema validation), and the `storm.*` configuration properties (`StormProperties`), used by both starters. Configuration keys are unchanged.
- SQL failures raised by Storm translate to Spring's `DataAccessException` hierarchy in Spring applications: `SpringExceptionMapper` (storm-spring) translates on vendor error codes with `SQLException` subclass and SQL state fallback, auto-configured by both starters (`storm.exception-translation.enabled=false` to disable) and applied by the `SpringOrmTemplate.of` / `springOrmTemplate` compositions. Failures without a `SQLException` cause keep Storm's own exceptions.
- The Spring Boot starters bind query observations automatically: with an `ObservationRegistry` bean present (Actuator provides one), every query executed by the auto-configured template reports as a `storm.query` Micrometer Observation via the shared `StormObservationAutoConfiguration`. The starters ship storm-micrometer; override the convention with an `ObservationConvention` bean, replace the binding with a `QueryObserver` bean, or disable via `management.observations.enable.storm.query=false`.
- `@EnableStormRepositories(basePackages = ...)` (storm-spring, usable from both language stacks): switches on repository scanning in plain Spring applications, mirroring `@EnableJpaRepositories`; without packages, the annotated class's package is scanned. In Spring Boot the annotation doubles as the explicit override: the auto-configured scanning backs off. The `RepositoryBeanFactoryPostProcessor` adapters also gain configuring constructors (base packages, template bean name, prefix), so multi-template applications define one bean per repository set instead of one subclass.

### Changed

Expand Down
66 changes: 40 additions & 26 deletions docs/spring-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,14 +145,24 @@ fun processUsers() {

### Repository Injection

Storm repositories are interfaces with default method implementations. Spring cannot discover them automatically because they are not annotated with `@Component` or `@Repository`. The `RepositoryBeanFactoryPostProcessor` (in `st.orm.spring.kotlin` since 1.13) scans specified packages for interfaces that extend `EntityRepository` or `ProjectionRepository` and registers them as Spring beans. This makes them available for constructor injection like any other Spring-managed dependency.
Storm repositories are interfaces with default method implementations. Spring cannot discover them automatically because they are not annotated with `@Component` or `@Repository`. Enable scanning with `@EnableStormRepositories`, mirroring annotations like `@EnableJpaRepositories`: the given packages are scanned for interfaces that extend `EntityRepository` or `ProjectionRepository`, and each is registered as a Spring bean, available for constructor injection like any other Spring-managed dependency.

```kotlin
@Configuration
class AcmeRepositoryBeanFactoryPostProcessor : RepositoryBeanFactoryPostProcessor() {
@EnableStormRepositories(basePackages = ["com.acme.repository"])
class AcmeConfiguration
```

Without explicit packages, the package of the annotated class is scanned. For full control, or for multiple repository sets bound to different templates, define `RepositoryBeanFactoryPostProcessor` beans (from `st.orm.spring.kotlin`) instead:

```kotlin
@Configuration
class AcmeRepositoryConfiguration {

override fun getRepositoryBasePackages(): Array<String> =
arrayOf("com.acme.repository")
@Bean
fun acmeRepositories() = RepositoryBeanFactoryPostProcessor(
basePackages = arrayOf("com.acme.repository"),
)
}
```

Expand Down Expand Up @@ -220,15 +230,25 @@ A template created with `ORMTemplate.of(dataSource)` works too, but manages its

### Repository Injection

Register a `RepositoryBeanFactoryPostProcessor` that scans your repository packages. This works identically to the Kotlin version: Storm discovers interfaces extending `EntityRepository` or `ProjectionRepository` and registers them as Spring beans.
Enable repository scanning with `@EnableStormRepositories`. This works identically to the Kotlin version: Storm discovers interfaces extending `EntityRepository` or `ProjectionRepository` and registers them as Spring beans.

```java
@Configuration
@EnableStormRepositories(basePackages = "com.acme.repository")
public class AcmeConfiguration {
}
```

For full control, or for multiple repository sets bound to different templates, define `RepositoryBeanFactoryPostProcessor` beans instead:

```java
@Configuration
public class AcmeRepositoryBeanFactoryPostProcessor extends RepositoryBeanFactoryPostProcessor {
public class AcmeRepositoryConfiguration {

@Override
public String[] getRepositoryBasePackages() {
return new String[] { "com.acme.repository" };
@Bean
public RepositoryBeanFactoryPostProcessor acmeRepositories() {
return new RepositoryBeanFactoryPostProcessor(
new String[] { "com.acme.repository" }, null, "");
}
}
```
Expand Down Expand Up @@ -537,11 +557,8 @@ class StormConfig(private val dataSource: DataSource) {

```kotlin
@Configuration
class MyRepositoryPostProcessor : RepositoryBeanFactoryPostProcessor() {

override fun getRepositoryBasePackages(): Array<String> =
arrayOf("com.myapp.repository", "com.myapp.other")
}
@EnableStormRepositories(basePackages = ["com.myapp.repository", "com.myapp.other"])
class RepositoryConfiguration
```

### Minimal Spring Boot Setup (without Starter)
Expand All @@ -564,11 +581,8 @@ class StormConfig {
}

@Configuration
class MyRepositoryBeanFactoryPostProcessor : RepositoryBeanFactoryPostProcessor() {

override fun getRepositoryBasePackages(): Array<String> =
arrayOf("com.myapp.repository")
}
@EnableStormRepositories(basePackages = ["com.myapp.repository"])
class RepositoryConfiguration
```

This gives you:
Expand Down Expand Up @@ -637,14 +651,14 @@ Repositories bind to a specific template through a per-domain post-processor. Th

```kotlin
@Configuration
class BillingRepositoryPostProcessor : RepositoryBeanFactoryPostProcessor() {

override fun getOrmTemplateBeanName(): String = "billingTemplate"
class BillingRepositoryConfiguration {

override fun getRepositoryPrefix(): String = "billing"

override fun getRepositoryBasePackages(): Array<String> =
arrayOf("com.myapp.billing.repository")
@Bean
fun billingRepositories() = RepositoryBeanFactoryPostProcessor(
basePackages = arrayOf("com.myapp.billing.repository"),
ormTemplateBeanName = "billingTemplate",
repositoryPrefix = "billing",
)
}
```

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,22 @@ import st.orm.template.ORMTemplate
* BeanFactoryPostProcessor that scans base packages for Repository interfaces and registers them as beans.
*
* The scanning and registration engine lives in [AbstractRepositoryBeanFactoryPostProcessor]; this class binds
* it to the Kotlin API's repository types. Subclasses override the engine's methods, e.g.
* `override fun getRepositoryBasePackages(): Array<String>`.
* it to the Kotlin API's repository types. Configure through the constructor (one bean per domain in
* multi-template applications), by subclassing (`override fun getRepositoryBasePackages(): Array<String>`),
* or with `@EnableStormRepositories`.
*/
@Component
open class RepositoryBeanFactoryPostProcessor : AbstractRepositoryBeanFactoryPostProcessor() {
open class RepositoryBeanFactoryPostProcessor(
private val basePackages: Array<String> = emptyArray(),
private val ormTemplateBeanName: String? = null,
private val repositoryPrefix: String = "",
) : AbstractRepositoryBeanFactoryPostProcessor() {

override fun getRepositoryBasePackages(): Array<String> = if (basePackages.isNotEmpty()) basePackages.copyOf() else super.getRepositoryBasePackages()

override fun getOrmTemplateBeanName(): String? = ormTemplateBeanName ?: super.getOrmTemplateBeanName()

override fun getRepositoryPrefix(): String = repositoryPrefix.ifEmpty { super.getRepositoryPrefix() }

override fun getRepositoryType(): Class<*> = Repository::class.java

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package st.orm.spring

import io.kotest.matchers.shouldBe
import io.kotest.matchers.types.shouldBeInstanceOf
import org.junit.jupiter.api.Test
import org.springframework.boot.jdbc.DataSourceBuilder
import org.springframework.context.annotation.AnnotationConfigApplicationContext
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.core.io.ClassPathResource
import org.springframework.jdbc.datasource.init.ResourceDatabasePopulator
import st.orm.spring.repository.VisitRepository
import st.orm.template.ORMTemplate
import javax.sql.DataSource

/**
* Verifies that [EnableStormRepositories] binds repositories through the Kotlin adapter when
* storm-kotlin-spring is on the classpath.
*/
class EnableStormRepositoriesTest {

@Configuration
open class DatabaseConfiguration {
@Bean
open fun dataSource(): DataSource {
val dataSource = DataSourceBuilder.create()
.url("jdbc:h2:mem:enablerepositoriestestkt;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=false")
.username("sa")
.password("")
.driverClassName("org.h2.Driver")
.build()
ResourceDatabasePopulator(ClassPathResource("data.sql")).execute(dataSource)
return dataSource
}

@Bean
open fun ormTemplate(dataSource: DataSource): ORMTemplate = ORMTemplate.of(dataSource)
}

@Configuration
@EnableStormRepositories(basePackages = ["st.orm.spring.repository"], ormTemplateBeanName = "ormTemplate")
open class RepositoriesConfiguration

@Test
fun `annotation registers repositories through the Kotlin adapter`() {
AnnotationConfigApplicationContext(
DatabaseConfiguration::class.java,
RepositoriesConfiguration::class.java,
).use { context ->
// The registrar must pick the Kotlin adapter: repositories bind through the KClass API.
context.getBean("stormRepositoriesPostProcessor")
.shouldBeInstanceOf<st.orm.spring.kotlin.RepositoryBeanFactoryPostProcessor>()
val visitRepository = context.getBean(VisitRepository::class.java)
visitRepository.count() shouldBe 14
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -548,6 +548,27 @@ public io.micrometer.observation.tck.TestObservationRegistry observationRegistry
}
}

@Test
void enableStormRepositoriesBacksOffAutoConfiguredScanning() {
// @EnableStormRepositories doubles as the explicit override in Boot: its registrar-provided
// post-processor makes the auto-configured one back off.
contextRunner
.withPropertyValues(
"spring.datasource.url=jdbc:h2:mem:enableAnnotationTest;DB_CLOSE_DELAY=-1",
"spring.datasource.driver-class-name=org.h2.Driver"
)
.withUserConfiguration(EnableStormRepositoriesConfig.class)
.run(context -> {
assertThat(context).doesNotHaveBean(AutoConfiguredRepositoryBeanFactoryPostProcessor.class);
assertThat(context).hasBean("stormRepositoriesPostProcessor");
});
}

@Configuration
@st.orm.spring.EnableStormRepositories(basePackages = "com.example.repository")
static class EnableStormRepositoriesConfig {
}

@Configuration
static class CustomExceptionMapperConfig {
@Bean
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/*
* Copyright 2024 - 2026 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package st.orm.spring;

import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import org.springframework.context.annotation.Import;
import st.orm.spring.impl.StormRepositoriesRegistrar;

/**
* Enables Storm repository beans, mirroring annotations like {@code @EnableJpaRepositories}.
*
* <p>Scans the configured base packages for repository interfaces and registers each as a Spring bean,
* available for constructor injection. Without explicit packages, the package of the annotated class is
* scanned. Works with both language stacks: the repositories are bound through the Kotlin API when
* storm-kotlin-spring is on the classpath, and through the Java API otherwise.</p>
*
* {@snippet lang = java:
* @Configuration
* @EnableStormRepositories(basePackages = "com.acme.repository")
* public class AcmeConfiguration {
* }
* }
*
* <p>In Spring Boot, repositories are discovered automatically under the application's base package; this
* annotation doubles as the explicit override there — when present, the starter's auto-configured scanning
* backs off. For multiple repository sets bound to different templates, define one
* {@link RepositoryBeanFactoryPostProcessor} bean per set using its configuring constructor instead.</p>
*
* @since 1.13
*/
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Import(StormRepositoriesRegistrar.class)
public @interface EnableStormRepositories {

/**
* Base packages to scan for repository interfaces. Empty means the package of the annotated class.
*/
String[] basePackages() default {};

/**
* Type-safe alternative to {@link #basePackages()}: the packages of the given classes are scanned.
*/
Class<?>[] basePackageClasses() default {};

/**
* The {@code ORMTemplate} bean the repositories bind to. Empty means the primary template.
*/
String ormTemplateBeanName() default "";

/**
* Prefix for the registered repository bean names. Empty for none.
*/
String repositoryPrefix() default "";
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
package st.orm.spring;

import jakarta.annotation.Nonnull;
import jakarta.annotation.Nullable;
import java.util.Set;
import org.springframework.beans.factory.config.ConfigurableListableBeanFactory;
import org.springframework.stereotype.Component;
Expand All @@ -33,6 +34,49 @@
@Component
public class RepositoryBeanFactoryPostProcessor extends AbstractRepositoryBeanFactoryPostProcessor {

private final String[] basePackages;
private final String ormTemplateBeanName;
private final String repositoryPrefix;

/**
* Creates a post-processor configured through overrides; subclasses override
* {@link #getRepositoryBasePackages()} and friends.
*/
public RepositoryBeanFactoryPostProcessor() {
this(new String[0], null, "");
}

/**
* Creates a fully configured post-processor, without subclassing.
*
* @param basePackages the base packages to scan for repository interfaces.
* @param ormTemplateBeanName the ORMTemplate bean repositories bind to, or {@code null} for the primary.
* @param repositoryPrefix prefix for the registered repository bean names; empty for none.
* @since 1.13
*/
public RepositoryBeanFactoryPostProcessor(@Nonnull String[] basePackages,
@Nullable String ormTemplateBeanName,
@Nonnull String repositoryPrefix) {
this.basePackages = basePackages.clone();
this.ormTemplateBeanName = ormTemplateBeanName;
this.repositoryPrefix = repositoryPrefix;
}

@Override
public String[] getRepositoryBasePackages() {
return basePackages.length > 0 ? basePackages.clone() : super.getRepositoryBasePackages();
}

@Override
public String getOrmTemplateBeanName() {
return ormTemplateBeanName != null ? ormTemplateBeanName : super.getOrmTemplateBeanName();
}

@Override
protected String getRepositoryPrefix() {
return !repositoryPrefix.isEmpty() ? repositoryPrefix : super.getRepositoryPrefix();
}

@Override
protected Class<?> getRepositoryType() {
return Repository.class;
Expand Down
Loading
Loading