Last active
November 23, 2024 07:15
-
-
Save osdiab/054c4e1c9c7404edd87a38728622df3f to your computer and use it in GitHub Desktop.
fragment masking with graphql-codegen near-operation-file preset
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import type { PluginFunction } from "@graphql-codegen/plugin-helpers"; | |
export type FragmentMaskingPluginConfig = { | |
useTypeImports?: boolean; | |
augmentedModuleName?: string; | |
unmaskFunctionName?: string; | |
emitLegacyCommonJSImports?: boolean; | |
isStringDocumentMode?: boolean; | |
typesImportPath?: string; // this is new | |
}; | |
const fragmentTypeHelper = ` | |
export type FragmentType<TDocumentType extends DocumentTypeDecoration<any, any>> = TDocumentType extends DocumentTypeDecoration< | |
infer TType, | |
any | |
> | |
? [TType] extends [{ ' $fragmentName'?: infer TKey }] | |
? TKey extends string | |
? { ' $fragmentRefs'?: { [key in TKey]: TType } } | |
: never | |
: never | |
: never;`; | |
const makeFragmentDataHelper = ` | |
export function makeFragmentData< | |
F extends DocumentTypeDecoration<any, any>, | |
FT extends ResultOf<F> | |
>(data: FT, _fragment: F): FragmentType<F> { | |
return data as FragmentType<F>; | |
}`; | |
const defaultUnmaskFunctionName = "useFragment"; | |
const createUnmaskFunctionTypeDefinitions = ( | |
unmaskFunctionName = defaultUnmaskFunctionName, | |
) => [ | |
`// return non-nullable if \`fragmentType\` is non-nullable | |
export function ${unmaskFunctionName}<TType>( | |
_documentNode: DocumentTypeDecoration<TType, any>, | |
fragmentType: FragmentType<DocumentTypeDecoration<TType, any>> | |
): TType;`, | |
`// return nullable if \`fragmentType\` is undefined | |
export function ${unmaskFunctionName}<TType>( | |
_documentNode: DocumentTypeDecoration<TType, any>, | |
fragmentType: FragmentType<DocumentTypeDecoration<TType, any>> | undefined | |
): TType | undefined;`, | |
`// return nullable if \`fragmentType\` is nullable | |
export function ${unmaskFunctionName}<TType>( | |
_documentNode: DocumentTypeDecoration<TType, any>, | |
fragmentType: FragmentType<DocumentTypeDecoration<TType, any>> | null | |
): TType | null;`, | |
`// return nullable if \`fragmentType\` is nullable or undefined | |
export function ${unmaskFunctionName}<TType>( | |
_documentNode: DocumentTypeDecoration<TType, any>, | |
fragmentType: FragmentType<DocumentTypeDecoration<TType, any>> | null | undefined | |
): TType | null | undefined;`, | |
`// return array of non-nullable if \`fragmentType\` is array of non-nullable | |
export function ${unmaskFunctionName}<TType>( | |
_documentNode: DocumentTypeDecoration<TType, any>, | |
fragmentType: Array<FragmentType<DocumentTypeDecoration<TType, any>>> | |
): Array<TType>;`, | |
`// return array of nullable if \`fragmentType\` is array of nullable | |
export function ${unmaskFunctionName}<TType>( | |
_documentNode: DocumentTypeDecoration<TType, any>, | |
fragmentType: Array<FragmentType<DocumentTypeDecoration<TType, any>>> | null | undefined | |
): Array<TType> | null | undefined;`, | |
`// return readonly array of non-nullable if \`fragmentType\` is array of non-nullable | |
export function ${unmaskFunctionName}<TType>( | |
_documentNode: DocumentTypeDecoration<TType, any>, | |
fragmentType: ReadonlyArray<FragmentType<DocumentTypeDecoration<TType, any>>> | |
): ReadonlyArray<TType>;`, | |
`// return readonly array of nullable if \`fragmentType\` is array of nullable | |
export function ${unmaskFunctionName}<TType>( | |
_documentNode: DocumentTypeDecoration<TType, any>, | |
fragmentType: ReadonlyArray<FragmentType<DocumentTypeDecoration<TType, any>>> | null | undefined | |
): ReadonlyArray<TType> | null | undefined;`, | |
]; | |
const createUnmaskFunction = ( | |
unmaskFunctionName = defaultUnmaskFunctionName, | |
) => ` | |
${createUnmaskFunctionTypeDefinitions(unmaskFunctionName).join("\n")} | |
export function ${unmaskFunctionName}<TType>( | |
_documentNode: DocumentTypeDecoration<TType, any>, | |
fragmentType: FragmentType<DocumentTypeDecoration<TType, any>> | Array<FragmentType<DocumentTypeDecoration<TType, any>>> | ReadonlyArray<FragmentType<DocumentTypeDecoration<TType, any>>> | null | undefined | |
): TType | Array<TType> | ReadonlyArray<TType> | null | undefined { | |
return fragmentType as any; | |
} | |
`; | |
const isFragmentReadyFunction = (isStringDocumentMode: boolean) => { | |
if (isStringDocumentMode) { | |
return `\ | |
export function isFragmentReady<TQuery, TFrag>( | |
queryNode: TypedDocumentString<TQuery, any>, | |
fragmentNode: TypedDocumentString<TFrag, any>, | |
data: FragmentType<TypedDocumentString<Incremental<TFrag>, any>> | null | undefined | |
): data is FragmentType<typeof fragmentNode> { | |
const deferredFields = queryNode.__meta__?.deferredFields as Record<string, (keyof TFrag)[]>; | |
const fragName = fragmentNode.__meta__?.fragmentName as string | undefined; | |
if (!deferredFields || !fragName) return true; | |
const fields = deferredFields[fragName] ?? []; | |
return fields.length > 0 && fields.every(field => data && field in data); | |
} | |
`; | |
} | |
return `\ | |
export function isFragmentReady<TQuery, TFrag>( | |
queryNode: DocumentTypeDecoration<TQuery, any>, | |
fragmentNode: TypedDocumentNode<TFrag>, | |
data: FragmentType<TypedDocumentNode<Incremental<TFrag>, any>> | null | undefined | |
): data is FragmentType<typeof fragmentNode> { | |
const deferredFields = (queryNode as { __meta__?: { deferredFields: Record<string, (keyof TFrag)[]> } }).__meta__ | |
?.deferredFields; | |
if (!deferredFields) return true; | |
const fragDef = fragmentNode.definitions[0] as FragmentDefinitionNode | undefined; | |
const fragName = fragDef?.name?.value; | |
const fields = (fragName && deferredFields[fragName]) || []; | |
return fields.length > 0 && fields.every(field => data && field in data); | |
} | |
`; | |
}; | |
/** | |
* Plugin for generating fragment masking helper functions. | |
*/ | |
export const plugin: PluginFunction<FragmentMaskingPluginConfig> = ( | |
_1, | |
_2, | |
{ | |
useTypeImports, | |
augmentedModuleName, | |
unmaskFunctionName, | |
emitLegacyCommonJSImports, | |
isStringDocumentMode = false, | |
typesImportPath, | |
}, | |
_info, | |
) => { | |
const documentNodeImport = `${ | |
useTypeImports ? "import type" : "import" | |
} { ResultOf, DocumentTypeDecoration${ | |
isStringDocumentMode ? "" : ", TypedDocumentNode" | |
} } from '@graphql-typed-document-node/core';\n`; | |
const deferFragmentHelperImports = `${ | |
useTypeImports ? "import type" : "import" | |
} { Incremental${ | |
isStringDocumentMode ? ", TypedDocumentString" : "" | |
} } from '${ | |
typesImportPath ?? `./graphql${emitLegacyCommonJSImports ? "" : ".js"}` | |
}';\n`; | |
const fragmentDefinitionNodeImport = isStringDocumentMode | |
? "" | |
: `${ | |
useTypeImports ? "import type" : "import" | |
} { FragmentDefinitionNode } from 'graphql';\n`; | |
if (augmentedModuleName == null) { | |
return [ | |
documentNodeImport, | |
fragmentDefinitionNodeImport, | |
deferFragmentHelperImports, | |
`\n`, | |
fragmentTypeHelper, | |
`\n`, | |
createUnmaskFunction(unmaskFunctionName), | |
`\n`, | |
makeFragmentDataHelper, | |
`\n`, | |
isFragmentReadyFunction(isStringDocumentMode), | |
].join(``); | |
} | |
return [ | |
documentNodeImport, | |
`declare module "${augmentedModuleName}" {`, | |
[ | |
...fragmentTypeHelper.split(`\n`), | |
`\n`, | |
...createUnmaskFunctionTypeDefinitions(unmaskFunctionName) | |
.join("\n") | |
.split("\n"), | |
`\n`, | |
makeFragmentDataHelper, | |
] | |
.map((line) => (line === `\n` || line === "" ? line : ` ${line}`)) | |
.join(`\n`), | |
`}`, | |
].join(`\n`); | |
}; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// this is a graphql codegen config specifically for operations, meaning queries and mutations documents | |
import type { CodegenConfig } from "@graphql-codegen/cli"; | |
import type { TypeScriptDocumentsPluginConfig } from "@graphql-codegen/typescript-operations"; | |
import type { TypeScriptTypedDocumentNodesConfig } from "@graphql-codegen/typed-document-node"; | |
const typescriptOperationsConfig: TypeScriptDocumentsPluginConfig = { | |
inlineFragmentTypes: "mask", // this undocumented flag makes the generated operations produce masked fragments | |
// whatever else you need in here, e.g. changing scalar types and what not | |
}; | |
const typedDocumentNodeConfig: TypeScriptTypedDocumentNodesConfig = {}; | |
const config: CodegenConfig = { | |
overwrite: true, | |
schema: [ | |
// just get the schema from the generated file from the schema config; | |
// if you configure it some other way, change this to wherever your schema actually is | |
"./gen/gql-schema/schema.graphql", | |
], | |
// it's useful to only watch for your gql documents so that you can just regenerate the giant | |
// types file when your schema actually changes, and only regenerate operations files when | |
// the queries and mutations actually change | |
documents: ["./src/**/*.graphql"], | |
generates: { | |
"gen/graphql/": { | |
preset: "near-operation-file", | |
plugins: [ | |
{ "typescript-operations": typescriptOperationsConfig }, | |
{ "typed-document-node": typedDocumentNodeConfig }, | |
// add whatever plugins you need here; e.g. we use the add plugin | |
// to import some custom types that give us more specific scalars | |
], | |
presetConfig: { | |
// The double ~ is because in my codebase we export the generated graphql types to a | |
// gen/graphql folder at our project root and alias the `gen/` folder to a `~gen/` | |
// path in our tsconfig.json, but for some reason graphql-codegen uses the ~ character | |
// to signify the root of the project, so we end up with this silly looking double ~. | |
// For your own project you'll want to modify this to match wherever you generate your types | |
baseTypesPath: "~~gen/graphql/types", | |
extension: ".gen-graphql.ts", // this is optional, just the convention we use | |
}, | |
}, | |
}, | |
}; | |
export default config; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
{ | |
"scripts": [ | |
// we use pnpm, change these to match whatever you use | |
"gen:gql:schema": "graphql-codegen --config schema-config.ts", | |
"gen:gql:operations": "graphql-codegen --config operations-config.ts", | |
"gen:gql": "pnpm gen:gql:schema && pnpm gen:gql:operations", | |
"dev:gql": "pnpm gen:gql:operations --watch" | |
// In our codebase we also have a dev:gql:schema command, because we use Hasura, and whenever the | |
// graphql schema it generates changes, it also updates some files locally; we use chokidar-cli | |
// to retrigger the gen:gql:schema command whenever that file changes. | |
// That's specific to our setup so i've removed that from this gist, but that can serve as inspiration | |
// for however you want to watch for changes to your own remote schema if you use that type of setup | |
// (e.g. polling or something else) | |
], | |
"devDependencies": { | |
"@graphql-codegen/cli": "^5.0.3", | |
"@graphql-codegen/introspection": "3.0.0", | |
"@graphql-codegen/near-operation-file-preset": "^3.0.0", | |
"@graphql-codegen/plugin-helpers": "^5.1.0", | |
"@graphql-codegen/schema-ast": "^4.1.0", | |
"@graphql-codegen/typed-document-node": "^5.0.11", | |
"@graphql-codegen/typescript": "^4.1.1", | |
"@graphql-codegen/typescript-operations": "^4.3.1", | |
"@graphql-typed-document-node/core": "^3.2.0" | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import type { CodegenConfig } from "@graphql-codegen/cli"; | |
import type { FragmentMaskingPluginConfig } from "./fragment-masking-plugin"; | |
import "./fragment-masking-plugin"; // need this or else plugin fails to be loaded by codegen CLI | |
export const typescriptPluginConfig: TypeScriptPluginConfig = { | |
inlineFragmentTypes: "mask", | |
// whatever else you need in here, e.g. changing scalar types and what not | |
}; | |
const fragmentMaskingPluginConfig: FragmentMaskingPluginConfig = { | |
// i added this config because in my project we now control where the types are | |
// located relative to the rest of the generated graphql, so we need control | |
typesImportPath: "~gen/graphql/types", | |
}; | |
const config: CodegenConfig = { | |
overwrite: true, | |
schema: [ | |
// TODO: add wherever you get your actual graphql schema, for us it's a remote source | |
// in addition to some schema overrides | |
], | |
ignoreNoDocuments: true, | |
generates: { | |
// if your schema is produced some other way like manually in your codebase, you won't | |
// need this one | |
"gen/gql-schema/schema.graphql": { | |
plugins: ["schema-ast"], | |
}, | |
"gen/graphql/types.ts": { | |
plugins: [ | |
{ typescript: typescriptPluginConfig }, | |
// add whatever plugins you need here; e.g. we use the add plugin | |
// to import some custom types that give us more specific scalars | |
], | |
}, | |
"gen/graphql/fragment-masking.ts": { | |
plugins: [ | |
{ | |
"gql-codegen/fragment-masking-plugin.ts": fragmentMaskingPluginConfig, | |
}, | |
], | |
}, | |
}, | |
}; | |
export default config; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment