diff --git a/.changeset/forty-spiders-hug.md b/.changeset/forty-spiders-hug.md new file mode 100644 index 00000000..d69fb13f --- /dev/null +++ b/.changeset/forty-spiders-hug.md @@ -0,0 +1,15 @@ +--- +"groq-builder": minor +--- + +Added `createGroqBuilderWithZod()` and removed the `.include(zod)` method + +Removed internal validation methods; use Zod methods instead + +Added `validationRequired` option to require runtime validation + +Removed `$` from `q.conditional$` and `q.select$` methods; these are now just `q.conditional` and `q.select` + +Added optional validation parameter to `q.field(field, parser?)` + +Cleaned up some internal types, added better type documentation diff --git a/packages/groq-builder/docs/CONDITIONALS.md b/packages/groq-builder/docs/CONDITIONALS.md index c0fd9017..8f6b3cb6 100644 --- a/packages/groq-builder/docs/CONDITIONALS.md +++ b/packages/groq-builder/docs/CONDITIONALS.md @@ -4,14 +4,14 @@ In Groq, there are 2 ways to use conditional logic: inline in a projection, or u ## Conditions in a projection -In `groq-builder`, the `project` method allows inline conditional statements with the help of `q.conditional$(...)` or `q.conditionalByType(...)` using the following syntax: +In `groq-builder`, the `project` method allows inline conditional statements with the help of `q.conditional(...)` or `q.conditionalByType(...)` using the following syntax: ```ts const contentQuery = q.star .filterByType("movie", "actor") .project({ slug: "slug.current", - ...q.conditional$({ + ...q.conditional({ "_type == 'movie'": { title: "title", subtitle: "description" }, "_type == 'actor'": { title: "name", subtitle: "biography" }, }), @@ -43,11 +43,11 @@ type ContentResults = Array< >; ``` -Notice that the conditions are wrapped in `q.conditional$()` and then spread into the projection. This is necessary for type-safety and runtime validation. +Notice that the conditions are wrapped in `q.conditional()` and then spread into the projection. This is necessary for type-safety and runtime validation. -The `$` in the method `q.conditional$` indicates that this method is not completely type-safe; the condition statements (eg. `_type == 'movie'`) are not strongly-typed (this may be improved in a future version). +Please note that the condition statements (eg. `_type == 'movie'`) are not strongly-typed. For now, any string is valid, and no auto-complete is provided. This may be improved in a future version. -However, the most common use-case is to base conditional logic off the document's `_type`. For this, we have the `q.conditionalByType` helper: +However, the most common use-case is to base conditional logic off the document's `_type`. For this, we have a stronger-typed `q.conditionalByType` helper: ### Strongly-typed conditions via `q.conditionalByType(...)` @@ -66,7 +66,7 @@ const contentQuery = q.star })); ``` -The resulting query is identical to the above example with `q.conditional$`. +The resulting query is identical to the above example with `q.conditional`. The result type here is inferred as: @@ -78,23 +78,21 @@ type ContentResult = Array< > ``` -Notice that this type is stronger than the example with `q.conditional$`, because we've detected that the conditions are "exhaustive". +Notice that this type is stronger than the example with `q.conditional`, because we've detected that the conditions are "exhaustive". ## The `select` method -Adds support for the `select$` method: +Adds support for the `select` method: ```ts const qMovies = q.star.filterByType("movie").project({ name: true, - popularity: q.select$({ + popularity: q.select({ "popularity > 20": q.value("high"), "popularity > 10": q.value("medium"), }, q.value("low")), }); ``` -The `$` sign is to indicate that there's some "loosely typed" code in here -- the conditions are unchecked. - This will output the following query: ```groq *[_type == "movie"] { diff --git a/packages/groq-builder/docs/MIGRATION.md b/packages/groq-builder/docs/MIGRATION.md index 185bf0b4..2ae1a3fd 100644 --- a/packages/groq-builder/docs/MIGRATION.md +++ b/packages/groq-builder/docs/MIGRATION.md @@ -26,9 +26,9 @@ const productsQuery = q("*") #### After, with `groq-builder` ```ts -import { createGroqBuilder, validation } from "groq-builder"; +import { createGroqBuilderWithZod } from "groq-builder"; // Using 'any' makes the query schema-unaware: -const q = createGroqBuilder().include(validation); +const q = createGroqBuilderWithZod(); const productsQuery = q.star .filterByType("product") @@ -53,18 +53,15 @@ Keep reading for a deeper explanation of these changes. ```ts // src/queries/q.ts -import { createGroqBuilder, validation } from 'groq-builder'; +import { createGroqBuilderWithZod } from 'groq-builder'; type SchemaConfig = any; -export const q = createGroqBuilder().include(validation); +export const q = createGroqBuilderWithZod(); ``` By creating the root `q` this way, we're able to bind it to our `SchemaConfig`. By using `any` for now, our `q` will be schema-unaware (same as `groqd`). Later, we'll show you how to change this to a strongly-typed schema. -We also call `.include(validation)` to extend the root `q` with our validation methods, like `q.string()`. -This is for convenience and compatibility. - ## Step 2: Replacing the `q("...")` method This is the biggest API change. @@ -113,7 +110,7 @@ With `groq-builder`, by [adding a strongly-typed Sanity schema](./README.md#sche - Safer to write (all commands are type-checked, all fields are verified) - Faster to execute (because runtime validation can be skipped) -In a projection, we can skip runtime validation by simply using `true` instead of a validation method (like `q.string()`). For example: +In a projection, we can skip runtime validation by simply using `true` instead of a validation method like `q.string()`. For example: ```ts const productsQuery = q.star .filterByType("product") @@ -124,7 +121,7 @@ const productsQuery = q.star }); ``` -Since `q` is strongly-typed to our Sanity schema, it knows the types of the product's `name`, `price`, and `slug`, so it outputs a strongly-typed result. And assuming we trust our Sanity schema, we can skip the overhead of runtime checks. +Since `q` is strongly-typed to our Sanity schema, it knows the types of the product's `name`, `price`, and `slug.current`, so it outputs a strongly-typed result. And assuming we trust our Sanity schema, we can skip the overhead of runtime checks. ## Additional Improvements @@ -133,30 +130,22 @@ Since `q` is strongly-typed to our Sanity schema, it knows the types of the prod The `grab`, `grabOne`, `grab$`, and `grabOne$` methods still exist, but have been deprecated, and should be replaced with the `project` and `field` methods. -Sanity's documentation uses the word "projection" to refer to grabbing specific fields, so we have renamed the `grab` method to `project` (pronounced pruh-JEKT, if that helps). It also uses the phrase "naked projection" to refer to grabbing a single field, but to keep things terse, we've renamed `grabOne` to `field`. So we recommend migrating from `grab` to `project`, and from `grabOne` to `field`. - -Regarding `grab$` and `grabOne$`, these 2 variants were needed to improve compatibility with Zod's `.optional()` utility. But the `project` and `field` methods work just fine with the built-in validation functions (like `q.string().optional()`). - - -### `q.select(...)` -This is not yet supported by `groq-builder`. +Sanity's documentation uses the word "projection" to refer to grabbing specific fields, so we have renamed the `grab` method to `project` (pronounced *pruh-JEKT*, if that helps). It also uses the phrase "naked projection" to refer to grabbing a single field, but to keep things terse, we've renamed `grabOne` to `field`. So we recommend migrating from `grab` to `project`, and from `grabOne` to `field`. -### Validation methods +#### Alternatives for `grab$` and `grabOne$` -Most validation methods, like `q.string()` or `q.number()`, are built-in now, and are no longer powered by Zod. These validation methods work mostly the same, but are simplified and more specialized to work with a strongly-typed schema. - -Some of the built-in validation methods, like `q.object()` and `q.array()`, are much simpler than the previous Zod version. -These check that the data is an `object` or an `array`, but do NOT check the shape of the data. - -Please use Zod if you need to validate an object's shape, validate items inside an Array, or you'd like more powerful runtime validation logic. For example: - -```ts -import { z } from 'zod'; +Regarding `grab$` and `grabOne$`, these 2 variants were needed to improve compatibility with Zod's `.default(...)` utility. +This feature has been dropped, in favor of using the `q.default` utility. For example: -q.star.filterByType("user").project({ - email: z.coerce.string().email().min(5), - createdAt: z.string().datetime().optional(), -}); +Before: +``` +q.grab$({ + field: q.string().default("DEFAULT"), +}) +``` +After: +``` +q.project({ + field: q.default(q.string(), "DEFAULT")), +}) ``` - - diff --git a/packages/groq-builder/package.json b/packages/groq-builder/package.json index 94846bd0..d82d8933 100644 --- a/packages/groq-builder/package.json +++ b/packages/groq-builder/package.json @@ -44,6 +44,10 @@ "build": "pnpm run clean && tsc --project tsconfig.build.json", "prepublishOnly": "pnpm run build" }, + "dependencies": { + "type-fest": "^4.10.1", + "zod": "^3.22.4" + }, "devDependencies": { "@sanity/client": "^3.4.1", "groq-js": "^1.1.9", diff --git a/packages/groq-builder/src/commands/conditional-types.ts b/packages/groq-builder/src/commands/conditional-types.ts index 1d1ccbb9..078c25b9 100644 --- a/packages/groq-builder/src/commands/conditional-types.ts +++ b/packages/groq-builder/src/commands/conditional-types.ts @@ -3,13 +3,7 @@ import { ProjectionMap, ProjectionMapOrCallback, } from "./projection-types"; -import { - Empty, - IntersectionOfValues, - Simplify, - Tagged, - ValueOf, -} from "../types/utils"; +import { Empty, IntersectionOfValues, Simplify, ValueOf } from "../types/utils"; import { ExtractTypeNames, RootConfig } from "../types/schema-types"; import { GroqBuilder } from "../groq-builder"; import { IGroqBuilder, InferResultType } from "../types/public-types"; @@ -25,7 +19,12 @@ export type ConditionalProjectionMap< ) => ProjectionMap); }; -export type ConditionalExpression = Tagged; +/** + * For now, none of our "conditions" are strongly-typed, + * so we'll just use "string": + */ +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export type ConditionalExpression = string; export type ExtractConditionalProjectionResults< TResultItem, @@ -42,10 +41,6 @@ export type ExtractConditionalProjectionResults< }> >; -export type OmitConditionalProjections = { - [P in Exclude>]: TResultItem[P]; -}; - export type ExtractConditionalProjectionTypes = Simplify< IntersectionOfValues<{ [P in Extract< diff --git a/packages/groq-builder/src/commands/conditional$.test.ts b/packages/groq-builder/src/commands/conditional.test.ts similarity index 90% rename from packages/groq-builder/src/commands/conditional$.test.ts rename to packages/groq-builder/src/commands/conditional.test.ts index f926a220..2c94612c 100644 --- a/packages/groq-builder/src/commands/conditional$.test.ts +++ b/packages/groq-builder/src/commands/conditional.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, expectTypeOf, it } from "vitest"; import { createGroqBuilder, GroqBuilder, @@ -7,15 +7,14 @@ import { } from "../index"; import { SchemaConfig } from "../tests/schemas/nextjs-sanity-fe"; import { ExtractConditionalProjectionTypes } from "./conditional-types"; -import { expectType } from "../tests/expectType"; import { Empty, Simplify } from "../types/utils"; const q = createGroqBuilder({ indent: " " }); const qBase = q.star.filterByType("variant"); -describe("conditional$", () => { +describe("conditional", () => { describe("by itself", () => { - const conditionalResult = q.star.filterByType("variant").conditional$({ + const conditionalResult = q.star.filterByType("variant").conditional({ "price == msrp": { onSale: q.value(false), }, @@ -27,9 +26,9 @@ describe("conditional$", () => { }); it("we should be able to extract the intersection of projection types", () => { - expectType< + expectTypeOf< Simplify> - >().toStrictEqual< + >().toEqualTypeOf< | Empty | { onSale: false } | { onSale: true; price: number; msrp: number } @@ -44,7 +43,7 @@ describe("conditional$", () => { const qAll = qBase.project((qA) => ({ name: true, - ...qA.conditional$({ + ...qA.conditional({ "price == msrp": { onSale: q.value(false), }, @@ -57,7 +56,7 @@ describe("conditional$", () => { })); it("should be able to extract the return type", () => { - expectType>().toStrictEqual< + expectTypeOf>().toEqualTypeOf< Array< | { name: string } | { name: string; onSale: false } @@ -88,7 +87,7 @@ describe("conditional$", () => { describe("without using unique keys", () => { const qIncorrect = q.star.filterByType("variant").project((qV) => ({ name: true, - ...qV.conditional$({ + ...qV.conditional({ "price == msrp": { onSale: q.value(false), }, @@ -101,13 +100,13 @@ describe("conditional$", () => { // Here we're trying to spread another conditional, // however, it will override the first one // since we didn't specify a unique key: - ...qV.conditional$({ + ...qV.conditional({ "second == condition": { price: true }, }), })); it("the type will be missing the first conditionals", () => { - expectType>().toStrictEqual< + expectTypeOf>().toEqualTypeOf< Array<{ name: string } | { name: string; price: number }> >(); }); @@ -128,7 +127,7 @@ describe("conditional$", () => { .filterByType("variant") .project((qV) => ({ name: true, - ...qV.conditional$({ + ...qV.conditional({ "price == msrp": { onSale: q.value(false), }, @@ -138,7 +137,7 @@ describe("conditional$", () => { msrp: true, }, }), - ...qV.conditional$( + ...qV.conditional( { "another == condition1": { foo: q.value("FOO") }, "another == condition2": { bar: q.value("BAR") }, @@ -173,8 +172,8 @@ describe("conditional$", () => { }; type Remainder = Exclude; - expectType().toStrictEqual(); - expectType().toStrictEqual(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); }); it("the query should be compiled correctly", () => { diff --git a/packages/groq-builder/src/commands/conditional$.ts b/packages/groq-builder/src/commands/conditional.ts similarity index 96% rename from packages/groq-builder/src/commands/conditional$.ts rename to packages/groq-builder/src/commands/conditional.ts index 08c8c67c..e5c28660 100644 --- a/packages/groq-builder/src/commands/conditional$.ts +++ b/packages/groq-builder/src/commands/conditional.ts @@ -13,9 +13,9 @@ import { ProjectionMap } from "./projection-types"; declare module "../groq-builder" { // eslint-disable-next-line @typescript-eslint/no-unused-vars export interface GroqBuilder { - conditional$< + conditional< TConditionalProjections extends ConditionalProjectionMap< - ResultItem, + ResultItem.Infer, TRootConfig >, TKey extends string = "[$]", @@ -24,7 +24,7 @@ declare module "../groq-builder" { conditionalProjections: TConditionalProjections, config?: Partial> ): ExtractConditionalProjectionResults< - ResultItem, + ResultItem.Infer, TConditionalProjections, ConditionalConfig >; @@ -32,7 +32,7 @@ declare module "../groq-builder" { } GroqBuilder.implement({ - conditional$< + conditional< TCP extends object, TKey extends string, TIsExhaustive extends boolean diff --git a/packages/groq-builder/src/commands/conditionalByType.test.ts b/packages/groq-builder/src/commands/conditionalByType.test.ts index 4a13762b..fdb1fba7 100644 --- a/packages/groq-builder/src/commands/conditionalByType.test.ts +++ b/packages/groq-builder/src/commands/conditionalByType.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, expectTypeOf } from "vitest"; import { createGroqBuilder, ExtractTypeNames, @@ -9,7 +9,6 @@ import { } from "../index"; import { SchemaConfig } from "../tests/schemas/nextjs-sanity-fe"; import { ExtractConditionalProjectionTypes } from "./conditional-types"; -import { expectType } from "../tests/expectType"; import { executeBuilder } from "../tests/mocks/executeQuery"; import { mock } from "../tests/mocks/nextjs-sanity-fe-mocks"; import { Empty, Simplify, SimplifyDeep } from "../types/utils"; @@ -39,7 +38,7 @@ describe("conditionalByType", () => { | { _type: "category"; name: string; slug: string }; it('should have a "spreadable" signature', () => { - expectType>().toStrictEqual< + expectTypeOf>().toEqualTypeOf< SimplifyDeep<{ "[Conditional] [ByType]": IGroqBuilder; }> @@ -71,16 +70,16 @@ describe("conditionalByType", () => { | Empty | { price: number } | { slug: string } - | { description: string | undefined } - | { name: string | undefined } - | { price: number; description: string | undefined } - | { price: number; name: string | undefined } - | { slug: string; description: string | undefined } - | { slug: string; name: string | undefined }; + | { description: string | null } + | { name: string | null } + | { price: number; description: string | null } + | { price: number; name: string | null } + | { slug: string; description: string | null } + | { slug: string; name: string | null }; type Remainder = Exclude; - expectType().toStrictEqual(); - expectType().toStrictEqual(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); }); it("the query should be correct", () => { @@ -119,7 +118,7 @@ describe("conditionalByType", () => { | { _type: "product"; name: string } | { _type: "variant"; price: number }; - expectType>().toStrictEqual(); + expectTypeOf>().toEqualTypeOf(); }); it("should be able to extract the return types", () => { @@ -127,7 +126,7 @@ describe("conditionalByType", () => { typeof conditionalByType >; - expectType().toStrictEqual< + expectTypeOf().toEqualTypeOf< | Empty | { _type: "variant"; name: string; price: number } | { _type: "product"; name: string; slug: string } @@ -147,7 +146,7 @@ describe("conditionalByType", () => { type QueryResult = InferResultType; type DocTypes = ExtractTypeNames; - expectType().toStrictEqual< + expectTypeOf().toEqualTypeOf< Array< | { _type: DocTypes; diff --git a/packages/groq-builder/src/commands/conditionalByType.ts b/packages/groq-builder/src/commands/conditionalByType.ts index 5fc0fa35..c08a7480 100644 --- a/packages/groq-builder/src/commands/conditionalByType.ts +++ b/packages/groq-builder/src/commands/conditionalByType.ts @@ -13,7 +13,7 @@ declare module "../groq-builder" { export interface GroqBuilder { conditionalByType< TConditionalProjections extends ConditionalByTypeProjectionMap< - ResultItem, + ResultItem.Infer, TRootConfig >, TKey extends string = "[ByType]", @@ -21,7 +21,7 @@ declare module "../groq-builder" { * Did we supply a condition for all possible _type values? */ TIsExhaustive extends boolean = ExtractTypeNames< - ResultItem + ResultItem.Infer > extends keyof TConditionalProjections ? true : false @@ -29,7 +29,7 @@ declare module "../groq-builder" { conditionalProjections: TConditionalProjections, config?: Partial> ): ExtractConditionalByTypeProjectionResults< - ResultItem, + ResultItem.Infer, TConditionalProjections, ConditionalConfig >; diff --git a/packages/groq-builder/src/commands/deref.test.ts b/packages/groq-builder/src/commands/deref.test.ts index fe92bb5f..2d657213 100644 --- a/packages/groq-builder/src/commands/deref.test.ts +++ b/packages/groq-builder/src/commands/deref.test.ts @@ -1,5 +1,4 @@ -import { describe, expect, it } from "vitest"; -import { expectType } from "../tests/expectType"; +import { describe, expect, expectTypeOf, it } from "vitest"; import { InferResultType } from "../types/public-types"; import { createGroqBuilder } from "../index"; import { executeBuilder } from "../tests/mocks/executeQuery"; @@ -17,18 +16,18 @@ describe("deref", () => { const qVariants = qVariantsRefs.deref(); it("should deref a single item", () => { - expectType< + expectTypeOf< InferResultType - >().toEqual(); + >().toEqualTypeOf(); expect(qCategory.query).toMatchInlineSnapshot( '"*[_type == \\"product\\"][0].categories[][0]->"' ); }); it("should deref an array of items", () => { - expectType< + expectTypeOf< InferResultType - >().toStrictEqual | null>(); + >().toEqualTypeOf | null>(); expect(qVariants.query).toMatchInlineSnapshot( '"*[_type == \\"product\\"][0].variants[]->"' ); @@ -36,16 +35,16 @@ describe("deref", () => { it("should be an error if the item is not a reference", () => { const notAReference = qProduct.field("slug"); - expectType>().toStrictEqual<{ + expectTypeOf>().toEqualTypeOf<{ _type: "slug"; current: string; }>(); const res = notAReference.deref(); type ErrorResult = InferResultType; - expectType< + expectTypeOf< ErrorResult["error"] - >().toStrictEqual<"⛔️ Expected the object to be a reference type ⛔️">(); + >().toEqualTypeOf<"⛔️ Expected the object to be a reference type ⛔️">(); }); it("should execute correctly (single)", async () => { diff --git a/packages/groq-builder/src/commands/deref.ts b/packages/groq-builder/src/commands/deref.ts index 127ccea9..e0601d51 100644 --- a/packages/groq-builder/src/commands/deref.ts +++ b/packages/groq-builder/src/commands/deref.ts @@ -1,17 +1,20 @@ import { GroqBuilder } from "../groq-builder"; import { ExtractRefType, RootConfig } from "../types/schema-types"; -import { ResultItem, ResultOverride } from "../types/result-types"; +import { ResultItem } from "../types/result-types"; declare module "../groq-builder" { export interface GroqBuilder { deref< - TReferencedType = ExtractRefType, TRootConfig> - >(): GroqBuilder, TRootConfig>; + TReferencedType = ExtractRefType, TRootConfig> + >(): GroqBuilder< + ResultItem.Override, + TRootConfig + >; } } GroqBuilder.implement({ deref(this: GroqBuilder) { - return this.chain("->", null); + return this.chain("->"); }, }); diff --git a/packages/groq-builder/src/commands/filter.ts b/packages/groq-builder/src/commands/filter.ts index 90eb12f5..b5c166cc 100644 --- a/packages/groq-builder/src/commands/filter.ts +++ b/packages/groq-builder/src/commands/filter.ts @@ -1,14 +1,17 @@ import { GroqBuilder } from "../groq-builder"; import { RootConfig } from "../types/schema-types"; +import { ConditionalExpression } from "./conditional-types"; declare module "../groq-builder" { export interface GroqBuilder { - filter(filterExpression: string): GroqBuilder; + filter( + filterExpression: ConditionalExpression + ): GroqBuilder; } } GroqBuilder.implement({ filter(this: GroqBuilder, filterExpression) { - return this.chain(`[${filterExpression}]`, null); + return this.chain(`[${filterExpression}]`); }, }); diff --git a/packages/groq-builder/src/commands/filterByType.test.ts b/packages/groq-builder/src/commands/filterByType.test.ts index f4aad4be..f01c7e35 100644 --- a/packages/groq-builder/src/commands/filterByType.test.ts +++ b/packages/groq-builder/src/commands/filterByType.test.ts @@ -1,6 +1,5 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, expectTypeOf } from "vitest"; import { SanitySchema, SchemaConfig } from "../tests/schemas/nextjs-sanity-fe"; -import { expectType } from "../tests/expectType"; import { InferResultType } from "../types/public-types"; import { createGroqBuilder } from "../index"; import { executeBuilder } from "../tests/mocks/executeQuery"; @@ -13,7 +12,7 @@ const data = mock.generateSeedData({}); describe("filterByType", () => { const qProduct = q.star.filterByType("product"); it("types should be correct", () => { - expectType>().toStrictEqual< + expectTypeOf>().toEqualTypeOf< Array >(); }); diff --git a/packages/groq-builder/src/commands/filterByType.ts b/packages/groq-builder/src/commands/filterByType.ts index 17d000dc..2a27f20d 100644 --- a/packages/groq-builder/src/commands/filterByType.ts +++ b/packages/groq-builder/src/commands/filterByType.ts @@ -1,13 +1,16 @@ import { GroqBuilder } from "../groq-builder"; -import { ResultItem, ResultOverride } from "../types/result-types"; +import { ResultItem } from "../types/result-types"; import { ExtractTypeNames } from "../types/schema-types"; declare module "../groq-builder" { export interface GroqBuilder { - filterByType>>( + filterByType>>( ...type: TType[] ): GroqBuilder< - ResultOverride, { _type: TType }>>, + ResultItem.Override< + TResult, + Extract, { _type: TType }> + >, TRootConfig >; } @@ -15,9 +18,6 @@ declare module "../groq-builder" { GroqBuilder.implement({ filterByType(this: GroqBuilder, ...type) { - return this.chain( - `[${type.map((t) => `_type == "${t}"`).join(" || ")}]`, - null - ); + return this.chain(`[${type.map((t) => `_type == "${t}"`).join(" || ")}]`); }, }); diff --git a/packages/groq-builder/src/commands/fragment.test.ts b/packages/groq-builder/src/commands/fragment.test.ts index 8f392bcb..a7b4812d 100644 --- a/packages/groq-builder/src/commands/fragment.test.ts +++ b/packages/groq-builder/src/commands/fragment.test.ts @@ -1,6 +1,5 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, expectTypeOf } from "vitest"; import { SanitySchema, SchemaConfig } from "../tests/schemas/nextjs-sanity-fe"; -import { expectType } from "../tests/expectType"; import { InferFragmentType, InferResultType } from "../types/public-types"; import { createGroqBuilder } from "../index"; import { TypeMismatchError } from "../types/utils"; @@ -17,7 +16,7 @@ describe("fragment", () => { type VariantFragment = InferFragmentType; it("simple fragment should have the correct type", () => { - expectType().toStrictEqual<{ + expectTypeOf().toEqualTypeOf<{ name: string; price: number; slug: string; @@ -38,7 +37,7 @@ describe("fragment", () => { type ProductFragment = InferFragmentType; it("nested fragments should have the correct types", () => { - expectType().toEqual<{ + expectTypeOf().toEqualTypeOf<{ name: string; slug: string; variants: null | Array<{ @@ -52,7 +51,7 @@ describe("fragment", () => { it("fragments can be used in a query", () => { const qVariants = q.star.filterByType("variant").project(variantFragment); - expectType>().toStrictEqual< + expectTypeOf>().toEqualTypeOf< Array >(); @@ -72,7 +71,7 @@ describe("fragment", () => { ...variantFragment, msrp: true, }); - expectType>().toStrictEqual< + expectTypeOf>().toEqualTypeOf< Array<{ name: string; price: number; slug: string; msrp: number }> >(); @@ -89,10 +88,11 @@ describe("fragment", () => { }); it("should have errors if the variant is used incorrectly", () => { + // @ts-expect-error --- const qInvalid = q.star.filterByType("product").project(variantFragment); - expectType< + expectTypeOf< InferResultType[number]["price"] - >().toStrictEqual< + >().toEqualTypeOf< TypeMismatchError<{ error: "⛔️ 'true' can only be used for known properties ⛔️"; expected: keyof SanitySchema.Product; @@ -111,12 +111,12 @@ describe("fragment", () => { type VariantDetails = InferFragmentType; - expectType().toStrictEqual<{ + expectTypeOf().toEqualTypeOf<{ slug: string; name: string; msrp: number; price: number; - id: string | undefined; + id: string | null; }>(); }); @@ -131,7 +131,7 @@ describe("fragment", () => { name: true, }); type CommonFrag = InferFragmentType; - expectType().toStrictEqual<{ + expectTypeOf().toEqualTypeOf<{ _type: "product" | "variant" | "category"; _id: string; name: string; @@ -143,7 +143,7 @@ describe("fragment", () => { .fragment() .project((qP) => ({ name: true, - ...qP.conditional$({ + ...qP.conditional({ "price == msrp": { onSale: q.value(false) }, "price < msrp": { onSale: q.value(true), price: true, msrp: true }, }), @@ -154,9 +154,9 @@ describe("fragment", () => { }); it("the inferred type is correct", () => { - expectType< + expectTypeOf< InferFragmentType - >().toStrictEqual< + >().toEqualTypeOf< | { name: string } | { name: string; onSale: false } | { name: string; onSale: true; price: number; msrp: number } @@ -164,7 +164,7 @@ describe("fragment", () => { }); it("the fragment can be used in a query", () => { - expectType>().toStrictEqual< + expectTypeOf>().toEqualTypeOf< Array< | { slug: string; name: string } | { slug: string; name: string; onSale: false } diff --git a/packages/groq-builder/src/commands/grab-deprecated.test.ts b/packages/groq-builder/src/commands/grab-deprecated.test.ts index 5a82674c..34fcb1a9 100644 --- a/packages/groq-builder/src/commands/grab-deprecated.test.ts +++ b/packages/groq-builder/src/commands/grab-deprecated.test.ts @@ -1,9 +1,8 @@ -import { describe, expect, it } from "vitest"; -import { validation } from "../validation"; -import { expectType } from "../tests/expectType"; +import { describe, expect, expectTypeOf, it } from "vitest"; import { InferResultType } from "../types/public-types"; import { createGroqBuilder } from "../index"; import { SchemaConfig } from "../tests/schemas/nextjs-sanity-fe"; +import { zod } from "../validation/zod"; const q = createGroqBuilder(); const qVariants = q.star.filterByType("variant"); @@ -20,16 +19,16 @@ describe("grab (backwards compatibility)", () => { const qGrab = qVariants.grab((q) => ({ name: true, slug: "slug.current", - msrp: ["msrp", validation.number()], + msrp: ["msrp", zod.number()], styles: q.grabOne("style[]").deref().grabOne("name"), })); - expectType>().toStrictEqual< + expectTypeOf>().toEqualTypeOf< Array<{ name: string; slug: string; msrp: number; - styles: Array | null; + styles: Array | null; }> >(); }); diff --git a/packages/groq-builder/src/commands/index.ts b/packages/groq-builder/src/commands/index.ts index 3456f417..a20a3721 100644 --- a/packages/groq-builder/src/commands/index.ts +++ b/packages/groq-builder/src/commands/index.ts @@ -1,19 +1,18 @@ -import "./conditional$"; +import "./conditional"; import "./conditionalByType"; import "./deref"; import "./filter"; import "./filterByType"; import "./fragment"; import "./grab-deprecated"; +import "./nullable"; import "./order"; import "./project"; import "./projectField"; import "./raw"; -// import "./sanity-image"; -import "./select$"; +import "./select"; import "./selectByType"; import "./slice"; -import "./slug"; import "./star"; import "./validate"; diff --git a/packages/groq-builder/src/commands/nullable.test.ts b/packages/groq-builder/src/commands/nullable.test.ts new file mode 100644 index 00000000..57aa9b6c --- /dev/null +++ b/packages/groq-builder/src/commands/nullable.test.ts @@ -0,0 +1,110 @@ +import { describe, it, expect, expectTypeOf } from "vitest"; +import { SanitySchema, SchemaConfig } from "../tests/schemas/nextjs-sanity-fe"; +import { InferResultType } from "../types/public-types"; +import { createGroqBuilderWithZod } from "../index"; +import { executeBuilder } from "../tests/mocks/executeQuery"; +import { mock } from "../tests/mocks/nextjs-sanity-fe-mocks"; + +const q = createGroqBuilderWithZod(); +const qVariants = q.star.filterByType("variant"); + +describe("nullable", () => { + const data = mock.generateSeedData({ + variants: [ + mock.variant({ + // @ts-expect-error --- + name: null, + }), + mock.variant({}), + ], + }); + + const qVariantsNullable = qVariants.nullable(); + + it("should mark a valid query as nullable", () => { + expectTypeOf>().toEqualTypeOf< + Array + >(); + expectTypeOf< + InferResultType + >().toEqualTypeOf | null>(); + }); + it("doesn't really work with fields (naked projections), since it makes the whole array nullable", () => { + const qNullableField = qVariants.field("name").nullable(); + expectTypeOf< + InferResultType + >().toEqualTypeOf | null>(); + }); + it("to mark a field (naked projection) nullable, it's better to use zod", () => { + const qNullableFieldFixed = qVariants.field("name", q.string().nullable()); + expectTypeOf>().toEqualTypeOf< + Array + >(); + }); + + const qWithoutValidation = qVariants.project((qV) => ({ + name: qV.field("name").nullable(), + })); + it("should execute correctly, without runtime validation", async () => { + const results = await executeBuilder(qWithoutValidation, data.datalake); + expect(results).toMatchInlineSnapshot(` + [ + { + "name": null, + }, + { + "name": "Variant Name", + }, + ] + `); + }); + + describe("in a nested projection", () => { + const qNested = qVariants.project((qV) => ({ + name: qV.field("name"), + nameMaybe: qV.field("name").nullable(), + })); + it("should have the correct type", () => { + expectTypeOf>().toEqualTypeOf< + Array<{ + name: string; + nameMaybe: string | null; + }> + >(); + }); + }); + + describe("runtime validation", () => { + it("should do nothing if we're not using runtime validation", () => { + expect(qVariantsNullable.parser).toBeNull(); + }); + + const qWithValidation = q.star.filterByType("variant").project((qV) => ({ + name: qV.field("name", q.string()).nullable(), + })); + + it("should have the correct type", () => { + expectTypeOf>().toEqualTypeOf< + Array<{ + name: string | null; + }> + >(); + }); + it("should have a parser", () => { + expect(qWithValidation.parser).toBeTypeOf("function"); + }); + it("should execute correctly", async () => { + const results = await executeBuilder(qWithValidation, data.datalake); + expect(results).toMatchInlineSnapshot(` + [ + { + "name": null, + }, + { + "name": "Variant Name", + }, + ] + `); + }); + }); +}); diff --git a/packages/groq-builder/src/commands/nullable.ts b/packages/groq-builder/src/commands/nullable.ts new file mode 100644 index 00000000..fbe17e76 --- /dev/null +++ b/packages/groq-builder/src/commands/nullable.ts @@ -0,0 +1,23 @@ +import { GroqBuilder } from "../groq-builder"; +import { RootConfig } from "../types/schema-types"; + +declare module "../groq-builder" { + export interface GroqBuilder { + nullable(): GroqBuilder; + } +} + +GroqBuilder.implement({ + nullable(this: GroqBuilder) { + const parser = this.parser; + + if (!parser) { + // If there's no previous parser, then this method is just used + // for type-safety, and we don't need to perform runtime validation: + return this; + } + return this.chain("", (input) => { + return input === null ? null : parser(input); + }); + }, +}); diff --git a/packages/groq-builder/src/commands/order.test.ts b/packages/groq-builder/src/commands/order.test.ts index cd1f5808..f5a5b852 100644 --- a/packages/groq-builder/src/commands/order.test.ts +++ b/packages/groq-builder/src/commands/order.test.ts @@ -1,6 +1,5 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, expectTypeOf } from "vitest"; import { SanitySchema, SchemaConfig } from "../tests/schemas/nextjs-sanity-fe"; -import { expectType } from "../tests/expectType"; import { InferResultType } from "../types/public-types"; import { createGroqBuilder } from "../index"; import { executeBuilder } from "../tests/mocks/executeQuery"; @@ -28,12 +27,12 @@ describe("order", () => { qVariants.order("INVALID desc"); }); it("result type is not changed", () => { - expectType>().toStrictEqual< + expectTypeOf>().toEqualTypeOf< Array >(); const qOrder = qVariants.order("price"); - expectType>().toStrictEqual< + expectTypeOf>().toEqualTypeOf< Array >(); }); diff --git a/packages/groq-builder/src/commands/order.ts b/packages/groq-builder/src/commands/order.ts index 0b68ed79..f2074c6d 100644 --- a/packages/groq-builder/src/commands/order.ts +++ b/packages/groq-builder/src/commands/order.ts @@ -7,7 +7,7 @@ declare module "../groq-builder" { /** * Orders the results by the keys specified */ - order>>( + order>>( ...fields: Array<`${TKeys}${"" | " asc" | " desc"}`> ): GroqBuilder; @@ -19,6 +19,6 @@ declare module "../groq-builder" { GroqBuilder.implement({ order(this: GroqBuilder, ...fields) { const query = ` | order(${fields.join(", ")})`; - return this.chain(query, null); + return this.chain(query); }, }); diff --git a/packages/groq-builder/src/commands/project.test.ts b/packages/groq-builder/src/commands/project.test.ts index d5039ae0..e0fc9a71 100644 --- a/packages/groq-builder/src/commands/project.test.ts +++ b/packages/groq-builder/src/commands/project.test.ts @@ -1,15 +1,14 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, expectTypeOf, it } from "vitest"; import { SanitySchema, SchemaConfig } from "../tests/schemas/nextjs-sanity-fe"; -import { expectType } from "../tests/expectType"; import { InferResultType } from "../types/public-types"; import { Simplify, TypeMismatchError } from "../types/utils"; -import { createGroqBuilder } from "../index"; +import { createGroqBuilderWithZod } from "../index"; import { mock } from "../tests/mocks/nextjs-sanity-fe-mocks"; import { executeBuilder } from "../tests/mocks/executeQuery"; import { currencyFormat } from "../tests/utils"; -import { validation } from "../validation"; +import { zod } from "../validation/zod"; -const q = createGroqBuilder(); +const q = createGroqBuilderWithZod(); const qVariants = q.star.filterByType("variant"); describe("project (object projections)", () => { @@ -31,7 +30,7 @@ describe("project (object projections)", () => { categoryNames: q.star.filterByType("category").field("name"), }); it("should have the correct type", () => { - expectType>().toStrictEqual<{ + expectTypeOf>().toEqualTypeOf<{ productNames: string[]; categoryNames: string[]; }>(); @@ -67,11 +66,12 @@ describe("project (object projections)", () => { describe("a single plain property", () => { it("cannot use 'true' to project unknown properties", () => { + // @ts-expect-error --- const qInvalid = qVariants.project({ INVALID: true, }); - expectType>().toStrictEqual< + expectTypeOf>().toEqualTypeOf< Array<{ INVALID: TypeMismatchError<{ error: `⛔️ 'true' can only be used for known properties ⛔️`; @@ -90,7 +90,7 @@ describe("project (object projections)", () => { '"*[_type == \\"variant\\"] { name }"' ); - expectType>().toStrictEqual< + expectTypeOf>().toEqualTypeOf< Array<{ name: string; }> @@ -133,9 +133,9 @@ describe("project (object projections)", () => { '"*[_type == \\"variant\\"] { id, name, price, msrp }"' ); - expectType>().toStrictEqual< + expectTypeOf>().toEqualTypeOf< Array<{ - id: string | undefined; + id: string | null; name: string; price: number; msrp: number; @@ -184,15 +184,15 @@ describe("project (object projections)", () => { describe("projection with validation", () => { const qValidation = qVariants.project({ - name: validation.string(), - price: validation.number(), + name: zod.string(), + price: zod.number(), }); it("query should be typed correctly", () => { expect(qValidation.query).toMatchInlineSnapshot( '"*[_type == \\"variant\\"] { name, price }"' ); - expectType>().toStrictEqual< + expectTypeOf>().toEqualTypeOf< Array<{ name: string; price: number; @@ -227,6 +227,44 @@ describe("project (object projections)", () => { ] `); }); + + it("we should not be able to use the wrong parser type", () => { + // @ts-expect-error --- + const qNameInvalid = qVariants.project({ + name: zod.number(), + price: zod.string(), + }); + expectTypeOf>().toEqualTypeOf< + Array<{ + name: TypeMismatchError<{ + error: "⛔️ Parser expects a different input type ⛔️"; + expected: number; + actual: string; + }>; + price: TypeMismatchError<{ + error: "⛔️ Parser expects a different input type ⛔️"; + expected: string; + actual: number; + }>; + }> + >(); + + // @ts-expect-error --- + const qIdIsNullable = qVariants.project({ + id: q.string(), + }); + expectTypeOf>().toEqualTypeOf< + Array<{ + id: + | string + | TypeMismatchError<{ + error: "⛔️ Parser expects a different input type ⛔️"; + expected: string; + actual: null; + }>; + }> + >(); + }); }); describe("a projection with naked projections", () => { @@ -252,7 +290,7 @@ describe("project (object projections)", () => { }); it("types should be correct", () => { - expectType>().toStrictEqual< + expectTypeOf>().toEqualTypeOf< Array<{ NAME: string; SLUG: string; @@ -264,18 +302,18 @@ describe("project (object projections)", () => { describe("a projection with naked, validated projections", () => { const qNakedProjections = qVariants.project({ - NAME: ["name", validation.string()], - SLUG: ["slug.current", validation.string()], - msrp: ["msrp", validation.number()], + NAME: ["name", zod.string()], + SLUG: ["slug.current", zod.string()], + msrp: ["msrp", zod.number()], }); it("invalid projections should have type errors", () => { // @ts-expect-error --- - qVariants.project({ NAME: ["INVALID", validation.number()] }); + qVariants.project({ NAME: ["INVALID", zod.number()] }); // @ts-expect-error --- - qVariants.project({ NAME: ["slug.INVALID", validation.string()] }); + qVariants.project({ NAME: ["slug.INVALID", zod.string()] }); // @ts-expect-error --- - qVariants.project({ NAME: ["INVALID.current", validation.string()] }); + qVariants.project({ NAME: ["INVALID.current", zod.string()] }); }); it("query should be correct", () => { @@ -285,7 +323,7 @@ describe("project (object projections)", () => { }); it("types should be correct", () => { - expectType>().toStrictEqual< + expectTypeOf>().toEqualTypeOf< Array<{ NAME: string; SLUG: string; @@ -307,7 +345,7 @@ describe("project (object projections)", () => { }); it("types should be correct", () => { - expectType>().toStrictEqual< + expectTypeOf>().toEqualTypeOf< Array<{ NAME: string; }> @@ -352,7 +390,7 @@ describe("project (object projections)", () => { }); it("types should be correct", () => { - expectType>().toStrictEqual< + expectTypeOf>().toEqualTypeOf< Array<{ name: string; slug: string; @@ -408,7 +446,7 @@ describe("project (object projections)", () => { name: true, description: image .field("description") - .validate(validation.string().optional()), + .validate(zod.nullToUndefined(zod.string().optional())), })), })); @@ -419,12 +457,12 @@ describe("project (object projections)", () => { }); it("types should be correct", () => { - expectType>().toStrictEqual< + expectTypeOf>().toEqualTypeOf< Array<{ name: string; images: Array<{ name: string; - description: string | undefined | null; + description: string | undefined; }> | null; }> >(); @@ -472,7 +510,7 @@ describe("project (object projections)", () => { await expect(() => executeBuilder(qNested, dataWithInvalidData)).rejects .toThrowErrorMatchingInlineSnapshot(` "1 Parsing Error: - result[0].images[0].description: Expected string, received 1234" + result[0].images[0].description: Expected string, received number" `); }); }); @@ -492,7 +530,7 @@ describe("project (object projections)", () => { }); it("types should be correct", () => { - expectType>().toStrictEqual< + expectTypeOf>().toEqualTypeOf< Array<{ name: string; slug: string; @@ -542,14 +580,19 @@ describe("project (object projections)", () => { }); describe("validation", () => { + it("without validation, the parser should be null", () => { + const qWithoutValidation = qVariants.project({ name: true, price: true }); + expect(qWithoutValidation.parser).toBeNull(); + }); + const qParser = qVariants.project((q) => ({ name: true, msrp: q.field("msrp").validate((msrp) => currencyFormat(msrp)), - price: q.field("price").validate(validation.number()), + price: q.field("price").validate(zod.number()), })); it("the types should match", () => { - expectType>().toStrictEqual< + expectTypeOf>().toEqualTypeOf< Array<{ name: string; msrp: string; @@ -607,11 +650,78 @@ describe("project (object projections)", () => { await expect(() => executeBuilder(qParser, invalidData)).rejects .toThrowErrorMatchingInlineSnapshot(` "1 Parsing Error: - result[5].price: Expected number, received \\"INVALID\\"" + result[5].price: Expected number, received string" `); }); }); + describe("with validationRequired", () => { + const q = createGroqBuilderWithZod({ + validationRequired: true, + indent: " ", + }); + const qVariant = q.star.filterByType("variant").slice(0); + + it("should throw if a projection uses 'true'", () => { + expect(() => + qVariant.project({ + price: true, + }) + ).toThrowErrorMatchingInlineSnapshot( + '"[groq-builder] 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", () => { + expect(() => + qVariant.project({ + price: "price", + }) + ).toThrowErrorMatchingInlineSnapshot( + '"[groq-builder] 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", () => { + expect(() => + qVariant.project((qV) => ({ + nested: qV.field("price"), + })) + ).toThrowErrorMatchingInlineSnapshot( + '"[groq-builder] 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 ...", () => { + expect(() => + qVariant.project({ + "...": true, + }) + ).toThrowErrorMatchingInlineSnapshot( + '"[groq-builder] 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", () => { + const qNormal = qVariant.project((qV) => ({ + price: q.number(), + price2: ["price", q.number()], + price3: qV.field("price", q.number()), + price4: qV.field("price").validate(q.number()), + })); + expect(qNormal.query).toMatchInlineSnapshot(` + "*[_type == \\"variant\\"][0] { + price, + \\"price2\\": price, + \\"price3\\": price, + \\"price4\\": price + }" + `); + expectTypeOf>().toEqualTypeOf<{ + price: number; + price2: number; + price3: number; + price4: number; + }>(); + }); + }); + describe("ellipsis ... operator", () => { const qEllipsis = qVariants.project((q) => ({ "...": true, @@ -624,7 +734,7 @@ describe("project (object projections)", () => { }); it("types should be correct", () => { - expectType>().toStrictEqual< + expectTypeOf>().toEqualTypeOf< Array> >(); }); diff --git a/packages/groq-builder/src/commands/project.ts b/packages/groq-builder/src/commands/project.ts index 4112ff64..b8796aa0 100644 --- a/packages/groq-builder/src/commands/project.ts +++ b/packages/groq-builder/src/commands/project.ts @@ -1,40 +1,71 @@ -import { notNull, Simplify } from "../types/utils"; +import { ExtractTypeMismatchErrors, notNull, Simplify } from "../types/utils"; import { GroqBuilder } from "../groq-builder"; import { Parser, ParserFunction } from "../types/public-types"; import { isParser, normalizeValidationFunction } from "./validate-utils"; -import { ResultItem, ResultOverride } from "../types/result-types"; +import { ResultItem } from "../types/result-types"; import { ExtractProjectionResult, ProjectionFieldConfig, ProjectionMap, } from "./projection-types"; -import { objectValidation } from "../validation/object-shape"; -import { arrayValidation } from "../validation/array-shape"; import { isConditional } from "./conditional-types"; +import { + simpleArrayParser, + simpleObjectParser, +} from "../validation/simple-validation"; declare module "../groq-builder" { export interface GroqBuilder { /** * Performs an "object projection", returning an object with the fields specified. + * + * @param projectionMap - The projection map is an object, mapping field names to projection values. + * @param ProjectionMapTypeMismatchErrors - This is only used for reporting errors from the projection. */ - project>>( + project< + TProjection extends ProjectionMap>, + _TProjectionResult = ExtractProjectionResult< + ResultItem.Infer, + TProjection + > + >( projectionMap: | TProjection - | ((q: GroqBuilder, TRootConfig>) => TProjection) + | (( + q: GroqBuilder, TRootConfig> + ) => TProjection), + ...ProjectionMapTypeMismatchErrors: RequireAFakeParameterIfThereAreTypeMismatchErrors<_TProjectionResult> ): GroqBuilder< - ResultOverride< - TResult, - Simplify, TProjection>> - >, + ResultItem.Override>, TRootConfig >; } } +/** + * When we map projection results, we return TypeMismatchError's + * for any fields that have an invalid mapping configuration. + * However, this does not cause TypeScript to throw any errors. + * + * In order to get TypeScript to complain about these invalid mappings, + * we will "require" an extra parameter, which will reveal the error messages. + */ +type RequireAFakeParameterIfThereAreTypeMismatchErrors< + TProjectionResult, + _Errors extends never | string = ExtractTypeMismatchErrors +> = _Errors extends never + ? [] // No errors, yay! Do not require any extra parameters. + : // We've got errors; let's require an extra parameter, with the error message: + | [_Errors] + // And this extra error message causes TypeScript to always log the entire list of errors: + | ["⛔️ Error: this projection has type mismatches: ⛔️"]; + GroqBuilder.implement({ project( this: GroqBuilder, - projectionMapArg: object | ((q: any) => object) + projectionMapArg: object | ((q: any) => object), + // eslint-disable-next-line @typescript-eslint/no-unused-vars + ...ProjectionMapTypeMismatchErrors ): GroqBuilder { // Retrieve the projectionMap: let projectionMap: object; @@ -44,8 +75,9 @@ GroqBuilder.implement({ projectionMap = projectionMapArg; } - // Compile query from projection values: const keys = Object.keys(projectionMap) as Array; + + // Compile query from projection values: const fields = keys .map((key) => { const fieldConfig = projectionMap[key as keyof typeof projectionMap]; @@ -53,6 +85,19 @@ GroqBuilder.implement({ }) .filter(notNull); + if (this.internal.options.validationRequired) { + // Validate that we have provided validation functions for all fields: + const invalidFields = fields.filter((f) => !f.parser); + if (invalidFields.length) { + throw new TypeError( + "[groq-builder] Because 'validationRequired' is enabled, " + + "every field must have validation (like `q.string()`), " + + "but the following fields are missing it: " + + `${invalidFields.map((f) => `"${f.key}"`)}` + ); + } + } + const queries = fields.map((v) => v.query); const { newLine, space } = this.indentation; const newQuery = ` {${newLine}${space}${queries.join( @@ -128,7 +173,7 @@ function createProjectionParser( const objectShape = Object.fromEntries( normalFields.map((f) => [f.key, f.parser]) ); - const objectParser = objectValidation.object(objectShape); + const objectParser = simpleObjectParser(objectShape); // Parse all conditional fields: const conditionalFields = fields.filter((f) => isConditional(f.key)); @@ -148,7 +193,7 @@ function createProjectionParser( }; // Finally, transparently handle arrays or objects: - const arrayParser = arrayValidation.array(combinedParser); + const arrayParser = simpleArrayParser(combinedParser); return function projectionParser( input: UnknownObject | Array ) { diff --git a/packages/groq-builder/src/commands/projectField.test.ts b/packages/groq-builder/src/commands/projectField.test.ts index 2dc376e3..00f28a17 100644 --- a/packages/groq-builder/src/commands/projectField.test.ts +++ b/packages/groq-builder/src/commands/projectField.test.ts @@ -1,10 +1,9 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, expectTypeOf, it } from "vitest"; import { mock } from "../tests/mocks/nextjs-sanity-fe-mocks"; -import { expectType } from "../tests/expectType"; import { InferResultType } from "../types/public-types"; import { SanitySchema, SchemaConfig } from "../tests/schemas/nextjs-sanity-fe"; import { executeBuilder } from "../tests/mocks/executeQuery"; -import { createGroqBuilder } from "../index"; +import { createGroqBuilder, zod } from "../index"; const q = createGroqBuilder(); const qVariants = q.star.filterByType("variant"); @@ -13,6 +12,7 @@ describe("field (naked projections)", () => { const qPrices = qVariants.field("price"); const qNames = qVariants.field("name"); const qImages = qVariants.field("images[]"); + type ImagesArray = NonNullable; const data = mock.generateSeedData({ variants: mock.array(5, (i) => mock.variant({ @@ -24,7 +24,7 @@ describe("field (naked projections)", () => { }); it("can project a number", () => { - expectType>().toStrictEqual< + expectTypeOf>().toEqualTypeOf< Array >(); expect(qPrices.query).toMatchInlineSnapshot( @@ -32,28 +32,27 @@ describe("field (naked projections)", () => { ); }); it("can project a string", () => { - expectType>().toStrictEqual>(); + expectTypeOf>().toEqualTypeOf< + Array + >(); expect(qNames.query).toMatchInlineSnapshot( '"*[_type == \\"variant\\"].name"' ); }); it("can project arrays with []", () => { type ResultType = InferResultType; - - expectType().toStrictEqual - > | null>(); + expectTypeOf().toEqualTypeOf>(); }); it("can chain projections", () => { const qSlugCurrent = qVariants.field("slug").field("current"); - expectType>().toStrictEqual< + expectTypeOf>().toEqualTypeOf< Array >(); const qImageNames = qVariants.slice(0).field("images[]").field("name"); - expectType< + expectTypeOf< InferResultType - >().toStrictEqual | null>(); + >().toEqualTypeOf | null>(); }); it("executes correctly (price)", async () => { @@ -95,7 +94,7 @@ describe("field (naked projections)", () => { it("can project nested properties", () => { const qSlugs = qVariants.field("slug.current"); - expectType>().toStrictEqual< + expectTypeOf>().toEqualTypeOf< Array >(); expect(qSlugs.query).toMatchInlineSnapshot( @@ -107,9 +106,42 @@ describe("field (naked projections)", () => { const qImages = qVariants.field("images[]"); type ResultType = InferResultType; - expectType().toStrictEqual - > | null>(); + expectTypeOf().toEqualTypeOf>(); + }); + }); + + describe("validation", () => { + it("when no validation present, the parser should be null", () => { + expect(qPrices.parser).toBeNull(); + }); + + const qPrice = qVariants.slice(0).field("price", zod.number()); + it("should have the correct result type", () => { + expectTypeOf>().toEqualTypeOf(); + }); + it("should result in the right query", () => { + expect(qPrice.query).toMatchInlineSnapshot( + '"*[_type == \\"variant\\"][0].price"' + ); + }); + it("should execute correctly", async () => { + const results = await executeBuilder(qPrice, data.datalake); + expect(results).toMatchInlineSnapshot("55"); + }); + it("should throw an error if the data is invalid", async () => { + const invalidData = mock.generateSeedData({ + variants: [ + mock.variant({ + // @ts-expect-error --- + price: "INVALID", + }), + ], + }); + await expect(() => executeBuilder(qPrice, invalidData.datalake)).rejects + .toMatchInlineSnapshot(` + [ValidationErrors: 1 Parsing Error: + result: Expected number, received string] + `); }); }); }); diff --git a/packages/groq-builder/src/commands/projectField.ts b/packages/groq-builder/src/commands/projectField.ts index 9ec87973..96dd1042 100644 --- a/packages/groq-builder/src/commands/projectField.ts +++ b/packages/groq-builder/src/commands/projectField.ts @@ -1,19 +1,52 @@ import { GroqBuilder } from "../groq-builder"; -import { ResultItem, ResultOverride } from "../types/result-types"; -import { ProjectionKey, ProjectionKeyValue } from "./projection-types"; +import { ResultItem } from "../types/result-types"; +import { + ProjectionKey, + ProjectionKeyValue, + ValidateParserInput, +} from "./projection-types"; +import { Parser, ParserWithWidenedInput } from "../types/public-types"; declare module "../groq-builder" { export interface GroqBuilder { /** * Performs a "naked projection", returning just the values of the field specified. - * @param fieldName + * + * This overload does NOT perform any runtime validation; the return type is inferred. */ - field>>( + field>>( fieldName: TProjectionKey ): GroqBuilder< - ResultOverride< + ResultItem.Override< TResult, - ProjectionKeyValue, TProjectionKey> + ProjectionKeyValue, TProjectionKey> + >, + TRootConfig + >; + + /** + * Performs a "naked projection", returning just the values of the field specified. + * + * This overload allows a parser to be passed, for validating the results. + */ + field< + TProjectionKey extends ProjectionKey>, + TParser extends ParserWithWidenedInput< + ProjectionKeyValue, TProjectionKey> + > + >( + fieldName: TProjectionKey, + parser: TParser + ): GroqBuilder< + ResultItem.Override< + TResult, + TParser extends Parser + ? ValidateParserInput< + ProjectionKeyValue, TProjectionKey>, + TParserInput, + TParserOutput + > + : never >, TRootConfig >; @@ -26,10 +59,11 @@ declare module "../groq-builder" { } GroqBuilder.implement({ - field(this: GroqBuilder, fieldName: string) { + field(this: GroqBuilder, fieldName: string, parser?: Parser): GroqBuilder { if (this.internal.query) { fieldName = "." + fieldName; } - return this.chain(fieldName, null); + + return this.chain(fieldName, parser); }, }); diff --git a/packages/groq-builder/src/commands/projection-types.test.ts b/packages/groq-builder/src/commands/projection-types.test.ts index 48cc2486..489e2a95 100644 --- a/packages/groq-builder/src/commands/projection-types.test.ts +++ b/packages/groq-builder/src/commands/projection-types.test.ts @@ -1,6 +1,5 @@ -import { describe, it } from "vitest"; +import { describe, expectTypeOf, it } from "vitest"; import { ProjectionKey, ProjectionKeyValue } from "./projection-types"; -import { expectType } from "../tests/expectType"; describe("projection-types", () => { describe("Projection Keys (naked projections)", () => { @@ -21,17 +20,17 @@ describe("projection-types", () => { describe("ProjectionKey", () => { it("should extract simple types", () => { - expectType>().toStrictEqual< - "str" | "num" - >(); + expectTypeOf< + ProjectionKey<{ str: string; num: number }> + >().toEqualTypeOf<"str" | "num">(); }); it("should extract nested types", () => { - expectType< + expectTypeOf< ProjectionKey<{ str: string; nested: { num: number; bool: boolean } }> - >().toStrictEqual<"str" | "nested" | "nested.num" | "nested.bool">(); + >().toEqualTypeOf<"str" | "nested" | "nested.num" | "nested.bool">(); }); it("should extract arrays", () => { - expectType }>>().toStrictEqual< + expectTypeOf }>>().toEqualTypeOf< "arr" | "arr[]" >(); }); @@ -39,7 +38,7 @@ describe("projection-types", () => { type Keys = ProjectionKey<{ nested: { arr: Array }; }>; - expectType().toStrictEqual< + expectTypeOf().toEqualTypeOf< "nested" | "nested.arr" | "nested.arr[]" >(); }); @@ -47,11 +46,11 @@ describe("projection-types", () => { type Keys = ProjectionKey<{ nested?: { num: number }; }>; - expectType().toStrictEqual<"nested" | "nested.num">(); + expectTypeOf().toEqualTypeOf<"nested" | "nested.num">(); }); it("should extract all the deeply nested types", () => { - expectType().toStrictEqual< + expectTypeOf().toEqualTypeOf< | "str" | "num" | "arr" @@ -69,34 +68,35 @@ describe("projection-types", () => { describe("ProjectionKeyValue", () => { it("should extract the correct types for each projection", () => { - expectType>().toStrictEqual(); - expectType>().toStrictEqual< - number | undefined + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf< + number | null >(); - expectType>().toStrictEqual< + expectTypeOf>().toEqualTypeOf< Array >(); - expectType>().toStrictEqual< + expectTypeOf>().toEqualTypeOf< Array >(); - expectType>().toStrictEqual< + expectTypeOf>().toEqualTypeOf< Item["nested"] >(); - expectType>().toStrictEqual< - string | undefined + expectTypeOf>().toEqualTypeOf< + string | null >(); - expectType< + expectTypeOf< ProjectionKeyValue - >().toStrictEqual(); - expectType>().toStrictEqual< + >().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf< Array >(); - expectType>().toStrictEqual< + expectTypeOf>().toEqualTypeOf< Array >(); - // expectType>().toStrictEqual< - // string | undefined - // >(); + // @ts-expect-error -- Currently this isn't supported, so it's cast as 'never' + expectTypeOf>().toEqualTypeOf< + string | null + >(); }); }); }); diff --git a/packages/groq-builder/src/commands/projection-types.ts b/packages/groq-builder/src/commands/projection-types.ts index 26857335..347f385a 100644 --- a/packages/groq-builder/src/commands/projection-types.ts +++ b/packages/groq-builder/src/commands/projection-types.ts @@ -2,23 +2,26 @@ import { GroqBuilder } from "../groq-builder"; import { Empty, IsAny, + LiteralUnion, Simplify, SimplifyDeep, StringKeys, TypeMismatchError, + UndefinedToNull, ValueOf, } from "../types/utils"; import { FragmentInputTypeTag, IGroqBuilder, Parser, + ParserWithWidenedInput, } from "../types/public-types"; import { Path, PathEntries, PathValue } from "../types/path-types"; import { DeepRequired } from "../types/deep-required"; import { RootConfig } from "../types/schema-types"; import { + ConditionalKey, ExtractConditionalProjectionTypes, - OmitConditionalProjections, } from "./conditional-types"; export type ProjectionKey = IsAny extends true @@ -30,20 +33,21 @@ type ProjectionKeyImpl = ValueOf<{ : Key; }>; -export type ProjectionKeyValue = PathValue< - TResultItem, - Extract> +export type ProjectionKeyValue = UndefinedToNull< + PathValue< + TResultItem, + Extract> + > >; export type ProjectionMap = { - // This allows TypeScript to suggest known keys: - [P in keyof TResultItem]?: ProjectionFieldConfig; -} & { - // This allows any keys to be used in a projection: - [P in string]: ProjectionFieldConfig; + [P in LiteralUnion]?: ProjectionFieldConfig< + TResultItem, + P extends keyof TResultItem ? UndefinedToNull : any + >; } & { // Obviously this allows the ellipsis operator: - "..."?: true; + "..."?: true | Parser; }; export type ProjectionMapOrCallback< @@ -59,33 +63,39 @@ export type ProjectionFieldConfig = // Use a string for naked projections, like 'slug.current' | ProjectionKey // Use a parser to include a field, passing it through the parser at run-time - | Parser + | ParserWithWidenedInput // Use a tuple for naked projections with a parser - | [ProjectionKey, Parser] + | readonly [ProjectionKey, ParserWithWidenedInput] // Use a GroqBuilder instance to create a nested projection | IGroqBuilder; export type ExtractProjectionResult = + // Extract the "..." operator: (TProjectionMap extends { "...": true } ? TResultItem : Empty) & - ExtractProjectionResultImpl< + (TProjectionMap extends { "...": Parser } + ? TOutput + : Empty) & + // Extract any conditional expressions: + ExtractConditionalProjectionTypes & + // Extract all the fields: + ExtractProjectionResultFields< TResultItem, + // Be sure to omit the Conditionals, "...", and fragment metadata: Omit< - OmitConditionalProjections, - // Ensure we remove any "tags" that we don't want in the mapped type: - "..." | typeof FragmentInputTypeTag + TProjectionMap, + "..." | typeof FragmentInputTypeTag | ConditionalKey > - > & - ExtractConditionalProjectionTypes; + >; -type ExtractProjectionResultImpl = { +type ExtractProjectionResultFields = { [P in keyof TProjectionMap]: TProjectionMap[P] extends IGroqBuilder< infer TValue - > // Extract type from GroqBuilder: + > // Extract the type from GroqBuilder: ? TValue - : /* Extract type from 'true': */ + : /* Extract the type from a 'true' value: */ TProjectionMap[P] extends boolean ? P extends keyof TResultItem - ? TResultItem[P] + ? UndefinedToNull : TypeMismatchError<{ error: `⛔️ 'true' can only be used for known properties ⛔️`; expected: keyof TResultItem; @@ -101,16 +111,14 @@ type ExtractProjectionResultImpl = { actual: TProjectionMap[P]; }> : /* Extract type from a [ProjectionKey, Parser] tuple, like ['slug.current', q.string() ] */ - TProjectionMap[P] extends [infer TKey, infer TParser] + TProjectionMap[P] extends readonly [infer TKey, infer TParser] ? TKey extends ProjectionKey - ? TParser extends Parser - ? TInput extends ProjectionKeyValue - ? TOutput - : TypeMismatchError<{ - error: `⛔️ The value of the projection is not compatible with this parser ⛔️`; - expected: Parser, TOutput>; - actual: TParser; - }> + ? TParser extends Parser + ? ValidateParserInput< + ProjectionKeyValue, + TParserInput, + TParserOutput + > : TypeMismatchError<{ error: `⛔️ Naked projections must be known properties ⛔️`; expected: SimplifyDeep>; @@ -122,17 +130,13 @@ type ExtractProjectionResultImpl = { actual: TKey; }> : /* Extract type from Parser: */ - TProjectionMap[P] extends Parser + TProjectionMap[P] extends Parser ? P extends keyof TResultItem - ? TResultItem[P] extends TExpectedInput - ? TOutput - : IsAny extends true // When using for the schema - ? TOutput - : TypeMismatchError<{ - error: `⛔️ Parser expects a different input type ⛔️`; - expected: TExpectedInput; - actual: TResultItem[P]; - }> + ? ValidateParserInput< + UndefinedToNull, + TParserInput, + TParserOutput + > : TypeMismatchError<{ error: `⛔️ Parser can only be used with known properties ⛔️`; expected: keyof TResultItem; @@ -140,3 +144,15 @@ type ExtractProjectionResultImpl = { }> : never; }; + +export type ValidateParserInput = + // We need to ensure that the Parser accepts a WIDER input than the value: + TIncomingValue extends TParserInput + ? TParserOutput + : IsAny extends true // When using for the schema + ? TParserOutput + : TypeMismatchError<{ + error: `⛔️ Parser expects a different input type ⛔️`; + expected: TParserInput; + actual: TIncomingValue; + }>; diff --git a/packages/groq-builder/src/commands/raw.test.ts b/packages/groq-builder/src/commands/raw.test.ts index 6243f2de..2eadca39 100644 --- a/packages/groq-builder/src/commands/raw.test.ts +++ b/packages/groq-builder/src/commands/raw.test.ts @@ -1,6 +1,5 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, expectTypeOf } from "vitest"; import { SchemaConfig } from "../tests/schemas/nextjs-sanity-fe"; -import { expectType } from "../tests/expectType"; import { InferResultType } from "../types/public-types"; import { createGroqBuilder } from "../index"; import { executeBuilder } from "../tests/mocks/executeQuery"; @@ -15,7 +14,7 @@ describe("raw", () => { const data = mock.generateSeedData({}); it("should be typed correctly", () => { - expectType>().toStrictEqual< + expectTypeOf>().toEqualTypeOf< Array<{ ANYTHING: string | null }> >(); }); @@ -41,7 +40,7 @@ describe("raw", () => { const qInvalid = q.raw<{ NEVER: "gonna" }>( `give you up, never gonna let you down` ); - expectType>().toStrictEqual<{ + expectTypeOf>().toEqualTypeOf<{ NEVER: "gonna"; }>(); diff --git a/packages/groq-builder/src/commands/select$.test.ts b/packages/groq-builder/src/commands/select.test.ts similarity index 83% rename from packages/groq-builder/src/commands/select$.test.ts rename to packages/groq-builder/src/commands/select.test.ts index 000b58d2..48e08f29 100644 --- a/packages/groq-builder/src/commands/select$.test.ts +++ b/packages/groq-builder/src/commands/select.test.ts @@ -1,38 +1,37 @@ -import { describe, expect, it } from "vitest"; -import { createGroqBuilder, InferResultType, validation } from "../index"; +import { describe, expect, expectTypeOf, it } from "vitest"; +import { createGroqBuilder, InferResultType, zod } from "../index"; import { SchemaConfig } from "../tests/schemas/nextjs-sanity-fe"; -import { expectType } from "../tests/expectType"; import { mock } from "../tests/mocks/nextjs-sanity-fe-mocks"; import { executeBuilder } from "../tests/mocks/executeQuery"; const q = createGroqBuilder({ indent: " " }); -describe("select$", () => { +describe("select", () => { const qBase = q.star.filterByType("variant", "product", "category"); describe("without a default value", () => { describe("should infer the correct type", () => { it("with a single condition", () => { - const qSel = q.select$({ + const qSel = q.select({ '_type == "variant"': q.value("VARIANT"), }); - expectType>().toStrictEqual< + expectTypeOf>().toEqualTypeOf< "VARIANT" | null >(); }); it("with multiple selections", () => { - const qSelMultiple = q.select$({ + const qSelMultiple = q.select({ '_type == "variant"': q.value("VARIANT"), '_type == "product"': q.value("PRODUCT"), '_type == "category"': q.value("CATEGORY"), }); - expectType>().toStrictEqual< + expectTypeOf>().toEqualTypeOf< "VARIANT" | "PRODUCT" | "CATEGORY" | null >(); }); it("with complex mixed selections", () => { - const qSelMultiple = q.select$({ + const qSelMultiple = q.select({ '_type == "variant"': q.value("VARIANT"), '_type == "nested"': q.project({ nested: q.value("NESTED") }), '_type == "deeper"': q.project({ @@ -40,7 +39,7 @@ describe("select$", () => { }), }); - expectType>().toStrictEqual< + expectTypeOf>().toEqualTypeOf< | "VARIANT" | { nested: "NESTED" } | { @@ -54,7 +53,7 @@ describe("select$", () => { describe("with a default value", () => { const qSelect = qBase.project({ - selected: q.select$( + selected: q.select( { '_type == "variant"': q.value("VARIANT"), '_type == "product"': q.value("PRODUCT"), @@ -70,7 +69,7 @@ describe("select$", () => { }); it("the result types should be correct", () => { - expectType>().toStrictEqual< + expectTypeOf>().toEqualTypeOf< Array<{ selected: "VARIANT" | "PRODUCT" | "OTHER"; }> @@ -127,15 +126,15 @@ describe("select$", () => { describe("with validation", () => { const qSelect = qBase.project((q) => ({ - selected: q.select$({ - '_type == "product"': q.project({ - _type: validation.literal("product"), - name: validation.string(), + selected: q.select({ + '_type == "product"': q.asType<"product">().project({ + _type: zod.literal("product"), + name: zod.string(), }), - '_type == "variant"': q.project({ - _type: validation.literal("variant"), - name: validation.string(), - price: validation.number(), + '_type == "variant"': q.asType<"variant">().project({ + _type: zod.literal("variant"), + name: zod.string(), + price: zod.number(), }), }), })); diff --git a/packages/groq-builder/src/commands/select$.ts b/packages/groq-builder/src/commands/select.ts similarity index 95% rename from packages/groq-builder/src/commands/select$.ts rename to packages/groq-builder/src/commands/select.ts index 5c53fa1b..32be78b5 100644 --- a/packages/groq-builder/src/commands/select$.ts +++ b/packages/groq-builder/src/commands/select.ts @@ -6,9 +6,9 @@ import { InferResultType, ParserFunction } from "../types/public-types"; declare module "../groq-builder" { export interface GroqBuilder { - select$< + select< TSelectProjections extends SelectProjections< - ResultItem, + ResultItem.Infer, TRootConfig >, TDefault extends null | GroqBuilder = null @@ -25,7 +25,7 @@ declare module "../groq-builder" { } } GroqBuilder.implement({ - select$(this: GroqBuilder, selections, defaultSelection): GroqBuilder { + select(this: GroqBuilder, selections, defaultSelection): GroqBuilder { const conditions = Object.keys(selections); const queries = conditions.map((condition) => { @@ -53,7 +53,7 @@ GroqBuilder.implement({ `Missing validation: "${missing.join('", "')}"` ); // This only works on V8 engines: - (Error as any).captureStackTrace?.(err, GroqBuilder.prototype.select$); + (Error as any).captureStackTrace?.(err, GroqBuilder.prototype.select); throw err; } diff --git a/packages/groq-builder/src/commands/selectByType.test.ts b/packages/groq-builder/src/commands/selectByType.test.ts index 6f944c7d..a3526da9 100644 --- a/packages/groq-builder/src/commands/selectByType.test.ts +++ b/packages/groq-builder/src/commands/selectByType.test.ts @@ -1,6 +1,5 @@ -import { describe, expect, it } from "vitest"; -import { expectType } from "../tests/expectType"; -import { createGroqBuilder, InferResultType, validation } from "../index"; +import { describe, expect, expectTypeOf, it } from "vitest"; +import { createGroqBuilder, InferResultType, zod } from "../index"; import { SchemaConfig } from "../tests/schemas/nextjs-sanity-fe"; import { executeBuilder } from "../tests/mocks/executeQuery"; import { mock } from "../tests/mocks/nextjs-sanity-fe-mocks"; @@ -27,10 +26,10 @@ describe("selectByType", () => { }), })); - expectType>().toStrictEqual< + expectTypeOf>().toEqualTypeOf< Array<{ selected: "PRODUCT" | null }> >(); - expectType>().toStrictEqual< + expectTypeOf>().toEqualTypeOf< Array<{ selected: "PRODUCT" | null }> >(); @@ -63,7 +62,7 @@ describe("selectByType", () => { it("should infer the correct types", () => { type TSelect = InferResultType; - expectType().toStrictEqual< + expectTypeOf().toEqualTypeOf< Array<{ selected: | { _type: "product"; name: string } @@ -153,7 +152,7 @@ describe("selectByType", () => { })); it("should infer the correct type", () => { - expectType>().toStrictEqual< + expectTypeOf>().toEqualTypeOf< Array<{ selected: string | number | "UNKNOWN"; }> @@ -210,14 +209,14 @@ describe("selectByType", () => { selected: q.selectByType({ product: (q) => q.project({ - _type: validation.literal("product"), - name: validation.string(), + _type: zod.literal("product"), + name: zod.string(), }), variant: (q) => q.project({ - _type: validation.literal("variant"), - name: validation.string(), - price: validation.number(), + _type: zod.literal("variant"), + name: zod.string(), + price: zod.number(), }), }), })); diff --git a/packages/groq-builder/src/commands/selectByType.ts b/packages/groq-builder/src/commands/selectByType.ts index c5f6521b..e6e763a8 100644 --- a/packages/groq-builder/src/commands/selectByType.ts +++ b/packages/groq-builder/src/commands/selectByType.ts @@ -12,7 +12,7 @@ declare module "../groq-builder" { export interface GroqBuilder { selectByType< TSelectByTypeProjections extends SelectByTypeProjections< - ResultItem, + ResultItem.Infer, TRootConfig >, TDefaultSelection extends GroqBuilder | null = null @@ -37,10 +37,11 @@ GroqBuilder.implement({ const condition = `_type == "${key}"`; const queryFn = typeQueries[key]; - const query = typeof queryFn === "function" ? queryFn(root) : queryFn; + const query: GroqBuilder = + typeof queryFn === "function" ? queryFn(root) : queryFn!; mapped[condition] = query; } - return this.select$(mapped, defaultSelection) as any; + return this.select(mapped, defaultSelection) as any; }, }); diff --git a/packages/groq-builder/src/commands/slice.test.ts b/packages/groq-builder/src/commands/slice.test.ts index 8b8f2f46..7344e3f4 100644 --- a/packages/groq-builder/src/commands/slice.test.ts +++ b/packages/groq-builder/src/commands/slice.test.ts @@ -1,6 +1,5 @@ -import { describe, it, expect, beforeAll } from "vitest"; +import { describe, it, expect, beforeAll, expectTypeOf } from "vitest"; import { SanitySchema, SchemaConfig } from "../tests/schemas/nextjs-sanity-fe"; -import { expectType } from "../tests/expectType"; import { InferResultType } from "../types/public-types"; import { createGroqBuilder } from "../index"; import { executeBuilder } from "../tests/mocks/executeQuery"; @@ -19,9 +18,9 @@ describe("slice", () => { describe("a single item", () => { const qSlice0 = qVariants.slice(0); it("should be typed correctly", () => { - expectType< + expectTypeOf< InferResultType - >().toStrictEqual(); + >().toEqualTypeOf(); }); it("query should be correct", () => { expect(qSlice0).toMatchObject({ diff --git a/packages/groq-builder/src/commands/slice.ts b/packages/groq-builder/src/commands/slice.ts index d07ac392..61aa688b 100644 --- a/packages/groq-builder/src/commands/slice.ts +++ b/packages/groq-builder/src/commands/slice.ts @@ -1,9 +1,11 @@ import { GroqBuilder } from "../groq-builder"; -import { ResultItemMaybe } from "../types/result-types"; +import { ResultItem } from "../types/result-types"; declare module "../groq-builder" { export interface GroqBuilder { - slice(index: number): GroqBuilder, TRootConfig>; + slice( + index: number + ): GroqBuilder, TRootConfig>; slice( /** * The first index to include in the slice @@ -31,8 +33,8 @@ GroqBuilder.implement({ slice(this: GroqBuilder, start, end?, inclusive?): GroqBuilder { if (typeof end === "number") { const ellipsis = inclusive ? ".." : "..."; - return this.chain(`[${start}${ellipsis}${end}]`, null); + return this.chain(`[${start}${ellipsis}${end}]`); } - return this.chain(`[${start}]`, null); + return this.chain(`[${start}]`); }, }); diff --git a/packages/groq-builder/src/commands/slug.test.ts b/packages/groq-builder/src/commands/slug.test.ts deleted file mode 100644 index d6d707ae..00000000 --- a/packages/groq-builder/src/commands/slug.test.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { SchemaConfig } from "../tests/schemas/nextjs-sanity-fe"; -import { expectType } from "../tests/expectType"; -import { InferResultType } from "../types/public-types"; -import { createGroqBuilder } from "../index"; -import { executeBuilder } from "../tests/mocks/executeQuery"; -import { mock } from "../tests/mocks/nextjs-sanity-fe-mocks"; - -const q = createGroqBuilder(); -const qVariants = q.star.filterByType("variant"); - -describe("slug", () => { - const qVariantSlugs = qVariants.project((qVar) => ({ - SLUG: qVar.slug("slug"), - })); - - it("should have the correct type", () => { - expectType>().toStrictEqual< - Array<{ SLUG: string }> - >(); - }); - - it("should not allow invalid fields to be slugged", () => { - qVariants.project((qVar) => ({ - // @ts-expect-error --- - name: qVar.slug("name"), - // @ts-expect-error --- - INVALID: qVar.slug("INVALID"), - })); - }); - - describe("execution", () => { - const data = mock.generateSeedData({ - variants: [ - mock.variant({ slug: mock.slug({ current: "SLUG_1" }) }), - mock.variant({ slug: mock.slug({ current: "SLUG_2" }) }), - mock.variant({ slug: mock.slug({ current: "SLUG_3" }) }), - ], - }); - it("should retrieve all slugs", async () => { - const result = await executeBuilder(qVariantSlugs, data.datalake); - - expect(result).toEqual([ - { SLUG: "SLUG_1" }, - { SLUG: "SLUG_2" }, - { SLUG: "SLUG_3" }, - ]); - }); - it("should have errors for missing / invalid slugs", async () => { - const data = mock.generateSeedData({ - variants: [ - // @ts-expect-error --- - mock.variant({ slug: mock.slug({ current: 123 }) }), - // @ts-expect-error --- - mock.variant({ slug: mock.slug({ current: undefined }) }), - mock.variant({ slug: undefined }), - mock.variant({}), - ], - }); - - await expect(() => executeBuilder(qVariantSlugs, data.datalake)).rejects - .toThrowErrorMatchingInlineSnapshot(` - "3 Parsing Errors: - result[0].SLUG: Expected a string for 'slug.current' but got 123 - result[1].SLUG: Expected a string for 'slug.current' but got null - result[2].SLUG: Expected a string for 'slug.current' but got null" - `); - }); - }); -}); diff --git a/packages/groq-builder/src/commands/slug.ts b/packages/groq-builder/src/commands/slug.ts deleted file mode 100644 index e334e0df..00000000 --- a/packages/groq-builder/src/commands/slug.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { GroqBuilder } from "../groq-builder"; -import { EntriesOf } from "../types/utils"; -import { ResultItem, ResultOverride } from "../types/result-types"; - -declare module "../groq-builder" { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - export interface GroqBuilder { - slug( - fieldName: FieldsWithSlugs> - ): GroqBuilder, TRootConfig>; - } -} -GroqBuilder.implement({ - slug(this: GroqBuilder, fieldName) { - return this.field(`${fieldName}.current` as never).validate((input) => { - if (typeof input !== "string") - throw new TypeError( - `Expected a string for '${fieldName}.current' but got ${input}` - ); - return input; - }); - }, -}); - -/** - * Winner of silliest type name in this repo - */ -type FieldsWithSlugs = Extract< - EntriesOf, - [any, { current: string }] ->[0]; diff --git a/packages/groq-builder/src/commands/star.test.ts b/packages/groq-builder/src/commands/star.test.ts index 84a330b6..347da41a 100644 --- a/packages/groq-builder/src/commands/star.test.ts +++ b/packages/groq-builder/src/commands/star.test.ts @@ -1,6 +1,5 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, expectTypeOf } from "vitest"; import { SchemaConfig } from "../tests/schemas/nextjs-sanity-fe"; -import { expectType } from "../tests/expectType"; import { InferResultType } from "../types/public-types"; import { createGroqBuilder } from "../index"; import { executeBuilder } from "../tests/mocks/executeQuery"; @@ -12,7 +11,7 @@ describe("star", () => { const star = q.star; it("should have the correct type, matching all documents", () => { - expectType>().toStrictEqual< + expectTypeOf>().toEqualTypeOf< Array >(); }); diff --git a/packages/groq-builder/src/commands/star.ts b/packages/groq-builder/src/commands/star.ts index 6bde7998..4aba52fc 100644 --- a/packages/groq-builder/src/commands/star.ts +++ b/packages/groq-builder/src/commands/star.ts @@ -10,7 +10,7 @@ declare module "../groq-builder" { GroqBuilder.implementProperties({ star: { get(this: GroqBuilder) { - return this.chain("*", null); + return this.chain("*"); }, }, }); diff --git a/packages/groq-builder/src/commands/validate-utils.ts b/packages/groq-builder/src/commands/validate-utils.ts index c18e2fe4..aad0e8e7 100644 --- a/packages/groq-builder/src/commands/validate-utils.ts +++ b/packages/groq-builder/src/commands/validate-utils.ts @@ -4,6 +4,7 @@ import { ParserFunctionMaybe, ParserObject, } from "../types/public-types"; +import { QueryError } from "../types/query-error"; export function chainParsers( a: ParserFunctionMaybe, @@ -32,12 +33,17 @@ export function isParserObject( } export function normalizeValidationFunction( - parser: Parser | null + parser: Parser | null | undefined ): ParserFunction | null { - if (parser === null || typeof parser === "function") return parser; + if (!parser) { + return null; + } + if (typeof parser === "function") { + return parser; + } if (isParserObject(parser)) { return (input) => parser.parse(input); } - throw new TypeError(`Parser must be a function or an object`); + throw new QueryError(`Parser must be a function or an object`, { parser }); } diff --git a/packages/groq-builder/src/commands/validate.test.ts b/packages/groq-builder/src/commands/validate.test.ts index 4f40c86a..3d9fb4ff 100644 --- a/packages/groq-builder/src/commands/validate.test.ts +++ b/packages/groq-builder/src/commands/validate.test.ts @@ -1,10 +1,10 @@ -import { describe, it, expect } from "vitest"; +import { describe, expect, expectTypeOf, it } from "vitest"; import { SchemaConfig } from "../tests/schemas/nextjs-sanity-fe"; import { createGroqBuilder, InferResultType } from "../index"; import { executeBuilder } from "../tests/mocks/executeQuery"; import { mock } from "../tests/mocks/nextjs-sanity-fe-mocks"; import { currencyFormat } from "../tests/utils"; -import { expectType } from "../tests/expectType"; +import { zod } from "../validation/zod"; const q = createGroqBuilder(); const qVariants = q.star.filterByType("variant"); @@ -28,12 +28,16 @@ describe("parse", () => { }); it("should map types correctly", () => { - expectType>().toStrictEqual(); - expectType>().toStrictEqual(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf< + InferResultType + >().toEqualTypeOf(); }); }); - describe("Zod-like parser object", () => { - const qPriceParse = qPrice.validate({ parse: (p) => currencyFormat(p) }); + describe("Zod parsers", () => { + const qPriceParse = qPrice.validate( + zod.number().transform((p) => currencyFormat(p)) + ); it("shouldn't affect the query at all", () => { expect(qPriceParse.query).toEqual(qPrice.query); diff --git a/packages/groq-builder/src/commands/validate.ts b/packages/groq-builder/src/commands/validate.ts index 2093003b..535fafa6 100644 --- a/packages/groq-builder/src/commands/validate.ts +++ b/packages/groq-builder/src/commands/validate.ts @@ -1,18 +1,36 @@ import { GroqBuilder } from "../groq-builder"; -import { ParserFunction, ParserObject } from "../types/public-types"; +import { Parser } from "../types/public-types"; +import { chainParsers, normalizeValidationFunction } from "./validate-utils"; declare module "../groq-builder" { export interface GroqBuilder { + /** + * Adds runtime validation to the query results. + */ validate( - parser: - | ParserObject - | ParserFunction + parser: Parser + ): GroqBuilder; + + /** + * Adds runtime transformation to the query results. + * + * (alias for `validate`, for better semantics) + */ + transform( + parser: Parser ): GroqBuilder; } } GroqBuilder.implement({ validate(this: GroqBuilder, parser) { - return this.chain("", parser); + const chainedParser = chainParsers( + this.internal.parser, + normalizeValidationFunction(parser) + ); + return this.chain("", chainedParser); + }, + transform(this: GroqBuilder, parser) { + return this.validate(parser); }, }); diff --git a/packages/groq-builder/src/groq-builder.test.ts b/packages/groq-builder/src/groq-builder.test.ts index c3d682eb..6cd973a5 100644 --- a/packages/groq-builder/src/groq-builder.test.ts +++ b/packages/groq-builder/src/groq-builder.test.ts @@ -1,6 +1,5 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, expectTypeOf, it } from "vitest"; import { SchemaConfig } from "./tests/schemas/nextjs-sanity-fe"; -import { expectType } from "./tests/expectType"; import { InferResultType } from "./types/public-types"; import { createGroqBuilder } from "./index"; import { Empty } from "./types/utils"; @@ -9,7 +8,7 @@ const q = createGroqBuilder({ indent: " " }); describe("GroqBuilder", () => { it("root should have an Empty result", () => { - expectType>().toStrictEqual(); + expectTypeOf>().toEqualTypeOf(); }); it("should have an empty query", () => { expect(q).toMatchObject({ @@ -44,7 +43,7 @@ describe("GroqBuilder", () => { })); it("should have correct types", () => { - expectType>().toStrictEqual< + expectTypeOf>().toEqualTypeOf< Array<{ _id: string; name: string; @@ -60,7 +59,7 @@ describe("GroqBuilder", () => { slug: string; style: null | Array<{ _id: string; - name: string | undefined; + name: string | null; }>; }>; }> diff --git a/packages/groq-builder/src/groq-builder.ts b/packages/groq-builder/src/groq-builder.ts index a2bc0964..426d2129 100644 --- a/packages/groq-builder/src/groq-builder.ts +++ b/packages/groq-builder/src/groq-builder.ts @@ -3,26 +3,45 @@ import type { Parser, ParserFunction, } from "./types/public-types"; -import type { RootConfig } from "./types/schema-types"; -import { - chainParsers, - normalizeValidationFunction, -} from "./commands/validate-utils"; +import type { ExtractTypeNames, RootConfig } from "./types/schema-types"; +import { normalizeValidationFunction } from "./commands/validate-utils"; import { ValidationErrors } from "./validation/validation-errors"; import { Empty } from "./types/utils"; import { GroqBuilderResultType } from "./types/public-types"; +import { QueryError } from "./types/query-error"; export type RootResult = Empty; export type GroqBuilderOptions = { /** - * Enables "pretty printing" for the compiled GROQ string. Useful for debugging + * Enables "pretty printing" for the compiled GROQ string. Useful for debugging. + * @default "" (disabled) */ - indent: string; + indent?: string; + /** + * If enabled, then runtime validation is always required for all fields. + * If missing, an error will be thrown when the query is created. + * + * This affects the following 3 APIs where validation is normally optional: + * + * q.project({ + * example: true, // ⛔️ use a validation function instead + * example: q.string(), // ✅ + * + * example: "example.current", // ⛔️ use a tuple instead + * example: ["example.current", q.string()], // ✅ + * + * example: q.field("example.current"), // ⛔️ ensure you pass the 2nd validation parameter + * example: q.field("example.current", q.string()), // ✅ + * }) + * + * @default false + */ + validationRequired?: boolean; }; export class GroqBuilder< - TResult = unknown, + TResult = any, TRootConfig extends RootConfig = RootConfig > implements IGroqBuilder { @@ -55,7 +74,7 @@ export class GroqBuilder< constructor( protected readonly internal: { readonly query: string; - readonly parser: null | ParserFunction; + readonly parser: null | ParserFunction; readonly options: GroqBuilderOptions; } ) {} @@ -70,7 +89,7 @@ export class GroqBuilder< /** * The parser function that should be used to parse result data */ - public get parser() { + public get parser(): null | ParserFunction { return this.internal.parser; } @@ -79,17 +98,20 @@ export class GroqBuilder< */ public parse(data: unknown): TResult { const parser = this.internal.parser; - if (parser) { - try { - return parser(data); - } catch (err) { - if (err instanceof ValidationErrors) { - throw err.withMessage(); - } - throw err; + if (!parser) { + return data as TResult; + } + try { + return parser(data); + } catch (err) { + // Ensure we throw a ValidationErrors instance: + if (err instanceof ValidationErrors) { + throw err.withMessage(); } + const v = new ValidationErrors(); + v.add("", data, err as Error); + throw v.withMessage(); } - return data as TResult; } /** @@ -99,14 +121,22 @@ export class GroqBuilder< */ protected chain( query: string, - parser: Parser | null = null + parser?: Parser | null ): GroqBuilder { + if (query && this.internal.parser) { + throw new QueryError( + "You cannot chain a new query once you've specified a parser, " + + "since this changes the result type.", + { + existingQuery: this.internal.query, + newQuery: query, + } + ); + } + return new GroqBuilder({ query: this.internal.query + query, - parser: chainParsers( - this.internal.parser, - normalizeValidationFunction(parser) - ), + parser: normalizeValidationFunction(parser), options: this.internal.options, }); } @@ -128,6 +158,29 @@ 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 ExtractTypeNames + >(): GroqBuilder< + Extract, + TRootConfig + > { + return this as any; + } + + /** + * This utility returns whitespace, if 'indent' is enabled. + */ protected get indentation() { const indent = this.internal.options.indent; return { diff --git a/packages/groq-builder/src/index.ts b/packages/groq-builder/src/index.ts index 6e54f302..ce182f12 100644 --- a/packages/groq-builder/src/index.ts +++ b/packages/groq-builder/src/index.ts @@ -4,45 +4,52 @@ import "./commands"; import type { RootConfig } from "./types/schema-types"; import type { ButFirst } from "./types/utils"; +import { zod } from "./validation/zod"; -// Export all our public types: +// Re-export all our public types: export * from "./types/public-types"; export * from "./types/schema-types"; export { GroqBuilder, GroqBuilderOptions, RootResult } from "./groq-builder"; -export { validation } from "./validation"; +export { zod } from "./validation/zod"; /** * Creates the root `q` query builder. * + * Does not include runtime validation methods like `q.string()`. + * Instead, you have 3 options: + * - You can import `zod` and use `zod.string()` instead of `q.string()` + * - You can use inferred types without runtime validation + * - You can provide your own validation methods + * The Zod dependency can be tree-shaken with the latter 2 approaches. + * * The TRootConfig type argument is used to bind the query builder to the Sanity schema config. * If you specify `any`, then your schema will be loosely-typed, but the output types will still be strongly typed. - * - * @param options - Allows you to specify if you want indentation added to the final query. Useful for debugging. Defaults to none. */ export function createGroqBuilder( - options: GroqBuilderOptions = { indent: "" } + options: GroqBuilderOptions = {} ) { - const root = new GroqBuilder({ + const q = new GroqBuilder({ query: "", parser: null, options, }); + return q; +} - return Object.assign(root, { - /** - * Returns the root query object, extended with extra methods. - * Useful for making validation utilities. - * - * @example - * const q = createGroqBuilder().include(validation); - * - * // Now we have access to validation methods directly on `q`, like: - * q.string() - */ - include(extensions: TExtensions) { - return Object.assign(root, extensions); - }, - }); +/** + * Creates the root `q` query builder. + * + * Includes all Zod validation methods attached to the `q` object, + * like `q.string()` etc. This ensures an API that's backwards compatible with GroqD syntax. + * + * The TRootConfig type argument is used to bind the query builder to the Sanity schema config. + * If you specify `any`, then your schema will be loosely-typed, but the output types will still be strongly typed. + */ +export function createGroqBuilderWithZod( + options: GroqBuilderOptions = {} +) { + const q = createGroqBuilder(options); + return Object.assign(q, zod); } /** diff --git a/packages/groq-builder/src/tests/expectType.ts b/packages/groq-builder/src/tests/expectType.ts deleted file mode 100644 index 6aa13a09..00000000 --- a/packages/groq-builder/src/tests/expectType.ts +++ /dev/null @@ -1,140 +0,0 @@ -// This file was copied from: -// https://github.com/saiichihashimoto/sanity-typed/blob/main/packages/test-utils/src/index.ts -export type IsEqual = (() => G extends A ? 1 : 2) extends < - G ->() => G extends B ? 1 : 2 - ? true - : false; - -// -declare const RECEIVED: unique symbol; -declare const EXPECTED: unique symbol; -declare const ERROR: unique symbol; - -type Negate = Value extends true ? false : true; - -type IsAssignable = [Received] extends [Expected] - ? true - : false; - -type SimplifyDeep = Type extends Promise - ? Promise> - : Type extends any[] - ? { [index in keyof Type]: SimplifyDeep } - : { [key in keyof Type]: SimplifyDeep }; - -type IsSimplyEqual = IsEqual< - SimplifyDeep, - SimplifyDeep ->; - -declare const inverted: unique symbol; - -type TypeMatchers = { - [inverted]: Inverted; - /** Inverse next matcher. If you know how to test something, .not lets you test its opposite. */ - not: TypeMatchers>; - /** - * Checks if Received is assignable to Expected. - * - * @example - * ```typescript - * // Equivalent Checks: - * expectType().toBeAssignableTo(); - * - * const something: B = a; - * ``` - */ - toBeAssignableTo: < - Expected extends IsAssignable extends Negate - ? any - : Received & { - [ERROR]: Negate extends true - ? "Types should be assignable" - : "Types should not be assignable"; - } - >( - ...args: IsAssignable extends Negate - ? [] - : [ - error: { - [ERROR]: Negate extends true - ? "Types should be assignable" - : "Types should not be assignable"; - [RECEIVED]: Received; - [EXPECTED]: Expected; - } - ] - ) => void; - /** - * Checks if Received and Expected are exactly the same type after Simplify. - * - * Super duper experimental. - */ - toEqual: < - Expected extends IsSimplyEqual extends Negate - ? any - : SimplifyDeep & { - [ERROR]: Negate extends true - ? "Types should be equal" - : "Types should not be equal"; - } - >( - ...args: IsSimplyEqual extends Negate - ? [] - : [ - error: { - [ERROR]: Negate extends true - ? "Types should be equal" - : "Types should not be equal"; - [RECEIVED]: SimplifyDeep; - [EXPECTED]: SimplifyDeep; - } - ] - ) => void; - /** - * Checks if Received and Expected are exactly the same type. - */ - toStrictEqual: < - Expected extends IsEqual extends Negate - ? any - : Received & { - [ERROR]: Negate extends true - ? "Types should be strict equal" - : "Types should not be strict equal"; - } - >( - ...args: IsEqual extends Negate - ? [] - : [ - error: { - [ERROR]: Negate extends true - ? "Types should be strict equal" - : "Types should not be strict equal"; - [RECEIVED]: Received; - [EXPECTED]: Expected; - } - ] - ) => void; -}; - -export const expectType = () => { - const valWithoutNot: Omit, typeof inverted | "not"> = { - toBeAssignableTo: () => { - /**/ - }, - toEqual: () => { - /**/ - }, - toStrictEqual: () => { - /**/ - }, - }; - - const val = valWithoutNot as TypeMatchers; - - //// eslint-disable-next-line fp/no-mutation -- recursion requires mutation - val.not = val as unknown as typeof val.not; - - return val; -}; diff --git a/packages/groq-builder/src/types/path-types.test.ts b/packages/groq-builder/src/types/path-types.test.ts index 755529ad..64ae226a 100644 --- a/packages/groq-builder/src/types/path-types.test.ts +++ b/packages/groq-builder/src/types/path-types.test.ts @@ -1,6 +1,5 @@ -import { describe, it } from "vitest"; +import { describe, expectTypeOf, it } from "vitest"; import { Path, PathValue, PathEntries } from "./path-types"; -import { expectType } from "../tests/expectType"; import { DeepRequired } from "./deep-required"; describe("type-paths", () => { @@ -17,7 +16,7 @@ describe("type-paths", () => { describe("'Path'", () => { type Keys = Path; it("should extract all object keys", () => { - expectType().toStrictEqual< + expectTypeOf().toEqualTypeOf< | "a" // | "b" @@ -33,23 +32,23 @@ describe("type-paths", () => { }); it("optional values don't get included", () => { type KeysRequired = Path>; - expectType>().toStrictEqual<"j.k">(); + expectTypeOf>().toEqualTypeOf<"j.k">(); }); }); describe("'PathValue'", () => { it("should extract the correct values", () => { - expectType>().toStrictEqual<"A">(); - expectType>().toStrictEqual<{ c: "C" }>(); - expectType>().toStrictEqual<"C">(); - expectType>().toStrictEqual<0>(); + expectTypeOf>().toEqualTypeOf<"A">(); + expectTypeOf>().toEqualTypeOf<{ c: "C" }>(); + expectTypeOf>().toEqualTypeOf<"C">(); + expectTypeOf>().toEqualTypeOf<0>(); }); }); describe("'PathEntries'", () => { it("should extract all entries", () => { type Entries = PathEntries; - expectType().toStrictEqual<{ + expectTypeOf().toEqualTypeOf<{ a: "A"; b: { c: "C" }; "b.c": "C"; diff --git a/packages/groq-builder/src/types/public-types.ts b/packages/groq-builder/src/types/public-types.ts index 4b7b0cc0..7b13ea4f 100644 --- a/packages/groq-builder/src/types/public-types.ts +++ b/packages/groq-builder/src/types/public-types.ts @@ -1,13 +1,31 @@ -import { GroqBuilder } from "../groq-builder"; -import { ResultItem } from "./result-types"; -import { Simplify } from "./utils"; -import { ExtractProjectionResult } from "../commands/projection-types"; +import type { ZodType } from "zod"; +import type { GroqBuilder } from "../groq-builder"; +import type { ResultItem } from "./result-types"; +import type { Simplify } from "./utils"; +import type { ExtractProjectionResult } from "../commands/projection-types"; /* eslint-disable @typescript-eslint/no-explicit-any */ +/** + * A Parser is either a generic mapping function, or a Zod schema. + * It's used for run-time validation and/or transformation + * of the results of a field. + */ export type Parser = - | ParserObject - | ParserFunction; + | ParserFunction + | ZodType; + +/** + * Same as `Parser`, except it allows for wider input types, + * so that a value of `string` can be handled + * by a parser that accepts `string | null` etc. + */ +export type ParserWithWidenedInput = + // TypeScript automatically widens the parameters of a function: + | ParserFunction + // TypeScript doesn't widen types for the ZodType signature; + // (but we type-check this manually in the ExtractProjectionResult) + | ZodType; export type InferParserInput = TParser extends Parser< infer TInput @@ -22,15 +40,14 @@ export type InferParserOutput = TParser extends Parser< : never; /** - * A generic "parser" which can take any input and output a parsed type. - * This signature is compatible with Zod. + * A generic "parser" object which can take any input and output a parsed type. */ export type ParserObject = { parse: ParserFunction; }; /** - * A generic "parser" which takes any input and outputs a parsed type. + * A generic "parser" function which takes any input and outputs a parsed type. */ export type ParserFunction = ( input: TInput @@ -50,26 +67,43 @@ export type InferResultType> = /** * Extracts the Result type for a single item from a GroqBuilder query */ -export type InferResultItem = ResultItem< - InferResultType ->; +export type InferResultItem = + ResultItem.Infer>; +/** + * Used to store the Result types of a GroqBuilder. + * This symbol is not used at runtime. + */ export declare const GroqBuilderResultType: unique symbol; -// This is used to prevent circular references +/** + * IGroqBuilder is the bare minimum GroqBuilder, used to prevent circular references + */ export type IGroqBuilder = { readonly [GroqBuilderResultType]: TResult; query: string; parser: ParserFunction | null; }; -export type InferResultType2> = - TGroqBuilder extends IGroqBuilder ? TResult : never; +/** + * Used to store the Result types of a Fragment. + * This symbol is not used at runtime. + */ export declare const FragmentInputTypeTag: unique symbol; export type Fragment< TProjectionMap, TFragmentInput // This is used to capture the type, to be extracted by `InferFragmentType` > = TProjectionMap & { readonly [FragmentInputTypeTag]?: TFragmentInput }; +/** + * Infers the result types of a fragment. + * @example + * const productFragment = q.fragment().project({ + * name: q.string(), + * price: q.number(), + * }); + * + * type ProductFragment = InferFragmentType; + */ export type InferFragmentType> = TFragment extends Fragment ? Simplify> diff --git a/packages/groq-builder/src/types/query-error.ts b/packages/groq-builder/src/types/query-error.ts new file mode 100644 index 00000000..5084ccab --- /dev/null +++ b/packages/groq-builder/src/types/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 QueryError extends Error { + constructor(message: string, private details: any) { + super(message); + } +} diff --git a/packages/groq-builder/src/types/result-types.test.ts b/packages/groq-builder/src/types/result-types.test.ts index 31588fa0..ee79ba05 100644 --- a/packages/groq-builder/src/types/result-types.test.ts +++ b/packages/groq-builder/src/types/result-types.test.ts @@ -1,100 +1,100 @@ -import { describe, it } from "vitest"; -import { expectType } from "../tests/expectType"; -import { - ResultItem, - ResultItemMaybe, - ResultOverride, - ResultTypeInfer, - ResultTypeOutput, -} from "./result-types"; +import { describe, expectTypeOf, it } from "vitest"; +import { ResultItem, ResultUtils } from "./result-types"; -describe("result-types", () => { +describe("ResultItem (namespace)", () => { type Item = { ITEM: "ITEM" }; - it("ResultOverride", () => { - expectType, "NEW-ITEM">>().toStrictEqual< + it("Override", () => { + expectTypeOf, "NEW-ITEM">>().toEqualTypeOf< Array<"NEW-ITEM"> >(); - expectType< - ResultOverride | null, "NEW-ITEM"> - >().toStrictEqual | null>(); + expectTypeOf< + ResultItem.Override | null, "NEW-ITEM"> + >().toEqualTypeOf | null>(); }); - it("ResultItem", () => { - expectType>>().toStrictEqual(); - expectType | null>>().toStrictEqual(); + it("Infer", () => { + expectTypeOf>>().toEqualTypeOf(); + expectTypeOf | null>>().toEqualTypeOf(); }); - it("ResultItemMaybe", () => { - expectType>>().toStrictEqual(); - expectType< - ResultItemMaybe | null> - >().toStrictEqual(); + + it("InferMaybe", () => { + expectTypeOf>>().toEqualTypeOf(); + expectTypeOf< + ResultItem.InferMaybe | null> + >().toEqualTypeOf(); }); - describe("internal types", () => { - it("ResultTypeOutput", () => { + describe("ResultUtils (internal)", () => { + it("Wrap", () => { type ArrayResult = { - TItem: Item; + TResultItem: Item; IsArray: true; IsNullable: false; }; - expectType>().toStrictEqual>(); + expectTypeOf>().toEqualTypeOf< + Array + >(); type SingleItem = { - TItem: Item; + TResultItem: Item; IsArray: false; IsNullable: false; }; - expectType>().toStrictEqual(); + expectTypeOf>().toEqualTypeOf(); type NullableItem = { - TItem: Item; + TResultItem: Item; IsArray: false; IsNullable: true; }; - expectType>().not.toStrictEqual(); - expectType>().toStrictEqual(); + expectTypeOf>().not.toEqualTypeOf(); + expectTypeOf< + ResultUtils.Wrap + >().toEqualTypeOf(); type NullableArray = { - TItem: Item; + TResultItem: Item; IsArray: true; IsNullable: true; }; - expectType< - ResultTypeOutput - >().toStrictEqual | null>(); + expectTypeOf< + ResultUtils.Wrap + >().toEqualTypeOf | null>(); }); - it("ResultTypeInfer", () => { - expectType>().toStrictEqual<{ - TItem: Item; + it("Unwrap", () => { + expectTypeOf>().toEqualTypeOf<{ + TResultItem: Item; IsArray: false; IsNullable: false; }>(); // IsNullable variants: - expectType>().toStrictEqual<{ - TItem: Item; + expectTypeOf>().toEqualTypeOf<{ + TResultItem: Item; IsArray: false; IsNullable: true; }>(); - expectType>().toStrictEqual<{ - TItem: Item; + expectTypeOf>().toEqualTypeOf<{ + TResultItem: Item; IsArray: false; IsNullable: true; }>(); - expectType>().toStrictEqual<{ - TItem: Item; + expectTypeOf< + ResultUtils.Unwrap + >().toEqualTypeOf<{ + TResultItem: Item; IsArray: false; IsNullable: true; }>(); // IsArray variants: - expectType>>().toStrictEqual<{ - TItem: Item; + expectTypeOf>>().toEqualTypeOf<{ + TResultItem: Item; IsArray: true; IsNullable: false; }>(); - expectType | null>>().toStrictEqual<{ - TItem: Item; + expectTypeOf | null>>().toEqualTypeOf<{ + TResultItem: Item; IsArray: true; IsNullable: true; }>(); diff --git a/packages/groq-builder/src/types/result-types.ts b/packages/groq-builder/src/types/result-types.ts index 373a3190..c4a5ee9c 100644 --- a/packages/groq-builder/src/types/result-types.ts +++ b/packages/groq-builder/src/types/result-types.ts @@ -1,51 +1,110 @@ -import { Override, Simplify } from "./utils"; +import { Override as _Override, Simplify } from "./utils"; -export type ResultTypeInfo = { - TItem: unknown; - IsArray: boolean; - IsNullable: boolean; -}; +/* eslint-disable @typescript-eslint/no-namespace */ -export type ResultTypeInfer = { - TItem: NonNullable extends Array ? U : NonNullable; - IsArray: IsArray>; - IsNullable: IsNullable; -}; +/** + * The results of a query can be a single item, or an array of items, + * and the results can be nullable too. + * + * However, most chainable methods need to look at the shape + * of the inner "ResultItem", ignoring the nullable and Array wrappers. + * You can do this with `ResultItem.Infer`. + * + * Additionally, these methods might need to rewrap a new type, + * while preserving the nullable/Array wrappers. + * You can do this with `ResultItem.Override` + */ +export namespace ResultItem { + /** + * Retrieves just the shape of the TResult, unwrapping nulls and arrays. + * + * @example + * Infer<"FOO"> // Result: "FOO" + * Infer> // Result: "FOO" + * Infer // Result: "FOO" + * Infer> // Result: "FOO" + */ + export type Infer = ResultUtils.Unwrap["TResultItem"]; + + /** + * Retrieves just the shape of the TResult, unwrapping arrays. + * + * Preserves nulls. + * + * @example + * Infer<"FOO"> // Result: "FOO" + * Infer> // Result: "FOO" + * Infer // Result: null | "FOO" + * Infer> // Result: null | "FOO" + */ + export type InferMaybe = ResultUtils.Wrap< + _Override< + ResultUtils.Unwrap, + // Keep the "IsNullable" flag, but unwrap the array: + { IsArray: false } + > + >; -export type ResultTypeOutput = MakeNullable< - TResult["IsNullable"], - MakeArray ->; + /** + * Overrides the shape of the result, while preserving IsArray and IsNullable + * + * @example + * OverrideResultItem, "BAR">; // Result: null | Array<"BAR"> + * OverrideResultItem, "BAR">; // Result: Array<"BAR"> + * OverrideResultItem; // Result: null | "BAR" + * OverrideResultItem<"FOO", "BAR">; // Result: "BAR" + */ + export type Override = Simplify< + ResultUtils.Wrap< + _Override< + ResultUtils.Unwrap, + { + TResultItem: TResultItemNew; + } + > + > + >; +} /** - * Overrides the shape of the result, while preserving IsArray and IsNullable + * This namespace provides utilities for unwrapping + * and re-wrapping the null/Array types. + * + * @internal Only exported for tests */ -export type ResultOverride = Simplify< - ResultTypeOutput< - Override< - ResultTypeInfer, - { - TItem: NonNullable; - IsNullable: IsNullable extends true - ? true - : IsNullable; - } +export namespace ResultUtils { + type Unwrapped = { + TResultItem: unknown; + IsArray: boolean; + IsNullable: boolean; + }; + export type Unwrap = { + TResultItem: NonNullable extends Array + ? U + : NonNullable; + IsArray: IsArray>; + IsNullable: IsNullable; + }; + export type Wrap = MakeNullable< + TDetails["IsNullable"], + MakeArray< + // + TDetails["IsArray"], + TDetails["TResultItem"] > - > ->; - -export type ResultItem = ResultTypeOutput< - Override, { IsArray: false; IsNullable: false }> ->; -export type ResultItemMaybe = ResultTypeOutput< - Override, { IsArray: false }> ->; + >; -type MakeNullable = IsNullable extends true - ? null | T - : T; -type MakeArray = IsArray extends true - ? Array - : T; -type IsArray = T extends Array ? true : false; -type IsNullable = null extends T ? true : undefined extends T ? true : false; + // Internal utils: + type MakeNullable = IsNullable extends true + ? null | T + : T; + type MakeArray = IsArray extends true + ? Array + : T; + type IsArray = T extends Array ? true : false; + export type IsNullable = null extends T + ? true + : undefined extends T + ? true + : false; +} diff --git a/packages/groq-builder/src/types/utils.test.ts b/packages/groq-builder/src/types/utils.test.ts index c1d4c0c0..3d09f478 100644 --- a/packages/groq-builder/src/types/utils.test.ts +++ b/packages/groq-builder/src/types/utils.test.ts @@ -1,11 +1,8 @@ -import { describe, it } from "vitest"; -import { expectType } from "../tests/expectType"; +import { describe, expectTypeOf, it } from "vitest"; import { ExtractTypeMismatchErrors, - Tagged, TypeMismatchError, - TaggedUnwrap, - TaggedType, + UndefinedToNull, } from "./utils"; describe("ExtractTypeMismatchErrors", () => { @@ -15,60 +12,42 @@ describe("ExtractTypeMismatchErrors", () => { actual: unknown; }>; - type TestError = TME<"pass-through">; type Valid = { FOO: "FOO" }; - it("should pass-through errors", () => { - expectType< - ExtractTypeMismatchErrors - >().toStrictEqual(); - - expectType< - ExtractTypeMismatchErrors - >().toStrictEqual(); - - expectType< - ExtractTypeMismatchErrors - >().toStrictEqual(); - }); it("should find nested errors", () => { type TestObject = { FOO: TME<"foo-error">; BAR: TME<"bar-error">; BAZ: Valid; }; - expectType["error"]>().toStrictEqual< - | "The following property had a nested error: FOO" - | "The following property had a nested error: BAR" + expectTypeOf>().toEqualTypeOf< + | 'Error in "FOO": foo-error' + // + | 'Error in "BAR": bar-error' >(); }); it("should return 'never' when there's no errors", () => { - expectType>().toStrictEqual(); - expectType>().toStrictEqual(); - expectType>().toStrictEqual(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); }); }); -describe("Tagged", () => { - type Base = { - name: string; - }; - type TagInfo = { - tagInfo: string; - }; - type BaseWithTag = Tagged; - - it("should be assignable to the base type", () => { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const baseTagged: BaseWithTag = { name: "hey" }; - }); - it("should not be equal to the base type, because of the tag", () => { - expectType().not.toStrictEqual(); - }); - it("should be able to unwrap the tag", () => { - expectType>().toStrictEqual(); +describe("UndefinedToNull", () => { + it("should cast undefined or optional properties to null", () => { + type Foo = "Foo"; + expectTypeOf< + UndefinedToNull + >().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf< + UndefinedToNull + >().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); }); - it("should be able to extract the tag info", () => { - expectType>().toStrictEqual(); + it("should cast optional properties to null", () => { + type Foo = { Foo?: "FOO" }; + expectTypeOf().toEqualTypeOf<"FOO" | undefined>(); + expectTypeOf>().toEqualTypeOf<"FOO" | null>(); }); }); diff --git a/packages/groq-builder/src/types/utils.ts b/packages/groq-builder/src/types/utils.ts index b8e4901a..915493db 100644 --- a/packages/groq-builder/src/types/utils.ts +++ b/packages/groq-builder/src/types/utils.ts @@ -1,10 +1,9 @@ -/** - * All primitive types; just not objects or functions. - */ -export type Primitive = number | string | boolean | null | undefined | symbol; +import type { Simplify } from "type-fest"; + +export type { Simplify, Primitive, LiteralUnion, IsAny } from "type-fest"; /** - * Extracts the key from the object type, same as TObj[TKey[ + * Extracts the key from the object type, same as TObj[TKey] * If the key doesn't exist, returns a TypeMismatchError */ export type Get = TKey extends keyof TObj @@ -31,34 +30,8 @@ export type SimplifyDeep = T extends object : never : T; -// eslint-disable-next-line @typescript-eslint/ban-types -export type Simplify = { [P in keyof T]: T[P] } & {}; - -export type UnionToIntersection = ( - U extends any ? (k: U) => void : never -) extends (k: infer I) => void - ? I - : never; - export type Override = Omit & TOverrides; -/** - * Extracts the type of an array item, if the type is indeed an array. - */ -export type MaybeArrayItem = NonNullable< - T extends Array ? TItem : T ->; -/** - * Extracts the type of an array item; returns an error if it's not an array. - */ -export type ArrayItem = T extends Array - ? TItem - : TypeMismatchError<{ - error: "Expected an array"; - expected: Array; - actual: T; - }>; - export type TypeMismatchError< TError extends { error: string; expected: any; actual: any } = any > = { @@ -67,27 +40,28 @@ export type TypeMismatchError< actual: Simplify; }; -export type ExtractTypeMismatchErrors = - TResult extends TypeMismatchError - ? TResult - : // Search for error values in objects, like { foo: TypeMismatchError } - Extract, [string, TypeMismatchError]> extends [ - infer TKey, - infer TError - ] - ? TKey extends string - ? TypeMismatchError<{ - error: `The following property had a nested error: ${Extract< - TKey, - string - >}`; - expected: "No nested errors"; - actual: TError; - }> - : never - : never; +/** + * Extracts all TypeMismatchError's from the projection result, + * making it easy to report these errors. + * Returns `never` if there are no errors. + */ +export type ExtractTypeMismatchErrors = ValueOf<{ + [TKey in StringKeys]: `Error in "${TKey}": ${Extract< + TProjectionResult[TKey], + TypeMismatchError + >["error"]}`; +}>; +/** + * Returns a union of all value types in the object + */ export type ValueOf = T[keyof T]; +/** + * Returns a union of the [Key, Value] pairs of the object. + * @example + * EntriesOf<{ foo: "FOO", bar: "BAR" }> + * // Results: ["foo", "FOO"] | ["bar", "BAR"] + */ export type EntriesOf = ValueOf<{ [Key in StringKeys]: [Key, T[Key]]; }>; @@ -121,28 +95,11 @@ export type ButFirst> = T extends [any, ...infer Rest] ? Rest : never; -/** - * Extends a base type with extra type information. - * - * (also known as "opaque", "branding", or "flavoring") - * @example - * const id: Tagged = "hello"; - * - */ -export type Tagged = TActual & { readonly [Tag]?: TTag }; -export type TaggedUnwrap = Omit; -export type TaggedType> = - TTagged extends Tagged ? TTag : never; -declare const Tag: unique symbol; - /** * A completely empty object. */ export type Empty = Record; -/** Taken from type-fest; checks if a type is any */ -export type IsAny = 0 extends 1 & T ? true : false; - export function keys(obj: T) { return Object.keys(obj) as Array>; } @@ -150,3 +107,13 @@ export function keys(obj: T) { export function notNull(value: T | null): value is T { return !!value; } + +/** + * Replaces undefined with null. + * @example + * UndefinedToNull == Foo | null; + * UndefinedToNull == Foo; + */ +export type UndefinedToNull = T extends undefined + ? NonNullable | null + : T; diff --git a/packages/groq-builder/src/validation/array-shape.test.ts b/packages/groq-builder/src/validation/array-shape.test.ts deleted file mode 100644 index 7045f4bd..00000000 --- a/packages/groq-builder/src/validation/array-shape.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { validation } from "./index"; -import { arrayValidation } from "./array-shape"; -import { improveErrorMessage } from "./primitives.test"; - -describe("array", () => { - const arrParser = arrayValidation.array(validation.number()); - - it("should ensure the input was an array", () => { - expect( - improveErrorMessage(() => - arrParser( - // @ts-expect-error --- - {} - ) - ) - ).toThrowErrorMatchingInlineSnapshot( - '"Expected an array, received an object"' - ); - expect( - improveErrorMessage(() => - arrParser( - // @ts-expect-error --- - null - ) - ) - ).toThrowErrorMatchingInlineSnapshot('"Expected an array, received null"'); - }); - - it("should ensure all items are valid", () => { - const numbers = [1, 2, 3]; - expect(arrParser(numbers)).toEqual(numbers); - expect(arrParser(numbers)).not.toBe(numbers); - }); - - it("should fail for invalid items", () => { - const invalid = [1, "2", "3"]; - expect( - improveErrorMessage(() => - arrParser( - // @ts-expect-error --- - invalid - ) - ) - ).toThrowErrorMatchingInlineSnapshot(` - "2 Parsing Errors: - result[1]: Expected number, received \\"2\\" - result[2]: Expected number, received \\"3\\"" - `); - }); -}); diff --git a/packages/groq-builder/src/validation/array-shape.ts b/packages/groq-builder/src/validation/array-shape.ts deleted file mode 100644 index e7ad329e..00000000 --- a/packages/groq-builder/src/validation/array-shape.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { - InferParserInput, - InferParserOutput, - Parser, -} from "../types/public-types"; -import { normalizeValidationFunction } from "../commands/validate-utils"; -import { Simplify } from "../types/utils"; -import { ValidationErrors } from "./validation-errors"; -import { createOptionalParser, inspect, OptionalParser } from "./primitives"; - -export interface ArrayValidation { - array(): OptionalParser, Array>; - array( - itemParser: TParser - ): OptionalParser< - Array>>, - Array>> - >; -} - -export const arrayValidation: ArrayValidation = { - array(itemParser?: Parser) { - if (!itemParser) { - return createOptionalParser((input) => { - if (!Array.isArray(input)) { - throw new TypeError(`Expected an array, received ${inspect(input)}`); - } - return input; - }); - } - - const normalizedItemParser = normalizeValidationFunction(itemParser)!; - - return createOptionalParser((input) => { - if (!Array.isArray(input)) { - throw new TypeError(`Expected an array, received ${inspect(input)}`); - } - - const validationErrors = new ValidationErrors(); - const results = input.map((value, i) => { - try { - return normalizedItemParser(value); - } catch (err) { - validationErrors.add(`[${i}]`, value, err as Error); - return null; - } - }); - - if (validationErrors.length) throw validationErrors; - - return results; - }); - }, -}; diff --git a/packages/groq-builder/src/validation/index.test.ts b/packages/groq-builder/src/validation/index.test.ts deleted file mode 100644 index 1d70771e..00000000 --- a/packages/groq-builder/src/validation/index.test.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { expectType } from "../tests/expectType"; -import { InferResultItem, InferResultType } from "../types/public-types"; -import { createGroqBuilder, validation } from "../index"; -import { TypeMismatchError } from "../types/utils"; -import { SanitySchema, SchemaConfig } from "../tests/schemas/nextjs-sanity-fe"; - -describe("createGroqBuilder() (schema-less)", () => { - const q = createGroqBuilder(); - - it("filterByType", () => { - const qFilterByType = q.star.filterByType("ANYTHING"); - expectType< - InferResultType - >().toStrictEqual | null>(); - }); - it("deref", () => { - const qDeref = q.star.deref(); - expectType< - InferResultType - >().toStrictEqual | null>(); - }); - it("grab", () => { - // todo - }); - it("order", () => { - const qOrder = q.star.order("ANYTHING"); - expectType>().toStrictEqual>(); - }); - it("slice(0)", () => { - const qSlice = q.star.slice(0); - expectType>().toStrictEqual(); - expectType>().not.toStrictEqual< - Array - >(); - }); - it("slice(10, 5)", () => { - const qSlice = q.star.slice(10, 15); - expectType>().toStrictEqual>(); - }); - it("star", () => { - const qStar = q.star; - expectType>().toStrictEqual>(); - }); -}); - -describe("createGroqBuilder().include(validation)", () => { - const q = createGroqBuilder().include(validation); - - it("should contain all methods", () => { - expect(q.string()).toBeTypeOf("function"); - expect(q.number()).toBeTypeOf("function"); - expect(q.boolean()).toBeTypeOf("function"); - expect(q.bigint()).toBeTypeOf("function"); - expect(q.undefined()).toBeTypeOf("function"); - expect(q.date()).toBeTypeOf("function"); - expect(q.literal("LITERAL")).toBeTypeOf("function"); - expect(q.object()).toBeTypeOf("function"); - expect(q.array()).toBeTypeOf("function"); - expect(q.contentBlock()).toBeTypeOf("function"); - expect(q.contentBlocks()).toBeTypeOf("function"); - }); - - it('"q.string()" should work', () => { - const str = q.string(); - expect(str).toBeTypeOf("function"); - expect(str("FOO")).toEqual("FOO"); - // @ts-expect-error --- - expect(() => str(111)).toThrowErrorMatchingInlineSnapshot( - '"Expected string, received 111"' - ); - }); - - it("validation should work with projections", () => { - const qVariants = q.star.filterByType("variant").project({ - name: q.string(), - price: q.number(), - }); - - expectType>().toStrictEqual | null>(); - }); -}); - -describe("strongly-typed schema, with runtime validation", () => { - const q = createGroqBuilder().include(validation); - - it("validation should work with projections", () => { - const qVariants = q.star.filterByType("variant").project({ - name: q.string(), - price: q.number(), - }); - - expectType>().toStrictEqual< - Array<{ - name: string; - price: number; - }> - >(); - }); - - it("improper validation usage should be caught at compile time", () => { - q.star.filterByType("variant").project({ - // @ts-expect-error --- number is not assignable to string - price: q.string(), - // @ts-expect-error --- string is not assignable to number - name: q.number(), - }); - - const qUnknownFieldName = q.star.filterByType("variant").project({ - INVALID: q.string(), - }); - - type ResultItem = InferResultItem; - - expectType().not.toStrictEqual(); - expectType().toStrictEqual< - TypeMismatchError<{ - error: "⛔️ Parser can only be used with known properties ⛔️"; - expected: keyof SanitySchema.Variant; - actual: "INVALID"; - }> - >(); - }); -}); diff --git a/packages/groq-builder/src/validation/index.ts b/packages/groq-builder/src/validation/index.ts deleted file mode 100644 index a5b03f3b..00000000 --- a/packages/groq-builder/src/validation/index.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { primitiveValidation } from "./primitives"; -import { sanityValidation } from "./sanity-content-blocks"; -import { objectValidation } from "./object-shape"; -import { arrayValidation } from "./array-shape"; - -export const validation = { - ...primitiveValidation, - ...sanityValidation, - ...objectValidation, - ...arrayValidation, -}; diff --git a/packages/groq-builder/src/validation/object-shape.test.ts b/packages/groq-builder/src/validation/object-shape.test.ts deleted file mode 100644 index 07279ec1..00000000 --- a/packages/groq-builder/src/validation/object-shape.test.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { validation } from "./index"; -import { expectType } from "../tests/expectType"; -import { InferParserInput, InferParserOutput } from "../types/public-types"; -import { objectValidation } from "./object-shape"; -import { improveErrorMessage } from "./primitives.test"; - -describe("objectValidation.object", () => { - const objParser = objectValidation.object({ - str: validation.string(), - strOpt: validation.string().optional(), - num: validation.number(), - nested: objectValidation.object({ - bool: validation.boolean(), - }), - }); - - type Expected = { - str: string; - strOpt: string | null | undefined; - num: number; - nested: { - bool: boolean; - }; - }; - - it("should have the correct type", () => { - expectType>().toStrictEqual(); - expectType>().toStrictEqual(); - - const opt = objParser.optional(); - expectType>().toStrictEqual< - Expected | undefined | null - >(); - expectType>().toStrictEqual< - Expected | undefined | null - >(); - }); - - it("should successfully pass valid input", () => { - const valid: Expected = { - str: "string", - strOpt: null, - num: 5, - nested: { bool: true }, - }; - expect(objParser(valid)).toEqual(valid); - expect(objParser(valid)).not.toBe(valid); - }); - - it("should throw errors for invalid data", () => { - const invalid = { - str: null, - strOpt: 999, - num: "hey", - nested: { foo: true }, - }; - - expect( - improveErrorMessage(() => - objParser( - // @ts-expect-error --- - invalid - ) - ) - ).toThrowErrorMatchingInlineSnapshot(` - "4 Parsing Errors: - result.str: Expected string, received null - result.strOpt: Expected string, received 999 - result.num: Expected number, received \\"hey\\" - result.nested.bool: Expected boolean, received undefined" - `); - - expect( - improveErrorMessage(() => - objParser( - // @ts-expect-error --- - 123 - ) - ) - ).toThrowErrorMatchingInlineSnapshot('"Expected an object, received 123"'); - }); - - describe("with different inputs and outputs", () => { - const mapper = objectValidation.object({ - stringToNumber: (input: string) => Number(input), - numberToString: (input: number) => String(input), - stringToLiteral: (input: string) => input as "LITERAL", - }); - type ExpectedInput = { - stringToNumber: string; - numberToString: number; - stringToLiteral: string; - }; - type ExpectedOutput = { - stringToNumber: number; - numberToString: string; - stringToLiteral: "LITERAL"; - }; - - it("types should be correct", () => { - expectType< - InferParserInput - >().toStrictEqual(); - expectType< - InferParserOutput - >().toStrictEqual(); - }); - - it("should map data correctly", () => { - expect( - mapper({ - stringToNumber: "123", - numberToString: 456, - stringToLiteral: "FOO", - }) - ).toEqual({ - stringToNumber: 123, - numberToString: "456", - stringToLiteral: "FOO", - }); - }); - }); -}); diff --git a/packages/groq-builder/src/validation/object-shape.ts b/packages/groq-builder/src/validation/object-shape.ts deleted file mode 100644 index 896d6808..00000000 --- a/packages/groq-builder/src/validation/object-shape.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { createOptionalParser, inspect, OptionalParser } from "./primitives"; -import { - InferParserInput, - InferParserOutput, - Parser, - ParserFunction, -} from "../types/public-types"; -import { Simplify } from "../types/utils"; -import { normalizeValidationFunction } from "../commands/validate-utils"; -import { ValidationErrors } from "./validation-errors"; - -interface ObjectValidation { - object(): OptionalParser; - object( - map?: TMap - ): OptionalParser< - Simplify<{ - [P in keyof TMap]: TMap[P] extends {} - ? InferParserInput - : unknown; - }>, - Simplify<{ - [P in keyof TMap]: TMap[P] extends {} - ? InferParserOutput - : unknown; - }> - >; - - union( - parserA: TParserA, - parserB: TParserB - ): OptionalParser< - InferParserInput & InferParserInput, - InferParserOutput & InferParserOutput - >; -} - -export const objectValidation: ObjectValidation = { - object(map?: ObjectValidationMap) { - if (!map) { - return createOptionalParser((input) => { - if (input === null || typeof input !== "object") { - throw new TypeError(`Expected an object, received ${inspect(input)}`); - } - return input; - }); - } - - const keys = Object.keys(map) as Array; - const normalized = keys.map( - (key) => - [ - key, - normalizeValidationFunction(map[key as keyof typeof map]), - ] as const - ); - return createOptionalParser((input) => { - if (input === null || typeof input !== "object") { - throw new TypeError(`Expected an object, received ${inspect(input)}`); - } - - const validationErrors = new ValidationErrors(); - - const result: any = {}; - for (const [key, parse] of normalized) { - const value = input[key as keyof typeof input]; - try { - result[key] = parse ? parse(value) : value; - } catch (err) { - validationErrors.add(key, value, err as Error); - } - } - - if (validationErrors.length) throw validationErrors; - return result; - }); - }, - - union(parserA, parserB) { - return createOptionalParser((input) => { - return { - ...parserA(input), - ...parserB(input), - }; - }); - }, -}; - -export type ObjectValidationMap = Record; diff --git a/packages/groq-builder/src/validation/primitives.test.ts b/packages/groq-builder/src/validation/primitives.test.ts deleted file mode 100644 index ae8677b2..00000000 --- a/packages/groq-builder/src/validation/primitives.test.ts +++ /dev/null @@ -1,182 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { validation } from "./index"; -import { expectType } from "../tests/expectType"; -import { InferParserInput, InferParserOutput } from "../types/public-types"; -import { ValidationErrors } from "./validation-errors"; - -describe("primitiveValidation", () => { - it("string", () => { - const str = validation.string(); - - expect(str("TEST")).toEqual("TEST"); - const validResult = str("TEST"); - expectType().toStrictEqual(); - - // @ts-expect-error --- - expect(() => str(undefined)).toThrowErrorMatchingInlineSnapshot( - '"Expected string, received undefined"' - ); - // @ts-expect-error --- - expect(() => str(null)).toThrowErrorMatchingInlineSnapshot( - '"Expected string, received null"' - ); - // @ts-expect-error --- - expect(() => str(123)).toThrowErrorMatchingInlineSnapshot( - '"Expected string, received 123"' - ); - // @ts-expect-error --- - expect(() => str({})).toThrowErrorMatchingInlineSnapshot( - '"Expected string, received an object"' - ); - // @ts-expect-error --- - expect(() => str([])).toThrowErrorMatchingInlineSnapshot( - '"Expected string, received an array"' - ); - }); - it("string.optional", () => { - const str = validation.string().optional(); - - expect(str("TEST")).toEqual("TEST"); - expect(str(undefined)).toEqual(undefined); - expect(str(null)).toEqual(null); - - // @ts-expect-error --- - expect(() => str(123)).toThrowErrorMatchingInlineSnapshot( - '"Expected string, received 123"' - ); - // @ts-expect-error --- - expect(() => str({})).toThrowErrorMatchingInlineSnapshot( - '"Expected string, received an object"' - ); - // @ts-expect-error --- - expect(() => str([])).toThrowErrorMatchingInlineSnapshot( - '"Expected string, received an array"' - ); - }); - it("number", () => { - const num = validation.number(); - - expect(num(999)).toEqual(999); - - // @ts-expect-error --- - expect(() => num("123")).toThrowErrorMatchingInlineSnapshot( - '"Expected number, received \\"123\\""' - ); - // @ts-expect-error --- - expect(() => num([])).toThrowErrorMatchingInlineSnapshot( - '"Expected number, received an array"' - ); - // @ts-expect-error --- - expect(() => num({})).toThrowErrorMatchingInlineSnapshot( - '"Expected number, received an object"' - ); - }); - - describe("object", () => { - type ExpectedType = { - foo: "FOO"; - }; - - const objParser = validation.object(); - - it("should have the correct type", () => { - expectType< - InferParserInput - >().toStrictEqual(); - expectType< - InferParserOutput - >().toStrictEqual(); - - const opt = objParser.optional(); - expectType>().toStrictEqual< - ExpectedType | undefined | null - >(); - expectType>().toStrictEqual< - ExpectedType | undefined | null - >(); - }); - - it("should successfully pass valid input", () => { - const valid: ExpectedType = { foo: "FOO" }; - expect(objParser(valid)).toEqual(valid); - expect(objParser(valid)).toBe(valid); - }); - - it("should pass-through any object", () => { - const invalidObject = { INVALID: true }; - expect( - // @ts-expect-error --- - objParser(invalidObject) - ).toEqual(invalidObject); - }); - - it("should throw errors for non-objects", () => { - expect( - // @ts-expect-error --- - improveErrorMessage(() => objParser(null)) - ).toThrowErrorMatchingInlineSnapshot( - '"Expected an object, received null"' - ); - expect( - // @ts-expect-error --- - improveErrorMessage(() => objParser(123)) - ).toThrowErrorMatchingInlineSnapshot( - '"Expected an object, received 123"' - ); - expect( - // @ts-expect-error --- - improveErrorMessage(() => objParser("string")) - ).toThrowErrorMatchingInlineSnapshot( - '"Expected an object, received \\"string\\""' - ); - }); - }); - - describe("array", () => { - const arrParser = validation.array(); - - it("should ensure the input was an array", () => { - expect( - // @ts-expect-error --- - improveErrorMessage(() => arrParser({})) - ).toThrowErrorMatchingInlineSnapshot( - '"Expected an array, received an object"' - ); - expect( - // @ts-expect-error --- - improveErrorMessage(() => arrParser(null)) - ).toThrowErrorMatchingInlineSnapshot( - '"Expected an array, received null"' - ); - }); - - it("returns valid input", () => { - const numbers = [1, 2, 3]; - - expect(arrParser(numbers)).toEqual(numbers); - }); - - it("does not check invalid items", () => { - const invalid = [1, "2", "3"]; - expect( - arrParser( - // @ts-expect-error --- - invalid - ) - ).toEqual(invalid); - }); - }); -}); - -export function improveErrorMessage(cb: () => void) { - return () => { - try { - cb(); - } catch (err) { - if (err instanceof ValidationErrors) { - throw err.withMessage(); - } - throw err; - } - }; -} diff --git a/packages/groq-builder/src/validation/primitives.ts b/packages/groq-builder/src/validation/primitives.ts deleted file mode 100644 index 2564bcf0..00000000 --- a/packages/groq-builder/src/validation/primitives.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { ParserFunction } from "../types/public-types"; - -export const primitiveValidation = { - string: memo(() => createTypeValidator("string")), - boolean: memo(() => createTypeValidator("boolean")), - number: memo(() => createTypeValidator("number")), - bigint: memo(() => createTypeValidator("bigint")), - undefined: memo(() => createTypeValidator("undefined")), - - unknown: () => createOptionalParser((input: unknown) => input), - - literal: (literal: T) => - createOptionalParser((input) => { - if (input !== literal) { - throw new TypeError( - `Expected ${inspect(literal)}, received ${inspect(input)}` - ); - } - return input; - }), - - date: memo(() => - createOptionalParser((input) => { - if (typeof input === "string") { - const date = new Date(input); - if (!isNaN(date.getTime())) { - return date; - } - } - throw new TypeError(`Expected a date, received ${inspect(input)}`); - }) - ), - - object: () => - createOptionalParser((input) => { - if (typeof input !== "object" || input === null) { - throw new TypeError(`Expected an object, received ${inspect(input)}`); - } - return input; - }), - - array: >() => - createOptionalParser((input) => { - if (!Array.isArray(input)) { - throw new TypeError(`Expected an array, received ${inspect(input)}`); - } - return input; - }), -}; - -/** - * Simple function memoizer; does not support args, must return truthy - * @param fn - */ -export function memo any>(fn: T): T { - let result: ReturnType; - return (() => result || (result = fn())) as T; -} - -/** - * Pretty-prints the value - */ -export function inspect(value: unknown): string { - if (value) { - if (Array.isArray(value)) return "an array"; - if (typeof value === "object") return "an object"; - } - return JSON.stringify(value); -} - -/** - * Extends the parsing function with an `.optional()` extension, - * which allows null/undefined values. - */ -export function createOptionalParser( - check: ParserFunction -): OptionalParser { - const parser = check as OptionalParser; - parser.optional = () => (input) => { - // Allow nullish values: - if (input === undefined || input === null) { - return input as Extract; - } - return check(input); - }; - return parser; -} - -export type OptionalParser = ParserFunction< - TInput, - TOutput -> & { - optional(): ( - input: TInputMaybe - ) => TOutput | Extract; -}; - -type TypeValidators = { - string: string; - boolean: boolean; - number: number; - bigint: bigint; - undefined: undefined; -}; -function createTypeValidator( - type: TypeName -) { - return createOptionalParser< - TypeValidators[TypeName], - TypeValidators[TypeName] - >((input) => { - if (typeof input !== type) { - throw new TypeError(`Expected ${type}, received ${inspect(input)}`); - } - return input; - }); -} diff --git a/packages/groq-builder/src/validation/sanity-content-blocks.test.ts b/packages/groq-builder/src/validation/sanity-content-blocks.test.ts deleted file mode 100644 index 065099ff..00000000 --- a/packages/groq-builder/src/validation/sanity-content-blocks.test.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { validation } from "./index"; -import { mock } from "../tests/mocks/nextjs-sanity-fe-mocks"; - -describe("contentBlock", () => { - const parseBlock = validation.contentBlock(); - - const contentBlock = mock.contentBlock({ - children: [{ _type: "span", _key: "", text: "lorem ipsum" }], - }); - - it("should parse correctly", () => { - expect(parseBlock(contentBlock)).toEqual(contentBlock); - }); - - it("should not deep-check the data", () => { - const invalid = { - invalid: true, - }; - expect( - parseBlock( - // @ts-expect-error --- - invalid - ) - ).toEqual(invalid); - }); - - it("should fail for non-object data", () => { - expect(() => - parseBlock( - // @ts-expect-error --- - "invalid" - ) - ).toThrowErrorMatchingInlineSnapshot( - '"Expected an object, received \\"invalid\\""' - ); - expect(() => - parseBlock( - // @ts-expect-error --- - null - ) - ).toThrowErrorMatchingInlineSnapshot('"Expected an object, received null"'); - expect(() => - parseBlock( - // @ts-expect-error --- - 123 - ) - ).toThrowErrorMatchingInlineSnapshot('"Expected an object, received 123"'); - }); -}); - -describe("contentBlocks", () => { - const parseBlocks = validation.contentBlocks(); - const contentBlocks = mock.array(5, () => mock.contentBlock({})); - it("should work with valid data", () => { - expect(parseBlocks(contentBlocks)).toEqual(contentBlocks); - }); - - it("should not deep-check items in the array", () => { - const invalidData = [{ invalid: true }, "INVALID"]; - expect( - parseBlocks( - // @ts-expect-error --- - invalidData - ) - ).toEqual(invalidData); - }); - it("should fail for non-arrays", () => { - expect(() => - parseBlocks( - // @ts-expect-error --- - null - ) - ).toThrowErrorMatchingInlineSnapshot('"Expected an array, received null"'); - expect(() => - parseBlocks( - // @ts-expect-error --- - 123 - ) - ).toThrowErrorMatchingInlineSnapshot('"Expected an array, received 123"'); - expect(() => - parseBlocks( - // @ts-expect-error --- - "invalid" - ) - ).toThrowErrorMatchingInlineSnapshot( - '"Expected an array, received \\"invalid\\""' - ); - }); -}); diff --git a/packages/groq-builder/src/validation/sanity-content-blocks.ts b/packages/groq-builder/src/validation/sanity-content-blocks.ts deleted file mode 100644 index b09b9d60..00000000 --- a/packages/groq-builder/src/validation/sanity-content-blocks.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { memo, primitiveValidation } from "./primitives"; - -export const sanityValidation = { - contentBlock: memo(< - TConfig extends ContentBlockConfig = ContentBlockConfig - >() => primitiveValidation.object>()), - - contentBlocks: memo(< - TConfig extends ContentBlockConfig = ContentBlockConfig - >() => primitiveValidation.array>>()), -}; - -export type ContentBlocks< - TConfig extends ContentBlockConfig = ContentBlockConfig -> = Array>; -export type ContentBlock< - TConfig extends ContentBlockConfig = ContentBlockConfig -> = { - _type: string; - _key?: string; - children: Array<{ - _key: string; - _type: string; - text: string; - marks?: string[]; - }>; - style?: string; - listItem?: string; - level?: number; -} & TConfig; -export type ContentBlockConfig = { - markDefs?: Array<{ _type: string; _key: string }>; -}; diff --git a/packages/groq-builder/src/validation/simple-validation.ts b/packages/groq-builder/src/validation/simple-validation.ts new file mode 100644 index 00000000..db4980db --- /dev/null +++ b/packages/groq-builder/src/validation/simple-validation.ts @@ -0,0 +1,117 @@ +import { + InferParserInput, + InferParserOutput, + Parser, + ParserFunction, +} from "../types/public-types"; +import { ValidationErrors } from "./validation-errors"; +import { Simplify } from "../types/utils"; +import { normalizeValidationFunction } from "../commands/validate-utils"; + +/** + * Pretty-prints the value + */ +export function inspect(value: unknown): string { + if (value) { + if (Array.isArray(value)) return "an array"; + if (typeof value === "object") return "an object"; + } + return JSON.stringify(value); +} + +/** + * Validates (and maps) each item in an array. + */ +export function simpleArrayParser( + itemParser: null | ParserFunction +): ParserFunction, Array> { + if (!itemParser) { + return (input) => { + if (!Array.isArray(input)) { + throw new TypeError(`Expected an array, received ${inspect(input)}`); + } + return input as unknown as Array; + }; + } + + return (input) => { + if (!Array.isArray(input)) { + throw new TypeError(`Expected an array, received ${inspect(input)}`); + } + + const validationErrors = new ValidationErrors(); + const results = input.map((value, i) => { + try { + return itemParser(value); + } catch (err) { + validationErrors.add(`[${i}]`, value, err as Error); + return null as never; + } + }); + + if (validationErrors.length) throw validationErrors; + + return results; + }; +} + +/** + * Validates (and maps) each key in an object. + */ +export function simpleObjectParser( + objectMapper?: TMap +): ParserFunction< + Simplify<{ + [P in keyof TMap]: TMap[P] extends Parser + ? InferParserInput + : unknown; + }>, + Simplify<{ + [P in keyof TMap]: TMap[P] extends Parser + ? InferParserOutput + : unknown; + }> +> { + if (!objectMapper) { + return (input: unknown) => { + if (input === null || typeof input !== "object") { + throw new TypeError(`Expected an object, received ${inspect(input)}`); + } + return input as any; + }; + } + + const keys = Object.keys(objectMapper) as Array; + const entries = keys.map( + (key) => + [ + key, + normalizeValidationFunction( + objectMapper[key as keyof typeof objectMapper] + ), + ] as const + ); + + return (input) => { + if (input === null || typeof input !== "object") { + throw new TypeError(`Expected an object, received ${inspect(input)}`); + } + + const validationErrors = new ValidationErrors(); + + const result: any = {}; + for (const [key, parser] of entries) { + const value = input[key as keyof typeof input]; + try { + result[key] = parser ? parser(value) : value; + } catch (err) { + validationErrors.add(key, value, err as Error); + } + } + + if (validationErrors.length) throw validationErrors; + return result; + }; +} + +export type ObjectValidationMap = Record; diff --git a/packages/groq-builder/src/validation/validation-errors.ts b/packages/groq-builder/src/validation/validation-errors.ts index 4d541468..28a48b22 100644 --- a/packages/groq-builder/src/validation/validation-errors.ts +++ b/packages/groq-builder/src/validation/validation-errors.ts @@ -1,7 +1,9 @@ +import type { ZodError } from "zod"; + export type ErrorDetails = { - path: string; + path: string; // Will be overridden as errors bubble up readonly value: unknown; - readonly error: Error; + readonly message: string; }; export class ValidationErrors extends TypeError { @@ -14,13 +16,24 @@ export class ValidationErrors extends TypeError { } public add(path: string, value: unknown, error: Error) { - if (error instanceof ValidationErrors) { + if (isZodError(error)) { + this.errors.push( + ...error.errors.map((e) => ({ + path: joinPath( + path, + ...e.path.map((p) => (typeof p === "number" ? `[${p}]` : p)) + ), + value: value, + message: e.message, + })) + ); + } else if (error instanceof ValidationErrors) { error.errors.forEach((e) => { e.path = joinPath(path, e.path); }); this.errors.push(...error.errors); } else { - this.errors.push({ path, value, error }); + this.errors.push({ path, value, message: error.message }); } } @@ -29,18 +42,33 @@ export class ValidationErrors extends TypeError { } /** - * Returns a new error with an updated message (since an Error message is read-only) + * Returns the error with an updated message */ withMessage() { const l = this.errors.length; const message = `${l} Parsing Error${l === 1 ? "" : "s"}:\n${this.errors - .map((e) => `${joinPath("result", e.path)}: ${e.error.message}`) + .map((e) => `${joinPath("result", e.path)}: ${e.message}`) .join("\n")}`; - return new ValidationErrors(message, this.errors); + this.message = message; + return this; + } +} + +function joinPath(path1: string, ...paths: string[]) { + let result = path1; + for (const p of paths) { + const needsDot = result && p && !p.startsWith("["); + if (needsDot) result += "."; + result += p; } + return result; } -function joinPath(path1: string, path2: string) { - const emptyJoin = !path1 || !path2 || path2.startsWith("["); - return path1 + (emptyJoin ? "" : ".") + path2; +function isZodError(err: Error): err is ZodError { + const errZ = err as ZodError; + return ( + Array.isArray(errZ.errors) && + Array.isArray(errZ.issues) && + typeof errZ.isEmpty === "boolean" + ); } diff --git a/packages/groq-builder/src/validation/zod.test.ts b/packages/groq-builder/src/validation/zod.test.ts new file mode 100644 index 00000000..ea50d8d1 --- /dev/null +++ b/packages/groq-builder/src/validation/zod.test.ts @@ -0,0 +1,261 @@ +import { expect, describe, it, expectTypeOf } from "vitest"; +import { createGroqBuilderWithZod, InferResultType } 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"; +import { TypeMismatchError } from "../types/utils"; + +const q = createGroqBuilderWithZod(); +const qVariants = q.star.filterByType("variant"); + +describe("with zod", () => { + describe("simple projections", () => { + const qWithZod = qVariants.project({ + name: q.string(), + price: q.number(), + id: q.string().nullable(), + }); + + it("should infer the right type", () => { + expectTypeOf>().toEqualTypeOf< + Array<{ + name: string; + price: number; + id: string | null; + }> + >(); + }); + it("should execute with valid data", async () => { + const data = mock.generateSeedData({ + variants: [ + mock.variant({ name: "NAME", price: 999, id: "ID" }), + mock.variant({ name: "NAME", price: 999, id: undefined }), + ], + }); + + expect(qWithZod.query).toMatchInlineSnapshot( + '"*[_type == \\"variant\\"] { name, price, id }"' + ); + + expect(await executeBuilder(qWithZod, data.datalake)) + .toMatchInlineSnapshot(` + [ + { + "id": "ID", + "name": "NAME", + "price": 999, + }, + { + "id": null, + "name": "NAME", + "price": 999, + }, + ] + `); + }); + it("should throw with invalid data", async () => { + const data = mock.generateSeedData({ + variants: [ + mock.variant({ name: "NAME", price: undefined, id: "ID" }), + // @ts-expect-error --- + mock.variant({ name: undefined, price: 999, id: 999 }), + ], + }); + + expect(qWithZod.query).toMatchInlineSnapshot( + '"*[_type == \\"variant\\"] { name, price, id }"' + ); + + await expect(() => executeBuilder(qWithZod, data.datalake)).rejects + .toThrowErrorMatchingInlineSnapshot(` + "3 Parsing Errors: + result[0].price: Expected number, received null + result[1].name: Expected string, received null + result[1].id: Expected string, received number" + `); + }); + }); + describe("q.default helper", () => { + it('should have a type error if zod.string().default("") is used', () => { + // @ts-expect-error --- Parser expects a different input type + const qErr = qVariants.project({ + id: q.string().default("DEFAULT"), + }); + expectTypeOf>().toEqualTypeOf< + Array<{ + id: + | string + | TypeMismatchError<{ + error: "⛔️ Parser expects a different input type ⛔️"; + expected: string | undefined; + actual: null; + }>; + }> + >(); + + // @ts-expect-error --- Parser expects a different input type + const qRes = qVariants.project({ + id: q.string(), + }); + expectTypeOf>().toEqualTypeOf< + Array<{ + id: + | string + | TypeMismatchError<{ + error: "⛔️ Parser expects a different input type ⛔️"; + expected: string; + actual: null; + }>; + }> + >(); + }); + it("infers the correct type", () => { + const qNormal = qVariants.project({ id: true }); + expectTypeOf>().toEqualTypeOf< + Array<{ + id: string | null; + }> + >(); + + const query = qVariants.project({ + id: q.default(q.string(), "DEFAULT"), + }); + expectTypeOf>().toEqualTypeOf< + Array<{ + id: string; + }> + >(); + }); + }); + describe("q.nullToUndefined helper", () => { + it("optional types shouldn't normally work", () => { + // @ts-expect-error --- + const qErr = qVariants.project({ + id: q.string().optional(), + }); + expectTypeOf>().toEqualTypeOf< + Array<{ + id: + | string + | undefined + | TypeMismatchError<{ + error: "⛔️ Parser expects a different input type ⛔️"; + expected: string | undefined; + actual: null; + }>; + }> + >(); + }); + it("unless wrapped with nullToUndefined", () => { + const qValid = qVariants.project({ + id: q.nullToUndefined(q.string().optional()), + }); + expectTypeOf>().toEqualTypeOf< + Array<{ + id: string | undefined; + }> + >(); + }); + }); + describe("q.slug helper", () => { + const qVariantSlugs = qVariants.project({ + SLUG: q.slug("slug"), + }); + + it("should have the correct type", () => { + expectTypeOf>().toEqualTypeOf< + Array<{ SLUG: string }> + >(); + }); + + it("should not allow invalid fields to be slugged", () => { + qVariants.project({ + // @ts-expect-error --- + name: q.slug("name"), + // @ts-expect-error --- + INVALID: q.slug("INVALID"), + }); + }); + + describe("execution", () => { + const data = mock.generateSeedData({ + variants: [ + mock.variant({ slug: mock.slug({ current: "SLUG_1" }) }), + mock.variant({ slug: mock.slug({ current: "SLUG_2" }) }), + mock.variant({ slug: mock.slug({ current: "SLUG_3" }) }), + ], + }); + it("should retrieve all slugs", async () => { + const result = await executeBuilder(qVariantSlugs, data.datalake); + + expect(result).toEqual([ + { SLUG: "SLUG_1" }, + { SLUG: "SLUG_2" }, + { SLUG: "SLUG_3" }, + ]); + }); + it("should have errors for missing / invalid slugs", async () => { + const data = mock.generateSeedData({ + variants: [ + // @ts-expect-error --- + mock.variant({ slug: mock.slug({ current: 123 }) }), + // @ts-expect-error --- + mock.variant({ slug: mock.slug({ current: undefined }) }), + mock.variant({ slug: undefined }), + mock.variant({}), + ], + }); + + await expect(() => executeBuilder(qVariantSlugs, data.datalake)).rejects + .toThrowErrorMatchingInlineSnapshot(` + "3 Parsing Errors: + result[0].SLUG: Expected string, received number + result[1].SLUG: Expected string, received null + result[2].SLUG: Expected string, received null" + `); + }); + }); + }); + + 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<{ + id: string | null; + }>(); + + // Now, let's pick `id` with a too-narrow parser: + // @ts-expect-error --- + const qResult = qVariant.project({ id: q.string() }); + // Ensure we return an error result: + expectTypeOf>().toEqualTypeOf<{ + id: + | string + | TypeMismatchError<{ + error: "⛔️ Parser expects a different input type ⛔️"; + expected: string; + actual: null; + }>; + }>(); + }); + 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; + }> + >(); + + // Now let's use a parser that allows for string | null: + const qWideParser = qVariant.project({ + name: q.string().nullable(), + }); + expectTypeOf>().toEqualTypeOf<{ + name: string | null; + }>(); + }); + }); +}); diff --git a/packages/groq-builder/src/validation/zod.ts b/packages/groq-builder/src/validation/zod.ts new file mode 100644 index 00000000..709aea7f --- /dev/null +++ b/packages/groq-builder/src/validation/zod.ts @@ -0,0 +1,37 @@ +import { z } from "zod"; +import { ParserFunction } from "../types/public-types"; + +export const zod = { + string: z.string, + number: z.number, + boolean: z.boolean, + literal: z.literal, + union: z.union, + date: z.date, + null: z.null, + undefined: z.undefined, + array: z.array, + object: z.object, + nullToUndefined( + schema: TZodSchema + ): ParserFunction | null, z.output> { + return (input) => { + return schema.parse(input ?? undefined); + }; + }, + default>( + schema: TZodSchema, + defaultValue: z.output + ): ParserFunction< + z.input | null | undefined, + z.output + > { + return (input) => { + if (input === null || input === undefined) return defaultValue; + return schema.parse(input); + }; + }, + slug(fieldName: TFieldName) { + return [`${fieldName}.current`, z.string()] as const; + }, +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 236afdb1..36c99dc5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -82,6 +82,13 @@ importers: version: 4.9.5 packages/groq-builder: + dependencies: + type-fest: + specifier: ^4.10.1 + version: 4.10.1 + zod: + specifier: ^3.22.4 + version: 3.22.4 devDependencies: '@sanity/client': specifier: ^3.4.1 @@ -14848,6 +14855,11 @@ packages: engines: {node: '>=12.20'} dev: false + /type-fest@4.10.1: + resolution: {integrity: sha512-7ZnJYTp6uc04uYRISWtiX3DSKB/fxNQT0B5o1OUeCqiQiwF+JC9+rJiZIDrPrNCLLuTqyQmh4VdQqh/ZOkv9MQ==} + engines: {node: '>=16'} + dev: false + /type-is@1.6.18: resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} engines: {node: '>= 0.6'}