grafeo-ogm 2.2.0 → 2.3.0

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.
Files changed (59) hide show
  1. package/CHANGELOG.md +83 -0
  2. package/README.md +49 -1
  3. package/dist/cjs/compilers/mutation.compiler.d.ts +94 -31
  4. package/dist/cjs/compilers/mutation.compiler.d.ts.map +1 -1
  5. package/dist/cjs/compilers/mutation.compiler.js +381 -343
  6. package/dist/cjs/compilers/mutation.compiler.js.map +1 -1
  7. package/dist/cjs/compilers/selection.compiler.d.ts +1 -1
  8. package/dist/cjs/compilers/selection.compiler.d.ts.map +1 -1
  9. package/dist/cjs/compilers/selection.compiler.js +33 -39
  10. package/dist/cjs/compilers/selection.compiler.js.map +1 -1
  11. package/dist/cjs/compilers/where.compiler.d.ts +124 -1
  12. package/dist/cjs/compilers/where.compiler.d.ts.map +1 -1
  13. package/dist/cjs/compilers/where.compiler.js +371 -102
  14. package/dist/cjs/compilers/where.compiler.js.map +1 -1
  15. package/dist/cjs/interface-model.d.ts +20 -10
  16. package/dist/cjs/interface-model.d.ts.map +1 -1
  17. package/dist/cjs/interface-model.js +47 -104
  18. package/dist/cjs/interface-model.js.map +1 -1
  19. package/dist/cjs/model.d.ts +4 -29
  20. package/dist/cjs/model.d.ts.map +1 -1
  21. package/dist/cjs/model.js +46 -129
  22. package/dist/cjs/model.js.map +1 -1
  23. package/dist/cjs/policy/nested-writes.d.ts +35 -0
  24. package/dist/cjs/policy/nested-writes.d.ts.map +1 -0
  25. package/dist/cjs/policy/nested-writes.js +199 -0
  26. package/dist/cjs/policy/nested-writes.js.map +1 -0
  27. package/dist/cjs/policy/types.d.ts +18 -0
  28. package/dist/cjs/policy/types.d.ts.map +1 -1
  29. package/dist/cjs/policy/types.js +24 -0
  30. package/dist/cjs/policy/types.js.map +1 -1
  31. package/dist/esm/compilers/mutation.compiler.d.ts +94 -31
  32. package/dist/esm/compilers/mutation.compiler.d.ts.map +1 -1
  33. package/dist/esm/compilers/mutation.compiler.js +381 -344
  34. package/dist/esm/compilers/mutation.compiler.js.map +1 -1
  35. package/dist/esm/compilers/selection.compiler.d.ts +1 -1
  36. package/dist/esm/compilers/selection.compiler.d.ts.map +1 -1
  37. package/dist/esm/compilers/selection.compiler.js +33 -39
  38. package/dist/esm/compilers/selection.compiler.js.map +1 -1
  39. package/dist/esm/compilers/where.compiler.d.ts +124 -1
  40. package/dist/esm/compilers/where.compiler.d.ts.map +1 -1
  41. package/dist/esm/compilers/where.compiler.js +371 -104
  42. package/dist/esm/compilers/where.compiler.js.map +1 -1
  43. package/dist/esm/interface-model.d.ts +20 -10
  44. package/dist/esm/interface-model.d.ts.map +1 -1
  45. package/dist/esm/interface-model.js +47 -104
  46. package/dist/esm/interface-model.js.map +1 -1
  47. package/dist/esm/model.d.ts +4 -29
  48. package/dist/esm/model.d.ts.map +1 -1
  49. package/dist/esm/model.js +47 -130
  50. package/dist/esm/model.js.map +1 -1
  51. package/dist/esm/policy/nested-writes.d.ts +35 -0
  52. package/dist/esm/policy/nested-writes.d.ts.map +1 -0
  53. package/dist/esm/policy/nested-writes.js +193 -0
  54. package/dist/esm/policy/nested-writes.js.map +1 -0
  55. package/dist/esm/policy/types.d.ts +18 -0
  56. package/dist/esm/policy/types.d.ts.map +1 -1
  57. package/dist/esm/policy/types.js +23 -0
  58. package/dist/esm/policy/types.js.map +1 -1
  59. package/package.json +1 -1
@@ -1,5 +1,5 @@
1
1
  import { OGMError } from '../errors';
2
- import { isReadRestrictive } from '../policy/types';
2
+ import { isReadRestrictive, NO_ROOT_POLICY } from '../policy/types';
3
3
  import { buildRelPattern, getTargetLabelString, resolveTargetDef, } from '../schema/utils';
4
4
  import { CypherFieldScope } from '../utils/cypher-field-projection';
5
5
  import { assertSafeIdentifier, assertSafeKey, assertSafeRegexPattern, escapeIdentifier, isPlainObject, mergeParams, } from '../utils/validation';
@@ -118,6 +118,104 @@ export class WhereCompiler {
118
118
  policy: split?.policy ?? null,
119
119
  };
