grafeo-ogm 2.1.1 → 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.
- package/CHANGELOG.md +126 -0
- package/README.md +98 -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 +206 -6
- package/dist/cjs/compilers/where.compiler.d.ts.map +1 -1
- package/dist/cjs/compilers/where.compiler.js +525 -176
- package/dist/cjs/compilers/where.compiler.js.map +1 -1
- package/dist/cjs/execution/executor.d.ts +11 -1
- package/dist/cjs/execution/executor.d.ts.map +1 -1
- package/dist/cjs/execution/executor.js +6 -3
- package/dist/cjs/execution/executor.js.map +1 -1
- package/dist/cjs/index.d.ts +2 -2
- package/dist/cjs/index.d.ts.map +1 -1
- package/dist/cjs/index.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 +42 -30
- package/dist/cjs/model.d.ts.map +1 -1
- package/dist/cjs/model.js +134 -99
- package/dist/cjs/model.js.map +1 -1
- package/dist/cjs/ogm.d.ts.map +1 -1
- package/dist/cjs/ogm.js +1 -0
- package/dist/cjs/ogm.js.map +1 -1
- package/dist/cjs/policy/explain.d.ts +59 -0
- package/dist/cjs/policy/explain.d.ts.map +1 -0
- package/dist/cjs/policy/explain.js +175 -0
- package/dist/cjs/policy/explain.js.map +1 -0
- 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/resolver.d.ts +26 -2
- package/dist/cjs/policy/resolver.d.ts.map +1 -1
- package/dist/cjs/policy/resolver.js +103 -33
- package/dist/cjs/policy/resolver.js.map +1 -1
- package/dist/cjs/policy/types.d.ts +105 -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 +206 -6
- package/dist/esm/compilers/where.compiler.d.ts.map +1 -1
- package/dist/esm/compilers/where.compiler.js +524 -178
- package/dist/esm/compilers/where.compiler.js.map +1 -1
- package/dist/esm/execution/executor.d.ts +11 -1
- package/dist/esm/execution/executor.d.ts.map +1 -1
- package/dist/esm/execution/executor.js +6 -3
- package/dist/esm/execution/executor.js.map +1 -1
- package/dist/esm/index.d.ts +2 -2
- package/dist/esm/index.d.ts.map +1 -1
- package/dist/esm/index.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 +42 -30
- package/dist/esm/model.d.ts.map +1 -1
- package/dist/esm/model.js +136 -101
- package/dist/esm/model.js.map +1 -1
- package/dist/esm/ogm.d.ts.map +1 -1
- package/dist/esm/ogm.js +1 -0
- package/dist/esm/ogm.js.map +1 -1
- package/dist/esm/policy/explain.d.ts +59 -0
- package/dist/esm/policy/explain.d.ts.map +1 -0
- package/dist/esm/policy/explain.js +169 -0
- package/dist/esm/policy/explain.js.map +1 -0
- 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/resolver.d.ts +26 -2
- package/dist/esm/policy/resolver.d.ts.map +1 -1
- package/dist/esm/policy/resolver.js +102 -33
- package/dist/esm/policy/resolver.js.map +1 -1
- package/dist/esm/policy/types.d.ts +105 -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,13 +1,10 @@
|
|
|
1
1
|
import { OGMError } from '../errors';
|
|
2
|
+
import { PolicyDeniedError } from '../policy/errors';
|
|
3
|
+
import { NO_ROOT_POLICY } from '../policy/types';
|
|
2
4
|
import { buildRelPattern, getTargetLabelString, resolveTargetDef, } from '../schema/utils';
|
|
3
|
-
import { assertSafeIdentifier, assertSafeLabel, assertSafePropertyName, escapeIdentifier, mergeParams, } from '../utils/validation';
|
|
5
|
+
import { assertSafeIdentifier, assertSafeLabel, assertSafePropertyName, escapeIdentifier, isPlainObject, mergeParams, } from '../utils/validation';
|
|
4
6
|
import { coerceWriteValue, resolveDefaultWriteValue, wrapListItemExpr, wrapWhereParam, wrapWriteExpr, } from '../utils/write-coercion';
|
|
5
7
|
import { WhereCompiler } from './where.compiler';
|
|
6
|
-
/**
|
|
7
|
-
* Relationship filter suffixes that require EXISTS subquery patterns.
|
|
8
|
-
* These match related nodes via relationship traversal rather than scalar property comparison.
|
|
9
|
-
*/
|
|
10
|
-
const RELATIONSHIP_SUFFIXES = ['_SOME', '_NONE', '_ALL', '_SINGLE'];
|
|
11
8
|
/**
|
|
12
9
|
* Compiles create, update, delete, and label mutations into Cypher + params.
|
|
13
10
|
*/
|
|
@@ -19,8 +16,6 @@ export class MutationCompiler {
|
|
|
19
16
|
* Bounded by the number of node types in the schema (~50-100 entries).
|
|
20
17
|
*/
|
|
21
18
|
this.labelCache = new Map();
|
|
22
|
-
/** Counter for unique relationship filter variables within a single WHERE clause. */
|
|
23
|
-
this.relFilterCounter = 0;
|
|
24
19
|
this.logger = options?.logger;
|
|
25
20
|
this.policyWhereCompiler = options?.whereCompiler;
|
|
26
21
|
}
|
|
@@ -48,34 +43,126 @@ export class MutationCompiler {
|
|
|
48
43
|
* `connect_<field>_<prop>`) or the selection's params (allocated later
|
|
49
44
|
* from the same counter).
|
|
50
45
|
*/
|
|
51
|
-
buildTargetPolicyPredicate(targetNodeDef, targetVar, params, policyContext, paramCounter
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
46
|
+
buildTargetPolicyPredicate(targetNodeDef, targetVar, params, policyContext, paramCounter,
|
|
47
|
+
/**
|
|
48
|
+
* Operation whose policy gates the target (v2.3.0): `read` for
|
|
49
|
+
* connect/disconnect (you must see a node to link it), `update` for a
|
|
50
|
+
* nested update, `delete` for nested/cascade deletes — exactly what the
|
|
51
|
+
* equivalent DIRECT write on the target type enforces.
|
|
52
|
+
*/
|
|
53
|
+
op = 'read') {
|
|
54
|
+
if (!policyContext)
|
|
56
55
|
return null;
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
56
|
+
// v2.3.0 — a missing counter must never mean a missing policy.
|
|
57
|
+
const counter = paramCounter ?? counterAfter(params);
|
|
58
|
+
// Mirror the direct write's compile-time default-deny: a direct
|
|
59
|
+
// update/delete with `onDeny: 'throw'` and no applicable permissive
|
|
60
|
+
// throws before running, so the nested equivalent must too. (Connect/
|
|
61
|
+
// disconnect keep their pre-2.3.0 `read` semantics: compile to false.)
|
|
62
|
+
if (op !== 'read' && policyContext.defaults.onDeny === 'throw') {
|
|
63
|
+
const resolved = policyContext.resolveForType(targetNodeDef.typeName, op);
|
|
64
|
+
if (resolved && !resolved.overridden && resolved.permissives.length === 0)
|
|
65
|
+
throw new PolicyDeniedError({
|
|
66
|
+
typeName: targetNodeDef.typeName,
|
|
67
|
+
operation: op,
|
|
68
|
+
reason: 'no-permissive-matched',
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
// Abstract (interface / union) targets get a CASE over their concrete
|
|
72
|
+
// members' own clauses — pre-2.3.0 only the abstract type's own
|
|
73
|
+
// policies (usually none) were applied.
|
|
74
|
+
const clause = this.getPolicyWhereCompiler().compileTargetPolicyClause(targetNodeDef.typeName, targetVar, op, policyContext, counter);
|
|
75
|
+
if (clause.preludes.length > 0)
|
|
66
76
|
throw new OGMError(`Policy on "${targetNodeDef.typeName}" requires @cypher field ` +
|
|
67
|
-
`projection, which is not supported inside
|
|
77
|
+
`projection, which is not supported inside nested mutation ` +
|
|
68
78
|
`target filters. Refactor the policy to use stored properties.`);
|
|
69
|
-
if (!
|
|
79
|
+
if (!clause.cypher)
|
|
70
80
|
return null;
|
|
71
|
-
mergeParams(params,
|
|
72
|
-
return
|
|
81
|
+
mergeParams(params, clause.params);
|
|
82
|
+
return clause.cypher;
|
|
73
83
|
}
|
|
74
84
|
getPolicyWhereCompiler() {
|
|
85
|
+
// Inherit the logger: since v2.3.0 this compiler also compiles every
|
|
86
|
+
// nested-write `where`, and its empty-AND/OR warnings must surface.
|
|
75
87
|
if (!this.policyWhereCompiler)
|
|
76
|
-
this.policyWhereCompiler = new WhereCompiler(this.schema
|
|
88
|
+
this.policyWhereCompiler = new WhereCompiler(this.schema, {
|
|
89
|
+
logger: this.logger,
|
|
90
|
+
});
|
|
77
91
|
return this.policyWhereCompiler;
|
|
78
92
|
}
|
|
93
|
+
/**
|
|
94
|
+
* Compile a nested-write `where` (connect, disconnect, nested update,
|
|
95
|
+
* nested/cascade delete) against `targetVar` through `WhereCompiler` —
|
|
96
|
+
* the single where implementation (v2.3.0: the mutation compiler's
|
|
97
|
+
* parallel builder was removed after repeatedly drifting from it).
|
|
98
|
+
* Operators, null semantics, abstract relationship targets and —
|
|
99
|
+
* crucially — traversal-policy enforcement are identical to a direct
|
|
100
|
+
* `where`: a relationship filter inside it AND-stitches the traversed
|
|
101
|
+
* type's `read` policy. The target's OWN operation policy is added
|
|
102
|
+
* separately by `buildTargetPolicyPredicate`, so the root here is bound
|
|
103
|
+
* as `NO_ROOT_POLICY`.
|
|
104
|
+
*
|
|
105
|
+
* Returns `''` for an absent or empty where.
|
|
106
|
+
*/
|
|
107
|
+
compileTargetWhere(whereSpec, targetVar, targetNodeDef, params, policyContext, paramCounter) {
|
|
108
|
+
if (!whereSpec || Object.keys(whereSpec).length === 0)
|
|
109
|
+
return '';
|
|
110
|
+
const counter = paramCounter ?? counterAfter(params);
|
|
111
|
+
const nodeWhere = connectionWhereToNodeWhere(whereSpec);
|
|
112
|
+
const traversalBundle = policyContext
|
|
113
|
+
? { ...policyContext, operation: 'read', resolved: NO_ROOT_POLICY }
|
|
114
|
+
: undefined;
|
|
115
|
+
const compiled = this.getPolicyWhereCompiler().compile(nodeWhere, targetVar, targetNodeDef, counter, traversalBundle ? { policyContext: traversalBundle } : undefined);
|
|
116
|
+
if (compiled.preludes && compiled.preludes.length > 0)
|
|
117
|
+
throw new OGMError(`Filtering a nested mutation target of type "${targetNodeDef.typeName}" ` +
|
|
118
|
+
`by an @cypher field is not supported. Use stored properties in ` +
|
|
119
|
+
`nested connect/disconnect/update/delete filters.`);
|
|
120
|
+
mergeParams(params, compiled.params);
|
|
121
|
+
return compiled.cypher;
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* The bulk-connect UNWIND fast path compiles each where key against the
|
|
125
|
+
* ROW's own value (`connItem.where.node.<key>`), which `WhereCompiler`
|
|
126
|
+
* cannot express. It is therefore restricted to the one shape where
|
|
127
|
+
* that is exact: every item's where is `{ node: { … } }` or bare
|
|
128
|
+
* properties, holding only declared, stored scalar properties of the
|
|
129
|
+
* target (registered operators allowed) with NON-NULL values — no
|
|
130
|
+
* `AND`/`OR`/`NOT`/`node_NOT`, no relationship traversal, no `@cypher`
|
|
131
|
+
* field. Every predicate it emits is then a scalar comparison against a
|
|
132
|
+
* concrete row value, so it can only narrow matches, never fail open.
|
|
133
|
+
* Anything else takes the per-item path through `WhereCompiler`.
|
|
134
|
+
*/
|
|
135
|
+
isConnectFastPathEligible(items, targetNodeDef) {
|
|
136
|
+
for (const item of items) {
|
|
137
|
+
const where = item.where;
|
|
138
|
+
if (where === undefined)
|
|
139
|
+
continue;
|
|
140
|
+
if (!isPlainObject(where))
|
|
141
|
+
return false;
|
|
142
|
+
const keys = Object.keys(where).filter((k) => where[k] !== undefined);
|
|
143
|
+
let nodeWhere;
|
|
144
|
+
if (keys.some((k) => CONNECTION_WHERE_KEYS.has(k))) {
|
|
145
|
+
if (keys.length !== 1 || keys[0] !== 'node')
|
|
146
|
+
return false;
|
|
147
|
+
if (!isPlainObject(where.node))
|
|
148
|
+
return false;
|
|
149
|
+
nodeWhere = where.node;
|
|
150
|
+
}
|
|
151
|
+
else
|
|
152
|
+
nodeWhere = where;
|
|
153
|
+
for (const [key, val] of Object.entries(nodeWhere)) {
|
|
154
|
+
if (val === undefined || val === null)
|
|
155
|
+
return false;
|
|
156
|
+
if (isPlainObject(val))
|
|
157
|
+
return false;
|
|
158
|
+
const { baseProp } = this.parseOperatorSuffix(key);
|
|
159
|
+
const propDef = targetNodeDef.properties.get(baseProp);
|
|
160
|
+
if (!propDef || propDef.isCypher)
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return true;
|
|
165
|
+
}
|
|
79
166
|
/**
|
|
80
167
|
* Surface a logical operator that contributed zero effective conditions.
|
|
81
168
|
* Mirrors WhereCompiler.warnEmptyLogical — fires only on the anomalous
|
|
@@ -102,8 +189,19 @@ export class MutationCompiler {
|
|
|
102
189
|
* Generate CREATE Cypher for one or more nodes.
|
|
103
190
|
* Handles scalar properties, nested relationship creates, and connects.
|
|
104
191
|
*/
|
|
105
|
-
compileCreate(inputs, nodeDef, labels
|
|
106
|
-
|
|
192
|
+
compileCreate(inputs, nodeDef, labels,
|
|
193
|
+
/**
|
|
194
|
+
* The caller's `create` bundle (v2.3.0). Its `resolveForType` gates
|
|
195
|
+
* every connect target in the nested input by the TARGET type's `read`
|
|
196
|
+
* policy — pre-2.3.0 the create path threaded no policy at all.
|
|
197
|
+
*/
|
|
198
|
+
policyContext,
|
|
199
|
+
/**
|
|
200
|
+
* Shared `param<N>` counter; continue it into the RETURN selection so
|
|
201
|
+
* nested-filter params never collide with the selection's.
|
|
202
|
+
*/
|
|
203
|
+
paramCounter) {
|
|
204
|
+
const counter = paramCounter ?? { count: 0 };
|
|
107
205
|
const lines = [];
|
|
108
206
|
const params = {};
|
|
109
207
|
const createdVars = [];
|
|
@@ -120,10 +218,7 @@ export class MutationCompiler {
|
|
|
120
218
|
// Pass all previously created node vars as ancestors so WITH clauses
|
|
121
219
|
// don't drop them from scope (needed for RETURN n at the end)
|
|
122
220
|
const relLines = this.buildCreateRelationships(input, nodeDef, nodeVar, prefix, params, createdVars.slice(0, -1), // ancestors = all vars before current
|
|
123
|
-
|
|
124
|
-
// nested connect enforcement is out of scope for this fix, so
|
|
125
|
-
// the emitted create Cypher stays byte-identical.
|
|
126
|
-
undefined, undefined);
|
|
221
|
+
policyContext, counter);
|
|
127
222
|
lines.push(...relLines);
|
|
128
223
|
}
|
|
129
224
|
// Add extra labels to all created nodes
|
|
@@ -153,10 +248,12 @@ export class MutationCompiler {
|
|
|
153
248
|
* disconnect `where` params or the selection's params.
|
|
154
249
|
*/
|
|
155
250
|
paramCounter) {
|
|
156
|
-
this.relFilterCounter = 0;
|
|
157
251
|
const labelStr = this.getCachedLabelString(nodeDef);
|
|
158
252
|
const lines = [];
|
|
159
253
|
const params = { ...whereResult.params };
|
|
254
|
+
// Continue after the root WHERE's params when the caller threads no
|
|
255
|
+
// counter, so nested filters can never collide with them.
|
|
256
|
+
const counter = paramCounter ?? counterAfter(whereResult.params);
|
|
160
257
|
// Apply runtime labels to MATCH pattern (same as find())
|
|
161
258
|
if (labels && labels.length > 0) {
|
|
162
259
|
const extraLabels = labels.map((l) => assertSafeLabel(l)).join(':');
|
|
@@ -189,18 +286,18 @@ export class MutationCompiler {
|
|
|
189
286
|
if (setClauses.length > 0)
|
|
190
287
|
lines.push(`SET ${setClauses.join(', ')}`);
|
|
191
288
|
// Process nested relationship operations within update body
|
|
192
|
-
const relLines = this.buildUpdateRelationships(update, nodeDef, 'n', 'update', params, 0, [], policyContext,
|
|
289
|
+
const relLines = this.buildUpdateRelationships(update, nodeDef, 'n', 'update', params, 0, [], policyContext, counter);
|
|
193
290
|
lines.push(...relLines);
|
|
194
291
|
}
|
|
195
292
|
// Disconnect relationships (top-level) — must run before connects
|
|
196
293
|
// so that blanket disconnects don't remove newly-connected relationships.
|
|
197
294
|
if (disconnect) {
|
|
198
|
-
const disconnectLines = this.buildDisconnects(disconnect, nodeDef, params, policyContext,
|
|
295
|
+
const disconnectLines = this.buildDisconnects(disconnect, nodeDef, params, policyContext, counter);
|
|
199
296
|
lines.push(...disconnectLines);
|
|
200
297
|
}
|
|
201
298
|
// Connect relationships (top-level)
|
|
202
299
|
if (connect) {
|
|
203
|
-
const connectLines = this.buildConnects(connect, nodeDef, params, policyContext,
|
|
300
|
+
const connectLines = this.buildConnects(connect, nodeDef, params, policyContext, counter);
|
|
204
301
|
lines.push(...connectLines);
|
|
205
302
|
}
|
|
206
303
|
lines.push(returnMode === 'count' ? 'RETURN count(n) AS count' : 'RETURN n');
|
|
@@ -208,40 +305,121 @@ export class MutationCompiler {
|
|
|
208
305
|
}
|
|
209
306
|
/**
|
|
210
307
|
* Generate DELETE Cypher with optional cascade.
|
|
308
|
+
*
|
|
309
|
+
* v2.3.0 — each cascaded relationship honours its per-item `where` and
|
|
310
|
+
* is gated by the TARGET type's `delete` policy (`buildNestedDelete`).
|
|
311
|
+
* Pre-2.3.0 the cascade iterated only the relationship KEYS: the
|
|
312
|
+
* documented `{ where }` was ignored, so every related node — shared
|
|
313
|
+
* ones included — was deleted, with no policy check.
|
|
211
314
|
*/
|
|
212
|
-
compileDelete(nodeDef, whereResult, deleteInput
|
|
315
|
+
compileDelete(nodeDef, whereResult, deleteInput,
|
|
316
|
+
/** The caller's `delete` bundle; gates each cascade target. */
|
|
317
|
+
policyContext,
|
|
318
|
+
/** Shared `param<N>` counter, continued from the root WHERE. */
|
|
319
|
+
paramCounter) {
|
|
213
320
|
const labelStr = this.getCachedLabelString(nodeDef);
|
|
214
321
|
const lines = [];
|
|
215
322
|
const params = { ...whereResult.params };
|
|
323
|
+
const counter = paramCounter ?? counterAfter(whereResult.params);
|
|
216
324
|
lines.push(`MATCH (n:${labelStr})`);
|
|
217
325
|
if (whereResult.preludes && whereResult.preludes.length > 0)
|
|
218
326
|
lines.push(...whereResult.preludes);
|
|
219
327
|
if (whereResult.cypher)
|
|
220
328
|
lines.push(`WHERE ${whereResult.cypher}`);
|
|
221
329
|
if (deleteInput && Object.keys(deleteInput).length > 0) {
|
|
222
|
-
const deleteVars = [];
|
|
223
330
|
let varCounter = 0;
|
|
224
|
-
for (const [fieldName] of Object.entries(deleteInput)) {
|
|
331
|
+
for (const [fieldName, spec] of Object.entries(deleteInput)) {
|
|
332
|
+
if (spec === undefined)
|
|
333
|
+
continue;
|
|
225
334
|
const relDef = nodeDef.relationships.get(fieldName);
|
|
226
335
|
if (!relDef)
|
|
227
|
-
|
|
228
|
-
const
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
336
|
+
throw new OGMError(`Unknown relationship "${fieldName}" in the delete input of ${nodeDef.typeName}.`);
|
|
337
|
+
const targetNodeDef = resolveTargetDef(relDef.target, this.schema);
|
|
338
|
+
if (!targetNodeDef)
|
|
339
|
+
throw new OGMError(`Cannot resolve the target type "${relDef.target}" of relationship "${fieldName}".`);
|
|
340
|
+
lines.push(...this.buildNestedDelete({
|
|
341
|
+
spec,
|
|
232
342
|
relDef,
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
343
|
+
sourceVar: 'n',
|
|
344
|
+
withClause: 'WITH n',
|
|
345
|
+
targetNodeDef,
|
|
346
|
+
nextVar: () => `cascade_${varCounter++}`,
|
|
347
|
+
tag: fieldName,
|
|
348
|
+
params,
|
|
349
|
+
policyContext,
|
|
350
|
+
paramCounter: counter,
|
|
351
|
+
}));
|
|
238
352
|
}
|
|
239
|
-
lines.push(`DETACH DELETE ${[...deleteVars, 'n'].join(', ')}`);
|
|
240
353
|
}
|
|
241
|
-
|
|
242
|
-
lines.push('DETACH DELETE n');
|
|
354
|
+
lines.push('DETACH DELETE n');
|
|
243
355
|
return { cypher: lines.join('\n'), params };
|
|
244
356
|
}
|
|
357
|
+
/**
|
|
358
|
+
* Nested / cascade delete of ONE relationship's targets (v2.3.0), shared
|
|
359
|
+
* by `compileDelete` (`Model.delete`'s `delete` input) and delete-inside-
|
|
360
|
+
* `update`. One subquery per spec item:
|
|
361
|
+
*
|
|
362
|
+
* <withClause>
|
|
363
|
+
* CALL {
|
|
364
|
+
* <withClause>
|
|
365
|
+
* MATCH (src)-[:REL]->(t)
|
|
366
|
+
* WHERE <item.where via WhereCompiler> AND <target `delete` policy>
|
|
367
|
+
* DETACH DELETE t
|
|
368
|
+
* RETURN count(*) AS _del_<tag>_<i>
|
|
369
|
+
* }
|
|
370
|
+
*
|
|
371
|
+
* `spec` is one item or an array (singular vs list relationships);
|
|
372
|
+
* `null` means "no cascade" (GraphQL InputMaybe). `{}` / no `where`
|
|
373
|
+
* deletes every related node the target's `delete` policy permits.
|
|
374
|
+
* A multi-level cascade (`delete` inside an item) and unknown item keys
|
|
375
|
+
* are rejected instead of silently ignored.
|
|
376
|
+
*/
|
|
377
|
+
buildNestedDelete(args) {
|
|
378
|
+
const { spec, relDef, sourceVar, withClause, targetNodeDef } = args;
|
|
379
|
+
if (spec === null || spec === undefined)
|
|
380
|
+
return [];
|
|
381
|
+
// Legacy shorthand: `{ rel: true }` = delete every related node (the
|
|
382
|
+
// pre-2.3.0 cascade ignored values, so callers relied on it). Any other
|
|
383
|
+
// non-object value — `false` included, which used to delete everything
|
|
384
|
+
// too — is rejected below rather than silently reinterpreted.
|
|
385
|
+
const items = spec === true ? [{}] : Array.isArray(spec) ? spec : [spec];
|
|
386
|
+
const lines = [];
|
|
387
|
+
items.forEach((item, idx) => {
|
|
388
|
+
if (!isPlainObject(item))
|
|
389
|
+
throw new OGMError(`Each nested delete of "${relDef.fieldName}" must be an object ({ where?, delete? }).`);
|
|
390
|
+
for (const key of Object.keys(item)) {
|
|
391
|
+
if (item[key] === undefined)
|
|
392
|
+
continue;
|
|
393
|
+
if (key !== 'where' && key !== 'delete')
|
|
394
|
+
throw new OGMError(`Unknown key "${key}" in a nested delete of "${relDef.fieldName}". Allowed: "where", "delete".`);
|
|
395
|
+
}
|
|
396
|
+
if (hasNestedCascade(item.delete))
|
|
397
|
+
throw new OGMError(`Multi-level cascade delete is not supported (a "delete" inside the nested delete of "${relDef.fieldName}"). ` +
|
|
398
|
+
`Delete the deeper nodes in a separate call.`);
|
|
399
|
+
const targetVar = args.nextVar();
|
|
400
|
+
const pattern = buildRelPattern({
|
|
401
|
+
sourceVar,
|
|
402
|
+
relDef,
|
|
403
|
+
targetVar,
|
|
404
|
+
targetLabel: 'auto',
|
|
405
|
+
schema: this.schema,
|
|
406
|
+
});
|
|
407
|
+
const conditions = [
|
|
408
|
+
this.compileTargetWhere(item.where, targetVar, targetNodeDef, args.params, args.policyContext, args.paramCounter),
|
|
409
|
+
this.buildTargetPolicyPredicate(targetNodeDef, targetVar, args.params, args.policyContext, args.paramCounter, 'delete'),
|
|
410
|
+
].filter((c) => Boolean(c));
|
|
411
|
+
lines.push(withClause);
|
|
412
|
+
lines.push('CALL {');
|
|
413
|
+
lines.push(withClause);
|
|
414
|
+
lines.push(`MATCH ${pattern}`);
|
|
415
|
+
if (conditions.length > 0)
|
|
416
|
+
lines.push(`WHERE ${conditions.join(' AND ')}`);
|
|
417
|
+
lines.push(`DETACH DELETE ${targetVar}`);
|
|
418
|
+
lines.push(`RETURN count(*) AS _del_${args.tag}_${idx}`);
|
|
419
|
+
lines.push('}');
|
|
420
|
+
});
|
|
421
|
+
return lines;
|
|
422
|
+
}
|
|
245
423
|
/**
|
|
246
424
|
* Generate MERGE (upsert) Cypher with ON CREATE SET / ON MATCH SET.
|
|
247
425
|
* Scalar properties only — nested relationship ops are not supported.
|
|
@@ -674,11 +852,10 @@ export class MutationCompiler {
|
|
|
674
852
|
const connectVar = `${nodeVar}_cn${startCounter + counter}`;
|
|
675
853
|
counter++;
|
|
676
854
|
lines.push(`MATCH (${connectVar}:${targetLabelStr})`);
|
|
677
|
-
const
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
: conditions;
|
|
855
|
+
const connectConditions = [
|
|
856
|
+
this.compileTargetWhere(whereSpec, connectVar, targetNodeDef, params, policyContext, paramCounter),
|
|
857
|
+
this.buildTargetPolicyPredicate(targetNodeDef, connectVar, params, policyContext, paramCounter),
|
|
858
|
+
].filter((c) => Boolean(c));
|
|
682
859
|
if (connectConditions.length > 0)
|
|
683
860
|
lines.push(`WHERE ${connectConditions.join(' AND ')}`);
|
|
684
861
|
if (edgeInput && Object.keys(edgeInput).length > 0) {
|
|
@@ -732,13 +909,13 @@ export class MutationCompiler {
|
|
|
732
909
|
if (Array.isArray(spec)) {
|
|
733
910
|
if (spec.length === 0)
|
|
734
911
|
continue; // Empty array — nothing to connect
|
|
735
|
-
//
|
|
736
|
-
//
|
|
737
|
-
//
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
912
|
+
// The UNWIND fast path compiles filters against per-row values and
|
|
913
|
+
// is only exact for plain scalar, non-null filters (see
|
|
914
|
+
// isConnectFastPathEligible). Everything else — null values,
|
|
915
|
+
// logical keys, traversals — takes one CALL subquery per item,
|
|
916
|
+
// compiled through WhereCompiler.
|
|
917
|
+
const useFastPath = this.isConnectFastPathEligible(spec, targetNodeDef);
|
|
918
|
+
if (!useFastPath)
|
|
742
919
|
for (let ci = 0; ci < spec.length; ci++) {
|
|
743
920
|
const connectSpec = spec[ci];
|
|
744
921
|
const whereSpec = connectSpec.where;
|
|
@@ -748,11 +925,10 @@ export class MutationCompiler {
|
|
|
748
925
|
lines.push('WITH n');
|
|
749
926
|
const connectVar = `target_${fieldName}_${ci}`;
|
|
750
927
|
lines.push(`MATCH (${connectVar}:${targetLabelStr})`);
|
|
751
|
-
const
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
: conditions;
|
|
928
|
+
const connectConditions = [
|
|
929
|
+
this.compileTargetWhere(whereSpec, connectVar, targetNodeDef, params, policyContext, paramCounter),
|
|
930
|
+
this.buildTargetPolicyPredicate(targetNodeDef, connectVar, params, policyContext, paramCounter),
|
|
931
|
+
].filter((c) => Boolean(c));
|
|
756
932
|
if (connectConditions.length > 0)
|
|
757
933
|
lines.push(`WHERE ${connectConditions.join(' AND ')}`);
|
|
758
934
|
if (edgeInput && Object.keys(edgeInput).length > 0) {
|
|
@@ -860,11 +1036,10 @@ export class MutationCompiler {
|
|
|
860
1036
|
const edgeInput = connectSpec.edge;
|
|
861
1037
|
lines.push('WITH n');
|
|
862
1038
|
lines.push(`MATCH (target:${targetLabelStr})`);
|
|
863
|
-
const
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
: matchConditions;
|
|
1039
|
+
const singleConnectConditions = [
|
|
1040
|
+
this.compileTargetWhere(whereSpec, 'target', targetNodeDef, params, policyContext, paramCounter),
|
|
1041
|
+
this.buildTargetPolicyPredicate(targetNodeDef, 'target', params, policyContext, paramCounter),
|
|
1042
|
+
].filter((c) => Boolean(c));
|
|
868
1043
|
if (singleConnectConditions.length > 0)
|
|
869
1044
|
lines.push(`WHERE ${singleConnectConditions.join(' AND ')}`);
|
|
870
1045
|
if (edgeInput && Object.keys(edgeInput).length > 0) {
|
|
@@ -937,13 +1112,12 @@ export class MutationCompiler {
|
|
|
937
1112
|
targetLabel: 'auto',
|
|
938
1113
|
});
|
|
939
1114
|
lines.push(`OPTIONAL MATCH ${pattern}`);
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
: conditions;
|
|
1115
|
+
if (!targetNodeDef)
|
|
1116
|
+
throw new OGMError(`Cannot resolve the target type "${relDef.target}" of relationship "${fieldName}".`);
|
|
1117
|
+
const disconnectConditions = [
|
|
1118
|
+
this.compileTargetWhere(whereSpec, targetVar, targetNodeDef, params, policyContext, paramCounter),
|
|
1119
|
+
this.buildTargetPolicyPredicate(targetNodeDef, targetVar, params, policyContext, paramCounter),
|
|
1120
|
+
].filter((c) => Boolean(c));
|
|
947
1121
|
if (disconnectConditions.length > 0)
|
|
948
1122
|
lines.push(`WHERE ${disconnectConditions.join(' AND ')}`);
|
|
949
1123
|
lines.push(`DELETE ${relVar}`);
|
|
@@ -1019,47 +1193,21 @@ export class MutationCompiler {
|
|
|
1019
1193
|
const item = items[i];
|
|
1020
1194
|
const itemPrefix = `${relPrefix}_${i}`;
|
|
1021
1195
|
// --- delete (detach delete target nodes) ---
|
|
1022
|
-
//
|
|
1023
|
-
//
|
|
1024
|
-
if (item.delete)
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
const pattern = buildRelPattern({
|
|
1038
|
-
sourceVar,
|
|
1039
|
-
relDef,
|
|
1040
|
-
targetVar: delTarget,
|
|
1041
|
-
edgeVar: delRel,
|
|
1042
|
-
targetLabel: 'auto',
|
|
1043
|
-
});
|
|
1044
|
-
lines.push(`OPTIONAL MATCH ${pattern}`);
|
|
1045
|
-
if (whereSpec && Object.keys(whereSpec).length > 0) {
|
|
1046
|
-
const nodeWhere = (whereSpec.node ?? whereSpec);
|
|
1047
|
-
// Use the same WHERE-condition builder as the disconnect /
|
|
1048
|
-
// connect paths so operator suffixes (_GT/_CONTAINS/_IN/etc.),
|
|
1049
|
-
// `NOT`, and relationship sub-filters work consistently. The
|
|
1050
|
-
// pre-1.7.2 inline implementation only emitted `prop = $param`
|
|
1051
|
-
// for every key, silently turning operator-suffixed keys
|
|
1052
|
-
// (`name_CONTAINS`) into property lookups against
|
|
1053
|
-
// non-existent fields → zero rows deleted.
|
|
1054
|
-
const conditions = this.buildNodeWhereConditions(nodeWhere, delTarget, `${itemPrefix}_del${di}`, params, targetNodeDef);
|
|
1055
|
-
if (conditions.length > 0)
|
|
1056
|
-
lines.push(`WHERE ${conditions.join(' AND ')}`);
|
|
1057
|
-
}
|
|
1058
|
-
lines.push(`DETACH DELETE ${delTarget}`);
|
|
1059
|
-
lines.push(`RETURN count(*) AS _del_${key}_${i}_${di}`);
|
|
1060
|
-
lines.push(`}`);
|
|
1061
|
-
}
|
|
1062
|
-
}
|
|
1196
|
+
// Shared with `compileDelete`'s cascade: where + target `delete`
|
|
1197
|
+
// policy, one CALL subquery per spec item (v2.3.0).
|
|
1198
|
+
if (item.delete)
|
|
1199
|
+
lines.push(...this.buildNestedDelete({
|
|
1200
|
+
spec: item.delete,
|
|
1201
|
+
relDef,
|
|
1202
|
+
sourceVar,
|
|
1203
|
+
withClause,
|
|
1204
|
+
targetNodeDef,
|
|
1205
|
+
nextVar: () => `${sourceVar}_del${varCounter++}`,
|
|
1206
|
+
tag: `${key}_${i}`,
|
|
1207
|
+
params,
|
|
1208
|
+
policyContext,
|
|
1209
|
+
paramCounter,
|
|
1210
|
+
}));
|
|
1063
1211
|
// --- create ---
|
|
1064
1212
|
if (item.create) {
|
|
1065
1213
|
const createItems = Array.isArray(item.create)
|
|
@@ -1140,11 +1288,10 @@ export class MutationCompiler {
|
|
|
1140
1288
|
// keys are handled identically to the top-level disconnect
|
|
1141
1289
|
// path (`buildDisconnects`). Restores backwards-compat with
|
|
1142
1290
|
// @neo4j/graphql-ogm's nested update.disconnect[].where shape.
|
|
1143
|
-
const
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
: conditions;
|
|
1291
|
+
const nestedDiscConditions = [
|
|
1292
|
+
this.compileTargetWhere(whereSpec, discTarget, targetNodeDef, params, policyContext, paramCounter),
|
|
1293
|
+
this.buildTargetPolicyPredicate(targetNodeDef, discTarget, params, policyContext, paramCounter),
|
|
1294
|
+
].filter((c) => Boolean(c));
|
|
1148
1295
|
if (nestedDiscConditions.length > 0)
|
|
1149
1296
|
lines.push(`WHERE ${nestedDiscConditions.join(' AND ')}`);
|
|
1150
1297
|
lines.push(`DELETE ${relVar}`);
|
|
@@ -1174,11 +1321,10 @@ export class MutationCompiler {
|
|
|
1174
1321
|
// connect handle the same shapes as nested disconnect and
|
|
1175
1322
|
// top-level paths. Empty/undefined `whereSpec` short-circuits
|
|
1176
1323
|
// to `[]` inside the helper, so the no-where case still works.
|
|
1177
|
-
const
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
: conditions;
|
|
1324
|
+
const nestedConnConditions = [
|
|
1325
|
+
this.compileTargetWhere(whereSpec, connectVar, targetNodeDef, params, policyContext, paramCounter),
|
|
1326
|
+
this.buildTargetPolicyPredicate(targetNodeDef, connectVar, params, policyContext, paramCounter),
|
|
1327
|
+
].filter((c) => Boolean(c));
|
|
1182
1328
|
if (nestedConnConditions.length > 0)
|
|
1183
1329
|
lines.push(`WHERE ${nestedConnConditions.join(' AND ')}`);
|
|
1184
1330
|
if (edgeInput && Object.keys(edgeInput).length > 0) {
|
|
@@ -1285,11 +1431,15 @@ export class MutationCompiler {
|
|
|
1285
1431
|
// compiler; this was the missed third sibling. The param
|
|
1286
1432
|
// prefix is chosen so the legacy plain-equality case keeps
|
|
1287
1433
|
// byte-identical param names (`${itemPrefix}_where_${prop}`).
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1434
|
+
// v2.3.0 — the nested update is row-filtered by the TARGET's
|
|
1435
|
+
// `update` policy, exactly like a direct update on that type
|
|
1436
|
+
// (pre-2.3.0 it enforced no target policy at all).
|
|
1437
|
+
const updateConditions = [
|
|
1438
|
+
this.compileTargetWhere(updateWhere, updateVar, targetNodeDef, params, policyContext, paramCounter),
|
|
1439
|
+
this.buildTargetPolicyPredicate(targetNodeDef, updateVar, params, policyContext, paramCounter, 'update'),
|
|
1440
|
+
].filter((c) => Boolean(c));
|
|
1441
|
+
if (updateConditions.length > 0)
|
|
1442
|
+
lines.push(`WHERE ${updateConditions.join(' AND ')}`);
|
|
1293
1443
|
if (setClauses.length > 0)
|
|
1294
1444
|
lines.push(`SET ${setClauses.join(', ')}`);
|
|
1295
1445
|
mergeParams(params, setParams);
|
|
@@ -1317,180 +1467,6 @@ export class MutationCompiler {
|
|
|
1317
1467
|
// No suffix — simple equality
|
|
1318
1468
|
return { baseProp: key, template: '%v = $%p' };
|
|
1319
1469
|
}
|
|
1320
|
-
/**
|
|
1321
|
-
* Build WHERE conditions for connect/disconnect operations.
|
|
1322
|
-
* Handles operator suffixes (_IN, _NOT, _NOT_IN, _CONTAINS, etc.),
|
|
1323
|
-
* NOT blocks, and relationship filters (_SOME, _NONE, _ALL, _SINGLE).
|
|
1324
|
-
*
|
|
1325
|
-
* @param targetNodeDef - Optional node definition for the target node.
|
|
1326
|
-
* Required to resolve relationship filters like `configurations_SOME`.
|
|
1327
|
-
* When omitted, relationship filters are silently skipped.
|
|
1328
|
-
*/
|
|
1329
|
-
buildNodeWhereConditions(nodeWhere, targetVar, prefix, params, targetNodeDef) {
|
|
1330
|
-
const conditions = [];
|
|
1331
|
-
for (const [prop, val] of Object.entries(nodeWhere))
|
|
1332
|
-
if (prop === 'NOT') {
|
|
1333
|
-
const notSpec = val;
|
|
1334
|
-
for (const [notProp, notVal] of Object.entries(notSpec)) {
|
|
1335
|
-
const { baseProp, template } = this.parseOperatorSuffix(notProp);
|
|
1336
|
-
assertSafePropertyName(baseProp, 'where property');
|
|
1337
|
-
const paramName = `${prefix}_NOT_${notProp}`;
|
|
1338
|
-
const escapedProp = escapeIdentifier(baseProp);
|
|
1339
|
-
const paramRef = wrapWhereParam(`$${paramName}`, notVal, targetNodeDef?.properties.get(baseProp), notProp.slice(baseProp.length));
|
|
1340
|
-
let expr;
|
|
1341
|
-
if (template === '%v = $%p')
|
|
1342
|
-
// Simple equality → negate to <>
|
|
1343
|
-
expr = `${targetVar}.${escapedProp} <> ${paramRef}`;
|
|
1344
|
-
else {
|
|
1345
|
-
// Operator suffix — wrap with NOT if not already negated
|
|
1346
|
-
const resolved = template
|
|
1347
|
-
.replace('%v', `${targetVar}.${escapedProp}`)
|
|
1348
|
-
.replace('$%p', paramRef);
|
|
1349
|
-
expr = resolved.startsWith('NOT ') ? resolved : `NOT ${resolved}`;
|
|
1350
|
-
}
|
|
1351
|
-
conditions.push(expr);
|
|
1352
|
-
params[paramName] = notVal;
|
|
1353
|
-
}
|
|
1354
|
-
}
|
|
1355
|
-
else {
|
|
1356
|
-
// Check for relationship filter suffixes (_SOME, _NONE, _ALL, _SINGLE)
|
|
1357
|
-
const relFilter = this.tryBuildRelationshipFilter(prop, val, targetVar, prefix, params, targetNodeDef);
|
|
1358
|
-
if (relFilter) {
|
|
1359
|
-
conditions.push(relFilter);
|
|
1360
|
-
continue;
|
|
1361
|
-
}
|
|
1362
|
-
const { baseProp, template } = this.parseOperatorSuffix(prop);
|
|
1363
|
-
assertSafePropertyName(baseProp, 'where property');
|
|
1364
|
-
const paramName = `${prefix}_${prop}`;
|
|
1365
|
-
const paramRef = wrapWhereParam(`$${paramName}`, val, targetNodeDef?.properties.get(baseProp), prop.slice(baseProp.length));
|
|
1366
|
-
conditions.push(template
|
|
1367
|
-
.replace('%v', `${targetVar}.${escapeIdentifier(baseProp)}`)
|
|
1368
|
-
.replace('$%p', paramRef));
|
|
1369
|
-
params[paramName] = val;
|
|
1370
|
-
}
|
|
1371
|
-
return conditions;
|
|
1372
|
-
}
|
|
1373
|
-
/**
|
|
1374
|
-
* Build WHERE conditions from a connection-level WHERE spec.
|
|
1375
|
-
* Handles `node`, `NOT`, `AND`, `OR` at the connection level.
|
|
1376
|
-
*
|
|
1377
|
-
* Connection WHERE format: `{ node: {...}, NOT: { node: {...} }, AND: [...], OR: [...] }`
|
|
1378
|
-
*/
|
|
1379
|
-
buildConnectionWhereConditions(whereSpec, targetVar, prefix, params, targetNodeDef) {
|
|
1380
|
-
if (!whereSpec || Object.keys(whereSpec).length === 0)
|
|
1381
|
-
return [];
|
|
1382
|
-
const conditions = [];
|
|
1383
|
-
// Direct node conditions
|
|
1384
|
-
if (whereSpec.node) {
|
|
1385
|
-
const nodeConditions = this.buildNodeWhereConditions(whereSpec.node, targetVar, prefix, params, targetNodeDef);
|
|
1386
|
-
conditions.push(...nodeConditions);
|
|
1387
|
-
}
|
|
1388
|
-
// NOT wrapper — negate the inner connection conditions
|
|
1389
|
-
if (whereSpec.NOT) {
|
|
1390
|
-
const notSpec = whereSpec.NOT;
|
|
1391
|
-
const innerConditions = this.buildConnectionWhereConditions(notSpec, targetVar, `${prefix}_NOT`, params, targetNodeDef);
|
|
1392
|
-
if (innerConditions.length > 0)
|
|
1393
|
-
conditions.push(`NOT (${innerConditions.join(' AND ')})`);
|
|
1394
|
-
}
|
|
1395
|
-
// AND — all sub-conditions must hold
|
|
1396
|
-
if (Array.isArray(whereSpec.AND)) {
|
|
1397
|
-
const before = conditions.length;
|
|
1398
|
-
for (let i = 0; i < whereSpec.AND.length; i++) {
|
|
1399
|
-
const sub = this.buildConnectionWhereConditions(whereSpec.AND[i], targetVar, `${prefix}_AND${i}`, params, targetNodeDef);
|
|
1400
|
-
conditions.push(...sub);
|
|
1401
|
-
}
|
|
1402
|
-
if (conditions.length === before)
|
|
1403
|
-
this.warnEmptyLogical('AND', 'mutation connection where');
|
|
1404
|
-
}
|
|
1405
|
-
// OR — any sub-condition must hold
|
|
1406
|
-
if (Array.isArray(whereSpec.OR)) {
|
|
1407
|
-
const orClauses = [];
|
|
1408
|
-
for (let i = 0; i < whereSpec.OR.length; i++) {
|
|
1409
|
-
const sub = this.buildConnectionWhereConditions(whereSpec.OR[i], targetVar, `${prefix}_OR${i}`, params, targetNodeDef);
|
|
1410
|
-
if (sub.length > 0)
|
|
1411
|
-
orClauses.push(`(${sub.join(' AND ')})`);
|
|
1412
|
-
}
|
|
1413
|
-
if (orClauses.length > 0)
|
|
1414
|
-
conditions.push(`(${orClauses.join(' OR ')})`);
|
|
1415
|
-
// Prisma semantics: OR with zero effective disjuncts matches NOTHING.
|
|
1416
|
-
// Previously the operator vanished, so `disconnect: [{ where:
|
|
1417
|
-
// { OR: [] } }]` detached EVERY related node instead of none.
|
|
1418
|
-
else {
|
|
1419
|
-
this.warnEmptyLogical('OR', 'mutation connection where');
|
|
1420
|
-
conditions.push('false');
|
|
1421
|
-
}
|
|
1422
|
-
}
|
|
1423
|
-
// Fallback: if no recognized keys, treat the whole spec as node conditions
|
|
1424
|
-
// (backward compatibility for simple `{ id: '...' }` style)
|
|
1425
|
-
if (!whereSpec.node && !whereSpec.NOT && !whereSpec.AND && !whereSpec.OR) {
|
|
1426
|
-
// Reject unsupported connection WHERE keys (e.g., "edge")
|
|
1427
|
-
if (whereSpec.edge !== undefined)
|
|
1428
|
-
throw new OGMError(`Connection WHERE with "edge" filters is not supported in mutations. ` +
|
|
1429
|
-
`Only "node", "NOT", "AND", "OR" keys or direct property conditions are allowed.`);
|
|
1430
|
-
const fallbackConditions = this.buildNodeWhereConditions(whereSpec, targetVar, prefix, params, targetNodeDef);
|
|
1431
|
-
conditions.push(...fallbackConditions);
|
|
1432
|
-
}
|
|
1433
|
-
return conditions;
|
|
1434
|
-
}
|
|
1435
|
-
/**
|
|
1436
|
-
* Try to compile a relationship filter condition (_SOME, _NONE, _ALL, _SINGLE).
|
|
1437
|
-
* Returns the Cypher expression string or null if the key is not a relationship filter.
|
|
1438
|
-
*/
|
|
1439
|
-
tryBuildRelationshipFilter(key, value, nodeVar, prefix, params, targetNodeDef) {
|
|
1440
|
-
if (!targetNodeDef || typeof value !== 'object' || value === null)
|
|
1441
|
-
return null;
|
|
1442
|
-
// Detect suffix and extract field name
|
|
1443
|
-
let suffix = null;
|
|
1444
|
-
let fieldName = '';
|
|
1445
|
-
for (const s of RELATIONSHIP_SUFFIXES)
|
|
1446
|
-
if (key.endsWith(s)) {
|
|
1447
|
-
suffix = s;
|
|
1448
|
-
fieldName = key.slice(0, -s.length);
|
|
1449
|
-
break;
|
|
1450
|
-
}
|
|
1451
|
-
// If no suffix, check if bare key is a relationship (defaults to _SOME)
|
|
1452
|
-
if (!suffix) {
|
|
1453
|
-
const relDef = targetNodeDef.relationships.get(key);
|
|
1454
|
-
if (!relDef)
|
|
1455
|
-
return null;
|
|
1456
|
-
suffix = '_SOME';
|
|
1457
|
-
fieldName = key;
|
|
1458
|
-
}
|
|
1459
|
-
const relDef = targetNodeDef.relationships.get(fieldName);
|
|
1460
|
-
if (!relDef)
|
|
1461
|
-
return null;
|
|
1462
|
-
const relTargetDef = resolveTargetDef(relDef.target, this.schema);
|
|
1463
|
-
if (!relTargetDef)
|
|
1464
|
-
return null;
|
|
1465
|
-
const relVar = `rf${this.relFilterCounter++}`;
|
|
1466
|
-
const innerWhere = value;
|
|
1467
|
-
// Build the MATCH pattern
|
|
1468
|
-
const edgePart = `[:${escapeIdentifier(relDef.type)}]`;
|
|
1469
|
-
const escapedTarget = escapeIdentifier(relDef.target);
|
|
1470
|
-
const pattern = relDef.direction === 'IN'
|
|
1471
|
-
? `(${nodeVar})<-${edgePart}-(${relVar}:${escapedTarget})`
|
|
1472
|
-
: `(${nodeVar})-${edgePart}->(${relVar}:${escapedTarget})`;
|
|
1473
|
-
// Recursively compile inner conditions
|
|
1474
|
-
const innerConditions = this.buildNodeWhereConditions(innerWhere, relVar, `${prefix}_${fieldName}`, params, relTargetDef);
|
|
1475
|
-
const innerClause = innerConditions.length > 0
|
|
1476
|
-
? ` WHERE ${innerConditions.join(' AND ')}`
|
|
1477
|
-
: '';
|
|
1478
|
-
switch (suffix) {
|
|
1479
|
-
case '_SOME':
|
|
1480
|
-
return `EXISTS { MATCH ${pattern}${innerClause} }`;
|
|
1481
|
-
case '_NONE':
|
|
1482
|
-
return `NOT EXISTS { MATCH ${pattern}${innerClause} }`;
|
|
1483
|
-
case '_ALL':
|
|
1484
|
-
// Double negation: all match ≡ none fail to match
|
|
1485
|
-
if (!innerClause)
|
|
1486
|
-
return '';
|
|
1487
|
-
return `NOT EXISTS { MATCH ${pattern} WHERE NOT (${innerConditions.join(' AND ')}) }`;
|
|
1488
|
-
case '_SINGLE':
|
|
1489
|
-
return `size([${relVar} IN [(${pattern}${innerClause} | ${relVar})] | ${relVar}]) = 1`;
|
|
1490
|
-
default:
|
|
1491
|
-
return null;
|
|
1492
|
-
}
|
|
1493
|
-
}
|
|
1494
1470
|
buildGeneratedIdClause(nodeDef) {
|
|
1495
1471
|
const parts = [];
|
|
1496
1472
|
for (const [, propDef] of nodeDef.properties)
|
|
@@ -1498,38 +1474,6 @@ export class MutationCompiler {
|
|
|
1498
1474
|
parts.push(`${escapeIdentifier(propDef.name)}: randomUUID()`);
|
|
1499
1475
|
return parts.join(', ');
|
|
1500
1476
|
}
|
|
1501
|
-
/**
|
|
1502
|
-
* Check if any item in an array connect spec has relationship filters in its WHERE clause.
|
|
1503
|
-
* Relationship filters require EXISTS subqueries which can't use UNWIND param references.
|
|
1504
|
-
*/
|
|
1505
|
-
arrayConnectHasRelationshipFilters(items, targetNodeDef) {
|
|
1506
|
-
for (const item of items) {
|
|
1507
|
-
const whereSpec = item.where;
|
|
1508
|
-
if (!whereSpec)
|
|
1509
|
-
continue;
|
|
1510
|
-
const nodeWhere = (whereSpec.node ?? whereSpec);
|
|
1511
|
-
for (const key of Object.keys(nodeWhere)) {
|
|
1512
|
-
// Strip operator suffixes to get the base field name
|
|
1513
|
-
let fieldName = key;
|
|
1514
|
-
for (const s of RELATIONSHIP_SUFFIXES)
|
|
1515
|
-
if (key.endsWith(s)) {
|
|
1516
|
-
fieldName = key.slice(0, -s.length);
|
|
1517
|
-
break;
|
|
1518
|
-
}
|
|
1519
|
-
// If the base field name is a relationship on the target, it's a rel filter
|
|
1520
|
-
if (targetNodeDef.relationships.has(fieldName))
|
|
1521
|
-
return true;
|
|
1522
|
-
// Also check if the value is an object (nested filter on a relationship)
|
|
1523
|
-
const val = nodeWhere[key];
|
|
1524
|
-
if (typeof val === 'object' &&
|
|
1525
|
-
val !== null &&
|
|
1526
|
-
!Array.isArray(val) &&
|
|
1527
|
-
targetNodeDef.relationships.has(key))
|
|
1528
|
-
return true;
|
|
1529
|
-
}
|
|
1530
|
-
}
|
|
1531
|
-
return false;
|
|
1532
|
-
}
|
|
1533
1477
|
extractConnectWhereConditions(item, targetVar, itemVar, targetNodeDef) {
|
|
1534
1478
|
const conditions = [];
|
|
1535
1479
|
const whereSpec = item.where;
|
|
@@ -1586,6 +1530,99 @@ MutationCompiler.OPERATOR_SUFFIXES = [
|
|
|
1586
1530
|
* path to validate that every item shares the same shape; mismatched
|
|
1587
1531
|
* shapes silently drop keys from the compiled WHERE / SET.
|
|
1588
1532
|
*/
|
|
1533
|
+
/** Keys that make a nested-mutation `where` connection-shaped. */
|
|
1534
|
+
const CONNECTION_WHERE_KEYS = new Set([
|
|
1535
|
+
'node',
|
|
1536
|
+
'node_NOT',
|
|
1537
|
+
'NOT',
|
|
1538
|
+
'AND',
|
|
1539
|
+
'OR',
|
|
1540
|
+
'edge',
|
|
1541
|
+
'edge_NOT',
|
|
1542
|
+
]);
|
|
1543
|
+
/**
|
|
1544
|
+
* Map a nested-mutation connection where onto an equivalent NODE where on
|
|
1545
|
+
* the target, for `WhereCompiler` (v2.3.0). Mutation `where` never supports
|
|
1546
|
+
* edge filters, so the two shapes are interchangeable:
|
|
1547
|
+
*
|
|
1548
|
+
* { node: A } → A
|
|
1549
|
+
* { node_NOT: A } → { NOT: A }
|
|
1550
|
+
* { NOT: C } → { NOT: map(C) }
|
|
1551
|
+
* { AND: [C…] } → { AND: [map(C)…] } (likewise OR; `OR: []`
|
|
1552
|
+
* still matches nothing)
|
|
1553
|
+
* bare properties → themselves (legacy `{ id: 'x' }` shape)
|
|
1554
|
+
*
|
|
1555
|
+
* `edge`/`edge_NOT` throw (unsupported in mutations). In a connection-
|
|
1556
|
+
* shaped where, any other key throws too — the removed parallel builder
|
|
1557
|
+
* silently IGNORED such keys (`{ id: 'x', NOT: {…} }` dropped `id`),
|
|
1558
|
+
* widening the match.
|
|
1559
|
+
*/
|
|
1560
|
+
export function connectionWhereToNodeWhere(spec) {
|
|
1561
|
+
if (!isPlainObject(spec))
|
|
1562
|
+
throw new OGMError('A nested mutation where must be an object.');
|
|
1563
|
+
const keys = Object.keys(spec).filter((k) => spec[k] !== undefined);
|
|
1564
|
+
const edgeKey = keys.find((k) => k === 'edge' || k === 'edge_NOT');
|
|
1565
|
+
if (edgeKey)
|
|
1566
|
+
throw new OGMError(`Connection WHERE with "${edgeKey}" filters is not supported in mutations. ` +
|
|
1567
|
+
`Only "node", "node_NOT", "NOT", "AND", "OR" keys or direct property conditions are allowed.`);
|
|
1568
|
+
if (!keys.some((k) => CONNECTION_WHERE_KEYS.has(k)))
|
|
1569
|
+
return spec;
|
|
1570
|
+
const parts = [];
|
|
1571
|
+
for (const key of keys) {
|
|
1572
|
+
const value = spec[key];
|
|
1573
|
+
if (value === null)
|
|
1574
|
+
continue;
|
|
1575
|
+
switch (key) {
|
|
1576
|
+
case 'node':
|
|
1577
|
+
parts.push(value);
|
|
1578
|
+
break;
|
|
1579
|
+
case 'node_NOT':
|
|
1580
|
+
parts.push({ NOT: value });
|
|
1581
|
+
break;
|
|
1582
|
+
case 'NOT':
|
|
1583
|
+
parts.push({
|
|
1584
|
+
NOT: connectionWhereToNodeWhere(value),
|
|
1585
|
+
});
|
|
1586
|
+
break;
|
|
1587
|
+
case 'AND':
|
|
1588
|
+
case 'OR':
|
|
1589
|
+
if (!Array.isArray(value))
|
|
1590
|
+
throw new OGMError(`"${key}" in a nested mutation where must be an array.`);
|
|
1591
|
+
parts.push({
|
|
1592
|
+
[key]: value.map((item) => connectionWhereToNodeWhere(item)),
|
|
1593
|
+
});
|
|
1594
|
+
break;
|
|
1595
|
+
default:
|
|
1596
|
+
throw new OGMError(`Unknown key "${key}" in a connection-shaped nested mutation where. ` +
|
|
1597
|
+
`Allowed: "node", "node_NOT", "NOT", "AND", "OR" — put property ` +
|
|
1598
|
+
`conditions inside "node".`);
|
|
1599
|
+
}
|
|
1600
|
+
}
|
|
1601
|
+
return parts.length === 1 ? parts[0] : { AND: parts };
|
|
1602
|
+
}
|
|
1603
|
+
/** A nested delete item's own `delete` asks for a deeper cascade. */
|
|
1604
|
+
function hasNestedCascade(value) {
|
|
1605
|
+
if (value === undefined || value === null)
|
|
1606
|
+
return false;
|
|
1607
|
+
if (Array.isArray(value))
|
|
1608
|
+
return value.length > 0;
|
|
1609
|
+
return isPlainObject(value) ? Object.keys(value).length > 0 : true;
|
|
1610
|
+
}
|
|
1611
|
+
/**
|
|
1612
|
+
* A `param<N>` counter that continues after every `param<N>` already in
|
|
1613
|
+
* `params`, for callers that did not thread their own counter (direct
|
|
1614
|
+
* `MutationCompiler` use): nested-write filters compiled with it can never
|
|
1615
|
+
* collide with the root WHERE's params.
|
|
1616
|
+
*/
|
|
1617
|
+
function counterAfter(params) {
|
|
1618
|
+
let max = -1;
|
|
1619
|
+
for (const key of Object.keys(params)) {
|
|
1620
|
+
const match = /^param(\d+)$/.exec(key);
|
|
1621
|
+
if (match)
|
|
1622
|
+
max = Math.max(max, Number(match[1]));
|
|
1623
|
+
}
|
|
1624
|
+
return { count: max + 1 };
|
|
1625
|
+
}
|
|
1589
1626
|
function computeConnectItemSignature(item) {
|
|
1590
1627
|
const where = item.where;
|
|
1591
1628
|
const nodeWhere = (where?.node ?? where ?? {});
|