Skip to content

Commit baeeec9

Browse files
jeffjensenclaude
andcommitted
feat(spring): One datasource in generated tests, not many
The handling of multiple datasources per database integration module tests is rare and a cumbersome design. When encountering this, instead, configure the correct single datasource for the tests or generate separate tests for each datasource if more than one is valid. Replace the archetype's multi-datasource generation (multi/ token expansion, aggregator, @ParameterizedTest) with one specified datasource, named by dataSourceName / dataSourceBeanName / entityManagerFactoryBeanName and resolved by @qualifier; regenerate per datasource to cover several. Also: - Import the generated DatabaseAudit<Name>TestConfiguration once on the base test class, not on each generated IT, so it cannot clash with a consumer base that declares its context via @ContextConfiguration. - Auto-wire runtime SQL capture for the named datasource: the generated config registers a SqlCapturerRegisteringPostProcessor that sets its capturer as that EntityManagerFactory's Hibernate StatementInspector before the factory is built, so the plan-based audits run with no change to the application's own datasource configuration. - Drop SingleDataSourceCondition: the stock DatabaseAuditTestConfiguration resolves the DataSource/EntityManagerFactory by type and fails fast on genuinely ambiguous peer datasources instead of backing off silently. - Print the remaining manual steps after generation, and update the usage guide, CLAUDE.md, and archetype docs to match. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JA2JhuvNmmyGbsPYgVBdEr
1 parent 6f1711b commit baeeec9

23 files changed

Lines changed: 730 additions & 252 deletions

File tree

CLAUDE.md

Lines changed: 30 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -63,13 +63,27 @@ one suite from the context's primary `DataSource`/`EntityManagerFactory` and reg
6363
`@Bean` in the config. This is the spring-boot half of core's standing directive ("when updating any audit class,
6464
also update the spring-boot beans"); a compile failure against core is the intended signal.
6565

