From 49e5644d500d55c12b029a36943b5c707b03d2ae Mon Sep 17 00:00:00 2001 From: Greg Wardwell Date: Fri, 26 Apr 2024 12:03:28 -0400 Subject: [PATCH 1/2] feat: ensure transient key dependencies are applied --- src/schema/FroidSchema.ts | 39 +--- src/schema/ObjectType.ts | 71 ++++++- src/schema/__tests__/FroidSchema.test.ts | 243 ++++++++++++++++++++++- 3 files changed, 311 insertions(+), 42 deletions(-) diff --git a/src/schema/FroidSchema.ts b/src/schema/FroidSchema.ts index 12693d1..13a788f 100644 --- a/src/schema/FroidSchema.ts +++ b/src/schema/FroidSchema.ts @@ -12,7 +12,6 @@ import { ObjectTypeDefinitionNode, ScalarTypeDefinitionNode, SchemaExtensionNode, - StringValueNode, parse, print, specifiedScalarTypes, @@ -346,6 +345,7 @@ export class FroidSchema { } existingNode.addExternallySelectedFields(keyField.selections); + existingNode.addExternalKeySelections(keyField.selections); this.generateFroidDependency(keyField.selections, existingNode.allFields); }); @@ -365,43 +365,6 @@ export class FroidSchema { ) as ObjectTypeNode[]; } - /** - * Get contract @tag directives for an ID field. Returns all occurrences of unique @tag - * directives used across all fields included in the node's @key directive - * - * @param {ObjectTypeDefinitionNode} node - The node to process `@key` directives for - * @returns {ConstDirectiveNode[]} A list of `@tag` directives to use for the given `id` field - */ - private getTagDirectivesForIdField( - node: ObjectTypeDefinitionNode - ): ConstDirectiveNode[] { - const tagDirectiveNames = this.extensionAndDefinitionNodes - .filter((obj) => obj.name.value === node.name.value) - .flatMap((obj) => { - const taggableNodes = obj.fields?.flatMap((field) => [ - field, - ...(field?.arguments || []), - ]); - return taggableNodes?.flatMap((field) => - field.directives - ?.filter((directive) => directive.name.value === DirectiveName.Tag) - .map( - (directive) => - (directive?.arguments?.[0].value as StringValueNode).value - ) - ); - }) - .filter(Boolean) - .sort() as string[]; - - const uniqueTagDirectivesNames: string[] = [ - ...new Set(tagDirectiveNames || []), - ]; - return uniqueTagDirectivesNames.map((tagName) => - FroidSchema.createTagDirective(tagName) - ); - } - /** * Creates the Apollo Federation @link directive. * diff --git a/src/schema/ObjectType.ts b/src/schema/ObjectType.ts index 5cd4044..f49c1a2 100644 --- a/src/schema/ObjectType.ts +++ b/src/schema/ObjectType.ts @@ -55,6 +55,11 @@ export class ObjectType { * use in the keys of other object types. */ private _externallySelectedFields: string[] = []; + /** + * Key selections belonging to this object type that were selected + * for use in the keys of other object types. + */ + private _externalKeySelections: KeyField[] = []; /** * The key selected for use in the FROID schema. */ @@ -307,6 +312,15 @@ export class ObjectType { return this._externallySelectedFields; } + /** + * The key selections that are referenced in another entity's key. + * + * @returns {KeyField[]} The list of key field selections + */ + public get externalKeySelections(): KeyField[] { + return this._externalKeySelections; + } + /** * The list of all fields referenced in the node key and by other entities. * @@ -354,6 +368,10 @@ export class ObjectType { * @returns {Key|undefined} The final key or undefined if the node has no key. */ private getFinalKey(depth = 0, ancestors: string[] = []): Key | undefined { + if (!this._selectedKey) { + this.setSelectedKeyFromExternalSelections(); + } + if (!this._selectedKey) { return; } @@ -425,13 +443,46 @@ export class ObjectType { this.directlySelectedFields = this.getDirectlySelectedFields(); } + /** + * Assigns a new selected key based on key selections from + * another entity's key. + * + * This is used to generate a selected key for _value types_. + */ + private setSelectedKeyFromExternalSelections(): void { + if (this.isEntity) { + // This method is only meant to be used when the + // object is a value type. It the object is an entity, + // don't proceed + return; + } + let selectedKey: Key | undefined; + this._externalKeySelections.forEach((selections) => { + if (selectedKey) { + selectedKey.merge(new Key(this.typename, selections.toString())); + } else { + selectedKey = new Key(this.typename, selections.toString()); + } + }); + + if (!selectedKey) { + return; + } + + this.setSelectedKey(selectedKey); + } + /** * Gets the keys used either by default or in other entity keys. * * @returns {Key[]} The used keys */ private getUsedKeys(): Key[] { - if (!this.isEntity || !this._selectedKey) { + if (!this.isEntity) { + this.setSelectedKeyFromExternalSelections(); + } + + if (!this._selectedKey) { return []; } @@ -536,6 +587,17 @@ export class ObjectType { ]; } + /** + * Add key selections found in another entity's key. + * + * @param {KeyField[]} selections - The selections found in another entity's key + */ + public addExternalKeySelections(selections: KeyField[]): void { + this._externalKeySelections = [ + ...new Set([...this._externalKeySelections, ...selections]), + ]; + } + /** * Creates the object's AST. * @@ -544,14 +606,17 @@ export class ObjectType { public toAst(): ObjectTypeDefinitionNode { let froidFields: FieldDefinitionNode[] = []; let froidInterfaces: NamedTypeNode[] = []; + let finalKeyDirective: ConstDirectiveNode | undefined; + const finalKey = this.finalKey; + if (this.isEntity) { froidFields = [ FroidSchema.createIdField(this.getTagDirectivesForIdField()), ]; froidInterfaces = [implementsNodeInterface]; + finalKeyDirective = finalKey?.toDirective(); } - const finalKey = this.finalKey; - const finalKeyDirective = finalKey?.toDirective(); + const fields = [...froidFields, ...this.getFinalFields(finalKey)]; return { ...this.node, diff --git a/src/schema/__tests__/FroidSchema.test.ts b/src/schema/__tests__/FroidSchema.test.ts index f16e241..2c9102b 100644 --- a/src/schema/__tests__/FroidSchema.test.ts +++ b/src/schema/__tests__/FroidSchema.test.ts @@ -549,7 +549,7 @@ describe('FroidSchema class', () => { bookId: String! } - type Magazine implements Node @key(fields: "magazineId publisher { __typename address { __typename country } }") { + type Magazine implements Node @key(fields: "magazineId publisher { __typename address { __typename country postalCode } }") { "The globally unique identifier." id: ID! magazineId: String! @@ -881,6 +881,247 @@ describe('FroidSchema class', () => { ); }); + it('generates a compound key when there is a value type between the dependant entities', () => { + const bookSchema = gql` + type Foo @key(fields: "bar { baz { bazKey } }") { + bar: [Bar!]! + } + + type Bar { + baz: [Baz!]! + } + + type Baz @key(fields: "bazId bazKey") { + bazId: Int! + bazKey: String! + } + `; + + const subgraphs = new Map(); + subgraphs.set('book-subgraph', bookSchema); + + const actual = generateSchema({ + subgraphs, + froidSubgraphName: 'relay-subgraph', + contractTags: ['storefront', 'internal'], + federationVersion: FED2_DEFAULT_VERSION, + }); + + expect(actual).toEqual( + // prettier-ignore + gql` + extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external"]) + + type Bar { + baz: [Baz!]! + } + + type Baz implements Node @key(fields: "bazId bazKey") { + "The globally unique identifier." + id: ID! + bazId: Int! + bazKey: String! + } + + type Foo implements Node @key(fields: "bar { __typename baz { __typename bazId bazKey } }") { + "The globally unique identifier." + id: ID! + bar: [Bar!]! + } + + "The global identification interface implemented by all entities." + interface Node @tag(name: "internal") @tag(name: "storefront") { + "The globally unique identifier." + id: ID! + } + + type Query { + "Fetches an entity by its globally unique identifier." + node( + "A globally unique entity identifier." + id: ID! + ): Node @tag(name: "internal") @tag(name: "storefront") + } + ` + ); + }); + + it('generates a compound key when there are multiple value types between the dependant entities', () => { + const bookSchema = gql` + type Foo @key(fields: "bar1 { bar2 { baz { bazKey } } }") { + bar1: [Bar1!]! + } + + type Bar1 { + bar2: Bar2 + } + + type Bar2 { + baz: [Baz!]! + } + + type Baz @key(fields: "bazId bazKey") { + bazId: Int! + bazKey: String! + } + `; + + const subgraphs = new Map(); + subgraphs.set('book-subgraph', bookSchema); + + const actual = generateSchema({ + subgraphs, + froidSubgraphName: 'relay-subgraph', + contractTags: ['storefront', 'internal'], + federationVersion: FED2_DEFAULT_VERSION, + }); + + expect(actual).toEqual( + // prettier-ignore + gql` + extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external"]) + + type Bar1 { + bar2: Bar2 + } + + type Bar2 { + baz: [Baz!]! + } + + type Baz implements Node @key(fields: "bazId bazKey") { + "The globally unique identifier." + id: ID! + bazId: Int! + bazKey: String! + } + + type Foo implements Node @key(fields: "bar1 { __typename bar2 { __typename baz { __typename bazId bazKey } } }") { + "The globally unique identifier." + id: ID! + bar1: [Bar1!]! + } + + "The global identification interface implemented by all entities." + interface Node @tag(name: "internal") @tag(name: "storefront") { + "The globally unique identifier." + id: ID! + } + + type Query { + "Fetches an entity by its globally unique identifier." + node( + "A globally unique entity identifier." + id: ID! + ): Node @tag(name: "internal") @tag(name: "storefront") + } + ` + ); + }); + + it('generates a compound key when there are multiple value types mixed in with dependant entities', () => { + const bookSchema = gql` + type Foo + @key( + fields: "barEntity { barValue { bazEntity { bazValue { quxEntity { quxKey } } } } }" + ) { + barEntity: [BarEntity!]! + } + + type BarEntity + @key( + fields: "barKey barValue { bazEntity { bazValue { quxEntity { quxKey } } } }" + ) { + barKey: String! + barValue: [BarValue!]! + } + + type BarValue { + bazEntity: BazEntity + } + + type BazEntity @key(fields: "bazKey bazValue { quxEntity { quxKey } }") { + bazKey: String! + bazValue: [BazValue!]! + } + + type BazValue { + quxEntity: [QuxEntity!]! + } + + type QuxEntity @key(fields: "quxKey quxId") { + quxId: Int! + quxKey: String! + } + `; + + const subgraphs = new Map(); + subgraphs.set('book-subgraph', bookSchema); + + const actual = generateSchema({ + subgraphs, + froidSubgraphName: 'relay-subgraph', + contractTags: ['storefront', 'internal'], + federationVersion: FED2_DEFAULT_VERSION, + }); + + expect(actual).toEqual( + // prettier-ignore + gql` + extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external"]) + + type BarEntity implements Node @key(fields: "barKey barValue { __typename bazEntity { __typename bazKey bazValue { __typename quxEntity { __typename quxId quxKey } } } }") { + "The globally unique identifier." + id: ID! + barKey: String! + barValue: [BarValue!]! + } + + type BarValue { + bazEntity: BazEntity + } + + type BazEntity implements Node @key(fields: "bazKey bazValue { __typename quxEntity { __typename quxId quxKey } }") { + "The globally unique identifier." + id: ID! + bazKey: String! + bazValue: [BazValue!]! + } + + type BazValue { + quxEntity: [QuxEntity!]! + } + + type Foo implements Node @key(fields: "barEntity { __typename barKey barValue { __typename bazEntity { __typename bazKey bazValue { __typename quxEntity { __typename quxId quxKey } } } } }") { + "The globally unique identifier." + id: ID! + barEntity: [BarEntity!]! + } + + "The global identification interface implemented by all entities." + interface Node @tag(name: "internal") @tag(name: "storefront") { + "The globally unique identifier." + id: ID! + } + + type Query { + "Fetches an entity by its globally unique identifier." + node( + "A globally unique entity identifier." + id: ID! + ): Node @tag(name: "internal") @tag(name: "storefront") + } + + type QuxEntity implements Node @key(fields: "quxId quxKey") { + "The globally unique identifier." + id: ID! + quxId: Int! + quxKey: String! + } + ` + ); + }); + it('figures out key dependencies across multiple subgraphs when there are conflicts between the default selected key and key dependencies', () => { const bookSchema = gql` type Book From 2baec65e8fdd8a1fc3014fe393f3ec05ca038f2e Mon Sep 17 00:00:00 2001 From: Greg Wardwell Date: Mon, 29 Apr 2024 13:20:50 -0400 Subject: [PATCH 2/2] chore: update the changelog and package version --- CHANGELOG.md | 7 +++++++ package.json | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 804a5bb..551058a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [v3.2.1] - 2024-04-29 + +### Fixed + +- Fixes a case where sub-entity keys failed to roll up properly to referencing + entity keys when a value type was in-between. + ## [v3.2.0] - 2024-03-22 ### Added diff --git a/package.json b/package.json index 9382f05..9651501 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@wayfair/node-froid", - "version": "3.2.0", + "version": "3.2.1", "description": "Federated GQL Relay Object Identification implementation", "main": "dist/index.js", "types": "dist/index.d.ts",