Skip to content

Commit 74ffc64

Browse files
jeffjensenclaude
andcommitted
feat(spring): Support auditing multiple datasources
The audit suite injected DataSource and EntityManagerFactory by type, so it was ambiguous in an application with several datasources. Let a consumer wire the whole suite at any chosen (DataSource, EntityManagerFactory) pair, once per datasource. Core is unchanged: it stays dependency-injection-neutral, and the EntityManagerFactory constructor argument is the targeting hook. Extract DatabaseAuditSuite, a plain factory that wires the entire audit graph for one (DataSource, EntityManagerFactory) pair and exposes the DatabaseAuditAssertions facade plus each *AuditAssertion. It is now the single place mirroring core's audit constructors. DatabaseAuditTestConfiguration builds one suite from the context's primary DataSource/EntityManagerFactory and delegates every assertion bean to it, keeping the existing bean names; its capturer and facade beans are @primary so the default config's by-type injections survive the extra datasources' beans. An application with multiple datasources adds one @TestConfiguration per datasource that builds a suite from its @qualifier'd beans. The archetype generates these from a new dataSourceNames property (comma-separated, default 'none'): for each name it emits a DatabaseAudit<Name>TestConfiguration plus a @disabled DatabaseAudit<Name>IT under multi/, named alongside the default DatabaseAuditTestConfiguration with the datasource name inserted (e.g. DatabaseAuditAuroraTestConfiguration). The multi/ token templates are expanded once per name by archetype-post-generate.groovy. * Add DatabaseAuditSuite and update DatabaseAuditTestConfigurationIT to construct the suite against core's constructors. * Add the dataSourceNames archetype property and a multi-datasource self-test variant; cover per-name expansion and the multi/ deletion in PostGenerateScriptTest. * Document the suite and the property in usage.adoc, architecture.adoc, archetype.adoc, and CLAUDE.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UkRk16pW4VEM3E6vrDoG5y
1 parent 5aa1991 commit 74ffc64

19 files changed

Lines changed: 729 additions & 224 deletions

File tree

CLAUDE.md

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -52,13 +52,24 @@ Use the bundled Maven wrapper from this directory. JDK 21 is required.
5252

5353
## Architecture
5454

55-
### The config mirrors core's constructors — keep them in sync
56-
57-
Each `@Bean` method in `DatabaseAuditTestConfiguration` calls a core audit's constructor directly. There is no
58-
component scan and no magic: **when a core audit's constructor signature changes (or an audit is added/removed),
59-
this class must change in lockstep**, or the wiring fails to compile against core. This is the spring-boot half of
60-
core's standing directive ("when updating any audit class, also update the spring-boot beans"). Treat a compile
61-
failure here as a signal to read the changed core constructor.
55+
### `DatabaseAuditSuite` mirrors core's constructors — keep them in sync
56+
57+
`DatabaseAuditSuite` (`src/main/java/.../spring/boot/`) is the single place that calls core's audit and
58+
collaborator constructors directly: it wires the whole graph for one `(DataSource, EntityManagerFactory)` pair and
59+
exposes each `*AuditAssertion` plus the `DatabaseAuditAssertions` facade. `DatabaseAuditTestConfiguration` builds
60+
one suite from the context's primary `DataSource`/`EntityManagerFactory` and registers its assertions as `@Bean`s
61+
(no component scan). **When a core audit's constructor signature changes (or an audit is added/removed),
62+
`DatabaseAuditSuite` must change in lockstep** — and a new audit also needs its `*AuditAssertion` and a delegating
63+
`@Bean` in the config. This is the spring-boot half of core's standing directive ("when updating any audit class,
64+
also update the spring-boot beans"); a compile failure against core is the intended signal.
65+
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.
6273