120
120
  }
121
+ /**
122
+ * The policy clause guarding a node of a possibly ABSTRACT type for one
123
+ * operation (v2.3.0) — the single construction behind every target-
124
+ * policy consumer (nested selection, traversal filters, nested writes)
125
+ * and `InterfaceModel`'s root clause:
126
+ *
127
+ * - concrete type → its own composed clause for `op` (byte-identical
128
+ * to compiling it directly); `null` when it has no policy for `op`
129
+ * or an override fires.
130
+ * - interface / union →
131
+ * `(CASE WHEN v:M1 THEN <M1 clause> WHEN v:M2 THEN … ELSE false END)`
132
+ * over the concrete members, each branch being exactly that member's
133
+ * own clause (`resolveForType(M, op)` already folds in M's interface
134
+ * policies). A member without a policy for `op`, or whose override
135
+ * fires, is `true`; when EVERY member is `true` the result is `null`
136
+ * (unconstrained, like a concrete type without policies). `ELSE false`
137
+ * excludes a node carrying no known member label (defense in depth).
138
+ *
139
+ * Pre-2.3.0 abstract targets resolved the ABSTRACT type's name, so the
140
+ * implementers' own policies were never applied through relationships.
141
+ *
142
+ * `fallbackOp`: resolve it for a type with NO policy for `op` — the
143
+ * `aggregate` → `read` fallback `Model.aggregate` applies at the root.
144
+ *
145
+ * `preludes` (for `@cypher` fields referenced by a policy) are returned
146
+ * to the caller; nested contexts reject them.
147
+ *
148
+ * @internal
149
+ */
150
+ compileTargetPolicyClause(typeName, varName, op, policyContext, paramCounter, fallbackOp) {
151
+ const members = this.abstractMembers(typeName);
152
+ if (members === null)
153
+ return this.compileConcreteTargetClause(typeName, varName, op, policyContext, paramCounter, fallbackOp);
154
+ const params = {};
155
+ const preludes = [];
156
+ const branches = [];
157
+ let constrained = false;
158
+ for (const member of members) {
159
+ if (!this.schema.nodes.has(member))
160
+ continue;
161
+ const clause = this.compileConcreteTargetClause(member, varName, op, policyContext, paramCounter, fallbackOp);
162
+ mergeParams(params, clause.params);
163
+ // Each member compiles in its own `@cypher` scope; chaining two
164
+ // scopes would drop the first one's aliases at the second's `WITH`.
165
+ if (preludes.length > 0 && clause.preludes.length > 0)
166
+ throw new OGMError(`Policies on more than one member of "${typeName}" project @cypher ` +
167
+ `fields, which cannot be combined in one query. Refactor all but ` +
168
+ `one to stored properties.`);
169
+ preludes.push(...clause.preludes);
170
+ if (clause.cypher !== null)
171
+ constrained = true;
172
+ branches.push(`WHEN ${varName}:${escapeIdentifier(member)} THEN ${clause.cypher ?? 'true'}`);
173
+ }
174
+ if (!constrained)
175
+ return { cypher: null, params: {}, preludes: [] };
176
+ return {
177
+ cypher: `(CASE ${branches.join(' ')} ELSE false END)`,
178
+ params,
179
+ preludes,
180
+ };
181
+ }
182
+ compileConcreteTargetClause(typeName, varName, op, policyContext, paramCounter, fallbackOp) {
183
+ const nodeDef = this.schema.nodes.get(typeName);
184
+ let resolvedOp = op;
185
+ let resolved = policyContext.resolveForType(typeName, op);
186
+ if (!resolved && fallbackOp) {
187
+ resolvedOp = fallbackOp;
188
+ resolved = policyContext.resolveForType(typeName, fallbackOp);
189
+ }
190
+ if (!nodeDef || !resolved || resolved.overridden)
191
+ return { cypher: null, params: {}, preludes: [] };
192
+ const compiled = this.compile(undefined, varName, nodeDef, paramCounter, {
193
+ policyContext: {
194
+ ctx: policyContext.ctx,
195
+ operation: resolvedOp,
196
+ resolved,
197
+ resolveForType: policyContext.resolveForType,
198
+ defaults: policyContext.defaults,
199
+ },
200
+ });
201
+ return {
202
+ cypher: compiled.cypher || null,
203
+ params: compiled.params,
204
+ preludes: compiled.preludes ?? [],
205
+ };
206
+ }
207
+ /** Concrete members of an interface / union; `null` for any other type. */
208
+ abstractMembers(typeName) {
209
+ if (this.schema.nodes.has(typeName))
210
+ return null;
211
+ const iface = this.schema.interfaces?.get(typeName);
212
+ if (iface)
213
+ return [...iface.implementedBy];
214
+ const union = this.schema.unions?.get(typeName);
215
+ if (union)
216
+ return [...union];
217
+ return null;
218
+ }
121
219
  /**
122
220
  * Shared body of `compile()` / `compileForExplain()`: compiles the user
123
221
  * where, then the policy fragments, sharing one `paramCounter` and one
@@ -133,7 +231,9 @@ export class WhereCompiler {
133
231
  // Top-level scope — preludes here are returned to the caller for stitching
134
232
  // BEFORE the WHERE clause. Nested scopes (relationship quantifiers) build
135
233
  // their own scopes and stitch their preludes into the EXISTS body inline.
136
- const scope = new CypherFieldScope(nodeVar, options?.preserveVars ?? [], '__where');
234
+ const ownsScope = options?.scope === undefined;
235
+ const scope = options?.scope ??
236
+ new CypherFieldScope(nodeVar, options?.preserveVars ?? [], '__where');
137
237
  const userBody = hasUserWhere
138
238
  ? this.compileConditions(where, nodeVar, nodeDef, paramCounter, 0, scope, undefined, policyContext)
139
239
  : { cypher: '', params: {} };
@@ -146,7 +246,7 @@ export class WhereCompiler {
146
246
  return {
147
247
  userCypher: userBody.cypher,
148
248
  params,
149
- preludes: scope.hasAny() ? scope.emit() : undefined,
249
+ preludes: ownsScope && scope.hasAny() ? scope.emit() : undefined,
150
250
  policy,
151
251
  };
152
252
  }
@@ -264,9 +364,12 @@ export class WhereCompiler {
264
364
  buildTargetBundle(typeName, policyContext) {
265
365
  if (!policyContext)
266
366
  return undefined;
267
- const targetPolicy = policyContext.resolveForType(typeName, 'read');
268
- if (!targetPolicy)
269
- return undefined;
367
+ // v2.3.0 — a target WITHOUT a read policy still gets a bundle
368
+ // (`NO_ROOT_POLICY`: no clause of its own) so enforcement keeps
369
+ // cascading into deeper traversals. Pre-2.3.0 this returned
370
+ // `undefined`, making every policy-free type on a traversal path a
371
+ // gateway around the types beyond it (A → B(no policy) → C skipped C).
372
+ const targetPolicy = policyContext.resolveForType(typeName, 'read') ?? NO_ROOT_POLICY;
270
373
  return {
271
374
  ctx: policyContext.ctx,
272
375
  operation: 'read',
@@ -275,6 +378,73 @@ export class WhereCompiler {
275
378
  defaults: policyContext.defaults,
276
379
  };
277
380
  }
381
+ /**
382
+ * Compile a traversal's filter against its target node with the
383
+ * caller's filter (`user`) and the target's `read` policy (`policy`)
384
+ * kept SEPARATE, so each quantifier composes them correctly — the
385
+ * policy must never sit inside a negation (v2.3.0). Pre-2.3.0 the policy
386
+ * was compiled INTO the filter, so `_ALL` (`NOT (user AND policy)`) was
387
+ * falsified by hidden related nodes (an existence oracle), and a
388
+ * connection `node_NOT` was SATISFIED by them.
389
+ *
390
+ * - no policy context → plain compile.
391
+ * - concrete target → one `compileUserAndPolicy` pass (shared `@cypher`
392
+ * prelude scope); the bundle cascades as `NO_ROOT_POLICY` at any depth.
393
+ * - interface target → filter via a `NO_ROOT_POLICY` traversal bundle,
394
+ * policy via `compileTargetPolicyClause` (CASE over the members'
395
+ * own clauses — pre-2.3.0 only the interface's own policies ran).
396
+ *
397
+ * `''` for an absent part. With `scope`, concrete-target preludes
398
+ * register into it (caller emits); otherwise they are returned.
399
+ * Shared with `SelectionCompiler`'s nested-projection filters.
400
+ *
401
+ * @internal
402
+ */
403
+ compileTargetBody(where, targetVar, targetDef, counter, policyContext, scope) {
404
+ if (!policyContext) {
405
+ const plain = this.compile(where, targetVar, targetDef, counter, {
406
+ scope,
407
+ });
408
+ return {
409
+ user: plain.cypher,
410
+ policy: '',
411
+ params: plain.params,
412
+ preludes: plain.preludes ?? [],
413
+ };
414
+ }
415
+ if (this.abstractMembers(targetDef.typeName) === null) {
416
+ const split = this.compileUserAndPolicy(where, targetVar, targetDef, counter, {
417
+ policyContext: this.buildTargetBundle(targetDef.typeName, policyContext),
418
+ scope,
419
+ });
420
+ if (!split)
421
+ return { user: '', policy: '', params: {}, preludes: [] };
422
+ return {
423
+ user: split.userCypher,
424
+ policy: split.policy?.clause ?? '',
425
+ params: split.params,
426
+ preludes: split.preludes ?? [],
427
+ };
428
+ }
429
+ const userPart = this.compile(where, targetVar, targetDef, counter, {
430
+ policyContext: traversalBundle(policyContext),
431
+ scope,
432
+ });
433
+ const policyPart = this.compileTargetPolicyClause(targetDef.typeName, targetVar, 'read', policyContext, counter);
434
+ const userHasPreludes = (userPart.preludes?.length ?? 0) > 0 || (scope?.hasAny() ?? false);
435
+ if (userHasPreludes && policyPart.preludes.length > 0)
436
+ throw new OGMError(`Filtering "${targetDef.typeName}" by @cypher fields is not supported ` +
437
+ `when a member type's policy also projects @cypher fields. Filter on ` +
438
+ `stored properties, or refactor the member policy.`);
439
+ const params = { ...userPart.params };
440
+ mergeParams(params, policyPart.params);
441
+ return {
442
+ user: userPart.cypher,
443
+ policy: policyPart.cypher ?? '',
444
+ params,
445
+ preludes: [...(userPart.preludes ?? []), ...policyPart.preludes],
446
+ };
447
+ }
278
448
  compileConditions(where, nodeVar, nodeDef, counter, depth, scope,
279
449
  /**
280
450
  * Optional shared params accumulator. When provided, every leaf
@@ -358,37 +528,10 @@ export class WhereCompiler {
358
528
  clauses.push(`NOT (${sub.cypher})`);
359
529
  continue;
360
530
  }
361
- // Handle null values: relationship null → NOT EXISTS, scalar null → IS NULL
531
+ // Null values — the operator suffix is split off FIRST (v2.3.0); see
532
+ // `compileNullCondition`.
362
533
  if (value === null) {
363
- const relDef = nodeDef.relationships.get(key);
364
- if (relDef) {
365
- // Relationship null means "no such relationship exists"
366
- const targetNodeDef = resolveTargetDef(relDef.target, this.schema);
367
- if (targetNodeDef) {
368
- const relVar = `r${counter.count}`;
369
- counter.count++;
370
- const pattern = buildRelPattern({
371
- sourceVar: nodeVar,
372
- relDef,
373
- targetVar: relVar,
374
- targetLabel: 'auto',
375
- // Pass schema so that union/interface targets resolve to a
376
- // labelless target (relationship-type-only filter). Without
377
- // this, the literal abstract type name is escaped as a label
378
- // that no concrete node carries → NOT EXISTS is true for
379
- // every row → ALL rows match.
380
- schema: this.schema,
381
- });
382
- clauses.push(`NOT EXISTS { MATCH ${pattern} }`);
383
- }
384
- }
385
- else {
386
- // Scalar null means "property IS NULL". `@cypher` scalars need
387
- // to project through the scope first (NULL check on the alias).
388
- assertSafeIdentifier(key, 'where clause');
389
- const fieldRef = this.resolveFieldRef(key, nodeVar, nodeDef, scope);
390
- clauses.push(`${fieldRef} IS NULL`);
391
- }
534
+ clauses.push(this.compileNullCondition(key, nodeVar, nodeDef, counter, scope));
392
535
  continue;
393
536
  }
394
537
  // Connection operators (e.g. hasStatusConnection_SOME)
@@ -490,16 +633,24 @@ export class WhereCompiler {
490
633
  ? (this.schema.relationshipProperties.get(relDef.properties) ?? null)
491
634
  : null;
492
635
  const inner = this.compileConnectionWhereInput(value, relVar, edgeVar, targetNodeDef, propsDef, nodeScope, edgeScope, counter, depth, policyContext);
493
- const innerClauses = inner.cypher ? [inner.cypher] : [];
494
- const innerParams = inner.params;
636
+ const innerParams = { ...inner.params };
637
+ // v2.3.0 — the target's `'read'` policy, composed ONCE over the whole
638
+ // connection body (node, node_NOT, edge and logical groups alike) and
639
+ // kept outside every negation. Compiled after the caller's filter so
640
+ // parameter numbering matches the pre-2.3.0 single-`node` shape.
641
+ const target = policyContext
642
+ ? this.compileTargetBody(undefined, relVar, targetNodeDef, counter, policyContext, nodeScope)
643
+ : null;
644
+ const policy = target?.policy ?? '';
645
+ if (target)
646
+ mergeParams(innerParams, target.params);
495
647
  // Stitch the inner preludes (CALL { ... } + WITH ...) INSIDE the
496
648
  // EXISTS body, between the MATCH pattern and the inner WHERE.
497
- // - `nodeScope.emit()` — preludes from `@cypher` fields registered
498
- // directly on the connection-where input's edge-side scope.
649
+ // - `nodeScope.emit()` — node-side `@cypher` projections: the
650
+ // caller's `node` filters and (concrete target) the target policy.
499
651
  // - `edgeScope.emit()` — same, but edge-side.
500
- // - `inner.preludes` (v1.8.5) — preludes returned by the recursive
501
- // `compile()` calls on the node side, which now own their own
502
- // scopes.
652
+ // - `inner.preludes` / `target.preludes` — scopes owned elsewhere
653
+ // (an abstract target's member policies).
503
654
  const innerPreludes = [];
504
655
  if (nodeScope.hasAny())
505
656
  innerPreludes.push(...nodeScope.emit());
@@ -507,10 +658,13 @@ export class WhereCompiler {
507
658
  innerPreludes.push(...edgeScope.emit());
508
659
  if (inner.preludes && inner.preludes.length > 0)
509
660
  innerPreludes.push(...inner.preludes);
661
+ if (target && target.preludes.length > 0)
662
+ innerPreludes.push(...target.preludes);
510
663
  const preludeFragment = innerPreludes.length
511
664
  ? ` ${innerPreludes.join(' ')}`
512
665
  : '';
513
- const whereClause = innerClauses.length > 0 ? ` WHERE ${innerClauses.join(' AND ')}` : '';
666
+ const matched = stitchUserAndPolicy(inner.cypher, policy);
667
+ const whereClause = matched ? ` WHERE ${matched}` : '';
514
668
  switch (connSuffix) {
515
669
  case 'Connection':
516
670
  case 'Connection_SOME':
@@ -525,9 +679,9 @@ export class WhereCompiler {
525
679
  params: innerParams,
526
680
  };
527
681
  case 'Connection_ALL':
528
- if (innerClauses.length > 0)
682
+ if (inner.cypher)
529
683
  return {
530
- cypher: `NOT EXISTS { MATCH ${pattern}${preludeFragment} WHERE NOT (${innerClauses.join(' AND ')}) }`,
684
+ cypher: `NOT EXISTS { MATCH ${pattern}${preludeFragment} WHERE ${stitchAllCounterexample(inner.cypher, policy)} }`,
531
685
  params: innerParams,
532
686
  };
533
687
  return { cypher: '', params: {} };
@@ -573,23 +727,23 @@ export class WhereCompiler {
573
727
  * `MATCH ${pattern}` and `WHERE ...` inside the EXISTS body.
574
728
  */
575
729
  const extraPreludes = [];
576
- // Resolve the target type's `'read'` policy ONCE per connection-where
577
- // input. The same bundle is reused across every `node` / `node_NOT`
578
- // key we encounter at this level (and across nested AND/OR/NOT
579
- // groups via direct policyContext threading).
580
- const targetBundle = this.buildTargetBundle(targetNodeDef.typeName, policyContext);
581
730
  for (const [key, val] of Object.entries(value)) {
582
731
  if (val === undefined)
583
732
  continue;
584
733
  if (key === 'node' || key === 'node_NOT') {
585
- // Re-enter the top-level `compile()` so that the target type's
586
- // `'read'` policy AND-stitches into the same body that the user
587
- // filter produces. `compile()` manages its own inner scope and
588
- // returns any preludes via `WhereResult.preludes`; we hoist
589
- // those into `extraPreludes` so the surrounding `tryCompileConnection`
590
- // can stitch them inside the EXISTS body alongside the existing
591
- // `nodeScope.emit()` content.
592
- const nodeResult = this.compile(val, relVar, targetNodeDef, counter, targetBundle ? { policyContext: targetBundle } : undefined);
734
+ // v2.3.0 — the caller's node filter only. The target's `'read'`
735
+ // policy is composed ONCE by `tryCompileConnection` over the whole
736
+ // connection body, never here: stitched in here it sat inside the
737
+ // `node_NOT` negation (satisfied by hidden nodes) and was absent
738
+ // from edge-only filters. The traversal bundle keeps deeper
739
+ // traversals enforcing; `nodeScope` shares the `@cypher` chain
740
+ // with that policy.
741
+ const nodeResult = this.compile(val, relVar, targetNodeDef, counter, {
742
+ policyContext: policyContext
743
+ ? traversalBundle(policyContext)
744
+ : undefined,
745
+ scope: nodeScope,
746
+ });
593
747
  if (nodeResult.preludes && nodeResult.preludes.length > 0)
594
748
  extraPreludes.push(...nodeResult.preludes);
595
749
  if (nodeResult.cypher) {
@@ -709,28 +863,22 @@ export class WhereCompiler {
709
863
  // EXISTS never matches concrete-typed nodes.
710
864
  schema: this.schema,
711
865
  });
712
- // v1.8.5 — re-resolve the TARGET type's `'read'` policy when crossing
713
- // the relationship boundary, mirroring `selection.compiler.ts:826-847`.
714
- // Pre-1.8.5 this call site invoked `compileConditions` directly,
715
- // skipping policy AND-stitching for the target — CRIT-3.
716
- const targetBundle = this.buildTargetBundle(targetNodeDef.typeName, policyContext);
717
- // Re-enter the top-level `compile()` so the target's policy clause
718
- // (when present) AND-stitches into the same body that the user filter
719
- // produces. `compile()` manages its own inner scope and returns any
720
- // preludes via `WhereResult.preludes` for stitching inside EXISTS.
721
- const innerResult = this.compile(value, relVar, targetNodeDef, counter, targetBundle ? { policyContext: targetBundle } : undefined);
722
- const innerPreludes = innerResult.preludes ?? [];
866
+ // v1.8.5 — the TARGET type's `'read'` policy applies when crossing the
867
+ // relationship boundary (CRIT-3). v2.3.0 — the caller's filter and the
868
+ // target's policy compile SEPARATELY and compose per quantifier with
869
+ // the policy OUTSIDE every negation (see `compileTargetBody`).
870
+ const body = this.compileTargetBody(value, relVar, targetNodeDef, counter, policyContext);
871
+ const matched = stitchUserAndPolicy(body.user, body.policy);
872
+ const innerPreludes = body.preludes;
723
873
  const innerPreludeFragment = innerPreludes.length
724
874
  ? ` ${innerPreludes.join(' ')}`
725
875
  : '';
726
- const whereClause = innerResult.cypher
727
- ? ` WHERE ${innerResult.cypher}`
728
- : '';
876
+ const whereClause = matched ? ` WHERE ${matched}` : '';
729
877
  switch (suffix) {
730
878
  case '_SOME':
731
879
  return {
732
880
  cypher: `EXISTS { MATCH ${pattern}${innerPreludeFragment}${whereClause} }`,
733
- params: innerResult.params,
881
+ params: body.params,
734
882
  };
735
883
  case '_NONE':
736
884
  case '_NOT':
@@ -741,14 +889,16 @@ export class WhereCompiler {
741
889
  // Map → NULL → silent wrong rows.
742
890
  return {
743
891
  cypher: `NOT EXISTS { MATCH ${pattern}${innerPreludeFragment}${whereClause} }`,
744
- params: innerResult.params,
892
+ params: body.params,
745
893
  };
746
894
  case '_ALL':
747
- // All matching rels must satisfy: NOT EXISTS { MATCH pattern WHERE NOT (inner) }
748
- if (innerResult.cypher)
895
+ // No VISIBLE related node fails the filter. Without a caller filter
896
+ // the quantifier is vacuous — hidden nodes must not falsify it
897
+ // (pre-2.3.0 `_ALL: {}` leaked their existence).
898
+ if (body.user)
749
899
  return {
750
- cypher: `NOT EXISTS { MATCH ${pattern}${innerPreludeFragment} WHERE NOT (${innerResult.cypher}) }`,
751
- params: innerResult.params,
900
+ cypher: `NOT EXISTS { MATCH ${pattern}${innerPreludeFragment} WHERE ${stitchAllCounterexample(body.user, body.policy)} }`,
901
+ params: body.params,
752
902
  };
753
903
  return { cypher: '', params: {} };
754
904
  case '_SINGLE': {
@@ -758,7 +908,7 @@ export class WhereCompiler {
758
908
  // produces preludes — distinguish that case so the error message
759
909
  // points at the right cause.
760
910
  if (innerPreludes.length > 0) {
761
- if (targetBundle)
911
+ if (body.policy)
762
912
  throw new OGMError(`Policy on "${targetNodeDef.typeName}" requires @cypher field projection, ` +
763
913
  `which is not supported inside _SINGLE quantifiers. Refactor the policy ` +
764
914
  `to use stored properties, or refactor the predicate to _SOME + _NONE.`);
@@ -773,7 +923,7 @@ export class WhereCompiler {
773
923
  // collisions if/when a future compiler shared this counter.
774
924
  return {
775
925
  cypher: `size([${relVar} IN [(${pattern}${whereClause} | ${relVar})] | ${relVar}]) = 1`,
776
- params: innerResult.params,
926
+ params: body.params,
777
927
  };
778
928
  }
779
929
  default:
@@ -819,27 +969,27 @@ export class WhereCompiler {
819
969
  targetVar: relVar,
820
970
  targetLabelRaw: labelStr,
821
971
  });
822
- // Per-member target bundle. `null` member-policy → undefined bundle
823
- // → no policy stitching for this branch (original behavior).
824
- const targetBundle = this.buildTargetBundle(memberDef.typeName, policyContext);
825
- // Compile inner WHERE conditions for this member (if any properties
826
- // specified). When the member has its own `'read'` policy, we still
827
- // re-enter `compile()` so the policy AND-stitches into the EXISTS
828
- // body even when `memberValue` is empty/null.
972
+ // Caller filter and the member's own `'read'` policy, compiled
973
+ // separately (v2.3.0) so `_ALL` can keep the policy outside its
974
+ // negation. `_SOME`/`_NONE`/`_SINGLE` use the stitched form, which
975
+ // is byte-identical to the pre-2.3.0 single compile.
829
976
  const memberWhere = memberValue;
830
- const hasUserMemberWhere = memberWhere && Object.keys(memberWhere).length > 0;
831
- let inner = '';
832
- let preludeFragment = '';
833
- if (hasUserMemberWhere || targetBundle) {
834
- const memberCompile = this.compile(hasUserMemberWhere ? memberWhere : undefined, relVar, memberDef, counter, targetBundle ? { policyContext: targetBundle } : undefined);
835
- if (memberCompile.cypher) {
836
- inner = memberCompile.cypher;
837
- mergeParams(allParams, memberCompile.params);
838
- }
839
- if (memberCompile.preludes && memberCompile.preludes.length > 0)
840
- preludeFragment = ` ${memberCompile.preludes.join(' ')}`;
841
- }
842
- members.push({ memberKey, pattern, relVar, inner, preludeFragment });
977
+ const body = this.compileTargetBody(memberWhere, relVar, memberDef, counter, policyContext);
978
+ const inner = stitchUserAndPolicy(body.user, body.policy);
979
+ if (inner)
980
+ mergeParams(allParams, body.params);
981
+ const preludeFragment = body.preludes.length
982
+ ? ` ${body.preludes.join(' ')}`
983
+ : '';
984
+ members.push({
985
+ memberKey,
986
+ pattern,
987
+ relVar,
988
+ inner,
989
+ user: body.user,
990
+ policy: body.policy,
991
+ preludeFragment,
992
+ });
843
993
  }
844
994
  if (members.length === 0)
845
995
  return { cypher: '', params: {} };
@@ -866,9 +1016,12 @@ export class WhereCompiler {
866
1016
  // satisfied (same as the non-union empty-inner short-circuit),
867
1017
  // and union members absent from the input are unconstrained —
868
1018
  // consistent with `_SOME`, where only mentioned members count.
1019
+ // v2.3.0 — only a VISIBLE member node (its policy holds) can fail
1020
+ // the predicate; a member with no caller filter is vacuous even
1021
+ // when it has a policy (pre-2.3.0 its hidden nodes falsified it).
869
1022
  const failClauses = members
870
- .filter((m) => m.inner)
871
- .map((m) => `NOT EXISTS { MATCH ${m.pattern}${m.preludeFragment} WHERE NOT (${m.inner}) }`);
1023
+ .filter((m) => m.user)
1024
+ .map((m) => `NOT EXISTS { MATCH ${m.pattern}${m.preludeFragment} WHERE ${stitchAllCounterexample(m.user, m.policy)} }`);
872
1025
  if (failClauses.length === 0)
873
1026
  return { cypher: '', params: {} };
874
1027
  return {
@@ -901,11 +1054,105 @@ export class WhereCompiler {
901
1054
  return null;
902
1055
  }
903
1056
  }
1057
+ /**
1058
+ * Compile `<key>: null` on a node (v2.3.0). The operator suffix is split
1059
+ * off FIRST: pre-2.3.0 the whole key was treated as a property name, so
1060
+ * the common "is not null" idiom `{ deletedAt_NOT: null }` compiled to
1061
+ * `n.deletedAt_NOT IS NULL` — always true, silently matching every row
1062
+ * (and silently restricting nothing when used in a restrictive policy).
1063
+ *
1064
+ * field: null → field IS NULL
1065
+ * field_NOT: null → field IS NOT NULL
1066
+ * rel: null → NOT EXISTS { MATCH (n)-[:REL]->(…) }
1067
+ * rel_NOT: null → EXISTS { MATCH (n)-[:REL]->(…) }
1068
+ * any other operator → OGMError (null has no meaning there)
1069
+ * undeclared field → OGMError regardless of `strictWhere` — a null
1070
+ * filter on an undeclared field matches every
1071
+ * row (fail OPEN), unlike a non-null one
1072
+ *
1073
+ * A field literally named `key` always wins over suffix parsing.
1074
+ */
1075
+ compileNullCondition(key, nodeVar, nodeDef, counter, scope) {
1076
+ if (nodeDef.relationships.has(key))
1077
+ return this.compileRelationshipExistence(key, nodeVar, nodeDef, counter, false);
1078
+ if (!nodeDef.properties.has(key))
1079
+ for (const suffix of RELATIONSHIP_SUFFIXES) {
1080
+ if (!key.endsWith(suffix))
1081
+ continue;
1082
+ const field = key.slice(0, -suffix.length);
1083
+ if (!nodeDef.relationships.has(field))
1084
+ continue;
1085
+ if (suffix === '_NOT')
1086
+ return this.compileRelationshipExistence(field, nodeVar, nodeDef, counter, true);
1087
+ throw new OGMError(`Relationship filter "${key}" cannot be null. Use "${field}: null" ` +
1088
+ `(no related node) or "${field}_NOT: null" (at least one related node).`);
1089
+ }
1090
+ return this.compileNullScalar(key, nodeVar, nodeDef, scope);
1091
+ }
1092
+ /**
1093
+ * Scalar half of the null semantics above, shared by node properties and
1094
+ * relationship-edge properties: `IS NULL` / `IS NOT NULL`, or an
1095
+ * `OGMError` for any other operator and for undeclared fields.
1096
+ */
1097
+ compileNullScalar(key, varName, propsHolder, scope) {
1098
+ if (propsHolder?.properties.has(key)) {
1099
+ assertSafeIdentifier(key, 'where clause');
1100
+ // `@cypher` scalars project through the scope (NULL check on the alias).
1101
+ return `${this.resolveFieldRef(key, varName, propsHolder, scope)} IS NULL`;
1102
+ }
1103
+ for (const suffix of OPERATOR_SUFFIXES) {
1104
+ if (!key.endsWith(suffix))
1105
+ continue;
1106
+ const field = key.slice(0, -suffix.length);
1107
+ if (!propsHolder?.properties.has(field))
1108
+ continue;
1109
+ if (suffix !== '_NOT')
1110
+ throw new OGMError(`Operator "${suffix}" cannot be used with null ("${key}"). Use ` +
1111
+ `"${field}: null" (IS NULL) or "${field}_NOT: null" (IS NOT NULL) to test for null.`);
1112
+ assertSafeIdentifier(field, 'where clause');
1113
+ return `${this.resolveFieldRef(field, varName, propsHolder, scope)} IS NOT NULL`;
1114
+ }
1115
+ throw new OGMError(`Unknown field "${key}" in where clause with a null value. A null ` +
1116
+ `filter on a field the type does not declare would match every row, ` +
1117
+ `so it is rejected (regardless of strictWhere). Check for typos.`);
1118
+ }
1119
+ /**
1120
+ * `NOT EXISTS` (no related node) / `EXISTS` (at least one) for a
1121
+ * relationship null filter. Byte-identical to the pre-2.3.0 `rel: null`
1122
+ * emission for the `NOT EXISTS` case.
1123
+ */
1124
+ compileRelationshipExistence(field, nodeVar, nodeDef, counter, exists) {
1125
+ const relDef = nodeDef.relationships.get(field);
1126
+ if (!resolveTargetDef(relDef.target, this.schema))
1127
+ throw new OGMError(`Cannot resolve the target type "${relDef.target}" of relationship "${field}".`);
1128
+ const relVar = `r${counter.count}`;
1129
+ counter.count++;
1130
+ const pattern = buildRelPattern({
1131
+ sourceVar: nodeVar,
1132
+ relDef,
1133
+ targetVar: relVar,
1134
+ targetLabel: 'auto',
1135
+ // Pass schema so that union/interface targets resolve to a
1136
+ // labelless target (relationship-type-only filter). Without this,
1137
+ // the literal abstract type name is escaped as a label that no
1138
+ // concrete node carries → NOT EXISTS is true for every row → ALL
1139
+ // rows match.
1140
+ schema: this.schema,
1141
+ });
1142
+ return exists
1143
+ ? `EXISTS { MATCH ${pattern} }`
1144
+ : `NOT EXISTS { MATCH ${pattern} }`;
1145
+ }
904
1146
  compileEdgeConditions(edgeWhere, edgeVar, propsDef, edgeScope, counter) {
905
1147
  const clauses = [];
906
1148
  const params = {};
907
1149
  for (const [key, value] of Object.entries(edgeWhere)) {
908
1150
  assertSafeKey(key, 'edge where input');
1151
+ // Same null semantics as node properties (v2.3.0).
1152
+ if (value === null) {
1153
+ clauses.push(this.compileNullScalar(key, edgeVar, propsDef, edgeScope));
1154
+ continue;
1155
+ }
909
1156
  const result = this.compileScalarCondition(key, value, edgeVar, propsDef, edgeScope, counter);
910
1157
  clauses.push(result.cypher);
911
1158
  mergeParams(params, result.params);
@@ -997,13 +1244,33 @@ export class WhereCompiler {
997
1244
  * fragment. Both come from `compileConditions` so each is already a
998
1245
  * valid boolean expression. Empty user bodies skip the AND wrap.
999
1246
  */
1000
- function stitchUserAndPolicy(userBody, policyClause) {
1247
+ export function stitchUserAndPolicy(userBody, policyClause) {
1001
1248
  if (!userBody)
1002
1249
  return policyClause;
1003
1250
  if (!policyClause)
1004
1251
  return userBody;
1005
1252
  return `(${userBody}) AND ${policyClause}`;
1006
1253
  }
1254
+ /**
1255
+ * `_ALL` body (v2.3.0): a related node the caller may SEE (`policy`) that
1256
+ * fails the caller's filter. The policy sits OUTSIDE the negation, so
1257
+ * hidden related nodes neither satisfy nor falsify the quantifier.
1258
+ */
1259
+ function stitchAllCounterexample(userBody, policyClause) {
1260
+ return policyClause
1261
+ ? `${policyClause} AND NOT (${userBody})`
1262
+ : `NOT (${userBody})`;
1263
+ }
1264
+ /**
1265
+ * The bundle a traversal's USER filter compiles under (v2.3.0): no clause
1266
+ * for the target itself (its policy is composed separately by the caller),
1267
+ * but `resolveForType` stays live so deeper traversals keep enforcing.
1268
+ *
1269
+ * @internal
1270
+ */
1271
+ export function traversalBundle(policyContext) {
1272
+ return { ...policyContext, operation: 'read', resolved: NO_ROOT_POLICY };
1273
+ }
1007
1274
  /**
1008
1275
  * Compose compiled policy fragments into the single boolean clause
1009
1276
  * `(P1 OR P2 …) AND R1 AND R2 …` that `compile()` AND-stitches into the