diff --git a/.changeset/brown-ducks-develop.md b/.changeset/brown-ducks-develop.md new file mode 100644 index 00000000..bfe12366 --- /dev/null +++ b/.changeset/brown-ducks-develop.md @@ -0,0 +1,8 @@ +--- +"groq-builder": minor +--- + +- Added backwards compatibility with GroqD v0.x +- Implemented validation methods like `q.string()` +- Renamed `grab -> project`, `grabOne -> field` +- Fixed build issues and deployment files diff --git a/packages/groq-builder/README.md b/packages/groq-builder/README.md index 79a1d5b3..c778ef76 100644 --- a/packages/groq-builder/README.md +++ b/packages/groq-builder/README.md @@ -1,7 +1,16 @@ +# WARNING: this package is in "beta" state; feel free to use at your own risk + +> If you're looking for a feature-complete, strongly-typed Groq utility, please use [GroqD](https://formidable.com/open-source/groqd/). +> This package aims to be a successor to GroqD, but is not yet feature-complete. Please use at your own risk. + # `groq-builder` A **schema-aware**, strongly-typed GROQ query builder. -It enables you to use **auto-completion** and **type-checking** for your GROQ queries. +It enables you to use **auto-completion** and **type-checking** for your GROQ queries. + +### In case you're wondering "What is GROQ?" +From https://www.sanity.io/docs/groq: +> "GROQ is Sanity's open-source query language. It's a powerful and intuitive language that's easy to learn. With GROQ you can describe exactly what information your application needs, join information from several sets of documents, and stitch together a very specific response with only the exact fields you need." ## Features @@ -13,23 +22,23 @@ It enables you to use **auto-completion** and **type-checking** for your GROQ qu ```ts import { createGroqBuilder } from 'groq-builder'; -import type { MySchemaConfig } from './my-schema-config'; +import type { SchemaConfig } from './schema-config'; // ☝️ Note: // Please see the "Schema Configuration" docs // for an overview of this SchemaConfig type -const q = createGroqBuilder() +const q = createGroqBuilder() const productsQuery = ( q.star .filterByType('products') .order('price desc') .slice(0, 10) - .projection(q => ({ + .project(q => ({ name: true, price: true, - slug: q.projection('slug.current'), - imageUrls: q.projection('images[]').deref().projection('url') + slug: q.field("slug.current"), + imageUrls: q.field("images[]").deref().field("url") })) ); ``` @@ -69,23 +78,24 @@ type ProductsQueryResult = Array<{ ## Optional Runtime Validation and Custom Parsing -You can add custom runtime validation and/or parsing logic into your queries, using the `parse` method. +You can add custom runtime validation and/or parsing logic into your queries, using the `validate` method. -The `parse` function accepts a simple function: +The `validate` function accepts a simple function: ```ts -const products = q.star.filterByType('products').projection(q => ({ +const products = q.star.filterByType('products').project(q => ({ name: true, price: true, - priceFormatted: q.projection("price").parse(price => formatCurrency(price)), + priceFormatted: q.field("price").validate(price => formatCurrency(price)), })); ``` It is also compatible with [Zod](https://zod.dev/), and can take any Zod parser or validation logic: ```ts -const products = q.star.filterByType('products').projection(q => ({ - name: true, - price: q.projection("price").parse(z.number().nonnegative()), +const products = q.star.filterByType('products').project(q => ({ + name: z.string(), + slug: ["slug.current", z.string().optional()], + price: q.field("price").validate(z.number().nonnegative()), })); ``` diff --git a/packages/groq-builder/docs/MIGRATION.md b/packages/groq-builder/docs/MIGRATION.md new file mode 100644 index 00000000..ca3294bc --- /dev/null +++ b/packages/groq-builder/docs/MIGRATION.md @@ -0,0 +1,159 @@ +# Migrating from GroqD v0.x to Groq-Builder v0.x + + +## Minimal Migration Example + +Migrating from `groqd` to `groq-builder` is straightforward, since there are few API changes. +Here's an example of a simple `groqd` query, and the **minimum** changes required to migrate to `groq-builder`: + +#### Before, with `groqd` + +```ts +import { q } from "groqd"; + +const productsQuery = q("*") + .filterByType("product") + .order('price asc') + .slice(0, 10) + .grab({ + name: q.string(), + price: q.number(), + slug: ["slug.current", q.string().optional()], + image: q("image").deref(), + }); +``` + +#### After, with `groq-builder` + +```ts +import { createGroqBuilderWithValidation } from "groq-builder"; +const q = createGroqBuilderWithValidation(); // Using 'any' makes the query schema-unaware + +const productsQuery = q.star + .filterByType("product") + .order('price asc') + .slice(0, 10) + .grab({ + name: q.string(), + price: q.number(), + slug: ["slug.current", q.string().optional()], + image: q.field("image").deref(), + }); +``` + +In this minimal example, we made 3 changes: +1. We created the root `q` object, binding it to a schema (or `any` to keep it schema-unaware). +2. We changed `q("*")` to `q.star` +3. We changed `q("image")` to `q.field("image")` + +Keep reading for a deeper explanation of these changes. + +## Step 1: Creating the root `q` object + +```ts +// src/queries/q.ts +import { createGroqBuilder } from 'groq-builder'; +type SchemaConfig = any; +export const q = createGroqBuilder(); +``` + +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. + + +## Step 2: Replacing the `q("...")` method + +This is the biggest API change. +With `groqd`, the root `q` was a function that allowed any Groq string to be passed. +With `groq-builder`, all queries must be chained, using the type-safe methods. + +The 2 most common changes needed will be changing all `q("*")` into `q.star`, and changing projections from `q("name")` to `q.field("name")`. + +For example: +```ts +// Before: +q("*").grab({ + imageUrl: q("image"), +}); + +// After: +q.star.grab({ + imageUrl: q.field("image"), +}) +``` + +If you do have more complex query logic inside a `q("...")` function, you should refactor to use chainable methods. +However, if you cannot refactor at this time, you can use the `raw` method instead: + +## Step 3. An escape hatch: the `raw` method + +Not all Groq queries can be strongly-typed. Sometimes you need an escape hatch; a way to write a query, and manually specify the result type. +The `raw` method does this by accepting any Groq string. It requires you to specify the result type. For example: + +```ts +q.project({ + itemCount: q.raw(`count(*[_type === "item")`) +}); +``` + +Ideally, you could refactor this to be strongly-typed, but you might use the escape hatch for unsupported features, or for difficult-to-type queries. + + +## Adding a Strongly Typed Schema + +With `GroqD v0.x`, we use Zod to define the shape of our queries, and validate this shape at runtime. + +With `groq-builder`, by [adding a strongly-typed Sanity schema](./README.md#schema-configuration), we can validate our queries at compile-time too. This makes our queries: + +- Easier to write (provides auto-complete) +- 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: +```ts +const productsQuery = q.star + .filterByType("product") + .project({ + name: true, // 👈 'true' will bypass runtime validation + price: true, // 👈 and we still get strong result types from our schema + slug: "slug.current", // 👈 a naked projection string works too! + }); +``` + +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. + + +## Additional Improvements + +### Migrating from `grab -> project` and `grabOne-> field` + +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`. + +### Validation methods + +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'; + +q.star.filterByType("user").project({ + email: z.coerce.string().email().min(5), + createdAt: z.string().datetime().optional(), +}); +``` + + diff --git a/packages/groq-builder/package.json b/packages/groq-builder/package.json index 3d5887d9..0dfd7fb8 100644 --- a/packages/groq-builder/package.json +++ b/packages/groq-builder/package.json @@ -17,13 +17,15 @@ "query", "typescript" ], - "main": "dist/index.js", + "main": "./dist/index.js", + "sideEffects": [ + "./dist/commands/**" + ], "module": "dist/index.mjs", "types": "dist/index.d.ts", "exports": { ".": [ { - "import": "./dist/index.mjs", "types": "./dist/index.d.ts", "default": "./dist/index.js" }, diff --git a/packages/groq-builder/src/commands/deref.test.ts b/packages/groq-builder/src/commands/deref.test.ts index cf03f960..fe92bb5f 100644 --- a/packages/groq-builder/src/commands/deref.test.ts +++ b/packages/groq-builder/src/commands/deref.test.ts @@ -11,9 +11,9 @@ const data = mock.generateSeedData({}); describe("deref", () => { const qProduct = q.star.filterByType("product").slice(0); - const qCategoryRef = qProduct.projection("categories[]").slice(0); + const qCategoryRef = qProduct.field("categories[]").slice(0); const qCategory = qCategoryRef.deref(); - const qVariantsRefs = qProduct.projection("variants[]"); + const qVariantsRefs = qProduct.field("variants[]"); const qVariants = qVariantsRefs.deref(); it("should deref a single item", () => { @@ -35,7 +35,7 @@ describe("deref", () => { }); it("should be an error if the item is not a reference", () => { - const notAReference = qProduct.projection("slug"); + const notAReference = qProduct.field("slug"); expectType>().toStrictEqual<{ _type: "slug"; current: string; @@ -45,15 +45,15 @@ describe("deref", () => { type ErrorResult = InferResultType; expectType< ErrorResult["error"] - >().toStrictEqual<"Expected the object to be a reference type">(); + >().toStrictEqual<"⛔️ Expected the object to be a reference type ⛔️">(); }); it("should execute correctly (single)", async () => { - const results = await executeBuilder(data.datalake, qCategory); + const results = await executeBuilder(qCategory, data.datalake); expect(results).toEqual(data.categories[0]); }); it("should execute correctly (multiple)", async () => { - const results = await executeBuilder(data.datalake, qVariants); + const results = await executeBuilder(qVariants, data.datalake); expect(results).toEqual(data.variants); }); }); diff --git a/packages/groq-builder/src/commands/deref.ts b/packages/groq-builder/src/commands/deref.ts index cde154b0..6db4fd1b 100644 --- a/packages/groq-builder/src/commands/deref.ts +++ b/packages/groq-builder/src/commands/deref.ts @@ -12,7 +12,7 @@ declare module "../groq-builder" { } GroqBuilder.implement({ - deref(this: GroqBuilder): any { + deref(this: GroqBuilder) { return this.chain("->", null); }, }); diff --git a/packages/groq-builder/src/commands/filter.ts b/packages/groq-builder/src/commands/filter.ts index 1212badf..90eb12f5 100644 --- a/packages/groq-builder/src/commands/filter.ts +++ b/packages/groq-builder/src/commands/filter.ts @@ -1,38 +1,14 @@ import { GroqBuilder } from "../groq-builder"; -import { StringKeys } from "../types/utils"; -import { ResultItem, ResultOverride } from "../types/result-types"; +import { RootConfig } from "../types/schema-types"; declare module "../groq-builder" { export interface GroqBuilder { - filterBy< - TKey extends StringKeys>, - TValue extends Extract[TKey], string> - >( - filterString: `${TKey} == "${TValue}"` - ): GroqBuilder< - ResultOverride< - TResult, - Extract, { [P in TKey]: TValue }> - >, - TRootConfig - >; - - filterByType< - TType extends Extract, { _type: string }>["_type"] - >( - type: TType - ): GroqBuilder< - ResultOverride, { _type: TType }>>, - TRootConfig - >; + filter(filterExpression: string): GroqBuilder; } } GroqBuilder.implement({ - filterBy(this: GroqBuilder, filterString) { - return this.chain(`[${filterString}]`, null); - }, - filterByType(this: GroqBuilder, type) { - return this.chain(`[_type == "${type}"]`, null); + filter(this: GroqBuilder, filterExpression) { + return this.chain(`[${filterExpression}]`, null); }, }); diff --git a/packages/groq-builder/src/commands/filter.test.ts b/packages/groq-builder/src/commands/filterByType.test.ts similarity index 54% rename from packages/groq-builder/src/commands/filter.test.ts rename to packages/groq-builder/src/commands/filterByType.test.ts index 306aba6d..f4aad4be 100644 --- a/packages/groq-builder/src/commands/filter.test.ts +++ b/packages/groq-builder/src/commands/filterByType.test.ts @@ -10,37 +10,6 @@ const q = createGroqBuilder(); const data = mock.generateSeedData({}); -describe("filterBy", () => { - const qProduct = q.star.filterBy(`_type == "product"`); - - it("types should be correct", () => { - expectType>().toStrictEqual< - Array - >(); - expectType>().not.toStrictEqual< - Array - >(); - }); - - it("invalid types should be caught", () => { - // @ts-expect-error --- - q.star.filterBy(`INVALID == "product"`); - // @ts-expect-error --- - q.star.filterBy(`_type == "INVALID"`); - }); - - it("query should be correct", () => { - expect(qProduct).toMatchObject({ - query: `*[_type == "product"]`, - }); - }); - - it("should execute correctly", async () => { - const results = await executeBuilder(data.datalake, qProduct); - expect(results).toEqual(data.products); - }); -}); - describe("filterByType", () => { const qProduct = q.star.filterByType("product"); it("types should be correct", () => { @@ -59,7 +28,7 @@ describe("filterByType", () => { }); it("should execute correctly", async () => { - const results = await executeBuilder(data.datalake, qProduct); + const results = await executeBuilder(qProduct, data.datalake); expect(results).toEqual(data.products); }); }); diff --git a/packages/groq-builder/src/commands/filterByType.ts b/packages/groq-builder/src/commands/filterByType.ts new file mode 100644 index 00000000..d31b3049 --- /dev/null +++ b/packages/groq-builder/src/commands/filterByType.ts @@ -0,0 +1,21 @@ +import { GroqBuilder } from "../groq-builder"; +import { ResultItem, ResultOverride } from "../types/result-types"; + +declare module "../groq-builder" { + export interface GroqBuilder { + filterByType< + TType extends Extract, { _type: string }>["_type"] + >( + type: TType + ): GroqBuilder< + ResultOverride, { _type: TType }>>, + TRootConfig + >; + } +} + +GroqBuilder.implement({ + filterByType(this: GroqBuilder, type) { + return this.chain(`[_type == "${type}"]`, null); + }, +}); diff --git a/packages/groq-builder/src/commands/grab-deprecated.test.ts b/packages/groq-builder/src/commands/grab-deprecated.test.ts new file mode 100644 index 00000000..98cd2707 --- /dev/null +++ b/packages/groq-builder/src/commands/grab-deprecated.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { validate } from "../validation"; +import { expectType } from "../tests/expectType"; +import { InferResultType } from "../types/public-types"; +import { createGroqBuilder } from "../index"; +import { SchemaConfig } from "../tests/schemas/nextjs-sanity-fe"; + +const q = createGroqBuilder(); +const qVariants = q.star.filterByType("variant"); + +describe("grab (backwards compatibility)", () => { + it("should be defined", () => { + expect(q.grab).toBeTypeOf("function"); + expect(q.grab$).toBeTypeOf("function"); + expect(q.grabOne).toBeTypeOf("function"); + expect(q.grabOne$).toBeTypeOf("function"); + }); + + it("should be type-safe", () => { + const qGrab = qVariants.grab((q) => ({ + name: true, + slug: "slug.current", + msrp: ["msrp", validate.number()], + styles: q.grabOne("style[]").deref().grabOne("name"), + })); + + expectType>().toStrictEqual< + Array<{ + name: string; + slug: string; + msrp: number; + styles: Array | null; + }> + >(); + }); +}); diff --git a/packages/groq-builder/src/commands/grab-deprecated.ts b/packages/groq-builder/src/commands/grab-deprecated.ts new file mode 100644 index 00000000..2c8379d0 --- /dev/null +++ b/packages/groq-builder/src/commands/grab-deprecated.ts @@ -0,0 +1,64 @@ +// Ensure these files are imported first, so we can use their implementations: +import "./project"; +import "./projectField"; +/* + * For backwards compatibility, we'll keep `grab` and `grabOne` as deprecated aliases: + */ +import { GroqBuilder } from "../groq-builder"; + +declare module "../groq-builder" { + export interface GroqBuilder { + /** + * @deprecated This method has been renamed to 'project' and will be removed in a future version. + */ + grab: GroqBuilder["project"]; + /** + * @deprecated This method has been renamed to 'project' and will be removed in a future version. + */ + grab$: GroqBuilder["project"]; + /** + * @deprecated This method has been renamed to 'field' and will be removed in a future version. + */ + grabOne: GroqBuilder["field"]; + /** + * @deprecated This method has been renamed to 'field' and will be removed in a future version. + */ + grabOne$: GroqBuilder["field"]; + } +} +GroqBuilder.implement({ + grab: deprecated(GroqBuilder.prototype.project, () => { + console.warn( + "'grab' has been renamed to 'project' and will be removed in a future version" + ); + }), + grab$: deprecated(GroqBuilder.prototype.project, () => { + console.warn( + "'grab$' has been renamed to 'project' and will be removed in a future version" + ); + }), + grabOne: deprecated(GroqBuilder.prototype.field, () => { + console.warn( + "'grabOne' has been renamed to 'field' and will be removed in a future version" + ); + }), + grabOne$: deprecated(GroqBuilder.prototype.field, () => { + console.warn( + "'grabOne$' has been renamed to 'field' and will be removed in a future version" + ); + }), +}); + +function deprecated any>( + method: TMethod, + logWarning: () => void +): TMethod { + let logOnce = logWarning as null | typeof logWarning; + return function (this: GroqBuilder, ...args) { + if (logOnce) { + logOnce(); + logOnce = null; + } + return method.apply(this, args); + } as TMethod; +} diff --git a/packages/groq-builder/src/commands/index.ts b/packages/groq-builder/src/commands/index.ts index e0285d0d..0ab3c593 100644 --- a/packages/groq-builder/src/commands/index.ts +++ b/packages/groq-builder/src/commands/index.ts @@ -1,7 +1,12 @@ import "./deref"; import "./filter"; +import "./filterByType"; +import "./grab-deprecated"; import "./order"; -import "./parse"; -import "./projection"; +import "./project"; +import "./projectField"; +import "./raw"; import "./slice"; +import "./slug"; import "./star"; +import "./validate"; diff --git a/packages/groq-builder/src/commands/order.test.ts b/packages/groq-builder/src/commands/order.test.ts index a9a26585..cd1f5808 100644 --- a/packages/groq-builder/src/commands/order.test.ts +++ b/packages/groq-builder/src/commands/order.test.ts @@ -49,12 +49,12 @@ describe("order", () => { it("should execute correctly (asc)", async () => { const qOrder = qVariants.order("price"); - const results = await executeBuilder(data.datalake, qOrder); + const results = await executeBuilder(qOrder, data.datalake); expect(results).toMatchObject(priceAsc); }); it("should execute correctly (desc)", async () => { const qOrder = qVariants.order("price desc"); - const results = await executeBuilder(data.datalake, qOrder); + const results = await executeBuilder(qOrder, data.datalake); expect(results).toMatchObject(priceDesc); }); @@ -111,7 +111,7 @@ describe("order", () => { it("should execute correctly", async () => { const qOrder = qVariants.order("msrp", "price"); - const results = await executeBuilder(data.datalake, qOrder); + const results = await executeBuilder(qOrder, data.datalake); expect(results).toMatchObject([ { name: `Variant 4`, msrp: 500, price: 96 }, { name: `Variant 3`, msrp: 500, price: 97 }, diff --git a/packages/groq-builder/src/commands/order.ts b/packages/groq-builder/src/commands/order.ts index a1461fb0..0b68ed79 100644 --- a/packages/groq-builder/src/commands/order.ts +++ b/packages/groq-builder/src/commands/order.ts @@ -4,6 +4,9 @@ import { ResultItem } from "../types/result-types"; declare module "../groq-builder" { export interface GroqBuilder { + /** + * Orders the results by the keys specified + */ order>>( ...fields: Array<`${TKeys}${"" | " asc" | " desc"}`> ): GroqBuilder; diff --git a/packages/groq-builder/src/commands/projection.test.ts b/packages/groq-builder/src/commands/project.test.ts similarity index 64% rename from packages/groq-builder/src/commands/projection.test.ts rename to packages/groq-builder/src/commands/project.test.ts index 15e64bd9..2b04c24c 100644 --- a/packages/groq-builder/src/commands/projection.test.ts +++ b/packages/groq-builder/src/commands/project.test.ts @@ -7,120 +7,12 @@ import { createGroqBuilder } from "../index"; import { mock } from "../tests/mocks/nextjs-sanity-fe-mocks"; import { executeBuilder } from "../tests/mocks/executeQuery"; import { currencyFormat } from "../tests/utils"; +import { validate } from "../validation"; const q = createGroqBuilder(); - const qVariants = q.star.filterByType("variant"); -describe("projection (naked projection)", () => { - const qPrices = qVariants.projection("price"); - const qNames = qVariants.projection("name"); - const qImages = qVariants.projection("images[]"); - const data = mock.generateSeedData({ - variants: mock.array(5, (i) => - mock.variant({ - name: `Variant ${i}`, - price: 55 + i, - msrp: 55 + i, - }) - ), - }); - - it("can project a number", () => { - expectType>().toStrictEqual< - Array - >(); - expect(qPrices.query).toMatchInlineSnapshot( - '"*[_type == \\"variant\\"].price"' - ); - }); - it("can project a string", () => { - expectType>().toStrictEqual>(); - expect(qNames.query).toMatchInlineSnapshot( - '"*[_type == \\"variant\\"].name"' - ); - }); - it("can project arrays with []", () => { - type ResultType = InferResultType; - - expectType().toStrictEqual - > | null>(); - }); - it("can chain projections", () => { - const qSlugCurrent = qVariants.projection("slug").projection("current"); - expectType>().toStrictEqual< - Array - >(); - - const qImageNames = qVariants - .slice(0) - .projection("images[]") - .projection("name"); - expectType< - InferResultType - >().toStrictEqual | null>(); - }); - - it("executes correctly (price)", async () => { - const results = await executeBuilder(data.datalake, qPrices); - expect(results).toMatchInlineSnapshot(` - [ - 55, - 56, - 57, - 58, - 59, - ] - `); - }); - it("executes correctly (name)", async () => { - const results = await executeBuilder(data.datalake, qNames); - expect(results).toMatchInlineSnapshot(` - [ - "Variant 0", - "Variant 1", - "Variant 2", - "Variant 3", - "Variant 4", - ] - `); - }); - - describe("deep properties", () => { - it("invalid entries should have TS errors", () => { - // @ts-expect-error --- - qVariants.projection("slug[]"); - // @ts-expect-error --- - qVariants.projection("slug.INVALID"); - // @ts-expect-error --- - qVariants.projection("INVALID"); - // @ts-expect-error --- - qVariants.projection("INVALID.current"); - }); - - it("can project nested properties", () => { - const qSlugs = qVariants.projection("slug.current"); - expectType>().toStrictEqual< - Array - >(); - expect(qSlugs.query).toMatchInlineSnapshot( - '"*[_type == \\"variant\\"].slug.current"' - ); - }); - - it("can project arrays with []", () => { - const qImages = qVariants.projection("images[]"); - type ResultType = InferResultType; - - expectType().toStrictEqual - > | null>(); - }); - }); -}); - -describe("projection (objects)", () => { +describe("project (object projections)", () => { const data = mock.generateSeedData({ variants: mock.array(5, (i) => mock.variant({ @@ -135,7 +27,7 @@ describe("projection (objects)", () => { describe("a single plain property", () => { it("cannot use 'true' to project unknown properties", () => { - const qInvalid = qVariants.projection({ + const qInvalid = qVariants.project({ INVALID: true, }); @@ -150,7 +42,7 @@ describe("projection (objects)", () => { >(); }); - const qName = qVariants.projection({ + const qName = qVariants.project({ name: true, }); it("query should be typed correctly", () => { @@ -166,7 +58,7 @@ describe("projection (objects)", () => { }); it("should execute correctly", async () => { - const results = await executeBuilder(data.datalake, qName); + const results = await executeBuilder(qName, data.datalake); expect(results).toMatchInlineSnapshot(` [ { @@ -190,7 +82,7 @@ describe("projection (objects)", () => { }); describe("multiple plain properties", () => { - const qMultipleFields = qVariants.projection({ + const qMultipleFields = qVariants.project({ id: true, name: true, price: true, @@ -212,7 +104,7 @@ describe("projection (objects)", () => { }); it("should execute correctly", async () => { - const results = await executeBuilder(data.datalake, qMultipleFields); + const results = await executeBuilder(qMultipleFields, data.datalake); expect(results).toMatchInlineSnapshot(` [ { @@ -251,7 +143,7 @@ describe("projection (objects)", () => { }); describe("a projection with naked projections", () => { - const qNakedProjections = qVariants.projection({ + const qNakedProjections = qVariants.project({ NAME: "name", SLUG: "slug.current", msrp: "msrp", @@ -259,11 +151,11 @@ describe("projection (objects)", () => { it("invalid projections should have type errors", () => { // @ts-expect-error --- - qVariants.projection({ NAME: "INVALID" }); + qVariants.project({ FIELD: "INVALID" }); // @ts-expect-error --- - qVariants.projection({ NAME: "slug.INVALID" }); + qVariants.project({ FIELD: "slug.INVALID" }); // @ts-expect-error --- - qVariants.projection({ NAME: "INVALID.current" }); + qVariants.project({ FIELD: "INVALID.current" }); }); it("query should be correct", () => { @@ -283,9 +175,42 @@ describe("projection (objects)", () => { }); }); - describe("a single complex projection", () => { - const qComplex = qVariants.projection((q) => ({ - NAME: q.projection("name"), + describe("a projection with naked, validated projections", () => { + const qNakedProjections = qVariants.project({ + NAME: ["name", validate.string()], + SLUG: ["slug.current", validate.string()], + msrp: ["msrp", validate.number()], + }); + + it("invalid projections should have type errors", () => { + // @ts-expect-error --- + qVariants.project({ NAME: ["INVALID", validate.number()] }); + // @ts-expect-error --- + qVariants.project({ NAME: ["slug.INVALID", validate.string()] }); + // @ts-expect-error --- + qVariants.project({ NAME: ["INVALID.current", validate.string()] }); + }); + + it("query should be correct", () => { + expect(qNakedProjections.query).toMatchInlineSnapshot( + '"*[_type == \\"variant\\"] { \\"NAME\\": name, \\"SLUG\\": slug.current, msrp }"' + ); + }); + + it("types should be correct", () => { + expectType>().toStrictEqual< + Array<{ + NAME: string; + SLUG: string; + msrp: number; + }> + >(); + }); + }); + + describe("a single complex project", () => { + const qComplex = qVariants.project((q) => ({ + NAME: q.field("name"), })); it("query should be correct", () => { @@ -303,7 +228,7 @@ describe("projection (objects)", () => { }); it("should execute correctly", async () => { - const results = await executeBuilder(data.datalake, qComplex); + const results = await executeBuilder(qComplex, data.datalake); expect(results).toMatchInlineSnapshot(` [ { @@ -327,10 +252,10 @@ describe("projection (objects)", () => { }); describe("multiple complex projections", () => { - const qComplex = qVariants.projection((q) => ({ - name: q.projection("name"), - slug: q.projection("slug").projection("current"), - images: q.projection("images[]").projection("name"), + const qComplex = qVariants.project((q) => ({ + name: q.field("name"), + slug: q.field("slug").field("current"), + images: q.field("images[]").field("name"), })); it("query should be correct", () => { @@ -350,7 +275,7 @@ describe("projection (objects)", () => { }); it("should execute correctly", async () => { - const results = await executeBuilder(data.datalake, qComplex); + const results = await executeBuilder(qComplex, data.datalake); expect(results).toMatchInlineSnapshot(` [ { @@ -383,12 +308,94 @@ describe("projection (objects)", () => { }); }); + describe("nested projections", () => { + const { datalake: dataWithImages } = mock.generateSeedData({ + variants: [ + mock.variant({ images: [mock.keyed(mock.image({}))] }), + mock.variant({ images: [mock.keyed(mock.image({}))] }), + ], + }); + const qNested = qVariants.project((variant) => ({ + name: variant.field("name"), + images: variant.field("images[]").project((image) => ({ + name: true, + description: image + .field("description") + .validate(validate.string().optional()), + })), + })); + + it("query should be correct", () => { + expect(qNested.query).toMatchInlineSnapshot( + '"*[_type == \\"variant\\"] { name, \\"images\\": images[] { name, description } }"' + ); + }); + + it("types should be correct", () => { + expectType>().toStrictEqual< + Array<{ + name: string; + images: Array<{ + name: string; + description: string | undefined | null; + }> | null; + }> + >(); + }); + + it("should execute correctly", async () => { + const results = await executeBuilder(qNested, dataWithImages); + expect(results).toMatchInlineSnapshot(` + [ + { + "images": [ + { + "description": "Product Image", + "name": "ProductImage", + }, + ], + "name": "Variant Name", + }, + { + "images": [ + { + "description": "Product Image", + "name": "ProductImage", + }, + ], + "name": "Variant Name", + }, + ] + `); + }); + + it("nested objects should be validated", async () => { + const dataWithInvalidData = [ + mock.variant({ + images: [ + mock.keyed( + mock.image({ + // @ts-expect-error --- + description: 1234, + }) + ), + ], + }), + ]; + await expect(() => executeBuilder(qNested, dataWithInvalidData)).rejects + .toThrowErrorMatchingInlineSnapshot(` + "1 Parsing Error: + result[0].images[0].description: Expected string, received 1234" + `); + }); + }); + describe("mixed projections", () => { - const qComplex = qVariants.projection((q) => ({ + const qComplex = qVariants.project((q) => ({ name: true, - slug: q.projection("slug").projection("current"), + slug: q.field("slug").field("current"), price: true, - IMAGES: q.projection("images[]").projection("name"), + IMAGES: q.field("images[]").field("name"), })); it("query should be correct", () => { @@ -409,7 +416,7 @@ describe("projection (objects)", () => { }); it("should execute correctly", async () => { - const results = await executeBuilder(data.datalake, qComplex); + const results = await executeBuilder(qComplex, data.datalake); expect(results).toMatchInlineSnapshot(` [ { @@ -447,11 +454,11 @@ describe("projection (objects)", () => { }); }); - describe("parser", () => { - const qParser = qVariants.projection((q) => ({ + describe("validate", () => { + const qParser = qVariants.project((q) => ({ name: true, - msrp: q.projection("msrp").parse((msrp) => currencyFormat(msrp)), - price: q.projection("price"), + msrp: q.field("msrp").validate((msrp) => currencyFormat(msrp)), + price: q.field("price").validate(validate.number()), })); it("the types should match", () => { @@ -469,7 +476,7 @@ describe("projection (objects)", () => { ); }); it("should execute correctly", async () => { - const results = await executeBuilder(data.datalake, qParser); + const results = await executeBuilder(qParser, data.datalake); expect(results).toMatchInlineSnapshot(` [ { @@ -500,12 +507,28 @@ describe("projection (objects)", () => { ] `); }); + + it("should throw when the data doesn't match", async () => { + const invalidData = [ + ...data.datalake, + mock.variant({ + // @ts-expect-error --- + price: "INVALID", + }), + ]; + + await expect(() => executeBuilder(qParser, invalidData)).rejects + .toThrowErrorMatchingInlineSnapshot(` + "1 Parsing Error: + result[5].price: Expected number, received \\"INVALID\\"" + `); + }); }); describe("ellipsis ... operator", () => { - const qEllipsis = qVariants.projection((q) => ({ + const qEllipsis = qVariants.project((q) => ({ "...": true, - OTHER: q.projection("name"), + OTHER: q.field("name"), })); it("query should be correct", () => { expect(qEllipsis.query).toMatchInlineSnapshot( @@ -520,7 +543,7 @@ describe("projection (objects)", () => { }); it("should execute correctly", async () => { - const results = await executeBuilder(data.datalake, qEllipsis); + const results = await executeBuilder(qEllipsis, data.datalake); expect(results).toEqual( data.variants.map((v) => { // @ts-expect-error --- diff --git a/packages/groq-builder/src/commands/project.ts b/packages/groq-builder/src/commands/project.ts new file mode 100644 index 00000000..a8c898b8 --- /dev/null +++ b/packages/groq-builder/src/commands/project.ts @@ -0,0 +1,141 @@ +import { 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 { ValidationErrors } from "../validation/validation-errors"; +import { ExtractProjectionResult, ProjectionMap } from "./projection-types"; + +declare module "../groq-builder" { + export interface GroqBuilder { + /** + * Performs an "object projection", returning an object with the fields specified. + * @param projectionMap + */ + project>>( + projectionMap: + | TProjection + | ((q: GroqBuilder, TRootConfig>) => TProjection) + ): GroqBuilder< + ResultOverride< + TResult, + Simplify, TProjection>> + >, + TRootConfig + >; + } +} + +GroqBuilder.implement({ + project( + this: GroqBuilder, + projectionMapArg: object | ((q: GroqBuilder) => object) + ): GroqBuilder { + // Make the query pretty, if needed: + const indent = this.internal.options.indent; + const indent2 = indent ? indent + " " : ""; + + // Retrieve the projectionMap: + let projectionMap: object; + if (typeof projectionMapArg === "function") { + const newQ = new GroqBuilder({ + query: "", + parser: null, + options: { + ...this.internal.options, + indent: indent2, + }, + }); + projectionMap = projectionMapArg(newQ); + } else { + projectionMap = projectionMapArg; + } + + // Analyze all the projection values: + const keys = Object.keys(projectionMap) as Array; + const values = keys + .map((key) => { + const value: unknown = projectionMap[key as keyof typeof projectionMap]; + if (value instanceof GroqBuilder) { + const query = key === value.query ? key : `"${key}": ${value.query}`; + return { key, query, parser: value.internal.parser }; + } else if (typeof value === "string") { + const query = key === value ? key : `"${key}": ${value}`; + return { key, query, parser: null }; + } else if (typeof value === "boolean") { + if (value === false) return null; // 'false' will be excluded from the results + return { key, query: key, parser: null }; + } else if (Array.isArray(value)) { + const [projectionKey, parser] = value as [string, Parser]; + const query = + key === projectionKey ? key : `"${key}": ${projectionKey}`; + + return { + key, + query, + parser: normalizeValidationFunction(parser), + }; + } else if (isParser(value)) { + return { + key, + query: key, + parser: normalizeValidationFunction(value), + }; + } else { + throw new Error( + `Unexpected value for projection key "${key}": "${typeof value}"` + ); + } + }) + .filter(notNull); + + const queries = values.map((v) => v.query); + const newLine = indent ? "\n" : " "; + const newQuery = ` {${newLine}${indent2}${queries.join( + "," + newLine + indent2 + )}${newLine}${indent}}`; + + type TResult = Record; + const parsers = values.filter((v) => v.parser); + const newParser = !parsers.length + ? null + : function projectionParser(input: TResult) { + const isArray = Array.isArray(input); + const items = isArray ? input : [input]; + const validationErrors = new ValidationErrors(); + + const parsedResults = items.map((item, i) => { + const parsedResult = { ...item }; + + for (const { key, parser } of parsers) { + const value = item[key]; + try { + const parsedValue = parser!(value); + parsedResult[key] = parsedValue; + } catch (err) { + const path = isArray ? `[${i}].${key}` : key; + validationErrors.add(path, value, err as Error); + } + } + + return parsedResult; + }); + + if (validationErrors.length) { + throw validationErrors; + } + + return isArray ? parsedResults : parsedResults[0]; + }; + + return this.chain(newQuery, newParser); + }, +}); + +function notNull(value: T | null): value is T { + return !!value; +} diff --git a/packages/groq-builder/src/commands/projectField.test.ts b/packages/groq-builder/src/commands/projectField.test.ts new file mode 100644 index 00000000..2dc376e3 --- /dev/null +++ b/packages/groq-builder/src/commands/projectField.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, 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"; + +const q = createGroqBuilder(); +const qVariants = q.star.filterByType("variant"); + +describe("field (naked projections)", () => { + const qPrices = qVariants.field("price"); + const qNames = qVariants.field("name"); + const qImages = qVariants.field("images[]"); + const data = mock.generateSeedData({ + variants: mock.array(5, (i) => + mock.variant({ + name: `Variant ${i}`, + price: 55 + i, + msrp: 55 + i, + }) + ), + }); + + it("can project a number", () => { + expectType>().toStrictEqual< + Array + >(); + expect(qPrices.query).toMatchInlineSnapshot( + '"*[_type == \\"variant\\"].price"' + ); + }); + it("can project a string", () => { + expectType>().toStrictEqual>(); + expect(qNames.query).toMatchInlineSnapshot( + '"*[_type == \\"variant\\"].name"' + ); + }); + it("can project arrays with []", () => { + type ResultType = InferResultType; + + expectType().toStrictEqual + > | null>(); + }); + it("can chain projections", () => { + const qSlugCurrent = qVariants.field("slug").field("current"); + expectType>().toStrictEqual< + Array + >(); + + const qImageNames = qVariants.slice(0).field("images[]").field("name"); + expectType< + InferResultType + >().toStrictEqual | null>(); + }); + + it("executes correctly (price)", async () => { + const results = await executeBuilder(qPrices, data.datalake); + expect(results).toMatchInlineSnapshot(` + [ + 55, + 56, + 57, + 58, + 59, + ] + `); + }); + it("executes correctly (name)", async () => { + const results = await executeBuilder(qNames, data.datalake); + expect(results).toMatchInlineSnapshot(` + [ + "Variant 0", + "Variant 1", + "Variant 2", + "Variant 3", + "Variant 4", + ] + `); + }); + + describe("deep properties", () => { + it("invalid entries should have TS errors", () => { + // @ts-expect-error --- + qVariants.field("slug[]"); + // @ts-expect-error --- + qVariants.field("slug.INVALID"); + // @ts-expect-error --- + qVariants.field("INVALID"); + // @ts-expect-error --- + qVariants.field("INVALID.current"); + }); + + it("can project nested properties", () => { + const qSlugs = qVariants.field("slug.current"); + expectType>().toStrictEqual< + Array + >(); + expect(qSlugs.query).toMatchInlineSnapshot( + '"*[_type == \\"variant\\"].slug.current"' + ); + }); + + it("can project arrays with []", () => { + const qImages = qVariants.field("images[]"); + type ResultType = InferResultType; + + expectType().toStrictEqual + > | null>(); + }); + }); +}); diff --git a/packages/groq-builder/src/commands/projectField.ts b/packages/groq-builder/src/commands/projectField.ts new file mode 100644 index 00000000..9ec87973 --- /dev/null +++ b/packages/groq-builder/src/commands/projectField.ts @@ -0,0 +1,35 @@ +import { GroqBuilder } from "../groq-builder"; +import { ResultItem, ResultOverride } from "../types/result-types"; +import { ProjectionKey, ProjectionKeyValue } from "./projection-types"; + +declare module "../groq-builder" { + export interface GroqBuilder { + /** + * Performs a "naked projection", returning just the values of the field specified. + * @param fieldName + */ + field>>( + fieldName: TProjectionKey + ): GroqBuilder< + ResultOverride< + TResult, + ProjectionKeyValue, TProjectionKey> + >, + TRootConfig + >; + + /** @deprecated Please use the 'field' method for naked projections */ + projectField: never; + /** @deprecated Please use the 'field' method for naked projections */ + projectNaked: never; + } +} + +GroqBuilder.implement({ + field(this: GroqBuilder, fieldName: string) { + if (this.internal.query) { + fieldName = "." + fieldName; + } + return this.chain(fieldName, null); + }, +}); diff --git a/packages/groq-builder/src/commands/projection-types.test.ts b/packages/groq-builder/src/commands/projection-types.test.ts new file mode 100644 index 00000000..48cc2486 --- /dev/null +++ b/packages/groq-builder/src/commands/projection-types.test.ts @@ -0,0 +1,103 @@ +import { describe, it } from "vitest"; +import { ProjectionKey, ProjectionKeyValue } from "./projection-types"; +import { expectType } from "../tests/expectType"; + +describe("projection-types", () => { + describe("Projection Keys (naked projections)", () => { + type Item = { + str: string; + num?: number; + arr: Array; + nested: { + str?: string; + bool: true; + arr: Array; + }; + optional?: { + str: string; + }; + }; + type Keys = ProjectionKey; + + describe("ProjectionKey", () => { + it("should extract simple types", () => { + expectType>().toStrictEqual< + "str" | "num" + >(); + }); + it("should extract nested types", () => { + expectType< + ProjectionKey<{ str: string; nested: { num: number; bool: boolean } }> + >().toStrictEqual<"str" | "nested" | "nested.num" | "nested.bool">(); + }); + it("should extract arrays", () => { + expectType }>>().toStrictEqual< + "arr" | "arr[]" + >(); + }); + it("should extract nested arrays", () => { + type Keys = ProjectionKey<{ + nested: { arr: Array }; + }>; + expectType().toStrictEqual< + "nested" | "nested.arr" | "nested.arr[]" + >(); + }); + it("should extract nested optional props", () => { + type Keys = ProjectionKey<{ + nested?: { num: number }; + }>; + expectType().toStrictEqual<"nested" | "nested.num">(); + }); + + it("should extract all the deeply nested types", () => { + expectType().toStrictEqual< + | "str" + | "num" + | "arr" + | "arr[]" + | "nested" + | "nested.str" + | "nested.bool" + | "nested.arr" + | "nested.arr[]" + | "optional" + | "optional.str" + >(); + }); + }); + + describe("ProjectionKeyValue", () => { + it("should extract the correct types for each projection", () => { + expectType>().toStrictEqual(); + expectType>().toStrictEqual< + number | undefined + >(); + expectType>().toStrictEqual< + Array + >(); + expectType>().toStrictEqual< + Array + >(); + expectType>().toStrictEqual< + Item["nested"] + >(); + expectType>().toStrictEqual< + string | undefined + >(); + expectType< + ProjectionKeyValue + >().toStrictEqual(); + expectType>().toStrictEqual< + Array + >(); + expectType>().toStrictEqual< + Array + >(); + // expectType>().toStrictEqual< + // string | undefined + // >(); + }); + }); + }); +}); diff --git a/packages/groq-builder/src/commands/projection-types.ts b/packages/groq-builder/src/commands/projection-types.ts new file mode 100644 index 00000000..2df6e5d4 --- /dev/null +++ b/packages/groq-builder/src/commands/projection-types.ts @@ -0,0 +1,118 @@ +import { GroqBuilder } from "../groq-builder"; +import { + Simplify, + SimplifyDeep, + StringKeys, + TypeMismatchError, + ValueOf, +} from "../types/utils"; +import { Parser } from "../types/public-types"; +import { Path, PathEntries, PathValue } from "../types/path-types"; +import { DeepRequired } from "../types/deep-required"; + +export type ProjectionKey = ProjectionKeyImpl< + Simplify>> +>; +type ProjectionKeyImpl = ValueOf<{ + [Key in keyof Entries]: Entries[Key] extends Array + ? `${StringKeys}[]` | Key + : Key; +}>; + +export type ProjectionKeyValue = 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; +} & { + // Obviously this allows the ellipsis operator: + "..."?: true; +}; + +type ProjectionFieldConfig = + // Use 'true' to include a field as-is + | true + // 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 + // Use a tuple for naked projections with a parser + | [ProjectionKey, Parser] + // Use a GroqBuilder instance to create a nested projection + | GroqBuilder; + +export type ExtractProjectionResult = + TProjectionMap extends { + "...": true; + } + ? TResult & + ExtractProjectionResultImpl> + : ExtractProjectionResultImpl; + +type ExtractProjectionResultImpl = { + [P in keyof TProjectionMap]: TProjectionMap[P] extends GroqBuilder< + infer TValue, + any + > // Extract type from GroqBuilder: + ? TValue + : /* Extract type from 'true': */ + TProjectionMap[P] extends boolean + ? P extends keyof TResult + ? TResult[P] + : TypeMismatchError<{ + error: `⛔️ 'true' can only be used for known properties ⛔️`; + expected: keyof TResult; + actual: P; + }> + : /* Extract type from a ProjectionKey string, like 'slug.current': */ + TProjectionMap[P] extends string + ? TProjectionMap[P] extends ProjectionKey + ? ProjectionKeyValue + : TypeMismatchError<{ + error: `⛔️ Naked projections must be known properties ⛔️`; + expected: SimplifyDeep>; + actual: TProjectionMap[P]; + }> + : /* Extract type from a [ProjectionKey, Parser] tuple, like ['slug.current', q.string() ] */ + TProjectionMap[P] extends [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; + }> + : TypeMismatchError<{ + error: `⛔️ Naked projections must be known properties ⛔️`; + expected: SimplifyDeep>; + actual: TKey; + }> + : TypeMismatchError<{ + error: `⛔️ Naked projections must be known properties ⛔️`; + expected: SimplifyDeep>; + actual: TKey; + }> + : /* Extract type from Parser: */ + TProjectionMap[P] extends Parser + ? P extends keyof TResult + ? TInput extends TResult[P] + ? TOutput + : TypeMismatchError<{ + error: `⛔️ Parser expects a different input type ⛔️`; + expected: TResult[P]; + actual: TInput; + }> + : TypeMismatchError<{ + error: `⛔️ Parser can only be used with known properties ⛔️`; + expected: keyof TResult; + actual: P; + }> + : never; +}; diff --git a/packages/groq-builder/src/commands/projection.ts b/packages/groq-builder/src/commands/projection.ts deleted file mode 100644 index 1a104896..00000000 --- a/packages/groq-builder/src/commands/projection.ts +++ /dev/null @@ -1,214 +0,0 @@ -import { Simplify, SimplifyDeep, TypeMismatchError } from "../types/utils"; -import { GroqBuilder } from "../groq-builder"; -import { ParserFunction, ParserObject } from "../types/public-types"; -import { getParserFunction, isParser } from "./parseUtils"; -import { Path, PathEntries, PathValue } from "../types/path-types"; -import { DeepRequired } from "../types/deep-required"; -import { ResultItem, ResultOverride } from "../types/result-types"; - -declare module "../groq-builder" { - export interface GroqBuilder { - projection>>( - fieldName: TProjectionKey - ): GroqBuilder< - ResultOverride< - TResult, - ProjectionKeyValue, TProjectionKey> - >, - TRootConfig - >; - - projection< - TProjection extends { - // This allows TypeScript to suggest known keys: - [P in keyof ResultItem]?: ProjectionFieldConfig; - } & { - // This allows any keys to be used in a projection: - [P in string]: ProjectionFieldConfig; - } & { - // Obviously this allows the ellipsis operator: - "..."?: true; - } - >( - projectionMap: - | TProjection - | ((q: GroqBuilder, TRootConfig>) => TProjection) - ): GroqBuilder< - ResultOverride< - TResult, - Simplify, TProjection>> - >, - TRootConfig - >; - } -} - -export type ProjectionKey = Simplify< - | Path> - | `${PathsWithArrays>}[]` ->; - -type ProjectionKeyValue = PathValue< - TResultItem, - Extract> ->; - -/** - * Finds all paths that contain arrays - */ -type PathsWithArrays = Extract< - PathEntries, - [any, Array] ->[0]; - -/* eslint-disable @typescript-eslint/no-explicit-any */ -type ProjectionFieldConfig = - // Use 'true' to include a field as-is - | true - // Use a string for naked projections, like 'slug.current' - | ProjectionKey> - // | string - // Use a parser to include a field, passing it through the parser at run-time - | ParserObject - // Use a GroqBuilder instance to create a nested projection - | GroqBuilder; - -type ExtractProjectionResult = TProjection extends { - "...": true; -} - ? TResult & ExtractProjectionResultImpl> - : ExtractProjectionResultImpl; - -type ExtractProjectionResultImpl = { - [P in keyof TProjection]: TProjection[P] extends GroqBuilder< - infer TValue, - any - > // Extract type from GroqBuilder: - ? TValue - : /* Extract type from 'true': */ - TProjection[P] extends boolean - ? P extends keyof TResult - ? TResult[P] - : TypeMismatchError<{ - error: `⛔️ 'true' can only be used for known properties ⛔️`; - expected: keyof TResult; - actual: P; - }> - : /* Extract type from a ProjectionKey string, like 'slug.current': */ - TProjection[P] extends string - ? TProjection[P] extends ProjectionKey - ? ProjectionKeyValue - : TypeMismatchError<{ - error: `⛔️ Naked projections must be known properties ⛔️`; - expected: SimplifyDeep>; - actual: TProjection[P]; - }> - : /* Extract type from ParserObject: */ - TProjection[P] extends ParserObject - ? P extends keyof TResult - ? TInput extends TResult[P] - ? TOutput - : TypeMismatchError<{ - error: `⛔️ Parser expects a different input type ⛔️`; - expected: TResult[P]; - actual: TInput; - }> - : TypeMismatchError<{ - error: `⛔️ Parser can only be used with known properties ⛔️`; - expected: keyof TResult; - actual: P; - }> - : never; -}; - -GroqBuilder.implement({ - projection( - this: GroqBuilder, - arg: string | object | ((q: GroqBuilder) => object) - ): GroqBuilder { - if (typeof arg === "string") { - let nakedProjection = arg; - if (this.internal.query) { - nakedProjection = "." + arg; - } - return this.chain(nakedProjection, null); - } - - const indent = this.internal.options.indent; - const indent2 = indent ? indent + " " : ""; - - // Retrieve the projectionMap: - let projectionMap: object; - if (typeof arg === "function") { - const newQ = new GroqBuilder({ - query: "", - parser: null, - options: { - ...this.internal.options, - indent: indent2, - }, - }); - projectionMap = arg(newQ); - } else { - projectionMap = arg; - } - - // Analyze all the projection values: - const keys = Object.keys(projectionMap) as Array; - const values = keys - .map((key) => { - const value: unknown = projectionMap[key as keyof typeof projectionMap]; - if (value instanceof GroqBuilder) { - const query = key === value.query ? key : `"${key}": ${value.query}`; - return { key, query, parser: value.internal.parser }; - } else if (typeof value === "string") { - const query = key === value ? value : `"${key}": ${value}`; - return { key, query, parser: null }; - } else if (typeof value === "boolean") { - if (value === false) return null; // 'false' will be excluded from the results - return { key, query: key, parser: null }; - } else if (isParser(value)) { - return { key, query: key, parser: getParserFunction(value) }; - } else { - throw new Error( - `Unexpected value for projection key "${key}": "${typeof value}"` - ); - } - }) - .filter(notNull); - - const queries = values.map((v) => v.query); - const newLine = indent ? "\n" : " "; - const newQuery = ` {${newLine}${indent2}${queries.join( - "," + newLine + indent2 - )}${newLine}${indent}}`; - - type TResult = Record; - const parsers = values.filter((v) => v.parser); - const newParser = !parsers.length - ? null - : function projectionParser(input: TResult) { - const items = Array.isArray(input) ? input : [input]; - const parsedResults = items.map((item) => { - const parsedResult = { ...item }; - parsers.forEach(({ key, parser }) => { - const value = item[key]; - const parsedValue = parser!(value); - parsedResult[key] = parsedValue; - }); - return parsedResult; - }); - return Array.isArray(items) ? parsedResults : parsedResults[0]; - }; - - return this.chain(newQuery, newParser); - }, -}); - -function notNull(value: T | null): value is T { - return !!value; -} diff --git a/packages/groq-builder/src/commands/raw.test.ts b/packages/groq-builder/src/commands/raw.test.ts new file mode 100644 index 00000000..6243f2de --- /dev/null +++ b/packages/groq-builder/src/commands/raw.test.ts @@ -0,0 +1,52 @@ +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(); + +describe("raw", () => { + const qVariants = q.star.slice(0, 2); + const qRaw = + qVariants.raw>(`{ ANYTHING }`); + const data = mock.generateSeedData({}); + + it("should be typed correctly", () => { + expectType>().toStrictEqual< + Array<{ ANYTHING: string | null }> + >(); + }); + it("should append the query correctly", () => { + expect(qRaw.query).toMatchInlineSnapshot('"*[0...2]{ ANYTHING }"'); + }); + + it("should execute correctly", async () => { + const result = await executeBuilder(qRaw, data.datalake); + expect(result).toMatchInlineSnapshot(` + [ + { + "ANYTHING": null, + }, + { + "ANYTHING": null, + }, + ] + `); + }); + + it("should allow totally invalid syntax", () => { + const qInvalid = q.raw<{ NEVER: "gonna" }>( + `give you up, never gonna let you down` + ); + expectType>().toStrictEqual<{ + NEVER: "gonna"; + }>(); + + expect(qInvalid.query).toMatchInlineSnapshot( + '"give you up, never gonna let you down"' + ); + }); +}); diff --git a/packages/groq-builder/src/commands/raw.ts b/packages/groq-builder/src/commands/raw.ts index 31cf710b..9ac7b34b 100644 --- a/packages/groq-builder/src/commands/raw.ts +++ b/packages/groq-builder/src/commands/raw.ts @@ -1,20 +1,23 @@ import { GroqBuilder } from "../groq-builder"; -import { ResultTypeInfer } from "../types/result-types"; +import { Parser } from "../types/public-types"; declare module "../groq-builder" { // eslint-disable-next-line @typescript-eslint/no-unused-vars export interface GroqBuilder { /** - * Adds a raw string to the query + * An "escape hatch" allowing you to write any groq query you want. + * You must specify a type parameter for the new results. + * + * This should only be used for unsupported features, since it bypasses all strongly-typed inputs. */ - raw( - groq: string - ): GroqBuilder, TRootConfig>; + raw( + query: string, + parser?: Parser | null + ): GroqBuilder; } } - GroqBuilder.implement({ - raw(this: GroqBuilder, groq: string) { - return this.chain(groq); + raw(this: GroqBuilder, query, parser) { + return this.chain(query, parser); }, }); diff --git a/packages/groq-builder/src/commands/slice.test.ts b/packages/groq-builder/src/commands/slice.test.ts index ab9782fc..8b8f2f46 100644 --- a/packages/groq-builder/src/commands/slice.test.ts +++ b/packages/groq-builder/src/commands/slice.test.ts @@ -12,7 +12,7 @@ describe("slice", () => { const qVariants = q.star.filterByType("variant"); const data = mock.generateSeedData({}); beforeAll(async function checkRootQuery() { - const results = await executeBuilder(data.datalake, qVariants); + const results = await executeBuilder(qVariants, data.datalake); expect(results).toStrictEqual(data.variants); }); @@ -29,7 +29,7 @@ describe("slice", () => { }); }); it("should execute correctly", async () => { - const results = await executeBuilder(data.datalake, qSlice0); + const results = await executeBuilder(qSlice0, data.datalake); expect(results).toMatchObject(data.variants[0]); }); }); @@ -56,7 +56,7 @@ describe("slice", () => { }); it("should execute correctly", async () => { const qSlice = qVariants.slice(5, 7); - const results = await executeBuilder(data.datalake, qSlice); + const results = await executeBuilder(qSlice, data.datalake); expect(results).toMatchObject([ // Triple-dots is exclusive data.variants[5], diff --git a/packages/groq-builder/src/commands/slug.test.ts b/packages/groq-builder/src/commands/slug.test.ts new file mode 100644 index 00000000..d6d707ae --- /dev/null +++ b/packages/groq-builder/src/commands/slug.test.ts @@ -0,0 +1,70 @@ +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 new file mode 100644 index 00000000..e334e0df --- /dev/null +++ b/packages/groq-builder/src/commands/slug.ts @@ -0,0 +1,31 @@ +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 410b0d27..84a330b6 100644 --- a/packages/groq-builder/src/commands/star.test.ts +++ b/packages/groq-builder/src/commands/star.test.ts @@ -24,11 +24,11 @@ describe("star", () => { describe("execution", () => { it("should retrieve all documents", async () => { - const { datalake } = mock.generateSeedData({}); - const result = await executeBuilder(datalake, q.star); + const data = mock.generateSeedData({}); + const result = await executeBuilder(q.star, data.datalake); // I mean, this should be sufficient, right? - expect(result).toEqual(datalake); + expect(result).toEqual(data.datalake); }); }); }); diff --git a/packages/groq-builder/src/commands/parseUtils.ts b/packages/groq-builder/src/commands/validate-utils.ts similarity index 83% rename from packages/groq-builder/src/commands/parseUtils.ts rename to packages/groq-builder/src/commands/validate-utils.ts index 4a80baf4..c18e2fe4 100644 --- a/packages/groq-builder/src/commands/parseUtils.ts +++ b/packages/groq-builder/src/commands/validate-utils.ts @@ -31,11 +31,13 @@ export function isParserObject( ); } -export function getParserFunction(parser: Parser): ParserFunction { +export function normalizeValidationFunction( + parser: Parser | null +): ParserFunction | null { + if (parser === null || typeof parser === "function") return parser; if (isParserObject(parser)) { return (input) => parser.parse(input); } - if (typeof parser === "function") return parser; throw new TypeError(`Parser must be a function or an object`); } diff --git a/packages/groq-builder/src/commands/parse.test.ts b/packages/groq-builder/src/commands/validate.test.ts similarity index 58% rename from packages/groq-builder/src/commands/parse.test.ts rename to packages/groq-builder/src/commands/validate.test.ts index 9340de63..4f40c86a 100644 --- a/packages/groq-builder/src/commands/parse.test.ts +++ b/packages/groq-builder/src/commands/validate.test.ts @@ -1,9 +1,10 @@ import { describe, it, expect } from "vitest"; import { SchemaConfig } from "../tests/schemas/nextjs-sanity-fe"; -import { createGroqBuilder } from "../index"; +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"; const q = createGroqBuilder(); const qVariants = q.star.filterByType("variant"); @@ -12,29 +13,34 @@ describe("parse", () => { const data = mock.generateSeedData({ variants: [mock.variant({ price: 99 })], }); - const qPrice = qVariants.slice(0).projection("price"); + const qPrice = qVariants.slice(0).field("price"); describe("parser function", () => { - const qPriceParse = qPrice.parse((p) => currencyFormat(p)); + const qPriceParse = qPrice.validate((p) => currencyFormat(p)); it("shouldn't affect the query at all", () => { expect(qPriceParse.query).toEqual(qPrice.query); }); it("should parse the data", async () => { - const result = await executeBuilder(data.datalake, qPriceParse); + const result = await executeBuilder(qPriceParse, data.datalake); expect(result).toMatchInlineSnapshot('"$99.00"'); }); + + it("should map types correctly", () => { + expectType>().toStrictEqual(); + expectType>().toStrictEqual(); + }); }); - describe("zod-like parser object", () => { - const qPriceParse = qPrice.parse({ parse: (p) => currencyFormat(p) }); + describe("Zod-like parser object", () => { + const qPriceParse = qPrice.validate({ parse: (p) => currencyFormat(p) }); it("shouldn't affect the query at all", () => { expect(qPriceParse.query).toEqual(qPrice.query); }); it("should parse the data", async () => { - const result = await executeBuilder(data.datalake, qPriceParse); + const result = await executeBuilder(qPriceParse, data.datalake); expect(result).toMatchInlineSnapshot('"$99.00"'); }); }); diff --git a/packages/groq-builder/src/commands/parse.ts b/packages/groq-builder/src/commands/validate.ts similarity index 70% rename from packages/groq-builder/src/commands/parse.ts rename to packages/groq-builder/src/commands/validate.ts index 909b4e17..2093003b 100644 --- a/packages/groq-builder/src/commands/parse.ts +++ b/packages/groq-builder/src/commands/validate.ts @@ -1,11 +1,9 @@ import { GroqBuilder } from "../groq-builder"; import { ParserFunction, ParserObject } from "../types/public-types"; -import { getParserFunction } from "./parseUtils"; - declare module "../groq-builder" { export interface GroqBuilder { - parse( + validate( parser: | ParserObject | ParserFunction @@ -14,7 +12,7 @@ declare module "../groq-builder" { } GroqBuilder.implement({ - parse(this: GroqBuilder, parser) { - return this.chain("", getParserFunction(parser)); + validate(this: GroqBuilder, parser) { + return this.chain("", parser); }, }); diff --git a/packages/groq-builder/src/groq-builder.test.ts b/packages/groq-builder/src/groq-builder.test.ts index 3782804f..e339c7bf 100644 --- a/packages/groq-builder/src/groq-builder.test.ts +++ b/packages/groq-builder/src/groq-builder.test.ts @@ -18,24 +18,24 @@ describe("GroqBuilder", () => { describe("getProductBySlug", () => { const getProductBySlug = q.star .filterByType("product") - .any("[slug.current == $slug]") - .projection((q) => ({ + .filter("slug.current == $slug") + .grab((q) => ({ _id: true, name: true, - categories: q.projection("categories[]").deref().projection({ + categories: q.field("categories[]").deref().grab({ name: true, }), - slug: q.projection("slug").projection("current"), + slug: q.field("slug").field("current"), variants: q - .projection("variants[]") + .field("variants[]") .deref() - .projection((q) => ({ + .grab((q) => ({ _id: true, name: true, msrp: true, price: true, - slug: q.projection("slug").projection("current"), - style: q.projection("style[]").deref().projection({ + slug: q.field("slug").field("current"), + style: q.field("style[]").deref().grab({ _id: true, name: true, }), diff --git a/packages/groq-builder/src/groq-builder.ts b/packages/groq-builder/src/groq-builder.ts index 0da75303..67b2daf7 100644 --- a/packages/groq-builder/src/groq-builder.ts +++ b/packages/groq-builder/src/groq-builder.ts @@ -1,8 +1,15 @@ -import type { ParserFunction } from "./types/public-types"; +import type { Parser, ParserFunction } from "./types/public-types"; import type { RootConfig } from "./types/schema-types"; -import { chainParsers } from "./commands/parseUtils"; +import { + chainParsers, + normalizeValidationFunction, +} from "./commands/validate-utils"; +import { ValidationErrors } from "./validation/validation-errors"; export type GroqBuilderOptions = { + /** + * Enables "pretty printing" for the compiled GROQ string. Useful for debugging + */ indent: string; }; @@ -19,6 +26,11 @@ export class GroqBuilder< Object.assign(GroqBuilder.prototype, methods); } + /** + * Extends the GroqBuilder class by implementing properties. + * This allows for this class to be split across multiple files in the `./commands/` folder. + * @internal + */ static implementProperties(properties: { [P in keyof GroqBuilder]?: PropertyDescriptor; }) { @@ -36,34 +48,47 @@ export class GroqBuilder< } ) {} + /** + * The GROQ query as a string + */ public get query() { return this.internal.query; } - public get parser() { - return this.internal.parser; + + /** + * Parses and validates the query results, passing all data through the parsers. + */ + 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; + } + } + return data as TResult; } /** - * Chains a new query to the existing one. + * Returns a new GroqBuilder, extending the current one. + * + * For internal use. */ - protected chain( + protected chain( query: string, - parser: ParserFunction | null = null + parser: Parser | null = null ): GroqBuilder { return new GroqBuilder({ query: this.internal.query + query, - parser: chainParsers(this.internal.parser, parser), + parser: chainParsers( + this.internal.parser, + normalizeValidationFunction(parser) + ), options: this.internal.options, }); } - - /** - * Untyped "escape hatch" allowing you to write any query you want - */ - public any( - query: string, - parse?: ParserFunction | null - ) { - return this.chain(query, parse); - } } diff --git a/packages/groq-builder/src/index.ts b/packages/groq-builder/src/index.ts index bc2285b3..180a2e84 100644 --- a/packages/groq-builder/src/index.ts +++ b/packages/groq-builder/src/index.ts @@ -1,16 +1,26 @@ -import type { RootConfig } from "./types/schema-types"; +// Be sure to keep these 2 imports in the correct order: import { GroqBuilder, GroqBuilderOptions } from "./groq-builder"; - import "./commands"; -import { InferResultType } from "./types/public-types"; + +import type { RootConfig } from "./types/schema-types"; +import type { ButFirst } from "./types/utils"; // Export all our public types: export * from "./types/public-types"; export * from "./types/schema-types"; -export { GroqBuilder } from "./groq-builder"; +export { GroqBuilder, GroqBuilderOptions } from "./groq-builder"; +export { validate, createGroqBuilderWithValidation } from "./validation"; type RootResult = never; +/** + * Creates the root `q` query builder. + * + * 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: "" } ) { @@ -27,19 +37,13 @@ export function createGroqBuilder( export function makeSafeQueryRunner< FunnerFn extends (query: string, ...parameters: any[]) => Promise >(fn: FunnerFn) { - return async function queryRunner( - builder: TBuilder, + return async function queryRunner( + builder: GroqBuilder, ...parameters: ButFirst> - ): Promise> { + ): Promise { const data = await fn(builder.query, ...parameters); - const parsed = builder.parser ? builder.parser(data) : data; + + const parsed = builder.parse(data); return parsed; }; } - -/** - * Excludes the first item in a tuple - */ -type ButFirst> = T extends [any, ...infer Rest] - ? Rest - : never; diff --git a/packages/groq-builder/src/tests/expectType.ts b/packages/groq-builder/src/tests/expectType.ts index cd8e7557..6aa13a09 100644 --- a/packages/groq-builder/src/tests/expectType.ts +++ b/packages/groq-builder/src/tests/expectType.ts @@ -50,8 +50,8 @@ type TypeMatchers = { ? any : Received & { [ERROR]: Negate extends true - ? "Types should not be assignable" - : "Types should be assignable"; + ? "Types should be assignable" + : "Types should not be assignable"; } >( ...args: IsAssignable extends Negate @@ -59,8 +59,8 @@ type TypeMatchers = { : [ error: { [ERROR]: Negate extends true - ? "Types should not be assignable" - : "Types should be assignable"; + ? "Types should be assignable" + : "Types should not be assignable"; [RECEIVED]: Received; [EXPECTED]: Expected; } @@ -76,8 +76,8 @@ type TypeMatchers = { ? any : SimplifyDeep & { [ERROR]: Negate extends true - ? "Types should not be equal" - : "Types should be equal"; + ? "Types should be equal" + : "Types should not be equal"; } >( ...args: IsSimplyEqual extends Negate @@ -85,8 +85,8 @@ type TypeMatchers = { : [ error: { [ERROR]: Negate extends true - ? "Types should not be equal" - : "Types should be equal"; + ? "Types should be equal" + : "Types should not be equal"; [RECEIVED]: SimplifyDeep; [EXPECTED]: SimplifyDeep; } @@ -109,8 +109,8 @@ type TypeMatchers = { : [ error: { [ERROR]: Negate extends true - ? "Types should not be strict equal" - : "Types should be strict equal"; + ? "Types should be strict equal" + : "Types should not be strict equal"; [RECEIVED]: Received; [EXPECTED]: Expected; } diff --git a/packages/groq-builder/src/tests/mocks/executeQuery.ts b/packages/groq-builder/src/tests/mocks/executeQuery.ts index e60aa542..54bdd252 100644 --- a/packages/groq-builder/src/tests/mocks/executeQuery.ts +++ b/packages/groq-builder/src/tests/mocks/executeQuery.ts @@ -1,25 +1,16 @@ import * as groqJs from "groq-js"; -import { RootConfig } from "../../types/schema-types"; -import { GroqBuilder } from "../../groq-builder"; +import { makeSafeQueryRunner } from "../../index"; type Datalake = Array; -export async function executeBuilder( - datalake: Datalake, - builder: GroqBuilder, - params = {} -): Promise { - const query = builder.query; - const originalResult = await executeQuery(datalake, query, params); - const parsedResult = builder.parser - ? builder.parser(originalResult) - : originalResult; - return parsedResult as TResult; -} +export const executeBuilder = makeSafeQueryRunner( + async (query: string, datalake: Datalake, params = {}) => + await executeQuery(query, datalake, params) +); export async function executeQuery( - dataset: Datalake, query: string, + dataset: Datalake, params: Record ): Promise { try { @@ -33,14 +24,14 @@ export async function executeQuery( if (elapsed >= INEFFICIENT_QUERY_THRESHOLD) { // Issue a warning! console.warn(` - [groq-handler] WARNING: this query took ${elapsed} ms to mock execute. - This usually indicates an inefficient query, and you should consider improving it. - ${ - query.includes("&&") - ? "Instead of using [a && b], consider using [a][b] instead!" - : "" - } - Inefficient query: \n${query} + [groq-handler] WARNING: this query took ${elapsed} ms to mock execute. + This usually indicates an inefficient query, and you should consider improving it. + ${ + query.includes("&&") + ? "Instead of using [a && b], consider using [a][b] instead!" + : "" + } + Inefficient query: \n${query} `); } return result; diff --git a/packages/groq-builder/src/tests/mocks/nextjs-sanity-fe-mocks.ts b/packages/groq-builder/src/tests/mocks/nextjs-sanity-fe-mocks.ts index ae021cf8..927ab7e6 100644 --- a/packages/groq-builder/src/tests/mocks/nextjs-sanity-fe-mocks.ts +++ b/packages/groq-builder/src/tests/mocks/nextjs-sanity-fe-mocks.ts @@ -93,6 +93,34 @@ export class MockFactory { } satisfies Required; } + image(data: Partial): SanitySchema.ProductImage { + return { + ...this.common("productImage"), + name: "ProductImage", + description: "Product Image", + asset: this.reference({ _id: "mock-image-id" }), + ...data, + } satisfies Required; + } + + keyed(data: T): T & { _key: string } { + return { + _key: "", + ...data, + }; + } + + contentBlock( + data: Partial + ): SanitySchema.ContentBlock { + return { + _type: "block", + _key: "", + children: [{ _type: "span", _key: "", text: "", marks: [] }], + ...data, + }; + } + // Entire datasets: generateSeedData({ categories = this.array(10, (i) => diff --git a/packages/groq-builder/src/tests/schemas/nextjs-sanity-fe.ts b/packages/groq-builder/src/tests/schemas/nextjs-sanity-fe.ts index e23584c1..bd9a833a 100644 --- a/packages/groq-builder/src/tests/schemas/nextjs-sanity-fe.ts +++ b/packages/groq-builder/src/tests/schemas/nextjs-sanity-fe.ts @@ -23,6 +23,8 @@ export namespace SanitySchema { export type Variant = PromoteType; export type SiteSettings = PromoteType; + export type ContentBlock = NonNullable[0]; + type PromoteType = { _type: T["_type"]; } & Simplify>; diff --git a/packages/groq-builder/src/types/path-types.test.ts b/packages/groq-builder/src/types/path-types.test.ts index 3f3bcb24..755529ad 100644 --- a/packages/groq-builder/src/types/path-types.test.ts +++ b/packages/groq-builder/src/types/path-types.test.ts @@ -49,18 +49,18 @@ describe("type-paths", () => { describe("'PathEntries'", () => { it("should extract all entries", () => { type Entries = PathEntries; - expectType().toStrictEqual< - | ["a", "A"] - | ["b", { c: "C" }] - | ["b.c", "C"] - | ["d", { e: { f: 0 } }] - | ["d.e", { f: 0 }] - | ["d.e.f", 0] - | ["g", {}] - | ["h", []] - | ["i", Array<{ j: "J" }>] - | ["j", undefined | { k?: "K" }] - >(); + expectType().toStrictEqual<{ + a: "A"; + b: { c: "C" }; + "b.c": "C"; + d: { e: { f: 0 } }; + "d.e": { f: 0 }; + "d.e.f": 0; + g: {}; + h: []; + i: Array<{ j: "J" }>; + j: undefined | { k?: "K" }; + }>(); }); }); }); diff --git a/packages/groq-builder/src/types/path-types.ts b/packages/groq-builder/src/types/path-types.ts index d03d6ebb..0f30c732 100644 --- a/packages/groq-builder/src/types/path-types.ts +++ b/packages/groq-builder/src/types/path-types.ts @@ -35,12 +35,14 @@ export type PathValue< ? T[P] : never; -export type PathEntries< - TResultItem, - Paths extends Path = Path -> = { - [P in Paths]: [P, PathValue]; -}[Paths]; +/** + * Returns a deeply-flattened type + * @example + * PathEntries<{ a: { b: "C" } }> === { a: { b: "C" }, "a.b": "C" } + */ +export type PathEntries = { + [P in Path]: PathValue; +}; type IsAny = unknown extends T ? [keyof T] extends [never] diff --git a/packages/groq-builder/src/types/public-types.ts b/packages/groq-builder/src/types/public-types.ts index b94ecc30..701842bf 100644 --- a/packages/groq-builder/src/types/public-types.ts +++ b/packages/groq-builder/src/types/public-types.ts @@ -1,4 +1,5 @@ import { GroqBuilder } from "../groq-builder"; +import { ResultItem } from "./result-types"; /* eslint-disable @typescript-eslint/no-explicit-any */ @@ -6,12 +7,24 @@ export type Parser = | ParserObject | ParserFunction; +export type InferParserInput = TParser extends Parser< + infer TInput +> + ? TInput + : never; +export type InferParserOutput = TParser extends Parser< + any, + infer TOutput +> + ? TOutput + : never; + /** * A generic "parser" which can take any input and output a parsed type. * This signature is compatible with Zod. */ export type ParserObject = { - parse(input: TInput): TOutput; + parse: ParserFunction; }; /** @@ -27,9 +40,16 @@ export type ParserFunctionMaybe< > = null | ParserFunction; /** - * Extracts the Result type from a GroqBuilder + * Extracts the Result type from a GroqBuilder query */ export type InferResultType = TGroqBuilder extends GroqBuilder ? TResultType : never; + +/** + * Extracts the Result type for a single item from a GroqBuilder query + */ +export type InferResultItem = ResultItem< + InferResultType +>; diff --git a/packages/groq-builder/src/types/schema-types.ts b/packages/groq-builder/src/types/schema-types.ts index cb7b87db..3e89ad76 100644 --- a/packages/groq-builder/src/types/schema-types.ts +++ b/packages/groq-builder/src/types/schema-types.ts @@ -39,15 +39,15 @@ export type RefType = { [P in referenceSymbol]: TTypeName; }; -export type ExtractRefType = +export type ExtractRefType = // - TResult extends RefType + TResultItem extends RefType ? Extract : TypeMismatchError<{ - error: "Expected the object to be a reference type"; + error: "⛔️ Expected the object to be a reference type ⛔️"; expected: RefType< TRootConfig["referenceSymbol"], TRootConfig["documentTypes"]["_type"] >; - actual: TResult; + actual: TResultItem; }>; diff --git a/packages/groq-builder/src/types/utils.ts b/packages/groq-builder/src/types/utils.ts index 1c43b81f..f9aaf428 100644 --- a/packages/groq-builder/src/types/utils.ts +++ b/packages/groq-builder/src/types/utils.ts @@ -96,3 +96,10 @@ export type EntriesOf = { * Excludes symbol and number from keys, so that you only have strings. */ export type StringKeys = Exclude; + +/** + * Excludes the first item in a tuple + */ +export type ButFirst> = T extends [any, ...infer Rest] + ? Rest + : never; diff --git a/packages/groq-builder/src/validation/index.test.ts b/packages/groq-builder/src/validation/index.test.ts new file mode 100644 index 00000000..b22b9352 --- /dev/null +++ b/packages/groq-builder/src/validation/index.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect } from "vitest"; +import { expectType } from "../tests/expectType"; +import { InferResultType } from "../types/public-types"; + +import { createGroqBuilderWithValidation } from "./index"; + +const q = createGroqBuilderWithValidation(); + +describe("createGroqBuilderWithValidation (schema-less)", () => { + 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("createGroqBuilderWithValidation (validation functions)", () => { + 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>(); + }); +}); diff --git a/packages/groq-builder/src/validation/index.ts b/packages/groq-builder/src/validation/index.ts new file mode 100644 index 00000000..36a09744 --- /dev/null +++ b/packages/groq-builder/src/validation/index.ts @@ -0,0 +1,14 @@ +import { primitiveValidators } from "./primitives"; +import { createGroqBuilder, RootConfig, GroqBuilderOptions } from "../index"; +import { sanityValidators } from "./sanity"; + +export const validate = { + ...primitiveValidators, + ...sanityValidators, +}; + +export function createGroqBuilderWithValidation( + options?: GroqBuilderOptions +) { + return Object.assign(createGroqBuilder(options), validate); +} diff --git a/packages/groq-builder/src/validation/primitives.test.ts b/packages/groq-builder/src/validation/primitives.test.ts new file mode 100644 index 00000000..929e51ee --- /dev/null +++ b/packages/groq-builder/src/validation/primitives.test.ts @@ -0,0 +1,182 @@ +import { describe, expect, it } from "vitest"; +import { validate } from "./index"; +import { expectType } from "../tests/expectType"; +import { InferParserInput, InferParserOutput } from "../types/public-types"; +import { ValidationErrors } from "./validation-errors"; + +describe("primitiveValidators", () => { + it("string", () => { + const str = validate.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 = validate.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 = validate.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 = validate.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 = validate.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 new file mode 100644 index 00000000..e54cb6bd --- /dev/null +++ b/packages/groq-builder/src/validation/primitives.ts @@ -0,0 +1,117 @@ +import { ParserFunction } from "../types/public-types"; + +export const primitiveValidators = { + 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.test.ts b/packages/groq-builder/src/validation/sanity.test.ts new file mode 100644 index 00000000..2965a185 --- /dev/null +++ b/packages/groq-builder/src/validation/sanity.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from "vitest"; +import { validate } from "./index"; +import { mock } from "../tests/mocks/nextjs-sanity-fe-mocks"; + +describe("contentBlock", () => { + const parseBlock = validate.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 = validate.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.ts b/packages/groq-builder/src/validation/sanity.ts new file mode 100644 index 00000000..23578877 --- /dev/null +++ b/packages/groq-builder/src/validation/sanity.ts @@ -0,0 +1,33 @@ +import { memo, primitiveValidators } from "./primitives"; + +export const sanityValidators = { + contentBlock: memo(< + TConfig extends ContentBlockConfig = ContentBlockConfig + >() => primitiveValidators.object>()), + + contentBlocks: memo(< + TConfig extends ContentBlockConfig = ContentBlockConfig + >() => primitiveValidators.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/validation-errors.ts b/packages/groq-builder/src/validation/validation-errors.ts new file mode 100644 index 00000000..9c364703 --- /dev/null +++ b/packages/groq-builder/src/validation/validation-errors.ts @@ -0,0 +1,47 @@ +export type ErrorDetails = { + path: string; + readonly value: unknown; + readonly error: Error; +}; + +export class ValidationErrors extends TypeError { + constructor( + message = "Validation Errors", + protected errors: ErrorDetails[] = [] + ) { + super(message); + this.name = "ValidationErrors"; + } + + public add(path: string, value: unknown, error: Error) { + 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 }); + } + } + + public get length() { + return this.errors.length; + } + + /** + * Returns a new error with an updated message (since an Error message is read-only) + */ + 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}`) + .join("\n")}`; + return new ValidationErrors(message, this.errors); + } +} + +function joinPath(path1: string, path2: string) { + const emptyJoin = + !path1 || !path2 || path1.endsWith("]") || path2.startsWith("["); + return path1 + (emptyJoin ? "" : ".") + path2; +}