Skip to content

Commit

Permalink
Feature: Groq builder 0.1 (#239)
Browse files Browse the repository at this point in the history
Created a new package, `groq-builder`
  • Loading branch information
scottrippey committed Nov 27, 2023
1 parent 79edb79 commit 6b25bfa
Show file tree
Hide file tree
Showing 43 changed files with 3,170 additions and 9 deletions.
6 changes: 6 additions & 0 deletions .changeset/bright-peaches-wash.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"groq-builder": patch
---

- Added `indent` option
- Fixed circular dependency build issues
5 changes: 5 additions & 0 deletions .changeset/pink-flies-jump.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"groq-builder": patch
---

Implemented core methods
5 changes: 5 additions & 0 deletions packages/groq-builder/.eslintrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"rules": {
"@typescript-eslint/no-explicit-any": "off"
}
}
7 changes: 7 additions & 0 deletions packages/groq-builder/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# groq-builder

## 0.1.1

### Patch Changes

- Implemented core methods ([#239](https://github.com/FormidableLabs/groqd/pull/239))
174 changes: 174 additions & 0 deletions packages/groq-builder/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
# `groq-builder`

A **schema-aware**, strongly-typed GROQ query builder.
It enables you to use **auto-completion** and **type-checking** for your GROQ queries.

## 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

## Example

```ts
import { createGroqBuilder } from 'groq-builder';
import type { MySchemaConfig } from './my-schema-config';
// ☝️ Note:
// Please see the "Schema Configuration" docs
// for an overview of this SchemaConfig type

const q = createGroqBuilder<MySchemaConfig>()

const productsQuery = (
q.star
.filterByType('products')
.order('price desc')
.slice(0, 10)
.projection(q => ({
name: true,
price: true,
slug: q.projection('slug.current'),
imageUrls: q.projection('images[]').deref().projection('url')
}))
);
```
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.

This example generates the following GROQ query:
```groq
*[_type == "products"] | order(price desc)[0...10] {
name,
price,
"slug": slug.current,
"imageUrls": images[]->url
}
```


## Query Result Types

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

```ts
import type { InferResultType } from 'groq-builder';

type ProductsQueryResult = InferResultType<typeof productsQuery>;
// 👆 Evaluates to the following:
type ProductsQueryResult = Array<{
name: string,
price: number,
slug: string,
imageUrls: Array<string>,
}>;
```

## Optional Runtime Validation and Custom Parsing

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

The `parse` function accepts a simple function:

```ts
const products = q.star.filterByType('products').projection(q => ({
name: true,
price: true,
priceFormatted: q.projection("price").parse(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()),
}));
```

## 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`.

### 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:

```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>();
```

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

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.

#### Augment your `sanity.config.ts` to infer the types

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.

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>;
```


#### 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`).

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
```

This generates a `./sanity-schema.ts` file that has no dependencies, just the Sanity types!

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

export const q = createGroqBuilder<{
documentTypes: ExtractDocumentTypes<SanityValues>;
referenceSymbol: typeof referenced;
}>();
```
58 changes: 58 additions & 0 deletions packages/groq-builder/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
{
"name": "groq-builder",
"version": "0.1.2",
"license": "MIT",
"author": {
"name": "Formidable",
"url": "https://formidable.com"
},
"repository": {
"type": "git",
"url": "https://github.com/FormidableLabs/groqd"
},
"homepage": "https://github.com/formidablelabs/groqd",
"keywords": [
"sanity",
"groq",
"query",
"typescript"
],
"main": "dist/index.js",
"module": "dist/index.mjs",
"types": "dist/index.d.ts",
"exports": {
".": [
{
"import": "./dist/index.mjs",
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"./dist/index.js"
],
"./package.json": "./package.json"
},
"files": [
"dist"
],
"scripts": {
"test:watch": "vitest",
"test": "vitest run",
"typecheck": "tsc --noEmit",
"clean": "rimraf dist",
"build": "pnpm run clean && tsc --project tsconfig.build.json",
"prepublishOnly": "pnpm run build"
},
"devDependencies": {
"@sanity/client": "^3.4.1",
"groq-js": "^1.1.9",
"rimraf": "^5.0.5",
"typescript": "^5.0.4",
"vitest": "^0.28.5"
},
"engines": {
"node": ">= 14"
},
"publishConfig": {
"provenance": true
}
}
59 changes: 59 additions & 0 deletions packages/groq-builder/src/commands/deref.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { describe, expect, it } from "vitest";
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";
import { SanitySchema, SchemaConfig } from "../tests/schemas/nextjs-sanity-fe";

const q = createGroqBuilder<SchemaConfig>();
const data = mock.generateSeedData({});

describe("deref", () => {
const qProduct = q.star.filterByType("product").slice(0);
const qCategoryRef = qProduct.projection("categories[]").slice(0);
const qCategory = qCategoryRef.deref();
const qVariantsRefs = qProduct.projection("variants[]");
const qVariants = qVariantsRefs.deref();

it("should deref a single item", () => {
expectType<
InferResultType<typeof qCategory>
>().toEqual<SanitySchema.Category | null>();
expect(qCategory.query).toMatchInlineSnapshot(
'"*[_type == \\"product\\"][0].categories[][0]->"'
);
});

it("should deref an array of items", () => {
expectType<
InferResultType<typeof qVariants>
>().toStrictEqual<Array<SanitySchema.Variant> | null>();
expect(qVariants.query).toMatchInlineSnapshot(
'"*[_type == \\"product\\"][0].variants[]->"'
);
});

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

const res = notAReference.deref();
type ErrorResult = InferResultType<typeof res>;
expectType<
ErrorResult["error"]
>().toStrictEqual<"Expected the object to be a reference type">();
});

it("should execute correctly (single)", async () => {
const results = await executeBuilder(data.datalake, qCategory);
expect(results).toEqual(data.categories[0]);
});
it("should execute correctly (multiple)", async () => {
const results = await executeBuilder(data.datalake, qVariants);
expect(results).toEqual(data.variants);
});
});
18 changes: 18 additions & 0 deletions packages/groq-builder/src/commands/deref.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { GroqBuilder } from "../groq-builder";
import { ExtractRefType, RootConfig } from "../types/schema-types";
import { ResultItem, ResultOverride } from "../types/result-types";

declare module "../groq-builder" {
export interface GroqBuilder<TResult, TRootConfig> {
deref(): GroqBuilder<
ResultOverride<TResult, ExtractRefType<ResultItem<TResult>, TRootConfig>>,
TRootConfig
>;
}
}

GroqBuilder.implement({
deref(this: GroqBuilder<any, RootConfig>): any {
return this.chain("->", null);
},
});
Loading

0 comments on commit 6b25bfa

Please sign in to comment.