From 28fe42c0e1c98554d5ef79c1e7c1262c4405a08b Mon Sep 17 00:00:00 2001 From: Greg Wardwell Date: Fri, 22 Mar 2024 09:45:41 -0400 Subject: [PATCH 1/2] feat: allow omitted objects to be included with a new custom qualifier --- src/schema/FroidSchema.ts | 43 ++++- src/schema/__tests__/FroidSchema.test.ts | 214 ++++++++++++++++++++++- 2 files changed, 254 insertions(+), 3 deletions(-) diff --git a/src/schema/FroidSchema.ts b/src/schema/FroidSchema.ts index 943bdbb..12693d1 100644 --- a/src/schema/FroidSchema.ts +++ b/src/schema/FroidSchema.ts @@ -40,13 +40,18 @@ type SupportedFroidReturnTypes = export type KeySorter = (keys: Key[], node: ObjectTypeNode) => Key[]; export type NodeQualifier = ( node: ASTNode, - objectTypes: ObjectTypeNode[] + qualifiedNodes: ASTNode[] +) => boolean; +export type OmittedEntityQualifier = ( + omittedEntity: ObjectTypeDefinitionNode, + includedEntities: ObjectTypeDefinitionNode[] ) => boolean; export type FroidSchemaOptions = { contractTags?: string[]; keySorter?: KeySorter; nodeQualifier?: NodeQualifier; + omittedEntityQualifier?: OmittedEntityQualifier; typeExceptions?: string[]; }; @@ -62,6 +67,8 @@ const defaultKeySorter: KeySorter = (keys: Key[]): Key[] => keys; const defaultNodeQualifier: NodeQualifier = () => true; +const defaultOmittedEntityQualifier: NodeQualifier = () => false; + const scalarNames = specifiedScalarTypes.map((scalar) => scalar.name); // Custom types that are supported when generating node relay service schema @@ -90,6 +97,10 @@ export class FroidSchema { * The node qualifier function. */ private readonly nodeQualifier: NodeQualifier; + /** + * The omitted entity qualifier function. + */ + private readonly omittedEntityQualifier: OmittedEntityQualifier; /** * the list of types that should be omitted from the FROID schema. */ @@ -139,6 +150,8 @@ export class FroidSchema { this.typeExceptions = options?.typeExceptions ?? []; this.keySorter = options?.keySorter ?? defaultKeySorter; this.nodeQualifier = options?.nodeQualifier ?? defaultNodeQualifier; + this.omittedEntityQualifier = + options?.omittedEntityQualifier ?? defaultOmittedEntityQualifier; this.contractTags = options?.contractTags ?.sort() @@ -217,6 +230,7 @@ export class FroidSchema { * Finds the object types that should be included in the FROID schema. */ private findFroidObjectTypes() { + const omittedEntities: ObjectTypeDefinitionNode[] = []; this.objectTypes.forEach((node: ObjectTypeDefinitionNode) => { const isException = this.typeExceptions.some( (exception) => node.name.value === exception @@ -229,12 +243,37 @@ export class FroidSchema { ) ); - if (isException || !passesNodeQualifier || !FroidSchema.isEntity(node)) { + if (isException || !FroidSchema.isEntity(node)) { + return; + } + + if (!passesNodeQualifier) { + omittedEntities.push(node); return; } this.createFroidObjectType(node); }); + + // After all FROID objects are identified, ensure there aren't + // any omitted FROID objects we want to include + omittedEntities.forEach((entity) => { + if (this.froidObjectTypes[entity.name.value]) { + // Skip any objects that are already included + return; + } + const qualifies = this.omittedEntityQualifier( + entity, + Object.values(this.froidObjectTypes).map((obj) => obj.node) + ); + if (!qualifies) { + // Skip any omitted objects that we don't want to include + return; + } + + // Add any omitted objects that we do want to include + this.createFroidObjectType(entity); + }); } /** diff --git a/src/schema/__tests__/FroidSchema.test.ts b/src/schema/__tests__/FroidSchema.test.ts index d8842aa..f16e241 100644 --- a/src/schema/__tests__/FroidSchema.test.ts +++ b/src/schema/__tests__/FroidSchema.test.ts @@ -1,5 +1,10 @@ import {stripIndent as gql} from 'common-tags'; -import {FroidSchema, KeySorter, NodeQualifier} from '../FroidSchema'; +import { + FroidSchema, + KeySorter, + NodeQualifier, + OmittedEntityQualifier, +} from '../FroidSchema'; import {Kind} from 'graphql'; import {FED2_DEFAULT_VERSION} from '../constants'; @@ -10,6 +15,7 @@ function generateSchema({ typeExceptions = [], federationVersion, nodeQualifier, + omittedEntityQualifier, keySorter, }: { subgraphs: Map; @@ -18,6 +24,7 @@ function generateSchema({ typeExceptions?: string[]; federationVersion: string; nodeQualifier?: NodeQualifier; + omittedEntityQualifier?: OmittedEntityQualifier; keySorter?: KeySorter; }) { const froidSchema = new FroidSchema( @@ -28,6 +35,7 @@ function generateSchema({ contractTags, typeExceptions, nodeQualifier, + omittedEntityQualifier, keySorter, } ); @@ -1247,6 +1255,210 @@ describe('FroidSchema class', () => { ); }); + it('defaults to omitting entities that fail to match the custom qualifier', () => { + const bookSchema = gql` + type Book @key(fields: "isbn") { + isbn: String! + } + `; + const authorSchema = gql` + type Book @key(fields: "isbn") { + isbn: String! + } + + type Author @key(fields: "authorId") { + authorId: Int! + } + `; + const subgraphs = new Map(); + subgraphs.set('book-subgraph', bookSchema); + subgraphs.set('author-subgraph', authorSchema); + + const actual = generateSchema({ + subgraphs, + froidSubgraphName: 'relay-subgraph', + federationVersion: FED2_DEFAULT_VERSION, + nodeQualifier: (node) => { + if ( + node.kind === Kind.OBJECT_TYPE_DEFINITION && + node.name.value === 'Book' + ) { + return false; + } + return true; + }, + }); + + expect(actual).toEqual( + // prettier-ignore + gql` + extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external"]) + + type Author implements Node @key(fields: "authorId") { + "The globally unique identifier." + id: ID! + authorId: Int! + } + + "The global identification interface implemented by all entities." + interface Node { + "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 + } + ` + ); + }); + + it('includes entities that fail to match the custom qualifier if they are reference in another entity key', () => { + const bookSchema = gql` + type Book @key(fields: "isbn") { + isbn: String! + title: String + } + `; + const authorSchema = gql` + type Book @key(fields: "isbn") { + isbn: String! + title: [String] + } + + type Author @key(fields: "book { title }") { + book: Book! + } + `; + const subgraphs = new Map(); + subgraphs.set('book-subgraph', bookSchema); + subgraphs.set('author-subgraph', authorSchema); + + const actual = generateSchema({ + subgraphs, + froidSubgraphName: 'relay-subgraph', + federationVersion: FED2_DEFAULT_VERSION, + nodeQualifier: (node) => { + if ( + node.kind === Kind.OBJECT_TYPE_DEFINITION && + node.name.value === 'Book' + ) { + return false; + } + return true; + }, + }); + + expect(actual).toEqual( + // prettier-ignore + gql` + extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external"]) + + type Author implements Node @key(fields: "book { __typename isbn title }") { + "The globally unique identifier." + id: ID! + book: Book! + } + + type Book implements Node @key(fields: "isbn") { + "The globally unique identifier." + id: ID! + isbn: String! + title: String @external + } + + "The global identification interface implemented by all entities." + interface Node { + "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 + } + ` + ); + }); + + it('includes entities that fail to match the custom qualifier if they pass a custom qualifier for omitted entities', () => { + const bookSchema = gql` + type Book @key(fields: "isbn") { + isbn: String! + } + `; + const authorSchema = gql` + type Book @key(fields: "bookId") { + bookId: Int! + } + + type Author @key(fields: "authorId") { + authorId: Int! + } + `; + const subgraphs = new Map(); + subgraphs.set('book-subgraph', bookSchema); + subgraphs.set('author-subgraph', authorSchema); + + const actual = generateSchema({ + subgraphs, + froidSubgraphName: 'relay-subgraph', + federationVersion: FED2_DEFAULT_VERSION, + nodeQualifier: (node) => { + if ( + node.kind === Kind.OBJECT_TYPE_DEFINITION && + node.name.value === 'Book' + ) { + return false; + } + return true; + }, + omittedEntityQualifier: () => { + return true; + }, + }); + + expect(actual).toEqual( + // prettier-ignore + gql` + extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag", "@external"]) + + type Author implements Node @key(fields: "authorId") { + "The globally unique identifier." + id: ID! + authorId: Int! + } + + type Book implements Node @key(fields: "isbn") { + "The globally unique identifier." + id: ID! + isbn: String! + } + + "The global identification interface implemented by all entities." + interface Node { + "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 + } + ` + ); + }); + it('stops compound key generation recursion when an already-visited ancestor is encountered', () => { const bookSchema = gql` type Book @key(fields: "author { name }") { From 2ae576c44b6b24e36ea6856fdb6ff39b237f732a Mon Sep 17 00:00:00 2001 From: Greg Wardwell Date: Fri, 22 Mar 2024 11:37:49 -0400 Subject: [PATCH 2/2] build: add changelog entry and bump package version --- CHANGELOG.md | 9 +++++++++ package.json | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f364d52..804a5bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,15 @@ 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.0] - 2024-03-22 + +### Added + +- Added a new option for an `omittedEntityQualifier` to re-evaluate and include + entities that may have been erroneously omitted by the `nodeQualifier`. This + provided the flexibility to fix missing entities while preserving previous + behavior + ## [v3.1.1] - 2024-02-15 ### Fix diff --git a/package.json b/package.json index 045fe4f..9382f05 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@wayfair/node-froid", - "version": "3.1.1", + "version": "3.2.0", "description": "Federated GQL Relay Object Identification implementation", "main": "dist/index.js", "types": "dist/index.d.ts",