6374
### Platform detection and the PostgreSQL-only plan audits
6475

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

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,3 +61,39 @@ def destRoot = (generateMode == 'project') ? outputDir : resolveDestRoot(targetD
6161
if (parentClass != 'none') {
6262
new File(destRoot, "src/test/java/${packagePath}/AbstractDatabaseAuditIT.java").delete()
6363
}
64+
65+
// For each name in dataSourceNames (comma-separated; 'none' or empty means none), generate a
66+
// DatabaseAudit<Name>TestConfiguration plus a DatabaseAudit<Name>IT from the multi/ token templates, then delete
67+
// the templates; with no names, remove the whole multi/ directory.
68+
def dataSourceNames = request.properties.getProperty('dataSourceNames', 'none')
69+
def multiDir = new File(destRoot, "src/test/java/${packagePath}/multi")
70+
def names = []
71+
if (dataSourceNames && dataSourceNames != 'none') {
72+
names = dataSourceNames.split(',').collect { it.trim() }.findAll { it }
73+
names.each { name ->
74+
if (!(name ==~ /[A-Za-z_][A-Za-z0-9_]*/)) {
75+
throw new IllegalArgumentException("Invalid dataSourceNames entry '" + name +
76+
"': each name must be a valid Java identifier (a letter or underscore followed by " +
77+
"letters, digits, or underscores) so it can form class names like " +
78+
"DatabaseAudit<Name>TestConfiguration.")
79+
}
80+
}
81+
}
82+
if (names) {
83+
def configTemplate = new File(multiDir, "DatabaseAuditDsNameTokenTestConfiguration.java")
84+
def itTemplate = new File(multiDir, "DatabaseAuditDsNameTokenIT.java")
85+
def configBody = configTemplate.getText('UTF-8')
86+
def itBody = itTemplate.getText('UTF-8')
87+
names.each { name ->
88+
def pascal = name.substring(0, 1).toUpperCase() + name.substring(1)
89+
def camel = name.substring(0, 1).toLowerCase() + name.substring(1)
90+
new File(multiDir, "DatabaseAudit${pascal}TestConfiguration.java")
91+
.write(configBody.replace('DsNameToken', pascal).replace('dsNameToken', camel), 'UTF-8')
92+
new File(multiDir, "DatabaseAudit${pascal}IT.java")
93+
.write(itBody.replace('DsNameToken', pascal).replace('dsNameToken', camel), 'UTF-8')
94+
}
95+
configTemplate.delete()
96+
itTemplate.delete()
97+
} else {
98+
multiDir.deleteDir()
99+
}

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,13 @@
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">
51+
<defaultValue>none</defaultValue>
52+
</requiredProperty>
4653
</requiredProperties>
4754

4855
<fileSets>
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
package ${package}.multi;
2+
3+
import org.junit.jupiter.api.Disabled;
4+
import org.junit.jupiter.api.Test;
5+
import org.springframework.beans.factory.annotation.Autowired;
6+
import org.springframework.beans.factory.annotation.Qualifier;
7+
import org.springframework.context.annotation.Import;
8+
9+
#if($parentClass && $parentClass != '' && $parentClass != 'none')
10+
#set($simpleParentClass = $parentClass.replaceAll('.*\.', ''))
11+
import ${parentClass};
12+
#else
13+
import ${package}.AbstractDatabaseAuditIT;
14+
#end
15+
import io.github.databaseaudits.spring.boot.assertion.DatabaseAuditAssertions;
16+
17+
/**
18+
* Scaffold example that audits the DsNameToken datasource, using {@link DatabaseAuditDsNameTokenTestConfiguration}.
19+
* The default audit beans (from the imported {@code DatabaseAuditTestConfiguration}) audit the primary datasource;
20+
* the {@code @Qualifier} here selects the DsNameToken datasource's facade.
21+
*
22+
* <p>
23+
* Generated {@code @Disabled} because it cannot run until you wire the DsNameToken datasource: fill in the
24+
* {@code @Qualifier} bean names in {@link DatabaseAuditDsNameTokenTestConfiguration}, set the schema below, then
25+
* remove {@code @Disabled}.
26+
*/
27+
@Disabled("Scaffold: wire the DsNameToken datasource and fill in the @Qualifiers in DatabaseAuditDsNameTokenTestConfiguration, then remove @Disabled.")
28+
@Import(DatabaseAuditDsNameTokenTestConfiguration.class)
29+
#if($parentClass && $parentClass != '' && $parentClass != 'none')
30+
public class DatabaseAuditDsNameTokenIT extends ${simpleParentClass} {
31+
#else
32+
public class DatabaseAuditDsNameTokenIT extends AbstractDatabaseAuditIT {
33+
#end
34+
@Autowired
35+
@Qualifier("dsNameTokenDatabaseAuditAssertions")
36+
private DatabaseAuditAssertions dsNameTokenAudits;
37+
38+
@Test
39+
void testDsNameTokenSchemaIsClean() {
40+
// TODO: replace with the DsNameToken datasource's schema name.
41+
dsNameTokenAudits.assertCatalogClean("${schemaName}");
42+
dsNameTokenAudits.assertJpaClean();
43+
}
44+
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
package ${package}.multi;
2+
3+
import javax.sql.DataSource;
4+
5+
import org.springframework.beans.factory.annotation.Qualifier;
6+
import org.springframework.boot.test.context.TestConfiguration;
7+
import org.springframework.context.annotation.Bean;
8+
9+
import io.github.databaseaudits.capture.SqlCapturingStatementInspector;
10+
import io.github.databaseaudits.spring.boot.DatabaseAuditSuite;
11+
import io.github.databaseaudits.spring.boot.assertion.DatabaseAuditAssertions;
12+
import jakarta.persistence.EntityManagerFactory;
13+
14+
/**
15+
* Audits the DsNameToken datasource. The default {@code DatabaseAuditTestConfiguration} (imported by
16+
* {@code AbstractDatabaseAuditIT}) audits the application's primary {@code DataSource} and
17+
* {@code EntityManagerFactory}; this configuration audits the DsNameToken datasource, selected by
18+
* {@code @Qualifier}.
19+
*
20+
* <p>
21+
* To use it: replace the placeholder {@code @Qualifier} values with your DsNameToken {@code DataSource} and
22+
* {@code EntityManagerFactory} bean names, {@code @Import} this class from a test, and inject the
23+
* {@code dsNameTokenDatabaseAuditAssertions} bean (see {@code DatabaseAuditDsNameTokenIT}).
24+
*
25+
* <p>
26+
* It is a {@code @TestConfiguration}, so it stays inert until imported: the placeholder qualifiers never resolve
27+
* and the build still compiles. For the runtime (PostgreSQL-only) audits to see SQL, register
28+
* {@code dsNameTokenSqlCapturer} as that {@code EntityManagerFactory}'s Hibernate {@code StatementInspector} where
29+
* you build it, and connect that datasource with {@code preferQueryMode=simple}; otherwise call only
30+
* {@code assertCatalogClean} and {@code assertJpaClean}.
31+
*/
32+
@TestConfiguration(proxyBeanMethods = false)
33+
public class DatabaseAuditDsNameTokenTestConfiguration {
34+
/**
35+
* The SQL capturer the DsNameToken datasource's runtime audits read. Must be the same instance registered as
36+
* that {@code EntityManagerFactory}'s Hibernate {@code StatementInspector}.
37+
*
38+
* @return the DsNameToken datasource's SQL capturer.
39+
*/
40+
@Bean
41+
SqlCapturingStatementInspector dsNameTokenSqlCapturer() {
42+
return new SqlCapturingStatementInspector();
43+
}
44+
45+
/**
46+
* Builds the audit assertions for the DsNameToken datasource. Replace the placeholder {@code @Qualifier}
47+
* values with your own bean names.
48+
*
49+
* @param dataSource
50+
* the DsNameToken datasource to audit.
51+
* @param entityManagerFactory
52+
* the DsNameToken entity-manager factory the JPA audit confirms.
53+
* @param dsNameTokenSqlCapturer
54+
* the DsNameToken datasource's SQL capturer.
55+
* @return the DsNameToken datasource's assertions facade.
56+
*/
57+
@Bean
58+
DatabaseAuditAssertions dsNameTokenDatabaseAuditAssertions(
59+
@Qualifier("dsNameTokenDataSource") DataSource dataSource,
60+
@Qualifier("dsNameTokenEntityManagerFactory") EntityManagerFactory entityManagerFactory,
61+
@Qualifier("dsNameTokenSqlCapturer") SqlCapturingStatementInspector dsNameTokenSqlCapturer) {
62+
return new DatabaseAuditSuite(dataSource, entityManagerFactory, dsNameTokenSqlCapturer).assertions();
63+
}
64+
}

archetype/src/test/java/io/github/databaseaudits/archetype/PostGenerateScriptTest.java

Lines changed: 93 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package io.github.databaseaudits.archetype;
22

33
import static org.assertj.core.api.Assertions.assertThat;
4+
import static org.assertj.core.api.Assertions.assertThatThrownBy;
45

56
import java.io.IOException;
67
import java.io.InputStream;
@@ -16,10 +17,12 @@
1617
import groovy.lang.GroovyShell;
1718

1819
/**
19-
* Verifies the archetype post-generate script behavior for the {@code tests-only} generation mode,
20+
* Verifies the archetype post-generate script behavior: the {@code tests-only} generation mode
2021
* particularly that {@code projectDirectory} is honored whether it arrives in {@code request.properties}
2122
* (the archetype integration-test mojo) or only as a JVM system property (the {@code archetype:generate}
22-
* command line, where {@code projectDirectory} is not a declared archetype property).
23+
* command line, where {@code projectDirectory} is not a declared archetype property) — and that the
24+
* {@code multi/} token templates expand into a {@code DatabaseAudit<Name>TestConfiguration} per
25+
* {@code dataSourceNames} entry, or are removed when no names are given.
2326
*/
2427
class PostGenerateScriptTest {
2528

@@ -170,6 +173,67 @@ void testPostGenerate_TestsOnlyWithAbsoluteProjectDirectoryAsSystemProperty_Copi
170173
.doesNotExist();
171174
}
172175

176+
@Test
177+
void testPostGenerate_ProjectModeWithoutDataSourceNames_DeletesMultiTemplates()
178+
throws Exception {
179+
Path outputDir = tempDir.resolve("output");
180+
setupGeneratedProject(outputDir, "demo", PACKAGE_PATH);
181+
setupMultiTemplates(outputDir, "demo", PACKAGE_PATH);
182+
183+
runPostGenerateScriptInProjectMode(outputDir, "demo", "none");
184+
185+
assertThat(outputDir.resolve("demo/src/test/java/" + PACKAGE_PATH + "/multi").toFile())
186+
.as("The multi/ templates must be deleted when dataSourceNames is 'none'.")
187+
.doesNotExist();
188+
assertThat(outputDir.resolve("demo/src/test/java/" + PACKAGE_PATH + "/catalog/ForeignKeyIndexAuditIT.java"))
189+
.as("Audit IT files are kept in project mode.")
190+
.exists();
191+
}
192+
193+
@Test
194+
void testPostGenerate_ProjectModeWithDataSourceNames_GeneratesNamedClassesPerDatasource()
195+
throws Exception {
196+
Path outputDir = tempDir.resolve("output");
197+
setupGeneratedProject(outputDir, "demo", PACKAGE_PATH);
198+
setupMultiTemplates(outputDir, "demo", PACKAGE_PATH);
199+
200+
runPostGenerateScriptInProjectMode(outputDir, "demo", "Aurora, Reporting");
201+
202+
Path multi = outputDir.resolve("demo/src/test/java/" + PACKAGE_PATH + "/multi");
203+
assertThat(multi.resolve("DatabaseAuditAuroraTestConfiguration.java"))
204+
.as("A config class is generated for each datasource name.").exists();
205+
assertThat(multi.resolve("DatabaseAuditAuroraIT.java"))
206+
.as("An IT is generated for each datasource name.").exists();
207+
assertThat(multi.resolve("DatabaseAuditReportingTestConfiguration.java"))
208+
.as("A config class is generated for each datasource name (whitespace is trimmed).").exists();
209+
assertThat(multi.resolve("DatabaseAuditReportingIT.java"))
210+
.as("An IT is generated for each datasource name.").exists();
211+
assertThat(multi.resolve("DatabaseAuditDsNameTokenTestConfiguration.java").toFile())
212+
.as("The token templates are removed after generation.").doesNotExist();
213+
assertThat(Files.readString(multi.resolve("DatabaseAuditAuroraTestConfiguration.java")))
214+
.as("Tokens are replaced with the datasource name throughout.")
215+
.contains("class DatabaseAuditAuroraTestConfiguration", "auroraDataSource")
216+
.doesNotContain("DsNameToken", "dsNameToken");
217+
assertThat(Files.readString(multi.resolve("DatabaseAuditAuroraIT.java")))
218+
.as("Tokens are replaced in the generated IT body too, not just its filename.")
219+
.contains("class DatabaseAuditAuroraIT", "auroraDataSource")
220+
.doesNotContain("DsNameToken", "dsNameToken");
221+
}
222+
223+
@Test
224+
void testPostGenerate_ProjectModeWithInvalidDataSourceName_FailsWithClearError()
225+
throws Exception {
226+
Path outputDir = tempDir.resolve("output");
227+
setupGeneratedProject(outputDir, "demo", PACKAGE_PATH);
228+
setupMultiTemplates(outputDir, "demo", PACKAGE_PATH);
229+
230+
assertThatThrownBy(() -> runPostGenerateScriptInProjectMode(outputDir, "demo",
231+
"read-replica"))
232+
.as("A datasource name that is not a valid Java identifier must fail generation, "
233+
+ "not emit an uncompilable class.")
234+
.hasMessageContaining("read-replica");
235+
}
236+
173237
private void setupGeneratedProject(Path outputDir, String artifactId, String packagePath)
174238
throws IOException {
175239
Path projectDir = outputDir.resolve(artifactId);
@@ -245,6 +309,33 @@ private void runPostGenerateScriptWithSystemProjectDirectory(Path outputDir, Str
245309
}
246310
}
247311

312+
private void setupMultiTemplates(Path outputDir, String artifactId, String packagePath)
313+
throws IOException {
314+
Path multiDir = outputDir.resolve(artifactId).resolve("src/test/java/" + packagePath + "/multi");
315+
Files.createDirectories(multiDir);
316+
Files.writeString(multiDir.resolve("DatabaseAuditDsNameTokenTestConfiguration.java"),
317+
"class DatabaseAuditDsNameTokenTestConfiguration { String ds = \"dsNameTokenDataSource\"; }");
318+
Files.writeString(multiDir.resolve("DatabaseAuditDsNameTokenIT.java"),
319+
"class DatabaseAuditDsNameTokenIT { String ds = \"dsNameTokenDataSource\"; }");
320+
}
321+
322+
/**
323+
* Runs the post-generate script in {@code project} mode, where the only cleanup is the {@code parentClass}
324+
* deletion and the {@code dataSourceNames} expansion. A {@code null} {@code dataSourceNames} omits the
325+
* property entirely, mirroring no datasource names.
326+
*/
327+
private void runPostGenerateScriptInProjectMode(Path outputDir, String artifactId, String dataSourceNames)
328+
throws IOException {
329+
Properties props = new Properties();
330+
props.setProperty("generateMode", "project");
331+
props.setProperty("parentClass", "none");
332+
props.setProperty("package", PACKAGE);
333+
if (dataSourceNames != null) {
334+
props.setProperty("dataSourceNames", dataSourceNames);
335+
}
336+
evaluateScript(outputDir, artifactId, props, null);
337+
}
338+
248339
private void evaluateScript(Path outputDir, String artifactId, Properties props, String pwdOverride)
249340
throws IOException {
250341
String script;

archetype/src/test/resources/projects/disabled-tests/archetype.properties

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,4 @@ springBootVersion=4.1.0
1010
generateMode=project
1111
parentClass=none
1212
disabledTests=true
13+
dataSourceNames=none

archetype/src/test/resources/projects/full/archetype.properties

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,4 @@ springBootVersion=4.1.0
1010
generateMode=project
1111
parentClass=none
1212
disabledTests=false
13+
dataSourceNames=none
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
groupId=com.example
2+
artifactId=archetype-it-multi-datasource
3+
version=1.0.0-SNAPSHOT
4+
package=com.example.demo
5+
schemaName=public
6+
schemaPropertyName=database.datasource.schema-name
7+
postgresImage=postgres:16
8+
databaseAuditsVersion=@project.version@
9+
springBootVersion=4.1.0
10+
generateMode=project
11+
parentClass=none
12+
disabledTests=true
13+
dataSourceNames=Aurora,Reporting
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
verify

0 commit comments

Comments
 (0)