Skip to content

Commit

Permalink
groq-builder: Added q.fragment implementation (#250)
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): fixed broken import

---------

Co-authored-by: scottrippey <scott.william.rippey@gmail.com>
  • Loading branch information
scottrippey and scottrippey committed Jan 10, 2024
1 parent 175913b commit 2b53b9f
Show file tree
Hide file tree
Showing 9 changed files with 309 additions and 7 deletions.
5 changes: 5 additions & 0 deletions .changeset/violet-crabs-invent.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"groq-builder": patch
---

Added support for Fragments via `q.fragment`
76 changes: 76 additions & 0 deletions packages/groq-builder/docs/FRAGMENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# Fragments

A "fragment" is a reusable projection. It is just a `groq-builder` concept, not a part of the Groq language.

Fragments can be reused across multiple queries, and they can be easily extended or combined.

## Defining a Fragment

To create a fragment, you specify the "input type" for the fragment, then define the projection. For example:

```ts
const productFragment = q.fragment<SanitySchema.Product>().project({
name: q.string(),
price: q.number(),
slug: ["slug.current", q.string()],
});
```

You can easily extract a type from this fragment too:

```ts
type ProductFragment = InferFragmentType<typeof productFragment>;
```

## Using a Fragment

To use this fragment in a query, you can pass it directly to the `.project` method:

```ts
const productQuery = q.star.filterByType("product").project(productFragment);
```

You can also spread the fragment into a projection:
```ts
const productQuery = q.star.filterByType("product").project({
...productFragment,
description: q.string(),
images: "images[]",
});
```

## Extending and combining Fragments

Fragments are just plain objects, with extra type information. This makes it easy to extend and combine your fragments.

To extend a fragment:

```ts
const productDetailsFragment = q.fragment<SanitySchema.Product>().project({
...productFragment,
description: q.string(),
msrp: q.number(),
slug: q.slug("slug"),
});
```

To combine fragments:

```ts
const productDetailsFragment = q.fragment<SanitySchema.Product>().project({
...productFragment,
...productDescriptionFragment,
...productImagesFragment,
});
```

To infer the "result type" of any of these fragments, use `InferFragmentType`:

```ts
import { InferFragmentType } from './public-types';

type ProductFragment = InferFragmentType<typeof productFragment>;
type ProductDetailsFragment = InferFragmentType<typeof productDetailsFragment>;
type ProductDescriptionFragment = InferFragmentType<typeof productDescriptionFragment>;
type ProductImagesFragment = InferFragmentType<typeof productImagesFragment>;
```
126 changes: 126 additions & 0 deletions packages/groq-builder/src/commands/fragment.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { describe, it, expect } from "vitest";
import { SanitySchema, SchemaConfig } from "../tests/schemas/nextjs-sanity-fe";
import { expectType } from "../tests/expectType";
import { InferFragmentType, InferResultType } from "../types/public-types";
import { createGroqBuilder } from "../index";
import { TypeMismatchError } from "../types/utils";

const q = createGroqBuilder<SchemaConfig>();

