@apollo/federation-internals 2.1.0-alpha.1 → 2.1.0-alpha.4

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 (56) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/dist/buildSchema.d.ts.map +1 -1
  3. package/dist/buildSchema.js +13 -3
  4. package/dist/buildSchema.js.map +1 -1
  5. package/dist/definitions.d.ts +47 -8
  6. package/dist/definitions.d.ts.map +1 -1
  7. package/dist/definitions.js +137 -23
  8. package/dist/definitions.js.map +1 -1
  9. package/dist/error.d.ts +1 -0
  10. package/dist/error.d.ts.map +1 -1
  11. package/dist/error.js +2 -0
  12. package/dist/error.js.map +1 -1
  13. package/dist/extractSubgraphsFromSupergraph.d.ts.map +1 -1
  14. package/dist/extractSubgraphsFromSupergraph.js +9 -0
  15. package/dist/extractSubgraphsFromSupergraph.js.map +1 -1
  16. package/dist/federation.d.ts +5 -1
  17. package/dist/federation.d.ts.map +1 -1
  18. package/dist/federation.js +13 -5
  19. package/dist/federation.js.map +1 -1
  20. package/dist/federationSpec.d.ts +13 -9
  21. package/dist/federationSpec.d.ts.map +1 -1
  22. package/dist/federationSpec.js +27 -5
  23. package/dist/federationSpec.js.map +1 -1
  24. package/dist/inaccessibleSpec.js +1 -2
  25. package/dist/inaccessibleSpec.js.map +1 -1
  26. package/dist/operations.d.ts +48 -21
  27. package/dist/operations.d.ts.map +1 -1
  28. package/dist/operations.js +329 -48
  29. package/dist/operations.js.map +1 -1
  30. package/dist/print.d.ts +1 -1
  31. package/dist/print.d.ts.map +1 -1
  32. package/dist/print.js +1 -1
  33. package/dist/print.js.map +1 -1
  34. package/dist/schemaUpgrader.js +2 -2
  35. package/dist/schemaUpgrader.js.map +1 -1
  36. package/dist/utils.d.ts +9 -0
  37. package/dist/utils.d.ts.map +1 -1
  38. package/dist/utils.js +31 -1
  39. package/dist/utils.js.map +1 -1
  40. package/package.json +3 -3
  41. package/src/__tests__/definitions.test.ts +18 -0
  42. package/src/__tests__/operations.test.ts +217 -99
  43. package/src/__tests__/subgraphValidation.test.ts +2 -0
  44. package/src/buildSchema.ts +19 -5
  45. package/src/definitions.ts +217 -29
  46. package/src/error.ts +7 -0
  47. package/src/extractSubgraphsFromSupergraph.ts +20 -0
  48. package/src/federation.ts +16 -5
  49. package/src/federationSpec.ts +32 -5
  50. package/src/inaccessibleSpec.ts +2 -5
  51. package/src/operations.ts +520 -71
  52. package/src/print.ts +1 -1
  53. package/src/schemaUpgrader.ts +2 -2
  54. package/src/utils.ts +40 -0
  55. package/tsconfig.test.tsbuildinfo +1 -1
  56. package/tsconfig.tsbuildinfo +1 -1
package/src/operations.ts CHANGED
@@ -39,14 +39,16 @@ import {
39
39
  variableDefinitionsFromAST,
40
40
  CompositeType,
41
41
  typenameFieldName,
42
- NamedType,
43
42
  sameDirectiveApplications,
44
43
  isConditionalDirective,
45
44
  isDirectiveApplicationsSubset,
45
+ isAbstractType,
46
+ DeferDirectiveArgs,
47
+ Variable,
46
48
  } from "./definitions";
47
49
  import { ERRORS } from "./error";
48
- import { sameType } from "./types";
49
- import { assert, mapEntries, MapWithCachedArrays, MultiMap } from "./utils";
50
+ import { isDirectSubtype, sameType } from "./types";
51
+ import { assert, mapEntries, MapWithCachedArrays, MultiMap, SetMultiMap } from "./utils";
50
52
  import { argumentsEquals, argumentsFromAST, isValidValue, valueToAST, valueToString } from "./values";
51
53
 
