grafeo-ogm 2.5.3 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/README.md +123 -0
  3. package/dist/cjs/compilers/fulltext.compiler.d.ts +17 -1
  4. package/dist/cjs/compilers/fulltext.compiler.d.ts.map +1 -1
  5. package/dist/cjs/compilers/fulltext.compiler.js +35 -3
  6. package/dist/cjs/compilers/fulltext.compiler.js.map +1 -1
  7. package/dist/cjs/compilers/mutation.compiler.d.ts +39 -0
  8. package/dist/cjs/compilers/mutation.compiler.d.ts.map +1 -1
  9. package/dist/cjs/compilers/mutation.compiler.js +165 -14
  10. package/dist/cjs/compilers/mutation.compiler.js.map +1 -1
  11. package/dist/cjs/compilers/selection.compiler.d.ts +79 -1
  12. package/dist/cjs/compilers/selection.compiler.d.ts.map +1 -1
  13. package/dist/cjs/compilers/selection.compiler.js +549 -28
  14. package/dist/cjs/compilers/selection.compiler.js.map +1 -1
  15. package/dist/cjs/compilers/where.compiler.d.ts +21 -7
  16. package/dist/cjs/compilers/where.compiler.d.ts.map +1 -1
  17. package/dist/cjs/compilers/where.compiler.js +47 -13
  18. package/dist/cjs/compilers/where.compiler.js.map +1 -1
  19. package/dist/cjs/interface-model.d.ts +14 -10
  20. package/dist/cjs/interface-model.d.ts.map +1 -1
  21. package/dist/cjs/interface-model.js +25 -14
  22. package/dist/cjs/interface-model.js.map +1 -1
  23. package/dist/cjs/model.d.ts +7 -0
  24. package/dist/cjs/model.d.ts.map +1 -1
  25. package/dist/cjs/model.js +20 -4
  26. package/dist/cjs/model.js.map +1 -1
  27. package/dist/cjs/ogm.d.ts +14 -0
  28. package/dist/cjs/ogm.d.ts.map +1 -1
  29. package/dist/cjs/ogm.js +37 -3
  30. package/dist/cjs/ogm.js.map +1 -1
  31. package/dist/cjs/utils/strict-mode.d.ts +39 -0
  32. package/dist/cjs/utils/strict-mode.d.ts.map +1 -0
  33. package/dist/cjs/utils/strict-mode.js +59 -0
  34. package/dist/cjs/utils/strict-mode.js.map +1 -0
  35. package/dist/esm/compilers/fulltext.compiler.d.ts +17 -1
  36. package/dist/esm/compilers/fulltext.compiler.d.ts.map +1 -1
  37. package/dist/esm/compilers/fulltext.compiler.js +35 -3
  38. package/dist/esm/compilers/fulltext.compiler.js.map +1 -1
  39. package/dist/esm/compilers/mutation.compiler.d.ts +39 -0
  40. package/dist/esm/compilers/mutation.compiler.d.ts.map +1 -1
  41. package/dist/esm/compilers/mutation.compiler.js +165 -14
  42. package/dist/esm/compilers/mutation.compiler.js.map +1 -1
  43. package/dist/esm/compilers/selection.compiler.d.ts +79 -1
  44. package/dist/esm/compilers/selection.compiler.d.ts.map +1 -1
  45. package/dist/esm/compilers/selection.compiler.js +549 -28
  46. package/dist/esm/compilers/selection.compiler.js.map +1 -1
  47. package/dist/esm/compilers/where.compiler.d.ts +21 -7
  48. package/dist/esm/compilers/where.compiler.d.ts.map +1 -1
  49. package/dist/esm/compilers/where.compiler.js +47 -13
  50. package/dist/esm/compilers/where.compiler.js.map +1 -1
  51. package/dist/esm/interface-model.d.ts +14 -10
  52. package/dist/esm/interface-model.d.ts.map +1 -1
  53. package/dist/esm/interface-model.js +25 -14
  54. package/dist/esm/interface-model.js.map +1 -1
  55. package/dist/esm/model.d.ts +7 -0
  56. package/dist/esm/model.d.ts.map +1 -1
  57. package/dist/esm/model.js +20 -4
  58. package/dist/esm/model.js.map +1 -1
  59. package/dist/esm/ogm.d.ts +14 -0
  60. package/dist/esm/ogm.d.ts.map +1 -1
  61. package/dist/esm/ogm.js +37 -3
  62. package/dist/esm/ogm.js.map +1 -1
  63. package/dist/esm/utils/strict-mode.d.ts +39 -0
  64. package/dist/esm/utils/strict-mode.d.ts.map +1 -0
  65. package/dist/esm/utils/strict-mode.js +54 -0
  66. package/dist/esm/utils/strict-mode.js.map +1 -0
  67. package/package.json +1 -1
@@ -7,6 +7,7 @@ const errors_1 = require("../errors");
7
7
  const utils_1 = require("../schema/utils");
8
8
  const cypher_field_projection_1 = require("../utils/cypher-field-projection");
9
9
  const validation_1 = require("../utils/validation");
10
+ const strict_mode_1 = require("../utils/strict-mode");
10
11
  const where_compiler_1 = require("./where.compiler");
11
12
  /**
12
13
  * v2.5.3 — the printed arguments of a parsed selectionSet field, so a
@@ -14,6 +15,52 @@ const where_compiler_1 = require("./where.compiler");
14
15
  * selections carry the same arguments.
15
16
  */
16
17
  const argumentKeys = new WeakMap();