describe("fragment", () => {
// define a fragment:
const variantFragment = q.fragment<SanitySchema.Variant>().project({
name: true,
price: true,
slug: "slug.current",
});
type VariantFragment = InferFragmentType<typeof variantFragment>;

it("should have the correct type", () => {
expectType<VariantFragment>().toStrictEqual<{
name: string;
price: number;
slug: string;
}>();
});

const productFrag = q.fragment<SanitySchema.Product>().project((qP) => ({
name: true,
slug: "slug.current",
variants: qP
.field("variants[]")
.deref()
.project({
...variantFragment,
msrp: true,
}),
}));
type ProductFragment = InferFragmentType<typeof productFrag>;

it("should have the correct types", () => {
expectType<ProductFragment>().toEqual<{
name: string;
slug: string;
variants: null | Array<{
name: string;
price: number;
slug: string;
msrp: number;
}>;
}>();
});

it("fragments can be used in a query", () => {
const qVariants = q.star.filterByType("variant").project(variantFragment);
expectType<InferResultType<typeof qVariants>>().toStrictEqual<
Array<VariantFragment>
>();

expect(qVariants.query).toMatchInlineSnapshot(
'"*[_type == \\"variant\\"] { name, price, \\"slug\\": slug.current }"'
);
});
it("fragments can be spread in a query", () => {
const qVariantsPlus = q.star.filterByType("variant").project({
...variantFragment,
msrp: true,
});
expectType<InferResultType<typeof qVariantsPlus>>().toStrictEqual<
Array<{ name: string; price: number; slug: string; msrp: number }>
>();

expect(qVariantsPlus.query).toMatchInlineSnapshot(
'"*[_type == \\"variant\\"] { name, price, \\"slug\\": slug.current, msrp }"'
);
});

it("should have errors if the variant is used incorrectly", () => {
const qInvalid = q.star.filterByType("product").project(variantFragment);
expectType<
InferResultType<typeof qInvalid>[number]["price"]
>().toStrictEqual<
TypeMismatchError<{
error: "⛔️ 'true' can only be used for known properties ⛔️";
expected: keyof SanitySchema.Product;
actual: "price";
}>
>();
});

it("can be composed", () => {
const idFrag = q.fragment<SanitySchema.Variant>().project({ id: true });
const variantDetailsFrag = q.fragment<SanitySchema.Variant>().project({
...idFrag,
...variantFragment,
msrp: true,
});

type VariantDetails = InferFragmentType<typeof variantDetailsFrag>;

expectType<VariantDetails>().toStrictEqual<{
slug: string;
name: string;
msrp: number;
price: number;
id: string | undefined;
}>();
});

it("can be used to query multiple types", () => {
const commonFrag = q
.fragment<
SanitySchema.Product | SanitySchema.Variant | SanitySchema.Category
>()
.project({
_type: true,
_id: true,
name: true,
});
type CommonFrag = InferFragmentType<typeof commonFrag>;
expectType<CommonFrag>().toStrictEqual<{
_type: "product" | "variant" | "category";
_id: string;
name: string;
}>();
});
});
29 changes: 29 additions & 0 deletions packages/groq-builder/src/commands/fragment.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { GroqBuilder } from "../groq-builder";
import { ProjectionMap } from "./projection-types";
import { Fragment } from "../types/public-types";

declare module "../groq-builder" {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export interface GroqBuilder<TResult, TRootConfig> {
fragment<TFragmentInput>(): {
project<TProjectionMap extends ProjectionMap<TFragmentInput>>(
projectionMap:
| TProjectionMap
| ((q: GroqBuilder<TFragmentInput, TRootConfig>) => TProjectionMap)
): Fragment<TProjectionMap, TFragmentInput>;
};
}
}

GroqBuilder.implement({
fragment(this: GroqBuilder<any>) {
return {
project: (projectionMap) => {
if (typeof projectionMap === "function") {
projectionMap = projectionMap(this);
}
return projectionMap;
},
};
},
});
1 change: 1 addition & 0 deletions packages/groq-builder/src/commands/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import "./deref";
import "./filter";
import "./filterByType";
import "./fragment";
import "./grab-deprecated";
import "./order";
import "./project";
Expand Down
16 changes: 10 additions & 6 deletions packages/groq-builder/src/commands/projection-types.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { GroqBuilder } from "../groq-builder";
import {
Empty,
Simplify,
SimplifyDeep,
StringKeys,
TaggedUnwrap,
TypeMismatchError,
ValueOf,
} from "../types/utils";
Expand Down Expand Up @@ -47,12 +49,14 @@ type ProjectionFieldConfig<TResultItem> =
| GroqBuilder;