66-
To audit **multiple datasources**, a consumer builds one `DatabaseAuditSuite` per datasource from its
67-
`@Qualifier`'d beans in a `@TestConfiguration` named `DatabaseAudit<Name>TestConfiguration` (mirroring the default
68-
config with the datasource name inserted). The archetype generates one such config plus a `@Disabled`
69-
`DatabaseAudit<Name>IT` per name under `multi/` when `-DdataSourceNames=Aurora,Reporting`: the `multi/` holds
70-
`DsNameToken` token templates that `archetype-post-generate.groovy` expands once per name (`dataSourceNames` is a
71-
declared property, default `none`). The default config's capturer and facade beans are `@Primary` so its by-type
72-
injections survive the extra datasources' beans.
66+
To audit a datasource in an application that configures **several peer datasources** (with no `@Primary`
67+
`DataSource`/`EntityManagerFactory`), the archetype generates tests that target **one specified datasource**,
68+
resolved **by name** rather than by type. Pass `-DdataSourceName=Reporting -DdataSourceBeanName=reportingDataSource
69+
-DentityManagerFactoryBeanName=reportingEntityManagerFactory`: `archetype-post-generate.groovy` expands the
70+
`DsNameToken` config token template into a `DatabaseAuditReportingTestConfiguration` — a `@Qualifier`-based mirror of
71+
the stock config (its own capturer, a `DatabaseAuditSuite` built from the two named beans, and every
72+
`*AuditAssertion` bean) — and the generated `AbstractDatabaseAuditIT` `@Import`s it (the audit ITs carry no
73+
`@Import` of their own; they just extend that base, so a per-test `@Import` can't clash with a consumer base that
74+
declares its context via `@ContextConfiguration`). In this **targeted** mode `AbstractDatabaseAuditIT` imports the
75+
per-datasource config instead of the default `DatabaseAuditTestConfiguration`; when a `parentClass` is given
76+
`AbstractDatabaseAuditIT` is not generated, so `archetype-post-generate.groovy` prints an instruction telling the
77+
consumer to add that `@Import` to their own base class. To audit more than one datasource, generate once per
78+
datasource into its own package. `AbstractDatabaseAuditIT` branches on a `#set($targeted ...)` flag for which config
79+
it imports; each IT's `*AuditAssertion` injection is unchanged in both modes. Because the generated token config mirrors the stock config's bean list,
80+
adding/removing a core audit now touches it too — the same lockstep the stock config follows. `dataSourceName`,
81+
`dataSourceBeanName`, and `entityManagerFactoryBeanName` are declared properties, default `none`.
82+
83+
The stock `DatabaseAuditTestConfiguration` resolves the `DataSource`/`EntityManagerFactory` **by type** — Spring
84+
picks the single, `@Primary`, or conventionally-named (`dataSource`/`entityManagerFactory`) candidate. Several peer
85+
datasources it can't disambiguate make importing the config fail fast with `expected single matching bean but found
86+
2`; audit such a datasource by name via the targeted mode above instead.
7387

7488
### Platform detection and the PostgreSQL-only plan audits
7589

@@ -88,6 +102,15 @@ Hibernate's `StatementInspector` and the instance injected into the audits — t
88102
the *bean object*, not its class name. Registering by class name spawns a second capturer Hibernate fills but the
89103
audits never read, silently producing empty captures. Preserve this when editing the config.
90104

105+
The stock config wires the capturer onto the auto-configured **primary** `EntityManagerFactory` via a
106+
`HibernatePropertiesCustomizer`. That customizer reaches only the primary factory, so a **by-name (peer)**
107+
datasource can't use it: the generated `DatabaseAudit<Name>TestConfiguration` instead registers a
108+
`SqlCapturerRegisteringPostProcessor` (`…/spring/boot/`), a `BeanPostProcessor` that puts the same capturer instance
109+
under `JdbcSettings.STATEMENT_INSPECTOR` on the named `LocalContainerEntityManagerFactoryBean` **before Hibernate
110+
builds it** (the inspector is a build-time setting — it can't be attached to an already-built factory). This wires
111+
peer-datasource runtime capture with no change to the application's own factory configuration; it is the one bean
112+
the token config adds beyond the stock config's list.
113+
91114
### The reusable assertion API — how consumers consume the audits
92115

93116
The integration's published surface is not just the wiring. `…/spring/boot/assertion/` holds one

archetype/src/main/resources/META-INF/archetype-post-generate.groovy

Lines changed: 52 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -64,38 +64,60 @@ if (parentClass != 'none') {
6464
new File(destRoot, "src/test/java/${packagePath}/AbstractDatabaseAuditIT.java").delete()
6565
}
6666

67-
// For each name in dataSourceNames (comma-separated; 'none' or empty means none), generate a
68-
// DatabaseAudit<Name>TestConfiguration plus a DatabaseAudit<Name>IT from the multi/ token templates, then delete
69-
// the templates; with no names, remove the whole multi/ directory.
70-
def dataSourceNames = request.properties.getProperty('dataSourceNames', 'none')
71-
def multiDir = new File(destRoot, "src/test/java/${packagePath}/multi")
72-
def names = []
73-
if (dataSourceNames && dataSourceNames != 'none') {
74-
names = dataSourceNames.split(',').collect { it.trim() }.findAll { it }
75-
names.each { name ->
76-
if (!(name ==~ /[A-Za-z_][A-Za-z0-9_]*/)) {
77-
throw new IllegalArgumentException("Invalid dataSourceNames entry '" + name +
78-
"': each name must be a valid Java identifier (a letter or underscore followed by " +
79-
"letters, digits, or underscores) so it can form class names like " +
80-
"DatabaseAudit<Name>TestConfiguration.")
81-
}
67+
// When dataSourceName is set (not 'none'), the application configures several peer datasources with no @Primary:
68+
// expand the token config template into a DatabaseAudit<Name>TestConfiguration that resolves this datasource's
69+
// beans by @Qualifier (each audit IT @Imports it), then delete the token template. With no dataSourceName, just
70+
// remove the token template — the ITs import the stock DatabaseAuditTestConfiguration instead.
71+
def dataSourceName = request.properties.getProperty('dataSourceName', 'none')
72+
def configTemplate = new File(destRoot, "src/test/java/${packagePath}/DatabaseAuditDsNameTokenTestConfiguration.java")
73+
if (dataSourceName && dataSourceName != 'none') {
74+
if (!(dataSourceName ==~ /[A-Za-z_][A-Za-z0-9_]*/)) {
75+
throw new IllegalArgumentException("Invalid dataSourceName '" + dataSourceName +
76+
"': it must be a valid Java identifier (a letter or underscore followed by letters, digits, or " +
77+
"underscores) so it can form the class name DatabaseAudit<Name>TestConfiguration.")
8278
}
83-
}
84-
if (names) {
85-
def configTemplate = new File(multiDir, "DatabaseAuditDsNameTokenTestConfiguration.java")
86-
def itTemplate = new File(multiDir, "DatabaseAuditDsNameTokenIT.java")
87-
def configBody = configTemplate.getText('UTF-8')
88-
def itBody = itTemplate.getText('UTF-8')
89-
names.each { name ->
90-
def pascal = name.substring(0, 1).toUpperCase() + name.substring(1)
91-
def camel = name.substring(0, 1).toLowerCase() + name.substring(1)
92-
new File(multiDir, "DatabaseAudit${pascal}TestConfiguration.java")
93-
.write(configBody.replace('DsNameToken', pascal).replace('dsNameToken', camel), 'UTF-8')
94-
new File(multiDir, "DatabaseAudit${pascal}IT.java")
95-
.write(itBody.replace('DsNameToken', pascal).replace('dsNameToken', camel), 'UTF-8')
79+
def dataSourceBeanName = request.properties.getProperty('dataSourceBeanName', 'none')
80+
def entityManagerFactoryBeanName = request.properties.getProperty('entityManagerFactoryBeanName', 'none')
81+
if (!dataSourceBeanName || dataSourceBeanName == 'none'
82+
|| !entityManagerFactoryBeanName || entityManagerFactoryBeanName == 'none') {
83+
throw new IllegalArgumentException("dataSourceName '" + dataSourceName +
84+
"' requires both dataSourceBeanName and entityManagerFactoryBeanName, naming the DataSource and " +
85+
"EntityManagerFactory beans the generated config resolves by @Qualifier.")
9686
}
87+
def pascal = dataSourceName.substring(0, 1).toUpperCase() + dataSourceName.substring(1)
88+
def camel = dataSourceName.substring(0, 1).toLowerCase() + dataSourceName.substring(1)
89+
def configBody = configTemplate.getText('UTF-8')
90+
new File(configTemplate.parentFile, "DatabaseAudit${pascal}TestConfiguration.java")
91+
.write(configBody.replace('DsNameToken', pascal).replace('dsNameToken', camel), 'UTF-8')
9792
configTemplate.delete()
98-
itTemplate.delete()
9993
} else {
100-
multiDir.deleteDir()
94+
configTemplate.delete()
95+
}
96+
97+
// Remaining manual steps for the consumer, printed to the archetype:generate console output. The audit ITs no
98+
// longer carry @Import themselves; the config is imported once on the base class (generated or user-specified).
99+
def schemaProp = request.properties.getProperty('schemaPropertyName', 'database.datasource.schema-name')
100+
def site = 'https://database-audits.github.io/spring-boot-integration'
101+
println ''
102+
println '=================================================================================='
103+
println ' database-audits: generation complete. Remaining manual steps:'
104+
println '=================================================================================='
105+
if (dataSourceName && dataSourceName != 'none') {
106+
def pascal = dataSourceName.substring(0, 1).toUpperCase() + dataSourceName.substring(1)
107+
def configClass = "DatabaseAudit${pascal}TestConfiguration"
108+
if (parentClass && parentClass != 'none') {
109+
println " * Add @Import(${configClass}.class) to your base test class ${parentClass}"
110+
println " (or list ${configClass}.class in its @ContextConfiguration(classes = { ... }))."
111+
}
112+
println " * Add preferQueryMode=simple to the ${dataSourceName} datasource's JDBC URL (plan-based PostgreSQL"
113+
println " runtime audits only)."
114+
println " * Guide: ${site}/usage.html#_multiple_datasources"
115+
} else if (parentClass && parentClass != 'none') {
116+
println " * Add @Import(DatabaseAuditTestConfiguration.class) to your base test class ${parentClass}"
117+
println " (or list it in its @ContextConfiguration(classes = { ... }))."
118+
println " * Guide: ${site}/usage.html"
119+
} else {
120+
println " * Guide: ${site}/usage.html"
101121
}
122+
println " * Ensure ${schemaProp} is set to the schema the catalog audits scan."
123+
println '=================================================================================='

archetype/src/main/resources/META-INF/maven/archetype-metadata.xml

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -43,11 +43,24 @@
4343
<requiredProperty key="disabledTests">
4444
<defaultValue>false</defaultValue>
4545
</requiredProperty>
46-
<!-- Comma-separated datasource names (e.g. "Aurora,Reporting"). For each name, the post-generate script
47-
expands the multi/ token templates into a DatabaseAudit<Name>TestConfiguration plus a
48-
DatabaseAudit<Name>IT. Default 'none' (a non-empty sentinel, like parentClass, so the integration-test
49-
mojo does not treat it as missing) generates the single-datasource suite only. -->
50-
<requiredProperty key="dataSourceNames">
46+
<!-- The datasource to audit when the application configures several peer datasources with no @Primary.
47+
When set (not 'none'), the post-generate script expands the token config template into a
48+
DatabaseAudit<Name>TestConfiguration that resolves this datasource's beans by @Qualifier, and each audit IT
49+
@Imports it. The value labels the generated config class (Pascal-cased) and its SQL capturer bean
50+
(camel-cased), so it must be a valid Java identifier. Default 'none' (a non-empty sentinel, like
51+
parentClass, so the integration-test mojo does not treat it as missing) generates the single-datasource
52+
suite that imports the stock DatabaseAuditTestConfiguration. -->
53+
<requiredProperty key="dataSourceName">
54+
<defaultValue>none</defaultValue>
55+
</requiredProperty>
56+
<!-- The DataSource bean name the generated per-datasource config resolves by @Qualifier. Required (not 'none')
57+
when dataSourceName is set; ignored otherwise. -->
58+
<requiredProperty key="dataSourceBeanName">
59+
<defaultValue>none</defaultValue>
60+
</requiredProperty>
61+
<!-- The EntityManagerFactory bean name the generated per-datasource config resolves by @Qualifier. Required
62+
(not 'none') when dataSourceName is set; ignored otherwise. -->
63+
<requiredProperty key="entityManagerFactoryBeanName">
5164
<defaultValue>none</defaultValue>
5265
</requiredProperty>
5366
</requiredProperties>

archetype/src/main/resources/archetype-resources/src/test/java/AbstractDatabaseAuditIT.java

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,31 @@
11
package ${package};
22

3+
#set($targeted = $dataSourceName && $dataSourceName != '' && $dataSourceName != 'none')
4+
#if($targeted)
5+
#set($dsPascal = "${dataSourceName.substring(0,1).toUpperCase()}${dataSourceName.substring(1)}")
6+
#end
37
import org.springframework.boot.test.context.SpringBootTest;
48
import org.springframework.context.annotation.Import;
59

10+
#if($targeted)
11+
import ${package}.DatabaseAudit${dsPascal}TestConfiguration;
12+
#else
613
import io.github.databaseaudits.spring.boot.DatabaseAuditTestConfiguration;
14+
#end
715

16+
#if($targeted)
17+
/**
18+
* Base class for the audit integration tests. It boots a Spring Boot test context and {@code @Import}s the generated
19+
* per-datasource {@code DatabaseAudit<Name>TestConfiguration}, which resolves that datasource's beans by name and
20+
* registers every audit bean; every audit IT extends this class and inherits that import. It deliberately does not
21+
* import {@code DatabaseAuditTestConfiguration}: that configuration resolves the
22+
* {@code DataSource}/{@code EntityManagerFactory} by type, which cannot pick one among several peer datasources.
23+
*
24+
* <p>
25+
* It carries no database or container wiring of its own, so it works unchanged against any application. Point it at
26+
* your own application and datasource to audit your real schema.
27+
*/
28+
#else
829
/**
930
* Base class for the audit integration tests. It boots a Spring Boot test context and imports
1031
* {@link DatabaseAuditTestConfiguration}, which registers every audit bean and wires the shared SQL capturer.
@@ -14,7 +35,12 @@
1435
* {@code @SpringBootApplication} and {@code DataSource} (a Testcontainers PostgreSQL) supply the database for the
1536
* audit ITs that extend it. Point it at your own application and {@code DataSource} to audit your real schema.
1637
*/
38+
#end
1739
@SpringBootTest
40+
#if($targeted)
41+
@Import(DatabaseAudit${dsPascal}TestConfiguration.class)
42+
#else
1843
@Import(DatabaseAuditTestConfiguration.class)
44+
#end
1945
public abstract class AbstractDatabaseAuditIT {
2046
}

0 commit comments

Comments
 (0)