grafeo-ogm 2.4.7 → 2.5.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 +95 -35
- package/dist/cjs/cli/commands/db-push.d.ts.map +1 -1
- package/dist/cjs/cli/commands/db-push.js +74 -8
- package/dist/cjs/cli/commands/db-push.js.map +1 -1
- package/dist/cjs/cli/commands/db-seed.d.ts.map +1 -1
- package/dist/cjs/cli/commands/db-seed.js +10 -1
- package/dist/cjs/cli/commands/db-seed.js.map +1 -1
- package/dist/cjs/cli/introspect.d.ts +2 -2
- package/dist/cjs/cli/introspect.d.ts.map +1 -1
- package/dist/cjs/cli/introspect.js +44 -2
- package/dist/cjs/cli/introspect.js.map +1 -1
- package/dist/cjs/cli/push-planner.d.ts +57 -2
- package/dist/cjs/cli/push-planner.d.ts.map +1 -1
- package/dist/cjs/cli/push-planner.js +161 -3
- package/dist/cjs/cli/push-planner.js.map +1 -1
- package/dist/cjs/compilers/fulltext.compiler.d.ts.map +1 -1
- package/dist/cjs/compilers/fulltext.compiler.js +5 -1
- package/dist/cjs/compilers/fulltext.compiler.js.map +1 -1
- package/dist/cjs/compilers/mutation.compiler.d.ts +107 -1
- package/dist/cjs/compilers/mutation.compiler.d.ts.map +1 -1
- package/dist/cjs/compilers/mutation.compiler.js +695 -422
- package/dist/cjs/compilers/mutation.compiler.js.map +1 -1
- package/dist/cjs/compilers/select-normalizer.d.ts.map +1 -1
- package/dist/cjs/compilers/select-normalizer.js +12 -1
- package/dist/cjs/compilers/select-normalizer.js.map +1 -1
- package/dist/cjs/compilers/selection.compiler.d.ts +56 -0
- package/dist/cjs/compilers/selection.compiler.d.ts.map +1 -1
- package/dist/cjs/compilers/selection.compiler.js +271 -29
- package/dist/cjs/compilers/selection.compiler.js.map +1 -1
- package/dist/cjs/compilers/where.compiler.d.ts.map +1 -1
- package/dist/cjs/compilers/where.compiler.js +36 -15
- package/dist/cjs/compilers/where.compiler.js.map +1 -1
- package/dist/cjs/execution/executor.d.ts +34 -9
- package/dist/cjs/execution/executor.d.ts.map +1 -1
- package/dist/cjs/execution/executor.js +42 -5
- package/dist/cjs/execution/executor.js.map +1 -1
- package/dist/cjs/execution/result-mapper.d.ts.map +1 -1
- package/dist/cjs/execution/result-mapper.js +4 -2
- package/dist/cjs/execution/result-mapper.js.map +1 -1
- package/dist/cjs/generator/generate-types.d.ts.map +1 -1
- package/dist/cjs/generator/generate-types.js +76 -0
- package/dist/cjs/generator/generate-types.js.map +1 -1
- package/dist/cjs/generator/type-emitters/aggregation-emitter.d.ts.map +1 -1
- package/dist/cjs/generator/type-emitters/aggregation-emitter.js +19 -7
- package/dist/cjs/generator/type-emitters/aggregation-emitter.js.map +1 -1
- package/dist/cjs/generator/type-emitters/input-emitter.js +13 -4
- package/dist/cjs/generator/type-emitters/input-emitter.js.map +1 -1
- package/dist/cjs/generator/type-emitters/model-map-emitter.d.ts +1 -0
- package/dist/cjs/generator/type-emitters/model-map-emitter.d.ts.map +1 -1
- package/dist/cjs/generator/type-emitters/model-map-emitter.js +5 -20
- package/dist/cjs/generator/type-emitters/model-map-emitter.js.map +1 -1
- package/dist/cjs/generator/type-emitters/node-type-emitter.d.ts +3 -0
- package/dist/cjs/generator/type-emitters/node-type-emitter.d.ts.map +1 -1
- package/dist/cjs/generator/type-emitters/node-type-emitter.js +6 -11
- package/dist/cjs/generator/type-emitters/node-type-emitter.js.map +1 -1
- package/dist/cjs/generator/type-emitters/select-fields-emitter.js +21 -2
- package/dist/cjs/generator/type-emitters/select-fields-emitter.js.map +1 -1
- package/dist/cjs/generator/type-emitters/where-emitter.js +19 -3
- package/dist/cjs/generator/type-emitters/where-emitter.js.map +1 -1
- package/dist/cjs/index.d.ts +1 -1
- package/dist/cjs/index.d.ts.map +1 -1
- package/dist/cjs/interface-model.d.ts +12 -0
- package/dist/cjs/interface-model.d.ts.map +1 -1
- package/dist/cjs/interface-model.js +27 -13
- package/dist/cjs/interface-model.js.map +1 -1
- package/dist/cjs/model.d.ts.map +1 -1
- package/dist/cjs/model.js +12 -0
- package/dist/cjs/model.js.map +1 -1
- package/dist/cjs/ogm.d.ts +22 -2
- package/dist/cjs/ogm.d.ts.map +1 -1
- package/dist/cjs/ogm.js +42 -5
- package/dist/cjs/ogm.js.map +1 -1
- package/dist/cjs/policy/nested-writes.d.ts.map +1 -1
- package/dist/cjs/policy/nested-writes.js +40 -20
- package/dist/cjs/policy/nested-writes.js.map +1 -1
- package/dist/cjs/schema/parser.d.ts.map +1 -1
- package/dist/cjs/schema/parser.js +94 -16
- package/dist/cjs/schema/parser.js.map +1 -1
- package/dist/cjs/schema/types.d.ts +1 -1
- package/dist/cjs/schema/types.d.ts.map +1 -1
- package/dist/cjs/subgraph/subgraph-operations.d.ts.map +1 -1
- package/dist/cjs/subgraph/subgraph-operations.js +71 -6
- package/dist/cjs/subgraph/subgraph-operations.js.map +1 -1
- package/dist/cjs/subgraph/types.d.ts +14 -0
- package/dist/cjs/subgraph/types.d.ts.map +1 -1
- package/dist/cjs/testing/cypher-assert.d.ts +19 -1
- package/dist/cjs/testing/cypher-assert.d.ts.map +1 -1
- package/dist/cjs/testing/cypher-assert.js +217 -13
- package/dist/cjs/testing/cypher-assert.js.map +1 -1
- package/dist/cjs/testing/neo4j-record-factory.d.ts +29 -2
- package/dist/cjs/testing/neo4j-record-factory.d.ts.map +1 -1
- package/dist/cjs/testing/neo4j-record-factory.js +93 -3
- package/dist/cjs/testing/neo4j-record-factory.js.map +1 -1
- package/dist/cjs/utils/cypher-field-projection.d.ts +31 -3
- package/dist/cjs/utils/cypher-field-projection.d.ts.map +1 -1
- package/dist/cjs/utils/cypher-field-projection.js +59 -7
- package/dist/cjs/utils/cypher-field-projection.js.map +1 -1
- package/dist/cjs/utils/write-coercion.d.ts +18 -0
- package/dist/cjs/utils/write-coercion.d.ts.map +1 -1
- package/dist/cjs/utils/write-coercion.js +74 -1
- package/dist/cjs/utils/write-coercion.js.map +1 -1
- package/dist/esm/cli/commands/db-push.d.ts.map +1 -1
- package/dist/esm/cli/commands/db-push.js +74 -8
- package/dist/esm/cli/commands/db-push.js.map +1 -1
- package/dist/esm/cli/commands/db-seed.d.ts.map +1 -1
- package/dist/esm/cli/commands/db-seed.js +10 -1
- package/dist/esm/cli/commands/db-seed.js.map +1 -1
- package/dist/esm/cli/introspect.d.ts +2 -2
- package/dist/esm/cli/introspect.d.ts.map +1 -1
- package/dist/esm/cli/introspect.js +44 -2
- package/dist/esm/cli/introspect.js.map +1 -1
- package/dist/esm/cli/push-planner.d.ts +57 -2
- package/dist/esm/cli/push-planner.d.ts.map +1 -1
- package/dist/esm/cli/push-planner.js +162 -4
- package/dist/esm/cli/push-planner.js.map +1 -1
- package/dist/esm/compilers/fulltext.compiler.d.ts.map +1 -1
- package/dist/esm/compilers/fulltext.compiler.js +5 -1
- package/dist/esm/compilers/fulltext.compiler.js.map +1 -1
- package/dist/esm/compilers/mutation.compiler.d.ts +107 -1
- package/dist/esm/compilers/mutation.compiler.d.ts.map +1 -1
- package/dist/esm/compilers/mutation.compiler.js +692 -422
- package/dist/esm/compilers/mutation.compiler.js.map +1 -1
- package/dist/esm/compilers/select-normalizer.d.ts.map +1 -1
- package/dist/esm/compilers/select-normalizer.js +12 -1
- package/dist/esm/compilers/select-normalizer.js.map +1 -1
- package/dist/esm/compilers/selection.compiler.d.ts +56 -0
- package/dist/esm/compilers/selection.compiler.d.ts.map +1 -1
- package/dist/esm/compilers/selection.compiler.js +273 -31
- package/dist/esm/compilers/selection.compiler.js.map +1 -1
- package/dist/esm/compilers/where.compiler.d.ts.map +1 -1
- package/dist/esm/compilers/where.compiler.js +37 -16
- package/dist/esm/compilers/where.compiler.js.map +1 -1
- package/dist/esm/execution/executor.d.ts +34 -9
- package/dist/esm/execution/executor.d.ts.map +1 -1
- package/dist/esm/execution/executor.js +41 -5
- package/dist/esm/execution/executor.js.map +1 -1
- package/dist/esm/execution/result-mapper.d.ts.map +1 -1
- package/dist/esm/execution/result-mapper.js +4 -2
- package/dist/esm/execution/result-mapper.js.map +1 -1
- package/dist/esm/generator/generate-types.d.ts.map +1 -1
- package/dist/esm/generator/generate-types.js +76 -0
- package/dist/esm/generator/generate-types.js.map +1 -1
- package/dist/esm/generator/type-emitters/aggregation-emitter.d.ts.map +1 -1
- package/dist/esm/generator/type-emitters/aggregation-emitter.js +19 -7
- package/dist/esm/generator/type-emitters/aggregation-emitter.js.map +1 -1
- package/dist/esm/generator/type-emitters/input-emitter.js +13 -4
- package/dist/esm/generator/type-emitters/input-emitter.js.map +1 -1
- package/dist/esm/generator/type-emitters/model-map-emitter.d.ts +1 -0
- package/dist/esm/generator/type-emitters/model-map-emitter.d.ts.map +1 -1
- package/dist/esm/generator/type-emitters/model-map-emitter.js +5 -20
- package/dist/esm/generator/type-emitters/model-map-emitter.js.map +1 -1
- package/dist/esm/generator/type-emitters/node-type-emitter.d.ts +3 -0
- package/dist/esm/generator/type-emitters/node-type-emitter.d.ts.map +1 -1
- package/dist/esm/generator/type-emitters/node-type-emitter.js +6 -11
- package/dist/esm/generator/type-emitters/node-type-emitter.js.map +1 -1
- package/dist/esm/generator/type-emitters/select-fields-emitter.js +21 -2
- package/dist/esm/generator/type-emitters/select-fields-emitter.js.map +1 -1
- package/dist/esm/generator/type-emitters/where-emitter.js +19 -3
- package/dist/esm/generator/type-emitters/where-emitter.js.map +1 -1
- package/dist/esm/index.d.ts +1 -1
- package/dist/esm/index.d.ts.map +1 -1
- package/dist/esm/interface-model.d.ts +12 -0
- package/dist/esm/interface-model.d.ts.map +1 -1
- package/dist/esm/interface-model.js +27 -13
- package/dist/esm/interface-model.js.map +1 -1
- package/dist/esm/model.d.ts.map +1 -1
- package/dist/esm/model.js +12 -0
- package/dist/esm/model.js.map +1 -1
- package/dist/esm/ogm.d.ts +22 -2
- package/dist/esm/ogm.d.ts.map +1 -1
- package/dist/esm/ogm.js +43 -6
- package/dist/esm/ogm.js.map +1 -1
- package/dist/esm/policy/nested-writes.d.ts.map +1 -1
- package/dist/esm/policy/nested-writes.js +40 -20
- package/dist/esm/policy/nested-writes.js.map +1 -1
- package/dist/esm/schema/parser.d.ts.map +1 -1
- package/dist/esm/schema/parser.js +94 -16
- package/dist/esm/schema/parser.js.map +1 -1
- package/dist/esm/schema/types.d.ts +1 -1
- package/dist/esm/schema/types.d.ts.map +1 -1
- package/dist/esm/subgraph/subgraph-operations.d.ts.map +1 -1
- package/dist/esm/subgraph/subgraph-operations.js +71 -6
- package/dist/esm/subgraph/subgraph-operations.js.map +1 -1
- package/dist/esm/subgraph/types.d.ts +14 -0
- package/dist/esm/subgraph/types.d.ts.map +1 -1
- package/dist/esm/testing/cypher-assert.d.ts +19 -1
- package/dist/esm/testing/cypher-assert.d.ts.map +1 -1
- package/dist/esm/testing/cypher-assert.js +217 -13
- package/dist/esm/testing/cypher-assert.js.map +1 -1
- package/dist/esm/testing/neo4j-record-factory.d.ts +29 -2
- package/dist/esm/testing/neo4j-record-factory.d.ts.map +1 -1
- package/dist/esm/testing/neo4j-record-factory.js +93 -3
- package/dist/esm/testing/neo4j-record-factory.js.map +1 -1
- package/dist/esm/utils/cypher-field-projection.d.ts +31 -3
- package/dist/esm/utils/cypher-field-projection.d.ts.map +1 -1
- package/dist/esm/utils/cypher-field-projection.js +58 -7
- package/dist/esm/utils/cypher-field-projection.js.map +1 -1
- package/dist/esm/utils/write-coercion.d.ts +18 -0
- package/dist/esm/utils/write-coercion.d.ts.map +1 -1
- package/dist/esm/utils/write-coercion.js +73 -1
- package/dist/esm/utils/write-coercion.js.map +1 -1
- package/package.json +1 -1
|
@@ -5,6 +5,33 @@ import { buildRelPattern, getTargetLabelString, resolveTargetDef, } from '../sch
|
|
|
5
5
|
import { assertSafeIdentifier, assertSafeLabel, assertSafePropertyName, escapeIdentifier, isPlainObject, mergeParams, } from '../utils/validation';
|
|
6
6
|
import { coerceWriteValue, resolveDefaultWriteValue, wrapListItemExpr, wrapWhereParam, wrapWriteExpr, } from '../utils/write-coercion';
|
|
7
7
|
import { WhereCompiler } from './where.compiler';
|
|
8
|
+
/** The root node `n` of an update — the pre-2.4.8 names, unprefixed. */
|
|
9
|
+
const TOP_SCOPE = {
|
|
10
|
+
sourceVar: 'n',
|
|
11
|
+
carryVars: ['n'],
|
|
12
|
+
withClause: 'WITH n',
|
|
13
|
+
varPrefix: '',
|
|
14
|
+
depth: 0,
|
|
15
|
+
};
|
|
16
|
+
/** Max depth of connects nested in connects (disconnects likewise). */
|
|
17
|
+
const MAX_NESTED_LINK_DEPTH = 5;
|
|
18
|
+
/**
|
|
19
|
+
* The scope of a connect / disconnect nested in an item whose target is
|
|
20
|
+
* bound as `targetVar`: that node becomes the source, everything in scope
|
|
21
|
+
* stays carried, and its own names are prefixed by it.
|
|
22
|
+
*/
|
|
23
|
+
function nestedScope(carryVars, targetVar, depth) {
|
|
24
|
+
if (depth >= MAX_NESTED_LINK_DEPTH)
|
|
25
|
+
throw new OGMError(`Nested connect / disconnect deeper than ${MAX_NESTED_LINK_DEPTH} levels is not supported.`);
|
|
26
|
+
const vars = [...new Set([...carryVars, targetVar])];
|
|
27
|
+
return {
|
|
28
|
+
sourceVar: targetVar,
|
|
29
|
+
carryVars: vars,
|
|
30
|
+
withClause: `WITH ${vars.join(', ')}`,
|
|
31
|
+
varPrefix: `${targetVar}_`,
|
|
32
|
+
depth: depth + 1,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
8
35
|
/**
|
|
9
36
|
* Compiles create, update, delete, and label mutations into Cypher + params.
|
|
10
37
|
*/
|
|
@@ -16,6 +43,13 @@ export class MutationCompiler {
|
|
|
16
43
|
* Bounded by the number of node types in the schema (~50-100 entries).
|
|
17
44
|
*/
|
|
18
45
|
this.labelCache = new Map();
|
|
46
|
+
/**
|
|
47
|
+
* Relationships narrowed from an abstract target to one member by the
|
|
48
|
+
* per-member dispatch. A BLANKET disconnect on one adds the member's
|
|
49
|
+
* label: the relationship type alone also reaches the other members, so
|
|
50
|
+
* pre-2.4.8 `{ Ebook: [{ disconnect: [{}] }] }` unlinked every member.
|
|
51
|
+
*/
|
|
52
|
+
this.narrowedRelDefs = new WeakSet();
|
|
19
53
|
this.logger = options?.logger;
|
|
20
54
|
this.policyWhereCompiler = options?.whereCompiler;
|
|
21
55
|
}
|
|
@@ -134,6 +168,9 @@ export class MutationCompiler {
|
|
|
134
168
|
*/
|
|
135
169
|
isConnectFastPathEligible(items, targetNodeDef) {
|
|
136
170
|
for (const item of items) {
|
|
171
|
+
// A nested `connect` is per item (v2.4.8): the UNWIND cannot run it.
|
|
172
|
+
if (item.connect != null)
|
|
173
|
+
return false;
|
|
137
174
|
const where = item.where;
|
|
138
175
|
if (where === undefined)
|
|
139
176
|
continue;
|
|
@@ -185,6 +222,36 @@ export class MutationCompiler {
|
|
|
185
222
|
this.labelCache.set(nodeDef.typeName, labelStr);
|
|
186
223
|
return labelStr;
|
|
187
224
|
}
|
|
225
|
+
/**
|
|
226
|
+
* The MATCH that binds a connect TARGET by type. A node type keeps its
|
|
227
|
+
* label pattern; an interface / union target matches its MEMBERS' labels
|
|
228
|
+
* through a WHERE predicate (v2.4.8). Pre-2.4.8 it matched the abstract
|
|
229
|
+
* type's own label, which implementer and member nodes do not carry, so
|
|
230
|
+
* the connect linked nothing.
|
|
231
|
+
*/
|
|
232
|
+
targetMatch(targetNodeDef, targetVar) {
|
|
233
|
+
const abstract = abstractTarget(targetNodeDef.typeName, this.schema);
|
|
234
|
+
if (!abstract)
|
|
235
|
+
return {
|
|
236
|
+
match: `MATCH (${targetVar}:${this.getCachedLabelString(targetNodeDef)})`,
|
|
237
|
+
labelPredicate: null,
|
|
238
|
+
};
|
|
239
|
+
const alternatives = abstract.members.map((m) => `${targetVar}:${this.getCachedLabelString(this.schema.nodes.get(m))}`);
|
|
240
|
+
return {
|
|
241
|
+
match: `MATCH (${targetVar})`,
|
|
242
|
+
labelPredicate: alternatives.length > 0 ? `(${alternatives.join(' OR ')})` : 'false',
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* Resolve a relationship's target for a nested write. Throws when the
|
|
247
|
+
* target is unknown — pre-2.4.8 the nested op was silently skipped.
|
|
248
|
+
*/
|
|
249
|
+
requireTargetDef(relDef) {
|
|
250
|
+
const targetNodeDef = resolveTargetDef(relDef.target, this.schema);
|
|
251
|
+
if (!targetNodeDef)
|
|
252
|
+
throw new OGMError(`Cannot resolve the target type "${relDef.target}" of relationship "${relDef.fieldName}".`);
|
|
253
|
+
return targetNodeDef;
|
|
254
|
+
}
|
|
188
255
|
/**
|
|
189
256
|
* Generate CREATE Cypher for one or more nodes.
|
|
190
257
|
* Handles scalar properties, nested relationship creates, and connects.
|
|
@@ -209,8 +276,10 @@ export class MutationCompiler {
|
|
|
209
276
|
const input = inputs[i];
|
|
210
277
|
const prefix = `create${i}`;
|
|
211
278
|
const nodeVar = i === 0 ? 'n' : `n_${i}`;
|
|
212
|
-
|
|
279
|
+
// Labels first: an unsafe label must fail as such, before any
|
|
280
|
+
// input-key check (v2.5.0 rejects unknown keys).
|
|
213
281
|
const labelStr = this.getCachedLabelString(nodeDef);
|
|
282
|
+
const { propString, propParams } = this.buildCreateProperties(input, nodeDef, prefix);
|
|
214
283
|
lines.push(`CREATE (${nodeVar}:${labelStr} { ${propString} })`);
|
|
215
284
|
mergeParams(params, propParams);
|
|
216
285
|
createdVars.push(nodeVar);
|
|
@@ -285,6 +354,7 @@ export class MutationCompiler {
|
|
|
285
354
|
if (value === undefined)
|
|
286
355
|
continue;
|
|
287
356
|
assertSafePropertyName(key, 'update property');
|
|
357
|
+
assertKnownField(nodeDef, key, 'update');
|
|
288
358
|
const propDef = nodeDef.properties.get(key);
|
|
289
359
|
const paramName = `update_${key}`;
|
|
290
360
|
const valueExpr = wrapWriteExpr(`$${paramName}`, value, propDef);
|
|
@@ -436,19 +506,31 @@ export class MutationCompiler {
|
|
|
436
506
|
const labelStr = this.getCachedLabelString(nodeDef);
|
|
437
507
|
const lines = [];
|
|
438
508
|
const params = {};
|
|
439
|
-
//
|
|
509
|
+
// MERGE key: the `@id` / `@unique` fields of `where`, and nothing else
|
|
510
|
+
// (v2.5.0). Any other key throws:
|
|
511
|
+
// - a plain field (`{ email, name }`) became part of the MERGE
|
|
512
|
+
// pattern, so an existing node with that email but another name did
|
|
513
|
+
// not match — MERGE created a DUPLICATE beside it;
|
|
514
|
+
// - an operator key (`name_CONTAINS`) was dropped, silently widening
|
|
515
|
+
// the match to the remaining keys;
|
|
516
|
+
// - a typo (`usernme`) became a phantom property on the new node.
|
|
517
|
+
// Pre-1.7.4 any key set was accepted; 1.7.4 required at least one
|
|
518
|
+
// unique key but still let the others through.
|
|
440
519
|
const mergeProps = [];
|
|
441
520
|
const mergeKeyNames = [];
|
|
442
521
|
for (const [key, value] of Object.entries(where)) {
|
|
443
|
-
if (value === undefined
|
|
444
|
-
continue;
|
|
445
|
-
// Skip relationship suffixes and operators
|
|
446
|
-
if (key === 'AND' || key === 'OR' || key === 'NOT')
|
|
447
|
-
continue;
|
|
448
|
-
if (key.includes('_') && !nodeDef.properties.has(key))
|
|
522
|
+
if (value === undefined)
|
|
449
523
|
continue;
|
|
450
|
-
assertSafePropertyName(key, 'merge key');
|
|
451
524
|
const propDef = nodeDef.properties.get(key);
|
|
525
|
+
if (!propDef ||
|
|
526
|
+
propDef.isCypher ||
|
|
527
|
+
!(propDef.isUnique || propDef.isGenerated))
|
|
528
|
+
throw new OGMError(`upsert "where" accepts only @id / @unique fields of ${nodeDef.typeName}; "${key}" is not one. ` +
|
|
529
|
+
`MERGE matches on exactly these keys — put other fields in "create" / "update", ` +
|
|
530
|
+
`or use findFirst() + create() / update() for a non-unique match.`);
|
|
531
|
+
if (value === null)
|
|
532
|
+
throw new OGMError(`upsert "where.${key}" is null; a MERGE key must have a value.`);
|
|
533
|
+
assertSafePropertyName(key, 'merge key');
|
|
452
534
|
const paramName = `merge_${key}`;
|
|
453
535
|
const valueExpr = wrapWriteExpr(`$${paramName}`, value, propDef);
|
|
454
536
|
mergeProps.push(`${escapeIdentifier(key)}: ${valueExpr}`);
|
|
@@ -456,39 +538,21 @@ export class MutationCompiler {
|
|
|
456
538
|
params[paramName] = coerceWriteValue(value, propDef);
|
|
457
539
|
}
|
|
458
540
|
if (mergeProps.length === 0)
|
|
459
|
-
throw new OGMError(
|
|
460
|
-
|
|
461
|
-
// `@id` property. Pre-1.7.4 we accepted any key set, so:
|
|
462
|
-
// - A typo in `where` (e.g. `usernme`) became a phantom MERGE
|
|
463
|
-
// property — Neo4j happily created a node with that mis-named
|
|
464
|
-
// attribute on first use.
|
|
465
|
-
// - A non-unique `where` (e.g. `where: { country: 'AR' }`) matched
|
|
466
|
-
// multiple existing nodes; MERGE then fans out across all of
|
|
467
|
-
// them, applying ON MATCH SET to every match. Almost always a
|
|
468
|
-
// bug, never the developer's intent.
|
|
469
|
-
// Requiring at least one unique/id key forces MERGE to target at
|
|
470
|
-
// most one row.
|
|
471
|
-
const hasUniqueOrId = mergeKeyNames.some((name) => {
|
|
472
|
-
const prop = nodeDef.properties.get(name);
|
|
473
|
-
// `isGenerated` is the parser's flag for the `@id` directive;
|
|
474
|
-
// `isUnique` is the flag for `@unique`. Either is sufficient
|
|
475
|
-
// to guarantee the MERGE pattern targets at most one row.
|
|
476
|
-
return prop !== undefined && (prop.isUnique || prop.isGenerated);
|
|
477
|
-
});
|
|
478
|
-
if (!hasUniqueOrId)
|
|
479
|
-
throw new OGMError(`upsert "where" must contain at least one property marked with @id or @unique. ` +
|
|
480
|
-
`Got [${mergeKeyNames.map((n) => `"${n}"`).join(', ')}] — none of these are unique on ${nodeDef.typeName}. ` +
|
|
481
|
-
`Without a unique key, MERGE can fan out across multiple existing nodes (applying ON MATCH SET to each) ` +
|
|
482
|
-
`or create phantom properties on typo. If you really want a non-unique merge, use create() + update().`);
|
|
541
|
+
throw new OGMError(`upsert requires at least one scalar property in "where" for MERGE key: ` +
|
|
542
|
+
`an @id or @unique field of ${nodeDef.typeName}.`);
|
|
483
543
|
lines.push(`MERGE (n:${labelStr} { ${mergeProps.join(', ')} })`);
|
|
484
544
|
// ON CREATE SET — all properties from create input (scalar only)
|
|
485
545
|
const createSets = [];
|
|
486
546
|
for (const [key, value] of Object.entries(create)) {
|
|
487
547
|
if (value === undefined)
|
|
488
548
|
continue;
|
|
489
|
-
if (nodeDef.relationships.has(key))
|
|
490
|
-
|
|
549
|
+
if (nodeDef.relationships.has(key)) {
|
|
550
|
+
if (value === null)
|
|
551
|
+
continue;
|
|
552
|
+
throw upsertRelationshipError(nodeDef, key, 'create');
|
|
553
|
+
}
|
|
491
554
|
assertSafePropertyName(key, 'create property');
|
|
555
|
+
assertKnownField(nodeDef, key, 'create');
|
|
492
556
|
const propDef = nodeDef.properties.get(key);
|
|
493
557
|
const paramName = `onCreate_${key}`;
|
|
494
558
|
const valueExpr = wrapWriteExpr(`$${paramName}`, value, propDef);
|
|
@@ -533,9 +597,13 @@ export class MutationCompiler {
|
|
|
533
597
|
for (const [key, value] of Object.entries(update)) {
|
|
534
598
|
if (value === undefined)
|
|
535
599
|
continue;
|
|
536
|
-
if (nodeDef.relationships.has(key))
|
|
537
|
-
|
|
600
|
+
if (nodeDef.relationships.has(key)) {
|
|
601
|
+
if (value === null)
|
|
602
|
+
continue;
|
|
603
|
+
throw upsertRelationshipError(nodeDef, key, 'update');
|
|
604
|
+
}
|
|
538
605
|
assertSafePropertyName(key, 'update property');
|
|
606
|
+
assertKnownField(nodeDef, key, 'update');
|
|
539
607
|
const propDef = nodeDef.properties.get(key);
|
|
540
608
|
const paramName = `onMatch_${key}`;
|
|
541
609
|
const valueExpr = wrapWriteExpr(`$${paramName}`, value, propDef);
|
|
@@ -609,16 +677,13 @@ export class MutationCompiler {
|
|
|
609
677
|
for (const key of Object.keys(item)) {
|
|
610
678
|
if (item[key] === undefined || scalarKeys.includes(key))
|
|
611
679
|
continue;
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
if (index > 0)
|
|
618
|
-
continue;
|
|
619
|
-
throw new OGMError(`createMany does not support relationship fields. Found: "${key}". Use create() for nested operations.`);
|
|
620
|
-
}
|
|
680
|
+
// v2.5.0 — rejected in EVERY item. Pre-2.5.0 only the first item's
|
|
681
|
+
// relationship fields were; in later items the nested input was
|
|
682
|
+
// skipped unwritten.
|
|
683
|
+
if (nodeDef.relationships.has(key))
|
|
684
|
+
throw new OGMError(`createMany does not support relationship fields. Found: "${key}" in data[${index}]. Use create() for nested operations.`);
|
|
621
685
|
assertSafePropertyName(key, 'createMany property');
|
|
686
|
+
assertKnownField(nodeDef, key, 'createMany');
|
|
622
687
|
scalarKeys.push(key);
|
|
623
688
|
}
|
|
624
689
|
// Identify @id (generated) fields that are NOT provided in data
|
|
@@ -750,6 +815,7 @@ export class MutationCompiler {
|
|
|
750
815
|
if (value === undefined)
|
|
751
816
|
continue;
|
|
752
817
|
assertSafePropertyName(key, 'create property');
|
|
818
|
+
assertKnownField(nodeDef, key, 'create');
|
|
753
819
|
const propDef = nodeDef.properties.get(key);
|
|
754
820
|
const paramName = `${prefix}_${key}`;
|
|
755
821
|
const valueExpr = wrapWriteExpr(`$${paramName}`, value, propDef);
|
|
@@ -785,19 +851,20 @@ export class MutationCompiler {
|
|
|
785
851
|
const relDef = nodeDef.relationships.get(key);
|
|
786
852
|
if (!relDef)
|
|
787
853
|
continue;
|
|
854
|
+
if (value == null)
|
|
855
|
+
continue;
|
|
788
856
|
const relInput = value;
|
|
789
|
-
//
|
|
790
|
-
//
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
857
|
+
// Union / interface targets: per-member keys (e.g. items: { Ebook:
|
|
858
|
+
// { create: [...] } }) dispatch to the concrete member; an interface
|
|
859
|
+
// also takes the flat `{ connect, create }` shape (v2.4.8 — pre-2.4.8
|
|
860
|
+
// interface targets were skipped entirely, so nothing was written).
|
|
861
|
+
const abstract = abstractTarget(relDef.target, this.schema);
|
|
862
|
+
const memberEntries = abstract
|
|
863
|
+
? perMemberEntries(relInput, abstract, key)
|
|
864
|
+
: null;
|
|
865
|
+
if (memberEntries) {
|
|
866
|
+
for (const [memberKey, memberValue] of memberEntries) {
|
|
798
867
|
const memberNodeDef = this.schema.nodes.get(memberKey);
|
|
799
|
-
if (!memberNodeDef)
|
|
800
|
-
continue;
|
|
801
868
|
const memberInput = memberValue;
|
|
802
869
|
// Process creates/connects for this union member
|
|
803
870
|
const memberLines = this.buildCreateRelationshipsForTarget(memberInput, memberNodeDef, relDef, nodeVar, `${prefix}_${key}_${memberKey}`, params, allVars, withClause, nestedCounter, policyContext, paramCounter);
|
|
@@ -806,9 +873,7 @@ export class MutationCompiler {
|
|
|
806
873
|
}
|
|
807
874
|
continue;
|
|
808
875
|
}
|
|
809
|
-
const targetNodeDef = this.
|
|
810
|
-
if (!targetNodeDef)
|
|
811
|
-
continue;
|
|
876
|
+
const targetNodeDef = this.requireTargetDef(relDef);
|
|
812
877
|
const resultLines = this.buildCreateRelationshipsForTarget(relInput, targetNodeDef, relDef, nodeVar, `${prefix}_${key}`, params, allVars, withClause, nestedCounter, policyContext, paramCounter);
|
|
813
878
|
nestedCounter += resultLines.counterUsed;
|
|
814
879
|
lines.push(...resultLines.lines);
|
|
@@ -822,7 +887,8 @@ export class MutationCompiler {
|
|
|
822
887
|
buildCreateRelationshipsForTarget(relInput, targetNodeDef, relDef, nodeVar, prefix, params, allVars, withClause, startCounter, policyContext, paramCounter) {
|
|
823
888
|
const lines = [];
|
|
824
889
|
let counter = 0;
|
|
825
|
-
|
|
890
|
+
// A flat interface input (v2.4.8): each create names its implementer.
|
|
891
|
+
const abstract = abstractTarget(targetNodeDef.typeName, this.schema);
|
|
826
892
|
// --- create ---
|
|
827
893
|
if (relInput.create) {
|
|
828
894
|
const createItems = Array.isArray(relInput.create)
|
|
@@ -830,15 +896,22 @@ export class MutationCompiler {
|
|
|
830
896
|
: [relInput.create];
|
|
831
897
|
for (let ci = 0; ci < createItems.length; ci++) {
|
|
832
898
|
const createSpec = createItems[ci];
|
|
833
|
-
|
|
899
|
+
let nodeSpec = (createSpec.node ?? createSpec);
|
|
900
|
+
let createDef = targetNodeDef;
|
|
901
|
+
if (abstract) {
|
|
902
|
+
const [member, memberSpec] = unwrapAbstractCreateNode(nodeSpec, abstract, relDef.fieldName);
|
|
903
|
+
createDef = this.schema.nodes.get(member);
|
|
904
|
+
nodeSpec = memberSpec;
|
|
905
|
+
}
|
|
906
|
+
const targetLabelStr = this.getCachedLabelString(createDef);
|
|
834
907
|
const createVar = `${nodeVar}_c${startCounter + counter}`;
|
|
835
908
|
counter++;
|
|
836
|
-
const { propString, propParams } = this.buildCreateProperties(nodeSpec,
|
|
909
|
+
const { propString, propParams } = this.buildCreateProperties(nodeSpec, createDef, `${prefix}_create${ci}`);
|
|
837
910
|
// Handle nested relationship creates within the created node
|
|
838
|
-
const nestedRelLines = this.buildCreateRelationships(nodeSpec,
|
|
911
|
+
const nestedRelLines = this.buildCreateRelationships(nodeSpec, createDef, createVar, `${prefix}_create${ci}`, params, allVars, policyContext, paramCounter);
|
|
839
912
|
const propsClause = propString.length > 0
|
|
840
913
|
? ` { ${propString} }`
|
|
841
|
-
: ` { ${this.buildGeneratedIdClause(
|
|
914
|
+
: ` { ${this.buildGeneratedIdClause(createDef)} }`;
|
|
842
915
|
const relPattern = buildRelPattern({
|
|
843
916
|
sourceVar: nodeVar,
|
|
844
917
|
relDef,
|
|
@@ -868,6 +941,9 @@ export class MutationCompiler {
|
|
|
868
941
|
targetVar: createVar,
|
|
869
942
|
edgeVar: relVar,
|
|
870
943
|
targetLabel: 'auto',
|
|
944
|
+
// Abstract targets: no label — the created node never carries
|
|
945
|
+
// the union / interface label (v2.4.8).
|
|
946
|
+
schema: this.schema,
|
|
871
947
|
});
|
|
872
948
|
lines.push(`WITH ${[...allVars, createVar].join(', ')}`);
|
|
873
949
|
lines.push(`MATCH ${relArrow}`);
|
|
@@ -897,339 +973,426 @@ export class MutationCompiler {
|
|
|
897
973
|
? relInput.connect
|
|
898
974
|
: [relInput.connect];
|
|
899
975
|
for (let ci = 0; ci < connectItems.length; ci++) {
|
|
900
|
-
const connectSpec = connectItems[ci];
|
|
901
|
-
const whereSpec = connectSpec.where;
|
|
902
|
-
const edgeInput = connectSpec.edge;
|
|
903
976
|
// Wrap in CALL subquery so a failed MATCH doesn't kill the outer pipeline
|
|
904
|
-
|
|
905
|
-
lines.push(`CALL {`);
|
|
906
|
-
lines.push(withClause);
|
|
907
|
-
const connectVar = `${nodeVar}_cn${startCounter + counter}`;
|
|
977
|
+
const index = startCounter + counter;
|
|
908
978
|
counter++;
|
|
909
|
-
lines.push(
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
? `(${nodeVar})-[${relVar}:${escapedRelType}]->(${connectVar})`
|
|
924
|
-
: `(${nodeVar})<-[${relVar}:${escapedRelType}]-(${connectVar})`;
|
|
925
|
-
lines.push(`MERGE ${mergePattern}`);
|
|
926
|
-
const setItems = [];
|
|
927
|
-
for (const [prop, val] of Object.entries(edgeInput)) {
|
|
928
|
-
if (val === undefined)
|
|
929
|
-
continue;
|
|
930
|
-
assertSafePropertyName(prop, 'edge property');
|
|
931
|
-
const edgePropDef = this.getEdgePropDef(relDef, prop);
|
|
932
|
-
const paramName = `${prefix}_conn${ci}_edge_${prop}`;
|
|
933
|
-
setItems.push(`r_conn_${startCounter + counter - 1}.${escapeIdentifier(prop)} = ${wrapWriteExpr(`$${paramName}`, val, edgePropDef)}`);
|
|
934
|
-
params[paramName] = coerceWriteValue(val, edgePropDef);
|
|
935
|
-
}
|
|
936
|
-
if (setItems.length > 0)
|
|
937
|
-
lines.push(`SET ${setItems.join(', ')}`);
|
|
938
|
-
}
|
|
939
|
-
else {
|
|
940
|
-
const relPattern = buildRelPattern({
|
|
979
|
+
lines.push(...this.buildConnectItem({
|
|
980
|
+
connectSpec: connectItems[ci],
|
|
981
|
+
relDef,
|
|
982
|
+
targetNodeDef,
|
|
983
|
+
sourceVar: nodeVar,
|
|
984
|
+
withClause,
|
|
985
|
+
connectVar: `${nodeVar}_cn${index}`,
|
|
986
|
+
relVar: `r_conn_${index}`,
|
|
987
|
+
edgeParamPrefix: `${prefix}_conn${ci}`,
|
|
988
|
+
returnAlias: `_ccn_${prefix}_${ci}`,
|
|
989
|
+
params,
|
|
990
|
+
policyContext,
|
|
991
|
+
paramCounter,
|
|
992
|
+
scope: {
|
|
941
993
|
sourceVar: nodeVar,
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
lines.push(`}`);
|
|
994
|
+
carryVars: allVars,
|
|
995
|
+
withClause,
|
|
996
|
+
varPrefix: '',
|
|
997
|
+
depth: 0,
|
|
998
|
+
},
|
|
999
|
+
}));
|
|
949
1000
|
}
|
|
950
1001
|
}
|
|
951
1002
|
return { lines, counterUsed: counter };
|
|
952
1003
|
}
|
|
953
|
-
|
|
1004
|
+
/**
|
|
1005
|
+
* Top-level `connect`. A union / interface relationship keyed by member
|
|
1006
|
+
* (`{ media: { Book: [...] } }`, the shape codegen emits) connects each
|
|
1007
|
+
* member separately; a flat interface input matches the implementers by
|
|
1008
|
+
* their own labels (v2.4.8 — pre-2.4.8 both matched the abstract type's
|
|
1009
|
+
* label, which no node carries, and the per-member shape was never
|
|
1010
|
+
* unwrapped, so the MATCH carried NO filter at all).
|
|
1011
|
+
*/
|
|
1012
|
+
buildConnects(connect, nodeDef, params, policyContext, paramCounter, scope = TOP_SCOPE) {
|
|
954
1013
|
const lines = [];
|
|
955
1014
|
for (const [fieldName, spec] of Object.entries(connect)) {
|
|
956
1015
|
const relDef = nodeDef.relationships.get(fieldName);
|
|
957
1016
|
if (!relDef)
|
|
958
1017
|
continue;
|
|
959
|
-
const
|
|
960
|
-
|
|
1018
|
+
const abstract = abstractTarget(relDef.target, this.schema);
|
|
1019
|
+
const memberEntries = abstract && !isBlanketSpec(spec)
|
|
1020
|
+
? perMemberEntries(spec, abstract, fieldName)
|
|
1021
|
+
: null;
|
|
1022
|
+
if (memberEntries) {
|
|
1023
|
+
for (const [member, memberSpec] of memberEntries)
|
|
1024
|
+
lines.push(...this.buildRelConnects(memberSpec, this.narrowToMember(relDef, member), this.schema.nodes.get(member), `${fieldName}_${member}`, params, policyContext, paramCounter, scope));
|
|
961
1025
|
continue;
|
|
962
|
-
const targetLabelStr = this.getCachedLabelString(targetNodeDef);
|
|
963
|
-
// Array connect
|
|
964
|
-
if (Array.isArray(spec)) {
|
|
965
|
-
if (spec.length === 0)
|
|
966
|
-
continue; // Empty array — nothing to connect
|
|
967
|
-
// The UNWIND fast path compiles filters against per-row values and
|
|
968
|
-
// is only exact for plain scalar, non-null filters (see
|
|
969
|
-
// isConnectFastPathEligible). Everything else — null values,
|
|
970
|
-
// logical keys, traversals — takes one CALL subquery per item,
|
|
971
|
-
// compiled through WhereCompiler.
|
|
972
|
-
const useFastPath = this.isConnectFastPathEligible(spec, targetNodeDef);
|
|
973
|
-
if (!useFastPath)
|
|
974
|
-
for (let ci = 0; ci < spec.length; ci++) {
|
|
975
|
-
const connectSpec = spec[ci];
|
|
976
|
-
const whereSpec = connectSpec.where;
|
|
977
|
-
const edgeInput = connectSpec.edge;
|
|
978
|
-
lines.push('WITH n');
|
|
979
|
-
lines.push(`CALL {`);
|
|
980
|
-
lines.push('WITH n');
|
|
981
|
-
const connectVar = `target_${fieldName}_${ci}`;
|
|
982
|
-
lines.push(`MATCH (${connectVar}:${targetLabelStr})`);
|
|
983
|
-
const connectConditions = [
|
|
984
|
-
this.compileTargetWhere(whereSpec, connectVar, targetNodeDef, params, policyContext, paramCounter),
|
|
985
|
-
this.buildTargetPolicyPredicate(targetNodeDef, connectVar, params, policyContext, paramCounter),
|
|
986
|
-
].filter((c) => Boolean(c));
|
|
987
|
-
if (connectConditions.length > 0)
|
|
988
|
-
lines.push(`WHERE ${connectConditions.join(' AND ')}`);
|
|
989
|
-
if (edgeInput && Object.keys(edgeInput).length > 0) {
|
|
990
|
-
assertSafeIdentifier(relDef.type, 'relationship type');
|
|
991
|
-
const escapedRelType = escapeIdentifier(relDef.type);
|
|
992
|
-
const relVar = `r_conn_${fieldName}_${ci}`;
|
|
993
|
-
const mergePattern = relDef.direction === 'OUT'
|
|
994
|
-
? `(n)-[${relVar}:${escapedRelType}]->(${connectVar})`
|
|
995
|
-
: `(n)<-[${relVar}:${escapedRelType}]-(${connectVar})`;
|
|
996
|
-
lines.push(`MERGE ${mergePattern}`);
|
|
997
|
-
const setItems = [];
|
|
998
|
-
for (const [prop, val] of Object.entries(edgeInput)) {
|
|
999
|
-
if (val === undefined)
|
|
1000
|
-
continue;
|
|
1001
|
-
assertSafePropertyName(prop, 'edge property');
|
|
1002
|
-
const edgePropDef = this.getEdgePropDef(relDef, prop);
|
|
1003
|
-
const paramName = `connect_${fieldName}_${ci}_edge_${prop}`;
|
|
1004
|
-
setItems.push(`${relVar}.${escapeIdentifier(prop)} = ${wrapWriteExpr(`$${paramName}`, val, edgePropDef)}`);
|
|
1005
|
-
params[paramName] = coerceWriteValue(val, edgePropDef);
|
|
1006
|
-
}
|
|
1007
|
-
if (setItems.length > 0)
|
|
1008
|
-
lines.push(`SET ${setItems.join(', ')}`);
|
|
1009
|
-
}
|
|
1010
|
-
else {
|
|
1011
|
-
const mergePattern = buildRelPattern({
|
|
1012
|
-
sourceVar: 'n',
|
|
1013
|
-
relDef,
|
|
1014
|
-
targetVar: connectVar,
|
|
1015
|
-
});
|
|
1016
|
-
lines.push(`MERGE ${mergePattern}`);
|
|
1017
|
-
}
|
|
1018
|
-
lines.push(`RETURN count(*) AS _ccn_${fieldName}_${ci}`);
|
|
1019
|
-
lines.push(`}`);
|
|
1020
|
-
}
|
|
1021
|
-
else {
|
|
1022
|
-
// Scalar-only WHERE — use efficient UNWIND approach
|
|
1023
|
-
const paramName = `connect_${fieldName}`;
|
|
1024
|
-
// Strip undefined values from edge objects — Neo4j driver drops undefined,
|
|
1025
|
-
// causing param mismatches when the Cypher references nested edge properties.
|
|
1026
|
-
params[paramName] = spec.map((item) => {
|
|
1027
|
-
const edge = item.edge;
|
|
1028
|
-
if (!edge)
|
|
1029
|
-
return item;
|
|
1030
|
-
const cleaned = {};
|
|
1031
|
-
for (const [k, v] of Object.entries(edge))
|
|
1032
|
-
if (v !== undefined)
|
|
1033
|
-
cleaned[k] = coerceWriteValue(v, this.getEdgePropDef(relDef, k));
|
|
1034
|
-
return { ...item, edge: cleaned };
|
|
1035
|
-
});
|
|
1036
|
-
// Wrap in a CALL subquery: UNWIND + MATCH yields one row per
|
|
1037
|
-
// matched target (zero when nothing matches), which would
|
|
1038
|
-
// multiply — or drop — the updated node for every later clause.
|
|
1039
|
-
// Connecting to no target is a no-op, as in @neo4j/graphql.
|
|
1040
|
-
lines.push('WITH n');
|
|
1041
|
-
lines.push('CALL {');
|
|
1042
|
-
lines.push('WITH n');
|
|
1043
|
-
lines.push(`UNWIND $${paramName} AS connItem`);
|
|
1044
|
-
lines.push(`MATCH (target:${targetLabelStr})`);
|
|
1045
|
-
// Build WHERE from the first item's structure to determine the path.
|
|
1046
|
-
// Pre-1.7.4 we silently used `firstItem`'s keys for ALL items —
|
|
1047
|
-
// if `spec[1]` had different filter keys (e.g. an extra
|
|
1048
|
-
// `tenantId`), those keys were silently dropped from the WHERE.
|
|
1049
|
-
// Validate homogeneity now and throw if items diverge so the
|
|
1050
|
-
// caller knows to split into separate calls or use the per-item
|
|
1051
|
-
// CALL fallback.
|
|
1052
|
-
const firstItem = spec[0];
|
|
1053
|
-
const firstItemSig = computeConnectItemSignature(firstItem);
|
|
1054
|
-
for (let idx = 1; idx < spec.length; idx++) {
|
|
1055
|
-
const sig = computeConnectItemSignature(spec[idx]);
|
|
1056
|
-
if (sig !== firstItemSig)
|
|
1057
|
-
throw new OGMError(`connect array items have divergent shapes — item[0] has keys "${firstItemSig}" ` +
|
|
1058
|
-
`but item[${idx}] has keys "${sig}". The UNWIND fast path requires every item ` +
|
|
1059
|
-
`to share the same WHERE / edge key set; otherwise the additional keys are ` +
|
|
1060
|
-
`silently dropped. Split into separate connect calls, or normalise the items ` +
|
|
1061
|
-
`to share the same shape.`);
|
|
1062
|
-
}
|
|
1063
|
-
const whereConditions = this.extractConnectWhereConditions(firstItem, 'target', 'connItem', targetNodeDef);
|
|
1064
|
-
const policyPredicate = this.buildTargetPolicyPredicate(targetNodeDef, 'target', params, policyContext, paramCounter);
|
|
1065
|
-
const unwindConditions = policyPredicate
|
|
1066
|
-
? [...whereConditions, policyPredicate]
|
|
1067
|
-
: whereConditions;
|
|
1068
|
-
if (unwindConditions.length > 0)
|
|
1069
|
-
lines.push(`WHERE ${unwindConditions.join(' AND ')}`);
|
|
1070
|
-
const mergePattern = buildRelPattern({
|
|
1071
|
-
sourceVar: 'n',
|
|
1072
|
-
relDef,
|
|
1073
|
-
targetVar: 'target',
|
|
1074
|
-
});
|
|
1075
|
-
lines.push(`MERGE ${mergePattern}`);
|
|
1076
|
-
// Handle edge properties from first item structure
|
|
1077
|
-
const edgeProps = this.extractEdgeProperties(firstItem);
|
|
1078
|
-
if (edgeProps.length > 0) {
|
|
1079
|
-
// Use bare target var (no label) since target is already bound by MATCH
|
|
1080
|
-
assertSafeIdentifier(relDef.type, 'relationship type');
|
|
1081
|
-
const escapedRelType = escapeIdentifier(relDef.type);
|
|
1082
|
-
const mergeWithVar = relDef.direction === 'OUT'
|
|
1083
|
-
? `(n)-[r:${escapedRelType}]->(target)`
|
|
1084
|
-
: `(n)<-[r:${escapedRelType}]-(target)`;
|
|
1085
|
-
// Replace MERGE line with one that has a variable
|
|
1086
|
-
lines[lines.length - 1] = `MERGE ${mergeWithVar}`;
|
|
1087
|
-
// r.prop is relationship property (escape), connItem.edge.prop is parameter map (no escape)
|
|
1088
|
-
const setItems = edgeProps.map((p) => `r.${escapeIdentifier(p)} = ${wrapListItemExpr(`connItem.edge.${p}`, spec.map((it) => it.edge?.[p]), this.getEdgePropDef(relDef, p))}`);
|
|
1089
|
-
lines.push(`SET ${setItems.join(', ')}`);
|
|
1090
|
-
}
|
|
1091
|
-
lines.push(`RETURN count(*) AS _conn_${fieldName}`);
|
|
1092
|
-
lines.push('}');
|
|
1093
|
-
}
|
|
1094
1026
|
}
|
|
1027
|
+
lines.push(...this.buildRelConnects(spec, relDef, this.requireTargetDef(relDef), fieldName, params, policyContext, paramCounter, scope));
|
|
1028
|
+
}
|
|
1029
|
+
return lines;
|
|
1030
|
+
}
|
|
1031
|
+
/**
|
|
1032
|
+
* One relationship's top-level connect. `tag` names its variables,
|
|
1033
|
+
* params and aliases: the field name, or `<field>_<Member>` for an
|
|
1034
|
+
* abstract relationship given per member, so two members never collide.
|
|
1035
|
+
*/
|
|
1036
|
+
buildRelConnects(spec, relDef, targetNodeDef, tag, params, policyContext, paramCounter, scope) {
|
|
1037
|
+
const lines = [];
|
|
1038
|
+
const { sourceVar, withClause, varPrefix } = scope;
|
|
1039
|
+
// Array connect
|
|
1040
|
+
if (Array.isArray(spec)) {
|
|
1041
|
+
if (spec.length === 0)
|
|
1042
|
+
return lines; // Empty array — nothing to connect
|
|
1043
|
+
// The UNWIND fast path compiles filters against per-row values and
|
|
1044
|
+
// is only exact for plain scalar, non-null filters (see
|
|
1045
|
+
// isConnectFastPathEligible). Everything else — null values,
|
|
1046
|
+
// logical keys, traversals, abstract targets — takes one CALL
|
|
1047
|
+
// subquery per item, compiled through WhereCompiler.
|
|
1048
|
+
const useFastPath = !abstractTarget(targetNodeDef.typeName, this.schema) &&
|
|
1049
|
+
this.isConnectFastPathEligible(spec, targetNodeDef);
|
|
1050
|
+
if (!useFastPath)
|
|
1051
|
+
for (let ci = 0; ci < spec.length; ci++)
|
|
1052
|
+
lines.push(...this.buildConnectItem({
|
|
1053
|
+
connectSpec: spec[ci],
|
|
1054
|
+
relDef,
|
|
1055
|
+
targetNodeDef,
|
|
1056
|
+
sourceVar,
|
|
1057
|
+
withClause,
|
|
1058
|
+
connectVar: `${varPrefix}target_${tag}_${ci}`,
|
|
1059
|
+
relVar: `${varPrefix}r_conn_${tag}_${ci}`,
|
|
1060
|
+
edgeParamPrefix: `${varPrefix}connect_${tag}_${ci}`,
|
|
1061
|
+
returnAlias: `_ccn_${varPrefix}${tag}_${ci}`,
|
|
1062
|
+
params,
|
|
1063
|
+
policyContext,
|
|
1064
|
+
paramCounter,
|
|
1065
|
+
scope,
|
|
1066
|
+
}));
|
|
1095
1067
|
else {
|
|
1096
|
-
|
|
1097
|
-
const
|
|
1098
|
-
const
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1068
|
+
const targetVar = `${varPrefix}target`;
|
|
1069
|
+
const relVar = `${varPrefix}r`;
|
|
1070
|
+
const targetLabelStr = this.getCachedLabelString(targetNodeDef);
|
|
1071
|
+
// Scalar-only WHERE — use efficient UNWIND approach
|
|
1072
|
+
const paramName = `${varPrefix}connect_${tag}`;
|
|
1073
|
+
// Strip undefined values from edge objects — Neo4j driver drops undefined,
|
|
1074
|
+
// causing param mismatches when the Cypher references nested edge properties.
|
|
1075
|
+
params[paramName] = spec.map((raw) => {
|
|
1076
|
+
// The WHERE reads `connItem.where.node.<key>`, so a bare-property
|
|
1077
|
+
// where (`{ where: { title } }`, which the fast path accepts) is
|
|
1078
|
+
// rebound under `node`. Pre-2.4.8 it was sent as-is: every lookup
|
|
1079
|
+
// was null, so the array connect silently linked nothing.
|
|
1080
|
+
const where = raw.where;
|
|
1081
|
+
const item = isPlainObject(where) &&
|
|
1082
|
+
!Object.keys(where).some((k) => CONNECTION_WHERE_KEYS.has(k))
|
|
1083
|
+
? { ...raw, where: { node: where } }
|
|
1084
|
+
: raw;
|
|
1085
|
+
const edge = item.edge;
|
|
1086
|
+
if (!edge)
|
|
1087
|
+
return item;
|
|
1088
|
+
const cleaned = {};
|
|
1089
|
+
for (const [k, v] of Object.entries(edge))
|
|
1090
|
+
if (v !== undefined)
|
|
1091
|
+
cleaned[k] = coerceWriteValue(v, this.getEdgePropDef(relDef, k));
|
|
1092
|
+
return { ...item, edge: cleaned };
|
|
1093
|
+
});
|
|
1094
|
+
// Wrap in a CALL subquery: UNWIND + MATCH yields one row per
|
|
1095
|
+
// matched target (zero when nothing matches), which would
|
|
1096
|
+
// multiply — or drop — the updated node for every later clause.
|
|
1097
|
+
// Connecting to no target is a no-op, as in @neo4j/graphql.
|
|
1098
|
+
lines.push(withClause);
|
|
1102
1099
|
lines.push('CALL {');
|
|
1103
|
-
lines.push(
|
|
1104
|
-
lines.push(`
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
]
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1100
|
+
lines.push(withClause);
|
|
1101
|
+
lines.push(`UNWIND $${paramName} AS connItem`);
|
|
1102
|
+
lines.push(`MATCH (${targetVar}:${targetLabelStr})`);
|
|
1103
|
+
// Build WHERE from the first item's structure to determine the path.
|
|
1104
|
+
// Pre-1.7.4 we silently used `firstItem`'s keys for ALL items —
|
|
1105
|
+
// if `spec[1]` had different filter keys (e.g. an extra
|
|
1106
|
+
// `tenantId`), those keys were silently dropped from the WHERE.
|
|
1107
|
+
// Validate homogeneity now and throw if items diverge so the
|
|
1108
|
+
// caller knows to split into separate calls or use the per-item
|
|
1109
|
+
// CALL fallback.
|
|
1110
|
+
const firstItem = spec[0];
|
|
1111
|
+
const firstItemSig = computeConnectItemSignature(firstItem);
|
|
1112
|
+
for (let idx = 1; idx < spec.length; idx++) {
|
|
1113
|
+
const sig = computeConnectItemSignature(spec[idx]);
|
|
1114
|
+
if (sig !== firstItemSig)
|
|
1115
|
+
throw new OGMError(`connect array items have divergent shapes — item[0] has keys "${firstItemSig}" ` +
|
|
1116
|
+
`but item[${idx}] has keys "${sig}". The UNWIND fast path requires every item ` +
|
|
1117
|
+
`to share the same WHERE / edge key set; otherwise the additional keys are ` +
|
|
1118
|
+
`silently dropped. Split into separate connect calls, or normalise the items ` +
|
|
1119
|
+
`to share the same shape.`);
|
|
1120
|
+
}
|
|
1121
|
+
const whereConditions = this.extractConnectWhereConditions(firstItem, targetVar, 'connItem', targetNodeDef);
|
|
1122
|
+
const policyPredicate = this.buildTargetPolicyPredicate(targetNodeDef, targetVar, params, policyContext, paramCounter);
|
|
1123
|
+
const unwindConditions = policyPredicate
|
|
1124
|
+
? [...whereConditions, policyPredicate]
|
|
1125
|
+
: whereConditions;
|
|
1126
|
+
if (unwindConditions.length > 0)
|
|
1127
|
+
lines.push(`WHERE ${unwindConditions.join(' AND ')}`);
|
|
1128
|
+
const mergePattern = buildRelPattern({
|
|
1129
|
+
sourceVar,
|
|
1130
|
+
relDef,
|
|
1131
|
+
targetVar,
|
|
1132
|
+
});
|
|
1133
|
+
lines.push(`MERGE ${mergePattern}`);
|
|
1134
|
+
// Handle edge properties from first item structure
|
|
1135
|
+
const edgeProps = this.extractEdgeProperties(firstItem);
|
|
1136
|
+
if (edgeProps.length > 0) {
|
|
1112
1137
|
// Use bare target var (no label) since target is already bound by MATCH
|
|
1113
1138
|
assertSafeIdentifier(relDef.type, 'relationship type');
|
|
1114
1139
|
const escapedRelType = escapeIdentifier(relDef.type);
|
|
1115
1140
|
const mergeWithVar = relDef.direction === 'OUT'
|
|
1116
|
-
? `(
|
|
1117
|
-
: `(
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
const edgePropDef = this.getEdgePropDef(relDef, prop);
|
|
1124
|
-
const paramName = `connect_${fieldName}_edge_${prop}`;
|
|
1125
|
-
setItems.push(`r.${escapeIdentifier(prop)} = ${wrapWriteExpr(`$${paramName}`, val, edgePropDef)}`);
|
|
1126
|
-
params[paramName] = coerceWriteValue(val, edgePropDef);
|
|
1127
|
-
}
|
|
1128
|
-
if (setItems.length > 0)
|
|
1129
|
-
lines.push(`SET ${setItems.join(', ')}`);
|
|
1141
|
+
? `(${sourceVar})-[${relVar}:${escapedRelType}]->(${targetVar})`
|
|
1142
|
+
: `(${sourceVar})<-[${relVar}:${escapedRelType}]-(${targetVar})`;
|
|
1143
|
+
// Replace MERGE line with one that has a variable
|
|
1144
|
+
lines[lines.length - 1] = `MERGE ${mergeWithVar}`;
|
|
1145
|
+
// r.prop is relationship property (escape), connItem.edge.prop is parameter map (no escape)
|
|
1146
|
+
const setItems = edgeProps.map((p) => `${relVar}.${escapeIdentifier(p)} = ${wrapListItemExpr(`connItem.edge.${p}`, spec.map((it) => it.edge?.[p]), this.getEdgePropDef(relDef, p))}`);
|
|
1147
|
+
lines.push(`SET ${setItems.join(', ')}`);
|
|
1130
1148
|
}
|
|
1131
|
-
|
|
1132
|
-
const mergePattern = buildRelPattern({
|
|
1133
|
-
sourceVar: 'n',
|
|
1134
|
-
relDef,
|
|
1135
|
-
targetVar: 'target',
|
|
1136
|
-
});
|
|
1137
|
-
lines.push(`MERGE ${mergePattern}`);
|
|
1138
|
-
}
|
|
1139
|
-
lines.push(`RETURN count(*) AS _conn_${fieldName}`);
|
|
1149
|
+
lines.push(`RETURN count(*) AS _conn_${varPrefix}${tag}`);
|
|
1140
1150
|
lines.push('}');
|
|
1141
1151
|
}
|
|
1152
|
+
return lines;
|
|
1153
|
+
}
|
|
1154
|
+
// Single connect — same row-preserving isolation as the UNWIND path.
|
|
1155
|
+
return this.buildConnectItem({
|
|
1156
|
+
connectSpec: spec,
|
|
1157
|
+
relDef,
|
|
1158
|
+
targetNodeDef,
|
|
1159
|
+
sourceVar,
|
|
1160
|
+
withClause,
|
|
1161
|
+
connectVar: `${varPrefix}target`,
|
|
1162
|
+
relVar: `${varPrefix}r`,
|
|
1163
|
+
edgeParamPrefix: `${varPrefix}connect_${tag}`,
|
|
1164
|
+
returnAlias: `_conn_${varPrefix}${tag}`,
|
|
1165
|
+
params,
|
|
1166
|
+
policyContext,
|
|
1167
|
+
paramCounter,
|
|
1168
|
+
scope,
|
|
1169
|
+
});
|
|
1170
|
+
}
|
|
1171
|
+
/**
|
|
1172
|
+
* One connect item, isolated in a CALL subquery so a MATCH that finds
|
|
1173
|
+
* nothing (or many) never changes the outer row count:
|
|
1174
|
+
*
|
|
1175
|
+
* <withClause>
|
|
1176
|
+
* CALL {
|
|
1177
|
+
* <withClause>
|
|
1178
|
+
* MATCH (cv[:Label])
|
|
1179
|
+
* WHERE <member labels> AND <where> AND <target `read` policy>
|
|
1180
|
+
* MERGE (src)-[rv:REL]->(cv) [SET rv.<edge> = …]
|
|
1181
|
+
* RETURN count(*) AS <alias>
|
|
1182
|
+
* }
|
|
1183
|
+
*
|
|
1184
|
+
* Shared by every connect site (top-level, nested in create, nested in
|
|
1185
|
+
* update), which differ only in the names they pass.
|
|
1186
|
+
*/
|
|
1187
|
+
buildConnectItem(a) {
|
|
1188
|
+
const { connectSpec, relDef, targetNodeDef, sourceVar, connectVar } = a;
|
|
1189
|
+
const lines = [];
|
|
1190
|
+
const whereSpec = connectSpec?.where;
|
|
1191
|
+
const edgeInput = connectSpec?.edge;
|
|
1192
|
+
lines.push(a.withClause);
|
|
1193
|
+
lines.push('CALL {');
|
|
1194
|
+
lines.push(a.withClause);
|
|
1195
|
+
const { match, labelPredicate } = this.targetMatch(targetNodeDef, connectVar);
|
|
1196
|
+
lines.push(match);
|
|
1197
|
+
const conditions = [
|
|
1198
|
+
labelPredicate,
|
|
1199
|
+
this.compileTargetWhere(whereSpec, connectVar, targetNodeDef, a.params, a.policyContext, a.paramCounter),
|
|
1200
|
+
this.buildTargetPolicyPredicate(targetNodeDef, connectVar, a.params, a.policyContext, a.paramCounter),
|
|
1201
|
+
].filter((c) => Boolean(c));
|
|
1202
|
+
if (conditions.length > 0)
|
|
1203
|
+
lines.push(`WHERE ${conditions.join(' AND ')}`);
|
|
1204
|
+
if (edgeInput && Object.keys(edgeInput).length > 0) {
|
|
1205
|
+
// Use bare target var (no label) since connectVar is already bound by MATCH.
|
|
1206
|
+
// Including labels on a bound variable in MERGE causes Neo4j to reject it.
|
|
1207
|
+
assertSafeIdentifier(relDef.type, 'relationship type');
|
|
1208
|
+
const escapedRelType = escapeIdentifier(relDef.type);
|
|
1209
|
+
const mergePattern = relDef.direction === 'OUT'
|
|
1210
|
+
? `(${sourceVar})-[${a.relVar}:${escapedRelType}]->(${connectVar})`
|
|
1211
|
+
: `(${sourceVar})<-[${a.relVar}:${escapedRelType}]-(${connectVar})`;
|
|
1212
|
+
lines.push(`MERGE ${mergePattern}`);
|
|
1213
|
+
const setItems = [];
|
|
1214
|
+
for (const [prop, val] of Object.entries(edgeInput)) {
|
|
1215
|
+
if (val === undefined)
|
|
1216
|
+
continue;
|
|
1217
|
+
assertSafePropertyName(prop, 'edge property');
|
|
1218
|
+
const edgePropDef = this.getEdgePropDef(relDef, prop);
|
|
1219
|
+
const paramName = `${a.edgeParamPrefix}_edge_${prop}`;
|
|
1220
|
+
setItems.push(`${a.relVar}.${escapeIdentifier(prop)} = ${wrapWriteExpr(`$${paramName}`, val, edgePropDef)}`);
|
|
1221
|
+
a.params[paramName] = coerceWriteValue(val, edgePropDef);
|
|
1222
|
+
}
|
|
1223
|
+
if (setItems.length > 0)
|
|
1224
|
+
lines.push(`SET ${setItems.join(', ')}`);
|
|
1225
|
+
}
|
|
1226
|
+
else {
|
|
1227
|
+
const relPattern = buildRelPattern({
|
|
1228
|
+
sourceVar,
|
|
1229
|
+
relDef,
|
|
1230
|
+
targetVar: connectVar,
|
|
1231
|
+
});
|
|
1232
|
+
lines.push(`MERGE ${relPattern}`);
|
|
1233
|
+
}
|
|
1234
|
+
// A `connect` nested in the item links the CONNECTED node onward
|
|
1235
|
+
// (`connect: { posts: [{ where, connect: { tags: [...] } }] }`), with
|
|
1236
|
+
// the same policy predicates at its level. Pre-2.4.8 it was ignored.
|
|
1237
|
+
const nested = connectSpec?.connect;
|
|
1238
|
+
if (nested != null) {
|
|
1239
|
+
if (abstractTarget(targetNodeDef.typeName, this.schema))
|
|
1240
|
+
throw new OGMError(`A nested "connect" inside a connect on "${relDef.fieldName}" (abstract type "${targetNodeDef.typeName}") ` +
|
|
1241
|
+
`must be keyed by the implementing type, e.g. { ${relDef.fieldName}: { Member: [{ where, connect }] } }.`);
|
|
1242
|
+
const inner = nestedScope(a.scope.carryVars, connectVar, a.scope.depth);
|
|
1243
|
+
for (const nestedConnect of Array.isArray(nested) ? nested : [nested]) {
|
|
1244
|
+
if (!isPlainObject(nestedConnect))
|
|
1245
|
+
throw new OGMError(`A nested "connect" inside a connect on "${relDef.fieldName}" must be an object keyed by relationship.`);
|
|
1246
|
+
lines.push(...this.buildConnects(nestedConnect, targetNodeDef, a.params, a.policyContext, a.paramCounter, inner));
|
|
1247
|
+
}
|
|
1142
1248
|
}
|
|
1249
|
+
lines.push(`RETURN count(*) AS ${a.returnAlias}`);
|
|
1250
|
+
lines.push('}');
|
|
1143
1251
|
return lines;
|
|
1144
1252
|
}
|
|
1145
|
-
|
|
1253
|
+
/**
|
|
1254
|
+
* Top-level `disconnect`. Abstract relationships follow the connect
|
|
1255
|
+
* rules (see buildConnects): per member, or flat on an interface,
|
|
1256
|
+
* matching implementers by their own labels (v2.4.8 — pre-2.4.8 the
|
|
1257
|
+
* target pattern used the interface / union label, so nothing matched).
|
|
1258
|
+
*/
|
|
1259
|
+
buildDisconnects(disconnect, nodeDef, params, policyContext, paramCounter, scope = TOP_SCOPE) {
|
|
1146
1260
|
const lines = [];
|
|
1147
1261
|
for (const [fieldName, spec] of Object.entries(disconnect)) {
|
|
1148
1262
|
const relDef = nodeDef.relationships.get(fieldName);
|
|
1149
1263
|
if (!relDef)
|
|
1150
1264
|
continue;
|
|
1151
|
-
const
|
|
1152
|
-
//
|
|
1153
|
-
const
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
lines.push('WITH n');
|
|
1161
|
-
lines.push('CALL {');
|
|
1162
|
-
lines.push('WITH n');
|
|
1163
|
-
if (item == null ||
|
|
1164
|
-
(typeof item === 'object' && Object.keys(item).length === 0)) {
|
|
1165
|
-
// Blanket disconnect — remove every relationship of this type to
|
|
1166
|
-
// a target the caller can READ. Like the `where` branch below, a
|
|
1167
|
-
// policy-bound context only unlinks nodes it can see; pre-2.5.0
|
|
1168
|
-
// the blanket form bound an anonymous `()` and also severed links
|
|
1169
|
-
// to policy-hidden nodes. Without policies there is no predicate
|
|
1170
|
-
// and the Cypher is unchanged.
|
|
1171
|
-
const blanketTarget = `target_${fieldName}_${si}`;
|
|
1172
|
-
const predicate = targetNodeDef
|
|
1173
|
-
? this.buildTargetPolicyPredicate(targetNodeDef, blanketTarget, params, policyContext, paramCounter)
|
|
1174
|
-
: undefined;
|
|
1175
|
-
const pattern = buildRelPattern({
|
|
1176
|
-
sourceVar: 'n',
|
|
1177
|
-
relDef,
|
|
1178
|
-
targetVar: predicate ? blanketTarget : '',
|
|
1179
|
-
edgeVar: relVar,
|
|
1180
|
-
});
|
|
1181
|
-
lines.push(`OPTIONAL MATCH ${pattern}`);
|
|
1182
|
-
if (predicate)
|
|
1183
|
-
lines.push(`WHERE ${predicate}`);
|
|
1184
|
-
lines.push(`DELETE ${relVar}`);
|
|
1185
|
-
}
|
|
1186
|
-
else {
|
|
1187
|
-
// Specific disconnect with conditions
|
|
1188
|
-
const disconnectSpec = item;
|
|
1189
|
-
const whereSpec = disconnectSpec.where;
|
|
1190
|
-
const targetVar = `target_${fieldName}_${si}`;
|
|
1191
|
-
const pattern = buildRelPattern({
|
|
1192
|
-
sourceVar: 'n',
|
|
1193
|
-
relDef,
|
|
1194
|
-
targetVar,
|
|
1195
|
-
edgeVar: relVar,
|
|
1196
|
-
targetLabel: 'auto',
|
|
1197
|
-
});
|
|
1198
|
-
lines.push(`OPTIONAL MATCH ${pattern}`);
|
|
1199
|
-
if (!targetNodeDef)
|
|
1200
|
-
throw new OGMError(`Cannot resolve the target type "${relDef.target}" of relationship "${fieldName}".`);
|
|
1201
|
-
const disconnectConditions = [
|
|
1202
|
-
this.compileTargetWhere(whereSpec, targetVar, targetNodeDef, params, policyContext, paramCounter),
|
|
1203
|
-
this.buildTargetPolicyPredicate(targetNodeDef, targetVar, params, policyContext, paramCounter),
|
|
1204
|
-
].filter((c) => Boolean(c));
|
|
1205
|
-
if (disconnectConditions.length > 0)
|
|
1206
|
-
lines.push(`WHERE ${disconnectConditions.join(' AND ')}`);
|
|
1207
|
-
lines.push(`DELETE ${relVar}`);
|
|
1208
|
-
}
|
|
1209
|
-
lines.push(`RETURN count(*) AS _${relVar}`);
|
|
1210
|
-
lines.push('}');
|
|
1265
|
+
const abstract = abstractTarget(relDef.target, this.schema);
|
|
1266
|
+
// `null` / `{}` stays a blanket disconnect of the whole relationship.
|
|
1267
|
+
const memberEntries = abstract && !isBlanketSpec(spec)
|
|
1268
|
+
? perMemberEntries(spec, abstract, fieldName)
|
|
1269
|
+
: null;
|
|
1270
|
+
if (memberEntries) {
|
|
1271
|
+
for (const [member, memberSpec] of memberEntries)
|
|
1272
|
+
lines.push(...this.buildRelDisconnects(memberSpec, this.narrowToMember(relDef, member), this.schema.nodes.get(member), `${fieldName}_${member}`, params, policyContext, paramCounter, scope));
|
|
1273
|
+
continue;
|
|
1211
1274
|
}
|
|
1275
|
+
lines.push(...this.buildRelDisconnects(spec, relDef, resolveTargetDef(relDef.target, this.schema) ?? undefined, fieldName, params, policyContext, paramCounter, scope));
|
|
1212
1276
|
}
|
|
1213
1277
|
return lines;
|
|
1214
1278
|
}
|
|
1215
1279
|
/**
|
|
1216
|
-
*
|
|
1280
|
+
* A `disconnect` nested in a disconnect item unlinks the disconnected
|
|
1281
|
+
* node's OWN relationships (`disconnect: { posts: [{ where, disconnect:
|
|
1282
|
+
* { tags: [...] } }] }`), with the same policy predicates at its level.
|
|
1283
|
+
* Pre-2.4.8 it was ignored. `''`-free: no input, no lines.
|
|
1284
|
+
*/
|
|
1285
|
+
buildNestedDisconnect(nested, relDef, targetNodeDef, targetVar, carryVars, params, policyContext, paramCounter, depth = 0) {
|
|
1286
|
+
if (nested == null)
|
|
1287
|
+
return [];
|
|
1288
|
+
if (abstractTarget(targetNodeDef.typeName, this.schema))
|
|
1289
|
+
throw new OGMError(`A nested "disconnect" inside a disconnect on "${relDef.fieldName}" (abstract type "${targetNodeDef.typeName}") ` +
|
|
1290
|
+
`must be keyed by the implementing type, e.g. { ${relDef.fieldName}: { Member: [{ where, disconnect }] } }.`);
|
|
1291
|
+
const inner = nestedScope(carryVars, targetVar, depth);
|
|
1292
|
+
const lines = [];
|
|
1293
|
+
for (const nestedDisconnect of Array.isArray(nested) ? nested : [nested]) {
|
|
1294
|
+
if (!isPlainObject(nestedDisconnect))
|
|
1295
|
+
throw new OGMError(`A nested "disconnect" inside a disconnect on "${relDef.fieldName}" must be an object keyed by relationship.`);
|
|
1296
|
+
lines.push(...this.buildDisconnects(nestedDisconnect, targetNodeDef, params, policyContext, paramCounter, inner));
|
|
1297
|
+
}
|
|
1298
|
+
return lines;
|
|
1299
|
+
}
|
|
1300
|
+
/** One relationship's disconnect items; `tag` as in buildRelConnects. */
|
|
1301
|
+
buildRelDisconnects(spec, relDef, targetNodeDef, tag, params, policyContext, paramCounter, scope) {
|
|
1302
|
+
const lines = [];
|
|
1303
|
+
const { sourceVar, withClause, varPrefix } = scope;
|
|
1304
|
+
// Normalize spec to an array of disconnect items
|
|
1305
|
+
const specItems = Array.isArray(spec) ? spec : [spec];
|
|
1306
|
+
for (let si = 0; si < specItems.length; si++) {
|
|
1307
|
+
const item = specItems[si];
|
|
1308
|
+
const relVar = `${varPrefix}r_${tag}_${si}`;
|
|
1309
|
+
// Wrap in a CALL subquery: the OPTIONAL MATCH yields one row per
|
|
1310
|
+
// matched relationship, which would multiply every later clause.
|
|
1311
|
+
// `count(*)` collapses it back to exactly one row per `n`.
|
|
1312
|
+
lines.push(withClause);
|
|
1313
|
+
lines.push('CALL {');
|
|
1314
|
+
lines.push(withClause);
|
|
1315
|
+
if (item == null ||
|
|
1316
|
+
(typeof item === 'object' && Object.keys(item).length === 0)) {
|
|
1317
|
+
// Blanket disconnect — remove every relationship of this type to
|
|
1318
|
+
// a target the caller can READ. Like the `where` branch below, a
|
|
1319
|
+
// policy-bound context only unlinks nodes it can see; pre-2.4.4
|
|
1320
|
+
// the blanket form bound an anonymous `()` and also severed links
|
|
1321
|
+
// to policy-hidden nodes. Without policies there is no predicate
|
|
1322
|
+
// and the Cypher is unchanged.
|
|
1323
|
+
const blanketTarget = `${varPrefix}target_${tag}_${si}`;
|
|
1324
|
+
const predicate = targetNodeDef
|
|
1325
|
+
? this.buildTargetPolicyPredicate(targetNodeDef, blanketTarget, params, policyContext, paramCounter)
|
|
1326
|
+
: undefined;
|
|
1327
|
+
const pattern = buildRelPattern({
|
|
1328
|
+
sourceVar,
|
|
1329
|
+
relDef,
|
|
1330
|
+
targetVar: predicate ? blanketTarget : '',
|
|
1331
|
+
edgeVar: relVar,
|
|
1332
|
+
targetLabelRaw: this.blanketTargetLabel(relDef),
|
|
1333
|
+
});
|
|
1334
|
+
lines.push(`OPTIONAL MATCH ${pattern}`);
|
|
1335
|
+
if (predicate)
|
|
1336
|
+
lines.push(`WHERE ${predicate}`);
|
|
1337
|
+
lines.push(`DELETE ${relVar}`);
|
|
1338
|
+
}
|
|
1339
|
+
else {
|
|
1340
|
+
// Specific disconnect with conditions
|
|
1341
|
+
const disconnectSpec = item;
|
|
1342
|
+
const whereSpec = disconnectSpec.where;
|
|
1343
|
+
const targetVar = `${varPrefix}target_${tag}_${si}`;
|
|
1344
|
+
const pattern = buildRelPattern({
|
|
1345
|
+
sourceVar,
|
|
1346
|
+
relDef,
|
|
1347
|
+
targetVar,
|
|
1348
|
+
edgeVar: relVar,
|
|
1349
|
+
targetLabel: 'auto',
|
|
1350
|
+
// An abstract target binds no label (its nodes carry their
|
|
1351
|
+
// members' labels); the member predicate below narrows it.
|
|
1352
|
+
schema: this.schema,
|
|
1353
|
+
});
|
|
1354
|
+
lines.push(`OPTIONAL MATCH ${pattern}`);
|
|
1355
|
+
if (!targetNodeDef)
|
|
1356
|
+
throw new OGMError(`Cannot resolve the target type "${relDef.target}" of relationship "${relDef.fieldName}".`);
|
|
1357
|
+
const labelPredicate = abstractTarget(targetNodeDef.typeName, this.schema)
|
|
1358
|
+
? this.targetMatch(targetNodeDef, targetVar).labelPredicate
|
|
1359
|
+
: null;
|
|
1360
|
+
const disconnectConditions = [
|
|
1361
|
+
labelPredicate,
|
|
1362
|
+
this.compileTargetWhere(whereSpec, targetVar, targetNodeDef, params, policyContext, paramCounter),
|
|
1363
|
+
this.buildTargetPolicyPredicate(targetNodeDef, targetVar, params, policyContext, paramCounter),
|
|
1364
|
+
].filter((c) => Boolean(c));
|
|
1365
|
+
if (disconnectConditions.length > 0)
|
|
1366
|
+
lines.push(`WHERE ${disconnectConditions.join(' AND ')}`);
|
|
1367
|
+
lines.push(`DELETE ${relVar}`);
|
|
1368
|
+
lines.push(...this.buildNestedDisconnect(disconnectSpec.disconnect, relDef, targetNodeDef, targetVar, scope.carryVars, params, policyContext, paramCounter, scope.depth));
|
|
1369
|
+
}
|
|
1370
|
+
lines.push(`RETURN count(*) AS _${relVar}`);
|
|
1371
|
+
lines.push('}');
|
|
1372
|
+
}
|
|
1373
|
+
return lines;
|
|
1374
|
+
}
|
|
1375
|
+
narrowToMember(relDef, member) {
|
|
1376
|
+
const narrowed = { ...relDef, target: member };
|
|
1377
|
+
this.narrowedRelDefs.add(narrowed);
|
|
1378
|
+
return narrowed;
|
|
1379
|
+
}
|
|
1380
|
+
/** The label a blanket disconnect over `relDef` must keep (see above). */
|
|
1381
|
+
blanketTargetLabel(relDef) {
|
|
1382
|
+
if (!this.narrowedRelDefs.has(relDef))
|
|
1383
|
+
return undefined;
|
|
1384
|
+
return this.getCachedLabelString(this.schema.nodes.get(relDef.target));
|
|
1385
|
+
}
|
|
1386
|
+
/**
|
|
1387
|
+
* Dispatch update operations for a union- or interface-typed
|
|
1388
|
+
* relationship given per member (interfaces since v2.4.8).
|
|
1217
1389
|
* Each member key in the input is recursed into buildUpdateRelationships
|
|
1218
1390
|
* with a narrowed relDef pointing at the concrete member type.
|
|
1219
1391
|
*/
|
|
1220
1392
|
dispatchUnionUpdateOps(key, memberEntries, relDef, nodeDef, sourceVar, prefix, params, depth, ancestorVars, policyContext, paramCounter) {
|
|
1221
|
-
const unionMembers = this.schema.unions.get(relDef.target);
|
|
1222
1393
|
const lines = [];
|
|
1223
|
-
for (const [memberKey, memberValue] of
|
|
1224
|
-
|
|
1225
|
-
continue;
|
|
1226
|
-
const memberNodeDef = this.schema.nodes.get(memberKey);
|
|
1227
|
-
if (!memberNodeDef || memberValue == null)
|
|
1228
|
-
continue;
|
|
1229
|
-
const memberRelDef = {
|
|
1230
|
-
...relDef,
|
|
1231
|
-
target: memberKey,
|
|
1232
|
-
};
|
|
1394
|
+
for (const [memberKey, memberValue] of memberEntries) {
|
|
1395
|
+
const memberRelDef = this.narrowToMember(relDef, memberKey);
|
|
1233
1396
|
const memberItems = Array.isArray(memberValue)
|
|
1234
1397
|
? memberValue
|
|
1235
1398
|
: [memberValue];
|
|
@@ -1258,18 +1421,21 @@ export class MutationCompiler {
|
|
|
1258
1421
|
const relDef = nodeDef.relationships.get(key);
|
|
1259
1422
|
if (!relDef || value == null)
|
|
1260
1423
|
continue;
|
|
1261
|
-
// Union targets
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1424
|
+
// Union / interface targets given per member — dispatch each member
|
|
1425
|
+
// recursively. A flat interface input is handled below against the
|
|
1426
|
+
// interface (v2.4.8 — pre-2.4.8 it was skipped, writing nothing).
|
|
1427
|
+
const abstract = abstractTarget(relDef.target, this.schema);
|
|
1428
|
+
const memberEntries = abstract
|
|
1429
|
+
? perMemberEntries(value, abstract, key)
|
|
1430
|
+
: null;
|
|
1431
|
+
if (memberEntries) {
|
|
1432
|
+
lines.push(...this.dispatchUnionUpdateOps(key, memberEntries, relDef, nodeDef, sourceVar, prefix, params, depth, ancestorVars, policyContext, paramCounter));
|
|
1266
1433
|
continue;
|
|
1267
1434
|
}
|
|
1268
|
-
const targetNodeDef = this.
|
|
1269
|
-
if (!targetNodeDef)
|
|
1270
|
-
continue;
|
|
1271
|
-
const targetLabelStr = this.getCachedLabelString(targetNodeDef);
|
|
1435
|
+
const targetNodeDef = this.requireTargetDef(relDef);
|
|
1272
1436
|
const relPrefix = `${prefix}_${key}`;
|
|
1437
|
+
// Past the dispatch, `abstract` is set only for a flat INTERFACE
|
|
1438
|
+
// input (a flat union input threw in perMemberEntries).
|
|
1273
1439
|
// Value can be an array (e.g., chapters: [{ create: ... }, { update: ... }])
|
|
1274
1440
|
// or an object (e.g., author: { connect: ... })
|
|
1275
1441
|
const items = Array.isArray(value)
|
|
@@ -1302,13 +1468,17 @@ export class MutationCompiler {
|
|
|
1302
1468
|
for (let di = 0; di < disconnectItems.length; di++) {
|
|
1303
1469
|
const disconnectSpec = disconnectItems[di];
|
|
1304
1470
|
const whereSpec = disconnectSpec.where;
|
|
1471
|
+
// A nested `disconnect` needs the target bound, so an item that
|
|
1472
|
+
// carries one is never the anonymous blanket form.
|
|
1473
|
+
const nestedDisconnect = disconnectSpec.disconnect;
|
|
1305
1474
|
const relVar = `r_disc_${key}_${i}_${di}`;
|
|
1306
1475
|
// Same isolation as the top-level disconnect: keep the
|
|
1307
1476
|
// OPTIONAL MATCH fan-out from multiplying later nested ops.
|
|
1308
1477
|
lines.push(withClause);
|
|
1309
1478
|
lines.push('CALL {');
|
|
1310
1479
|
lines.push(withClause);
|
|
1311
|
-
if (!whereSpec || Object.keys(whereSpec).length === 0)
|
|
1480
|
+
if ((!whereSpec || Object.keys(whereSpec).length === 0) &&
|
|
1481
|
+
nestedDisconnect == null) {
|
|
1312
1482
|
// Blanket — same read-policy gate as the top-level blanket
|
|
1313
1483
|
// disconnect: only unlink targets the caller can read.
|
|
1314
1484
|
const blanketTarget = `${sourceVar}_disc${varCounter}`;
|
|
@@ -1320,6 +1490,7 @@ export class MutationCompiler {
|
|
|
1320
1490
|
relDef,
|
|
1321
1491
|
targetVar: predicate ? blanketTarget : '',
|
|
1322
1492
|
edgeVar: relVar,
|
|
1493
|
+
targetLabelRaw: this.blanketTargetLabel(relDef),
|
|
1323
1494
|
});
|
|
1324
1495
|
lines.push(`OPTIONAL MATCH ${pattern}`);
|
|
1325
1496
|
if (predicate)
|
|
@@ -1335,6 +1506,9 @@ export class MutationCompiler {
|
|
|
1335
1506
|
targetVar: discTarget,
|
|
1336
1507
|
edgeVar: relVar,
|
|
1337
1508
|
targetLabel: 'auto',
|
|
1509
|
+
// An interface target matches its implementers, which do
|
|
1510
|
+
// not carry the interface label (v2.4.8).
|
|
1511
|
+
schema: this.schema,
|
|
1338
1512
|
});
|
|
1339
1513
|
lines.push(`OPTIONAL MATCH ${pattern}`);
|
|
1340
1514
|
// Pre-1.8.1 this called `buildNodeWhereConditions` with
|
|
@@ -1347,12 +1521,16 @@ export class MutationCompiler {
|
|
|
1347
1521
|
// path (`buildDisconnects`). Restores backwards-compat with
|
|
1348
1522
|
// @neo4j/graphql-ogm's nested update.disconnect[].where shape.
|
|
1349
1523
|
const nestedDiscConditions = [
|
|
1524
|
+
abstract
|
|
1525
|
+
? this.targetMatch(targetNodeDef, discTarget).labelPredicate
|
|
1526
|
+
: null,
|
|
1350
1527
|
this.compileTargetWhere(whereSpec, discTarget, targetNodeDef, params, policyContext, paramCounter),
|
|
1351
1528
|
this.buildTargetPolicyPredicate(targetNodeDef, discTarget, params, policyContext, paramCounter),
|
|
1352
1529
|
].filter((c) => Boolean(c));
|
|
1353
1530
|
if (nestedDiscConditions.length > 0)
|
|
1354
1531
|
lines.push(`WHERE ${nestedDiscConditions.join(' AND ')}`);
|
|
1355
1532
|
lines.push(`DELETE ${relVar}`);
|
|
1533
|
+
lines.push(...this.buildNestedDisconnect(nestedDisconnect, relDef, targetNodeDef, discTarget, allVars, params, policyContext, paramCounter));
|
|
1356
1534
|
}
|
|
1357
1535
|
lines.push(`RETURN count(*) AS _${relVar}`);
|
|
1358
1536
|
lines.push('}');
|
|
@@ -1364,61 +1542,30 @@ export class MutationCompiler {
|
|
|
1364
1542
|
? item.connect
|
|
1365
1543
|
: [item.connect];
|
|
1366
1544
|
for (let ci = 0; ci < connectItems.length; ci++) {
|
|
1367
|
-
const connectSpec = connectItems[ci];
|
|
1368
|
-
const whereSpec = connectSpec.where;
|
|
1369
|
-
const edgeInput = connectSpec.edge;
|
|
1370
1545
|
// Wrap in CALL subquery so a failed MATCH doesn't kill the outer pipeline
|
|
1371
|
-
lines.push(withClause);
|
|
1372
|
-
lines.push(`CALL {`);
|
|
1373
|
-
lines.push(withClause);
|
|
1374
1546
|
const connectVar = `${sourceVar}_conn${varCounter}`;
|
|
1375
1547
|
varCounter++;
|
|
1376
|
-
lines.push(
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
if (edgeInput && Object.keys(edgeInput).length > 0) {
|
|
1391
|
-
// Use bare target var (no label) since connectVar is already bound by MATCH
|
|
1392
|
-
const relVar = `r_conn_${key}_${ci}`;
|
|
1393
|
-
assertSafeIdentifier(relDef.type, 'relationship type');
|
|
1394
|
-
const escapedRelType = escapeIdentifier(relDef.type);
|
|
1395
|
-
const mergePattern = relDef.direction === 'OUT'
|
|
1396
|
-
? `(${sourceVar})-[${relVar}:${escapedRelType}]->(${connectVar})`
|
|
1397
|
-
: `(${sourceVar})<-[${relVar}:${escapedRelType}]-(${connectVar})`;
|
|
1398
|
-
lines.push(`MERGE ${mergePattern}`);
|
|
1399
|
-
const setItems = [];
|
|
1400
|
-
for (const [prop, val] of Object.entries(edgeInput)) {
|
|
1401
|
-
if (val === undefined)
|
|
1402
|
-
continue;
|
|
1403
|
-
assertSafePropertyName(prop, 'edge property');
|
|
1404
|
-
const edgePropDef = this.getEdgePropDef(relDef, prop);
|
|
1405
|
-
const paramName = `${itemPrefix}_conn${ci}_edge_${prop}`;
|
|
1406
|
-
setItems.push(`r_conn_${key}_${ci}.${escapeIdentifier(prop)} = ${wrapWriteExpr(`$${paramName}`, val, edgePropDef)}`);
|
|
1407
|
-
params[paramName] = coerceWriteValue(val, edgePropDef);
|
|
1408
|
-
}
|
|
1409
|
-
if (setItems.length > 0)
|
|
1410
|
-
lines.push(`SET ${setItems.join(', ')}`);
|
|
1411
|
-
}
|
|
1412
|
-
else {
|
|
1413
|
-
const relPattern = buildRelPattern({
|
|
1548
|
+
lines.push(...this.buildConnectItem({
|
|
1549
|
+
connectSpec: connectItems[ci],
|
|
1550
|
+
relDef,
|
|
1551
|
+
targetNodeDef,
|
|
1552
|
+
sourceVar,
|
|
1553
|
+
withClause,
|
|
1554
|
+
connectVar,
|
|
1555
|
+
relVar: `r_conn_${key}_${ci}`,
|
|
1556
|
+
edgeParamPrefix: `${itemPrefix}_conn${ci}`,
|
|
1557
|
+
returnAlias: `_cn_${key}_${ci}`,
|
|
1558
|
+
params,
|
|
1559
|
+
policyContext,
|
|
1560
|
+
paramCounter,
|
|
1561
|
+
scope: {
|
|
1414
1562
|
sourceVar,
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
lines.push(`}`);
|
|
1563
|
+
carryVars: allVars,
|
|
1564
|
+
withClause,
|
|
1565
|
+
varPrefix: '',
|
|
1566
|
+
depth: 0,
|
|
1567
|
+
},
|
|
1568
|
+
}));
|
|
1422
1569
|
}
|
|
1423
1570
|
}
|
|
1424
1571
|
// --- create ---
|
|
@@ -1431,15 +1578,23 @@ export class MutationCompiler {
|
|
|
1431
1578
|
: [item.create];
|
|
1432
1579
|
for (let ci = 0; ci < createItems.length; ci++) {
|
|
1433
1580
|
const createSpec = createItems[ci];
|
|
1434
|
-
|
|
1581
|
+
let nodeSpec = (createSpec.node ?? createSpec);
|
|
1582
|
+
// Flat interface input: the create names its implementer.
|
|
1583
|
+
let createDef = targetNodeDef;
|
|
1584
|
+
if (abstract) {
|
|
1585
|
+
const [member, memberSpec] = unwrapAbstractCreateNode(nodeSpec, abstract, key);
|
|
1586
|
+
createDef = this.schema.nodes.get(member);
|
|
1587
|
+
nodeSpec = memberSpec;
|
|
1588
|
+
}
|
|
1589
|
+
const targetLabelStr = this.getCachedLabelString(createDef);
|
|
1435
1590
|
const createVar = `${sourceVar}_cr${varCounter}`;
|
|
1436
1591
|
varCounter++;
|
|
1437
|
-
const { propString, propParams } = this.buildCreateProperties(nodeSpec,
|
|
1592
|
+
const { propString, propParams } = this.buildCreateProperties(nodeSpec, createDef, `${itemPrefix}_create${ci}`);
|
|
1438
1593
|
// Handle nested relationship creates within the created node
|
|
1439
|
-
const nestedRelLines = this.buildCreateRelationships(nodeSpec,
|
|
1594
|
+
const nestedRelLines = this.buildCreateRelationships(nodeSpec, createDef, createVar, `${itemPrefix}_create${ci}`, params, allVars, policyContext, paramCounter);
|
|
1440
1595
|
const propsClause = propString.length > 0
|
|
1441
1596
|
? ` { ${propString} }`
|
|
1442
|
-
: ` { ${this.buildGeneratedIdClause(
|
|
1597
|
+
: ` { ${this.buildGeneratedIdClause(createDef)} }`;
|
|
1443
1598
|
// `edge` properties on the new relationship — the same input
|
|
1444
1599
|
// create() accepts. Pre-2.4.5 this path never read
|
|
1445
1600
|
// `createSpec.edge`, so they were silently dropped.
|
|
@@ -1488,6 +1643,11 @@ export class MutationCompiler {
|
|
|
1488
1643
|
const nodeUpdate = (updateSpec.node ??
|
|
1489
1644
|
(edgeUpdate ? {} : updateSpec));
|
|
1490
1645
|
const updateWhere = item.where;
|
|
1646
|
+
// A flat interface update writes the interface's own fields; the
|
|
1647
|
+
// per-implementer `_on` form is not supported (v2.4.8).
|
|
1648
|
+
if (abstract && isPlainObject(nodeUpdate) && '_on' in nodeUpdate)
|
|
1649
|
+
throw new OGMError(`"_on" in a nested update of "${key}" is not supported. Key the update by implementing type instead ` +
|
|
1650
|
+
`(e.g. { ${key}: { ${abstract.members[0] ?? 'Member'}: [{ where, update }] } }).`);
|
|
1491
1651
|
const updateVar = `${sourceVar}_u${varCounter}`;
|
|
1492
1652
|
const relVar = `r_${key}_${i}`;
|
|
1493
1653
|
varCounter++;
|
|
@@ -1500,6 +1660,7 @@ export class MutationCompiler {
|
|
|
1500
1660
|
if (val === undefined)
|
|
1501
1661
|
continue;
|
|
1502
1662
|
assertSafePropertyName(prop, 'nested update property');
|
|
1663
|
+
assertKnownField(targetNodeDef, prop, 'nested update');
|
|
1503
1664
|
const nodePropDef = targetNodeDef.properties.get(prop);
|
|
1504
1665
|
const paramName = `${itemPrefix}_set_${prop}`;
|
|
1505
1666
|
setClauses.push(`${updateVar}.${escapeIdentifier(prop)} = ${wrapWriteExpr(`$${paramName}`, val, nodePropDef)}`);
|
|
@@ -1538,6 +1699,7 @@ export class MutationCompiler {
|
|
|
1538
1699
|
targetVar: updateVar,
|
|
1539
1700
|
edgeVar: relVar,
|
|
1540
1701
|
targetLabel: 'auto',
|
|
1702
|
+
schema: this.schema,
|
|
1541
1703
|
});
|
|
1542
1704
|
lines.push(`MATCH ${relArrow}`);
|
|
1543
1705
|
// WHERE on the target.
|
|
@@ -1720,6 +1882,114 @@ export function connectionWhereToNodeWhere(spec) {
|
|
|
1720
1882
|
}
|
|
1721
1883
|
return parts.length === 1 ? parts[0] : { AND: parts };
|
|
1722
1884
|
}
|
|
1885
|
+
/**
|
|
1886
|
+
* The concrete members of an interface / union relationship target, or
|
|
1887
|
+
* `null` for a node type. Implementer nodes carry their OWN labels, not
|
|
1888
|
+
* the interface's, so every write that has to find one matches by these
|
|
1889
|
+
* members' labels (v2.4.8).
|
|
1890
|
+
*/
|
|
1891
|
+
export function abstractTarget(target, schema) {
|
|
1892
|
+
if (schema.nodes.has(target))
|
|
1893
|
+
return null;
|
|
1894
|
+
const iface = schema.interfaces?.get(target);
|
|
1895
|
+
if (iface)
|
|
1896
|
+
return {
|
|
1897
|
+
kind: 'interface',
|
|
1898
|
+
name: target,
|
|
1899
|
+
members: iface.implementedBy.filter((m) => schema.nodes.has(m)),
|
|
1900
|
+
};
|
|
1901
|
+
const union = schema.unions?.get(target);
|
|
1902
|
+
if (union)
|
|
1903
|
+
return {
|
|
1904
|
+
kind: 'union',
|
|
1905
|
+
name: target,
|
|
1906
|
+
members: union.filter((m) => schema.nodes.has(m)),
|
|
1907
|
+
};
|
|
1908
|
+
return null;
|
|
1909
|
+
}
|
|
1910
|
+
/**
|
|
1911
|
+
* Classify the nested-write value of an abstract relationship (v2.4.8):
|
|
1912
|
+
*
|
|
1913
|
+
* - PER-MEMBER — keyed by member type (`{ Dog: { … } }`), the shape
|
|
1914
|
+
* codegen emits for union AND interface targets. Returns the defined
|
|
1915
|
+
* `[member, value]` entries.
|
|
1916
|
+
* - FLAT — the operation keys themselves (`{ connect, create, … }`, or an
|
|
1917
|
+
* array of them), the @neo4j/graphql interface shape. Returns `null`.
|
|
1918
|
+
* Only interfaces accept it: a union has no common fields to filter or
|
|
1919
|
+
* write, so a flat union input throws.
|
|
1920
|
+
*
|
|
1921
|
+
* Pre-2.4.8 a key that was not a member was skipped, so a flat or typo'd
|
|
1922
|
+
* input on an abstract relationship compiled to no write at all. Mixing
|
|
1923
|
+
* member keys with other keys now throws instead.
|
|
1924
|
+
*/
|
|
1925
|
+
export function perMemberEntries(value, target, fieldName) {
|
|
1926
|
+
if (Array.isArray(value) || !isPlainObject(value)) {
|
|
1927
|
+
if (target.kind === 'union')
|
|
1928
|
+
throw new OGMError(`Relationship "${fieldName}" targets the union "${target.name}": key its nested input by member type ` +
|
|
1929
|
+
`(${target.members.map((m) => `"${m}"`).join(', ')}).`);
|
|
1930
|
+
return null;
|
|
1931
|
+
}
|
|
1932
|
+
const keys = Object.keys(value).filter((k) => value[k] !== undefined);
|
|
1933
|
+
const memberKeys = keys.filter((k) => target.members.includes(k));
|
|
1934
|
+
if (memberKeys.length === 0 && target.kind === 'interface')
|
|
1935
|
+
return null;
|
|
1936
|
+
if (memberKeys.length !== keys.length) {
|
|
1937
|
+
const other = keys.filter((k) => !target.members.includes(k));
|
|
1938
|
+
throw new OGMError(`Unknown key(s) ${other.map((k) => `"${k}"`).join(', ')} in the nested input of "${fieldName}" ` +
|
|
1939
|
+
`(${target.kind} "${target.name}"). ` +
|
|
1940
|
+
(target.kind === 'union'
|
|
1941
|
+
? `Key it by member type: ${target.members.map((m) => `"${m}"`).join(', ')}.`
|
|
1942
|
+
: `Use either member keys (${target.members.map((m) => `"${m}"`).join(', ')}) or the operation keys, not both.`));
|
|
1943
|
+
}
|
|
1944
|
+
return memberKeys
|
|
1945
|
+
.filter((k) => value[k] !== null)
|
|
1946
|
+
.map((k) => [k, value[k]]);
|
|
1947
|
+
}
|
|
1948
|
+
/**
|
|
1949
|
+
* Unwrap a FLAT interface nested-create node spec — `{ Dog: { … } }`,
|
|
1950
|
+
* exactly one implementer key — into the implementer and its own input
|
|
1951
|
+
* (v2.4.8). Anything else throws: without a member key the compiler cannot
|
|
1952
|
+
* know which labels and properties the new node gets.
|
|
1953
|
+
*/
|
|
1954
|
+
export function unwrapAbstractCreateNode(nodeSpec, target, fieldName) {
|
|
1955
|
+
const keys = isPlainObject(nodeSpec)
|
|
1956
|
+
? Object.keys(nodeSpec).filter((k) => nodeSpec[k] !== undefined)
|
|
1957
|
+
: [];
|
|
1958
|
+
if (!isPlainObject(nodeSpec) ||
|
|
1959
|
+
keys.length !== 1 ||
|
|
1960
|
+
!target.members.includes(keys[0]) ||
|
|
1961
|
+
!isPlainObject(nodeSpec[keys[0]]))
|
|
1962
|
+
throw new OGMError(`A nested create on "${fieldName}" (${target.kind} "${target.name}") must name exactly one implementing type, ` +
|
|
1963
|
+
`e.g. { node: { ${target.members[0] ?? 'Member'}: { … } } }. Allowed: ${target.members.map((m) => `"${m}"`).join(', ')}.`);
|
|
1964
|
+
return [keys[0], nodeSpec[keys[0]]];
|
|
1965
|
+
}
|
|
1966
|
+
/**
|
|
1967
|
+
* Reject a write key that is neither a property nor a relationship of the
|
|
1968
|
+
* type (v2.5.0). Pre-2.5.0 only its spelling was checked, so a typo
|
|
1969
|
+
* (`nmae`) was stored as a stray property and the intended field was left
|
|
1970
|
+
* untouched — at every depth (nested creates and updates included).
|
|
1971
|
+
*/
|
|
1972
|
+
function assertKnownField(nodeDef, key, input) {
|
|
1973
|
+
if (nodeDef.properties.has(key) || nodeDef.relationships.has(key))
|
|
1974
|
+
return;
|
|
1975
|
+
throw new OGMError(`Unknown field "${key}" in the ${input} input of ${nodeDef.typeName}: it is neither a property nor a ` +
|
|
1976
|
+
`relationship of the type. Declare it in the schema, or fix the key.`);
|
|
1977
|
+
}
|
|
1978
|
+
/**
|
|
1979
|
+
* MERGE writes scalars only. Pre-2.4.8 a relationship field in an upsert's
|
|
1980
|
+
* `create` / `update` was skipped, so its nested connect / create never
|
|
1981
|
+
* ran and nothing said so.
|
|
1982
|
+
*/
|
|
1983
|
+
function upsertRelationshipError(nodeDef, key, branch) {
|
|
1984
|
+
return new OGMError(`upsert does not support relationship fields: "${key}" in "${branch}" (${nodeDef.typeName}). ` +
|
|
1985
|
+
`Use create() / update() for nested relationship operations.`);
|
|
1986
|
+
}
|
|
1987
|
+
/** `null` / `{}`: the whole-relationship (blanket) form, never per member. */
|
|
1988
|
+
function isBlanketSpec(spec) {
|
|
1989
|
+
if (spec == null)
|
|
1990
|
+
return true;
|
|
1991
|
+
return (isPlainObject(spec) && Object.keys(spec).every((k) => spec[k] === undefined));
|
|
1992
|
+
}
|
|
1723
1993
|
/** A nested delete item's own `delete` asks for a deeper cascade. */
|
|
1724
1994
|
function hasNestedCascade(value) {
|
|
1725
1995
|
if (value === undefined || value === null)
|