Skip to content

Commit 4c81159

Browse files
authored
Feature: simplify Fragments (#385)
2 parents 3c4a93f + 328f1e0 commit 4c81159

File tree

12 files changed

+289
-58
lines changed

12 files changed

+289
-58
lines changed

.changeset/kind-lies-act.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"groqd": minor
3+
---
4+
5+
Fix: simplify Fragments types to eliminate `"Type instantiation is excessively deep and possibly infinite."` errors

.changeset/nine-balloons-add.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"groqd": patch
3+
---
4+
5+
Feature: reduce dependency on Zod to ensure wider compatibility between Zod versions

packages/groqd/src/commands/root/fragment.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import {
66
import { QueryConfig } from "../../types/query-config";
77
import { RequireAFakeParameterIfThereAreTypeMismatchErrors } from "../../types/type-mismatch-error";
88
import { ExtractDocumentTypes } from "../../types/document-types";
9-
import { Simplify } from "type-fest";
109
import { Fragment } from "../../types/fragment-types";
1110

1211
declare module "../../groq-builder" {
@@ -72,7 +71,7 @@ export type FragmentUtil<TQueryConfig extends QueryConfig, TFragmentInput> = {
7271
sub: GroqBuilderSubquery<TFragmentInput, TQueryConfig>
7372
) => TProjectionMap),
7473
...__projectionMapTypeMismatchErrors: RequireAFakeParameterIfThereAreTypeMismatchErrors<_TProjectionResult>
75-
): Fragment<Simplify<_TProjectionResult>, TFragmentInput, TProjectionMap>;
74+
): Fragment<TFragmentInput, TQueryConfig, TProjectionMap>;
7675
};
7776

7877
GroqBuilderRoot.implement({

packages/groqd/src/commands/subquery/conditional.test.ts

Lines changed: 195 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import { describe, expect, expectTypeOf, it } from "vitest";
2+
import { z } from "zod";
23
import { GroqBuilderBase, InferResultItem, InferResultType } from "../../index";
4+
import { executeBuilder } from "../../tests/mocks/executeQuery";
5+
import { mock } from "../../tests/mocks/nextjs-sanity-fe-mocks";
36
import { q } from "../../tests/schemas/nextjs-sanity-fe";
47
import { ExtractConditionalProjectionTypes } from "./conditional-types";
58
import { Empty, Simplify } from "../../types/utils";
@@ -37,7 +40,7 @@ describe("conditional", () => {
3740
});
3841
});
3942

