Skip to content

Commit

Permalink
groq-builder: zod compatibility (#257)
Browse files Browse the repository at this point in the history
* feature(zod): added zodValidations

* feature(zod): added nullToUndefined helper

* feature(zod): added `as` and `asType` helpers

* feature(zod): improved jsdocs

* feature(zod): better support for Zod types in parsers

* feature(zod): renamed to simply `zod`

* feature(zod): add zod dependency

* feature(zod): switch more tests to use zod

* feature(zod): moved "lite validation" into new folder

* feature(zod): handle ZodError

* feature(zod): extracted the object/array parsing logic, used by `project`

* feature(infer): implemented `q.infer()` for inferred result types

* feature(infer): updated error messages

* feature(cleanup): renamed to `InferResultItem`

* feature(cleanup): moved type utils into a `ResultItem` namespace

* feature(cleanup): use type-fest types, and removed unused types

* feature(cleanup): removed useless `$` convention

* feature(infer): updated naked projections (`.field`, projection tuples) with mandatory parser

* Revert "feature(infer): updated naked projections (`.field`, projection tuples) with mandatory parser"

This reverts commit 42cb7e6

* Revert "feature(infer): implemented `q.infer()` for inferred result types"

This reverts commit ac59a43

* feature(cleanup): allow `q.field` to accept a parser

* feature(cleanup): updated tests to use zod

* feature(validationRequired): added validationRequired flag

* feature(validationRequired): implemented validationRequired field for naked projections

* feature(validationRequired): implemented validationRequired field for projections

* changeset

* feature(cleanup): added jsdocs

* feature(zod): do not require validation for naked projections, since they can be chained; enforce stronger for projections

* feature(zod): allow a parser for the `...` type

* feature(cleanup): simplified Omit conditional keys

* feature(zod): map all `undefined` to `null`

* feature(zod): added `q.default` utility

* feature(zod): updated zod test with nullable field

* feature(zod): support Parser input widening

* feature(projection): massively improved error messages for projections

* feature(projection): updated tests

* feature(projection): removed custom `expectType`, replaced with `expectTypeOf` from vitest

* feature(zod): updated MIGRATION with updated Zod examples

* feature(zod): added `createGroqBuilderWithZod` method to simplify API

* feature(zod): moved `nullToUndefined` into zod namespacr

* feature(zod): when chaining methods, check to ensure parsers are not added before queries

* feature(zod): implemented `.nullable()`

* feature(zod): moved `q.slug` into zod, since that's the only place it's useful

* feature(zod): make chain's `parser` optional

* feature(zod): removed unused `validation/lite`

* feature(zod): ensure naked projection parsers are validated

* feature(zod): properly preserve `null` values in naked projections

* feature(zod): properly preserve `null` values in naked projections

* feature(zod): removed unused import

* feature(zod): updated changeset

---------

Co-authored-by: scottrippey <scott.william.rippey@gmail.com>
  • Loading branch information
scottrippey and scottrippey authored Feb 5, 2024
1 parent 22e5dd3 commit a6e10ab
Show file tree
Hide file tree
Showing 67 changed files with 1,598 additions and 1,760 deletions.
15 changes: 15 additions & 0 deletions .changeset/forty-spiders-hug.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
"groq-builder": minor
---

Added `createGroqBuilderWithZod()` and removed the `.include(zod)` method

Removed internal validation methods; use Zod methods instead

Added `validationRequired` option to require runtime validation

Removed `$` from `q.conditional$` and `q.select$` methods; these are now just `q.conditional` and `q.select`

Added optional validation parameter to `q.field(field, parser?)`

Cleaned up some internal types, added better type documentation
20 changes: 9 additions & 11 deletions packages/groq-builder/docs/CONDITIONALS.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,14 @@ In Groq, there are 2 ways to use conditional logic: inline in a projection, or u

## Conditions in a projection

In `groq-builder`, the `project` method allows inline conditional statements with the help of `q.conditional$(...)` or `q.conditionalByType(...)` using the following syntax:
In `groq-builder`, the `project` method allows inline conditional statements with the help of `q.conditional(...)` or `q.conditionalByType(...)` using the following syntax:

```ts
const contentQuery = q.star
.filterByType("movie", "actor")
.project({
slug: "slug.current",
...q.conditional$({
...q.conditional({
"_type == 'movie'": { title: "title", subtitle: "description" },
"_type == 'actor'": { title: "name", subtitle: "biography" },
}),
Expand Down Expand Up @@ -43,11 +43,11 @@ type ContentResults = Array<
>;
```

Notice that the conditions are wrapped in `q.conditional$()` and then spread into the projection. This is necessary for type-safety and runtime validation.
Notice that the conditions are wrapped in `q.conditional()` and then spread into the projection. This is necessary for type-safety and runtime validation.

The `$` in the method `q.conditional$` indicates that this method is not completely type-safe; the condition statements (eg. `_type == 'movie'`) are not strongly-typed (this may be improved in a future version).
Please note that the condition statements (eg. `_type == 'movie'`) are not strongly-typed. For now, any string is valid, and no auto-complete is provided. This may be improved in a future version.

However, the most common use-case is to base conditional logic off the document's `_type`. For this, we have the `q.conditionalByType` helper:
However, the most common use-case is to base conditional logic off the document's `_type`. For this, we have a stronger-typed `q.conditionalByType` helper:

### Strongly-typed conditions via `q.conditionalByType(...)`

Expand All @@ -66,7 +66,7 @@ const contentQuery = q.star
}));
```

The resulting query is identical to the above example with `q.conditional$`.
The resulting query is identical to the above example with `q.conditional`.

The result type here is inferred as:

Expand All @@ -78,23 +78,21 @@ type ContentResult = Array<
>
```
Notice that this type is stronger than the example with `q.conditional$`, because we've detected that the conditions are "exhaustive".
Notice that this type is stronger than the example with `q.conditional`, because we've detected that the conditions are "exhaustive".
## The `select` method
Adds support for the `select$` method:
Adds support for the `select` method:
```ts
const qMovies = q.star.filterByType("movie").project({
name: true,
popularity: q.select$({
popularity: q.select({
"popularity > 20": q.value("high"),
"popularity > 10": q.value("medium"),
}, q.value("low")),
});
```