18
+ /**
19
+ * v2.6.0 — the strict-mode warnings of each parsed selectionSet, replayed
20
+ * on cache hits (see `SelectionCompiler.replayParseWarnings`).
21
+ */
22
+ const parseWarnings = new WeakMap();
23
+ /**
24
+ * v2.6.0 — whether two selections of one response key can apply to the
25
+ * same object. A selection outside any fragment applies to every object;
26
+ * fragment-only selections overlap when their type conditions share a
27
+ * possible concrete type. Only overlapping selections with different
28
+ * arguments conflict (GraphQL's "fields in set can merge" rule).
29
+ */
30
+ function canApplyToSameObject(a, b, ctx) {
31
+ if (!a || !b)
32
+ return true;
33
+ const types = new Set(a.flatMap((t) => ctx.possibleTypes(t)));
34
+ return b.some((t) => ctx.possibleTypes(t).some((p) => types.has(p)));
35
+ }
36
+ /**
37
+ * v2.6.0 — strict merge of a response key selected with different
38
+ * arguments: a conflict throws (as GraphQL validation does) when the two
39
+ * selections can apply to the same object; otherwise the incoming
40
+ * selection becomes (or joins) a per-type variant.
41
+ */
42
+ function mergeTypeVariant(existing, incoming, key, ctx) {
43
+ const groups = [existing, ...(existing.typeVariants ?? [])];
44
+ for (const group of groups)
45
+ if (!sameArguments(group, incoming) &&
46
+ canApplyToSameObject(group.typeConditions, incoming.typeConditions, ctx))
47
+ throw new errors_1.OGMError(`Fields "${key}" conflict because they have differing arguments. ` +
48
+ `Use different aliases on the fields to fetch both.`);
49
+ const same = groups.find((g) => sameArguments(g, incoming));
50
+ if (!same) {
51
+ existing.typeVariants = [...(existing.typeVariants ?? []), incoming];
52
+ return;
53
+ }
54
+ if (same.typeConditions && incoming.typeConditions)
55
+ same.typeConditions = [
56
+ ...new Set([...same.typeConditions, ...incoming.typeConditions]),
57
+ ];
58
+ mergeSameArguments(same, incoming, ctx);
59
+ }
60
+ function parseWarn(ctx, warning) {
61
+ if (!ctx.warnings.some((w) => w.code === warning.code && w.detail === warning.detail))
62
+ ctx.warnings.push(warning);
63
+ }
17
64
  function sameArguments(a, b) {
18
65
  return (argumentKeys.get(a) ?? '') === (argumentKeys.get(b) ?? '');
19
66
  }
