0001 — Shared Cypher translation layer and multi-provider architecture
- Status: Accepted (all decisions ratified by @savasp on PR #97; contributor comments on the PR #66 landing path remain welcome via #86)
- Date: 2026-07-02
- Related: #53, #66, #67, #80, #81, #84, #85, #86, #90, #93, #94, #95, #96
- Related code: src/Graph/GraphQueryable/, src/Cvoya.Graph.Neo4j/Querying/
Context
CVOYA graph has one LINQ-to-Cypher translation pipeline and, as of PR #66, two providers that need it.
The in-tree Neo4j provider translates by string-building: ExpressionToCypherVisitor
(src/Cvoya.Graph.Neo4j/Querying/Cypher/Visitors/ExpressionToCypherVisitor.cs, 1,079 lines) returns Cypher
fragments as Expression.Constant(string), and CypherQueryBuilder
(src/Cvoya.Graph.Neo4j/Querying/Cypher/Builders/CypherQueryBuilder.cs, 1,216 lines) accumulates raw clause
strings (AddMatchPattern(string), AddWhere(string)). Dialect syntax — datetime(), apoc.text.*,
db.index.fulltext calls — is welded inline into LINQ recognition. Terminal operations are dispatched by
string method name in CypherQueryVisitor
(src/Cvoya.Graph.Neo4j/Querying/Cypher/Visitors/Core/CypherQueryVisitor.cs, 1,683 lines), matching ~60
public *AsyncMarker stubs (src/Graph/GraphQueryable/QueryableAsyncExtensionsMarkers.cs).
PR #66 (@paule96) contributes a complete Apache AGE provider for PostgreSQL (~23k added lines). Because
nothing in the pipeline was reusable, it had to fork the stack (AgeGraphQueryableBase,
AgeGraphQueryProvider — byte-similar to their Neo4j counterparts) — but its internals are better than
what they fork: a typed fragment IR plus a renderer (AgeQueryFragments, AgeFragmentRenderer under
src/Cvoya.Graph.Age/Querying/Cypher/Visitors/Core/ in the PR) instead of string accumulation, and a thin
shared src/Cvoya.Graph.Cypher package that the Neo4j provider does not yet consume.
Without a shared layer, every LINQ feature must be built twice and the two implementations will drift
semantically. The public query surface compounds the cost: the node/relationship distinction is modeled as
an interface hierarchy (IGraphNodeQueryable<T>, IGraphRelationshipQueryable<T>), which forces ~3,700
lines of shadowed LINQ operators across GraphQueryableExtensions.cs (838 lines),
GraphNodeQueryableExtensions.cs / GraphRelationshipQueryableExtensions.cs (110 lines each), and
QueryableAsyncExtensions.cs (1,736 lines) — and the shadowing is incomplete, so .OrderBy(…).Traverse<…>()
does not compile (#93 §B4).
A full design review of the surface, operator model, and translation architecture ran as #93 (§A–§F).
@savasp’s decisions there (single interface with constraints; markers redesigned as internals; breaking
changes accepted pre-1.0; IGraphPath in phase 1) fixed most of the architecture. This ADR records those
decisions, settles the remaining packaging and migration questions, and establishes the repo’s ADR
convention (see README.md).
Decision
Decided in #93 and its decision comment (recorded here, not re-litigated)
-
Single queryable interface, operators gated by constraints. The public surface collapses to
IGraphQueryable<T>+IOrderedGraphQueryable<T>. Graph operators are gated by generic constraints (where TStart : INode,where T : IRelationship), not receiver interfaces. The node/relationship interface hierarchy is retired (kept for one release as aliases, then removed by #240). This deletes the triple-shadowed operator layers and fixes the ordering-degradation bug by construction. Rationale: the hierarchy’s only job was operator gating, which constraints do with zero shadowing (#93 §C1). Implementation: #94. -
Marker methods become internals with
MethodInfo-identity dispatch. The ~60 public*AsyncMarkerstubs are replaced by a non-extension internalQueryTerminalsclass; providers dispatch onMethodInfoidentity from a shared table — no string-name dispatch anywhere (#93 §C2). The numericSum/Averageoverload triplication collapses to one definition. Implementation: #94. -
Breaking changes are accepted pre-1.0;
IGraphPathlands in phase 1.IGraphPath(Start,End, orderedSegments) becomes the result type of variable-length traversals (TraversePaths(...));IGraphPathSegment<S,R,T>remains the single-hop case. Sync query roots,IAsyncEnumerable<T>, and the traversal-ergonomics changes ride the same breaking window (#94, with #76/#73 scheduled adjacent per the #90 plan) so consumers absorb one breaking release, not three. -
Two-level IR. Level 1 is a semantic
GraphQueryModel— what the query means: root (node/relationship/dynamic/search), predicates, ordered traversal steps (relationship type, direction, depth, relationship predicates), projection shape, ordering/paging, terminal operation. It is produced by the shared LINQ front-end (the recognition halves ofCypherQueryVisitor/ExpressionToCypherVisitor, dispatching onMethodInfo), and it is where new operators bind — once. A sharedCypherQueryPlannerlowers it to level 2, a typed Cypher fragment AST (clause fragments + expression nodes instead of strings), rendered per dialect throughICypherDialect— function mappings, full-text strategy, label/ID/parameter syntax, and a capability surface that fails unsupported constructs at translation time with an error naming the construct and dialect. A future non-Cypher provider (Gremlin, SQL) targets level 1 and skips level 2 entirely. This amends the original single-IR scope of #84/#85 per #93 §E. -
Neutral result wire model. Provider-independent graph values (node/relationship/path/scalar/list/map records). The hard materialization logic of
CypherResultProcessor(src/Cvoya.Graph.Neo4j/Querying/Cypher/Execution/CypherResultProcessor.cs, 1,173 lines — complex-property reassembly, polymorphic type resolution, path stitching) moves into shared code operating on the wire model; each provider shrinks to a driver→wire-model adapter (#85). -
tests/Cvoya.Graph.Testsships as a provider compatibility suite with a harness SPI (create/tear down store, produceIGraph, per-test isolation) and a capability model aligned 1:1 with theICypherDialectcapability surface, so “dialect says unsupported” and “suite skips-with-reason” can never disagree. The in-tree Neo4j tests consume it exactly as an external provider would (#95). -
Adopt and generalize PR #66’s fragment/renderer design for the level-2 AST rather than inventing a third design. @paule96’s
AgeQueryFragments+AgeFragmentRenderersplit independently arrived at the right shape — typed fragments rendered per dialect — and is the design input for #84. Credit belongs in the #84 PR descriptions as well as here.
Settled by this ADR (ratified by @savasp on PR #97)
(a) Package layout: fold the shared querying front-end into Graph; the shared Cypher package is
Cvoya.Graph.Cypher. The #93 §A decomposition left open whether the shared queryable implementations,
provider base, and LINQ front-end live in core or a separate Cvoya.Graph.Querying. Decision: fold
into Graph. The operator surface and the front-end that recognizes it are one release unit — the
MethodInfo dispatch table binds directly to operator identities defined in the same assembly, so a
separate package would version in lockstep anyway (pure SemVer coupling with no independent consumer) while
adding a second dependency for every provider and an InternalsVisibleTo seam for the semantic model’s
internals. Folding keeps GraphQueryModel internals internal and gives non-Cypher providers a
single-package dependency. The level-2 package keeps PR #66’s name, Cvoya.Graph.Cypher (NuGet ID
Cvoya.Graph.Cypher, matching the existing ID convention): it is accurate — the package contains the
Cypher AST, planner, dialect SPI, and renderer base — and reusing the contributed name minimizes friction
with #66. Alternatives (Cvoya.Graph.OpenCypher, Cvoya.Graph.Cypher.Core) add no information. The shared
wire-model/materialization code (#85) follows the same folding logic into Graph, since a non-Cypher
provider needs it too; final placement is an #85 implementation detail.
(b) Cvoya.Graph.Cypher ships as a public NuGet package. Decision: public from the release in
which #84 lands, not internal shared source. The multi-provider story is the point of this ADR; an external
Cypher-dialect provider (and eventually AGE, whether in-tree or out) needs the planner/dialect SPI as a
package, and #95 already commits to shipping a provider-author-facing package — a provider SPI without the
translation SPI is half a story. The stability concern is moot pre-1.0: the whole library is 0.x, breaking
changes are accepted (#93 decision), and holding the package back buys no stability a 0.x version number
doesn’t already disclaim. Document it as an SPI package that versions in lockstep with Cvoya.Graph.
(c) Compatibility-suite package name: Cvoya.Graph.CompatibilityTests. As per the #95 working
name. “CompatibilityTests” is self-describing on nuget.org where “TCK” is jargon, and the ID sorts with the
package family it certifies.
(d) PR #66 landing path: stage, then converge (path (a) of #86). #86 lists three candidates:
(a) merge nothing until the shared layer lands, then converge the AGE provider as dialect + adapter;
(b) merge now as an explicitly-experimental preview package; (c) split — land the shareable pieces first,
hold the fork pipeline. Decision: (a), because the now-fixed plan removes what (b) and (c) would
buy. Merging the fork now (b) means merging ~40 classes that #94 breaks (surface rewrite) and #84/#85 then
delete (the pipeline it forks), maintaining a second CI lane through that churn, with 864 lines of
self-reported security issues (docs/age/issues/security-issues.md in the PR) still to triage. Splitting (c)
has lost its payload: the genuinely shareable pieces are being absorbed by design rather than by merge —
the fragment/renderer approach becomes the #84 AST (decision 7), the PR’s Cvoya.Graph.Cypher seed is
superseded by that package’s two-level rebuild, and the AGE test fixture’s natural home is as the second
harness of the #95 suite once that SPI exists. Under the converged architecture the AGE provider becomes an
AgeDialect + Npgsql/AGE wire-model adapter + store/transaction layer — a fraction of the current 23k
lines. Mitigation for contributor friction, per #86: maintainers do the conversion collaboratively with
@paule96, the security triage happens as part of that review, and #86 posts a concrete “what mergeable
means” checklist on #66/#53.
Consequences
- Ordered migration path (track B of the #90 execution plan; hard ordering encoded as blocked-by
links): #80 (characterization/snapshot safety net) → #94 (query surface v2 — the one big breaking PR)
→ #84 (shared front-end producing
GraphQueryModel; planner; Cypher AST; Neo4j cutover with byte-identical snapshots) → #85 (ICypherDialect+ wire model) → #95 (compatibility suite packaging) → #86 (AGE convergence) / #96 (new operators, one vertical slice each). #81 closes when this ADR is Accepted. - Testing policy (binding, per the #90 plan): every implementation PR ships both provider-free unit tests (expression-shape, IR-structure, Cypher-snapshot — no Docker) and integration coverage through the compatibility suite so all providers inherit it. Snapshot diffs are reviewed individually; intentional Cypher changes are one commit each with justification. CI must report a non-trivial executed-test count per lane — green-with-zero-tests is a failure. New operators land with capability-gated tests so non-supporting providers skip-with-reason, never fail or silently pass.
- Impact on PR #66 / @paule96: the fork pipeline as posted will not merge; the convergence target is
AgeDialect+ result adapter + store layer + compatibility harness once #85/#95 land, done collaboratively. The fragment/renderer design is adopted upstream with credit (decision 7). AGE’s known capability gaps (nested transactions, full-text search — #53) become declared capabilities: translation fails informatively and suite tests skip-with-reason instead of failing. Security-issue triage from the PR’s own docs happens during #86. Concrete checklist lands on #66/#53 per #86. - New shipped packages:
Cvoya.Graph.CypherandCvoya.Graph.CompatibilityTestsjoin the release pipeline (#71). Both are 0.x and version in lockstep with the core. - Cost per new operator drops to one binding: surface +
GraphQueryModelnode + planner lowering + per-dialect rendering + capability entry + suite tests (#96) — instead of one full visitor-stack implementation per provider. - Funcletization is bounded and structurally allowlisted: the shared front-end evaluates only parameter-free expression subtrees at translation time. That includes closure-member reads and parameterless method calls, matching standard LINQ-provider behavior; an expression that references a query parameter is never compiled and invoked. This is deliberately not a method/type sandbox: query authors already execute application code while constructing expression trees, so the policy prevents accidental evaluation of server-side expressions without pretending to isolate trusted application code. Translation rejects trees over 10,000 nodes or 100 levels deep by default; internal callers may override both limits for generated queries.
- Breaking-change window: consumers absorb one breaking release (#94, with #76/#73 adjacent), with
docs/migration-0.x.mdcovering every break. - The provider guide (#82) grows a “Certifying a provider” chapter driven by the compatibility suite.
Alternatives considered
- Two independent providers sharing only contract tests and a conventions doc. Rejected. This is the status quo PR #66 demonstrates: a ~40-class byte-similar fork, every LINQ feature built twice, and semantic drift bounded only by test coverage — which the contract suite cannot fully provide, since translation nuances (null semantics, depth boundaries, aggregation edge cases) surface as subtle result differences, not failures. The fork tax also falls on every future provider.
- Single-level, Cypher-shaped IR (the original #84 plan). Rejected in favor of the two-level IR (#93 §E): with only a Cypher AST, new operators bind to Cypher syntax, every operator addition touches all renderers directly, and a non-Cypher provider gets nothing. The semantic level is where operator meaning lives once; lowering is shared; only rendering is per-dialect.
- Keep the interface hierarchy and complete the shadowing. Rejected (#93 §C1): completing ~3,700 lines of shadow operators (and maintaining the matrix forever, for every new operator times every interface) treats the symptom; constraints on a single interface remove the disease.
- A fresh fragment-IR design, ignoring PR #66’s. Rejected:
AgeQueryFragments/AgeFragmentRendereralready validate the fragments + per-dialect-renderer shape in a working provider; a third design would re-derive the same structure while discarding both the validation and the contribution. - Separate
Cvoya.Graph.Queryingpackage — see decision (a); rejected (SemVer-coupled with no independent consumer). - Hold
Cvoya.Graph.Cypheras internal shared source — see decision (b); rejected (pre-1.0 versioning already disclaims the stability that internalizing would protect).