grafeo-ogm 1.8.3 → 1.8.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +183 -0
- package/dist/cjs/compilers/where.compiler.d.ts +15 -0
- package/dist/cjs/compilers/where.compiler.d.ts.map +1 -1
- package/dist/cjs/compilers/where.compiler.js +164 -33
- package/dist/cjs/compilers/where.compiler.js.map +1 -1
- package/dist/cjs/interface-model.d.ts.map +1 -1
- package/dist/cjs/interface-model.js +46 -2
- package/dist/cjs/interface-model.js.map +1 -1
- package/dist/cjs/model.d.ts.map +1 -1
- package/dist/cjs/model.js +66 -26
- package/dist/cjs/model.js.map +1 -1
- package/dist/esm/compilers/where.compiler.d.ts +15 -0
- package/dist/esm/compilers/where.compiler.d.ts.map +1 -1
- package/dist/esm/compilers/where.compiler.js +164 -33
- package/dist/esm/compilers/where.compiler.js.map +1 -1
- package/dist/esm/interface-model.d.ts.map +1 -1
- package/dist/esm/interface-model.js +46 -2
- package/dist/esm/interface-model.js.map +1 -1
- package/dist/esm/model.d.ts.map +1 -1
- package/dist/esm/model.js +66 -26
- package/dist/esm/model.js.map +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,188 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.8.5 (2026-05-07) — 🚨 SECURITY ADVISORY
|
|
4
|
+
|
|
5
|
+
> **CRITICAL.** When a query traversed a relationship in a WHERE filter (`_SOME` / `_NONE` / `_ALL` / `_SINGLE` / `*Connection*` / union-typed relationships), the target type's NLS `'read'` policy was NOT applied inside the `EXISTS` body. A kill-switched org with a leaked target-type ID could use `where: { contentRel_SOME: { id: 'leakedId' } }` as a confirmation oracle / IDOR vector — even when its source-type policy correctly denied direct reads of the target type. **Upgrade immediately if you use NLS policies on target types reachable via relationship filters.**
|
|
6
|
+
|
|
7
|
+
### Affected versions
|
|
8
|
+
|
|
9
|
+
`1.7.0` through `1.8.4` (all NLS-enabled releases prior to 1.8.5).
|
|
10
|
+
|
|
11
|
+
### Severity
|
|
12
|
+
|
|
13
|
+
**CRITICAL** — survived the v1.7.0 NLS audit, the v1.8.0 perf audit, and the v1.8.2 default-deny security fix. Identified by external reviewers running mutation-style analysis on a downstream service.
|
|
14
|
+
|
|
15
|
+
### Impact
|
|
16
|
+
|
|
17
|
+
`WhereCompiler.tryCompileRelationship`, `compileUnionRelationship`, and `tryCompileConnection`/`compileConnectionWhereInput` recursed into target types via `compileConditions(...)` directly — there was no path for the target's `'read'` policy to AND-stitch into the EXISTS body. `SelectionCompiler.compileNestedWhere` already had the correct pattern (re-resolve `targetPolicy` via `policyContext.resolveForType(targetTypeName, 'read')` and inject a synthesized bundle); WhereCompiler did not.
|
|
18
|
+
|
|
19
|
+
`InterfaceModel.find()` and `aggregate()` had a related gap — they never passed `policyContext` to `whereCompiler.compile()` at all, so even with the source-side fix, user-where relationship traversals on interface entry points still bypassed target-side policy.
|
|
20
|
+
|
|
21
|
+
### Reproducer
|
|
22
|
+
|
|
23
|
+
```ts
|
|
24
|
+
// User has a permissive NLS policy that grants 'read' iff !ctx.killed.
|
|
25
|
+
ogm.policies.register('Content', {
|
|
26
|
+
permissive: [{
|
|
27
|
+
operations: ['read'],
|
|
28
|
+
appliesWhen: (ctx) => !ctx.killed,
|
|
29
|
+
when: () => ({}),
|
|
30
|
+
}],
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
// Attacker's ctx has killed = true. Direct reads correctly return [].
|
|
34
|
+
await ogm.withContext({ killed: true })
|
|
35
|
+
.model('Content')
|
|
36
|
+
.findUnique({ where: { id: 'leaked' } });
|
|
37
|
+
// Pre/Post-1.8.5: WHERE false → [] ✅
|
|
38
|
+
|
|
39
|
+
// Pre-1.8.5 IDOR: traverse via a relationship.
|
|
40
|
+
await ogm.withContext({ killed: true, userId: 'attacker' })
|
|
41
|
+
.model('User')
|
|
42
|
+
.find({ where: { contentRel_SOME: { id: 'leaked' } } });
|
|
43
|
+
// Pre-1.8.5 cypher:
|
|
44
|
+
// MATCH (n:User)
|
|
45
|
+
// WHERE EXISTS { MATCH (n)-[:HAS_CONTENT]->(r0:Content) WHERE r0.id = $param0 }
|
|
46
|
+
// RETURN n
|
|
47
|
+
// The User-side policy fires, but Content's appliesWhen never runs.
|
|
48
|
+
// If the attacker has any User row at all (own profile), the EXISTS
|
|
49
|
+
// acts as a confirmation oracle: row exists ↔ leaked Content ID
|
|
50
|
+
// exists in the graph + linked to the attacker's user.
|
|
51
|
+
//
|
|
52
|
+
// Post-1.8.5 cypher:
|
|
53
|
+
// MATCH (n:User)
|
|
54
|
+
// WHERE EXISTS { MATCH (n)-[:HAS_CONTENT]->(r0:Content)
|
|
55
|
+
// WHERE (r0.id = $param0) AND false }
|
|
56
|
+
// RETURN n
|
|
57
|
+
// Content's appliesWhen runs → permissives.length > 0 but all abstain
|
|
58
|
+
// → permClause = 'false' → EXISTS can never match → IDOR closed.
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### Root cause
|
|
62
|
+
|
|
63
|
+
`WhereCompiler.tryCompileRelationship` (line 856 pre-fix) and the union/connection variants recursed via:
|
|
64
|
+
|
|
65
|
+
```ts
|
|
66
|
+
const innerResult = this.compileConditions(value, relVar, targetNodeDef, ...);
|
|
67
|
+
// ❌ No policyContext threaded — target policy never AND-stitched
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
The correct pattern, already present in `SelectionCompiler.compileNestedWhere` (selection.compiler.ts:826-847), is:
|
|
71
|
+
|
|
72
|
+
```ts
|
|
73
|
+
const targetPolicy = policyContext?.resolveForType(targetDef.typeName, 'read');
|
|
74
|
+
const targetBundle = targetPolicy ? { ctx, operation: 'read', resolved: targetPolicy, ... } : undefined;
|
|
75
|
+
const compiled = this.whereCompiler.compile(value, childVar, targetDef, ..., { policyContext: targetBundle });
|
|
76
|
+
// ✅ result.cypher = (user filter) AND (target policy) AND-stitched
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
The fix mirrors this pattern at every WHERE-side relationship-traversal entry point.
|
|
80
|
+
|
|
81
|
+
### Fix
|
|
82
|
+
|
|
83
|
+
5 surfaces closed:
|
|
84
|
+
|
|
85
|
+
- `src/compilers/where.compiler.ts` — `tryCompileRelationship` (`_SOME` / `_NONE` / `_NOT` / `_ALL` / `_SINGLE` quantifiers + bare-relationship shorthand): now builds `targetBundle = buildTargetBundle(targetNodeDef.typeName, policyContext)` and re-enters `this.compile()` with the bundle. Inner result already AND-stitches user + policy; the EXISTS template substitutes it.
|
|
86
|
+
- `src/compilers/where.compiler.ts` — `compileUnionRelationship`: same pattern per union member, so each branch enforces ITS OWN target policy (not the union's).
|
|
87
|
+
- `src/compilers/where.compiler.ts` — `tryCompileConnection` / `compileConnectionWhereInput`: `node` and `node_NOT` keys now route through `this.compile()` with target bundle. Recursive `AND` / `OR` / `NOT` paths inside connection-where propagate `policyContext` for any nested node-keys to pick up.
|
|
88
|
+
- `src/compilers/where.compiler.ts` — `_SINGLE` quantifier: distinguishes user-`@cypher` vs policy-`@cypher` errors so authors get a precise message naming the target type when the policy is the blocker.
|
|
89
|
+
- `src/interface-model.ts` — `find()` and `aggregate()` now pass a synthetic `PolicyContextBundle` with `resolved.overridden: true` (no top-level policy clause emit, since CASE-per-label already enforces source-side policy) and live `resolveForType` (so user-where relationship traversals enforce target-side policy via the new WhereCompiler logic).
|
|
90
|
+
|
|
91
|
+
### What this DOES NOT change
|
|
92
|
+
|
|
93
|
+
- **No-policy queries** — when no `policyContext` is present, the relationship-traversal code paths short-circuit identically to pre-1.8.5. Byte-identical Cypher emission, byte-identical params, zero overhead.
|
|
94
|
+
- **Source-side policy** — the v1.7.0+ AND-stitch of source-type policy at the top-level `WHERE` is unchanged. `compilePolicyClause` is untouched.
|
|
95
|
+
- **Edge WHERE** — relationship-properties types are NOT registered in the policy registry by design. No NLS on edges. `compileEdgeConditions` is unchanged.
|
|
96
|
+
- **`null` relationship → `NOT EXISTS`** — graph-shape only (no row leakage). Documented as out-of-scope; no change.
|
|
97
|
+
- **Policy-on-policy recursion** — when a policy's own `when()` partial uses `_SOME`, the recursive `compileConditions` calls inside `compilePolicyClause` deliberately do NOT thread `policyContext`. Adding this would create infinite-regress risk requiring cycle detection. Documented as deferred to v1.9.0.
|
|
98
|
+
|
|
99
|
+
### Tests
|
|
100
|
+
|
|
101
|
+
New file `tests/policy/where-relationship-traversal.spec.ts` — 7 regression tests using a real `WhereCompiler` instance and a real `PolicyContextBundle` with a real `resolveForType` callback. NO mocks of the WhereCompiler or OGM.
|
|
102
|
+
|
|
103
|
+
Cases covered:
|
|
104
|
+
1. `_SOME` + permissive on target → asserts target permissive predicate inside EXISTS body
|
|
105
|
+
2. `_NONE` + restrictive on target → asserts restrictive AND-stitched inside `NOT EXISTS { ... }`
|
|
106
|
+
3. `_ALL` + default-deny → asserts target unreachable
|
|
107
|
+
4. `*Connection { node: {...} }` + permissive on target → asserts connection EXISTS body has policy
|
|
108
|
+
5. Union relationship `_SOME: { Image: {...} }` + per-member permissive → policy in `Image` branch only
|
|
109
|
+
6. `_SINGLE` + permissive (stored properties only) → works
|
|
110
|
+
7. `_SINGLE` + policy with `@cypher` field → throws with target-type-named error
|
|
111
|
+
|
|
112
|
+
Test count: **1381 → 1388** (+7). All 1388 pass.
|
|
113
|
+
|
|
114
|
+
**Zero existing tests required modification.** The byte-identical regression contract held: when no `policyContext` is passed, every relationship-traversal path emits the same Cypher as pre-1.8.5.
|
|
115
|
+
|
|
116
|
+
### Compatibility
|
|
117
|
+
|
|
118
|
+
Public API surface unchanged. Cypher emission changes ONLY for queries with `policyContext` AND a relationship traversal AND a registered `'read'` policy on the target type — exactly the queries that were silently leaking pre-1.8.5.
|
|
119
|
+
|
|
120
|
+
### Acknowledgement
|
|
121
|
+
|
|
122
|
+
Discovered by external reviewers performing mutation-style analysis on a downstream service. Reported, scoped via parallel exploration agents, fixed via parallel implementation agents, tested with real-compiler regression tests, and shipped within hours.
|
|
123
|
+
|
|
124
|
+
### For consumers writing tests against grafeo-ogm
|
|
125
|
+
|
|
126
|
+
The failure mode that hid CRIT-3 in downstream services was mocking `clientOgm.find()` itself — those tests proved wiring (the service called the OGM with the right ctx) but never proved authorization (the kill-switch policy actually compiled to `WHERE false`). The robust pattern, used throughout grafeo-ogm's own test suite, is to construct a real `OGM` with a mocked `Driver` (capture `cypher` + `params` via `session.run`), let the real WhereCompiler run, and assert on the captured Cypher. See `tests/policy/where-relationship-traversal.spec.ts` for a concrete example. A first-class `TestDriver` API is planned for v1.9.0.
|
|
127
|
+
|
|
128
|
+
---
|
|
129
|
+
|
|
130
|
+
## 1.8.4 (2026-05-07)
|
|
131
|
+
|
|
132
|
+
> **Backwards-compat fix.** `Model` now always auto-emits `__typename` in default projections — matching `@neo4j/graphql-ogm` behaviour. Apollo Client cache normalisation, type-discriminated unions in TS callers, and any consumer that uses `__typename` as a discriminator now get it on every concrete-type read and mutation, not just on union/interface targets.
|
|
133
|
+
|
|
134
|
+
### What was happening
|
|
135
|
+
|
|
136
|
+
```ts
|
|
137
|
+
const result = await ogm.model('Book').find({ where: { id: 'b1' } });
|
|
138
|
+
// Pre-1.8.4: { id: 'b1', title: 'Dune', pageCount: 412 }
|
|
139
|
+
// Post-1.8.4: { __typename: 'Book', id: 'b1', title: 'Dune', pageCount: 412 }
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
Pre-1.8.4, `__typename` was auto-emitted ONLY when the target was abstract (union or interface with implementations) — `selection.compiler.ts:145-158`. Reads of concrete `@node` types (`Book`, `User`, etc.) returned the schema's scalar properties and nothing else. Mutations without explicit selection returned the bare Neo4j Node, which has no `__typename` field at all.
|
|
143
|
+
|
|
144
|
+
This was a documented behaviour difference from `@neo4j/graphql-ogm` that consumers running Apollo Client / GraphQL pipelines hit immediately:
|
|
145
|
+
|
|
146
|
+
- Apollo's `InMemoryCache` keys entries by `__typename + id`. Without `__typename` it can't normalise.
|
|
147
|
+
- Discriminated-union pattern matching in TypeScript silently falls into the `default` branch.
|
|
148
|
+
- Test fixtures depending on `__typename === 'Book'` evaluate to `false`.
|
|
149
|
+
|
|
150
|
+
### Fix
|
|
151
|
+
|
|
152
|
+
Three call sites in `src/model.ts` now route through the default projection (which includes `__typename`) instead of bailing to a bare `RETURN n`:
|
|
153
|
+
|
|
154
|
+
- `defaultSelection()` (line 1728) — `__typename` is now the first scalar in the default selection list. `find()`, `findFirst()`, `findUnique()`, `findFirstOrThrow()`, `findUniqueOrThrow()`, `searchByVector()`, and `searchByPhrase()` all inherit this transitively via `resolveSelection()`.
|
|
155
|
+
- `applySelectionSetToMutation()` (line 1770) — when `selectionSet` is undefined (the no-explicit-selection path used by `create()` / `update()` / `delete()` / `setLabels()`), now compiles `defaultSelection()` instead of leaving the bare `RETURN n`.
|
|
156
|
+
- `applySelectionSetToUpsert()` (line 1888) — same fix for `upsert()`.
|
|
157
|
+
|
|
158
|
+
`SelectionCompiler` already understood `__typename` as a synthetic scalar field (line 164 of `selection.compiler.ts`) — for concrete types it emits the constant string (`__typename: 'Book'`), for unions/interfaces it emits a `head([__label IN labels(n) WHERE __label IN [...]])` expression. The fix is purely a default-selection change; no compiler logic changed.
|
|
159
|
+
|
|
160
|
+
### What this DOES NOT change
|
|
161
|
+
|
|
162
|
+
- **Explicit `select` / `selectionSet`** — callers who specify their own projection get exactly what they ask for. We do NOT silently inject `__typename` into a user-supplied selection. If you want it, ask for it explicitly: `select: { __typename: true, id: true }`.
|
|
163
|
+
- **InterfaceModel** — already emitted `__typename` via CASE-per-label (resolved from `labels(n)`). Unchanged.
|
|
164
|
+
- **Abstract relationship targets** — `Model.find({ select: { authors: { ... } } })` where `authors` points at a union/interface still auto-emits `__typename` in the nested projection, same as pre-1.8.4.
|
|
165
|
+
- **`@cypher` field** projections, `aggregate()`, `count()` — all unchanged.
|
|
166
|
+
|
|
167
|
+
### Behaviour change risks
|
|
168
|
+
|
|
169
|
+
Two edge cases for consumers to be aware of:
|
|
170
|
+
|
|
171
|
+
1. **Wire size** — every entity result now includes a `__typename` field. For a 50-row result this is ~50 × ~13 bytes (`__typename: 'Book'`) = ~650 extra bytes. Negligible for almost all callers.
|
|
172
|
+
2. **Schema-vs-store divergence** — pre-1.8.4 mutations without selection returned the raw Neo4j Node, including any properties stored on the node that are NOT in the GraphQL schema (e.g., legacy fields removed from the SDL but still present in the database). Post-1.8.4 the projection includes only schema-declared fields plus `__typename`. If your code relied on out-of-schema properties leaking through mutation responses, you must either (a) add them to the schema or (b) supply an explicit `selectionSet` that mentions them. Reads via `find()` already had this constraint pre-1.8.4 and behaved identically.
|
|
173
|
+
|
|
174
|
+
### Tests
|
|
175
|
+
|
|
176
|
+
- New file `tests/typename-auto-emit.spec.ts` — 11 regression tests covering `find()`, `findFirst()`, `findUnique()`, `create()`, `update()`, `upsert()`, explicit-select preservation, and unchanged InterfaceModel behaviour.
|
|
177
|
+
- One existing test in `tests/policy/model-mutation.spec.ts:305` was updated — it asserted "policy bypass means cypher must NOT contain `ownerId`" via a bare substring match. With the new projection emitting `.\`ownerId\``, the test now asserts the policy's WHERE-clause shape (`n.\`ownerId\` = $...`) is absent, which was the test's actual intent.
|
|
178
|
+
- Test count: **1370 → 1381** (+11). All pass.
|
|
179
|
+
|
|
180
|
+
### Compatibility
|
|
181
|
+
|
|
182
|
+
Public API surface unchanged. The Cypher emit changes for `find()`-family and mutation calls that did NOT pass an explicit `select` / `selectionSet`. Result shape gains a single `__typename` field; existing fields remain. This is the third backwards-compat fix in the v1.8.x series (after v1.8.1 connection-shape WHERE in nested disconnect/connect and v1.8.2 NLS permissive abstain default-deny).
|
|
183
|
+
|
|
184
|
+
---
|
|
185
|
+
|
|
3
186
|
## 1.8.3 (2026-05-06)
|
|
4
187
|
|
|
5
188
|
> **Bugfix.** `ogm.model('SomeInterface')` no longer silently returns an `InterfaceModel` — it now throws an `OGMError` immediately, naming the correct API. Pre-1.8.3 this returned an `InterfaceModel` cast to `any`, and the first mutation call (`.create()`, `.update()`, `.delete()`, `.upsert()`, `.setLabels()`) failed with `TypeError: this.create is not a function`.
|
|
@@ -70,6 +70,21 @@ export declare class WhereCompiler {
|
|
|
70
70
|
* to guarantee no collision with `param0..N`.
|
|
71
71
|
*/
|
|
72
72
|
private compilePolicyClause;
|
|
73
|
+
/**
|
|
74
|
+
* Build a `PolicyContextBundle` for a target type when crossing a
|
|
75
|
+
* node-type boundary inside a relationship traversal (`_SOME` /
|
|
76
|
+
* `_NONE` / `_ALL` / `_SINGLE` / connection-where `node`). Mirrors the
|
|
77
|
+
* canonical pattern at `selection.compiler.ts:826-847`.
|
|
78
|
+
*
|
|
79
|
+
* Returns `undefined` when:
|
|
80
|
+
* - the input `policyContext` is `undefined` (caller has no policy state), OR
|
|
81
|
+
* - `resolveForType(typeName, 'read')` returns `null` (no policy
|
|
82
|
+
* registered for the target type).
|
|
83
|
+
*
|
|
84
|
+
* The synthesized bundle reuses the same `resolveForType` so further
|
|
85
|
+
* nesting (target → target's relationship → ...) keeps cascading.
|
|
86
|
+
*/
|
|
87
|
+
private buildTargetBundle;
|
|
73
88
|
private compileConditions;
|
|
74
89
|
/**
|
|
75
90
|
* Resolve the Cypher reference for a where-clause field. For stored
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"where.compiler.d.ts","sourceRoot":"","sources":["../../../src/compilers/where.compiler.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,iBAAiB,CAAC;AAC3D,OAAO,EACL,cAAc,EAGd,cAAc,EACf,MAAM,iBAAiB,CAAC;AAezB,MAAM,WAAW,WAAW;IAC1B,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAChC;;;;;;;;;;OAUG;IACH,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAC;CACrB;AAqDD,KAAK,cAAc,GAAG,MAAM,CAAC;AA4B7B,MAAM,WAAW,oBAAoB;IACnC,mFAAmF;IACnF,iBAAiB,CAAC,EAAE,GAAG,CAAC,cAAc,CAAC,CAAC;IACxC;;;;;;OAMG;IACH,WAAW,CAAC,EAAE,OAAO,CAAC;CACvB;AAED;;GAEG;AACH,qBAAa,aAAa;IAKtB,OAAO,CAAC,MAAM;IAJhB,OAAO,CAAC,iBAAiB,CAAsB;IAC/C,OAAO,CAAC,WAAW,CAAU;gBAGnB,MAAM,EAAE,cAAc,EAC9B,OAAO,CAAC,EAAE,oBAAoB;IAMhC,OAAO,CACL,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,GAAG,IAAI,EACjD,OAAO,EAAE,MAAM,EACf,OAAO,EAAE,cAAc,EACvB,YAAY,GAAE;QAAE,KAAK,EAAE,MAAM,CAAA;KAAiB,EAC9C,OAAO,CAAC,EAAE;QACR;;;;;WAKG;QACH,YAAY,CAAC,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;QACrC;;;;;;;;;;WAUG;QACH,aAAa,CAAC,EAAE,mBAAmB,CAAC;KACrC,GACA,WAAW;
|
|
1
|
+
{"version":3,"file":"where.compiler.d.ts","sourceRoot":"","sources":["../../../src/compilers/where.compiler.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,iBAAiB,CAAC;AAC3D,OAAO,EACL,cAAc,EAGd,cAAc,EACf,MAAM,iBAAiB,CAAC;AAezB,MAAM,WAAW,WAAW;IAC1B,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAChC;;;;;;;;;;OAUG;IACH,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAC;CACrB;AAqDD,KAAK,cAAc,GAAG,MAAM,CAAC;AA4B7B,MAAM,WAAW,oBAAoB;IACnC,mFAAmF;IACnF,iBAAiB,CAAC,EAAE,GAAG,CAAC,cAAc,CAAC,CAAC;IACxC;;;;;;OAMG;IACH,WAAW,CAAC,EAAE,OAAO,CAAC;CACvB;AAED;;GAEG;AACH,qBAAa,aAAa;IAKtB,OAAO,CAAC,MAAM;IAJhB,OAAO,CAAC,iBAAiB,CAAsB;IAC/C,OAAO,CAAC,WAAW,CAAU;gBAGnB,MAAM,EAAE,cAAc,EAC9B,OAAO,CAAC,EAAE,oBAAoB;IAMhC,OAAO,CACL,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,GAAG,IAAI,EACjD,OAAO,EAAE,MAAM,EACf,OAAO,EAAE,cAAc,EACvB,YAAY,GAAE;QAAE,KAAK,EAAE,MAAM,CAAA;KAAiB,EAC9C,OAAO,CAAC,EAAE;QACR;;;;;WAKG;QACH,YAAY,CAAC,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;QACrC;;;;;;;;;;WAUG;QACH,aAAa,CAAC,EAAE,mBAAmB,CAAC;KACrC,GACA,WAAW;IAoDd;;;;;;;;OAQG;IACH,OAAO,CAAC,mBAAmB;IA6K3B;;;;;;;;;;;;;OAaG;IACH,OAAO,CAAC,iBAAiB;IAgBzB,OAAO,CAAC,iBAAiB;IA2MzB;;;;;OAKG;IACH,OAAO,CAAC,eAAe;IAavB,OAAO,CAAC,oBAAoB;IA0I5B;;;;;;;;;;;OAWG;IACH,OAAO,CAAC,2BAA2B;IAiKnC,OAAO,CAAC,sBAAsB;IAsK9B;;;;;OAKG;IACH,OAAO,CAAC,wBAAwB;IA8GhC,OAAO,CAAC,qBAAqB;IA8B7B;;;;;;OAMG;IACH,OAAO,CAAC,sBAAsB;CAmF/B"}
|
|
@@ -82,7 +82,7 @@ class WhereCompiler {
|
|
|
82
82
|
// their own scopes and stitch their preludes into the EXISTS body inline.
|
|
83
83
|
const scope = new cypher_field_projection_1.CypherFieldScope(nodeVar, options?.preserveVars ?? [], '__where');
|
|
84
84
|
const userBody = hasUserWhere
|
|
85
|
-
? this.compileConditions(where, nodeVar, nodeDef, paramCounter, 0, scope)
|
|
85
|
+
? this.compileConditions(where, nodeVar, nodeDef, paramCounter, 0, scope, undefined, policyContext)
|
|
86
86
|
: { cypher: '', params: {} };
|
|
87
87
|
let cypher = userBody.cypher;
|
|
88
88
|
const params = { ...userBody.params };
|
|
@@ -236,6 +236,34 @@ class WhereCompiler {
|
|
|
236
236
|
? permClause
|
|
237
237
|
: `(${permClause} AND ${restClause})`;
|
|
238
238
|
}
|
|
239
|
+
/**
|
|
240
|
+
* Build a `PolicyContextBundle` for a target type when crossing a
|
|
241
|
+
* node-type boundary inside a relationship traversal (`_SOME` /
|
|
242
|
+
* `_NONE` / `_ALL` / `_SINGLE` / connection-where `node`). Mirrors the
|
|
243
|
+
* canonical pattern at `selection.compiler.ts:826-847`.
|
|
244
|
+
*
|
|
245
|
+
* Returns `undefined` when:
|
|
246
|
+
* - the input `policyContext` is `undefined` (caller has no policy state), OR
|
|
247
|
+
* - `resolveForType(typeName, 'read')` returns `null` (no policy
|
|
248
|
+
* registered for the target type).
|
|
249
|
+
*
|
|
250
|
+
* The synthesized bundle reuses the same `resolveForType` so further
|
|
251
|
+
* nesting (target → target's relationship → ...) keeps cascading.
|
|
252
|
+
*/
|
|
253
|
+
buildTargetBundle(typeName, policyContext) {
|
|
254
|
+
if (!policyContext)
|
|
255
|
+
return undefined;
|
|
256
|
+
const targetPolicy = policyContext.resolveForType(typeName, 'read');
|
|
257
|
+
if (!targetPolicy)
|
|
258
|
+
return undefined;
|
|
259
|
+
return {
|
|
260
|
+
ctx: policyContext.ctx,
|
|
261
|
+
operation: 'read',
|
|
262
|
+
resolved: targetPolicy,
|
|
263
|
+
resolveForType: policyContext.resolveForType,
|
|
264
|
+
defaults: policyContext.defaults,
|
|
265
|
+
};
|
|
266
|
+
}
|
|
239
267
|
compileConditions(where, nodeVar, nodeDef, counter, depth, scope,
|
|
240
268
|
/**
|
|
241
269
|
* Optional shared params accumulator. When provided, every leaf
|
|
@@ -247,7 +275,18 @@ class WhereCompiler {
|
|
|
247
275
|
* deep recursions write into a single owner object. Public callers
|
|
248
276
|
* keep the old contract (no arg → fresh Map allocated locally).
|
|
249
277
|
*/
|
|
250
|
-
paramsTarget
|
|
278
|
+
paramsTarget,
|
|
279
|
+
/**
|
|
280
|
+
* Policy context to thread through relationship / connection
|
|
281
|
+
* recursions so target-type `'read'` policies AND-stitch into the
|
|
282
|
+
* EXISTS body when crossing a node-type boundary. NOT used for
|
|
283
|
+
* leaf scalar predicates or for AND/OR/NOT logical recursions
|
|
284
|
+
* within the same node-type scope (those simply forward through).
|
|
285
|
+
*
|
|
286
|
+
* v1.8.5 — fixes CRIT-3 (target-policy bypass via `_SOME`/`_NONE`/
|
|
287
|
+
* `_ALL`/`_SINGLE` and `Connection_*` user-where filters).
|
|
288
|
+
*/
|
|
289
|
+
policyContext) {
|
|
251
290
|
if (depth > MAX_DEPTH)
|
|
252
291
|
throw new errors_1.OGMError(`WHERE clause nesting depth exceeds maximum (${MAX_DEPTH})`);
|
|
253
292
|
if (where == null)
|
|
@@ -277,7 +316,7 @@ class WhereCompiler {
|
|
|
277
316
|
// method with paramsTarget undefined at intermediate levels.
|
|
278
317
|
const subClauses = [];
|
|
279
318
|
for (const item of items) {
|
|
280
|
-
const sub = this.compileConditions(item, nodeVar, nodeDef, counter, depth + 1, scope, params);
|
|
319
|
+
const sub = this.compileConditions(item, nodeVar, nodeDef, counter, depth + 1, scope, params, policyContext);
|
|
281
320
|
if (sub.cypher)
|
|
282
321
|
subClauses.push(sub.cypher);
|
|
283
322
|
}
|
|
@@ -291,7 +330,7 @@ class WhereCompiler {
|
|
|
291
330
|
continue;
|
|
292
331
|
if (!(0, validation_1.isPlainObject)(value))
|
|
293
332
|
throw new errors_1.OGMError(`NOT operator requires an object value.`);
|
|
294
|
-
const sub = this.compileConditions(value, nodeVar, nodeDef, counter, depth + 1, scope, params);
|
|
333
|
+
const sub = this.compileConditions(value, nodeVar, nodeDef, counter, depth + 1, scope, params, policyContext);
|
|
295
334
|
if (sub.cypher)
|
|
296
335
|
clauses.push(`NOT (${sub.cypher})`);
|
|
297
336
|
continue;
|
|
@@ -330,14 +369,14 @@ class WhereCompiler {
|
|
|
330
369
|
continue;
|
|
331
370
|
}
|
|
332
371
|
// Connection operators (e.g. hasStatusConnection_SOME)
|
|
333
|
-
const connResult = this.tryCompileConnection(key, value, nodeVar, nodeDef, counter, depth);
|
|
372
|
+
const connResult = this.tryCompileConnection(key, value, nodeVar, nodeDef, counter, depth, policyContext);
|
|
334
373
|
if (connResult) {
|
|
335
374
|
clauses.push(connResult.cypher);
|
|
336
375
|
(0, validation_1.mergeParams)(params, connResult.params);
|
|
337
376
|
continue;
|
|
338
377
|
}
|
|
339
378
|
// Relationship operators (e.g. drugs_SOME, drugs_NONE)
|
|
340
|
-
const relResult = this.tryCompileRelationship(key, value, nodeVar, nodeDef, counter, depth);
|
|
379
|
+
const relResult = this.tryCompileRelationship(key, value, nodeVar, nodeDef, counter, depth, policyContext);
|
|
341
380
|
if (relResult) {
|
|
342
381
|
clauses.push(relResult.cypher);
|
|
343
382
|
(0, validation_1.mergeParams)(params, relResult.params);
|
|
@@ -377,7 +416,15 @@ class WhereCompiler {
|
|
|
377
416
|
return scope.register(fieldName, propDef);
|
|
378
417
|
return `${nodeVar}.${(0, validation_1.escapeIdentifier)(fieldName)}`;
|
|
379
418
|
}
|
|
380
|
-
tryCompileConnection(key, value, nodeVar, nodeDef, counter, depth
|
|
419
|
+
tryCompileConnection(key, value, nodeVar, nodeDef, counter, depth,
|
|
420
|
+
/**
|
|
421
|
+
* Policy context for target-type enforcement inside the connection's
|
|
422
|
+
* `node` filter. v1.8.5 — fixes CRIT-3: the target type's `'read'`
|
|
423
|
+
* policy MUST AND-stitch into the EXISTS body when crossing a node-
|
|
424
|
+
* type boundary via a connection-where filter. Edges have no NLS by
|
|
425
|
+
* design, so this never threads into `compileEdgeConditions`.
|
|
426
|
+
*/
|
|
427
|
+
policyContext) {
|
|
381
428
|
let connSuffix = null;
|
|
382
429
|
let fieldName = '';
|
|
383
430
|
for (const suffix of CONNECTION_SUFFIXES)
|
|
@@ -419,16 +466,24 @@ class WhereCompiler {
|
|
|
419
466
|
const propsDef = relDef.properties
|
|
420
467
|
? (this.schema.relationshipProperties.get(relDef.properties) ?? null)
|
|
421
468
|
: null;
|
|
422
|
-
const inner = this.compileConnectionWhereInput(value, relVar, edgeVar, targetNodeDef, propsDef, nodeScope, edgeScope, counter, depth);
|
|
469
|
+
const inner = this.compileConnectionWhereInput(value, relVar, edgeVar, targetNodeDef, propsDef, nodeScope, edgeScope, counter, depth, policyContext);
|
|
423
470
|
const innerClauses = inner.cypher ? [inner.cypher] : [];
|
|
424
471
|
const innerParams = inner.params;
|
|
425
472
|
// Stitch the inner preludes (CALL { ... } + WITH ...) INSIDE the
|
|
426
473
|
// EXISTS body, between the MATCH pattern and the inner WHERE.
|
|
474
|
+
// - `nodeScope.emit()` — preludes from `@cypher` fields registered
|
|
475
|
+
// directly on the connection-where input's edge-side scope.
|
|
476
|
+
// - `edgeScope.emit()` — same, but edge-side.
|
|
477
|
+
// - `inner.preludes` (v1.8.5) — preludes returned by the recursive
|
|
478
|
+
// `compile()` calls on the node side, which now own their own
|
|
479
|
+
// scopes.
|
|
427
480
|
const innerPreludes = [];
|
|
428
481
|
if (nodeScope.hasAny())
|
|
429
482
|
innerPreludes.push(...nodeScope.emit());
|
|
430
483
|
if (edgeScope.hasAny())
|
|
431
484
|
innerPreludes.push(...edgeScope.emit());
|
|
485
|
+
if (inner.preludes && inner.preludes.length > 0)
|
|
486
|
+
innerPreludes.push(...inner.preludes);
|
|
432
487
|
const preludeFragment = innerPreludes.length
|
|
433
488
|
? ` ${innerPreludes.join(' ')}`
|
|
434
489
|
: '';
|
|
@@ -477,14 +532,43 @@ class WhereCompiler {
|
|
|
477
532
|
* constrain the same `(relVar, edgeVar)` pair. This matches the codegen
|
|
478
533
|
* shape declared in `connection-emitter.ts`.
|
|
479
534
|
*/
|
|
480
|
-
compileConnectionWhereInput(value, relVar, edgeVar, targetNodeDef, propsDef, nodeScope, edgeScope, counter, depth
|
|
535
|
+
compileConnectionWhereInput(value, relVar, edgeVar, targetNodeDef, propsDef, nodeScope, edgeScope, counter, depth,
|
|
536
|
+
/**
|
|
537
|
+
* Policy context to thread into target-side `node` / `node_NOT`
|
|
538
|
+
* filters AND through nested AND/OR/NOT logical groups. Edge-side
|
|
539
|
+
* compilation never receives this — edges have no NLS by design.
|
|
540
|
+
* v1.8.5 — fixes CRIT-3.
|
|
541
|
+
*/
|
|
542
|
+
policyContext) {
|
|
481
543
|
const innerClauses = [];
|
|
482
544
|
const innerParams = {};
|
|
545
|
+
/**
|
|
546
|
+
* Preludes returned by recursive `compile()` calls on the node side.
|
|
547
|
+
* Pre-1.8.5 the node-side scope was shared via `nodeScope`; now the
|
|
548
|
+
* inner `compile()` owns its own scope and we hoist its preludes back
|
|
549
|
+
* out so the caller (`tryCompileConnection`) can stitch them between
|
|
550
|
+
* `MATCH ${pattern}` and `WHERE ...` inside the EXISTS body.
|
|
551
|
+
*/
|
|
552
|
+
const extraPreludes = [];
|
|
553
|
+
// Resolve the target type's `'read'` policy ONCE per connection-where
|
|
554
|
+
// input. The same bundle is reused across every `node` / `node_NOT`
|
|
555
|
+
// key we encounter at this level (and across nested AND/OR/NOT
|
|
556
|
+
// groups via direct policyContext threading).
|
|
557
|
+
const targetBundle = this.buildTargetBundle(targetNodeDef.typeName, policyContext);
|
|
483
558
|
for (const [key, val] of Object.entries(value)) {
|
|
484
559
|
if (val === undefined)
|
|
485
560
|
continue;
|
|
486
561
|
if (key === 'node' || key === 'node_NOT') {
|
|
487
|
-
|
|
562
|
+
// Re-enter the top-level `compile()` so that the target type's
|
|
563
|
+
// `'read'` policy AND-stitches into the same body that the user
|
|
564
|
+
// filter produces. `compile()` manages its own inner scope and
|
|
565
|
+
// returns any preludes via `WhereResult.preludes`; we hoist
|
|
566
|
+
// those into `extraPreludes` so the surrounding `tryCompileConnection`
|
|
567
|
+
// can stitch them inside the EXISTS body alongside the existing
|
|
568
|
+
// `nodeScope.emit()` content.
|
|
569
|
+
const nodeResult = this.compile(val, relVar, targetNodeDef, counter, targetBundle ? { policyContext: targetBundle } : undefined);
|
|
570
|
+
if (nodeResult.preludes && nodeResult.preludes.length > 0)
|
|
571
|
+
extraPreludes.push(...nodeResult.preludes);
|
|
488
572
|
if (nodeResult.cypher) {
|
|
489
573
|
innerClauses.push(key === 'node_NOT'
|
|
490
574
|
? `NOT (${nodeResult.cypher})`
|
|
@@ -508,34 +592,50 @@ class WhereCompiler {
|
|
|
508
592
|
if (items.length > MAX_LOGICAL_ARRAY_LENGTH)
|
|
509
593
|
throw new errors_1.OGMError(`${key} array length ${items.length} exceeds the maximum of ${MAX_LOGICAL_ARRAY_LENGTH}. ` +
|
|
510
594
|
`Restructure the predicate instead of passing a large logical array inside a connection where.`);
|
|
511
|
-
|
|
595
|
+
// Thread `policyContext` through so deeply-nested connection-
|
|
596
|
+
// where keeps target-policy enforcement.
|
|
597
|
+
const subResults = items.map((item) => this.compileConnectionWhereInput(item, relVar, edgeVar, targetNodeDef, propsDef, nodeScope, edgeScope, counter, depth + 1, policyContext));
|
|
512
598
|
const subClauses = subResults.map((r) => r.cypher).filter(Boolean);
|
|
513
599
|
if (subClauses.length > 0) {
|
|
514
600
|
innerClauses.push(`(${subClauses.join(` ${key} `)})`);
|
|
515
|
-
for (const r of subResults)
|
|
601
|
+
for (const r of subResults) {
|
|
516
602
|
(0, validation_1.mergeParams)(innerParams, r.params);
|
|
603
|
+
if (r.preludes && r.preludes.length > 0)
|
|
604
|
+
extraPreludes.push(...r.preludes);
|
|
605
|
+
}
|
|
517
606
|
}
|
|
518
607
|
continue;
|
|
519
608
|
}
|
|
520
609
|
if (key === 'NOT') {
|
|
521
610
|
if (!(0, validation_1.isPlainObject)(val))
|
|
522
611
|
throw new errors_1.OGMError(`NOT operator inside a connection where requires an object value.`);
|
|
523
|
-
const sub = this.compileConnectionWhereInput(val, relVar, edgeVar, targetNodeDef, propsDef, nodeScope, edgeScope, counter, depth + 1);
|
|
612
|
+
const sub = this.compileConnectionWhereInput(val, relVar, edgeVar, targetNodeDef, propsDef, nodeScope, edgeScope, counter, depth + 1, policyContext);
|
|
524
613
|
if (sub.cypher) {
|
|
525
614
|
innerClauses.push(`NOT (${sub.cypher})`);
|
|
526
615
|
(0, validation_1.mergeParams)(innerParams, sub.params);
|
|
527
616
|
}
|
|
617
|
+
if (sub.preludes && sub.preludes.length > 0)
|
|
618
|
+
extraPreludes.push(...sub.preludes);
|
|
528
619
|
continue;
|
|
529
620
|
}
|
|
530
621
|
// Unknown key — silently ignore to remain forward-compatible with
|
|
531
622
|
// future codegen additions.
|
|
532
623
|
}
|
|
533
|
-
|
|
624
|
+
const result = {
|
|
534
625
|
cypher: innerClauses.join(' AND '),
|
|
535
626
|
params: innerParams,
|
|
536
627
|
};
|
|
628
|
+
if (extraPreludes.length > 0)
|
|
629
|
+
result.preludes = extraPreludes;
|
|
630
|
+
return result;
|
|
537
631
|
}
|
|
538
|
-
tryCompileRelationship(key, value, nodeVar, nodeDef, counter, depth
|
|
632
|
+
tryCompileRelationship(key, value, nodeVar, nodeDef, counter, depth,
|
|
633
|
+
/**
|
|
634
|
+
* Policy context to thread into the target type when crossing the
|
|
635
|
+
* relationship boundary. v1.8.5 — fixes CRIT-3 (target-policy bypass
|
|
636
|
+
* via `_SOME`/`_NONE`/`_ALL`/`_SINGLE` user-where filters).
|
|
637
|
+
*/
|
|
638
|
+
policyContext) {
|
|
539
639
|
let suffix = null;
|
|
540
640
|
let fieldName = key;
|
|
541
641
|
for (const s of RELATIONSHIP_SUFFIXES)
|
|
@@ -562,7 +662,7 @@ class WhereCompiler {
|
|
|
562
662
|
const isUnionTarget = !this.schema.nodes.has(relDef.target) &&
|
|
563
663
|
this.schema.unions?.has(relDef.target);
|
|
564
664
|
if (isUnionTarget)
|
|
565
|
-
return this.compileUnionRelationship(suffix, value, nodeVar, relDef, counter, depth);
|
|
665
|
+
return this.compileUnionRelationship(suffix, value, nodeVar, relDef, counter, depth, policyContext);
|
|
566
666
|
const targetNodeDef = (0, utils_1.resolveTargetDef)(relDef.target, this.schema);
|
|
567
667
|
if (!targetNodeDef)
|
|
568
668
|
throw new errors_1.OGMError(`Invalid relationship filter: target type for "${fieldName}" is not defined in the schema.`);
|
|
@@ -579,12 +679,19 @@ class WhereCompiler {
|
|
|
579
679
|
// EXISTS never matches concrete-typed nodes.
|
|
580
680
|
schema: this.schema,
|
|
581
681
|
});
|
|
582
|
-
//
|
|
583
|
-
//
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
const
|
|
587
|
-
|
|
682
|
+
// v1.8.5 — re-resolve the TARGET type's `'read'` policy when crossing
|
|
683
|
+
// the relationship boundary, mirroring `selection.compiler.ts:826-847`.
|
|
684
|
+
// Pre-1.8.5 this call site invoked `compileConditions` directly,
|
|
685
|
+
// skipping policy AND-stitching for the target — CRIT-3.
|
|
686
|
+
const targetBundle = this.buildTargetBundle(targetNodeDef.typeName, policyContext);
|
|
687
|
+
// Re-enter the top-level `compile()` so the target's policy clause
|
|
688
|
+
// (when present) AND-stitches into the same body that the user filter
|
|
689
|
+
// produces. `compile()` manages its own inner scope and returns any
|
|
690
|
+
// preludes via `WhereResult.preludes` for stitching inside EXISTS.
|
|
691
|
+
const innerResult = this.compile(value, relVar, targetNodeDef, counter, targetBundle ? { policyContext: targetBundle } : undefined);
|
|
692
|
+
const innerPreludes = innerResult.preludes ?? [];
|
|
693
|
+
const innerPreludeFragment = innerPreludes.length
|
|
694
|
+
? ` ${innerPreludes.join(' ')}`
|
|
588
695
|
: '';
|
|
589
696
|
const whereClause = innerResult.cypher
|
|
590
697
|
? ` WHERE ${innerResult.cypher}`
|
|
@@ -617,9 +724,17 @@ class WhereCompiler {
|
|
|
617
724
|
case '_SINGLE': {
|
|
618
725
|
// Exactly one relationship satisfies. Pattern comprehensions cannot
|
|
619
726
|
// contain CALL { ... } subqueries, so reject `@cypher` fields here.
|
|
620
|
-
|
|
727
|
+
// v1.8.5: a `@cypher`-projecting policy on the TARGET type also
|
|
728
|
+
// produces preludes — distinguish that case so the error message
|
|
729
|
+
// points at the right cause.
|
|
730
|
+
if (innerPreludes.length > 0) {
|
|
731
|
+
if (targetBundle)
|
|
732
|
+
throw new errors_1.OGMError(`Policy on "${targetNodeDef.typeName}" requires @cypher field projection, ` +
|
|
733
|
+
`which is not supported inside _SINGLE quantifiers. Refactor the policy ` +
|
|
734
|
+
`to use stored properties, or refactor the predicate to _SOME + _NONE.`);
|
|
621
735
|
throw new errors_1.OGMError(`_SINGLE quantifiers do not support filtering by @cypher fields. ` +
|
|
622
736
|
`Refactor the predicate to use _SOME + _NONE, or remove the @cypher reference.`);
|
|
737
|
+
}
|
|
623
738
|
// Pre-1.7.5 we incremented `counter.count` here a second time
|
|
624
739
|
// even though no new variable was bound — this branch already
|
|
625
740
|
// claimed `r${counter.count}` at the top of the function and
|
|
@@ -641,10 +756,20 @@ class WhereCompiler {
|
|
|
641
756
|
* Each member generates a separate EXISTS pattern using the member's labels.
|
|
642
757
|
* Multiple members are combined with OR.
|
|
643
758
|
*/
|
|
644
|
-
compileUnionRelationship(suffix, value, nodeVar, relDef, counter, depth
|
|
759
|
+
compileUnionRelationship(suffix, value, nodeVar, relDef, counter, depth,
|
|
760
|
+
/**
|
|
761
|
+
* Policy context to thread per-member: each union member receives its
|
|
762
|
+
* OWN target-bundle resolved via `policyContext.resolveForType(memberKey,
|
|
763
|
+
* 'read')`. Members without a registered policy compile as before (no
|
|
764
|
+
* extra clause). v1.8.5 — fixes CRIT-3 in the union path.
|
|
765
|
+
*/
|
|
766
|
+
policyContext) {
|
|
645
767
|
const unionMembers = this.schema.unions.get(relDef.target);
|
|
646
768
|
const memberClauses = [];
|
|
647
769
|
const allParams = {};
|
|
770
|
+
// depth-only suppression for unused-when-no-policy lint passes; keep
|
|
771
|
+
// for symmetry with `tryCompileRelationship`.
|
|
772
|
+
void depth;
|
|
648
773
|
for (const [memberKey, memberValue] of Object.entries(value)) {
|
|
649
774
|
if (!unionMembers.includes(memberKey))
|
|
650
775
|
throw new errors_1.OGMError(`Invalid union member key "${memberKey}" in WHERE filter. Expected one of: ${unionMembers.join(', ')}.`);
|
|
@@ -660,19 +785,25 @@ class WhereCompiler {
|
|
|
660
785
|
targetVar: relVar,
|
|
661
786
|
targetLabelRaw: labelStr,
|
|
662
787
|
});
|
|
663
|
-
//
|
|
788
|
+
// Per-member target bundle. `null` member-policy → undefined bundle
|
|
789
|
+
// → no policy stitching for this branch (original behavior).
|
|
790
|
+
const targetBundle = this.buildTargetBundle(memberDef.typeName, policyContext);
|
|
791
|
+
// Compile inner WHERE conditions for this member (if any properties
|
|
792
|
+
// specified). When the member has its own `'read'` policy, we still
|
|
793
|
+
// re-enter `compile()` so the policy AND-stitches into the EXISTS
|
|
794
|
+
// body even when `memberValue` is empty/null.
|
|
664
795
|
const memberWhere = memberValue;
|
|
796
|
+
const hasUserMemberWhere = memberWhere && Object.keys(memberWhere).length > 0;
|
|
665
797
|
let whereClause = '';
|
|
666
798
|
let preludeFragment = '';
|
|
667
|
-
if (
|
|
668
|
-
const
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
(0, validation_1.mergeParams)(allParams, innerResult.params);
|
|
799
|
+
if (hasUserMemberWhere || targetBundle) {
|
|
800
|
+
const memberCompile = this.compile(hasUserMemberWhere ? memberWhere : undefined, relVar, memberDef, counter, targetBundle ? { policyContext: targetBundle } : undefined);
|
|
801
|
+
if (memberCompile.cypher) {
|
|
802
|
+
whereClause = ` WHERE ${memberCompile.cypher}`;
|
|
803
|
+
(0, validation_1.mergeParams)(allParams, memberCompile.params);
|
|
673
804
|
}
|
|
674
|
-
if (
|
|
675
|
-
preludeFragment = ` ${
|
|
805
|
+
if (memberCompile.preludes && memberCompile.preludes.length > 0)
|
|
806
|
+
preludeFragment = ` ${memberCompile.preludes.join(' ')}`;
|
|
676
807
|
}
|
|
677
808
|
memberClauses.push(`EXISTS { MATCH ${pattern}${preludeFragment}${whereClause} }`);
|
|
678
809
|
}
|