grafeo-ogm 2.5.4 → 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 (65) hide show
  1. package/CHANGELOG.md +54 -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 +161 -11
  10. package/dist/cjs/compilers/mutation.compiler.js.map +1 -1
  11. package/dist/cjs/compilers/selection.compiler.d.ts +65 -1
  12. package/dist/cjs/compilers/selection.compiler.d.ts.map +1 -1
  13. package/dist/cjs/compilers/selection.compiler.js +521 -25
  14. package/dist/cjs/compilers/selection.compiler.js.map +1 -1
  15. package/dist/cjs/compilers/where.compiler.d.ts +4 -0
  16. package/dist/cjs/compilers/where.compiler.d.ts.map +1 -1
  17. package/dist/cjs/compilers/where.compiler.js +23 -0
  18. package/dist/cjs/compilers/where.compiler.js.map +1 -1
  19. package/dist/cjs/interface-model.d.ts.map +1 -1
  20. package/dist/cjs/interface-model.js +8 -1
  21. package/dist/cjs/interface-model.js.map +1 -1
  22. package/dist/cjs/model.d.ts +7 -0
  23. package/dist/cjs/model.d.ts.map +1 -1
  24. package/dist/cjs/model.js +20 -4
  25. package/dist/cjs/model.js.map +1 -1
  26. package/dist/cjs/ogm.d.ts +14 -0
  27. package/dist/cjs/ogm.d.ts.map +1 -1
  28. package/dist/cjs/ogm.js +36 -2
  29. package/dist/cjs/ogm.js.map +1 -1
  30. package/dist/cjs/utils/strict-mode.d.ts +39 -0
  31. package/dist/cjs/utils/strict-mode.d.ts.map +1 -0
  32. package/dist/cjs/utils/strict-mode.js +59 -0
  33. package/dist/cjs/utils/strict-mode.js.map +1 -0
  34. package/dist/esm/compilers/fulltext.compiler.d.ts +17 -1
  35. package/dist/esm/compilers/fulltext.compiler.d.ts.map +1 -1
  36. package/dist/esm/compilers/fulltext.compiler.js +35 -3
  37. package/dist/esm/compilers/fulltext.compiler.js.map +1 -1
  38. package/dist/esm/compilers/mutation.compiler.d.ts +39 -0
  39. package/dist/esm/compilers/mutation.compiler.d.ts.map +1 -1
  40. package/dist/esm/compilers/mutation.compiler.js +161 -11
  41. package/dist/esm/compilers/mutation.compiler.js.map +1 -1
  42. package/dist/esm/compilers/selection.compiler.d.ts +65 -1
  43. package/dist/esm/compilers/selection.compiler.d.ts.map +1 -1
  44. package/dist/esm/compilers/selection.compiler.js +521 -25
  45. package/dist/esm/compilers/selection.compiler.js.map +1 -1
  46. package/dist/esm/compilers/where.compiler.d.ts +4 -0
  47. package/dist/esm/compilers/where.compiler.d.ts.map +1 -1
  48. package/dist/esm/compilers/where.compiler.js +23 -0
  49. package/dist/esm/compilers/where.compiler.js.map +1 -1
  50. package/dist/esm/interface-model.d.ts.map +1 -1
  51. package/dist/esm/interface-model.js +8 -1
  52. package/dist/esm/interface-model.js.map +1 -1
  53. package/dist/esm/model.d.ts +7 -0
  54. package/dist/esm/model.d.ts.map +1 -1
  55. package/dist/esm/model.js +20 -4
  56. package/dist/esm/model.js.map +1 -1
  57. package/dist/esm/ogm.d.ts +14 -0
  58. package/dist/esm/ogm.d.ts.map +1 -1
  59. package/dist/esm/ogm.js +36 -2
  60. package/dist/esm/ogm.js.map +1 -1
  61. package/dist/esm/utils/strict-mode.d.ts +39 -0
  62. package/dist/esm/utils/strict-mode.d.ts.map +1 -0
  63. package/dist/esm/utils/strict-mode.js +54 -0
  64. package/dist/esm/utils/strict-mode.js.map +1 -0
  65. package/package.json +1 -1
@@ -4,6 +4,7 @@ import { OGMError } from '../errors';
4
4
  import { buildRelPattern, resolveTargetDef } from '../schema/utils';
5
5
  import { buildInlineCypherFieldExpr, } from '../utils/cypher-field-projection';
6
6
  import { assertSafeIdentifier, assertSortDirection, escapeIdentifier, isPlainObject, mergeParams, } from '../utils/validation';
7
+ import { lenientStrictMode } from '../utils/strict-mode';
7
8
  import { stitchUserAndPolicy, traversalBundle, } from './where.compiler';
8
9
  /**
9
10
  * v2.5.3 — the printed arguments of a parsed selectionSet field, so a
@@ -11,6 +12,52 @@ import { stitchUserAndPolicy, traversalBundle, } from './where.compiler';
11
12
  * selections carry the same arguments.
12
13
  */
13
14
  const argumentKeys = new WeakMap();
