Skip to content
Permalink

Comparing changes

Choose two branches to see what’s changed or to start a new pull request. If you need to, you can also or learn more about diff comparisons.

Open a pull request

Create a new pull request by comparing changes across two branches. If you need to, you can also . Learn more about diff comparisons here.
base repository: FormidableLabs/groqd
Failed to load repositories. Confirm that selected base ref is valid, then try again.
Loading
base: groqd@1.1.0
Choose a base ref
...
head repository: FormidableLabs/groqd
Failed to load repositories. Confirm that selected head ref is valid, then try again.
Loading
compare: groqd@1.2.0
Choose a head ref
  • 3 commits
  • 23 files changed
  • 4 contributors

Commits on Feb 19, 2025

  1. Fix: always require [] when projecting arrays (#358)

    * feature(projections): always require `[]` when projecting arrays
    
    * changeset
    
    * feature(projections): added "filter inside projection" test
    
    ---------
    
    Co-authored-by: scottrippey <scott.william.rippey@gmail.com>
    scottrippey and scottrippey authored Feb 19, 2025

    Verified

    This commit was signed with the committer’s verified signature.
    IvanGoncharov Ivan Goncharov
    Copy the full SHA
    f332148 View commit details
  2. Feature: support count and coalesce methods (#354)

    * feature(count): extracted types to separate files
    
    * feature(count): implemented `count`
    
    * feature(coalesce): implemented `coalesce`
    
    * feature(coalesce): added parser support to `coalesce`
    
    * feature(coalesce): added execution tests for `coalesce`
    
    * feature(coalesce): added jsdocs
    
    * feature(count): added jsdocs
    
    * changeset
    
    * feature(count): reexport zod
    
    * feature(count): updated global tests
    
    * feature(projections): always require `[]` when projecting arrays
    
    * feature(coalesce): implemented runtime test
    
    ---------
    
    Co-authored-by: scottrippey <scott.william.rippey@gmail.com>
    scottrippey and scottrippey authored Feb 19, 2025
    Copy the full SHA
    260fa3c View commit details
  3. Version Packages (#359)

    Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
    github-actions[bot] and github-actions[bot] authored Feb 19, 2025
    Copy the full SHA
    78394d4 View commit details
16 changes: 16 additions & 0 deletions packages/groqd/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,21 @@
# groqd

## 1.2.0

### Minor Changes

- Feature: added `count` method ([#354](https://github.com/FormidableLabs/groqd/pull/354))

Feature: added `coalesce` method

### Patch Changes

- Fix: always require `[]` when projecting arrays. ([#358](https://github.com/FormidableLabs/groqd/pull/358))

- Prevents issues with chaining `.deref()` and `.field()`
- Makes code clearer
- Reduces noise in auto-complete suggestions

## 1.1.0

### Minor Changes
2 changes: 1 addition & 1 deletion packages/groqd/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "groqd",
"description": "GroqD is a GROQ query builder, designed to give the best GROQ developer experience possible, with the flexibility of GROQ, the runtime safety of Zod, and provides schema-aware auto-completion and type-checking.",
"version": "1.1.0",
"version": "1.2.0",
"license": "MIT",
"author": {
"name": "Formidable",
19 changes: 19 additions & 0 deletions packages/groqd/src/commands/filter.test.ts
Original file line number Diff line number Diff line change
@@ -257,4 +257,23 @@ describe("filterBy", () => {
`);
});
});

describe("when used in a projection", () => {
type VariantImage = NonNullable<SanitySchema.Variant["images"]>[number];
const query = q.star.filterByType("variant").project((variant) => ({
images: variant.field("images[]").filterBy("asset == null"),
}));
it("should generate a valid query", () => {
expect(query.query).toMatchInlineSnapshot(`
"*[_type == "variant"] {
"images": images[][asset == null]
}"
`);
});
it("should have a valid result type", () => {
expectTypeOf<InferResultItem<typeof query>>().toEqualTypeOf<{
images: null | Array<VariantImage>;
}>();
});
});
});
2 changes: 2 additions & 0 deletions packages/groqd/src/commands/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import "./root/coalesce";
import "./root/count";
import "./root/fragment";
import "./root/parameters";
import "./root/star";
3 changes: 1 addition & 2 deletions packages/groqd/src/commands/order.ts
Original file line number Diff line number Diff line change
@@ -18,7 +18,6 @@ declare module "../groq-builder" {

GroqBuilder.implement({
order(this: GroqBuilder, ...fields) {
const query = ` | order(${fields.join(", ")})`;
return this.pipe(query);
return this.pipe(` | order(${fields.join(", ")})`);
},
});
49 changes: 49 additions & 0 deletions packages/groqd/src/commands/root/coalesce-types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
// eslint-disable-next-line @typescript-eslint/no-namespace
import { ProjectionPathEntries } from "../../types/projection-paths";
import { IGroqBuilder } from "../../types/public-types";

// eslint-disable-next-line @typescript-eslint/no-namespace
export namespace CoalesceExpressions {
/**
* Represents the array of args that can be
* passed to the `coalesce` method
*/
export type CoalesceArgs<TResult> = ArrayMin2<CoalesceArg<TResult>>;

type ArrayMin2<T> = [T, T, ...Array<T>];

type CoalesceArg<TResult> =
| keyof ProjectionPathEntries<TResult>
| IGroqBuilder;

/**
* Extracts the result type from the `coalesce` method
*/
export type CoalesceResult<
TResult,
TExpressions extends CoalesceArgs<TResult>
> = CoalesceValues<TResult, TExpressions> extends [
...Array<infer TNullableValues>,
infer TFinalValue
]
? NonNullable<TNullableValues> | TFinalValue
: never;

type CoalesceValues<
TResult,
TExpressions extends CoalesceArgs<TResult>,
_PathEntries = ProjectionPathEntries<TResult>
> = {
[Index in keyof TExpressions]: CoalesceExpressionValue<
_PathEntries,
TExpressions[Index]
>;
};

type CoalesceExpressionValue<PathEntries, TExpression> =
TExpression extends IGroqBuilder<infer ExpressionResult>
? ExpressionResult
: TExpression extends keyof PathEntries
? PathEntries[TExpression]
: never; // (unreachable)
}
213 changes: 213 additions & 0 deletions packages/groqd/src/commands/root/coalesce.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
import { describe, it, expect, expectTypeOf } from "vitest";
import { q, SanitySchema, zod } from "../../tests/schemas/nextjs-sanity-fe";
import { InferResultItem, InferResultType } from "../../types/public-types";
import { executeBuilder } from "../../tests/mocks/executeQuery";
import { mock } from "../../tests/mocks/nextjs-sanity-fe-mocks";

describe("coalesce", () => {
describe("as a root-level query", () => {
describe("should have the correct type", () => {
it("with literal values", () => {
const qAB = q.coalesce(q.value("A"), q.value("B"));
expectTypeOf<InferResultType<typeof qAB>>().toEqualTypeOf<"A" | "B">();

const qABC = q.coalesce(q.value("A"), q.value("B"), q.value("C"));
expectTypeOf<InferResultType<typeof qABC>>().toEqualTypeOf<
"A" | "B" | "C"
>();

const qA_B = q.coalesce(q.value("A").nullable(), q.value("B"));
expectTypeOf<InferResultType<typeof qA_B>>().toEqualTypeOf<"A" | "B">();

const qA_B_C = q.coalesce(
q.value("A").nullable(),
q.value("B").nullable(),
q.value("C")
);
expectTypeOf<InferResultType<typeof qA_B_C>>().toEqualTypeOf<
"A" | "B" | "C"
>();

const qA_B_C_ = q.coalesce(
q.value("A").nullable(),
q.value("B").nullable(),
q.value("C").nullable()
);
expectTypeOf<InferResultType<typeof qA_B_C_>>().toEqualTypeOf<
"A" | "B" | "C" | null
>();

const qAB_ = q.coalesce(q.value("A"), q.value("B").nullable());
expectTypeOf<InferResultType<typeof qAB_>>().toEqualTypeOf<
"A" | "B" | null
>();
});
});
it("should execute correctly", async () => {
const noDataNecessary = { datalake: [] };

const queryA = q.coalesce(q.value<"A" | null>("A"), q.value("B"));
const resultA = await executeBuilder(queryA, noDataNecessary);
expect(resultA).toEqual("A");

const queryB = q.coalesce(q.value<"A" | null>(null), q.value("B"));
const resultB = await executeBuilder(queryB, noDataNecessary);
expect(resultB).toEqual("B");
});
});

describe("in a projection", () => {
const qVariants = q.star.filterByType("variant");
it("can use a projection string", () => {
const qTests = qVariants.project((sub) => ({
id_name: sub.coalesce("id", "name"),
name_id: sub.coalesce("name", "id"),
name_price: sub.coalesce("name", "price"),
id_price: sub.coalesce("id", "price"),
name_id_price: sub.coalesce("name", "id", "price"),
}));
expectTypeOf<InferResultItem<typeof qTests>>().toEqualTypeOf<{
id_name: string;
name_id: string | null;
name_price: number | string;
id_price: string | number;
name_id_price: string | number;
}>();
});
it("can use any nested groq expression", () => {
const qTests = qVariants.project((sub) => ({
flavour_style: sub.coalesce(
sub.field("flavour[]").deref(),
sub.field("style[]").deref()
),
}));
expectTypeOf<InferResultItem<typeof qTests>>().toEqualTypeOf<{
flavour_style:
| Array<SanitySchema.Flavour>
| Array<SanitySchema.Style>
| null;
}>();
});

const data = mock.generateSeedData({
variants: [
mock.variant({ id: undefined, _id: "A" }),
mock.variant({ id: undefined, _id: "B" }),
mock.variant({ id: "C", _id: "FOO" }),
mock.variant({ id: undefined, _id: undefined }),
],
});
it("executes correctly", async () => {
const query = qVariants.project((v) => ({
coalesceTest: v.coalesce("id", "_id"),
}));
const results = await executeBuilder(query, data);
expect(results).toMatchInlineSnapshot(`
[
{
"coalesceTest": "A",
},
{
"coalesceTest": "B",
},
{
"coalesceTest": "C",
},
{
"coalesceTest": null,
},
]
`);
});
it("executes correctly with validation", async () => {
const query = qVariants.project((v) => ({
coalesceTest: v.coalesce(
v.field("id", zod.string().nullable()),
v.field("_id", zod.string().nullable()),
q.value("DEFAULT", zod.literal("DEFAULT"))
),
}));
const results = await executeBuilder(query, data);
expect(results).toMatchInlineSnapshot(`
[
{
"coalesceTest": "A",
},
{
"coalesceTest": "B",
},
{
"coalesceTest": "C",
},
{
"coalesceTest": "DEFAULT",
},
]
`);

const invalidData = mock.generateSeedData({
variants: [
mock.variant({
// @ts-expect-error ---
id: 55,
}),
mock.variant({
// @ts-expect-error ---
_id: 66,
}),
],
});
await expect(() => executeBuilder(query, invalidData)).rejects
.toThrowErrorMatchingInlineSnapshot(`
[ValidationErrors: 4 Parsing Errors:
result[0].coalesceTest: Expected string, received number
result[0].coalesceTest: Expected string, received number
result[0].coalesceTest: Invalid literal value, expected "DEFAULT"
result[0].coalesceTest: Expected the value to match one of the values above, but got: 55]
`);
});
});

describe("with validation", () => {
const valueA = q.value("A", q.literal("A"));
const valueB = q.value("B", q.literal("B"));
const valueANull = q.value("A", q.literal("A").nullable());

describe("when all expressions include validation", () => {
const query = q.coalesce(valueANull, valueA, valueB);
it("should allow all expressions to include validation", () => {
expect(query.parser).toBeDefined();
});
it("should parse valid inputs without problem", () => {
query.parse("A");
query.parse("B");
query.parse(null);
});
it("should throw errors when the input is invalid", () => {
expect(() => {
query.parse("INVALID");
}).toThrowErrorMatchingInlineSnapshot(`
[ValidationErrors: 4 Parsing Errors:
result: Invalid literal value, expected "A"
result: Invalid literal value, expected "A"
result: Invalid literal value, expected "B"
result: Expected the value to match one of the values above, but got: "INVALID"]
`);
});
});
describe("when only some expressions include validation", () => {
it("should throw an InvalidQueryError", () => {
expect(() => {
q.coalesce(valueA, q.value("NOT VALIDATED"));
}).toThrowErrorMatchingInlineSnapshot(
`[Error: [COALESCE_MISSING_VALIDATION] With 'coalesce', you must supply validation for either all, or none, of the expressions. You did not supply validation for ""NOT VALIDATED""]`
);
expect(() => {
q.coalesce(valueA, q.value("NOT VALIDATED"), valueB, q.value("SAME"));
}).toThrowErrorMatchingInlineSnapshot(
`[Error: [COALESCE_MISSING_VALIDATION] With 'coalesce', you must supply validation for either all, or none, of the expressions. You did not supply validation for ""NOT VALIDATED"" or ""SAME""]`
);
});
});
});
});
Loading