Adding an Audit
This guide walks through adding a new audit to database-audits-core. The families, their collaborators, and
the design rules an audit follows are described in Architecture; this page is the
step-by-step for each family.
The shape of every audit
An audit is a plain class — constructor injection, no dependency-injection annotations — whose audit(…)
method returns a List<Finding>, empty when clean. Each Finding (io.github.databaseaudits.audit.finding)
is a record carrying the structured facts behind the violation plus a description() that is the exact
human-readable line the audit historically emitted. The audit never asserts and never throws for a finding; the
caller asserts on the returned list (typically via Finding::description). It throws only when it could not
actually check anything (an empty SQL capture, an unsupported platform), so a misconfigured run fails loudly
instead of passing vacuously. See "Audits return findings; callers assert." and "Never pass vacuously." in
Architecture.
Pick the family your check belongs to — each has a recipe below:
| Family | Use when your check reads … |
|---|---|
| Catalog audit | database metadata (information_schema / pg_catalog) — deterministic, no test data needed. |
| Runtime plan audit (PostgreSQL-only) | the plans of the SQL the app ran — to prove an access path has no serving index (PostgreSQL-only). |
| Runtime capture-scan audit | the text of the SQL the app ran — e.g. a dangerous statement shape. |
| JPA mapping audit | Hibernate’s mapping model against the live schema. |
Catalog audit
A catalog audit takes CatalogQueries (the JDBC-to-list-of-maps layer) plus whatever it needs to obtain SQL and
compare results: the DatabasePlatform when it runs per-engine catalog SQL, and/or IndexCatalog when it works
from indexes. PrimaryKeyPresenceAudit is the minimal example:
@AllArgsConstructor
public class XxxAudit {
private final CatalogQueries catalogQueries;
private final DatabasePlatform platform;
String sql() {
return platform.catalogDialect().xxxSql(); // per-engine SQL from the dialect
}
/** Returns one finding per offending row; an empty list when clean. */
public List<Finding> audit(final String schema, final Set<String> excluded) {
return catalogQueries.queryForList(sql(), schema).stream()
.map(row -> String.valueOf(row.get("some_column")))
.filter(finding -> !excluded.contains(finding))
.<Finding>map(XxxFinding::new)
.toList();
}
}If the audit needs metadata no existing query returns, add a method to CatalogDialect — abstract if the SQL
diverges between engines, default if the standard information_schema SQL serves every engine (see
Adding a Database — Step 1). Read result columns by alias,
case-insensitively. Index-based audits (ForeignKeyIndexAudit, RedundantIndexAudit) take IndexCatalog
instead of writing their own SQL, and do the leading-prefix / containment comparison in plain Java where it is
unit-testable and platform-independent.
Every audit needs a finding record: a small class in io.github.databaseaudits.audit.finding implementing
Finding, added to Finding’s `permits list, carrying the structured fields a fix generator needs plus a
description() returning the exact reported line:
public record XxxFinding(String someColumn) implements Finding {
@Override
public String description() {
return someColumn;
}
}Because Finding is sealed, adding a new record to its permits list is a compile error everywhere an
exhaustive switch covers it — including the Spring module’s FixRenderer, which must gain a case for the new
finding before that module compiles again. That compile failure is the intended signal to do the Phase 5-style
lockstep update, not a bug to work around.
Runtime plan audit (PostgreSQL-only)
The three EXPLAIN-driven audits share CapturedSqlPlanAuditTemplate, which owns the fixed algorithm — read the
capture, de-duplicate by statement shape, EXPLAIN each candidate with penalties applied, collect offending
nodes — and both vacuous-run guards (empty capture, wholly-unexplainable run). A new plan audit lives beside the template in the
audit.runtime.plan package (the template is package-private), extends it, and supplies only the four variation
points:
@Slf4j
public class XxxIndexAudit extends CapturedSqlPlanAuditTemplate {
public XxxIndexAudit(final QueryPlanExplainer explainer,
final SqlCapturingStatementInspector capturer) {
super(explainer, capturer);
}
@Override
protected boolean isCandidate(final String upperCasedSql) {
return upperCasedSql.contains("..."); // which statements to EXPLAIN
}
@Override
protected String[] plannerSettings() {
return new String[] { "enable_seqscan = off" }; // GUCs to penalize
}
@Override
protected void collectFindings(final JsonNode plan, final List<String> findings,
final Set<String> excludedRelations) {
// walk the plan; add a finding for each surviving penalized node.
// firstRelationName(...) and collectChildFindings(...) are provided by the base.
}
@Override
protected String statementNoun() {
return "XXX"; // for the vacuous-run guard message
}
}You do not re-implement capture reading, de-duplication, the empty-capture guard, or the all-skipped guard —
they live in the template exactly once. Requires preferQueryMode=simple on the JDBC URL (generic-plan EXPLAIN
only works over the simple query protocol); the base’s guard message says so when a run explains nothing.
Runtime capture-scan audit
An audit that inspects the text rather than the plan of captured SQL takes only the
SqlCapturingStatementInspector, reads capturedSql(), and scans — UnconditionalMutationAudit is the model.
Guard the empty capture yourself so the audit never passes vacuously:
public List<Finding> audit(final Set<String> excludedStatements) {
final Set<String> captured = sqlCapturer.capturedSql();
if (captured.isEmpty()) {
throw new IllegalStateException(SqlCapturingStatementInspector.EMPTY_CAPTURE_MESSAGE);
}
return captured.stream()...<Finding>map(XxxFinding::new).toList();
}JPA mapping audit
JPA audits validate Hibernate’s boot mapping model against the live schema. SchemaEntityValidationAudit is the
sole example; it obtains the mapping model from a MappingMetadataIntegrator captured during bootstrap plus a
DataSource, and is built through a static factory (forEntityManagerFactory(emf, dataSource)) rather than a
public constructor. A new JPA audit follows the same route. This is the rarest family — reach for it only when
the check is genuinely about entity-to-schema mapping.
The standing directive — update the Spring beans in lockstep
database-audits-core deliberately ships no Spring wiring; database-audits-spring-boot supplies it. Its
DatabaseAuditSuite calls every core audit constructor directly, and a paired <Audit>AuditAssertion exposes
each audit’s findings as a test assertion.
Important: A new audit (or any change to an existing audit’s constructor or public audit(…) signature) must
be matched in the Spring module. A compile failure there against core is the intended signal. The integration’s
Adding an Audit guide is the
other half of this recipe — and its roster-guard test fails the build if a new core audit has no wired
assertion, so the two stay in step.
Tests
- A
<Audit>Testunit test over the finding logic — feed rows (catalog), plan JSON (plan), or captured statements (capture) and assert the returnedList<Finding>(typically via.extracting(Finding::description)). Assert whole lists with AssertJ. - For a catalog audit, an
…ITagainst a real engine (as the dialect ITs do) proves the SQL and the mapping agree end-to-end. - Cover both a clean run (empty list) and the cannot-run guard (the thrown
IllegalStateException/UnsupportedOperationException), so the never-pass-vacuously contract is enforced.
Checklist
- Audit class in the right
audit.*package, returningList<Finding>, throwing only on a cannot-run condition. - A
Findingrecord for the new violation kind, added toFinding’s `permitslist. - Any new
CatalogDialectmethod (abstract vs default chosen per Catalog audit). - Unit test, and an IT where the audit runs real SQL; clean and cannot-run cases covered.
- Audits reference entry for the new audit (what it detects, finding format, exclusion type).
- Spring module updated in lockstep — see the integration’s Adding an Audit guide.

