Skip to content

Commit

Permalink
groq-builder: implement "conditionals" feature (target fixed) (#255)
Browse files Browse the repository at this point in the history
* feature(fragment): added `q.fragment` implementation

* feature(fragment): added tests for fragment queries

* feature(fragment): ensure we export `Fragment` and `InferFragmentType` types

* feature(fragment): added docs

* feature(validation): allow `deref` to manually specify the type

* feature(validation): renamed sanity validation files

* feature(validation): replaced `createGroqBuilderWithValidation` with `createGroqBuilder().include(validation)`

* feature(validation): renamed to `validation`

* feature(validation): catch validation errors at compile time

* feature(validation): fixed broken import

* feature(validation): changeset

* feature(validation): updated docs

* feature(conditionals): added conditional type helpers

* feature(conditionals): implemented the `conditional$` method

* feature(conditionals): implemented `conditionalByType` method

* feature(conditionals): ensure conditionals can be spread into projections

* feature(conditionals): use custom tagged types

* feature(conditionals): `filterByType` can accept multiple types

* feature(conditionals): improved handling of `indent`

* feature(conditionals): implemented conditionals

* feature(conditionals): implemented `select` method

* feature(conditionals): removed select's "callback" signature

* feature(conditionals): added type tests

* feature(conditionals): created "root projection" test

* feature(conditionals): added test for projection + validatio

* feature(conditionals): added import for select method

* feature(conditionals): use Empty for RootResult

* feature(conditionals): implemented `selectByType`

* feature(conditionals): fixed type errors

* feature(conditionals): implemented "default" parameter

* feature(conditionals): added tests for validation

* feature(conditionals): added support for non-callback signature

* feature(conditionals): added jsdocs

* feature(conditionals): changed signature for conditional projections; uses 2nd parameter

* feature(conditionals): added shape validation to assist with projection validations

* feature(conditionals): refactored `project` to utilize object shape

* feature(conditionals): added `IGroqBuilder` for easier circular references

* feature(conditionals): improve `EntriesOf`

* feature(conditionals): updated `Conditional` signature again, to ensure unique, spreadable keys

* feature(conditionals): updated implementation of `conditional$`

* feature(conditionals): added tests for conditionals within fragments

* feature(conditionals): added tests for multiple conditionals

* feature(conditionals): improved multiple-conditional intersection types

* feature(conditionals): added CONDITIONALS docs

* feature(conditionals): made `key` a configuration option

* feature(conditionals): added an "exhaustive" check for conditionals

* feature(conditionals): updated CONDITIONALS docs

* feature(conditionals): added docs on Select function

* feature(conditionals): added more examples for `q.select`

* feature(conditionals): added jsdocs for ConditionalConfig

* feature(conditionals): fixed validation error formatting

* feature(conditionals): updated conditional tests

* feature(conditionals): removed debugging code

* feature(conditionals): fixed EntriesOf implementation

* feature(conditionals): fixed type param of no-arg versions of `validation.object()` and `validation.array()`

* feature(conditionals): removed unused imports

---------

Co-authored-by: scottrippey <scott.william.rippey@gmail.com>
  • Loading branch information
scottrippey and scottrippey authored Jan 18, 2024
1 parent 2b53b9f commit 8087388
Show file tree
Hide file tree
Showing 39 changed files with 2,437 additions and 221 deletions.
7 changes: 7 additions & 0 deletions .changeset/fast-pianos-occur.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"groq-builder": minor
---

Improved the way we handle validation.
Enable "tree shaking" to remove validation, if unused.
Improve type-checking of validation methods, for better error detection.
132 changes: 132 additions & 0 deletions packages/groq-builder/docs/CONDITIONALS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
# Conditionals

In Groq, there are 2 ways to use conditional logic: inline in a projection, or using the `select` function.

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

```ts
const contentQuery = q.star
.filterByType("movie", "actor")
.project({
slug: "slug.current",
...q.conditional$({
"_type == 'movie'": { title: "title", subtitle: "description" },
"_type == 'actor'": { title: "name", subtitle: "biography" },
}),
});
```

This outputs the following groq query:
```groq
*[_type == "movie" || _type == "actor"] {
"slug": slug.current,
_type == 'movie' => {
title,
"subtitle": description
},
_type == 'actor' => {
"title": name,
"subtitle": biography
}
}
```

And the result type is inferred as:
```ts
type ContentResults = InferResultType<typeof contentQuery>;
// Same as:
type ContentResults = Array<
| { slug: string }
| { slug: string, title: string, subtitle: string }
>;
```

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

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

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

The most common use-case for conditional logic is to check the `_type` field.
The `q.conditionalByType(...)` method makes this easier, by ensuring all conditional logic is strongly-typed, and it enables auto-complete. For example:

```ts
const contentQuery = q.star
.filterByType("movie", "actor")
.project(q => ({
slug: "slug.current",
...q.conditionalByType({
movie: { title: "title", subtitle: "description" },
actor: { title: "name", subtitle: "biography" },
})
}));
```

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

The result type here is inferred as:

```ts
type ContentResult = InferResultType<typeof contentQuery>;
// Same as:
type ContentResult = Array<
{ slug: string, title: string, subtitle: string }
>
```
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:
```ts
const qMovies = q.star.filterByType("movie").project({
name: true,
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"] {
name,
"popularity": select(
popularity > 20 => "high",
popularity > 10 => "medium",
"low"
)
}
```

And will have the following result type:
```ts
type MoviesResult = InferResultType<typeof qMovies>;
// Same as:
type MoviesResult = Array<{
name: string
popularity: "high" | "medium" | "low"
}>
```
## The `selectByType` method
Adds a `selectByType` helper, which facilitates type-based logic. This is completely strongly-typed:
```ts
const qContent = q.star.filterByType("movie", "actor").project(q => ({
name: q.selectByType({
movie: q => q.field("title"),
actor: q => q.field("name"),
})
}));
```

11 changes: 7 additions & 4 deletions packages/groq-builder/docs/MIGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,9 @@ const productsQuery = q("*")
#### After, with `groq-builder`

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

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

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

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

Expand Down
203 changes: 203 additions & 0 deletions packages/groq-builder/src/commands/conditional$.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
import { describe, expect, it } from "vitest";
import {
createGroqBuilder,
GroqBuilder,
InferResultItem,
InferResultType,
} 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("by itself", () => {
const conditionalResult = q.star.filterByType("variant").conditional$({
"price == msrp": {
onSale: q.value(false),
},
"price < msrp": {
onSale: q.value(true),
price: true,
msrp: true,
},
});

it("we should be able to extract the intersection of projection types", () => {
expectType<
Simplify<ExtractConditionalProjectionTypes<typeof conditionalResult>>
>().toStrictEqual<
| Empty
| { onSale: false }
| { onSale: true; price: number; msrp: number }
>();
});
it("should return a spreadable object", () => {
expect(conditionalResult).toMatchObject({
"[Conditional] [$]": expect.any(GroqBuilder),
});
});
});

const qAll = qBase.project((qA) => ({
name: true,
...qA.conditional$({
"price == msrp": {
onSale: q.value(false),
},
"price < msrp": {
onSale: q.value(true),
price: true,
msrp: true,
},
}),
}));

it("should be able to extract the return type", () => {
expectType<InferResultType<typeof qAll>>().toStrictEqual<
Array<
| { name: string }
| { name: string; onSale: false }
| { name: string; onSale: true; price: number; msrp: number }
>
>();
});

it("the query should look correct", () => {
expect(qAll.query).toMatchInlineSnapshot(
`
"*[_type == \\"variant\\"] {
name,
price == msrp => {
\\"onSale\\": false
},
price < msrp => {
\\"onSale\\": true,
price,
msrp
}
}"
`
);
});

describe("multiple conditionals", () => {
describe("without using unique keys", () => {
const qIncorrect = q.star.filterByType("variant").project((qV) => ({
name: true,
...qV.conditional$({
"price == msrp": {
onSale: q.value(false),
},
"price < msrp": {
onSale: q.value(true),
price: true,
msrp: true,
},
}),
// 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$({
"second == condition": { price: true },
}),
}));

it("the type will be missing the first conditionals", () => {
expectType<InferResultType<typeof qIncorrect>>().toStrictEqual<
Array<{ name: string } | { name: string; price: number }>
>();
});
it("the query will also be missing the first conditionals", () => {
expect(qIncorrect.query).toMatchInlineSnapshot(`
"*[_type == \\"variant\\"] {
name,
second == condition => {
price
}
}"
`);
});
});

describe("with different keys", () => {
const qMultipleConditions = q.star
.filterByType("variant")
.project((qV) => ({
name: true,
...qV.conditional$({
"price == msrp": {
onSale: q.value(false),
},
"price < msrp": {
onSale: q.value(true),
price: true,
msrp: true,
},
}),
...qV.conditional$(
{
"another == condition1": { foo: q.value("FOO") },
"another == condition2": { bar: q.value("BAR") },
},
{ key: "unique-key" }
),
}));

it("the types should be inferred correctly", () => {
type ActualItem = InferResultItem<typeof qMultipleConditions>;
type ExpectedItem =
| { name: string }
| { name: string; onSale: false }
| { name: string; onSale: true; price: number; msrp: number }
| { name: string; foo: "FOO" }
| { name: string; onSale: false; foo: "FOO" }
| {
name: string;
onSale: true;
price: number;
msrp: number;
foo: "FOO";
}
| { name: string; bar: "BAR" }
| { name: string; onSale: false; bar: "BAR" }
| {
name: string;
onSale: true;
price: number;
msrp: number;
bar: "BAR";
};

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

it("the query should be compiled correctly", () => {
expect(qMultipleConditions.query).toMatchInlineSnapshot(`
"*[_type == \\"variant\\"] {
name,
price == msrp => {
\\"onSale\\": false
},
price < msrp => {
\\"onSale\\": true,
price,
msrp
},
another == condition1 => {
\\"foo\\": \\"FOO\\"
},
another == condition2 => {
\\"bar\\": \\"BAR\\"
}
}"
`);
});
});
});
});
Loading

0 comments on commit 8087388

Please sign in to comment.