Skip to content

Commit 4f290aa

Browse files
feat: add support for metadata and persisted operations in client preset (#8757)
* feat: add support for persisted operations * always do cleanup * crypto ftw * sanitize document * stable print and config options * feat: only include executable documents in persisted operations map * feat: stable print * fix shit * feat: new __meta__ approach * test: embed metadata in document node * refactor: use Map and remove hacks * docs: update react-query instructions * add changesets * feat: update to stable package version * chore(dependencies): updated changesets for modified dependencies Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
1 parent 5bc753d commit 4f290aa

File tree

10 files changed

+955
-134
lines changed

10 files changed

+955
-134
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@graphql-codegen/client-preset": patch
3+
---
4+
dependencies updates:
5+
- Added dependency [`@graphql-tools/documents@^0.1.0` ↗︎](https://www.npmjs.com/package/@graphql-tools/documents/v/0.1.0) (to `dependencies`)

.changeset/lemon-zebras-hunt.md

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
---
2+
'@graphql-codegen/client-preset': minor
3+
---
4+
5+
Add support for persisted documents.
6+
7+
You can now generate and embed a persisted documents hash for the executable documents.
8+
9+
```ts
10+
/** codegen.ts */
11+
import { CodegenConfig } from '@graphql-codegen/cli'
12+
13+
const config: CodegenConfig = {
14+
schema: 'https://swapi-graphql.netlify.app/.netlify/functions/index',
15+
documents: ['src/**/*.tsx'],
16+
ignoreNoDocuments: true, // for better experience with the watcher
17+
generates: {
18+
'./src/gql/': {
19+
preset: 'client',
20+
plugins: [],
21+
presetConfig: {
22+
persistedOperations: true,
23+
}
24+
}
25+
}
26+
}
27+
28+
export default config
29+
```
30+
31+
This will generate `./src/gql/persisted-documents.json` (dictionary of hashes with their operation string).
32+
33+
In addition to that each generated document node will have a `__meta__.hash` property.
34+
35+
```ts
36+
import { gql } from './gql.js'
37+
38+
const allFilmsWithVariablesQueryDocument = graphql(/* GraphQL */ `
39+
query allFilmsWithVariablesQuery($first: Int!) {
40+
allFilms(first: $first) {
41+
edges {
42+
node {
43+
...FilmItem
44+
}
45+
}
46+
}
47+
}
48+
`)
49+
50+
console.log((allFilmsWithVariablesQueryDocument as any)["__meta__"]["hash"])
51+
```
52+

.changeset/tasty-adults-doubt.md

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
---
2+
'@graphql-codegen/client-preset': minor
3+
---
4+
5+
Add support for embedding metadata in the document AST.
6+
7+
It is now possible to embed metadata (e.g. for your GraphQL client within the emitted code).
8+
9+
```ts
10+
/** codegen.ts */
11+
import { CodegenConfig } from '@graphql-codegen/cli'
12+
13+
const config: CodegenConfig = {
14+
schema: 'https://swapi-graphql.netlify.app/.netlify/functions/index',
15+
documents: ['src/**/*.tsx'],
16+
ignoreNoDocuments: true, // for better experience with the watcher
17+
generates: {
18+
'./src/gql/': {
19+
preset: 'client',
20+
plugins: [],
21+
presetConfig: {
22+
onExecutableDocumentNode(documentNode) {
23+
return {
24+
operation: documentNode.definitions[0].operation,
25+
name: documentNode.definitions[0].name.value
26+
}
27+
}
28+
}
29+
}
30+
}
31+
}
32+
33+
export default config
34+
```
35+
36+
You can then access the metadata via the `__meta__` property on the document node.
37+
38+
```ts
39+
import { gql } from './gql.js'
40+
41+
const allFilmsWithVariablesQueryDocument = graphql(/* GraphQL */ `
42+
query allFilmsWithVariablesQuery($first: Int!) {
43+
allFilms(first: $first) {
44+
edges {
45+
node {
46+
...FilmItem
47+
}
48+
}
49+
}
50+
}
51+
`)
52+
53+
console.log((allFilmsWithVariablesQueryDocument as any)["__meta__"])
54+
```

packages/plugins/other/visitor-plugin-common/src/client-side-base-visitor.ts

Lines changed: 79 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import autoBind from 'auto-bind';
55
import { pascalCase } from 'change-case-all';
66
import { DepGraph } from 'dependency-graph';
77
import {
8+
DefinitionNode,
9+
DocumentNode,
810
FragmentDefinitionNode,
911
FragmentSpreadNode,
1012
GraphQLSchema,
@@ -228,8 +230,14 @@ export interface ClientSideBasePluginConfig extends ParsedConfig {
228230
pureMagicComment?: boolean;
229231
optimizeDocumentNode: boolean;
230232
experimentalFragmentVariables?: boolean;
233+
unstable_onExecutableDocumentNode?: Unstable_OnExecutableDocumentNode;
234+
unstable_omitDefinitions?: boolean;
231235
}
232236

237+
type ExecutableDocumentNodeMeta = Record<string, unknown>;
238+
239+
type Unstable_OnExecutableDocumentNode = (documentNode: DocumentNode) => void | ExecutableDocumentNodeMeta;
240+
233241
export class ClientSideBaseVisitor<
234242
TRawConfig extends RawClientSideBasePluginConfig = RawClientSideBasePluginConfig,
235243
TPluginConfig extends ClientSideBasePluginConfig = ClientSideBasePluginConfig
@@ -239,9 +247,13 @@ export class ClientSideBaseVisitor<
239247
protected _additionalImports: string[] = [];
240248
protected _imports = new Set<string>();
241249

250+
private _onExecutableDocumentNode?: Unstable_OnExecutableDocumentNode;
251+
private _omitDefinitions?: boolean;
252+
private _fragments: Map<string, LoadedFragment>;
253+
242254
constructor(
243255
protected _schema: GraphQLSchema,
244-
protected _fragments: LoadedFragment[],
256+
fragments: LoadedFragment[],
245257
rawConfig: TRawConfig,
246258
additionalConfig: Partial<TPluginConfig>,
247259
documents?: Types.DocumentFile[]
@@ -271,9 +283,10 @@ export class ClientSideBaseVisitor<
271283
experimentalFragmentVariables: getConfigValue(rawConfig.experimentalFragmentVariables, false),
272284
...additionalConfig,
273285
} as any);
274-
275286
this._documents = documents;
276-
287+
this._onExecutableDocumentNode = (rawConfig as any).unstable_onExecutableDocumentNode;
288+
this._omitDefinitions = (rawConfig as any).unstable_omitDefinitions;
289+
this._fragments = new Map(fragments.map(fragment => [fragment.name, fragment]));
277290
autoBind(this);
278291
}
279292

@@ -293,7 +306,7 @@ export class ClientSideBaseVisitor<
293306
names.add(node.name.value);
294307

295308
if (withNested) {
296-
const foundFragment = this._fragments.find(f => f.name === node.name.value);
309+
const foundFragment = this._fragments.get(node.name.value);
297310

298311
if (foundFragment) {
299312
const childItems = this._extractFragments(foundFragment.node, true);
@@ -312,20 +325,14 @@ export class ClientSideBaseVisitor<
312325
return Array.from(names);
313326
}
314327

315-
protected _transformFragments(document: FragmentDefinitionNode | OperationDefinitionNode): string[] {
316-
const includeNestedFragments =
317-
this.config.documentMode === DocumentMode.documentNode ||
318-
(this.config.dedupeFragments && document.kind === 'OperationDefinition');
319-
320-
return this._extractFragments(document, includeNestedFragments).map(document =>
321-
this.getFragmentVariableName(document)
322-
);
328+
protected _transformFragments(fragmentNames: Array<string>): string[] {
329+
return fragmentNames.map(document => this.getFragmentVariableName(document));
323330
}
324331

325332
protected _includeFragments(fragments: string[], nodeKind: 'FragmentDefinition' | 'OperationDefinition'): string {
326333
if (fragments && fragments.length > 0) {
327334
if (this.config.documentMode === DocumentMode.documentNode) {
328-
return this._fragments
335+
return Array.from(this._fragments.values())
329336
.filter(f => fragments.includes(this.getFragmentVariableName(f.name)))
330337
.map(fragment => print(fragment.node))
331338
.join('\n');
@@ -346,8 +353,35 @@ export class ClientSideBaseVisitor<
346353
return documentStr;
347354
}
348355

356+
private _generateDocumentNodeMeta(
357+
definitions: ReadonlyArray<DefinitionNode>,
358+
fragmentNames: Array<string>
359+
): ExecutableDocumentNodeMeta | void {
360+
// If the document does not contain any executable operation, we don't need to hash it
361+
if (definitions.every(def => def.kind !== Kind.OPERATION_DEFINITION)) {
362+
return undefined;
363+
}
364+
365+
const allDefinitions = [...definitions];
366+
367+
for (const fragment of fragmentNames) {
368+
const fragmentRecord = this._fragments.get(fragment);
369+
if (fragmentRecord) {
370+
allDefinitions.push(fragmentRecord.node);
371+
}
372+
}
373+
374+
const documentNode: DocumentNode = { kind: Kind.DOCUMENT, definitions: allDefinitions };
375+
376+
return this._onExecutableDocumentNode(documentNode);
377+
}
378+
349379
protected _gql(node: FragmentDefinitionNode | OperationDefinitionNode): string {
350-
const fragments = this._transformFragments(node);
380+
const includeNestedFragments =
381+
this.config.documentMode === DocumentMode.documentNode ||
382+
(this.config.dedupeFragments && node.kind === 'OperationDefinition');
383+
const fragmentNames = this._extractFragments(node, includeNestedFragments);
384+
const fragments = this._transformFragments(fragmentNames);
351385

352386
const doc = this._prepareDocument(`
353387
${print(node).split('\\').join('\\\\') /* Re-escape escaped values in GraphQL syntax */}
@@ -375,11 +409,39 @@ export class ClientSideBaseVisitor<
375409
...fragments.map(name => `...${name}.definitions`),
376410
].join();
377411

378-
return `{"kind":"${Kind.DOCUMENT}","definitions":[${definitions}]}`;
412+
let hashPropertyStr = '';
413+
414+
if (this._onExecutableDocumentNode) {
415+
const meta = this._generateDocumentNodeMeta(gqlObj.definitions, fragmentNames);
416+
if (meta) {
417+
hashPropertyStr = `"__meta__": ${JSON.stringify(meta)}, `;
418+
if (this._omitDefinitions === true) {
419+
return `{${hashPropertyStr}}`;
420+
}
421+
}
422+
}
423+
424+
return `{${hashPropertyStr}"kind":"${Kind.DOCUMENT}", "definitions":[${definitions}]}`;
425+
}
426+
427+
let meta: ExecutableDocumentNodeMeta | void;
428+
429+
if (this._onExecutableDocumentNode) {
430+
meta = this._generateDocumentNodeMeta(gqlObj.definitions, fragmentNames);
431+
const metaNodePartial = { ['__meta__']: meta };
432+
433+
if (this._omitDefinitions === true) {
434+
return JSON.stringify(metaNodePartial);
435+
}
436+
437+
if (meta) {
438+
return JSON.stringify({ ...metaNodePartial, ...gqlObj });
439+
}
379440
}
380441

381442
return JSON.stringify(gqlObj);
382443
}
444+
383445
if (this.config.documentMode === DocumentMode.string) {
384446
return '`' + doc + '`';
385447
}
@@ -411,7 +473,7 @@ export class ClientSideBaseVisitor<
411473
private get fragmentsGraph(): DepGraph<LoadedFragment> {
412474
const graph = new DepGraph<LoadedFragment>({ circular: true });
413475

414-
for (const fragment of this._fragments) {
476+
for (const fragment of this._fragments.values()) {
415477
if (graph.hasNode(fragment.name)) {
416478
const cachedAsString = print(graph.getNodeData(fragment.name).node);
417479
const asString = print(fragment.node);
@@ -438,7 +500,7 @@ export class ClientSideBaseVisitor<
438500
}
439501

440502
public get fragments(): string {
441-
if (this._fragments.length === 0 || this.config.documentMode === DocumentMode.external) {
503+
if (this._fragments.size === 0 || this.config.documentMode === DocumentMode.external) {
442504
return '';
443505
}
444506

packages/presets/client/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
"@graphql-codegen/plugin-helpers": "^3.1.2",
2828
"@graphql-codegen/visitor-plugin-common": "^2.13.7",
2929
"@graphql-typed-document-node/core": "3.1.1",
30+
"@graphql-tools/documents": "^0.1.0",
3031
"@graphql-tools/utils": "^9.0.0",
3132
"tslib": "~2.4.0"
3233
},

0 commit comments

Comments
 (0)