Skip to content

Commit

Permalink
Fix: improve types for ellipsis operator (...) when using overrides (
Browse files Browse the repository at this point in the history
…#324)

* tests(deref): added extra test cases

* tests(fragment): added extra test cases for `fragment<any>`

* tests(project): added extra test cases for projection errors

* fix(projections): Allow "..." properties to be overridden

* Changeset

* chore: extracted `TypeMismatchError` to separate file

* fix(project): ensure the ellipsis operator will pass-through all properties, and supports a parser

* fix(project): unused import

* changeset

---------

Co-authored-by: scottrippey <[email protected]>
  • Loading branch information
scottrippey and scottrippey authored Jan 30, 2025
1 parent 29956a3 commit f03820f
Show file tree
Hide file tree
Showing 14 changed files with 339 additions and 167 deletions.
6 changes: 6 additions & 0 deletions .changeset/long-geese-try.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"groqd": patch
---

Fixed: improve support for ellipsis operator (`...`) when using overrides
Fixes #317
16 changes: 15 additions & 1 deletion packages/groqd/src/commands/deref.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { describe, expect, expectTypeOf, it } from "vitest";
import { InferResultType } from "../types/public-types";
import { executeBuilder } from "../tests/mocks/executeQuery";
import { mock } from "../tests/mocks/nextjs-sanity-fe-mocks";
import { SanitySchema, q } from "../tests/schemas/nextjs-sanity-fe";
import { q, SanitySchema } from "../tests/schemas/nextjs-sanity-fe";

const data = mock.generateSeedData({});

Expand Down Expand Up @@ -52,4 +52,18 @@ describe("deref", () => {
const results = await executeBuilder(qVariants, data);
expect(results).toEqual(data.variants);
});

describe("as part of a projection", () => {
it("should deref an array of fields", () => {
const query = q.star.filterByType("product").project((sub) => ({
categories: sub.field("categories[]").deref(),
}));

expectTypeOf<InferResultType<typeof query>>().toEqualTypeOf<
Array<{
categories: SanitySchema.Category[] | null;
}>
>();
});
});
});
16 changes: 15 additions & 1 deletion packages/groqd/src/commands/fragment.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { describe, it, expect, expectTypeOf } from "vitest";
import { SanitySchema, q } from "../tests/schemas/nextjs-sanity-fe";
import { InferFragmentType, InferResultType } from "../types/public-types";
import { TypeMismatchError } from "../types/utils";
import { TypeMismatchError } from "../types/type-mismatch-error";

describe("fragment", () => {
// define a fragment:
Expand Down Expand Up @@ -219,4 +219,18 @@ describe("fragment", () => {
}>();
});
});

describe("fragment<any>", () => {
const anyFrag = q.fragment<any>().project({
foo: q.string(),
bar: q.number(),
});
type AnyFragType = InferFragmentType<typeof anyFrag>;
it("simple fragment should have the correct type", () => {
expectTypeOf<AnyFragType>().toEqualTypeOf<{
foo: string;
bar: number;
}>();
});
});
});
2 changes: 1 addition & 1 deletion packages/groqd/src/commands/fragment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { GroqBuilder } from "../groq-builder";
import { ExtractProjectionResult, ProjectionMap } from "./projection-types";
import { Fragment } from "../types/public-types";
import { ExtractDocumentTypes, QueryConfig } from "../types/schema-types";
import { RequireAFakeParameterIfThereAreTypeMismatchErrors } from "../types/utils";
import { RequireAFakeParameterIfThereAreTypeMismatchErrors } from "../types/type-mismatch-error";