The `$` sign is to indicate that there's some "loosely typed" code in here -- the conditions are unchecked.

This will output the following query:
```groq
*[_type == "movie"] {
Expand Down
53 changes: 21 additions & 32 deletions packages/groq-builder/docs/MIGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,9 @@ const productsQuery = q("*")
#### After, with `groq-builder`

```ts
import { createGroqBuilder, validation } from "groq-builder";
import { createGroqBuilderWithZod } from "groq-builder";
// Using 'any' makes the query schema-unaware:
const q = createGroqBuilder<any>().include(validation);
const q = createGroqBuilderWithZod<any>();

const productsQuery = q.star
.filterByType("product")
Expand All @@ -53,18 +53,15 @@ Keep reading for a deeper explanation of these changes.

```ts
// src/queries/q.ts
import { createGroqBuilder, validation } from 'groq-builder';
import { createGroqBuilderWithZod } from 'groq-builder';
type SchemaConfig = any;
export const q = createGroqBuilder<SchemaConfig>().include(validation);
export const q = createGroqBuilderWithZod<SchemaConfig>();
```

By creating the root `q` this way, we're able to bind it to our `SchemaConfig`.
By using `any` for now, our `q` will be schema-unaware (same as `groqd`).
Later, we'll show you how to change this to a strongly-typed schema.

We also call `.include(validation)` to extend the root `q` with our validation methods, like `q.string()`.
This is for convenience and compatibility.

## Step 2: Replacing the `q("...")` method