15
+ /**
16
+ * v2.6.0 — the strict-mode warnings of each parsed selectionSet, replayed
17
+ * on cache hits (see `SelectionCompiler.replayParseWarnings`).
18
+ */
19
+ const parseWarnings = new WeakMap();
20
+ /**
21
+ * v2.6.0 — whether two selections of one response key can apply to the
22
+ * same object. A selection outside any fragment applies to every object;
23
+ * fragment-only selections overlap when their type conditions share a
24
+ * possible concrete type. Only overlapping selections with different
25
+ * arguments conflict (GraphQL's "fields in set can merge" rule).
26
+ */
27
+ function canApplyToSameObject(a, b, ctx) {
28
+ if (!a || !b)
29
+ return true;
30
+ const types = new Set(a.flatMap((t) => ctx.possibleTypes(t)));
31
+ return b.some((t) => ctx.possibleTypes(t).some((p) => types.has(p)));
32
+ }
33
+ /**
34
+ * v2.6.0 — strict merge of a response key selected with different
35
+ * arguments: a conflict throws (as GraphQL validation does) when the two
36
+ * selections can apply to the same object; otherwise the incoming
37
+ * selection becomes (or joins) a per-type variant.
38
+ */
39
+ function mergeTypeVariant(existing, incoming, key, ctx) {
40
+ const groups = [existing, ...(existing.typeVariants ?? [])];
41
+ for (const group of groups)
42
+ if (!sameArguments(group, incoming) &&
43
+ canApplyToSameObject(group.typeConditions, incoming.typeConditions, ctx))
44
+ throw new OGMError(`Fields "${key}" conflict because they have differing arguments. ` +
45
+ `Use different aliases on the fields to fetch both.`);
46
+ const same = groups.find((g) => sameArguments(g, incoming));
47
+ if (!same) {
48
+ existing.typeVariants = [...(existing.typeVariants ?? []), incoming];
49
+ return;
50
+ }
51
+ if (same.typeConditions && incoming.typeConditions)
52
+ same.typeConditions = [
53
+ ...new Set([...same.typeConditions, ...incoming.typeConditions]),
54
+ ];
55
+ mergeSameArguments(same, incoming, ctx);
56
+ }
57
+ function parseWarn(ctx, warning) {
58
+ if (!ctx.warnings.some((w) => w.code === warning.code && w.detail === warning.detail))
59
+ ctx.warnings.push(warning);
60
+ }
14
61
  function sameArguments(a, b) {
15
62
  return (argumentKeys.get(a) ?? '') === (argumentKeys.get(b) ?? '');
16
63
  }