declare module "../groq-builder" {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
Expand Down
173 changes: 149 additions & 24 deletions packages/groqd/src/commands/project.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { describe, expect, expectTypeOf, it } from "vitest";
import { SanitySchema, SchemaConfig } from "../tests/schemas/nextjs-sanity-fe";
import { InferResultType } from "../types/public-types";
import { Simplify, TypeMismatchError } from "../types/utils";
import { InferResultItem, InferResultType } from "../types/public-types";
import { Simplify } from "../types/utils";
import { TypeMismatchError } from "../types/type-mismatch-error";
import { createGroqBuilderWithZod } from "../index";
import { mock } from "../tests/mocks/nextjs-sanity-fe-mocks";
import { executeBuilder } from "../tests/mocks/executeQuery";
Expand Down Expand Up @@ -65,19 +66,27 @@ describe("project (object projections)", () => {
});

describe("a single plain property", () => {
it("cannot use 'true' to project unknown properties", () => {
it("cannot use 'true' or a parser to project unknown properties", () => {
// @ts-expect-error ---
const qInvalid = qVariants.project({
name: true,
INVALID: true,
INVALID_PARSER: q.string(),
});

expectTypeOf<InferResultType<typeof qInvalid>>().toEqualTypeOf<
Array<{
name: string;
INVALID: TypeMismatchError<{
error: `⛔️ 'true' can only be used for known properties ⛔️`;
expected: keyof SanitySchema.Variant;
actual: "INVALID";
}>;
INVALID_PARSER: TypeMismatchError<{
error: `⛔️ Parser can only be used with known properties ⛔️`;
expected: keyof SanitySchema.Variant;
actual: "INVALID_PARSER";
}>;
}>
>();
});
Expand Down Expand Up @@ -722,31 +731,147 @@ describe("project (object projections)", () => {
});

describe("ellipsis ... operator", () => {
const qEllipsis = qVariants.project((q) => ({
"...": true,
OTHER: q.field("name"),
}));
it("query should be correct", () => {
expect(qEllipsis.query).toMatchInlineSnapshot(
`"*[_type == "variant"] { ..., "OTHER": name }"`
);
describe("when set to 'true'", () => {
const qEllipsis = qVariants.project((sub) => ({
"...": true,
OTHER: sub.field("name"),
}));

it("query should be correct", () => {
expect(qEllipsis.query).toMatchInlineSnapshot(
`"*[_type == "variant"] { ..., "OTHER": name }"`
);
});

it("types should be correct", () => {
expectTypeOf<InferResultType<typeof qEllipsis>>().toEqualTypeOf<
Array<
Simplify<
SanitySchema.Variant & {
OTHER: string;
}
>
>
>();
});

it("should not need to introduce runtime parsing", () => {
expect(qEllipsis.parser).toBeNull();
});

it("should execute correctly", async () => {
const results = await executeBuilder(qEllipsis, data);
expect(results).toEqual(
data.variants.map((variant) => ({
...variant,
OTHER: variant.name,
}))
);
});
});

it("types should be correct", () => {
expectTypeOf<InferResultType<typeof qEllipsis>>().toEqualTypeOf<
Array<Simplify<SanitySchema.Variant & { OTHER: string }>>
>();
describe("when set to a parser", () => {
const qEllipsisWithParser = qVariants.project({
"...": q.object({
name: q.string(),
msrp: q.number(),
}),
price: q.number(),
});

it("a parser is created", () => {
expect(qEllipsisWithParser.parser).not.toBeNull();
});

it("executes correctly", async () => {
const results = await executeBuilder(qEllipsisWithParser, data);
expect(results).toEqual(
data.variants.map((variant) => ({
name: variant.name,
msrp: variant.msrp,
price: variant.price,
}))
);
});

it("should throw when the data doesn't match", async () => {
const invalidData = [
...data.datalake,
mock.variant({
// @ts-expect-error ---
msrp: "INVALID",
}),
];

await expect(() =>
executeBuilder(qEllipsisWithParser, { datalake: invalidData })
).rejects.toThrowErrorMatchingInlineSnapshot(`
[ValidationErrors: 1 Parsing Error:
result[5].msrp: Expected number, received string]
`);
});
});

it("should execute correctly", async () => {
const results = await executeBuilder(qEllipsis, data);
expect(results).toEqual(
data.variants.map((v) => {
// @ts-expect-error ---
v.OTHER = v.name;
return v;
})
);
describe("when other fields have parsers", () => {
const qEllipsis = qVariants.project((sub) => ({
"...": true,
VALIDATED: sub.field("name", q.string().toUpperCase()),
}));

it("query should be correct", () => {
expect(qEllipsis.query).toMatchInlineSnapshot(
`"*[_type == "variant"] { ..., "VALIDATED": name }"`
);
});

it("types should be correct", () => {
expectTypeOf<InferResultType<typeof qEllipsis>>().toEqualTypeOf<
Array<
Simplify<
SanitySchema.Variant & {
VALIDATED: string;
}
>
>
>();
});

it("should include runtime parsing", () => {
expect(qEllipsis.parser).not.toBeNull();
});

it("should execute correctly", async () => {
const results = await executeBuilder(qEllipsis, data);
expect(results).toEqual(
data.variants.map((variant) => ({
...variant,
VALIDATED: variant.name.toUpperCase(),
}))
);
});
});

describe("when fields are overridden", () => {
const overridden = qVariants.project((sub) => ({
"...": true,
style: sub.field("style[]").deref(),
}));

type Item = InferResultItem<typeof overridden>;

it("normal properties get passed through", () => {
// Properties like flavour should be included by "...":
expectTypeOf<Item["flavour"]>().toEqualTypeOf<
SanitySchema.Variant["flavour"]
>();
});
it("the overridden properties are correctly typed", () => {
type StyleReferences = SanitySchema.Variant["style"];
expectTypeOf<Item["style"]>().not.toEqualTypeOf<StyleReferences>();

type OverriddenStyle = SanitySchema.Style[] | null;
expectTypeOf<Item["style"]>().toEqualTypeOf<OverriddenStyle>();
});
});
});
});
28 changes: 21 additions & 7 deletions packages/groqd/src/commands/project.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,5 @@
import {
notNull,
RequireAFakeParameterIfThereAreTypeMismatchErrors,
Simplify,
} from "../types/utils";
import { notNull, Simplify } from "../types/utils";
import { RequireAFakeParameterIfThereAreTypeMismatchErrors } from "../types/type-mismatch-error";
import { GroqBuilder } from "../groq-builder";
import { Parser, ParserFunction } from "../types/public-types";
import { isParser, normalizeValidationFunction } from "./validate-utils";
Expand All @@ -17,6 +14,7 @@ import {
combineObjectParsers,
maybeArrayParser,
simpleObjectParser,
UnknownObjectParser,
} from "../validation/simple-validation";

declare module "../groq-builder" {
Expand Down Expand Up @@ -148,12 +146,23 @@ function createProjectionParser(
fields: NormalizedProjectionField[]
): ParserFunction | null {
if (!fields.some((f) => f.parser)) {
// No nested parsers!
// No parsers found for any keys
return null;
}

// Parse the ellipsis operator ("..."):
const ellipsisField = fields.find((f) => isEllipsis(f.key));
const ellipsisParser: UnknownObjectParser | null = ellipsisField
? // Allow a custom parser:
ellipsisField.parser ||
// Or just pass-through the whole object:
((obj) => obj)
: null;

// Parse all normal fields:
const normalFields = fields.filter((f) => !isConditional(f.key));
const normalFields = fields.filter(
(f) => !isEllipsis(f.key) && !isConditional(f.key)
);
const objectShape = Object.fromEntries(
normalFields.map((f) => [f.key, f.parser])
);
Expand All @@ -167,10 +176,15 @@ function createProjectionParser(

// Combine normal and conditional parsers:
const combinedParser = combineObjectParsers(
...[ellipsisParser].filter(notNull),
objectParser,
...conditionalParsers
);

// Finally, transparently handle arrays or objects:
return maybeArrayParser(combinedParser);
}

function isEllipsis(key: string) {
return key === "...";
}
14 changes: 8 additions & 6 deletions packages/groqd/src/commands/projection-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,14 @@ import {
Empty,
IsAny,
LiteralUnion,
Override,
Simplify,
SimplifyDeep,
StringKeys,
TypeMismatchError,
UndefinedToNull,
ValueOf,
} from "../types/utils";
import { TypeMismatchError } from "../types/type-mismatch-error";
import {
FragmentInputTypeTag,
IGroqBuilder,
Expand Down Expand Up @@ -69,14 +70,14 @@ export type ProjectionFieldConfig<TResultItem, TFieldType> =
// Use a GroqBuilder instance to create a nested projection
| IGroqBuilder;

export type ExtractProjectionResult<TResultItem, TProjectionMap> =
export type ExtractProjectionResult<TResultItem, TProjectionMap> = Override<
// Extract the "..." operator:
(TProjectionMap extends { "...": true } ? TResultItem : Empty) &
(TProjectionMap extends { "...": Parser<TResultItem, infer TOutput> }
? TOutput
: Empty) &
// Extract any conditional expressions:
ExtractConditionalProjectionTypes<TProjectionMap> &
: Empty),
// Extract any conditional expressions:
ExtractConditionalProjectionTypes<TProjectionMap> &
// Extract all the fields:
ExtractProjectionResultFields<
TResultItem,
Expand All @@ -85,7 +86,8 @@ export type ExtractProjectionResult<TResultItem, TProjectionMap> =
TProjectionMap,
"..." | typeof FragmentInputTypeTag | ConditionalKey<string>
>
>;
>
>;

type ExtractProjectionResultFields<TResultItem, TProjectionMap> = {
[P in keyof TProjectionMap]: TProjectionMap[P] extends IGroqBuilder<
Expand Down
2 changes: 1 addition & 1 deletion packages/groqd/src/types/schema-types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { TypeMismatchError } from "./utils";
import { TypeMismatchError } from "./type-mismatch-error";

export type QueryConfig = {
/**
Expand Down
Loading

0 comments on commit f03820f

Please sign in to comment.