diff --git a/examples/apollo-server/schema.ts b/examples/apollo-server/schema.ts index 02f6597b..6b3ceb6b 100644 --- a/examples/apollo-server/schema.ts +++ b/examples/apollo-server/schema.ts @@ -107,3 +107,7 @@ function resolveType(obj: any): string { } throw new Error("Cannot find type name."); } +export const getTypeName = resolveType; +export const iPersonClassMap = { + User: UserClass +}; diff --git a/examples/express-graphql-http/schema.ts b/examples/express-graphql-http/schema.ts index ea09bc5e..50d0f77e 100644 --- a/examples/express-graphql-http/schema.ts +++ b/examples/express-graphql-http/schema.ts @@ -107,3 +107,7 @@ function resolveType(obj: any): string { } throw new Error("Cannot find type name."); } +export const getTypeName = resolveType; +export const iPersonClassMap = { + User: UserClass +}; diff --git a/examples/next-js/schema.ts b/examples/next-js/schema.ts index 7d607739..92dfd93f 100644 --- a/examples/next-js/schema.ts +++ b/examples/next-js/schema.ts @@ -106,3 +106,7 @@ function resolveType(obj: any): string { } throw new Error("Cannot find type name."); } +export const getTypeName = resolveType; +export const iPersonClassMap = { + User: UserClass +}; diff --git a/examples/production-app/graphql/Node.ts b/examples/production-app/graphql/Node.ts index 602dccf4..b130a37f 100644 --- a/examples/production-app/graphql/Node.ts +++ b/examples/production-app/graphql/Node.ts @@ -1,6 +1,7 @@ import { fromGlobalId, toGlobalId } from "graphql-relay"; import { ID } from "grats"; import { VC } from "../ViewerContext.js"; +import { nodeClassMap, getTypeName } from "../schema.js"; /** * Converts a globally unique ID into a local ID asserting @@ -18,7 +19,6 @@ export function getLocalTypeAssert(id: ID, typename: string): string { * Indicates a stable refetchable object in the system. * @gqlInterface Node */ export interface GraphQLNode { - __typename: string; localID(): string; } @@ -31,7 +31,7 @@ export interface GraphQLNode { * @gqlField * @killsParentOnException */ export function id(node: GraphQLNode): ID { - return toGlobalId(node.__typename, node.localID()); + return toGlobalId(getTypeName(node), node.localID()); } /** @@ -42,20 +42,11 @@ export async function node( vc: VC, ): Promise { const { type, id } = fromGlobalId(args.id); - - // Note: Every type which implements `Node` must be represented here, and - // there's not currently any static way to enforce that. This is a potential - // source of bugs. - switch (type) { - case "User": - return vc.getUserById(id); - case "Post": - return vc.getPostById(id); - case "Like": - return vc.getLikeById(id); - default: - throw new Error(`Unknown typename: ${type}`); + const cls = nodeClassMap[type as keyof typeof nodeClassMap]; + if (cls == null) { + throw new Error(`Type "${type}" does not implement Node`); } + return cls.fetchById(vc, id); } /** diff --git a/examples/production-app/models/Like.ts b/examples/production-app/models/Like.ts index 4f7ae6cd..b20e8c1c 100644 --- a/examples/production-app/models/Like.ts +++ b/examples/production-app/models/Like.ts @@ -11,7 +11,9 @@ import { Post } from "./Post.js"; * A reaction from a user indicating that they like a post. * @gqlType */ export class Like extends Model implements GraphQLNode { - __typename = "Like" as const; + static async fetchById(vc: VC, id: string): Promise { + return vc.getLikeById(id); + } /** * The date and time at which the post was liked. diff --git a/examples/production-app/models/Post.ts b/examples/production-app/models/Post.ts index 50d1711b..bea3f065 100644 --- a/examples/production-app/models/Post.ts +++ b/examples/production-app/models/Post.ts @@ -12,7 +12,9 @@ import { connectionFromSelectOrCount } from "../graphql/gqlUtils.js"; * A blog post. * @gqlType */ export class Post extends Model implements GraphQLNode { - __typename = "Post" as const; + static async fetchById(vc: VC, id: string): Promise { + return vc.getPostById(id); + } /** * The editor-approved title of the post. diff --git a/examples/production-app/models/User.ts b/examples/production-app/models/User.ts index eee087c3..b323fcbf 100644 --- a/examples/production-app/models/User.ts +++ b/examples/production-app/models/User.ts @@ -9,7 +9,9 @@ import { connectionFromSelectOrCount } from "../graphql/gqlUtils.js"; /** @gqlType */ export class User extends Model implements GraphQLNode { - __typename = "User" as const; + static async fetchById(vc: VC, id: string): Promise { + return vc.getUserById(id); + } /** * User's name. **Note:** This field is not guaranteed to be unique. diff --git a/examples/production-app/schema.ts b/examples/production-app/schema.ts index b77fe4fb..ac85fe30 100644 --- a/examples/production-app/schema.ts +++ b/examples/production-app/schema.ts @@ -7,14 +7,14 @@ import type { GqlScalar } from "grats"; import type { GqlDate as DateInternal } from "./graphql/CustomScalars.js"; import { GraphQLSchema, GraphQLDirective, DirectiveLocation, GraphQLNonNull, GraphQLInt, specifiedDirectives, GraphQLObjectType, GraphQLList, GraphQLString, GraphQLScalarType, GraphQLID, GraphQLInterfaceType, GraphQLBoolean, GraphQLInputObjectType } from "graphql"; import { id as likeIdResolver, id as userIdResolver, id as postIdResolver, node as queryNodeResolver, nodes as queryNodesResolver } from "./graphql/Node.js"; +import { Like as LikeClass, createLike as mutationCreateLikeResolver } from "./models/Like.js"; +import { Post as PostClass, createPost as mutationCreatePostResolver } from "./models/Post.js"; +import { User as UserClass, createUser as mutationCreateUserResolver } from "./models/User.js"; import { nodes as postConnectionNodesResolver, posts as queryPostsResolver } from "./models/PostConnection.js"; import { nodes as likeConnectionNodesResolver, likes as queryLikesResolver, postLikes as subscriptionPostLikesResolver } from "./models/LikeConnection.js"; import { getVc } from "./ViewerContext.js"; import { nodes as userConnectionNodesResolver, users as queryUsersResolver } from "./models/UserConnection.js"; import { Viewer as queryViewerResolver } from "./models/Viewer.js"; -import { createLike as mutationCreateLikeResolver } from "./models/Like.js"; -import { createPost as mutationCreatePostResolver } from "./models/Post.js"; -import { createUser as mutationCreateUserResolver } from "./models/User.js"; export type SchemaConfig = { scalars: { Date: GqlScalar; @@ -38,7 +38,8 @@ export function getSchema(config: SchemaConfig): GraphQLSchema { type: new GraphQLNonNull(GraphQLID) } }; - } + }, + resolveType }); const PostType: GraphQLObjectType = new GraphQLObjectType({ name: "Post", @@ -687,3 +688,27 @@ export function getSchema(config: SchemaConfig): GraphQLSchema { types: [DateType, NodeType, CreateLikeInputType, CreatePostInputType, CreateUserInputType, MarkdownNodeType, PostContentInputType, CreateLikePayloadType, CreatePostPayloadType, CreateUserPayloadType, LikeType, LikeConnectionType, LikeEdgeType, MutationType, PageInfoType, PostType, PostConnectionType, PostEdgeType, QueryType, SubscriptionType, UserType, UserConnectionType, UserEdgeType, ViewerType] }); } +const typeNameMap = new Map(); +typeNameMap.set(LikeClass, "Like"); +typeNameMap.set(PostClass, "Post"); +typeNameMap.set(UserClass, "User"); +function resolveType(obj: any): string { + if (typeof obj.__typename === "string") { + return obj.__typename; + } + let prototype = Object.getPrototypeOf(obj); + while (prototype) { + const name = typeNameMap.get(prototype.constructor); + if (name != null) { + return name; + } + prototype = Object.getPrototypeOf(prototype); + } + throw new Error("Cannot find type name."); +} +export const getTypeName = resolveType; +export const nodeClassMap = { + Like: LikeClass, + Post: PostClass, + User: UserClass +}; diff --git a/examples/strict-semantic-nullability/schema.ts b/examples/strict-semantic-nullability/schema.ts index e5948402..ce44a632 100644 --- a/examples/strict-semantic-nullability/schema.ts +++ b/examples/strict-semantic-nullability/schema.ts @@ -183,3 +183,7 @@ function resolveType(obj: any): string { } throw new Error("Cannot find type name."); } +export const getTypeName = resolveType; +export const iPersonClassMap = { + User: UserClass +}; diff --git a/examples/yoga/schema.ts b/examples/yoga/schema.ts index ede983c3..39bf7ca1 100644 --- a/examples/yoga/schema.ts +++ b/examples/yoga/schema.ts @@ -131,3 +131,7 @@ function resolveType(obj: any): string { } throw new Error("Cannot find type name."); } +export const getTypeName = resolveType; +export const iPersonClassMap = { + User: UserClass +}; diff --git a/llm-docs/guides/node-spec.md b/llm-docs/guides/node-spec.md new file mode 100644 index 00000000..5c000e71 --- /dev/null +++ b/llm-docs/guides/node-spec.md @@ -0,0 +1,98 @@ +# Node Interface (Global Object Identification) + +The [Global Object Identification](https://graphql.org/learn/global-object-identification/) spec defines a pattern for fetching any object by a globally unique ID. It is used by clients like [Relay](https://relay.dev) to efficiently refetch individual objects and normalize cached data. + +The spec requires: + +- A `Node` interface with a single `id: ID!` field +- A root `node(id: ID!): Node` query field that can fetch any `Node` by its global ID +- A root `nodes(ids: [ID!]!): [Node]!` query field for batch fetching + +Grats provides several features that make implementing this spec straightforward, with full static type safety. + +> **INFO:** +> For a full working example of the Node interface in action, see our [Production App](../examples/production-app.md) example app. + +## Implementation + +### Step 1: Define the Node interface + +Define a TypeScript interface for `Node`. Since TypeScript has a built-in `Node` type (for DOM nodes), use a different TypeScript name and rename it with `@gqlInterface Node`: + +```ts +import { ID } from "grats"; +import { getTypeName } from "./schema"; +import { toGlobalId } from "graphql-relay"; + +/** @gqlInterface Node */ +export interface GraphQLNode { + localID(): string; +} + +/** + * @gqlField + * @killsParentOnException */ +export function id(node: GraphQLNode): ID { + return toGlobalId(getTypeName(node), node.localID()); +} +``` + +`localID()` is a TypeScript-only contract (not a GraphQL field) that each implementor must provide. It returns the type's local (unprefixed) identifier. + +The functional `@gqlField` automatically adds the `id` field to every type that implements `Node`. `getTypeName` is exported by Grats' generated `schema.ts` — it returns the GraphQL typename for any class instance, so you don't need to define `__typename` on your classes. `toGlobalId` from `graphql-relay` encodes `typename:localId` as a base64 string. + +### Step 2: Implement Node on your types + +Each type that should be a `Node` implements the interface and provides a static `fetchById` method: + +```ts +/** @gqlType */ +export class User implements GraphQLNode { + constructor(private _id: string) {} + + localID() { + return this._id; + } + + static async fetchById(id: string): Promise { + return db.users.get(id); + } +} +``` + +### Step 3: Implement the `node` and `nodes` query fields + +Grats generates a `nodeClassMap` in `schema.ts` that maps every `Node` implementor's typename to its class. Use it to dispatch to the correct `fetchById`: + +```ts +import { fromGlobalId } from "graphql-relay"; +import { nodeClassMap } from "./schema"; + +/** @gqlQueryField */ +export async function node(args: { id: ID }): Promise { + const { type, id } = fromGlobalId(args.id); + const cls = nodeClassMap[type as keyof typeof nodeClassMap]; + if (cls == null) { + throw new Error(`Type "${type}" does not implement Node`); + } + return cls.fetchById(id); +} + +/** @gqlQueryField */ +export async function nodes(ids: ID[]): Promise> { + return Promise.all(ids.map((id) => node({ id }))); +} +``` + +## Static type safety + +If you add a new type that implements `GraphQLNode` but forget to add a `fetchById` static method, TypeScript will report an error at the `cls.fetchById(...)` call — because the union of all classes in `nodeClassMap` now includes a class without that method. + +This eliminates the common bug of adding a `Node` implementor but forgetting to register it in the `node()` resolver. + +## How it works + +Grats generates two exports in `schema.ts` that power this pattern: + +- **`nodeClassMap`** — An object mapping GraphQL typenames to their class constructors for every type that implements the `Node` interface. Grats generates one of these maps per interface in your schema. +- **`getTypeName`** — A function that returns the GraphQL typename for any class instance, using the same prototype-chain resolution that GraphQL uses internally. This lets you encode global IDs without defining `__typename` on your classes. diff --git a/src/Extractor.ts b/src/Extractor.ts index 0df41b8a..02aa3349 100644 --- a/src/Extractor.ts +++ b/src/Extractor.ts @@ -1351,19 +1351,17 @@ class Extractor { let exported: { tsModulePath: string; exportName: string | null } | null = null; - if (!hasTypeName) { - const isExported = node.modifiers?.find( - (modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword, - ); - const isDefault = node.modifiers?.find( - (modifier) => modifier.kind === ts.SyntaxKind.DefaultKeyword, - ); - if (isExported) { - exported = { - tsModulePath: relativePath(node.getSourceFile().fileName), - exportName: isDefault ? null : node.name.text, - }; - } + const isExported = node.modifiers?.find( + (modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword, + ); + const isDefault = node.modifiers?.find( + (modifier) => modifier.kind === ts.SyntaxKind.DefaultKeyword, + ); + if (isExported) { + exported = { + tsModulePath: relativePath(node.getSourceFile().fileName), + exportName: isDefault ? null : node.name.text, + }; } const directives = this.collectDirectives(node); diff --git a/src/codegen/schemaCodegen.ts b/src/codegen/schemaCodegen.ts index ffb9f3bd..0acdbe33 100644 --- a/src/codegen/schemaCodegen.ts +++ b/src/codegen/schemaCodegen.ts @@ -427,6 +427,24 @@ class Codegen { return F.createIdentifier(varName); } + ensureClassImported( + t: GraphQLObjectType, + exported: ExportDefinition, + ): string { + let localName = this._typeNameMappings.get(t.name); + if (localName == null) { + localName = `${t.name}Class`; + this.ts.importUserConstruct( + exported.tsModulePath, + exported.exportName, + localName, + false, + ); + this._typeNameMappings.set(t.name, localName); + } + return localName; + } + resolveType(obj: GraphQLAbstractType): ts.ShorthandPropertyAssignment | null { let needsResolveType = false; for (const t of this._schema.getPossibleTypes(obj)) { @@ -434,20 +452,8 @@ class Codegen { if (ast.hasTypeNameField) { continue; } - - const exportedMetadata = ast.exported; - if (exportedMetadata != null) { - if (!this._typeNameMappings.has(t.name)) { - const localName = `${t.name}Class`; - this.ts.importUserConstruct( - exportedMetadata.tsModulePath, - exportedMetadata.exportName, - localName, - false, - ); - - this._typeNameMappings.set(t.name, localName); - } + if (ast.exported != null) { + this.ensureClassImported(t, ast.exported); needsResolveType = true; } } @@ -972,8 +978,75 @@ class Codegen { ); } this.ts.addStatement(this.resolveTypeFunctionDeclaration()); + this.ts.addStatement( + F.createVariableStatement( + [F.createModifier(ts.SyntaxKind.ExportKeyword)], + F.createVariableDeclarationList( + [ + F.createVariableDeclaration( + "getTypeName", + undefined, + undefined, + F.createIdentifier("resolveType"), + ), + ], + ts.NodeFlags.Const, + ), + ), + ); } + this.interfaceClassMaps(); + return this.ts.print(); } + + interfaceClassMaps(): void { + const interfaceTypes = Object.values(this._schema.getTypeMap()) + .filter(isInterfaceType) + .sort((a, b) => naturalCompare(a.name, b.name)); + + for (const interfaceType of interfaceTypes) { + const possibleTypes = this._schema.getPossibleTypes(interfaceType); + const classEntries: Array<[string, string]> = []; + + for (const t of possibleTypes) { + const exported = nullThrows(t.astNode).exported; + if (exported == null) continue; + const localName = this.ensureClassImported(t, exported); + classEntries.push([t.name, localName]); + } + + if (classEntries.length === 0) continue; + + classEntries.sort(([a], [b]) => naturalCompare(a, b)); + + const mapName = `${lowercaseFirst(interfaceType.name)}ClassMap`; + + const properties = classEntries.map(([typeName, localName]) => + F.createPropertyAssignment(typeName, F.createIdentifier(localName)), + ); + + this.ts.addStatement( + F.createVariableStatement( + [F.createModifier(ts.SyntaxKind.ExportKeyword)], + F.createVariableDeclarationList( + [ + F.createVariableDeclaration( + F.createIdentifier(mapName), + undefined, + undefined, + this.ts.objectLiteral(properties), + ), + ], + ts.NodeFlags.Const, + ), + ), + ); + } + } +} + +function lowercaseFirst(str: string): string { + return str.charAt(0).toLowerCase() + str.slice(1); } diff --git a/src/tests/fixtures/semantic_nullability/semanticNonNullMatchesInterface.ts.expected.md b/src/tests/fixtures/semantic_nullability/semanticNonNullMatchesInterface.ts.expected.md index 34b0cd13..ba040480 100644 --- a/src/tests/fixtures/semantic_nullability/semanticNonNullMatchesInterface.ts.expected.md +++ b/src/tests/fixtures/semantic_nullability/semanticNonNullMatchesInterface.ts.expected.md @@ -75,6 +75,7 @@ type User implements IPerson { ```ts import { GraphQLSchema, GraphQLDirective, DirectiveLocation, GraphQLList, GraphQLInt, specifiedDirectives, GraphQLInterfaceType, GraphQLString, GraphQLObjectType, defaultFieldResolver } from "graphql"; +import { User as UserClass } from "./semanticNonNullMatchesInterface"; async function assertNonNull(value: T | Promise): Promise { const awaited = await value; if (awaited == null) @@ -125,4 +126,7 @@ export function getSchema(): GraphQLSchema { types: [IPersonType, UserType] }); } +export const iPersonClassMap = { + User: UserClass +}; ``` \ No newline at end of file diff --git a/src/tests/fixtures/type_definitions/TypeFromClassDefinitionImplementsInterface.ts.expected.md b/src/tests/fixtures/type_definitions/TypeFromClassDefinitionImplementsInterface.ts.expected.md index 65fbe924..0f79af16 100644 --- a/src/tests/fixtures/type_definitions/TypeFromClassDefinitionImplementsInterface.ts.expected.md +++ b/src/tests/fixtures/type_definitions/TypeFromClassDefinitionImplementsInterface.ts.expected.md @@ -34,6 +34,7 @@ type User implements Person { ### TypeScript ```ts +import UserClass from "./TypeFromClassDefinitionImplementsInterface"; import { GraphQLSchema, GraphQLInterfaceType, GraphQLString, GraphQLObjectType } from "graphql"; export function getSchema(): GraphQLSchema { const PersonType: GraphQLInterfaceType = new GraphQLInterfaceType({ @@ -65,4 +66,7 @@ export function getSchema(): GraphQLSchema { types: [PersonType, UserType] }); } +export const personClassMap = { + User: UserClass +}; ``` \ No newline at end of file diff --git a/src/tests/fixtures/type_definitions/TypeFromClassDefinitionImplementsMultipleInterfaces.ts.expected.md b/src/tests/fixtures/type_definitions/TypeFromClassDefinitionImplementsMultipleInterfaces.ts.expected.md index 6ac502a6..b32dad75 100644 --- a/src/tests/fixtures/type_definitions/TypeFromClassDefinitionImplementsMultipleInterfaces.ts.expected.md +++ b/src/tests/fixtures/type_definitions/TypeFromClassDefinitionImplementsMultipleInterfaces.ts.expected.md @@ -48,6 +48,7 @@ type User implements Node & Person { ### TypeScript ```ts +import UserClass from "./TypeFromClassDefinitionImplementsMultipleInterfaces"; import { GraphQLSchema, GraphQLInterfaceType, GraphQLString, GraphQLObjectType } from "graphql"; export function getSchema(): GraphQLSchema { const NodeType: GraphQLInterfaceType = new GraphQLInterfaceType({ @@ -94,4 +95,10 @@ export function getSchema(): GraphQLSchema { types: [NodeType, PersonType, UserType] }); } +export const nodeClassMap = { + User: UserClass +}; +export const personClassMap = { + User: UserClass +}; ``` \ No newline at end of file diff --git a/src/tests/fixtures/typename/PropertySignatureTypename.ts.expected.md b/src/tests/fixtures/typename/PropertySignatureTypename.ts.expected.md index 778d76cc..c978853c 100644 --- a/src/tests/fixtures/typename/PropertySignatureTypename.ts.expected.md +++ b/src/tests/fixtures/typename/PropertySignatureTypename.ts.expected.md @@ -35,6 +35,7 @@ type User implements IPerson { ```ts import { GraphQLSchema, GraphQLInterfaceType, GraphQLString, GraphQLObjectType } from "graphql"; +import { User as UserClass } from "./PropertySignatureTypename"; export function getSchema(): GraphQLSchema { const IPersonType: GraphQLInterfaceType = new GraphQLInterfaceType({ name: "IPerson", @@ -65,4 +66,7 @@ export function getSchema(): GraphQLSchema { types: [IPersonType, UserType] }); } +export const iPersonClassMap = { + User: UserClass +}; ``` \ No newline at end of file diff --git a/src/tests/integrationFixtures/resolveTypeViaClass/index.ts b/src/tests/integrationFixtures/resolveTypeViaClass/index.ts index 62325986..588ddf63 100644 --- a/src/tests/integrationFixtures/resolveTypeViaClass/index.ts +++ b/src/tests/integrationFixtures/resolveTypeViaClass/index.ts @@ -1,83 +1,34 @@ import { ID } from "../../../Types.js"; - -/** @gqlInterface */ -interface GqlNode { - /** @gqlField */ - id: ID; -} - -/** @gqlType */ -export default class DefaultNode implements GqlNode { - constructor( - /** @gqlField */ - public id: ID, - ) {} -} - -/** @gqlType */ -export class User implements GqlNode { - constructor( - /** @gqlField */ - public id: ID, - ) {} -} - -/** @gqlType RenamedNode */ -export class ThisNameGetsIgnored implements GqlNode { - constructor( - /** @gqlField */ - public id: ID, - ) {} -} - -/** @gqlType */ -export class Guest implements GqlNode { - constructor( - /** @gqlField */ - public id: ID, - ) {} -} - -class AlsoUser extends User { - constructor(id: ID) { - super(id); - } -} +import { gqlNodeClassMap } from "./schema.js"; +import type { GqlNode } from "./models.js"; /** @gqlQueryField */ export function node(args: { id: ID }): GqlNode { - const { id } = args; - if (id.startsWith("User:")) { - return new User(id); - } else if (id.startsWith("Guest:")) { - return new Guest(id); - } else if (id.startsWith("DefaultNode:")) { - return new DefaultNode(id); - } else if (id.startsWith("RenamedNode:")) { - return new ThisNameGetsIgnored(id); - } else if (id.startsWith("AlsoUser:")) { - return new AlsoUser(id); - } else { - return new Guest(id); + const [type, localId] = (args.id as string).split(":"); + const cls = gqlNodeClassMap[type as keyof typeof gqlNodeClassMap]; + if (cls == null) { + throw new Error(`Type "${type}" does not implement GqlNode`); } + return cls.fromId(localId); } export const query = /* GraphQL */ ` query { user: node(id: "User:1") { __typename + id } - alsoUser: node(id: "AlsoUser:1") { - __typename - } - guest: node(id: "Guest:1") { + guest: node(id: "Guest:2") { __typename + id } - defaultNode: node(id: "DefaultNode:1") { + defaultNode: node(id: "DefaultNode:3") { __typename + id } - renamedNode: node(id: "RenamedNode:1") { + renamedNode: node(id: "RenamedNode:4") { __typename + id } } `; diff --git a/src/tests/integrationFixtures/resolveTypeViaClass/index.ts.expected.md b/src/tests/integrationFixtures/resolveTypeViaClass/index.ts.expected.md index 040cb61f..160759e4 100644 --- a/src/tests/integrationFixtures/resolveTypeViaClass/index.ts.expected.md +++ b/src/tests/integrationFixtures/resolveTypeViaClass/index.ts.expected.md @@ -4,85 +4,36 @@ ```ts title="resolveTypeViaClass/index.ts" import { ID } from "../../../Types.js"; - -/** @gqlInterface */ -interface GqlNode { - /** @gqlField */ - id: ID; -} - -/** @gqlType */ -export default class DefaultNode implements GqlNode { - constructor( - /** @gqlField */ - public id: ID, - ) {} -} - -/** @gqlType */ -export class User implements GqlNode { - constructor( - /** @gqlField */ - public id: ID, - ) {} -} - -/** @gqlType RenamedNode */ -export class ThisNameGetsIgnored implements GqlNode { - constructor( - /** @gqlField */ - public id: ID, - ) {} -} - -/** @gqlType */ -export class Guest implements GqlNode { - constructor( - /** @gqlField */ - public id: ID, - ) {} -} - -class AlsoUser extends User { - constructor(id: ID) { - super(id); - } -} +import { gqlNodeClassMap } from "./schema.js"; +import type { GqlNode } from "./models.js"; /** @gqlQueryField */ export function node(args: { id: ID }): GqlNode { - const { id } = args; - if (id.startsWith("User:")) { - return new User(id); - } else if (id.startsWith("Guest:")) { - return new Guest(id); - } else if (id.startsWith("DefaultNode:")) { - return new DefaultNode(id); - } else if (id.startsWith("RenamedNode:")) { - return new ThisNameGetsIgnored(id); - } else if (id.startsWith("AlsoUser:")) { - return new AlsoUser(id); - } else { - return new Guest(id); + const [type, localId] = (args.id as string).split(":"); + const cls = gqlNodeClassMap[type as keyof typeof gqlNodeClassMap]; + if (cls == null) { + throw new Error(`Type "${type}" does not implement GqlNode`); } + return cls.fromId(localId); } export const query = /* GraphQL */ ` query { user: node(id: "User:1") { __typename + id } - alsoUser: node(id: "AlsoUser:1") { - __typename - } - guest: node(id: "Guest:1") { + guest: node(id: "Guest:2") { __typename + id } - defaultNode: node(id: "DefaultNode:1") { + defaultNode: node(id: "DefaultNode:3") { __typename + id } - renamedNode: node(id: "RenamedNode:1") { + renamedNode: node(id: "RenamedNode:4") { __typename + id } } `; @@ -96,19 +47,20 @@ export const query = /* GraphQL */ ` { "data": { "user": { - "__typename": "User" - }, - "alsoUser": { - "__typename": "User" + "__typename": "User", + "id": "User:1" }, "guest": { - "__typename": "Guest" + "__typename": "Guest", + "id": "Guest:2" }, "defaultNode": { - "__typename": "DefaultNode" + "__typename": "DefaultNode", + "id": "DefaultNode:3" }, "renamedNode": { - "__typename": "RenamedNode" + "__typename": "RenamedNode", + "id": "RenamedNode:4" } } } diff --git a/src/tests/integrationFixtures/resolveTypeViaClass/models.ts b/src/tests/integrationFixtures/resolveTypeViaClass/models.ts new file mode 100644 index 00000000..7afd2c93 --- /dev/null +++ b/src/tests/integrationFixtures/resolveTypeViaClass/models.ts @@ -0,0 +1,56 @@ +import { ID } from "../../../Types.js"; +import { getTypeName } from "./schema.js"; + +/** @gqlInterface */ +export interface GqlNode { + localID(): string; +} + +/** @gqlField */ +export function id(node: GqlNode): ID { + return `${getTypeName(node)}:${node.localID()}`; +} + +/** @gqlType */ +export default class DefaultNode implements GqlNode { + constructor(private _id: string) {} + localID() { + return this._id; + } + static fromId(id: string): DefaultNode { + return new DefaultNode(id); + } +} + +/** @gqlType */ +export class User implements GqlNode { + constructor(private _id: string) {} + localID() { + return this._id; + } + static fromId(id: string): User { + return new User(id); + } +} + +/** @gqlType RenamedNode */ +export class ThisNameGetsIgnored implements GqlNode { + constructor(private _id: string) {} + localID() { + return this._id; + } + static fromId(id: string): ThisNameGetsIgnored { + return new ThisNameGetsIgnored(id); + } +} + +/** @gqlType */ +export class Guest implements GqlNode { + constructor(private _id: string) {} + localID() { + return this._id; + } + static fromId(id: string): Guest { + return new Guest(id); + } +} diff --git a/src/tests/integrationFixtures/resolveTypeViaClass/schema.ts b/src/tests/integrationFixtures/resolveTypeViaClass/schema.ts index 0e486ce2..91ca6075 100644 --- a/src/tests/integrationFixtures/resolveTypeViaClass/schema.ts +++ b/src/tests/integrationFixtures/resolveTypeViaClass/schema.ts @@ -1,6 +1,7 @@ -import DefaultNodeClass from "./index.js"; +import DefaultNodeClass from "./models.js"; import { GraphQLSchema, GraphQLObjectType, GraphQLInterfaceType, GraphQLID, GraphQLNonNull } from "graphql"; -import { Guest as GuestClass, ThisNameGetsIgnored as RenamedNodeClass, User as UserClass, node as queryNodeResolver } from "./index.js"; +import { Guest as GuestClass, ThisNameGetsIgnored as RenamedNodeClass, User as UserClass, id as defaultNodeIdResolver, id as guestIdResolver, id as renamedNodeIdResolver, id as userIdResolver } from "./models.js"; +import { node as queryNodeResolver } from "./index.js"; export function getSchema(): GraphQLSchema { const GqlNodeType: GraphQLInterfaceType = new GraphQLInterfaceType({ name: "GqlNode", @@ -39,7 +40,10 @@ export function getSchema(): GraphQLSchema { return { id: { name: "id", - type: GraphQLID + type: GraphQLID, + resolve(source) { + return defaultNodeIdResolver(source); + } } }; }, @@ -53,7 +57,10 @@ export function getSchema(): GraphQLSchema { return { id: { name: "id", - type: GraphQLID + type: GraphQLID, + resolve(source) { + return guestIdResolver(source); + } } }; }, @@ -67,7 +74,10 @@ export function getSchema(): GraphQLSchema { return { id: { name: "id", - type: GraphQLID + type: GraphQLID, + resolve(source) { + return renamedNodeIdResolver(source); + } } }; }, @@ -81,7 +91,10 @@ export function getSchema(): GraphQLSchema { return { id: { name: "id", - type: GraphQLID + type: GraphQLID, + resolve(source) { + return userIdResolver(source); + } } }; }, @@ -113,3 +126,10 @@ function resolveType(obj: any): string { } throw new Error("Cannot find type name."); } +export const getTypeName = resolveType; +export const gqlNodeClassMap = { + DefaultNode: DefaultNodeClass, + Guest: GuestClass, + RenamedNode: RenamedNodeClass, + User: UserClass +}; diff --git a/src/tests/test.ts b/src/tests/test.ts index e13d0143..7bd18a38 100644 --- a/src/tests/test.ts +++ b/src/tests/test.ts @@ -17,7 +17,7 @@ import { import { Command } from "commander"; import { locate } from "../Locate.js"; import { gqlErr, ReportableDiagnostics } from "../utils/DiagnosticError.js"; -import { readFileSync, writeFileSync } from "fs"; +import { readdirSync, readFileSync, writeFileSync } from "fs"; import { codegen } from "../codegen/schemaCodegen.js"; import { printEnumsModule } from "../printSchema.js"; import { diff } from "jest-diff"; @@ -277,7 +277,7 @@ const testDirs: TestDir[] = [ { fixturesDir: integrationFixturesDir, testFilePattern: /index.ts$/, - ignoreFilePattern: /(schema)|(enums).ts$/, + ignoreFilePattern: /(? f.endsWith(".ts") && f !== "schema.ts" && f !== "enums.ts", + ) + .map((f) => path.join(fixtureDir, f)); + + const files = [...siblingFiles, path.join(__dirname, `../Types.ts`)]; const parsedOptionsResult = validateGratsOptions({ options: { // Required to enable ts-node to locate function exports diff --git a/website/docs/05-guides/11-node-spec.md b/website/docs/05-guides/11-node-spec.md new file mode 100644 index 00000000..92554931 --- /dev/null +++ b/website/docs/05-guides/11-node-spec.md @@ -0,0 +1,99 @@ +# Node Interface (Global Object Identification) + +The [Global Object Identification](https://graphql.org/learn/global-object-identification/) spec defines a pattern for fetching any object by a globally unique ID. It is used by clients like [Relay](https://relay.dev) to efficiently refetch individual objects and normalize cached data. + +The spec requires: + +- A `Node` interface with a single `id: ID!` field +- A root `node(id: ID!): Node` query field that can fetch any `Node` by its global ID +- A root `nodes(ids: [ID!]!): [Node]!` query field for batch fetching + +Grats provides several features that make implementing this spec straightforward, with full static type safety. + +:::info +For a full working example of the Node interface in action, see our [Production App](../05-examples/10-production-app.md) example app. +::: + +## Implementation + +### Step 1: Define the Node interface + +Define a TypeScript interface for `Node`. Since TypeScript has a built-in `Node` type (for DOM nodes), use a different TypeScript name and rename it with `@gqlInterface Node`: + +```ts +import { ID } from "grats"; +import { getTypeName } from "./schema"; +import { toGlobalId } from "graphql-relay"; + +/** @gqlInterface Node */ +export interface GraphQLNode { + localID(): string; +} + +/** + * @gqlField + * @killsParentOnException */ +export function id(node: GraphQLNode): ID { + return toGlobalId(getTypeName(node), node.localID()); +} +``` + +`localID()` is a TypeScript-only contract (not a GraphQL field) that each implementor must provide. It returns the type's local (unprefixed) identifier. + +The functional `@gqlField` automatically adds the `id` field to every type that implements `Node`. `getTypeName` is exported by Grats' generated `schema.ts` — it returns the GraphQL typename for any class instance, so you don't need to define `__typename` on your classes. `toGlobalId` from `graphql-relay` encodes `typename:localId` as a base64 string. + +### Step 2: Implement Node on your types + +Each type that should be a `Node` implements the interface and provides a static `fetchById` method: + +```ts +/** @gqlType */ +export class User implements GraphQLNode { + constructor(private _id: string) {} + + localID() { + return this._id; + } + + static async fetchById(id: string): Promise { + return db.users.get(id); + } +} +``` + +### Step 3: Implement the `node` and `nodes` query fields + +Grats generates a `nodeClassMap` in `schema.ts` that maps every `Node` implementor's typename to its class. Use it to dispatch to the correct `fetchById`: + +```ts +import { fromGlobalId } from "graphql-relay"; +import { nodeClassMap } from "./schema"; + +/** @gqlQueryField */ +export async function node(args: { id: ID }): Promise { + const { type, id } = fromGlobalId(args.id); + const cls = nodeClassMap[type as keyof typeof nodeClassMap]; + if (cls == null) { + throw new Error(`Type "${type}" does not implement Node`); + } + return cls.fetchById(id); +} + +/** @gqlQueryField */ +export async function nodes(ids: ID[]): Promise> { + return Promise.all(ids.map((id) => node({ id }))); +} +``` + +## Static type safety + +If you add a new type that implements `GraphQLNode` but forget to add a `fetchById` static method, TypeScript will report an error at the `cls.fetchById(...)` call — because the union of all classes in `nodeClassMap` now includes a class without that method. + +This eliminates the common bug of adding a `Node` implementor but forgetting to register it in the `node()` resolver. + +## How it works + +Grats generates two exports in `schema.ts` that power this pattern: + +- **`nodeClassMap`** — An object mapping GraphQL typenames to their class constructors for every type that implements the `Node` interface. Grats generates one of these maps per interface in your schema. +- **`getTypeName`** — A function that returns the GraphQL typename for any class instance, using the same prototype-chain resolution that GraphQL uses internally. This lets you encode global IDs without defining `__typename` on your classes.