52
54
  function validate(condition: any, message: () => string, sourceAST?: ASTNode): asserts condition {
@@ -178,27 +180,46 @@ export class Field<TArgs extends {[key: string]: any} = {[key: string]: any}> ex
178
180
  updateForAddingTo(selectionSet: SelectionSet): Field<TArgs> {
179
181
  const selectionParent = selectionSet.parentType;
180
182
  const fieldParent = this.definition.parent;
181
- if (selectionParent.name !== fieldParent.name) {
182
- if (this.name === typenameFieldName) {
183
- return this.withUpdatedDefinition(selectionParent.typenameField()!);
184
- }
183
+ if (selectionParent === fieldParent) {
184
+ return this;
185
+ }
185
186
 
186
- // We accept adding a selection of an interface field to a selection of one of its subtype. But otherwise, it's invalid.
187
- // Do note that the field might come from a supergraph while the selection is on a subgraph, so we avoid relying on isDirectSubtype (because
188
- // isDirectSubtype relies on the subtype knowing which interface it implements, but the one of the subgraph might not declare implementing
189
- // the supergraph interface, even if it does in the subgraph).
190
- validate(
187
+ if (this.name === typenameFieldName) {
188
+ return this.withUpdatedDefinition(selectionParent.typenameField()!);
189
+ }
190
+
191
+ // We accept adding a selection of an interface field to a selection of one of its subtype. But otherwise, it's invalid.
192
+ // Do note that the field might come from a supergraph while the selection is on a subgraph, so we avoid relying on isDirectSubtype (because
193
+ // isDirectSubtype relies on the subtype knowing which interface it implements, but the one of the subgraph might not declare implementing
194
+ // the supergraph interface, even if it does in the subgraph).
195
+ validate(
196
+ selectionParent.name == fieldParent.name
197
+ || (
191
198
  !isUnionType(selectionParent)
192
199
  && (
193
200
  (isInterfaceType(fieldParent) && fieldParent.allImplementations().some(i => i.name == selectionParent.name))
194
201
  || (isObjectType(fieldParent) && fieldParent.name == selectionParent.name)
195
- ),
196
- () => `Cannot add selection of field "${this.definition.coordinate}" to selection set of parent type "${selectionSet.parentType}"`
197
- );
198
- const fieldDef = selectionParent.field(this.name);
199
- validate(fieldDef, () => `Cannot add selection of field "${this.definition.coordinate}" to selection set of parent type "${selectionParent} (that does not declare that type)"`);
200
- return this.withUpdatedDefinition(fieldDef);
201
- }
202
+ )
203
+ ),
204
+ () => `Cannot add selection of field "${this.definition.coordinate}" to selection set of parent type "${selectionSet.parentType}"`
205
+ );
206
+ const fieldDef = selectionParent.field(this.name);
207
+ validate(fieldDef, () => `Cannot add selection of field "${this.definition.coordinate}" to selection set of parent type "${selectionParent}" (that does not declare that field)`);
208
+ return this.withUpdatedDefinition(fieldDef);
209
+ }
210
+
211
+ hasDefer(): boolean {
212
+ // @defer cannot be on field at the moment
213
+ return false;
214
+ }
215
+
216
+ deferDirectiveArgs(): undefined {
217
+ // @defer cannot be on field at the moment (but exists so we can call this method on any `OperationElement` conveniently)
218
+ return undefined;
219
+ }
220
+
221
+ withoutDefer(): Field<TArgs> {
222
+ // @defer cannot be on field at the moment
202
223
  return this;
203
224
  }
204
225
 
@@ -243,8 +264,15 @@ export class FragmentElement extends AbstractOperationElement<FragmentElement> {
243
264
  return this.sourceType;
244
265
  }
245
266
 
