From d035d5a0b7618a7a601e3210f8d3b5f743080bca Mon Sep 17 00:00:00 2001 From: scottrippey Date: Sat, 23 Dec 2023 21:21:34 -0600 Subject: [PATCH 01/45] feature(conditionals): added conditional type helpers --- .../src/commands/conditional-types.ts | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 packages/groq-builder/src/commands/conditional-types.ts diff --git a/packages/groq-builder/src/commands/conditional-types.ts b/packages/groq-builder/src/commands/conditional-types.ts new file mode 100644 index 0000000..2bc66f1 --- /dev/null +++ b/packages/groq-builder/src/commands/conditional-types.ts @@ -0,0 +1,66 @@ +import { + ExtractProjectionResult, + ProjectionMap, + ProjectionMapOrCallback, +} from "./projection-types"; +import { Empty, Simplify, Tagged, ValueOf } from "../types/utils"; +import { ExtractTypeNames, RootConfig } from "../types/schema-types"; + +export type ConditionalProjections = { + [Condition in ConditionalExpression]: ProjectionMap; +}; + +export type ConditionalExpression = Tagged; + +export type WrapConditionalProjectionResults< + TConditionalProjections extends ConditionalProjections +> = TConditionalProjections extends ConditionalProjections + ? ConditionalProjectionResultWrapper< + ValueOf<{ + [Condition in keyof TConditionalProjections]: Simplify< + ExtractProjectionResult< + TResultItem, + TConditionalProjections[Condition] + > + >; + }> + > + : never; + +declare const ConditionalProjectionResultTypes: unique symbol; +export type ConditionalProjectionResultWrapper = { + [ConditionalProjectionResultTypes]: TResultTypes; +}; + +export type ExtractConditionalProjectionTypes = + TResultItem extends ConditionalProjectionResultWrapper + ? TResultTypes + : Empty; + +export type ConditionalByTypeProjections< + TResultItem, + TRootConfig extends RootConfig +> = { + [_type in ExtractTypeNames]: ProjectionMapOrCallback< + TResultItem, + TRootConfig + >; +}; + +export type ExtractConditionalByTypeProjectionResults< + TConditionalProjections extends ConditionalByTypeProjections +> = TConditionalProjections extends ConditionalByTypeProjections< + infer TResultItem, + any +> + ? ConditionalProjectionResultWrapper< + ValueOf<{ + [_type in keyof TConditionalProjections]: TConditionalProjections[_type] extends ProjectionMapOrCallback< + infer TProjectionMap, + any + > + ? ExtractProjectionResult + : never; + }> + > + : never; From 623910ba3617d1e03166d129190564c5a98a38f6 Mon Sep 17 00:00:00 2001 From: scottrippey Date: Sun, 24 Dec 2023 00:01:28 -0600 Subject: [PATCH 02/45] feature(conditionals): implemented the `conditional$` method --- .../src/commands/conditional$.test.ts | 28 +++++++++++++++++ .../groq-builder/src/commands/conditional$.ts | 31 +++++++++++++++++++ .../src/commands/conditional-types.ts | 20 +++++------- 3 files changed, 67 insertions(+), 12 deletions(-) create mode 100644 packages/groq-builder/src/commands/conditional$.test.ts create mode 100644 packages/groq-builder/src/commands/conditional$.ts diff --git a/packages/groq-builder/src/commands/conditional$.test.ts b/packages/groq-builder/src/commands/conditional$.test.ts new file mode 100644 index 0000000..025d456 --- /dev/null +++ b/packages/groq-builder/src/commands/conditional$.test.ts @@ -0,0 +1,28 @@ +import { describe, it } from "vitest"; +import { createGroqBuilder } from "../index"; +import { SchemaConfig } from "../tests/schemas/nextjs-sanity-fe"; +import { ExtractConditionalProjectionTypes } from "./conditional-types"; +import { expectType } from "../tests/expectType"; + +const q = createGroqBuilder(); + +describe("conditional$", () => { + it("by itself, we should be able to extract the union of projection types", () => { + const qConditional = q.star.filterByType("variant").conditional$({ + '_type == "foo"': { + name: true, + price: true, + }, + '_type == "bar"': { + name: true, + msrp: true, + }, + }); + + expectType< + ExtractConditionalProjectionTypes + >().toStrictEqual< + { name: string; price: number } | { name: string; msrp: number } + >(); + }); +}); diff --git a/packages/groq-builder/src/commands/conditional$.ts b/packages/groq-builder/src/commands/conditional$.ts new file mode 100644 index 0000000..cee1b08 --- /dev/null +++ b/packages/groq-builder/src/commands/conditional$.ts @@ -0,0 +1,31 @@ +import { GroqBuilder } from "../groq-builder"; +import { ResultItem } from "../types/result-types"; +import { + ConditionalProjections, + WrapConditionalProjectionResults, +} from "./conditional-types"; + +declare module "../groq-builder" { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + export interface GroqBuilder { + conditional$< + TConditionalProjections extends ConditionalProjections< + ResultItem + > + >( + conditionalProjections: TConditionalProjections + ): WrapConditionalProjectionResults< + ResultItem, + TConditionalProjections + >; + } +} + +GroqBuilder.implement({ + conditional$(this: GroqBuilder, conditionalProjections): any { + // Just pass the object back as-is. + // The `project` method will turn it into a query. + // This utility is all about the TypeScript. + return conditionalProjections; + }, +}); diff --git a/packages/groq-builder/src/commands/conditional-types.ts b/packages/groq-builder/src/commands/conditional-types.ts index 2bc66f1..fe4c299 100644 --- a/packages/groq-builder/src/commands/conditional-types.ts +++ b/packages/groq-builder/src/commands/conditional-types.ts @@ -13,19 +13,15 @@ export type ConditionalProjections = { export type ConditionalExpression = Tagged; export type WrapConditionalProjectionResults< + TResultItem, TConditionalProjections extends ConditionalProjections -> = TConditionalProjections extends ConditionalProjections - ? ConditionalProjectionResultWrapper< - ValueOf<{ - [Condition in keyof TConditionalProjections]: Simplify< - ExtractProjectionResult< - TResultItem, - TConditionalProjections[Condition] - > - >; - }> - > - : never; +> = ConditionalProjectionResultWrapper< + ValueOf<{ + [Condition in keyof TConditionalProjections]: Simplify< + ExtractProjectionResult + >; + }> +>; declare const ConditionalProjectionResultTypes: unique symbol; export type ConditionalProjectionResultWrapper = { From 008db1c98dc814e46f1d669a4ca138b300470802 Mon Sep 17 00:00:00 2001 From: scottrippey Date: Sun, 24 Dec 2023 01:18:52 -0600 Subject: [PATCH 03/45] feature(conditionals): implemented `conditionalByType` method --- .../src/commands/conditional-types.ts | 39 +++++++------- .../src/commands/conditionalByType.test.ts | 53 +++++++++++++++++++ .../src/commands/conditionalByType.ts | 46 ++++++++++++++++ .../src/commands/projection-types.ts | 17 ++++-- .../groq-builder/src/types/schema-types.ts | 5 ++ packages/groq-builder/src/types/utils.ts | 4 ++ 6 files changed, 142 insertions(+), 22 deletions(-) create mode 100644 packages/groq-builder/src/commands/conditionalByType.test.ts create mode 100644 packages/groq-builder/src/commands/conditionalByType.ts diff --git a/packages/groq-builder/src/commands/conditional-types.ts b/packages/groq-builder/src/commands/conditional-types.ts index fe4c299..cc658d5 100644 --- a/packages/groq-builder/src/commands/conditional-types.ts +++ b/packages/groq-builder/src/commands/conditional-types.ts @@ -37,26 +37,29 @@ export type ConditionalByTypeProjections< TResultItem, TRootConfig extends RootConfig > = { - [_type in ExtractTypeNames]: ProjectionMapOrCallback< - TResultItem, + [_type in ExtractTypeNames]?: ProjectionMapOrCallback< + Extract, TRootConfig >; }; -export type ExtractConditionalByTypeProjectionResults< +export type WrapConditionalByTypeProjectionResults< + TResultItem, TConditionalProjections extends ConditionalByTypeProjections -> = TConditionalProjections extends ConditionalByTypeProjections< - infer TResultItem, - any -> - ? ConditionalProjectionResultWrapper< - ValueOf<{ - [_type in keyof TConditionalProjections]: TConditionalProjections[_type] extends ProjectionMapOrCallback< - infer TProjectionMap, - any - > - ? ExtractProjectionResult - : never; - }> - > - : never; +> = ConditionalProjectionResultWrapper< + Simplify< + ValueOf<{ + [_type in keyof TConditionalProjections]: TConditionalProjections[_type] extends ( + q: any + ) => infer TProjectionMap + ? ExtractProjectionResult< + Extract, + TProjectionMap + > + : ExtractProjectionResult< + Extract, + TConditionalProjections[_type] + >; + }> + > +>; diff --git a/packages/groq-builder/src/commands/conditionalByType.test.ts b/packages/groq-builder/src/commands/conditionalByType.test.ts new file mode 100644 index 0000000..8e4a066 --- /dev/null +++ b/packages/groq-builder/src/commands/conditionalByType.test.ts @@ -0,0 +1,53 @@ +import { describe, it, expect } from "vitest"; +import { createGroqBuilder, InferResultType } from "../index"; +import { SchemaConfig } from "../tests/schemas/nextjs-sanity-fe"; +import { ExtractConditionalProjectionTypes } from "./conditional-types"; +import { expectType } from "../tests/expectType"; + +const q = createGroqBuilder(); + +describe("conditionalByType", () => { + const qConditionalByType = q.star.conditionalByType({ + variant: { + _type: true, + name: true, + price: true, + }, + style: { + _type: true, + name: true, + slug: "slug.current", + }, + category: (qC) => ({ + _type: true, + name: true, + slug: qC.field("slug.current"), + }), + }); + + it("we should be able to extract the return types", () => { + type ConditionalResults = ExtractConditionalProjectionTypes< + typeof qConditionalByType + >; + + expectType().toStrictEqual< + | { _type: "variant"; name: string; price: number } + | { _type: "style"; name: string | undefined; slug: string } + | { _type: "category"; name: string; slug: string } + >(); + }); + + it("a projection should return the correct types", () => { + const qAll = q.star.project({ + _type: true, + ...q.conditionalByType({ + style: { name: true, color: true }, + variant: { name: true, price: true }, + }), + }); + + type QueryResult = InferResultType; + + expectType().toStrictEqual<>(); + }); +}); diff --git a/packages/groq-builder/src/commands/conditionalByType.ts b/packages/groq-builder/src/commands/conditionalByType.ts new file mode 100644 index 0000000..7c0a516 --- /dev/null +++ b/packages/groq-builder/src/commands/conditionalByType.ts @@ -0,0 +1,46 @@ +import { GroqBuilder } from "../groq-builder"; +import { RootConfig } from "../types/schema-types"; +import { ResultItem } from "../types/result-types"; +import { + ConditionalByTypeProjections, + ConditionalProjections, + WrapConditionalByTypeProjectionResults, +} from "./conditional-types"; +import { keys } from "../types/utils"; + +declare module "../groq-builder" { + export interface GroqBuilder { + conditionalByType< + TConditionalProjections extends ConditionalByTypeProjections< + ResultItem, + TRootConfig + > + >( + conditionalProjections: TConditionalProjections + ): WrapConditionalByTypeProjectionResults< + ResultItem, + TConditionalProjections + >; + } +} + +GroqBuilder.implement({ + conditionalByType( + this: GroqBuilder, + conditionalProjections + ) { + // eslint-disable-next-line @typescript-eslint/no-this-alias + const rootQ = this; + const conditions: ConditionalProjections = {}; + for (const _type of keys(conditionalProjections)) { + let projectionMap = conditionalProjections[_type]; + if (typeof projectionMap === "function") { + projectionMap = projectionMap(rootQ); + } + + const condition = `_type == "${_type}"`; + conditions[condition] = projectionMap as any; + } + return this.conditional$(conditions) as any; + }, +}); diff --git a/packages/groq-builder/src/commands/projection-types.ts b/packages/groq-builder/src/commands/projection-types.ts index 2f8ccbd..8409fb2 100644 --- a/packages/groq-builder/src/commands/projection-types.ts +++ b/packages/groq-builder/src/commands/projection-types.ts @@ -12,10 +12,11 @@ import { import { Parser } from "../types/public-types"; import { Path, PathEntries, PathValue } from "../types/path-types"; import { DeepRequired } from "../types/deep-required"; +import { RootConfig } from "../types/schema-types"; -export type ProjectionKey = ProjectionKeyImpl< - Simplify>> ->; +export type ProjectionKey = IsAny extends true + ? string + : ProjectionKeyImpl>>>; type ProjectionKeyImpl = ValueOf<{ [Key in keyof Entries]: Entries[Key] extends Array ? `${StringKeys}[]` | Key @@ -26,17 +27,25 @@ export type ProjectionKeyValue = PathValue< TResultItem, Extract> >; + export type ProjectionMap = { // This allows TypeScript to suggest known keys: [P in keyof TResultItem]?: ProjectionFieldConfig; } & { // This allows any keys to be used in a projection: - [P in string]: ProjectionFieldConfig; + [P in string]: ProjectionFieldConfig; } & { // Obviously this allows the ellipsis operator: "..."?: true; }; +export type ProjectionMapOrCallback< + TResultItem, + TRootConfig extends RootConfig +> = + | ProjectionMap + | ((q: GroqBuilder) => ProjectionMap); + type ProjectionFieldConfig = // Use 'true' to include a field as-is | true diff --git a/packages/groq-builder/src/types/schema-types.ts b/packages/groq-builder/src/types/schema-types.ts index 3e89ad7..94a6f47 100644 --- a/packages/groq-builder/src/types/schema-types.ts +++ b/packages/groq-builder/src/types/schema-types.ts @@ -35,6 +35,11 @@ export type ExtractDocumentTypes = Extract< { _type: string } >; +export type ExtractTypeNames = Extract< + TResultItem, + { _type: any } +>["_type"]; + export type RefType = { [P in referenceSymbol]: TTypeName; }; diff --git a/packages/groq-builder/src/types/utils.ts b/packages/groq-builder/src/types/utils.ts index 5eaa42c..6ccf6b5 100644 --- a/packages/groq-builder/src/types/utils.ts +++ b/packages/groq-builder/src/types/utils.ts @@ -125,3 +125,7 @@ export type Empty = Record; /** Taken from type-fest; checks if a type is any */ export type IsAny = 0 extends 1 & T ? true : false; + +export function keys(obj: T) { + return Object.keys(obj) as Array>; +} From afec46b2a582095bcf27fc1e769c462f643f31f4 Mon Sep 17 00:00:00 2001 From: scottrippey Date: Sun, 24 Dec 2023 01:35:21 -0600 Subject: [PATCH 04/45] feature(conditionals): ensure conditionals can be spread into projections --- .../src/commands/conditional-types.ts | 29 ++++++++++--------- .../src/commands/conditionalByType.test.ts | 29 +++++++++++++++---- .../src/commands/projection-types.ts | 16 ++++++++-- packages/groq-builder/src/types/utils.ts | 2 +- 4 files changed, 52 insertions(+), 24 deletions(-) diff --git a/packages/groq-builder/src/commands/conditional-types.ts b/packages/groq-builder/src/commands/conditional-types.ts index cc658d5..2a91d06 100644 --- a/packages/groq-builder/src/commands/conditional-types.ts +++ b/packages/groq-builder/src/commands/conditional-types.ts @@ -23,7 +23,7 @@ export type WrapConditionalProjectionResults< }> >; -declare const ConditionalProjectionResultTypes: unique symbol; +export declare const ConditionalProjectionResultTypes: unique symbol; export type ConditionalProjectionResultWrapper = { [ConditionalProjectionResultTypes]: TResultTypes; }; @@ -48,18 +48,19 @@ export type WrapConditionalByTypeProjectionResults< TConditionalProjections extends ConditionalByTypeProjections > = ConditionalProjectionResultWrapper< Simplify< - ValueOf<{ - [_type in keyof TConditionalProjections]: TConditionalProjections[_type] extends ( - q: any - ) => infer TProjectionMap - ? ExtractProjectionResult< - Extract, - TProjectionMap - > - : ExtractProjectionResult< - Extract, - TConditionalProjections[_type] - >; - }> + | Empty + | ValueOf<{ + [_type in keyof TConditionalProjections]: TConditionalProjections[_type] extends ( + q: any + ) => infer TProjectionMap + ? ExtractProjectionResult< + Extract, + TProjectionMap + > + : ExtractProjectionResult< + Extract, + TConditionalProjections[_type] + >; + }> > >; diff --git a/packages/groq-builder/src/commands/conditionalByType.test.ts b/packages/groq-builder/src/commands/conditionalByType.test.ts index 8e4a066..985f697 100644 --- a/packages/groq-builder/src/commands/conditionalByType.test.ts +++ b/packages/groq-builder/src/commands/conditionalByType.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from "vitest"; +import { describe, it } from "vitest"; import { createGroqBuilder, InferResultType } from "../index"; import { SchemaConfig } from "../tests/schemas/nextjs-sanity-fe"; import { ExtractConditionalProjectionTypes } from "./conditional-types"; @@ -31,6 +31,7 @@ describe("conditionalByType", () => { >; expectType().toStrictEqual< + | {} | { _type: "variant"; name: string; price: number } | { _type: "style"; name: string | undefined; slug: string } | { _type: "category"; name: string; slug: string } @@ -38,16 +39,32 @@ describe("conditionalByType", () => { }); it("a projection should return the correct types", () => { - const qAll = q.star.project({ + const qAll = q.star.project((qA) => ({ _type: true, - ...q.conditionalByType({ - style: { name: true, color: true }, + ...qA.conditionalByType({ + style: { name: true, slug: "slug.current" }, variant: { name: true, price: true }, }), - }); + })); type QueryResult = InferResultType; - expectType().toStrictEqual<>(); + expectType().toStrictEqual< + Array< + | { + _type: SchemaConfig["documentTypes"]["_type"]; + } + | { + _type: SchemaConfig["documentTypes"]["_type"]; + name: string | undefined; + slug: string; + } + | { + _type: SchemaConfig["documentTypes"]["_type"]; + name: string; + price: number; + } + > + >(); }); }); diff --git a/packages/groq-builder/src/commands/projection-types.ts b/packages/groq-builder/src/commands/projection-types.ts index 8409fb2..b2260d0 100644 --- a/packages/groq-builder/src/commands/projection-types.ts +++ b/packages/groq-builder/src/commands/projection-types.ts @@ -5,7 +5,7 @@ import { Simplify, SimplifyDeep, StringKeys, - TaggedUnwrap, + Tag, TypeMismatchError, ValueOf, } from "../types/utils"; @@ -13,6 +13,10 @@ import { Parser } from "../types/public-types"; import { Path, PathEntries, PathValue } from "../types/path-types"; import { DeepRequired } from "../types/deep-required"; import { RootConfig } from "../types/schema-types"; +import { + ConditionalProjectionResultTypes, + ConditionalProjectionResultWrapper, +} from "./conditional-types"; export type ProjectionKey = IsAny extends true ? string @@ -60,11 +64,17 @@ type ProjectionFieldConfig = export type ExtractProjectionResult = (TProjectionMap extends { "...": true } ? TResultItem : Empty) & + (TProjectionMap extends ConditionalProjectionResultWrapper< + infer TConditionalTypes + > + ? TConditionalTypes + : Empty) & ExtractProjectionResultImpl< TResultItem, Omit< - TaggedUnwrap, // Ensure we unwrap any tags (used by Fragments) - "..." + TProjectionMap, + // Ensure we remove any "tags" that we don't want in the mapped type: + "..." | typeof ConditionalProjectionResultTypes | typeof Tag > >; diff --git a/packages/groq-builder/src/types/utils.ts b/packages/groq-builder/src/types/utils.ts index 6ccf6b5..a28a1fb 100644 --- a/packages/groq-builder/src/types/utils.ts +++ b/packages/groq-builder/src/types/utils.ts @@ -116,7 +116,7 @@ export type Tagged = TActual & { readonly [Tag]?: TTag }; export type TaggedUnwrap = Omit; export type TaggedType> = TTagged extends Tagged ? TTag : never; -declare const Tag: unique symbol; +export declare const Tag: unique symbol; /** * A completely empty object. From bf8e8f421cf39421c68c05209d323adcc028da56 Mon Sep 17 00:00:00 2001 From: scottrippey Date: Sun, 24 Dec 2023 20:37:36 -0600 Subject: [PATCH 05/45] feature(conditionals): use custom tagged types --- packages/groq-builder/src/commands/conditional-types.ts | 4 ++-- packages/groq-builder/src/commands/projection-types.ts | 9 +++++---- packages/groq-builder/src/types/public-types.ts | 3 ++- packages/groq-builder/src/types/utils.ts | 2 +- 4 files changed, 10 insertions(+), 8 deletions(-) diff --git a/packages/groq-builder/src/commands/conditional-types.ts b/packages/groq-builder/src/commands/conditional-types.ts index 2a91d06..f568bc9 100644 --- a/packages/groq-builder/src/commands/conditional-types.ts +++ b/packages/groq-builder/src/commands/conditional-types.ts @@ -23,9 +23,9 @@ export type WrapConditionalProjectionResults< }> >; -export declare const ConditionalProjectionResultTypes: unique symbol; +export declare const ConditionalProjectionResultTypesTag: unique symbol; export type ConditionalProjectionResultWrapper = { - [ConditionalProjectionResultTypes]: TResultTypes; + readonly [ConditionalProjectionResultTypesTag]?: TResultTypes; }; export type ExtractConditionalProjectionTypes = diff --git a/packages/groq-builder/src/commands/projection-types.ts b/packages/groq-builder/src/commands/projection-types.ts index b2260d0..7780546 100644 --- a/packages/groq-builder/src/commands/projection-types.ts +++ b/packages/groq-builder/src/commands/projection-types.ts @@ -5,16 +5,15 @@ import { Simplify, SimplifyDeep, StringKeys, - Tag, TypeMismatchError, ValueOf, } from "../types/utils"; -import { Parser } from "../types/public-types"; +import { FragmentInputTypeTag, Parser } from "../types/public-types"; import { Path, PathEntries, PathValue } from "../types/path-types"; import { DeepRequired } from "../types/deep-required"; import { RootConfig } from "../types/schema-types"; import { - ConditionalProjectionResultTypes, + ConditionalProjectionResultTypesTag, ConditionalProjectionResultWrapper, } from "./conditional-types"; @@ -74,7 +73,9 @@ export type ExtractProjectionResult = Omit< TProjectionMap, // Ensure we remove any "tags" that we don't want in the mapped type: - "..." | typeof ConditionalProjectionResultTypes | typeof Tag + | "..." + | typeof ConditionalProjectionResultTypesTag + | typeof FragmentInputTypeTag > >; diff --git a/packages/groq-builder/src/types/public-types.ts b/packages/groq-builder/src/types/public-types.ts index 521f9d7..e3d584c 100644 --- a/packages/groq-builder/src/types/public-types.ts +++ b/packages/groq-builder/src/types/public-types.ts @@ -56,10 +56,11 @@ export type InferResultItem = ResultItem< InferResultType >; +export declare const FragmentInputTypeTag: unique symbol; export type Fragment< TProjectionMap, TFragmentInput // This is used to capture the type, to be extracted by `InferFragmentType` -> = Tagged; +> = TProjectionMap & { readonly [FragmentInputTypeTag]?: TFragmentInput }; export type InferFragmentType> = TFragment extends Fragment diff --git a/packages/groq-builder/src/types/utils.ts b/packages/groq-builder/src/types/utils.ts index a28a1fb..6ccf6b5 100644 --- a/packages/groq-builder/src/types/utils.ts +++ b/packages/groq-builder/src/types/utils.ts @@ -116,7 +116,7 @@ export type Tagged = TActual & { readonly [Tag]?: TTag }; export type TaggedUnwrap = Omit; export type TaggedType> = TTagged extends Tagged ? TTag : never; -export declare const Tag: unique symbol; +declare const Tag: unique symbol; /** * A completely empty object. From f72dc030c8925921db08e38c0586f83daf052195 Mon Sep 17 00:00:00 2001 From: scottrippey Date: Mon, 25 Dec 2023 04:26:39 -0600 Subject: [PATCH 06/45] feature(conditionals): `filterByType` can accept multiple types --- packages/groq-builder/src/commands/filterByType.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/groq-builder/src/commands/filterByType.ts b/packages/groq-builder/src/commands/filterByType.ts index d31b304..17d000d 100644 --- a/packages/groq-builder/src/commands/filterByType.ts +++ b/packages/groq-builder/src/commands/filterByType.ts @@ -1,12 +1,11 @@ import { GroqBuilder } from "../groq-builder"; import { ResultItem, ResultOverride } from "../types/result-types"; +import { ExtractTypeNames } from "../types/schema-types"; declare module "../groq-builder" { export interface GroqBuilder { - filterByType< - TType extends Extract, { _type: string }>["_type"] - >( - type: TType + filterByType>>( + ...type: TType[] ): GroqBuilder< ResultOverride, { _type: TType }>>, TRootConfig @@ -15,7 +14,10 @@ declare module "../groq-builder" { } GroqBuilder.implement({ - filterByType(this: GroqBuilder, type) { - return this.chain(`[_type == "${type}"]`, null); + filterByType(this: GroqBuilder, ...type) { + return this.chain( + `[${type.map((t) => `_type == "${t}"`).join(" || ")}]`, + null + ); }, }); From c7f5fb82f38615a03805db572ee753c1b3fc99df Mon Sep 17 00:00:00 2001 From: scottrippey Date: Mon, 25 Dec 2023 04:30:19 -0600 Subject: [PATCH 07/45] feature(conditionals): improved handling of `indent` --- packages/groq-builder/src/commands/project.ts | 24 +++++------------ packages/groq-builder/src/groq-builder.ts | 27 +++++++++++++++++++ packages/groq-builder/src/index.ts | 6 ++--- .../groq-builder/src/types/schema-types.ts | 2 +- 4 files changed, 37 insertions(+), 22 deletions(-) diff --git a/packages/groq-builder/src/commands/project.ts b/packages/groq-builder/src/commands/project.ts index a8c898b..a742c22 100644 --- a/packages/groq-builder/src/commands/project.ts +++ b/packages/groq-builder/src/commands/project.ts @@ -31,22 +31,10 @@ GroqBuilder.implement({ this: GroqBuilder, projectionMapArg: object | ((q: GroqBuilder) => object) ): GroqBuilder { - // Make the query pretty, if needed: - const indent = this.internal.options.indent; - const indent2 = indent ? indent + " " : ""; - // Retrieve the projectionMap: let projectionMap: object; if (typeof projectionMapArg === "function") { - const newQ = new GroqBuilder({ - query: "", - parser: null, - options: { - ...this.internal.options, - indent: indent2, - }, - }); - projectionMap = projectionMapArg(newQ); + projectionMap = projectionMapArg(this.root); } else { projectionMap = projectionMapArg; } @@ -94,10 +82,12 @@ GroqBuilder.implement({ .filter(notNull); const queries = values.map((v) => v.query); - const newLine = indent ? "\n" : " "; - const newQuery = ` {${newLine}${indent2}${queries.join( - "," + newLine + indent2 - )}${newLine}${indent}}`; + + const { newLine, space } = this.indentation; + + const newQuery = ` {${newLine}${space}${queries.join( + `,${newLine}${space}` + )}${newLine}}`; type TResult = Record; const parsers = values.filter((v) => v.parser); diff --git a/packages/groq-builder/src/groq-builder.ts b/packages/groq-builder/src/groq-builder.ts index 67b2daf..5c51d74 100644 --- a/packages/groq-builder/src/groq-builder.ts +++ b/packages/groq-builder/src/groq-builder.ts @@ -6,6 +6,8 @@ import { } from "./commands/validate-utils"; import { ValidationErrors } from "./validation/validation-errors"; +export type RootResult = never; + export type GroqBuilderOptions = { /** * Enables "pretty printing" for the compiled GROQ string. Useful for debugging @@ -91,4 +93,29 @@ export class GroqBuilder< options: this.internal.options, }); } + + /** + * Returns an empty GroqBuilder + */ + public get root() { + let options = this.internal.options; + // Make the query pretty, if needed: + if (options.indent) { + options = { ...options, indent: options.indent + " " }; + } + + return new GroqBuilder({ + query: "", + parser: null, + options: options, + }); + } + + public get indentation() { + const indent = this.internal.options.indent; + return { + newLine: indent ? `\n${indent}` : " ", + space: indent ? " " : "", + }; + } } diff --git a/packages/groq-builder/src/index.ts b/packages/groq-builder/src/index.ts index 91efa6f..6e54f30 100644 --- a/packages/groq-builder/src/index.ts +++ b/packages/groq-builder/src/index.ts @@ -1,5 +1,5 @@ // Be sure to keep these 2 imports in the correct order: -import { GroqBuilder, GroqBuilderOptions } from "./groq-builder"; +import { GroqBuilder, GroqBuilderOptions, RootResult } from "./groq-builder"; import "./commands"; import type { RootConfig } from "./types/schema-types"; @@ -8,11 +8,9 @@ import type { ButFirst } from "./types/utils"; // Export all our public types: export * from "./types/public-types"; export * from "./types/schema-types"; -export { GroqBuilder, GroqBuilderOptions } from "./groq-builder"; +export { GroqBuilder, GroqBuilderOptions, RootResult } from "./groq-builder"; export { validation } from "./validation"; -type RootResult = never; - /** * Creates the root `q` query builder. * diff --git a/packages/groq-builder/src/types/schema-types.ts b/packages/groq-builder/src/types/schema-types.ts index 94a6f47..f139ed4 100644 --- a/packages/groq-builder/src/types/schema-types.ts +++ b/packages/groq-builder/src/types/schema-types.ts @@ -37,7 +37,7 @@ export type ExtractDocumentTypes = Extract< export type ExtractTypeNames = Extract< TResultItem, - { _type: any } + { _type: string } >["_type"]; export type RefType = { From 70fa8b668fedff41590d2a9a24dcbba5edae5729 Mon Sep 17 00:00:00 2001 From: scottrippey Date: Mon, 25 Dec 2023 04:30:36 -0600 Subject: [PATCH 08/45] feature(conditionals): implemented conditionals --- .../groq-builder/src/commands/conditional$.ts | 26 ++++- .../src/commands/conditional-types.ts | 14 ++- .../src/commands/conditionalByType.test.ts | 95 ++++++++++++++----- .../src/commands/conditionalByType.ts | 12 +-- packages/groq-builder/src/commands/index.ts | 2 + 5 files changed, 105 insertions(+), 44 deletions(-) diff --git a/packages/groq-builder/src/commands/conditional$.ts b/packages/groq-builder/src/commands/conditional$.ts index cee1b08..97ec716 100644 --- a/packages/groq-builder/src/commands/conditional$.ts +++ b/packages/groq-builder/src/commands/conditional$.ts @@ -10,7 +10,8 @@ declare module "../groq-builder" { export interface GroqBuilder { conditional$< TConditionalProjections extends ConditionalProjections< - ResultItem + ResultItem, + TRootConfig > >( conditionalProjections: TConditionalProjections @@ -23,9 +24,24 @@ declare module "../groq-builder" { GroqBuilder.implement({ conditional$(this: GroqBuilder, conditionalProjections): any { - // Just pass the object back as-is. - // The `project` method will turn it into a query. - // This utility is all about the TypeScript. - return conditionalProjections; + // Return an object; the `project` method will turn it into a query. + return Object.fromEntries( + Object.entries(conditionalProjections).map( + ([condition, projectionMap]) => { + if (typeof projectionMap === "function") { + projectionMap = projectionMap(this.root); + } + + const projection = this.root + .chain(`${condition} => `) + .project(projectionMap); + + // By returning a key that's equal to the query, + // this will instruct `project` to output the entry without ":" + const newKey = projection.query; + return [newKey, projection]; + } + ) + ); }, }); diff --git a/packages/groq-builder/src/commands/conditional-types.ts b/packages/groq-builder/src/commands/conditional-types.ts index f568bc9..ebf9728 100644 --- a/packages/groq-builder/src/commands/conditional-types.ts +++ b/packages/groq-builder/src/commands/conditional-types.ts @@ -5,16 +5,24 @@ import { } from "./projection-types"; import { Empty, Simplify, Tagged, ValueOf } from "../types/utils"; import { ExtractTypeNames, RootConfig } from "../types/schema-types"; +import { GroqBuilder } from "../groq-builder"; -export type ConditionalProjections = { - [Condition in ConditionalExpression]: ProjectionMap; +export type ConditionalProjections< + TResultItem, + TRootConfig extends RootConfig +> = { + [Condition: ConditionalExpression]: + | ProjectionMap + | (( + q: GroqBuilder + ) => ProjectionMap); }; export type ConditionalExpression = Tagged; export type WrapConditionalProjectionResults< TResultItem, - TConditionalProjections extends ConditionalProjections + TConditionalProjections extends ConditionalProjections > = ConditionalProjectionResultWrapper< ValueOf<{ [Condition in keyof TConditionalProjections]: Simplify< diff --git a/packages/groq-builder/src/commands/conditionalByType.test.ts b/packages/groq-builder/src/commands/conditionalByType.test.ts index 985f697..6ba50d8 100644 --- a/packages/groq-builder/src/commands/conditionalByType.test.ts +++ b/packages/groq-builder/src/commands/conditionalByType.test.ts @@ -1,23 +1,22 @@ -import { describe, it } from "vitest"; -import { createGroqBuilder, InferResultType } from "../index"; +import { describe, it, expect } from "vitest"; +import { createGroqBuilder, ExtractTypeNames, InferResultType } from "../index"; import { SchemaConfig } from "../tests/schemas/nextjs-sanity-fe"; import { ExtractConditionalProjectionTypes } from "./conditional-types"; import { expectType } from "../tests/expectType"; +import { executeBuilder } from "../tests/mocks/executeQuery"; +import { mock } from "../tests/mocks/nextjs-sanity-fe-mocks"; -const q = createGroqBuilder(); +const q = createGroqBuilder({ indent: " " }); +const data = mock.generateSeedData({ + products: mock.array(5, (i) => + mock.product({ slug: mock.slug({ current: `product-slug-${i}` }) }) + ), +}); describe("conditionalByType", () => { const qConditionalByType = q.star.conditionalByType({ - variant: { - _type: true, - name: true, - price: true, - }, - style: { - _type: true, - name: true, - slug: "slug.current", - }, + variant: { _type: true, name: true, price: true }, + product: { _type: true, name: true, slug: "slug.current" }, category: (qC) => ({ _type: true, name: true, @@ -25,6 +24,14 @@ describe("conditionalByType", () => { }), }); + const qAll = q.star.project((qA) => ({ + _type: true, + ...qA.conditionalByType({ + product: { name: true, slug: "slug.current" }, + variant: { name: true, price: true }, + }), + })); + it("we should be able to extract the return types", () => { type ConditionalResults = ExtractConditionalProjectionTypes< typeof qConditionalByType @@ -33,38 +40,74 @@ describe("conditionalByType", () => { expectType().toStrictEqual< | {} | { _type: "variant"; name: string; price: number } - | { _type: "style"; name: string | undefined; slug: string } + | { _type: "product"; name: string; slug: string } | { _type: "category"; name: string; slug: string } >(); }); it("a projection should return the correct types", () => { - const qAll = q.star.project((qA) => ({ - _type: true, - ...qA.conditionalByType({ - style: { name: true, slug: "slug.current" }, - variant: { name: true, price: true }, - }), - })); - type QueryResult = InferResultType; + type DocTypes = ExtractTypeNames; expectType().toStrictEqual< Array< | { - _type: SchemaConfig["documentTypes"]["_type"]; + _type: DocTypes; } | { - _type: SchemaConfig["documentTypes"]["_type"]; - name: string | undefined; + _type: DocTypes; + name: string; slug: string; } | { - _type: SchemaConfig["documentTypes"]["_type"]; + _type: DocTypes; name: string; price: number; } > >(); }); + + it("a projection should return the correct query", () => { + expect(qAll.query).toMatchInlineSnapshot(` + "* { + _type, + _type == \\"product\\" => { + name, + \\"slug\\": slug.current + }, + _type == \\"variant\\" => { + name, + price + } + }" + `); + }); + + it("should execute correctly", async () => { + const res = await executeBuilder(qAll, data.datalake); + + expect(res.find((item) => item._type === "category")) + .toMatchInlineSnapshot(` + { + "_type": "category", + } + `); + expect(res.find((item) => item._type === "variant")).toMatchInlineSnapshot(` + { + "_type": "variant", + "name": "Variant 0", + "price": 0, + } + `); + expect(res.find((item) => item._type === "product")).toMatchInlineSnapshot( + ` + { + "_type": "product", + "name": "Name", + "slug": "product-slug-0", + } + ` + ); + }); }); diff --git a/packages/groq-builder/src/commands/conditionalByType.ts b/packages/groq-builder/src/commands/conditionalByType.ts index 7c0a516..4a19534 100644 --- a/packages/groq-builder/src/commands/conditionalByType.ts +++ b/packages/groq-builder/src/commands/conditionalByType.ts @@ -29,17 +29,9 @@ GroqBuilder.implement({ this: GroqBuilder, conditionalProjections ) { - // eslint-disable-next-line @typescript-eslint/no-this-alias - const rootQ = this; - const conditions: ConditionalProjections = {}; + const conditions: ConditionalProjections = {}; for (const _type of keys(conditionalProjections)) { - let projectionMap = conditionalProjections[_type]; - if (typeof projectionMap === "function") { - projectionMap = projectionMap(rootQ); - } - - const condition = `_type == "${_type}"`; - conditions[condition] = projectionMap as any; + conditions[`_type == "${_type}"`] = conditionalProjections[_type] as any; } return this.conditional$(conditions) as any; }, diff --git a/packages/groq-builder/src/commands/index.ts b/packages/groq-builder/src/commands/index.ts index e39530d..cc2293e 100644 --- a/packages/groq-builder/src/commands/index.ts +++ b/packages/groq-builder/src/commands/index.ts @@ -1,3 +1,5 @@ +import "./conditional$"; +import "./conditionalByType"; import "./deref"; import "./filter"; import "./filterByType"; From fdefb468692efc17bc423f1964f5b5be54af2d82 Mon Sep 17 00:00:00 2001 From: scottrippey Date: Tue, 26 Dec 2023 14:24:24 -0600 Subject: [PATCH 09/45] feature(conditionals): implemented `select` method --- .../src/commands/functions/value.ts | 24 ++++ packages/groq-builder/src/commands/index.ts | 2 + packages/groq-builder/src/commands/project.ts | 6 +- .../groq-builder/src/commands/select$.test.ts | 85 +++++++++++++ packages/groq-builder/src/commands/select$.ts | 118 ++++++++++++++++++ .../groq-builder/src/commands/select-types.ts | 35 ++++++ packages/groq-builder/src/groq-builder.ts | 2 +- packages/groq-builder/src/types/utils.ts | 4 + 8 files changed, 270 insertions(+), 6 deletions(-) create mode 100644 packages/groq-builder/src/commands/functions/value.ts create mode 100644 packages/groq-builder/src/commands/select$.test.ts create mode 100644 packages/groq-builder/src/commands/select$.ts create mode 100644 packages/groq-builder/src/commands/select-types.ts diff --git a/packages/groq-builder/src/commands/functions/value.ts b/packages/groq-builder/src/commands/functions/value.ts new file mode 100644 index 0000000..bddf1f9 --- /dev/null +++ b/packages/groq-builder/src/commands/functions/value.ts @@ -0,0 +1,24 @@ +import { GroqBuilder } from "../../groq-builder"; +import { Parser } from "../../types/public-types"; + +declare module "../../groq-builder" { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + export interface GroqBuilder { + value( + value: T, + validation?: Parser + ): GroqBuilder; + } +} + +GroqBuilder.implement({ + value(this: GroqBuilder, value) { + return this.chain(escapeValue(value)); + }, +}); + +export type LiteralValueTypes = string | boolean | number | null; + +function escapeValue(value: LiteralValueTypes): string { + return JSON.stringify(value); +} diff --git a/packages/groq-builder/src/commands/index.ts b/packages/groq-builder/src/commands/index.ts index cc2293e..2ccbafd 100644 --- a/packages/groq-builder/src/commands/index.ts +++ b/packages/groq-builder/src/commands/index.ts @@ -13,3 +13,5 @@ import "./slice"; import "./slug"; import "./star"; import "./validate"; + +import "./functions/value"; diff --git a/packages/groq-builder/src/commands/project.ts b/packages/groq-builder/src/commands/project.ts index a742c22..3a67495 100644 --- a/packages/groq-builder/src/commands/project.ts +++ b/packages/groq-builder/src/commands/project.ts @@ -1,4 +1,4 @@ -import { Simplify } from "../types/utils"; +import { notNull, Simplify } from "../types/utils"; import { GroqBuilder } from "../groq-builder"; import { Parser, ParserFunction } from "../types/public-types"; import { isParser, normalizeValidationFunction } from "./validate-utils"; @@ -125,7 +125,3 @@ GroqBuilder.implement({ return this.chain(newQuery, newParser); }, }); - -function notNull(value: T | null): value is T { - return !!value; -} diff --git a/packages/groq-builder/src/commands/select$.test.ts b/packages/groq-builder/src/commands/select$.test.ts new file mode 100644 index 0000000..c71e5ce --- /dev/null +++ b/packages/groq-builder/src/commands/select$.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from "vitest"; +import { createGroqBuilder, InferResultType } from "../index"; +import { SchemaConfig } from "../tests/schemas/nextjs-sanity-fe"; +import { expectType } from "../tests/expectType"; +import { mock } from "../tests/mocks/nextjs-sanity-fe-mocks"; +import { executeBuilder } from "../tests/mocks/executeQuery"; + +const q = createGroqBuilder({ indent: " " }); + +describe("select$", () => { + const qBase = q.star.filterByType("variant", "product", "category"); + + describe("with a default value", () => { + const qSelect = qBase.project({ + selected: q.select$( + { + '_type == "variant"': q.value("VARIANT"), + '_type == "product"': q.value("PRODUCT"), + }, + q.value("OTHER") + ), + }); + + const data = mock.generateSeedData({ + variants: mock.array(2, () => mock.variant({})), + products: mock.array(3, () => mock.product({})), + categories: mock.array(4, () => mock.category({})), + }); + + it("the result types should be correct", () => { + expectType>().toStrictEqual< + Array<{ + selected: "VARIANT" | "PRODUCT" | "OTHER"; + }> + >(); + }); + + it("the query should be formed correctly", () => { + expect(qSelect.query).toMatchInlineSnapshot(` + "*[_type == \\"variant\\" || _type == \\"product\\" || _type == \\"category\\"] { + \\"selected\\": select( + _type == \\"variant\\" => \\"VARIANT\\", + _type == \\"product\\" => \\"PRODUCT\\", + \\"OTHER\\" + ) + }" + `); + }); + + it("should execute correctly", async () => { + const results = await executeBuilder(qSelect, data.datalake); + expect(results).toMatchInlineSnapshot(` + [ + { + "selected": "PRODUCT", + }, + { + "selected": "PRODUCT", + }, + { + "selected": "PRODUCT", + }, + { + "selected": "OTHER", + }, + { + "selected": "OTHER", + }, + { + "selected": "OTHER", + }, + { + "selected": "OTHER", + }, + { + "selected": "VARIANT", + }, + { + "selected": "VARIANT", + }, + ] + `); + }); + }); +}); diff --git a/packages/groq-builder/src/commands/select$.ts b/packages/groq-builder/src/commands/select$.ts new file mode 100644 index 0000000..44e398a --- /dev/null +++ b/packages/groq-builder/src/commands/select$.ts @@ -0,0 +1,118 @@ +import { GroqBuilder } from "../groq-builder"; +import { ResultItem, ResultOverride } from "../types/result-types"; +import { ExtractSelectResult, SelectProjections } from "./select-types"; +import { notNull, Simplify, SimplifyDeep } from "../types/utils"; +import { InferResultType, ParserFunction } from "../types/public-types"; + +declare module "../groq-builder" { + export interface GroqBuilder { + select$< + TSelectProjections extends SelectProjections< + ResultItem, + TRootConfig + >, + TDefault extends null | GroqBuilder = null + >( + selections: + | TSelectProjections + | (( + q: GroqBuilder, TRootConfig> + ) => TSelectProjections), + defaultSelection?: + | TDefault + | ((q: GroqBuilder, TRootConfig>) => TDefault) + ): GroqBuilder< + ResultOverride< + TResult, + | ExtractSelectResult + | (TDefault extends null + ? null + : InferResultType>) + >, + TRootConfig + >; + } +} +GroqBuilder.implement({ + select$( + this: GroqBuilder, + selectionsArg, + defaultSelectionArg + ): GroqBuilder { + const selections = + typeof selectionsArg === "function" + ? selectionsArg(this.root) + : selectionsArg; + + const defaultSelection = + typeof defaultSelectionArg === "function" + ? defaultSelectionArg(this.root) + : defaultSelectionArg; + + const conditions = Object.keys(selections); + + const queries = conditions.map((condition) => { + const builder = selections[condition]; + return `${condition} => ${builder.query}`; + }); + + if (defaultSelection) { + queries.push(defaultSelection.query); + } + + const parsers = conditions + .map((c) => selections[c].internal.parser) + .filter(notNull); + const conditionalParser = + parsers.length === 0 + ? null + : createConditionalParser(parsers, defaultSelection?.internal.parser); + + // Check that we've got "all or nothing" parsers: + if (parsers.length !== 0 && parsers.length !== conditions.length) { + const missing = conditions.filter((c) => !selections[c].internal.parser); + const err = new TypeError( + "When using 'select', either all conditions must have validation, or none of them. " + + `Missing validation: "${missing.join('", "')}"` + ); + Error.captureStackTrace(err, GroqBuilder.prototype.select$); + throw err; + } + + const { newLine, space } = this.indentation; + return this.chain( + `select(${newLine}${space}${queries.join( + `,${newLine}${space}` + )}${newLine})`, + conditionalParser + ); + }, +}); + +function createConditionalParser( + parsers: Array, + defaultParser?: ParserFunction | null +): ParserFunction { + return function conditionalParser(input) { + if (input === null && !defaultParser) { + return null; + } + for (const parser of parsers) { + try { + // Returns the first parser that passes without an error: + const result = parser(input); + return result; + } catch (err) { + // Ignore the error, keep trying + } + } + + if (defaultParser) { + return defaultParser(input); + } + + throw new TypeError( + `Conditional parsing failed; all ${parsers.length} conditions failed trying to parse ${input}` + ); + }; +} diff --git a/packages/groq-builder/src/commands/select-types.ts b/packages/groq-builder/src/commands/select-types.ts new file mode 100644 index 0000000..a492644 --- /dev/null +++ b/packages/groq-builder/src/commands/select-types.ts @@ -0,0 +1,35 @@ +import { ExtractTypeNames, RootConfig } from "../types/schema-types"; +import { StringKeys, ValueOf } from "../types/utils"; +import { ConditionalExpression } from "./conditional-types"; +import { GroqBuilder } from "../groq-builder"; +import { InferResultType } from "../types/public-types"; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export type SelectProjections = { + [Condition: ConditionalExpression]: GroqBuilder; +}; + +export type ExtractSelectResult< + TSelectProjections extends SelectProjections +> = ValueOf<{ + [P in StringKeys]: InferResultType< + TSelectProjections[P] + >; +}>; + +export type SelectByTypeProjections< + TResultItem, + TRootConfig extends RootConfig +> = { + [_type in ExtractTypeNames]?: ( + q: GroqBuilder, TRootConfig> + ) => GroqBuilder; +}; + +export type ExtractSelectByTypeResult< + TSelectProjections extends SelectByTypeProjections +> = ValueOf<{ + [_type in keyof TSelectProjections]: InferResultType< + ReturnType> + >; +}>; diff --git a/packages/groq-builder/src/groq-builder.ts b/packages/groq-builder/src/groq-builder.ts index 5c51d74..423e853 100644 --- a/packages/groq-builder/src/groq-builder.ts +++ b/packages/groq-builder/src/groq-builder.ts @@ -111,7 +111,7 @@ export class GroqBuilder< }); } - public get indentation() { + protected get indentation() { const indent = this.internal.options.indent; return { newLine: indent ? `\n${indent}` : " ", diff --git a/packages/groq-builder/src/types/utils.ts b/packages/groq-builder/src/types/utils.ts index 6ccf6b5..4900b6e 100644 --- a/packages/groq-builder/src/types/utils.ts +++ b/packages/groq-builder/src/types/utils.ts @@ -129,3 +129,7 @@ export type IsAny = 0 extends 1 & T ? true : false; export function keys(obj: T) { return Object.keys(obj) as Array>; } + +export function notNull(value: T | null): value is T { + return !!value; +} From f3989ec64c1b69f8f03b695b48c7e937a58d066e Mon Sep 17 00:00:00 2001 From: scottrippey Date: Tue, 26 Dec 2023 16:34:50 -0600 Subject: [PATCH 10/45] feature(conditionals): removed select's "callback" signature --- packages/groq-builder/src/commands/select$.ts | 41 ++++--------------- 1 file changed, 9 insertions(+), 32 deletions(-) diff --git a/packages/groq-builder/src/commands/select$.ts b/packages/groq-builder/src/commands/select$.ts index 44e398a..cfb3a51 100644 --- a/packages/groq-builder/src/commands/select$.ts +++ b/packages/groq-builder/src/commands/select$.ts @@ -1,7 +1,7 @@ import { GroqBuilder } from "../groq-builder"; -import { ResultItem, ResultOverride } from "../types/result-types"; +import { ResultItem } from "../types/result-types"; import { ExtractSelectResult, SelectProjections } from "./select-types"; -import { notNull, Simplify, SimplifyDeep } from "../types/utils"; +import { notNull } from "../types/utils"; import { InferResultType, ParserFunction } from "../types/public-types"; declare module "../groq-builder" { @@ -13,42 +13,19 @@ declare module "../groq-builder" { >, TDefault extends null | GroqBuilder = null >( - selections: - | TSelectProjections - | (( - q: GroqBuilder, TRootConfig> - ) => TSelectProjections), - defaultSelection?: - | TDefault - | ((q: GroqBuilder, TRootConfig>) => TDefault) + selections: TSelectProjections, + defaultSelection?: TDefault ): GroqBuilder< - ResultOverride< - TResult, - | ExtractSelectResult - | (TDefault extends null - ? null - : InferResultType>) - >, + | ExtractSelectResult + | (TDefault extends null | undefined + ? null + : InferResultType>), TRootConfig >; } } GroqBuilder.implement({ - select$( - this: GroqBuilder, - selectionsArg, - defaultSelectionArg - ): GroqBuilder { - const selections = - typeof selectionsArg === "function" - ? selectionsArg(this.root) - : selectionsArg; - - const defaultSelection = - typeof defaultSelectionArg === "function" - ? defaultSelectionArg(this.root) - : defaultSelectionArg; - + select$(this: GroqBuilder, selections, defaultSelection): GroqBuilder { const conditions = Object.keys(selections); const queries = conditions.map((condition) => { From 827a347ba906f63505c6e01c97d19ba457918886 Mon Sep 17 00:00:00 2001 From: scottrippey Date: Tue, 26 Dec 2023 16:34:59 -0600 Subject: [PATCH 11/45] feature(conditionals): added type tests --- .../groq-builder/src/commands/select$.test.ts | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/packages/groq-builder/src/commands/select$.test.ts b/packages/groq-builder/src/commands/select$.test.ts index c71e5ce..7dfbaa3 100644 --- a/packages/groq-builder/src/commands/select$.test.ts +++ b/packages/groq-builder/src/commands/select$.test.ts @@ -10,6 +10,48 @@ const q = createGroqBuilder({ indent: " " }); describe("select$", () => { const qBase = q.star.filterByType("variant", "product", "category"); + describe("without a default value", () => { + describe("should infer the correct type", () => { + it("with a single condition", () => { + const qSel = q.select$({ + '_type == "variant"': q.value("VARIANT"), + }); + expectType>().toStrictEqual< + "VARIANT" | null + >(); + }); + it("with multiple selections", () => { + const qSelMultiple = q.select$({ + '_type == "variant"': q.value("VARIANT"), + '_type == "product"': q.value("PRODUCT"), + '_type == "category"': q.value("CATEGORY"), + }); + expectType>().toStrictEqual< + "VARIANT" | "PRODUCT" | "CATEGORY" | null + >(); + }); + + it("with complex mixed selections", () => { + const qSelMultiple = q.select$({ + '_type == "variant"': q.value("VARIANT"), + '_type == "nested"': q.project({ nested: q.value("NESTED") }), + '_type == "deeper"': q.project({ + nested: q.project({ deep: q.value("DEEP") }), + }), + }); + + expectType>().toStrictEqual< + | "VARIANT" + | { nested: "NESTED" } + | { + nested: { deep: "DEEP" }; + } + | null + >(); + }); + }); + }); + describe("with a default value", () => { const qSelect = qBase.project({ selected: q.select$( From ba1124ef5a60df66f5f69a55617d6e3bcef88e7f Mon Sep 17 00:00:00 2001 From: scottrippey Date: Tue, 26 Dec 2023 16:56:14 -0600 Subject: [PATCH 12/45] feature(conditionals): created "root projection" test --- .../groq-builder/src/commands/project.test.ts | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/packages/groq-builder/src/commands/project.test.ts b/packages/groq-builder/src/commands/project.test.ts index 937b639..2eab86d 100644 --- a/packages/groq-builder/src/commands/project.test.ts +++ b/packages/groq-builder/src/commands/project.test.ts @@ -25,6 +25,46 @@ describe("project (object projections)", () => { ), }); + describe("root projections", () => { + const qRoot = q.project({ + productNames: q.star.filterByType("product").field("name"), + categoryNames: q.star.filterByType("category").field("name"), + }); + it("should have the correct type", () => { + expectType>().toStrictEqual<{ + productNames: string[]; + categoryNames: string[]; + }>(); + }); + + it("should have the correct query", () => { + expect(qRoot.query).toMatchInlineSnapshot( + '" { \\"productNames\\": *[_type == \\"product\\"].name, \\"categoryNames\\": *[_type == \\"category\\"].name }"' + ); + }); + + it("should execute correctly", async () => { + const data = mock.generateSeedData({ + products: mock.array(2, () => mock.product({})), + categories: mock.array(3, () => mock.category({})), + }); + const results = await executeBuilder(qRoot, data.datalake); + expect(results).toMatchInlineSnapshot(` + { + "categoryNames": [ + "Category Name", + "Category Name", + "Category Name", + ], + "productNames": [ + "Name", + "Name", + ], + } + `); + }); + }); + describe("a single plain property", () => { it("cannot use 'true' to project unknown properties", () => { const qInvalid = qVariants.project({ From 7ec7c17f2e6af891e98f4717ccc97f40b6b54ded Mon Sep 17 00:00:00 2001 From: scottrippey Date: Tue, 26 Dec 2023 21:05:36 -0600 Subject: [PATCH 13/45] feature(conditionals): added test for projection + validatio --- .../groq-builder/src/commands/project.test.ts | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/packages/groq-builder/src/commands/project.test.ts b/packages/groq-builder/src/commands/project.test.ts index 2eab86d..d5039ae 100644 --- a/packages/groq-builder/src/commands/project.test.ts +++ b/packages/groq-builder/src/commands/project.test.ts @@ -182,6 +182,53 @@ describe("project (object projections)", () => { }); }); + describe("projection with validation", () => { + const qValidation = qVariants.project({ + name: validation.string(), + price: validation.number(), + }); + it("query should be typed correctly", () => { + expect(qValidation.query).toMatchInlineSnapshot( + '"*[_type == \\"variant\\"] { name, price }"' + ); + + expectType>().toStrictEqual< + Array<{ + name: string; + price: number; + }> + >(); + }); + + it("should execute correctly", async () => { + const results = await executeBuilder(qValidation, data.datalake); + expect(results).toMatchInlineSnapshot(` + [ + { + "name": "Variant 0", + "price": 0, + }, + { + "name": "Variant 1", + "price": 100, + }, + { + "name": "Variant 2", + "price": 200, + }, + { + "name": "Variant 3", + "price": 300, + }, + { + "name": "Variant 4", + "price": 400, + }, + ] + `); + }); + }); + describe("a projection with naked projections", () => { const qNakedProjections = qVariants.project({ NAME: "name", From 3e24691eccad21c85dbf9404fc916b5ad79b44f1 Mon Sep 17 00:00:00 2001 From: scottrippey Date: Tue, 26 Dec 2023 21:06:18 -0600 Subject: [PATCH 14/45] feature(conditionals): added import for select method --- packages/groq-builder/src/commands/index.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/groq-builder/src/commands/index.ts b/packages/groq-builder/src/commands/index.ts index 2ccbafd..749a022 100644 --- a/packages/groq-builder/src/commands/index.ts +++ b/packages/groq-builder/src/commands/index.ts @@ -9,6 +9,9 @@ import "./order"; import "./project"; import "./projectField"; import "./raw"; +// import "./sanity-image"; +import "./select$"; +// import "./selectByType"; import "./slice"; import "./slug"; import "./star"; From 7d1b0e1da23114f1b5c729467672878519335969 Mon Sep 17 00:00:00 2001 From: scottrippey Date: Tue, 26 Dec 2023 21:15:07 -0600 Subject: [PATCH 15/45] feature(conditionals): use Empty for RootResult --- packages/groq-builder/src/commands/projection-types.ts | 2 +- packages/groq-builder/src/groq-builder.test.ts | 5 +++-- packages/groq-builder/src/groq-builder.ts | 3 ++- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/groq-builder/src/commands/projection-types.ts b/packages/groq-builder/src/commands/projection-types.ts index 7780546..3934ea2 100644 --- a/packages/groq-builder/src/commands/projection-types.ts +++ b/packages/groq-builder/src/commands/projection-types.ts @@ -36,7 +36,7 @@ export type ProjectionMap = { [P in keyof TResultItem]?: ProjectionFieldConfig; } & { // This allows any keys to be used in a projection: - [P in string]: ProjectionFieldConfig; + [P in string]: ProjectionFieldConfig; } & { // Obviously this allows the ellipsis operator: "..."?: true; diff --git a/packages/groq-builder/src/groq-builder.test.ts b/packages/groq-builder/src/groq-builder.test.ts index e339c7b..c3d682e 100644 --- a/packages/groq-builder/src/groq-builder.test.ts +++ b/packages/groq-builder/src/groq-builder.test.ts @@ -3,12 +3,13 @@ import { SchemaConfig } from "./tests/schemas/nextjs-sanity-fe"; import { expectType } from "./tests/expectType"; import { InferResultType } from "./types/public-types"; import { createGroqBuilder } from "./index"; +import { Empty } from "./types/utils"; const q = createGroqBuilder({ indent: " " }); describe("GroqBuilder", () => { - it("should have a 'never' result", () => { - expectType>().toStrictEqual(); + it("root should have an Empty result", () => { + expectType>().toStrictEqual(); }); it("should have an empty query", () => { expect(q).toMatchObject({ diff --git a/packages/groq-builder/src/groq-builder.ts b/packages/groq-builder/src/groq-builder.ts index 423e853..55630c0 100644 --- a/packages/groq-builder/src/groq-builder.ts +++ b/packages/groq-builder/src/groq-builder.ts @@ -5,8 +5,9 @@ import { normalizeValidationFunction, } from "./commands/validate-utils"; import { ValidationErrors } from "./validation/validation-errors"; +import { Empty } from "./types/utils"; -export type RootResult = never; +export type RootResult = Empty; export type GroqBuilderOptions = { /** From 2f3ff1c2f1014894ee8f7475a994615e4f5f8704 Mon Sep 17 00:00:00 2001 From: scottrippey Date: Wed, 27 Dec 2023 10:36:15 -0600 Subject: [PATCH 16/45] feature(conditionals): implemented `selectByType` --- packages/groq-builder/src/commands/index.ts | 2 +- .../src/commands/selectByType.test.ts | 123 ++++++++++++++++++ .../groq-builder/src/commands/selectByType.ts | 41 ++++++ 3 files changed, 165 insertions(+), 1 deletion(-) create mode 100644 packages/groq-builder/src/commands/selectByType.test.ts create mode 100644 packages/groq-builder/src/commands/selectByType.ts diff --git a/packages/groq-builder/src/commands/index.ts b/packages/groq-builder/src/commands/index.ts index 749a022..3456f41 100644 --- a/packages/groq-builder/src/commands/index.ts +++ b/packages/groq-builder/src/commands/index.ts @@ -11,7 +11,7 @@ import "./projectField"; import "./raw"; // import "./sanity-image"; import "./select$"; -// import "./selectByType"; +import "./selectByType"; import "./slice"; import "./slug"; import "./star"; diff --git a/packages/groq-builder/src/commands/selectByType.test.ts b/packages/groq-builder/src/commands/selectByType.test.ts new file mode 100644 index 0000000..26b48df --- /dev/null +++ b/packages/groq-builder/src/commands/selectByType.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from "vitest"; +import { expectType } from "../tests/expectType"; +import { createGroqBuilder, InferResultType } from "../index"; +import { SchemaConfig } from "../tests/schemas/nextjs-sanity-fe"; +import { executeBuilder } from "../tests/mocks/executeQuery"; +import { mock } from "../tests/mocks/nextjs-sanity-fe-mocks"; + +describe("selectByType", () => { + const q = createGroqBuilder({ indent: " " }); + + const qSelect = q.star + .filterByType("category", "product", "variant") + .project((q) => ({ + selected: q.selectByType({ + category: (q) => + q.project({ + _type: true, + name: true, + description: true, + }), + product: (q) => + q.project({ + _type: true, + name: true, + }), + variant: (q) => + q.project({ + _type: true, + name: true, + price: true, + }), + }), + })); + + const data = mock.generateSeedData({ + categories: mock.array(1, () => mock.category({})), + products: mock.array(2, () => mock.product({})), + variants: mock.array(3, () => mock.variant({})), + }); + + it("should infer the correct types", () => { + type TSelect = InferResultType; + expectType().toStrictEqual< + Array<{ + selected: + | { _type: "category"; name: string; description: string | undefined } + | { _type: "product"; name: string } + | { _type: "variant"; name: string; price: number }; + }> + >(); + }); + + it("the query should be correct", () => { + expect(qSelect.query).toMatchInlineSnapshot(` + "*[_type == \\"category\\" || _type == \\"product\\" || _type == \\"variant\\"] { + \\"selected\\": select( + _type == \\"category\\" => { + _type, + name, + description + }, + _type == \\"product\\" => { + _type, + name + }, + _type == \\"variant\\" => { + _type, + name, + price + } + ) + }" + `); + }); + + it("should execute correctly", async () => { + const results = await executeBuilder(qSelect, data.datalake); + expect(results).toMatchInlineSnapshot(` + [ + { + "selected": { + "_type": "product", + "name": "Name", + }, + }, + { + "selected": { + "_type": "product", + "name": "Name", + }, + }, + { + "selected": { + "_type": "category", + "description": "", + "name": "Category Name", + }, + }, + { + "selected": { + "_type": "variant", + "name": "Variant Name", + "price": 0, + }, + }, + { + "selected": { + "_type": "variant", + "name": "Variant Name", + "price": 0, + }, + }, + { + "selected": { + "_type": "variant", + "name": "Variant Name", + "price": 0, + }, + }, + ] + `); + }); +}); diff --git a/packages/groq-builder/src/commands/selectByType.ts b/packages/groq-builder/src/commands/selectByType.ts new file mode 100644 index 0000000..d0e08e2 --- /dev/null +++ b/packages/groq-builder/src/commands/selectByType.ts @@ -0,0 +1,41 @@ +import { GroqBuilder } from "../groq-builder"; +import { ResultItem, ResultOverride } from "../types/result-types"; +import { keys, Simplify, ValueOf } from "../types/utils"; +import { + ExtractSelectByTypeResult, + SelectByTypeProjections, + SelectProjections, +} from "./select-types"; + +declare module "../groq-builder" { + export interface GroqBuilder { + selectByType< + TSelectProjections extends SelectByTypeProjections< + ResultItem, + TRootConfig + > + >( + typeProjections: TSelectProjections + ): GroqBuilder< + ResultOverride< + TResult, + Simplify> + >, + TRootConfig + >; + } +} + +GroqBuilder.implement({ + selectByType(this: GroqBuilder, typeProjections) { + const mapped: SelectProjections = {}; + const root = this.root; + for (const key of keys(typeProjections)) { + const condition = `_type == "${key}"`; + const selectFn = typeProjections[key]; + mapped[condition] = selectFn(root); + } + console.log({ mapped }); + return this.select$(mapped); + }, +}); From 8ba0285b8b6679911de498b17df95f3db40ddc7e Mon Sep 17 00:00:00 2001 From: scottrippey Date: Wed, 27 Dec 2023 10:48:08 -0600 Subject: [PATCH 17/45] feature(conditionals): fixed type errors --- .../groq-builder/src/commands/selectByType.ts | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/packages/groq-builder/src/commands/selectByType.ts b/packages/groq-builder/src/commands/selectByType.ts index d0e08e2..325fb12 100644 --- a/packages/groq-builder/src/commands/selectByType.ts +++ b/packages/groq-builder/src/commands/selectByType.ts @@ -10,16 +10,16 @@ import { declare module "../groq-builder" { export interface GroqBuilder { selectByType< - TSelectProjections extends SelectByTypeProjections< + TSelectByTypeProjections extends SelectByTypeProjections< ResultItem, TRootConfig > >( - typeProjections: TSelectProjections + typeQueries: TSelectByTypeProjections ): GroqBuilder< ResultOverride< TResult, - Simplify> + Simplify> >, TRootConfig >; @@ -27,15 +27,14 @@ declare module "../groq-builder" { } GroqBuilder.implement({ - selectByType(this: GroqBuilder, typeProjections) { + selectByType(this: GroqBuilder, typeQueries) { const mapped: SelectProjections = {}; const root = this.root; - for (const key of keys(typeProjections)) { + for (const key of keys(typeQueries)) { const condition = `_type == "${key}"`; - const selectFn = typeProjections[key]; - mapped[condition] = selectFn(root); + const queryFn = typeQueries[key] as (q: GroqBuilder) => GroqBuilder; + mapped[condition] = queryFn(root); } - console.log({ mapped }); - return this.select$(mapped); + return this.select$(mapped) as any; }, }); From fe6c1f5a590d392fbb128e6fb5871827c60358b3 Mon Sep 17 00:00:00 2001 From: scottrippey Date: Wed, 27 Dec 2023 11:38:30 -0600 Subject: [PATCH 18/45] feature(conditionals): implemented "default" parameter --- .../src/commands/selectByType.test.ts | 234 +++++++++++------- .../groq-builder/src/commands/selectByType.ts | 19 +- 2 files changed, 151 insertions(+), 102 deletions(-) diff --git a/packages/groq-builder/src/commands/selectByType.test.ts b/packages/groq-builder/src/commands/selectByType.test.ts index 26b48df..14d2755 100644 --- a/packages/groq-builder/src/commands/selectByType.test.ts +++ b/packages/groq-builder/src/commands/selectByType.test.ts @@ -8,116 +8,162 @@ import { mock } from "../tests/mocks/nextjs-sanity-fe-mocks"; describe("selectByType", () => { const q = createGroqBuilder({ indent: " " }); - const qSelect = q.star - .filterByType("category", "product", "variant") - .project((q) => ({ - selected: q.selectByType({ - category: (q) => - q.project({ - _type: true, - name: true, - description: true, - }), - product: (q) => - q.project({ - _type: true, - name: true, - }), - variant: (q) => - q.project({ - _type: true, - name: true, - price: true, - }), - }), - })); - const data = mock.generateSeedData({ categories: mock.array(1, () => mock.category({})), products: mock.array(2, () => mock.product({})), variants: mock.array(3, () => mock.variant({})), }); - it("should infer the correct types", () => { - type TSelect = InferResultType; - expectType().toStrictEqual< - Array<{ - selected: - | { _type: "category"; name: string; description: string | undefined } - | { _type: "product"; name: string } - | { _type: "variant"; name: string; price: number }; - }> - >(); - }); + describe("without a default param", () => { + const qSelect = q.star + .filterByType("product", "variant", "category") + .project((q) => ({ + selected: q.selectByType({ + product: (q) => + q.project({ + _type: true, + name: true, + }), + variant: (q) => + q.project({ + _type: true, + name: true, + price: true, + }), + }), + })); + + it("should infer the correct types", () => { + type TSelect = InferResultType; + expectType().toStrictEqual< + Array<{ + selected: + | { _type: "product"; name: string } + | { _type: "variant"; name: string; price: number } + | null; + }> + >(); + }); + + it("the query should be correct", () => { + expect(qSelect.query).toMatchInlineSnapshot(` + "*[_type == \\"product\\" || _type == \\"variant\\" || _type == \\"category\\"] { + \\"selected\\": select( + _type == \\"product\\" => { + _type, + name + }, + _type == \\"variant\\" => { + _type, + name, + price + } + ) + }" + `); + }); - it("the query should be correct", () => { - expect(qSelect.query).toMatchInlineSnapshot(` - "*[_type == \\"category\\" || _type == \\"product\\" || _type == \\"variant\\"] { - \\"selected\\": select( - _type == \\"category\\" => { - _type, - name, - description + it("should execute correctly", async () => { + const results = await executeBuilder(qSelect, data.datalake); + expect(results).toMatchInlineSnapshot(` + [ + { + "selected": { + "_type": "product", + "name": "Name", + }, + }, + { + "selected": { + "_type": "product", + "name": "Name", + }, + }, + { + "selected": null, + }, + { + "selected": { + "_type": "variant", + "name": "Variant Name", + "price": 0, + }, + }, + { + "selected": { + "_type": "variant", + "name": "Variant Name", + "price": 0, }, - _type == \\"product\\" => { - _type, - name + }, + { + "selected": { + "_type": "variant", + "name": "Variant Name", + "price": 0, }, - _type == \\"variant\\" => { - _type, - name, - price - } - ) - }" - `); + }, + ] + `); + }); }); - it("should execute correctly", async () => { - const results = await executeBuilder(qSelect, data.datalake); - expect(results).toMatchInlineSnapshot(` - [ - { - "selected": { - "_type": "product", - "name": "Name", + describe("with default param", () => { + const qSelect = q.star + .filterByType("product", "variant", "category") + .project((q) => ({ + selected: q.selectByType( + { + product: (q) => q.field("name"), + variant: (q) => q.field("price"), + }, + q.value("UNKNOWN") + ), + })); + + it("should infer the correct type", () => { + expectType>().toStrictEqual< + Array<{ + selected: string | number | "UNKNOWN"; + }> + >(); + }); + it("the query should be correct", () => { + expect(qSelect.query).toMatchInlineSnapshot(` + "*[_type == \\"product\\" || _type == \\"variant\\" || _type == \\"category\\"] { + \\"selected\\": select( + _type == \\"product\\" => name, + _type == \\"variant\\" => price, + \\"UNKNOWN\\" + ) + }" + `); + }); + it("should execute correctly", async () => { + const results = await executeBuilder(qSelect, data.datalake); + + expect(results).toMatchInlineSnapshot(` + [ + { + "selected": "Name", }, - }, - { - "selected": { - "_type": "product", - "name": "Name", + { + "selected": "Name", }, - }, - { - "selected": { - "_type": "category", - "description": "", - "name": "Category Name", + { + "selected": "UNKNOWN", }, - }, - { - "selected": { - "_type": "variant", - "name": "Variant Name", - "price": 0, + { + "selected": 0, }, - }, - { - "selected": { - "_type": "variant", - "name": "Variant Name", - "price": 0, + { + "selected": 0, }, - }, - { - "selected": { - "_type": "variant", - "name": "Variant Name", - "price": 0, + { + "selected": 0, }, - }, - ] - `); + ] + `); + }); }); }); diff --git a/packages/groq-builder/src/commands/selectByType.ts b/packages/groq-builder/src/commands/selectByType.ts index 325fb12..1bcb79a 100644 --- a/packages/groq-builder/src/commands/selectByType.ts +++ b/packages/groq-builder/src/commands/selectByType.ts @@ -6,6 +6,7 @@ import { SelectByTypeProjections, SelectProjections, } from "./select-types"; +import { InferResultType } from "../types/public-types"; declare module "../groq-builder" { export interface GroqBuilder { @@ -13,21 +14,23 @@ declare module "../groq-builder" { TSelectByTypeProjections extends SelectByTypeProjections< ResultItem, TRootConfig - > + >, + TDefaultSelection extends GroqBuilder | null = null >( - typeQueries: TSelectByTypeProjections + typeQueries: TSelectByTypeProjections, + defaultSelection?: TDefaultSelection ): GroqBuilder< - ResultOverride< - TResult, - Simplify> - >, + | Simplify> + | (TDefaultSelection extends null | undefined + ? null + : InferResultType>), TRootConfig >; } } GroqBuilder.implement({ - selectByType(this: GroqBuilder, typeQueries) { + selectByType(this: GroqBuilder, typeQueries, defaultSelection) { const mapped: SelectProjections = {}; const root = this.root; for (const key of keys(typeQueries)) { @@ -35,6 +38,6 @@ GroqBuilder.implement({ const queryFn = typeQueries[key] as (q: GroqBuilder) => GroqBuilder; mapped[condition] = queryFn(root); } - return this.select$(mapped) as any; + return this.select$(mapped, defaultSelection) as any; }, }); From 266347cd22c802cd8ce300a9e3987580e1a39a4d Mon Sep 17 00:00:00 2001 From: scottrippey Date: Wed, 27 Dec 2023 13:22:12 -0600 Subject: [PATCH 19/45] feature(conditionals): added tests for validation --- .../groq-builder/src/commands/select$.test.ts | 71 +++++++++++++++++- packages/groq-builder/src/commands/select$.ts | 2 +- .../src/commands/selectByType.test.ts | 75 ++++++++++++++++++- 3 files changed, 145 insertions(+), 3 deletions(-) diff --git a/packages/groq-builder/src/commands/select$.test.ts b/packages/groq-builder/src/commands/select$.test.ts index 7dfbaa3..000b58d 100644 --- a/packages/groq-builder/src/commands/select$.test.ts +++ b/packages/groq-builder/src/commands/select$.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { createGroqBuilder, InferResultType } from "../index"; +import { createGroqBuilder, InferResultType, validation } from "../index"; import { SchemaConfig } from "../tests/schemas/nextjs-sanity-fe"; import { expectType } from "../tests/expectType"; import { mock } from "../tests/mocks/nextjs-sanity-fe-mocks"; @@ -124,4 +124,73 @@ describe("select$", () => { `); }); }); + + describe("with validation", () => { + const qSelect = qBase.project((q) => ({ + selected: q.select$({ + '_type == "product"': q.project({ + _type: validation.literal("product"), + name: validation.string(), + }), + '_type == "variant"': q.project({ + _type: validation.literal("variant"), + name: validation.string(), + price: validation.number(), + }), + }), + })); + + const data = mock.generateSeedData({ + products: [mock.product({})], + variants: [mock.variant({})], + categories: [mock.category({})], + }); + const invalidData = mock.generateSeedData({ + products: [ + mock.product({ + // @ts-expect-error --- + name: 999, + }), + ], + variants: [ + mock.variant({ + // @ts-expect-error --- + price: "EXPENSIVE", + }), + ], + categories: [mock.category({})], + }); + + it("should execute correctly", async () => { + const results = await executeBuilder(qSelect, data.datalake); + expect(results).toMatchInlineSnapshot(` + [ + { + "selected": { + "_type": "product", + "name": "Name", + }, + }, + { + "selected": null, + }, + { + "selected": { + "_type": "variant", + "name": "Variant Name", + "price": 0, + }, + }, + ] + `); + }); + it("should fail with invalid data", async () => { + await expect(() => executeBuilder(qSelect, invalidData.datalake)).rejects + .toThrowErrorMatchingInlineSnapshot(` + "2 Parsing Errors: + result[0].selected: Conditional parsing failed; all 2 conditions failed + result[2].selected: Conditional parsing failed; all 2 conditions failed" + `); + }); + }); }); diff --git a/packages/groq-builder/src/commands/select$.ts b/packages/groq-builder/src/commands/select$.ts index cfb3a51..fd7f6d3 100644 --- a/packages/groq-builder/src/commands/select$.ts +++ b/packages/groq-builder/src/commands/select$.ts @@ -89,7 +89,7 @@ function createConditionalParser( } throw new TypeError( - `Conditional parsing failed; all ${parsers.length} conditions failed trying to parse ${input}` + `Conditional parsing failed; all ${parsers.length} conditions failed` ); }; } diff --git a/packages/groq-builder/src/commands/selectByType.test.ts b/packages/groq-builder/src/commands/selectByType.test.ts index 14d2755..700fb32 100644 --- a/packages/groq-builder/src/commands/selectByType.test.ts +++ b/packages/groq-builder/src/commands/selectByType.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import { expectType } from "../tests/expectType"; -import { createGroqBuilder, InferResultType } from "../index"; +import { createGroqBuilder, InferResultType, validation } from "../index"; import { SchemaConfig } from "../tests/schemas/nextjs-sanity-fe"; import { executeBuilder } from "../tests/mocks/executeQuery"; import { mock } from "../tests/mocks/nextjs-sanity-fe-mocks"; @@ -166,4 +166,77 @@ describe("selectByType", () => { `); }); }); + + describe("with validation", () => { + const qSelect = q.star + .filterByType("product", "variant", "category") + .project((q) => ({ + selected: q.selectByType({ + product: (q) => + q.project({ + _type: validation.literal("product"), + name: validation.string(), + }), + variant: (q) => + q.project({ + _type: validation.literal("variant"), + name: validation.string(), + price: validation.number(), + }), + }), + })); + + const data = mock.generateSeedData({ + products: [mock.product({})], + variants: [mock.variant({})], + categories: [mock.category({})], + }); + const invalidData = mock.generateSeedData({ + products: [ + mock.product({ + // @ts-expect-error --- + name: 999, + }), + ], + variants: [ + mock.variant({ + // @ts-expect-error --- + price: "EXPENSIVE", + }), + ], + categories: [mock.category({})], + }); + + it("should execute correctly", async () => { + const results = await executeBuilder(qSelect, data.datalake); + expect(results).toMatchInlineSnapshot(` + [ + { + "selected": { + "_type": "product", + "name": "Name", + }, + }, + { + "selected": null, + }, + { + "selected": { + "_type": "variant", + "name": "Variant Name", + "price": 0, + }, + }, + ] + `); + }); + it("should fail with invalid data", async () => { + await expect(() => executeBuilder(qSelect, invalidData.datalake)).rejects + .toThrowErrorMatchingInlineSnapshot(` + "2 Parsing Errors: + result[0].selected: Conditional parsing failed; all 2 conditions failed + result[2].selected: Conditional parsing failed; all 2 conditions failed" + `); + }); + }); }); From 3458bb6cf81132ea976ed49d699ecfa2e0f9fa04 Mon Sep 17 00:00:00 2001 From: scottrippey Date: Thu, 28 Dec 2023 14:31:34 -0600 Subject: [PATCH 20/45] feature(conditionals): added support for non-callback signature --- .../groq-builder/src/commands/select-types.ts | 18 ++- .../src/commands/selectByType.test.ts | 126 +++++++++++------- .../groq-builder/src/commands/selectByType.ts | 11 +- 3 files changed, 100 insertions(+), 55 deletions(-) diff --git a/packages/groq-builder/src/commands/select-types.ts b/packages/groq-builder/src/commands/select-types.ts index a492644..f7f2398 100644 --- a/packages/groq-builder/src/commands/select-types.ts +++ b/packages/groq-builder/src/commands/select-types.ts @@ -21,15 +21,21 @@ export type SelectByTypeProjections< TResultItem, TRootConfig extends RootConfig > = { - [_type in ExtractTypeNames]?: ( - q: GroqBuilder, TRootConfig> - ) => GroqBuilder; + [_type in ExtractTypeNames]?: + | GroqBuilder + | (( + q: GroqBuilder, TRootConfig> + ) => GroqBuilder); }; export type ExtractSelectByTypeResult< TSelectProjections extends SelectByTypeProjections > = ValueOf<{ - [_type in keyof TSelectProjections]: InferResultType< - ReturnType> - >; + [_type in keyof TSelectProjections]: TSelectProjections[_type] extends GroqBuilder< + infer TResult + > + ? TResult + : TSelectProjections[_type] extends (q: any) => GroqBuilder + ? TResult + : never; }>; diff --git a/packages/groq-builder/src/commands/selectByType.test.ts b/packages/groq-builder/src/commands/selectByType.test.ts index 700fb32..6f944c7 100644 --- a/packages/groq-builder/src/commands/selectByType.test.ts +++ b/packages/groq-builder/src/commands/selectByType.test.ts @@ -7,6 +7,7 @@ import { mock } from "../tests/mocks/nextjs-sanity-fe-mocks"; describe("selectByType", () => { const q = createGroqBuilder({ indent: " " }); + const qBase = q.star.filterByType("product", "variant", "category"); const data = mock.generateSeedData({ categories: mock.array(1, () => mock.category({})), @@ -14,24 +15,51 @@ describe("selectByType", () => { variants: mock.array(3, () => mock.variant({})), }); + it("can be used with or without callback functions", () => { + const qWithCb = qBase.project((q) => ({ + selected: q.selectByType({ + product: (q) => q.value("PRODUCT"), // <-- uses the callback API + }), + })); + const qWithoutCb = qBase.project((q) => ({ + selected: q.selectByType({ + product: q.value("PRODUCT"), // <-- no callback + }), + })); + + expectType>().toStrictEqual< + Array<{ selected: "PRODUCT" | null }> + >(); + expectType>().toStrictEqual< + Array<{ selected: "PRODUCT" | null }> + >(); + + expect(qWithCb.query).toEqual(qWithoutCb.query); + expect(qWithCb.query).toMatchInlineSnapshot(` + "*[_type == \\"product\\" || _type == \\"variant\\" || _type == \\"category\\"] { + \\"selected\\": select( + _type == \\"product\\" => \\"PRODUCT\\" + ) + }" + `); + }); + describe("without a default param", () => { - const qSelect = q.star - .filterByType("product", "variant", "category") - .project((q) => ({ - selected: q.selectByType({ - product: (q) => - q.project({ - _type: true, - name: true, - }), - variant: (q) => - q.project({ - _type: true, - name: true, - price: true, - }), - }), - })); + const qSelect = qBase.project((q) => ({ + selected: q.selectByType({ + product: (q) => + q.project({ + _type: true, + name: true, + }), + variant: (q) => + q.project({ + _type: true, + name: true, + price: true, + }), + }), + })); it("should infer the correct types", () => { type TSelect = InferResultType; @@ -45,6 +73,11 @@ describe("selectByType", () => { >(); }); + it("no runtime validation is used", () => { + // @ts-expect-error --- + expect(qSelect.internal.parser).toBe(null); + }); + it("the query should be correct", () => { expect(qSelect.query).toMatchInlineSnapshot(` "*[_type == \\"product\\" || _type == \\"variant\\" || _type == \\"category\\"] { @@ -109,17 +142,15 @@ describe("selectByType", () => { }); describe("with default param", () => { - const qSelect = q.star - .filterByType("product", "variant", "category") - .project((q) => ({ - selected: q.selectByType( - { - product: (q) => q.field("name"), - variant: (q) => q.field("price"), - }, - q.value("UNKNOWN") - ), - })); + const qSelect = qBase.project((q) => ({ + selected: q.selectByType( + { + product: (q) => q.field("name"), + variant: (q) => q.field("price"), + }, + q.value("UNKNOWN") + ), + })); it("should infer the correct type", () => { expectType>().toStrictEqual< @@ -128,6 +159,7 @@ describe("selectByType", () => { }> >(); }); + it("the query should be correct", () => { expect(qSelect.query).toMatchInlineSnapshot(` "*[_type == \\"product\\" || _type == \\"variant\\" || _type == \\"category\\"] { @@ -139,6 +171,12 @@ describe("selectByType", () => { }" `); }); + + it("no runtime validation is used", () => { + // @ts-expect-error --- + expect(qSelect.internal.parser).toBe(null); + }); + it("should execute correctly", async () => { const results = await executeBuilder(qSelect, data.datalake); @@ -168,23 +206,21 @@ describe("selectByType", () => { }); describe("with validation", () => { - const qSelect = q.star - .filterByType("product", "variant", "category") - .project((q) => ({ - selected: q.selectByType({ - product: (q) => - q.project({ - _type: validation.literal("product"), - name: validation.string(), - }), - variant: (q) => - q.project({ - _type: validation.literal("variant"), - name: validation.string(), - price: validation.number(), - }), - }), - })); + const qSelect = qBase.project((q) => ({ + selected: q.selectByType({ + product: (q) => + q.project({ + _type: validation.literal("product"), + name: validation.string(), + }), + variant: (q) => + q.project({ + _type: validation.literal("variant"), + name: validation.string(), + price: validation.number(), + }), + }), + })); const data = mock.generateSeedData({ products: [mock.product({})], diff --git a/packages/groq-builder/src/commands/selectByType.ts b/packages/groq-builder/src/commands/selectByType.ts index 1bcb79a..c5f6521 100644 --- a/packages/groq-builder/src/commands/selectByType.ts +++ b/packages/groq-builder/src/commands/selectByType.ts @@ -1,6 +1,6 @@ import { GroqBuilder } from "../groq-builder"; -import { ResultItem, ResultOverride } from "../types/result-types"; -import { keys, Simplify, ValueOf } from "../types/utils"; +import { ResultItem } from "../types/result-types"; +import { keys, Simplify } from "../types/utils"; import { ExtractSelectByTypeResult, SelectByTypeProjections, @@ -35,8 +35,11 @@ GroqBuilder.implement({ const root = this.root; for (const key of keys(typeQueries)) { const condition = `_type == "${key}"`; - const queryFn = typeQueries[key] as (q: GroqBuilder) => GroqBuilder; - mapped[condition] = queryFn(root); + + const queryFn = typeQueries[key]; + const query = typeof queryFn === "function" ? queryFn(root) : queryFn; + + mapped[condition] = query; } return this.select$(mapped, defaultSelection) as any; }, From c936ab4efeeb8eb1ce0df812ea51097a59aeba93 Mon Sep 17 00:00:00 2001 From: scottrippey Date: Thu, 28 Dec 2023 14:33:56 -0600 Subject: [PATCH 21/45] feature(conditionals): added jsdocs --- packages/groq-builder/src/commands/functions/value.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/groq-builder/src/commands/functions/value.ts b/packages/groq-builder/src/commands/functions/value.ts index bddf1f9..175046b 100644 --- a/packages/groq-builder/src/commands/functions/value.ts +++ b/packages/groq-builder/src/commands/functions/value.ts @@ -4,16 +4,21 @@ import { Parser } from "../../types/public-types"; declare module "../../groq-builder" { // eslint-disable-next-line @typescript-eslint/no-unused-vars export interface GroqBuilder { + /** + * Returns a literal Groq value, properly escaped. + * @param value + * @param validation + */ value( value: T, - validation?: Parser + validation?: Parser | null ): GroqBuilder; } } GroqBuilder.implement({ - value(this: GroqBuilder, value) { - return this.chain(escapeValue(value)); + value(this: GroqBuilder, value, validation = null) { + return this.chain(escapeValue(value), validation); }, }); From f32fdf1e4e83f282ee877f1ddcac88787bcb8b78 Mon Sep 17 00:00:00 2001 From: scottrippey Date: Thu, 28 Dec 2023 15:08:10 -0600 Subject: [PATCH 22/45] feature(conditionals): changed signature for conditional projections; uses 2nd parameter --- .../src/commands/conditional$.test.ts | 51 +++++++++++++++++-- .../groq-builder/src/commands/conditional$.ts | 5 +- .../src/commands/conditionalByType.test.ts | 17 ++++--- packages/groq-builder/src/commands/project.ts | 36 ++++++++++--- .../src/commands/projection-types.ts | 13 +---- 5 files changed, 92 insertions(+), 30 deletions(-) diff --git a/packages/groq-builder/src/commands/conditional$.test.ts b/packages/groq-builder/src/commands/conditional$.test.ts index 025d456..31a4659 100644 --- a/packages/groq-builder/src/commands/conditional$.test.ts +++ b/packages/groq-builder/src/commands/conditional$.test.ts @@ -1,10 +1,11 @@ -import { describe, it } from "vitest"; -import { createGroqBuilder } from "../index"; +import { describe, expect, it } from "vitest"; +import { createGroqBuilder, InferResultType } from "../index"; import { SchemaConfig } from "../tests/schemas/nextjs-sanity-fe"; import { ExtractConditionalProjectionTypes } from "./conditional-types"; import { expectType } from "../tests/expectType"; -const q = createGroqBuilder(); +const q = createGroqBuilder({ indent: " " }); +const qBase = q.star.filterByType("variant"); describe("conditional$", () => { it("by itself, we should be able to extract the union of projection types", () => { @@ -25,4 +26,48 @@ describe("conditional$", () => { { name: string; price: number } | { name: string; msrp: number } >(); }); + + const qAll = qBase.project( + { + name: true, + }, + (qA) => + 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>().toStrictEqual< + Array< + | { 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 + } + }" + ` + ); + }); }); diff --git a/packages/groq-builder/src/commands/conditional$.ts b/packages/groq-builder/src/commands/conditional$.ts index 97ec716..f0870a7 100644 --- a/packages/groq-builder/src/commands/conditional$.ts +++ b/packages/groq-builder/src/commands/conditional$.ts @@ -25,14 +25,15 @@ declare module "../groq-builder" { GroqBuilder.implement({ conditional$(this: GroqBuilder, conditionalProjections): any { // Return an object; the `project` method will turn it into a query. + const root = this.root; return Object.fromEntries( Object.entries(conditionalProjections).map( ([condition, projectionMap]) => { if (typeof projectionMap === "function") { - projectionMap = projectionMap(this.root); + projectionMap = projectionMap(root); } - const projection = this.root + const projection = root .chain(`${condition} => `) .project(projectionMap); diff --git a/packages/groq-builder/src/commands/conditionalByType.test.ts b/packages/groq-builder/src/commands/conditionalByType.test.ts index 6ba50d8..f35bf7b 100644 --- a/packages/groq-builder/src/commands/conditionalByType.test.ts +++ b/packages/groq-builder/src/commands/conditionalByType.test.ts @@ -24,13 +24,16 @@ describe("conditionalByType", () => { }), }); - const qAll = q.star.project((qA) => ({ - _type: true, - ...qA.conditionalByType({ - product: { name: true, slug: "slug.current" }, - variant: { name: true, price: true }, - }), - })); + const qAll = q.star.project( + { + _type: true, + }, + (qA) => + qA.conditionalByType({ + product: { name: true, slug: "slug.current" }, + variant: { name: true, price: true }, + }) + ); it("we should be able to extract the return types", () => { type ConditionalResults = ExtractConditionalProjectionTypes< diff --git a/packages/groq-builder/src/commands/project.ts b/packages/groq-builder/src/commands/project.ts index 3a67495..749083d 100644 --- a/packages/groq-builder/src/commands/project.ts +++ b/packages/groq-builder/src/commands/project.ts @@ -1,25 +1,41 @@ -import { notNull, Simplify } from "../types/utils"; +import { Empty, notNull, Simplify } from "../types/utils"; import { GroqBuilder } from "../groq-builder"; import { Parser, ParserFunction } from "../types/public-types"; import { isParser, normalizeValidationFunction } from "./validate-utils"; import { ResultItem, ResultOverride } from "../types/result-types"; import { ValidationErrors } from "../validation/validation-errors"; import { ExtractProjectionResult, ProjectionMap } from "./projection-types"; +import { + ConditionalProjectionResultWrapper, + ExtractConditionalProjectionTypes, +} from "./conditional-types"; declare module "../groq-builder" { export interface GroqBuilder { /** * Performs an "object projection", returning an object with the fields specified. - * @param projectionMap */ - project>>( + project< + TProjection extends ProjectionMap>, + TConditionals extends + | ConditionalProjectionResultWrapper + | undefined = undefined + >( projectionMap: | TProjection - | ((q: GroqBuilder, TRootConfig>) => TProjection) + | ((q: GroqBuilder, TRootConfig>) => TProjection), + conditionalProjections?: + | TConditionals + | ((q: GroqBuilder, TRootConfig>) => TConditionals) ): GroqBuilder< ResultOverride< TResult, - Simplify, TProjection>> + Simplify< + ExtractProjectionResult, TProjection> & + (TConditionals extends undefined + ? Empty + : ExtractConditionalProjectionTypes) + > >, TRootConfig >; @@ -29,7 +45,8 @@ declare module "../groq-builder" { GroqBuilder.implement({ project( this: GroqBuilder, - projectionMapArg: object | ((q: GroqBuilder) => object) + projectionMapArg: object | ((q: any) => object), + conditionalProjections?: object | ((q: any) => object) ): GroqBuilder { // Retrieve the projectionMap: let projectionMap: object; @@ -38,6 +55,13 @@ GroqBuilder.implement({ } else { projectionMap = projectionMapArg; } + if (conditionalProjections) { + if (typeof conditionalProjections === "function") { + conditionalProjections = conditionalProjections(this.root); + } + // Just push the conditions into the `projectionMap` since the logic is the same + Object.assign(projectionMap, conditionalProjections); + } // Analyze all the projection values: const keys = Object.keys(projectionMap) as Array; diff --git a/packages/groq-builder/src/commands/projection-types.ts b/packages/groq-builder/src/commands/projection-types.ts index 3934ea2..cdc48f4 100644 --- a/packages/groq-builder/src/commands/projection-types.ts +++ b/packages/groq-builder/src/commands/projection-types.ts @@ -12,10 +12,6 @@ import { FragmentInputTypeTag, Parser } from "../types/public-types"; import { Path, PathEntries, PathValue } from "../types/path-types"; import { DeepRequired } from "../types/deep-required"; import { RootConfig } from "../types/schema-types"; -import { - ConditionalProjectionResultTypesTag, - ConditionalProjectionResultWrapper, -} from "./conditional-types"; export type ProjectionKey = IsAny extends true ? string @@ -63,19 +59,12 @@ type ProjectionFieldConfig = export type ExtractProjectionResult = (TProjectionMap extends { "...": true } ? TResultItem : Empty) & - (TProjectionMap extends ConditionalProjectionResultWrapper< - infer TConditionalTypes - > - ? TConditionalTypes - : Empty) & ExtractProjectionResultImpl< TResultItem, Omit< TProjectionMap, // Ensure we remove any "tags" that we don't want in the mapped type: - | "..." - | typeof ConditionalProjectionResultTypesTag - | typeof FragmentInputTypeTag + "..." | typeof FragmentInputTypeTag > >; From af9331f4a8fc77a0808376b1d8e6c7a13cefca4c Mon Sep 17 00:00:00 2001 From: scottrippey Date: Fri, 5 Jan 2024 15:33:47 -0600 Subject: [PATCH 23/45] feature(conditionals): added shape validation to assist with projection validations --- .../src/validation/array-shape.test.ts | 51 +++++++ .../src/validation/array-shape.ts | 54 ++++++++ packages/groq-builder/src/validation/index.ts | 4 + .../src/validation/object-shape.test.ts | 124 ++++++++++++++++++ .../src/validation/object-shape.ts | 89 +++++++++++++ 5 files changed, 322 insertions(+) create mode 100644 packages/groq-builder/src/validation/array-shape.test.ts create mode 100644 packages/groq-builder/src/validation/array-shape.ts create mode 100644 packages/groq-builder/src/validation/object-shape.test.ts create mode 100644 packages/groq-builder/src/validation/object-shape.ts diff --git a/packages/groq-builder/src/validation/array-shape.test.ts b/packages/groq-builder/src/validation/array-shape.test.ts new file mode 100644 index 0000000..7045f4b --- /dev/null +++ b/packages/groq-builder/src/validation/array-shape.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; +import { validation } from "./index"; +import { arrayValidation } from "./array-shape"; +import { improveErrorMessage } from "./primitives.test"; + +describe("array", () => { + const arrParser = arrayValidation.array(validation.number()); + + it("should ensure the input was an array", () => { + expect( + improveErrorMessage(() => + arrParser( + // @ts-expect-error --- + {} + ) + ) + ).toThrowErrorMatchingInlineSnapshot( + '"Expected an array, received an object"' + ); + expect( + improveErrorMessage(() => + arrParser( + // @ts-expect-error --- + null + ) + ) + ).toThrowErrorMatchingInlineSnapshot('"Expected an array, received null"'); + }); + + it("should ensure all items are valid", () => { + const numbers = [1, 2, 3]; + expect(arrParser(numbers)).toEqual(numbers); + expect(arrParser(numbers)).not.toBe(numbers); + }); + + it("should fail for invalid items", () => { + const invalid = [1, "2", "3"]; + expect( + improveErrorMessage(() => + arrParser( + // @ts-expect-error --- + invalid + ) + ) + ).toThrowErrorMatchingInlineSnapshot(` + "2 Parsing Errors: + result[1]: Expected number, received \\"2\\" + result[2]: Expected number, received \\"3\\"" + `); + }); +}); diff --git a/packages/groq-builder/src/validation/array-shape.ts b/packages/groq-builder/src/validation/array-shape.ts new file mode 100644 index 0000000..118ecaf --- /dev/null +++ b/packages/groq-builder/src/validation/array-shape.ts @@ -0,0 +1,54 @@ +import { + InferParserInput, + InferParserOutput, + Parser, +} from "../types/public-types"; +import { normalizeValidationFunction } from "../commands/validate-utils"; +import { Simplify } from "../types/utils"; +import { ValidationErrors } from "./validation-errors"; +import { createOptionalParser, inspect, OptionalParser } from "./primitives"; + +export interface ArrayValidation { + array(): OptionalParser, Array>; + array( + itemParser: TParser + ): OptionalParser< + Array>>, + Array>> + >; +} + +export const arrayValidation: ArrayValidation = { + array(itemParser?: Parser) { + if (!itemParser) { + return createOptionalParser((input) => { + if (!Array.isArray(input)) { + throw new TypeError(`Expected an array, received ${inspect(input)}`); + } + return input; + }); + } + + const normalizedItemParser = normalizeValidationFunction(itemParser)!; + + return createOptionalParser((input) => { + if (!Array.isArray(input)) { + throw new TypeError(`Expected an array, received ${inspect(input)}`); + } + + const validationErrors = new ValidationErrors(); + const results = input.map((value, i) => { + try { + return normalizedItemParser(value); + } catch (err) { + validationErrors.add(`[${i}]`, value, err as Error); + return null; + } + }); + + if (validationErrors.length) throw validationErrors; + + return results; + }); + }, +}; diff --git a/packages/groq-builder/src/validation/index.ts b/packages/groq-builder/src/validation/index.ts index 3ba4652..a5b03f3 100644 --- a/packages/groq-builder/src/validation/index.ts +++ b/packages/groq-builder/src/validation/index.ts @@ -1,7 +1,11 @@ import { primitiveValidation } from "./primitives"; import { sanityValidation } from "./sanity-content-blocks"; +import { objectValidation } from "./object-shape"; +import { arrayValidation } from "./array-shape"; export const validation = { ...primitiveValidation, ...sanityValidation, + ...objectValidation, + ...arrayValidation, }; diff --git a/packages/groq-builder/src/validation/object-shape.test.ts b/packages/groq-builder/src/validation/object-shape.test.ts new file mode 100644 index 0000000..07279ec --- /dev/null +++ b/packages/groq-builder/src/validation/object-shape.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from "vitest"; +import { validation } from "./index"; +import { expectType } from "../tests/expectType"; +import { InferParserInput, InferParserOutput } from "../types/public-types"; +import { objectValidation } from "./object-shape"; +import { improveErrorMessage } from "./primitives.test"; + +describe("objectValidation.object", () => { + const objParser = objectValidation.object({ + str: validation.string(), + strOpt: validation.string().optional(), + num: validation.number(), + nested: objectValidation.object({ + bool: validation.boolean(), + }), + }); + + type Expected = { + str: string; + strOpt: string | null | undefined; + num: number; + nested: { + bool: boolean; + }; + }; + + it("should have the correct type", () => { + expectType>().toStrictEqual(); + expectType>().toStrictEqual(); + + const opt = objParser.optional(); + expectType>().toStrictEqual< + Expected | undefined | null + >(); + expectType>().toStrictEqual< + Expected | undefined | null + >(); + }); + + it("should successfully pass valid input", () => { + const valid: Expected = { + str: "string", + strOpt: null, + num: 5, + nested: { bool: true }, + }; + expect(objParser(valid)).toEqual(valid); + expect(objParser(valid)).not.toBe(valid); + }); + + it("should throw errors for invalid data", () => { + const invalid = { + str: null, + strOpt: 999, + num: "hey", + nested: { foo: true }, + }; + + expect( + improveErrorMessage(() => + objParser( + // @ts-expect-error --- + invalid + ) + ) + ).toThrowErrorMatchingInlineSnapshot(` + "4 Parsing Errors: + result.str: Expected string, received null + result.strOpt: Expected string, received 999 + result.num: Expected number, received \\"hey\\" + result.nested.bool: Expected boolean, received undefined" + `); + + expect( + improveErrorMessage(() => + objParser( + // @ts-expect-error --- + 123 + ) + ) + ).toThrowErrorMatchingInlineSnapshot('"Expected an object, received 123"'); + }); + + describe("with different inputs and outputs", () => { + const mapper = objectValidation.object({ + stringToNumber: (input: string) => Number(input), + numberToString: (input: number) => String(input), + stringToLiteral: (input: string) => input as "LITERAL", + }); + type ExpectedInput = { + stringToNumber: string; + numberToString: number; + stringToLiteral: string; + }; + type ExpectedOutput = { + stringToNumber: number; + numberToString: string; + stringToLiteral: "LITERAL"; + }; + + it("types should be correct", () => { + expectType< + InferParserInput + >().toStrictEqual(); + expectType< + InferParserOutput + >().toStrictEqual(); + }); + + it("should map data correctly", () => { + expect( + mapper({ + stringToNumber: "123", + numberToString: 456, + stringToLiteral: "FOO", + }) + ).toEqual({ + stringToNumber: 123, + numberToString: "456", + stringToLiteral: "FOO", + }); + }); + }); +}); diff --git a/packages/groq-builder/src/validation/object-shape.ts b/packages/groq-builder/src/validation/object-shape.ts new file mode 100644 index 0000000..9e1db61 --- /dev/null +++ b/packages/groq-builder/src/validation/object-shape.ts @@ -0,0 +1,89 @@ +import { createOptionalParser, inspect, OptionalParser } from "./primitives"; +import { + InferParserInput, + InferParserOutput, + Parser, + ParserFunction, +} from "../types/public-types"; +import { Simplify } from "../types/utils"; +import { normalizeValidationFunction } from "../commands/validate-utils"; +import { ValidationErrors } from "./validation-errors"; + +interface ObjectValidation { + object(): OptionalParser; + object( + map?: TMap + ): OptionalParser< + Simplify<{ + [P in keyof TMap]: TMap[P] extends {} + ? InferParserInput + : unknown; + }>, + Simplify<{ + [P in keyof TMap]: TMap[P] extends {} + ? InferParserOutput + : unknown; + }> + >; + + union( + parserA: TParserA, + parserB: TParserB + ): OptionalParser< + InferParserInput & InferParserInput, + InferParserOutput & InferParserOutput + >; +} + +export const objectValidation: ObjectValidation = { + object(map?: ObjectValidationMap) { + if (!map) { + return createOptionalParser((input) => { + if (input === null || typeof input !== "object") { + throw new TypeError(`Expected an object, received ${inspect(input)}`); + } + return input; + }); + } + + const keys = Object.keys(map) as Array; + const normalized = keys.map( + (key) => + [ + key, + normalizeValidationFunction(map[key as keyof typeof map]), + ] as const + ); + return createOptionalParser((input) => { + if (input === null || typeof input !== "object") { + throw new TypeError(`Expected an object, received ${inspect(input)}`); + } + + const validationErrors = new ValidationErrors(); + + const result: any = {}; + for (const [key, parse] of normalized) { + const value = input[key as keyof typeof input]; + try { + result[key] = parse ? parse(value) : value; + } catch (err) { + validationErrors.add(key, value, err as Error); + } + } + + if (validationErrors.length) throw validationErrors; + return result; + }); + }, + + union(parserA, parserB) { + return createOptionalParser((input) => { + return { + ...parserA(input), + ...parserB(input), + }; + }); + }, +}; + +export type ObjectValidationMap = Record; From 326fef8d181e1650261ee56b4feb3c8adddc9387 Mon Sep 17 00:00:00 2001 From: scottrippey Date: Fri, 5 Jan 2024 15:47:44 -0600 Subject: [PATCH 24/45] feature(conditionals): refactored `project` to utilize object shape --- packages/groq-builder/src/commands/project.ts | 185 ++++++++++-------- .../src/commands/projection-types.ts | 2 +- 2 files changed, 103 insertions(+), 84 deletions(-) diff --git a/packages/groq-builder/src/commands/project.ts b/packages/groq-builder/src/commands/project.ts index 749083d..faaff26 100644 --- a/packages/groq-builder/src/commands/project.ts +++ b/packages/groq-builder/src/commands/project.ts @@ -3,12 +3,20 @@ import { GroqBuilder } from "../groq-builder"; import { Parser, ParserFunction } from "../types/public-types"; import { isParser, normalizeValidationFunction } from "./validate-utils"; import { ResultItem, ResultOverride } from "../types/result-types"; -import { ValidationErrors } from "../validation/validation-errors"; -import { ExtractProjectionResult, ProjectionMap } from "./projection-types"; +import { + ExtractProjectionResult, + ProjectionFieldConfig, + ProjectionMap, +} from "./projection-types"; import { ConditionalProjectionResultWrapper, ExtractConditionalProjectionTypes, } from "./conditional-types"; +import { + objectValidation, + ObjectValidationMap, +} from "../validation/object-shape"; +import { arrayValidation } from "../validation/array-shape"; declare module "../groq-builder" { export interface GroqBuilder { @@ -46,7 +54,7 @@ GroqBuilder.implement({ project( this: GroqBuilder, projectionMapArg: object | ((q: any) => object), - conditionalProjections?: object | ((q: any) => object) + conditionalProjectionsArg? ): GroqBuilder { // Retrieve the projectionMap: let projectionMap: object; @@ -55,97 +63,108 @@ GroqBuilder.implement({ } else { projectionMap = projectionMapArg; } - if (conditionalProjections) { - if (typeof conditionalProjections === "function") { - conditionalProjections = conditionalProjections(this.root); - } - // Just push the conditions into the `projectionMap` since the logic is the same - Object.assign(projectionMap, conditionalProjections); + + let conditionalProjections: + | ConditionalProjectionResultWrapper + | undefined; + if (typeof conditionalProjectionsArg === "function") { + conditionalProjections = conditionalProjectionsArg(this.root); + } else { + conditionalProjections = conditionalProjectionsArg; } - // Analyze all the projection values: + // Compile query from projection values: const keys = Object.keys(projectionMap) as Array; - const values = keys - .map((key) => { - const value: unknown = projectionMap[key as keyof typeof projectionMap]; - if (value instanceof GroqBuilder) { - const query = key === value.query ? key : `"${key}": ${value.query}`; - return { key, query, parser: value.internal.parser }; - } else if (typeof value === "string") { - const query = key === value ? key : `"${key}": ${value}`; - return { key, query, parser: null }; - } else if (typeof value === "boolean") { - if (value === false) return null; // 'false' will be excluded from the results - return { key, query: key, parser: null }; - } else if (Array.isArray(value)) { - const [projectionKey, parser] = value as [string, Parser]; - const query = - key === projectionKey ? key : `"${key}": ${projectionKey}`; - - return { - key, - query, - parser: normalizeValidationFunction(parser), - }; - } else if (isParser(value)) { - return { - key, - query: key, - parser: normalizeValidationFunction(value), - }; - } else { - throw new Error( - `Unexpected value for projection key "${key}": "${typeof value}"` - ); - } + const fields = keys + .map((key) => { + const fieldConfig = projectionMap[key as keyof typeof projectionMap]; + return normalizeProjectionField(key, fieldConfig); }) .filter(notNull); - const queries = values.map((v) => v.query); + const queries = fields.map((v) => v.query); - const { newLine, space } = this.indentation; + if (conditionalProjections) { + queries.push(conditionalProjections.query); + } + const { newLine, space } = this.indentation; const newQuery = ` {${newLine}${space}${queries.join( `,${newLine}${space}` )}${newLine}}`; - type TResult = Record; - const parsers = values.filter((v) => v.parser); - const newParser = !parsers.length - ? null - : function projectionParser(input: TResult) { - const isArray = Array.isArray(input); - const items = isArray ? input : [input]; - const validationErrors = new ValidationErrors(); - - const parsedResults = items.map((item, i) => { - const parsedResult = { ...item }; - - for (const { key, parser } of parsers) { - const value = item[key]; - try { - const parsedValue = parser!(value); - parsedResult[key] = parsedValue; - } catch (err) { - const path = isArray ? `[${i}].${key}` : key; - validationErrors.add(path, value, err as Error); - } - } - - return parsedResult; - }); - - if (validationErrors.length) { - throw validationErrors; - } - - return isArray ? parsedResults : parsedResults[0]; - }; - - return this.chain(newQuery, newParser); + // Create a combined parser: + let projectionParser: ParserFunction | null = null; + if (fields.some((f) => f.parser)) { + const objectShape = Object.fromEntries( + fields.map((f) => [f.key, f.parser]) + ); + projectionParser = createProjectionParser(objectShape); + } + + const conditionalParser = conditionalProjections?.parser; + if (conditionalParser) { + projectionParser = objectValidation.union( + projectionParser || objectValidation.object(), + conditionalParser + ); + } + + return this.chain(newQuery, projectionParser); }, }); + +function normalizeProjectionField( + key: string, + fieldConfig: ProjectionFieldConfig +): null | { key: string; query: string; parser: ParserFunction | null } { + // Analyze the field configuration: + const value: unknown = fieldConfig; + if (value instanceof GroqBuilder) { + const query = key === value.query ? key : `"${key}": ${value.query}`; + return { key, query, parser: value.parser }; + } else if (typeof value === "string") { + const query = key === value ? key : `"${key}": ${value}`; + return { key, query, parser: null }; + } else if (typeof value === "boolean") { + if (value === false) return null; // 'false' will be excluded from the results + return { key, query: key, parser: null }; + } else if (Array.isArray(value)) { + const [projectionKey, parser] = value as [string, Parser]; + const query = key === projectionKey ? key : `"${key}": ${projectionKey}`; + + return { + key, + query, + parser: normalizeValidationFunction(parser), + }; + } else if (isParser(value)) { + return { + key, + query: key, + parser: normalizeValidationFunction(value), + }; + } else { + throw new Error( + `Unexpected value for projection key "${key}": "${typeof value}"` + ); + } +} + +type UnknownObject = Record; + +function createProjectionParser(parsers: ObjectValidationMap): ParserFunction { + const objectParser = objectValidation.object(parsers); + const arrayParser = arrayValidation.array(objectParser); + + return function projectionParser( + input: UnknownObject | Array + ) { + // Operates against either an array or a single item: + if (!Array.isArray(input)) { + return objectParser(input); + } + + return arrayParser(input); + }; +} diff --git a/packages/groq-builder/src/commands/projection-types.ts b/packages/groq-builder/src/commands/projection-types.ts index cdc48f4..aaffacf 100644 --- a/packages/groq-builder/src/commands/projection-types.ts +++ b/packages/groq-builder/src/commands/projection-types.ts @@ -45,7 +45,7 @@ export type ProjectionMapOrCallback< | ProjectionMap | ((q: GroqBuilder) => ProjectionMap); -type ProjectionFieldConfig = +export type ProjectionFieldConfig = // Use 'true' to include a field as-is | true // Use a string for naked projections, like 'slug.current' From 2b53b9fd40d5e27a8e64461dbb4b15f63e070a69 Mon Sep 17 00:00:00 2001 From: Scott Rippey Date: Wed, 10 Jan 2024 12:05:12 -0700 Subject: [PATCH 25/45] groq-builder: Added `q.fragment` implementation (#250) * 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 --- .changeset/violet-crabs-invent.md | 5 + packages/groq-builder/docs/FRAGMENTS.md | 76 +++++++++++ .../src/commands/fragment.test.ts | 126 ++++++++++++++++++ .../groq-builder/src/commands/fragment.ts | 29 ++++ packages/groq-builder/src/commands/index.ts | 1 + .../src/commands/projection-types.ts | 16 ++- .../groq-builder/src/types/public-types.ts | 12 ++ packages/groq-builder/src/types/utils.test.ts | 32 ++++- packages/groq-builder/src/types/utils.ts | 19 +++ 9 files changed, 309 insertions(+), 7 deletions(-) create mode 100644 .changeset/violet-crabs-invent.md create mode 100644 packages/groq-builder/docs/FRAGMENTS.md create mode 100644 packages/groq-builder/src/commands/fragment.test.ts create mode 100644 packages/groq-builder/src/commands/fragment.ts diff --git a/.changeset/violet-crabs-invent.md b/.changeset/violet-crabs-invent.md new file mode 100644 index 0000000..20c133d --- /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 0000000..7b435d2 --- /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 0000000..b769c5c --- /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 0000000..f72a76a --- /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 0ab3c59..e39530d 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 2df6e5d..3e61743 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 701842b..521f9d7 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 c4a870b..c1d4c0c 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 f9aaf42..8601433 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; From 176eed0bf6637907160df1b21f3380480999a805 Mon Sep 17 00:00:00 2001 From: scottrippey Date: Thu, 11 Jan 2024 14:04:42 -0600 Subject: [PATCH 26/45] feature(conditionals): added `IGroqBuilder` for easier circular references --- packages/groq-builder/src/groq-builder.ts | 20 +++++++++++++++++-- .../groq-builder/src/types/public-types.ts | 18 ++++++++++++----- 2 files changed, 31 insertions(+), 7 deletions(-) diff --git a/packages/groq-builder/src/groq-builder.ts b/packages/groq-builder/src/groq-builder.ts index 55630c0..a2bc096 100644 --- a/packages/groq-builder/src/groq-builder.ts +++ b/packages/groq-builder/src/groq-builder.ts @@ -1,4 +1,8 @@ -import type { Parser, ParserFunction } from "./types/public-types"; +import type { + IGroqBuilder, + Parser, + ParserFunction, +} from "./types/public-types"; import type { RootConfig } from "./types/schema-types"; import { chainParsers, @@ -6,6 +10,7 @@ import { } from "./commands/validate-utils"; import { ValidationErrors } from "./validation/validation-errors"; import { Empty } from "./types/utils"; +import { GroqBuilderResultType } from "./types/public-types"; export type RootResult = Empty; @@ -19,7 +24,11 @@ export type GroqBuilderOptions = { export class GroqBuilder< TResult = unknown, TRootConfig extends RootConfig = RootConfig -> { +> implements IGroqBuilder +{ + // @ts-expect-error --- This property doesn't actually exist, it's only used to capture type info + readonly [GroqBuilderResultType]: TResult; + /** * Extends the GroqBuilder class by implementing methods. * This allows for this class to be split across multiple files in the `./commands/` folder. @@ -58,6 +67,13 @@ export class GroqBuilder< return this.internal.query; } + /** + * The parser function that should be used to parse result data + */ + public get parser() { + return this.internal.parser; + } + /** * Parses and validates the query results, passing all data through the parsers. */ diff --git a/packages/groq-builder/src/types/public-types.ts b/packages/groq-builder/src/types/public-types.ts index e3d584c..4b7b0cc 100644 --- a/packages/groq-builder/src/types/public-types.ts +++ b/packages/groq-builder/src/types/public-types.ts @@ -1,6 +1,6 @@ import { GroqBuilder } from "../groq-builder"; import { ResultItem } from "./result-types"; -import { Simplify, Tagged } from "./utils"; +import { Simplify } from "./utils"; import { ExtractProjectionResult } from "../commands/projection-types"; /* eslint-disable @typescript-eslint/no-explicit-any */ @@ -44,10 +44,8 @@ export type ParserFunctionMaybe< /** * Extracts the Result type from a GroqBuilder query */ -export type InferResultType = - TGroqBuilder extends GroqBuilder - ? TResultType - : never; +export type InferResultType> = + TGroqBuilder extends IGroqBuilder ? TResultType : never; /** * Extracts the Result type for a single item from a GroqBuilder query @@ -56,6 +54,16 @@ export type InferResultItem = ResultItem< InferResultType >; +export declare const GroqBuilderResultType: unique symbol; +// This is used to prevent circular references +export type IGroqBuilder = { + readonly [GroqBuilderResultType]: TResult; + query: string; + parser: ParserFunction | null; +}; +export type InferResultType2> = + TGroqBuilder extends IGroqBuilder ? TResult : never; + export declare const FragmentInputTypeTag: unique symbol; export type Fragment< TProjectionMap, From fc019748c70ba5524ecd7e6338af44e0dc33f787 Mon Sep 17 00:00:00 2001 From: scottrippey Date: Thu, 11 Jan 2024 14:04:54 -0600 Subject: [PATCH 27/45] feature(conditionals): improve `EntriesOf` --- packages/groq-builder/src/types/utils.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/groq-builder/src/types/utils.ts b/packages/groq-builder/src/types/utils.ts index 4900b6e..78fa117 100644 --- a/packages/groq-builder/src/types/utils.ts +++ b/packages/groq-builder/src/types/utils.ts @@ -88,9 +88,9 @@ export type ExtractTypeMismatchErrors = : never; export type ValueOf = T[keyof T]; -export type EntriesOf = { - [Key in keyof T]: [Key, T[Key]]; -}[keyof T]; +export type EntriesOf = ValueOf<{ + [Key in StringKeys]: readonly [Key, T[Key]]; +}>; /** * Excludes symbol and number from keys, so that you only have strings. From b9da0050c338e90a27b4ff93abd89f4f8ac8b778 Mon Sep 17 00:00:00 2001 From: scottrippey Date: Thu, 11 Jan 2024 14:07:55 -0600 Subject: [PATCH 28/45] feature(conditionals): updated `Conditional` signature again, to ensure unique, spreadable keys --- .../src/commands/conditional-types.ts | 99 +++++++++------ .../src/commands/conditionalByType.test.ts | 58 ++++++--- .../src/commands/conditionalByType.ts | 65 +++++++--- packages/groq-builder/src/commands/project.ts | 116 ++++++++---------- .../src/commands/projection-types.ts | 22 ++-- 5 files changed, 221 insertions(+), 139 deletions(-) diff --git a/packages/groq-builder/src/commands/conditional-types.ts b/packages/groq-builder/src/commands/conditional-types.ts index ebf9728..366335d 100644 --- a/packages/groq-builder/src/commands/conditional-types.ts +++ b/packages/groq-builder/src/commands/conditional-types.ts @@ -6,8 +6,9 @@ import { import { Empty, Simplify, Tagged, ValueOf } from "../types/utils"; import { ExtractTypeNames, RootConfig } from "../types/schema-types"; import { GroqBuilder } from "../groq-builder"; +import { IGroqBuilder, InferResultType } from "../types/public-types"; -export type ConditionalProjections< +export type ConditionalProjectionMap< TResultItem, TRootConfig extends RootConfig > = { @@ -20,28 +21,44 @@ export type ConditionalProjections< export type ConditionalExpression = Tagged; -export type WrapConditionalProjectionResults< +export type ExtractConditionalProjectionResults< TResultItem, - TConditionalProjections extends ConditionalProjections -> = ConditionalProjectionResultWrapper< - ValueOf<{ - [Condition in keyof TConditionalProjections]: Simplify< - ExtractProjectionResult - >; - }> + TConditionalProjectionMap extends ConditionalProjectionMap, + TKey extends string +> = SpreadableConditionals< + TKey, + | Empty + | ValueOf<{ + [P in keyof TConditionalProjectionMap]: ExtractProjectionResult< + TResultItem, + TConditionalProjectionMap[P] + >; + }> >; -export declare const ConditionalProjectionResultTypesTag: unique symbol; -export type ConditionalProjectionResultWrapper = { - readonly [ConditionalProjectionResultTypesTag]?: TResultTypes; +// { +// [Condition in StringKeys< +// keyof TConditionalProjectionMap +// >]: Simplify< +// ExtractProjectionResult +// >; +// }; + +export type OmitConditionalProjections = { + [P in Exclude>]: TResultItem[P]; }; -export type ExtractConditionalProjectionTypes = - TResultItem extends ConditionalProjectionResultWrapper - ? TResultTypes - : Empty; +export type ExtractConditionalProjectionTypes = Simplify< + | Empty + | ValueOf<{ + [P in Extract< + keyof TProjectionMap, + ConditionalKey + >]: InferResultType>; + }> +>; -export type ConditionalByTypeProjections< +export type ConditionalByTypeProjectionMap< TResultItem, TRootConfig extends RootConfig > = { @@ -51,24 +68,34 @@ export type ConditionalByTypeProjections< >; }; -export type WrapConditionalByTypeProjectionResults< +export type ExtractConditionalByTypeProjectionResults< TResultItem, - TConditionalProjections extends ConditionalByTypeProjections -> = ConditionalProjectionResultWrapper< - Simplify< - | Empty - | ValueOf<{ - [_type in keyof TConditionalProjections]: TConditionalProjections[_type] extends ( - q: any - ) => infer TProjectionMap - ? ExtractProjectionResult< - Extract, - TProjectionMap - > - : ExtractProjectionResult< - Extract, - TConditionalProjections[_type] - >; - }> - > + TConditionalByTypeProjectionMap extends ConditionalByTypeProjectionMap< + any, + any + >, + TKey extends string +> = SpreadableConditionals< + TKey, + ValueOf<{ + [_type in keyof TConditionalByTypeProjectionMap]: ExtractProjectionResult< + Extract, + TConditionalByTypeProjectionMap[_type] extends ( + q: any + ) => infer TProjectionMap + ? TProjectionMap + : TConditionalByTypeProjectionMap[_type] + >; + }> >; + +export type ConditionalKey = `[Conditional] ${TKey}`; +export function isConditional(key: string): key is ConditionalKey { + return key.startsWith("[Conditional] "); +} +export type SpreadableConditionals< + TKey extends string, + ConditionalResultType +> = { + [UniqueConditionalKey in ConditionalKey]: IGroqBuilder; +}; diff --git a/packages/groq-builder/src/commands/conditionalByType.test.ts b/packages/groq-builder/src/commands/conditionalByType.test.ts index f35bf7b..adf9954 100644 --- a/packages/groq-builder/src/commands/conditionalByType.test.ts +++ b/packages/groq-builder/src/commands/conditionalByType.test.ts @@ -1,10 +1,17 @@ import { describe, it, expect } from "vitest"; -import { createGroqBuilder, ExtractTypeNames, InferResultType } from "../index"; +import { + createGroqBuilder, + ExtractTypeNames, + GroqBuilder, + IGroqBuilder, + InferResultType, +} from "../index"; import { SchemaConfig } from "../tests/schemas/nextjs-sanity-fe"; import { ExtractConditionalProjectionTypes } from "./conditional-types"; import { expectType } from "../tests/expectType"; import { executeBuilder } from "../tests/mocks/executeQuery"; import { mock } from "../tests/mocks/nextjs-sanity-fe-mocks"; +import { Empty, SimplifyDeep } from "../types/utils"; const q = createGroqBuilder({ indent: " " }); const data = mock.generateSeedData({ @@ -14,7 +21,7 @@ const data = mock.generateSeedData({ }); describe("conditionalByType", () => { - const qConditionalByType = q.star.conditionalByType({ + const conditionalByType = q.star.conditionalByType({ variant: { _type: true, name: true, price: true }, product: { _type: true, name: true, slug: "slug.current" }, category: (qC) => ({ @@ -24,30 +31,44 @@ describe("conditionalByType", () => { }), }); - const qAll = q.star.project( - { - _type: true, - }, - (qA) => - qA.conditionalByType({ - product: { name: true, slug: "slug.current" }, - variant: { name: true, price: true }, - }) - ); + type ExpectedConditionalUnion = + | { _type: "variant"; name: string; price: number } + | { _type: "product"; name: string; slug: string } + | { _type: "category"; name: string; slug: string }; + + it('should have a "spreadable" signature', () => { + expectType>().toStrictEqual< + SimplifyDeep<{ + "[Conditional] [ByType]": IGroqBuilder; + }> + >(); + + expect(conditionalByType).toMatchObject({ + "[Conditional] [ByType]": expect.any(GroqBuilder), + }); + }); - it("we should be able to extract the return types", () => { + it("should be able to extract the return types", () => { type ConditionalResults = ExtractConditionalProjectionTypes< - typeof qConditionalByType + typeof conditionalByType >; expectType().toStrictEqual< - | {} + | Empty | { _type: "variant"; name: string; price: number } | { _type: "product"; name: string; slug: string } | { _type: "category"; name: string; slug: string } >(); }); + const qAll = q.star.project((qA) => ({ + _type: true, + ...qA.conditionalByType({ + product: { _type: true, name: true, slug: "slug.current" }, + variant: { name: true, price: true }, + }), + })); + it("a projection should return the correct types", () => { type QueryResult = InferResultType; @@ -58,7 +79,7 @@ describe("conditionalByType", () => { _type: DocTypes; } | { - _type: DocTypes; + _type: "product"; name: string; slug: string; } @@ -75,11 +96,12 @@ describe("conditionalByType", () => { expect(qAll.query).toMatchInlineSnapshot(` "* { _type, - _type == \\"product\\" => { + _type == \\"product\\" => { + _type, name, \\"slug\\": slug.current }, - _type == \\"variant\\" => { + _type == \\"variant\\" => { name, price } diff --git a/packages/groq-builder/src/commands/conditionalByType.ts b/packages/groq-builder/src/commands/conditionalByType.ts index 4a19534..018a6e3 100644 --- a/packages/groq-builder/src/commands/conditionalByType.ts +++ b/packages/groq-builder/src/commands/conditionalByType.ts @@ -2,37 +2,70 @@ import { GroqBuilder } from "../groq-builder"; import { RootConfig } from "../types/schema-types"; import { ResultItem } from "../types/result-types"; import { - ConditionalByTypeProjections, - ConditionalProjections, - WrapConditionalByTypeProjectionResults, + ExtractConditionalByTypeProjectionResults, + ConditionalByTypeProjectionMap, + ConditionalKey, } from "./conditional-types"; -import { keys } from "../types/utils"; +import { ProjectionMap } from "./projection-types"; declare module "../groq-builder" { export interface GroqBuilder { conditionalByType< - TConditionalProjections extends ConditionalByTypeProjections< + TConditionalProjections extends ConditionalByTypeProjectionMap< ResultItem, TRootConfig - > + >, + TKey extends string = "[ByType]" >( - conditionalProjections: TConditionalProjections - ): WrapConditionalByTypeProjectionResults< + conditionalProjections: TConditionalProjections, + conditionalKey?: TKey + ): ExtractConditionalByTypeProjectionResults< ResultItem, - TConditionalProjections + TConditionalProjections, + TKey >; } } GroqBuilder.implement({ - conditionalByType( + conditionalByType( this: GroqBuilder, - conditionalProjections + conditionalProjections: TConditionalProjections, + conditionalKey = "[ByType]" as TKey ) { - const conditions: ConditionalProjections = {}; - for (const _type of keys(conditionalProjections)) { - conditions[`_type == "${_type}"`] = conditionalProjections[_type] as any; - } - return this.conditional$(conditions) as any; + const typeNames = Object.keys(conditionalProjections as object); + + const root = this.root; + const conditions = typeNames.map((_type) => { + const projectionMap = conditionalProjections[ + _type as keyof typeof conditionalProjections + ] as ProjectionMap; + const conditionQuery = root + .chain(`_type == "${_type}" =>`) + .project(projectionMap); + const { query, parser } = conditionQuery; + return { _type, query, parser }; + }); + + const { newLine } = this.indentation; + const query = conditions.map((c) => c.query).join(`,${newLine}`); + + const parser = !conditions.some((c) => c.parser) + ? null + : function conditionalByTypeParser(input: { _type: string }) { + // find the right conditional parser + const conditionalParser = conditions.find( + (c) => c._type === input._type + ); + if (conditionalParser?.parser) { + return conditionalParser.parser(input); + } + return {}; + }; + + const uniqueKey: ConditionalKey = `[Conditional] ${conditionalKey}`; + return { + [uniqueKey]: this.root.chain(query, parser), + } as any; }, }); diff --git a/packages/groq-builder/src/commands/project.ts b/packages/groq-builder/src/commands/project.ts index faaff26..4112ff6 100644 --- a/packages/groq-builder/src/commands/project.ts +++ b/packages/groq-builder/src/commands/project.ts @@ -1,4 +1,4 @@ -import { Empty, notNull, Simplify } from "../types/utils"; +import { notNull, Simplify } from "../types/utils"; import { GroqBuilder } from "../groq-builder"; import { Parser, ParserFunction } from "../types/public-types"; import { isParser, normalizeValidationFunction } from "./validate-utils"; @@ -8,42 +8,23 @@ import { ProjectionFieldConfig, ProjectionMap, } from "./projection-types"; -import { - ConditionalProjectionResultWrapper, - ExtractConditionalProjectionTypes, -} from "./conditional-types"; -import { - objectValidation, - ObjectValidationMap, -} from "../validation/object-shape"; +import { objectValidation } from "../validation/object-shape"; import { arrayValidation } from "../validation/array-shape"; +import { isConditional } from "./conditional-types"; declare module "../groq-builder" { export interface GroqBuilder { /** * Performs an "object projection", returning an object with the fields specified. */ - project< - TProjection extends ProjectionMap>, - TConditionals extends - | ConditionalProjectionResultWrapper - | undefined = undefined - >( + project>>( projectionMap: | TProjection - | ((q: GroqBuilder, TRootConfig>) => TProjection), - conditionalProjections?: - | TConditionals - | ((q: GroqBuilder, TRootConfig>) => TConditionals) + | ((q: GroqBuilder, TRootConfig>) => TProjection) ): GroqBuilder< ResultOverride< TResult, - Simplify< - ExtractProjectionResult, TProjection> & - (TConditionals extends undefined - ? Empty - : ExtractConditionalProjectionTypes) - > + Simplify, TProjection>> >, TRootConfig >; @@ -53,8 +34,7 @@ declare module "../groq-builder" { GroqBuilder.implement({ project( this: GroqBuilder, - projectionMapArg: object | ((q: any) => object), - conditionalProjectionsArg? + projectionMapArg: object | ((q: any) => object) ): GroqBuilder { // Retrieve the projectionMap: let projectionMap: object; @@ -64,15 +44,6 @@ GroqBuilder.implement({ projectionMap = projectionMapArg; } - let conditionalProjections: - | ConditionalProjectionResultWrapper - | undefined; - if (typeof conditionalProjectionsArg === "function") { - conditionalProjections = conditionalProjectionsArg(this.root); - } else { - conditionalProjections = conditionalProjectionsArg; - } - // Compile query from projection values: const keys = Object.keys(projectionMap) as Array; const fields = keys @@ -83,32 +54,13 @@ GroqBuilder.implement({ .filter(notNull); const queries = fields.map((v) => v.query); - - if (conditionalProjections) { - queries.push(conditionalProjections.query); - } - const { newLine, space } = this.indentation; const newQuery = ` {${newLine}${space}${queries.join( `,${newLine}${space}` )}${newLine}}`; // Create a combined parser: - let projectionParser: ParserFunction | null = null; - if (fields.some((f) => f.parser)) { - const objectShape = Object.fromEntries( - fields.map((f) => [f.key, f.parser]) - ); - projectionParser = createProjectionParser(objectShape); - } - - const conditionalParser = conditionalProjections?.parser; - if (conditionalParser) { - projectionParser = objectValidation.union( - projectionParser || objectValidation.object(), - conditionalParser - ); - } + const projectionParser = createProjectionParser(fields); return this.chain(newQuery, projectionParser); }, @@ -117,11 +69,15 @@ GroqBuilder.implement({ function normalizeProjectionField( key: string, fieldConfig: ProjectionFieldConfig -): null | { key: string; query: string; parser: ParserFunction | null } { +): null | NormalizedProjectionField { // Analyze the field configuration: const value: unknown = fieldConfig; if (value instanceof GroqBuilder) { - const query = key === value.query ? key : `"${key}": ${value.query}`; + const query = isConditional(key) // Conditionals can ignore the key + ? value.query + : key === value.query // Use shorthand syntax + ? key + : `"${key}": ${value.query}`; return { key, query, parser: value.parser }; } else if (typeof value === "string") { const query = key === value ? key : `"${key}": ${value}`; @@ -153,16 +109,52 @@ function normalizeProjectionField( type UnknownObject = Record; -function createProjectionParser(parsers: ObjectValidationMap): ParserFunction { - const objectParser = objectValidation.object(parsers); - const arrayParser = arrayValidation.array(objectParser); +type NormalizedProjectionField = { + key: string; + query: string; + parser: ParserFunction | null; +}; + +function createProjectionParser( + fields: NormalizedProjectionField[] +): ParserFunction | null { + if (!fields.some((f) => f.parser)) { + // No nested parsers! + return null; + } + + // Parse all normal fields: + const normalFields = fields.filter((f) => !isConditional(f.key)); + const objectShape = Object.fromEntries( + normalFields.map((f) => [f.key, f.parser]) + ); + const objectParser = objectValidation.object(objectShape); + + // Parse all conditional fields: + const conditionalFields = fields.filter((f) => isConditional(f.key)); + const conditionalParsers = conditionalFields + .map((f) => f.parser) + .filter(notNull); + + // Combine normal and conditional parsers: + const combinedParsers = [objectParser, ...conditionalParsers]; + const combinedParser = (input: Record) => { + const result = {}; + for (const p of combinedParsers) { + const parsed = p(input); + Object.assign(result, parsed); + } + return result; + }; + // Finally, transparently handle arrays or objects: + const arrayParser = arrayValidation.array(combinedParser); return function projectionParser( input: UnknownObject | Array ) { // Operates against either an array or a single item: if (!Array.isArray(input)) { - return objectParser(input); + return combinedParser(input); } return arrayParser(input); diff --git a/packages/groq-builder/src/commands/projection-types.ts b/packages/groq-builder/src/commands/projection-types.ts index aaffacf..2685733 100644 --- a/packages/groq-builder/src/commands/projection-types.ts +++ b/packages/groq-builder/src/commands/projection-types.ts @@ -8,10 +8,18 @@ import { TypeMismatchError, ValueOf, } from "../types/utils"; -import { FragmentInputTypeTag, Parser } from "../types/public-types"; +import { + FragmentInputTypeTag, + IGroqBuilder, + Parser, +} from "../types/public-types"; import { Path, PathEntries, PathValue } from "../types/path-types"; import { DeepRequired } from "../types/deep-required"; import { RootConfig } from "../types/schema-types"; +import { + ExtractConditionalProjectionTypes, + OmitConditionalProjections, +} from "./conditional-types"; export type ProjectionKey = IsAny extends true ? string @@ -55,23 +63,23 @@ export type ProjectionFieldConfig = // Use a tuple for naked projections with a parser | [ProjectionKey, Parser] // Use a GroqBuilder instance to create a nested projection - | GroqBuilder; + | IGroqBuilder; export type ExtractProjectionResult = (TProjectionMap extends { "...": true } ? TResultItem : Empty) & ExtractProjectionResultImpl< TResultItem, Omit< - TProjectionMap, + OmitConditionalProjections, // Ensure we remove any "tags" that we don't want in the mapped type: "..." | typeof FragmentInputTypeTag > - >; + > & + ExtractConditionalProjectionTypes; type ExtractProjectionResultImpl = { - [P in keyof TProjectionMap]: TProjectionMap[P] extends GroqBuilder< - infer TValue, - any + [P in keyof TProjectionMap]: TProjectionMap[P] extends IGroqBuilder< + infer TValue > // Extract type from GroqBuilder: ? TValue : /* Extract type from 'true': */ From 80e12916ebbc414c58d187ddb64fbaec7f3d2c95 Mon Sep 17 00:00:00 2001 From: scottrippey Date: Fri, 12 Jan 2024 09:46:00 -0600 Subject: [PATCH 29/45] feature(conditionals): updated implementation of `conditional$` --- .../src/commands/conditional$.test.ts | 69 ++++++++------- .../groq-builder/src/commands/conditional$.ts | 85 +++++++++++++------ .../src/commands/conditionalByType.ts | 23 ++--- 3 files changed, 110 insertions(+), 67 deletions(-) diff --git a/packages/groq-builder/src/commands/conditional$.test.ts b/packages/groq-builder/src/commands/conditional$.test.ts index 31a4659..e30f509 100644 --- a/packages/groq-builder/src/commands/conditional$.test.ts +++ b/packages/groq-builder/src/commands/conditional$.test.ts @@ -1,52 +1,57 @@ import { describe, expect, it } from "vitest"; -import { createGroqBuilder, InferResultType } from "../index"; +import { createGroqBuilder, GroqBuilder, InferResultType } from "../index"; import { SchemaConfig } from "../tests/schemas/nextjs-sanity-fe"; import { ExtractConditionalProjectionTypes } from "./conditional-types"; import { expectType } from "../tests/expectType"; +import { Simplify } from "../types/utils"; const q = createGroqBuilder({ indent: " " }); const qBase = q.star.filterByType("variant"); describe("conditional$", () => { - it("by itself, we should be able to extract the union of projection types", () => { - const qConditional = q.star.filterByType("variant").conditional$({ - '_type == "foo"': { - name: true, - price: true, + describe("by itself", () => { + const conditionalResult = q.star.filterByType("variant").conditional$({ + "price == msrp": { + onSale: q.value(false), }, - '_type == "bar"': { - name: true, + "price < msrp": { + onSale: q.value(true), + price: true, msrp: true, }, }); - - expectType< - ExtractConditionalProjectionTypes - >().toStrictEqual< - { name: string; price: number } | { name: string; msrp: number } - >(); + it("we should be able to extract the intersection of projection types", () => { + expectType< + Simplify> + >().toStrictEqual< + {} | { 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( - { - name: true, - }, - (qA) => - qA.conditional$({ - "price == msrp": { - onSale: q.value(false), - }, - "price < msrp": { - onSale: q.value(true), - price: true, - msrp: true, - }, - }) - ); + 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>().toStrictEqual< Array< + | { name: string } | { name: string; onSale: false } | { name: string; onSale: true; price: number; msrp: number } > @@ -58,10 +63,10 @@ describe("conditional$", () => { ` "*[_type == \\"variant\\"] { name, - price == msrp => { + price == msrp => { \\"onSale\\": false }, - price < msrp => { + price < msrp => { \\"onSale\\": true, price, msrp diff --git a/packages/groq-builder/src/commands/conditional$.ts b/packages/groq-builder/src/commands/conditional$.ts index f0870a7..3e1c217 100644 --- a/packages/groq-builder/src/commands/conditional$.ts +++ b/packages/groq-builder/src/commands/conditional$.ts @@ -1,48 +1,83 @@ import { GroqBuilder } from "../groq-builder"; import { ResultItem } from "../types/result-types"; import { - ConditionalProjections, - WrapConditionalProjectionResults, + ConditionalKey, + ConditionalProjectionMap, + ExtractConditionalProjectionResults, + SpreadableConditionals, } from "./conditional-types"; +import { notNull } from "../types/utils"; +import { ParserFunction } from "../types/public-types"; +import { ProjectionMap } from "./projection-types"; declare module "../groq-builder" { // eslint-disable-next-line @typescript-eslint/no-unused-vars export interface GroqBuilder { conditional$< - TConditionalProjections extends ConditionalProjections< + TConditionalProjections extends ConditionalProjectionMap< ResultItem, TRootConfig - > + >, + TKey extends string = "[$]" >( - conditionalProjections: TConditionalProjections - ): WrapConditionalProjectionResults< + conditionalProjections: TConditionalProjections, + conditionalKey?: TKey + ): ExtractConditionalProjectionResults< ResultItem, - TConditionalProjections + TConditionalProjections, + TKey >; } } GroqBuilder.implement({ - conditional$(this: GroqBuilder, conditionalProjections): any { - // Return an object; the `project` method will turn it into a query. + conditional$( + this: GroqBuilder, + conditionalProjections: TCP, + conditionalKey = "[$]" as TKey + ) { const root = this.root; - return Object.fromEntries( - Object.entries(conditionalProjections).map( - ([condition, projectionMap]) => { - if (typeof projectionMap === "function") { - projectionMap = projectionMap(root); - } + const allConditionalProjections = Object.entries( + conditionalProjections + ).map(([condition, projectionMap]) => { + const conditionalProjection = root + .chain(`${condition} =>`) + .project(projectionMap as ProjectionMap); - const projection = root - .chain(`${condition} => `) - .project(projectionMap); + return conditionalProjection; + }); - // By returning a key that's equal to the query, - // this will instruct `project` to output the entry without ":" - const newKey = projection.query; - return [newKey, projection]; - } - ) - ); + const { newLine } = this.indentation; + const query = allConditionalProjections + .map((q) => q.query) + .join(`,${newLine}`); + + const parsers = allConditionalProjections + .map((q) => q.internal.parser) + .filter(notNull); + const conditionalParser = !parsers.length + ? null + : createConditionalParserUnion(parsers); + + const conditionalQuery = root.chain(query, conditionalParser); + const uniqueKey: ConditionalKey = `[Conditional] ${conditionalKey}`; + + return { + [uniqueKey]: conditionalQuery, + } as unknown as SpreadableConditionals; }, }); +function createConditionalParserUnion(parsers: ParserFunction[]) { + return function parserUnion(input: unknown) { + for (const parser of parsers) { + try { + return parser(input); + } catch (err) { + // All errors are ignored, + // since we never know if it errored due to invalid data, + // or if it errored due to not meeting the conditional check. + } + } + return {}; + }; +} diff --git a/packages/groq-builder/src/commands/conditionalByType.ts b/packages/groq-builder/src/commands/conditionalByType.ts index 018a6e3..08e9fc2 100644 --- a/packages/groq-builder/src/commands/conditionalByType.ts +++ b/packages/groq-builder/src/commands/conditionalByType.ts @@ -5,6 +5,7 @@ import { ExtractConditionalByTypeProjectionResults, ConditionalByTypeProjectionMap, ConditionalKey, + SpreadableConditionals, } from "./conditional-types"; import { ProjectionMap } from "./projection-types"; @@ -28,12 +29,15 @@ declare module "../groq-builder" { } GroqBuilder.implement({ - conditionalByType( + conditionalByType< + TConditionalProjections extends object, + TKey extends string + >( this: GroqBuilder, conditionalProjections: TConditionalProjections, conditionalKey = "[ByType]" as TKey ) { - const typeNames = Object.keys(conditionalProjections as object); + const typeNames = Object.keys(conditionalProjections); const root = this.root; const conditions = typeNames.map((_type) => { @@ -50,22 +54,21 @@ GroqBuilder.implement({ const { newLine } = this.indentation; const query = conditions.map((c) => c.query).join(`,${newLine}`); - const parser = !conditions.some((c) => c.parser) + const conditionalParser = !conditions.some((c) => c.parser) ? null : function conditionalByTypeParser(input: { _type: string }) { // find the right conditional parser - const conditionalParser = conditions.find( - (c) => c._type === input._type - ); - if (conditionalParser?.parser) { - return conditionalParser.parser(input); + const typeParser = conditions.find((c) => c._type === input._type); + if (typeParser?.parser) { + return typeParser.parser(input); } return {}; }; + const conditionalQuery = this.root.chain(query, conditionalParser); const uniqueKey: ConditionalKey = `[Conditional] ${conditionalKey}`; return { - [uniqueKey]: this.root.chain(query, parser), - } as any; + [uniqueKey]: conditionalQuery, + } as unknown as SpreadableConditionals; }, }); From 8712923dc0f8eaf0034144bb4f8b07806ddc2947 Mon Sep 17 00:00:00 2001 From: scottrippey Date: Fri, 12 Jan 2024 10:17:52 -0600 Subject: [PATCH 30/45] feature(conditionals): added tests for conditionals within fragments --- .../src/commands/fragment.test.ts | 83 +++++++++++++++++-- 1 file changed, 78 insertions(+), 5 deletions(-) diff --git a/packages/groq-builder/src/commands/fragment.test.ts b/packages/groq-builder/src/commands/fragment.test.ts index b769c5c..8f392bc 100644 --- a/packages/groq-builder/src/commands/fragment.test.ts +++ b/packages/groq-builder/src/commands/fragment.test.ts @@ -5,7 +5,7 @@ import { InferFragmentType, InferResultType } from "../types/public-types"; import { createGroqBuilder } from "../index"; import { TypeMismatchError } from "../types/utils"; -const q = createGroqBuilder(); +const q = createGroqBuilder({ indent: " " }); describe("fragment", () => { // define a fragment: @@ -16,7 +16,7 @@ describe("fragment", () => { }); type VariantFragment = InferFragmentType; - it("should have the correct type", () => { + it("simple fragment should have the correct type", () => { expectType().toStrictEqual<{ name: string; price: number; @@ -37,7 +37,7 @@ describe("fragment", () => { })); type ProductFragment = InferFragmentType; - it("should have the correct types", () => { + it("nested fragments should have the correct types", () => { expectType().toEqual<{ name: string; slug: string; @@ -57,9 +57,16 @@ describe("fragment", () => { >(); expect(qVariants.query).toMatchInlineSnapshot( - '"*[_type == \\"variant\\"] { name, price, \\"slug\\": slug.current }"' + ` + "*[_type == \\"variant\\"] { + name, + price, + \\"slug\\": slug.current + }" + ` ); }); + it("fragments can be spread in a query", () => { const qVariantsPlus = q.star.filterByType("variant").project({ ...variantFragment, @@ -70,7 +77,14 @@ describe("fragment", () => { >(); expect(qVariantsPlus.query).toMatchInlineSnapshot( - '"*[_type == \\"variant\\"] { name, price, \\"slug\\": slug.current, msrp }"' + ` + "*[_type == \\"variant\\"] { + name, + price, + \\"slug\\": slug.current, + msrp + }" + ` ); }); @@ -123,4 +137,63 @@ describe("fragment", () => { name: string; }>(); }); + + describe("fragments can use conditionals", () => { + const fragmentWithConditional = q + .fragment() + .project((qP) => ({ + name: true, + ...qP.conditional$({ + "price == msrp": { onSale: q.value(false) }, + "price < msrp": { onSale: q.value(true), price: true, msrp: true }, + }), + })); + const qConditional = q.star.filterByType("variant").project({ + slug: "slug.current", + ...fragmentWithConditional, + }); + + it("the inferred type is correct", () => { + expectType< + InferFragmentType + >().toStrictEqual< + | { name: string } + | { name: string; onSale: false } + | { name: string; onSale: true; price: number; msrp: number } + >(); + }); + + it("the fragment can be used in a query", () => { + expectType>().toStrictEqual< + Array< + | { slug: string; name: string } + | { slug: string; name: string; onSale: false } + | { + slug: string; + name: string; + onSale: true; + price: number; + msrp: number; + } + > + >(); + }); + + it("the query is compiled correctly", () => { + expect(qConditional.query).toMatchInlineSnapshot(` + "*[_type == \\"variant\\"] { + \\"slug\\": slug.current, + name, + price == msrp => { + \\"onSale\\": false + }, + price < msrp => { + \\"onSale\\": true, + price, + msrp + } + }" + `); + }); + }); }); From c1d70c8705fc1b6623da23a4a083c485b27c4b39 Mon Sep 17 00:00:00 2001 From: scottrippey Date: Fri, 12 Jan 2024 10:34:00 -0600 Subject: [PATCH 31/45] feature(conditionals): added tests for multiple conditionals --- .../src/commands/conditional$.test.ts | 102 ++++++++++++++++++ 1 file changed, 102 insertions(+) diff --git a/packages/groq-builder/src/commands/conditional$.test.ts b/packages/groq-builder/src/commands/conditional$.test.ts index e30f509..ff2d955 100644 --- a/packages/groq-builder/src/commands/conditional$.test.ts +++ b/packages/groq-builder/src/commands/conditional$.test.ts @@ -75,4 +75,106 @@ describe("conditional$", () => { ` ); }); + + 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>().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 == condition": { foo: q.value("FOO") }, + }, + "unique-key" + ), + })); + + it("the types should be inferred correctly", () => { + expectType>().toStrictEqual< + Array< + | { 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"; + } + > + >(); + }); + + 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 == condition => { + \\"foo\\": \\"FOO\\" + } + }" + `); + }); + }); + }); }); From c085092b5685876bf031765191666a05a0f85f1d Mon Sep 17 00:00:00 2001 From: scottrippey Date: Fri, 12 Jan 2024 11:34:40 -0600 Subject: [PATCH 32/45] feature(conditionals): improved multiple-conditional intersection types --- .../src/commands/conditional$.test.ts | 6 ++++-- .../src/commands/conditional-types.ts | 18 ++++++++---------- packages/groq-builder/src/types/utils.ts | 17 +++++++++++++++++ 3 files changed, 29 insertions(+), 12 deletions(-) diff --git a/packages/groq-builder/src/commands/conditional$.test.ts b/packages/groq-builder/src/commands/conditional$.test.ts index ff2d955..60ee216 100644 --- a/packages/groq-builder/src/commands/conditional$.test.ts +++ b/packages/groq-builder/src/commands/conditional$.test.ts @@ -3,7 +3,7 @@ import { createGroqBuilder, GroqBuilder, InferResultType } from "../index"; import { SchemaConfig } from "../tests/schemas/nextjs-sanity-fe"; import { ExtractConditionalProjectionTypes } from "./conditional-types"; import { expectType } from "../tests/expectType"; -import { Simplify } from "../types/utils"; +import { Empty, Simplify } from "../types/utils"; const q = createGroqBuilder({ indent: " " }); const qBase = q.star.filterByType("variant"); @@ -24,7 +24,9 @@ describe("conditional$", () => { expectType< Simplify> >().toStrictEqual< - {} | { onSale: false } | { onSale: true; price: number; msrp: number } + | Empty + | { onSale: false } + | { onSale: true; price: number; msrp: number } >(); }); it("should return a spreadable object", () => { diff --git a/packages/groq-builder/src/commands/conditional-types.ts b/packages/groq-builder/src/commands/conditional-types.ts index 366335d..7076eb2 100644 --- a/packages/groq-builder/src/commands/conditional-types.ts +++ b/packages/groq-builder/src/commands/conditional-types.ts @@ -3,7 +3,13 @@ import { ProjectionMap, ProjectionMapOrCallback, } from "./projection-types"; -import { Empty, Simplify, Tagged, ValueOf } from "../types/utils"; +import { + Empty, + IntersectionOfValues, + Simplify, + Tagged, + ValueOf, +} from "../types/utils"; import { ExtractTypeNames, RootConfig } from "../types/schema-types"; import { GroqBuilder } from "../groq-builder"; import { IGroqBuilder, InferResultType } from "../types/public-types"; @@ -36,21 +42,13 @@ export type ExtractConditionalProjectionResults< }> >; -// { -// [Condition in StringKeys< -// keyof TConditionalProjectionMap -// >]: Simplify< -// ExtractProjectionResult -// >; -// }; - export type OmitConditionalProjections = { [P in Exclude>]: TResultItem[P]; }; export type ExtractConditionalProjectionTypes = Simplify< | Empty - | ValueOf<{ + | IntersectionOfValues<{ [P in Extract< keyof TProjectionMap, ConditionalKey diff --git a/packages/groq-builder/src/types/utils.ts b/packages/groq-builder/src/types/utils.ts index 78fa117..e56e9f9 100644 --- a/packages/groq-builder/src/types/utils.ts +++ b/packages/groq-builder/src/types/utils.ts @@ -92,6 +92,23 @@ export type EntriesOf = ValueOf<{ [Key in StringKeys]: readonly [Key, T[Key]]; }>; +/** + * Returns the intersection (&) of the values of a type. + * + * Similar to ValueOf, which returns the union (|) of the values. + * + * @example + * IntersectionOfValues<{ + * foo: { foo: "FOO" } | { }, + * bar: { bar: "BAR" } | { }, + * }> == { } | { foo: "FOO" } | { bar: "BAR" } | { foo: "FOO", bar: "BAR" } + */ +export type IntersectionOfValues = { + [P in keyof T]: (x: T[P]) => void; +}[keyof T] extends (x: infer ValueIntersection) => void + ? ValueIntersection + : never; + /** * Excludes symbol and number from keys, so that you only have strings. */ From 53852f6c6c3182085c4648c735c855df3befe0dd Mon Sep 17 00:00:00 2001 From: scottrippey Date: Fri, 12 Jan 2024 22:55:12 -0600 Subject: [PATCH 33/45] feature(conditionals): added CONDITIONALS docs --- packages/groq-builder/docs/CONDITIONALS.md | 51 ++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 packages/groq-builder/docs/CONDITIONALS.md diff --git a/packages/groq-builder/docs/CONDITIONALS.md b/packages/groq-builder/docs/CONDITIONALS.md new file mode 100644 index 0000000..95bce33 --- /dev/null +++ b/packages/groq-builder/docs/CONDITIONALS.md @@ -0,0 +1,51 @@ +# 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" }, + }), + }); +``` + +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", description: true }, + actor: { title: "name", biography: true }, + }) + })); +``` + +The result type here is inferred as: +```ts +Array< + | { slug: string } + | { slug: string, title: string, description: string } + | { slug: string, title: string, biography: string } +> +``` From 90d612f211f3f2b8c3d514bc2b6f9b02ab692dc0 Mon Sep 17 00:00:00 2001 From: scottrippey Date: Tue, 16 Jan 2024 09:29:55 -0600 Subject: [PATCH 34/45] feature(conditionals): made `key` a configuration option --- .../src/commands/conditional$.test.ts | 55 +++++++++++------- .../groq-builder/src/commands/conditional$.ts | 10 +++- .../src/commands/conditional-types.ts | 23 ++++---- .../src/commands/conditionalByType.test.ts | 57 ++++++++++++++++++- .../src/commands/conditionalByType.ts | 9 ++- 5 files changed, 118 insertions(+), 36 deletions(-) diff --git a/packages/groq-builder/src/commands/conditional$.test.ts b/packages/groq-builder/src/commands/conditional$.test.ts index 60ee216..7c6b07a 100644 --- a/packages/groq-builder/src/commands/conditional$.test.ts +++ b/packages/groq-builder/src/commands/conditional$.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from "vitest"; -import { createGroqBuilder, GroqBuilder, InferResultType } from "../index"; +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"; @@ -134,29 +139,41 @@ describe("conditional$", () => { }), ...qV.conditional$( { - "another == condition": { foo: q.value("FOO") }, + "another == condition1": { foo: q.value("FOO") }, + "another == condition2": { bar: q.value("BAR") }, }, - "unique-key" + { key: "unique-key" } ), })); it("the types should be inferred correctly", () => { - expectType>().toStrictEqual< - Array< - | { 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"; - } - > - >(); + type ActualItem = InferResultItem; + 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; + expectType().toStrictEqual(); + expectType().toStrictEqual(); }); it("the query should be compiled correctly", () => { diff --git a/packages/groq-builder/src/commands/conditional$.ts b/packages/groq-builder/src/commands/conditional$.ts index 3e1c217..a075ab1 100644 --- a/packages/groq-builder/src/commands/conditional$.ts +++ b/packages/groq-builder/src/commands/conditional$.ts @@ -1,6 +1,7 @@ import { GroqBuilder } from "../groq-builder"; import { ResultItem } from "../types/result-types"; import { + ConditionalConfig, ConditionalKey, ConditionalProjectionMap, ExtractConditionalProjectionResults, @@ -21,7 +22,7 @@ declare module "../groq-builder" { TKey extends string = "[$]" >( conditionalProjections: TConditionalProjections, - conditionalKey?: TKey + config?: ConditionalConfig ): ExtractConditionalProjectionResults< ResultItem, TConditionalProjections, @@ -34,7 +35,7 @@ GroqBuilder.implement({ conditional$( this: GroqBuilder, conditionalProjections: TCP, - conditionalKey = "[$]" as TKey + config?: ConditionalConfig ) { const root = this.root; const allConditionalProjections = Object.entries( @@ -60,13 +61,16 @@ GroqBuilder.implement({ : createConditionalParserUnion(parsers); const conditionalQuery = root.chain(query, conditionalParser); - const uniqueKey: ConditionalKey = `[Conditional] ${conditionalKey}`; + const uniqueKey: ConditionalKey = `[Conditional] ${ + config?.key ?? ("[$]" as TKey) + }`; return { [uniqueKey]: conditionalQuery, } as unknown as SpreadableConditionals; }, }); + function createConditionalParserUnion(parsers: ParserFunction[]) { return function parserUnion(input: unknown) { for (const parser of parsers) { diff --git a/packages/groq-builder/src/commands/conditional-types.ts b/packages/groq-builder/src/commands/conditional-types.ts index 7076eb2..3225a4c 100644 --- a/packages/groq-builder/src/commands/conditional-types.ts +++ b/packages/groq-builder/src/commands/conditional-types.ts @@ -75,16 +75,17 @@ export type ExtractConditionalByTypeProjectionResults< TKey extends string > = SpreadableConditionals< TKey, - ValueOf<{ - [_type in keyof TConditionalByTypeProjectionMap]: ExtractProjectionResult< - Extract, - TConditionalByTypeProjectionMap[_type] extends ( - q: any - ) => infer TProjectionMap - ? TProjectionMap - : TConditionalByTypeProjectionMap[_type] - >; - }> + | Empty + | ValueOf<{ + [_type in keyof TConditionalByTypeProjectionMap]: ExtractProjectionResult< + Extract, + TConditionalByTypeProjectionMap[_type] extends ( + q: any + ) => infer TProjectionMap + ? TProjectionMap + : TConditionalByTypeProjectionMap[_type] + >; + }> >; export type ConditionalKey = `[Conditional] ${TKey}`; @@ -97,3 +98,5 @@ export type SpreadableConditionals< > = { [UniqueConditionalKey in ConditionalKey]: IGroqBuilder; }; + +export type ConditionalConfig = { key: TKey }; diff --git a/packages/groq-builder/src/commands/conditionalByType.test.ts b/packages/groq-builder/src/commands/conditionalByType.test.ts index adf9954..360c800 100644 --- a/packages/groq-builder/src/commands/conditionalByType.test.ts +++ b/packages/groq-builder/src/commands/conditionalByType.test.ts @@ -4,6 +4,7 @@ import { ExtractTypeNames, GroqBuilder, IGroqBuilder, + InferResultItem, InferResultType, } from "../index"; import { SchemaConfig } from "../tests/schemas/nextjs-sanity-fe"; @@ -11,7 +12,7 @@ import { ExtractConditionalProjectionTypes } from "./conditional-types"; import { expectType } from "../tests/expectType"; import { executeBuilder } from "../tests/mocks/executeQuery"; import { mock } from "../tests/mocks/nextjs-sanity-fe-mocks"; -import { Empty, SimplifyDeep } from "../types/utils"; +import { Empty, Simplify, SimplifyDeep } from "../types/utils"; const q = createGroqBuilder({ indent: " " }); const data = mock.generateSeedData({ @@ -32,6 +33,7 @@ describe("conditionalByType", () => { }); type ExpectedConditionalUnion = + | Empty | { _type: "variant"; name: string; price: number } | { _type: "product"; name: string; slug: string } | { _type: "category"; name: string; slug: string }; @@ -48,6 +50,59 @@ describe("conditionalByType", () => { }); }); + describe("multiple conditionals can be spread", () => { + const qMultiple = q.star.project((q) => ({ + ...q.conditionalByType({ + variant: { price: true }, + product: { slug: "slug.current" }, + }), + ...q.conditionalByType( + { + category: { description: true }, + style: { name: true }, + }, + { key: "unique-key" } + ), + })); + + it("should infer the correct type", () => { + type ActualItem = Simplify>; + type ExpectedItem = + | Empty + | { price: number } + | { slug: string } + | { description: string | undefined } + | { name: string | undefined } + | { price: number; description: string | undefined } + | { price: number; name: string | undefined } + | { slug: string; description: string | undefined } + | { slug: string; name: string | undefined }; + + type Remainder = Exclude; + expectType().toStrictEqual(); + expectType().toStrictEqual(); + }); + + it("the query should be correct", () => { + expect(qMultiple.query).toMatchInlineSnapshot(` + "* { + _type == \\"variant\\" => { + price + }, + _type == \\"product\\" => { + \\"slug\\": slug.current + }, + _type == \\"category\\" => { + description + }, + _type == \\"style\\" => { + name + } + }" + `); + }); + }); + it("should be able to extract the return types", () => { type ConditionalResults = ExtractConditionalProjectionTypes< typeof conditionalByType diff --git a/packages/groq-builder/src/commands/conditionalByType.ts b/packages/groq-builder/src/commands/conditionalByType.ts index 08e9fc2..e743ae8 100644 --- a/packages/groq-builder/src/commands/conditionalByType.ts +++ b/packages/groq-builder/src/commands/conditionalByType.ts @@ -6,6 +6,7 @@ import { ConditionalByTypeProjectionMap, ConditionalKey, SpreadableConditionals, + ConditionalConfig, } from "./conditional-types"; import { ProjectionMap } from "./projection-types"; @@ -19,7 +20,7 @@ declare module "../groq-builder" { TKey extends string = "[ByType]" >( conditionalProjections: TConditionalProjections, - conditionalKey?: TKey + config?: ConditionalConfig ): ExtractConditionalByTypeProjectionResults< ResultItem, TConditionalProjections, @@ -35,7 +36,7 @@ GroqBuilder.implement({ >( this: GroqBuilder, conditionalProjections: TConditionalProjections, - conditionalKey = "[ByType]" as TKey + config?: ConditionalConfig ) { const typeNames = Object.keys(conditionalProjections); @@ -66,7 +67,9 @@ GroqBuilder.implement({ }; const conditionalQuery = this.root.chain(query, conditionalParser); - const uniqueKey: ConditionalKey = `[Conditional] ${conditionalKey}`; + const uniqueKey: ConditionalKey = `[Conditional] ${ + config?.key ?? ("[ByType]" as TKey) + }`; return { [uniqueKey]: conditionalQuery, } as unknown as SpreadableConditionals; From 29529ab78727da9ffc7273cce39a20d07537e077 Mon Sep 17 00:00:00 2001 From: scottrippey Date: Tue, 16 Jan 2024 22:59:35 -0600 Subject: [PATCH 35/45] feature(conditionals): added an "exhaustive" check for conditionals --- .../src/commands/conditional$.test.ts | 1 + .../groq-builder/src/commands/conditional$.ts | 38 ++++++++++++------- .../src/commands/conditional-types.ts | 33 +++++++++------- .../src/commands/conditionalByType.test.ts | 18 +++++++++ .../src/commands/conditionalByType.ts | 33 ++++++++++------ 5 files changed, 84 insertions(+), 39 deletions(-) diff --git a/packages/groq-builder/src/commands/conditional$.test.ts b/packages/groq-builder/src/commands/conditional$.test.ts index 7c6b07a..b866f3a 100644 --- a/packages/groq-builder/src/commands/conditional$.test.ts +++ b/packages/groq-builder/src/commands/conditional$.test.ts @@ -25,6 +25,7 @@ describe("conditional$", () => { msrp: true, }, }); + it("we should be able to extract the intersection of projection types", () => { expectType< Simplify> diff --git a/packages/groq-builder/src/commands/conditional$.ts b/packages/groq-builder/src/commands/conditional$.ts index a075ab1..08c8c67 100644 --- a/packages/groq-builder/src/commands/conditional$.ts +++ b/packages/groq-builder/src/commands/conditional$.ts @@ -5,7 +5,6 @@ import { ConditionalKey, ConditionalProjectionMap, ExtractConditionalProjectionResults, - SpreadableConditionals, } from "./conditional-types"; import { notNull } from "../types/utils"; import { ParserFunction } from "../types/public-types"; @@ -19,23 +18,28 @@ declare module "../groq-builder" { ResultItem, TRootConfig >, - TKey extends string = "[$]" + TKey extends string = "[$]", + TIsExhaustive extends boolean = false >( conditionalProjections: TConditionalProjections, - config?: ConditionalConfig + config?: Partial> ): ExtractConditionalProjectionResults< ResultItem, TConditionalProjections, - TKey + ConditionalConfig >; } } GroqBuilder.implement({ - conditional$( + conditional$< + TCP extends object, + TKey extends string, + TIsExhaustive extends boolean + >( this: GroqBuilder, conditionalProjections: TCP, - config?: ConditionalConfig + config?: Partial> ) { const root = this.root; const allConditionalProjections = Object.entries( @@ -58,20 +62,21 @@ GroqBuilder.implement({ .filter(notNull); const conditionalParser = !parsers.length ? null - : createConditionalParserUnion(parsers); + : createConditionalParserUnion(parsers, config?.isExhaustive ?? false); const conditionalQuery = root.chain(query, conditionalParser); - const uniqueKey: ConditionalKey = `[Conditional] ${ - config?.key ?? ("[$]" as TKey) - }`; - + const key = config?.key || ("[$]" as TKey); + const conditionalKey: ConditionalKey = `[Conditional] ${key}`; return { - [uniqueKey]: conditionalQuery, - } as unknown as SpreadableConditionals; + [conditionalKey]: conditionalQuery, + } as any; }, }); -function createConditionalParserUnion(parsers: ParserFunction[]) { +function createConditionalParserUnion( + parsers: ParserFunction[], + isExhaustive: boolean +) { return function parserUnion(input: unknown) { for (const parser of parsers) { try { @@ -82,6 +87,11 @@ function createConditionalParserUnion(parsers: ParserFunction[]) { // or if it errored due to not meeting the conditional check. } } + if (isExhaustive) { + throw new TypeError( + `The data did not match any of the ${parsers.length} conditional assertions` + ); + } return {}; }; } diff --git a/packages/groq-builder/src/commands/conditional-types.ts b/packages/groq-builder/src/commands/conditional-types.ts index 3225a4c..e5e7aac 100644 --- a/packages/groq-builder/src/commands/conditional-types.ts +++ b/packages/groq-builder/src/commands/conditional-types.ts @@ -30,10 +30,10 @@ export type ConditionalExpression = Tagged; export type ExtractConditionalProjectionResults< TResultItem, TConditionalProjectionMap extends ConditionalProjectionMap, - TKey extends string + TConfig extends ConditionalConfig > = SpreadableConditionals< - TKey, - | Empty + TConfig["key"], + | (TConfig["isExhaustive"] extends true ? never : Empty) | ValueOf<{ [P in keyof TConditionalProjectionMap]: ExtractProjectionResult< TResultItem, @@ -47,13 +47,12 @@ export type OmitConditionalProjections = { }; export type ExtractConditionalProjectionTypes = Simplify< - | Empty - | IntersectionOfValues<{ - [P in Extract< - keyof TProjectionMap, - ConditionalKey - >]: InferResultType>; - }> + IntersectionOfValues<{ + [P in Extract< + keyof TProjectionMap, + ConditionalKey + >]: InferResultType>; + }> >; export type ConditionalByTypeProjectionMap< @@ -72,10 +71,10 @@ export type ExtractConditionalByTypeProjectionResults< any, any >, - TKey extends string + TConfig extends ConditionalConfig > = SpreadableConditionals< - TKey, - | Empty + TConfig["key"], + | (TConfig["isExhaustive"] extends true ? never : Empty) | ValueOf<{ [_type in keyof TConditionalByTypeProjectionMap]: ExtractProjectionResult< Extract, @@ -99,4 +98,10 @@ export type SpreadableConditionals< [UniqueConditionalKey in ConditionalKey]: IGroqBuilder; }; -export type ConditionalConfig = { key: TKey }; +export type ConditionalConfig< + TKey extends string = string, + TIsExhaustive extends boolean = boolean +> = { + key: TKey; + isExhaustive: TIsExhaustive; +}; diff --git a/packages/groq-builder/src/commands/conditionalByType.test.ts b/packages/groq-builder/src/commands/conditionalByType.test.ts index 360c800..1ada883 100644 --- a/packages/groq-builder/src/commands/conditionalByType.test.ts +++ b/packages/groq-builder/src/commands/conditionalByType.test.ts @@ -103,6 +103,24 @@ describe("conditionalByType", () => { }); }); + it("types are correct when the conditions are exhaustive", () => { + const conditionsExhaustive = q.star + .filterByType("product", "variant") + .conditionalByType({ + product: { _type: true, name: true }, + variant: { _type: true, price: true }, + }); + + type ActualItem = ExtractConditionalProjectionTypes< + typeof conditionsExhaustive + >; + type ExpectedItem = + | { _type: "product"; name: string } + | { _type: "variant"; price: number }; + + expectType>().toStrictEqual(); + }); + it("should be able to extract the return types", () => { type ConditionalResults = ExtractConditionalProjectionTypes< typeof conditionalByType diff --git a/packages/groq-builder/src/commands/conditionalByType.ts b/packages/groq-builder/src/commands/conditionalByType.ts index e743ae8..1266418 100644 --- a/packages/groq-builder/src/commands/conditionalByType.ts +++ b/packages/groq-builder/src/commands/conditionalByType.ts @@ -5,10 +5,10 @@ import { ExtractConditionalByTypeProjectionResults, ConditionalByTypeProjectionMap, ConditionalKey, - SpreadableConditionals, ConditionalConfig, } from "./conditional-types"; import { ProjectionMap } from "./projection-types"; +import { IsEqual } from "../tests/expectType"; declare module "../groq-builder" { export interface GroqBuilder { @@ -17,14 +17,19 @@ declare module "../groq-builder" { ResultItem, TRootConfig >, - TKey extends string = "[ByType]" + TKey extends string = "[ByType]", + TIsExhaustive extends boolean = TConditionalProjections extends Required< + ConditionalByTypeProjectionMap, TRootConfig> + > + ? true + : false >( conditionalProjections: TConditionalProjections, - config?: ConditionalConfig + config?: Partial> ): ExtractConditionalByTypeProjectionResults< ResultItem, TConditionalProjections, - TKey + ConditionalConfig >; } } @@ -32,11 +37,12 @@ declare module "../groq-builder" { GroqBuilder.implement({ conditionalByType< TConditionalProjections extends object, - TKey extends string + TKey extends string, + TIsExhaustive extends boolean >( this: GroqBuilder, conditionalProjections: TConditionalProjections, - config?: ConditionalConfig + config?: Partial> ) { const typeNames = Object.keys(conditionalProjections); @@ -63,15 +69,20 @@ GroqBuilder.implement({ if (typeParser?.parser) { return typeParser.parser(input); } + if (!typeParser && config?.isExhaustive) { + throw new TypeError( + `Unexpected _type "${input._type}"; expected one of: ${typeNames}` + ); + } return {}; }; const conditionalQuery = this.root.chain(query, conditionalParser); - const uniqueKey: ConditionalKey = `[Conditional] ${ - config?.key ?? ("[ByType]" as TKey) - }`; + const key: TKey = config?.key || ("[ByType]" as TKey); + const conditionalKey: ConditionalKey = `[Conditional] ${key}`; return { - [uniqueKey]: conditionalQuery, - } as unknown as SpreadableConditionals; + _type: true, // Ensure we request the `_type` parameter + [conditionalKey]: conditionalQuery, + } as any; }, }); From 79de34a127dc1e62a5c997592842a17d2e13b8e1 Mon Sep 17 00:00:00 2001 From: scottrippey Date: Wed, 17 Jan 2024 11:03:17 -0600 Subject: [PATCH 36/45] feature(conditionals): updated CONDITIONALS docs --- packages/groq-builder/docs/CONDITIONALS.md | 44 ++++++++++++++++++---- 1 file changed, 37 insertions(+), 7 deletions(-) diff --git a/packages/groq-builder/docs/CONDITIONALS.md b/packages/groq-builder/docs/CONDITIONALS.md index 95bce33..3165c95 100644 --- a/packages/groq-builder/docs/CONDITIONALS.md +++ b/packages/groq-builder/docs/CONDITIONALS.md @@ -18,11 +18,36 @@ const contentQuery = q.star }); ``` +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; +// Same as: +type ContentResults = + | { 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. +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. +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(...)` @@ -35,17 +60,22 @@ const contentQuery = q.star .project(q => ({ slug: "slug.current", ...q.conditionalByType({ - movie: { title: "title", description: true }, - actor: { title: "name", biography: true }, + 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 Array< - | { slug: string } - | { slug: string, title: string, description: string } - | { slug: string, title: string, biography: string } + { 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` function + From 2be8e6087add8a51ef92be7549e151f1ca4d72a7 Mon Sep 17 00:00:00 2001 From: scottrippey Date: Wed, 17 Jan 2024 11:06:43 -0600 Subject: [PATCH 37/45] feature(conditionals): added docs on Select function --- packages/groq-builder/docs/CONDITIONALS.md | 27 +++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/packages/groq-builder/docs/CONDITIONALS.md b/packages/groq-builder/docs/CONDITIONALS.md index 3165c95..e7c75bb 100644 --- a/packages/groq-builder/docs/CONDITIONALS.md +++ b/packages/groq-builder/docs/CONDITIONALS.md @@ -77,5 +77,30 @@ Array< Notice that this type is stronger than the example with `q.conditional$`, because we've detected that the conditions are "exhaustive". -## The `select` function +## 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. + +## 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"), + }) +})); +``` From 77ade33d709743ac83dd5dd80b25edce86056a7c Mon Sep 17 00:00:00 2001 From: scottrippey Date: Wed, 17 Jan 2024 11:19:36 -0600 Subject: [PATCH 38/45] feature(conditionals): added more examples for `q.select` --- packages/groq-builder/docs/CONDITIONALS.md | 34 +++++++++++++++++++--- 1 file changed, 30 insertions(+), 4 deletions(-) diff --git a/packages/groq-builder/docs/CONDITIONALS.md b/packages/groq-builder/docs/CONDITIONALS.md index e7c75bb..c0fd901 100644 --- a/packages/groq-builder/docs/CONDITIONALS.md +++ b/packages/groq-builder/docs/CONDITIONALS.md @@ -37,10 +37,10 @@ And the result type is inferred as: ```ts type ContentResults = InferResultType; // Same as: -type ContentResults = +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. @@ -68,9 +68,12 @@ const contentQuery = q.star The resulting query is identical to the above example with `q.conditional$`. -The result type here is inferred as: +The result type here is inferred as: + ```ts -Array< +type ContentResult = InferResultType; +// Same as: +type ContentResult = Array< { slug: string, title: string, subtitle: string } > ``` @@ -92,6 +95,29 @@ const qMovies = q.star.filterByType("movie").project({ 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; +// 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: From d09ece41a02948b7409dc5cd1d6b62f18c659914 Mon Sep 17 00:00:00 2001 From: scottrippey Date: Wed, 17 Jan 2024 11:35:41 -0600 Subject: [PATCH 39/45] feature(conditionals): added jsdocs for ConditionalConfig --- .../groq-builder/src/commands/conditional-types.ts | 10 ++++++++++ .../groq-builder/src/commands/conditionalByType.ts | 11 +++++++---- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/packages/groq-builder/src/commands/conditional-types.ts b/packages/groq-builder/src/commands/conditional-types.ts index e5e7aac..1d1ccbb 100644 --- a/packages/groq-builder/src/commands/conditional-types.ts +++ b/packages/groq-builder/src/commands/conditional-types.ts @@ -102,6 +102,16 @@ export type ConditionalConfig< TKey extends string = string, TIsExhaustive extends boolean = boolean > = { + /** + * If using multiple conditions in a single projection, + * each condition must have a unique key. + * This key is not used in the resulting query, and can be anything. + */ key: TKey; + /** + * If the conditional statements cover all possible scenarios, + * then setting `isExhaustive` to `true` will ensure stronger types, + * and can throw runtime errors if none of the conditions are satisfied. + */ isExhaustive: TIsExhaustive; }; diff --git a/packages/groq-builder/src/commands/conditionalByType.ts b/packages/groq-builder/src/commands/conditionalByType.ts index 1266418..04d71d3 100644 --- a/packages/groq-builder/src/commands/conditionalByType.ts +++ b/packages/groq-builder/src/commands/conditionalByType.ts @@ -1,5 +1,5 @@ import { GroqBuilder } from "../groq-builder"; -import { RootConfig } from "../types/schema-types"; +import { ExtractTypeNames, RootConfig } from "../types/schema-types"; import { ResultItem } from "../types/result-types"; import { ExtractConditionalByTypeProjectionResults, @@ -18,9 +18,12 @@ declare module "../groq-builder" { TRootConfig >, TKey extends string = "[ByType]", - TIsExhaustive extends boolean = TConditionalProjections extends Required< - ConditionalByTypeProjectionMap, TRootConfig> - > + /** + * Did we supply a condition for all possible _type values? + */ + TIsExhaustive extends boolean = ExtractTypeNames< + ResultItem + > extends keyof TConditionalProjections ? true : false >( From 7bb04d4b0abb040e3f3d8de33ccacda1370e2bf0 Mon Sep 17 00:00:00 2001 From: scottrippey Date: Wed, 17 Jan 2024 12:15:45 -0600 Subject: [PATCH 40/45] feature(conditionals): fixed validation error formatting --- packages/groq-builder/src/validation/validation-errors.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/groq-builder/src/validation/validation-errors.ts b/packages/groq-builder/src/validation/validation-errors.ts index 9c36470..f9107f3 100644 --- a/packages/groq-builder/src/validation/validation-errors.ts +++ b/packages/groq-builder/src/validation/validation-errors.ts @@ -41,7 +41,7 @@ export class ValidationErrors extends TypeError { } function joinPath(path1: string, path2: string) { - const emptyJoin = - !path1 || !path2 || path1.endsWith("]") || path2.startsWith("["); + const emptyJoin = !path1 || !path2 || path2.startsWith("["); + console.log({ path1, path2, emptyJoin }); return path1 + (emptyJoin ? "" : ".") + path2; } From 35b92245c2f4a0adda60f7867402067568ad705e Mon Sep 17 00:00:00 2001 From: scottrippey Date: Wed, 17 Jan 2024 12:15:52 -0600 Subject: [PATCH 41/45] feature(conditionals): updated conditional tests --- packages/groq-builder/src/commands/conditional$.test.ts | 5 ++++- packages/groq-builder/src/commands/conditionalByType.test.ts | 1 + 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/groq-builder/src/commands/conditional$.test.ts b/packages/groq-builder/src/commands/conditional$.test.ts index b866f3a..f926a22 100644 --- a/packages/groq-builder/src/commands/conditional$.test.ts +++ b/packages/groq-builder/src/commands/conditional$.test.ts @@ -189,8 +189,11 @@ describe("conditional$", () => { price, msrp }, - another == condition => { + another == condition1 => { \\"foo\\": \\"FOO\\" + }, + another == condition2 => { + \\"bar\\": \\"BAR\\" } }" `); diff --git a/packages/groq-builder/src/commands/conditionalByType.test.ts b/packages/groq-builder/src/commands/conditionalByType.test.ts index 1ada883..4a13762 100644 --- a/packages/groq-builder/src/commands/conditionalByType.test.ts +++ b/packages/groq-builder/src/commands/conditionalByType.test.ts @@ -86,6 +86,7 @@ describe("conditionalByType", () => { it("the query should be correct", () => { expect(qMultiple.query).toMatchInlineSnapshot(` "* { + _type, _type == \\"variant\\" => { price }, From a87661677bb66537d2793ea0c1b0b65d565c1ea4 Mon Sep 17 00:00:00 2001 From: scottrippey Date: Wed, 17 Jan 2024 16:14:11 -0600 Subject: [PATCH 42/45] feature(conditionals): removed debugging code --- packages/groq-builder/src/validation/validation-errors.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/groq-builder/src/validation/validation-errors.ts b/packages/groq-builder/src/validation/validation-errors.ts index f9107f3..4d54146 100644 --- a/packages/groq-builder/src/validation/validation-errors.ts +++ b/packages/groq-builder/src/validation/validation-errors.ts @@ -42,6 +42,5 @@ export class ValidationErrors extends TypeError { function joinPath(path1: string, path2: string) { const emptyJoin = !path1 || !path2 || path2.startsWith("["); - console.log({ path1, path2, emptyJoin }); return path1 + (emptyJoin ? "" : ".") + path2; } From eb6fb08c83649dabcb600ed3480123d0178c4324 Mon Sep 17 00:00:00 2001 From: scottrippey Date: Wed, 17 Jan 2024 17:09:57 -0600 Subject: [PATCH 43/45] feature(conditionals): fixed EntriesOf implementation --- packages/groq-builder/src/types/utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/groq-builder/src/types/utils.ts b/packages/groq-builder/src/types/utils.ts index e56e9f9..b8e4901 100644 --- a/packages/groq-builder/src/types/utils.ts +++ b/packages/groq-builder/src/types/utils.ts @@ -89,7 +89,7 @@ export type ExtractTypeMismatchErrors = export type ValueOf = T[keyof T]; export type EntriesOf = ValueOf<{ - [Key in StringKeys]: readonly [Key, T[Key]]; + [Key in StringKeys]: [Key, T[Key]]; }>; /** From 52c60e4ca7762656744070571f5362a342f995f8 Mon Sep 17 00:00:00 2001 From: scottrippey Date: Wed, 17 Jan 2024 17:10:38 -0600 Subject: [PATCH 44/45] feature(conditionals): fixed type param of no-arg versions of `validation.object()` and `validation.array()` --- packages/groq-builder/src/validation/array-shape.ts | 2 +- packages/groq-builder/src/validation/object-shape.ts | 2 +- packages/groq-builder/src/validation/primitives.test.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/groq-builder/src/validation/array-shape.ts b/packages/groq-builder/src/validation/array-shape.ts index 118ecaf..e7ad329 100644 --- a/packages/groq-builder/src/validation/array-shape.ts +++ b/packages/groq-builder/src/validation/array-shape.ts @@ -9,7 +9,7 @@ import { ValidationErrors } from "./validation-errors"; import { createOptionalParser, inspect, OptionalParser } from "./primitives"; export interface ArrayValidation { - array(): OptionalParser, Array>; + array(): OptionalParser, Array>; array( itemParser: TParser ): OptionalParser< diff --git a/packages/groq-builder/src/validation/object-shape.ts b/packages/groq-builder/src/validation/object-shape.ts index 9e1db61..896d680 100644 --- a/packages/groq-builder/src/validation/object-shape.ts +++ b/packages/groq-builder/src/validation/object-shape.ts @@ -10,7 +10,7 @@ import { normalizeValidationFunction } from "../commands/validate-utils"; import { ValidationErrors } from "./validation-errors"; interface ObjectValidation { - object(): OptionalParser; + object(): OptionalParser; object( map?: TMap ): OptionalParser< diff --git a/packages/groq-builder/src/validation/primitives.test.ts b/packages/groq-builder/src/validation/primitives.test.ts index cc30b31..ae8677b 100644 --- a/packages/groq-builder/src/validation/primitives.test.ts +++ b/packages/groq-builder/src/validation/primitives.test.ts @@ -133,7 +133,7 @@ describe("primitiveValidation", () => { }); describe("array", () => { - const arrParser = validation.array(); + const arrParser = validation.array(); it("should ensure the input was an array", () => { expect( From 0974de7dce6830d855bbfe3c58b68be069e169c6 Mon Sep 17 00:00:00 2001 From: scottrippey Date: Wed, 17 Jan 2024 17:41:29 -0600 Subject: [PATCH 45/45] feature(conditionals): removed unused imports --- packages/groq-builder/src/commands/conditionalByType.ts | 1 - packages/groq-builder/src/commands/projection-types.ts | 1 - packages/groq-builder/src/types/public-types.ts | 2 +- 3 files changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/groq-builder/src/commands/conditionalByType.ts b/packages/groq-builder/src/commands/conditionalByType.ts index 04d71d3..5fc0fa3 100644 --- a/packages/groq-builder/src/commands/conditionalByType.ts +++ b/packages/groq-builder/src/commands/conditionalByType.ts @@ -8,7 +8,6 @@ import { ConditionalConfig, } from "./conditional-types"; import { ProjectionMap } from "./projection-types"; -import { IsEqual } from "../tests/expectType"; declare module "../groq-builder" { export interface GroqBuilder { diff --git a/packages/groq-builder/src/commands/projection-types.ts b/packages/groq-builder/src/commands/projection-types.ts index 7a0a906..2685733 100644 --- a/packages/groq-builder/src/commands/projection-types.ts +++ b/packages/groq-builder/src/commands/projection-types.ts @@ -5,7 +5,6 @@ import { Simplify, SimplifyDeep, StringKeys, - TaggedUnwrap, TypeMismatchError, ValueOf, } from "../types/utils"; diff --git a/packages/groq-builder/src/types/public-types.ts b/packages/groq-builder/src/types/public-types.ts index fd4f422..4b7b0cc 100644 --- a/packages/groq-builder/src/types/public-types.ts +++ b/packages/groq-builder/src/types/public-types.ts @@ -1,6 +1,6 @@ import { GroqBuilder } from "../groq-builder"; import { ResultItem } from "./result-types"; -import { Simplify, Tagged } from "./utils"; +import { Simplify } from "./utils"; import { ExtractProjectionResult } from "../commands/projection-types"; /* eslint-disable @typescript-eslint/no-explicit-any */