grafeo-ogm 2.4.7 → 2.4.8
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 +68 -0
- package/README.md +77 -31
- 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 +647 -380
- 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 +40 -0
- package/dist/cjs/compilers/selection.compiler.d.ts.map +1 -1
- package/dist/cjs/compilers/selection.compiler.js +123 -21
- 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 +3 -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.map +1 -1
- package/dist/cjs/interface-model.js +6 -0
- package/dist/cjs/interface-model.js.map +1 -1
- package/dist/cjs/model.d.ts.map +1 -1
- package/dist/cjs/model.js +6 -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/testing/cypher-assert.d.ts.map +1 -1
- package/dist/cjs/testing/cypher-assert.js +25 -2
- package/dist/cjs/testing/cypher-assert.js.map +1 -1
- package/dist/cjs/testing/neo4j-record-factory.d.ts +25 -2
- package/dist/cjs/testing/neo4j-record-factory.d.ts.map +1 -1
- package/dist/cjs/testing/neo4j-record-factory.js +82 -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 +644 -380
- 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 +40 -0
- package/dist/esm/compilers/selection.compiler.d.ts.map +1 -1
- package/dist/esm/compilers/selection.compiler.js +123 -21
- 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 +3 -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.map +1 -1
- package/dist/esm/interface-model.js +6 -0
- package/dist/esm/interface-model.js.map +1 -1
- package/dist/esm/model.d.ts.map +1 -1
- package/dist/esm/model.js +6 -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/testing/cypher-assert.d.ts.map +1 -1
- package/dist/esm/testing/cypher-assert.js +25 -2
- package/dist/esm/testing/cypher-assert.js.map +1 -1
- package/dist/esm/testing/neo4j-record-factory.d.ts +25 -2
- package/dist/esm/testing/neo4j-record-factory.d.ts.map +1 -1
- package/dist/esm/testing/neo4j-record-factory.js +82 -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.
|
|
@@ -486,8 +553,11 @@ export class MutationCompiler {
|
|
|
486
553
|
for (const [key, value] of Object.entries(create)) {
|
|
487
554
|
if (value === undefined)
|
|
488
555
|
continue;
|
|
489
|
-
if (nodeDef.relationships.has(key))
|
|
490
|
-
|
|
556
|
+
if (nodeDef.relationships.has(key)) {
|
|
557
|
+
if (value === null)
|
|
558
|
+
continue;
|
|
559
|
+
throw upsertRelationshipError(nodeDef, key, 'create');
|
|
560
|
+
}
|
|
491
561
|
assertSafePropertyName(key, 'create property');
|
|
492
562
|
const propDef = nodeDef.properties.get(key);
|
|
493
563
|
const paramName = `onCreate_${key}`;
|
|
@@ -533,8 +603,11 @@ export class MutationCompiler {
|
|
|
533
603
|
for (const [key, value] of Object.entries(update)) {
|
|
534
604
|
if (value === undefined)
|
|
535
605
|
continue;
|
|
536
|
-
if (nodeDef.relationships.has(key))
|
|
537
|
-
|
|
606
|
+
if (nodeDef.relationships.has(key)) {
|
|
607
|
+
if (value === null)
|
|
608
|
+
continue;
|
|
609
|
+
throw upsertRelationshipError(nodeDef, key, 'update');
|
|
610
|
+
}
|
|
538
611
|
assertSafePropertyName(key, 'update property');
|
|
539
612
|
const propDef = nodeDef.properties.get(key);
|
|
540
613
|
const paramName = `onMatch_${key}`;
|
|
@@ -785,19 +858,20 @@ export class MutationCompiler {
|
|
|
785
858
|
const relDef = nodeDef.relationships.get(key);
|
|
786
859
|
if (!relDef)
|
|
787
860
|
continue;
|
|
861
|
+
if (value == null)
|
|
862
|
+
continue;
|
|
788
863
|
const relInput = value;
|
|
789
|
-
//
|
|
790
|
-
//
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
864
|
+
// Union / interface targets: per-member keys (e.g. items: { Ebook:
|
|
865
|
+
// { create: [...] } }) dispatch to the concrete member; an interface
|
|
866
|
+
// also takes the flat `{ connect, create }` shape (v2.4.8 — pre-2.4.8
|
|
867
|
+
// interface targets were skipped entirely, so nothing was written).
|
|
868
|
+
const abstract = abstractTarget(relDef.target, this.schema);
|
|
869
|
+
const memberEntries = abstract
|
|
870
|
+
? perMemberEntries(relInput, abstract, key)
|
|
871
|
+
: null;
|
|
872
|
+
if (memberEntries) {
|
|
873
|
+
for (const [memberKey, memberValue] of memberEntries) {
|
|
798
874
|
const memberNodeDef = this.schema.nodes.get(memberKey);
|
|
799
|
-
if (!memberNodeDef)
|
|
800
|
-
continue;
|
|
801
875
|
const memberInput = memberValue;
|
|
802
876
|
// Process creates/connects for this union member
|
|
803
877
|
const memberLines = this.buildCreateRelationshipsForTarget(memberInput, memberNodeDef, relDef, nodeVar, `${prefix}_${key}_${memberKey}`, params, allVars, withClause, nestedCounter, policyContext, paramCounter);
|
|
@@ -806,9 +880,7 @@ export class MutationCompiler {
|
|
|
806
880
|
}
|
|
807
881
|
continue;
|
|
808
882
|
}
|
|
809
|
-
const targetNodeDef = this.
|
|
810
|
-
if (!targetNodeDef)
|
|
811
|
-
continue;
|
|
883
|
+
const targetNodeDef = this.requireTargetDef(relDef);
|
|
812
884
|
const resultLines = this.buildCreateRelationshipsForTarget(relInput, targetNodeDef, relDef, nodeVar, `${prefix}_${key}`, params, allVars, withClause, nestedCounter, policyContext, paramCounter);
|
|
813
885
|
nestedCounter += resultLines.counterUsed;
|
|
814
886
|
lines.push(...resultLines.lines);
|
|
@@ -822,7 +894,8 @@ export class MutationCompiler {
|
|
|
822
894
|
buildCreateRelationshipsForTarget(relInput, targetNodeDef, relDef, nodeVar, prefix, params, allVars, withClause, startCounter, policyContext, paramCounter) {
|
|
823
895
|
const lines = [];
|
|
824
896
|
let counter = 0;
|
|
825
|
-
|
|
897
|
+
// A flat interface input (v2.4.8): each create names its implementer.
|
|
898
|
+
const abstract = abstractTarget(targetNodeDef.typeName, this.schema);
|
|
826
899
|
// --- create ---
|
|
827
900
|
if (relInput.create) {
|
|
828
901
|
const createItems = Array.isArray(relInput.create)
|
|
@@ -830,15 +903,22 @@ export class MutationCompiler {
|
|
|
830
903
|
: [relInput.create];
|
|
831
904
|
for (let ci = 0; ci < createItems.length; ci++) {
|
|
832
905
|
const createSpec = createItems[ci];
|
|
833
|
-
|
|
906
|
+
let nodeSpec = (createSpec.node ?? createSpec);
|
|
907
|
+
let createDef = targetNodeDef;
|
|
908
|
+
if (abstract) {
|
|
909
|
+
const [member, memberSpec] = unwrapAbstractCreateNode(nodeSpec, abstract, relDef.fieldName);
|
|
910
|
+
createDef = this.schema.nodes.get(member);
|
|
911
|
+
nodeSpec = memberSpec;
|
|
912
|
+
}
|
|
913
|
+
const targetLabelStr = this.getCachedLabelString(createDef);
|
|
834
914
|
const createVar = `${nodeVar}_c${startCounter + counter}`;
|
|
835
915
|
counter++;
|
|
836
|
-
const { propString, propParams } = this.buildCreateProperties(nodeSpec,
|
|
916
|
+
const { propString, propParams } = this.buildCreateProperties(nodeSpec, createDef, `${prefix}_create${ci}`);
|
|
837
917
|
// Handle nested relationship creates within the created node
|
|
838
|
-
const nestedRelLines = this.buildCreateRelationships(nodeSpec,
|
|
918
|
+
const nestedRelLines = this.buildCreateRelationships(nodeSpec, createDef, createVar, `${prefix}_create${ci}`, params, allVars, policyContext, paramCounter);
|
|
839
919
|
const propsClause = propString.length > 0
|
|
840
920
|
? ` { ${propString} }`
|
|
841
|
-
: ` { ${this.buildGeneratedIdClause(
|
|
921
|
+
: ` { ${this.buildGeneratedIdClause(createDef)} }`;
|
|
842
922
|
const relPattern = buildRelPattern({
|
|
843
923
|
sourceVar: nodeVar,
|
|
844
924
|
relDef,
|
|
@@ -868,6 +948,9 @@ export class MutationCompiler {
|
|
|
868
948
|
targetVar: createVar,
|
|
869
949
|
edgeVar: relVar,
|
|
870
950
|
targetLabel: 'auto',
|
|
951
|
+
// Abstract targets: no label — the created node never carries
|
|
952
|
+
// the union / interface label (v2.4.8).
|
|
953
|
+
schema: this.schema,
|
|
871
954
|
});
|
|
872
955
|
lines.push(`WITH ${[...allVars, createVar].join(', ')}`);
|
|
873
956
|
lines.push(`MATCH ${relArrow}`);
|
|
@@ -897,339 +980,426 @@ export class MutationCompiler {
|
|
|
897
980
|
? relInput.connect
|
|
898
981
|
: [relInput.connect];
|
|
899
982
|
for (let ci = 0; ci < connectItems.length; ci++) {
|
|
900
|
-
const connectSpec = connectItems[ci];
|
|
901
|
-
const whereSpec = connectSpec.where;
|
|
902
|
-
const edgeInput = connectSpec.edge;
|
|
903
983
|
// 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}`;
|
|
984
|
+
const index = startCounter + counter;
|
|
908
985
|
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({
|
|
986
|
+
lines.push(...this.buildConnectItem({
|
|
987
|
+
connectSpec: connectItems[ci],
|
|
988
|
+
relDef,
|
|
989
|
+
targetNodeDef,
|
|
990
|
+
sourceVar: nodeVar,
|
|
991
|
+
withClause,
|
|
992
|
+
connectVar: `${nodeVar}_cn${index}`,
|
|
993
|
+
relVar: `r_conn_${index}`,
|
|
994
|
+
edgeParamPrefix: `${prefix}_conn${ci}`,
|
|
995
|
+
returnAlias: `_ccn_${prefix}_${ci}`,
|
|
996
|
+
params,
|
|
997
|
+
policyContext,
|
|
998
|
+
paramCounter,
|
|
999
|
+
scope: {
|
|
941
1000
|
sourceVar: nodeVar,
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
lines.push(`}`);
|
|
1001
|
+
carryVars: allVars,
|
|
1002
|
+
withClause,
|
|
1003
|
+
varPrefix: '',
|
|
1004
|
+
depth: 0,
|
|
1005
|
+
},
|
|
1006
|
+
}));
|
|
949
1007
|
}
|
|
950
1008
|
}
|
|
951
1009
|
return { lines, counterUsed: counter };
|
|
952
1010
|
}
|
|
953
|
-
|
|
1011
|
+
/**
|
|
1012
|
+
* Top-level `connect`. A union / interface relationship keyed by member
|
|
1013
|
+
* (`{ media: { Book: [...] } }`, the shape codegen emits) connects each
|
|
1014
|
+
* member separately; a flat interface input matches the implementers by
|
|
1015
|
+
* their own labels (v2.4.8 — pre-2.4.8 both matched the abstract type's
|
|
1016
|
+
* label, which no node carries, and the per-member shape was never
|
|
1017
|
+
* unwrapped, so the MATCH carried NO filter at all).
|
|
1018
|
+
*/
|
|
1019
|
+
buildConnects(connect, nodeDef, params, policyContext, paramCounter, scope = TOP_SCOPE) {
|
|
954
1020
|
const lines = [];
|
|
955
1021
|
for (const [fieldName, spec] of Object.entries(connect)) {
|
|
956
1022
|
const relDef = nodeDef.relationships.get(fieldName);
|
|
957
1023
|
if (!relDef)
|
|
958
1024
|
continue;
|
|
959
|
-
const
|
|
960
|
-
|
|
1025
|
+
const abstract = abstractTarget(relDef.target, this.schema);
|
|
1026
|
+
const memberEntries = abstract && !isBlanketSpec(spec)
|
|
1027
|
+
? perMemberEntries(spec, abstract, fieldName)
|
|
1028
|
+
: null;
|
|
1029
|
+
if (memberEntries) {
|
|
1030
|
+
for (const [member, memberSpec] of memberEntries)
|
|
1031
|
+
lines.push(...this.buildRelConnects(memberSpec, this.narrowToMember(relDef, member), this.schema.nodes.get(member), `${fieldName}_${member}`, params, policyContext, paramCounter, scope));
|
|
961
1032
|
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
1033
|
}
|
|
1034
|
+
lines.push(...this.buildRelConnects(spec, relDef, this.requireTargetDef(relDef), fieldName, params, policyContext, paramCounter, scope));
|
|
1035
|
+
}
|
|
1036
|
+
return lines;
|
|
1037
|
+
}
|
|
1038
|
+
/**
|
|
1039
|
+
* One relationship's top-level connect. `tag` names its variables,
|
|
1040
|
+
* params and aliases: the field name, or `<field>_<Member>` for an
|
|
1041
|
+
* abstract relationship given per member, so two members never collide.
|
|
1042
|
+
*/
|
|
1043
|
+
buildRelConnects(spec, relDef, targetNodeDef, tag, params, policyContext, paramCounter, scope) {
|
|
1044
|
+
const lines = [];
|
|
1045
|
+
const { sourceVar, withClause, varPrefix } = scope;
|
|
1046
|
+
// Array connect
|
|
1047
|
+
if (Array.isArray(spec)) {
|
|
1048
|
+
if (spec.length === 0)
|
|
1049
|
+
return lines; // Empty array — nothing to connect
|
|
1050
|
+
// The UNWIND fast path compiles filters against per-row values and
|
|
1051
|
+
// is only exact for plain scalar, non-null filters (see
|
|
1052
|
+
// isConnectFastPathEligible). Everything else — null values,
|
|
1053
|
+
// logical keys, traversals, abstract targets — takes one CALL
|
|
1054
|
+
// subquery per item, compiled through WhereCompiler.
|
|
1055
|
+
const useFastPath = !abstractTarget(targetNodeDef.typeName, this.schema) &&
|
|
1056
|
+
this.isConnectFastPathEligible(spec, targetNodeDef);
|
|
1057
|
+
if (!useFastPath)
|
|
1058
|
+
for (let ci = 0; ci < spec.length; ci++)
|
|
1059
|
+
lines.push(...this.buildConnectItem({
|
|
1060
|
+
connectSpec: spec[ci],
|
|
1061
|
+
relDef,
|
|
1062
|
+
targetNodeDef,
|
|
1063
|
+
sourceVar,
|
|
1064
|
+
withClause,
|
|
1065
|
+
connectVar: `${varPrefix}target_${tag}_${ci}`,
|
|
1066
|
+
relVar: `${varPrefix}r_conn_${tag}_${ci}`,
|
|
1067
|
+
edgeParamPrefix: `${varPrefix}connect_${tag}_${ci}`,
|
|
1068
|
+
returnAlias: `_ccn_${varPrefix}${tag}_${ci}`,
|
|
1069
|
+
params,
|
|
1070
|
+
policyContext,
|
|
1071
|
+
paramCounter,
|
|
1072
|
+
scope,
|
|
1073
|
+
}));
|
|
1095
1074
|
else {
|
|
1096
|
-
|
|
1097
|
-
const
|
|
1098
|
-
const
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1075
|
+
const targetVar = `${varPrefix}target`;
|
|
1076
|
+
const relVar = `${varPrefix}r`;
|
|
1077
|
+
const targetLabelStr = this.getCachedLabelString(targetNodeDef);
|
|
1078
|
+
// Scalar-only WHERE — use efficient UNWIND approach
|
|
1079
|
+
const paramName = `${varPrefix}connect_${tag}`;
|
|
1080
|
+
// Strip undefined values from edge objects — Neo4j driver drops undefined,
|
|
1081
|
+
// causing param mismatches when the Cypher references nested edge properties.
|
|
1082
|
+
params[paramName] = spec.map((raw) => {
|
|
1083
|
+
// The WHERE reads `connItem.where.node.<key>`, so a bare-property
|
|
1084
|
+
// where (`{ where: { title } }`, which the fast path accepts) is
|
|
1085
|
+
// rebound under `node`. Pre-2.4.8 it was sent as-is: every lookup
|
|
1086
|
+
// was null, so the array connect silently linked nothing.
|
|
1087
|
+
const where = raw.where;
|
|
1088
|
+
const item = isPlainObject(where) &&
|
|
1089
|
+
!Object.keys(where).some((k) => CONNECTION_WHERE_KEYS.has(k))
|
|
1090
|
+
? { ...raw, where: { node: where } }
|
|
1091
|
+
: raw;
|
|
1092
|
+
const edge = item.edge;
|
|
1093
|
+
if (!edge)
|
|
1094
|
+
return item;
|
|
1095
|
+
const cleaned = {};
|
|
1096
|
+
for (const [k, v] of Object.entries(edge))
|
|
1097
|
+
if (v !== undefined)
|
|
1098
|
+
cleaned[k] = coerceWriteValue(v, this.getEdgePropDef(relDef, k));
|
|
1099
|
+
return { ...item, edge: cleaned };
|
|
1100
|
+
});
|
|
1101
|
+
// Wrap in a CALL subquery: UNWIND + MATCH yields one row per
|
|
1102
|
+
// matched target (zero when nothing matches), which would
|
|
1103
|
+
// multiply — or drop — the updated node for every later clause.
|
|
1104
|
+
// Connecting to no target is a no-op, as in @neo4j/graphql.
|
|
1105
|
+
lines.push(withClause);
|
|
1102
1106
|
lines.push('CALL {');
|
|
1103
|
-
lines.push(
|
|
1104
|
-
lines.push(`
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
]
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1107
|
+
lines.push(withClause);
|
|
1108
|
+
lines.push(`UNWIND $${paramName} AS connItem`);
|
|
1109
|
+
lines.push(`MATCH (${targetVar}:${targetLabelStr})`);
|
|
1110
|
+
// Build WHERE from the first item's structure to determine the path.
|
|
1111
|
+
// Pre-1.7.4 we silently used `firstItem`'s keys for ALL items —
|
|
1112
|
+
// if `spec[1]` had different filter keys (e.g. an extra
|
|
1113
|
+
// `tenantId`), those keys were silently dropped from the WHERE.
|
|
1114
|
+
// Validate homogeneity now and throw if items diverge so the
|
|
1115
|
+
// caller knows to split into separate calls or use the per-item
|
|
1116
|
+
// CALL fallback.
|
|
1117
|
+
const firstItem = spec[0];
|
|
1118
|
+
const firstItemSig = computeConnectItemSignature(firstItem);
|
|
1119
|
+
for (let idx = 1; idx < spec.length; idx++) {
|
|
1120
|
+
const sig = computeConnectItemSignature(spec[idx]);
|
|
1121
|
+
if (sig !== firstItemSig)
|
|
1122
|
+
throw new OGMError(`connect array items have divergent shapes — item[0] has keys "${firstItemSig}" ` +
|
|
1123
|
+
`but item[${idx}] has keys "${sig}". The UNWIND fast path requires every item ` +
|
|
1124
|
+
`to share the same WHERE / edge key set; otherwise the additional keys are ` +
|
|
1125
|
+
`silently dropped. Split into separate connect calls, or normalise the items ` +
|
|
1126
|
+
`to share the same shape.`);
|
|
1127
|
+
}
|
|
1128
|
+
const whereConditions = this.extractConnectWhereConditions(firstItem, targetVar, 'connItem', targetNodeDef);
|
|
1129
|
+
const policyPredicate = this.buildTargetPolicyPredicate(targetNodeDef, targetVar, params, policyContext, paramCounter);
|
|
1130
|
+
const unwindConditions = policyPredicate
|
|
1131
|
+
? [...whereConditions, policyPredicate]
|
|
1132
|
+
: whereConditions;
|
|
1133
|
+
if (unwindConditions.length > 0)
|
|
1134
|
+
lines.push(`WHERE ${unwindConditions.join(' AND ')}`);
|
|
1135
|
+
const mergePattern = buildRelPattern({
|
|
1136
|
+
sourceVar,
|
|
1137
|
+
relDef,
|
|
1138
|
+
targetVar,
|
|
1139
|
+
});
|
|
1140
|
+
lines.push(`MERGE ${mergePattern}`);
|
|
1141
|
+
// Handle edge properties from first item structure
|
|
1142
|
+
const edgeProps = this.extractEdgeProperties(firstItem);
|
|
1143
|
+
if (edgeProps.length > 0) {
|
|
1112
1144
|
// Use bare target var (no label) since target is already bound by MATCH
|
|
1113
1145
|
assertSafeIdentifier(relDef.type, 'relationship type');
|
|
1114
1146
|
const escapedRelType = escapeIdentifier(relDef.type);
|
|
1115
1147
|
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(', ')}`);
|
|
1148
|
+
? `(${sourceVar})-[${relVar}:${escapedRelType}]->(${targetVar})`
|
|
1149
|
+
: `(${sourceVar})<-[${relVar}:${escapedRelType}]-(${targetVar})`;
|
|
1150
|
+
// Replace MERGE line with one that has a variable
|
|
1151
|
+
lines[lines.length - 1] = `MERGE ${mergeWithVar}`;
|
|
1152
|
+
// r.prop is relationship property (escape), connItem.edge.prop is parameter map (no escape)
|
|
1153
|
+
const setItems = edgeProps.map((p) => `${relVar}.${escapeIdentifier(p)} = ${wrapListItemExpr(`connItem.edge.${p}`, spec.map((it) => it.edge?.[p]), this.getEdgePropDef(relDef, p))}`);
|
|
1154
|
+
lines.push(`SET ${setItems.join(', ')}`);
|
|
1130
1155
|
}
|
|
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}`);
|
|
1156
|
+
lines.push(`RETURN count(*) AS _conn_${varPrefix}${tag}`);
|
|
1140
1157
|
lines.push('}');
|
|
1141
1158
|
}
|
|
1159
|
+
return lines;
|
|
1142
1160
|
}
|
|
1161
|
+
// Single connect — same row-preserving isolation as the UNWIND path.
|
|
1162
|
+
return this.buildConnectItem({
|
|
1163
|
+
connectSpec: spec,
|
|
1164
|
+
relDef,
|
|
1165
|
+
targetNodeDef,
|
|
1166
|
+
sourceVar,
|
|
1167
|
+
withClause,
|
|
1168
|
+
connectVar: `${varPrefix}target`,
|
|
1169
|
+
relVar: `${varPrefix}r`,
|
|
1170
|
+
edgeParamPrefix: `${varPrefix}connect_${tag}`,
|
|
1171
|
+
returnAlias: `_conn_${varPrefix}${tag}`,
|
|
1172
|
+
params,
|
|
1173
|
+
policyContext,
|
|
1174
|
+
paramCounter,
|
|
1175
|
+
scope,
|
|
1176
|
+
});
|
|
1177
|
+
}
|
|
1178
|
+
/**
|
|
1179
|
+
* One connect item, isolated in a CALL subquery so a MATCH that finds
|
|
1180
|
+
* nothing (or many) never changes the outer row count:
|
|
1181
|
+
*
|
|
1182
|
+
* <withClause>
|
|
1183
|
+
* CALL {
|
|
1184
|
+
* <withClause>
|
|
1185
|
+
* MATCH (cv[:Label])
|
|
1186
|
+
* WHERE <member labels> AND <where> AND <target `read` policy>
|
|
1187
|
+
* MERGE (src)-[rv:REL]->(cv) [SET rv.<edge> = …]
|
|
1188
|
+
* RETURN count(*) AS <alias>
|
|
1189
|
+
* }
|
|
1190
|
+
*
|
|
1191
|
+
* Shared by every connect site (top-level, nested in create, nested in
|
|
1192
|
+
* update), which differ only in the names they pass.
|
|
1193
|
+
*/
|
|
1194
|
+
buildConnectItem(a) {
|
|
1195
|
+
const { connectSpec, relDef, targetNodeDef, sourceVar, connectVar } = a;
|
|
1196
|
+
const lines = [];
|
|
1197
|
+
const whereSpec = connectSpec?.where;
|
|
1198
|
+
const edgeInput = connectSpec?.edge;
|
|
1199
|
+
lines.push(a.withClause);
|
|
1200
|
+
lines.push('CALL {');
|
|
1201
|
+
lines.push(a.withClause);
|
|
1202
|
+
const { match, labelPredicate } = this.targetMatch(targetNodeDef, connectVar);
|
|
1203
|
+
lines.push(match);
|
|
1204
|
+
const conditions = [
|
|
1205
|
+
labelPredicate,
|
|
1206
|
+
this.compileTargetWhere(whereSpec, connectVar, targetNodeDef, a.params, a.policyContext, a.paramCounter),
|
|
1207
|
+
this.buildTargetPolicyPredicate(targetNodeDef, connectVar, a.params, a.policyContext, a.paramCounter),
|
|
1208
|
+
].filter((c) => Boolean(c));
|
|
1209
|
+
if (conditions.length > 0)
|
|
1210
|
+
lines.push(`WHERE ${conditions.join(' AND ')}`);
|
|
1211
|
+
if (edgeInput && Object.keys(edgeInput).length > 0) {
|
|
1212
|
+
// Use bare target var (no label) since connectVar is already bound by MATCH.
|
|
1213
|
+
// Including labels on a bound variable in MERGE causes Neo4j to reject it.
|
|
1214
|
+
assertSafeIdentifier(relDef.type, 'relationship type');
|
|
1215
|
+
const escapedRelType = escapeIdentifier(relDef.type);
|
|
1216
|
+
const mergePattern = relDef.direction === 'OUT'
|
|
1217
|
+
? `(${sourceVar})-[${a.relVar}:${escapedRelType}]->(${connectVar})`
|
|
1218
|
+
: `(${sourceVar})<-[${a.relVar}:${escapedRelType}]-(${connectVar})`;
|
|
1219
|
+
lines.push(`MERGE ${mergePattern}`);
|
|
1220
|
+
const setItems = [];
|
|
1221
|
+
for (const [prop, val] of Object.entries(edgeInput)) {
|
|
1222
|
+
if (val === undefined)
|
|
1223
|
+
continue;
|
|
1224
|
+
assertSafePropertyName(prop, 'edge property');
|
|
1225
|
+
const edgePropDef = this.getEdgePropDef(relDef, prop);
|
|
1226
|
+
const paramName = `${a.edgeParamPrefix}_edge_${prop}`;
|
|
1227
|
+
setItems.push(`${a.relVar}.${escapeIdentifier(prop)} = ${wrapWriteExpr(`$${paramName}`, val, edgePropDef)}`);
|
|
1228
|
+
a.params[paramName] = coerceWriteValue(val, edgePropDef);
|
|
1229
|
+
}
|
|
1230
|
+
if (setItems.length > 0)
|
|
1231
|
+
lines.push(`SET ${setItems.join(', ')}`);
|
|
1232
|
+
}
|
|
1233
|
+
else {
|
|
1234
|
+
const relPattern = buildRelPattern({
|
|
1235
|
+
sourceVar,
|
|
1236
|
+
relDef,
|
|
1237
|
+
targetVar: connectVar,
|
|
1238
|
+
});
|
|
1239
|
+
lines.push(`MERGE ${relPattern}`);
|
|
1240
|
+
}
|
|
1241
|
+
// A `connect` nested in the item links the CONNECTED node onward
|
|
1242
|
+
// (`connect: { posts: [{ where, connect: { tags: [...] } }] }`), with
|
|
1243
|
+
// the same policy predicates at its level. Pre-2.4.8 it was ignored.
|
|
1244
|
+
const nested = connectSpec?.connect;
|
|
1245
|
+
if (nested != null) {
|
|
1246
|
+
if (abstractTarget(targetNodeDef.typeName, this.schema))
|
|
1247
|
+
throw new OGMError(`A nested "connect" inside a connect on "${relDef.fieldName}" (abstract type "${targetNodeDef.typeName}") ` +
|
|
1248
|
+
`must be keyed by the implementing type, e.g. { ${relDef.fieldName}: { Member: [{ where, connect }] } }.`);
|
|
1249
|
+
const inner = nestedScope(a.scope.carryVars, connectVar, a.scope.depth);
|
|
1250
|
+
for (const nestedConnect of Array.isArray(nested) ? nested : [nested]) {
|
|
1251
|
+
if (!isPlainObject(nestedConnect))
|
|
1252
|
+
throw new OGMError(`A nested "connect" inside a connect on "${relDef.fieldName}" must be an object keyed by relationship.`);
|
|
1253
|
+
lines.push(...this.buildConnects(nestedConnect, targetNodeDef, a.params, a.policyContext, a.paramCounter, inner));
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
lines.push(`RETURN count(*) AS ${a.returnAlias}`);
|
|
1257
|
+
lines.push('}');
|
|
1143
1258
|
return lines;
|
|
1144
1259
|
}
|
|
1145
|
-
|
|
1260
|
+
/**
|
|
1261
|
+
* Top-level `disconnect`. Abstract relationships follow the connect
|
|
1262
|
+
* rules (see buildConnects): per member, or flat on an interface,
|
|
1263
|
+
* matching implementers by their own labels (v2.4.8 — pre-2.4.8 the
|
|
1264
|
+
* target pattern used the interface / union label, so nothing matched).
|
|
1265
|
+
*/
|
|
1266
|
+
buildDisconnects(disconnect, nodeDef, params, policyContext, paramCounter, scope = TOP_SCOPE) {
|
|
1146
1267
|
const lines = [];
|
|
1147
1268
|
for (const [fieldName, spec] of Object.entries(disconnect)) {
|
|
1148
1269
|
const relDef = nodeDef.relationships.get(fieldName);
|
|
1149
1270
|
if (!relDef)
|
|
1150
1271
|
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('}');
|
|
1272
|
+
const abstract = abstractTarget(relDef.target, this.schema);
|
|
1273
|
+
// `null` / `{}` stays a blanket disconnect of the whole relationship.
|
|
1274
|
+
const memberEntries = abstract && !isBlanketSpec(spec)
|
|
1275
|
+
? perMemberEntries(spec, abstract, fieldName)
|
|
1276
|
+
: null;
|
|
1277
|
+
if (memberEntries) {
|
|
1278
|
+
for (const [member, memberSpec] of memberEntries)
|
|
1279
|
+
lines.push(...this.buildRelDisconnects(memberSpec, this.narrowToMember(relDef, member), this.schema.nodes.get(member), `${fieldName}_${member}`, params, policyContext, paramCounter, scope));
|
|
1280
|
+
continue;
|
|
1211
1281
|
}
|
|
1282
|
+
lines.push(...this.buildRelDisconnects(spec, relDef, resolveTargetDef(relDef.target, this.schema) ?? undefined, fieldName, params, policyContext, paramCounter, scope));
|
|
1212
1283
|
}
|
|
1213
1284
|
return lines;
|
|
1214
1285
|
}
|
|
1215
1286
|
/**
|
|
1216
|
-
*
|
|
1287
|
+
* A `disconnect` nested in a disconnect item unlinks the disconnected
|
|
1288
|
+
* node's OWN relationships (`disconnect: { posts: [{ where, disconnect:
|
|
1289
|
+
* { tags: [...] } }] }`), with the same policy predicates at its level.
|
|
1290
|
+
* Pre-2.4.8 it was ignored. `''`-free: no input, no lines.
|
|
1291
|
+
*/
|
|
1292
|
+
buildNestedDisconnect(nested, relDef, targetNodeDef, targetVar, carryVars, params, policyContext, paramCounter, depth = 0) {
|
|
1293
|
+
if (nested == null)
|
|
1294
|
+
return [];
|
|
1295
|
+
if (abstractTarget(targetNodeDef.typeName, this.schema))
|
|
1296
|
+
throw new OGMError(`A nested "disconnect" inside a disconnect on "${relDef.fieldName}" (abstract type "${targetNodeDef.typeName}") ` +
|
|
1297
|
+
`must be keyed by the implementing type, e.g. { ${relDef.fieldName}: { Member: [{ where, disconnect }] } }.`);
|
|
1298
|
+
const inner = nestedScope(carryVars, targetVar, depth);
|
|
1299
|
+
const lines = [];
|
|
1300
|
+
for (const nestedDisconnect of Array.isArray(nested) ? nested : [nested]) {
|
|
1301
|
+
if (!isPlainObject(nestedDisconnect))
|
|
1302
|
+
throw new OGMError(`A nested "disconnect" inside a disconnect on "${relDef.fieldName}" must be an object keyed by relationship.`);
|
|
1303
|
+
lines.push(...this.buildDisconnects(nestedDisconnect, targetNodeDef, params, policyContext, paramCounter, inner));
|
|
1304
|
+
}
|
|
1305
|
+
return lines;
|
|
1306
|
+
}
|
|
1307
|
+
/** One relationship's disconnect items; `tag` as in buildRelConnects. */
|
|
1308
|
+
buildRelDisconnects(spec, relDef, targetNodeDef, tag, params, policyContext, paramCounter, scope) {
|
|
1309
|
+
const lines = [];
|
|
1310
|
+
const { sourceVar, withClause, varPrefix } = scope;
|
|
1311
|
+
// Normalize spec to an array of disconnect items
|
|
1312
|
+
const specItems = Array.isArray(spec) ? spec : [spec];
|
|
1313
|
+
for (let si = 0; si < specItems.length; si++) {
|
|
1314
|
+
const item = specItems[si];
|
|
1315
|
+
const relVar = `${varPrefix}r_${tag}_${si}`;
|
|
1316
|
+
// Wrap in a CALL subquery: the OPTIONAL MATCH yields one row per
|
|
1317
|
+
// matched relationship, which would multiply every later clause.
|
|
1318
|
+
// `count(*)` collapses it back to exactly one row per `n`.
|
|
1319
|
+
lines.push(withClause);
|
|
1320
|
+
lines.push('CALL {');
|
|
1321
|
+
lines.push(withClause);
|
|
1322
|
+
if (item == null ||
|
|
1323
|
+
(typeof item === 'object' && Object.keys(item).length === 0)) {
|
|
1324
|
+
// Blanket disconnect — remove every relationship of this type to
|
|
1325
|
+
// a target the caller can READ. Like the `where` branch below, a
|
|
1326
|
+
// policy-bound context only unlinks nodes it can see; pre-2.4.4
|
|
1327
|
+
// the blanket form bound an anonymous `()` and also severed links
|
|
1328
|
+
// to policy-hidden nodes. Without policies there is no predicate
|
|
1329
|
+
// and the Cypher is unchanged.
|
|
1330
|
+
const blanketTarget = `${varPrefix}target_${tag}_${si}`;
|
|
1331
|
+
const predicate = targetNodeDef
|
|
1332
|
+
? this.buildTargetPolicyPredicate(targetNodeDef, blanketTarget, params, policyContext, paramCounter)
|
|
1333
|
+
: undefined;
|
|
1334
|
+
const pattern = buildRelPattern({
|
|
1335
|
+
sourceVar,
|
|
1336
|
+
relDef,
|
|
1337
|
+
targetVar: predicate ? blanketTarget : '',
|
|
1338
|
+
edgeVar: relVar,
|
|
1339
|
+
targetLabelRaw: this.blanketTargetLabel(relDef),
|
|
1340
|
+
});
|
|
1341
|
+
lines.push(`OPTIONAL MATCH ${pattern}`);
|
|
1342
|
+
if (predicate)
|
|
1343
|
+
lines.push(`WHERE ${predicate}`);
|
|
1344
|
+
lines.push(`DELETE ${relVar}`);
|
|
1345
|
+
}
|
|
1346
|
+
else {
|
|
1347
|
+
// Specific disconnect with conditions
|
|
1348
|
+
const disconnectSpec = item;
|
|
1349
|
+
const whereSpec = disconnectSpec.where;
|
|
1350
|
+
const targetVar = `${varPrefix}target_${tag}_${si}`;
|
|
1351
|
+
const pattern = buildRelPattern({
|
|
1352
|
+
sourceVar,
|
|
1353
|
+
relDef,
|
|
1354
|
+
targetVar,
|
|
1355
|
+
edgeVar: relVar,
|
|
1356
|
+
targetLabel: 'auto',
|
|
1357
|
+
// An abstract target binds no label (its nodes carry their
|
|
1358
|
+
// members' labels); the member predicate below narrows it.
|
|
1359
|
+
schema: this.schema,
|
|
1360
|
+
});
|
|
1361
|
+
lines.push(`OPTIONAL MATCH ${pattern}`);
|
|
1362
|
+
if (!targetNodeDef)
|
|
1363
|
+
throw new OGMError(`Cannot resolve the target type "${relDef.target}" of relationship "${relDef.fieldName}".`);
|
|
1364
|
+
const labelPredicate = abstractTarget(targetNodeDef.typeName, this.schema)
|
|
1365
|
+
? this.targetMatch(targetNodeDef, targetVar).labelPredicate
|
|
1366
|
+
: null;
|
|
1367
|
+
const disconnectConditions = [
|
|
1368
|
+
labelPredicate,
|
|
1369
|
+
this.compileTargetWhere(whereSpec, targetVar, targetNodeDef, params, policyContext, paramCounter),
|
|
1370
|
+
this.buildTargetPolicyPredicate(targetNodeDef, targetVar, params, policyContext, paramCounter),
|
|
1371
|
+
].filter((c) => Boolean(c));
|
|
1372
|
+
if (disconnectConditions.length > 0)
|
|
1373
|
+
lines.push(`WHERE ${disconnectConditions.join(' AND ')}`);
|
|
1374
|
+
lines.push(`DELETE ${relVar}`);
|
|
1375
|
+
lines.push(...this.buildNestedDisconnect(disconnectSpec.disconnect, relDef, targetNodeDef, targetVar, scope.carryVars, params, policyContext, paramCounter, scope.depth));
|
|
1376
|
+
}
|
|
1377
|
+
lines.push(`RETURN count(*) AS _${relVar}`);
|
|
1378
|
+
lines.push('}');
|
|
1379
|
+
}
|
|
1380
|
+
return lines;
|
|
1381
|
+
}
|
|
1382
|
+
narrowToMember(relDef, member) {
|
|
1383
|
+
const narrowed = { ...relDef, target: member };
|
|
1384
|
+
this.narrowedRelDefs.add(narrowed);
|
|
1385
|
+
return narrowed;
|
|
1386
|
+
}
|
|
1387
|
+
/** The label a blanket disconnect over `relDef` must keep (see above). */
|
|
1388
|
+
blanketTargetLabel(relDef) {
|
|
1389
|
+
if (!this.narrowedRelDefs.has(relDef))
|
|
1390
|
+
return undefined;
|
|
1391
|
+
return this.getCachedLabelString(this.schema.nodes.get(relDef.target));
|
|
1392
|
+
}
|
|
1393
|
+
/**
|
|
1394
|
+
* Dispatch update operations for a union- or interface-typed
|
|
1395
|
+
* relationship given per member (interfaces since v2.4.8).
|
|
1217
1396
|
* Each member key in the input is recursed into buildUpdateRelationships
|
|
1218
1397
|
* with a narrowed relDef pointing at the concrete member type.
|
|
1219
1398
|
*/
|
|
1220
1399
|
dispatchUnionUpdateOps(key, memberEntries, relDef, nodeDef, sourceVar, prefix, params, depth, ancestorVars, policyContext, paramCounter) {
|
|
1221
|
-
const unionMembers = this.schema.unions.get(relDef.target);
|
|
1222
1400
|
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
|
-
};
|
|
1401
|
+
for (const [memberKey, memberValue] of memberEntries) {
|
|
1402
|
+
const memberRelDef = this.narrowToMember(relDef, memberKey);
|
|
1233
1403
|
const memberItems = Array.isArray(memberValue)
|
|
1234
1404
|
? memberValue
|
|
1235
1405
|
: [memberValue];
|
|
@@ -1258,18 +1428,21 @@ export class MutationCompiler {
|
|
|
1258
1428
|
const relDef = nodeDef.relationships.get(key);
|
|
1259
1429
|
if (!relDef || value == null)
|
|
1260
1430
|
continue;
|
|
1261
|
-
// Union targets
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1431
|
+
// Union / interface targets given per member — dispatch each member
|
|
1432
|
+
// recursively. A flat interface input is handled below against the
|
|
1433
|
+
// interface (v2.4.8 — pre-2.4.8 it was skipped, writing nothing).
|
|
1434
|
+
const abstract = abstractTarget(relDef.target, this.schema);
|
|
1435
|
+
const memberEntries = abstract
|
|
1436
|
+
? perMemberEntries(value, abstract, key)
|
|
1437
|
+
: null;
|
|
1438
|
+
if (memberEntries) {
|
|
1439
|
+
lines.push(...this.dispatchUnionUpdateOps(key, memberEntries, relDef, nodeDef, sourceVar, prefix, params, depth, ancestorVars, policyContext, paramCounter));
|
|
1266
1440
|
continue;
|
|
1267
1441
|
}
|
|
1268
|
-
const targetNodeDef = this.
|
|
1269
|
-
if (!targetNodeDef)
|
|
1270
|
-
continue;
|
|
1271
|
-
const targetLabelStr = this.getCachedLabelString(targetNodeDef);
|
|
1442
|
+
const targetNodeDef = this.requireTargetDef(relDef);
|
|
1272
1443
|
const relPrefix = `${prefix}_${key}`;
|
|
1444
|
+
// Past the dispatch, `abstract` is set only for a flat INTERFACE
|
|
1445
|
+
// input (a flat union input threw in perMemberEntries).
|
|
1273
1446
|
// Value can be an array (e.g., chapters: [{ create: ... }, { update: ... }])
|
|
1274
1447
|
// or an object (e.g., author: { connect: ... })
|
|
1275
1448
|
const items = Array.isArray(value)
|
|
@@ -1302,13 +1475,17 @@ export class MutationCompiler {
|
|
|
1302
1475
|
for (let di = 0; di < disconnectItems.length; di++) {
|
|
1303
1476
|
const disconnectSpec = disconnectItems[di];
|
|
1304
1477
|
const whereSpec = disconnectSpec.where;
|
|
1478
|
+
// A nested `disconnect` needs the target bound, so an item that
|
|
1479
|
+
// carries one is never the anonymous blanket form.
|
|
1480
|
+
const nestedDisconnect = disconnectSpec.disconnect;
|
|
1305
1481
|
const relVar = `r_disc_${key}_${i}_${di}`;
|
|
1306
1482
|
// Same isolation as the top-level disconnect: keep the
|
|
1307
1483
|
// OPTIONAL MATCH fan-out from multiplying later nested ops.
|
|
1308
1484
|
lines.push(withClause);
|
|
1309
1485
|
lines.push('CALL {');
|
|
1310
1486
|
lines.push(withClause);
|
|
1311
|
-
if (!whereSpec || Object.keys(whereSpec).length === 0)
|
|
1487
|
+
if ((!whereSpec || Object.keys(whereSpec).length === 0) &&
|
|
1488
|
+
nestedDisconnect == null) {
|
|
1312
1489
|
// Blanket — same read-policy gate as the top-level blanket
|
|
1313
1490
|
// disconnect: only unlink targets the caller can read.
|
|
1314
1491
|
const blanketTarget = `${sourceVar}_disc${varCounter}`;
|
|
@@ -1320,6 +1497,7 @@ export class MutationCompiler {
|
|
|
1320
1497
|
relDef,
|
|
1321
1498
|
targetVar: predicate ? blanketTarget : '',
|
|
1322
1499
|
edgeVar: relVar,
|
|
1500
|
+
targetLabelRaw: this.blanketTargetLabel(relDef),
|
|
1323
1501
|
});
|
|
1324
1502
|
lines.push(`OPTIONAL MATCH ${pattern}`);
|
|
1325
1503
|
if (predicate)
|
|
@@ -1335,6 +1513,9 @@ export class MutationCompiler {
|
|
|
1335
1513
|
targetVar: discTarget,
|
|
1336
1514
|
edgeVar: relVar,
|
|
1337
1515
|
targetLabel: 'auto',
|
|
1516
|
+
// An interface target matches its implementers, which do
|
|
1517
|
+
// not carry the interface label (v2.4.8).
|
|
1518
|
+
schema: this.schema,
|
|
1338
1519
|
});
|
|
1339
1520
|
lines.push(`OPTIONAL MATCH ${pattern}`);
|
|
1340
1521
|
// Pre-1.8.1 this called `buildNodeWhereConditions` with
|
|
@@ -1347,12 +1528,16 @@ export class MutationCompiler {
|
|
|
1347
1528
|
// path (`buildDisconnects`). Restores backwards-compat with
|
|
1348
1529
|
// @neo4j/graphql-ogm's nested update.disconnect[].where shape.
|
|
1349
1530
|
const nestedDiscConditions = [
|
|
1531
|
+
abstract
|
|
1532
|
+
? this.targetMatch(targetNodeDef, discTarget).labelPredicate
|
|
1533
|
+
: null,
|
|
1350
1534
|
this.compileTargetWhere(whereSpec, discTarget, targetNodeDef, params, policyContext, paramCounter),
|
|
1351
1535
|
this.buildTargetPolicyPredicate(targetNodeDef, discTarget, params, policyContext, paramCounter),
|
|
1352
1536
|
].filter((c) => Boolean(c));
|
|
1353
1537
|
if (nestedDiscConditions.length > 0)
|
|
1354
1538
|
lines.push(`WHERE ${nestedDiscConditions.join(' AND ')}`);
|
|
1355
1539
|
lines.push(`DELETE ${relVar}`);
|
|
1540
|
+
lines.push(...this.buildNestedDisconnect(nestedDisconnect, relDef, targetNodeDef, discTarget, allVars, params, policyContext, paramCounter));
|
|
1356
1541
|
}
|
|
1357
1542
|
lines.push(`RETURN count(*) AS _${relVar}`);
|
|
1358
1543
|
lines.push('}');
|
|
@@ -1364,61 +1549,30 @@ export class MutationCompiler {
|
|
|
1364
1549
|
? item.connect
|
|
1365
1550
|
: [item.connect];
|
|
1366
1551
|
for (let ci = 0; ci < connectItems.length; ci++) {
|
|
1367
|
-
const connectSpec = connectItems[ci];
|
|
1368
|
-
const whereSpec = connectSpec.where;
|
|
1369
|
-
const edgeInput = connectSpec.edge;
|
|
1370
1552
|
// 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
1553
|
const connectVar = `${sourceVar}_conn${varCounter}`;
|
|
1375
1554
|
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({
|
|
1555
|
+
lines.push(...this.buildConnectItem({
|
|
1556
|
+
connectSpec: connectItems[ci],
|
|
1557
|
+
relDef,
|
|
1558
|
+
targetNodeDef,
|
|
1559
|
+
sourceVar,
|
|
1560
|
+
withClause,
|
|
1561
|
+
connectVar,
|
|
1562
|
+
relVar: `r_conn_${key}_${ci}`,
|
|
1563
|
+
edgeParamPrefix: `${itemPrefix}_conn${ci}`,
|
|
1564
|
+
returnAlias: `_cn_${key}_${ci}`,
|
|
1565
|
+
params,
|
|
1566
|
+
policyContext,
|
|
1567
|
+
paramCounter,
|
|
1568
|
+
scope: {
|
|
1414
1569
|
sourceVar,
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
lines.push(`}`);
|
|
1570
|
+
carryVars: allVars,
|
|
1571
|
+
withClause,
|
|
1572
|
+
varPrefix: '',
|
|
1573
|
+
depth: 0,
|
|
1574
|
+
},
|
|
1575
|
+
}));
|
|
1422
1576
|
}
|
|
1423
1577
|
}
|
|
1424
1578
|
// --- create ---
|
|
@@ -1431,15 +1585,23 @@ export class MutationCompiler {
|
|
|
1431
1585
|
: [item.create];
|
|
1432
1586
|
for (let ci = 0; ci < createItems.length; ci++) {
|
|
1433
1587
|
const createSpec = createItems[ci];
|
|
1434
|
-
|
|
1588
|
+
let nodeSpec = (createSpec.node ?? createSpec);
|
|
1589
|
+
// Flat interface input: the create names its implementer.
|
|
1590
|
+
let createDef = targetNodeDef;
|
|
1591
|
+
if (abstract) {
|
|
1592
|
+
const [member, memberSpec] = unwrapAbstractCreateNode(nodeSpec, abstract, key);
|
|
1593
|
+
createDef = this.schema.nodes.get(member);
|
|
1594
|
+
nodeSpec = memberSpec;
|
|
1595
|
+
}
|
|
1596
|
+
const targetLabelStr = this.getCachedLabelString(createDef);
|
|
1435
1597
|
const createVar = `${sourceVar}_cr${varCounter}`;
|
|
1436
1598
|
varCounter++;
|
|
1437
|
-
const { propString, propParams } = this.buildCreateProperties(nodeSpec,
|
|
1599
|
+
const { propString, propParams } = this.buildCreateProperties(nodeSpec, createDef, `${itemPrefix}_create${ci}`);
|
|
1438
1600
|
// Handle nested relationship creates within the created node
|
|
1439
|
-
const nestedRelLines = this.buildCreateRelationships(nodeSpec,
|
|
1601
|
+
const nestedRelLines = this.buildCreateRelationships(nodeSpec, createDef, createVar, `${itemPrefix}_create${ci}`, params, allVars, policyContext, paramCounter);
|
|
1440
1602
|
const propsClause = propString.length > 0
|
|
1441
1603
|
? ` { ${propString} }`
|
|
1442
|
-
: ` { ${this.buildGeneratedIdClause(
|
|
1604
|
+
: ` { ${this.buildGeneratedIdClause(createDef)} }`;
|
|
1443
1605
|
// `edge` properties on the new relationship — the same input
|
|
1444
1606
|
// create() accepts. Pre-2.4.5 this path never read
|
|
1445
1607
|
// `createSpec.edge`, so they were silently dropped.
|
|
@@ -1488,6 +1650,11 @@ export class MutationCompiler {
|
|
|
1488
1650
|
const nodeUpdate = (updateSpec.node ??
|
|
1489
1651
|
(edgeUpdate ? {} : updateSpec));
|
|
1490
1652
|
const updateWhere = item.where;
|
|
1653
|
+
// A flat interface update writes the interface's own fields; the
|
|
1654
|
+
// per-implementer `_on` form is not supported (v2.4.8).
|
|
1655
|
+
if (abstract && isPlainObject(nodeUpdate) && '_on' in nodeUpdate)
|
|
1656
|
+
throw new OGMError(`"_on" in a nested update of "${key}" is not supported. Key the update by implementing type instead ` +
|
|
1657
|
+
`(e.g. { ${key}: { ${abstract.members[0] ?? 'Member'}: [{ where, update }] } }).`);
|
|
1491
1658
|
const updateVar = `${sourceVar}_u${varCounter}`;
|
|
1492
1659
|
const relVar = `r_${key}_${i}`;
|
|
1493
1660
|
varCounter++;
|
|
@@ -1538,6 +1705,7 @@ export class MutationCompiler {
|
|
|
1538
1705
|
targetVar: updateVar,
|
|
1539
1706
|
edgeVar: relVar,
|
|
1540
1707
|
targetLabel: 'auto',
|
|
1708
|
+
schema: this.schema,
|
|
1541
1709
|
});
|
|
1542
1710
|
lines.push(`MATCH ${relArrow}`);
|
|
1543
1711
|
// WHERE on the target.
|
|
@@ -1720,6 +1888,102 @@ export function connectionWhereToNodeWhere(spec) {
|
|
|
1720
1888
|
}
|
|
1721
1889
|
return parts.length === 1 ? parts[0] : { AND: parts };
|
|
1722
1890
|
}
|
|
1891
|
+
/**
|
|
1892
|
+
* The concrete members of an interface / union relationship target, or
|
|
1893
|
+
* `null` for a node type. Implementer nodes carry their OWN labels, not
|
|
1894
|
+
* the interface's, so every write that has to find one matches by these
|
|
1895
|
+
* members' labels (v2.4.8).
|
|
1896
|
+
*/
|
|
1897
|
+
export function abstractTarget(target, schema) {
|
|
1898
|
+
if (schema.nodes.has(target))
|
|
1899
|
+
return null;
|
|
1900
|
+
const iface = schema.interfaces?.get(target);
|
|
1901
|
+
if (iface)
|
|
1902
|
+
return {
|
|
1903
|
+
kind: 'interface',
|
|
1904
|
+
name: target,
|
|
1905
|
+
members: iface.implementedBy.filter((m) => schema.nodes.has(m)),
|
|
1906
|
+
};
|
|
1907
|
+
const union = schema.unions?.get(target);
|
|
1908
|
+
if (union)
|
|
1909
|
+
return {
|
|
1910
|
+
kind: 'union',
|
|
1911
|
+
name: target,
|
|
1912
|
+
members: union.filter((m) => schema.nodes.has(m)),
|
|
1913
|
+
};
|
|
1914
|
+
return null;
|
|
1915
|
+
}
|
|
1916
|
+
/**
|
|
1917
|
+
* Classify the nested-write value of an abstract relationship (v2.4.8):
|
|
1918
|
+
*
|
|
1919
|
+
* - PER-MEMBER — keyed by member type (`{ Dog: { … } }`), the shape
|
|
1920
|
+
* codegen emits for union AND interface targets. Returns the defined
|
|
1921
|
+
* `[member, value]` entries.
|
|
1922
|
+
* - FLAT — the operation keys themselves (`{ connect, create, … }`, or an
|
|
1923
|
+
* array of them), the @neo4j/graphql interface shape. Returns `null`.
|
|
1924
|
+
* Only interfaces accept it: a union has no common fields to filter or
|
|
1925
|
+
* write, so a flat union input throws.
|
|
1926
|
+
*
|
|
1927
|
+
* Pre-2.4.8 a key that was not a member was skipped, so a flat or typo'd
|
|
1928
|
+
* input on an abstract relationship compiled to no write at all. Mixing
|
|
1929
|
+
* member keys with other keys now throws instead.
|
|
1930
|
+
*/
|
|
1931
|
+
export function perMemberEntries(value, target, fieldName) {
|
|
1932
|
+
if (Array.isArray(value) || !isPlainObject(value)) {
|
|
1933
|
+
if (target.kind === 'union')
|
|
1934
|
+
throw new OGMError(`Relationship "${fieldName}" targets the union "${target.name}": key its nested input by member type ` +
|
|
1935
|
+
`(${target.members.map((m) => `"${m}"`).join(', ')}).`);
|
|
1936
|
+
return null;
|
|
1937
|
+
}
|
|
1938
|
+
const keys = Object.keys(value).filter((k) => value[k] !== undefined);
|
|
1939
|
+
const memberKeys = keys.filter((k) => target.members.includes(k));
|
|
1940
|
+
if (memberKeys.length === 0 && target.kind === 'interface')
|
|
1941
|
+
return null;
|
|
1942
|
+
if (memberKeys.length !== keys.length) {
|
|
1943
|
+
const other = keys.filter((k) => !target.members.includes(k));
|
|
1944
|
+
throw new OGMError(`Unknown key(s) ${other.map((k) => `"${k}"`).join(', ')} in the nested input of "${fieldName}" ` +
|
|
1945
|
+
`(${target.kind} "${target.name}"). ` +
|
|
1946
|
+
(target.kind === 'union'
|
|
1947
|
+
? `Key it by member type: ${target.members.map((m) => `"${m}"`).join(', ')}.`
|
|
1948
|
+
: `Use either member keys (${target.members.map((m) => `"${m}"`).join(', ')}) or the operation keys, not both.`));
|
|
1949
|
+
}
|
|
1950
|
+
return memberKeys
|
|
1951
|
+
.filter((k) => value[k] !== null)
|
|
1952
|
+
.map((k) => [k, value[k]]);
|
|
1953
|
+
}
|
|
1954
|
+
/**
|
|
1955
|
+
* Unwrap a FLAT interface nested-create node spec — `{ Dog: { … } }`,
|
|
1956
|
+
* exactly one implementer key — into the implementer and its own input
|
|
1957
|
+
* (v2.4.8). Anything else throws: without a member key the compiler cannot
|
|
1958
|
+
* know which labels and properties the new node gets.
|
|
1959
|
+
*/
|
|
1960
|
+
export function unwrapAbstractCreateNode(nodeSpec, target, fieldName) {
|
|
1961
|
+
const keys = isPlainObject(nodeSpec)
|
|
1962
|
+
? Object.keys(nodeSpec).filter((k) => nodeSpec[k] !== undefined)
|
|
1963
|
+
: [];
|
|
1964
|
+
if (!isPlainObject(nodeSpec) ||
|
|
1965
|
+
keys.length !== 1 ||
|
|
1966
|
+
!target.members.includes(keys[0]) ||
|
|
1967
|
+
!isPlainObject(nodeSpec[keys[0]]))
|
|
1968
|
+
throw new OGMError(`A nested create on "${fieldName}" (${target.kind} "${target.name}") must name exactly one implementing type, ` +
|
|
1969
|
+
`e.g. { node: { ${target.members[0] ?? 'Member'}: { … } } }. Allowed: ${target.members.map((m) => `"${m}"`).join(', ')}.`);
|
|
1970
|
+
return [keys[0], nodeSpec[keys[0]]];
|
|
1971
|
+
}
|
|
1972
|
+
/**
|
|
1973
|
+
* MERGE writes scalars only. Pre-2.4.8 a relationship field in an upsert's
|
|
1974
|
+
* `create` / `update` was skipped, so its nested connect / create never
|
|
1975
|
+
* ran and nothing said so.
|
|
1976
|
+
*/
|
|
1977
|
+
function upsertRelationshipError(nodeDef, key, branch) {
|
|
1978
|
+
return new OGMError(`upsert does not support relationship fields: "${key}" in "${branch}" (${nodeDef.typeName}). ` +
|
|
1979
|
+
`Use create() / update() for nested relationship operations.`);
|
|
1980
|
+
}
|
|
1981
|
+
/** `null` / `{}`: the whole-relationship (blanket) form, never per member. */
|
|
1982
|
+
function isBlanketSpec(spec) {
|
|
1983
|
+
if (spec == null)
|
|
1984
|
+
return true;
|
|
1985
|
+
return (isPlainObject(spec) && Object.keys(spec).every((k) => spec[k] === undefined));
|
|
1986
|
+
}
|
|
1723
1987
|
/** A nested delete item's own `delete` asks for a deeper cascade. */
|
|
1724
1988
|
function hasNestedCascade(value) {
|
|
1725
1989
|
if (value === undefined || value === null)
|