Quarry v0.4.0
Released 2026-04-30
Real-execution coverage now spans all four dialects on every cross-dialect test, surfacing and fixing five generator bugs that previously slipped past mock-only assertions: silent default(T) parameter binding for captured variables in OrderBy/GroupBy/Join, invalid SQL Server INSERT ... OUTPUT VALUES shape, MySQL LIKE parse errors on default sql_mode, SQL Server BIGINT window-function readers, and FK .Id projection in CTE post-Select. Adds QRY037 (generator self-check), QRA403/QRA404 (analyzer warnings for dangling ThenBy/Having), and promotes QRA503 from Warning to Error. 17 commits merged since v0.3.2.
Highlights
- Silent
default(T)parameter binding fixed for captured variables inOrderBy/GroupBy/Join— four clause-emitter paths were skipping carrier extraction; non-zero captured values silently bound0/null!. NewQRY037generator self-check fails the build if any carrierPxfield is declared without a matching assignment. (#293) - SQL Server
INSERT … OUTPUT … VALUESplacement fixed — every entity-insert returning identity was emittingINSERT … VALUES (…) OUTPUT INSERTED.[Id]and failing at runtime withIncorrect syntax near 'OUTPUT'against realSqlConnection. (#276) - MySQL
LIKEportable across allsql_modesettings — generator no longer emitsLIKE '%foo\_bar%' ESCAPE '\'(1064 syntax error on defaultsql_mode). NewMySqlBackslashEscapesflag on[QuarryContextAttribute](defaulttrue) drives backslash doubling. Underpinned by aSqlDialectConfigcarrier that replaces the flatSqlDialectenum threaded through the generator. (#288) - SQL Server window-function int projections execute —
ROW_NUMBER/RANK/DENSE_RANK/NTILEand int-typed aggregateOVERprojections wrap withCAST(… AS INT)on SQL Server (returnsBIGINT;SqlDataReader.GetInt32does not auto-narrow). (#287) - DISTINCT + ORDER BY non-projected column portable across all four dialects — generator now wraps in a derived table on every dialect; PostgreSQL no longer fails 42P10 at runtime. (#275)
- Two new analyzer warnings + code fix:
QRA403 ThenBy without preceding OrderBy(withThenByToOrderByCodeFix) andQRA404 Having without preceding GroupBy. (#289) QRA503Error for dialect capability gaps — MySQLFullOuterJoinand SQL Server.Offset(N)without.OrderBy(...)now fail compilation instead of warning.QRY070/QRY071/QRY072start actually surfacing (silently dropped by the generator pre-v0.4.0). (#279)IEntityAccessor<T>chain-continuation methods —OrderBy/ThenBy/Limit/Offset/Havingand 12 set-op overloads added sodb.With<T>(...).FromCte<T>().OrderBy(...).Select(...)compiles cleanly. (#284)- Per-context
[EntityReader]resolution — generator emits the reader FQN at<contextNamespace>.<readerSimpleName>. Multi-context, multi-namespace setups now match per-context entity types statically (was papered over withUnsafe.As<>). (#278) - Real-execution test coverage on all four dialects — every
CrossDialect*Testssite now executes against realNpgsqlConnection(PG 17),MySqlConnection(MySQL 8.4), andSqlConnection(SQL Server 2022) Testcontainers — not just SQLite. (#266, #271, #276, #279) - Source-generator pipeline benchmarks — new
Quarry_GeneratorColdCompile/Quarry_Throughput_*/Quarry_Pipeline_*benchmark methods publish to the existing dashboard. (#290)
Breaking Changes
API Changes
SQL Server
INSERTshape changed (#276). Pre-v0.4.0:INSERT INTO [tbl] (cols) VALUES (params) OUTPUT INSERTED.[Id]v0.4.0:
INSERT INTO [tbl] (cols) OUTPUT INSERTED.[Id] VALUES (params)The old shape produced
Incorrect syntax near 'OUTPUT'at runtime — no realMicrosoft.Data.SqlClientconsumer could have been using it. SQL-string assertions or schema audits pinned to the old shape need updating. PG/MySQL/SQLite shapes unchanged. Same restructure applies to batch insert.OrderBy(non-projected).Distinct().Select(proj)row count changes on SQLite/MySQL (#275). The flat shape SQLite/MySQL accepted produced one row perprojwith an arbitrarynon-projectedvalue picked by the engine. The new wrap applies DISTINCT to(proj, non-projected), so chains of this exact shape now return one row per(proj, non-projected)pair on every dialect. PostgreSQL/SQL Server users were already affected (PG returned 42P10; SS rejected under standard rules), so this surfaces as a row-count change only for SQLite/MySQL on the affected query shape.Per-context
[EntityReader]resolution (#278). Multi-context consumers with schema and contexts in different namespaces (e.g.App.Pg.PgDb,App.My.MyDbreferencing a schema inApp.Schemas) must provide a per-context reader at<contextNamespace>.<readerSimpleName>inheritingEntityReader<TPerContextEntity>. Single-context consumers and consumers with schema/context in the same namespace see no change. The compile error replaces previously latentUnsafe.As<>runtime mismatch.
Diagnostic / Analyzer Changes
QRY037(Error) — generator carrier-assignment self-check (#293). New diagnostic that fires if the generator produces a carrier with aPxfield but no matching__c.Px = ...assignment. By design — closes the silentdefault(T)parameter-binding hole. Should never surface in user code; if it does, it's a generator regression and a build break.QRA403(Warning) —ThenBywithout precedingOrderBy(#289). Detects.ThenBy(...)whose receiver chain has no.OrderBy(...). NewThenByToOrderByCodeFix(Quarry.Analyzers.CodeFixes) renames the method-name token while preserving generic type arguments.QRA404(Warning) —Havingwithout precedingGroupBy(#289). Detects.Having(...)whose receiver chain has no.GroupBy(...). No code fix (the intended grouping key cannot be inferred).QRA503(Error) — capability gap for dialect (#279). Promoted fromQRA502 Warning. Fires for MySQLFullOuterJoin<T>(...)and SQL Server.Offset(N)without.OrderBy(...)at every execution terminal (ExecuteFetchAllAsync,ToAsyncEnumerable, etc.). The previousQRA502 Warningis preserved for genuine perf hints.QRY070/QRY071/QRY072now actually surface (#279). Pre-v0.4.0 the generator silently dropped these descriptors via a registration gap ins_deferredDescriptors. Code that compiled before withIntersectAll/ExceptAllon a non-PostgreSQL dialect, or with set-operation projection mismatches, now fails at compile time. The diagnostics already existed; only the silent-drop bug is fixed.Stale SQLite RIGHT/FULL OUTER QRA502 rules removed (#279).
Microsoft.Data.Sqlite 10.0.3+ships SQLite 3.49.1, which executes both joins. The rules emitted false positives.QRY019message format tightened (#292). The doubled"OrderBy clause contains an expression that cannot be translated to SQL clause could not be translated to SQL at compile time."is now"<clause-context>. The original runtime method will be used instead.". Diagnostic ID unchanged; pure message-text change.
Opt-In Upgrades
MySqlBackslashEscapes(defaulttrue) on[QuarryContextAttribute]. Stock MySQL behavior; no action required for default-mode consumers (the underlying parse-error fix lands automatically). Consumers running MySQL withNO_BACKSLASH_ESCAPESinsql_modeshould opt out:[QuarryContext(Dialect = SqlDialect.MySQL, MySqlBackslashEscapes = false)] public partial class MyDb : QuarryContext { ... }
New Features
Query Engine
IEntityAccessor<T> chain-continuation methods (#284)
var orders = await db
.With<ActiveOrder>(orders => orders.Where(o => o.IsActive))
.FromCte<ActiveOrder>()
.OrderBy(o => o.Total, Direction.Descending)
.Limit(10)
.Select(o => o)
.ExecuteFetchAllAsync();
- 13 default-throwing interface methods added:
OrderBy,ThenBy,Limit,Offset,Having, plus 6 direct + 6 lambda set-op overloads. - Removes the malformed
Order<Order>interceptor shape (CS0308) on post-FromCte<T>()chains. - The generator overrides each method only at discovered interceptable call sites; unintercepted chains throw
InvalidOperationExceptionat runtime (same pattern every other uninterceptedIEntityAccessormethod uses).
Analyzers
QRA403 + ThenByToOrderByCodeFix (#289)
// QRA403 fires
var rows = await db.Users()
.ThenBy(u => u.UserName) // no preceding OrderBy
.Select(u => u).ExecuteFetchAllAsync();
The code fix renames ThenBy → OrderBy, preserving any TypeArgumentList on a generic-name call. BatchFixer for FixAll across a document.
QRA404 (#289)
// QRA404 fires
var rows = await db.Orders()
.Having(o => o.Total > 100m) // no preceding GroupBy
.Select(o => o).ExecuteFetchAllAsync();
No code fix — the intended grouping key cannot be inferred from the chain.
Tooling
Source-generator pipeline benchmarks (#290)
Seven new BDN benchmark methods added to Quarry.Benchmarks (all Quarry_*-prefixed for the gh-pages publish filter):
Quarry_GeneratorColdCompile— headline cold-compile number, fixture + Medium corpus.Quarry_Throughput_{Small,Medium,Large}— 10 / 50 / 200 query call sites; surfaces O(n²) regressions.Quarry_Pipeline_{SchemaOnly,PlusQueries,PlusMigrations}— cumulative shapes; cost differences attribute per-pipeline.
Powered by a new Quarry.Benchmarks.GeneratorHarness project (extracted to keep BDN/Dapper/EFCore out of Quarry.Tests's runtime closure). No CI / publish-pipeline changes required — the existing --filter '*' discovery + Quarry_* filter pick them up automatically.
Architecture
SqlDialectConfig carrier (#288)
Internal SqlDialect enum threaded through the generator becomes internal sealed record SqlDialectConfig(SqlDialect Dialect) with per-context mode flags. Threaded through SqlExprRenderer, SqlAssembler, ContextInfo, BoundCallSite, and TranslatedCallSite. Existing Render(SqlDialect) overloads preserved for fragment paths that don't have a config. Foundation for additional sql_mode axes (MySqlAnsiQuotes, PgStandardConformingStrings, SqlServerQuotedIdentifier) — each follows the same additive shape.
Carrier-assignment recorder + QRY037 (#293)
CarrierAssignmentRecorder threaded through ~14 emit methods that write to carrier P-fields (CarrierEmitter, ClauseBodyEmitter, JoinBodyEmitter, SetOperationBodyEmitter, TransitionBodyEmitter). FileEmitter.ProduceCarrierAssignmentDiagnostics performs a post-emission gap scan; any Px field declared without a matching __c.Px = … assignment fires QRY037 (Error) with carrier name + index. Closes the CS0649 hole — non-nullable reference-type captures (string, custom classes) suppressed CS8618 with = null! and silently shipped null parameter bindings before this PR.
SQL Server window-function int wrap (#287)
ProjectedColumn gains RequiresSqlServerIntCast. ProjectionAnalyzer sets it for int-typed window functions excluding Lag/Lead/FirstValue/LastValue (those inherit the source column's type). SqlAssembler.AppendProjectionColumnSql and ReaderCodeGenerator emit CAST(… AS INT) on SQL Server only. PG/My/Lite emit unchanged.
Per-context [EntityReader] resolution (#278)
InterceptorCodeGenerator.ResolvePerContextReaderFqn rewrites the schema-namespace FQN to <contextNamespace>.<simpleName>. CollectEntityReaderInstances and ReaderCodeGenerator.GenerateReaderDelegate thread the context namespace. When schema and context share a namespace, the per-context FQN equals the schema-namespace FQN — byte-identical emit preserved.
CarrierStructuralKey dedup invariant (#272)
CarrierStructuralKey promoted from private to internal; doc comment now spells out load-bearing extractor fields (MethodName + VariableName + VariableType + DisplayClassName + CaptureKind + IsStaticField). Locks in the chained-With<> dispatch invariant against future contributors collapsing the dedup axes.
Bug Fixes
SQL Correctness
- Silent
default(T)parameter binding for captured vars inOrderBy/GroupBy/Join(#293). Four emitter paths (ClauseBodyEmitter.EmitOrderBy,ClauseBodyEmitter.EmitGroupBy,JoinBodyEmitter.EmitJoinedOrderBy,JoinBodyEmitter.EmitJoin) hardcoded the lambda parameter to_(extraction body emitted against an undeclared name) and skipped the per-clause extraction plan in the generic-key carrier path. With non-zero captured values, the SQL referenced@pxbut no body wrote toPx— silently bound0(ornull!).QRY037permanently locks the regression out. - SQL Server
INSERT … OUTPUT INSERTED.[Id]placement (#276). Generator now emitsINSERT … (cols) OUTPUT INSERTED.[Id] VALUES (…)instead of the invalid… VALUES (…) OUTPUT INSERTED.[Id]. Same restructure forRenderBatchInsertSql. PG/My/Lite unchanged. - MySQL
LIKE1064 on defaultsql_mode(#288).LIKE '%foo\_bar%' ESCAPE '\'parsed as a syntax error against stock MySQL because backslash inside string literals is the escape character. Generator now emitsLIKE '%foo\\_bar%' ESCAPE '\\'(parses to literal\) on MySQL withMySqlBackslashEscapes = true(default). - DISTINCT + ORDER BY non-projected column emits subquery wrap (#275). Pre-v0.4.0 this returned 42P10 on PostgreSQL, was rejected by SQL Server under standard rules, and produced implementation-defined results on SQLite/MySQL. Generator now wraps in a derived table on all four dialects when the ORDER BY references a column outside the projection. Detection compares each ORDER BY expression's rendered SQL against the projection-column reference set; chains where every term matches a projection column keep the flat form.
- DISTINCT wrap detection cast-mismatch on SQL Server (#292).
NeedsDistinctOrderByWrapcomparedRenderProjectionColumnRef's output (withCAST(... AS INT)wrap on SS) againstSqlExprRenderer.Render's output (no wrap). Same chain shape would unnecessarily fire the wrap path. NewforComparisonflag onAppendProjectionColumnSqlremoves the cast for comparison-side rendering only; emit-side preserves it. - SQL Server window-function int projections throw
InvalidCastException(#287).ROW_NUMBER/RANK/DENSE_RANK/NTILE(and int-typed aggregateOVER) returnBIGINT;SqlDataReader.GetInt32doesn't auto-narrow. Now wrapped server-side withCAST(… AS INT).Lag/Lead/FirstValue/LastValueexcluded — they inherit the source column type. - FK
.Idprojection in CTE post-Select and non-CTE single-entity Select (#285).o.UserId.Idwas matched in two discovery paths and broke in slightly different ways in each — placeholder path emittedColumnName=""; semantic-model path mis-classified the column kind during partial-type discovery. NewIsRefKeyAccessflag onProjectedColumnis the single signal both paths use; enrichment looks up the FK column, copies the registry's key type, and suppresses theEntityRef<T,K>wrap. Removes ~358 lines of deadAnalyzeJoinedcode in the same change. - Empty-alias on CTE post-Select (#282).
SqlAssembler.AppendProjectionColumnSqlemitted""."col"(invalid SQL) whenTableAliaswas empty. One-line fix aligns withReaderCodeGenerator's convention. - Npgsql 10
08P01: bind message supplies 0 parameterson PostgreSQL (#266). The redux of #258 — empirically traced to Npgsql's named-vs-positional mode switch on whether anyDbParameter.ParameterNameis set. Quarry now leavesParameterNameempty on PostgreSQL across all five generator paths (MigrationRunner.InsertHistoryRowAsync, entity insert, batch insert, scalar param emission, runtime-collection expansion inCarrierEmitter). PR #261's hypothesis ("strict name match") was wrong; v0.3.1 and v0.3.2 reproduce the same failure as v0.3.0 on Npgsql 10 + PG 17. Consumer rebuild required after upgrading — the generated__pN.ParameterNameassignments change.
Documentation Hardening
QRY019errorMessage punctuation contract documented (commit774476d, no PR). Comments at the QRY019messageFormatliteral and at eachCallSiteTranslator.errorMessagecallsite note that{0}must be a complete clause without trailing punctuation. Prevents the doubled-period regression #292 fixed.
Test Infrastructure
- Real-execution coverage on PostgreSQL (#266, Phase 9). 233 mirror sites added across 22
CrossDialect*Tests.csfiles;QueryTestHarness.Pgmoves offMockDbConnectiononto a realNpgsqlConnectionagainst Testcontainers PG 17. Surfaced and fixed the Npgsql binding bug, the QRY070/071/072 silent-drop, and the chained-With<>carrier dispatch hazard (#268, locked in via #272). - Real-execution coverage on MySQL (#271). 259 mirror sites against Testcontainers MySQL 8.4 +
MySqlConnection. Pinned--collation-server=utf8mb4_binand--sql-mode=…,NO_BACKSLASH_ESCAPESat the container level to mask MySQL defaults for the suite (the proper LIKE-emit fix landed in #288). - Real-execution coverage on SQL Server (#276). 208 mirror sites against Testcontainers MSSQL 2022 +
SqlConnection. PinnedCOLLATE SQL_Latin1_General_CP1_CS_ASat column declaration; substituteddefault(DateTime)with explicit dates where SqlClient's DATETIME range rejects0001-01-01. Surfaced and fixed theINSERT…OUTPUT…VALUESplacement (#276) andBIGINTwindow-function readers (#287). - Cross-dialect test coverage (#279). Eight
Integration/*.csSQLite-only test files migrated intoSqlOutput/CrossDialect*.cswith the verbatim 4-dialect pattern. Surfaced and fixed thes_deferredDescriptorssilent-drop (QRY070/071/072 → #279) and discovered the latent[EntityReader]cross-dialect resolution bug (→ #277/#278).
Test Lock-in
- Chained-
With<>dispatch regression locked out (#272). PR #266 worked around an emergingMissingFieldExceptionat.Prepare()time by renaming a captured variable; investigation showed the generator's currentCarrierStructuralKeyalready covers the dedup axis. PR #272 reverts the workaround, adds focused unit tests pinningVariableName/DisplayClassName/VariableTypeaxes, and adds aAssertEveryDispatchArrowResolveshelper that walks everyChain_N.__ExtractVar_X(__target)reference in interceptor bodies and asserts X is owned by Chain_N.
Documentation & Tooling
docs/articles/analyzer-rules.mdupdated for QRY037, QRA403, QRA404, QRA503, and the QRY019 message change.docs/articles/context-definition.mddocuments the newMySqlBackslashEscapesproperty on[QuarryContextAttribute].llm.mdupdated with the per-context[EntityReader]resolution rules (#278) and theQuarryContextLIKE-emit guarantee (#288).
Migration Guide from v0.3.2
Required Changes
MySQL
FullOuterJoin<T>(...)— was QRA502 Warning, now QRA503 Error (#279). The rewrite pattern is unchanged; consumers who previously suppressed the warning must now actually rewrite the chain to aUNIONofLEFT JOIN+RIGHT JOIN:// Before (suppressed warning, produced engine-rejected SQL at runtime) db.Users().FullOuterJoin<Order>((u, o) => u.UserId == o.UserId)… // After db.Users().LeftJoin<Order>((u, o) => u.UserId == o.UserId).Select(...) .Union(db.Users().RightJoin<Order>((u, o) => u.UserId == o.UserId).Select(...))…SQL Server
.Offset(N)without.OrderBy(...)— was QRA502 Warning, now QRA503 Error (#279). Add an.OrderBy(...)clause; QRA503 fires at every execution terminal, includingToAsyncEnumerable(the previous QRA502 missed this terminal — fixed in the same PR).// Before db.Users().Offset(20).Select(u => u).ExecuteFetchAllAsync(); // After db.Users().OrderBy(u => u.UserId).Offset(20).Select(u => u).ExecuteFetchAllAsync();Non-PostgreSQL
IntersectAll/ExceptAll— useIntersect/Exceptinstead, or move that query to a PostgreSQL context. QRY070/QRY071 now actually surface (silently dropped pre-v0.4.0 and produced runtime SQL the dialect rejected).Set-operation projection mismatches — pre-v0.4.0 these compiled and broke at runtime; QRY072 now fails the build.
Multi-context
[EntityReader]consumers (multi-namespace) — provide a per-context reader inheritingEntityReader<TPerContextEntity>at<contextNamespace>.<readerSimpleName>. Per-context partial extensions on the entity may also be needed if the schema-namespace partial defines members that the per-context entity needs to expose. Single-context-same-namespace consumers see no change.Consumer rebuild required after upgrading — the source generator re-emits interceptors. NuGet binary-only package bumps without a rebuild will not pick up the Npgsql / MySQL / SQL Server fixes.
Optional Improvements
- Annotate MySQL contexts running
NO_BACKSLASH_ESCAPES:[QuarryContext(Dialect = SqlDialect.MySQL, MySqlBackslashEscapes = false)]. - Run tests against real database engines (not just SQLite) — the
QueryTestHarnessnow supports it via Testcontainers; see the cross-dialect test fixtures for the pattern. - Address
QRA403/QRA404warnings — they catch chains that compile but emit semantically dubious SQL.
Stats
- 17 commits / 16 PRs merged since v0.3.2 (one direct commit on top of master, no PR)
- 3 new diagnostics: QRY037 (Error), QRA403 (Warning), QRA404 (Warning)
- 1 promoted diagnostic: QRA503 (Error) — was QRA502 (Warning) for capability gaps
- 3 silent-drop diagnostics now surfacing: QRY070, QRY071, QRY072
- 5 generator bugs surfaced and fixed by real-execution coverage: silent
default(T)binding (#293), SQL Server INSERT shape (#276), MySQL LIKE escape (#288), SQL Server window-function int reader (#287), Npgsql 10 parameter binding (#266) - New benchmarks: 7 generator-pipeline methods (
Quarry_GeneratorColdCompile,Quarry_Throughput_{Small,Medium,Large},Quarry_Pipeline_{SchemaOnly,PlusQueries,PlusMigrations}) - New project:
Quarry.Benchmarks.GeneratorHarness
Full Changelog
Query Engine
- Extend IEntityAccessor<T> with chain-continuation methods (#284, closes #281)
- Wide-tuple projection coverage (TRest) + fix empty-alias on CTE post-Select (#282)
Generator / CodeGen
- Fix silent default(T) binding for captured vars in OrderBy/GroupBy/Join + add QRY037 (#293)
- Generator: replace flat SqlDialect with SqlDialectConfig carrier; fix MySQL LIKE-escape on default sql_mode (#288, closes #273)
- Fix: SQL Server window-function int projections throw InvalidCastException (BIGINT vs Int32) (#287, closes #274)
- Fix FK .Id projection in CTE post-Select and non-CTE single-entity Select (#285, closes #280)
- Generator: per-context [EntityReader] resolution by default (#278, closes #277)
- Fix DISTINCT + ORDER BY non-projected column emits subquery wrap (#275, closes #267)
- Fix: DISTINCT wrap detection cast-mismatch on SQL Server (#292, closes #286)
- Document QRY019 errorMessage punctuation contract (
774476d) - Lock out #268 chained-With dispatch regression with tests + doc (#272, closes #268)
- Fix Npgsql 10 parameter-binding mismatch on PostgreSQL (redux of #258) (#266, closes #258)
Analyzers
- Analyzer: warn on ThenBy without OrderBy and Having without GroupBy (#289, closes #283)
- Cross-dialect test coverage + dialect-rule hardening (#279)
Test Infrastructure
- Mirror PG execution coverage to SQL Server: Testcontainers.MsSql + cross-dialect mirror (#276, closes #270)
- Mirror PG execution coverage to MySQL: Testcontainers.MySql + cross-dialect mirror (#271, closes #269)
Benchmarks
- Add source-generator pipeline benchmarks (#290)