267
+ castedType(): CompositeType {
268
+ return this.typeCondition ? this.typeCondition : this.sourceType;
269
+ }
270
+
246
271
  withUpdatedSourceType(newSourceType: CompositeType): FragmentElement {
247
- const newFragment = new FragmentElement(newSourceType, this.typeCondition);
272
+ // Note that we pass the type-condition name instead of the type itself, to ensure that if `newSourceType` was from a different
273
+ // schema (typically, the supergraph) than `this.sourceType` (typically, a subgraph), then the new condition uses the
274
+ // definition of the proper schema (the supergraph in such cases, instead of the subgraph).
275
+ const newFragment = new FragmentElement(newSourceType, this.typeCondition?.name);
248
276
  for (const directive of this.appliedDirectives) {
249
277
  newFragment.applyDirective(directive.definition!, directive.arguments());
250
278
  }
@@ -266,6 +294,106 @@ export class FragmentElement extends AbstractOperationElement<FragmentElement> {
266
294
  return this;
267
295
  }
268
296
 
297
+ hasDefer(): boolean {
298
+ return this.hasAppliedDirective('defer');
299
+ }
300
+
301
+ hasStream(): boolean {
302
+ return this.hasAppliedDirective('stream');
303
+ }
304
+
305
+ deferDirectiveArgs(): DeferDirectiveArgs | undefined {
306
+ // Note: @defer is not repeatable, so the return array below is either empty, or has a single value.
307
+ return this.appliedDirectivesOf(this.schema().deferDirective())[0]?.arguments();
308
+ }
309
+
310
+ /**
311
+ * Returns this fragment element but with any @defer directive on it removed.
312
+ *
313
+ * This method will return `undefined` if, upon removing @defer, the fragment has no conditions nor
314
+ * any remaining applied directives (meaning that it carries no information whatsoever and can be
315
+ * ignored).
316
+ */
317
+ withoutDefer(): FragmentElement | undefined {
318
+ const deferName = this.schema().deferDirective().name;
319
+ const updatedDirectives = this.appliedDirectives.filter((d) => d.name !== deferName);
320
+ if (!this.typeCondition && updatedDirectives.length === 0) {
321
+ return undefined;
322
+ }
323
+
324
+ if (updatedDirectives.length === this.appliedDirectives.length) {
325
+ return this;
326
+ }
327
+
328
+ const updated = new FragmentElement(this.sourceType, this.typeCondition);
329
+ updatedDirectives.forEach((d) => updated.applyDirective(d.definition!, d.arguments()));
330
+ return updated;
331
+ }
332
+
333
+ /**
334
+ * Returns this fragment element, but it is has a @defer directive, the element is returned with
335
+ * the @defer "normalized".
336
+ *
337
+ * See `Operation.withNormalizedDefer` for details on our so-called @defer normalization.
338
+ */
339
+ withNormalizedDefer(normalizer: DeferNormalizer): FragmentElement | undefined {
340
+ const deferArgs = this.deferDirectiveArgs();
341
+ if (!deferArgs) {
342
+ return this;
343
+ }
344
+
345
+ let newDeferArgs: DeferDirectiveArgs | undefined = undefined;
346
+ let conditionVariable: Variable | undefined = undefined;
347
+ if (deferArgs.if !== undefined) {
348
+ if (typeof deferArgs.if === 'boolean') {
349
+ if (deferArgs.if) {
350
+ // Harcoded `if: true`, remove the `if`
351
+ newDeferArgs = {
352
+ ...deferArgs,
353
+ if: undefined,
354
+ }
355
+ } else {
356
+ // Harcoded `if: false`, remove the @defer altogether
357
+ return this.withoutDefer();
358
+ }
359
+ } else {
360
+ // `if` on a variable
361
+ conditionVariable = deferArgs.if;
362
+ }
363
+ }
364
+
365
+ let label = deferArgs.label;
366
+ if (!label) {
367
+ label = normalizer.newLabel();
368
+ if (newDeferArgs) {
369
+ newDeferArgs.label = label;
370
+ } else {
371
+ newDeferArgs = {
372
+ ...deferArgs,
373
+ label,
374
+ }
375
+ }
376
+ }
377
+
378
+ // Now that we are sure to have a label, if we had a (non-trivial) condition,
379
+ // associate it to that label.
380
+ if (conditionVariable) {
381
+ normalizer.registerCondition(label, conditionVariable);
382
+ }
383
+
384
+ if (!newDeferArgs) {
385
+ return this;
386
+ }
387
+
388
+ const updated = new FragmentElement(this.sourceType, this.typeCondition);
389
+ const deferDirective = this.schema().deferDirective();
390
+ // Re-apply all the non-defer directives
391
+ this.appliedDirectives.filter((d) => d.name !== deferDirective.name).forEach((d) => updated.applyDirective(d.definition!, d.arguments()));
392
+ // And then re-apply the @defer with the new label.
393
+ updated.applyDirective(this.schema().deferDirective(), newDeferArgs);
394
+ return updated;
395
+ }
396
+
269
397
  equals(that: OperationElement): boolean {
270
398
  if (this === that) {
271
399
  return true;
@@ -360,30 +488,37 @@ export class Operation {
360
488
  }
361
489
 
362
490
  optimize(fragments?: NamedFragments, minUsagesToOptimize: number = 2): Operation {
363
- if (!fragments) {
491
+ assert(minUsagesToOptimize >= 1, `Expected 'minUsagesToOptimize' to be at least 1, but got ${minUsagesToOptimize}`)
492
+ if (!fragments || fragments.isEmpty()) {
364
493
  return this;
365
494
  }
495
+
366
496
  let optimizedSelection = this.selectionSet.optimize(fragments);
367
497
  if (optimizedSelection === this.selectionSet) {
368
498
  return this;
369
499
  }
370
500
 
371
- // Optimizing fragments away, and then de-optimizing if it's used less than we want, feels a bit wasteful,
372
- // but it's simple and probably don't matter too much in practice (we only call this optimization on the
373
- // final compted query plan, so not a very hot path; plus in most case we won't even reach that point
374
- // either because there is no fragment, or none will have been optimized away and we'll exit above). We
375
- // can optimize later if this show up in profiling though.
376
- if (minUsagesToOptimize > 1) {
377
- const usages = new Map<string, number>();
378
- optimizedSelection.collectUsedFragmentNames(usages);
379
- for (const fragment of fragments.names()) {
380
- if (!usages.has(fragment)) {
381
- usages.set(fragment, 0);
382
- }
501
+ const usages = new Map<string, number>();
502
+ optimizedSelection.collectUsedFragmentNames(usages);
503
+ for (const fragment of fragments.names()) {
504
+ if (!usages.has(fragment)) {
505
+ usages.set(fragment, 0);
383
506
  }
384
- const toDeoptimize = mapEntries(usages).filter(([_, count]) => count < minUsagesToOptimize).map(([name]) => name);
385
- optimizedSelection = optimizedSelection.expandFragments(toDeoptimize);
386
507
  }
508
+
509
+ // We re-expand any fragments that is used less than our minimum. Optimizing all fragments to potentially
510
+ // re-expand some is not entirely optimal, but it's simple and probably don't matter too much in practice
511
+ // (we only call this optimization on the final computed query plan, so not a very hot path; plus in most
512
+ // cases we won't even reach that point either because there is no fragment, or none will have been
513
+ // optimized away so we'll exit above). We can optimize later if this show up in profiling though.
514
+ //
515
+ // Also note `toDeoptimize` will always contains the unused fragments, which will allow `expandFragments`
516
+ // to remove them from the listed fragments in `optimizedSelection` (here again, this could make use call
517
+ // `expandFragments` on _only_ unused fragments and that case could be dealt with more efficiently, but
518
+ // probably not noticeable in practice so ...).
519
+ const toDeoptimize = mapEntries(usages).filter(([_, count]) => count < minUsagesToOptimize).map(([name]) => name);
520
+ optimizedSelection = optimizedSelection.expandFragments(toDeoptimize);
521
+
387
522
  return new Operation(this.rootKind, optimizedSelection, this.variableDefinitions, this.name);
388
523
  }
389
524
 
@@ -401,6 +536,61 @@ export class Operation {
401
536
  );
402
537
  }
403
538
 
539
+ /**
540
+ * Returns this operation but potentially modified so all/some of the @defer applications have been removed.
541
+ *
542
+ * @param labelsToRemove - If provided, then only the `@defer` applications with labels in the provided
543
+ * set will be remove. Other `@defer` applications will be untouched. If `undefined`, then all `@defer`
544
+ * applications are removed.
545
+ */
546
+ withoutDefer(labelsToRemove?: Set<string>): Operation {
547
+ // If we have named fragments, we should be looking inside those and either expand those having @defer or,
548
+ // probably better, replace them with a verison without @defer. But as we currently only call this method
549
+ // after `expandAllFragments`, we'll implement this when/if we need it.
550
+ assert(!this.selectionSet.fragments || this.selectionSet.fragments.isEmpty(), 'Removing @defer currently only work on "expanded" selections (no named fragments)');
551
+ const updated = this.selectionSet.withoutDefer(labelsToRemove);
552
+ return updated == this.selectionSet
553
+ ? this
554
+ : new Operation(this.rootKind, updated, this.variableDefinitions, this.name);
555
+ }
556
+
557
+ /**
558
+ * Returns this operation but modified to "normalize" all the @defer applications.
559
+ *
560
+ * "Normalized" in this context means that all the `@defer` application in the
561
+ * resulting operation will:
562
+ * - have a (unique) label. Which imply that this method generates label for
563
+ * any `@defer` not having a label.
564
+ * - have a non-trivial `if` condition, if any. By non-trivial, we mean that
565
+ * the condition will be a variable and not an hard-coded `true` or `false`.
566
+ * To do this, this method will remove the condition of any `@defer` that
567
+ * has `if: true`, and will completely remove any `@defer` application that
568
+ * has `if: false`.
569
+ */
570
+ withNormalizedDefer(): {
571
+ operation: Operation,
572
+ hasDefers: boolean,
573
+ assignedDeferLabels: Set<string>,
574
+ deferConditions: SetMultiMap<string, string>,
575
+ } {
576
+ // Similar comment than in `withoutDefer`
577
+ assert(!this.selectionSet.fragments || this.selectionSet.fragments.isEmpty(), 'Assigning @defer lables currently only work on "expanded" selections (no named fragments)');
578
+
579
+ const normalizer = new DeferNormalizer();
580
+ const { hasDefers, hasNonLabelledOrConditionalDefers } = normalizer.init(this.selectionSet);
581
+ let updatedOperation: Operation = this;
582
+ if (hasNonLabelledOrConditionalDefers) {
583
+ const updated = this.selectionSet.withNormalizedDefer(normalizer);
584
+ updatedOperation = new Operation(this.rootKind, updated, this.variableDefinitions, this.name);
585
+ }
586
+ return {
587
+ operation: updatedOperation,
588
+ hasDefers,
589
+ assignedDeferLabels: normalizer.assignedLabels,
590
+ deferConditions: normalizer.deferConditions,
591
+ };
592
+ }
593
+
404
594
  toString(expandFragments: boolean = false, prettyPrint: boolean = true): string {
405
595
  return this.selectionSet.toOperationString(this.rootKind, this.variableDefinitions, this.name, expandFragments, prettyPrint);
406
596
  }
@@ -434,6 +624,10 @@ export class NamedFragmentDefinition extends DirectiveTargetElement<NamedFragmen
434
624
  super(schema);
435
625
  }
436
626
 
627
+ withUpdatedSelectionSet(newSelectionSet: SelectionSet): NamedFragmentDefinition {
628
+ return new NamedFragmentDefinition(this.schema(), this.name, this.typeCondition, newSelectionSet);
629
+ }
630
+
437
631
  variables(): Variables {
438
632
  return mergeVariables(this.variablesInAppliedDirectives(), this.selectionSet.usedVariables());
439
633
  }
@@ -460,6 +654,19 @@ export class NamedFragmentDefinition extends DirectiveTargetElement<NamedFragmen
460
654
  };
461
655
  }
462
656
 
657
+ /**
658
+ * Whether this fragment may apply at the provided type, that is if its type condition matches the type
659
+ * or is a supertype of it.
660
+ *
661
+ * @param type - the type at which we're looking at applying the fragment
662
+ */
663
+ canApplyAtType(type: CompositeType): boolean {
664
+ return (
665
+ sameType(this.typeCondition, type)
666
+ || (isAbstractType(this.typeCondition) && !isUnionType(type) && isDirectSubtype(this.typeCondition, type))
667
+ );
668
+ }
669
+
463
670
  toString(indent?: string): string {
464
671
  return (indent ?? '') + `fragment ${this.name} on ${this.typeCondition}${this.appliedDirectivesToString()} ${this.selectionSet.toString(false, true, indent)}`;
465
672
  }
@@ -497,8 +704,8 @@ export class NamedFragments {
497
704
  }
498
705
  }
499
706
 
500
- onType(type: NamedType): NamedFragmentDefinition[] {
501
- return this.fragments.values().filter(f => f.typeCondition.name === type.name);
707
+ maybeApplyingAtType(type: CompositeType): NamedFragmentDefinition[] {
708
+ return this.fragments.values().filter(f => f.canApplyAtType(type));
502
709
  }
503
710
 
504
711
  without(names: string[]): NamedFragments {
@@ -525,6 +732,10 @@ export class NamedFragments {
525
732
  return this.fragments.get(name);
526
733
  }
527
734
 
735
+ has(name: string): boolean {
736
+ return this.fragments.has(name);
737
+ }
738
+
528
739
  definitions(): readonly NamedFragmentDefinition[] {
529
740
  return this.fragments.values();
530
741
  }
@@ -593,6 +804,67 @@ abstract class Freezable<T> {
593
804
  abstract clone(): T;
594
805
  }
595
806
 
807
+ /**
808
+ * Utility class used to handle "normalizing" the @defer in an operation.
809
+ *
810
+ * See `Operation.withNormalizedDefer` for details on what we mean by normalizing in
811
+ * this context.
812
+ */
813
+ class DeferNormalizer {
814
+ private index = 0;
815
+ readonly assignedLabels = new Set<string>();
816
+ readonly deferConditions = new SetMultiMap<string, string>();
817
+ private readonly usedLabels = new Set<string>();
818
+
819
+ /**
820
+ * Initializes the "labeller" with all the labels used in the provided selections set.
821
+ *
822
+ * @return - whether `selectionSet` has any non-labeled @defer.
823
+ */
824
+ init(selectionSet: SelectionSet): { hasDefers: boolean, hasNonLabelledOrConditionalDefers: boolean } {
825
+ let hasNonLabelledOrConditionalDefers = false;
826
+ let hasDefers = false;
827
+ const stack: Selection[] = selectionSet.selections().concat();
828
+ while (stack.length > 0) {
829
+ const selection = stack.pop()!;
830
+ if (selection.kind === 'FragmentSelection') {
831
+ const deferArgs = selection.element().deferDirectiveArgs();
832
+ if (deferArgs) {
833
+ hasDefers = true;
834
+ if (deferArgs.label) {
835
+ this.usedLabels.add(deferArgs.label);
836
+ } else {
837
+ hasNonLabelledOrConditionalDefers = true;
838
+ }
839
+ }
840
+ }
841
+ if (selection.selectionSet) {
842
+ selection.selectionSet.selections().forEach((s) => stack.push(s));
843
+ }
844
+ }
845
+ return { hasDefers, hasNonLabelledOrConditionalDefers };
846
+ }
847
+
848
+ private nextLabel(): string {
849
+ return `qp__${this.index++}`;
850
+ }
851
+
852
+ newLabel(): string {
853
+ let candidate = this.nextLabel();
854
+ // It's unlikely that auto-generated label would conflict an existing one, but
855
+ // not taking any chances.
856
+ while (this.usedLabels.has(candidate)) {
857
+ candidate = this.nextLabel();
858
+ }
859
+ this.assignedLabels.add(candidate);
860
+ return candidate;
861
+ }
862
+
863
+ registerCondition(label: string, condition: Variable): void {
864
+ this.deferConditions.add(condition.name, label);
865
+ }
866
+ }
867
+
596
868
  export class SelectionSet extends Freezable<SelectionSet> {
597
869
  // The argument is either the responseName (for fields), or the type name (for fragments), with the empty string being used as a special
598
870
  // case for a fragment with no type condition.
@@ -648,10 +920,6 @@ export class SelectionSet extends Freezable<SelectionSet> {
648
920
  }
649
921
 
650
922
  collectUsedFragmentNames(collector: Map<string, number>) {
651
- if (!this.fragments) {
652
- return;
653
- }
654
-
655
923
  for (const byResponseName of this._selections.values()) {
656
924
  for (const selection of byResponseName) {
657
925
  selection.collectUsedFragmentNames(collector);
@@ -680,10 +948,6 @@ export class SelectionSet extends Freezable<SelectionSet> {
680
948
  }
681
949
 
682
950
  expandFragments(names?: string[], updateSelectionSetFragments: boolean = true): SelectionSet {
683
- if (!this.fragments) {
684
- return this;
685
- }
686
-
687
951
  if (names && names.length === 0) {
688
952
  return this;
689
953
  }
@@ -693,11 +957,60 @@ export class SelectionSet extends Freezable<SelectionSet> {
693
957
  : this.fragments;
694
958
  const withExpanded = new SelectionSet(this.parentType, newFragments);
695
959
  for (const selection of this.selections()) {
696
- withExpanded.add(selection.expandFragments(names, updateSelectionSetFragments));
960
+ const expanded = selection.expandFragments(names, updateSelectionSetFragments);
961
+ if (Array.isArray(expanded)) {
962
+ withExpanded.addAll(expanded);
963
+ } else {
964
+ withExpanded.add(expanded as Selection);
965
+ }
697
966
  }
698
967
  return withExpanded;
699
968
  }
700
969
 
970
+ /**
971
+ * Returns the result of mapping the provided `mapper` to all the selection of this selection set.
972
+ *
973
+ * This method assumes that the `mapper` may often return it's argument directly, meaning that only
974
+ * a small subset of selection actually need any modifications, and will avoid re-creating new
975
+ * objects when that is the case. This does mean that the resulting selection set may be `this`
976
+ * directly, or may alias some of the sub-selection in `this`.
977
+ */
978
+ private lazyMap(mapper: (selection: Selection) => Selection | SelectionSet | undefined): SelectionSet {
979
+ let updatedSelections: Selection[] | undefined = undefined;
980
+ const selections = this.selections();
981
+ for (let i = 0; i < selections.length; i++) {
982
+ const selection = selections[i];
983
+ const updated = mapper(selection);
984
+ if (updated !== selection && !updatedSelections) {
985
+ updatedSelections = [];
986
+ for (let j = 0; j < i; j++) {
987
+ updatedSelections.push(selections[j]);
988
+ }
989
+ }
990
+ if (!!updated && updatedSelections) {
991
+ if (updated instanceof SelectionSet) {
992
+ updated.selections().forEach((s) => updatedSelections!.push(s));
993
+ } else {
994
+ updatedSelections.push(updated);
995
+ }
996
+ }
997
+ }
998
+ if (!updatedSelections) {
999
+ return this;
1000
+ }
1001
+ return new SelectionSet(this.parentType, this.fragments).addAll(updatedSelections)
1002
+ }
1003
+
1004
+ withoutDefer(labelsToRemove?: Set<string>): SelectionSet {
1005
+ assert(!this.fragments, 'Not yet supported');
1006
+ return this.lazyMap((selection) => selection.withoutDefer(labelsToRemove));
1007
+ }
1008
+
1009
+ withNormalizedDefer(normalizer: DeferNormalizer): SelectionSet {
1010
+ assert(!this.fragments, 'Not yet supported');
1011
+ return this.lazyMap((selection) => selection.withNormalizedDefer(normalizer));
1012
+ }
1013
+
701
1014
  /**
702
1015
  * Returns the selection select from filtering out any selection that does not match the provided predicate.
703
1016
  *
@@ -705,14 +1018,12 @@ export class SelectionSet extends Freezable<SelectionSet> {
705
1018
  * call `optimize` on the result if you want to re-apply some fragments.
706
1019
  */
707
1020
  filter(predicate: (selection: Selection) => boolean): SelectionSet {
708
- const filtered = new SelectionSet(this.parentType, this.fragments);
709
- for (const selection of this.selections()) {
710
- const filteredSelection = selection.filter(predicate);
711
- if (filteredSelection) {
712
- filtered.add(filteredSelection);
713
- }
714
- }
715
- return filtered;
1021
+ return this.lazyMap((selection) => selection.filter(predicate));
1022
+ }
1023
+
1024
+ withoutEmptyBranches(): SelectionSet | undefined {
1025
+ const updated = this.filter((selection) => selection.selectionSet?.isEmpty() !== true);
1026
+ return updated.isEmpty() ? undefined : updated;
716
1027
  }
717
1028
 
718
1029
  protected freezeInternals(): void {
@@ -1080,8 +1391,44 @@ export class FieldSelection extends Freezable<FieldSelection> {
1080
1391
  }
1081
1392
  }
1082
1393
 
1083
- optimize(fragments: NamedFragments): FieldSelection {
1394
+ optimize(fragments: NamedFragments): Selection {
1084
1395
  const optimizedSelection = this.selectionSet ? this.selectionSet.optimize(fragments) : undefined;
1396
+ const fieldBaseType = baseType(this.field.definition.type!);
1397
+ if (isCompositeType(fieldBaseType) && optimizedSelection) {
1398
+ for (const candidate of fragments.maybeApplyingAtType(fieldBaseType)) {
1399
+ // TODO: Checking `equals` here is very simple, but somewhat restrictive in theory. That is, if a query
1400
+ // is:
1401
+ // {
1402
+ // t {
1403
+ // a
1404
+ // b
1405
+ // c
1406
+ // }
1407
+ // }
1408
+ // and we have:
1409
+ // fragment X on T {
1410
+ // t {
1411
+ // a
1412
+ // b
1413
+ // }
1414
+ // }
1415
+ // then the current code will not use the fragment because `c` is not in the fragment, but in relatity,
1416
+ // we could use it and make the result be:
1417
+ // {
1418
+ // ...X
1419
+ // t {
1420
+ // c
1421
+ // }
1422
+ // }
1423
+ // To do that, we can change that `equals` to `contains`, but then we should also "extract" the remainder
1424
+ // of `optimizedSelection` that isn't covered by the fragment, and that is the part slighly more involved.
1425
+ if (optimizedSelection.equals(candidate.selectionSet)) {
1426
+ const fragmentSelection = new FragmentSpreadSelection(fieldBaseType, fragments, candidate.name);
1427
+ return new FieldSelection(this.field, selectionSetOf(fieldBaseType, fragmentSelection));
1428
+ }
1429
+ }
1430
+ }
1431
+
1085
1432
  return this.selectionSet === optimizedSelection
1086
1433
  ? this
1087
1434
  : new FieldSelection(this.field, optimizedSelection);
@@ -1094,7 +1441,11 @@ export class FieldSelection extends Freezable<FieldSelection> {
1094
1441
  if (!this.selectionSet) {
1095
1442
  return this;
1096
1443
  }
1097
- return new FieldSelection(this.field, this.selectionSet.filter(predicate));
1444
+
1445
+ const updatedSelectionSet = this.selectionSet.filter(predicate);
1446
+ return this.selectionSet === updatedSelectionSet
1447
+ ? this
1448
+ : new FieldSelection(this.field, updatedSelectionSet);
1098
1449
  }
1099
1450
 
1100
1451
  protected freezeInternals(): void {
@@ -1203,6 +1554,20 @@ export class FieldSelection extends Freezable<FieldSelection> {
1203
1554
  return this.selectionSet?.fragments;
1204
1555
  }
1205
1556
 
1557
+ withoutDefer(labelsToRemove?: Set<string>): FieldSelection {
1558
+ const updatedSubSelections = this.selectionSet?.withoutDefer(labelsToRemove);
1559
+ return updatedSubSelections === this.selectionSet
1560
+ ? this
1561
+ : new FieldSelection(this.field, updatedSubSelections);
1562
+ }
1563
+
1564
+ withNormalizedDefer(normalizer: DeferNormalizer): FieldSelection {
1565
+ const updatedSubSelections = this.selectionSet?.withNormalizedDefer(normalizer);
1566
+ return updatedSubSelections === this.selectionSet
1567
+ ? this
1568
+ : new FieldSelection(this.field, updatedSubSelections);
1569
+ }
1570
+
1206
1571
  clone(): FieldSelection {
1207
1572
  if (!this.selectionSet) {
1208
1573
  return this;
@@ -1230,33 +1595,69 @@ export abstract class FragmentSelection extends Freezable<FragmentSelection> {
1230
1595
 
1231
1596
  abstract optimize(fragments: NamedFragments): FragmentSelection;
1232
1597
 
1233
- abstract expandFragments(names?: string[]): FragmentSelection;
1598
+ abstract expandFragments(names?: string[]): Selection | readonly Selection[];
1234
1599
 
1235
1600
  abstract toSelectionNode(): SelectionNode;
1236
1601
 
1237
1602
  abstract validate(): void;
1238
1603
 
1604
+ abstract withoutDefer(labelsToRemove?: Set<string>): FragmentSelection | SelectionSet;
1605
+
1606
+ abstract withNormalizedDefer(normalizer: DeferNormalizer): FragmentSelection | SelectionSet;
1607
+
1239
1608
  protected us(): FragmentSelection {
1240
1609
  return this;
1241
1610
  }
1242
1611
 
1612
+ protected validateDeferAndStream() {
1613
+ if (this.element().hasDefer() || this.element().hasStream()) {
1614
+ const schemaDef = this.element().schema().schemaDefinition;
1615
+ const parentType = this.element().parentType;
1616
+ validate(
1617
+ schemaDef.rootType('mutation') !== parentType && schemaDef.rootType('subscription') !== parentType,
1618
+ () => `The @defer and @stream directives cannot be used on ${schemaDef.roots().filter((t) => t.type === parentType).pop()?.rootKind} root type "${parentType}"`,
1619
+ );
1620
+ }
1621
+ }
1622
+
1243
1623
  usedVariables(): Variables {
1244
1624
  return mergeVariables(this.element().variables(), this.selectionSet.usedVariables());
1245
1625
  }
1246
1626
 
1247
1627
  updateForAddingTo(selectionSet: SelectionSet): FragmentSelection {
1248
1628
  const updatedFragment = this.element().updateForAddingTo(selectionSet);
1249
- return this.element() === updatedFragment
1250
- ? this.cloneIfFrozen()
1251
- : new InlineFragmentSelection(updatedFragment, this.selectionSet.cloneIfFrozen());
1629
+ if (this.element() === updatedFragment) {
1630
+ return this.cloneIfFrozen();
1631
+ }
1632
+
1633
+ // Like for fields, we create a new selection that not only uses the updated fragment, but also ensures
1634
+ // the underlying selection set uses the updated type as parent type.
1635
+ const updatedCastedType = updatedFragment.castedType();
1636
+ let updatedSelectionSet : SelectionSet | undefined;
1637
+ if (this.selectionSet.parentType !== updatedCastedType) {
1638
+ updatedSelectionSet = new SelectionSet(updatedCastedType);
1639
+ // Note that re-adding every selection ensures that anything frozen will be cloned as needed, on top of handling any knock-down
1640
+ // effect of the type change.
1641
+ for (const selection of this.selectionSet.selections()) {
1642
+ updatedSelectionSet.add(selection);
1643
+ }
1644
+ } else {
1645
+ updatedSelectionSet = this.selectionSet?.cloneIfFrozen();
1646
+ }
1647
+
1648
+ return new InlineFragmentSelection(updatedFragment, updatedSelectionSet);
1252
1649
  }
1253
1650
 
1254
- filter(predicate: (selection: Selection) => boolean): InlineFragmentSelection | undefined {
1651
+ filter(predicate: (selection: Selection) => boolean): FragmentSelection | undefined {
1255
1652
  if (!predicate(this)) {
1256
1653
  return undefined;
1257
1654
  }
1258
1655
  // Note that we essentially expand all fragments as part of this.
1259
- return new InlineFragmentSelection(this.element(), this.selectionSet.filter(predicate));
1656
+ const selectionSet = this.selectionSet;
1657
+ const updatedSelectionSet = selectionSet.filter(predicate);
1658
+ return updatedSelectionSet === selectionSet
1659
+ ? this
1660
+ : new InlineFragmentSelection(this.element(), updatedSelectionSet);
1260
1661
  }
1261
1662
 
1262
1663
  protected freezeInternals() {
@@ -1302,6 +1703,7 @@ class InlineFragmentSelection extends FragmentSelection {
1302
1703
  }
1303
1704
 
1304
1705
  validate() {
1706
+ this.validateDeferAndStream();
1305
1707
  // Note that validation is kind of redundant since `this.selectionSet.validate()` will check that it isn't empty. But doing it
1306
1708
  // allow to provide much better error messages.
1307
1709
  validate(
@@ -1311,7 +1713,6 @@ class InlineFragmentSelection extends FragmentSelection {
1311
1713
  this.selectionSet.validate();
1312
1714
  }
1313
1715
 
1314
-
1315
1716
  get selectionSet(): SelectionSet {
1316
1717
  return this._selectionSet;
1317
1718
  }
@@ -1343,13 +1744,21 @@ class InlineFragmentSelection extends FragmentSelection {
1343
1744
  }
1344
1745
 
1345
1746
  optimize(fragments: NamedFragments): FragmentSelection {
1346
- const optimizedSelection = this.selectionSet.optimize(fragments);
1747
+ let optimizedSelection = this.selectionSet.optimize(fragments);
1347
1748
  const typeCondition = this.element().typeCondition;
1348
1749
  if (typeCondition) {
1349
- for (const candidate of fragments.onType(typeCondition)) {
1350
- if (candidate.selectionSet.equals(optimizedSelection)) {
1351
- fragments.addIfNotExist(candidate);
1352
- return new FragmentSpreadSelection(this.element().parentType, fragments, candidate.name);
1750
+ for (const candidate of fragments.maybeApplyingAtType(typeCondition)) {
1751
+ // See comment in `FieldSelection.optimize` about the `equals`: this fully apply here too.
1752
+ if (optimizedSelection.equals(candidate.selectionSet)) {
1753
+ const spread = new FragmentSpreadSelection(this.element().parentType, fragments, candidate.name);
1754
+ // We use the fragment when the fragments condition is either the same, or a supertype of our current condition.
1755
+ // If it's the same type, then we don't really want to preserve the current condition, it is included in the
1756
+ // spread and we can return it directive. But if the fragment condition is a superset, then we should preserve
1757
+ // our current condition since it restricts the selection more than the fragment actual does.
1758
+ if (sameType(typeCondition, candidate.typeCondition)) {
1759
+ return spread;
1760
+ }
1761
+ optimizedSelection = selectionSetOf(spread.element().parentType, spread);
1353
1762
  }
1354
1763
  }
1355
1764
  }
@@ -1369,6 +1778,32 @@ class InlineFragmentSelection extends FragmentSelection {
1369
1778
  this.selectionSet.collectUsedFragmentNames(collector);
1370
1779
  }
1371
1780
 
1781
+ withoutDefer(labelsToRemove?: Set<string>): FragmentSelection | SelectionSet {
1782
+ const updatedSubSelections = this.selectionSet.withoutDefer(labelsToRemove);
1783
+ const deferArgs = this.fragmentElement.deferDirectiveArgs();
1784
+ const hasDeferToRemove = deferArgs && (!labelsToRemove || (deferArgs.label && labelsToRemove.has(deferArgs.label)));
1785
+ if (updatedSubSelections === this.selectionSet && !hasDeferToRemove) {
1786
+ return this;
1787
+ }
1788
+
1789
+ const newFragment = hasDeferToRemove ? this.fragmentElement.withoutDefer() : this.fragmentElement;
1790
+ if (!newFragment) {
1791
+ return updatedSubSelections;
1792
+ }
1793
+ return new InlineFragmentSelection(newFragment, updatedSubSelections);
1794
+ }
1795
+
1796
+ withNormalizedDefer(normalizer: DeferNormalizer): InlineFragmentSelection | SelectionSet {
1797
+ const newFragment = this.fragmentElement.withNormalizedDefer(normalizer);
1798
+ const updatedSubSelections = this.selectionSet.withNormalizedDefer(normalizer);
1799
+ if (!newFragment) {
1800
+ return updatedSubSelections;
1801
+ }
1802
+ return newFragment === this.fragmentElement && updatedSubSelections === this.selectionSet
1803
+ ? this
1804
+ : new InlineFragmentSelection(newFragment, updatedSubSelections);
1805
+ }
1806
+
1372
1807
  toString(expandFragments: boolean = true, indent?: string): string {
1373
1808
  return (indent ?? '') + this.fragmentElement + ' ' + this.selectionSet.toString(expandFragments, true, indent);
1374
1809
  }
@@ -1412,7 +1847,9 @@ class FragmentSpreadSelection extends FragmentSelection {
1412
1847
  }
1413
1848
 
1414
1849
  validate(): void {
1415
- // We don't do anything because fragment definition are validated when created.
1850
+ this.validateDeferAndStream();
1851
+
1852
+ // We don't do anything else because fragment definition are validated when created.
1416
1853
  }
1417
1854
 
1418
1855
  toSelectionNode(): FragmentSpreadNode {
@@ -1440,11 +1877,15 @@ class FragmentSpreadSelection extends FragmentSelection {
1440
1877
  return this;
1441
1878
  }
1442
1879
 
1443
- expandFragments(names?: string[], updateSelectionSetFragments: boolean = true): FragmentSelection {
1880
+ expandFragments(names?: string[], updateSelectionSetFragments: boolean = true): FragmentSelection | readonly Selection[] {
1444
1881
  if (names && !names.includes(this.namedFragment.name)) {
1445
1882
  return this;
1446
1883
  }
1447
- return new InlineFragmentSelection(this._element, this.selectionSet.expandFragments(names, updateSelectionSetFragments));
1884
+
1885
+ const expandedSubSelections = this.selectionSet.expandFragments(names, updateSelectionSetFragments);
1886
+ return sameType(this._element.parentType, this.namedFragment.typeCondition)
1887
+ ? expandedSubSelections.selections()
1888
+ : new InlineFragmentSelection(this._element, expandedSubSelections);
1448
1889
  }
1449
1890
 
1450
1891
  collectUsedFragmentNames(collector: Map<string, number>): void {
@@ -1453,6 +1894,14 @@ class FragmentSpreadSelection extends FragmentSelection {
1453
1894
  collector.set(this.namedFragment.name, usageCount === undefined ? 1 : usageCount + 1);
1454
1895
  }
1455
1896
 
1897
+ withoutDefer(_labelsToRemove?: Set<string>): FragmentSelection {
1898
+ assert(false, 'Unsupported, see `Operation.withoutDefer`');
1899
+ }
1900
+
1901
+ withNormalizedDefer(_normalizezr: DeferNormalizer): FragmentSelection {
1902
+ assert(false, 'Unsupported, see `Operation.withAllDeferLabelled`');
1903
+ }
1904
+
1456
1905
  private spreadDirectives(): Directive<FragmentElement>[] {
1457
1906
  return this._element.appliedDirectives.slice(this.namedFragment.appliedDirectives.length);
1458
1907
  }