Provider support

QueryGuard captures commands through EF Core's relational interception contract, which every relational provider implements. So in one sense every relational provider works.

But the feature you care about, grouping repeated queries, depends on the shape of the SQL the provider generates. Parameter naming, quoting, and formatting all differ, and the fingerprint normalizer is deliberately conservative. Capture and fingerprint quality are two different claims, and blurring them is how a support matrix becomes a lie.

The matrix

Provider Tier What that means
SQLite Integration-tested Real commands run in CI, on Ubuntu and Windows, on net8.0 and net10.0
PostgreSQL (Npgsql) Integration-tested Focused Testcontainers suite in CI
SQL Server Integration-tested Real commands run in CI through Testcontainers
MySQL Integration-tested Real commands run in CI through Testcontainers, via Oracle's provider: see the caveat
MariaDB Community No tests, no promises. Wire-compatible with MySQL, which is evidence and not verification
Other relational providers Best effort Works through the official interception contract
Non-relational EF providers Unsupported DbCommand interception is relational only

"Integration-tested" means real database commands run in CI. "Fixture-verified" means the normalizer is checked against captured SQL from that provider, but nothing live runs. See ADR-0009.

Every one of those live suites has found something. SQL Server was fixture-verified until a live suite was added, and the first run found a shipped bug. MySQL found a reporting bug affecting every provider. That is the argument for the tier distinction, restated twice.

Why these four, specifically

SQLite is the workhorse. Real relational execution, no container, fast enough to run the whole surface: interception, fingerprinting, budgets, middleware, and failure paths on every pull request.

PostgreSQL exists to prove the design is not accidentally SQLite-shaped. Npgsql generates positional $1 parameters and quotes differently. With one provider, a dialect assumption could hide in the normalizer indefinitely; the second data point is what makes the generic approach credible. It also caught the case that had to be handled explicitly: PostgreSQL's :: cast operator looks like the start of a named parameter, and treating it as one silently merged queries that differ by type.

SQL Server is the provider most .NET developers check first, so "probably works" was not a good enough answer for it. It also has the most distinctive generated SQL of the four: a parameter declaration prologue in front of the actual statement, which turned out to matter more than expected.

MySQL brings the third quoting style and the inlining case. Backticks are a distinct third form after " and [], and MySQL inlines some constants the other providers parameterize, so where the SQL Server suite exercises the parameter path, MySQL exercises literal redaction. Both have to end up hiding the value, and only running both shows that they do.

What the live SQL Server suite found

A bug that had shipped, and that fixtures could not have caught.

EF Core's insert batch on SQL Server does not begin with the interesting statement:

SET IMPLICIT_TRANSACTIONS OFF;
SET NOCOUNT ON;
INSERT INTO [Departments] ([Id], [CompanyId], [Name]) VALUES (@p0, @p1, @p2);

QueryGuard decides whether a command is a read or a write from its leading keyword, because the execution method alone is provider-dependent: on SQLite an INSERT … RETURNING runs through the reader path. It saw SET, concluded "not a modification", and left the command classified as a read. So every SaveChanges on SQL Server consumed a read budget, and a budget of ten reads meant something different there than on SQLite, quietly.

Classification now walks every statement in the batch rather than only the first. The shapes are pinned by unit tests that run without Docker, so the regression is caught on every pull request; the live suite is what noticed it existed.

The general lesson, which is why the tier distinction is kept: a fixture proves the normalizer still does what it did when the fixture was written. It cannot notice SQL the fixture never contained.

The MySQL provider caveat

The suite runs against MySql.EntityFrameworkCore, Oracle's provider, not Pomelo.

Pomelo is the more widely used of the two by a wide margin, so this is worth stating plainly rather than leaving in a package file: its latest release is 9.0.0 and there is no EF Core 10 line, while this project targets EF Core 8 and 10. There was no version of Pomelo the suite could have used.

What is verified, precisely: QueryGuard captures and groups MySQL SQL as Oracle's provider generates it. Since a fingerprint is derived from the SQL text, a Pomelo user's SQL may differ in ways this suite cannot see. Capture is unaffected: that goes through EF Core's interception contract, which both providers implement identically.

