Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .changeset/social-worms-report.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
'@graphql-codegen/gql-tag-operations': minor
'@graphql-codegen/visitor-plugin-common': minor
'@graphql-codegen/typescript-operations': minor
'@graphql-codegen/plugin-helpers': minor
'@graphql-codegen/cli': minor
'@graphql-codegen/client-preset': minor
---

Add support for `externalDocuments`

`externalDocuments` declares GraphQL documents that will be read but will not have type files generated for them. These documents are available to plugins for type resolution (e.g. fragment types), but no output files will be generated based on them. Accepts the same formats as `documents`.

This config option is useful for monorepos where each project may want to generate types for its own documents, but some may need to read shared fragments from across projects.
8 changes: 8 additions & 0 deletions dev-test/codegen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,14 @@ const config: CodegenConfig = {
},
},
},
// #region externalDocuments option
'./dev-test/external-documents/app/types.generated.ts': {
schema: './dev-test/external-documents/schema.graphqls',
documents: ['./dev-test/external-documents/app/*.graphql.ts'],
externalDocuments: ['./dev-test/external-documents/lib/*.graphql.ts'],
plugins: ['typescript-operations'],
},
// #endregion
},
};

Expand Down
8 changes: 8 additions & 0 deletions dev-test/external-documents/app/User.graphql.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/* GraphQL */ `
query User($id: ID!) {
user(id: $id) {
id
...UserFragment
}
}
`;
8 changes: 8 additions & 0 deletions dev-test/external-documents/app/types.generated.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export type UserQueryVariables = Exact<{
id: Scalars['ID']['input'];
}>;

export type UserQuery = {
__typename?: 'Query';
user?: { __typename?: 'User'; id: string; name: string; role: UserRole } | null;
};
7 changes: 7 additions & 0 deletions dev-test/external-documents/lib/UserFragment.graphql.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/* GraphQL */ `
fragment UserFragment on User {
id
name
role
}
`;
14 changes: 14 additions & 0 deletions dev-test/external-documents/schema.graphqls
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
type Query {
user(id: ID!): User
}

type User {
id: ID!
name: String!
role: UserRole!
}

