diff --git a/.changeset/cyan-hats-play.md b/.changeset/cyan-hats-play.md new file mode 100644 index 00000000..75f7fa7e --- /dev/null +++ b/.changeset/cyan-hats-play.md @@ -0,0 +1,12 @@ +--- +"groq-builder": minor +--- + +Added support for parameters. + +Added `filterBy` for strongly-typed filtering. + +Added auto-complete help for conditional statements and filters. + +Changed how parameters are passed to `makeSafeQueryRunner`. + diff --git a/packages/groq-builder/package.json b/packages/groq-builder/package.json index 2aef65dc..00f0f019 100644 --- a/packages/groq-builder/package.json +++ b/packages/groq-builder/package.json @@ -19,7 +19,8 @@ ], "main": "./dist/index.js", "sideEffects": [ - "./dist/commands/**" + "./dist/commands/**", + "./dist/groq-builder" ], "module": "dist/index.mjs", "types": "dist/index.d.ts", diff --git a/packages/groq-builder/src/commands/conditional-types.ts b/packages/groq-builder/src/commands/conditional-types.ts index 078c25b9..1b1cddff 100644 --- a/packages/groq-builder/src/commands/conditional-types.ts +++ b/packages/groq-builder/src/commands/conditional-types.ts @@ -4,27 +4,23 @@ import { ProjectionMapOrCallback, } from "./projection-types"; import { Empty, IntersectionOfValues, Simplify, ValueOf } from "../types/utils"; -import { ExtractTypeNames, RootConfig } from "../types/schema-types"; +import { ExtractTypeNames, QueryConfig } from "../types/schema-types"; import { GroqBuilder } from "../groq-builder"; import { IGroqBuilder, InferResultType } from "../types/public-types"; +import { Expressions } from "../types/groq-expressions"; export type ConditionalProjectionMap< TResultItem, - TRootConfig extends RootConfig -> = { - [Condition: ConditionalExpression]: + TQueryConfig extends QueryConfig +> = Partial< + Record< + Expressions.AnyConditional, | ProjectionMap | (( - q: GroqBuilder - ) => ProjectionMap); -}; - -/** - * For now, none of our "conditions" are strongly-typed, - * so we'll just use "string": - */ -// eslint-disable-next-line @typescript-eslint/no-unused-vars -export type ConditionalExpression = string; + q: GroqBuilder + ) => ProjectionMap) + > +>; export type ExtractConditionalProjectionResults< TResultItem, @@ -52,11 +48,11 @@ export type ExtractConditionalProjectionTypes = Simplify< export type ConditionalByTypeProjectionMap< TResultItem, - TRootConfig extends RootConfig + TQueryConfig extends QueryConfig > = { [_type in ExtractTypeNames]?: ProjectionMapOrCallback< Extract, - TRootConfig + TQueryConfig >; }; @@ -82,9 +78,9 @@ export type ExtractConditionalByTypeProjectionResults< }> >; -export type ConditionalKey = `[Conditional] ${TKey}`; +export type ConditionalKey = `[CONDITIONAL] ${TKey}`; export function isConditional(key: string): key is ConditionalKey { - return key.startsWith("[Conditional] "); + return key.startsWith("[CONDITIONAL] "); } export type SpreadableConditionals< TKey extends string, diff --git a/packages/groq-builder/src/commands/conditional.test.ts b/packages/groq-builder/src/commands/conditional.test.ts index 2c94612c..578c2bd1 100644 --- a/packages/groq-builder/src/commands/conditional.test.ts +++ b/packages/groq-builder/src/commands/conditional.test.ts @@ -10,7 +10,7 @@ import { ExtractConditionalProjectionTypes } from "./conditional-types"; import { Empty, Simplify } from "../types/utils"; const q = createGroqBuilder({ indent: " " }); -const qBase = q.star.filterByType("variant"); +const qVariants = q.star.filterByType("variant"); describe("conditional", () => { describe("by itself", () => { @@ -36,14 +36,14 @@ describe("conditional", () => { }); it("should return a spreadable object", () => { expect(conditionalResult).toMatchObject({ - "[Conditional] [$]": expect.any(GroqBuilder), + "[CONDITIONAL] [KEY]": expect.any(GroqBuilder), }); }); }); - const qAll = qBase.project((qA) => ({ + const qAll = qVariants.project((qV) => ({ name: true, - ...qA.conditional({ + ...qV.conditional({ "price == msrp": { onSale: q.value(false), }, @@ -142,7 +142,7 @@ describe("conditional", () => { "another == condition1": { foo: q.value("FOO") }, "another == condition2": { bar: q.value("BAR") }, }, - { key: "unique-key" } + { key: "[UNIQUE-KEY]" } ), })); diff --git a/packages/groq-builder/src/commands/conditional.ts b/packages/groq-builder/src/commands/conditional.ts index e5c28660..ec5ba402 100644 --- a/packages/groq-builder/src/commands/conditional.ts +++ b/packages/groq-builder/src/commands/conditional.ts @@ -12,13 +12,13 @@ import { ProjectionMap } from "./projection-types"; declare module "../groq-builder" { // eslint-disable-next-line @typescript-eslint/no-unused-vars - export interface GroqBuilder { + export interface GroqBuilder { conditional< TConditionalProjections extends ConditionalProjectionMap< ResultItem.Infer, - TRootConfig + TQueryConfig >, - TKey extends string = "[$]", + TKey extends string = typeof DEFAULT_KEY, TIsExhaustive extends boolean = false >( conditionalProjections: TConditionalProjections, @@ -31,6 +31,8 @@ declare module "../groq-builder" { } } +const DEFAULT_KEY = "[KEY]" as const; + GroqBuilder.implement({ conditional< TCP extends object, @@ -58,15 +60,15 @@ GroqBuilder.implement({ .join(`,${newLine}`); const parsers = allConditionalProjections - .map((q) => q.internal.parser) + .map((q) => q.parser) .filter(notNull); const conditionalParser = !parsers.length ? null : createConditionalParserUnion(parsers, config?.isExhaustive ?? false); const conditionalQuery = root.chain(query, conditionalParser); - const key = config?.key || ("[$]" as TKey); - const conditionalKey: ConditionalKey = `[Conditional] ${key}`; + const key = config?.key || (DEFAULT_KEY as TKey); + const conditionalKey: ConditionalKey = `[CONDITIONAL] ${key}`; return { [conditionalKey]: conditionalQuery, } as any; diff --git a/packages/groq-builder/src/commands/conditionalByType.test.ts b/packages/groq-builder/src/commands/conditionalByType.test.ts index fdb1fba7..0bcab4f8 100644 --- a/packages/groq-builder/src/commands/conditionalByType.test.ts +++ b/packages/groq-builder/src/commands/conditionalByType.test.ts @@ -40,12 +40,12 @@ describe("conditionalByType", () => { it('should have a "spreadable" signature', () => { expectTypeOf>().toEqualTypeOf< SimplifyDeep<{ - "[Conditional] [ByType]": IGroqBuilder; + "[CONDITIONAL] [BY_TYPE]": IGroqBuilder; }> >(); expect(conditionalByType).toMatchObject({ - "[Conditional] [ByType]": expect.any(GroqBuilder), + "[CONDITIONAL] [BY_TYPE]": expect.any(GroqBuilder), }); }); @@ -183,7 +183,7 @@ describe("conditionalByType", () => { }); it("should execute correctly", async () => { - const res = await executeBuilder(qAll, data.datalake); + const res = await executeBuilder(qAll, data); expect(res.find((item) => item._type === "category")) .toMatchInlineSnapshot(` diff --git a/packages/groq-builder/src/commands/conditionalByType.ts b/packages/groq-builder/src/commands/conditionalByType.ts index c08a7480..0182ab54 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 { ExtractTypeNames, RootConfig } from "../types/schema-types"; +import { ExtractTypeNames, QueryConfig } from "../types/schema-types"; import { ResultItem } from "../types/result-types"; import { ExtractConditionalByTypeProjectionResults, @@ -10,13 +10,13 @@ import { import { ProjectionMap } from "./projection-types"; declare module "../groq-builder" { - export interface GroqBuilder { + export interface GroqBuilder { conditionalByType< TConditionalProjections extends ConditionalByTypeProjectionMap< ResultItem.Infer, - TRootConfig + TQueryConfig >, - TKey extends string = "[ByType]", + TKey extends string = typeof DEFAULT_KEY, /** * Did we supply a condition for all possible _type values? */ @@ -35,6 +35,7 @@ declare module "../groq-builder" { >; } } +const DEFAULT_KEY = "[BY_TYPE]" as const; GroqBuilder.implement({ conditionalByType< @@ -42,7 +43,7 @@ GroqBuilder.implement({ TKey extends string, TIsExhaustive extends boolean >( - this: GroqBuilder, + this: GroqBuilder, conditionalProjections: TConditionalProjections, config?: Partial> ) { @@ -80,8 +81,8 @@ GroqBuilder.implement({ }; const conditionalQuery = this.root.chain(query, conditionalParser); - const key: TKey = config?.key || ("[ByType]" as TKey); - const conditionalKey: ConditionalKey = `[Conditional] ${key}`; + const key: TKey = config?.key || (DEFAULT_KEY as TKey); + const conditionalKey: ConditionalKey = `[CONDITIONAL] ${key}`; return { _type: true, // Ensure we request the `_type` parameter [conditionalKey]: conditionalQuery, diff --git a/packages/groq-builder/src/commands/deref.test.ts b/packages/groq-builder/src/commands/deref.test.ts index 2d657213..64a2f94f 100644 --- a/packages/groq-builder/src/commands/deref.test.ts +++ b/packages/groq-builder/src/commands/deref.test.ts @@ -1,10 +1,11 @@ import { describe, expect, expectTypeOf, it } from "vitest"; import { InferResultType } from "../types/public-types"; -import { createGroqBuilder } from "../index"; import { executeBuilder } from "../tests/mocks/executeQuery"; import { mock } from "../tests/mocks/nextjs-sanity-fe-mocks"; import { SanitySchema, SchemaConfig } from "../tests/schemas/nextjs-sanity-fe"; +import { createGroqBuilder } from "../index"; + const q = createGroqBuilder(); const data = mock.generateSeedData({}); @@ -48,11 +49,11 @@ describe("deref", () => { }); it("should execute correctly (single)", async () => { - const results = await executeBuilder(qCategory, data.datalake); + const results = await executeBuilder(qCategory, data); expect(results).toEqual(data.categories[0]); }); it("should execute correctly (multiple)", async () => { - const results = await executeBuilder(qVariants, data.datalake); + const results = await executeBuilder(qVariants, data); expect(results).toEqual(data.variants); }); }); diff --git a/packages/groq-builder/src/commands/deref.ts b/packages/groq-builder/src/commands/deref.ts index e0601d51..6a8607f2 100644 --- a/packages/groq-builder/src/commands/deref.ts +++ b/packages/groq-builder/src/commands/deref.ts @@ -1,20 +1,20 @@ import { GroqBuilder } from "../groq-builder"; -import { ExtractRefType, RootConfig } from "../types/schema-types"; +import { ExtractRefType, QueryConfig } from "../types/schema-types"; import { ResultItem } from "../types/result-types"; declare module "../groq-builder" { - export interface GroqBuilder { + export interface GroqBuilder { deref< - TReferencedType = ExtractRefType, TRootConfig> + TReferencedType = ExtractRefType, TQueryConfig> >(): GroqBuilder< ResultItem.Override, - TRootConfig + TQueryConfig >; } } GroqBuilder.implement({ - deref(this: GroqBuilder) { + deref(this: GroqBuilder) { return this.chain("->"); }, }); diff --git a/packages/groq-builder/src/commands/filter.test.ts b/packages/groq-builder/src/commands/filter.test.ts new file mode 100644 index 00000000..051e4356 --- /dev/null +++ b/packages/groq-builder/src/commands/filter.test.ts @@ -0,0 +1,182 @@ +import { describe, it, expect, expectTypeOf } from "vitest"; +import { SanitySchema, SchemaConfig } from "../tests/schemas/nextjs-sanity-fe"; +import { InferResultType } from "../types/public-types"; +import { executeBuilder } from "../tests/mocks/executeQuery"; +import { mock } from "../tests/mocks/nextjs-sanity-fe-mocks"; +import { createGroqBuilder } from "../index"; + +const q = createGroqBuilder(); +const qVariants = q.star.filterByType("variant"); + +describe("filter", () => { + it("should allow for untyped filter expressions", () => { + const qAnything = qVariants.filter("ANYTHING"); + expectTypeOf>().toEqualTypeOf< + Array + >(); + expect(qAnything.query).toMatchInlineSnapshot( + '"*[_type == \\"variant\\"][ANYTHING]"' + ); + }); + + const qFiltered = qVariants + .filter('name == "FOO"') + .project({ name: true, id: true }); + const data = mock.generateSeedData({ + variants: [ + mock.variant({ id: "1", name: "FOO" }), + mock.variant({ id: "2", name: "BAR" }), + mock.variant({ id: "3", name: "BAZ" }), + ], + }); + + it("should not change the result type", () => { + const qFiltered = qVariants.filter("ANYTHING"); + expectTypeOf>().toEqualTypeOf< + InferResultType + >(); + }); + + it("should execute correctly", async () => { + const results = await executeBuilder(qFiltered, data); + expect(results).toMatchInlineSnapshot(` + [ + { + "id": "1", + "name": "FOO", + }, + ] + `); + }); +}); + +describe("filterBy", () => { + const qFiltered = q.star + .filterBy('_type == "variant"') + .project({ _type: true }); + + it("should not allow invalid expressions", () => { + qVariants.filterBy( + // @ts-expect-error --- + "INVALID" + ); + qVariants.filterBy( + // @ts-expect-error --- + "invalid == null" + ); + }); + it("should allow strongly-typed filters", () => { + qVariants.filterBy('_type == "variant"'); + qVariants.filterBy('name == ""'); + qVariants.filterBy('name == "anything"'); + qVariants.filterBy("price == 55"); + qVariants.filterBy("id == null"); + qVariants.filterBy('id == "id"'); + qVariants.filterBy("id == (string)"); // (this is just for auto-complete) + }); + it("should not allow mismatched types", () => { + qVariants.filterBy( + // @ts-expect-error --- + "name == null" + ); + qVariants.filterBy( + // @ts-expect-error --- + "name == 999" + ); + qVariants.filterBy( + // @ts-expect-error --- + 'price == "hello"' + ); + }); + it("should not change the result type", () => { + const unfiltered = q.star; + const filtered = q.star.filterBy('_type == "variant"'); + expectTypeOf>().toEqualTypeOf< + InferResultType + >(); + }); + it("should execute correctly", async () => { + const data = mock.generateSeedData({ + variants: [mock.variant({}), mock.variant({})], + }); + const results = await executeBuilder(qFiltered, data); + expect(results).toMatchInlineSnapshot(` + [ + { + "_type": "variant", + }, + { + "_type": "variant", + }, + ] + `); + }); + + describe("with parameters", () => { + type Parameters = { + str: string; + num: number; + }; + const qWithVars = qVariants.parameters(); + it("should support strongly-typed parameters", () => { + qWithVars.filterBy("name == $str"); + qWithVars.filterBy("price == $num"); + }); + it("should fail for invalid / mismatched parameters", () => { + qWithVars.filterBy( + // @ts-expect-error --- + "name == $num" + ); + qWithVars.filterBy( + // @ts-expect-error --- + "name == $INVALID" + ); + qWithVars.filterBy( + // @ts-expect-error --- + "price == $str" + ); + qWithVars.filterBy( + // @ts-expect-error --- + "price == $INVALID" + ); + }); + }); + + describe("nested properties", () => { + const qVariants = q.star.filterByType("variant"); + const data = mock.generateSeedData({ + variants: [ + // + mock.variant({ slug: mock.slug({ current: "SLUG-1" }) }), + mock.variant({ slug: mock.slug({ current: "SLUG-2" }) }), + mock.variant({ slug: mock.slug({ current: "SLUG-3" }) }), + ], + }); + + const qFiltered = qVariants.filterBy('slug.current == "SLUG-1"').project({ + slug: "slug.current", + }); + + it("nested properties should not allow invalid types", () => { + qVariants.filterBy( + // @ts-expect-error --- + "slug.current == 999" + ); + qVariants.filterBy( + // @ts-expect-error --- + "slug.current == true" + ); + }); + + it("should execute correctly", async () => { + const results = await executeBuilder(qFiltered, data); + expect(results).toMatchInlineSnapshot(` + [ + { + "slug": "SLUG-1", + }, + ] + `); + }); + }); +}); diff --git a/packages/groq-builder/src/commands/filter.ts b/packages/groq-builder/src/commands/filter.ts index b5c166cc..dfcfbcae 100644 --- a/packages/groq-builder/src/commands/filter.ts +++ b/packages/groq-builder/src/commands/filter.ts @@ -1,17 +1,33 @@ import { GroqBuilder } from "../groq-builder"; -import { RootConfig } from "../types/schema-types"; -import { ConditionalExpression } from "./conditional-types"; +import { Expressions } from "../types/groq-expressions"; +import { ResultItem } from "../types/result-types"; declare module "../groq-builder" { - export interface GroqBuilder { + export interface GroqBuilder { filter( - filterExpression: ConditionalExpression - ): GroqBuilder; + filterExpression: Expressions.AnyConditional< + ResultItem.Infer, + TQueryConfig + > + ): GroqBuilder; + + /** + * Same as `filter`, but only supports simple, strongly-typed equality expressions. + */ + filterBy( + filterExpression: Expressions.Conditional< + ResultItem.Infer, + TQueryConfig + > + ): GroqBuilder; } } GroqBuilder.implement({ - filter(this: GroqBuilder, filterExpression) { + filter(this: GroqBuilder, filterExpression) { return this.chain(`[${filterExpression}]`); }, + filterBy(this: GroqBuilder, filterExpression) { + return this.filter(filterExpression); + }, }); diff --git a/packages/groq-builder/src/commands/filterByType.test.ts b/packages/groq-builder/src/commands/filterByType.test.ts index f01c7e35..1853ad78 100644 --- a/packages/groq-builder/src/commands/filterByType.test.ts +++ b/packages/groq-builder/src/commands/filterByType.test.ts @@ -27,7 +27,7 @@ describe("filterByType", () => { }); it("should execute correctly", async () => { - const results = await executeBuilder(qProduct, data.datalake); + const results = await executeBuilder(qProduct, data); expect(results).toEqual(data.products); }); }); diff --git a/packages/groq-builder/src/commands/filterByType.ts b/packages/groq-builder/src/commands/filterByType.ts index 2a27f20d..cd5d28ef 100644 --- a/packages/groq-builder/src/commands/filterByType.ts +++ b/packages/groq-builder/src/commands/filterByType.ts @@ -3,7 +3,7 @@ import { ResultItem } from "../types/result-types"; import { ExtractTypeNames } from "../types/schema-types"; declare module "../groq-builder" { - export interface GroqBuilder { + export interface GroqBuilder { filterByType>>( ...type: TType[] ): GroqBuilder< @@ -11,7 +11,7 @@ declare module "../groq-builder" { TResult, Extract, { _type: TType }> >, - TRootConfig + TQueryConfig >; } } diff --git a/packages/groq-builder/src/commands/fragment.ts b/packages/groq-builder/src/commands/fragment.ts index f72a76ab..75da1f5c 100644 --- a/packages/groq-builder/src/commands/fragment.ts +++ b/packages/groq-builder/src/commands/fragment.ts @@ -4,12 +4,12 @@ import { Fragment } from "../types/public-types"; declare module "../groq-builder" { // eslint-disable-next-line @typescript-eslint/no-unused-vars - export interface GroqBuilder { + export interface GroqBuilder { fragment(): { project>( projectionMap: | TProjectionMap - | ((q: GroqBuilder) => TProjectionMap) + | ((q: GroqBuilder) => TProjectionMap) ): Fragment; }; } diff --git a/packages/groq-builder/src/commands/functions/value.ts b/packages/groq-builder/src/commands/functions/value.ts index 175046bc..a9779e10 100644 --- a/packages/groq-builder/src/commands/functions/value.ts +++ b/packages/groq-builder/src/commands/functions/value.ts @@ -3,7 +3,7 @@ import { Parser } from "../../types/public-types"; declare module "../../groq-builder" { // eslint-disable-next-line @typescript-eslint/no-unused-vars - export interface GroqBuilder { + export interface GroqBuilder { /** * Returns a literal Groq value, properly escaped. * @param value @@ -12,7 +12,7 @@ declare module "../../groq-builder" { value( value: T, validation?: Parser | null - ): GroqBuilder; + ): GroqBuilder; } } diff --git a/packages/groq-builder/src/commands/grab-deprecated.ts b/packages/groq-builder/src/commands/grab-deprecated.ts index 2c8379d0..59e5664f 100644 --- a/packages/groq-builder/src/commands/grab-deprecated.ts +++ b/packages/groq-builder/src/commands/grab-deprecated.ts @@ -7,23 +7,23 @@ import "./projectField"; import { GroqBuilder } from "../groq-builder"; declare module "../groq-builder" { - export interface GroqBuilder { + export interface GroqBuilder { /** * @deprecated This method has been renamed to 'project' and will be removed in a future version. */ - grab: GroqBuilder["project"]; + grab: GroqBuilder["project"]; /** * @deprecated This method has been renamed to 'project' and will be removed in a future version. */ - grab$: GroqBuilder["project"]; + grab$: GroqBuilder["project"]; /** * @deprecated This method has been renamed to 'field' and will be removed in a future version. */ - grabOne: GroqBuilder["field"]; + grabOne: GroqBuilder["field"]; /** * @deprecated This method has been renamed to 'field' and will be removed in a future version. */ - grabOne$: GroqBuilder["field"]; + grabOne$: GroqBuilder["field"]; } } GroqBuilder.implement({ diff --git a/packages/groq-builder/src/commands/index.ts b/packages/groq-builder/src/commands/index.ts index a20a3721..7b2d0194 100644 --- a/packages/groq-builder/src/commands/index.ts +++ b/packages/groq-builder/src/commands/index.ts @@ -7,6 +7,7 @@ import "./fragment"; import "./grab-deprecated"; import "./nullable"; import "./order"; +import "./parameters"; import "./project"; import "./projectField"; import "./raw"; diff --git a/packages/groq-builder/src/commands/nullable.test.ts b/packages/groq-builder/src/commands/nullable.test.ts index 57aa9b6c..fa44b111 100644 --- a/packages/groq-builder/src/commands/nullable.test.ts +++ b/packages/groq-builder/src/commands/nullable.test.ts @@ -46,7 +46,7 @@ describe("nullable", () => { name: qV.field("name").nullable(), })); it("should execute correctly, without runtime validation", async () => { - const results = await executeBuilder(qWithoutValidation, data.datalake); + const results = await executeBuilder(qWithoutValidation, data); expect(results).toMatchInlineSnapshot(` [ { @@ -94,7 +94,7 @@ describe("nullable", () => { expect(qWithValidation.parser).toBeTypeOf("function"); }); it("should execute correctly", async () => { - const results = await executeBuilder(qWithValidation, data.datalake); + const results = await executeBuilder(qWithValidation, data); expect(results).toMatchInlineSnapshot(` [ { diff --git a/packages/groq-builder/src/commands/nullable.ts b/packages/groq-builder/src/commands/nullable.ts index fbe17e76..264668bd 100644 --- a/packages/groq-builder/src/commands/nullable.ts +++ b/packages/groq-builder/src/commands/nullable.ts @@ -1,14 +1,14 @@ import { GroqBuilder } from "../groq-builder"; -import { RootConfig } from "../types/schema-types"; +import { QueryConfig } from "../types/schema-types"; declare module "../groq-builder" { - export interface GroqBuilder { - nullable(): GroqBuilder; + export interface GroqBuilder { + nullable(): GroqBuilder; } } GroqBuilder.implement({ - nullable(this: GroqBuilder) { + nullable(this: GroqBuilder) { const parser = this.parser; if (!parser) { diff --git a/packages/groq-builder/src/commands/order.test.ts b/packages/groq-builder/src/commands/order.test.ts index f5a5b852..6d9b5e09 100644 --- a/packages/groq-builder/src/commands/order.test.ts +++ b/packages/groq-builder/src/commands/order.test.ts @@ -48,12 +48,12 @@ describe("order", () => { it("should execute correctly (asc)", async () => { const qOrder = qVariants.order("price"); - const results = await executeBuilder(qOrder, data.datalake); + const results = await executeBuilder(qOrder, data); expect(results).toMatchObject(priceAsc); }); it("should execute correctly (desc)", async () => { const qOrder = qVariants.order("price desc"); - const results = await executeBuilder(qOrder, data.datalake); + const results = await executeBuilder(qOrder, data); expect(results).toMatchObject(priceDesc); }); @@ -110,7 +110,7 @@ describe("order", () => { it("should execute correctly", async () => { const qOrder = qVariants.order("msrp", "price"); - const results = await executeBuilder(qOrder, data.datalake); + const results = await executeBuilder(qOrder, data); expect(results).toMatchObject([ { name: `Variant 4`, msrp: 500, price: 96 }, { name: `Variant 3`, msrp: 500, price: 97 }, diff --git a/packages/groq-builder/src/commands/order.ts b/packages/groq-builder/src/commands/order.ts index f2074c6d..24fd349e 100644 --- a/packages/groq-builder/src/commands/order.ts +++ b/packages/groq-builder/src/commands/order.ts @@ -3,13 +3,13 @@ import { StringKeys } from "../types/utils"; import { ResultItem } from "../types/result-types"; declare module "../groq-builder" { - export interface GroqBuilder { + export interface GroqBuilder { /** * Orders the results by the keys specified */ order>>( ...fields: Array<`${TKeys}${"" | " asc" | " desc"}`> - ): GroqBuilder; + ): GroqBuilder; /** @deprecated Sorting is done via the 'order' method */ sort: never; diff --git a/packages/groq-builder/src/commands/parameters.test.ts b/packages/groq-builder/src/commands/parameters.test.ts new file mode 100644 index 00000000..98726767 --- /dev/null +++ b/packages/groq-builder/src/commands/parameters.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, expectTypeOf, it } from "vitest"; +import { SchemaConfig } from "../tests/schemas/nextjs-sanity-fe"; +import { createGroqBuilder, InferParametersType } from "../index"; +import { executeBuilder } from "../tests/mocks/executeQuery"; +import { mock } from "../tests/mocks/nextjs-sanity-fe-mocks"; + +const q = createGroqBuilder(); + +describe("parameters", () => { + const data = mock.generateSeedData({ + variants: [ + mock.variant({ slug: mock.slug({ current: "SLUG-1" }) }), + mock.variant({ slug: mock.slug({ current: "SLUG-2" }) }), + mock.variant({ slug: mock.slug({ current: "SLUG-3" }) }), + ], + }); + + it("the root q object should have no parameters", () => { + expectTypeOf>().toEqualTypeOf(); + }); + + const qWithParameters = q + .parameters<{ slug: string }>() + .star.filterByType("variant") + .filter("slug.current == $slug") + .project({ slug: "slug.current" }); + + it("chains should retain the parameters type", () => { + expectTypeOf>().toEqualTypeOf<{ + slug: string; + }>(); + }); + + it("should require all parameters", () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async function onlyCheckTypes() { + // @ts-expect-error --- property 'parameters' is missing + await executeBuilder(qWithParameters, { + datalake: data.datalake, + }); + await executeBuilder(qWithParameters, { + datalake: data.datalake, + // @ts-expect-error --- property 'slug' is missing + parameters: {}, + }); + await executeBuilder(qWithParameters, { + datalake: data.datalake, + parameters: { + // @ts-expect-error --- 'invalid' does not exist + invalid: "", + }, + }); + await executeBuilder(qWithParameters, { + datalake: data.datalake, + parameters: { + // @ts-expect-error --- 'number' is not assignable to 'string' + slug: 999, + }, + }); + } + }); + + it("should execute correctly", async () => { + const results = await executeBuilder(qWithParameters, { + datalake: data.datalake, + parameters: { + slug: "SLUG-2", + }, + }); + expect(results).toMatchInlineSnapshot(` + [ + { + "slug": "SLUG-2", + }, + ] + `); + }); +}); diff --git a/packages/groq-builder/src/commands/parameters.ts b/packages/groq-builder/src/commands/parameters.ts new file mode 100644 index 00000000..38435eb9 --- /dev/null +++ b/packages/groq-builder/src/commands/parameters.ts @@ -0,0 +1,51 @@ +import { GroqBuilder } from "../groq-builder"; +import { Override } from "../types/utils"; +import { Simplify } from "type-fest"; + +declare module "../groq-builder" { + export interface GroqBuilder { + /** + * Defines the names and types of parameters that + * must be passed to the query. + * + * This method is just for defining types; + * it has no runtime effects. + * + * The parameter types should not include the `$` prefix. + * + * @example + * const productsBySlug = ( + * q.parameters<{ slug: string }>() + * .star + * .filterByType('product') + * // You can now reference the $slug parameter: + * .filterBy('slug.current == $slug') + * ); + * const results = await executeQuery( + * productsBySlug, + * // The 'slug' parameter is required: + * { parameters: { slug: "123" } } + * ) + */ + parameters(): GroqBuilder< + TResult, + Override< + TQueryConfig, + { + // Merge existing parameters with the new parameters: + parameters: Simplify; + } + > + >; + + /** @deprecated Use `parameters` to define parameters */ + variables: never; + } +} + +GroqBuilder.implement({ + parameters(this: GroqBuilder) { + // This method is used just for chaining types + return this as any; + }, +}); diff --git a/packages/groq-builder/src/commands/project.test.ts b/packages/groq-builder/src/commands/project.test.ts index e0fc9a71..49c2e62e 100644 --- a/packages/groq-builder/src/commands/project.test.ts +++ b/packages/groq-builder/src/commands/project.test.ts @@ -47,7 +47,7 @@ describe("project (object projections)", () => { products: mock.array(2, () => mock.product({})), categories: mock.array(3, () => mock.category({})), }); - const results = await executeBuilder(qRoot, data.datalake); + const results = await executeBuilder(qRoot, data); expect(results).toMatchInlineSnapshot(` { "categoryNames": [ @@ -98,7 +98,7 @@ describe("project (object projections)", () => { }); it("should execute correctly", async () => { - const results = await executeBuilder(qName, data.datalake); + const results = await executeBuilder(qName, data); expect(results).toMatchInlineSnapshot(` [ { @@ -144,7 +144,7 @@ describe("project (object projections)", () => { }); it("should execute correctly", async () => { - const results = await executeBuilder(qMultipleFields, data.datalake); + const results = await executeBuilder(qMultipleFields, data); expect(results).toMatchInlineSnapshot(` [ { @@ -201,7 +201,7 @@ describe("project (object projections)", () => { }); it("should execute correctly", async () => { - const results = await executeBuilder(qValidation, data.datalake); + const results = await executeBuilder(qValidation, data); expect(results).toMatchInlineSnapshot(` [ { @@ -353,7 +353,7 @@ describe("project (object projections)", () => { }); it("should execute correctly", async () => { - const results = await executeBuilder(qComplex, data.datalake); + const results = await executeBuilder(qComplex, data); expect(results).toMatchInlineSnapshot(` [ { @@ -400,7 +400,7 @@ describe("project (object projections)", () => { }); it("should execute correctly", async () => { - const results = await executeBuilder(qComplex, data.datalake); + const results = await executeBuilder(qComplex, data); expect(results).toMatchInlineSnapshot(` [ { @@ -434,7 +434,7 @@ describe("project (object projections)", () => { }); describe("nested projections", () => { - const { datalake: dataWithImages } = mock.generateSeedData({ + const dataWithImages = mock.generateSeedData({ variants: [ mock.variant({ images: [mock.keyed(mock.image({}))] }), mock.variant({ images: [mock.keyed(mock.image({}))] }), @@ -507,8 +507,9 @@ describe("project (object projections)", () => { ], }), ]; - await expect(() => executeBuilder(qNested, dataWithInvalidData)).rejects - .toThrowErrorMatchingInlineSnapshot(` + await expect(() => + executeBuilder(qNested, { datalake: dataWithInvalidData }) + ).rejects.toThrowErrorMatchingInlineSnapshot(` "1 Parsing Error: result[0].images[0].description: Expected string, received number" `); @@ -541,7 +542,7 @@ describe("project (object projections)", () => { }); it("should execute correctly", async () => { - const results = await executeBuilder(qComplex, data.datalake); + const results = await executeBuilder(qComplex, data); expect(results).toMatchInlineSnapshot(` [ { @@ -606,7 +607,7 @@ describe("project (object projections)", () => { ); }); it("should execute correctly", async () => { - const results = await executeBuilder(qParser, data.datalake); + const results = await executeBuilder(qParser, data); expect(results).toMatchInlineSnapshot(` [ { @@ -647,8 +648,8 @@ describe("project (object projections)", () => { }), ]; - await expect(() => executeBuilder(qParser, invalidData)).rejects - .toThrowErrorMatchingInlineSnapshot(` + await expect(() => executeBuilder(qParser, { datalake: invalidData })) + .rejects.toThrowErrorMatchingInlineSnapshot(` "1 Parsing Error: result[5].price: Expected number, received string" `); @@ -740,7 +741,7 @@ describe("project (object projections)", () => { }); it("should execute correctly", async () => { - const results = await executeBuilder(qEllipsis, data.datalake); + const results = await executeBuilder(qEllipsis, data); expect(results).toEqual( data.variants.map((v) => { // @ts-expect-error --- diff --git a/packages/groq-builder/src/commands/project.ts b/packages/groq-builder/src/commands/project.ts index b8796aa0..b26df7da 100644 --- a/packages/groq-builder/src/commands/project.ts +++ b/packages/groq-builder/src/commands/project.ts @@ -15,7 +15,7 @@ import { } from "../validation/simple-validation"; declare module "../groq-builder" { - export interface GroqBuilder { + export interface GroqBuilder { /** * Performs an "object projection", returning an object with the fields specified. * @@ -32,12 +32,12 @@ declare module "../groq-builder" { projectionMap: | TProjection | (( - q: GroqBuilder, TRootConfig> + q: GroqBuilder, TQueryConfig> ) => TProjection), ...ProjectionMapTypeMismatchErrors: RequireAFakeParameterIfThereAreTypeMismatchErrors<_TProjectionResult> ): GroqBuilder< ResultItem.Override>, - TRootConfig + TQueryConfig >; } } diff --git a/packages/groq-builder/src/commands/projectField.test.ts b/packages/groq-builder/src/commands/projectField.test.ts index 00f28a17..0b92ff16 100644 --- a/packages/groq-builder/src/commands/projectField.test.ts +++ b/packages/groq-builder/src/commands/projectField.test.ts @@ -56,7 +56,7 @@ describe("field (naked projections)", () => { }); it("executes correctly (price)", async () => { - const results = await executeBuilder(qPrices, data.datalake); + const results = await executeBuilder(qPrices, data); expect(results).toMatchInlineSnapshot(` [ 55, @@ -68,7 +68,7 @@ describe("field (naked projections)", () => { `); }); it("executes correctly (name)", async () => { - const results = await executeBuilder(qNames, data.datalake); + const results = await executeBuilder(qNames, data); expect(results).toMatchInlineSnapshot(` [ "Variant 0", @@ -125,7 +125,7 @@ describe("field (naked projections)", () => { ); }); it("should execute correctly", async () => { - const results = await executeBuilder(qPrice, data.datalake); + const results = await executeBuilder(qPrice, data); expect(results).toMatchInlineSnapshot("55"); }); it("should throw an error if the data is invalid", async () => { @@ -137,7 +137,7 @@ describe("field (naked projections)", () => { }), ], }); - await expect(() => executeBuilder(qPrice, invalidData.datalake)).rejects + await expect(() => executeBuilder(qPrice, invalidData)).rejects .toMatchInlineSnapshot(` [ValidationErrors: 1 Parsing Error: result: Expected number, received string] diff --git a/packages/groq-builder/src/commands/projectField.ts b/packages/groq-builder/src/commands/projectField.ts index 96dd1042..1ea0e94a 100644 --- a/packages/groq-builder/src/commands/projectField.ts +++ b/packages/groq-builder/src/commands/projectField.ts @@ -8,7 +8,7 @@ import { import { Parser, ParserWithWidenedInput } from "../types/public-types"; declare module "../groq-builder" { - export interface GroqBuilder { + export interface GroqBuilder { /** * Performs a "naked projection", returning just the values of the field specified. * @@ -21,7 +21,7 @@ declare module "../groq-builder" { TResult, ProjectionKeyValue, TProjectionKey> >, - TRootConfig + TQueryConfig >; /** @@ -48,7 +48,7 @@ declare module "../groq-builder" { > : never >, - TRootConfig + TQueryConfig >; /** @deprecated Please use the 'field' method for naked projections */ diff --git a/packages/groq-builder/src/commands/projection-types.ts b/packages/groq-builder/src/commands/projection-types.ts index 347f385a..abfaa36c 100644 --- a/packages/groq-builder/src/commands/projection-types.ts +++ b/packages/groq-builder/src/commands/projection-types.ts @@ -18,7 +18,7 @@ import { } 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 { QueryConfig } from "../types/schema-types"; import { ConditionalKey, ExtractConditionalProjectionTypes, @@ -52,10 +52,10 @@ export type ProjectionMap = { export type ProjectionMapOrCallback< TResultItem, - TRootConfig extends RootConfig + TQueryConfig extends QueryConfig > = | ProjectionMap - | ((q: GroqBuilder) => ProjectionMap); + | ((q: GroqBuilder) => ProjectionMap); export type ProjectionFieldConfig = // Use 'true' to include a field as-is diff --git a/packages/groq-builder/src/commands/raw.test.ts b/packages/groq-builder/src/commands/raw.test.ts index 2eadca39..9fd9df16 100644 --- a/packages/groq-builder/src/commands/raw.test.ts +++ b/packages/groq-builder/src/commands/raw.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, expectTypeOf } from "vitest"; import { SchemaConfig } from "../tests/schemas/nextjs-sanity-fe"; import { InferResultType } from "../types/public-types"; -import { createGroqBuilder } from "../index"; +import { createGroqBuilder, zod } from "../index"; import { executeBuilder } from "../tests/mocks/executeQuery"; import { mock } from "../tests/mocks/nextjs-sanity-fe-mocks"; @@ -18,12 +18,13 @@ describe("raw", () => { Array<{ ANYTHING: string | null }> >(); }); + it("should append the query correctly", () => { expect(qRaw.query).toMatchInlineSnapshot('"*[0...2]{ ANYTHING }"'); }); it("should execute correctly", async () => { - const result = await executeBuilder(qRaw, data.datalake); + const result = await executeBuilder(qRaw, data); expect(result).toMatchInlineSnapshot(` [ { @@ -48,4 +49,13 @@ describe("raw", () => { '"give you up, never gonna let you down"' ); }); + + it("should infer type from runtime validation", () => { + const qRaw = qVariants.raw("SOMETHING", zod.string()); + expectTypeOf>().toEqualTypeOf(); + const qRawNullable = qVariants.raw("SOMETHING", zod.string().nullable()); + expectTypeOf>().toEqualTypeOf< + string | null + >(); + }); }); diff --git a/packages/groq-builder/src/commands/raw.ts b/packages/groq-builder/src/commands/raw.ts index 9ac7b34b..8ca79843 100644 --- a/packages/groq-builder/src/commands/raw.ts +++ b/packages/groq-builder/src/commands/raw.ts @@ -3,7 +3,7 @@ import { Parser } from "../types/public-types"; declare module "../groq-builder" { // eslint-disable-next-line @typescript-eslint/no-unused-vars - export interface GroqBuilder { + export interface GroqBuilder { /** * An "escape hatch" allowing you to write any groq query you want. * You must specify a type parameter for the new results. @@ -12,8 +12,8 @@ declare module "../groq-builder" { */ raw( query: string, - parser?: Parser | null - ): GroqBuilder; + parser?: Parser | null + ): GroqBuilder; } } GroqBuilder.implement({ diff --git a/packages/groq-builder/src/commands/select-types.ts b/packages/groq-builder/src/commands/select-types.ts index f7f2398f..3a258bd1 100644 --- a/packages/groq-builder/src/commands/select-types.ts +++ b/packages/groq-builder/src/commands/select-types.ts @@ -1,41 +1,44 @@ -import { ExtractTypeNames, RootConfig } from "../types/schema-types"; +import { ExtractTypeNames, QueryConfig } 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"; +import { IGroqBuilder, InferResultType } from "../types/public-types"; +import { Expressions } from "../types/groq-expressions"; // eslint-disable-next-line @typescript-eslint/no-unused-vars -export type SelectProjections = { - [Condition: ConditionalExpression]: GroqBuilder; -}; +export type SelectProjections< + TResultItem, + TQueryConfig extends QueryConfig +> = Partial< + Record, IGroqBuilder> +>; export type ExtractSelectResult< TSelectProjections extends SelectProjections > = ValueOf<{ [P in StringKeys]: InferResultType< - TSelectProjections[P] + NonNullable >; }>; export type SelectByTypeProjections< TResultItem, - TRootConfig extends RootConfig + TQueryConfig extends QueryConfig > = { [_type in ExtractTypeNames]?: - | GroqBuilder + | IGroqBuilder | (( - q: GroqBuilder, TRootConfig> - ) => GroqBuilder); + q: GroqBuilder, TQueryConfig> + ) => IGroqBuilder); }; export type ExtractSelectByTypeResult< TSelectProjections extends SelectByTypeProjections > = ValueOf<{ - [_type in keyof TSelectProjections]: TSelectProjections[_type] extends GroqBuilder< + [_type in keyof TSelectProjections]: TSelectProjections[_type] extends IGroqBuilder< infer TResult > ? TResult - : TSelectProjections[_type] extends (q: any) => GroqBuilder + : TSelectProjections[_type] extends (q: any) => IGroqBuilder ? TResult : never; }>; diff --git a/packages/groq-builder/src/commands/select.test.ts b/packages/groq-builder/src/commands/select.test.ts index 48e08f29..83ab5003 100644 --- a/packages/groq-builder/src/commands/select.test.ts +++ b/packages/groq-builder/src/commands/select.test.ts @@ -89,7 +89,7 @@ describe("select", () => { }); it("should execute correctly", async () => { - const results = await executeBuilder(qSelect, data.datalake); + const results = await executeBuilder(qSelect, data); expect(results).toMatchInlineSnapshot(` [ { @@ -125,13 +125,13 @@ describe("select", () => { }); describe("with validation", () => { - const qSelect = qBase.project((q) => ({ - selected: q.select({ - '_type == "product"': q.asType<"product">().project({ + const qSelect = qBase.project((qB) => ({ + selected: qB.select({ + '_type == "product"': qB.asType<"product">().project({ _type: zod.literal("product"), name: zod.string(), }), - '_type == "variant"': q.asType<"variant">().project({ + '_type == "variant"': qB.asType<"variant">().project({ _type: zod.literal("variant"), name: zod.string(), price: zod.number(), @@ -161,7 +161,7 @@ describe("select", () => { }); it("should execute correctly", async () => { - const results = await executeBuilder(qSelect, data.datalake); + const results = await executeBuilder(qSelect, data); expect(results).toMatchInlineSnapshot(` [ { @@ -184,7 +184,7 @@ describe("select", () => { `); }); it("should fail with invalid data", async () => { - await expect(() => executeBuilder(qSelect, invalidData.datalake)).rejects + await expect(() => executeBuilder(qSelect, invalidData)).rejects .toThrowErrorMatchingInlineSnapshot(` "2 Parsing Errors: result[0].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 32be78b5..12fd9fcf 100644 --- a/packages/groq-builder/src/commands/select.ts +++ b/packages/groq-builder/src/commands/select.ts @@ -2,16 +2,20 @@ import { GroqBuilder } from "../groq-builder"; import { ResultItem } from "../types/result-types"; import { ExtractSelectResult, SelectProjections } from "./select-types"; import { notNull } from "../types/utils"; -import { InferResultType, ParserFunction } from "../types/public-types"; +import { + IGroqBuilder, + InferResultType, + ParserFunction, +} from "../types/public-types"; declare module "../groq-builder" { - export interface GroqBuilder { + export interface GroqBuilder { select< TSelectProjections extends SelectProjections< ResultItem.Infer, - TRootConfig + TQueryConfig >, - TDefault extends null | GroqBuilder = null + TDefault extends null | IGroqBuilder = null >( selections: TSelectProjections, defaultSelection?: TDefault @@ -20,7 +24,7 @@ declare module "../groq-builder" { | (TDefault extends null | undefined ? null : InferResultType>), - TRootConfig + TQueryConfig >; } } @@ -29,7 +33,7 @@ GroqBuilder.implement({ const conditions = Object.keys(selections); const queries = conditions.map((condition) => { - const builder = selections[condition]; + const builder = selections[condition]!; return `${condition} => ${builder.query}`; }); @@ -38,16 +42,16 @@ GroqBuilder.implement({ } const parsers = conditions - .map((c) => selections[c].internal.parser) + .map((c) => selections[c]!.parser) .filter(notNull); const conditionalParser = parsers.length === 0 ? null - : createConditionalParser(parsers, defaultSelection?.internal.parser); + : createConditionalParser(parsers, defaultSelection?.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 missing = conditions.filter((c) => !selections[c]!.parser); const err = new TypeError( "When using 'select', either all conditions must have validation, or none of them. " + `Missing validation: "${missing.join('", "')}"` diff --git a/packages/groq-builder/src/commands/selectByType.test.ts b/packages/groq-builder/src/commands/selectByType.test.ts index a3526da9..e998bb9e 100644 --- a/packages/groq-builder/src/commands/selectByType.test.ts +++ b/packages/groq-builder/src/commands/selectByType.test.ts @@ -15,14 +15,14 @@ describe("selectByType", () => { }); 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 qWithCb = qBase.project((qB) => ({ + selected: qB.selectByType({ + product: (qP) => qP.value("PRODUCT"), // <-- uses the callback API }), })); - const qWithoutCb = qBase.project((q) => ({ - selected: q.selectByType({ - product: q.value("PRODUCT"), // <-- no callback + const qWithoutCb = qBase.project((qB) => ({ + selected: qB.selectByType({ + product: qB.value("PRODUCT"), // <-- no callback }), })); @@ -44,15 +44,15 @@ describe("selectByType", () => { }); describe("without a default param", () => { - const qSelect = qBase.project((q) => ({ - selected: q.selectByType({ - product: (q) => - q.project({ + const qSelect = qBase.project((qB) => ({ + selected: qB.selectByType({ + product: (qP) => + qP.project({ _type: true, name: true, }), - variant: (q) => - q.project({ + variant: (qP) => + qP.project({ _type: true, name: true, price: true, @@ -96,7 +96,7 @@ describe("selectByType", () => { }); it("should execute correctly", async () => { - const results = await executeBuilder(qSelect, data.datalake); + const results = await executeBuilder(qSelect, data); expect(results).toMatchInlineSnapshot(` [ { @@ -141,13 +141,13 @@ describe("selectByType", () => { }); describe("with default param", () => { - const qSelect = qBase.project((q) => ({ - selected: q.selectByType( + const qSelect = qBase.project((qB) => ({ + selected: qB.selectByType( { - product: (q) => q.field("name"), - variant: (q) => q.field("price"), + product: (qP) => qP.field("name"), + variant: (qV) => qV.field("price"), }, - q.value("UNKNOWN") + qB.value("UNKNOWN") ), })); @@ -177,7 +177,7 @@ describe("selectByType", () => { }); it("should execute correctly", async () => { - const results = await executeBuilder(qSelect, data.datalake); + const results = await executeBuilder(qSelect, data); expect(results).toMatchInlineSnapshot(` [ @@ -205,15 +205,15 @@ describe("selectByType", () => { }); describe("with validation", () => { - const qSelect = qBase.project((q) => ({ - selected: q.selectByType({ - product: (q) => - q.project({ + const qSelect = qBase.project((qB) => ({ + selected: qB.selectByType({ + product: (qP) => + qP.project({ _type: zod.literal("product"), name: zod.string(), }), - variant: (q) => - q.project({ + variant: (qV) => + qV.project({ _type: zod.literal("variant"), name: zod.string(), price: zod.number(), @@ -243,7 +243,7 @@ describe("selectByType", () => { }); it("should execute correctly", async () => { - const results = await executeBuilder(qSelect, data.datalake); + const results = await executeBuilder(qSelect, data); expect(results).toMatchInlineSnapshot(` [ { @@ -266,7 +266,7 @@ describe("selectByType", () => { `); }); it("should fail with invalid data", async () => { - await expect(() => executeBuilder(qSelect, invalidData.datalake)).rejects + await expect(() => executeBuilder(qSelect, invalidData)).rejects .toThrowErrorMatchingInlineSnapshot(` "2 Parsing Errors: result[0].selected: Conditional parsing failed; all 2 conditions failed diff --git a/packages/groq-builder/src/commands/selectByType.ts b/packages/groq-builder/src/commands/selectByType.ts index e6e763a8..927e5153 100644 --- a/packages/groq-builder/src/commands/selectByType.ts +++ b/packages/groq-builder/src/commands/selectByType.ts @@ -6,16 +6,16 @@ import { SelectByTypeProjections, SelectProjections, } from "./select-types"; -import { InferResultType } from "../types/public-types"; +import { IGroqBuilder, InferResultType } from "../types/public-types"; declare module "../groq-builder" { - export interface GroqBuilder { + export interface GroqBuilder { selectByType< TSelectByTypeProjections extends SelectByTypeProjections< ResultItem.Infer, - TRootConfig + TQueryConfig >, - TDefaultSelection extends GroqBuilder | null = null + TDefaultSelection extends IGroqBuilder | null = null >( typeQueries: TSelectByTypeProjections, defaultSelection?: TDefaultSelection @@ -24,7 +24,7 @@ declare module "../groq-builder" { | (TDefaultSelection extends null | undefined ? null : InferResultType>), - TRootConfig + TQueryConfig >; } } @@ -37,7 +37,7 @@ GroqBuilder.implement({ const condition = `_type == "${key}"`; const queryFn = typeQueries[key]; - const query: GroqBuilder = + const query: IGroqBuilder = typeof queryFn === "function" ? queryFn(root) : queryFn!; mapped[condition] = query; diff --git a/packages/groq-builder/src/commands/slice.test.ts b/packages/groq-builder/src/commands/slice.test.ts index 7344e3f4..11849444 100644 --- a/packages/groq-builder/src/commands/slice.test.ts +++ b/packages/groq-builder/src/commands/slice.test.ts @@ -11,7 +11,7 @@ describe("slice", () => { const qVariants = q.star.filterByType("variant"); const data = mock.generateSeedData({}); beforeAll(async function checkRootQuery() { - const results = await executeBuilder(qVariants, data.datalake); + const results = await executeBuilder(qVariants, data); expect(results).toStrictEqual(data.variants); }); @@ -28,7 +28,7 @@ describe("slice", () => { }); }); it("should execute correctly", async () => { - const results = await executeBuilder(qSlice0, data.datalake); + const results = await executeBuilder(qSlice0, data); expect(results).toMatchObject(data.variants[0]); }); }); @@ -55,7 +55,7 @@ describe("slice", () => { }); it("should execute correctly", async () => { const qSlice = qVariants.slice(5, 7); - const results = await executeBuilder(qSlice, data.datalake); + const results = await executeBuilder(qSlice, data); expect(results).toMatchObject([ // Triple-dots is exclusive data.variants[5], diff --git a/packages/groq-builder/src/commands/slice.ts b/packages/groq-builder/src/commands/slice.ts index 61aa688b..5f67f140 100644 --- a/packages/groq-builder/src/commands/slice.ts +++ b/packages/groq-builder/src/commands/slice.ts @@ -2,10 +2,10 @@ import { GroqBuilder } from "../groq-builder"; import { ResultItem } from "../types/result-types"; declare module "../groq-builder" { - export interface GroqBuilder { + export interface GroqBuilder { slice( index: number - ): GroqBuilder, TRootConfig>; + ): GroqBuilder, TQueryConfig>; slice( /** * The first index to include in the slice @@ -21,7 +21,7 @@ declare module "../groq-builder" { * @default false */ inclusive?: boolean - ): GroqBuilder; + ): GroqBuilder; /** @deprecated Use the 'slice' method */ index: never; diff --git a/packages/groq-builder/src/commands/star.test.ts b/packages/groq-builder/src/commands/star.test.ts index 347da41a..688a87a2 100644 --- a/packages/groq-builder/src/commands/star.test.ts +++ b/packages/groq-builder/src/commands/star.test.ts @@ -24,7 +24,7 @@ describe("star", () => { describe("execution", () => { it("should retrieve all documents", async () => { const data = mock.generateSeedData({}); - const result = await executeBuilder(q.star, data.datalake); + const result = await executeBuilder(q.star, data); // I mean, this should be sufficient, right? expect(result).toEqual(data.datalake); diff --git a/packages/groq-builder/src/commands/star.ts b/packages/groq-builder/src/commands/star.ts index 4aba52fc..d254e228 100644 --- a/packages/groq-builder/src/commands/star.ts +++ b/packages/groq-builder/src/commands/star.ts @@ -2,8 +2,8 @@ import { GroqBuilder } from "../groq-builder"; declare module "../groq-builder" { // eslint-disable-next-line @typescript-eslint/no-unused-vars - export interface GroqBuilder { - star: GroqBuilder, TRootConfig>; + export interface GroqBuilder { + star: GroqBuilder, TQueryConfig>; } } diff --git a/packages/groq-builder/src/commands/validate.test.ts b/packages/groq-builder/src/commands/validate.test.ts index 3d9fb4ff..164b4cba 100644 --- a/packages/groq-builder/src/commands/validate.test.ts +++ b/packages/groq-builder/src/commands/validate.test.ts @@ -23,7 +23,7 @@ describe("parse", () => { }); it("should parse the data", async () => { - const result = await executeBuilder(qPriceParse, data.datalake); + const result = await executeBuilder(qPriceParse, data); expect(result).toMatchInlineSnapshot('"$99.00"'); }); @@ -44,7 +44,7 @@ describe("parse", () => { }); it("should parse the data", async () => { - const result = await executeBuilder(qPriceParse, data.datalake); + const result = await executeBuilder(qPriceParse, data); expect(result).toMatchInlineSnapshot('"$99.00"'); }); }); diff --git a/packages/groq-builder/src/commands/validate.ts b/packages/groq-builder/src/commands/validate.ts index 535fafa6..924d94f0 100644 --- a/packages/groq-builder/src/commands/validate.ts +++ b/packages/groq-builder/src/commands/validate.ts @@ -3,13 +3,13 @@ import { Parser } from "../types/public-types"; import { chainParsers, normalizeValidationFunction } from "./validate-utils"; declare module "../groq-builder" { - export interface GroqBuilder { + export interface GroqBuilder { /** * Adds runtime validation to the query results. */ validate( parser: Parser - ): GroqBuilder; + ): GroqBuilder; /** * Adds runtime transformation to the query results. @@ -18,7 +18,7 @@ declare module "../groq-builder" { */ transform( parser: Parser - ): GroqBuilder; + ): GroqBuilder; } } diff --git a/packages/groq-builder/src/groq-builder.test.ts b/packages/groq-builder/src/groq-builder.test.ts index 6cd973a5..8bbc2c2a 100644 --- a/packages/groq-builder/src/groq-builder.test.ts +++ b/packages/groq-builder/src/groq-builder.test.ts @@ -1,7 +1,7 @@ import { describe, expect, expectTypeOf, it } from "vitest"; +import { createGroqBuilder } from "./index"; import { SchemaConfig } from "./tests/schemas/nextjs-sanity-fe"; import { InferResultType } from "./types/public-types"; -import { createGroqBuilder } from "./index"; import { Empty } from "./types/utils"; const q = createGroqBuilder({ indent: " " }); diff --git a/packages/groq-builder/src/groq-builder.ts b/packages/groq-builder/src/groq-builder.ts index 426d2129..aac7c192 100644 --- a/packages/groq-builder/src/groq-builder.ts +++ b/packages/groq-builder/src/groq-builder.ts @@ -1,13 +1,14 @@ -import type { +import { + GroqBuilderConfigType, + GroqBuilderResultType, IGroqBuilder, Parser, ParserFunction, } from "./types/public-types"; -import type { ExtractTypeNames, RootConfig } from "./types/schema-types"; +import type { ExtractTypeNames, QueryConfig } from "./types/schema-types"; import { normalizeValidationFunction } from "./commands/validate-utils"; import { ValidationErrors } from "./validation/validation-errors"; -import { Empty } from "./types/utils"; -import { GroqBuilderResultType } from "./types/public-types"; +import type { Empty } from "./types/utils"; import { QueryError } from "./types/query-error"; export type RootResult = Empty; @@ -42,11 +43,13 @@ export type GroqBuilderOptions = { export class GroqBuilder< TResult = any, - TRootConfig extends RootConfig = RootConfig + TQueryConfig extends QueryConfig = QueryConfig > implements IGroqBuilder { - // @ts-expect-error --- This property doesn't actually exist, it's only used to capture type info + // @ts-expect-error --- This property doesn't actually exist, it's only used to capture type info */ readonly [GroqBuilderResultType]: TResult; + // @ts-expect-error --- This property doesn't actually exist, it's only used to capture type info */ + readonly [GroqBuilderConfigType]: TQueryConfig; /** * Extends the GroqBuilder class by implementing methods. @@ -122,7 +125,7 @@ export class GroqBuilder< protected chain( query: string, parser?: Parser | null - ): GroqBuilder { + ): GroqBuilder { if (query && this.internal.parser) { throw new QueryError( "You cannot chain a new query once you've specified a parser, " + @@ -151,7 +154,7 @@ export class GroqBuilder< options = { ...options, indent: options.indent + " " }; } - return new GroqBuilder({ + return new GroqBuilder({ query: "", parser: null, options: options, @@ -161,7 +164,7 @@ export class GroqBuilder< /** * Returns a GroqBuilder, overriding the result type. */ - public as(): GroqBuilder { + public as(): GroqBuilder { return this as any; } @@ -170,10 +173,10 @@ export class GroqBuilder< * with the specified document type. */ public asType< - _type extends ExtractTypeNames + _type extends ExtractTypeNames >(): GroqBuilder< - Extract, - TRootConfig + Extract, + TQueryConfig > { return this as any; } diff --git a/packages/groq-builder/src/index.ts b/packages/groq-builder/src/index.ts index ce182f12..02b695eb 100644 --- a/packages/groq-builder/src/index.ts +++ b/packages/groq-builder/src/index.ts @@ -1,16 +1,17 @@ -// Be sure to keep these 2 imports in the correct order: -import { GroqBuilder, GroqBuilderOptions, RootResult } from "./groq-builder"; +// Be sure to keep these first 2 imports in this order: +import "./groq-builder"; import "./commands"; -import type { RootConfig } from "./types/schema-types"; -import type { ButFirst } from "./types/utils"; +import type { RootQueryConfig } from "./types/schema-types"; +import { GroqBuilder, GroqBuilderOptions, RootResult } from "./groq-builder"; import { zod } from "./validation/zod"; // Re-export all our public types: +export * from "./groq-builder"; export * from "./types/public-types"; export * from "./types/schema-types"; -export { GroqBuilder, GroqBuilderOptions, RootResult } from "./groq-builder"; export { zod } from "./validation/zod"; +export { makeSafeQueryRunner } from "./makeSafeQueryRunner"; /** * Creates the root `q` query builder. @@ -25,7 +26,7 @@ export { zod } from "./validation/zod"; * The TRootConfig type argument is used to bind the query builder to the Sanity schema config. * If you specify `any`, then your schema will be loosely-typed, but the output types will still be strongly typed. */ -export function createGroqBuilder( +export function createGroqBuilder( options: GroqBuilderOptions = {} ) { const q = new GroqBuilder({ @@ -45,26 +46,9 @@ export function createGroqBuilder( * The TRootConfig type argument is used to bind the query builder to the Sanity schema config. * If you specify `any`, then your schema will be loosely-typed, but the output types will still be strongly typed. */ -export function createGroqBuilderWithZod( +export function createGroqBuilderWithZod( options: GroqBuilderOptions = {} ) { const q = createGroqBuilder(options); return Object.assign(q, zod); } - -/** - * Utility to create a "query runner" that consumes the result of the `q` function. - */ -export function makeSafeQueryRunner< - FunnerFn extends (query: string, ...parameters: any[]) => Promise ->(fn: FunnerFn) { - return async function queryRunner( - builder: GroqBuilder, - ...parameters: ButFirst> - ): Promise { - const data = await fn(builder.query, ...parameters); - - const parsed = builder.parse(data); - return parsed; - }; -} diff --git a/packages/groq-builder/src/makeSafeQueryRunner.test.ts b/packages/groq-builder/src/makeSafeQueryRunner.test.ts new file mode 100644 index 00000000..98c8ac42 --- /dev/null +++ b/packages/groq-builder/src/makeSafeQueryRunner.test.ts @@ -0,0 +1,105 @@ +import { describe, it, expect, expectTypeOf } from "vitest"; +import { SchemaConfig } from "./tests/schemas/nextjs-sanity-fe"; +import { createGroqBuilder } from "./index"; +import { makeSafeQueryRunner } from "./makeSafeQueryRunner"; + +const q = createGroqBuilder({ indent: " " }); + +describe("makeSafeQueryRunner", () => { + it("should have correctly-typed parameters", () => { + makeSafeQueryRunner(async (query, options) => { + expectTypeOf(query).toEqualTypeOf(); + expectTypeOf(options).toEqualTypeOf<{ + parameters: {} | undefined; + }>(); + return null; + }); + }); + it("should have correctly-typed extra parameters", () => { + makeSafeQueryRunner<{ foo: "FOO"; bar?: "BAR" }>(async (query, options) => { + expectTypeOf(query).toEqualTypeOf(); + expectTypeOf(options).toEqualTypeOf<{ + parameters: {} | undefined; + foo: "FOO"; + bar?: "BAR"; + }>(); + return null; + }); + }); + + const query = q.star.filterByType("variant").project({ name: true }); + const queryWithVars = q + .parameters<{ foo: string }>() + .star.filterByType("variant") + .project({ name: true }); + const runner = makeSafeQueryRunner(async (query, options) => { + return [query, options]; + }); + const runnerWithExtraParams = makeSafeQueryRunner<{ extraParameter: string }>( + async (query, options) => { + return [query, options]; + } + ); + + it("should return a runner function", () => { + expect(runner).toBeTypeOf("function"); + }); + it("the function should be strongly-typed", async () => { + const result = await runner(query); + expectTypeOf(result).toEqualTypeOf>(); + // But actually, our result contains the query and options: + expect(result).toMatchInlineSnapshot(` + [ + "*[_type == \\"variant\\"] { + name + }", + {}, + ] + `); + }); + it("should require parameters when present", () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async function onlyCheckTypes() { + // @ts-expect-error --- requires 2 parameters + await runner(queryWithVars); + + await runner( + queryWithVars, + // @ts-expect-error --- 'null' is not assignable + null + ); + + await runner(queryWithVars, { + // @ts-expect-error --- property 'foo' is missing + parameters: {}, + }); + await runner(queryWithVars, { + parameters: { + // @ts-expect-error --- 'number' is not assignable to 'string' + foo: 999, + }, + }); + } + }); + + it("should require extra parameters if defined", () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async function onlyCheckTypes() { + // @ts-expect-error --- expected 2 arguments + await runnerWithExtraParams(query); + await runnerWithExtraParams( + query, + // @ts-expect-error -- property 'extraParameter' is missing + {} + ); + await runnerWithExtraParams(query, { + // @ts-expect-error -- null is not assignable to string + extraParameter: null, + }); + + await runnerWithExtraParams(query, { + extraParameter: "valid", + }); + } + }); +}); diff --git a/packages/groq-builder/src/makeSafeQueryRunner.ts b/packages/groq-builder/src/makeSafeQueryRunner.ts new file mode 100644 index 00000000..48acd548 --- /dev/null +++ b/packages/groq-builder/src/makeSafeQueryRunner.ts @@ -0,0 +1,76 @@ +import type { HasRequiredKeys, IsUnknown } from "type-fest"; +import type { QueryConfig } from "./types/schema-types"; + +import { IGroqBuilder } from "./types/public-types"; + +export type QueryRunnerOptions = + IsUnknown extends true + ? { + /** + * This query does not have any parameters defined. + * Please use `q.parameters<...>()` to define the required input parameters. + */ + parameters?: never; + } + : { + /** + * This query requires the following input parameters. + */ + parameters: TQueryConfig["parameters"]; + }; + +/** + * Utility to create a "query runner" that consumes the result of the `q` chain. + * + * If you need to pass custom options to your `execute` function, + * use the TCustomOptions to ensure they're strongly typed. + * + * @example + * const runner = makeSafeQueryRunner( + * async (query, { parameters }) => { + * return await sanityClient.fetch(query, { params: parameters }); + * } + * ) + * + * @example + * const runner = makeSafeQueryRunner<{ withAuth: boolean }>( + * async (query, { parameters, withAuth }) => { + * if (withAuth) ... + * } + * ) + * */ +export function makeSafeQueryRunner( + execute: ( + query: string, + options: QueryRunnerOptions & TCustomOptions + ) => Promise +) { + /** + * This queryRunner will execute a query and return strongly-typed results. + * If the query has any parameters, you can pass them here too. + */ + return async function queryRunner( + builder: IGroqBuilder, + ..._options: MaybeRequired< + QueryRunnerOptions & TCustomOptions + > + ): Promise { + const options: any = _options[0] || {}; + const results = await execute(builder.query, options); + + const parsed = builder.parse(results); + return parsed; + }; +} + +/** + * If all options are fully optional, + * then this makes the entire options argument optional too. + * + * If the options argument has any required keys, + * then the entire options argument is required too. + */ +type MaybeRequired = + HasRequiredKeys extends true + ? [TOptions] // Required + : [] | [TOptions]; // Optional diff --git a/packages/groq-builder/src/tests/mocks/executeQuery.ts b/packages/groq-builder/src/tests/mocks/executeQuery.ts index 54bdd252..c24c0994 100644 --- a/packages/groq-builder/src/tests/mocks/executeQuery.ts +++ b/packages/groq-builder/src/tests/mocks/executeQuery.ts @@ -1,21 +1,25 @@ import * as groqJs from "groq-js"; -import { makeSafeQueryRunner } from "../../index"; + +import { makeSafeQueryRunner } from "../../makeSafeQueryRunner"; type Datalake = Array; -export const executeBuilder = makeSafeQueryRunner( - async (query: string, datalake: Datalake, params = {}) => - await executeQuery(query, datalake, params) +export const executeBuilder = makeSafeQueryRunner<{ datalake: Datalake }>( + async (query: string, { parameters, datalake }) => + await executeQuery(datalake, query, parameters ?? {}) ); export async function executeQuery( - query: string, dataset: Datalake, - params: Record + query: string, + parameters: Record ): Promise { try { - const parsed = groqJs.parse(query, { params }); - const streamResult = await groqJs.evaluate(parsed, { dataset, params }); + const parsed = groqJs.parse(query, { params: parameters }); + const streamResult = await groqJs.evaluate(parsed, { + dataset, + params: parameters, + }); const start = Date.now(); const result = await streamResult.get(); diff --git a/packages/groq-builder/src/types/groq-expressions.test.ts b/packages/groq-builder/src/types/groq-expressions.test.ts new file mode 100644 index 00000000..12de370b --- /dev/null +++ b/packages/groq-builder/src/types/groq-expressions.test.ts @@ -0,0 +1,203 @@ +import { describe, expectTypeOf, it } from "vitest"; +import { Expressions } from "./groq-expressions"; +import { QueryConfig, RootQueryConfig } from "./schema-types"; +import { Simplify } from "./utils"; + +describe("Expressions", () => { + it("literal values are properly escaped", () => { + expectTypeOf< + Expressions.Equality<{ foo: "FOO" }, QueryConfig> + >().toEqualTypeOf<'foo == "FOO"'>(); + expectTypeOf< + Expressions.Equality<{ foo: 999 }, QueryConfig> + >().toEqualTypeOf<"foo == 999">(); + expectTypeOf< + Expressions.Equality<{ foo: true }, QueryConfig> + >().toEqualTypeOf<"foo == true">(); + expectTypeOf< + Expressions.Equality<{ foo: null }, QueryConfig> + >().toEqualTypeOf<"foo == null">(); + }); + it("primitive values are properly typed", () => { + expectTypeOf< + Expressions.Equality<{ foo: string }, QueryConfig> + >().toEqualTypeOf<`foo == "${string}"` | "foo == (string)">(); + expectTypeOf< + Expressions.Equality<{ foo: number }, QueryConfig> + >().toEqualTypeOf<`foo == ${number}` | "foo == (number)">(); + expectTypeOf< + Expressions.Equality<{ foo: boolean }, QueryConfig> + >().toEqualTypeOf<"foo == true" | "foo == false">(); + expectTypeOf< + Expressions.Equality<{ foo: null }, QueryConfig> + >().toEqualTypeOf<`foo == null`>(); + }); + + it("multiple literals", () => { + expectTypeOf< + Expressions.Equality<{ foo: "FOO"; bar: 999 }, QueryConfig> + >().toEqualTypeOf<'foo == "FOO"' | "bar == 999">(); + }); + it("multiple primitives", () => { + expectTypeOf< + Expressions.Equality<{ foo: string; bar: number }, QueryConfig> + >().toEqualTypeOf< + | "foo == (string)" + | `foo == "${string}"` + | "bar == (number)" + | `bar == ${number}` + >(); + }); + it("mixed types", () => { + expectTypeOf< + Expressions.Equality<{ foo: "FOO"; bar: number }, QueryConfig> + >().toEqualTypeOf< + 'foo == "FOO"' | "bar == (number)" | `bar == ${number}` + >(); + }); + + describe("with parameters", () => { + type WithVars = RootQueryConfig & { parameters: TVars }; + + it("a literal value can be compared to parameters with the same type", () => { + expectTypeOf< + Expressions.Equality<{ foo: "FOO" }, WithVars<{ str: string }>> + >().toEqualTypeOf<'foo == "FOO"' | "foo == $str">(); + expectTypeOf< + Expressions.Equality<{ foo: string }, WithVars<{ str: "FOO" }>> + >().toEqualTypeOf< + `foo == "${string}"` | "foo == (string)" | "foo == $str" + >(); + expectTypeOf< + Expressions.Equality<{ bar: number }, WithVars<{ str: string }>> + >().toEqualTypeOf<`bar == ${number}` | "bar == (number)">(); + expectTypeOf< + Expressions.Equality<{ foo: 999 }, WithVars<{ num: number }>> + >().toEqualTypeOf<`foo == 999` | "foo == $num">(); + expectTypeOf< + Expressions.Equality<{ foo: number }, WithVars<{ num: number }>> + >().toEqualTypeOf< + "foo == $num" | "foo == (number)" | `foo == ${number}` + >(); + }); + + it("nested properties can be compared", () => { + type WithNested = { + foo: "FOO"; + bar: { + baz: "BAZ"; + str: string; + num: number; + }; + }; + type Actual = Expressions.Equality< + WithNested, + WithVars<{ str: string; num: number }> + >; + type Expected = + | "foo == $str" + | 'foo == "FOO"' + | 'bar.baz == "BAZ"' + | "bar.baz == $str" + | "bar.str == $str" + | "bar.str == (string)" + | `bar.str == "${string}"` + | "bar.num == $num" + | "bar.num == (number)" + | `bar.num == ${number}`; + + // This is really hard to debug: + expectTypeOf().toEqualTypeOf(); + // Here are 2 easier ways to debug: + type ActualExtras = Exclude; + type ActualMissing = Exclude; + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + }); + + type ManyParameters = { + str1: string; + str2: string; + num1: number; + bool: boolean; + }; + + it("we can extract parameters based on their type", () => { + type ParameterEntries = Expressions.ParameterEntries; + expectTypeOf>().toEqualTypeOf<{ + $str1: string; + $str2: string; + $num1: number; + $bool: boolean; + }>(); + + expectTypeOf< + Expressions.StringKeysWithType + >().toEqualTypeOf<"$str1" | "$str2">(); + expectTypeOf< + Expressions.StringKeysWithType + >().toEqualTypeOf<"$str1" | "$str2">(); + expectTypeOf< + Expressions.StringKeysWithType + >().toEqualTypeOf<"$num1">(); + expectTypeOf< + Expressions.StringKeysWithType + >().toEqualTypeOf<"$bool">(); + }); + + it("multiple values are compared to same-typed parameters", () => { + type Item = { foo: string; bar: number; baz: boolean }; + type Res = Expressions.Equality>; + expectTypeOf().toEqualTypeOf< + | "foo == $str1" + | "foo == $str2" + | "foo == (string)" + | `foo == "${string}"` + | "bar == $num1" + | "bar == (number)" + | `bar == ${number}` + | "baz == $bool" + | "baz == true" + | "baz == false" + >(); + }); + + type NestedParameters = { + nested: { + str1: string; + deep: { + str2: string; + num1: number; + }; + }; + }; + it("should work with deeply-nested parameters", () => { + type Item = { foo: string; bar: number; baz: boolean }; + type Res = Expressions.Equality>; + + type StandardSuggestions = + | `foo == (string)` + | `foo == "${string}"` + | `bar == (number)` + | `bar == ${number}` + | "baz == true" + | "baz == false"; + expectTypeOf>().toEqualTypeOf< + | "foo == $nested.str1" + | "foo == $nested.deep.str2" + | "bar == $nested.deep.num1" + >(); + }); + + it("we can extract parameters based on their type", () => { + type ParameterEntries = Expressions.ParameterEntries; + expectTypeOf>().toEqualTypeOf<{ + $nested: NestedParameters["nested"]; + "$nested.str1": string; + "$nested.deep": NestedParameters["nested"]["deep"]; + "$nested.deep.str2": string; + "$nested.deep.num1": number; + }>(); + }); + }); +}); diff --git a/packages/groq-builder/src/types/groq-expressions.ts b/packages/groq-builder/src/types/groq-expressions.ts new file mode 100644 index 00000000..622960a2 --- /dev/null +++ b/packages/groq-builder/src/types/groq-expressions.ts @@ -0,0 +1,92 @@ +import { QueryConfig } from "./schema-types"; +import type { IsLiteral, LiteralUnion } from "type-fest"; +import { StringKeys, UndefinedToNull, ValueOf } from "./utils"; +import { Path, PathValue } from "./path-types"; + +// eslint-disable-next-line @typescript-eslint/no-namespace +export namespace Expressions { + /** + * This type allows any string, but provides + * TypeScript suggestions for common expressions, + * like '_type == "product"' or 'slug.current == $slug'. + */ + export type AnyConditional< + TResultItem, + TQueryConfig extends QueryConfig + > = LiteralUnion< + // Suggest some equality expressions, like `slug.current == $slug`, + Conditional, + // but still allow for any string: + string + >; + + /** + * A strongly-typed conditional Groq conditional expression. + * Currently, this only supports simple "equality" expressions, + * like '_type == "product"' or 'slug.current == $slug'. + * */ + export type Conditional = + // Currently we only support equality expressions: + Equality; + + export type Equality< + TResultItem, + TQueryConfig extends QueryConfig, + /** (local use only) Calculate our Parameter entries once, and reuse across suggestions */ + _ParameterEntries = ParameterEntries + > = ValueOf<{ + [Key in SuggestedKeys]: `${Key} == ${SuggestedValues< + _ParameterEntries, + SuggestedKeysValue + >}`; + }>; + + // Escape literal values: + type LiteralValue = TValue extends string + ? `"${TValue}"` + : TValue extends number | boolean | null + ? TValue + : never; + + // Make some literal suggestions: + type LiteralSuggestion = IsLiteral extends true + ? // If we're already dealing with a literal value, we don't need suggestions: + never + : TValue extends string + ? "(string)" + : TValue extends number + ? "(number)" + : never; + + // Suggest keys: + type SuggestedKeys = Path; + type SuggestedKeysValue< + TResultItem, + TKey extends SuggestedKeys + > = UndefinedToNull>; + + type SuggestedValues = + // First, suggest parameters: + | StringKeysWithType + // Next, make some literal suggestions: + | LiteralSuggestion + // Finally, allow all literal values: + | LiteralValue; + + export type ParameterEntries = { + [P in Path as `$${P}`]: PathValue; + }; + + /** + * Finds all (string) keys of TObject where the value matches the given TType + */ + export type StringKeysWithType = StringKeys< + ValueOf<{ + [P in keyof TObject]: TObject[P] extends TType + ? P + : TType extends TObject[P] + ? P + : never; + }> + >; +} diff --git a/packages/groq-builder/src/types/public-types.ts b/packages/groq-builder/src/types/public-types.ts index 7b13ea4f..49361ba1 100644 --- a/packages/groq-builder/src/types/public-types.ts +++ b/packages/groq-builder/src/types/public-types.ts @@ -1,8 +1,8 @@ import type { ZodType } from "zod"; -import type { GroqBuilder } from "../groq-builder"; import type { ResultItem } from "./result-types"; import type { Simplify } from "./utils"; import type { ExtractProjectionResult } from "../commands/projection-types"; +import type { QueryConfig } from "./schema-types"; /* eslint-disable @typescript-eslint/no-explicit-any */ @@ -58,6 +58,34 @@ export type ParserFunctionMaybe< TOutput = any > = null | ParserFunction; +// Not used at runtime, just used to infer types: +export declare const GroqBuilderResultType: unique symbol; +export declare const GroqBuilderConfigType: unique symbol; + +/** + * IGroqBuilder is the bare minimum GroqBuilder, used to prevent circular references + * @internal + */ +export type IGroqBuilder< + TResult = unknown, + TQueryConfig extends QueryConfig = QueryConfig +> = { + /** + * Used to infer the Result types of a GroqBuilder. + * This symbol is not used at runtime. + * @internal + */ + readonly [GroqBuilderResultType]: TResult; + /** + * Used to infer the TQueryConfig types of a GroqBuilder. + * This symbol is not used at runtime + * @internal + */ + readonly [GroqBuilderConfigType]: TQueryConfig; + query: string; + parser: ParserFunction | null; + parse: ParserFunction; +}; /** * Extracts the Result type from a GroqBuilder query */ @@ -67,22 +95,13 @@ export type InferResultType> = /** * Extracts the Result type for a single item from a GroqBuilder query */ -export type InferResultItem = +export type InferResultItem> = ResultItem.Infer>; -/** - * Used to store the Result types of a GroqBuilder. - * This symbol is not used at runtime. - */ -export declare const GroqBuilderResultType: unique symbol; -/** - * IGroqBuilder is the bare minimum GroqBuilder, used to prevent circular references - */ -export type IGroqBuilder = { - readonly [GroqBuilderResultType]: TResult; - query: string; - parser: ParserFunction | null; -}; +export type InferParametersType> = + TGroqBuilder extends IGroqBuilder + ? TQueryConfig["parameters"] + : never; /** * Used to store the Result types of a Fragment. diff --git a/packages/groq-builder/src/types/schema-types.ts b/packages/groq-builder/src/types/schema-types.ts index f139ed4b..60598bfc 100644 --- a/packages/groq-builder/src/types/schema-types.ts +++ b/packages/groq-builder/src/types/schema-types.ts @@ -1,6 +1,6 @@ import { TypeMismatchError } from "./utils"; -export type RootConfig = { +export type RootQueryConfig = { /** * This should be a union of all possible document types, according to your Sanity config. * @@ -26,6 +26,14 @@ export type RootConfig = { referenceSymbol: symbol; }; +export type QueryConfig = RootQueryConfig & { + /** + * Represents a map of input parameter names, and their types. + * To set this, use the `q.parameters<{ id: string }>()` syntax + */ + parameters?: {}; +}; + /** * Extracts all document types from an inferred schema. * The inferred schema type should look like { [string]: Document } @@ -44,15 +52,15 @@ export type RefType = { [P in referenceSymbol]: TTypeName; }; -export type ExtractRefType = +export type ExtractRefType = // - TResultItem extends RefType - ? Extract + TResultItem extends RefType + ? Extract : TypeMismatchError<{ error: "⛔️ Expected the object to be a reference type ⛔️"; expected: RefType< - TRootConfig["referenceSymbol"], - TRootConfig["documentTypes"]["_type"] + TQueryConfig["referenceSymbol"], + TQueryConfig["documentTypes"]["_type"] >; actual: TResultItem; }>; diff --git a/packages/groq-builder/src/validation/zod.test.ts b/packages/groq-builder/src/validation/zod.test.ts index ea50d8d1..71844c3e 100644 --- a/packages/groq-builder/src/validation/zod.test.ts +++ b/packages/groq-builder/src/validation/zod.test.ts @@ -37,8 +37,7 @@ describe("with zod", () => { '"*[_type == \\"variant\\"] { name, price, id }"' ); - expect(await executeBuilder(qWithZod, data.datalake)) - .toMatchInlineSnapshot(` + expect(await executeBuilder(qWithZod, data)).toMatchInlineSnapshot(` [ { "id": "ID", @@ -66,7 +65,7 @@ describe("with zod", () => { '"*[_type == \\"variant\\"] { name, price, id }"' ); - await expect(() => executeBuilder(qWithZod, data.datalake)).rejects + await expect(() => executeBuilder(qWithZod, data)).rejects .toThrowErrorMatchingInlineSnapshot(` "3 Parsing Errors: result[0].price: Expected number, received null @@ -186,7 +185,7 @@ describe("with zod", () => { ], }); it("should retrieve all slugs", async () => { - const result = await executeBuilder(qVariantSlugs, data.datalake); + const result = await executeBuilder(qVariantSlugs, data); expect(result).toEqual([ { SLUG: "SLUG_1" }, @@ -206,7 +205,7 @@ describe("with zod", () => { ], }); - await expect(() => executeBuilder(qVariantSlugs, data.datalake)).rejects + await expect(() => executeBuilder(qVariantSlugs, data)).rejects .toThrowErrorMatchingInlineSnapshot(` "3 Parsing Errors: result[0].SLUG: Expected string, received number diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 36c99dc5..c00072af 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1,5 +1,9 @@ lockfileVersion: '6.0' +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + importers: .: @@ -1992,7 +1996,7 @@ packages: '@lezer/common': 1.0.2 dev: false - /@codemirror/autocomplete@6.5.1(@codemirror/language@6.8.0)(@codemirror/state@6.2.1)(@codemirror/view@6.14.0)(@lezer/common@1.0.3): + /@codemirror/autocomplete@6.5.1(@codemirror/language@6.6.0)(@codemirror/state@6.2.1)(@codemirror/view@6.9.4)(@lezer/common@1.0.3): resolution: {integrity: sha512-/Sv9yJmqyILbZ26U4LBHnAtbikuVxWUp+rQ8BXuRGtxZfbfKOY/WPbsUtvSP2h0ZUZMlkxV/hqbKRFzowlA6xw==} peerDependencies: '@codemirror/language': ^6.0.0 @@ -2000,9 +2004,9 @@ packages: '@codemirror/view': ^6.0.0 '@lezer/common': ^1.0.0 dependencies: - '@codemirror/language': 6.8.0 + '@codemirror/language': 6.6.0 '@codemirror/state': 6.2.1 - '@codemirror/view': 6.14.0 + '@codemirror/view': 6.9.4 '@lezer/common': 1.0.3 dev: false @@ -2075,8 +2079,8 @@ packages: /@codemirror/lint@6.2.1: resolution: {integrity: sha512-y1muai5U/uUPAGRyHMx9mHuHLypPcHWxzlZGknp/U5Mdb5Ol8Q5ZLp67UqyTbNFJJ3unVxZ8iX3g1fMN79S1JQ==} dependencies: - '@codemirror/state': 6.2.0 - '@codemirror/view': 6.9.4 + '@codemirror/state': 6.2.1 + '@codemirror/view': 6.14.0 crelt: 1.0.5 dev: false @@ -2519,7 +2523,7 @@ packages: wait-on: 6.0.1 webpack: 5.88.0 webpack-bundle-analyzer: 4.8.0 - webpack-dev-server: 4.13.2(webpack-cli@5.0.1)(webpack@5.79.0) + webpack-dev-server: 4.13.2(webpack@5.88.0) webpack-merge: 5.8.0 webpackbar: 5.0.2(webpack@5.88.0) transitivePeerDependencies: @@ -4316,14 +4320,14 @@ packages: /@lezer/javascript@1.4.2: resolution: {integrity: sha512-77qdAD4zanmImPiAu4ibrMUzRc79UHoccdPa+Ey5iwS891TAkhnMAodUe17T7zV7tnF7e9HXM0pfmjoGEhrppg==} dependencies: - '@lezer/highlight': 1.1.4 - '@lezer/lr': 1.3.3 + '@lezer/highlight': 1.1.6 + '@lezer/lr': 1.3.7 dev: false /@lezer/lr@1.3.3: resolution: {integrity: sha512-JPQe3mwJlzEVqy67iQiiGozhcngbO8QBgpqZM6oL1Wj/dXckrEexpBLeFkq0edtW5IqnPRFxA24BHJni8Js69w==} dependencies: - '@lezer/common': 1.0.2 + '@lezer/common': 1.0.3 dev: false /@lezer/lr@1.3.7: @@ -4845,7 +4849,7 @@ packages: react: ^18 styled-components: ^5.2 dependencies: - '@codemirror/autocomplete': 6.5.1(@codemirror/language@6.8.0)(@codemirror/state@6.2.1)(@codemirror/view@6.14.0)(@lezer/common@1.0.3) + '@codemirror/autocomplete': 6.5.1(@codemirror/language@6.6.0)(@codemirror/state@6.2.1)(@codemirror/view@6.9.4)(@lezer/common@1.0.3) '@codemirror/commands': 6.2.2 '@codemirror/lang-javascript': 6.1.6 '@codemirror/language': 6.6.0 @@ -5601,7 +5605,7 @@ packages: '@codemirror/state': '>=6.0.0' '@codemirror/view': '>=6.0.0' dependencies: - '@codemirror/autocomplete': 6.5.1(@codemirror/language@6.8.0)(@codemirror/state@6.2.1)(@codemirror/view@6.14.0)(@lezer/common@1.0.3) + '@codemirror/autocomplete': 6.5.1(@codemirror/language@6.6.0)(@codemirror/state@6.2.1)(@codemirror/view@6.9.4)(@lezer/common@1.0.3) '@codemirror/commands': 6.2.2 '@codemirror/language': 6.6.0 '@codemirror/lint': 6.3.0 @@ -6313,7 +6317,7 @@ packages: /axios@0.25.0: resolution: {integrity: sha512-cD8FOb0tRH3uuEe6+evtAbgJtfxr7ly3fQjYcMcuPlgkwVS9xboaVIpcDV+cYQe+yGykgwZCs1pzjntcGa6l5g==} dependencies: - follow-redirects: 1.15.2(debug@4.3.4) + follow-redirects: 1.15.2(debug@2.6.9) transitivePeerDependencies: - debug dev: false @@ -8863,7 +8867,6 @@ packages: optional: true dependencies: debug: 2.6.9 - dev: true /follow-redirects@1.15.2(debug@4.3.4): resolution: {integrity: sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==} @@ -8875,6 +8878,7 @@ packages: optional: true dependencies: debug: 4.3.4(supports-color@5.5.0) + dev: false /for-each@0.3.3: resolution: {integrity: sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==} @@ -9753,7 +9757,7 @@ packages: engines: {node: '>=8.0.0'} dependencies: eventemitter3: 4.0.7 - follow-redirects: 1.15.2(debug@4.3.4) + follow-redirects: 1.15.2(debug@2.6.9) requires-port: 1.0.0 transitivePeerDependencies: - debug @@ -15527,6 +15531,20 @@ packages: schema-utils: 4.0.0 webpack: 5.79.0(webpack-cli@5.0.1) + /webpack-dev-middleware@5.3.3(webpack@5.88.0): + resolution: {integrity: sha512-hj5CYrY0bZLB+eTO+x/j67Pkrquiy7kWepMHmUMoPsmcUaeEnQJqFzHJOyxgWlq746/wUuA64p9ta34Kyb01pA==} + engines: {node: '>= 12.13.0'} + peerDependencies: + webpack: ^4.0.0 || ^5.0.0 + dependencies: + colorette: 2.0.19 + memfs: 3.5.0 + mime-types: 2.1.35 + range-parser: 1.2.1 + schema-utils: 4.0.0 + webpack: 5.88.0 + dev: false + /webpack-dev-server@4.13.2(webpack-cli@5.0.1)(webpack@5.79.0): resolution: {integrity: sha512-5i6TrGBRxG4vnfDpB6qSQGfnB6skGBXNL5/542w2uRGLimX6qeE5BQMLrzIC3JYV/xlGOv+s+hTleI9AZKUQNw==} engines: {node: '>= 12.13.0'} @@ -15578,6 +15596,57 @@ packages: - supports-color - utf-8-validate + /webpack-dev-server@4.13.2(webpack@5.88.0): + resolution: {integrity: sha512-5i6TrGBRxG4vnfDpB6qSQGfnB6skGBXNL5/542w2uRGLimX6qeE5BQMLrzIC3JYV/xlGOv+s+hTleI9AZKUQNw==} + engines: {node: '>= 12.13.0'} + hasBin: true + peerDependencies: + webpack: ^4.37.0 || ^5.0.0 + webpack-cli: '*' + peerDependenciesMeta: + webpack: + optional: true + webpack-cli: + optional: true + dependencies: + '@types/bonjour': 3.5.10 + '@types/connect-history-api-fallback': 1.3.5 + '@types/express': 4.17.17 + '@types/serve-index': 1.9.1 + '@types/serve-static': 1.15.1 + '@types/sockjs': 0.3.33 + '@types/ws': 8.5.4 + ansi-html-community: 0.0.8 + bonjour-service: 1.1.1 + chokidar: 3.5.3 + colorette: 2.0.19 + compression: 1.7.4 + connect-history-api-fallback: 2.0.0 + default-gateway: 6.0.3 + express: 4.18.2 + graceful-fs: 4.2.11 + html-entities: 2.3.3 + http-proxy-middleware: 2.0.6(@types/express@4.17.17) + ipaddr.js: 2.0.1 + launch-editor: 2.6.0 + open: 8.4.2 + p-retry: 4.6.2 + rimraf: 3.0.2 + schema-utils: 4.0.0 + selfsigned: 2.1.1 + serve-index: 1.9.1 + sockjs: 0.3.24 + spdy: 4.0.2 + webpack: 5.88.0 + webpack-dev-middleware: 5.3.3(webpack@5.88.0) + ws: 8.13.0 + transitivePeerDependencies: + - bufferutil + - debug + - supports-color + - utf-8-validate + dev: false + /webpack-merge@5.8.0: resolution: {integrity: sha512-/SaI7xY0831XwP6kzuwhKWVKDP9t1QY1h65lAFLbZqMPIuYcD9QAW4u9STIbU9kaJbPBB/geU/gLr1wDjOhQ+Q==} engines: {node: '>=10.0.0'} @@ -16013,7 +16082,3 @@ packages: /zwitch@1.0.5: resolution: {integrity: sha512-V50KMwwzqJV0NpZIZFwfOD5/lyny3WlSzRiXgA0G7VUnRlqttta1L6UQIHzd6EuBY/cHGfwTIck7w1yH6Q5zUw==} dev: false - -settings: - autoInstallPeers: true - excludeLinksFromLockfile: false