Skip to content

ADR 0049: Fluent Cypher entity returns

Date: 2026-10-22. Status: Accepted on 2026-10-23, after the verification below passed (evidence). Amends the return rule of ADR 0041 ("return only scalar/DTO values") and extends bounded collect from ADR 0048. Every other ADR 0041, 0047 and 0048 decision is unchanged.

Context

Fluent Cypher returns scalars and DTOs. Returning a matched node or relationship as its mapped entity, Return(() => movie.Value), is refused with GBFC404, so a graph question that ends in "and give me the movies" needs either one DTO per entity type or a second LINQ query by key.

This is the gap people notice first when they come from Neo4jClient, whose most common idioms are Return(m => m.As<Movie>()) and CollectAs<Movie>() (PL-34 review, 19 October 2026). Neo4jClient builds those objects by deserializing JSON from the node's properties, outside any model.

ADR 0041 refused entity returns for a reason that still holds: Fluent is a native query builder, not a second entity tracker. A Fluent result must not be attached, identity-resolved or presented as an EF integrity contract. Those are the properties that make entity returns risky, not the construction of the object itself. ADR 0030 already constructs LINQ DTO projections through EF's shaper rather than a parallel mapper.

Decision

A node or relationship symbol's Value can be returned as its mapped entity type, untracked:

var movies = await db.Cypher()
    .Match(actor.Out<Credit>(movie))
    .Where(() => actor.Value.Name == name)
    .Return(() => movie.Value)
    .OrderBy(() => movie.Value.Released)
    .Limit(100)
    .ToListAsync();                                    // IReadOnlyList<Movie>, untracked

var movieList = Graph.Collect(movie, 50);              // grouped by the other returned values
var filmographies = await db.Cypher()
    .Match(actor.Out<Credit>(movie))
    .Return(() => new { Actor = actor.Value, Movies = movieList.Value })
    .Limit(100)
    .ToListAsync();

What is accepted

  • alias.Value of a node or relationship symbol in Return/ReturnDistinct: alone, as an anonymous-type member, as a DTO constructor argument or member assignment.
  • Graph.Collect(symbol, max) and Graph.CollectDistinct(symbol, max) over a node or relationship symbol, in With or inline in Return, giving IReadOnlyList<T> of entities. The bound (1–10,000) and the server-side slice are the same as ADR 0048's.
  • OrderBy/ThenBy on a member of a returned entity, () => movie.Value.Released, when the member is a mapped property stored on that node or relationship. It renders as a member of the returned column (ORDER BY c0.released), which is valid after RETURN DISTINCT too.
  • Every node mapping, including a dependent whose foreign key is stored as a native reference (ToGraphReference), and both relationship mappings: association edges and identified edges.

How a value is read

  1. The query returns only mapped properties, as a map projection, never the whole node or relationship. Unmapped payloads, such as embeddings written by native Cypher, do not travel.
  2. Node: n0 {.`id`, .`title`, .`released`}.
  3. Relationship: its stored properties plus the principal keys of its endpoints, r1 {.`role`, __gb_source: {`id`: startNode(r1).`id`}, __gb_target: {`id`: endNode(r1).`id`}}. Endpoint symbols need not still be in scope. Endpoint keys are map literals because Cypher allows a map projection only on a variable; the first live run refused startNode(r1) {.`id`} (syntax error 42I06).
  4. Native-reference dependent: its stored properties plus at most two candidate parents, __gb_reference: COLLECT { MATCH (parent)-[:`HAS_REVIEW`]->(n0) RETURN parent {.`id`, __gb_principal: (parent:`Movie`)} LIMIT 2 }. This mirrors the LINQ membership diagnostic: count the physical links first, then test the parent's label.
  5. A symbol that is null after OPTIONAL MATCH projects as null, because a map projection over null is null, and the entity is null.
  6. Values are decoded exactly as LINQ decodes them. The same GraphReadBinding slots and GraphPropertyBinding conversions place each value at its EF property index in a ValueBuffer: value converters, native DateTimeOffset, 64-bit to 32-bit integers, and the required-value, store-type and canonical-key checks. A native-reference row must have exactly one valid principal when the foreign key is required, and zero or one when it is optional; otherwise the read fails, because the foreign key value would be ambiguous.
  7. EF creates the instance. IStructuralTypeMaterializerSource.CreateMaterializeExpression with QueryTrackingBehavior.NoTracking builds the object from the buffer: constructor binding, service properties and materialization interceptors behave as for a LINQ no-tracking query. The compiled delegate is cached per entity type, held weakly with the finalized model.
  8. Nothing is attached. No state-manager entry, no identity resolution and no fix-up: the same node returned in two rows gives two instances. Navigations are not loaded, as in a LINQ query without Include.

