diff --git a/packages/@sanity/cli/src/workers/typegenGenerate.ts b/packages/@sanity/cli/src/workers/typegenGenerate.ts index f5876688a93..3c879a4f908 100644 --- a/packages/@sanity/cli/src/workers/typegenGenerate.ts +++ b/packages/@sanity/cli/src/workers/typegenGenerate.ts @@ -5,10 +5,11 @@ import { getResolver, readSchema, registerBabel, + safeParseQuery, TypeGenerator, } from '@sanity/codegen' import createDebug from 'debug' -import {parse, typeEvaluate, type TypeNode} from 'groq-js' +import {typeEvaluate, type TypeNode} from 'groq-js' const $info = createDebug('sanity:codegen:generate:info') @@ -99,7 +100,7 @@ async function main() { }[] = [] for (const {name: queryName, result: query} of result.queries) { try { - const ast = parse(query) + const ast = safeParseQuery(query) const queryTypes = typeEvaluate(ast, schema) const type = typeGenerator.generateTypeNodeTypes(`${queryName}Result`, queryTypes) diff --git a/packages/@sanity/codegen/src/__tests__/safeParseQuery.test.ts b/packages/@sanity/codegen/src/__tests__/safeParseQuery.test.ts new file mode 100644 index 00000000000..ab342182909 --- /dev/null +++ b/packages/@sanity/codegen/src/__tests__/safeParseQuery.test.ts @@ -0,0 +1,47 @@ +import {describe, expect, test} from '@jest/globals' + +import {extractSliceParams, safeParseQuery} from '../safeParseQuery' + +const variants = [ + { + query: '*[_type == "author"][$from...$to]', + params: ['from', 'to'], + }, + { + query: '*[_type == "author"][$from...5]', + params: ['from'], + }, + { + query: '*[_type == "author"][5...$to]', + params: ['to'], + }, + { + query: '*[_type == "author"][3...5]', + params: [], + }, + { + query: '*[_type == "author"][3...5] { name, "foo": *[_type == "bar"][0...$limit] }', + params: ['limit'], + }, + { + query: '*[_type == "author"][$from...$to] { name, "foo": *[_type == "bar"][0...$limit] }', + params: ['from', 'to', 'limit'], + }, +] +describe('safeParseQuery', () => { + test.each(variants)('can extract: $query', async (variant) => { + const params = collectAll(extractSliceParams(variant.query)) + expect(params).toStrictEqual(variant.params) + }) + test.each(variants)('can parse: $query', async (variant) => { + safeParseQuery(variant.query) + }) +}) + +function collectAll(iterator: Generator) { + const res = [] + for (const item of iterator) { + res.push(item) + } + return res +} diff --git a/packages/@sanity/codegen/src/_exports/index.ts b/packages/@sanity/codegen/src/_exports/index.ts index 83cae78520d..c18325db0a6 100644 --- a/packages/@sanity/codegen/src/_exports/index.ts +++ b/packages/@sanity/codegen/src/_exports/index.ts @@ -1,5 +1,6 @@ export {type CodegenConfig, readConfig} from '../readConfig' export {readSchema} from '../readSchema' +export {safeParseQuery} from '../safeParseQuery' export {findQueriesInPath} from '../typescript/findQueriesInPath' export {findQueriesInSource} from '../typescript/findQueriesInSource' export {getResolver} from '../typescript/moduleResolver' diff --git a/packages/@sanity/codegen/src/safeParseQuery.ts b/packages/@sanity/codegen/src/safeParseQuery.ts new file mode 100644 index 00000000000..37bcc2637f8 --- /dev/null +++ b/packages/@sanity/codegen/src/safeParseQuery.ts @@ -0,0 +1,39 @@ +import {parse} from 'groq-js' + +/** + * safeParseQuery parses a GROQ query string, but first attempts to extract any parameters used in slices. This method is _only_ + * intended for use in type generation where we don't actually execute the parsed AST on a dataset, and should not be used elsewhere. + * @internal + */ +export function safeParseQuery(query: string) { + const params: Record = {} + + for (const param of extractSliceParams(query)) { + params[param] = 0 // we don't care about the value, just the type + } + return parse(query, {params}) +} + +/** + * Finds occurences of `[($start|{number})..($end|{number})]` in a query string and returns the start and end values, and return + * the names of the start and end variables. + * @internal + */ +export function* extractSliceParams(query: string): Generator { + const sliceRegex = /\[(\$(\w+)|\d)\.\.\.?(\$(\w+)|\d)\]/g + const matches = query.matchAll(sliceRegex) + if (!matches) { + return + } + const params = new Set() + for (const match of matches) { + const start = match[1] === `$${match[2]}` ? match[2] : null + if (start !== null) { + yield start + } + const end = match[3] === `$${match[4]}` ? match[4] : null + if (end !== null) { + yield end + } + } +}