-
Notifications
You must be signed in to change notification settings - Fork 16
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
groq-builder: implement "conditionals" feature (target fixed) (#255)
* 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
1 parent
2b53b9f
commit 8087388
Showing
39 changed files
with
2,437 additions
and
221 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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"), | ||
}) | ||
})); | ||
``` | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
203 changes: 203 additions & 0 deletions
203
packages/groq-builder/src/commands/conditional$.test.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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\\" | ||
} | ||
}" | ||
`); | ||
}); | ||
}); | ||
}); | ||
}); |
Oops, something went wrong.