Changelog¶
All notable changes to this project are documented here. The format follows Keep a Changelog and the project uses Semantic Versioning.
Unreleased¶
0.1.0 - 2026-09-24¶
First release. QueryFence checks the SQL your integration tests actually send to the database against a policy you declare once, and fails the build on the statements that break it, naming the class, method and line that produced the query.
Added¶
Rule engine (queryfence-core)
require-predicate: every occurrence of a protected table must be filtered by the tenant column — inFROM, joins, subqueries, CTEs, eachUNIONbranch,UPDATE/DELETEtargets andINSERT ... SELECTsources. Value predicates,INlists, casts and functions named inallowedFunctionscount; transitive tenant-column equalities count when the chain has a value anchor; anORcounts only when every branch fences the table.update-without-whereanddelete-without-where: no WHERE clause, or one that is always true.- Violation codes
MISSING_PREDICATE,PRIMARY_KEY_LOOKUP,AMBIGUOUS_COLUMN,MISSING_INSERT_COLUMN,NO_WHERE,TAUTOLOGICAL_WHERE,UNSUPPORTED_STATEMENTandUNPARSEABLE. Every message states the problem and the fix. - Fail closed: SQL that does not parse, and statement types not analysed yet, are violations.
Statements that can neither read nor write rows (
SET,SHOW,FLUSH, DDL,CALL) are ignored when they do not parse, because test fixtures run them all the time. onUnparseablegovernsUNPARSEABLEfindings and nothing else, independently ofmode:mode: FAILwithonUnparseable: REPORTfails the build on a leak while only recording the statements the parser could not read. The decision is per finding (Policy.modeFor(code)).- An
UNPARSEABLEfinding names the protected tables the statement mentions — one finding per table, matched on whole identifiers, ignoring comments and string literals — so a parser failure cannot hide a table from the report. These findings carry the rule idparserand are suppressed withrule: parser. - Public API:
Policy(with a builder),Rule,Mode,Suppression,SqlChecker,Violation. The engine depends on JSqlParser only — no JDBC, no JUnit, no Spring, and it does not model where a statement came from.
Capture (queryfence-jdbc)
QueryFence.wrap(DataSource, Policy)records every statement the driver executes, including each statement of a JDBC batch and statements that failed while executing, and passes the SQL on unchanged.- The origin — class, method, file, line — is resolved with
StackWalker, skipping the JDK, drivers, ORMs, frameworks and QueryFence itself.CaptureSettings.ofBasePackages("com.acme"), orbasePackages:in the policy file, makes it exact. A lambda is reported as the method that contains it rather than under its syntheticlambda$...$0name.
Reports (queryfence-report)
- A console summary and
target/queryfence/report.json, grouped per policy, each group with its own mode. Findings carry the rule, the code, the table, the message, the SQL and the origin. - The summary is printed when the test plan ends, through a JUnit Platform
TestExecutionListener, so it reaches the build log under Maven Surefire and Gradle instead of a stream that a JVM shutdown hook writes to after the runner has stopped listening. - Suppressions that matched nothing during the run are listed in the summary and in the report under
unmatchedSuppressions: that is how an exception whose code has moved shows up. tools/queryfence-summary.pysummarises a report by rule, table, code and origin, with a--triagelisting for adoption. No dependencies.
JUnit 5 (queryfence-junit5)
QueryFenceExtension.fromClasspath()loadsqueryfence.yml;wrap(DataSource)fences a data source. Only statements of the test method and the code it calls are checked, on any thread.FAILmode fails the test with the rule, the fix, the SQL and the origin line;REPORTmode only collects.- The policy loader rejects unknown keys, unknown rule types and modes, a wrong version and
suppressions without a reason, naming the resource and the offending key — including the errors
raised by the policy model itself, such as an origin that is not
Class#method. A policy file is read once per JVM, so a mistake is diagnosed once instead of once per test class. basePackages:in the policy file names the packages origins are resolved in, whichqueryfence-spring-testreads as well; nothing else needs to change.- Built against JUnit 5.10.5, the floor we support;
junit-jupiter-apiisprovided, so your build chooses the version. CI runs 5.10, 5.13 and 6.1.
Spring (queryfence-spring-test)
- Every
DataSourcebean of a Spring test context is wrapped automatically: the dependency plus aqueryfence.ymlis the whole setup, with no test code to change. @QueryFencePolicy("other.yml")selects another policy for a test class, and takes part in the Spring context cache key.
Safety rails
- Suppressions live in the policy, keyed by
Class#method, and a blank reason is a configuration error. Production code never depends on QueryFence. queryfence.enabled=falseswitches the checks off, prints a loud warning, records"disabled": truewith the reason in the report, and fails the build when theCIenvironment variable is set unlessqueryfence.allowDisabledInCi=truesays so deliberately.
Testing
- 205 golden corpus cases (SQL, policy, expected violations with their exact messages), a metamorphic suite that weakens every passing case five ways and requires a violation, and PIT mutation testing at 88% threshold.
- Testcontainers matrix {Hibernate, MyBatis, JdbcTemplate} × {MySQL 8.4, Postgres 17} on framework-generated SQL.
Known limitations¶
These are documented in docs/DESIGN.md and measured against real frameworks in
queryfence-integration-tests.
- ORM associations. A fetch join or a lazy association loads child rows by foreign key only
(
... from order_item where order_id = ?). The rows belong to a parent your code fenced, but the statement does not say so, so QueryFence reports them. Map the tenant on the child entity with Hibernate@TenantId— then the SQL carries it and QueryFence goes quiet — or protect only the aggregate root and accept that the child table is no longer checked. - Primary-key lookups.
findByIdandEntityManager.findare reported asPRIMARY_KEY_LOOKUP. That is deliberate: an id is guessable, so it is not tenant isolation. UsefindByIdAndTenantId, or map the tenant on the entity. - Derived tables and CTEs. A filter applied outside a derived table or a CTE does not fence the tables inside it: v0.1 does not push predicates down. Move the filter inside the subquery.
- Statements that do not parse. What JSqlParser 5.4 cannot parse, QueryFence cannot clear, so a
DML statement that fails to parse is a violation even if it is correct. One shape is known:
an unqualified column named
number(SELECT number FROM invoice) does not parse, whilei.numberand"number"do. Qualify or quote the column, or suppress the origin withrule: parser; reported upstream in docs/upstream-issues. The finding names the protected tables the statement mentions, so the blind spot is visible. - Only what your tests run. Untested code paths are unchecked, parameter values are not checked, views and stored procedures are opaque, and JUnit parallel execution is unsupported.
API stability¶
0.1.0 is the first release: the API is small on purpose, but it is not frozen. Anything in a
*.internal package carries no promise at all and may change in any release. Breaking changes to
the public API will be listed here, and the API freezes at 1.0.