Skip to content

Commit

Permalink
groq-builder: compatibility with GroqD (#248)
Browse files Browse the repository at this point in the history
Added backwards compatibility with GroqD v0.15
  • Loading branch information
scottrippey committed Dec 21, 2023
1 parent f35225e commit 1cbc5f2
Show file tree
Hide file tree
Showing 49 changed files with 1,946 additions and 567 deletions.
8 changes: 8 additions & 0 deletions .changeset/brown-ducks-develop.md
Original file line number Diff line number Diff line change
@@ -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
36 changes: 23 additions & 13 deletions packages/groq-builder/README.md
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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<MySchemaConfig>()
const q = createGroqBuilder<SchemaConfig>()

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")
}))
);
```
Expand Down Expand Up @@ -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()),
}));
```

Expand Down
159 changes: 159 additions & 0 deletions packages/groq-builder/docs/MIGRATION.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
# Migrating from GroqD v0.x to Groq-Builder v0.x
<!-- TODO: rename `Groq-Builder v0.x` to `groqd v1` throughout this document -->

## 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<any>(); // 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<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.


## 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<number>(`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(),
});
```


6 changes: 4 additions & 2 deletions packages/groq-builder/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
12 changes: 6 additions & 6 deletions packages/groq-builder/src/commands/deref.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand All @@ -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<InferResultType<typeof notAReference>>().toStrictEqual<{
_type: "slug";
current: string;
Expand All @@ -45,15 +45,15 @@ describe("deref", () => {
type ErrorResult = InferResultType<typeof res>;
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);
});
});
2 changes: 1 addition & 1 deletion packages/groq-builder/src/commands/deref.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ declare module "../groq-builder" {
}

GroqBuilder.implement({
deref(this: GroqBuilder<any, RootConfig>): any {
deref(this: GroqBuilder<any, RootConfig>) {
return this.chain("->", null);
},
});
32 changes: 4 additions & 28 deletions packages/groq-builder/src/commands/filter.ts
Original file line number Diff line number Diff line change
@@ -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<TResult, TRootConfig> {
filterBy<
TKey extends StringKeys<keyof ResultItem<TResult>>,
TValue extends Extract<ResultItem<TResult>[TKey], string>
>(
filterString: `${TKey} == "${TValue}"`
): GroqBuilder<
ResultOverride<
TResult,
Extract<ResultItem<TResult>, { [P in TKey]: TValue }>
>,
TRootConfig
>;

filterByType<
TType extends Extract<ResultItem<TResult>, { _type: string }>["_type"]
>(
type: TType
): GroqBuilder<
ResultOverride<TResult, Extract<ResultItem<TResult>, { _type: TType }>>,
TRootConfig
>;
filter(filterExpression: string): GroqBuilder<TResult, TRootConfig>;
}
}

GroqBuilder.implement({
filterBy(this: GroqBuilder, filterString) {
return this.chain(`[${filterString}]`, null);
},
filterByType(this: GroqBuilder, type) {
return this.chain(`[_type == "${type}"]`, null);
filter(this: GroqBuilder<any, RootConfig>, filterExpression) {
return this.chain(`[${filterExpression}]`, null);
},
});
Loading

0 comments on commit 1cbc5f2

Please sign in to comment.