ADR-0017a: Property-path semantics across multi-valued hops (amendment to ADR-0017)¶
- Status: Accepted (2026-04-14)
- Amends: ADR-0017 — resolves Open Question #4
- Related: ADR-0021 (Progressive enhancement)
Context¶
ADR-0017 §Phase 3 commits the ORM to lower Django-style traversal
(Patient.where(care_team__lead__name="Alice")) onto SPARQL property
paths. Open Question #4 in that ADR flags an ambiguity the surface
alone cannot resolve: when an intermediate hop is multi-valued
(care_team declared with max_count > 1 on the @node_type, per
ADR-0021), one filter expression has two readings:
- Existential — match Patients where AT LEAST ONE care-team member
has a lead named Alice. Lowers to a plain triple pattern in
WHERE(orEXISTS { … }when wrapped). - Universal — match Patients where EVERY care-team member has a
lead named Alice. Vanilla SPARQL has no
forall; the only faithful lowering is double-negation (FILTER NOT EXISTS { … FILTER NOT EXISTS { … } }).
The Phase 3 sprint plan
(internal planning §Risks) blocks on
this decision: _compile cannot emit property-path SPARQL until the
ORM agrees which reading is the default.
Decision¶
Existential is the default. Universal is opt-in via an __all__
modifier inserted at the multi-valued hop.
Patient.where(care_team__lead__name="Alice")— existential. Plain property-path triple underWHERE; one matching member satisfies.Patient.where(care_team__all__lead__name="Alice")— universal. NestedFILTER NOT EXISTS(negation-of-negation) anchored on the multi-valued hop.
__all__ is reserved at the segment level — not a filter suffix, not
a field name. _resolve_association_chain MUST reject any
@node_type declaring all as a literal field.
Worked example¶
# Existential (default). Generated SPARQL:
# ?patient a :Patient ; :care_team ?m .
# ?m :lead ?l . ?l :name "Alice"^^xsd:string .
hits = Patient.where(care_team__lead__name="Alice").fetch(ctx)
# Universal. Generated SPARQL:
# ?patient a :Patient .
# FILTER NOT EXISTS {
# ?patient :care_team ?m .
# FILTER NOT EXISTS { ?m :lead ?l . ?l :name "Alice"^^xsd:string . }
# }
strict = Patient.where(care_team__all__lead__name="Alice").fetch(ctx)
Consequences¶
Positive. Matches SQL/ActiveRecord muscle memory: JOIN is
existential by default — what users from Rails, Django, Prisma
expect. Existential is also the cheaper SPARQL (a single BGP walk),
so the default is the fast path.
Negative. Universal lowers to double-negation: slow on large
graphs and hard for engine optimisers. The Phase 3 tutorial addendum
(docs/tutorials/growing-your-kg-app.md) MUST flag this beside the
N+1 callout ADR-0017 §Mitigations already requires. Universal is
vacuously true when the multi-valued edge is empty; combine with an
existential filter when "non-empty AND universal" is needed.
Neutral. Naming follows Django filter-suffix convention (__in,
__contains, now __all__), lowering friction for Python
developers. Single-valued hops accept __all__ as a no-op (the
lowering collapses to existential because forall over a singleton
is exists), so shape evolution narrowing a hop does not break
existing queries.
Alternatives considered¶
- Universal as default. Rejected: surprises every user with
JOINintuition; makes the common case the slow lowering. - Forbid traversal across multi-valued hops. Rejected: defeats
the property-path traversal Phase 3 exists to deliver; pushes users
back to
sparql"…"for exactly the case the ORM should make ergonomic. ANY(...)/ALL(...)wrappers around the chain. Rejected: more verbose than__all__, breaks chain-as-string composition, invents a Trails-only idiom where Django convention covers the case.
Open questions¶
__inover a multi-valued field. Doeswhere(care_team__name__in=[…])mean "any member's name is in the list" (Cartesian) or "the set of member names equals the list" (element-wise)? Current stance matches the existential default (Cartesian); the set-equality reading may force its own amendment.- Reasoner interaction. When property paths cross edges
materialised by ADR-0004 query-time reasoning, does
__all__quantify over the inferred closure or the asserted base only? Needs a worked example before the Phase 3 gate. - Cardinality detection. Must
max_count > 1be declared on@node_typefor__all__to be accepted at that segment, or can the resolver detect multi-valuedness dynamically from the store? Static matches ADR-0021's feature-detection rule; dynamic is more permissive for label-only graphs.