Skip to content

Commit

Permalink
Feature: notNull (#331)
Browse files Browse the repository at this point in the history
* chore: removed unused files

* feat(notNull): added notNull helper

* feat(notNull): changeset

* feat(notNull): changed `slice(#)` to be nullable; use notNull to fix

* feat(notNull): added MISSING_PARSER detection

* feat(notNull): added validationRequired to `.field`

* feat(notNull): revert last commit

* feat(notNull): added docs around CHAINED_PARSER_ERROR

* feat(notNull): ensure `notNull` and `nullable` come last in a chain

* feat(notNull): ensure `notNull` and `nullable` come last in a chain

* feat(notNull): updated changeset

* feat(notNull): extracted "as" helper into its own file, and updated docs

* feat(notNull): improved docs for the non-chainability of `notNull / nullable` and introduced `IGroqBuilderNotChainable`

* feat(notNull): added examples to docs

* feat(notNull): renamed to `InvalidQueryError`

* feat(notNull): changeset

---------

Co-authored-by: scottrippey <[email protected]>
  • Loading branch information
scottrippey and scottrippey authored Feb 3, 2025
1 parent d2dab36 commit 74d0628
Show file tree
Hide file tree
Showing 26 changed files with 427 additions and 563 deletions.
11 changes: 11 additions & 0 deletions .changeset/chilly-stingrays-unite.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"groqd": patch
---

Fixed: `.slice(number)` returns a nullable result
Added: `.notNull()` utility
Updated: `.nullable()` utility to not be chainable
Updated: require a `redundant: true` parameter for `nullable` and `notNull`
Added: `InvalidQueryError` is thrown for all invalid queries detected

Fixes #268
42 changes: 42 additions & 0 deletions packages/groqd/src/commands/as.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { GroqBuilder } from "../groq-builder";
import { ExtractDocumentTypes } from "../types/schema-types";

declare module "../groq-builder" {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export interface GroqBuilder<TResult, TQueryConfig> {
/**
* Overrides the result type to anything you specify.
*
* Use this carefully, since it's essentially "lying" to TypeScript, and there's no runtime validation.
*
* @example
* q.star.filter("slug.current == $productSlug").as<Product>()...
*
*/
as<TResultNew>(): GroqBuilder<TResultNew, TQueryConfig>;

/**
* Overrides the result type to a specific document type.
*
* Use this carefully, since it's essentially "lying" to TypeScript, and there's no runtime validation.
*
* @example
* q.star.filter("slug.current == $productSlug").asType<"product">()...
*/
asType<
_type extends ExtractDocumentTypes<TQueryConfig["schemaTypes"]>
>(): GroqBuilder<
Extract<TQueryConfig["schemaTypes"], { _type: _type }>,
TQueryConfig
>;
}
}

GroqBuilder.implement({
as(this: GroqBuilder) {
return this;
},
asType(this: GroqBuilder<never>) {
return this;
},
});
24 changes: 11 additions & 13 deletions packages/groqd/src/commands/deref.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, expectTypeOf, it } from "vitest";
import { InferResultType } from "../types/public-types";
import { InferResultItem, InferResultType } from "../types/public-types";
import { executeBuilder } from "../tests/mocks/executeQuery";
import { mock } from "../tests/mocks/nextjs-sanity-fe-mocks";
import { q, SanitySchema } from "../tests/schemas/nextjs-sanity-fe";
Expand All @@ -15,17 +15,17 @@ describe("deref", () => {

it("should deref a single item", () => {
expectTypeOf<
InferResultType<typeof qCategory>
>().toEqualTypeOf<SanitySchema.Category | null>();
InferResultItem<typeof qCategory>
>().toEqualTypeOf<SanitySchema.Category>();
expect(qCategory.query).toMatchInlineSnapshot(
`"*[_type == "product"][0].categories[][0]->"`
);
});

it("should deref an array of items", () => {
expectTypeOf<
InferResultType<typeof qVariants>
>().toEqualTypeOf<Array<SanitySchema.Variant> | null>();
expectTypeOf<InferResultType<typeof qVariants>>().toEqualTypeOf<
SanitySchema.Variant[] | null
>();
expect(qVariants.query).toMatchInlineSnapshot(
`"*[_type == "product"][0].variants[]->"`
);
Expand All @@ -34,11 +34,11 @@ describe("deref", () => {
it("should be an error if the item is not a reference", () => {
const notAReference = qProduct.field("slug");
expectTypeOf<
InferResultType<typeof notAReference>
InferResultItem<typeof notAReference>
>().toEqualTypeOf<SanitySchema.Slug>();

const res = notAReference.deref();
type ErrorResult = InferResultType<typeof res>;
type ErrorResult = InferResultItem<typeof res>;
expectTypeOf<
ErrorResult["error"]
>().toEqualTypeOf<"⛔️ Expected the object to be a reference type ⛔️">();
Expand All @@ -59,11 +59,9 @@ describe("deref", () => {
categories: sub.field("categories[]").deref(),
}));

expectTypeOf<InferResultType<typeof query>>().toEqualTypeOf<
Array<{
categories: SanitySchema.Category[] | null;
}>
>();
expectTypeOf<InferResultItem<typeof query>>().toEqualTypeOf<{
categories: SanitySchema.Category[] | null;
}>();
});
});
});
14 changes: 12 additions & 2 deletions packages/groqd/src/commands/filter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,8 +203,18 @@ describe("filterBy", () => {
const data = mock.generateSeedData({
extraData: [flavourNoName, flavourWithName],
variants: [
mock.variant({}, { flavour: [flavourNoName, flavourNoName] }),
mock.variant({}, { flavour: [flavourWithName, flavourWithName] }),
mock.variant({
flavour: [
mock.reference(flavourNoName),
mock.reference(flavourNoName),
],
}),
mock.variant({
flavour: [
mock.reference(flavourWithName),
mock.reference(flavourWithName),
],
}),
],
});
const results = await executeBuilder(qFilterAfterDeref, data);
Expand Down
2 changes: 1 addition & 1 deletion packages/groqd/src/commands/filter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ declare module "../groq-builder" {
GroqBuilder.implement({
filter(this: GroqBuilder, filterExpression) {
const needsWrap = this.query.endsWith("->");
const self = needsWrap ? this.wrap("(", ")") : this;
const self = needsWrap ? this.extend({ query: `(${this.query})` }) : this;
return self.chain(`[${filterExpression}]`);
},
filterBy(this: GroqBuilder, filterExpression) {
Expand Down
2 changes: 2 additions & 0 deletions packages/groqd/src/commands/index.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import "./as";
import "./conditional";
import "./conditionalByType";
import "./deref";
import "./filter";
import "./filterByType";
import "./fragment";
import "./grab-deprecated";
import "./notNull";
import "./nullable";
import "./order";
import "./parameters";
Expand Down
96 changes: 96 additions & 0 deletions packages/groqd/src/commands/notNull.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { describe, it, expectTypeOf, expect } from "vitest";
import { q } from "../tests/schemas/nextjs-sanity-fe";
import { mock } from "../tests/mocks/nextjs-sanity-fe-mocks";
import { InferResultItem } from "../types/public-types";
import { executeBuilder } from "../tests/mocks/executeQuery";

describe("notNull", () => {
const category = mock.category({});
const data = mock.generateSeedData({
products: [
mock.product({ categories: [mock.reference(category)] }),
mock.product({ categories: [mock.reference(category)] }),
],
categories: [category],
});
const dataWithNulls = mock.generateSeedData({
products: [
mock.product({ categories: [mock.reference(category)] }),
mock.product({ categories: undefined }),
],
categories: [category],
});

describe("when used on nullable fields", () => {
const qVariants = q.star.filterByType("product").project((sub) => ({
categories: sub.field("categories[]").deref().field("name"),
categoriesNotNull: sub
.field("categories[]")
.deref()
.field("name")
.notNull(),
}));
type Result = InferResultItem<typeof qVariants>;

it("should have the correct type", () => {
expectTypeOf<Result["categories"]>().toEqualTypeOf<string[] | null>();
expectTypeOf<Result["categoriesNotNull"]>().toEqualTypeOf<string[]>();
});

it("should not affect the query", () => {
expect(qVariants.query).toMatchInlineSnapshot(`
"*[_type == "product"] {
"categories": categories[]->.name,
"categoriesNotNull": categories[]->.name
}"
`);
});

it("should execute correctly when there are no nulls", async () => {
const results = await executeBuilder(qVariants, data);
expect(results).toMatchInlineSnapshot(`
[
{
"categories": [
"Category Name",
],
"categoriesNotNull": [
"Category Name",
],
},
{
"categories": [
"Category Name",
],
"categoriesNotNull": [
"Category Name",
],
},
]
`);
});

it("should throw errors when finding a null value", async () => {
await expect(() => executeBuilder(qVariants, dataWithNulls)).rejects
.toThrowErrorMatchingInlineSnapshot(`
[ValidationErrors: 1 Parsing Error:
result[1].categoriesNotNull: Expected a non-null value]
`);
});
});

describe("when it's redundant", () => {
it("should give a typescript error", () => {
q.star.filterByType("flavour").project((sub) => ({
test: sub
.field("_id")
// You must pass `true` to acknowledge the redundancy:
.notNull(true),
test2: sub
.field("_id")
// @ts-expect-error --- otherwise it will error:
.notNull(),
}));
});
});
});
58 changes: 58 additions & 0 deletions packages/groqd/src/commands/notNull.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { GroqBuilder } from "../groq-builder";
import { QueryConfig } from "../types/schema-types";
import { ResultUtils } from "../types/result-types";
import { Override } from "../types/utils";
import { IGroqBuilderNotChainable } from "../types/public-types";

declare module "../groq-builder" {
export interface GroqBuilder<TResult, TQueryConfig> {
/**
* Asserts that the results are NOT nullable.
* Useful when you know there must be a value,
* even though the query thinks it might be null.
*
* ⚠️ NOTE: This method can only be used at the end of a query chain,
* because you cannot chain more commands after making an assertion.
* See CHAINED_ASSERTION_ERROR for more details.
*
* @example
* q.star
* .filter("slug.current == $slug")
* .slice(0) // <- this return type is nullable, even though we expect there will be a match
* .project({ name: q.string() })
* .notNull() // <- this ensures that the results are not null
*
* @example
* q.star.filterByType("product").project(sub => ({
* categories: sub.field("categories[]") // <- nullable array
* .deref()
* .field("name", q.string())
* .notNull()
* }));
*
* @param redundant - If the type is already not-nullable, then you must explicitly pass `.notNull(true)` to allow this redundancy. (This has no impact at runtime)
*/
notNull(
...redundant: ResultUtils.IsNullable<TResult> extends true ? [] : [true]
): IGroqBuilderNotChainable<
ResultUtils.Wrap<
Override<ResultUtils.Unwrap<TResult>, { IsNullable: false }>
>,
TQueryConfig
>;
}
}

GroqBuilder.implement({
notNull(this: GroqBuilder<any, QueryConfig>, ..._redundant) {
const parser = this.parser;
return this.extend({
parser: (input) => {
if (input === null) {
throw new TypeError("Expected a non-null value");
}
return parser ? parser(input) : input;
},
});
},
});
15 changes: 15 additions & 0 deletions packages/groqd/src/commands/nullable.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,4 +105,19 @@ describe("nullable", () => {
`);
});
});

describe("when it's redundant", () => {
it("should give a typescript error", () => {
q.star.filterByType("flavour").project((sub) => ({
name: sub
.field("name")
// You must pass `true` to acknowledge the redundancy:
.nullable(true),
name2: sub
.field("name")
// @ts-expect-error --- otherwise it will error:
.nullable(),
}));
});
});
});
29 changes: 25 additions & 4 deletions packages/groqd/src/commands/nullable.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,47 @@
import { GroqBuilder } from "../groq-builder";
import { QueryConfig } from "../types/schema-types";
import { ResultUtils } from "../types/result-types";
import { IGroqBuilderNotChainable } from "../types/public-types";

declare module "../groq-builder" {
export interface GroqBuilder<TResult, TQueryConfig> {
/**
* Marks a query as nullable – in case you are expecting a potential `null` value.
* Useful when you expect missing values in your data,
* even though the query thinks it's required.
*
* ⚠️ NOTE: This method can only be used at the end of a query chain,
* because you cannot chain more commands after making an assertion.
* See CHAINED_ASSERTION_ERROR for more details.
*
* @param redundant - If the type is already nullable, then you must explicitly pass `.nullable(true)` to allow this redundancy. (this has no impact at runtime)
*
* @example
* q.star.filterByType("product").project(sub => ({
* // In our schema, "category" is required, but we know
* // that we have old entries that are missing this field:
* category: sub.field("category").nullable(),
* });
*/
nullable(): GroqBuilder<TResult | null, TQueryConfig>;
nullable(
...redundant: ResultUtils.IsNullable<TResult> extends true ? [true] : []
): IGroqBuilderNotChainable<TResult | null, TQueryConfig>;
}
}

GroqBuilder.implement({
nullable(this: GroqBuilder<any, QueryConfig>) {
nullable(this: GroqBuilder<any, QueryConfig>, _redundant) {
const parser = this.parser;

if (!parser) {
// If there's no previous parser, then this method is just used
// for type-safety, and we don't need to perform runtime validation:
return this;
}
return this.chain("", (input) => {
return input === null ? null : parser(input);
return this.extend({
parser: (input) => {
return input === null || input === undefined ? null : parser(input);
},
});
},
});
Loading

0 comments on commit 74d0628

Please sign in to comment.