grafeo-ogm 2.2.0 → 2.3.1
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 +93 -0
- package/README.md +49 -1
- package/dist/cjs/compilers/mutation.compiler.d.ts +94 -31
- package/dist/cjs/compilers/mutation.compiler.d.ts.map +1 -1
- package/dist/cjs/compilers/mutation.compiler.js +381 -343
- package/dist/cjs/compilers/mutation.compiler.js.map +1 -1
- package/dist/cjs/compilers/selection.compiler.d.ts +1 -1
- package/dist/cjs/compilers/selection.compiler.d.ts.map +1 -1
- package/dist/cjs/compilers/selection.compiler.js +33 -39
- package/dist/cjs/compilers/selection.compiler.js.map +1 -1
- package/dist/cjs/compilers/where.compiler.d.ts +124 -1
- package/dist/cjs/compilers/where.compiler.d.ts.map +1 -1
- package/dist/cjs/compilers/where.compiler.js +371 -102
- package/dist/cjs/compilers/where.compiler.js.map +1 -1
- package/dist/cjs/interface-model.d.ts +20 -10
- package/dist/cjs/interface-model.d.ts.map +1 -1
- package/dist/cjs/interface-model.js +47 -104
- package/dist/cjs/interface-model.js.map +1 -1
- package/dist/cjs/model.d.ts +15 -29
- package/dist/cjs/model.d.ts.map +1 -1
- package/dist/cjs/model.js +66 -145
- package/dist/cjs/model.js.map +1 -1
- package/dist/cjs/policy/nested-writes.d.ts +35 -0
- package/dist/cjs/policy/nested-writes.d.ts.map +1 -0
- package/dist/cjs/policy/nested-writes.js +199 -0
- package/dist/cjs/policy/nested-writes.js.map +1 -0
- package/dist/cjs/policy/types.d.ts +18 -0
- package/dist/cjs/policy/types.d.ts.map +1 -1
- package/dist/cjs/policy/types.js +24 -0
- package/dist/cjs/policy/types.js.map +1 -1
- package/dist/esm/compilers/mutation.compiler.d.ts +94 -31
- package/dist/esm/compilers/mutation.compiler.d.ts.map +1 -1
- package/dist/esm/compilers/mutation.compiler.js +381 -344
- package/dist/esm/compilers/mutation.compiler.js.map +1 -1
- package/dist/esm/compilers/selection.compiler.d.ts +1 -1
- package/dist/esm/compilers/selection.compiler.d.ts.map +1 -1
- package/dist/esm/compilers/selection.compiler.js +33 -39
- package/dist/esm/compilers/selection.compiler.js.map +1 -1
- package/dist/esm/compilers/where.compiler.d.ts +124 -1
- package/dist/esm/compilers/where.compiler.d.ts.map +1 -1
- package/dist/esm/compilers/where.compiler.js +371 -104
- package/dist/esm/compilers/where.compiler.js.map +1 -1
- package/dist/esm/interface-model.d.ts +20 -10
- package/dist/esm/interface-model.d.ts.map +1 -1
- package/dist/esm/interface-model.js +47 -104
- package/dist/esm/interface-model.js.map +1 -1
- package/dist/esm/model.d.ts +15 -29
- package/dist/esm/model.d.ts.map +1 -1
- package/dist/esm/model.js +67 -146
- package/dist/esm/model.js.map +1 -1
- package/dist/esm/policy/nested-writes.d.ts +35 -0
- package/dist/esm/policy/nested-writes.d.ts.map +1 -0
- package/dist/esm/policy/nested-writes.js +193 -0
- package/dist/esm/policy/nested-writes.js.map +1 -0
- package/dist/esm/policy/types.d.ts +18 -0
- package/dist/esm/policy/types.d.ts.map +1 -1
- package/dist/esm/policy/types.js +23 -0
- package/dist/esm/policy/types.js.map +1 -1
- 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
|
|
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
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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
|
-
//
|
|
531
|
+
// Null values — the operator suffix is split off FIRST (v2.3.0); see
|
|
532
|
+
// `compileNullCondition`.
|
|
362
533
|
if (value === null) {
|
|
363
|
-
|
|
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
|
|
494
|
-
|
|
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()` —
|
|
498
|
-
//
|
|
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`
|
|
501
|
-
//
|
|
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
|
|
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 (
|
|
682
|
+
if (inner.cypher)
|
|
529
683
|
return {
|
|
530
|
-
cypher: `NOT EXISTS { MATCH ${pattern}${preludeFragment} WHERE
|
|
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
|
-
//
|
|
586
|
-
//
|
|
587
|
-
//
|
|
588
|
-
//
|
|
589
|
-
//
|
|
590
|
-
//
|
|
591
|
-
//
|
|
592
|
-
const nodeResult = this.compile(val, relVar, targetNodeDef, counter,
|
|
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 —
|
|
713
|
-
//
|
|
714
|
-
//
|
|
715
|
-
//
|
|
716
|
-
const
|
|
717
|
-
|
|
718
|
-
|
|
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 =
|
|
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:
|
|
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:
|
|
892
|
+
params: body.params,
|
|
745
893
|
};
|
|
746
894
|
case '_ALL':
|
|
747
|
-
//
|
|
748
|
-
|
|
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
|
|
751
|
-
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 (
|
|
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:
|
|
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
|
-
//
|
|
823
|
-
//
|
|
824
|
-
|
|
825
|
-
//
|
|
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
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
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.
|
|
871
|
-
.map((m) => `NOT EXISTS { MATCH ${m.pattern}${m.preludeFragment} WHERE
|
|
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
|