Skip to content

Commit

Permalink
Merge pull request #434 from get-convex/ian/validate
Browse files Browse the repository at this point in the history
Ian/validate
  • Loading branch information
ianmacartney authored Feb 5, 2025
2 parents 43779d2 + 7b1275d commit fcc216b
Show file tree
Hide file tree
Showing 5 changed files with 414 additions and 11 deletions.
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

24 changes: 16 additions & 8 deletions packages/convex-helpers/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -600,20 +600,23 @@ await ctx.runMutation(internal.users.update, {
When using validators for defining database schema or function arguments,
these validators help:

1. Add a `Table` utility that defines a table and keeps references to the fields
to avoid re-defining validators. To learn more about sharing validators, read
[this article](https://stack.convex.dev/argument-validation-without-repetition),
an extension of [this article](https://stack.convex.dev/types-cookbook).
2. Add utilties for `partial`, `pick` and `omit` to match the TypeScript type
utilities.
3. Add shorthand for a union of `literals`, a `nullable` field, a `deprecated`
field, and `brandedString`. To learn more about branded strings see
1. Add shorthand for a union of `literals`, a `nullable` field, a `deprecated`
field, a `partial` object, and `brandedString`.
To learn more about branded strings see
[this article](https://stack.convex.dev/using-branded-types-in-validators).
2. A `validate(validator, data)` function validates a value against a validator.
Warning: this does not validate that the value of v.id is an ID for the given table.
3. Add utilties for `partial`, `pick` and `omit` to match the TypeScript type
utilities.
4. Add a `doc(schema, "tableName")` helper to validate a document with system
fields included.
5. Add a `typedV(schema)` helper that is a `v` replacement that also has:
- `doc("tableName")` that works like `doc` above.
- `id("tableName")` that is typed to tables in your schema.
6. Add a `Table` utility that defines a table and keeps references to the fields
to avoid re-defining validators. To learn more about sharing validators, read
[this article](https://stack.convex.dev/argument-validation-without-repetition),
an extension of [this article](https://stack.convex.dev/types-cookbook).

Example:

Expand Down Expand Up @@ -666,6 +669,11 @@ const balanceAndEmail = pick(vv.doc("accounts").fields, ["balance", "email"]);

// A validator for all the fields except balance.
const accountWithoutBalance = omit(vv.doc("accounts").fields, ["balance"]);

// Validate against a validator. Can optionally throw on error.
validate(balanceAndEmail, { balance: 123n, email: "[email protected]" });
// Warning: this only validates that `accountId` is a string.
validate(vv.id("accounts"), accountId);
```

## Filter
Expand Down
2 changes: 1 addition & 1 deletion packages/convex-helpers/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "convex-helpers",
"version": "0.1.68",
"version": "0.1.69-alpha.0",
"description": "A collection of useful code to complement the official convex package.",
"type": "module",
"bin": {
Expand Down
198 changes: 198 additions & 0 deletions packages/convex-helpers/server/validators.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { assert, Equals } from "../index.js";
import {
any,
array,
arrayBuffer,
bigint,
boolean,
brandedString,
Expand All @@ -20,6 +21,7 @@ import {
pretendRequired,
string,
typedV,
ValidationError,

Check failure on line 24 in packages/convex-helpers/server/validators.test.ts

View workflow job for this annotation

GitHub Actions / Test and lint

'ValidationError' is declared but its value is never read.
} from "../validators.js";
import { convexTest } from "convex-test";
import {
Expand All @@ -37,6 +39,7 @@ import { Infer, ObjectType } from "convex/values";
import { expect, test } from "vitest";
import { modules } from "./setup.test.js";
import { getOrThrow } from "convex-helpers/server/relationships";
import { validate } from "../validators.js";

export const testLiterals = internalQueryGeneric({
args: {
Expand Down Expand Up @@ -268,3 +271,198 @@ test("validators disallow things when they're wrong", async () => {
} as ExampleFields);
}).rejects.toThrowError("Validator error");
});

describe("validate", () => {
test("validates primitive validators", () => {
// String
expect(validate(string, "hello")).toBe(true);
expect(validate(string, 123)).toBe(false);
expect(validate(string, null)).toBe(false);

// Number
expect(validate(number, 123)).toBe(true);
expect(validate(number, "123")).toBe(false);
expect(validate(number, null)).toBe(false);

// Boolean
expect(validate(boolean, true)).toBe(true);
expect(validate(boolean, false)).toBe(true);
expect(validate(boolean, "true")).toBe(false);
expect(validate(boolean, 1)).toBe(false);

// Null
expect(validate(null_, null)).toBe(true);
expect(validate(null_, undefined)).toBe(false);
expect(validate(null_, false)).toBe(false);

// BigInt/Int64
expect(validate(bigint, 123n)).toBe(true);
expect(validate(bigint, 123)).toBe(false);
expect(validate(bigint, "123")).toBe(false);
});

test("validates array validator", () => {
const arrayOfStrings = array(string);
expect(validate(arrayOfStrings, ["a", "b", "c"])).toBe(true);
expect(validate(arrayOfStrings, [])).toBe(true);
expect(validate(arrayOfStrings, ["a", 1, "c"])).toBe(false);
expect(validate(arrayOfStrings, null)).toBe(false);
expect(validate(arrayOfStrings, "not an array")).toBe(false);
});

test("validates object validator", () => {
const personValidator = object({
name: string,
age: number,
optional: optional(string),
});

expect(validate(personValidator, { name: "Alice", age: 30 })).toBe(true);
expect(
validate(personValidator, { name: "Bob", age: 25, optional: "test" }),
).toBe(true);
expect(validate(personValidator, { name: "Charlie", age: "30" })).toBe(
false,
);
expect(validate(personValidator, { name: "Dave" })).toBe(false);
expect(validate(personValidator, null)).toBe(false);
expect(
validate(personValidator, { name: "Eve", age: 20, extra: "field" }),
).toBe(false);
});

test("validates union validator", () => {
const unionValidator = or(string, number, object({ type: is("test") }));

expect(validate(unionValidator, "string")).toBe(true);
expect(validate(unionValidator, 123)).toBe(true);
expect(validate(unionValidator, { type: "test" })).toBe(true);
expect(validate(unionValidator, { type: "wrong" })).toBe(false);
expect(validate(unionValidator, true)).toBe(false);
expect(validate(unionValidator, null)).toBe(false);
});

test("validates literal validator", () => {
const literalValidator = is("specific");
expect(validate(literalValidator, "specific")).toBe(true);
expect(validate(literalValidator, "other")).toBe(false);
expect(validate(literalValidator, null)).toBe(false);
});

test("validates optional values", () => {
const optionalString = optional(string);
expect(validate(optionalString, "value")).toBe(true);
expect(validate(optionalString, undefined)).toBe(true);
expect(validate(optionalString, null)).toBe(false);
expect(validate(optionalString, 123)).toBe(false);
});

test("throws validation errors when configured", () => {
expect(() => validate(string, 123, { throw: true })).toThrow(
"Validator error",
);

expect(() =>
validate(object({ name: string }), { name: 123 }, { throw: true }),
).toThrow("Validator error");

expect(() =>
validate(
object({ name: string }),
{ name: "valid", extra: true },
{ throw: true },
),
).toThrow("Validator error");
});

test("includes path in error messages", () => {
const complexValidator = object({
user: object({
details: object({
name: string,
}),
}),
});

try {
validate(
complexValidator,
{
user: {
details: {
name: 123,
},
},
},
{ throw: true },
);
fail("Should have thrown");
} catch (e: any) {
expect(e.message).toContain("user.details.name");
}
});

test("includes path for nested objects", () => {
const complexValidator = object({
user: object({
details: object({
name: string,
}),
}),
});
expect(
validate(complexValidator, { user: { details: { name: "Alice" } } }),
).toBe(true);
expect(
validate(complexValidator, { user: { details: { name: 123 } } }),
).toBe(false);
try {
validate(
complexValidator,
{ user: { details: { name: 123 } } },
{ throw: true },
);
fail("Should have thrown");
} catch (e: any) {
expect(e.message).toContain("user.details.name");
}
});

test("includes path for nested arrays", () => {
const complexValidator = object({
user: object({
details: array(string),
}),
});
expect(
validate(complexValidator, { user: { details: ["a", "b", "c"] } }),
).toBe(true);
expect(validate(complexValidator, { user: { details: [1, 2, 3] } })).toBe(
false,
);
try {
validate(
complexValidator,
{ user: { details: ["a", 3] } },
{ throw: true },
);
fail("Should have thrown");
} catch (e: any) {
expect(e.message).toContain("user.details[1]");
}
});

test("validates bytes/ArrayBuffer", () => {
const buffer = new ArrayBuffer(8);
expect(validate(arrayBuffer, buffer)).toBe(true);
expect(validate(arrayBuffer, new Uint8Array(8))).toBe(false);
expect(validate(arrayBuffer, "binary")).toBe(false);
});

test("validates any", () => {
expect(validate(any, "anything")).toBe(true);
expect(validate(any, 123)).toBe(true);
expect(validate(any, null)).toBe(true);
expect(validate(any, { complex: "object" })).toBe(true);
});
});
Loading

0 comments on commit fcc216b

Please sign in to comment.