enum UserRole {
ADMIN
CUSTOMER
}
129 changes: 101 additions & 28 deletions packages/graphql-codegen-cli/src/codegen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {
normalizeOutputParam,
Types,
} from '@graphql-codegen/plugin-helpers';
import { NoTypeDefinitionsFound } from '@graphql-tools/load';
import { NoTypeDefinitionsFound, type UnnormalizedTypeDefPointer } from '@graphql-tools/load';
import { mergeTypeDefs } from '@graphql-tools/merge';
import { CodegenContext, ensureContext } from './config.js';
import { getDocumentTransform } from './documentTransforms.js';
Expand Down Expand Up @@ -86,6 +86,7 @@ export async function executeCodegen(
let rootConfig: { [key: string]: any } = {};
let rootSchemas: Types.Schema[];
let rootDocuments: Types.OperationDocument[];
let rootExternalDocuments: Types.OperationDocument[];
const generates: { [filename: string]: Types.ConfiguredOutput } = {};

const cache = createCache();
Expand Down Expand Up @@ -136,6 +137,11 @@ export async function executeCodegen(
/* Normalize root "documents" field */
rootDocuments = normalizeInstanceOrArray<Types.OperationDocument>(config.documents);

/* Normalize root "externalDocuments" field */
rootExternalDocuments = normalizeInstanceOrArray<Types.OperationDocument>(
config.externalDocuments,
);

/* Normalize "generators" field */
const generateKeys = Object.keys(config.generates || {});

Expand Down Expand Up @@ -228,13 +234,15 @@ export async function executeCodegen(
let outputSchemaAst: GraphQLSchema;
let outputSchema: DocumentNode;
const outputFileTemplateConfig = outputConfig.config || {};
let outputDocuments: Types.DocumentFile[] = [];
const outputDocuments: Types.DocumentFile[] = [];
const outputSpecificSchemas = normalizeInstanceOrArray<Types.Schema>(
outputConfig.schema,
);
let outputSpecificDocuments = normalizeInstanceOrArray<Types.OperationDocument>(
outputConfig.documents,
);
let outputSpecificExternalDocuments =
normalizeInstanceOrArray<Types.OperationDocument>(outputConfig.externalDocuments);

const preset: Types.OutputPreset | null = hasPreset
? typeof outputConfig.preset === 'string'
Expand All @@ -247,6 +255,10 @@ export async function executeCodegen(
filename,
outputSpecificDocuments,
);
outputSpecificExternalDocuments = await preset.prepareDocuments(
filename,
outputSpecificExternalDocuments,
);
}

return subTask.newListr(
Expand Down Expand Up @@ -308,41 +320,102 @@ export async function executeCodegen(
task: wrapTask(
async () => {
debugLog(`[CLI] Loading Documents`);
const documentPointerMap: any = {};

const populateDocumentPointerMap = (
allDocumentsDenormalizedPointers: Types.OperationDocument[],
): UnnormalizedTypeDefPointer => {
const pointer: UnnormalizedTypeDefPointer = {};
for (const denormalizedPtr of allDocumentsDenormalizedPointers) {
if (typeof denormalizedPtr === 'string') {
pointer[denormalizedPtr] = {};
} else if (typeof denormalizedPtr === 'object') {
Object.assign(pointer, denormalizedPtr);
}
}
return pointer;
};

const allDocumentsDenormalizedPointers = [
...rootDocuments,
...outputSpecificDocuments,
];
for (const denormalizedPtr of allDocumentsDenormalizedPointers) {
if (typeof denormalizedPtr === 'string') {
documentPointerMap[denormalizedPtr] = {};
} else if (typeof denormalizedPtr === 'object') {
Object.assign(documentPointerMap, denormalizedPtr);
}
}
const documentPointerMap = populateDocumentPointerMap(
allDocumentsDenormalizedPointers,
);

const hash = JSON.stringify(documentPointerMap);
const result = await cache('documents', hash, async () => {
try {
const documents = await context.loadDocuments(documentPointerMap);
return {
documents,
};
} catch (error: any) {
if (
error instanceof NoTypeDefinitionsFound &&
config.ignoreNoDocuments
) {
return {
documents: [],
};
const outputDocumentsStandard = await cache(
'documents',
hash,
async (): Promise<Types.DocumentFile[]> => {
try {
const documents = await context.loadDocuments(
documentPointerMap,
'standard',
);
return documents;
} catch (error) {
if (
error instanceof NoTypeDefinitionsFound &&
config.ignoreNoDocuments
) {
return [];
}
throw error;
}
},
);

const allExternalDocumentsDenormalizedPointers = [
...rootExternalDocuments,
...outputSpecificExternalDocuments,
];

const externalDocumentsPointerMap = populateDocumentPointerMap(
allExternalDocumentsDenormalizedPointers,
);

const externalDocumentHash = JSON.stringify(externalDocumentsPointerMap);
const outputExternalDocuments = await cache(
'documents',
externalDocumentHash,
async (): Promise<Types.DocumentFile[]> => {
try {
const documents = await context.loadDocuments(
externalDocumentsPointerMap,
'external',
);
return documents;
} catch (error) {
if (
error instanceof NoTypeDefinitionsFound &&
config.ignoreNoDocuments
) {
return [];
}
throw error;
}
},
);

throw error;
/**
* Merging `standard` and `external` documents here,
* so they can be processed the same way,
* before passed into presets and plugins
*/
const processedFile: Record<string, true> = {};
const mergedDocuments = [
...outputDocumentsStandard,
...outputExternalDocuments,
];
for (const file of mergedDocuments) {
if (processedFile[file.hash]) {
continue;
}
});

outputDocuments = result.documents;
outputDocuments.push(file);
processedFile[file.hash] = true;
}
Comment on lines +406 to +418
Copy link
Copy Markdown
Collaborator

@eddeee888 eddeee888 Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've considered forwarding documentsReadOnly as-is but hit a few annoying cases:

  • documents are processed a lot after the initial loading stage here. If we kept documentsReadOnly separate, we'd need to run the same functions over them
  • Plugins use positional param, which means documentsReadOnly need to be at the last position to avoid a breaking change. This works, but feels disconnected

For this reason, I feel it's easier if we merge them as early as possible, and run the same processing over both types, and plugins like typescript-operations can handle the filtering without having to access another param

},
filename,
`Load GraphQL documents: ${filename}`,
Expand Down Expand Up @@ -437,7 +510,7 @@ export async function executeCodegen(
pluginContext,
profiler: context.profiler,
documentTransforms,
},
} satisfies Types.GenerateOptions,
];

const process = async (outputArgs: Types.GenerateOptions) => {
Expand Down
42 changes: 25 additions & 17 deletions packages/graphql-codegen-cli/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { createRequire } from 'module';
import { resolve } from 'path';
import { cosmiconfig, defaultLoaders } from 'cosmiconfig';
import { GraphQLSchema, GraphQLSchemaExtensions, print } from 'graphql';
import { GraphQLConfig } from 'graphql-config';
import { GraphQLConfig, type Source } from 'graphql-config';
import { createJiti } from 'jiti';
import { env } from 'string-env-interpolation';
import yaml from 'yaml';
Expand All @@ -16,6 +16,7 @@ import {
Profiler,
Types,
} from '@graphql-codegen/plugin-helpers';
import type { UnnormalizedTypeDefPointer } from '@graphql-tools/load';
import { findAndLoadGraphQLConfig } from './graphql-config.js';
import {
defaultDocumentsLoadOptions,
Expand Down Expand Up @@ -473,18 +474,22 @@ export class CodegenContext {
return addHashToSchema(loadSchema(pointer, config));
}

async loadDocuments(pointer: Types.OperationDocument[]): Promise<Types.DocumentFile[]> {
async loadDocuments(
pointer: UnnormalizedTypeDefPointer,
type: 'standard' | 'external',
): Promise<Types.DocumentFile[]> {
const config = this.getConfig(defaultDocumentsLoadOptions);
if (this._graphqlConfig) {
// TODO: pointer won't work here
return addHashToDocumentFiles(
return addMetadataToSources(
this._graphqlConfig
.getProject(this._project)
.loadDocuments(pointer, { ...config, ...config.config }),
type,
);
}

return addHashToDocumentFiles(loadDocuments(pointer, config));
return addMetadataToSources(loadDocuments(pointer, config), type);
}
}

Expand All @@ -511,24 +516,27 @@ function addHashToSchema(schemaPromise: Promise<GraphQLSchema>): Promise<GraphQL
});
}

function hashDocument(doc: Types.DocumentFile) {
if (doc.rawSDL) {
return hashContent(doc.rawSDL);
}
async function addMetadataToSources(
documentFilesPromise: Promise<Source[]>,
type: 'standard' | 'external',
): Promise<Types.DocumentFile[]> {
function hashDocument(doc: Source): string | null {
if (doc.rawSDL) {
return hashContent(doc.rawSDL);
}

if (doc.document) {
return hashContent(print(doc.document));
}
if (doc.document) {
return hashContent(print(doc.document));
}

return null;
}
return null;
}

function addHashToDocumentFiles(
documentFilesPromise: Promise<Types.DocumentFile[]>,
): Promise<Types.DocumentFile[]> {
return documentFilesPromise.then(documentFiles =>
documentFiles.map(doc => {
// Note: `doc` here is technically `Source`, but by the end of the funciton it's `Types.DocumentFile`. This re-declaration makes TypeScript happy.
documentFiles.map((doc: Types.DocumentFile): Types.DocumentFile => {
doc.hash = hashDocument(doc);
doc.type = type;

return doc;
}),
Expand Down
3 changes: 2 additions & 1 deletion packages/graphql-codegen-cli/src/load.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { extname, join } from 'path';
import { GraphQLError, GraphQLSchema } from 'graphql';
import type { Source } from 'graphql-config';
import { Types } from '@graphql-codegen/plugin-helpers';
import { ApolloEngineLoader } from '@graphql-tools/apollo-engine-loader';
import { CodeFileLoader } from '@graphql-tools/code-file-loader';
Expand Down Expand Up @@ -69,7 +70,7 @@ export async function loadSchema(
export async function loadDocuments(
documentPointers: UnnormalizedTypeDefPointer | UnnormalizedTypeDefPointer[],
config: Types.Config,
): Promise<Types.DocumentFile[]> {
): Promise<Source[]> {
const loaders = [
new CodeFileLoader({
pluckConfig: {
Expand Down
Loading
Loading