If you run Pomelo and see either failure mode from the table below, it is worth reporting even though MySQL reads as integration-tested. When Pomelo ships an EF Core 10 line, running the same suite against it is a small change.

What the live MySQL suite found

Not a MySQL bug. A bug in what every provider reported.

TagWith emits the tag as a line comment, and normalization collapses runs of whitespace, including the line break that terminated the comment. A recognized QueryGuard: directive has to survive that pass, because it changes behaviour, and it was being kept in the form it arrived in:

--QueryGuard:Ignore reason=bounded-reference-lookup SELECT `c`.`Id`, `c`.`City` FROM `Companies` AS `c`

One line, and everything after the -- is inside the comment. Every reporter prints that text, so the SQL shown for any tagged query read as entirely commented out, and pasting it into a client ran nothing. An ignored finding is still reported, with its reason, so this was on a path users see.

A directive is now normalized to a block comment whichever way it was written:

/*QueryGuard:Ignore reason=bounded-reference-lookup*/ SELECT `c`.`Id`, `c`.`City` FROM `Companies` AS `c`

The block-comment branch was already correct, and a test named for exactly this concern already covered it. The line-comment branch had the same intent and the opposite outcome, and the assertions on it checked that QueryGuard:Ignore appeared somewhere in the string, which stayed true throughout. A substring assertion cannot see a delimiter bug.

Two smaller consequences, both improvements: the same directive written -- or /* */ now produces one fingerprint instead of two, which is right because the delimiter is not part of what the query does; and the fingerprint id of a tagged query changed, so an allowlist entry keyed on one needs the new value. Baselines are unaffected: they store counts, not fingerprint ids.

Parameter syntaxes the normalizer handles

All of these become a single placeholder, which is what lets a per-parent query in a loop group into one fingerprint instead of N:

Syntax Where it comes from
@p0, @__city_0 SQL Server, SQLite, MySQL
$1, $2 PostgreSQL positional
:name Oracle, some Npgsql configurations
? Positional placeholders

Without this, provider-generated identifiers alone would split one logical query into N groups: precisely the case QueryGuard exists to find.

What is not normalized

Identifier quoting is left exactly as written: "Departments", [Departments], and `Departments` are three different fingerprints. That is deliberate. A table name is structure rather than data, and rewriting it would mean the report shows SQL your application never ran.

The practical consequence: a fingerprint is provider-specific. An allowlist entry recorded against SQLite will not match the same logical query on PostgreSQL. Allowlist by tag when a project runs more than one provider.

Running the provider suite

# SQLite only: no Docker needed
dotnet test tests/QueryGuard.ProviderTests

# With Docker running, the PostgreSQL, SQL Server, and MySQL tests execute too (a few minutes)
docker info && dotnet test tests/QueryGuard.ProviderTests

The container-backed tests skip themselves when no Docker daemon is reachable, with a skip reason that says so. A contributor without Docker gets a green run; CI runs them for real.

That skip is for an unavailable environment, not for a flaky test. A container that starts and then produces inconsistent results is a bug to diagnose, not to retry away.

Using an untested provider

It will probably work. Capture uses the official contract, so commands will be recorded and grouped. What is unverified is whether your provider's SQL formatting produces equally good fingerprint grouping.

Two failure modes to watch for, and what each means:

What you see What it means
One logical query appears as several fingerprints Under-normalization. Real patterns go unreported: the tool is quiet, not wrong
Two different queries share a fingerprint Over-normalization. Report this. A report pointing at the wrong SQL is worse than no report

Either is worth a provider report. A synthetic SQL sample becomes a fixture, which is the cheapest way to widen coverage, and the fastest route from "best effort" to "fixture-verified" for a provider you depend on.

Use synthetic or fully redacted SQL. Do not paste production schema names into a public issue.

Widening the matrix

A provider moves to "integration-tested" when there is both a suite and someone willing to maintain it. Adding providers is the easiest way to feel productive and the fastest way to lose a release: each one is a container in CI, a set of fixtures, and a source of flakiness, so the bar is a contribution that comes with its own upkeep, not a request.