diff --git a/.changeset/violet-crabs-invent.md b/.changeset/violet-crabs-invent.md new file mode 100644 index 00000000..20c133d7 --- /dev/null +++ b/.changeset/violet-crabs-invent.md @@ -0,0 +1,5 @@ +--- +"groq-builder": patch +--- + +Added support for Fragments via `q.fragment` diff --git a/packages/groq-builder/docs/FRAGMENTS.md b/packages/groq-builder/docs/FRAGMENTS.md new file mode 100644 index 00000000..7b435d23 --- /dev/null +++ b/packages/groq-builder/docs/FRAGMENTS.md @@ -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().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; +``` + +## 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().project({ + ...productFragment, + description: q.string(), + msrp: q.number(), + slug: q.slug("slug"), +}); +``` + +To combine fragments: + +```ts +const productDetailsFragment = q.fragment().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; +type ProductDetailsFragment = InferFragmentType; +type ProductDescriptionFragment = InferFragmentType; +type ProductImagesFragment = InferFragmentType; +``` diff --git a/packages/groq-builder/src/commands/fragment.test.ts b/packages/groq-builder/src/commands/fragment.test.ts new file mode 100644 index 00000000..b769c5c2 --- /dev/null +++ b/packages/groq-builder/src/commands/fragment.test.ts @@ -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(); + +describe("fragment", () => { + // define a fragment: + const variantFragment = q.fragment().project({ + name: true, + price: true, + slug: "slug.current", + }); + type VariantFragment = InferFragmentType; + + it("should have the correct type", () => { + expectType().toStrictEqual<{ + name: string; + price: number; + slug: string; + }>(); + }); + + const productFrag = q.fragment().project((qP) => ({ + name: true, + slug: "slug.current", + variants: qP + .field("variants[]") + .deref() + .project({ + ...variantFragment, + msrp: true, + }), + })); + type ProductFragment = InferFragmentType; + + it("should have the correct types", () => { + expectType().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>().toStrictEqual< + Array + >(); + + 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>().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[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().project({ id: true }); + const variantDetailsFrag = q.fragment().project({ + ...idFrag, + ...variantFragment, + msrp: true, + }); + + type VariantDetails = InferFragmentType; + + expectType().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; + expectType().toStrictEqual<{ + _type: "product" | "variant" | "category"; + _id: string; + name: string; + }>(); + }); +}); diff --git a/packages/groq-builder/src/commands/fragment.ts b/packages/groq-builder/src/commands/fragment.ts new file mode 100644 index 00000000..f72a76ab --- /dev/null +++ b/packages/groq-builder/src/commands/fragment.ts @@ -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 { + fragment(): { + project>( + projectionMap: + | TProjectionMap + | ((q: GroqBuilder) => TProjectionMap) + ): Fragment; + }; + } +} + +GroqBuilder.implement({ + fragment(this: GroqBuilder) { + return { + project: (projectionMap) => { + if (typeof projectionMap === "function") { + projectionMap = projectionMap(this); + } + return projectionMap; + }, + }; + }, +}); diff --git a/packages/groq-builder/src/commands/index.ts b/packages/groq-builder/src/commands/index.ts index 0ab3c593..e39530d4 100644 --- a/packages/groq-builder/src/commands/index.ts +++ b/packages/groq-builder/src/commands/index.ts @@ -1,6 +1,7 @@ import "./deref"; import "./filter"; import "./filterByType"; +import "./fragment"; import "./grab-deprecated"; import "./order"; import "./project"; diff --git a/packages/groq-builder/src/commands/projection-types.ts b/packages/groq-builder/src/commands/projection-types.ts index 2df6e5d4..3e61743a 100644 --- a/packages/groq-builder/src/commands/projection-types.ts +++ b/packages/groq-builder/src/commands/projection-types.ts @@ -1,8 +1,10 @@ import { GroqBuilder } from "../groq-builder"; import { + Empty, Simplify, SimplifyDeep, StringKeys, + TaggedUnwrap, TypeMismatchError, ValueOf, } from "../types/utils"; @@ -47,12 +49,14 @@ type ProjectionFieldConfig = | GroqBuilder; export type ExtractProjectionResult = - TProjectionMap extends { - "...": true; - } - ? TResult & - ExtractProjectionResultImpl> - : ExtractProjectionResultImpl; + (TProjectionMap extends { "...": true } ? TResult : Empty) & + ExtractProjectionResultImpl< + TResult, + Omit< + TaggedUnwrap, // Ensure we unwrap any tags (used by Fragments) + "..." + > + >; type ExtractProjectionResultImpl = { [P in keyof TProjectionMap]: TProjectionMap[P] extends GroqBuilder< diff --git a/packages/groq-builder/src/types/public-types.ts b/packages/groq-builder/src/types/public-types.ts index 701842bf..521f9d7c 100644 --- a/packages/groq-builder/src/types/public-types.ts +++ b/packages/groq-builder/src/types/public-types.ts @@ -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 */ @@ -53,3 +55,13 @@ export type InferResultType = export type InferResultItem = ResultItem< InferResultType >; + +export type Fragment< + TProjectionMap, + TFragmentInput // This is used to capture the type, to be extracted by `InferFragmentType` +> = Tagged; + +export type InferFragmentType> = + TFragment extends Fragment + ? Simplify> + : never; diff --git a/packages/groq-builder/src/types/utils.test.ts b/packages/groq-builder/src/types/utils.test.ts index c4a870bd..c1d4c0c0 100644 --- a/packages/groq-builder/src/types/utils.test.ts +++ b/packages/groq-builder/src/types/utils.test.ts @@ -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 = TypeMismatchError<{ @@ -42,3 +48,27 @@ describe("ExtractTypeMismatchErrors", () => { expectType>().toStrictEqual(); }); }); + +describe("Tagged", () => { + type Base = { + name: string; + }; + type TagInfo = { + tagInfo: string; + }; + type BaseWithTag = Tagged; + + 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().not.toStrictEqual(); + }); + it("should be able to unwrap the tag", () => { + expectType>().toStrictEqual(); + }); + it("should be able to extract the tag info", () => { + expectType>().toStrictEqual(); + }); +}); diff --git a/packages/groq-builder/src/types/utils.ts b/packages/groq-builder/src/types/utils.ts index f9aaf428..86014330 100644 --- a/packages/groq-builder/src/types/utils.ts +++ b/packages/groq-builder/src/types/utils.ts @@ -103,3 +103,22 @@ export type StringKeys = Exclude; export type ButFirst> = 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 = "hello"; + * + */ +export type Tagged = TActual & { readonly [Tag]?: TTag }; +export type TaggedUnwrap = Omit; +export type TaggedType> = + TTagged extends Tagged ? TTag : never; +declare const Tag: unique symbol; + +/** + * A completely empty object. + */ +export type Empty = Record;