Configuration Reference¶
Choose the result you need before changing global settings:
| Workflow | Start with | Expected result |
|---|---|---|
| Keep a read path free of writes | @ExpectQueries(insert = 0, update = 0, delete = 0); add a SELECT upper bound when useful | A captured write or exceeded budget fails the test |
| Review each test's count changes | Record a query snapshot contract | Count deviations fail until the reviewed contract is updated |
| Locate the SQL behind a failure | Keep the failure's statement list and enable JSON reports | Captured SQL and retained stack traces help locate the application operation |
| Compare CI runs with known coverage and inputs | Expected-test coverage and report comparison | Missing audits or incompatible inputs remain distinct from a successful comparison |
Choose Your Workflow provides the recipes. Rule profiles and finding thresholds below configure supporting analysis; they do not change explicit query budgets or snapshot count contracts.
Version scope
This reference documents QueryAudit 0.7.2. Check Versions and compatibility for the published dependency and tested framework/database combinations.
QueryAudit can be configured at three levels. When multiple levels conflict, the most specific one wins:
application.yml Properties¶
All properties are optional. The table below lists every supported key under the query-audit prefix.
| Property | Type | Default | Description |
|---|---|---|---|
enabled | boolean | true | Master switch for the entire auto-configuration. When false, the QueryInterceptor bean and the wrapping BeanPostProcessor are both skipped — the @QueryAudit annotation will not work either. Use wrap-data-source.enabled: false instead if you want to keep the interceptor active but skip the auto-wrap. |
profile | String | "recommended" | Rule tier: recommended (call-site N+1 only), minimal (N+1, index, and write safety), or strict (all rules). See Rule Profiles. |
enabled-rules | List<String> | [] | Rule codes to run even when the profile tier excludes them. disabled-rules still wins. |
mode | String | "annotated" | Which tests the JUnit extension audits: annotated (opt-in via @QueryAudit) or all (every test, opt-out via @QueryAuditExclude). all additionally requires JUnit extension autodetection — see Audit Coverage Mode. |
contracts.path | String | ".query-audit-contracts" | Query contracts for test methods and QueryContractScope: one file, or a directory whose *.contracts files are all read. See contracts. |
await-executors | List<String> | [] | Bean names of ThreadPoolTaskExecutor or ThreadPoolExecutor pools that audited test methods and the injected QueryContractScope wait for. See Background Work. |
wrap-data-source.enabled | boolean | true | Surgical escape hatch (issue #134) — disables only the auto-wrap BeanPostProcessor while keeping QueryInterceptor and QueryAuditConfig beans active. Use this when integrating with an existing datasource-proxy (e.g. gavlyukovskiy). |
fail-on-detection | boolean | true | Whether confirmed issues (ERROR/WARNING) should cause the test to fail with an AssertionError. |
count-instead-of-exists.enabled | boolean | false | Enable the count-instead-of-exists INFO detector. Off by default because it can fire on legitimate aggregate counts. |
connection-held-idle.threshold-ms | long | 200 | Minimum idle time (connection held minus database work) before the connection-held-idle rule fires. Requires the non-DB work to actually take time in tests — zero-latency mocks never reproduce the gap. |
repeated-insert.exclude-tables | List<String> | ["temp_*", "*_temp", "tmp_*", "*_tmp", "staging_*", "*_staging"] | Table-name globs (case-insensitive, * wildcard) treated as deliberate staging targets — repeated single-row inserts into these tables are not flagged. |
repeated-update.exclude-tables | List<String> | ["temp_*", "*_temp", "tmp_*", "*_tmp", "staging_*", "*_staging"] | Case-insensitive table-name globs excluded from repeated single-row UPDATE detection. Replace with [] to inspect every table. |
n-plus-one.threshold | int | 3 | Number of times a structurally identical query must repeat before it is flagged as N+1. |
offset-pagination.threshold | int | 1000 | The OFFSET value that triggers a warning. |
or-clause.threshold | int | 3 | Number of OR conditions in a single WHERE clause before flagging. |
suppress-patterns | List<String> | [] | Issue codes or qualified patterns to suppress globally. See Suppressing Issues. |
suppress-queries | List<String> | [] | SQL query substrings to suppress (e.g., health-check queries). Case-insensitive substring match. |
baseline-path | String | null | Path to the finding baseline file. When null, uses .query-audit-baseline in the working directory. |
auto-open-report | boolean | true | Whether to open the selected HTML report after tests. Ignored for console and json. |
max-queries | int | 10000 | Maximum number of queries retained per test. If additional queries are dropped, the audit fails as incomplete. |
report.format | String | "console" | Suite artifact: console writes no file, json writes report.json, and html writes the browser report. Per-test console diagnostics remain available for every selection. |
report.output-dir | String | "build/reports/query-audit" | Directory used by the selected JSON or HTML report. |
report.show-info | boolean | true | Whether unacknowledged INFO findings appear in console, HTML, and GitHub Actions display. Canonical run JSON and sink summaries retain them for trustworthy comparisons; analysis and query statistics are unchanged. |
disabled-rules | List<String> | [] | Rule codes to completely disable. |
severity-overrides | Map<String,String> | {} | Override severity per rule code (e.g., select-all: WARNING). |
large-in-list.threshold | int | 100 | Number of values in IN clause before flagging. |
too-many-joins.threshold | int | 5 | Number of JOINs before flagging. |
excessive-column.threshold | int | 15 | Number of selected columns before flagging. |
repeated-insert.threshold | int | 3 | Number of repeated single-row INSERTs before flagging. |
repeated-update.threshold | int | 3 | Number of repeated single-row UPDATEs with the same normalized shape before flagging. |
write-amplification.threshold | int | 6 | Number of indexes per table before flagging write amplification. |
slow-query.warning-ms | long | 500 | Execution time (ms) that triggers a WARNING-level slow query. |
slow-query.error-ms | long | 3000 | Execution time (ms) that triggers an ERROR-level slow query. |
Full Example¶
query-audit:
enabled: true
fail-on-detection: true
n-plus-one:
threshold: 3
offset-pagination:
threshold: 1000
or-clause:
threshold: 3
large-in-list:
threshold: 100
too-many-joins:
threshold: 5
excessive-column:
threshold: 15
repeated-insert:
threshold: 3
repeated-update:
threshold: 3
exclude-tables: ["temp_*", "*_temp", "tmp_*", "*_tmp", "staging_*", "*_staging"]
write-amplification:
threshold: 6
slow-query:
warning-ms: 500
error-ms: 3000
disabled-rules:
- "select-all"
severity-overrides:
unbounded-result-set: ERROR
suppress-patterns:
- "select-all"
- "missing-where-index:config.key"
suppress-queries:
- "SELECT 1"
baseline-path: ""
auto-open-report: false
max-queries: 10000
report:
format: console
output-dir: build/reports/query-audit
show-info: true
Rule Profiles¶
Since 0.7.0, omitted or blank profile settings select recommended, which runs only the call-site N+1 rule among built-in rules. Choose minimal or strict to add index, write, and SQL style checks:
| Profile | What runs | Use it for |
|---|---|---|
recommended (default) | n-plus-one: the same SELECT with different values repeated from one full application call stack | First adoption, day-to-day CI |
minimal | n-plus-one, missing-where-index, missing-join-index, cartesian-join, update-without-where, unbounded-result-set, slow-query | Adding index and write-safety review |
strict | Every rule | Maximum coverage, mature suppression setup |
query-audit:
profile: recommended
enabled-rules:
- update-without-where # add one rule to the profile
Precedence: disabled-rules > enabled-rules > profile tier.
recommended and minimal are allow-lists over built-in rules, so a new built-in rule stays off until it is added explicitly. recommended never filters custom finding kinds from AuditRule extensions. Legacy DetectionRule extensions report built-in issue types and follow the profile like built-in rules; enable their codes with enabled-rules. External rules registered via ServiceLoader without a rule code are never filtered by profiles.
The N+1 rule groups captured SELECT statements by normalized SQL and the full application call stack, ignoring proxy, reflection, and framework frames. Three executions from one call site that bind different values report a confirmed n-plus-one finding; n-plus-one.threshold changes the count. A query with a multi-placeholder IN list is a batched fetch and a query with OFFSET reads the next page; neither counts. Executions that all bind the same values repeat one lookup and are reported as INFO, which does not fail the test. Hibernate lazy-load events are reported as INFO n-plus-one findings that name the association; they explain a finding but never confirm one on their own.
Migrating from 0.6.x to 0.7¶
If you did not configure a profile, upgrading reduces the active built-in rules to n-plus-one. Set profile: strict to keep every rule, or list the rules you enforce in enabled-rules. Hibernate lazy-load N+1 findings move from ERROR to INFO; the confirmed finding now comes from a SELECT repeated with different values at one call site, so one batch fetch no longer fails a test.
SQL from other threads still counts toward a test that runs alone, as in 0.6. During parallel audits it makes the run INCONCLUSIVE. Name the pools that tests hand work to in await-executors so each test waits for its own background work.
Setting names now follow one rule. The earlier system property names keep working, so existing builds need no change; the Gradle bridge in the CI guide forwards the new names without a per-setting map.
Migrating from 0.5.x to 0.6.0¶
If you did not configure a profile, upgrading changes the active rules from strict to recommended. The profile excludes these 21 rule codes:
| Area | Excluded rule codes |
|---|---|
| Query shape and style | force-index-hint, offset-pagination, like-leading-wildcard, or-abuse, case-in-where, regexp-usage, find-in-set, having-misuse, distinct-misuse, union-without-all |
| Counts and repeated queries | count-star-no-where, count-instead-of-exists, n-plus-one-suspect, mergeable-queries |
| Execution plans and indexes | full-scan, filesort, temporary-table, covering-index-opportunity |
| Locks, windows and connection timing | for-update-no-timeout, window-no-partition, connection-held-idle |
count-instead-of-exists already required a separate opt-in in 0.5.x. With recommended, it also needs an entry in enabled-rules. The other listed rules now need an explicit profile or rule override to run.
To retain the previous rule selection in Spring:
For direct core use, select it on the builder:
For plain JUnit, set -DqueryAudit.profile=strict on the test JVM. In Gradle, configure tasks.withType(Test).configureEach { systemProperty 'queryAudit.profile', 'strict' }; with Maven Surefire, use mvn test -DqueryAudit.profile=strict. This system property also overrides Spring's profile for the JUnit audit. Omitted values preserve Spring configuration; blank values select recommended.
Explicit strict, recommended, and minimal retain their rule selections. You can also keep recommended and re-enable selected rules with enabled-rules. Query budgets and snapshot contracts still run independently of the profile. Re-record a comparison reference after changing profiles; reports produced with different active rules are not equivalent.
The JUnit console report shows the effective profile for each test. Spring startup logs show the configured profile; a test JVM override, if present, appears in the JUnit output. JSON schema 1.6 records the effective profile in each test's comparisonInputs entry. The comparator rejects profile differences; see Comparison inputs before replacing a saved baseline.
Audit Coverage Mode¶
By default only tests that opt in via @QueryAudit (or a related annotation) are audited (mode: annotated). This keeps adoption friction low, but every unannotated test is a blind spot — a regression in an uncovered domain merges silently.
mode: all flips the coverage model: every test is audited, and individual tests opt out with @QueryAuditExclude.
Two switches are required:
- Register the extension suite-wide via JUnit's extension autodetection. In
src/test/resources/junit-platform.properties:
- Set the mode — in
application.ymlfor Spring projects:
For plain JUnit, set the same value in the test JVM. With the Gradle property bridge, run ./gradlew test -PqueryAudit.mode=all; with Maven, run mvn test -DqueryAudit.mode=all. The system property wins over the yml value.
Enabling autodetection alone does not widen coverage: in the default annotated mode the extension stays inactive for classes that never opted in, and a class that ends up registered twice (autodetection + @QueryAudit) is audited exactly once.
Brownfield onboarding¶
Turning mode: all on for an existing suite usually surfaces a wall of findings at once. The supported adoption path pairs it with the baseline:
- Enable
mode: allas above. - Record the current findings once into the baseline (see Suppressing Issues).
- From then on, existing findings are frozen — only new violations fail the build.
Coverage first, cleanup incrementally — the audit becomes a ratchet instead of a wall.
Background Work¶
Each audited test captures the SQL that runs on its own thread. Some SQL runs on another thread: an @Async method, an @Async event listener, or a request that a RANDOM_PORT server handles. QueryAudit treats it in two steps.
- Count: while the test is the only audit running, SQL and Hibernate lazy loads from other threads count toward it. When several audits run in parallel, that SQL cannot be assigned to one of them and makes the run
INCONCLUSIVE. - Wait: work handed to a thread pool can still be running when the test method returns. Name those pools in
await-executors, and QueryAudit waits until they are idle before it moves to the teardown phase. Their SQL then counts in the test phase, where budgets, contracts, and findings see it. After 30 seconds the test fails with the busy pool's name.
Without the wait, background SQL can land in teardown, after the audit ends, or in the next test. QueryContractScope waits for the same pools; see contract a request or job.
Counting covers every thread, not only the named pools, so keep schedulers off during tests. await-executors accepts ThreadPoolTaskExecutor and ThreadPoolExecutor beans. With spring.threads.virtual.enabled, Spring runs @Async work on a SimpleAsyncTaskExecutor, which cannot be awaited; define a ThreadPoolTaskExecutor for tests or wait for the work in the test. Without Spring, wrap the tasks with QueryCaptureSession.wrap; see parallel capture.
Common Configurations¶
Copy-paste these presets for typical use cases.
The default profile runs only the call-site N+1 rule. Name the pools your requests hand work to, and keep contracts next to the tests.
Report N+1 findings without failing tests while you review the existing ones. Budgets and contracts still fail.
Write the JSON report that the first CI check and report comparison read.
Recommended Threshold Values¶
| Threshold | Conservative | Moderate (default) | Strict |
|---|---|---|---|
n-plus-one.threshold | 5 | 3 | 2 |
slow-query.warning-ms | 1000 | 500 | 200 |
slow-query.error-ms | 5000 | 3000 | 1000 |
offset-pagination.threshold | 5000 | 1000 | 500 |
large-in-list.threshold | 500 | 100 | 50 |
too-many-joins.threshold | 8 | 5 | 3 |
excessive-column.threshold | 25 | 15 | 10 |
or-clause.threshold | 5 | 3 | 2 |
repeated-update.threshold | 5 | 3 | 2 |
Thresholds other than n-plus-one.threshold apply only when their rule is enabled.
Choosing threshold values
- Conservative: Use when adopting QueryAudit in a large existing codebase. Minimizes false positives at the cost of missing some real issues.
- Moderate (default): Balanced for most projects. Good starting point.
- Strict: Use for new projects or performance-critical services. May require more suppression of intentional patterns.
@QueryAudit Annotation Attributes¶
See the Annotations Guide for detailed usage patterns.
| Attribute | Type | Default | Description |
|---|---|---|---|
suppress | String[] | {} | Issue codes or qualified patterns to suppress. |
failOn | IssueType[] | {} (all confirmed) | Restrict which issue types cause a test failure. |
nPlusOneThreshold | int | -1 (use yml/default) | Override the N+1 detection threshold. |
failOnDetection | BooleanOverride | INHERIT (yml default: true) | Whether to fail the test when confirmed issues are detected. Use BooleanOverride.TRUE/FALSE to override. |
baselinePath | String | "" (default) | Path to the baseline file. |
autoOpenReport | BooleanOverride | INHERIT (yml default: true) | Open HTML report in browser after tests. Use BooleanOverride.TRUE/FALSE to override. |
includeSetupQueries | boolean | false | Include queries from @BeforeEach/@AfterEach in analysis. |
Annotation-Based Configuration Examples¶
Programmatic Configuration¶
When using QueryAudit without Spring Boot:
QueryAuditConfig config = QueryAuditConfig.builder()
.enabled(true)
.failOnDetection(true)
.nPlusOneThreshold(5)
.offsetPaginationThreshold(500)
.orClauseThreshold(4)
.largeInListThreshold(100)
.tooManyJoinsThreshold(5)
.excessiveColumnThreshold(15)
.repeatedInsertThreshold(3)
.repeatedUpdateThreshold(3)
.repeatedUpdateExcludeTables(Set.of("temp_*", "staging_*"))
.writeAmplificationThreshold(6)
.slowQueryWarningMs(500)
.slowQueryErrorMs(3000)
.addDisabledRule("select-all")
.addSeverityOverride("unbounded-result-set", "ERROR")
.addSuppressPattern("select-all")
.addSuppressPattern("missing-where-index:users.email")
.addSuppressQuery("SELECT 1") // Suppress health-check queries
.showInfo(true)
.maxQueries(10_000) // Limit recorded queries per test
.build();
Builder Method Reference¶
| Method | Type | Default | Description |
|---|---|---|---|
enabled(boolean) | boolean | true | Master switch. |
failOnDetection(boolean) | boolean | true | Fail on confirmed issues. |
nPlusOneThreshold(int) | int | 3 | N+1 detection threshold. |
offsetPaginationThreshold(int) | int | 1000 | OFFSET pagination threshold. |
orClauseThreshold(int) | int | 3 | OR clause threshold. |
suppressPatterns(Set<String>) | Set<String> | {} | Replace all suppress patterns. |
addSuppressPattern(String) | String | -- | Add a single suppress pattern. |
suppressQueries(Set<String>) | Set<String> | {} | Replace all suppressed queries. |
addSuppressQuery(String) | String | -- | Add a single suppressed query substring. |
showInfo(boolean) | boolean | true | Show INFO-level issues. |
maxQueries(int) | int | 10000 | Max queries recorded per test. |
largeInListThreshold(int) | int | 100 | IN clause value count threshold. |
tooManyJoinsThreshold(int) | int | 5 | JOIN count threshold. |
excessiveColumnThreshold(int) | int | 15 | Selected column count threshold. |
repeatedInsertThreshold(int) | int | 3 | Repeated single-row INSERT threshold. |
repeatedUpdateThreshold(int) | int | 3 | Repeated single-row UPDATE threshold. |
repeatedUpdateExcludeTables(Set<String>) | Set<String> | staging/temp globs | Replace the table exclusions for repeated UPDATE detection. |
addRepeatedUpdateExcludeTable(String) | String | -- | Add one table-name glob to the repeated UPDATE exclusions. |
writeAmplificationThreshold(int) | int | 6 | Index count per table threshold. |
slowQueryWarningMs(long) | long | 500 | Slow query WARNING threshold (ms). |
slowQueryErrorMs(long) | long | 3000 | Slow query ERROR threshold (ms). |
addDisabledRule(String) | String | -- | Disable a specific rule by code. |
disabledRules(Set<String>) | Set<String> | {} | Replace all disabled rules. |
addSeverityOverride(String, String) | String, String | -- | Override severity for a rule code. |
severityOverrides(Map<String,String>) | Map<String,String> | {} | Replace all severity overrides. |
Setting names¶
Every setting has one name. In application.yml it is a kebab-case path under query-audit. As a system property, Maven -D, or Gradle -P it is the same path in camelCase under queryAudit:
Spring Boot binds either form, so a system property overrides application.yml. Plain JUnit projects without Spring read the system properties below. Gradle forwards them to test workers through the property bridge.
Configuration can live in application.yml or on the command line. Actions that change files for one run, such as recording, are command-line flags only.
| Setting | Kind | Earlier names, still accepted |
|---|---|---|
queryAudit.profile | configuration | |
queryAudit.mode | configuration | queryGuard.mode |
queryAudit.failOnDetection | configuration | |
queryAudit.baselinePath | configuration | |
queryAudit.report.format | configuration | queryAudit.reportFormat, queryGuard.reportFormat |
queryAudit.report.outputDir | configuration | queryAudit.reportOutputDir |
queryAudit.report.redaction | configuration | queryAudit.reportRedaction |
queryAudit.autoOpenReport | configuration | queryaudit.autoOpenReport, env QUERYGUARD_AUTO_OPEN_REPORT |
queryAudit.contracts.path | configuration | queryAudit.contractsPath, queryGuard.contractsPath |
queryAudit.contracts.record | action | queryGuard.contracts.record |
queryAudit.coverage.manifest | configuration, command line only | queryAudit.coverageManifest |
queryAudit.counts.path | configuration, deprecated | queryAudit.countBaselinePath, queryGuard.countBaselinePath |
queryAudit.counts.record | action, deprecated | queryAudit.updateBaseline, queryGuard.updateBaseline |
queryAudit.coverage.manifest is read before any Spring context starts, so it has no application.yml form. The environment variable QUERYAUDIT_AUTO_OPEN_REPORT matches queryAudit.autoOpenReport. Count baselines are deprecated since 0.7.0; use query contracts instead.
Issue Types Reference¶
Use these codes in suppress, failOn, and suppress-patterns after reviewing the matching finding. Some codes are disabled or reserved; their presence in configuration does not mean that the corresponding check ran.
ERROR Severity¶
| Code | Enum | Description |
|---|---|---|
n-plus-one | N_PLUS_ONE | Hibernate lazy-load finding (same collection/proxy loaded for many distinct owners) |
where-function | WHERE_FUNCTION | Function on column in WHERE disables index |
missing-where-index | MISSING_WHERE_INDEX | No index on WHERE column |
missing-join-index | MISSING_JOIN_INDEX | No index on JOIN column |
cartesian-join | CARTESIAN_JOIN | JOIN without ON condition |
non-sargable | NON_SARGABLE_EXPRESSION | Arithmetic on column prevents index |
null-comparison | NULL_COMPARISON | = NULL instead of IS NULL (logic bug) |
for-update-no-index | FOR_UPDATE_WITHOUT_INDEX | FOR UPDATE without index locks table |
update-without-where | UPDATE_WITHOUT_WHERE | UPDATE/DELETE without WHERE |
order-by-rand | ORDER_BY_RAND | ORDER BY RAND() causes full table scan and sort |
not-in-subquery | NOT_IN_SUBQUERY | NOT IN (subquery) returns empty when subquery contains NULL |
WARNING Severity¶
| Code | Enum | Description |
|---|---|---|
or-abuse | OR_ABUSE | Excessive OR conditions |
offset-pagination | OFFSET_PAGINATION | Large OFFSET pagination |
missing-order-by-index | MISSING_ORDER_BY_INDEX | No index on ORDER BY column |
missing-group-by-index | MISSING_GROUP_BY_INDEX | No index on GROUP BY column |
composite-index-leading | COMPOSITE_INDEX_LEADING_COLUMN | Composite index leading column unused |
like-leading-wildcard | LIKE_LEADING_WILDCARD | Leading wildcard in LIKE |
duplicate-query | DUPLICATE_QUERY | Exact duplicate SQL (disabled) |
correlated-subquery | CORRELATED_SUBQUERY | Correlated subquery in SELECT |
redundant-index | REDUNDANT_INDEX | Redundant index (prefix of another) |
slow-query | SLOW_QUERY | Query exceeding time threshold |
unbounded-result-set | UNBOUNDED_RESULT_SET | SELECT without LIMIT |
write-amplification | WRITE_AMPLIFICATION | Too many indexes on table |
implicit-type-conversion | IMPLICIT_TYPE_CONVERSION | Implicit type conversion disables index |
order-by-limit-no-index | ORDER_BY_LIMIT_WITHOUT_INDEX | ORDER BY + LIMIT without index |
large-in-list | LARGE_IN_LIST | IN clause with too many values |
distinct-misuse | DISTINCT_MISUSE | Unnecessary DISTINCT |
having-misuse | HAVING_MISUSE | HAVING on non-aggregate column |
range-lock-risk | RANGE_LOCK_RISK | Range + FOR UPDATE on unindexed column |
query-count-regression | QUERY_COUNT_REGRESSION | Query count regression vs baseline |
dml-without-index | DML_WITHOUT_INDEX | UPDATE/DELETE WHERE without index |
repeated-single-insert | REPEATED_SINGLE_INSERT | Repeated single-row INSERT |
repeated-single-update | REPEATED_SINGLE_UPDATE | Repeated UPDATEs scoped by a unique key should use a set-based statement or batch |
insert-select-all | INSERT_SELECT_ALL | INSERT with SELECT * |
insert-on-duplicate-key | INSERT_ON_DUPLICATE_KEY | INSERT ON DUPLICATE KEY UPDATE may cause deadlocks |
subquery-in-dml | SUBQUERY_IN_DML | Subquery in UPDATE/DELETE blocks semijoin optimization |
implicit-columns-insert | IMPLICIT_COLUMNS_INSERT | INSERT without explicit column list |
too-many-joins | TOO_MANY_JOINS | Query has too many JOINs |
implicit-join | IMPLICIT_JOIN | Implicit comma-separated join syntax |
string-concat-where | STRING_CONCAT_IN_WHERE | String concatenation in WHERE prevents index |
unused-join | UNUSED_JOIN | LEFT JOIN table never referenced in query |
for-update-non-unique | FOR_UPDATE_NON_UNIQUE | FOR UPDATE on non-unique index causes gap locks |
group-by-function | GROUP_BY_FUNCTION | Function in GROUP BY prevents index usage |
regexp-usage | REGEXP_INSTEAD_OF_LIKE | REGEXP/RLIKE prevents index usage |
find-in-set | FIND_IN_SET_USAGE | FIND_IN_SET indicates comma-separated values violating 1NF |
collection-delete-reinsert | COLLECTION_DELETE_REINSERT | DELETE-all + re-INSERT pattern (inefficient collection management) |
derived-delete-loads-entities | DERIVED_DELETE_LOADS_ENTITIES | Derived delete loads entities before individual deletes |
limit-without-order-by | LIMIT_WITHOUT_ORDER_BY | LIMIT without ORDER BY returns non-deterministic rows |
window-no-partition | WINDOW_FUNCTION_WITHOUT_PARTITION | Window function without PARTITION BY operates on entire result set |
for-update-no-timeout | FOR_UPDATE_WITHOUT_TIMEOUT | FOR UPDATE without NOWAIT or SKIP LOCKED may block indefinitely |
case-in-where | CASE_IN_WHERE | CASE expression in WHERE clause prevents index usage |
INFO Severity¶
| Code | Enum | Description |
|---|---|---|
n-plus-one-suspect | N_PLUS_ONE_SUSPECT | SQL-level heuristic: same normalized query repeated above threshold (review with the Hibernate evidence in n-plus-one) |
select-all | SELECT_ALL | SELECT * usage |
redundant-filter | REDUNDANT_FILTER | Duplicate WHERE condition |
count-instead-of-exists | COUNT_INSTEAD_OF_EXISTS | COUNT where EXISTS is better |
full-scan | FULL_TABLE_SCAN | Full table scan (EXPLAIN-based, reserved) |
filesort | FILESORT | Filesort detected by an EXPLAIN analyzer |
temporary-table | TEMPORARY_TABLE | Temporary table usage detected by an EXPLAIN analyzer |
union-without-all | UNION_WITHOUT_ALL | UNION without ALL |
covering-index-opportunity | COVERING_INDEX_OPPORTUNITY | Could benefit from covering index |
count-star-no-where | COUNT_STAR_WITHOUT_WHERE | COUNT(*) without WHERE scans entire table |
insert-select-locks-source | INSERT_SELECT_LOCKS_SOURCE | INSERT...SELECT locks source table rows |
excessive-column-fetch | EXCESSIVE_COLUMN_FETCH | Query fetches too many columns |
mergeable-queries | MERGEABLE_QUERIES | Multiple queries to same table could be merged |
non-deterministic-pagination | NON_DETERMINISTIC_PAGINATION | ORDER BY + LIMIT on non-unique column gives inconsistent results |
read-modify-write | READ_MODIFY_WRITE | SELECT without a lock followed by INSERT/UPDATE on the same table, with no unique-constraint backing, upsert, atomic SET, or version column |
connection-held-idle | CONNECTION_HELD_IDLE | Connection held while non-database work runs — the pool-exhaustion shape |
force-index-hint | FORCE_INDEX_HINT | FORCE/USE/IGNORE INDEX hint overrides optimizer decisions |
find-by-id-for-association | FIND_BY_ID_FOR_ASSOCIATION | findById() used only for FK association — consider getReferenceById() to avoid the SELECT |
Memory Optimization¶
QueryAudit retains SQL captured through the instrumented DataSource during the audit window. In large test suites or tests that generate many queries, this can consume significant heap memory. The following settings help control memory usage.
Max Queries Per Test (default: 10,000)¶
Each test retains up to 10,000 queries by default. Reaching the limit is valid when no queries are lost. If another query arrives, it is dropped, a warning is printed to stderr, and the suite outcome becomes INCONCLUSIVE with reason QUERY_LIMIT_REACHED. QueryAudit still analyzes the retained queries and preserves their partial findings and statistics, but it does not evaluate count-based contracts against incomplete data. This limits memory used by retained query records and keeps partial collection from producing a successful audit.
The same limit bounds Hibernate lazy-load and explicit-load events per test. Events beyond it are dropped and the run becomes INCONCLUSIVE with AUDIT_ANALYSIS_FAILED and the detail Query capture incomplete [LAZY_LOAD_LIMIT_REACHED]. Metadata still contributes to heap usage.
Or programmatically:
Automatic String Pooling¶
QueryAudit automatically deduplicates SQL strings and stack traces in memory. N+1 queries originate from the same call site and execute the same SQL, so pooling ensures that identical strings share a single object reference. This optimization is always active and requires no configuration.
HTML Report Aggregator Memory¶
The HTML report aggregator accumulates reports across all test classes. For test suites with hundreds of test classes, it automatically switches to lightweight summaries (issues only, no raw query list) after the first 200 reports to prevent unbounded memory growth.
Recommended JVM Heap for Large Test Suites¶
| Test suite size | Recommended -Xmx | Notes |
|---|---|---|
| < 100 test classes | Default (256m-512m) | No tuning needed |
| 100-500 test classes | 512m-1g | Consider lowering max-queries |
| 500+ test classes | 1g-2g | Lower max-queries to 5000 or less |
Example Gradle configuration:
See Also¶
- Annotations Guide -- Annotation usage and examples
- Suppressing Issues -- How to suppress specific detections
- Reports -- Report format details and example output
- CI/CD Integration -- Using configuration in CI pipelines
Machine report detail¶
query-audit.report.redaction accepts redacted (the default) or full. The default removes SQL values, comments, raw diagnostic prose, and framework/absolute-path stack details from JSON and GitHub Actions output. It does not change findings or enforcement. Use full only for local debugging; see machine report redaction.
The plain JUnit equivalent is -DqueryAudit.report.redaction=full. Core callers use QueryAuditConfig.builder().reportRedaction(ReportRedaction.FULL) with JsonReporter.