Conversation
Generated interceptors for InsertBatch chains emit an unconditional call to Quarry.Internal.BatchInsertSqlBuilder.Build(...) at TerminalBodyEmitter.cs:518 and :559, but the type was internal to the Quarry assembly. Any consumer outside Quarry's InternalsVisibleTo list therefore failed to compile with CS0122 in the generated *.Interceptors.*.g.cs — the feature did not work at all for ordinary consumers. Promote the type to public with [EditorBrowsable(Never)], matching the existing convention for the emitted runtime surface (OpId, QueryExecutor, QueryLog, ParameterLog). MaxParameterCount goes public alongside it since the now-public Build documents that ceiling. No generator change is needed — the emitted name was already correct, just unreachable. Every project in this repo holds a friend grant, so no in-repo build modelled an ordinary consumer and nothing caught this. Return the two InsertBatch shapes to the clean-binding matrix in InterceptorBindingGuardTests, whose synthetic CSharpCompilation is deliberately not a friend assembly, and drop the KnownBug_Issue334 pin. Refs #334
AssertBindsCleanly previously caught CS0122 only through its catch-all "fixture does not compile cleanly" assertion, which reports the symptom rather than the cause. Check the accessibility diagnostics explicitly and first, so a regression reads as "the emitter named a type consumers cannot reach" and says what to do about it. CS0122 is the #334 call-site case; CS0050/CS0051/CS0053/CS0060 cover the same defect surfacing through an emitted member's own signature. Add AccessibilityGuard_DetectsAnInaccessibleType as a negative control. The whole matrix is only meaningful if this compilation genuinely lacks friend access to Quarry; if that silently stopped being true, every shape would keep passing while guarding nothing — the exact blind spot that let #334 ship. The probe references Quarry.Internal.ScalarConverter, which is internal by design (called only from QueryExecutor, never emitted) and so stays a valid control. That split is also why this cannot be a namespace convention: Quarry.Internal holds both the public emitted surface and internal runtime-private helpers. Extract CompileNonFriend so the control shares the matrix's exact references and assembly name instead of a parallel setup that could drift. Verified by temporarily reverting BatchInsertSqlBuilder to internal and watching both BatchInsert shapes fail with the new message. Refs #334
Adding a ToDiagnostics shape to the non-friend guard matrix showed that
ToDiagnostics() does not compile for any consumer, on any chain shape:
error CS1729: 'QueryDiagnostics' does not contain a constructor that
takes 23 arguments
QueryDiagnostics' only constructor was internal. Three emitter sites construct
it — TerminalEmitHelpers.cs:615 (the general path every non-batch chain uses),
CarrierEmitter.cs:1095, and TerminalBodyEmitter.cs:519 (batch insert) — so a
documented headline API was unusable outside Quarry's InternalsVisibleTo list.
Same root cause as the BatchInsertSqlBuilder defect, but it surfaced as CS1729
rather than CS0122: an inaccessible constructor with no accessible overload is
not a candidate at all, so the compiler reports the arity rather than the
protection level. Every sibling type the same emitted code constructs —
DiagnosticParameter, ClauseDiagnostic, SqlVariantDiagnostic,
ProjectionColumnDiagnostic, JoinDiagnostic, CollectionSqlCache — already has a
public constructor, so this restores consistency rather than widening the API.
Add both uncovered ToDiagnostics shapes: Projected_ToDiagnostics for the general
path and BatchInsert_ToDiagnostics for the batch path that the original #334 pin
never reached.
CS1729 is deliberately kept out of AccessibilityDiagnosticIds, since it usually
does mean a genuine emitter arity bug and mislabelling those would blunt the
matrix. The catch-all assertion instead notes that a CS1729 naming a Quarry type
may mean an internal constructor.
Refs #334
The matrix reached only single-table chains, so JoinBodyEmitter and the GroupBy/Having assembly paths had no non-friend compilation coverage at all — an internal type emitted on any of them would have shipped exactly the way #334 did. Add OrderSchema and an Orders() accessor to the fixture's shared source, with FK and navigation declarations mirroring Samples/OrderSchema.cs, then add four shapes: inner join, left join (which additionally emits IsDBNull guards for the nullable side), GroupBy/Having aggregate, and a correlated EXISTS subquery off a Many<T> navigation. Adding a second entity to the shared context regenerates output for every pre-existing shape too; all 39 stay green. Refs #334
…them Add shapes for the emitter paths that call the remaining Quarry.Internal helpers: set operations, collection IN (CollectionSqlCache, ParameterNames), branched clauses (ThrowHelper.UnenumeratedMask), multi-terminal PreparedQuery, and a window function in a projection. Also add Shape_StillReachesItsRuntimeHelper. AssertBindsCleanly only proves a shape compiles and that some interceptor was emitted for its terminal — it cannot tell whether the shape exercised the emitter path it was added for. A chain that silently stopped being analyzable would keep passing while guarding nothing, which is indistinguishable from real coverage in a green run. That test earned itself on its first run: the collection shape as first written (an array literal with an entity terminal) emitted none of the collection helpers despite passing the binding matrix. Rewriting it to the form CollectionParameterCollisionTests uses fixed two of three, and the third turned out to need a distinct shape — CarrierEmitter emits CollectionHelper.Materialize only for collections typed IEnumerable<T>, since an IReadOnlyList is used directly. Both arms are now covered. Refs #334
RawSqlBodyEmitter is a separate emission path from the chain emitters, with its own reader strategies, and had no non-friend compilation coverage. Two things the fixture had to learn: Raw-SQL interceptors are emitted into Quarry.Generated rather than the context's namespace, so the fixture's InterceptorsNamespaces feature rejected them with CS9137. Adding that namespace matches an ordinary consumer more closely, not less — Quarry's shipped build targets register exactly it. RawSqlNonQueryAsync is not intercepted at all: only RawSqlAsync and RawSqlScalarAsync have an InterceptorKind, and the non-query overload is a plain public method on QuarryContext. It emits nothing into the consumer's assembly, so there is no emitted surface to guard and no shape for it. llm.md lists all three together, which makes the asymmetry easy to miss. Refs #334
The plan called for a dedicated QuarryContext<TSelf> context source and a refactor of Run to support it, on the premise that CTEs require the generic base. They do not: llm.md scopes that requirement to typed post-With accessors, and FromCte<T>() works on the plain non-generic QuarryContext — Quarry.Tests' own TestDbContext derives from it and CrossDialectCteTests drives CTEs through exactly that. The shape goes onto the existing shared context and Run is unchanged. Refs #334
Record the rule the preceding commits enforce: generated interceptors compile into the consumer's assembly, so every Quarry type, method and constructor they name is public API whether or not it is documented as such — and no ordinary build in this repo can catch a violation, because every project is a friend assembly. Both docs now name the two failure signatures, since the second is genuinely unintuitive: CS0122 means an internal type was named, while CS1729 "does not contain a constructor that takes N arguments" means that type's only constructor is internal and therefore not a candidate at all — not an emitter arity bug. Also record the maintenance rule that makes the matrix hold its value: add a shape when adding an emitter path, and a RuntimeHelperExpectations entry with it, or the shape can stop exercising its emitter and still pass green. Refs #334
All 13 review findings addressed (10A/3B). Runtime, following from making these members public: - Build validates sqlPrefix, rejects non-positive entityCount/columnsPerRow, and computes the parameter product as long so it cannot wrap past the ceiling - QueryDiagnostics' constructor validates its three required arguments; a null parameters list previously flowed into non-nullable properties - MaxParameterCount is static readonly rather than const — a public const is inlined into consumer assemblies, freezing a value its own doc calls a conservative default - The constructor carries the same "not supported API, may change without notice" disclaimer BatchInsertSqlBuilder already had, and warns against binding to a 23-parameter signature that grows Guard matrix: - Insert(...).ToDiagnostics() was the third QueryDiagnostics construction site and the only one still uncovered; it now has a shape - Shapes may declare AdditionalTerminals, so multi-terminal shapes have every terminal probed. Prepared_MultiTerminal's ToDiagnostics half was compiled but never checked — the very path the internal ctor broke - RuntimeHelperExpectations becomes ShapeEmissionExpectations, 9 entries to 33, covering every shape. Emitters with no distinctive helper pin an SQL or interceptor-header fragment instead, which makes the rule one every shape can follow. EveryShape_HasAnEmissionExpectation enforces it - CS1729 is now classified as accessibility when the quoted name is a type Quarry declares — the form an internal constructor takes — while a CS1729 naming a consumer type stays an arity bug. Replaces the hand-diagnosis hint with a tested classifier Docs: llm.md no longer claims all seven emitted-surface types carry [EditorBrowsable(Never)]; four predate the convention and are plain public. Release notes staged for both fixes. Refs #334
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
InsertBatch(...)did not compile in any project outside this repository. Its generated interceptorcalls
Quarry.Internal.BatchInsertSqlBuilder.Build(...), and that type wasinternal, so consumersgot
error CS0122: 'BatchInsertSqlBuilder' is inaccessible due to its protection levelin thegenerated
*.Interceptors.*.g.cs.Issue #334 also asked for a recurrence guard: "a second instance would fail the same way." There
was one, and it was larger — see below.
Reason for Change
A generated interceptor is compiled into the consumer's assembly, so every Quarry type, method
and constructor it names is part of the public API contract whether or not it is documented as such.
Nothing enforced that.
The reason it went unnoticed is structural: all seven
InternalsVisibleTogrants insrc/Quarry/Quarry.csprojcover every project in the solution —Quarry.Tests,Quarry.Benchmarks,Quarry.Sample.WebApp,Quarry.Sample.Aot. No in-repo build has ever compiled generatedinterceptors the way a consumer does. The synthetic non-friend
CSharpCompilationinsideGeneration/InterceptorBindingGuardTests.cs(added by #314) is the only thing in the repository thatcan observe this class of defect at all.
Impact
Two consumer-facing fixes, both of which made a documented feature unusable from NuGet:
Quarry.Internal.BatchInsertSqlBuilderwasinternalInsertBatch(...)—CS0122publicQueryDiagnostics' only constructor wasinternalToDiagnostics()on any chain shape —CS1729publicThe second was found by this PR's own guard on the first
ToDiagnosticsshape added to the matrix,and is the wider of the two:
llm.mddocumentsToDiagnostics()as available on every builder typeand "the primary tool for asserting generated SQL in tests". Three emitter sites construct it
(
TerminalEmitHelpers.cs:615— the general path every non-batch chain uses,CarrierEmitter.cs:1095,TerminalBodyEmitter.cs:519).It surfaced as
CS1729, notCS0122, which is worth knowing: when a type's only constructor isinternalit is not an overload candidate at all outside a friend assembly, so the compiler reportsthe arity rather than the protection level.
Fixing it here rather than deferring was an explicit decision (recorded in
workflow.md): splittingit out would have meant pinning a known-broken headline API out of the very guard being built.
Plan items implemented as specified
BatchInsertSqlBuildertopublic+[EditorBrowsable(Never)], matching the existingconvention for the emitted surface (
OpId,QueryExecutor,QueryLog,ParameterLog). Nogenerator change — the emitted name was already correct, just unreachable.
InsertBatchshapes return to the clean-binding matrix andKnownBug_Issue334_BatchInsert_ReferencesInternalTypeis deleted."the emitter named a type consumers cannot reach" rather than "the fixture does not compile".
joins (inner/left), aggregates, correlated
EXISTSsubqueries, set operations, collectionIN(both the
IReadOnlyListandIEnumerable<T>arms), conditional masks,Prepare()/ToDiagnostics,window functions, CTEs and raw SQL.
llm-testing.mdandsrc/Quarry.Generator/llm.md.Deviations from plan implemented
QuarryContext<TSelf>. The plan budgeted a separate context source and a refactorof
Runon that premise.llm.mdscopes the generic base to typed post-Withaccessors;FromCte<T>()works on the plain non-genericQuarryContext. Both were dropped as unnecessary.RawSqlNonQueryAsyncis never intercepted — onlyRawSqlAsyncandRawSqlScalarAsynchave anInterceptorKind. It emits nothing into the consumer's assembly, so there is no surface to guardand no shape for it. (
llm.mdlists all three together, which makes the asymmetry easy to miss.)Quarry.Generated, not the context's namespace, so thefixture's
InterceptorsNamespacesneeded it. This matches an ordinary consumer more closely, notless — Quarry's shipped build targets (
src/Quarry/build/Quarry.targets) register exactly it.Sql.RowNumberrather than the plannedSql.RankwithPartitionBy/descending; those two clauses remain unexercised.Gaps in original plan implemented
Shape_StillReachesItsEmitter. Compiling clean and emitting an interceptor does not prove ashape reached the emitter it was added for. This earned itself on its first run: the collection
shape as first written emitted none of the collection helpers while passing the binding matrix
green.
EveryShape_HasAnEmissionExpectationenforces that every shape declares what it must emit,so the coverage cannot silently rot.
AccessibilityGuard_DetectsAnInaccessibleType. The matrix is meaningful only if thiscompilation genuinely lacks friend access; if that quietly stopped being true every shape would
keep passing while guarding nothing. The probe uses
ScalarConverter, which is internal by designand stays that way. The guard was also verified by hand — temporarily reverting
BatchInsertSqlBuildertointernaland watching both shapes fail with the new message.CS1729classification. Classified as accessibility only when the quoted name is atype Quarry declares, so a genuine emitter arity bug — a defect class this matrix exists to
catch — is still reported as one. Covered by tests including the negative case.
Prepared_MultiTerminal'sToDiagnostics()half was compiled but neverprobed;
Shape.AdditionalTerminalsfixes that.Insert(...).ToDiagnostics()— the thirdQueryDiagnosticsconstruction site — had no shape.Migration Steps
None. All changes are strictly widening; no consumer action required. Consumers who previously could
not compile
InsertBatchorToDiagnostics()will find they now do.Performance Considerations
None. No change to any emitted code path or runtime algorithm — the emitters were already producing
the correct calls.
MaxParameterCountmoves fromconsttostatic readonly, replacing a compile-timeinline with a static field read on a path that already builds a SQL string.
Security Considerations
Widening visibility grants consumers no capability they lacked — anything reachable through
BatchInsertSqlBuilderis reachable throughRawSqlAsyncalready. Both newly public entry pointsnow validate their arguments rather than assuming a generated caller:
Buildnull-checkssqlPrefix, rejects non-positiveentityCount/columnsPerRow, and computes the parameter productin 64-bit so it cannot wrap negative past the
MaxParameterCountceiling; theQueryDiagnosticsconstructor null-checks its three required arguments.
Breaking Changes
no behavioural change to any existing member.
MaxParameterCountispublic static readonlyrather thanpublic const,deliberately: a public
constis inlined into consumer assemblies at their compile time, whichwould freeze a value its own documentation calls "a conservative default". The
QueryDiagnosticsconstructor is now frozen public API; its
<remarks>states it is not supported and warns againstbinding to the signature, since new diagnostics fields are appended as optional parameters.
Testing
Quarry.Tests3561 passed / 0 failed;Quarry.Migration.Tests201 passed / 0 failed. Baselinebefore this branch was 3501 / 201, both green. Manifest goldens unchanged throughout.