export type ExtractProjectionResult<TResult, TProjectionMap> =
TProjectionMap extends {
"...": true;
}
? TResult &
ExtractProjectionResultImpl<TResult, Omit<TProjectionMap, "...">>
: ExtractProjectionResultImpl<TResult, TProjectionMap>;
(TProjectionMap extends { "...": true } ? TResult : Empty) &
ExtractProjectionResultImpl<
TResult,
Omit<
TaggedUnwrap<TProjectionMap>, // Ensure we unwrap any tags (used by Fragments)
"..."
>
>;

type ExtractProjectionResultImpl<TResult, TProjectionMap> = {
[P in keyof TProjectionMap]: TProjectionMap[P] extends GroqBuilder<
Expand Down
12 changes: 12 additions & 0 deletions packages/groq-builder/src/types/public-types.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { GroqBuilder } from "../groq-builder";
import { ResultItem } from "./result-types";
import { Simplify, Tagged } from "./utils";
import { ExtractProjectionResult } from "../commands/projection-types";

/* eslint-disable @typescript-eslint/no-explicit-any */

Expand Down Expand Up @@ -53,3 +55,13 @@ export type InferResultType<TGroqBuilder extends GroqBuilder> =
export type InferResultItem<TGroqBuilder extends GroqBuilder> = ResultItem<
InferResultType<TGroqBuilder>
>;

export type Fragment<
TProjectionMap,
TFragmentInput // This is used to capture the type, to be extracted by `InferFragmentType`
> = Tagged<TProjectionMap, TFragmentInput>;

export type InferFragmentType<TFragment extends Fragment<any, any>> =
TFragment extends Fragment<infer TProjectionMap, infer TFragmentInput>
? Simplify<ExtractProjectionResult<TFragmentInput, TProjectionMap>>
: never;
32 changes: 31 additions & 1 deletion packages/groq-builder/src/types/utils.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import { describe, it } from "vitest";
import { expectType } from "../tests/expectType";
import { ExtractTypeMismatchErrors, TypeMismatchError } from "./utils";
import {
ExtractTypeMismatchErrors,
Tagged,
TypeMismatchError,
TaggedUnwrap,
TaggedType,
} from "./utils";

describe("ExtractTypeMismatchErrors", () => {
type TME<ErrorMessage extends string> = TypeMismatchError<{
Expand Down Expand Up @@ -42,3 +48,27 @@ describe("ExtractTypeMismatchErrors", () => {
expectType<ExtractTypeMismatchErrors<null>>().toStrictEqual<never>();
});
});

describe("Tagged", () => {
type Base = {
name: string;
};
type TagInfo = {
tagInfo: string;
};
type BaseWithTag = Tagged<Base, TagInfo>;

it("should be assignable to the base type", () => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const baseTagged: BaseWithTag = { name: "hey" };
});
it("should not be equal to the base type, because of the tag", () => {
expectType<BaseWithTag>().not.toStrictEqual<Base>();
});
it("should be able to unwrap the tag", () => {
expectType<TaggedUnwrap<BaseWithTag>>().toStrictEqual<Base>();
});
it("should be able to extract the tag info", () => {
expectType<TaggedType<BaseWithTag>>().toStrictEqual<TagInfo>();
});
});
19 changes: 19 additions & 0 deletions packages/groq-builder/src/types/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,3 +103,22 @@ export type StringKeys<T> = Exclude<T, symbol | number>;
export type ButFirst<T extends Array<any>> = T extends [any, ...infer Rest]
? Rest
: never;

/**
* Extends a base type with extra type information.
*
* (also known as "opaque", "branding", or "flavoring")
* @example
* const id: Tagged<string, "UserId"> = "hello";
*
*/
export type Tagged<TActual, TTag> = TActual & { readonly [Tag]?: TTag };
export type TaggedUnwrap<TTagged> = Omit<TTagged, typeof Tag>;
export type TaggedType<TTagged extends Tagged<any, any>> =
TTagged extends Tagged<unknown, infer TTag> ? TTag : never;
declare const Tag: unique symbol;

/**
* A completely empty object.
*/
export type Empty = Record<never, never>;

0 comments on commit 2b53b9f

Please sign in to comment.