40-
const qAll = qVariants.project((qV) => ({
43+
const qConditional = qVariants.project((qV) => ({
4144
name: true,
4245
...qV.conditional({
4346
"price == msrp": {
@@ -50,19 +53,17 @@ describe("conditional", () => {
5053
},
5154
}),
5255
}));
53-
5456
it("should be able to extract the return type", () => {
55-
expectTypeOf<InferResultType<typeof qAll>>().toEqualTypeOf<
57+
expectTypeOf<InferResultType<typeof qConditional>>().toEqualTypeOf<
5658
Array<
5759
| { name: string }
5860
| { name: string; onSale: false }
5961
| { name: string; onSale: true; price: number; msrp: number }
6062
>
6163
>();
6264
});
63-
6465
it("the query should look correct", () => {
65-
expect(qAll.query).toMatchInlineSnapshot(
66+
expect(qConditional.query).toMatchInlineSnapshot(
6667
`
6768
"*[_type == "variant"] {
6869
name,
@@ -238,47 +239,226 @@ describe("conditional", () => {
238239
});
239240
});
240241

242+
const data = mock.generateSeedData({
243+
variants: [
244+
//
245+
mock.variant({
246+
name: "Variant 1",
247+
price: 10,
248+
msrp: 10,
249+
}),
250+
mock.variant({ name: "Variant 2", price: 8, msrp: 9 }),
251+
],
252+
});
241253
describe("using query syntax", () => {
242-
const qAll = qVariants.project((q) => ({
254+
const qConditional = qVariants.project((q) => ({
243255
name: true,
244256
...q.conditional({
245257
"price == msrp": q.project({
246258
onSale: q.value(false),
259+
msrp: true,
247260
}),
248261
"price < msrp": (q) =>
249262
q.project({
250263
onSale: q.value(true),
251264
price: true,
252-
msrp: true,
253265
}),
254266
}),
255267
}));
256268
it("should have the correct expected type", () => {
257-
type Result = InferResultType<typeof qAll>;
269+
type Result = InferResultType<typeof qConditional>;
258270
type Expected = Array<
259271
| { name: string }
260-
| { name: string; onSale: false }
261-
| { name: string; onSale: true; price: number; msrp: number }
272+
| { name: string; onSale: false; msrp: number }
273+
| { name: string; onSale: true; price: number }
262274
>;
263275
expectTypeOf<Result>().toEqualTypeOf<Expected>();
264276
});
265277
it("should generate the correct query", () => {
266-
expect(qAll.query).toMatchInlineSnapshot(`
278+
expect(qConditional.query).toMatchInlineSnapshot(`
267279
"*[_type == "variant"] {
268280
name,
269281
price == msrp => {
270-
"onSale": false
282+
"onSale": false,
283+
msrp
271284
},
272285
price < msrp => {
273286
"onSale": true,
274-
price,
287+
price
288+
}
289+
}"
290+
`);
291+
});
292+
it("should execute correctly", async () => {
293+
const results = await executeBuilder(qConditional, data);
294+
expect(results).toMatchInlineSnapshot(`
295+
[
296+
{
297+
"msrp": 10,
298+
"name": "Variant 1",
299+
"onSale": false,
300+
},
301+
{
302+
"name": "Variant 2",
303+
"onSale": true,
304+
"price": 8,
305+
},
306+
]
307+
`);
308+
});
309+
});
310+
311+
describe("with validation", () => {
312+
const qConditional = qVariants.project((q) => ({
313+
name: z.string(),
314+
...q.conditional({
315+
"price == msrp": {
316+
onSale: q.value(false, z.literal(false)),
317+
msrp: z.number(),
318+
},
319+
"price < msrp": {
320+
onSale: q.value(true, z.literal(true)),
321+
price: z.number(),
322+
},
323+
}),
324+
}));
325+
it("should have the correct expected type", () => {
326+
type Result = InferResultType<typeof qConditional>;
327+
type Expected = Array<
328+
| { name: string }
329+
| { name: string; onSale: false; msrp: number }
330+
| { name: string; onSale: true; price: number }
331+
>;
332+
expectTypeOf<Result>().toEqualTypeOf<Expected>();
333+
});
334+
it("should generate the correct query", () => {
335+
expect(qConditional.query).toMatchInlineSnapshot(`
336+
"*[_type == "variant"] {
337+
name,
338+
price == msrp => {
339+
"onSale": false,
275340
msrp
341+
},
342+
price < msrp => {
343+
"onSale": true,
344+
price
276345
}
277346
}"
278347
`);
279348
});
280-
it("should execute correctly", () => {
281-
// (we actually already test this exact query in a previous test)
349+
it("should execute correctly", async () => {
350+
const results = await executeBuilder(qConditional, data);
351+
expect(results).toMatchInlineSnapshot(`
352+
[
353+
{
354+
"msrp": 10,
355+
"name": "Variant 1",
356+
"onSale": false,
357+
},
358+
{
359+
"name": "Variant 2",
360+
"onSale": true,
361+
"price": 8,
362+
},
363+
]
364+
`);
365+
});
366+
367+
const invalidData = mock.generateSeedData({
368+
variants: [
369+
mock.variant({ name: "Variant 1 (valid)", price: 10, msrp: 10 }),
370+
mock.variant({ name: "Variant 2 (valid)", price: 9, msrp: 10 }),
371+
mock.variant({ name: "Variant 3 (invalid)", price: 11, msrp: 10 }),
372+
// @ts-expect-error -- must be numbers
373+
mock.variant({ name: "Variant 4 (invalid)", price: "10", msrp: "10" }),
374+
// @ts-expect-error -- must be numbers
375+
mock.variant({ name: "Variant 5 (invalid)", price: "8", msrp: "9" }),
376+
],
377+
});
378+
describe("when the data is invalid", () => {
379+
it("should strip invalid fields", async () => {
380+
const result = await executeBuilder(qConditional, invalidData);
381+
expect(result).toMatchInlineSnapshot(`
382+
[
383+
{
384+
"msrp": 10,
385+
"name": "Variant 1 (valid)",
386+
"onSale": false,
387+
},
388+
{
389+
"name": "Variant 2 (valid)",
390+
"onSale": true,
391+
"price": 9,
392+
},
393+
{
394+
"name": "Variant 3 (invalid)",
395+
},
396+
{
397+
"name": "Variant 4 (invalid)",
398+
},
399+
{
400+
"name": "Variant 5 (invalid)",
401+
},
402+
]
403+
`);
404+
});
405+
});
406+
describe("when the isExhaustive flag is set", () => {
407+
const qConditional = qVariants.project((q) => ({
408+
name: z.string(),
409+
...q.conditional(
410+
{
411+
"price == msrp": {
412+
onSale: q.value(false, z.literal(false)),
413+
msrp: z.number(),
414+
},
415+
"price < msrp": {
416+
onSale: q.value(true, z.literal(true)),
417+
price: z.number(),
418+
},
419+
},
420+
{ isExhaustive: true }
421+
),
422+
}));
423+
it("should throw an error", async () => {
424+
await expect(async () => {
425+
return await executeBuilder(qConditional, invalidData);
426+
}).rejects.toThrowErrorMatchingInlineSnapshot(`
427+
[ValidationErrors: 3 Parsing Errors:
428+
result[2]: The data did not match any of the 2 conditional assertions
429+
result[3]: The data did not match any of the 2 conditional assertions
430+
result[4]: The data did not match any of the 2 conditional assertions]
431+
`);
432+
});
433+
});
434+
describe("when multiple conditions can be true", () => {
435+
const qConditional = qVariants.project((q) => ({
436+
name: z.string(),
437+
...q.conditional({
438+
"price < msrp": {
439+
price: z.number(),
440+
},
441+
"price <= msrp": {
442+
msrp: z.number(),
443+
},
444+
}),
445+
}));
446+
it("should include fields from both conditions", async () => {
447+
const results = await executeBuilder(qConditional, data);
448+
expect(results).toMatchInlineSnapshot(`
449+
[
450+
{
451+
"msrp": 10,
452+
"name": "Variant 1",
453+
},
454+
{
455+
"msrp": 9,
456+
"name": "Variant 2",
457+
"price": 8,
458+
},
459+
]
460+
`);
461+
});
282462
});
283463
});
284464
});

packages/groqd/src/commands/subquery/conditional.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -100,20 +100,24 @@ function createConditionalParserUnion(
100100
isExhaustive: boolean
101101
) {
102102
return function parserUnion(input: unknown) {
103+
let foundMatch = false;
104+
const result = {};
103105
for (const parser of parsers) {
104106
try {
105-
return parser(input);
107+
const parsed = parser(input);
108+
Object.assign(result, parsed);
109+
foundMatch = true;
106110
} catch (err) {
107111
// All errors are ignored,
108112
// since we never know if it errored due to invalid data,
109113
// or if it errored due to not meeting the conditional check.
110114
}
111115
}
112-
if (isExhaustive) {
116+
if (isExhaustive && !foundMatch) {
113117
throw new TypeError(
114118
`The data did not match any of the ${parsers.length} conditional assertions`
115119
);
116120
}
117-
return {};
121+
return result;
118122
};
119123
}

packages/groqd/src/groq-builder/groq-builder-types.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,11 +32,11 @@ export type IGroqBuilder<
3232
/**
3333
* The parser function that should be used to parse result data
3434
*/
35-
readonly parser: ParserFunction | null;
35+
readonly parser: ParserFunction<unknown, TResult> | null;
3636
/**
3737
* Parses and validates the query results, passing all data through the parsers.
3838
*/
39-
readonly parse: ParserFunction;
39+
readonly parse: ParserFunction<unknown, TResult>;
4040
};
4141

4242
export function isGroqBuilder(
@@ -70,3 +70,8 @@ export type InferResultType<TGroqBuilder extends IGroqBuilder<any>> =
7070
*/
7171
export type InferResultItem<TGroqBuilder extends IGroqBuilder<any>> =
7272
ResultItem.Infer<InferResultType<TGroqBuilder>>;
73+
74+
export type InferQueryConfig<TGroqBuilder extends IGroqBuilder<any>> =
75+
TGroqBuilder extends IGroqBuilder<any, infer TQueryConfig>
76+
? TQueryConfig
77+
: never;

0 commit comments

Comments
 (0)