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: support sanity typegen output #293

Merged
merged 18 commits into from
Aug 1, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 5 additions & 0 deletions .changeset/wise-apples-swim.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"groq-builder": patch
---

Added support for types generated by `sanity typegen`
150 changes: 62 additions & 88 deletions packages/groq-builder/README.md
Original file line number Diff line number Diff line change
@@ -1,17 +1,26 @@
# `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 build GROQ queries using **auto-completion**, **type-checking**, and **runtime validation**.

### 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."
<details>
<summary>What is GROQ?</summary>

[GROQ is Sanity's open-source query language.](https://www.sanity.io/docs/groq)

> "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."

</details>

## Features

- **Schema-aware** - Use your `sanity.config.ts` for auto-completion and type-checking
- **Strongly-typed** - Query results are strongly typed, based on the schema
- **Optional runtime validation** - Validate or transform query results at run-time, with broad or granular levels
- **Schema-aware** - uses your `sanity.config.ts` schema for auto-completion and type-checking.
- **Strongly-typed** - query results are strongly typed, based on your Sanity schema.
- **Runtime validation** - validate, parse, and transform query results at run-time, with broad or granular levels.

## Brought to you by the team behind `GroqD`

`groq-builder` is the successor to `GroqD`. In addition to runtime validation and strongly-typed results, `groq-builder` adds schema-awareness and auto-completion.

## Example

Expand Down Expand Up @@ -39,11 +48,13 @@ const productsQuery = (
```
In the above query, ALL fields are strongly-typed, according to the Sanity schema defined in `sanity.config.ts`!

- All strings like `'products'`, `'price desc'`, and `'images[]'` are strongly-typed, based on the matching field definitions.
- In the projection, `name` and `price` are strongly-typed based on the fields of `product`.
- In the projection, sub-queries are strongly typed too.
- All the strings above are strongly-typed, based on field definitions, including `'products'`, `'price desc'`, `'slug.current'`, `'images[]'`, and `'url'`.
- In the projection, the keys `name` and `price` have auto-completion, and are strongly-typed, based on the fields of `product`.
- In the projection, the keys `slug` and `imageUrls` are strongly-typed based on their sub-queries.

This example generates the following GROQ query:
### Example Query:

This example above generates the following GROQ query:
```groq
*[_type == "products"] | order(price desc)[0...10] {
name,
Expand All @@ -54,9 +65,9 @@ This example generates the following GROQ query:
```


## Query Result Types
### Example Types:

The above `productsQuery` example generates the following results type:
The example above also generates the following result type:

```ts
import type { InferResultType } from 'groq-builder';
Expand All @@ -71,109 +82,72 @@ type ProductsQueryResult = Array<{
}>;
```

## Optional Runtime Validation and Custom Parsing

You can add custom runtime validation and/or parsing logic into your queries, using the `validate` method.
## Runtime Validation

The `validate` function accepts a simple function:
`groq-builder` enables effortless runtime validation using [Zod](https://zod.dev/):

```ts
const products = q.star.filterByType('products').project(q => ({
name: true,
price: true,
priceFormatted: q.field("price").validate(price => formatCurrency(price)),
}));
```
import { z } from 'zod';

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').project(q => ({
name: z.string(),
slug: ["slug.current", z.string().optional()],
price: q.field("price").validate(z.number().nonnegative()),
slug: ["slug.current", z.string()],
price: q.field("price", z.number().nonnegative()),
}));
```

## Schema Configuration

The entry-point to this library is the `createGroqBuilder<SchemaConfig>()` function, which returns a strongly-typed `q` object. You must supply the `SchemaConfig` type parameter, which lists all document types from your Sanity Schema.

There are 2 approaches for creating this Schema. You can specify the Schema manually, or you can auto-generate the types based on your `sanity.config.ts`.
## Custom Parsing

### Manually typing your Sanity Schema

The simplest way to create a Sanity Schema is to manually specify the document types. Here's a working example:
Validation methods can include custom validation and/or parsing logic too:

```ts
import { createGroqBuilder } from './index';

declare const references: unique symbol;
type Product = {
_type: "product";
_id: string;
name: string;
price: number;
images: Array<{ width: number; height: number; url: string; }>;
category: { _type: "reference"; _ref: string; [references]: "category"; };
}
type Category = {
_type: "category";
_id: string;
name: string;
products: Array<{ _type: "reference"; _ref: string; [references]: "product"; }>;
}

export type SchemaConfig = {
documentTypes: Product | Category;
referenceSymbol: typeof references;
}

export const q = createGroqBuilder<SchemaConfig>();
const products = q.star.filterByType('products').project(q => ({
price: z.number(),
priceFormatted: q.field("price", price => formatCurrency(price)),
}));
```

The only complexity is how **references** are handled. In the Sanity data, the `reference` object doesn't say what kind of document it's referencing. We have to add this type information, using a unique symbol. So above, we added `[references]: "category"` to capture the reference type. This information is used by the `.deref()` method to ensure we follow references correctly.

### Automatically generating your Sanity Schema
## Sanity Schema Configuration

Fortunately, there is a way to automatically generate the Sanity Schema, using the Sanity configuration itself (`sanity.config.ts`). This workflow has 2 steps: inferring types from the config, then copying the compiled types to your application.
To support auto-completion and maximum type-safety, you must configure `groq-builder` by providing type information for your Sanity Schema.

#### Augment your `sanity.config.ts` to infer the types
Fortunately, the Sanity CLI supports a `typegen` command that will generate the Sanity Schema Types for you!

In the repo with your Sanity configuration (`sanity.config.ts`), [use the `@sanity-typed/types` library](https://www.sanity.io/plugins/sanity-typed-types) to augment your configuration code.
### Generating Sanity Schema Types

This is pretty easy, and involves:
- Changing your imports `from 'sanity';` to `from '@sanity-typed/types'`
- Adding `as const` in a few places (according to the docs)

Then, in your `schema.config.ts`, you infer all document types by adding:
```ts
import { InferSchemaValues } from '@sanity-typed/types';
export type SanityValues = InferSchemaValues<typeof config>;
First, in your Sanity Studio project (where you have your `sanity.config.ts`), follow [the Sanity documentation](https://www.sanity.io/docs/sanity-typegen) to run the following commands:
```sh
sanity schema extract --enforce-required-fields
sanity typegen generate
```

This generates a `sanity.types.ts` file, which contains type definitions for all your Sanity documents.

#### Compile the types and copy to your application

Now that you've got the `SanityValues` type, you'll need to compile the types, and copy them to your application (where you're using `groq-builder`).
Second, copy the newly generated `sanity.types.ts` into your application (where you intend to use `groq-builder`).

Normally you could use `tsc` to compile the types, and copy them over. However, there is a far better approach: use [the `ts-simplify` CLI tool](https://www.npmjs.com/package/ts-simplify) to compile and simplify the types.

From your Sanity repo, run:
```sh
npx ts-simplify ./sanity.config.ts ./sanity-schema.ts
```
### Configuring `groq-builder` with your Sanity Schema:

This generates a `./sanity-schema.ts` file that has no dependencies, just the Sanity types!
In your application, you can create a strongly-typed `groq-builder` using the following snippet:

Move this file to your application (where you're using `groq-builder`), and finally, glue it all together like so:

`./q.ts`
```ts
import { createGroqBuilder, ExtractDocumentTypes } from 'groq-builder';
import { referenced, SanityValues } from './sanity-schema'; // This is the generated file
// ./q.ts
import { createGroqBuilder } from 'groq-builder';
import {
AllSanitySchemaTypes,
internalGroqTypeReferenceTo,
} from "./sanity.types.ts";

export const q = createGroqBuilder<{
documentTypes: ExtractDocumentTypes<SanityValues>;
referenceSymbol: typeof referenced;
documentTypes: AllSanitySchemaTypes,
referenceSymbol: typeof internalGroqTypeReferenceTo;
}>();
```

And that's it! Wherever you write queries, be sure to import this strongly-typed `q` and you'll get full auto-completion and type-safety!
```ts
import { q } from './q';

const productQuery = q.star.filterByType('product');
```
5 changes: 3 additions & 2 deletions packages/groq-builder/docs/CONDITIONALS.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ Notice that this type is stronger than the example with `q.conditional`, because

## The `select` method

Adds support for the `select` method:
You can add conditional logic for a single field by using the `select` method:
```ts
const qMovies = q.star.filterByType("movie").project({
name: true,
Expand Down Expand Up @@ -115,10 +115,11 @@ type MoviesResult = Array<{
}>
```

> Note: just like `q.conditional`, the "conditions" (eg `"popularity > 20"`) are not strongly-typed; any string is allowed. See the `selectByType` method for a better option.

## The `selectByType` method

Adds a `selectByType` helper, which facilitates type-based logic. This is completely strongly-typed:
You can also use the `selectByType` helper, which facilitates type-based logic. The following example is completely strongly-typed:
```ts
const qContent = q.star.filterByType("movie", "actor").project(q => ({
name: q.selectByType({
Expand Down
4 changes: 2 additions & 2 deletions packages/groq-builder/docs/MIGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,11 +126,11 @@ Since `q` is strongly-typed to our Sanity schema, it knows the types of the prod

## Additional Improvements

### Migrating from `grab -> project` and `grabOne-> field`
### 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`.
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). Sanity 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`.

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

Expand Down
7 changes: 3 additions & 4 deletions packages/groq-builder/src/commands/deref.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,9 @@ describe("deref", () => {

it("should be an error if the item is not a reference", () => {
const notAReference = qProduct.field("slug");
expectTypeOf<InferResultType<typeof notAReference>>().toEqualTypeOf<{
_type: "slug";
current: string;
}>();
expectTypeOf<
InferResultType<typeof notAReference>
>().toEqualTypeOf<SanitySchema.Slug>();

const res = notAReference.deref();
type ErrorResult = InferResultType<typeof res>;
Expand Down
73 changes: 59 additions & 14 deletions packages/groq-builder/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@
import "./groq-builder";
import "./commands";

import type { RootQueryConfig } from "./types/schema-types";
import type { QueryConfig } from "./types/schema-types";
import { GroqBuilder, GroqBuilderOptions, RootResult } from "./groq-builder";
import { zod } from "./validation/zod";
import type { Override } from "./types/utils";

// Re-export all our public types:
export * from "./groq-builder";
Expand All @@ -13,23 +14,53 @@ export * from "./types/schema-types";
export { zod } from "./validation/zod";
export { makeSafeQueryRunner } from "./makeSafeQueryRunner";

export type RootQueryConfig = Override<
QueryConfig,
{
/**
* This is a union of all possible document types,
* according to your Sanity config.
*
* You can automatically generate these types using the
* `sanity typegen generate` command in your Sanity Studio project.
*
* Alternatively, you can use `any`, which disables schema-awareness,
* but still allows strongly-typed query results.
*/
documentTypes: object; // We'll filter out non-documents later
}
>;
type ExtractQueryConfig<TRootConfig extends RootQueryConfig> =
// Filter out all non-documents:
Override<
TRootConfig,
{ documentTypes: Extract<TRootConfig["documentTypes"], { _type: string }> }
>;

/**
* Creates the root `q` query builder.
*
* Does not include runtime validation methods like `q.string()`.
* Instead, you have 3 options:
* - You can import `zod` and use `zod.string()` instead of `q.string()`
* - You can use inferred types without runtime validation
* - You can provide your own validation methods
* The Zod dependency can be tree-shaken with the latter 2 approaches.
* This method does not include the `zod` utilities
* for runtime validation, like `q.string()`;
* see `createGroqBuilderWithZod` for more information.
*
* @example
* import { createGroqBuilder } from 'groq-builder';
* import {
* AllSanitySchemaTypes,
* internalGroqTypeReferenceTo,
* } from "./sanity.types.ts";
*
* The TRootConfig type argument is used to bind the query builder to the Sanity schema config.
* If you specify `any`, then your schema will be loosely-typed, but the output types will still be strongly typed.
* export const q = createGroqBuilder<{
* documentTypes: AllSanitySchemaTypes,
* referenceSymbol: typeof internalGroqTypeReferenceTo;
* }>();
*/
export function createGroqBuilder<TRootConfig extends RootQueryConfig>(
options: GroqBuilderOptions = {}
) {
const q = new GroqBuilder<RootResult, TRootConfig>({
type TQueryConfig = ExtractQueryConfig<TRootConfig>;
const q = new GroqBuilder<RootResult, TQueryConfig>({
query: "",
parser: null,
options,
Expand All @@ -40,11 +71,25 @@ export function createGroqBuilder<TRootConfig extends RootQueryConfig>(
/**
* Creates the root `q` query builder.
*
* Includes all Zod validation methods attached to the `q` object,
* like `q.string()` etc. This ensures an API that's backwards compatible with GroqD syntax.
* For convenience, includes all Zod validation methods attached to the `q` object, like `q.string()` etc.
* This ensures an API that's backwards compatible with GroqD syntax.
*
* If you want to use `zod` directly,
* or a different validation library,
* or don't need runtime validation,
* use `createGroqBuilder` instead.
*
* @example
* import { createGroqBuilderWithZod } from 'groq-builder';
* import {
* AllSanitySchemaTypes,
* internalGroqTypeReferenceTo,
* } from "./sanity.types.ts";
*
* The TRootConfig type argument is used to bind the query builder to the Sanity schema config.
* If you specify `any`, then your schema will be loosely-typed, but the output types will still be strongly typed.
* export const q = createGroqBuilderWithZod<{
* documentTypes: AllSanitySchemaTypes,
* referenceSymbol: typeof internalGroqTypeReferenceTo;
* }>();
*/
export function createGroqBuilderWithZod<TRootConfig extends RootQueryConfig>(
options: GroqBuilderOptions = {}
Expand Down
Loading