diff --git a/.changeset/typed-prepare-variables.md b/.changeset/typed-prepare-variables.md new file mode 100644 index 0000000..d2705f3 --- /dev/null +++ b/.changeset/typed-prepare-variables.md @@ -0,0 +1,5 @@ +--- +"@shopify/shopify_function": minor +--- + +Add an opt-in codegen plugin that types prepare result JSON fields with the matching run query's generated variables type. diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..d11fc5d --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,21 @@ +name: Test + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "24" + cache: npm + - run: npm ci + - run: npm test diff --git a/README.md b/README.md index a70fa53..7957edf 100644 --- a/README.md +++ b/README.md @@ -3,3 +3,117 @@ This repository contains the source code for the [`@shopify/shopify_function`][package] npm package. [package]: https://npm.im/@shopify/shopify_function + +## Typed prepare variables + +A prepare target returns variables for its run target's input query. By default, +that result field is an untyped JSON scalar. The opt-in +`@shopify/shopify_function/prepare-variables.cjs` codegen plugin gives it the +run operation's generated variables type instead. + +### Configure codegen + +Replace **both** `typescript` and `typescript-operations` in your function's +`package.json` codegen configuration with this plugin: + +```json +{ + "codegen": { + "schema": "schema.graphql", + "documents": "src/**/*.graphql", + "generates": { + "generated/api.ts": { + "plugins": ["@shopify/shopify_function/prepare-variables.cjs"] + } + }, + "config": { + "defaultScalarType": "unknown", + "scalars": { "JSON": "unknown" }, + "prepareVariables": { + "CartValidationsGeneratePrepareResult.variables": "CartValidationsGenerateRun" + } + } + } +} +``` + +Mapping keys are schema `Type.field` paths, not target names or export names. +Values are **named GraphQL query operations**, not document filenames. Include +both prepare and run documents in `documents`. Multiple mappings are supported. +The plugin does not read extension TOML or infer prepare/run pairs. + +For example, `src/cart_validations_generate_run.graphql` might contain: + +```graphql +query CartValidationsGenerateRun($ruleSetHandle: String! = "rules-default") { + cart { + validations(handles: [$ruleSetHandle]) { + title + } + } +} +``` + +After running `shopify app function typegen`, the prepare result's `variables` +field accepts `CartValidationsGenerateRunQueryVariables`. Other JSON fields +remain unchanged. The field retains its schema nullability and optionality. + +### Check your prepare implementation + +Annotate the return type in TypeScript: + +```ts +import type { CartValidationsGeneratePrepareResult } from "../generated/api"; + +export function cartValidationsGeneratePrepare(): CartValidationsGeneratePrepareResult { + return { variables: { ruleSetHandle: "rules-custom" } }; +} +``` + +Or use a JSDoc return annotation in JavaScript: + +```js +/** @returns {import('../generated/api').CartValidationsGeneratePrepareResult} */ +export function cartValidationsGeneratePrepare() { + return { variables: { ruleSetHandle: "rules-custom" } }; +} +``` + +Run TypeScript checking separately, for example with `tsc --noEmit`. JavaScript +projects also need `allowJs` and `checkJs`; enable `strict` (in particular +`strictNullChecks`) to catch invalid null values. + +The generated contract catches missing required variables, incompatible values, +invalid enums and input objects, and unknown keys in object literals. Variables +with defaults can be omitted; a non-null variable with a default still rejects +explicit `null`. GraphQL list input coercion follows the operations generator's +`arrayInputCoercion` option. + +**This does not add mandatory build or deploy validation.** The CLI's JavaScript +bundling does not type-check. TypeScript's usual limitations still apply: `any`, +assertions and disabled checking can bypass the contract; structural assignments +can carry extra keys even when fresh object literals would be rejected. + +### Compatibility + +The plugin composes the package's TypeScript schema and operations generators. +Codegen 6 emits used input objects and enums from the operations generator, so +those declarations take precedence over overlapping schema declarations. Operation +naming options, scalar mappings, readonly types and nullability options are +forwarded to the generators. Do not also list `typescript` or +`typescript-operations` for the same output. + +Mapped fields must be `JSON` or `JSON!` on an object type. Anonymous operations +cannot be mapped. `globalNamespace`, `noExport`, `onlyEnums` and +`onlyOperationTypes` are not supported: the plugin needs exported schema and +operation declarations in the same file. + +## Development + +```sh +npm install +npm test +``` + +Tests generate types and check valid and invalid prepare results with the +TypeScript compiler, including JavaScript/JSDoc usage. diff --git a/package-lock.json b/package-lock.json index 792c830..cb4b019 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,12 +13,14 @@ "@graphql-codegen/typescript": "6.0.1", "@graphql-codegen/typescript-operations": "6.0.2", "@graphql-tools/url-loader": "8.0.16", + "@graphql-tools/utils": "10.11.0", "graphql": "^16.10.0", "graphql-config": "5.1.3", "typescript": "5.8.3" }, "devDependencies": { - "@changesets/cli": "^2.28.1" + "@changesets/cli": "^2.28.1", + "@graphql-codegen/core": "6.0.0" } }, "node_modules/@ardatan/relay-compiler": { @@ -1137,9 +1139,9 @@ } }, "node_modules/@graphql-codegen/core/node_modules/@graphql-tools/utils": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/@graphql-tools/utils/-/utils-11.1.0.tgz", - "integrity": "sha512-PtFVG4r8Z2LEBSaPYQMusBiB3o6kjLVJyjCLbnWem/SpSuM21v6LTmgpkXfYU1qpBV2UGsFyuEnSJInl8fR1Ag==", + "version": "11.2.2", + "resolved": "https://registry.npmjs.org/@graphql-tools/utils/-/utils-11.2.2.tgz", + "integrity": "sha512-Do2A/t6ayqOma8m7PvpYMsCBswL+NB35BQSng4GWSBqafL2/BnfAZy/NSENPwV721Rm2fG0BHETFC0+FJJZmLw==", "license": "MIT", "dependencies": { "@graphql-typed-document-node/core": "^3.1.1", @@ -1192,12 +1194,12 @@ } }, "node_modules/@graphql-codegen/plugin-helpers": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/@graphql-codegen/plugin-helpers/-/plugin-helpers-7.0.1.tgz", - "integrity": "sha512-S2X0YT3XQbP2haqhIeku8GOXo2j8QuBu7BrLsOEHz4UeMu78y3rja1Q4ri3oJ0jq4dMgaQlazoVHI/A+FAKMGw==", + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/@graphql-codegen/plugin-helpers/-/plugin-helpers-7.4.2.tgz", + "integrity": "sha512-5SmkQvoNg3orbCNWBIv6lD1Zk1/mJbgdqv5oUy2Pw8Q70sO1Y3CGOkdwnPUp5l/pm0GKApQUyqPJkqFaJKewpQ==", "license": "MIT", "dependencies": { - "@graphql-tools/utils": "^11.0.0", + "@graphql-tools/utils": "^12.0.3", "change-case-all": "^2.1.0", "common-tags": "1.8.2", "import-from": "4.0.0", @@ -1207,17 +1209,17 @@ "node": ">=16" }, "peerDependencies": { - "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" + "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "node_modules/@graphql-codegen/plugin-helpers/node_modules/@graphql-tools/utils": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/@graphql-tools/utils/-/utils-11.1.0.tgz", - "integrity": "sha512-PtFVG4r8Z2LEBSaPYQMusBiB3o6kjLVJyjCLbnWem/SpSuM21v6LTmgpkXfYU1qpBV2UGsFyuEnSJInl8fR1Ag==", + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/@graphql-tools/utils/-/utils-12.1.0.tgz", + "integrity": "sha512-+mOuIX7u83/BHG0L9JviMzKid1j35cxOWhLoox47TXHAJ6Tnn30RgrmEQPmk1FhmaQbhJEcwYBDVyieUvZopJw==", "license": "MIT", "dependencies": { "@graphql-typed-document-node/core": "^3.1.1", - "@whatwg-node/promise-helpers": "^1.0.0", + "@whatwg-node/promise-helpers": "^2.0.0", "cross-inspect": "1.0.1", "tslib": "^2.4.0" }, @@ -1228,6 +1230,18 @@ "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, + "node_modules/@graphql-codegen/plugin-helpers/node_modules/@whatwg-node/promise-helpers": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@whatwg-node/promise-helpers/-/promise-helpers-2.0.1.tgz", + "integrity": "sha512-ziVgm01A9irPLVBaUFei64qp771BrDXldbv5pY6kgFgNtA+7zq8ux6nj/6w8bELGH/+Ttnc76ox04HIjU8nqvg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.6.3" + }, + "engines": { + "node": ">=22.15.0" + } + }, "node_modules/@graphql-codegen/schema-ast": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/@graphql-codegen/schema-ast/-/schema-ast-6.0.0.tgz", @@ -1971,12 +1985,12 @@ } }, "node_modules/@graphql-tools/merge": { - "version": "9.1.9", - "resolved": "https://registry.npmjs.org/@graphql-tools/merge/-/merge-9.1.9.tgz", - "integrity": "sha512-iHUWNjRHeQRYdgIMIuChThOwoKzA9vrzYeslgfBo5eUYEyHGZCoDPjAavssoYXLwstYt1dZj2J22jSzc2DrN0Q==", + "version": "9.2.8", + "resolved": "https://registry.npmjs.org/@graphql-tools/merge/-/merge-9.2.8.tgz", + "integrity": "sha512-OsCEmJ9RIMT4LB/1jBbyKHe7pMIlfGhLAQ0FVNaZqteydDUC+rTI9wZ+yaKqxQpPOFFycD+UtMhovg19lPq92Q==", "license": "MIT", "dependencies": { - "@graphql-tools/utils": "^11.1.0", + "@graphql-tools/utils": "^12.1.0", "tslib": "^2.4.0" }, "engines": { @@ -1987,13 +2001,13 @@ } }, "node_modules/@graphql-tools/merge/node_modules/@graphql-tools/utils": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/@graphql-tools/utils/-/utils-11.1.0.tgz", - "integrity": "sha512-PtFVG4r8Z2LEBSaPYQMusBiB3o6kjLVJyjCLbnWem/SpSuM21v6LTmgpkXfYU1qpBV2UGsFyuEnSJInl8fR1Ag==", + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/@graphql-tools/utils/-/utils-12.1.0.tgz", + "integrity": "sha512-+mOuIX7u83/BHG0L9JviMzKid1j35cxOWhLoox47TXHAJ6Tnn30RgrmEQPmk1FhmaQbhJEcwYBDVyieUvZopJw==", "license": "MIT", "dependencies": { "@graphql-typed-document-node/core": "^3.1.1", - "@whatwg-node/promise-helpers": "^1.0.0", + "@whatwg-node/promise-helpers": "^2.0.0", "cross-inspect": "1.0.1", "tslib": "^2.4.0" }, @@ -2004,6 +2018,18 @@ "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, + "node_modules/@graphql-tools/merge/node_modules/@whatwg-node/promise-helpers": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@whatwg-node/promise-helpers/-/promise-helpers-2.0.1.tgz", + "integrity": "sha512-ziVgm01A9irPLVBaUFei64qp771BrDXldbv5pY6kgFgNtA+7zq8ux6nj/6w8bELGH/+Ttnc76ox04HIjU8nqvg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.6.3" + }, + "engines": { + "node": ">=22.15.0" + } + }, "node_modules/@graphql-tools/optimize": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@graphql-tools/optimize/-/optimize-2.0.0.tgz", @@ -2055,13 +2081,13 @@ } }, "node_modules/@graphql-tools/schema": { - "version": "10.0.33", - "resolved": "https://registry.npmjs.org/@graphql-tools/schema/-/schema-10.0.33.tgz", - "integrity": "sha512-O6P3RIftO0jafnSsFAqpjurUuUxJ43s/AdPVLQsBkI6y4Ic/tKm4C1Qm1KKQsCDTOxXPJClh/v3g7k7yLKCFBQ==", + "version": "10.1.5", + "resolved": "https://registry.npmjs.org/@graphql-tools/schema/-/schema-10.1.5.tgz", + "integrity": "sha512-F0Onw0ZBREakKGqcgsKhdSCjOXgmntzQWL0QA+/WrwKmnJ7nJRyGKUJavwuVZL2TrXblaIUvQxm4t1h3FuA3cA==", "license": "MIT", "dependencies": { - "@graphql-tools/merge": "^9.1.9", - "@graphql-tools/utils": "^11.1.0", + "@graphql-tools/merge": "^9.2.8", + "@graphql-tools/utils": "^12.1.0", "tslib": "^2.4.0" }, "engines": { @@ -2072,13 +2098,13 @@ } }, "node_modules/@graphql-tools/schema/node_modules/@graphql-tools/utils": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/@graphql-tools/utils/-/utils-11.1.0.tgz", - "integrity": "sha512-PtFVG4r8Z2LEBSaPYQMusBiB3o6kjLVJyjCLbnWem/SpSuM21v6LTmgpkXfYU1qpBV2UGsFyuEnSJInl8fR1Ag==", + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/@graphql-tools/utils/-/utils-12.1.0.tgz", + "integrity": "sha512-+mOuIX7u83/BHG0L9JviMzKid1j35cxOWhLoox47TXHAJ6Tnn30RgrmEQPmk1FhmaQbhJEcwYBDVyieUvZopJw==", "license": "MIT", "dependencies": { "@graphql-typed-document-node/core": "^3.1.1", - "@whatwg-node/promise-helpers": "^1.0.0", + "@whatwg-node/promise-helpers": "^2.0.0", "cross-inspect": "1.0.1", "tslib": "^2.4.0" }, @@ -2089,6 +2115,18 @@ "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, + "node_modules/@graphql-tools/schema/node_modules/@whatwg-node/promise-helpers": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@whatwg-node/promise-helpers/-/promise-helpers-2.0.1.tgz", + "integrity": "sha512-ziVgm01A9irPLVBaUFei64qp771BrDXldbv5pY6kgFgNtA+7zq8ux6nj/6w8bELGH/+Ttnc76ox04HIjU8nqvg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.6.3" + }, + "engines": { + "node": ">=22.15.0" + } + }, "node_modules/@graphql-tools/url-loader": { "version": "8.0.16", "resolved": "https://registry.npmjs.org/@graphql-tools/url-loader/-/url-loader-8.0.16.tgz", @@ -2115,14 +2153,14 @@ } }, "node_modules/@graphql-tools/utils": { - "version": "10.8.6", - "resolved": "https://registry.npmjs.org/@graphql-tools/utils/-/utils-10.8.6.tgz", - "integrity": "sha512-Alc9Vyg0oOsGhRapfL3xvqh1zV8nKoFUdtLhXX7Ki4nClaIJXckrA86j+uxEuG3ic6j4jlM1nvcWXRn/71AVLQ==", + "version": "10.11.0", + "resolved": "https://registry.npmjs.org/@graphql-tools/utils/-/utils-10.11.0.tgz", + "integrity": "sha512-iBFR9GXIs0gCD+yc3hoNswViL1O5josI33dUqiNStFI/MHLCEPduasceAcazRH77YONKNiviHBV8f7OgcT4o2Q==", + "license": "MIT", "dependencies": { "@graphql-typed-document-node/core": "^3.1.1", "@whatwg-node/promise-helpers": "^1.0.0", "cross-inspect": "1.0.1", - "dset": "^3.1.4", "tslib": "^2.4.0" }, "engines": { diff --git a/package.json b/package.json index 1d16242..870083b 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,9 @@ "version": "2.0.1", "description": "", "main": "index.ts", + "scripts": { + "test": "node --test tests/*.test.cjs" + }, "keywords": [], "author": "Surma ", "license": "Apache-2.0", @@ -17,13 +20,15 @@ "dependencies": { "@graphql-codegen/cli": "7.0.0", "@graphql-codegen/typescript": "6.0.1", - "graphql-config": "5.1.3", "@graphql-codegen/typescript-operations": "6.0.2", "@graphql-tools/url-loader": "8.0.16", + "@graphql-tools/utils": "10.11.0", "graphql": "^16.10.0", + "graphql-config": "5.1.3", "typescript": "5.8.3" }, "devDependencies": { - "@changesets/cli": "^2.28.1" + "@changesets/cli": "^2.28.1", + "@graphql-codegen/core": "6.0.0" } } diff --git a/prepare-variables.cjs b/prepare-variables.cjs new file mode 100644 index 0000000..dedabb8 --- /dev/null +++ b/prepare-variables.cjs @@ -0,0 +1,132 @@ +const { plugin: typescriptPlugin } = require('@graphql-codegen/typescript'); +const { TypeScriptDocumentsVisitor, plugin: operationsPlugin } = require('@graphql-codegen/typescript-operations'); +const ts = require('typescript'); +const { mapSchema, MapperKind } = require('@graphql-tools/utils'); +const { GraphQLScalarType, GraphQLNonNull, Kind, isObjectType, isScalarType, isNonNullType } = require('graphql'); + +/** + * Composes the TypeScript schema and operation generators. Operation types + * are generated against the original schema; only prepare result fields change. + * + * @param {import('graphql').GraphQLSchema} schema + * @param {import('@graphql-codegen/plugin-helpers').Types.DocumentFile[]} documents + * @param {object} config + */ +exports.plugin = async function (schema, documents, config, info) { + if (config.globalNamespace || config.noExport || config.onlyEnums || config.onlyOperationTypes) { + throw new Error('prepareVariables requires exported schema and operation types (globalNamespace, noExport, onlyEnums and onlyOperationTypes are not supported).'); + } + const mappings = config.prepareVariables; + if (!mappings || typeof mappings !== 'object' || Array.isArray(mappings)) { + throw new Error('prepareVariables must map result fields to named run query operations.'); + } + + const operations = new Map(); + for (const { document } of documents) { + for (const definition of document?.definitions ?? []) { + if (definition.kind === Kind.OPERATION_DEFINITION && definition.name) { + const name = definition.name.value; + if (operations.has(name)) throw new Error(`Duplicate operation name: ${name}`); + operations.set(name, definition); + } + } + } + + // Use the same naming logic as typescript-operations, including prefixes, + // suffixes, namingConvention and omit/dedupeOperationSuffix. + const visitor = new TypeScriptDocumentsVisitor(schema, config, { + kind: Kind.DOCUMENT, + definitions: documents.flatMap(({ document }) => document?.definitions ?? []), + }); + const overrides = new Map(); + const scalars = { ...config.scalars }; + let index = 0; + for (const [path, operationName] of Object.entries(mappings)) { + const parts = path.split('.'); + const type = schema.getType(parts[0]); + const field = parts.length === 2 && isObjectType(type) && type.getFields()[parts[1]]; + const fieldType = field && (isNonNullType(field.type) ? field.type.ofType : field.type); + if (!fieldType || !isScalarType(fieldType) || fieldType.name !== 'JSON') { + throw new Error(`prepareVariables field ${path} must be an object field of type JSON or JSON!.`); + } + const operation = operations.get(operationName); + if (!operation || operation.operation !== 'query') { + throw new Error(`prepareVariables field ${path} references missing named query ${operationName}. Include its document in codegen.`); + } + // Substitute only this field, not every use of the JSON scalar. Delegating + // to typescript preserves its nullability, readonly and formatting options. + let scalarName; + do { scalarName = `ShopifyFunctionPrepareVariables${index++}`; } + while (schema.getType(scalarName) || Object.hasOwn(scalars, scalarName)); + const variablesType = visitor.convertName(operationName, { + suffix: visitor.getOperationSuffix(operationName, 'Query') + 'Variables', + }); + const scalar = new GraphQLScalarType({ name: scalarName }); + overrides.set(path, isNonNullType(field.type) ? new GraphQLNonNull(scalar) : scalar); + scalars[scalarName] = { input: 'never', output: variablesType }; + } + + const typedSchema = mapSchema(schema, { + [MapperKind.OBJECT_FIELD]: (field, fieldName, typeName) => { + const type = overrides.get(`${typeName}.${fieldName}`); + return type ? { ...field, type } : undefined; + }, + }); + const schemaOutput = await typescriptPlugin(typedSchema, documents, { ...config, scalars }); + const operationsOutput = await operationsPlugin(schema, documents, config, info); + const operationsSource = [...operationsOutput.prepend, operationsOutput.content].filter(Boolean).join('\n'); + const operationNames = declarationNames(operationsSource); + const schemaSource = [...schemaOutput.prepend, schemaOutput.content].filter(Boolean).join('\n'); + + // Codegen 6 operations include the input objects and enums they use. Prefer + // those definitions (and their enum representation) over the schema copies. + // Parse declarations rather than editing generated types with regexes. + const parsed = ts.createSourceFile('schema.ts', schemaSource, ts.ScriptTarget.Latest, true); + const schemaDeclarations = parsed.statements.filter(statement => + !statement.name || !operationNames.has(statement.name.text)); + const printer = ts.createPrinter(); + const combined = schemaDeclarations.map(statement => printer.printNode(ts.EmitHint.Unspecified, statement, parsed)).join('\n') + + '\n' + operationsSource; + return deduplicateImports(combined); +}; + +// Both upstream generators may import the same custom scalar or enum. Prune +// repeated bindings, including overlapping imports such as { A } and { A, B }. +function deduplicateImports(source) { + const parsed = ts.createSourceFile('api.ts', source, ts.ScriptTarget.Latest, true); + const seen = new Map(); + const printer = ts.createPrinter(); + const keep = (name, origin) => { + if (!seen.has(name)) { seen.set(name, origin); return true; } + if (seen.get(name) !== origin) throw new Error(`Conflicting generated import: ${name}`); + return false; + }; + return parsed.statements.flatMap(statement => { + if (!ts.isImportDeclaration(statement) || !statement.importClause) return [statement]; + const clause = statement.importClause; + const source = statement.moduleSpecifier.text; + const name = clause.name && keep(clause.name.text, `${source}:default`) ? clause.name : undefined; + let bindings = clause.namedBindings; + if (bindings && ts.isNamedImports(bindings)) { + const elements = bindings.elements.filter(element => keep(element.name.text, `${source}:${element.propertyName?.text ?? element.name.text}`)); + bindings = elements.length ? ts.factory.updateNamedImports(bindings, elements) : undefined; + } else if (bindings && !keep(bindings.name.text, `${source}:*`)) { + bindings = undefined; + } + if (!name && !bindings) return []; + return [ts.factory.updateImportDeclaration(statement, statement.modifiers, + ts.factory.updateImportClause(clause, clause.isTypeOnly, name, bindings), + statement.moduleSpecifier, statement.attributes)]; + }).map(statement => printer.printNode(ts.EmitHint.Unspecified, statement, parsed)).join('\n'); +} + +function declarationNames(source) { + const parsed = ts.createSourceFile('operations.ts', source, ts.ScriptTarget.Latest, true); + return new Set(parsed.statements.flatMap(statement => statement.name ? [statement.name.text] : [])); +} + +exports.validate = function (_schema, _documents, _config, _outputFile, plugins) { + if (plugins.some(plugin => Object.hasOwn(plugin, 'typescript') || Object.hasOwn(plugin, 'typescript-operations'))) { + throw new Error('Use prepare-variables instead of typescript and typescript-operations, not alongside them.'); + } +}; diff --git a/tests/prepare-variables.test.cjs b/tests/prepare-variables.test.cjs new file mode 100644 index 0000000..6a4094e --- /dev/null +++ b/tests/prepare-variables.test.cjs @@ -0,0 +1,179 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { mkdtempSync, writeFileSync, rmSync } = require('node:fs'); +const { tmpdir } = require('node:os'); +const { join } = require('node:path'); +const ts = require('typescript'); +const { buildSchema, parse } = require('graphql'); +const { codegen } = require('@graphql-codegen/core'); +const prepare = require('../prepare-variables.cjs'); + +const schema = buildSchema(` + scalar JSON + enum Mode { STRICT LENIENT } + input Rule { handle: String!, mode: Mode, children: [Rule!] } + type PrepareResult { variables: JSON, metadata: JSON } + type OtherPrepareResult { variables: JSON! } + type Query { value(handle: String, rules: [Rule!]): String, count: Int } +`); +const documents = [{ document: parse(` + query Run($handle: String!, $defaulted: String! = "default", $rules: [Rule!], $mode: Mode) { + value(handle: $handle, rules: $rules) + } + query OtherRun($count: Int!) { value } + query NoVariables { value } +`) }]; +const config = { + defaultScalarType: 'unknown', + scalars: { JSON: 'unknown' }, + prepareVariables: { 'PrepareResult.variables': 'Run' }, +}; + +function generate(overrides = {}, docs = documents) { + return codegen({ + filename: 'api.ts', schema, documents: docs, + config: { ...config, ...overrides }, + plugins: [{ prepare: {} }], + pluginMap: { prepare }, + }); +} + +function diagnostics(source, options = {}) { + const dir = mkdtempSync(join(tmpdir(), 'prepare-variables-')); + try { + const file = join(dir, options.allowJs ? 'example.js' : 'example.ts'); + writeFileSync(file, source); + const program = ts.createProgram([file], { + strict: true, noEmit: true, skipLibCheck: true, + target: ts.ScriptTarget.ES2022, types: [], ...options, + }); + return ts.getPreEmitDiagnostics(program).map(d => ts.flattenDiagnosticMessageText(d.messageText, '\n')); + } finally { rmSync(dir, { recursive: true, force: true }); } +} + +function checkTypes(generated, body) { + return diagnostics(`${generated}\n${body}`); +} + +test('valid variables, omitted defaults, nullable variables, recursive input objects and unrelated JSON', async () => { + const generated = await generate(); + assert.deepEqual(checkTypes(generated, ` + const valid: PrepareResult = { variables: { handle: 'rules', rules: [{ handle: 'nested', children: [] }] } }; + const nullable: PrepareResult = { variables: { handle: 'rules', mode: null } }; + const omitted: PrepareResult = {}; + const nullVariables: PrepareResult = { variables: null }; + const metadata: PrepareResult = { metadata: { arbitrary: true } }; + const unrelated: OtherPrepareResult = { variables: { anything: 42 } }; + `), []); + assert.equal(schema.getType('PrepareResult').getFields().variables.type.toString(), 'JSON'); +}); + +test('type checking rejects renamed, missing, retyped, null and invalid nested variables', async () => { + const generated = await generate(); + for (const variables of [ + `{ handl: 'rules' }`, '{}', '{ handle: 42 }', '{ handle: null }', + '{ handle: undefined }', `{ handle: 'rules', defaulted: null }`, + `{ handle: 'rules', mode: 'INVALID' }`, + `{ handle: 'rules', rules: [{ handle: 42 }] }`, + `{ handle: 'rules', rules: [null] }`, + `{ handle: 'rules', rules: [{ handle: 'nested', typo: true }] }`, + ]) { + assert.notEqual(checkTypes(generated, `const result: PrepareResult = { variables: ${variables} };`).length, 0, variables); + } +}); + +test('run query edits change the prepare contract', async () => { + for (const declaration of ['$renamed: String!', '$handle: String!, $added: Int!', '$handle: Int!']) { + const generated = await generate({}, [{ document: parse(`query Run(${declaration}) { value }`) }]); + assert.notEqual(checkTypes(generated, `const result: PrepareResult = { variables: { handle: 'rules' } };`).length, 0); + } +}); + +test('multiple mappings, non-null fields and operations without variables', async () => { + const generated = await generate({ prepareVariables: { + 'PrepareResult.variables': 'NoVariables', 'OtherPrepareResult.variables': 'OtherRun', + } }); + assert.deepEqual(checkTypes(generated, ` + const a: PrepareResult = { variables: {} }; + const b: OtherPrepareResult = { variables: { count: 2 } }; + `), []); + for (const body of [ + `const a: PrepareResult = { variables: { extra: true } };`, + `const b: OtherPrepareResult = { variables: null };`, + `const b: OtherPrepareResult = {};`, + ]) assert.notEqual(checkTypes(generated, body).length, 0); +}); + +test('uses operation naming options and preserves readonly and nullability options', async () => { + for (const options of [ + { typesPrefix: 'Api', typesSuffix: 'Type' }, + { omitOperationSuffix: true }, + { dedupeOperationSuffix: true }, + { namingConvention: 'keep' }, + { immutableTypes: true, avoidOptionals: true, maybeValue: 'T | null | undefined' }, + ]) { + const generated = await generate(options); + assert.deepEqual(checkTypes(generated, `const result: ${options.typesPrefix ?? ''}PrepareResult${options.typesSuffix ?? ''} = { + ${options.avoidOptionals ? "__typename: 'PrepareResult', metadata: null," : ''} + variables: { handle: 'rules', ${options.avoidOptionals ? 'rules: null, mode: null, defaulted: "default",' : ''} } + };`), [], JSON.stringify(options)); + } +}); + +test('JavaScript JSDoc returns are checked when checkJs is enabled', async () => { + const generated = await generate(); + const dir = mkdtempSync(join(tmpdir(), 'prepare-js-')); + try { + writeFileSync(join(dir, 'api.ts'), generated); + const file = join(dir, 'prepare.js'); + writeFileSync(file, `/** @returns {import('./api').PrepareResult} */ + export function prepare() { return { variables: { handl: 'rules' } }; }`); + const program = ts.createProgram([file], { allowJs: true, checkJs: true, strict: true, noEmit: true, types: [] }); + const errors = ts.getPreEmitDiagnostics(program).map(d => ts.flattenDiagnosticMessageText(d.messageText, '\n')); + assert.ok(errors.some(error => error.includes('handl')), errors.join('\n')); + } finally { rmSync(dir, { recursive: true, force: true }); } +}); + +test('custom scalar imports are not duplicated between generators', async () => { + const generated = await generate({ scalars: { JSON: './custom#JsonValue' } }, [{ + document: parse('query Run($data: JSON) { value }'), + }]); + const dir = mkdtempSync(join(tmpdir(), 'prepare-imports-')); + try { + writeFileSync(join(dir, 'custom.ts'), 'export type JsonValue = string | { [key: string]: JsonValue };'); + writeFileSync(join(dir, 'api.ts'), generated); + const program = ts.createProgram([join(dir, 'api.ts')], { strict: true, noEmit: true, types: [] }); + assert.deepEqual(ts.getPreEmitDiagnostics(program).map(d => ts.flattenDiagnosticMessageText(d.messageText, '\n')), []); + } finally { rmSync(dir, { recursive: true, force: true }); } +}); + +test('plugin loads through the codegen CLI using the published package subpath', () => { + const { symlinkSync, mkdirSync } = require('node:fs'); + const { execFileSync } = require('node:child_process'); + const dir = mkdtempSync(join(tmpdir(), 'prepare-cli-')); + try { + mkdirSync(join(dir, 'node_modules', '@shopify'), { recursive: true }); + symlinkSync(join(__dirname, '..'), join(dir, 'node_modules', '@shopify', 'shopify_function'), 'dir'); + writeFileSync(join(dir, 'schema.graphql'), `scalar JSON\ntype PrepareResult { variables: JSON }\ntype Query { value(handle: String): String }`); + writeFileSync(join(dir, 'run.graphql'), 'query Run($handle: String!) { value(handle: $handle) }'); + writeFileSync(join(dir, 'package.json'), JSON.stringify({ codegen: { + schema: 'schema.graphql', documents: 'run.graphql', + generates: { 'api.ts': { plugins: ['@shopify/shopify_function/prepare-variables.cjs'] } }, + config, + } })); + execFileSync(process.execPath, [join(require.resolve('@graphql-codegen/cli'), '..', 'bin.js'), '--config', 'package.json'], { cwd: dir, stdio: 'pipe' }); + const { readFileSync } = require('node:fs'); + const generated = readFileSync(join(dir, 'api.ts'), 'utf8'); + assert.deepEqual(checkTypes(generated, `const result: PrepareResult = { variables: { handle: 'ok' } };`), []); + } finally { rmSync(dir, { recursive: true, force: true }); } +}); + +test('invalid configuration fails early', async () => { + for (const prepareVariables of [undefined, [], { 'Missing.variables': 'Run' }, + { 'PrepareResult.variables': 'Missing' }, { 'PrepareResult.variables.extra': 'Run' }]) { + await assert.rejects(generate({ prepareVariables }), /prepareVariables/); + } + await assert.rejects(generate({ onlyOperationTypes: true }), /onlyOperationTypes/); + assert.throws(() => prepare.validate(null, null, null, null, [{ typescript: {} }]), /instead of typescript/); + assert.throws(() => prepare.validate(null, null, null, null, [{ 'typescript-operations': {} }]), /instead of typescript/); +});