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
@@ -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() {
@@ -285,7 +587,7 @@ export class SelectionCompiler {
285
587
  }
286
588
  assertSafeIdentifier(node.fieldName, 'selection field');
287
589
  if (isAbstract && node.typeConditions) {
288
- const conditional = this.compileFragmentScalar(node, key, nodeVar, nodeDef, cypherScope);
590
+ const conditional = this.compileFragmentScalar(node, key, nodeVar, nodeDef, cypherScope, params, paramCounter, policyContext);
289
591
  if (conditional) {
290
592
  parts.push(conditional);
291
593
  continue;
@@ -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.)
@@ -361,8 +672,15 @@ export class SelectionCompiler {
361
672
  * when the abstract type declares the field as `@cypher` itself, when a
362
673
  * condition isn't a concrete type, or when no fragment type declares it
363
674
  * as `@cypher`.
675
+ *
676
+ * v2.5.4 — under a policy context, each `@cypher` branch is also guarded
677
+ * by that member's own `read` clause (`WHEN n:B AND <B read> THEN …`),
678
+ * so a member's statement only projects for a node that passes the
679
+ * member's policy. The node filter (`compileTargetPolicyClause`) already
680
+ * requires every carried member's clause; this is defense in depth.
681
+ * Without a policy context the branches are unchanged.
364
682
  */
365
- compileFragmentScalar(node, key, nodeVar, nodeDef, cypherScope) {
683
+ compileFragmentScalar(node, key, nodeVar, nodeDef, cypherScope, params, paramCounter, policyContext = null) {
366
684
  // v2.5.3 — `fieldByType`: sibling fragments selected different fields
367
685
  // under this key, so each type projects its own field.
368
686
  const perType = node.fieldByType;
@@ -390,10 +708,28 @@ export class SelectionCompiler {
390
708
  : buildInlineCypherFieldExpr(prop.cypherStatement, nodeVar, `${def.typeName}.${field}`);
391
709
  else
392
710
  expr = `${nodeVar}.${escapeIdentifier(field)}`;
393
- return `WHEN ${nodeVar}:${escapeIdentifier(def.label)} THEN ${expr}`;
711
+ const guard = prop.isCypher && prop.cypherStatement
712
+ ? this.memberReadGuard(def, nodeVar, params, paramCounter, policyContext)
713
+ : '';
714
+ return `WHEN ${nodeVar}:${escapeIdentifier(def.label)}${guard} THEN ${expr}`;
394
715
  });
395
716
  return `${escapeIdentifier(key)}: CASE ${branches.join(' ')} END`;
396
717
  }
718
+ /**
719
+ * ` AND <member read clause>` guarding a fragment `@cypher` branch
720
+ * (v2.5.4); `''` without a policy context, when the member has no read
721
+ * clause, or when that clause needs `@cypher` preludes a projection
722
+ * cannot host (the node filter still enforces it).
723
+ */
724
+ memberReadGuard(def, nodeVar, params, paramCounter, policyContext) {
725
+ if (!policyContext || !this.whereCompiler || !params || !paramCounter)
726
+ return '';
727
+ const clause = this.whereCompiler.compileTargetPolicyClause(def.typeName, nodeVar, 'read', policyContext, paramCounter);
728
+ if (clause.cypher === null || clause.preludes.length > 0)
729
+ return '';
730
+ mergeParams(params, clause.params);
731
+ return ` AND ${clause.cypher}`;
732
+ }
397
733
  /**
398
734
  * Parse a GraphQL selection set string into SelectionNode[].
399
735
  *
@@ -407,6 +743,7 @@ export class SelectionCompiler {
407
743
  // order is the LRU iteration order we rely on for eviction.
408
744
  this.parseCache.delete(selectionSet);
409
745
  this.parseCache.set(selectionSet, cached);
746
+ this.replayParseWarnings(cached);
410
747
  return cached;
411
748
  }
412
749
  const trimmed = selectionSet.trim();
@@ -425,7 +762,16 @@ export class SelectionCompiler {
425
762
  const dummyField = opDef.selectionSet.selections[0];
426
763
  if (dummyField.kind !== Kind.FIELD || !dummyField.selectionSet)
427
764
  return [];
428
- 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
+ }
429
775
  // Real-LRU eviction: drop the oldest entry when the cache is full,
430
776
  // then insert. Pre-1.7.5 we just stopped caching at saturation,
431
777
  // forcing every subsequent miss to re-parse — a silent perf cliff
@@ -438,12 +784,24 @@ export class SelectionCompiler {
438
784
  this.parseCache.set(selectionSet, result);
439
785
  return result;
440
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
+ }
441
799
  /**
442
800
  * Convert a GraphQL SelectionSetNode into SelectionNode[].
443
801
  */
444
802
  convertSelectionSet(selectionSet,
445
803
  /** Type condition of the enclosing inline fragment, if any (v2.4.8). */
446
- typeCondition) {
804
+ typeCondition, ctx) {
447
805
  const nodes = [];
448
806
  const seen = new Map();
449
807
  for (const sel of selectionSet.selections) {
@@ -454,15 +812,19 @@ export class SelectionCompiler {
454
812
  if (sel.kind === Kind.INLINE_FRAGMENT) {
455
813
  const fragment = sel;
456
814
  const condition = fragment.typeCondition?.name.value ?? typeCondition;
815
+ if (!keptByDirectives(fragment.directives, `... on ${condition ?? '(inline fragment)'}`, ctx))
816
+ continue;
457
817
  if (fragment.selectionSet)
458
- for (const merged of this.convertSelectionSet(fragment.selectionSet, condition))
459
- mergeSelection(nodes, seen, merged);
818
+ for (const merged of this.convertSelectionSet(fragment.selectionSet, condition, ctx))
819
+ mergeSelection(nodes, seen, merged, ctx);
460
820
  continue;
461
821
  }
462
822
  if (sel.kind !== Kind.FIELD)
463
823
  continue;
464
824
  const field = sel;
465
825
  const fieldName = field.name.value;
826
+ if (!keptByDirectives(field.directives, field.alias?.value ?? fieldName, ctx))
827
+ continue;
466
828
  // v2.5.0 — fields are keyed by their OUTPUT name, so the same field
467
829
  // under two aliases (`a: kids { .. } b: kids { .. }`) stays two
468
830
  // selections. Pre-2.5.0 they collapsed into one.
@@ -488,30 +850,82 @@ export class SelectionCompiler {
488
850
  if (hasChildren && field.selectionSet)
489
851
  if (isConnection)
490
852
  // For connections, parse edges -> node and edges -> properties
491
- this.parseConnectionChildren(field.selectionSet, node);
853
+ this.parseConnectionChildren(field.selectionSet, node, ctx);
492
854
  else
493
- node.children = this.convertSelectionSet(field.selectionSet);
494
- mergeSelection(nodes, seen, node);
855
+ node.children = this.convertSelectionSet(field.selectionSet, undefined, ctx);
856
+ mergeSelection(nodes, seen, node, ctx);
495
857
  }
496
858
  return nodes;
497
859
  }
498
860
  /**
499
861
  * Parse the children of a connection field, extracting node and edge children.
500
862
  */
501
- parseConnectionChildren(selectionSet, parentNode) {
863
+ parseConnectionChildren(selectionSet, parentNode, ctx) {
502
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
+ };
503
896
  for (const sel of selectionSet.selections) {
504
897
  if (sel.kind !== Kind.FIELD)
505
898
  continue;
506
899
  const field = sel;
507
900
  assertNoArguments(field, `${parentNode.fieldName}.${field.name.value}`);
901
+ if (!keptByDirectives(field.directives, `${parentNode.fieldName}.${field.name.value}`, ctx))
902
+ continue;
508
903
  if (field.name.value === 'totalCount') {
509
904
  parentNode.totalCount = true;
510
905
  this.recordConnectionAlias(parentNode, 'totalCount', field);
511
906
  }
907
+ let target = parentNode;
512
908
  if (field.name.value === 'edges') {
513
909
  selectsEdges = true;
514
- 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);
515
929
  }
516
930
  if (field.name.value === 'edges' && field.selectionSet)
517
931
  for (const edgeSel of field.selectionSet.selections) {
@@ -519,20 +933,36 @@ export class SelectionCompiler {
519
933
  continue;
520
934
  const edgeField = edgeSel;
521
935
  assertNoArguments(edgeField, `${parentNode.fieldName}.edges.${edgeField.name.value}`);
936
+ if (!keptByDirectives(edgeField.directives, `${parentNode.fieldName}.edges.${edgeField.name.value}`, ctx))
937
+ continue;
522
938
  // v2.5.3 — a repeated `edges { node { … } }` merges its
523
939
  // sub-selections; pre-2.5.3 the last one replaced the others.
524
940
  if (edgeField.name.value === 'node' && edgeField.selectionSet) {
525
- this.recordConnectionAlias(parentNode, 'node', edgeField);
526
- 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
+ }
527
949
  }
528
950
  if (edgeField.name.value === 'properties' && edgeField.selectionSet) {
529
- this.recordConnectionAlias(parentNode, 'properties', edgeField);
530
- 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
+ }
531
959
  }
532
960
  }
533
961
  }
534
962
  if (parentNode.totalCount && !selectsEdges)
535
963
  parentNode.totalCountOnly = true;
964
+ if (extras.size > 0)
965
+ parentNode.extraEdges = [...extras.values()];
536
966
  }
537
967
  /**
538
968
  * Keep the selectionSet alias of a connection key (v2.5.3). The first
@@ -558,12 +988,40 @@ export class SelectionCompiler {
558
988
  * Returns `null` (unchanged behaviour) when no fragment type declares it.
559
989
  */
560
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) {
561
1019
  const relField = node.isConnection
562
1020
  ? node.fieldName.replace(/Connection$/, '')
563
1021
  : node.fieldName;
564
1022
  const branches = [];
565
1023
  let singular = node.isConnection;
566
- for (const typeName of node.typeConditions ?? []) {
1024
+ for (const typeName of typeNames) {
567
1025
  const def = this.schema.nodes.get(typeName);
568
1026
  const relDef = def?.relationships.get(relField);
569
1027
  if (!def || !relDef)
@@ -576,9 +1034,7 @@ export class SelectionCompiler {
576
1034
  if (expr)
577
1035
  branches.push(`WHEN ${nodeVar}:${escapeIdentifier(def.label)} THEN ${expr}`);
578
1036
  }
579
- if (branches.length === 0)
580
- return null;
581
- return `CASE ${branches.join(' ')} ELSE ${singular ? 'null' : '[]'} END`;
1037
+ return { branches, singular };
582
1038
  }
583
1039
  /**
584
1040
  * Compile a relationship field into a Cypher pattern comprehension.
@@ -811,6 +1267,11 @@ export class SelectionCompiler {
811
1267
  mapParts.push(`${propertiesKey}: ${edgeVar} { ${edgeFields} }`);
812
1268
  projectionKeys.push(propertiesKey);
813
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
+ }
814
1275
  // Inject synthetic sort scalars at the top level of the edge map so
815
1276
  // apoc.coll.sortMulti (which only accepts top-level keys) can order the
816
1277
  // collection. The outer list comprehension strips them on the way out.
@@ -957,7 +1418,12 @@ export class SelectionCompiler {
957
1418
  : '';
958
1419
  if (node.totalCountOnly)
959
1420
  return `{ ${totalCount} }`;
960
- 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}` : ''}`;
961
1427
  if (sortKeyTokens.length === 0)
962
1428
  return `{ ${edgesKey}: [${pattern}${whereClause} | ${innerMap}]${withTotal} }`;
963
1429
  // Re-project only the user-facing keys so synthetic __skN scalars don't
@@ -967,6 +1433,61 @@ export class SelectionCompiler {
967
1433
  : `{}`;
968
1434
  return `{ ${edgesKey}: [x IN apoc.coll.sortMulti([${pattern}${whereClause} | ${innerMap}], [${sortKeyTokens.join(', ')}]) | ${outerProjection}]${withTotal} }`;
969
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
+ }
970
1491
  /**
971
1492
  * AND-combine an optional user where (`select.where` / `connectionWhere`)
972
1493
  * with the target type's `'read'` policy and emit a `WHERE …` fragment