Skip to content

Commit 43d0f65

Browse files
Widen SchemaWithLoaderOptions.loader to accept a custom loader function (#11025)
* Widen SchemaWithLoaderOptions.loader to accept a custom loader function Allows passing an imported loader function (or a class instance exposing a `loader` property/method) directly as the `loader` value in a per-entry `schema` custom-loader config, instead of only a path string that `@graphql-tools/load` resolves with `require()`. `@graphql-tools/load`'s `useCustomLoader`/`collectCustomLoader` already dispatch on a function value at runtime (`typeof loaderPointer === 'function'`); only the type was missing this case. Backward compatible — `loader: string` keeps working. * Add changeset for custom schema loader function type * Document passing a loader function directly * Key custom loader functions by identity in the schema cache The CLI caches loaded schemas under JSON.stringify(schemaPointerMap), but JSON.stringify drops functions, so distinct custom loader functions for the same pointer key collide and the second target silently reuses the first target's schema. Serialize function values by object identity (via the existing getJsObjectId, widened to object) so different loaders load separately, while one function reused across targets still loads once. * Include @graphql-codegen/cli in changeset for the schema-cache fix * Add tests for function loaders (direct, distinct-per-pointer, reused-once) * Prettier --------- Co-authored-by: Eddy Nguyen <ch@eddeee888.me>
1 parent e469065 commit 43d0f65

5 files changed

Lines changed: 124 additions & 11 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@graphql-codegen/plugin-helpers': patch
3+
'@graphql-codegen/cli': patch
4+
---
5+
6+
Widen `Types.SchemaWithLoaderOptions['loader']` to accept a custom loader function (`CustomSchemaLoaderFn`) in addition to a path string.
7+
8+
A per-entry `schema` custom loader (`{ '<pointer>': { loader, ...options } }`) is resolved by `@graphql-tools/load`'s `useCustomLoader`, which already accepts either a path string (resolved via `require()`) or a function value at runtime (`typeof loaderPointer === 'function'`). Only the TypeScript type restricted `loader` to `string`, forcing consumers who pass an imported loader function (or a class instance exposing a bound `loader` property) to cast the `schema` array to bypass the type error, even though it worked correctly at runtime.
9+
10+
This is additive and backward compatible — `loader: string` continues to type-check exactly as before.
11+
12+
Also fixes a schema-cache collision for function loaders in `@graphql-codegen/cli`: the cache key is `JSON.stringify(schemaPointerMap)`, and `JSON.stringify` drops functions, so two `generates` targets using different loader functions for the same pointer (`{ 'schema.graphql': { loader: fnA } }` vs `{ loader: fnB }`) both keyed as `{"schema.graphql":{}}` and the second silently reused the first's schema. Function values are now keyed by object identity (via the existing `getJsObjectId`), so distinct loaders load separately while one function reused across targets still loads once.

‎packages/graphql-codegen-cli/src/codegen.ts‎

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -87,17 +87,18 @@ export async function executeCodegen(
8787

8888
const cache = createCache();
8989

90-
// We need a simple string to uniqually identify the provided GraphQLSchema objects for the above cache.
90+
// We need a simple string to uniqually identify the provided objects (e.g. GraphQLSchema
91+
// instances passed inline, or custom loader functions) for the above cache.
9192
// Because JavaScript does not provide access to its internal object ids, we need a workaround.
9293
// Below is a common way to get unique ids for objects in JavaScript,
9394
// by using a WeakMap and autoincrementing the id.
94-
const jsObjectIds = new WeakMap<GraphQLSchema, number>();
95+
const jsObjectIds = new WeakMap<object, number>();
9596
let jsObjectIdCounter = 0;
96-
function getJsObjectId(schema: GraphQLSchema): number {
97-
if (!jsObjectIds.has(schema)) {
98-
jsObjectIds.set(schema, jsObjectIdCounter++);
97+
function getJsObjectId(obj: object): number {
98+
if (!jsObjectIds.has(obj)) {
99+
jsObjectIds.set(obj, jsObjectIdCounter++);
99100
}
100-
return jsObjectIds.get(schema)!;
101+
return jsObjectIds.get(obj)!;
101102
}
102103

103104
function wrapTask(task: () => void | Promise<void>, source: string, taskName: string, ctx: Ctx) {
@@ -290,8 +291,12 @@ export async function executeCodegen(
290291
}
291292

292293
const hash =
293-
JSON.stringify(schemaPointerMap) +
294-
parsedSchemas.map(getJsObjectId).join(',');
294+
// `JSON.stringify` drops functions, so custom loader functions
295+
// would all serialize to the same key and collide in the cache.
296+
// Key them by object identity instead, like `parsedSchemas` below.
297+
JSON.stringify(schemaPointerMap, (_key, value) =>
298+
typeof value === 'function' ? `[fn:${getJsObjectId(value)}]` : value,
299+
) + parsedSchemas.map(getJsObjectId).join(',');
295300
const result = await cache('schema', hash, async () => {
296301
// collect parsed schemas
297302
const schemasToMerge: GraphQLSchema[] = [...parsedSchemas];

‎packages/graphql-codegen-cli/tests/codegen.spec.ts‎

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -990,6 +990,66 @@ describe('Codegen Executor', () => {
990990
expect(error.message).toContain('Failed to load schema');
991991
expect(error.message).toContain('Failed to load custom loader');
992992
});
993+
994+
it('Should allow a loader to be a function passed directly', async () => {
995+
const { result } = await executeCodegen({
996+
schema: [
997+
{
998+
'schema.graphql': {
999+
loader: () => buildSchema(`type Query { fromFunctionLoader: String }`),
1000+
},
1001+
},
1002+
],
1003+
generates: {
1004+
'out1.ts': { plugins: ['typescript'] },
1005+
},
1006+
});
1007+
1008+
expect(result[0].content).toContain('fromFunctionLoader');
1009+
});
1010+
1011+
it('Should not collide distinct function loaders using the same pointer across outputs', async () => {
1012+
const { result } = await executeCodegen({
1013+
generates: {
1014+
'a.ts': {
1015+
schema: [
1016+
{ 'schema.graphql': { loader: () => buildSchema(`type Query { fromA: String }`) } },
1017+
],
1018+
plugins: ['typescript'],
1019+
},
1020+
'b.ts': {
1021+
schema: [
1022+
{ 'schema.graphql': { loader: () => buildSchema(`type Query { fromB: String }`) } },
1023+
],
1024+
plugins: ['typescript'],
1025+
},
1026+
},
1027+
});
1028+
1029+
const a = result.find(f => f.filename === 'a.ts').content;
1030+
const b = result.find(f => f.filename === 'b.ts').content;
1031+
expect(a).toContain('fromA');
1032+
expect(a).not.toContain('fromB');
1033+
expect(b).toContain('fromB');
1034+
expect(b).not.toContain('fromA');
1035+
});
1036+
1037+
it('Should call a function loader reused across outputs only once (cached by identity)', async () => {
1038+
let calls = 0;
1039+
const loader = () => {
1040+
calls++;
1041+
return buildSchema(`type Query { shared: String }`);
1042+
};
1043+
1044+
await executeCodegen({
1045+
generates: {
1046+
'a.ts': { schema: [{ 'schema.graphql': { loader } }], plugins: ['typescript'] },
1047+
'b.ts': { schema: [{ 'schema.graphql': { loader } }], plugins: ['typescript'] },
1048+
},
1049+
});
1050+
1051+
expect(calls).toBe(1);
1052+
});
9931053
});
9941054

9951055
describe('Custom documents loader', () => {

‎packages/utils/plugins-helpers/src/types.ts‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -74,15 +74,22 @@ export namespace Types {
7474
export type Promisable<T> = T | Promise<T>;
7575
export type InstanceOrArray<T> = T | T[];
7676

77+
export type CustomSchemaLoaderFn = (
78+
pointer: string,
79+
config: any,
80+
pointerOptionMap?: Record<string, any>,
81+
) => Promisable<GraphQLSchema | DocumentNode | Source | void>;
82+
7783
/**
7884
* @additionalProperties false
79-
* @description Loads schema using a pointer, with a custom loader (code file).
85+
* @description Loads schema using a pointer, with a custom loader (code file or function).
8086
*/
8187
export interface SchemaWithLoaderOptions {
8288
/**
83-
* @description Specify a path to a custom code file (local or module) that will handle the schema loading.
89+
* @description Specify a path to a custom code file (local or module), or a
90+
* custom loader function directly, that will handle the schema loading.
8491
*/
85-
loader: string;
92+
loader: string | CustomSchemaLoaderFn;
8693
}
8794
export interface SchemaWithLoader {
8895
[pointer: string]: SchemaWithLoaderOptions;

‎website/content/docs/config-reference/schema-field.mdx‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -567,6 +567,35 @@ module.exports = (schemaString, config) => {
567567
plugins.
568568
</Callout>
569569

570+
### Passing a Loader Function Directly
571+
572+
Instead of a path string, `loader` can also be a function defined directly in your config,
573+
typed with the `Types.CustomSchemaLoaderFn` type. This skips the file-path/`require()`
574+
resolution step, so there's no need for `ts-node/register` to transpile a loader file at
575+
runtime (see the note at the end of the previous section).
576+
577+
```ts
578+
import { CodegenConfig } from '@graphql-codegen/cli';
579+
import { Types } from '@graphql-codegen/plugin-helpers';
580+
import { buildSchema } from 'graphql';
581+
582+
const loader: Types.CustomSchemaLoaderFn = async (pointer, config) => {
583+
// Your logic for loading your GraphQLSchema
584+
return buildSchema(/* ... */);
585+
};
586+
587+
const config: CodegenConfig = {
588+
schema: [
589+
{
590+
'schema.graphql': {
591+
loader,
592+
},
593+
},
594+
],
595+
};
596+
export default config;
597+
```
598+
570599
### Loading API URL from TypeScript file example:
571600

572601
If you store your API config in a file, and don't want to repeat the URL in the codegen config. You can follow the following example:

0 commit comments

Comments
 (0)