What stays the same or stays refused

  • No EF uniqueness guards. As ADR 0041 decided, native row multiplicity is preserved: two physical relationships between the same endpoints are two rows. LINQ's physical-identity diagnostics (association cardinality, identified-edge ID uniqueness, key-constraint presence) protect EF identity resolution, which Fluent never performs. Per-value checks (store types, required values, canonical keys, non-empty identified-edge IDs, native-reference membership) still apply.
  • RETURN DISTINCT and CollectDistinct compare the returned maps: all mapped properties, keys included. With unique keys this equals EF key identity.
  • Refused, with their existing codes: entity types with query filters (GBFC212); a repeated relationship-list symbol as a scalar (GBFC207); constructing a mapped entity type in Return, such as new Movie { Title = … } (GBFC404, which now covers only this); ordering by a member that isn't stored on the returned node or relationship, such as a foreign key slot (GBFC405). Inheritance, owned types and complex properties are already refused by model validation.
  • New refusal, GBFC408: an entity whose store property names begin with the reserved __gb_ prefix cannot be returned, because the projection uses that prefix for endpoint and reference entries.
  • Tracking stays with LINQ. To track or update the returned entities, return their keys and load them with LINQ, as the Fluent guide already describes.

EF services

IStructuralTypeMaterializerSource (singleton, provider-facing public interface), StructuralTypeMaterializerSourceParameters and MaterializationContext, resolved through the context's service provider. Recorded in EF_SERVICE_MAP with the EF 10.0.13 source references.

Not adopted

  • Returning the whole node and deserializing it, as Neo4jClient does. It transfers unmapped properties, bypasses converters and EF construction, and maps by name rather than by the model.
  • Tracked or identity-resolved returns. They would make Fluent a second change tracker. That remains PL-24, deferred.
  • Integrity diagnostics on every entity return. They would add a subquery per relationship symbol and change the row semantics ADR 0041 chose. Doctor and LINQ remain the integrity checks.
  • Ordering by a foreign key slot or a computed member of a returned entity. Return the scalar explicitly when one exists.

Verification

  • Unit: rendering for a node, a native-reference dependent, an association edge and an identified edge, alone, in a DTO and after OPTIONAL MATCH; Collect and CollectDistinct in With and inline; ordering by an entity member with and without DISTINCT; GBFC404, GBFC405 and GBFC408; the Inspect() snapshot of an entity return.
  • Live, against Neo4j 2026.09: Fluent entity returns equal the same entities loaded with AsNoTracking LINQ, property by property, for every mapped property type in the test models, including converters, enums, Guid keys and DateTimeOffset; nodes, native-reference dependents (present and absent optional parent, invalid membership refused), association and identified edges; null after OPTIONAL MATCH; collected lists; ordering by an entity member after RETURN DISTINCT; nothing is tracked afterwards.

Result, 2026-10-23: 844 provider units and, on the owned Neo4j 2026.09.0 Community fixture, the five ProviderFluentEntityReturnTests, sample 05's C9 oracle, the whole Integration suite (305), the Specification suite (81), the Tooling suite (23) and the Workshop suite (9, all lessons live) pass. The first live run refused relationship returns: Neo4j allows a map projection only on a variable, so startNode(r) {.`id`} is a syntax error (42I06). Endpoint keys became map literals, and the unit tests that had pinned the invalid text were corrected.

Consequences

  • Most "query the graph, then load the entities" round trips become one Fluent query.
  • The guide for Neo4jClient users can map As<T>() and CollectAs<T>() to real equivalents.
  • GraphBridge depends on one more EF provider-facing service, IStructuralTypeMaterializerSource, already used indirectly through EF's shaper.
  • Result payloads grow with the mapped properties of each returned entity; return scalars when only a few are needed.