0002 — Complex (nested-object) properties as first-class graph structure
- Status: Accepted (decided by @savasp on #199, 2026-07-09)
- Date: 2026-07-09
- Related: #84, #85, #199, ADR-0001
- Related code: src/Cvoya.Graph.Neo4j/Entities/ComplexPropertyManager.cs, src/Cvoya.Graph.Neo4j/Querying/, src/Cvoya.Graph.Serialization/, src/Cvoya.Graph.Serialization.CodeGen/
Context
CVOYA graph’s data model goes beyond the native property-graph model: a .NET node/relationship property
can be a complex object — a nested POCO/value object, a collection of them, or a polymorphic hierarchy.
Today those are stored as a hidden, parallel mechanism: each complex property is decomposed into
provider-private related nodes joined by mangled __PROPERTY__{name}__ relationships, reconstructed on
read via an unbounded [rels*1..] traversal plus APOC (apoc.coll.toSet). This is the single largest
source of conditional branching in the query-translation layer (~74 decisions in CypherQueryVisitor,
~63 in CypherQueryBuilder, ~53 in CypherResultProcessor), query-into-nested is capped at one level and
has a null-check inconsistency, and the APOC dependency is a cross-provider liability.
While planning #84 (shared LINQ front-end + two-level IR), @savasp asked whether to align the model with
the native graph model, and required the data-model direction be settled before the level-1
GraphQueryModel IR is finalized (the IR must either model complex-property navigation as a first-class
concept or deliberately exclude it). #199 analyzes four options (A keep-but-harden, B opaque serialized
value, C explicit modeling, D first-class structural mapping); this ADR records the decision.
Decision
Adopt option D: complex-object properties map to first-class, semantically-named graph structure.
Person { Address Home; Address Work; } maps to:
(:Person {Name})-[:Home]->(:Address {Street, City})
(:Person {Name})-[:Work]->(:Address {Street, City})
- Visibility: the
:Addressnodes and:Home/:Workrelationships are first-class — visible, queryable, and traversable — not provider-private. - Naming: the relationship type is derived from the property name by convention, overridable via
attribute to avoid collisions with user-defined domain relationships (replacing the collision-avoidance
role of the
__PROPERTY__prefix). - Identity: per-instance value nodes — each in-memory instance becomes its own node; two owners
referencing the same in-memory instance produce two nodes (preserves value-object semantics). Shared /
deduped identity (e.g. via an attribute or a
NodeWithIdentity-style opt-in) is deferred to a follow-up issue. - Round-trip (read-shape driven): if the target class declares the property, the infrastructure
auto-loads it recursively (transparent round-trip preserved), bounded by an explicit depth/cycle
guard (the current
GraphDataModel.DefaultDepthAllowed = 5+ cycle detection become enforced rather than incidental); if the class omits it (a slim projection type), there is no property to populate but the relationship remains navigable by traversal, with a co-load shortcut that returns owner + related together (reusing the path/projection machinery). - Query: complex member access (
p.Home.City) lowers to traversal steps in the level-1 IR — there is no bespoke complex-property-navigation concept. Typed LINQ is preserved and extended: arbitrary depth works,p.Home == nullbecomes relationship-existence, and complex collections (p.Offices.Any(a => …)) translate to traversal-existence / aggregate predicates (new front-end work, in scope for the IR). - Removed: the
__PROPERTY__mangled edges, hidden nodes, the APOC read reconstruction, and theNeedsComplexProperties/ enable-disable flag machinery threaded through the translation layer.
Consequences
- #84’s level-1 IR gets simpler, not more complex: complex-object access is expressed with the same traversal + projection nodes the IR already needs. This is why the direction had to be fixed first.
- Behavior change (snapshots): complex-property Cypher differs from today’s
__PROPERTY__/APOC output. The affected translation snapshots are intentionally re-baselined (itemized, one reviewable commit each with justification); all non-complex snapshots stay byte-identical per the #84 acceptance bar. - Cross-cutting rollout. D spans the write path (
ComplexPropertyManager), query translation (#84), and materialization (#85’s wire model). A half-migrated store is internally inconsistent (queries expect:Homewhile storage still writes__PROPERTY__), so the write-path + query + materialization switch must land cohesively. Recommended sequencing (open for confirmation): #84 delivers the shared IR- front-end/planner/renderer with D’s query translation; the write-path and materialization flip land together with (or immediately adjacent to) #84 so read/write stay consistent; #85’s neutral wire model then generalizes the materialization. This is the one sequencing item to confirm before the behavioral switch.
- Serialization CodeGen: complex-property POCOs are generated as node types with relationship mappings
rather than nested
EntityInfo; the generator’s reachability closure still applies. - Migration: breaking storage change. Existing
__PROPERTY__data requires migration, or a clean rebuild — acceptable pre-1.0 per ADR-0001.docs/provider-implementers-guide.md’s normative__PROPERTY__contract is replaced;docs/migration-0.x.mdand the value-object docs are updated. - Follow-up filed if/when D is implemented: shared/deduped node identity for value objects.
Alternatives considered
Full analysis in #199. In brief:
- A — keep decomposition, harden + de-APOC. Preserves behavior but keeps a bespoke, hidden code path; highest effort for the least model alignment.
- B — opaque serialized value (JSON/agtype). Biggest translation reduction, but the sub-objects stop being graph structure (no query-into, no traversal), and it is a write-only-ish blob.
- C — require explicit modeling. Cleanest, but the harshest DX cut: users hand-declare and hand-wire every value object.
- D — first-class structural mapping (chosen). Deletes the special case by folding it into traversal + projection, keeps transparent authoring and round-trip, gains first-class queryability of the sub-entities, and aligns the .NET model with the property-graph model.