Skip to content

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:

method-level @QueryAudit  >  class-level @QueryAudit  >  application.yml  >  built-in defaults

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:

query-audit:
  profile: strict

For direct core use, select it on the builder:

QueryAuditConfig config = QueryAuditConfig.builder()
    .ruleProfile(RuleProfile.STRICT)
    .build();

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:

  1. Register the extension suite-wide via JUnit's extension autodetection. In src/test/resources/junit-platform.properties:
junit.jupiter.extensions.autodetection.enabled=true
  1. Set the mode — in application.yml for Spring projects:
query-audit:
  mode: all

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:

  1. Enable mode: all as above.
  2. Record the current findings once into the baseline (see Suppressing Issues).
  3. 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.
query-audit:
  await-executors: [taskExecutor, webhookExecutor]

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.

query-audit:
  profile: recommended               # call-site N+1 only
  await-executors: [taskExecutor]    # wait for @Async work and count its SQL
  contracts:
    path: src/test/resources/query-contracts

Report N+1 findings without failing tests while you review the existing ones. Budgets and contracts still fail.

query-audit:
  fail-on-detection: false           # report findings, keep budgets and contracts
  report:
    format: html

Write the JSON report that the first CI check and report comparison read.

query-audit:
  auto-open-report: false            # no browser in CI
  report:
    format: json
    output-dir: build/reports/query-audit

Other rules run only when a profile or enabled-rules selects them. minimal adds index, join, and write-safety rules; strict runs every rule. Index rules need the MySQL or PostgreSQL module and that database.

query-audit:
  profile: minimal
  slow-query:
    warning-ms: 200
    error-ms: 1000
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

@QueryAudit(
    failOnDetection = BooleanOverride.TRUE,
    nPlusOneThreshold = 2,
    suppress = {"select-all"},
    autoOpenReport = BooleanOverride.FALSE
)
@SpringBootTest
class OrderServiceTest { }
// Annotation overrides only what it specifies.
// Everything else comes from application.yml.
@QueryAudit(nPlusOneThreshold = 2)
@SpringBootTest
class OrderServiceTest { }
# application.yml (base configuration)
query-audit:
  fail-on-detection: true
  suppress-patterns:
    - "select-all"
  report:
    format: console

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:

query-audit.report.output-dir   <->   queryAudit.report.outputDir

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.

./gradlew test -PqueryAudit.contracts.record=true
mvn test -DqueryAudit.contracts.record=true

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.

query-audit:
  max-queries: 5000   # Lower limit for memory-constrained environments

Or programmatically:

QueryAuditConfig config = QueryAuditConfig.builder()
    .maxQueries(5000)
    .build();

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.

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:

test {
    jvmArgs '-Xmx1g'
}

See Also

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.