Skip to content

Commit

Permalink
Merge pull request #28 from wayfair-incubator/gwardwell_custom_entity…
Browse files Browse the repository at this point in the history
…_matcher_failure_handling

Allow omitted objects to be included with a new custom qualifier
  • Loading branch information
gwardwell authored Mar 22, 2024
2 parents 4f35e40 + 2ae576c commit 4d4a912
Show file tree
Hide file tree
Showing 4 changed files with 264 additions and 4 deletions.
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
43 changes: 41 additions & 2 deletions src/schema/FroidSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
};

Expand All @@ -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
Expand Down Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand All @@ -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);
});
}

/**
Expand Down
214 changes: 213 additions & 1 deletion src/schema/__tests__/FroidSchema.test.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -10,6 +15,7 @@ function generateSchema({
typeExceptions = [],
federationVersion,
nodeQualifier,
omittedEntityQualifier,
keySorter,
}: {
subgraphs: Map<string, string>;
Expand All @@ -18,6 +24,7 @@ function generateSchema({
typeExceptions?: string[];
federationVersion: string;
nodeQualifier?: NodeQualifier;
omittedEntityQualifier?: OmittedEntityQualifier;
keySorter?: KeySorter;
}) {
const froidSchema = new FroidSchema(
Expand All @@ -28,6 +35,7 @@ function generateSchema({
contractTags,
typeExceptions,
nodeQualifier,
omittedEntityQualifier,
keySorter,
}
);
Expand Down Expand Up @@ -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 }") {
Expand Down

0 comments on commit 4d4a912

Please sign in to comment.