From 74d062876d107fa46ba0df80d1a38ce951103ba3 Mon Sep 17 00:00:00 2001 From: Scott Rippey Date: Mon, 3 Feb 2025 14:14:16 -0700 Subject: [PATCH] Feature: `notNull` (#331) * chore: removed unused files * feat(notNull): added notNull helper * feat(notNull): changeset * feat(notNull): changed `slice(#)` to be nullable; use notNull to fix * feat(notNull): added MISSING_PARSER detection * feat(notNull): added validationRequired to `.field` * feat(notNull): revert last commit * feat(notNull): added docs around CHAINED_PARSER_ERROR * feat(notNull): ensure `notNull` and `nullable` come last in a chain * feat(notNull): ensure `notNull` and `nullable` come last in a chain * feat(notNull): updated changeset * feat(notNull): extracted "as" helper into its own file, and updated docs * feat(notNull): improved docs for the non-chainability of `notNull / nullable` and introduced `IGroqBuilderNotChainable` * feat(notNull): added examples to docs * feat(notNull): renamed to `InvalidQueryError` * feat(notNull): changeset --------- Co-authored-by: scottrippey --- .changeset/chilly-stingrays-unite.md | 11 + packages/groqd/src/commands/as.ts | 42 +++ packages/groqd/src/commands/deref.test.ts | 24 +- packages/groqd/src/commands/filter.test.ts | 14 +- packages/groqd/src/commands/filter.ts | 2 +- packages/groqd/src/commands/index.ts | 2 + packages/groqd/src/commands/notNull.test.ts | 96 +++++ packages/groqd/src/commands/notNull.ts | 58 +++ packages/groqd/src/commands/nullable.test.ts | 15 + packages/groqd/src/commands/nullable.ts | 29 +- packages/groqd/src/commands/project.test.ts | 10 +- packages/groqd/src/commands/project.ts | 9 +- .../groqd/src/commands/projectField.test.ts | 14 +- packages/groqd/src/commands/projectField.ts | 2 +- packages/groqd/src/commands/slice.test.ts | 2 +- packages/groqd/src/commands/slice.ts | 2 +- packages/groqd/src/commands/validate-utils.ts | 8 +- packages/groqd/src/commands/validate.test.ts | 14 +- packages/groqd/src/groq-builder.ts | 84 +++-- .../src/tests/mocks/nextjs-sanity-fe-mocks.ts | 33 +- .../src/tests/schemas/nextjs-sanity-fe-old.ts | 50 --- .../schemas/nextjs-sanity-fe.generated.ts | 340 ------------------ .../groqd/src/types/invalid-query-error.ts | 9 + packages/groqd/src/types/public-types.ts | 14 +- packages/groqd/src/types/query-error.ts | 9 - packages/groqd/src/validation/zod.test.ts | 97 +++-- 26 files changed, 427 insertions(+), 563 deletions(-) create mode 100644 .changeset/chilly-stingrays-unite.md create mode 100644 packages/groqd/src/commands/as.ts create mode 100644 packages/groqd/src/commands/notNull.test.ts create mode 100644 packages/groqd/src/commands/notNull.ts delete mode 100644 packages/groqd/src/tests/schemas/nextjs-sanity-fe-old.ts delete mode 100644 packages/groqd/src/tests/schemas/nextjs-sanity-fe.generated.ts create mode 100644 packages/groqd/src/types/invalid-query-error.ts delete mode 100644 packages/groqd/src/types/query-error.ts diff --git a/.changeset/chilly-stingrays-unite.md b/.changeset/chilly-stingrays-unite.md new file mode 100644 index 00000000..780c208c --- /dev/null +++ b/.changeset/chilly-stingrays-unite.md @@ -0,0 +1,11 @@ +--- +"groqd": patch +--- + +Fixed: `.slice(number)` returns a nullable result +Added: `.notNull()` utility +Updated: `.nullable()` utility to not be chainable +Updated: require a `redundant: true` parameter for `nullable` and `notNull` +Added: `InvalidQueryError` is thrown for all invalid queries detected + +Fixes #268 diff --git a/packages/groqd/src/commands/as.ts b/packages/groqd/src/commands/as.ts new file mode 100644 index 00000000..5f16e655 --- /dev/null +++ b/packages/groqd/src/commands/as.ts @@ -0,0 +1,42 @@ +import { GroqBuilder } from "../groq-builder"; +import { ExtractDocumentTypes } from "../types/schema-types"; + +declare module "../groq-builder" { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + export interface GroqBuilder { + /** + * Overrides the result type to anything you specify. + * + * Use this carefully, since it's essentially "lying" to TypeScript, and there's no runtime validation. + * + * @example + * q.star.filter("slug.current == $productSlug").as()... + * + */ + as(): GroqBuilder; + + /** + * Overrides the result type to a specific document type. + * + * Use this carefully, since it's essentially "lying" to TypeScript, and there's no runtime validation. + * + * @example + * q.star.filter("slug.current == $productSlug").asType<"product">()... + */ + asType< + _type extends ExtractDocumentTypes + >(): GroqBuilder< + Extract, + TQueryConfig + >; + } +} + +GroqBuilder.implement({ + as(this: GroqBuilder) { + return this; + }, + asType(this: GroqBuilder) { + return this; + }, +}); diff --git a/packages/groqd/src/commands/deref.test.ts b/packages/groqd/src/commands/deref.test.ts index 4a9b08f0..e366b1cd 100644 --- a/packages/groqd/src/commands/deref.test.ts +++ b/packages/groqd/src/commands/deref.test.ts @@ -1,5 +1,5 @@ import { describe, expect, expectTypeOf, it } from "vitest"; -import { InferResultType } from "../types/public-types"; +import { InferResultItem, InferResultType } from "../types/public-types"; import { executeBuilder } from "../tests/mocks/executeQuery"; import { mock } from "../tests/mocks/nextjs-sanity-fe-mocks"; import { q, SanitySchema } from "../tests/schemas/nextjs-sanity-fe"; @@ -15,17 +15,17 @@ describe("deref", () => { it("should deref a single item", () => { expectTypeOf< - InferResultType - >().toEqualTypeOf(); + InferResultItem + >().toEqualTypeOf(); expect(qCategory.query).toMatchInlineSnapshot( `"*[_type == "product"][0].categories[][0]->"` ); }); it("should deref an array of items", () => { - expectTypeOf< - InferResultType - >().toEqualTypeOf | null>(); + expectTypeOf>().toEqualTypeOf< + SanitySchema.Variant[] | null + >(); expect(qVariants.query).toMatchInlineSnapshot( `"*[_type == "product"][0].variants[]->"` ); @@ -34,11 +34,11 @@ describe("deref", () => { it("should be an error if the item is not a reference", () => { const notAReference = qProduct.field("slug"); expectTypeOf< - InferResultType + InferResultItem >().toEqualTypeOf(); const res = notAReference.deref(); - type ErrorResult = InferResultType; + type ErrorResult = InferResultItem; expectTypeOf< ErrorResult["error"] >().toEqualTypeOf<"⛔️ Expected the object to be a reference type ⛔️">(); @@ -59,11 +59,9 @@ describe("deref", () => { categories: sub.field("categories[]").deref(), })); - expectTypeOf>().toEqualTypeOf< - Array<{ - categories: SanitySchema.Category[] | null; - }> - >(); + expectTypeOf>().toEqualTypeOf<{ + categories: SanitySchema.Category[] | null; + }>(); }); }); }); diff --git a/packages/groqd/src/commands/filter.test.ts b/packages/groqd/src/commands/filter.test.ts index e295d7b7..01e68086 100644 --- a/packages/groqd/src/commands/filter.test.ts +++ b/packages/groqd/src/commands/filter.test.ts @@ -203,8 +203,18 @@ describe("filterBy", () => { const data = mock.generateSeedData({ extraData: [flavourNoName, flavourWithName], variants: [ - mock.variant({}, { flavour: [flavourNoName, flavourNoName] }), - mock.variant({}, { flavour: [flavourWithName, flavourWithName] }), + mock.variant({ + flavour: [ + mock.reference(flavourNoName), + mock.reference(flavourNoName), + ], + }), + mock.variant({ + flavour: [ + mock.reference(flavourWithName), + mock.reference(flavourWithName), + ], + }), ], }); const results = await executeBuilder(qFilterAfterDeref, data); diff --git a/packages/groqd/src/commands/filter.ts b/packages/groqd/src/commands/filter.ts index d06ebc53..fe390457 100644 --- a/packages/groqd/src/commands/filter.ts +++ b/packages/groqd/src/commands/filter.ts @@ -26,7 +26,7 @@ declare module "../groq-builder" { GroqBuilder.implement({ filter(this: GroqBuilder, filterExpression) { const needsWrap = this.query.endsWith("->"); - const self = needsWrap ? this.wrap("(", ")") : this; + const self = needsWrap ? this.extend({ query: `(${this.query})` }) : this; return self.chain(`[${filterExpression}]`); }, filterBy(this: GroqBuilder, filterExpression) { diff --git a/packages/groqd/src/commands/index.ts b/packages/groqd/src/commands/index.ts index 7b2d0194..c9be794d 100644 --- a/packages/groqd/src/commands/index.ts +++ b/packages/groqd/src/commands/index.ts @@ -1,3 +1,4 @@ +import "./as"; import "./conditional"; import "./conditionalByType"; import "./deref"; @@ -5,6 +6,7 @@ import "./filter"; import "./filterByType"; import "./fragment"; import "./grab-deprecated"; +import "./notNull"; import "./nullable"; import "./order"; import "./parameters"; diff --git a/packages/groqd/src/commands/notNull.test.ts b/packages/groqd/src/commands/notNull.test.ts new file mode 100644 index 00000000..1e0e4e57 --- /dev/null +++ b/packages/groqd/src/commands/notNull.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expectTypeOf, expect } from "vitest"; +import { q } from "../tests/schemas/nextjs-sanity-fe"; +import { mock } from "../tests/mocks/nextjs-sanity-fe-mocks"; +import { InferResultItem } from "../types/public-types"; +import { executeBuilder } from "../tests/mocks/executeQuery"; + +describe("notNull", () => { + const category = mock.category({}); + const data = mock.generateSeedData({ + products: [ + mock.product({ categories: [mock.reference(category)] }), + mock.product({ categories: [mock.reference(category)] }), + ], + categories: [category], + }); + const dataWithNulls = mock.generateSeedData({ + products: [ + mock.product({ categories: [mock.reference(category)] }), + mock.product({ categories: undefined }), + ], + categories: [category], + }); + + describe("when used on nullable fields", () => { + const qVariants = q.star.filterByType("product").project((sub) => ({ + categories: sub.field("categories[]").deref().field("name"), + categoriesNotNull: sub + .field("categories[]") + .deref() + .field("name") + .notNull(), + })); + type Result = InferResultItem; + + it("should have the correct type", () => { + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + }); + + it("should not affect the query", () => { + expect(qVariants.query).toMatchInlineSnapshot(` + "*[_type == "product"] { + "categories": categories[]->.name, + "categoriesNotNull": categories[]->.name + }" + `); + }); + + it("should execute correctly when there are no nulls", async () => { + const results = await executeBuilder(qVariants, data); + expect(results).toMatchInlineSnapshot(` + [ + { + "categories": [ + "Category Name", + ], + "categoriesNotNull": [ + "Category Name", + ], + }, + { + "categories": [ + "Category Name", + ], + "categoriesNotNull": [ + "Category Name", + ], + }, + ] + `); + }); + + it("should throw errors when finding a null value", async () => { + await expect(() => executeBuilder(qVariants, dataWithNulls)).rejects + .toThrowErrorMatchingInlineSnapshot(` + [ValidationErrors: 1 Parsing Error: + result[1].categoriesNotNull: Expected a non-null value] + `); + }); + }); + + describe("when it's redundant", () => { + it("should give a typescript error", () => { + q.star.filterByType("flavour").project((sub) => ({ + test: sub + .field("_id") + // You must pass `true` to acknowledge the redundancy: + .notNull(true), + test2: sub + .field("_id") + // @ts-expect-error --- otherwise it will error: + .notNull(), + })); + }); + }); +}); diff --git a/packages/groqd/src/commands/notNull.ts b/packages/groqd/src/commands/notNull.ts new file mode 100644 index 00000000..12510fa0 --- /dev/null +++ b/packages/groqd/src/commands/notNull.ts @@ -0,0 +1,58 @@ +import { GroqBuilder } from "../groq-builder"; +import { QueryConfig } from "../types/schema-types"; +import { ResultUtils } from "../types/result-types"; +import { Override } from "../types/utils"; +import { IGroqBuilderNotChainable } from "../types/public-types"; + +declare module "../groq-builder" { + export interface GroqBuilder { + /** + * Asserts that the results are NOT nullable. + * Useful when you know there must be a value, + * even though the query thinks it might be null. + * + * ⚠️ NOTE: This method can only be used at the end of a query chain, + * because you cannot chain more commands after making an assertion. + * See CHAINED_ASSERTION_ERROR for more details. + * + * @example + * q.star + * .filter("slug.current == $slug") + * .slice(0) // <- this return type is nullable, even though we expect there will be a match + * .project({ name: q.string() }) + * .notNull() // <- this ensures that the results are not null + * + * @example + * q.star.filterByType("product").project(sub => ({ + * categories: sub.field("categories[]") // <- nullable array + * .deref() + * .field("name", q.string()) + * .notNull() + * })); + * + * @param redundant - If the type is already not-nullable, then you must explicitly pass `.notNull(true)` to allow this redundancy. (This has no impact at runtime) + */ + notNull( + ...redundant: ResultUtils.IsNullable extends true ? [] : [true] + ): IGroqBuilderNotChainable< + ResultUtils.Wrap< + Override, { IsNullable: false }> + >, + TQueryConfig + >; + } +} + +GroqBuilder.implement({ + notNull(this: GroqBuilder, ..._redundant) { + const parser = this.parser; + return this.extend({ + parser: (input) => { + if (input === null) { + throw new TypeError("Expected a non-null value"); + } + return parser ? parser(input) : input; + }, + }); + }, +}); diff --git a/packages/groqd/src/commands/nullable.test.ts b/packages/groqd/src/commands/nullable.test.ts index cf5a427b..593f6787 100644 --- a/packages/groqd/src/commands/nullable.test.ts +++ b/packages/groqd/src/commands/nullable.test.ts @@ -105,4 +105,19 @@ describe("nullable", () => { `); }); }); + + describe("when it's redundant", () => { + it("should give a typescript error", () => { + q.star.filterByType("flavour").project((sub) => ({ + name: sub + .field("name") + // You must pass `true` to acknowledge the redundancy: + .nullable(true), + name2: sub + .field("name") + // @ts-expect-error --- otherwise it will error: + .nullable(), + })); + }); + }); }); diff --git a/packages/groqd/src/commands/nullable.ts b/packages/groqd/src/commands/nullable.ts index ebb6b763..691dec7f 100644 --- a/packages/groqd/src/commands/nullable.ts +++ b/packages/groqd/src/commands/nullable.ts @@ -1,17 +1,36 @@ import { GroqBuilder } from "../groq-builder"; import { QueryConfig } from "../types/schema-types"; +import { ResultUtils } from "../types/result-types"; +import { IGroqBuilderNotChainable } from "../types/public-types"; declare module "../groq-builder" { export interface GroqBuilder { /** * Marks a query as nullable – in case you are expecting a potential `null` value. + * Useful when you expect missing values in your data, + * even though the query thinks it's required. + * + * ⚠️ NOTE: This method can only be used at the end of a query chain, + * because you cannot chain more commands after making an assertion. + * See CHAINED_ASSERTION_ERROR for more details. + * + * @param redundant - If the type is already nullable, then you must explicitly pass `.nullable(true)` to allow this redundancy. (this has no impact at runtime) + * + * @example + * q.star.filterByType("product").project(sub => ({ + * // In our schema, "category" is required, but we know + * // that we have old entries that are missing this field: + * category: sub.field("category").nullable(), + * }); */ - nullable(): GroqBuilder; + nullable( + ...redundant: ResultUtils.IsNullable extends true ? [true] : [] + ): IGroqBuilderNotChainable; } } GroqBuilder.implement({ - nullable(this: GroqBuilder) { + nullable(this: GroqBuilder, _redundant) { const parser = this.parser; if (!parser) { @@ -19,8 +38,10 @@ GroqBuilder.implement({ // for type-safety, and we don't need to perform runtime validation: return this; } - return this.chain("", (input) => { - return input === null ? null : parser(input); + return this.extend({ + parser: (input) => { + return input === null || input === undefined ? null : parser(input); + }, }); }, }); diff --git a/packages/groqd/src/commands/project.test.ts b/packages/groqd/src/commands/project.test.ts index a744de63..2d2e2eb5 100644 --- a/packages/groqd/src/commands/project.test.ts +++ b/packages/groqd/src/commands/project.test.ts @@ -676,7 +676,7 @@ describe("project (object projections)", () => { price: true, }) ).toThrowErrorMatchingInlineSnapshot( - `[TypeError: [groqd] Because 'validationRequired' is enabled, every field must have validation (like \`q.string()\`), but the following fields are missing it: "price"]` + `[Error: [MISSING_PROJECTION_VALIDATION] Because 'validationRequired' is enabled, every field must have validation (like \`q.string()\`), but the following fields are missing it: "price"]` ); }); it("should throw if a projection uses a naked projection", () => { @@ -685,7 +685,7 @@ describe("project (object projections)", () => { price: "price", }) ).toThrowErrorMatchingInlineSnapshot( - `[TypeError: [groqd] Because 'validationRequired' is enabled, every field must have validation (like \`q.string()\`), but the following fields are missing it: "price"]` + `[Error: [MISSING_PROJECTION_VALIDATION] Because 'validationRequired' is enabled, every field must have validation (like \`q.string()\`), but the following fields are missing it: "price"]` ); }); it("should throw if a nested projection is missing a parser", () => { @@ -694,7 +694,7 @@ describe("project (object projections)", () => { nested: qV.field("price"), })) ).toThrowErrorMatchingInlineSnapshot( - `[TypeError: [groqd] Because 'validationRequired' is enabled, every field must have validation (like \`q.string()\`), but the following fields are missing it: "nested"]` + `[Error: [MISSING_PROJECTION_VALIDATION] Because 'validationRequired' is enabled, every field must have validation (like \`q.string()\`), but the following fields are missing it: "nested"]` ); }); it("should throw when using ellipsis operator ...", () => { @@ -703,7 +703,7 @@ describe("project (object projections)", () => { "...": true, }) ).toThrowErrorMatchingInlineSnapshot( - `[TypeError: [groqd] Because 'validationRequired' is enabled, every field must have validation (like \`q.string()\`), but the following fields are missing it: "..."]` + `[Error: [MISSING_PROJECTION_VALIDATION] Because 'validationRequired' is enabled, every field must have validation (like \`q.string()\`), but the following fields are missing it: "..."]` ); }); it("should work just fine when validation is provided", () => { @@ -721,7 +721,7 @@ describe("project (object projections)", () => { "price4": price }" `); - expectTypeOf>().toEqualTypeOf<{ + expectTypeOf>().toEqualTypeOf<{ price: number; price2: number; price3: number; diff --git a/packages/groqd/src/commands/project.ts b/packages/groqd/src/commands/project.ts index 41308f2b..f1155973 100644 --- a/packages/groqd/src/commands/project.ts +++ b/packages/groqd/src/commands/project.ts @@ -16,6 +16,7 @@ import { simpleObjectParser, UnknownObjectParser, } from "../validation/simple-validation"; +import { InvalidQueryError } from "../types/invalid-query-error"; declare module "../groq-builder" { export interface GroqBuilder { @@ -73,8 +74,9 @@ GroqBuilder.implement({ // Validate that we have provided validation functions for all fields: const invalidFields = fields.filter((f) => !f.parser); if (invalidFields.length) { - throw new TypeError( - "[groqd] Because 'validationRequired' is enabled, " + + throw new InvalidQueryError( + "MISSING_PROJECTION_VALIDATION", + "Because 'validationRequired' is enabled, " + "every field must have validation (like `q.string()`), " + "but the following fields are missing it: " + `${invalidFields.map((f) => `"${f.key}"`)}` @@ -130,7 +132,8 @@ function normalizeProjectionField( parser: normalizeValidationFunction(value), }; } else { - throw new Error( + throw new InvalidQueryError( + "INVALID_PROJECTION_VALUE", `Unexpected value for projection key "${key}": "${typeof value}"` ); } diff --git a/packages/groqd/src/commands/projectField.test.ts b/packages/groqd/src/commands/projectField.test.ts index 7547f04e..1245cb8b 100644 --- a/packages/groqd/src/commands/projectField.test.ts +++ b/packages/groqd/src/commands/projectField.test.ts @@ -46,10 +46,14 @@ describe("field (naked projections)", () => { Array >(); - const qImageNames = qVariants.slice(0).field("images[]").field("name"); - expectTypeOf< - InferResultType - >().toEqualTypeOf | null>(); + const qImageNames = qVariants + .slice(0) + .field("images[]") + .field("name") + .notNull(); + expectTypeOf>().toEqualTypeOf< + Array + >(); }); it("executes correctly (price)", async () => { @@ -112,7 +116,7 @@ describe("field (naked projections)", () => { expect(qPrices.parser).toBeNull(); }); - const qPrice = qVariants.slice(0).field("price", zod.number()); + const qPrice = qVariants.slice(0).field("price", zod.number()).notNull(); it("should have the correct result type", () => { expectTypeOf>().toEqualTypeOf(); }); diff --git a/packages/groqd/src/commands/projectField.ts b/packages/groqd/src/commands/projectField.ts index acbb40a3..1b863ca8 100644 --- a/packages/groqd/src/commands/projectField.ts +++ b/packages/groqd/src/commands/projectField.ts @@ -67,7 +67,7 @@ GroqBuilder.implement({ fieldName = "." + fieldName; } - // Finally, transparently handle arrays or objects: + // Transparently handle arrays or objects: const arrayParser = maybeArrayParser(normalizeValidationFunction(parser)); return this.chain(fieldName, arrayParser); diff --git a/packages/groqd/src/commands/slice.test.ts b/packages/groqd/src/commands/slice.test.ts index 960dec1a..1f8f1c77 100644 --- a/packages/groqd/src/commands/slice.test.ts +++ b/packages/groqd/src/commands/slice.test.ts @@ -17,7 +17,7 @@ describe("slice", () => { it("should be typed correctly", () => { expectTypeOf< InferResultType - >().toEqualTypeOf(); + >().toEqualTypeOf(); }); it("query should be correct", () => { expect(qSlice0).toMatchObject({ diff --git a/packages/groqd/src/commands/slice.ts b/packages/groqd/src/commands/slice.ts index 31df1951..9fe57631 100644 --- a/packages/groqd/src/commands/slice.ts +++ b/packages/groqd/src/commands/slice.ts @@ -17,7 +17,7 @@ declare module "../groq-builder" { */ slice( index: number - ): GroqBuilder, TQueryConfig>; + ): GroqBuilder, TQueryConfig>; /** * Returns a range of items from the results. diff --git a/packages/groqd/src/commands/validate-utils.ts b/packages/groqd/src/commands/validate-utils.ts index aad0e8e7..4a98cd61 100644 --- a/packages/groqd/src/commands/validate-utils.ts +++ b/packages/groqd/src/commands/validate-utils.ts @@ -4,7 +4,7 @@ import { ParserFunctionMaybe, ParserObject, } from "../types/public-types"; -import { QueryError } from "../types/query-error"; +import { InvalidQueryError } from "../types/invalid-query-error"; export function chainParsers( a: ParserFunctionMaybe, @@ -45,5 +45,9 @@ export function normalizeValidationFunction( return (input) => parser.parse(input); } - throw new QueryError(`Parser must be a function or an object`, { parser }); + throw new InvalidQueryError( + "INVALID_PARSER", + `Parser must be a function or an object`, + { parser } + ); } diff --git a/packages/groqd/src/commands/validate.test.ts b/packages/groqd/src/commands/validate.test.ts index ba3b18d6..bfc07427 100644 --- a/packages/groqd/src/commands/validate.test.ts +++ b/packages/groqd/src/commands/validate.test.ts @@ -1,20 +1,18 @@ import { describe, expect, expectTypeOf, it } from "vitest"; import { q } from "../tests/schemas/nextjs-sanity-fe"; -import { InferResultType } from "../index"; +import { InferResultItem, InferResultType } from "../index"; import { executeBuilder } from "../tests/mocks/executeQuery"; import { mock } from "../tests/mocks/nextjs-sanity-fe-mocks"; import { currencyFormat } from "../tests/utils"; -const qVariants = q.star.filterByType("variant"); - -describe("parse", () => { +describe("validate", () => { const data = mock.generateSeedData({ variants: [mock.variant({ price: 99 })], }); - const qPrice = qVariants.slice(0).field("price"); + const qPrice = q.star.filterByType("variant").slice(0).field("price"); - describe("parser function", () => { - const qPriceParse = qPrice.validate((p) => currencyFormat(p)); + describe("validate function", () => { + const qPriceParse = qPrice.validate((p) => currencyFormat(p || 0)); it("shouldn't affect the query at all", () => { expect(qPriceParse.query).toEqual(qPrice.query); @@ -26,7 +24,7 @@ describe("parse", () => { }); it("should map types correctly", () => { - expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); expectTypeOf< InferResultType >().toEqualTypeOf(); diff --git a/packages/groqd/src/groq-builder.ts b/packages/groqd/src/groq-builder.ts index 27e8dba8..93e822f0 100644 --- a/packages/groqd/src/groq-builder.ts +++ b/packages/groqd/src/groq-builder.ts @@ -5,11 +5,11 @@ import { Parser, ParserFunction, } from "./types/public-types"; -import type { ExtractDocumentTypes, QueryConfig } from "./types/schema-types"; +import type { QueryConfig } from "./types/schema-types"; import { normalizeValidationFunction } from "./commands/validate-utils"; import { ValidationErrors } from "./validation/validation-errors"; import type { Empty } from "./types/utils"; -import { QueryError } from "./types/query-error"; +import { InvalidQueryError } from "./types/invalid-query-error"; export type RootResult = Empty; @@ -46,9 +46,9 @@ export class GroqBuilder< TQueryConfig extends QueryConfig = QueryConfig > implements IGroqBuilder { - // @ts-expect-error --- This property doesn't actually exist, it's only used to capture type info */ + // @ts-expect-error --- This property doesn't actually exist, it's only used to capture type info readonly [GroqBuilderResultType]: TResult; - // @ts-expect-error --- This property doesn't actually exist, it's only used to capture type info */ + // @ts-expect-error --- This property doesn't actually exist, it's only used to capture type info readonly [GroqBuilderConfigType]: TQueryConfig; /** @@ -97,10 +97,20 @@ export class GroqBuilder< } /** - * Parses and validates the query results, passing all data through the parsers. + * Parses and validates the query results, + * passing all data through the parsers. */ public parse(data: unknown): TResult { const parser = this.internal.parser; + if (!parser && this.internal.options.validationRequired) { + throw new InvalidQueryError( + "MISSING_QUERY_VALIDATION", + "Because 'validationRequired' is enabled, " + + "every query must have validation (like `q.string()`), " + + "but this query is missing it!" + ); + } + if (!parser) { return data as TResult; } @@ -118,8 +128,7 @@ export class GroqBuilder< } /** - * Returns a new GroqBuilder, extending the current one. - * + * Returns a new GroqBuilder, appending the query. * @internal */ protected chain( @@ -127,10 +136,27 @@ export class GroqBuilder< parser?: Parser | null ): GroqBuilder { if (query && this.internal.parser) { - throw new QueryError( - "You cannot chain a new query once you've specified a parser, " + + /** + * This happens if you accidentally chain too many times, like: + * + * q.star + * .project({ a: q.string() }) + * .field("a") + * + * The first part of this projection should NOT have validation, + * since this data will never be sent client-side. + * This should be rewritten as: + * + * q.star + * .project({ a: true }) + * .field("a", q.string()) + */ + throw new InvalidQueryError( + "CHAINED_ASSERTION_ERROR", + "You cannot chain a new query after you've specified a validation function, " + "since this changes the result type.", { + existingParser: this.internal.parser, existingQuery: this.internal.query, newQuery: query, } @@ -145,21 +171,21 @@ export class GroqBuilder< } /** - * Wraps the current expression with a prefix + suffix. + * Returns a new GroqBuilder, extending the current one with the given parameters. + * @internal */ - protected wrap( - prefix: string, - suffix: string - ): GroqBuilder { - return new GroqBuilder({ - query: prefix + this.internal.query + suffix, - parser: this.internal.parser, - options: this.internal.options, + protected extend< + TResultNew = TResult, + TQueryConfigNew extends QueryConfig = TQueryConfig + >(overrides: Partial) { + return new GroqBuilder({ + ...this.internal, + ...overrides, }); } /** - * Returns an empty GroqBuilder + * Returns an empty "child" GroqBuilder */ public get root() { let options = this.internal.options; @@ -175,26 +201,6 @@ export class GroqBuilder< }); } - /** - * Returns a GroqBuilder, overriding the result type. - */ - public as(): GroqBuilder { - return this as any; - } - - /** - * Returns a GroqBuilder, overriding the result type - * with the specified document type. - */ - public asType< - _type extends ExtractDocumentTypes - >(): GroqBuilder< - Extract, - TQueryConfig - > { - return this as any; - } - /** * This utility returns whitespace, if 'indent' is enabled. */ diff --git a/packages/groqd/src/tests/mocks/nextjs-sanity-fe-mocks.ts b/packages/groqd/src/tests/mocks/nextjs-sanity-fe-mocks.ts index 134dfc1e..fc9bff70 100644 --- a/packages/groqd/src/tests/mocks/nextjs-sanity-fe-mocks.ts +++ b/packages/groqd/src/tests/mocks/nextjs-sanity-fe-mocks.ts @@ -42,19 +42,13 @@ export class MockFactory { } // Document types: - product( - data: Partial, - references?: { - categories?: SanitySchema.Category[]; - variants?: SanitySchema.Variant[]; - } - ): SanitySchema.Product { + product(data: Partial): SanitySchema.Product { return { ...this.common("product"), name: "Name", slug: this.slug("product"), - variants: references?.variants?.map((v) => this.reference(v)) ?? [], - categories: references?.categories?.map((c) => this.reference(c)) ?? [], + variants: [], + categories: [], images: [], description: [], ...data, @@ -70,13 +64,7 @@ export class MockFactory { ...data, } satisfies Required; } - variant( - data: Partial, - references?: { - flavour?: SanitySchema.Flavour[]; - style?: SanitySchema.Style[]; - } - ): SanitySchema.Variant { + variant(data: Partial): SanitySchema.Variant { const common = this.common("variant"); return { ...common, @@ -84,11 +72,11 @@ export class MockFactory { slug: this.slug("variant"), name: "Variant Name", description: [], - flavour: references?.flavour?.map((f) => this.reference(f)) ?? [], + flavour: [], images: [], price: 0, msrp: 0, - style: references?.style?.map((s) => this.reference(s)) ?? [], + style: [], ...data, } satisfies Required; } @@ -144,10 +132,11 @@ export class MockFactory { ), variants = this.array(10, (i) => this.variant({ name: `Variant ${i}` })), products = this.array(10, (i) => - this.product( - { name: `Product ${i}` }, - { categories: categories, variants: variants } - ) + this.product({ + name: `Product ${i}`, + categories: categories.map((c) => this.reference(c)), + variants: variants.map((c) => this.reference(c)), + }) ), extraData = [], }: { diff --git a/packages/groqd/src/tests/schemas/nextjs-sanity-fe-old.ts b/packages/groqd/src/tests/schemas/nextjs-sanity-fe-old.ts deleted file mode 100644 index 9f18f550..00000000 --- a/packages/groqd/src/tests/schemas/nextjs-sanity-fe-old.ts +++ /dev/null @@ -1,50 +0,0 @@ -// Types generated from "./sanity-studio/sanity.config.ts" -import { Simplify } from "../../types/utils"; -import { referenced, SchemaValues } from "./nextjs-sanity-fe.generated"; - -export { referenced }; - -export type SchemaConfigOld = { - schemaTypes: SanitySchemaOld.Reconstructed; - referenceSymbol: typeof referenced; -}; - -// eslint-disable-next-line @typescript-eslint/no-namespace -export namespace SanitySchemaOld { - // We're going to "reconstruct" our types here, - // so that we get better type aliases when debugging: - - type PromoteType = Simplify< - { - _type: T["_type"]; - } & Omit - >; - - export type Description = SchemaValues["description"]; - export type Style = PromoteType; - export type Category = PromoteType; - export type CategoryImage = PromoteType; - export type Flavour = PromoteType; - export type Product = PromoteType; - export type ProductImage = PromoteType; - export type Variant = PromoteType; - export type SiteSettings = PromoteType; - export type SanityImageAsset = PromoteType; - export type SanityFileAsset = PromoteType; - - export type Reconstructed = { - description: Description; - style: Style; - category: Category; - categoryImage: CategoryImage; - flavour: Flavour; - product: Product; - productImage: ProductImage; - variant: Variant; - siteSettings: SiteSettings; - "sanity.imageAsset": SanityImageAsset; - "sanity.fileAsset": SanityFileAsset; - }; - - export type ContentBlock = NonNullable[0]; -} diff --git a/packages/groqd/src/tests/schemas/nextjs-sanity-fe.generated.ts b/packages/groqd/src/tests/schemas/nextjs-sanity-fe.generated.ts deleted file mode 100644 index a02cf078..00000000 --- a/packages/groqd/src/tests/schemas/nextjs-sanity-fe.generated.ts +++ /dev/null @@ -1,340 +0,0 @@ -/* Types generated from './sanity-studio/sanity.config.ts' */ - -export declare const decorator: unique symbol; -export declare const referenced: unique symbol; -export type SchemaValues = { - "sanity.fileAsset": { - _type: "sanity.fileAsset"; - metadata: { [x: string]: unknown }; - url: string; - path: string; - assetId: string; - extension: string; - mimeType: string; - sha1hash: string; - size: number; - originalFilename?: string | undefined; - label?: string | undefined; - title?: string | undefined; - description?: string | undefined; - creditLine?: string | undefined; - source?: { id: string; name: string; url?: string | undefined } | undefined; - _id: string; - _createdAt: string; - _updatedAt: string; - _rev: string; - }; - "sanity.imageAsset": { - _type: "sanity.imageAsset"; - url: string; - path: string; - assetId: string; - extension: string; - mimeType: string; - sha1hash: string; - size: number; - originalFilename?: string | undefined; - label?: string | undefined; - title?: string | undefined; - description?: string | undefined; - creditLine?: string | undefined; - source?: { id: string; name: string; url?: string | undefined } | undefined; - _id: string; - _createdAt: string; - _updatedAt: string; - _rev: string; - metadata: { - [x: string]: unknown; - _type: "sanity.imageMetadata"; - dimensions: { - _type: "sanity.imageDimensions"; - height: number; - width: number; - aspectRatio: number; - }; - palette?: - | { - _type: "sanity.imagePalette"; - darkMuted?: - | { - _type: "sanity.imagePaletteSwatch"; - background: string; - foreground: string; - population: number; - title?: string | undefined; - } - | undefined; - darkVibrant?: - | { - _type: "sanity.imagePaletteSwatch"; - background: string; - foreground: string; - population: number; - title?: string | undefined; - } - | undefined; - dominant?: - | { - _type: "sanity.imagePaletteSwatch"; - background: string; - foreground: string; - population: number; - title?: string | undefined; - } - | undefined; - lightMuted?: - | { - _type: "sanity.imagePaletteSwatch"; - background: string; - foreground: string; - population: number; - title?: string | undefined; - } - | undefined; - lightVibrant?: - | { - _type: "sanity.imagePaletteSwatch"; - background: string; - foreground: string; - population: number; - title?: string | undefined; - } - | undefined; - muted?: - | { - _type: "sanity.imagePaletteSwatch"; - background: string; - foreground: string; - population: number; - title?: string | undefined; - } - | undefined; - vibrant?: - | { - _type: "sanity.imagePaletteSwatch"; - background: string; - foreground: string; - population: number; - title?: string | undefined; - } - | undefined; - } - | undefined; - lqip?: string | undefined; - blurHash?: string | undefined; - hasAlpha: boolean; - isOpaque: boolean; - exif?: - | { [x: string]: unknown; _type: "sanity.imageExifMetadata" } - | undefined; - location?: - | { - _type: "geopoint"; - lat: number; - lng: number; - alt?: number | undefined; - } - | undefined; - }; - }; - description: { - _type: "block"; - children: { - text: string; - _type: "span"; - [decorator]: any; - marks: string[]; - _key: string; - }[]; - level?: number | undefined; - listItem?: any; - markDefs: never[]; - style: any; - _key: string; - }[]; - style: { - name?: string | undefined; - slug: { _type: "slug"; current: string }; - _createdAt: string; - _id: string; - _rev: string; - _updatedAt: string; - _type: "style"; - }; - category: { - name: string; - slug: { _type: "slug"; current: string }; - description?: string | undefined; - _createdAt: string; - _id: string; - _rev: string; - _updatedAt: string; - images?: - | { - _ref: string; - _type: "reference"; - [referenced]?: "categoryImage"; - _key: string; - }[] - | undefined; - _type: "category"; - }; - categoryImage: { - name: string; - description?: string | undefined; - _createdAt: string; - _id: string; - _rev: string; - _updatedAt: string; - images: { - _type: "image"; - asset: { - _ref: string; - _type: "reference"; - [referenced]?: "sanity.imageAsset"; - }; - }; - _type: "categoryImage"; - }; - flavour: { - name?: string | undefined; - slug: { _type: "slug"; current: string }; - _createdAt: string; - _id: string; - _rev: string; - _updatedAt: string; - _type: "flavour"; - }; - product: { - name: string; - slug: { _type: "slug"; current: string }; - description?: - | { - _type: "block"; - children: { - text: string; - _type: "span"; - [decorator]: any; - marks: string[]; - _key: string; - }[]; - level?: number | undefined; - listItem?: any; - markDefs: never[]; - style: any; - _key: string; - }[] - | undefined; - _createdAt: string; - _id: string; - _rev: string; - _updatedAt: string; - images?: - | { - name: string; - description?: string | undefined; - asset: { - _ref: string; - _type: "reference"; - [referenced]?: "sanity.imageAsset"; - }; - _type: "productImage"; - _key: string; - }[] - | undefined; - categories?: - | { - _ref: string; - _type: "reference"; - [referenced]?: "category"; - _key: string; - }[] - | undefined; - variants?: - | { - _ref: string; - _type: "reference"; - [referenced]?: "variant"; - _key: string; - }[] - | undefined; - _type: "product"; - }; - productImage: { - name: string; - description?: string | undefined; - asset: { - _ref: string; - _type: "reference"; - [referenced]?: "sanity.imageAsset"; - }; - _type: "productImage"; - }; - variant: { - name: string; - slug: { _type: "slug"; current: string }; - description?: - | { - _type: "block"; - children: { - text: string; - _type: "span"; - [decorator]: any; - marks: string[]; - _key: string; - }[]; - level?: number | undefined; - listItem?: any; - markDefs: never[]; - style: any; - _key: string; - }[] - | undefined; - style?: - | { - _ref: string; - _type: "reference"; - [referenced]?: "style"; - _key: string; - }[] - | undefined; - id?: string | undefined; - _createdAt: string; - _id: string; - _rev: string; - _updatedAt: string; - images?: - | { - name: string; - description?: string | undefined; - asset: { - _ref: string; - _type: "reference"; - [referenced]?: "sanity.imageAsset"; - }; - _type: "productImage"; - _key: string; - }[] - | undefined; - flavour?: - | { - _ref: string; - _type: "reference"; - [referenced]?: "flavour"; - _key: string; - }[] - | undefined; - msrp: number; - price: number; - _type: "variant"; - }; - siteSettings: { - title?: string | undefined; - description?: string | undefined; - _createdAt: string; - _id: string; - _rev: string; - _updatedAt: string; - _type: "siteSettings"; - }; -}; diff --git a/packages/groqd/src/types/invalid-query-error.ts b/packages/groqd/src/types/invalid-query-error.ts new file mode 100644 index 00000000..4860e166 --- /dev/null +++ b/packages/groqd/src/types/invalid-query-error.ts @@ -0,0 +1,9 @@ +/** + * An error that is thrown when compiling a query. + * This indicates that you've performed an invalid chain. + */ +export class InvalidQueryError extends Error { + constructor(public key: string, message: string, private details?: any) { + super(`[${key}] ${message}`); + } +} diff --git a/packages/groqd/src/types/public-types.ts b/packages/groqd/src/types/public-types.ts index e7ca2f53..f06325ca 100644 --- a/packages/groqd/src/types/public-types.ts +++ b/packages/groqd/src/types/public-types.ts @@ -63,8 +63,8 @@ export declare const GroqBuilderResultType: unique symbol; export declare const GroqBuilderConfigType: unique symbol; /** - * IGroqBuilder is the bare minimum GroqBuilder, used to prevent circular references - * @internal + * IGroqBuilder contains the minimum results of a GroqBuilder chain, + * used to prevent circular references */ export type IGroqBuilder< TResult = unknown, @@ -95,6 +95,16 @@ export type IGroqBuilder< */ readonly parse: ParserFunction; }; + +/** + * Represents a GroqBuilder chain that is "terminal", + * and should not be further chained. + */ +export type IGroqBuilderNotChainable< + TResult, + TQueryConfig extends QueryConfig +> = IGroqBuilder; + /** * Extracts the Result type from a GroqBuilder query */ diff --git a/packages/groqd/src/types/query-error.ts b/packages/groqd/src/types/query-error.ts deleted file mode 100644 index 5084ccab..00000000 --- a/packages/groqd/src/types/query-error.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** - * An error that is thrown when compiling a query. - * This indicates that you've performed an invalid chain. - */ -export class QueryError extends Error { - constructor(message: string, private details: any) { - super(message); - } -} diff --git a/packages/groqd/src/validation/zod.test.ts b/packages/groqd/src/validation/zod.test.ts index abdad71e..b481453a 100644 --- a/packages/groqd/src/validation/zod.test.ts +++ b/packages/groqd/src/validation/zod.test.ts @@ -1,5 +1,5 @@ import { expect, describe, it, expectTypeOf } from "vitest"; -import { createGroqBuilderWithZod, InferResultType } from "../index"; +import { createGroqBuilderWithZod, InferResultItem } from "../index"; import { SchemaConfig } from "../tests/schemas/nextjs-sanity-fe"; import { mock } from "../tests/mocks/nextjs-sanity-fe-mocks"; import { executeBuilder } from "../tests/mocks/executeQuery"; @@ -18,13 +18,11 @@ describe("with zod", () => { }); it("should infer the right type", () => { - expectTypeOf>().toEqualTypeOf< - Array<{ - name: string; - price: number; - id: string | null; - }> - >(); + expectTypeOf>().toEqualTypeOf<{ + name: string; + price: number; + id: string | null; + }>(); }); it("should execute with valid data", async () => { const data = mock.generateSeedData({ @@ -81,50 +79,42 @@ describe("with zod", () => { const qErr = qVariants.project({ id: q.string().default("DEFAULT"), }); - expectTypeOf>().toEqualTypeOf< - Array<{ - id: - | string - | TypeMismatchError<{ - error: `⛔️ The 'id' field has a data type that is not fully compatible with the specified parser ⛔️`; - expected: string | undefined; - actual: null; - }>; - }> - >(); + expectTypeOf>().toEqualTypeOf<{ + id: + | string + | TypeMismatchError<{ + error: `⛔️ The 'id' field has a data type that is not fully compatible with the specified parser ⛔️`; + expected: string | undefined; + actual: null; + }>; + }>(); // @ts-expect-error --- The parser for the 'id' field expects the wrong input type const qRes = qVariants.project({ id: q.string(), }); - expectTypeOf>().toEqualTypeOf< - Array<{ - id: - | string - | TypeMismatchError<{ - error: `⛔️ The 'id' field has a data type that is not fully compatible with the specified parser ⛔️`; - expected: string; - actual: null; - }>; - }> - >(); + expectTypeOf>().toEqualTypeOf<{ + id: + | string + | TypeMismatchError<{ + error: `⛔️ The 'id' field has a data type that is not fully compatible with the specified parser ⛔️`; + expected: string; + actual: null; + }>; + }>(); }); it("infers the correct type", () => { const qNormal = qVariants.project({ id: true }); - expectTypeOf>().toEqualTypeOf< - Array<{ - id: string | null; - }> - >(); + expectTypeOf>().toEqualTypeOf<{ + id: string | null; + }>(); const query = qVariants.project({ id: q.default(q.string(), "DEFAULT"), }); - expectTypeOf>().toEqualTypeOf< - Array<{ - id: string; - }> - >(); + expectTypeOf>().toEqualTypeOf<{ + id: string; + }>(); }); }); describe("q.slug helper", () => { @@ -133,9 +123,9 @@ describe("with zod", () => { }); it("should have the correct type", () => { - expectTypeOf>().toEqualTypeOf< - Array<{ SLUG: string }> - >(); + expectTypeOf>().toEqualTypeOf<{ + SLUG: string; + }>(); }); it("should not allow invalid fields to be slugged", () => { @@ -188,19 +178,18 @@ describe("with zod", () => { }); describe("zod input widening", () => { - const qVariant = qVariants.slice(0); it("should complain if the parser's input is narrower than the input", () => { // First, show that `id` is optional/nullable - const qResultNormal = qVariant.project({ id: true }); - expectTypeOf>().toEqualTypeOf<{ + const qResultNormal = qVariants.project({ id: true }); + expectTypeOf>().toEqualTypeOf<{ id: string | null; }>(); // Now, let's pick `id` with a too-narrow parser: // @ts-expect-error --- - const qResult = qVariant.project({ id: q.string() }); + const qResult = qVariants.project({ id: q.string() }); // Ensure we return an error result: - expectTypeOf>().toEqualTypeOf<{ + expectTypeOf>().toEqualTypeOf<{ id: | string | TypeMismatchError<{ @@ -213,17 +202,15 @@ describe("with zod", () => { it("shouldn't complain if the parser's input is wider than the input", () => { // First, show that `name` is a required string: const qName = qVariants.project({ name: true }); - expectTypeOf>().toEqualTypeOf< - Array<{ - name: string; - }> - >(); + expectTypeOf>().toEqualTypeOf<{ + name: string; + }>(); // Now let's use a parser that allows for string | null: - const qWideParser = qVariant.project({ + const qWideParser = qVariants.project({ name: q.string().nullable(), }); - expectTypeOf>().toEqualTypeOf<{ + expectTypeOf>().toEqualTypeOf<{ name: string | null; }>(); });