Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

groq-builder: zod compatibility #257

Merged
merged 52 commits into from
Feb 5, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
52 commits
Select commit Hold shift + click to select a range
83403e9
feature(zod): added zodValidations
Jan 21, 2024
f91e0d1
feature(zod): added nullToUndefined helper
Jan 21, 2024
a02cd92
feature(zod): added `as` and `asType` helpers
Jan 22, 2024
58299b2
feature(zod): improved jsdocs
Jan 22, 2024
b45119d
feature(zod): better support for Zod types in parsers
Jan 22, 2024
ea0cb95
feature(zod): renamed to simply `zod`
Jan 22, 2024
9d9d069
feature(zod): add zod dependency
Jan 22, 2024
ca0d3fc
feature(zod): switch more tests to use zod
Jan 22, 2024
f9ff3dd
feature(zod): moved "lite validation" into new folder
Jan 22, 2024
98a15f1
feature(zod): handle ZodError
Jan 23, 2024
3b5d359
feature(zod): extracted the object/array parsing logic, used by `proj…
Jan 23, 2024
ac59a43
feature(infer): implemented `q.infer()` for inferred result types
Jan 23, 2024
f721d13
feature(infer): updated error messages
Jan 23, 2024
0a71280
feature(cleanup): renamed to `InferResultItem`
Jan 24, 2024
5b9e735
feature(cleanup): moved type utils into a `ResultItem` namespace
Jan 24, 2024
faa43d7
feature(cleanup): use type-fest types, and removed unused types
Jan 24, 2024
1728311
feature(cleanup): removed useless `$` convention
Jan 24, 2024
42cb7e6
feature(infer): updated naked projections (`.field`, projection tuple…
Jan 25, 2024
beb9723
Merge branch 'infer' into gb/type-cleanup
Jan 26, 2024
edfce71
Revert "feature(infer): updated naked projections (`.field`, projecti…
Jan 26, 2024
b5031ba
Revert "feature(infer): implemented `q.infer()` for inferred result t…
Jan 26, 2024
8e2783c
feature(cleanup): allow `q.field` to accept a parser
Jan 26, 2024
4adb1b2
feature(cleanup): updated tests to use zod
Jan 26, 2024
c108584
feature(validationRequired): added validationRequired flag
Jan 26, 2024
43f85b1
feature(validationRequired): implemented validationRequired field for…
Jan 26, 2024
721bc46
feature(validationRequired): implemented validationRequired field for…
Jan 26, 2024
2f0d49c
changeset
Jan 26, 2024
87daeab
Merge remote-tracking branch 'origin/main' into zod-again
Jan 26, 2024
8880598
feature(cleanup): added jsdocs
Jan 26, 2024
11b321f
feature(zod): do not require validation for naked projections, since …
Jan 27, 2024
71bd574
feature(zod): allow a parser for the `...` type
Jan 27, 2024
721ecb2
feature(cleanup): simplified Omit conditional keys
Jan 28, 2024
140bb81
feature(zod): map all `undefined` to `null`
Jan 29, 2024
71f6178
feature(zod): added `q.default` utility
Jan 29, 2024
1657877
feature(zod): updated zod test with nullable field
Jan 30, 2024
9ef6cfb
feature(zod): support Parser input widening
Jan 30, 2024
f25000f
feature(projection): massively improved error messages for projections
Jan 31, 2024
9e7a08e
feature(projection): updated tests
Feb 1, 2024
9ac6394
feature(projection): removed custom `expectType`, replaced with `expe…
Feb 1, 2024
649260f
feature(zod): updated MIGRATION with updated Zod examples
Feb 1, 2024
050602e
feature(zod): added `createGroqBuilderWithZod` method to simplify API
Feb 1, 2024
3d21527
feature(zod): moved `nullToUndefined` into zod namespacr
Feb 1, 2024
9a8553a
feature(zod): when chaining methods, check to ensure parsers are not …
Feb 3, 2024
c747b36
feature(zod): implemented `.nullable()`
Feb 3, 2024
c4926e9
feature(zod): moved `q.slug` into zod, since that's the only place it…
Feb 3, 2024
e847f75
feature(zod): make chain's `parser` optional
Feb 3, 2024
538a6ba
feature(zod): removed unused `validation/lite`
Feb 3, 2024
6d73677
feature(zod): ensure naked projection parsers are validated
Feb 3, 2024
8d30788
feature(zod): properly preserve `null` values in naked projections
Feb 3, 2024
62b8040
feature(zod): properly preserve `null` values in naked projections
Feb 3, 2024
db6cfbe
feature(zod): removed unused import
Feb 3, 2024
a8da1a7
feature(zod): updated changeset
Feb 3, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading