diff --git a/.changeset/strong-pots-kneel.md b/.changeset/strong-pots-kneel.md new file mode 100644 index 000000000..f0184f387 --- /dev/null +++ b/.changeset/strong-pots-kneel.md @@ -0,0 +1,6 @@ +--- + +--- + + + \ No newline at end of file diff --git a/internals-js/src/__tests__/operations.test.ts b/internals-js/src/__tests__/operations.test.ts index ead1a83bd..e512a56f4 100644 --- a/internals-js/src/__tests__/operations.test.ts +++ b/internals-js/src/__tests__/operations.test.ts @@ -5,7 +5,7 @@ import { SchemaRootKind, } from '../../dist/definitions'; import { buildSchema } from '../../dist/buildSchema'; -import { MutableSelectionSet, NamedFragmentDefinition, Operation, operationFromDocument, parseOperation, SelectionSetAtType } from '../../dist/operations'; +import { FragmentRestrictionAtType, MutableSelectionSet, NamedFragmentDefinition, Operation, operationFromDocument, parseOperation } from '../../dist/operations'; import './matchers'; import { DocumentNode, FieldNode, GraphQLError, Kind, OperationDefinitionNode, OperationTypeNode, parse, SelectionNode, SelectionSetNode, validate } from 'graphql'; import { assert } from '../utils'; @@ -903,6 +903,51 @@ describe('fragments optimization', () => { }); }); + test('handles fragments on union in context with limited intersection', () => { + const schema = parseSchema(` + type Query { + t1: T1 + } + + union U = T1 | T2 + + type T1 { + x: Int + } + + type T2 { + y: Int + } + `); + + testFragmentsRoundtrip({ + schema, + query: ` + fragment OnU on U { + ... on T1 { + x + } + ... on T2 { + y + } + } + + { + t1 { + ...OnU + } + } + `, + expanded: ` + { + t1 { + x + } + } + `, + }); + }); + describe('applied directives', () => { test('reuse fragments with directives on the fragment, but only when there is those directives', () => { const schema = parseSchema(` @@ -1400,6 +1445,373 @@ describe('fragments optimization', () => { } `); }); + + test('due to conflict between selection and reused fragment at different levels', () => { + const schema = parseSchema(` + type Query { + t1: SomeV + t2: SomeV + } + + union SomeV = V1 | V2 | V3 + + type V1 { + x: String + } + + type V2 { + y: String! + } + + type V3 { + x: Int + } + `); + const gqlSchema = schema.toGraphQLJSSchema(); + + const operation = parseOperation(schema, ` + fragment onV1V2 on SomeV { + ... on V1 { + x + } + ... on V2 { + y + } + } + + query { + t1 { + ...onV1V2 + } + t2 { + ... on V2 { + y + } + ... on V3 { + x + } + } + } + `); + expect(validate(gqlSchema, parse(operation.toString()))).toStrictEqual([]); + + const withoutFragments = operation.expandAllFragments(); + expect(withoutFragments.toString()).toMatchString(` + { + t1 { + ... on V1 { + x + } + ... on V2 { + y + } + } + t2 { + ... on V2 { + y + } + ... on V3 { + x + } + } + } + `); + + const optimized = withoutFragments.optimize(operation.fragments!, 1); + expect(validate(gqlSchema, parse(optimized.toString()))).toStrictEqual([]); + + expect(optimized.toString()).toMatchString(` + fragment onV1V2 on SomeV { + ... on V1 { + x + } + ... on V2 { + y + } + } + + { + t1 { + ...onV1V2 + } + t2 { + ... on V2 { + y + } + ... on V3 { + x + } + } + } + `); + }); + + test('due to conflict between the trimmed parts of 2 fragments at different levels', () => { + const schema = parseSchema(` + type Query { + t1: SomeV + t2: SomeV + t3: OtherV + } + + union SomeV = V1 | V2 | V3 + union OtherV = V3 + + type V1 { + x: String + } + + type V2 { + x: Int + } + + type V3 { + y: String! + z: String! + } + `); + const gqlSchema = schema.toGraphQLJSSchema(); + + const operation = parseOperation(schema, ` + fragment onV1V3 on SomeV { + ... on V1 { + x + } + ... on V3 { + y + } + } + + fragment onV2V3 on SomeV { + ... on V2 { + x + } + ... on V3 { + z + } + } + + query { + t1 { + ...onV1V3 + } + t2 { + ...onV2V3 + } + t3 { + ... on V3 { + y + z + } + } + } + `); + expect(validate(gqlSchema, parse(operation.toString()))).toStrictEqual([]); + + const withoutFragments = operation.expandAllFragments(); + expect(withoutFragments.toString()).toMatchString(` + { + t1 { + ... on V1 { + x + } + ... on V3 { + y + } + } + t2 { + ... on V2 { + x + } + ... on V3 { + z + } + } + t3 { + ... on V3 { + y + z + } + } + } + `); + + const optimized = withoutFragments.optimize(operation.fragments!, 1); + expect(validate(gqlSchema, parse(optimized.toString()))).toStrictEqual([]); + + expect(optimized.toString()).toMatchString(` + fragment onV1V3 on SomeV { + ... on V1 { + x + } + ... on V3 { + y + } + } + + fragment onV2V3 on SomeV { + ... on V2 { + x + } + ... on V3 { + z + } + } + + { + t1 { + ...onV1V3 + } + t2 { + ...onV2V3 + } + t3 { + ...onV1V3 + ... on V3 { + z + } + } + } + `); + }); + + test('due to conflict between 2 sibling branches', () => { + const schema = parseSchema(` + type Query { + t1: SomeV + i: I + } + + interface I { + id: ID! + } + + type T1 implements I { + id: ID! + t2: SomeV + } + + type T2 implements I { + id: ID! + t2: SomeV + } + + union SomeV = V1 | V2 | V3 + + type V1 { + x: String + } + + type V2 { + y: String! + } + + type V3 { + x: Int + } + `); + const gqlSchema = schema.toGraphQLJSSchema(); + + const operation = parseOperation(schema, ` + fragment onV1V2 on SomeV { + ... on V1 { + x + } + ... on V2 { + y + } + } + + query { + t1 { + ...onV1V2 + } + i { + ... on T1 { + t2 { + ... on V2 { + y + } + } + } + ... on T2 { + t2 { + ... on V3 { + x + } + } + } + } + } + `); + expect(validate(gqlSchema, parse(operation.toString()))).toStrictEqual([]); + + const withoutFragments = operation.expandAllFragments(); + expect(withoutFragments.toString()).toMatchString(` + { + t1 { + ... on V1 { + x + } + ... on V2 { + y + } + } + i { + ... on T1 { + t2 { + ... on V2 { + y + } + } + } + ... on T2 { + t2 { + ... on V3 { + x + } + } + } + } + } + `); + + const optimized = withoutFragments.optimize(operation.fragments!, 1); + expect(validate(gqlSchema, parse(optimized.toString()))).toStrictEqual([]); + + expect(optimized.toString()).toMatchString(` + fragment onV1V2 on SomeV { + ... on V1 { + x + } + ... on V2 { + y + } + } + + { + t1 { + ...onV1V2 + } + i { + ... on T1 { + t2 { + ... on V2 { + y + } + } + } + ... on T2 { + t2 { + ... on V3 { + x + } + } + } + } + } + `); + }); }); test('does not leave unused fragments', () => { @@ -1856,7 +2268,7 @@ describe('unsatisfiable branches removal', () => { }); describe('named fragment selection set restrictions at type', () => { - const expandAtType = (frag: NamedFragmentDefinition, schema: Schema, typeName: string): SelectionSetAtType => { + const expandAtType = (frag: NamedFragmentDefinition, schema: Schema, typeName: string): FragmentRestrictionAtType => { const type = schema.type(typeName); assert(type && isCompositeType(type), `Invalid type ${typeName}`) // `expandedSelectionSetAtType` assumes it's argument passes `canApplyAtType`, so let's make sure we're @@ -1922,18 +2334,26 @@ describe('named fragment selection set restrictions at type', () => { const frag = operation.fragments?.get('FonI1')!; - let { selectionSet, trimmed } = expandAtType(frag, schema, 'I1'); + let { selectionSet, validator } = expandAtType(frag, schema, 'I1'); expect(selectionSet.toString()).toBe('{ x ... on T1 { x } ... on T2 { x } ... on I2 { x } ... on I3 { x } }'); - expect(trimmed?.toString()).toBeUndefined(); + expect(validator?.toString()).toBeUndefined(); - ({ selectionSet, trimmed } = expandAtType(frag, schema, 'T1')); + ({ selectionSet, validator } = expandAtType(frag, schema, 'T1')); expect(selectionSet.toString()).toBe('{ x }'); - // Note: one could remark that having `... on T1 { x }` in `trimmed` below is a tad weird: this is - // because in this case `normalized` removed that fragment and so when we do `normalized.mins(original)`, - // then it shows up. It a tad difficult to avoid this however and it's ok for what we do (`trimmed` - // is used to check for field conflict and and the only reason we use `trimmed` is to make things faster - // but we could use the `original` instead). - expect(trimmed?.toString()).toBe('{ ... on T1 { x } ... on T2 { x } ... on I2 { x } ... on I3 { x } }'); + // Note: one could remark that having `T1.x` in the `validator` below is a tad weird: this is + // because in this case `normalized` removed that fragment and so when we do `normalized.minus(original)`, + // then it shows up. It a tad difficult to avoid this however and it's ok for what we do (`validator` + // is used to check for field conflict and save for efficiency, we could use the `original` instead). + expect(validator?.toString()).toMatchString(` + { + x: [ + T1.x + T2.x + I2.x + I3.x + ] + } + `); }); test('for fragment on unions', () => { @@ -1990,21 +2410,54 @@ describe('named fragment selection set restrictions at type', () => { // Note that with unions, the fragments inside the unions can be "lifted" and so they everyting normalize to just the // possible runtimes. - let { selectionSet, trimmed } = expandAtType(frag, schema, 'U1'); + let { selectionSet, validator } = expandAtType(frag, schema, 'U1'); expect(selectionSet.toString()).toBe('{ ... on T1 { x y } ... on T2 { z w } }'); - expect(trimmed?.toString()).toBeUndefined(); + expect(validator?.toString()).toBeUndefined(); - ({ selectionSet, trimmed } = expandAtType(frag, schema, 'U2')); + ({ selectionSet, validator } = expandAtType(frag, schema, 'U2')); expect(selectionSet.toString()).toBe('{ ... on T1 { x y } }'); - expect(trimmed?.toString()).toBe('{ ... on T2 { z w } }'); + expect(validator?.toString()).toMatchString(` + { + z: [ + T2.z + ] + w: [ + T2.w + ] + } + `); - ({ selectionSet, trimmed } = expandAtType(frag, schema, 'U3')); + ({ selectionSet, validator } = expandAtType(frag, schema, 'U3')); expect(selectionSet.toString()).toBe('{ ... on T2 { z w } }'); - expect(trimmed?.toString()).toBe('{ ... on T1 { x y } }'); + expect(validator?.toString()).toMatchString(` + { + x: [ + T1.x + ] + y: [ + T1.y + ] + } + `); - ({ selectionSet, trimmed } = expandAtType(frag, schema, 'T1')); + ({ selectionSet, validator } = expandAtType(frag, schema, 'T1')); expect(selectionSet.toString()).toBe('{ x y }'); // Similar remarks that on interfaces - expect(trimmed?.toString()).toBe('{ ... on T1 { x y } ... on T2 { z w } }'); + expect(validator?.toString()).toMatchString(` + { + x: [ + T1.x + ] + y: [ + T1.y + ] + z: [ + T2.z + ] + w: [ + T2.w + ] + } + `); }); }); diff --git a/internals-js/src/operations.ts b/internals-js/src/operations.ts index c79e0a77a..57cff3199 100644 --- a/internals-js/src/operations.ts +++ b/internals-js/src/operations.ts @@ -1015,7 +1015,7 @@ export class Operation { } } -export type SelectionSetAtType = { selectionSet: SelectionSet, trimmed?: SelectionSet }; +export type FragmentRestrictionAtType = { selectionSet: SelectionSet, validator?: FieldsConflictValidator }; export class NamedFragmentDefinition extends DirectiveTargetElement { private _selectionSet: SelectionSet | undefined; @@ -1026,7 +1026,7 @@ export class NamedFragmentDefinition extends DirectiveTargetElement | undefined; private _includedFragmentNames: Set | undefined; - private readonly expandedSelectionSetsAtTypesCache = new Map(); + private readonly expandedSelectionSetsAtTypesCache = new Map(); constructor( schema: Schema, @@ -1098,27 +1098,26 @@ export class NamedFragmentDefinition extends DirectiveTargetElement; private readonly _selections: readonly Selection[]; @@ -1497,7 +1498,7 @@ export class SelectionSet { return this._keyedSelections.has(typenameFieldName); } - fieldsInSet(): { path: string[], field: FieldSelection }[] { + fieldsInSet(): CollectedFieldsInSet { const fields = new Array<{ path: string[], field: FieldSelection }>(); for (const selection of this.selections()) { if (selection.kind === 'FieldSelection') { @@ -1568,7 +1569,8 @@ export class SelectionSet { // With that, `optimizeSelections` will correctly match on the `on Query` fragment; after which // we can unpack the final result. const wrapped = new InlineFragmentSelection(new FragmentElement(this.parentType, this.parentType), this); - const optimized = wrapped.optimize(fragments); + const validator = FieldsConflictMultiBranchValidator.ofInitial(FieldsConflictValidator.build(this)); + const optimized = wrapped.optimize(fragments, validator); // Now, it's possible we matched a full fragment, in which case `optimized` will be just the named fragment, // and in that case we return a singleton selection with just that. Otherwise, it's our wrapping inline fragment @@ -1581,8 +1583,8 @@ export class SelectionSet { // Tries to match fragments inside each selections of this selection set, and this recursively. However, note that this // may not match fragments that would apply at top-level, so you should usually use `optimize` instead (this exists mostly // for the recursion). - optimizeSelections(fragments: NamedFragments): SelectionSet { - return this.lazyMap((selection) => selection.optimize(fragments)); + optimizeSelections(fragments: NamedFragments, validator: FieldsConflictMultiBranchValidator): SelectionSet { + return this.lazyMap((selection) => selection.optimize(fragments, validator)); } expandFragments(updatedFragments?: NamedFragments): SelectionSet { @@ -1654,10 +1656,12 @@ export class SelectionSet { * } * ``` * - * For this operation to be valid (to not throw), `parentType` must be such this selection set would - * be valid as a subselection of an inline fragment `... on parentType { }` (and - * so `this.normalize(this.parentType)` is always valid and useful, but it is possible to pass a `parentType` - * that is more "restrictive" than the selection current parent type). + * For this operation to be valid (to not throw), `parentType` must be such that every field selection in + * this selection set is such that the field parent type intersects `parentType` (there is no limitation + * on the fragment selections, though any fragment selections whose condition do not intersects `parentType` + * will be discarded). Note that `this.normalize(this.parentType)` is always valid and useful, but it is + * also possible to pass a `parentType` that is more "restrictive" than the selection current parent type + * (as long as the top-level fields of this selection set can be rebased on that type). * * Passing the option `recursive == false` makes the normalization only apply at the top-level, removing * any unecessary top-level inline fragments, possibly multiple layers of them, but we never recurse @@ -2324,7 +2328,7 @@ abstract class AbstractSelection boolean, }): SelectionSet | NamedFragmentDefinition { // We limit to fragments whose selection could be applied "directly" at `parentType`, meaning without taking the fragment condition @@ -2429,12 +2435,15 @@ abstract class AbstractSelection selectionSetsDoMerge(t, trimmed)))) { - continue; - } - addedTrimmedParts.push(trimmed); + if (!validator.checkCanReuseFragmentAndTrackIt(atType)) { + continue; } + const notCovered = subSelection.minus(atType.selectionSet); notCoveredByFragments = notCoveredByFragments.intersectionWith(notCovered); optimized.add(new FragmentSpreadSelection(parentType, fragments, fragment, [])); } @@ -2573,74 +2559,199 @@ abstract class AbstractSelection vs.forField(field)); + // As this is called on (non-leaf) field from the same query on which we have build the initial validators, we + // should find at least one validator. + assert(forAllBranches.length > 0, `Shoud have found at least one validator for ${field}`); + return new FieldsConflictMultiBranchValidator(forAllBranches); + } + + // At this point, we known that the fragment, restricted to the current parent type, matches a subset of the + // sub-selection. However, there is still one case we we cannot use it that we need to check, and this is + // if using the fragment would create a field "conflict" (in the sense of the graphQL spec + // [`FieldsInSetCanMerge`](https://spec.graphql.org/draft/#FieldsInSetCanMerge())) and thus create an + // invalid selection. To be clear, `atType.selectionSet` cannot create a conflict, since it is a subset + // of `subSelection` and `subSelection` is valid. *But* there may be some part of the fragment that + // is not `atType.selectionSet` due to being "dead branches" for type `parentType`. And while those + // branches _are_ "dead" as far as execution goes, the `FieldsInSetCanMerge` validation does not take + // this into account (it's 1st step says "including visiting fragments and inline fragments" but has + // no logic regarding ignoring any fragment that may not apply due to the intersection of runtimes + // between multiple fragment being empty). + checkCanReuseFragmentAndTrackIt(fragment: FragmentRestrictionAtType): boolean { + // No validator means that everything in the fragment selection was part of the selection we're optimizing + // away (by using the fragment), and we know the original selection was ok, so nothing to check. + const validator = fragment.validator; + if (!validator) { + return true; } - // We're basically checking [FieldInSetCanMerge](https://spec.graphql.org/draft/#FieldsInSetCanMerge()), - // but were we know the selections in `selection1` (resp. in `selection2`) do merge together, so - // we only check for non-merging between a selection of `selection1` and one of `selection2`. - for (const selection1 of selections1) { - for (const selection2 of selections2) { - if (!fieldsCanMerge(selection1, selection2)) { - return false; - } + if (!this.validators.every((v) => v.doMergeWith(validator))) { + return false; + } + + // We need to make sure the trimmed parts of `fragment` merges with the rest of the selection, + // but also that it merge with any of the trimmed parts of any fragment we have added already. + // Note: this last condition means that if 2 fragment conflict on their "trimmed" parts, + // then the choice of which is used can be based on the fragment ordering and selection order, + // which may not be optimal. This feels niche enough that we keep it simple for now, but we + // can revisit this decision if we run into real cases that justify it (but making it optimal + // would be a involved in general, as in theory you could have complex dependencies of fragments + // that conflict, even cycles, and you need to take the size of fragments into account to know + // what's best; and even then, this could even depend on overall usage, as it can be better to + // reuse a fragment that is used in other places, than to use one for which it's the only usage. + // Adding to all that the fact that conflict can happen in sibling branches). + if (this.usedSpreadTrimmedPartAtLevel) { + if (!this.usedSpreadTrimmedPartAtLevel.every((t) => validator.doMergeWith(t))) { + return false; } + } else { + this.usedSpreadTrimmedPartAtLevel = []; } + + // We're good, but track the fragment + this.usedSpreadTrimmedPartAtLevel.push(validator); + return true; } - return true; } -function fieldsCanMerge(selection1: FieldSelection, selection2: FieldSelection): boolean { - const f1 = selection1.element; - const f2 = selection2.element; - // The `SameResponseShape` test that all fields must pass. - if (!typesCanBeMerged(f1.definition.type!, f2.definition.type!)) { - return false; +class FieldsConflictValidator { + private constructor( + private readonly byResponseName: Map>, + ) { } - // Additional checks of `FieldsInSetCanMerge` when same parent type or one isn't object - const p1 = f1.parentType; - const p2 = f2.parentType; - if (sameType(p1, p2) || !isObjectType(p1) || !isObjectType(p2)) { - return f1.name === f2.name - && (f1.args ? !!f2.args && argumentsEquals(f1.args, f2.args) : !f2.args) - && (!selection1.selectionSet || !selection2.selectionSet || selectionSetsDoMerge(selection1.selectionSet, selection2.selectionSet)); - } else if (selection1.selectionSet && selection2.selectionSet) { - return sameResponseShape(selection1.selectionSet, selection2.selectionSet); - } else { - return true; + static build(s: SelectionSet): FieldsConflictValidator { + return FieldsConflictValidator.forLevel(s.fieldsInSet()); } -} -function sameResponseShape(s1: SelectionSet, s2: SelectionSet): boolean { - const byResponseName1 = s1.fieldsByResponseName(); - const byResponseName2 = s2.fieldsByResponseName(); + private static forLevel(level: CollectedFieldsInSet): FieldsConflictValidator { + const atLevel = new Map>(); - for (const [responseName, selections1] of byResponseName1.entries()) { - const selections2 = byResponseName2.get(responseName); - if (!selections2) { - // No possible conflict on this response name to check. - continue; + for (const { field } of level) { + const responseName = field.element.responseName(); + let atResponseName = atLevel.get(responseName); + if (!atResponseName) { + atResponseName = new Map(); + atLevel.set(responseName, atResponseName); + } + if (field.selectionSet) { + // It's unlikely that we've seen the same `field.element` as we don't particularly "intern" `Field` object (so even if the exact same field + // is used in 2 parts of a selection set, it will probably be a different `Field` object), so the `get` below will probably mostly return `undefined`, + // but it wouldn't be incorrect to re-use a `Field` object multiple side, so no reason not to handle that correctly. + let forField = atResponseName.get(field.element) ?? []; + atResponseName.set(field.element, forField.concat(field.selectionSet.fieldsInSet())); + } else { + // Note that whether a `FieldSelection` has `selectionSet` or not is entirely determined by whether the field type is a composite type + // or not, so even if we've seen a previous version of `field.element` before, we know it's guarantee to have had no `selectionSet`. + // So the `set` below may overwrite a previous entry, but it would be a `null` so no harm done. + atResponseName.set(field.element, null); + } } - for (const selection1 of selections1) { - for (const selection2 of selections2) { - if (!typesCanBeMerged(selection1.element.definition.type!, selection2.element.definition.type!) - || (selection1.selectionSet && selection2.selectionSet && !sameResponseShape(selection1.selectionSet, selection2.selectionSet))) { - return false; + const byResponseName = new Map>(); + for (const [name, level] of atLevel.entries()) { + const atResponseName = new Map(); + for (const [field, collectedFields] of level) { + const validator = collectedFields ? FieldsConflictValidator.forLevel(collectedFields) : null; + atResponseName.set(field, validator); + } + byResponseName.set(name, atResponseName); + } + return new FieldsConflictValidator(byResponseName); + } + + forField(field: Field): FieldsConflictValidator[] { + const byResponseName = this.byResponseName.get(field.responseName()); + if (!byResponseName) { + return []; + } + return mapValues(byResponseName).filter((v): v is FieldsConflictValidator => !!v); + } + + doMergeWith(that: FieldsConflictValidator): boolean { + for (const [responseName, thisFields] of this.byResponseName.entries()) { + const thatFields = that.byResponseName.get(responseName); + if (!thatFields) { + continue; + } + + // We're basically checking [FieldInSetCanMerge](https://spec.graphql.org/draft/#FieldsInSetCanMerge()), + // but from 2 set of fields (`thisFields` and `thatFields`) of the same response that we know individually + // merge already. + for (const [thisField, thisValidator] of thisFields.entries()) { + for (const [thatField, thatValidator] of thatFields.entries()) { + // The `SameResponseShape` test that all fields must pass. + if (!typesCanBeMerged(thisField.definition.type!, thatField.definition.type!)) { + return false; + } + + const p1 = thisField.parentType; + const p2 = thatField.parentType; + if (sameType(p1, p2) || !isObjectType(p1) || !isObjectType(p2)) { + // Additional checks of `FieldsInSetCanMerge` when same parent type or one isn't object + if (thisField.name !== thatField.name + || !argumentsEquals(thisField.args ?? {}, thatField.args ?? {}) + || (thisValidator && thatValidator && !thisValidator.doMergeWith(thatValidator)) + ) { + return false; + } + } else { + // Otherwise, the sub-selection must pass [SameResponseShape](https://spec.graphql.org/draft/#SameResponseShape()). + if (thisValidator && thatValidator && !thisValidator.hasSameResponseShapeThan(thatValidator)) { + return false; + } + } } } } + return true; + } + + hasSameResponseShapeThan(that: FieldsConflictValidator): boolean { + for (const [responseName, thisFields] of this.byResponseName.entries()) { + const thatFields = that.byResponseName.get(responseName); + if (!thatFields) { + continue; + } + + for (const [thisField, thisValidator] of thisFields.entries()) { + for (const [thatField, thatValidator] of thatFields.entries()) { + if (!typesCanBeMerged(thisField.definition.type!, thatField.definition.type!) + || (thisValidator && thatValidator && !thisValidator.hasSameResponseShapeThan(thatValidator))) { + return false; + } + } + } + } + return true; + } + + toString(indent: string = ''): string { + // For debugging/testing ... + return '{\n' + + [...this.byResponseName.entries()].map(([name, byFields]) => { + const innerIndent = indent + ' '; + return `${innerIndent}${name}: [\n` + + [...byFields.entries()] + .map(([field, next]) => `${innerIndent} ${field.parentType}.${field}${next ? next.toString(innerIndent + ' '): ''}`) + .join('\n') + + `\n${innerIndent}]`; + }).join('\n') + + `\n${indent}}` } - return true; } export class FieldSelection extends AbstractSelection, undefined, FieldSelection> { @@ -2676,33 +2787,34 @@ export class FieldSelection extends AbstractSelection, undefined, Fie return this.element.key(); } - optimize(fragments: NamedFragments): Selection { + optimize(fragments: NamedFragments, validator: FieldsConflictMultiBranchValidator): Selection { const fieldBaseType = baseType(this.element.definition.type!); if (!isCompositeType(fieldBaseType) || !this.selectionSet) { return this; } + const fieldValidator = validator.forField(this.element); + // First, see if we can reuse fragments for the selection of this field. - let optimizedSelection = this.selectionSet; - if (isCompositeType(fieldBaseType) && this.selectionSet) { - const optimized = this.tryOptimizeSubselectionWithFragments({ - parentType: fieldBaseType, - subSelection: this.selectionSet, - fragments, - // We can never apply a fragments that has directives on it at the field level. - canUseFullMatchingFragment: (fragment) => fragment.appliedDirectives.length === 0, - }); + const optimized = this.tryOptimizeSubselectionWithFragments({ + parentType: fieldBaseType, + subSelection: this.selectionSet, + fragments, + validator: fieldValidator, + // We can never apply a fragments that has directives on it at the field level. + canUseFullMatchingFragment: (fragment) => fragment.appliedDirectives.length === 0, + }); - if (optimized instanceof NamedFragmentDefinition) { - optimizedSelection = selectionSetOf(fieldBaseType, new FragmentSpreadSelection(fieldBaseType, fragments, optimized, [])); - } else { - optimizedSelection = optimized; - } + let optimizedSelection; + if (optimized instanceof NamedFragmentDefinition) { + optimizedSelection = selectionSetOf(fieldBaseType, new FragmentSpreadSelection(fieldBaseType, fragments, optimized, [])); + } else { + optimizedSelection = optimized; } // Then, recurse inside the field sub-selection (note that if we matched some fragments above, // this recursion will "ignore" those as `FragmentSpreadSelection.optimize()` is a no-op). - optimizedSelection = optimizedSelection.optimizeSelections(fragments); + optimizedSelection = optimizedSelection.optimizeSelections(fragments, fieldValidator); return this.selectionSet === optimizedSelection ? this @@ -3002,7 +3114,7 @@ class InlineFragmentSelection extends FragmentSelection { }; } - optimize(fragments: NamedFragments): FragmentSelection { + optimize(fragments: NamedFragments, validator: FieldsConflictMultiBranchValidator): FragmentSelection { let optimizedSelection = this.selectionSet; // First, see if we can reuse fragments for the selection of this field. @@ -3012,6 +3124,7 @@ class InlineFragmentSelection extends FragmentSelection { parentType: typeCondition, subSelection: optimizedSelection, fragments, + validator, canUseFullMatchingFragment: (fragment) => { // To be able to use a matching fragment, it needs to have either no directives, or if it has // some, then: @@ -3052,7 +3165,7 @@ class InlineFragmentSelection extends FragmentSelection { // Then, recurse inside the field sub-selection (note that if we matched some fragments above, // this recursion will "ignore" those as `FragmentSpreadSelection.optimize()` is a no-op). - optimizedSelection = optimizedSelection.optimizeSelections(fragments); + optimizedSelection = optimizedSelection.optimizeSelections(fragments, validator); return this.selectionSet === optimizedSelection ? this @@ -3274,7 +3387,7 @@ class FragmentSpreadSelection extends FragmentSelection { }; } - optimize(_: NamedFragments): FragmentSelection { + optimize(_1: NamedFragments, _2: FieldsConflictMultiBranchValidator): FragmentSelection { return this; }