@@ -29,7 +76,7 @@ function sameArguments(a, b) {
29
76
  * different types: recorded in `fieldByType` for a per-type projection.
30
77
  * - A selection outside any fragment makes the field unconditional.
31
78
  */
32
- function mergeSelection(list, seen, incoming) {
79
+ function mergeSelection(list, seen, incoming, ctx) {
33
80
  const key = incoming.alias ?? incoming.fieldName;
34
81
  const existing = seen.get(key);
35
82
  if (!existing) {
@@ -41,6 +88,12 @@ function mergeSelection(list, seen, incoming) {
41
88
  mergeFragmentField(existing, incoming);
42
89
  return;
43
90
  }
91
+ if (ctx.strict &&
92
+ (!sameArguments(existing, incoming) || existing.typeVariants)) {
93
+ mergeTypeVariant(existing, incoming, key, ctx);
94
+ return;
95
+ }
96
+ const priorTypes = existing.typeConditions;
44
97
  if (existing.typeConditions && incoming.typeConditions)
45
98
  existing.typeConditions = [
46
99
  ...new Set([...existing.typeConditions, ...incoming.typeConditions]),
@@ -48,12 +101,51 @@ function mergeSelection(list, seen, incoming) {
48
101
  // Also selected outside any fragment → unconditional.
49
102
  else
50
103
  delete existing.typeConditions;
51
- if (!sameArguments(existing, incoming))
104
+ if (!sameArguments(existing, incoming)) {
105
+ // v2.6.0 — GraphQL rejects one response key selected with different
106
+ // arguments when both selections can apply to the same object; 2.x
107
+ // keeps the first selection and drops the second. Fragments on types
108
+ // that can never be the same object are valid GraphQL: no warning
109
+ // (strict projects each per type, see `mergeTypeVariant`).
110
+ if (!canApplyToSameObject(priorTypes, incoming.typeConditions, ctx))
111
+ return;
112
+ parseWarn(ctx, {
113
+ code: 'selection-key-arguments',
114
+ detail: key,
115
+ problem: `The selectionSet selects "${key}" twice with different arguments; ` +
116
+ `2.x keeps the first selection and drops the second.`,
117
+ fix: 'reject the conflict, as GraphQL validation does',
118
+ });
52
119
  return;
120
+ }
121
+ mergeSameArguments(existing, incoming, ctx);
122
+ }
123
+ /** Merge a repeat of a response key with the same arguments (v2.5.3). */
124
+ function mergeSameArguments(existing, incoming, ctx) {
125
+ const key = incoming.alias ?? incoming.fieldName;
126
+ if (existing.isConnection) {
127
+ if (ctx.strict) {
128
+ mergeConnectionEdgeSets(existing, incoming, ctx);
129
+ return;
130
+ }
131
+ if (!existing.totalCountOnly &&
132
+ !incoming.totalCountOnly &&
133
+ edgesKeyOf(existing) !== edgesKeyOf(incoming))
134
+ warnAliasedEdges(ctx, key, edgesKeyOf(existing), edgesKeyOf(incoming));
135
+ else
136
+ for (const which of ['node', 'properties']) {
137
+ const a = which === 'node' ? existing.children : existing.edgeChildren;
138
+ const b = which === 'node' ? incoming.children : incoming.edgeChildren;
139
+ const ka = existing.connectionAliases?.[which] ?? which;
140
+ const kb = incoming.connectionAliases?.[which] ?? which;
141
+ if (a && b && ka !== kb)
142
+ warnAliasedEdgeKey(ctx, key, which, ka, kb);
143
+ }
144
+ }
53
145
  if (incoming.children)
54
- existing.children = mergeSelectionLists(existing.children, incoming.children);
146
+ existing.children = mergeSelectionLists(existing.children, incoming.children, ctx);
55
147
  if (incoming.edgeChildren)
56
- existing.edgeChildren = mergeSelectionLists(existing.edgeChildren, incoming.edgeChildren);
148
+ existing.edgeChildren = mergeSelectionLists(existing.edgeChildren, incoming.edgeChildren, ctx);
57
149
  if (existing.isConnection) {
58
150
  if (incoming.totalCount)
59
151
  existing.totalCount = true;
@@ -66,6 +158,168 @@ function mergeSelection(list, seen, incoming) {
66
158
  };
67
159
  }
68
160
  }
161
+ /** The response key of a connection's (first) `edges` selection. */
162
+ function edgesKeyOf(node) {
163
+ return node.connectionAliases?.edges ?? 'edges';
164
+ }
165
+ /** v2.6.0 — 2.x merges differently aliased `edges` under the first alias. */
166
+ function warnAliasedEdges(ctx, connection, first, second) {
167
+ parseWarn(ctx, {
168
+ code: 'selection-aliased-edges',
169
+ detail: `${connection}:${first}:${second}`,
170
+ problem: `"${connection}" selects \`edges\` under two response keys ("${first}", "${second}"); ` +
171
+ `2.x returns only "${first}", with both selections merged into it.`,
172
+ fix: 'return each `edges` alias with its own selection',
173
+ });
174
+ }
175
+ /** v2.6.0 — 2.x merges differently aliased `node` / `properties` keys. */
176
+ function warnAliasedEdgeKey(ctx, connection, which, first, second) {
177
+ parseWarn(ctx, {
178
+ code: 'selection-aliased-edge-key',
179
+ detail: `${connection}:${which}:${first}:${second}`,
180
+ problem: `"${connection}" selects \`${which}\` under two response keys ("${first}", "${second}") in its edges; ` +
181
+ `2.x returns only "${first}", with both selections merged into it.`,
182
+ fix: `return each \`${which}\` alias with its own selection`,
183
+ });
184
+ }
185
+ /** The `node` (or `properties`) keys of an edge set, main key first. */
186
+ function edgeKeyEntries(set, which) {
187
+ const main = which === 'node' ? set.children : set.edgeChildren;
188
+ const extras = (which === 'node' ? set.extraNodes : set.extraProperties) ?? [];
189
+ return [
190
+ ...(main
191
+ ? [{ key: set.connectionAliases?.[which] ?? which, children: main }]
192
+ : []),
193
+ ...extras,
194
+ ];
195
+ }
196
+ /** Store `entries` (main key first) back on an edge set (strict). */
197
+ function setEdgeKeyEntries(set, which, entries) {
198
+ const [main, ...rest] = entries;
199
+ const extrasProp = which === 'node' ? 'extraNodes' : 'extraProperties';
200
+ if (which === 'node')
201
+ set.children = main?.children;
202
+ else
203
+ set.edgeChildren = main?.children;
204
+ if (!main)
205
+ if (which === 'node')
206
+ delete set.children;
207
+ else
208
+ delete set.edgeChildren;
209
+ if (main && main.key !== which)
210
+ set.connectionAliases = { ...set.connectionAliases, [which]: main.key };
211
+ else if (set.connectionAliases) {
212
+ delete set.connectionAliases[which];
213
+ if (Object.keys(set.connectionAliases).length === 0)
214
+ delete set.connectionAliases;
215
+ }
216
+ if (rest.length > 0)
217
+ set[extrasProp] = rest;
218
+ else
219
+ delete set[extrasProp];
220
+ }
221
+ /** Merge two edge sets' `node` / `properties` keys by response key (strict). */
222
+ function mergeEdgeKeys(into, from, ctx) {
223
+ for (const which of ['node', 'properties']) {
224
+ const merged = edgeKeyEntries(into, which).map((e) => ({ ...e }));
225
+ for (const entry of edgeKeyEntries(from, which)) {
226
+ const same = merged.find((m) => m.key === entry.key);
227
+ if (same)
228
+ same.children = mergeSelectionLists(same.children, entry.children, ctx);
229
+ else
230
+ merged.push({ ...entry });
231
+ }
232
+ setEdgeKeyEntries(into, which, merged);
233
+ }
234
+ }
235
+ /** A connection's edges selection as a standalone edge set (strict). */
236
+ function asEdgeSet(node) {
237
+ if (node.totalCountOnly)
238
+ return null;
239
+ const set = {
240
+ fieldName: node.fieldName,
241
+ isScalar: false,
242
+ isRelationship: false,
243
+ isConnection: true,
244
+ };
245
+ if (node.children)
246
+ set.children = node.children;
247
+ if (node.edgeChildren)
248
+ set.edgeChildren = node.edgeChildren;
249
+ if (node.extraNodes)
250
+ set.extraNodes = node.extraNodes;
251
+ if (node.extraProperties)
252
+ set.extraProperties = node.extraProperties;
253
+ const { totalCount: _total, ...aliases } = node.connectionAliases ?? {};
254
+ if (Object.keys(aliases).length > 0)
255
+ set.connectionAliases = aliases;
256
+ return set;
257
+ }
258
+ /**
259
+ * v2.6.0 — strict merge of a connection selected twice (same arguments):
260
+ * edge sets merge by response key, so `a: edges` and `b: edges` stay apart.
261
+ */
262
+ function mergeConnectionEdgeSets(existing, incoming, ctx) {
263
+ const sets = new Map();
264
+ const add = (set) => {
265
+ const k = edgesKeyOf(set);
266
+ const prev = sets.get(k);
267
+ if (!prev) {
268
+ sets.set(k, {
269
+ ...set,
270
+ ...(set.connectionAliases
271
+ ? { connectionAliases: { ...set.connectionAliases } }
272
+ : {}),
273
+ });
274
+ return;
275
+ }
276
+ // v2.6.0 — `node` / `properties` keys merge by response key too.
277
+ mergeEdgeKeys(prev, set, ctx);
278
+ };
279
+ for (const node of [existing, incoming]) {
280
+ const main = asEdgeSet(node);
281
+ if (main)
282
+ add(main);
283
+ for (const extra of node.extraEdges ?? [])
284
+ add(extra);
285
+ }
286
+ const totalAlias = existing.connectionAliases?.totalCount ??
287
+ incoming.connectionAliases?.totalCount;
288
+ if (incoming.totalCount)
289
+ existing.totalCount = true;
290
+ const [main, ...rest] = [...sets.values()];
291
+ if (main) {
292
+ delete existing.totalCountOnly;
293
+ if (main.children)
294
+ existing.children = main.children;
295
+ else
296
+ delete existing.children;
297
+ if (main.edgeChildren)
298
+ existing.edgeChildren = main.edgeChildren;
299
+ else
300
+ delete existing.edgeChildren;
301
+ if (main.extraNodes)
302
+ existing.extraNodes = main.extraNodes;
303
+ else
304
+ delete existing.extraNodes;
305
+ if (main.extraProperties)
306
+ existing.extraProperties = main.extraProperties;
307
+ else
308
+ delete existing.extraProperties;
309
+ }
310
+ const aliases = {
311
+ ...(main?.connectionAliases ?? {}),
312
+ ...(totalAlias !== undefined ? { totalCount: totalAlias } : {}),
313
+ };
314
+ if (Object.keys(aliases).length > 0)
315
+ existing.connectionAliases = aliases;
316
+ else
317
+ delete existing.connectionAliases;
318
+ if (rest.length > 0)
319
+ existing.extraEdges = rest;
320
+ else
321
+ delete existing.extraEdges;
322
+ }
69
323
  /** Two scalars under one key, from fragments on different types (v2.5.3). */
70
324
  function mergeFragmentField(existing, incoming) {
71
325
  // Only scalars selected by inline fragments can differ per type; any
@@ -87,11 +341,11 @@ function mergeFragmentField(existing, incoming) {
87
341
  ...new Set([...existing.typeConditions, ...incoming.typeConditions]),
88
342
  ];
89
343
  }
90
- function mergeSelectionLists(base, extra) {
344
+ function mergeSelectionLists(base, extra, ctx) {
91
345
  const list = [];
92
346
  const seen = new Map();
93
347
  for (const node of [...(base ?? []), ...extra])
94
- mergeSelection(list, seen, node);
348
+ mergeSelection(list, seen, node, ctx);
95
349
  return list;
96
350
  }
97
351
  /** selectionSet field arguments the OGM honours, by field kind (v2.5.0). */
@@ -110,6 +364,43 @@ function assertNoVariables(value, where) {
110
364
  for (const f of value.fields)
111
365
  assertNoVariables(f.value, where);
112
366
  }
367
+ /**
368
+ * v2.6.0 — whether `@include` / `@skip` keep a selectionSet field or inline
369
+ * fragment. 2.x ignores both directives (the selection is always kept) and
370
+ * warns; strict honours a literal `if: true | false` and rejects anything
371
+ * else, `$variables` included (selectionSet strings have no variables).
372
+ */
373
+ function keptByDirectives(directives, where, ctx) {
374
+ const relevant = (directives ?? []).filter((d) => d.name.value === 'include' || d.name.value === 'skip');
375
+ if (relevant.length === 0)
376
+ return true;
377
+ if (!ctx.strict) {
378
+ parseWarn(ctx, {
379
+ code: 'selection-include-skip',
380
+ detail: where,
381
+ problem: `@include / @skip on "${where}" in a selectionSet is ignored in 2.x: ` +
382
+ `the selection is always kept.`,
383
+ fix: 'honour @include / @skip with a literal `if: true | false`',
384
+ });
385
+ return true;
386
+ }
387
+ let kept = true;
388
+ for (const directive of relevant) {
389
+ const name = directive.name.value;
390
+ const args = directive.arguments ?? [];
391
+ const arg = args.length === 1 ? args[0] : undefined;
392
+ if (!arg || arg.name.value !== 'if')
393
+ throw new errors_1.OGMError(`@${name} on "${where}" takes exactly one argument: if: true | false.`);
394
+ if (arg.value.kind === graphql_1.Kind.VARIABLE)
395
+ throw new errors_1.OGMError(`GraphQL variables are not supported in selectionSet directives (@${name} on "${where}": ` +
396
+ `$${arg.value.name.value}). Use a literal true or false.`);
397
+ if (arg.value.kind !== graphql_1.Kind.BOOLEAN)
398
+ throw new errors_1.OGMError(`@${name}(if:) on "${where}" must be a literal true or false.`);
399
+ if (name === 'include' ? !arg.value.value : arg.value.value)
400
+ kept = false;
401
+ }
402
+ return kept;
403
+ }
113
404
  function assertNoArguments(field, where) {
114
405
  if (field.arguments && field.arguments.length > 0)
115
406
  throw new errors_1.OGMError(`Unsupported argument "${field.arguments[0].name.value}" on "${where}". ` +
@@ -191,7 +482,7 @@ function applyFieldArguments(field, node) {
191
482
  * connection fields with edge properties, and nested traversals with depth limiting.
192
483
  */
193
484
  class SelectionCompiler {
194
- constructor(schema, whereCompiler) {
485
+ constructor(schema, whereCompiler, strictMode) {
195
486
  this.schema = schema;
196
487
  /**
197
488
  * Real LRU cache of parsed selection sets, capped at
@@ -208,6 +499,17 @@ class SelectionCompiler {
208
499
  */
209
500
  this.parseCache = new Map();
210
501
  this.whereCompiler = whereCompiler;
502
+ this.strictMode = strictMode ?? (0, strict_mode_1.lenientStrictMode)();
503
+ }
504
+ /**
505
+ * v2.6.0 — re-log (through the warn-once log) the strict-mode warnings
506
+ * found when `parsed` was first parsed. Parsed selections are cached —
507
+ * here and in `Model`'s cross-OGM cache — so a cache hit must still warn
508
+ * on an OGM instance that has not seen the shape yet.
509
+ */
510
+ replayParseWarnings(parsed) {
511
+ for (const w of parseWarnings.get(parsed) ?? [])
512
+ this.strictMode.warn(w.code, w.detail, w.problem, w.fix);
211
513
  }
212
514
  /** Clear the parse cache. Useful in tests to prevent cross-test pollution. */
213
515
  clearCache() {
@@ -288,7 +590,7 @@ class SelectionCompiler {
288
590
  }
289
591
  (0, validation_1.assertSafeIdentifier)(node.fieldName, 'selection field');
290
592
  if (isAbstract && node.typeConditions) {
291
- const conditional = this.compileFragmentScalar(node, key, nodeVar, nodeDef, cypherScope);
593
+ const conditional = this.compileFragmentScalar(node, key, nodeVar, nodeDef, cypherScope, params, paramCounter, policyContext);
292
594
  if (conditional) {
293
595
  parts.push(conditional);
294
596
  continue;
@@ -315,6 +617,15 @@ class SelectionCompiler {
315
617
  : `.${(0, validation_1.escapeIdentifier)(node.fieldName)}`);
316
618
  continue;
317
619
  }
620
+ // v2.6.0 — strict: one key selected with different arguments by
621
+ // fragments on disjoint types, projected per concrete type.
622
+ if (node.typeVariants && node.typeVariants.length > 0) {
623
+ const variantCypher = this.compileTypeVariants(node, nodeVar, maxDepth, currentDepth, params, paramCounter, policyContext);
624
+ if (variantCypher) {
625
+ parts.push(`${key}: ${variantCypher}`);
626
+ continue;
627
+ }
628
+ }
318
629
  // v2.5.3 — a relationship / connection field selected through an
319
630
  // inline fragment on an abstract type that doesn't declare it.
320
631
  // (Union relationship fields keep their per-member CASE below.)
@@ -364,8 +675,15 @@ class SelectionCompiler {
364
675
  * when the abstract type declares the field as `@cypher` itself, when a
365
676
  * condition isn't a concrete type, or when no fragment type declares it
366
677
  * as `@cypher`.
678
+ *
679
+ * v2.5.4 — under a policy context, each `@cypher` branch is also guarded
680
+ * by that member's own `read` clause (`WHEN n:B AND <B read> THEN …`),
681
+ * so a member's statement only projects for a node that passes the
682
+ * member's policy. The node filter (`compileTargetPolicyClause`) already
683
+ * requires every carried member's clause; this is defense in depth.
684
+ * Without a policy context the branches are unchanged.
367
685
  */
368
- compileFragmentScalar(node, key, nodeVar, nodeDef, cypherScope) {
686
+ compileFragmentScalar(node, key, nodeVar, nodeDef, cypherScope, params, paramCounter, policyContext = null) {
369
687
  // v2.5.3 — `fieldByType`: sibling fragments selected different fields
370
688
  // under this key, so each type projects its own field.
371
689
  const perType = node.fieldByType;
@@ -393,10 +711,28 @@ class SelectionCompiler {
393
711
  : (0, cypher_field_projection_1.buildInlineCypherFieldExpr)(prop.cypherStatement, nodeVar, `${def.typeName}.${field}`);
394
712
  else
395
713
  expr = `${nodeVar}.${(0, validation_1.escapeIdentifier)(field)}`;
396
- return `WHEN ${nodeVar}:${(0, validation_1.escapeIdentifier)(def.label)} THEN ${expr}`;
714
+ const guard = prop.isCypher && prop.cypherStatement
715
+ ? this.memberReadGuard(def, nodeVar, params, paramCounter, policyContext)
716
+ : '';
717
+ return `WHEN ${nodeVar}:${(0, validation_1.escapeIdentifier)(def.label)}${guard} THEN ${expr}`;
397
718
  });
398
719
  return `${(0, validation_1.escapeIdentifier)(key)}: CASE ${branches.join(' ')} END`;
399
720
  }
721
+ /**
722
+ * ` AND <member read clause>` guarding a fragment `@cypher` branch
723
+ * (v2.5.4); `''` without a policy context, when the member has no read
724
+ * clause, or when that clause needs `@cypher` preludes a projection
725
+ * cannot host (the node filter still enforces it).
726
+ */
727
+ memberReadGuard(def, nodeVar, params, paramCounter, policyContext) {
728
+ if (!policyContext || !this.whereCompiler || !params || !paramCounter)
729
+ return '';
730
+ const clause = this.whereCompiler.compileTargetPolicyClause(def.typeName, nodeVar, 'read', policyContext, paramCounter);
731
+ if (clause.cypher === null || clause.preludes.length > 0)
732
+ return '';
733
+ (0, validation_1.mergeParams)(params, clause.params);
734
+ return ` AND ${clause.cypher}`;
735
+ }
400
736
  /**
401
737
  * Parse a GraphQL selection set string into SelectionNode[].
402
738
  *
@@ -410,6 +746,7 @@ class SelectionCompiler {
410
746
  // order is the LRU iteration order we rely on for eviction.
411
747
  this.parseCache.delete(selectionSet);
412
748
  this.parseCache.set(selectionSet, cached);
749
+ this.replayParseWarnings(cached);
413
750
  return cached;
414
751
  }
415
752
  const trimmed = selectionSet.trim();
@@ -428,7 +765,16 @@ class SelectionCompiler {
428
765
  const dummyField = opDef.selectionSet.selections[0];
429
766
  if (dummyField.kind !== graphql_1.Kind.FIELD || !dummyField.selectionSet)
430
767
  return [];
431
- const result = this.convertSelectionSet(dummyField.selectionSet);
768
+ const ctx = {
769
+ strict: this.strictMode.strict,
770
+ warnings: [],
771
+ possibleTypes: (typeName) => this.possibleTypes(typeName),
772
+ };
773
+ const result = this.convertSelectionSet(dummyField.selectionSet, undefined, ctx);
774
+ if (ctx.warnings.length > 0) {
775
+ parseWarnings.set(result, ctx.warnings);
776
+ this.replayParseWarnings(result);
777
+ }
432
778
  // Real-LRU eviction: drop the oldest entry when the cache is full,
433
779
  // then insert. Pre-1.7.5 we just stopped caching at saturation,
434
780
  // forcing every subsequent miss to re-parse — a silent perf cliff
@@ -441,12 +787,24 @@ class SelectionCompiler {
441
787
  this.parseCache.set(selectionSet, result);
442
788
  return result;
443
789
  }
790
+ /**
791
+ * v2.6.0 — the concrete types a fragment on `typeName` can apply to (see
792
+ * `ParseContext.possibleTypes`). Unknown names stand for themselves.
793
+ */
794
+ possibleTypes(typeName) {
795
+ if (this.schema.nodes.has(typeName))
796
+ return [typeName];
797
+ const iface = this.schema.interfaces?.get(typeName);
798
+ if (iface)
799
+ return iface.implementedBy;
800
+ return this.schema.unions?.get(typeName) ?? [typeName];
801
+ }
444
802
  /**
445
803
  * Convert a GraphQL SelectionSetNode into SelectionNode[].
446
804
  */
447
805
  convertSelectionSet(selectionSet,
448
806
  /** Type condition of the enclosing inline fragment, if any (v2.4.8). */
449
- typeCondition) {
807
+ typeCondition, ctx) {
450
808
  const nodes = [];
451
809
  const seen = new Map();
452
810
  for (const sel of selectionSet.selections) {
@@ -457,15 +815,19 @@ class SelectionCompiler {
457
815
  if (sel.kind === graphql_1.Kind.INLINE_FRAGMENT) {
458
816
  const fragment = sel;
459
817
  const condition = fragment.typeCondition?.name.value ?? typeCondition;
818
+ if (!keptByDirectives(fragment.directives, `... on ${condition ?? '(inline fragment)'}`, ctx))
819
+ continue;
460
820
  if (fragment.selectionSet)
461
- for (const merged of this.convertSelectionSet(fragment.selectionSet, condition))
462
- mergeSelection(nodes, seen, merged);
821
+ for (const merged of this.convertSelectionSet(fragment.selectionSet, condition, ctx))
822
+ mergeSelection(nodes, seen, merged, ctx);
463
823
  continue;
464
824
  }
465
825
  if (sel.kind !== graphql_1.Kind.FIELD)
466
826
  continue;
467
827
  const field = sel;
468
828
  const fieldName = field.name.value;
829
+ if (!keptByDirectives(field.directives, field.alias?.value ?? fieldName, ctx))
830
+ continue;
469
831
  // v2.5.0 — fields are keyed by their OUTPUT name, so the same field
470
832
  // under two aliases (`a: kids { .. } b: kids { .. }`) stays two
471
833
  // selections. Pre-2.5.0 they collapsed into one.
@@ -491,30 +853,82 @@ class SelectionCompiler {
491
853
  if (hasChildren && field.selectionSet)
492
854
  if (isConnection)
493
855
  // For connections, parse edges -> node and edges -> properties
494
- this.parseConnectionChildren(field.selectionSet, node);
856
+ this.parseConnectionChildren(field.selectionSet, node, ctx);
495
857
  else
496
- node.children = this.convertSelectionSet(field.selectionSet);
497
- mergeSelection(nodes, seen, node);
858
+ node.children = this.convertSelectionSet(field.selectionSet, undefined, ctx);
859
+ mergeSelection(nodes, seen, node, ctx);
498
860
  }
499
861
  return nodes;
500
862
  }
501
863
  /**
502
864
  * Parse the children of a connection field, extracting node and edge children.
503
865
  */
504
- parseConnectionChildren(selectionSet, parentNode) {
866
+ parseConnectionChildren(selectionSet, parentNode, ctx) {
505
867
  let selectsEdges = false;
868
+ // v2.6.0 — the response key of the first `edges`, and (strict) the edge
869
+ // sets of `edges` selected under other keys.
870
+ let firstEdgesKey;
871
+ const extras = new Map();
872
+ // v2.6.0 — the first `node` / `properties` key of each edge set.
873
+ const firstKeys = new Map();
874
+ const connectionName = parentNode.alias ?? parentNode.fieldName;
875
+ /**
876
+ * Where a `node` / `properties` selection under `key` goes: the edge
877
+ * set's own (first) key, or — strict, another key — its own entry.
878
+ */
879
+ const edgeKeyTarget = (set, which, key) => {
880
+ const firsts = firstKeys.get(set) ?? {};
881
+ firstKeys.set(set, firsts);
882
+ firsts[which] ?? (firsts[which] = key);
883
+ if (firsts[which] === key)
884
+ return null;
885
+ if (!ctx.strict) {
886
+ warnAliasedEdgeKey(ctx, connectionName, which, firsts[which], key);
887
+ return null;
888
+ }
889
+ const prop = which === 'node' ? 'extraNodes' : 'extraProperties';
890
+ const list = (set[prop] ?? (set[prop] = []));
891
+ let entry = list.find((e) => e.key === key);
892
+ if (!entry) {
893
+ (0, validation_1.assertSafeIdentifier)(key, 'selection alias');
894
+ entry = { key, children: [] };
895
+ list.push(entry);
896
+ }
897
+ return entry;
898
+ };
506
899
  for (const sel of selectionSet.selections) {
507
900
  if (sel.kind !== graphql_1.Kind.FIELD)
508
901
  continue;
509
902
  const field = sel;
510
903
  assertNoArguments(field, `${parentNode.fieldName}.${field.name.value}`);
904
+ if (!keptByDirectives(field.directives, `${parentNode.fieldName}.${field.name.value}`, ctx))
905
+ continue;
511
906
  if (field.name.value === 'totalCount') {
512
907
  parentNode.totalCount = true;
513
908
  this.recordConnectionAlias(parentNode, 'totalCount', field);
514
909
  }
910
+ let target = parentNode;
515
911
  if (field.name.value === 'edges') {
516
912
  selectsEdges = true;
517
- this.recordConnectionAlias(parentNode, 'edges', field);
913
+ const edgesKey = field.alias?.value ?? 'edges';
914
+ firstEdgesKey ?? (firstEdgesKey = edgesKey);
915
+ if (edgesKey !== firstEdgesKey)
916
+ if (ctx.strict) {
917
+ let extra = extras.get(edgesKey);
918
+ if (!extra) {
919
+ extra = {
920
+ fieldName: parentNode.fieldName,
921
+ isScalar: false,
922
+ isRelationship: false,
923
+ isConnection: true,
924
+ };
925
+ extras.set(edgesKey, extra);
926
+ }
927
+ target = extra;
928
+ }
929
+ else
930
+ warnAliasedEdges(ctx, parentNode.alias ?? parentNode.fieldName, firstEdgesKey, edgesKey);
931
+ this.recordConnectionAlias(target, 'edges', field);
518
932
  }
519
933
  if (field.name.value === 'edges' && field.selectionSet)
520
934
  for (const edgeSel of field.selectionSet.selections) {
@@ -522,20 +936,36 @@ class SelectionCompiler {
522
936
  continue;
523
937
  const edgeField = edgeSel;
524
938
  assertNoArguments(edgeField, `${parentNode.fieldName}.edges.${edgeField.name.value}`);
939
+ if (!keptByDirectives(edgeField.directives, `${parentNode.fieldName}.edges.${edgeField.name.value}`, ctx))
940
+ continue;
525
941
  // v2.5.3 — a repeated `edges { node { … } }` merges its
526
942
  // sub-selections; pre-2.5.3 the last one replaced the others.
527
943
  if (edgeField.name.value === 'node' && edgeField.selectionSet) {
528
- this.recordConnectionAlias(parentNode, 'node', edgeField);
529
- parentNode.children = mergeSelectionLists(parentNode.children, this.convertSelectionSet(edgeField.selectionSet));
944
+ const extra = edgeKeyTarget(target, 'node', edgeField.alias?.value ?? 'node');
945
+ const parsed = this.convertSelectionSet(edgeField.selectionSet, undefined, ctx);
946
+ if (extra)
947
+ extra.children = mergeSelectionLists(extra.children, parsed, ctx);
948
+ else {
949
+ this.recordConnectionAlias(target, 'node', edgeField);
950
+ target.children = mergeSelectionLists(target.children, parsed, ctx);
951
+ }
530
952
  }
531
953
  if (edgeField.name.value === 'properties' && edgeField.selectionSet) {
532
- this.recordConnectionAlias(parentNode, 'properties', edgeField);
533
- parentNode.edgeChildren = mergeSelectionLists(parentNode.edgeChildren, this.convertSelectionSet(edgeField.selectionSet));
954
+ const extra = edgeKeyTarget(target, 'properties', edgeField.alias?.value ?? 'properties');
955
+ const parsed = this.convertSelectionSet(edgeField.selectionSet, undefined, ctx);
956
+ if (extra)
957
+ extra.children = mergeSelectionLists(extra.children, parsed, ctx);
958
+ else {
959
+ this.recordConnectionAlias(target, 'properties', edgeField);
960
+ target.edgeChildren = mergeSelectionLists(target.edgeChildren, parsed, ctx);
961
+ }
534
962
  }
535
963
  }
536
964
  }
537
965
  if (parentNode.totalCount && !selectsEdges)
538
966
  parentNode.totalCountOnly = true;
967
+ if (extras.size > 0)
968
+ parentNode.extraEdges = [...extras.values()];
539
969
  }
540
970
  /**
541
971
  * Keep the selectionSet alias of a connection key (v2.5.3). The first
@@ -561,12 +991,40 @@ class SelectionCompiler {
561
991
  * Returns `null` (unchanged behaviour) when no fragment type declares it.
562
992
  */
563
993
  compileFragmentRelationship(node, nodeVar, maxDepth, currentDepth, params, paramCounter, policyContext = null) {
994
+ const { branches, singular } = this.fragmentRelationshipBranches(node, node.typeConditions ?? [], nodeVar, maxDepth, currentDepth, params, paramCounter, policyContext);
995
+ if (branches.length === 0)
996
+ return null;
997
+ return `CASE ${branches.join(' ')} ELSE ${singular ? 'null' : '[]'} END`;
998
+ }
999
+ /**
1000
+ * v2.6.0 — strict: a relationship / connection key with per-type
1001
+ * variants (`SelectionNode.typeVariants`): one `CASE` whose branches
1002
+ * compile each variant, with its own arguments and the target's read
1003
+ * policy, against the concrete types its fragments apply to.
1004
+ */
1005
+ compileTypeVariants(node, nodeVar, maxDepth, currentDepth, params, paramCounter, policyContext = null) {
1006
+ const branches = [];
1007
+ let singular = false;
1008
+ for (const variant of [node, ...(node.typeVariants ?? [])]) {
1009
+ const types = [
1010
+ ...new Set((variant.typeConditions ?? []).flatMap((t) => this.possibleTypes(t))),
1011
+ ];
1012
+ const part = this.fragmentRelationshipBranches(variant, types, nodeVar, maxDepth, currentDepth, params, paramCounter, policyContext);
1013
+ branches.push(...part.branches);
1014
+ singular || (singular = part.singular);
1015
+ }
1016
+ if (branches.length === 0)
1017
+ return null;
1018
+ return `CASE ${branches.join(' ')} ELSE ${singular ? 'null' : '[]'} END`;
1019
+ }
1020
+ /** `WHEN n:T THEN <projection>` per type of `typeNames` declaring the field. */
1021
+ fragmentRelationshipBranches(node, typeNames, nodeVar, maxDepth, currentDepth, params, paramCounter, policyContext = null) {
564
1022
  const relField = node.isConnection
565
1023
  ? node.fieldName.replace(/Connection$/, '')
566
1024
  : node.fieldName;
567
1025
  const branches = [];
568
1026
  let singular = node.isConnection;
569
- for (const typeName of node.typeConditions ?? []) {
1027
+ for (const typeName of typeNames) {
570
1028
  const def = this.schema.nodes.get(typeName);
571
1029
  const relDef = def?.relationships.get(relField);
572
1030
  if (!def || !relDef)
@@ -579,9 +1037,7 @@ class SelectionCompiler {
579
1037
  if (expr)
580
1038
  branches.push(`WHEN ${nodeVar}:${(0, validation_1.escapeIdentifier)(def.label)} THEN ${expr}`);
581
1039
  }
582
- if (branches.length === 0)
583
- return null;
584
- return `CASE ${branches.join(' ')} ELSE ${singular ? 'null' : '[]'} END`;
1040
+ return { branches, singular };
585
1041
  }
586
1042
  /**
587
1043
  * Compile a relationship field into a Cypher pattern comprehension.
@@ -814,6 +1270,11 @@ class SelectionCompiler {
814
1270
  mapParts.push(`${propertiesKey}: ${edgeVar} { ${edgeFields} }`);
815
1271
  projectionKeys.push(propertiesKey);
816
1272
  }
1273
+ // v2.6.0 — strict: further `node` / `properties` keys of these edges.
1274
+ for (const [k, expr] of this.extraEdgeKeyParts(node, childVar, edgeVar, targetDef, maxDepth, currentDepth, params, paramCounter, policyContext)) {
1275
+ mapParts.push(`${k}: ${expr}`);
1276
+ projectionKeys.push(k);
1277
+ }
817
1278
  // Inject synthetic sort scalars at the top level of the edge map so
818
1279
  // apoc.coll.sortMulti (which only accepts top-level keys) can order the
819
1280
  // collection. The outer list comprehension strips them on the way out.
@@ -960,7 +1421,12 @@ class SelectionCompiler {
960
1421
  : '';
961
1422
  if (node.totalCountOnly)
962
1423
  return `{ ${totalCount} }`;
963
- const withTotal = totalCount ? `, ${totalCount}` : '';
1424
+ // v2.6.0 — strict: `edges` selected under further response keys, each
1425
+ // its own list over the same pattern and filter. Empty otherwise.
1426
+ const extraEdges = (node.extraEdges ?? [])
1427
+ .map((extra) => `, ${(0, validation_1.escapeIdentifier)(edgesKeyOf(extra))}: [${pattern}${whereClause} | ${this.compileEdgeSetMap(extra, childVar, edgeVar, targetDef, maxDepth, currentDepth, params, paramCounter, policyContext)}]`)
1428
+ .join('');
1429
+ const withTotal = `${extraEdges}${totalCount ? `, ${totalCount}` : ''}`;
964
1430
  if (sortKeyTokens.length === 0)
965
1431
  return `{ ${edgesKey}: [${pattern}${whereClause} | ${innerMap}]${withTotal} }`;
966
1432
  // Re-project only the user-facing keys so synthetic __skN scalars don't
@@ -970,6 +1436,61 @@ class SelectionCompiler {
970
1436
  : `{}`;
971
1437
  return `{ ${edgesKey}: [x IN apoc.coll.sortMulti([${pattern}${whereClause} | ${innerMap}], [${sortKeyTokens.join(', ')}]) | ${outerProjection}]${withTotal} }`;
972
1438
  }
1439
+ /**
1440
+ * v2.6.0 — the `{ node: …, properties: … }` map of one strict-mode extra
1441
+ * edge set (see `SelectionNode.extraEdges`), mirroring `compileConnection`.
1442
+ */
1443
+ compileEdgeSetMap(set, childVar, edgeVar, targetDef, maxDepth, currentDepth, params, paramCounter, policyContext = null) {
1444
+ const parts = [];
1445
+ const aliases = set.connectionAliases ?? {};
1446
+ if (set.children && set.children.length > 0)
1447
+ parts.push(`${aliases.node ? (0, validation_1.escapeIdentifier)(aliases.node) : 'node'}: ${this.compile(set.children, childVar, targetDef, maxDepth, currentDepth + 1, params, paramCounter, null, policyContext)}`);
1448
+ if (set.edgeChildren && set.edgeChildren.length > 0) {
1449
+ const edgeFields = set.edgeChildren
1450
+ .filter((c) => c.isScalar)
1451
+ .map((c) => {
1452
+ if (c.alias === undefined || c.alias === c.fieldName)
1453
+ return `.${(0, validation_1.escapeIdentifier)(c.fieldName)}`;
1454
+ (0, validation_1.assertSafeIdentifier)(c.alias, 'selection alias');
1455
+ return `${(0, validation_1.escapeIdentifier)(c.alias)}: ${edgeVar}.${(0, validation_1.escapeIdentifier)(c.fieldName)}`;
1456
+ })
1457
+ .join(', ');
1458
+ parts.push(`${aliases.properties ? (0, validation_1.escapeIdentifier)(aliases.properties) : 'properties'}: ${edgeVar} { ${edgeFields} }`);
1459
+ }
1460
+ for (const [k, expr] of this.extraEdgeKeyParts(set, childVar, edgeVar, targetDef, maxDepth, currentDepth, params, paramCounter, policyContext))
1461
+ parts.push(`${k}: ${expr}`);
1462
+ return `{ ${parts.join(', ')} }`;
1463
+ }
1464
+ /**
1465
+ * v2.6.0 — strict: the projections of an edge set's further `node` /
1466
+ * `properties` keys (`SelectionNode.extraNodes` / `extraProperties`),
1467
+ * as `[escapedKey, expression]` pairs. Empty in 2.x default mode.
1468
+ */
1469
+ extraEdgeKeyParts(set, childVar, edgeVar, targetDef, maxDepth, currentDepth, params, paramCounter, policyContext = null) {
1470
+ const out = [];
1471
+ for (const extra of set.extraNodes ?? [])
1472
+ if (extra.children.length > 0)
1473
+ out.push([
1474
+ (0, validation_1.escapeIdentifier)(extra.key),
1475
+ this.compile(extra.children, childVar, targetDef, maxDepth, currentDepth + 1, params, paramCounter, null, policyContext),
1476
+ ]);
1477
+ for (const extra of set.extraProperties ?? []) {
1478
+ const fields = extra.children
1479
+ .filter((c) => c.isScalar)
1480
+ .map((c) => {
1481
+ if (c.alias === undefined || c.alias === c.fieldName)
1482
+ return `.${(0, validation_1.escapeIdentifier)(c.fieldName)}`;
1483
+ (0, validation_1.assertSafeIdentifier)(c.alias, 'selection alias');
1484
+ return `${(0, validation_1.escapeIdentifier)(c.alias)}: ${edgeVar}.${(0, validation_1.escapeIdentifier)(c.fieldName)}`;
1485
+ });
1486
+ if (fields.length > 0)
1487
+ out.push([
1488
+ (0, validation_1.escapeIdentifier)(extra.key),
1489
+ `${edgeVar} { ${fields.join(', ')} }`,
1490
+ ]);
1491
+ }
1492
+ return out;
1493
+ }
973
1494
  /**
974
1495
  * AND-combine an optional user where (`select.where` / `connectionWhere`)
975
1496
  * with the target type's `'read'` policy and emit a `WHERE …` fragment