@@ -26,7 +73,7 @@ function sameArguments(a, b) {
26
73
  * different types: recorded in `fieldByType` for a per-type projection.
27
74
  * - A selection outside any fragment makes the field unconditional.
28
75
  */
29
- function mergeSelection(list, seen, incoming) {
76
+ function mergeSelection(list, seen, incoming, ctx) {
30
77
  const key = incoming.alias ?? incoming.fieldName;
31
78
  const existing = seen.get(key);
32
79
  if (!existing) {
@@ -38,6 +85,12 @@ function mergeSelection(list, seen, incoming) {
38
85
  mergeFragmentField(existing, incoming);
39
86
  return;
40
87
  }
88
+ if (ctx.strict &&
89
+ (!sameArguments(existing, incoming) || existing.typeVariants)) {
90
+ mergeTypeVariant(existing, incoming, key, ctx);
91
+ return;
92
+ }
93
+ const priorTypes = existing.typeConditions;
41
94
  if (existing.typeConditions && incoming.typeConditions)
42
95
  existing.typeConditions = [
43
96
  ...new Set([...existing.typeConditions, ...incoming.typeConditions]),
@@ -45,12 +98,51 @@ function mergeSelection(list, seen, incoming) {
45
98
  // Also selected outside any fragment → unconditional.
46
99
  else
47
100
  delete existing.typeConditions;
48
- if (!sameArguments(existing, incoming))
101
+ if (!sameArguments(existing, incoming)) {
102
+ // v2.6.0 — GraphQL rejects one response key selected with different
103
+ // arguments when both selections can apply to the same object; 2.x
104
+ // keeps the first selection and drops the second. Fragments on types
105
+ // that can never be the same object are valid GraphQL: no warning
106
+ // (strict projects each per type, see `mergeTypeVariant`).
107
+ if (!canApplyToSameObject(priorTypes, incoming.typeConditions, ctx))
108
+ return;
109
+ parseWarn(ctx, {
110
+ code: 'selection-key-arguments',
111
+ detail: key,
112
+ problem: `The selectionSet selects "${key}" twice with different arguments; ` +
113
+ `2.x keeps the first selection and drops the second.`,
114
+ fix: 'reject the conflict, as GraphQL validation does',
115
+ });
49
116
  return;
117
+ }
118
+ mergeSameArguments(existing, incoming, ctx);
119
+ }
120
+ /** Merge a repeat of a response key with the same arguments (v2.5.3). */
121
+ function mergeSameArguments(existing, incoming, ctx) {
122
+ const key = incoming.alias ?? incoming.fieldName;
123
+ if (existing.isConnection) {
124
+ if (ctx.strict) {
125
+ mergeConnectionEdgeSets(existing, incoming, ctx);
126
+ return;
127
+ }
128
+ if (!existing.totalCountOnly &&
129
+ !incoming.totalCountOnly &&
130
+ edgesKeyOf(existing) !== edgesKeyOf(incoming))
131
+ warnAliasedEdges(ctx, key, edgesKeyOf(existing), edgesKeyOf(incoming));
132
+ else
133
+ for (const which of ['node', 'properties']) {
134
+ const a = which === 'node' ? existing.children : existing.edgeChildren;
135
+ const b = which === 'node' ? incoming.children : incoming.edgeChildren;
136
+ const ka = existing.connectionAliases?.[which] ?? which;
137
+ const kb = incoming.connectionAliases?.[which] ?? which;
138
+ if (a && b && ka !== kb)
139
+ warnAliasedEdgeKey(ctx, key, which, ka, kb);
140
+ }
141
+ }
50
142
  if (incoming.children)
51
- existing.children = mergeSelectionLists(existing.children, incoming.children);
143
+ existing.children = mergeSelectionLists(existing.children, incoming.children, ctx);
52
144
  if (incoming.edgeChildren)
53
- existing.edgeChildren = mergeSelectionLists(existing.edgeChildren, incoming.edgeChildren);
145
+ existing.edgeChildren = mergeSelectionLists(existing.edgeChildren, incoming.edgeChildren, ctx);
54
146
  if (existing.isConnection) {
55
147
  if (incoming.totalCount)
56
148
  existing.totalCount = true;
@@ -63,6 +155,168 @@ function mergeSelection(list, seen, incoming) {
63
155
  };
64
156
  }
65
157
  }
158
+ /** The response key of a connection's (first) `edges` selection. */
159
+ function edgesKeyOf(node) {
160
+ return node.connectionAliases?.edges ?? 'edges';
161
+ }
162
+ /** v2.6.0 — 2.x merges differently aliased `edges` under the first alias. */
163
+ function warnAliasedEdges(ctx, connection, first, second) {
164
+ parseWarn(ctx, {
165
+ code: 'selection-aliased-edges',
166
+ detail: `${connection}:${first}:${second}`,
167
+ problem: `"${connection}" selects \`edges\` under two response keys ("${first}", "${second}"); ` +
168
+ `2.x returns only "${first}", with both selections merged into it.`,
169
+ fix: 'return each `edges` alias with its own selection',
170
+ });
171
+ }
172
+ /** v2.6.0 — 2.x merges differently aliased `node` / `properties` keys. */
173
+ function warnAliasedEdgeKey(ctx, connection, which, first, second) {
174
+ parseWarn(ctx, {
175
+ code: 'selection-aliased-edge-key',
176
+ detail: `${connection}:${which}:${first}:${second}`,
177
+ problem: `"${connection}" selects \`${which}\` under two response keys ("${first}", "${second}") in its edges; ` +
178
+ `2.x returns only "${first}", with both selections merged into it.`,
179
+ fix: `return each \`${which}\` alias with its own selection`,
180
+ });
181
+ }
182
+ /** The `node` (or `properties`) keys of an edge set, main key first. */
183
+ function edgeKeyEntries(set, which) {
184
+ const main = which === 'node' ? set.children : set.edgeChildren;
185
+ const extras = (which === 'node' ? set.extraNodes : set.extraProperties) ?? [];
186
+ return [
187
+ ...(main
188
+ ? [{ key: set.connectionAliases?.[which] ?? which, children: main }]
189
+ : []),
190
+ ...extras,
191
+ ];
192
+ }
193
+ /** Store `entries` (main key first) back on an edge set (strict). */
194
+ function setEdgeKeyEntries(set, which, entries) {
195
+ const [main, ...rest] = entries;
196
+ const extrasProp = which === 'node' ? 'extraNodes' : 'extraProperties';
197
+ if (which === 'node')
198
+ set.children = main?.children;
199
+ else
200
+ set.edgeChildren = main?.children;
201
+ if (!main)
202
+ if (which === 'node')
203
+ delete set.children;
204
+ else
205
+ delete set.edgeChildren;
206
+ if (main && main.key !== which)
207
+ set.connectionAliases = { ...set.connectionAliases, [which]: main.key };
208
+ else if (set.connectionAliases) {
209
+ delete set.connectionAliases[which];
210
+ if (Object.keys(set.connectionAliases).length === 0)
211
+ delete set.connectionAliases;
212
+ }
213
+ if (rest.length > 0)
214
+ set[extrasProp] = rest;
215
+ else
216
+ delete set[extrasProp];
217
+ }
218
+ /** Merge two edge sets' `node` / `properties` keys by response key (strict). */
219
+ function mergeEdgeKeys(into, from, ctx) {
220
+ for (const which of ['node', 'properties']) {
221
+ const merged = edgeKeyEntries(into, which).map((e) => ({ ...e }));
222
+ for (const entry of edgeKeyEntries(from, which)) {
223
+ const same = merged.find((m) => m.key === entry.key);
224
+ if (same)
225
+ same.children = mergeSelectionLists(same.children, entry.children, ctx);
226
+ else
227
+ merged.push({ ...entry });
228
+ }
229
+ setEdgeKeyEntries(into, which, merged);
230
+ }
231
+ }
232
+ /** A connection's edges selection as a standalone edge set (strict). */
233
+ function asEdgeSet(node) {
234
+ if (node.totalCountOnly)
235
+ return null;
236
+ const set = {
237
+ fieldName: node.fieldName,
238
+ isScalar: false,
239
+ isRelationship: false,
240
+ isConnection: true,
241
+ };
242
+ if (node.children)
243
+ set.children = node.children;
244
+ if (node.edgeChildren)
245
+ set.edgeChildren = node.edgeChildren;
246
+ if (node.extraNodes)
247
+ set.extraNodes = node.extraNodes;
248
+ if (node.extraProperties)
249
+ set.extraProperties = node.extraProperties;
250
+ const { totalCount: _total, ...aliases } = node.connectionAliases ?? {};
251
+ if (Object.keys(aliases).length > 0)
252
+ set.connectionAliases = aliases;
253
+ return set;
254
+ }
255
+ /**
256
+ * v2.6.0 — strict merge of a connection selected twice (same arguments):
257
+ * edge sets merge by response key, so `a: edges` and `b: edges` stay apart.
258
+ */
259
+ function mergeConnectionEdgeSets(existing, incoming, ctx) {
260
+ const sets = new Map();
261
+ const add = (set) => {
262
+ const k = edgesKeyOf(set);
263
+ const prev = sets.get(k);
264
+ if (!prev) {
265
+ sets.set(k, {
266
+ ...set,
267
+ ...(set.connectionAliases
268
+ ? { connectionAliases: { ...set.connectionAliases } }
269
+ : {}),
270
+ });
271
+ return;
272
+ }
273
+ // v2.6.0 — `node` / `properties` keys merge by response key too.
274
+ mergeEdgeKeys(prev, set, ctx);
275
+ };
276
+ for (const node of [existing, incoming]) {
277
+ const main = asEdgeSet(node);
278
+ if (main)
279
+ add(main);
280
+ for (const extra of node.extraEdges ?? [])
281
+ add(extra);
282
+ }
283
+ const totalAlias = existing.connectionAliases?.totalCount ??
284
+ incoming.connectionAliases?.totalCount;
285
+ if (incoming.totalCount)
286
+ existing.totalCount = true;
287
+ const [main, ...rest] = [...sets.values()];
288
+ if (main) {
289
+ delete existing.totalCountOnly;
290
+ if (main.children)
291
+ existing.children = main.children;
292
+ else
293
+ delete existing.children;
294
+ if (main.edgeChildren)
295
+ existing.edgeChildren = main.edgeChildren;
296
+ else
297
+ delete existing.edgeChildren;
298
+ if (main.extraNodes)
299
+ existing.extraNodes = main.extraNodes;
300
+ else
301
+ delete existing.extraNodes;
302
+ if (main.extraProperties)
303
+ existing.extraProperties = main.extraProperties;
304
+ else
305
+ delete existing.extraProperties;
306
+ }
307
+ const aliases = {
308
+ ...(main?.connectionAliases ?? {}),
309
+ ...(totalAlias !== undefined ? { totalCount: totalAlias } : {}),
310
+ };
311
+ if (Object.keys(aliases).length > 0)
312
+ existing.connectionAliases = aliases;
313
+ else
314
+ delete existing.connectionAliases;
315
+ if (rest.length > 0)
316
+ existing.extraEdges = rest;
317
+ else
318
+ delete existing.extraEdges;
319
+ }
66
320
  /** Two scalars under one key, from fragments on different types (v2.5.3). */
67
321
  function mergeFragmentField(existing, incoming) {
68
322
  // Only scalars selected by inline fragments can differ per type; any
@@ -84,11 +338,11 @@ function mergeFragmentField(existing, incoming) {
84
338
  ...new Set([...existing.typeConditions, ...incoming.typeConditions]),
85
339
  ];
86
340
  }
87
- function mergeSelectionLists(base, extra) {
341
+ function mergeSelectionLists(base, extra, ctx) {
88
342
  const list = [];
89
343
  const seen = new Map();
90
344
  for (const node of [...(base ?? []), ...extra])
91
- mergeSelection(list, seen, node);
345
+ mergeSelection(list, seen, node, ctx);
92
346
  return list;
93
347
  }
94
348
  /** selectionSet field arguments the OGM honours, by field kind (v2.5.0). */
@@ -107,6 +361,43 @@ function assertNoVariables(value, where) {
107
361
  for (const f of value.fields)
108
362
  assertNoVariables(f.value, where);
109
363
  }
364
+ /**
365
+ * v2.6.0 — whether `@include` / `@skip` keep a selectionSet field or inline
366
+ * fragment. 2.x ignores both directives (the selection is always kept) and
367
+ * warns; strict honours a literal `if: true | false` and rejects anything
368
+ * else, `$variables` included (selectionSet strings have no variables).
369
+ */
370
+ function keptByDirectives(directives, where, ctx) {
371
+ const relevant = (directives ?? []).filter((d) => d.name.value === 'include' || d.name.value === 'skip');
372
+ if (relevant.length === 0)
373
+ return true;
374
+ if (!ctx.strict) {
375
+ parseWarn(ctx, {
376
+ code: 'selection-include-skip',
377
+ detail: where,
378
+ problem: `@include / @skip on "${where}" in a selectionSet is ignored in 2.x: ` +
379
+ `the selection is always kept.`,
380
+ fix: 'honour @include / @skip with a literal `if: true | false`',
381
+ });
382
+ return true;
383
+ }
384
+ let kept = true;
385
+ for (const directive of relevant) {
386
+ const name = directive.name.value;
387
+ const args = directive.arguments ?? [];
388
+ const arg = args.length === 1 ? args[0] : undefined;
389
+ if (!arg || arg.name.value !== 'if')
390
+ throw new OGMError(`@${name} on "${where}" takes exactly one argument: if: true | false.`);
391
+ if (arg.value.kind === Kind.VARIABLE)
392
+ throw new OGMError(`GraphQL variables are not supported in selectionSet directives (@${name} on "${where}": ` +
393
+ `$${arg.value.name.value}). Use a literal true or false.`);
394
+ if (arg.value.kind !== Kind.BOOLEAN)
395
+ throw new OGMError(`@${name}(if:) on "${where}" must be a literal true or false.`);
396
+ if (name === 'include' ? !arg.value.value : arg.value.value)
397
+ kept = false;
398
+ }
399
+ return kept;
400
+ }
110
401
  function assertNoArguments(field, where) {
111
402
  if (field.arguments && field.arguments.length > 0)
112
403
  throw new OGMError(`Unsupported argument "${field.arguments[0].name.value}" on "${where}". ` +
@@ -188,7 +479,7 @@ function applyFieldArguments(field, node) {
188
479
  * connection fields with edge properties, and nested traversals with depth limiting.
189
480
  */
190
481
  export class SelectionCompiler {
191
- constructor(schema, whereCompiler) {
482
+ constructor(schema, whereCompiler, strictMode) {
192
483
  this.schema = schema;
193
484
  /**
194
485
  * Real LRU cache of parsed selection sets, capped at
@@ -205,6 +496,17 @@ export class SelectionCompiler {
205
496
  */
206
497
  this.parseCache = new Map();
207
498
  this.whereCompiler = whereCompiler;
499
+ this.strictMode = strictMode ?? lenientStrictMode();
500
+ }
501
+ /**
502
+ * v2.6.0 — re-log (through the warn-once log) the strict-mode warnings
503
+ * found when `parsed` was first parsed. Parsed selections are cached —
504
+ * here and in `Model`'s cross-OGM cache — so a cache hit must still warn
505
+ * on an OGM instance that has not seen the shape yet.
506
+ */
507
+ replayParseWarnings(parsed) {
508
+ for (const w of parseWarnings.get(parsed) ?? [])
509
+ this.strictMode.warn(w.code, w.detail, w.problem, w.fix);
208
510
  }
209
511
  /** Clear the parse cache. Useful in tests to prevent cross-test pollution. */
210
512
  clearCache() {
@@ -312,6 +614,15 @@ export class SelectionCompiler {
312
614
  : `.${escapeIdentifier(node.fieldName)}`);
313
615
  continue;
314
616
  }
617
+ // v2.6.0 — strict: one key selected with different arguments by
618
+ // fragments on disjoint types, projected per concrete type.
619
+ if (node.typeVariants && node.typeVariants.length > 0) {
620
+ const variantCypher = this.compileTypeVariants(node, nodeVar, maxDepth, currentDepth, params, paramCounter, policyContext);
621
+ if (variantCypher) {
622
+ parts.push(`${key}: ${variantCypher}`);
623
+ continue;
624
+ }
625
+ }
315
626
  // v2.5.3 — a relationship / connection field selected through an
316
627
  // inline fragment on an abstract type that doesn't declare it.
317
628
  // (Union relationship fields keep their per-member CASE below.)
@@ -432,6 +743,7 @@ export class SelectionCompiler {
432
743
  // order is the LRU iteration order we rely on for eviction.
433
744
  this.parseCache.delete(selectionSet);
434
745
  this.parseCache.set(selectionSet, cached);
746
+ this.replayParseWarnings(cached);
435
747
  return cached;
436
748
  }
437
749
  const trimmed = selectionSet.trim();
@@ -450,7 +762,16 @@ export class SelectionCompiler {
450
762
  const dummyField = opDef.selectionSet.selections[0];
451
763
  if (dummyField.kind !== Kind.FIELD || !dummyField.selectionSet)
452
764
  return [];
453
- const result = this.convertSelectionSet(dummyField.selectionSet);
765
+ const ctx = {
766
+ strict: this.strictMode.strict,
767
+ warnings: [],
768
+ possibleTypes: (typeName) => this.possibleTypes(typeName),
769
+ };
770
+ const result = this.convertSelectionSet(dummyField.selectionSet, undefined, ctx);
771
+ if (ctx.warnings.length > 0) {
772
+ parseWarnings.set(result, ctx.warnings);
773
+ this.replayParseWarnings(result);
774
+ }
454
775
  // Real-LRU eviction: drop the oldest entry when the cache is full,
455
776
  // then insert. Pre-1.7.5 we just stopped caching at saturation,
456
777
  // forcing every subsequent miss to re-parse — a silent perf cliff
@@ -463,12 +784,24 @@ export class SelectionCompiler {
463
784
  this.parseCache.set(selectionSet, result);
464
785
  return result;
465
786
  }
787
+ /**
788
+ * v2.6.0 — the concrete types a fragment on `typeName` can apply to (see
789
+ * `ParseContext.possibleTypes`). Unknown names stand for themselves.
790
+ */
791
+ possibleTypes(typeName) {
792
+ if (this.schema.nodes.has(typeName))
793
+ return [typeName];
794
+ const iface = this.schema.interfaces?.get(typeName);
795
+ if (iface)
796
+ return iface.implementedBy;
797
+ return this.schema.unions?.get(typeName) ?? [typeName];
798
+ }
466
799
  /**
467
800
  * Convert a GraphQL SelectionSetNode into SelectionNode[].
468
801
  */
469
802
  convertSelectionSet(selectionSet,
470
803
  /** Type condition of the enclosing inline fragment, if any (v2.4.8). */
471
- typeCondition) {
804
+ typeCondition, ctx) {
472
805
  const nodes = [];
473
806
  const seen = new Map();
474
807
  for (const sel of selectionSet.selections) {
@@ -479,15 +812,19 @@ export class SelectionCompiler {
479
812
  if (sel.kind === Kind.INLINE_FRAGMENT) {
480
813
  const fragment = sel;
481
814
  const condition = fragment.typeCondition?.name.value ?? typeCondition;
815
+ if (!keptByDirectives(fragment.directives, `... on ${condition ?? '(inline fragment)'}`, ctx))
816
+ continue;
482
817
  if (fragment.selectionSet)
483
- for (const merged of this.convertSelectionSet(fragment.selectionSet, condition))
484
- mergeSelection(nodes, seen, merged);
818
+ for (const merged of this.convertSelectionSet(fragment.selectionSet, condition, ctx))
819
+ mergeSelection(nodes, seen, merged, ctx);
485
820
  continue;
486
821
  }
487
822
  if (sel.kind !== Kind.FIELD)
488
823
  continue;
489
824
  const field = sel;
490
825
  const fieldName = field.name.value;
826
+ if (!keptByDirectives(field.directives, field.alias?.value ?? fieldName, ctx))
827
+ continue;
491
828
  // v2.5.0 — fields are keyed by their OUTPUT name, so the same field
492
829
  // under two aliases (`a: kids { .. } b: kids { .. }`) stays two
493
830
  // selections. Pre-2.5.0 they collapsed into one.
@@ -513,30 +850,82 @@ export class SelectionCompiler {
513
850
  if (hasChildren && field.selectionSet)
514
851
  if (isConnection)
515
852
  // For connections, parse edges -> node and edges -> properties
516
- this.parseConnectionChildren(field.selectionSet, node);
853
+ this.parseConnectionChildren(field.selectionSet, node, ctx);
517
854
  else
518
- node.children = this.convertSelectionSet(field.selectionSet);
519
- mergeSelection(nodes, seen, node);
855
+ node.children = this.convertSelectionSet(field.selectionSet, undefined, ctx);
856
+ mergeSelection(nodes, seen, node, ctx);
520
857
  }
521
858
  return nodes;
522
859
  }
523
860
  /**
524
861
  * Parse the children of a connection field, extracting node and edge children.
525
862
  */
526
- parseConnectionChildren(selectionSet, parentNode) {
863
+ parseConnectionChildren(selectionSet, parentNode, ctx) {
527
864
  let selectsEdges = false;
865
+ // v2.6.0 — the response key of the first `edges`, and (strict) the edge
866
+ // sets of `edges` selected under other keys.
867
+ let firstEdgesKey;
868
+ const extras = new Map();
869
+ // v2.6.0 — the first `node` / `properties` key of each edge set.
870
+ const firstKeys = new Map();
871
+ const connectionName = parentNode.alias ?? parentNode.fieldName;
872
+ /**
873
+ * Where a `node` / `properties` selection under `key` goes: the edge
874
+ * set's own (first) key, or — strict, another key — its own entry.
875
+ */
876
+ const edgeKeyTarget = (set, which, key) => {
877
+ const firsts = firstKeys.get(set) ?? {};
878
+ firstKeys.set(set, firsts);
879
+ firsts[which] ?? (firsts[which] = key);
880
+ if (firsts[which] === key)
881
+ return null;
882
+ if (!ctx.strict) {
883
+ warnAliasedEdgeKey(ctx, connectionName, which, firsts[which], key);
884
+ return null;
885
+ }
886
+ const prop = which === 'node' ? 'extraNodes' : 'extraProperties';
887
+ const list = (set[prop] ?? (set[prop] = []));
888
+ let entry = list.find((e) => e.key === key);
889
+ if (!entry) {
890
+ assertSafeIdentifier(key, 'selection alias');
891
+ entry = { key, children: [] };
892
+ list.push(entry);
893
+ }
894
+ return entry;
895
+ };
528
896
  for (const sel of selectionSet.selections) {
529
897
  if (sel.kind !== Kind.FIELD)
530
898
  continue;
531
899
  const field = sel;
532
900
  assertNoArguments(field, `${parentNode.fieldName}.${field.name.value}`);
901
+ if (!keptByDirectives(field.directives, `${parentNode.fieldName}.${field.name.value}`, ctx))
902
+ continue;
533
903
  if (field.name.value === 'totalCount') {
534
904
  parentNode.totalCount = true;
535
905
  this.recordConnectionAlias(parentNode, 'totalCount', field);
536
906
  }
907
+ let target = parentNode;
537
908
  if (field.name.value === 'edges') {
538
909
  selectsEdges = true;
539
- this.recordConnectionAlias(parentNode, 'edges', field);
910
+ const edgesKey = field.alias?.value ?? 'edges';
911
+ firstEdgesKey ?? (firstEdgesKey = edgesKey);
912
+ if (edgesKey !== firstEdgesKey)
913
+ if (ctx.strict) {
914
+ let extra = extras.get(edgesKey);
915
+ if (!extra) {
916
+ extra = {
917
+ fieldName: parentNode.fieldName,
918
+ isScalar: false,
919
+ isRelationship: false,
920
+ isConnection: true,
921
+ };
922
+ extras.set(edgesKey, extra);
923
+ }
924
+ target = extra;
925
+ }
926
+ else
927
+ warnAliasedEdges(ctx, parentNode.alias ?? parentNode.fieldName, firstEdgesKey, edgesKey);
928
+ this.recordConnectionAlias(target, 'edges', field);
540
929
  }
541
930
  if (field.name.value === 'edges' && field.selectionSet)
542
931
  for (const edgeSel of field.selectionSet.selections) {
@@ -544,20 +933,36 @@ export class SelectionCompiler {
544
933
  continue;
545
934
  const edgeField = edgeSel;
546
935
  assertNoArguments(edgeField, `${parentNode.fieldName}.edges.${edgeField.name.value}`);
936
+ if (!keptByDirectives(edgeField.directives, `${parentNode.fieldName}.edges.${edgeField.name.value}`, ctx))
937
+ continue;
547
938
  // v2.5.3 — a repeated `edges { node { … } }` merges its
548
939
  // sub-selections; pre-2.5.3 the last one replaced the others.
549
940
  if (edgeField.name.value === 'node' && edgeField.selectionSet) {
550
- this.recordConnectionAlias(parentNode, 'node', edgeField);
551
- parentNode.children = mergeSelectionLists(parentNode.children, this.convertSelectionSet(edgeField.selectionSet));
941
+ const extra = edgeKeyTarget(target, 'node', edgeField.alias?.value ?? 'node');
942
+ const parsed = this.convertSelectionSet(edgeField.selectionSet, undefined, ctx);
943
+ if (extra)
944
+ extra.children = mergeSelectionLists(extra.children, parsed, ctx);
945
+ else {
946
+ this.recordConnectionAlias(target, 'node', edgeField);
947
+ target.children = mergeSelectionLists(target.children, parsed, ctx);
948
+ }
552
949
  }
553
950
  if (edgeField.name.value === 'properties' && edgeField.selectionSet) {
554
- this.recordConnectionAlias(parentNode, 'properties', edgeField);
555
- parentNode.edgeChildren = mergeSelectionLists(parentNode.edgeChildren, this.convertSelectionSet(edgeField.selectionSet));
951
+ const extra = edgeKeyTarget(target, 'properties', edgeField.alias?.value ?? 'properties');
952
+ const parsed = this.convertSelectionSet(edgeField.selectionSet, undefined, ctx);
953
+ if (extra)
954
+ extra.children = mergeSelectionLists(extra.children, parsed, ctx);
955
+ else {
956
+ this.recordConnectionAlias(target, 'properties', edgeField);
957
+ target.edgeChildren = mergeSelectionLists(target.edgeChildren, parsed, ctx);
958
+ }
556
959
  }
557
960
  }
558
961
  }
559
962
  if (parentNode.totalCount && !selectsEdges)
560
963
  parentNode.totalCountOnly = true;
964
+ if (extras.size > 0)
965
+ parentNode.extraEdges = [...extras.values()];
561
966
  }
562
967
  /**
563
968
  * Keep the selectionSet alias of a connection key (v2.5.3). The first
@@ -583,12 +988,40 @@ export class SelectionCompiler {
583
988
  * Returns `null` (unchanged behaviour) when no fragment type declares it.
584
989
  */
585
990
  compileFragmentRelationship(node, nodeVar, maxDepth, currentDepth, params, paramCounter, policyContext = null) {
991
+ const { branches, singular } = this.fragmentRelationshipBranches(node, node.typeConditions ?? [], nodeVar, maxDepth, currentDepth, params, paramCounter, policyContext);
992
+ if (branches.length === 0)
993
+ return null;
994
+ return `CASE ${branches.join(' ')} ELSE ${singular ? 'null' : '[]'} END`;
995
+ }
996
+ /**
997
+ * v2.6.0 — strict: a relationship / connection key with per-type
998
+ * variants (`SelectionNode.typeVariants`): one `CASE` whose branches
999
+ * compile each variant, with its own arguments and the target's read
1000
+ * policy, against the concrete types its fragments apply to.
1001
+ */
1002
+ compileTypeVariants(node, nodeVar, maxDepth, currentDepth, params, paramCounter, policyContext = null) {
1003
+ const branches = [];
1004
+ let singular = false;
1005
+ for (const variant of [node, ...(node.typeVariants ?? [])]) {
1006
+ const types = [
1007
+ ...new Set((variant.typeConditions ?? []).flatMap((t) => this.possibleTypes(t))),
1008
+ ];
1009
+ const part = this.fragmentRelationshipBranches(variant, types, nodeVar, maxDepth, currentDepth, params, paramCounter, policyContext);
1010
+ branches.push(...part.branches);
1011
+ singular || (singular = part.singular);
1012
+ }
1013
+ if (branches.length === 0)
1014
+ return null;
1015
+ return `CASE ${branches.join(' ')} ELSE ${singular ? 'null' : '[]'} END`;
1016
+ }
1017
+ /** `WHEN n:T THEN <projection>` per type of `typeNames` declaring the field. */
1018
+ fragmentRelationshipBranches(node, typeNames, nodeVar, maxDepth, currentDepth, params, paramCounter, policyContext = null) {
586
1019
  const relField = node.isConnection
587
1020
  ? node.fieldName.replace(/Connection$/, '')
588
1021
  : node.fieldName;
589
1022
  const branches = [];
590
1023
  let singular = node.isConnection;
591
- for (const typeName of node.typeConditions ?? []) {
1024
+ for (const typeName of typeNames) {
592
1025
  const def = this.schema.nodes.get(typeName);
593
1026
  const relDef = def?.relationships.get(relField);
594
1027
  if (!def || !relDef)
@@ -601,9 +1034,7 @@ export class SelectionCompiler {
601
1034
  if (expr)
602
1035
  branches.push(`WHEN ${nodeVar}:${escapeIdentifier(def.label)} THEN ${expr}`);
603
1036
  }
604
- if (branches.length === 0)
605
- return null;
606
- return `CASE ${branches.join(' ')} ELSE ${singular ? 'null' : '[]'} END`;
1037
+ return { branches, singular };
607
1038
  }
608
1039
  /**
609
1040
  * Compile a relationship field into a Cypher pattern comprehension.
@@ -836,6 +1267,11 @@ export class SelectionCompiler {
836
1267
  mapParts.push(`${propertiesKey}: ${edgeVar} { ${edgeFields} }`);
837
1268
  projectionKeys.push(propertiesKey);
838
1269
  }
1270
+ // v2.6.0 — strict: further `node` / `properties` keys of these edges.
1271
+ for (const [k, expr] of this.extraEdgeKeyParts(node, childVar, edgeVar, targetDef, maxDepth, currentDepth, params, paramCounter, policyContext)) {
1272
+ mapParts.push(`${k}: ${expr}`);
1273
+ projectionKeys.push(k);
1274
+ }
839
1275
  // Inject synthetic sort scalars at the top level of the edge map so
840
1276
  // apoc.coll.sortMulti (which only accepts top-level keys) can order the
841
1277
  // collection. The outer list comprehension strips them on the way out.
@@ -982,7 +1418,12 @@ export class SelectionCompiler {
982
1418
  : '';
983
1419
  if (node.totalCountOnly)
984
1420
  return `{ ${totalCount} }`;
985
- const withTotal = totalCount ? `, ${totalCount}` : '';
1421
+ // v2.6.0 — strict: `edges` selected under further response keys, each
1422
+ // its own list over the same pattern and filter. Empty otherwise.
1423
+ const extraEdges = (node.extraEdges ?? [])
1424
+ .map((extra) => `, ${escapeIdentifier(edgesKeyOf(extra))}: [${pattern}${whereClause} | ${this.compileEdgeSetMap(extra, childVar, edgeVar, targetDef, maxDepth, currentDepth, params, paramCounter, policyContext)}]`)
1425
+ .join('');
1426
+ const withTotal = `${extraEdges}${totalCount ? `, ${totalCount}` : ''}`;
986
1427
  if (sortKeyTokens.length === 0)
987
1428
  return `{ ${edgesKey}: [${pattern}${whereClause} | ${innerMap}]${withTotal} }`;
988
1429
  // Re-project only the user-facing keys so synthetic __skN scalars don't
@@ -992,6 +1433,61 @@ export class SelectionCompiler {
992
1433
  : `{}`;
993
1434
  return `{ ${edgesKey}: [x IN apoc.coll.sortMulti([${pattern}${whereClause} | ${innerMap}], [${sortKeyTokens.join(', ')}]) | ${outerProjection}]${withTotal} }`;
994
1435
  }
1436
+ /**
1437
+ * v2.6.0 — the `{ node: …, properties: … }` map of one strict-mode extra
1438
+ * edge set (see `SelectionNode.extraEdges`), mirroring `compileConnection`.
1439
+ */
1440
+ compileEdgeSetMap(set, childVar, edgeVar, targetDef, maxDepth, currentDepth, params, paramCounter, policyContext = null) {
1441
+ const parts = [];
1442
+ const aliases = set.connectionAliases ?? {};
1443
+ if (set.children && set.children.length > 0)
1444
+ parts.push(`${aliases.node ? escapeIdentifier(aliases.node) : 'node'}: ${this.compile(set.children, childVar, targetDef, maxDepth, currentDepth + 1, params, paramCounter, null, policyContext)}`);
1445
+ if (set.edgeChildren && set.edgeChildren.length > 0) {
1446
+ const edgeFields = set.edgeChildren
1447
+ .filter((c) => c.isScalar)
1448
+ .map((c) => {
1449
+ if (c.alias === undefined || c.alias === c.fieldName)
1450
+ return `.${escapeIdentifier(c.fieldName)}`;
1451
+ assertSafeIdentifier(c.alias, 'selection alias');
1452
+ return `${escapeIdentifier(c.alias)}: ${edgeVar}.${escapeIdentifier(c.fieldName)}`;
1453
+ })
1454
+ .join(', ');
1455
+ parts.push(`${aliases.properties ? escapeIdentifier(aliases.properties) : 'properties'}: ${edgeVar} { ${edgeFields} }`);
1456
+ }
1457
+ for (const [k, expr] of this.extraEdgeKeyParts(set, childVar, edgeVar, targetDef, maxDepth, currentDepth, params, paramCounter, policyContext))
1458
+ parts.push(`${k}: ${expr}`);
1459
+ return `{ ${parts.join(', ')} }`;
1460
+ }
1461
+ /**
1462
+ * v2.6.0 — strict: the projections of an edge set's further `node` /
1463
+ * `properties` keys (`SelectionNode.extraNodes` / `extraProperties`),
1464
+ * as `[escapedKey, expression]` pairs. Empty in 2.x default mode.
1465
+ */
1466
+ extraEdgeKeyParts(set, childVar, edgeVar, targetDef, maxDepth, currentDepth, params, paramCounter, policyContext = null) {
1467
+ const out = [];
1468
+ for (const extra of set.extraNodes ?? [])
1469
+ if (extra.children.length > 0)
1470
+ out.push([
1471
+ escapeIdentifier(extra.key),
1472
+ this.compile(extra.children, childVar, targetDef, maxDepth, currentDepth + 1, params, paramCounter, null, policyContext),
1473
+ ]);
1474
+ for (const extra of set.extraProperties ?? []) {
1475
+ const fields = extra.children
1476
+ .filter((c) => c.isScalar)
1477
+ .map((c) => {
1478
+ if (c.alias === undefined || c.alias === c.fieldName)
1479
+ return `.${escapeIdentifier(c.fieldName)}`;
1480
+ assertSafeIdentifier(c.alias, 'selection alias');
1481
+ return `${escapeIdentifier(c.alias)}: ${edgeVar}.${escapeIdentifier(c.fieldName)}`;
1482
+ });
1483
+ if (fields.length > 0)
1484
+ out.push([
1485
+ escapeIdentifier(extra.key),
1486
+ `${edgeVar} { ${fields.join(', ')} }`,
1487
+ ]);
1488
+ }
1489
+ return out;
1490
+ }
995
1491
  /**
996
1492
  * AND-combine an optional user where (`select.where` / `connectionWhere`)
997
1493
  * with the target type's `'read'` policy and emit a `WHERE …` fragment