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: implement "conditionals" feature #252

Merged
merged 46 commits into from
Jan 18, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
d035d5a
feature(conditionals): added conditional type helpers
Dec 24, 2023
623910b
feature(conditionals): implemented the `conditional$` method
Dec 24, 2023
008db1c
feature(conditionals): implemented `conditionalByType` method
Dec 24, 2023
afec46b
feature(conditionals): ensure conditionals can be spread into project…
Dec 24, 2023
bf8e8f4
feature(conditionals): use custom tagged types
Dec 25, 2023
f72dc03
feature(conditionals): `filterByType` can accept multiple types
Dec 25, 2023
c7f5fb8
feature(conditionals): improved handling of `indent`
Dec 25, 2023
70fa8b6
feature(conditionals): implemented conditionals
Dec 25, 2023
fdefb46
feature(conditionals): implemented `select` method
Dec 26, 2023
f3989ec
feature(conditionals): removed select's "callback" signature
Dec 26, 2023
827a347
feature(conditionals): added type tests
Dec 26, 2023
ba1124e
feature(conditionals): created "root projection" test
Dec 26, 2023
7ec7c17
feature(conditionals): added test for projection + validatio
Dec 27, 2023
3e24691
feature(conditionals): added import for select method
Dec 27, 2023
7d1b0e1
feature(conditionals): use Empty for RootResult
Dec 27, 2023
2f3ff1c
feature(conditionals): implemented `selectByType`
Dec 27, 2023
8ba0285
feature(conditionals): fixed type errors
Dec 27, 2023
fe6c1f5
feature(conditionals): implemented "default" parameter
Dec 27, 2023
266347c
feature(conditionals): added tests for validation
Dec 27, 2023
3458bb6
feature(conditionals): added support for non-callback signature
Dec 28, 2023
c936ab4
feature(conditionals): added jsdocs
Dec 28, 2023
f32fdf1
feature(conditionals): changed signature for conditional projections;…
Dec 28, 2023
af9331f
feature(conditionals): added shape validation to assist with projecti…
Jan 5, 2024
326fef8
feature(conditionals): refactored `project` to utilize object shape
Jan 5, 2024
2b53b9f
groq-builder: Added `q.fragment` implementation (#250)
scottrippey Jan 10, 2024
176eed0
feature(conditionals): added `IGroqBuilder` for easier circular refer…
Jan 11, 2024
fc01974
feature(conditionals): improve `EntriesOf`
Jan 11, 2024
b9da005
feature(conditionals): updated `Conditional` signature again, to ensu…
Jan 11, 2024
80e1291
feature(conditionals): updated implementation of `conditional$`
Jan 12, 2024
8712923
feature(conditionals): added tests for conditionals within fragments
Jan 12, 2024
c1d70c8
feature(conditionals): added tests for multiple conditionals
Jan 12, 2024
c085092
feature(conditionals): improved multiple-conditional intersection types
Jan 12, 2024
53852f6
feature(conditionals): added CONDITIONALS docs
Jan 13, 2024
90d612f
feature(conditionals): made `key` a configuration option
Jan 16, 2024
29529ab
feature(conditionals): added an "exhaustive" check for conditionals
Jan 17, 2024
79de34a
feature(conditionals): updated CONDITIONALS docs
Jan 17, 2024
2be8e60
feature(conditionals): added docs on Select function
Jan 17, 2024
77ade33
feature(conditionals): added more examples for `q.select`
Jan 17, 2024
d09ece4
feature(conditionals): added jsdocs for ConditionalConfig
Jan 17, 2024
6d19d15
Merge remote-tracking branch 'origin/main' into conditionals
Jan 17, 2024
7bb04d4
feature(conditionals): fixed validation error formatting
Jan 17, 2024
35b9224
feature(conditionals): updated conditional tests
Jan 17, 2024
a876616
feature(conditionals): removed debugging code
Jan 17, 2024
eb6fb08
feature(conditionals): fixed EntriesOf implementation
Jan 17, 2024
52c60e4
feature(conditionals): fixed type param of no-arg versions of `valida…
Jan 17, 2024
0974de7
feature(conditionals): removed unused imports
Jan 17, 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
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".
scottrippey marked this conversation as resolved.
Show resolved Hide resolved

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

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
Loading