This is the biggest API change.
Expand Down Expand Up @@ -113,7 +110,7 @@ With `groq-builder`, by [adding a strongly-typed Sanity schema](./README.md#sche
- Safer to write (all commands are type-checked, all fields are verified)
- Faster to execute (because runtime validation can be skipped)

In a projection, we can skip runtime validation by simply using `true` instead of a validation method (like `q.string()`). For example:
In a projection, we can skip runtime validation by simply using `true` instead of a validation method like `q.string()`. For example:
```ts
const productsQuery = q.star
.filterByType("product")
Expand All @@ -124,7 +121,7 @@ const productsQuery = q.star
});
```

Since `q` is strongly-typed to our Sanity schema, it knows the types of the product's `name`, `price`, and `slug`, so it outputs a strongly-typed result. And assuming we trust our Sanity schema, we can skip the overhead of runtime checks.
Since `q` is strongly-typed to our Sanity schema, it knows the types of the product's `name`, `price`, and `slug.current`, so it outputs a strongly-typed result. And assuming we trust our Sanity schema, we can skip the overhead of runtime checks.


## Additional Improvements
Expand All @@ -133,30 +130,22 @@ Since `q` is strongly-typed to our Sanity schema, it knows the types of the prod

The `grab`, `grabOne`, `grab$`, and `grabOne$` methods still exist, but have been deprecated, and should be replaced with the `project` and `field` methods.

Sanity's documentation uses the word "projection" to refer to grabbing specific fields, so we have renamed the `grab` method to `project` (pronounced pruh-JEKT, if that helps). It also uses the phrase "naked projection" to refer to grabbing a single field, but to keep things terse, we've renamed `grabOne` to `field`. So we recommend migrating from `grab` to `project`, and from `grabOne` to `field`.

Regarding `grab$` and `grabOne$`, these 2 variants were needed to improve compatibility with Zod's `.optional()` utility. But the `project` and `field` methods work just fine with the built-in validation functions (like `q.string().optional()`).


### `q.select(...)`
This is not yet supported by `groq-builder`.
Sanity's documentation uses the word "projection" to refer to grabbing specific fields, so we have renamed the `grab` method to `project` (pronounced *pruh-JEKT*, if that helps). It also uses the phrase "naked projection" to refer to grabbing a single field, but to keep things terse, we've renamed `grabOne` to `field`. So we recommend migrating from `grab` to `project`, and from `grabOne` to `field`.

### Validation methods
#### Alternatives for `grab$` and `grabOne$`

Most validation methods, like `q.string()` or `q.number()`, are built-in now, and are no longer powered by Zod. These validation methods work mostly the same, but are simplified and more specialized to work with a strongly-typed schema.

Some of the built-in validation methods, like `q.object()` and `q.array()`, are much simpler than the previous Zod version.
These check that the data is an `object` or an `array`, but do NOT check the shape of the data.

Please use Zod if you need to validate an object's shape, validate items inside an Array, or you'd like more powerful runtime validation logic. For example:

```ts
import { z } from 'zod';
Regarding `grab$` and `grabOne$`, these 2 variants were needed to improve compatibility with Zod's `.default(...)` utility.
This feature has been dropped, in favor of using the `q.default` utility. For example:

q.star.filterByType("user").project({
email: z.coerce.string().email().min(5),
createdAt: z.string().datetime().optional(),
});
Before:
```
q.grab$({
field: q.string().default("DEFAULT"),
})
```
After:
```
q.project({
field: q.default(q.string(), "DEFAULT")),
})
```


4 changes: 4 additions & 0 deletions packages/groq-builder/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@
"build": "pnpm run clean && tsc --project tsconfig.build.json",
"prepublishOnly": "pnpm run build"
},
"dependencies": {
"type-fest": "^4.10.1",
"zod": "^3.22.4"
},
"devDependencies": {
"@sanity/client": "^3.4.1",
"groq-js": "^1.1.9",
Expand Down
19 changes: 7 additions & 12 deletions packages/groq-builder/src/commands/conditional-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,7 @@ import {
ProjectionMap,
ProjectionMapOrCallback,
} from "./projection-types";
import {
Empty,
IntersectionOfValues,
Simplify,
Tagged,
ValueOf,
} from "../types/utils";
import { Empty, IntersectionOfValues, Simplify, ValueOf } from "../types/utils";
import { ExtractTypeNames, RootConfig } from "../types/schema-types";
import { GroqBuilder } from "../groq-builder";
import { IGroqBuilder, InferResultType } from "../types/public-types";
Expand All @@ -25,7 +19,12 @@ export type ConditionalProjectionMap<
) => ProjectionMap<TResultItem>);
};

export type ConditionalExpression<TResultItem> = Tagged<string, TResultItem>;
/**
* For now, none of our "conditions" are strongly-typed,
* so we'll just use "string":
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export type ConditionalExpression<TResultItem> = string;

export type ExtractConditionalProjectionResults<
TResultItem,
Expand All @@ -42,10 +41,6 @@ export type ExtractConditionalProjectionResults<
}>
>;

export type OmitConditionalProjections<TResultItem> = {
[P in Exclude<keyof TResultItem, ConditionalKey<string>>]: TResultItem[P];
};

export type ExtractConditionalProjectionTypes<TProjectionMap> = Simplify<
IntersectionOfValues<{
[P in Extract<
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, expect, it } from "vitest";
import { describe, expect, expectTypeOf, it } from "vitest";
import {
createGroqBuilder,
GroqBuilder,
Expand All @@ -7,15 +7,14 @@ import {
} from "../index";
import { SchemaConfig } from "../tests/schemas/nextjs-sanity-fe";
import { ExtractConditionalProjectionTypes } from "./conditional-types";
import { expectType } from "../tests/expectType";
import { Empty, Simplify } from "../types/utils";

const q = createGroqBuilder<SchemaConfig>({ indent: " " });
const qBase = q.star.filterByType("variant");

describe("conditional$", () => {
describe("conditional", () => {
describe("by itself", () => {
const conditionalResult = q.star.filterByType("variant").conditional$({
const conditionalResult = q.star.filterByType("variant").conditional({
"price == msrp": {
onSale: q.value(false),
},
Expand All @@ -27,9 +26,9 @@ describe("conditional$", () => {
});

it("we should be able to extract the intersection of projection types", () => {
expectType<
expectTypeOf<
Simplify<ExtractConditionalProjectionTypes<typeof conditionalResult>>
>().toStrictEqual<
>().toEqualTypeOf<
| Empty
| { onSale: false }
| { onSale: true; price: number; msrp: number }
Expand All @@ -44,7 +43,7 @@ describe("conditional$", () => {

const qAll = qBase.project((qA) => ({
name: true,
...qA.conditional$({
...qA.conditional({
"price == msrp": {
onSale: q.value(false),
},
Expand All @@ -57,7 +56,7 @@ describe("conditional$", () => {
}));

it("should be able to extract the return type", () => {
expectType<InferResultType<typeof qAll>>().toStrictEqual<
expectTypeOf<InferResultType<typeof qAll>>().toEqualTypeOf<
Array<
| { name: string }
| { name: string; onSale: false }
Expand Down Expand Up @@ -88,7 +87,7 @@ describe("conditional$", () => {
describe("without using unique keys", () => {
const qIncorrect = q.star.filterByType("variant").project((qV) => ({
name: true,
...qV.conditional$({
...qV.conditional({
"price == msrp": {
onSale: q.value(false),
},
Expand All @@ -101,13 +100,13 @@ describe("conditional$", () => {
// Here we're trying to spread another conditional,
// however, it will override the first one
// since we didn't specify a unique key:
...qV.conditional$({
...qV.conditional({
"second == condition": { price: true },
}),
}));

it("the type will be missing the first conditionals", () => {
expectType<InferResultType<typeof qIncorrect>>().toStrictEqual<
expectTypeOf<InferResultType<typeof qIncorrect>>().toEqualTypeOf<
Array<{ name: string } | { name: string; price: number }>
>();
});
Expand All @@ -128,7 +127,7 @@ describe("conditional$", () => {
.filterByType("variant")
.project((qV) => ({
name: true,
...qV.conditional$({
...qV.conditional({
"price == msrp": {
onSale: q.value(false),
},
Expand All @@ -138,7 +137,7 @@ describe("conditional$", () => {
msrp: true,
},
}),
...qV.conditional$(
...qV.conditional(
{
"another == condition1": { foo: q.value("FOO") },
"another == condition2": { bar: q.value("BAR") },
Expand Down Expand Up @@ -173,8 +172,8 @@ describe("conditional$", () => {
};

type Remainder = Exclude<ActualItem, ExpectedItem>;
expectType<Remainder>().toStrictEqual<never>();
expectType<ActualItem>().toStrictEqual<ExpectedItem>();
expectTypeOf<Remainder>().toEqualTypeOf<never>();
expectTypeOf<ActualItem>().toEqualTypeOf<ExpectedItem>();
});

it("the query should be compiled correctly", () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,9 @@ import { ProjectionMap } from "./projection-types";
declare module "../groq-builder" {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export interface GroqBuilder<TResult, TRootConfig> {
conditional$<
conditional<
TConditionalProjections extends ConditionalProjectionMap<
ResultItem<TResult>,
ResultItem.Infer<TResult>,
TRootConfig
>,
TKey extends string = "[$]",
Expand All @@ -24,15 +24,15 @@ declare module "../groq-builder" {
conditionalProjections: TConditionalProjections,
config?: Partial<ConditionalConfig<TKey, TIsExhaustive>>
): ExtractConditionalProjectionResults<
ResultItem<TResult>,
ResultItem.Infer<TResult>,
TConditionalProjections,
ConditionalConfig<TKey, TIsExhaustive>
>;
}
}

GroqBuilder.implement({
conditional$<
conditional<
TCP extends object,
TKey extends string,
TIsExhaustive extends boolean
Expand Down
Loading

0 comments on commit a6e10ab

Please sign in to comment.