SqlCapturingStatementInspector.java
package io.github.databaseaudits.capture;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.regex.Pattern;
import org.hibernate.resource.jdbc.spi.StatementInspector;
/**
* Records every SQL statement Hibernate executes, so the runtime audits can
* inspect the real SQL the repositories generate — derived queries,
* {@code @Query} (JPQL and native), and EntityManager/cascade operations all
* flow through here.
*
* <p>
* It never mutates the SQL — {@link #inspect(String)} returns its input
* unchanged. The captured set holds the SQL <em>text</em> with {@code ?}
* placeholders (no bind values), which is what the EXPLAIN audits need.
*
* <h2>Registration — turn capture on across the suite</h2> Capture lives on the
* instance, so the <em>same instance</em> must be both Hibernate's
* {@code StatementInspector} and the one the audits use. With plain Hibernate —
* or any dependency-injection container — put the instance itself into the
* session-factory settings under
* {@link org.hibernate.cfg.JdbcSettings#STATEMENT_INSPECTOR} when
* bootstrapping, and hand the audits that same instance. Spring Boot users get
* exactly this wiring by importing {@code DatabaseAuditTestConfiguration} from
* the {@code database-audits-spring-boot} module.
*
* <p>
* (Registering by <em>class name</em> via the
* {@code hibernate.session_factory.statement_inspector} property is
* <em>not</em> supported here: Hibernate would instantiate a <em>separate</em>
* capturer whose capture the audits never see.) Only Hibernate-issued SQL is
* captured; statements run directly over JDBC are not.
*/
public class SqlCapturingStatementInspector implements StatementInspector {
private static final long serialVersionUID = 1L;
/**
* Shared message for the runtime audits when the capture is empty — they
* throw {@link IllegalStateException} with this rather than returning no
* violations (a vacuous pass).
*/
public static final String EMPTY_CAPTURE_MESSAGE =
"""
No SQL was captured, so this audit would pass vacuously.
* Register capture across the suite (one inspector instance shared by Hibernate and the audits, \
e.g. import DatabaseAuditTestConfiguration from database-audits-spring-boot), and
* run this audit AFTER your repository integration tests, in the same JVM.""";
private static final Pattern WHITESPACE = Pattern.compile("\\s+");
private final Set<String> captured = ConcurrentHashMap.newKeySet();
@Override
public String inspect(final String sql) {
if (sql != null && !sql.isBlank()) {
captured.add(sql);
}
return sql; // must return the SQL unchanged
}
/**
* Returns a snapshot of every distinct statement captured so far by this
* capturer.
*
* @return An immutable copy of all captured SQL statements.
*/
public Set<String> capturedSql() {
return Set.copyOf(captured);
}
/**
* Collapses every run of whitespace to a single space and trims — the
* canonical form the runtime audits match and de-duplicate on. Note this
* also rewrites whitespace <em>inside</em> string literals, so use it for
* detection/reporting, not for re-execution.
*
* @param sql
* The SQL string to normalize.
* @return The normalized SQL string, or an empty string if {@code sql} is
* {@code null}.
*/
public String normalize(final String sql) {
return sql == null ? ""
: WHITESPACE.matcher(sql).replaceAll(" ").strip();
}
/**
* Clears the capture. Not used by the bundled audits, but handy if a
* consumer wants per-test isolation (e.g. capture only one method's SQL) —
* call it from a {@code @BeforeEach}.
*/
public void clear() {
captured.clear();
}
}