Skip to content

Commit

Permalink
Merge pull request #427 from get-convex/doc-validator
Browse files Browse the repository at this point in the history
Add doc and typedV validator helpers
  • Loading branch information
ianmacartney authored Jan 31, 2025
2 parents 70df477 + e8b895a commit 1767492
Show file tree
Hide file tree
Showing 4 changed files with 222 additions and 8 deletions.
10 changes: 5 additions & 5 deletions package-lock.json

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

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-alpha.0",
"version": "0.1.68-alpha.1",
"description": "A collection of useful code to complement the official convex package.",
"type": "module",
"bin": {
Expand Down
101 changes: 99 additions & 2 deletions packages/convex-helpers/server/validators.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { assert, Equals } from "..";
import { assert, Equals } from "../index.js";
import {
any,
array,
bigint,
boolean,
brandedString,
deprecated,
doc,
id,
literal as is,
literals,
Expand All @@ -18,18 +19,24 @@ import {
pretend,
pretendRequired,
string,
} from "../validators";
typedV,
} from "../validators.js";
import { convexTest } from "convex-test";
import {
anyApi,
ApiFromModules,
DataModelFromSchemaDefinition,
defineSchema,
defineTable,
internalMutationGeneric,
internalQueryGeneric,
MutationBuilder,
QueryBuilder,
} from "convex/server";
import { Infer, ObjectType } from "convex/values";
import { expect, test } from "vitest";
import { modules } from "./setup.test.js";
import { getOrThrow } from "convex-helpers/server/relationships";

export const testLiterals = internalQueryGeneric({
args: {
Expand Down Expand Up @@ -110,14 +117,104 @@ const schema = defineSchema({
tokenIdentifier: string,
}),
kitchenSink: defineTable(ExampleFields),
unionTable: defineTable(or(object({ foo: string }), object({ bar: number }))),
});

const internalMutation = internalMutationGeneric as MutationBuilder<
DataModelFromSchemaDefinition<typeof schema>,
"internal"
>;
const internalQuery = internalQueryGeneric as QueryBuilder<
DataModelFromSchemaDefinition<typeof schema>,
"internal"
>;

export const toDoc = internalMutation({
args: {},
handler: async (ctx, args) => {
const kid = await ctx.db.insert("kitchenSink", valid);
const uid = await ctx.db.insert("unionTable", { foo: "" });

return {
sink: await getOrThrow(ctx, kid),
union: await getOrThrow(ctx, uid),
};
},
returns: object({
sink: doc(schema, "kitchenSink"),
union: doc(schema, "unionTable"),
}),
});

const vv = typedV(schema);

export const getSink = internalQuery({
args: { docId: vv.id("kitchenSink") },
returns: nullable(vv.doc("kitchenSink")),
handler: (ctx, args) => ctx.db.get(args.docId),
});

export const getUnion = internalQuery({
args: { docId: vv.id("unionTable") },
returns: nullable(vv.doc("unionTable")),
handler: (ctx, args) => ctx.db.get(args.docId),
});

const testApi: ApiFromModules<{
fns: {
echo: typeof echo;
toDoc: typeof toDoc;
getSink: typeof getSink;
getUnion: typeof getUnion;
};
}>["fns"] = anyApi["validators.test"] as any;

test("vv generates the right types for objects", async () => {
const t = convexTest(schema, modules);
const docId = await t.run((ctx) => ctx.db.insert("kitchenSink", valid));
const doc = await t.query(testApi.getSink, { docId });
expect(doc).toBeDefined();
expect(doc!._creationTime).toBeTypeOf("number");
});

test("vv generates the right types for unions", async () => {
const t = convexTest(schema, modules);
const docId = await t.run((ctx) =>
ctx.db.insert("unionTable", { foo: "foo" }),
);
const doc = await t.query(testApi.getUnion, { docId });
expect(doc).toBeDefined();
expect(doc!._creationTime).toBeTypeOf("number");
expect(doc!["foo"]).toBeDefined();
});

test("doc validator adds fields", async () => {
const t = convexTest(schema, modules);
await t.mutation(testApi.toDoc, {});
const userDoc = doc(schema, "users");
expect(userDoc.fields.tokenIdentifier).toBeDefined();
expect(userDoc.fields._id).toBeDefined();
expect(userDoc.fields._creationTime).toBeDefined();
const unionDoc = doc(schema, "unionTable");
expect(unionDoc.kind).toBe("union");
if (unionDoc.kind !== "union") {
throw new Error("Expected union");
}
expect(unionDoc.members[0]!.kind).toBe("object");
if (unionDoc.members[0]!.kind !== "object") {
throw new Error("Expected object");
}
expect(unionDoc.members[0]!.fields.foo).toBeDefined();
expect(unionDoc.members[0]!.fields._id).toBeDefined();
expect(unionDoc.members[0]!.fields._creationTime).toBeDefined();
if (unionDoc.members[1]!.kind !== "object") {
throw new Error("Expected object");
}
expect(unionDoc.members[1]!.fields.bar).toBeDefined();
expect(unionDoc.members[1]!.fields._id).toBeDefined();
expect(unionDoc.members[1]!.fields._creationTime).toBeDefined();
});

test("validators preserve things when they're set", async () => {
const t = convexTest(schema, modules);
const id = await t.run((ctx) => {
Expand Down
117 changes: 117 additions & 0 deletions packages/convex-helpers/validators.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,20 @@
import {
GenericValidator,
ObjectType,
PropertyValidators,
VObject,
VOptional,
VString,
VUnion,
Validator,
v,
} from "convex/values";
import { Expand } from "./index.js";
import {
DataModelFromSchemaDefinition,
SchemaDefinition,
TableNamesInDataModel,
} from "convex/server";

/**
* Helper for defining a union of literals more concisely.
Expand Down Expand Up @@ -106,6 +113,10 @@ export const systemFields = <TableName extends string>(
_creationTime: v.number(),
});

export type SystemFields<TableName extends string> = ReturnType<
typeof systemFields<TableName>
>;

/**
* Utility to add system fields to an object with fields mapping to validators.
* e.g. withSystemFields("users", { name: v.string() }) would return:
Expand All @@ -129,6 +140,112 @@ export const withSystemFields = <
} as Expand<T & typeof system>;
};

export type AddFieldsToValidator<
V extends Validator<any, any, any>,
Fields extends PropertyValidators,
> =
V extends VObject<infer T, infer F, infer O>
? VObject<Expand<T & ObjectType<Fields>>, Expand<F & Fields>, O>
: Validator<
Expand<V["type"] & ObjectType<Fields>>,
V["isOptional"],
V["fieldPaths"] &
{
[Property in keyof Fields & string]:
| `${Property}.${Fields[Property]["fieldPaths"]}`
| Property;
}[keyof Fields & string] &
string
>;

export const doc = <
Schema extends SchemaDefinition<any, boolean>,
TableName extends TableNamesInDataModel<
DataModelFromSchemaDefinition<Schema>
>,
>(
schema: Schema,
tableName: TableName,
): AddFieldsToValidator<
(typeof schema)["tables"][TableName]["validator"],
SystemFields<TableName>
> => {
function addSystemFields<V extends Validator<any, any, any>>(
validator: V,
): any {
if (validator.kind === "object") {
return v.object({
...validator.fields,
...systemFields(tableName),
});
}
if (validator.kind !== "union") {
throw new Error(
"Only object and union validators are supported for documents",
);
}
return v.union(...validator.members.map(addSystemFields));
}
return addSystemFields(schema.tables[tableName].validator);
};

/**
* Creates a validator with a type-safe `.id(table)` and a new `.doc(table)`.
* Can be used instead of `v` for function arugments & return validators.
* However, it cannot be used as part of defining a schema, since it would be
* circular.
* ```ts
* import schema from "./schema";
* export const vv = typedV(schema);
*
* export const myQuery = query({
* args: { docId: vv.id("mytable") },
* returns: vv.doc("mytable"),
* handler: (ctx, args) => ctx.db.get(args.docId),
* })
*
* @param schema Typically from `import schema from "./schema"`.
* @returns A validator like `v` with type-safe `v.id` and a new `v.doc`
*/
export function typedV<Schema extends SchemaDefinition<any, boolean>>(
schema: Schema,
) {
return {
...v,
/**
* Similar to v.id but is type-safe on the table name.
* @param tableName A table named in your schema.
* @returns A validator for an ID to the named table.
*/
id: <
TableName extends TableNamesInDataModel<
DataModelFromSchemaDefinition<Schema>
>,
>(
tableName: TableName,
) => v.id(tableName),
/**
* Generates a validator for a document, including system fields.
* To be used in validators when passing a full document in or out of a
* function.
* @param tableName A table named in your schema.
* @returns A validator that matches the schema validator, adding _id and
* _creationTime. If the validator was a union, it will update all documents
* recursively, but will currently lose the VUnion-specific type.
*/
doc: <
TableName extends TableNamesInDataModel<
DataModelFromSchemaDefinition<Schema>
>,
>(
tableName: TableName,
): AddFieldsToValidator<
(typeof schema)["tables"][TableName]["validator"],
SystemFields<TableName>
> => doc(schema, tableName),
};
}

/**
* A string validator that is a branded string type.
*
Expand Down

0 comments on commit 1767492

Please sign in to comment.