diff --git a/packages/convex-helpers/server/crud.test.ts b/packages/convex-helpers/server/crud.test.ts index 18552d3b..71c421fa 100644 --- a/packages/convex-helpers/server/crud.test.ts +++ b/packages/convex-helpers/server/crud.test.ts @@ -12,6 +12,7 @@ import { v } from "convex/values"; import { internalQueryGeneric, internalMutationGeneric } from "convex/server"; import { modules } from "./setup.test.js"; import { customCtx, customMutation, customQuery } from "./customFunctions.js"; +import { doc } from "../validators.js"; const ExampleFields = { foo: v.string(), @@ -20,9 +21,62 @@ const ExampleFields = { }; const CrudTable = "crud_example"; +// Union table test schema +const UnionTable = "union_example"; +const UnionFields = v.union( + v.object({ + type: v.literal("user"), + name: v.string(), + email: v.string(), + }), + v.object({ + type: v.literal("admin"), + name: v.string(), + permissions: v.array(v.string()), + }), + v.object({ + type: v.literal("guest"), + sessionId: v.string(), + }), +); + +// Complex object test schema +const ComplexTable = "complex_example"; +const ComplexFields = { + profile: v.object({ + name: v.string(), + age: v.optional(v.number()), + address: v.object({ + street: v.string(), + city: v.string(), + country: v.string(), + }), + }), + tags: v.array(v.string()), + metadata: v.record(v.string(), v.any()), + nested: v.object({ + level1: v.object({ + level2: v.object({ + deep: v.boolean(), + }), + }), + }), + optionalArray: v.optional( + v.array( + v.object({ + id: v.string(), + value: v.number(), + }), + ), + ), +}; + const schema = defineSchema({ [CrudTable]: defineTable(ExampleFields), + [UnionTable]: defineTable(UnionFields), + [ComplexTable]: defineTable(ComplexFields), }); + type DataModel = DataModelFromSchemaDefinition; const internalQuery = internalQueryGeneric as QueryBuilder< DataModel, @@ -38,6 +92,24 @@ export const { create, read, paginate, update, destroy } = crud( CrudTable, ); +// Union table CRUD +export const { + create: unionCreate, + read: unionRead, + paginate: unionPaginate, + update: unionUpdate, + destroy: unionDestroy, +} = crud(schema, UnionTable); + +// Complex object CRUD +export const { + create: complexCreate, + read: complexRead, + paginate: complexPaginate, + update: complexUpdate, + destroy: complexDestroy, +} = crud(schema, ComplexTable); + const testApi: ApiFromModules<{ fns: { create: typeof create; @@ -48,6 +120,26 @@ const testApi: ApiFromModules<{ }; }>["fns"] = anyApi["crud.test"] as any; +const unionTestApi: ApiFromModules<{ + fns: { + unionCreate: typeof unionCreate; + unionRead: typeof unionRead; + unionUpdate: typeof unionUpdate; + unionPaginate: typeof unionPaginate; + unionDestroy: typeof unionDestroy; + }; +}>["fns"] = anyApi["crud.test"] as any; + +const complexTestApi: ApiFromModules<{ + fns: { + complexCreate: typeof complexCreate; + complexRead: typeof complexRead; + complexUpdate: typeof complexUpdate; + complexPaginate: typeof complexPaginate; + complexDestroy: typeof complexDestroy; + }; +}>["fns"] = anyApi["crud.test"] as any; + test("crud for table", async () => { const t = convexTest(schema, modules); const doc = await t.mutation(testApi.create, { foo: "", bar: null }); @@ -67,6 +159,329 @@ test("crud for table", async () => { expect(await t.query(testApi.read, { id: doc._id })).toBe(null); }); +test("union table - user type", async () => { + const t = convexTest(schema, modules); + const userDoc = await t.mutation(unionTestApi.unionCreate, { + type: "user", + name: "John Doe", + email: "john@example.com", + }); + expect(userDoc).toMatchObject({ + type: "user", + name: "John Doe", + email: "john@example.com", + }); + + const readUser = await t.query(unionTestApi.unionRead, { id: userDoc._id }); + expect(readUser).toMatchObject(userDoc); + + await t.mutation(unionTestApi.unionUpdate, { + id: userDoc._id, + patch: { name: "Jane Doe", email: "jane@example.com" }, + }); + + const updatedUser = await t.query(unionTestApi.unionRead, { + id: userDoc._id, + }); + expect(updatedUser).toMatchObject({ + type: "user", + name: "Jane Doe", + email: "jane@example.com", + }); + + await t.mutation(unionTestApi.unionDestroy, { id: userDoc._id }); + expect(await t.query(unionTestApi.unionRead, { id: userDoc._id })).toBe(null); +}); + +test("union table - admin type", async () => { + const t = convexTest(schema, modules); + const adminDoc = await t.mutation(unionTestApi.unionCreate, { + type: "admin", + name: "Admin User", + permissions: ["read", "write", "delete"], + }); + expect(adminDoc).toMatchObject({ + type: "admin", + name: "Admin User", + permissions: ["read", "write", "delete"], + }); + + await t.mutation(unionTestApi.unionUpdate, { + id: adminDoc._id, + patch: { permissions: ["read", "write"] }, + }); + + const updatedAdmin = await t.query(unionTestApi.unionRead, { + id: adminDoc._id, + }); + expect(updatedAdmin).toMatchObject({ + type: "admin", + name: "Admin User", + permissions: ["read", "write"], + }); +}); + +test("union table - guest type", async () => { + const t = convexTest(schema, modules); + const guestDoc = await t.mutation(unionTestApi.unionCreate, { + type: "guest", + sessionId: "session_123", + }); + expect(guestDoc).toMatchObject({ + type: "guest", + sessionId: "session_123", + }); + + await t.mutation(unionTestApi.unionUpdate, { + id: guestDoc._id, + patch: { sessionId: "session_456" }, + }); + + const updatedGuest = await t.query(unionTestApi.unionRead, { + id: guestDoc._id, + }); + expect(updatedGuest).toMatchObject({ + type: "guest", + sessionId: "session_456", + }); +}); + +test("complex object - full structure", async () => { + const t = convexTest(schema, modules); + const complexDoc = await t.mutation(complexTestApi.complexCreate, { + profile: { + name: "Complex User", + age: 30, + address: { + street: "123 Main St", + city: "Anytown", + country: "USA", + }, + }, + tags: ["developer", "typescript", "react"], + metadata: { + theme: "dark", + language: "en", + timezone: "UTC", + }, + nested: { + level1: { + level2: { + deep: true, + }, + }, + }, + optionalArray: [ + { id: "item1", value: 100 }, + { id: "item2", value: 200 }, + ], + }); + + expect(complexDoc).toMatchObject({ + profile: { + name: "Complex User", + age: 30, + address: { + street: "123 Main St", + city: "Anytown", + country: "USA", + }, + }, + tags: ["developer", "typescript", "react"], + metadata: { + theme: "dark", + language: "en", + timezone: "UTC", + }, + nested: { + level1: { + level2: { + deep: true, + }, + }, + }, + optionalArray: [ + { id: "item1", value: 100 }, + { id: "item2", value: 200 }, + ], + }); + + const readComplex = await t.query(complexTestApi.complexRead, { + id: complexDoc._id, + }); + expect(readComplex).toMatchObject(complexDoc); +}); + +test("complex object - partial updates", async () => { + const t = convexTest(schema, modules); + const complexDoc = await t.mutation(complexTestApi.complexCreate, { + profile: { + name: "User", + address: { + street: "Old Street", + city: "Old City", + country: "Old Country", + }, + }, + tags: ["old-tag"], + metadata: { version: "1.0" }, + nested: { + level1: { + level2: { + deep: false, + }, + }, + }, + }); + + // Update nested address + await t.mutation(complexTestApi.complexUpdate, { + id: complexDoc._id, + patch: { + profile: { + name: "Updated User", + address: { + street: "New Street", + city: "New City", + country: "New Country", + }, + }, + }, + }); + + const updated = await t.query(complexTestApi.complexRead, { + id: complexDoc._id, + }); + expect(updated?.profile.address).toMatchObject({ + street: "New Street", + city: "New City", + country: "New Country", + }); + + + // Update array + await t.mutation(complexTestApi.complexUpdate, { + id: complexDoc._id, + patch: { + tags: ["new-tag", "another-tag"], + optionalArray: [{ id: "new-item", value: 999 }], + }, + }); + + const updated2 = await t.query(complexTestApi.complexRead, { + id: complexDoc._id, + }); + expect(updated2?.tags).toEqual(["new-tag", "another-tag"]); + expect(updated2?.optionalArray).toEqual([{ id: "new-item", value: 999 }]); +}); + +test("complex object - optional fields", async () => { + const t = convexTest(schema, modules); + const minimalDoc = await t.mutation(complexTestApi.complexCreate, { + profile: { + name: "Minimal User", + address: { + street: "Street", + city: "City", + country: "Country", + }, + }, + tags: [], + metadata: {}, + nested: { + level1: { + level2: { + deep: false, + }, + }, + }, + }); + + expect(minimalDoc).toMatchObject({ + profile: { + name: "Minimal User", + address: { + street: "Street", + city: "City", + country: "Country", + }, + }, + tags: [], + metadata: {}, + nested: { + level1: { + level2: { + deep: false, + }, + }, + }, + }); + + // optionalArray should be undefined + expect(minimalDoc.optionalArray).toBeUndefined(); + expect(minimalDoc.profile.age).toBeUndefined(); +}); + +test("pagination works", async () => { + const t = convexTest(schema, modules); + + // Create multiple documents + const docs: any[] = []; + for (let i = 0; i < 5; i++) { + const doc = await t.mutation(testApi.create, { + foo: `item-${i}`, + bar: { n: i }, + }); + docs.push(doc); + } + + // Test pagination + const page1 = await t.query(testApi.paginate, { + paginationOpts: { numItems: 3, cursor: null }, + }); + + expect(page1.page).toHaveLength(3); + expect(page1.isDone).toBe(false); + + const page2 = await t.query(testApi.paginate, { + paginationOpts: { numItems: 3, cursor: page1.continueCursor }, + }); + + expect(page2.page).toHaveLength(2); + expect(page2.isDone).toBe(true); +}); + +test("destroy returns the deleted document", async () => { + const t = convexTest(schema, modules); + const doc = await t.mutation(testApi.create, { + foo: "to-delete", + bar: null, + }); + + const deleted = await t.mutation(testApi.destroy, { id: doc._id }); + expect(deleted).not.toBe(null); + expect(deleted).toMatchObject({ foo: "to-delete", bar: null }); + + // Verify it's actually deleted + const read = await t.query(testApi.read, { id: doc._id }); + expect(read).toBe(null); +}); + +test("destroy non-existent document returns null", async () => { + const t = convexTest(schema, modules); + const doc = await t.mutation(testApi.create, { + foo: "temp", + bar: null, + }); + + // Delete it once + await t.mutation(testApi.destroy, { id: doc._id }); + + // Try to delete again + const deleted = await t.mutation(testApi.destroy, { id: doc._id }); + expect(deleted).toBe(null); +}); + /** * Custom function tests */ diff --git a/packages/convex-helpers/server/crud.ts b/packages/convex-helpers/server/crud.ts index 9bff5140..195f3b65 100644 --- a/packages/convex-helpers/server/crud.ts +++ b/packages/convex-helpers/server/crud.ts @@ -17,9 +17,13 @@ import { internalQueryGeneric, internalMutationGeneric, } from "convex/server"; -import type { GenericId, Infer } from "convex/values"; +import type { + GenericId, + Infer, + Validator, +} from "convex/values"; import { v } from "convex/values"; -import { partial } from "../validators.js"; +import { doc, partial } from "../validators.js"; /** * Create CRUD operations for a table. * You can expose these operations in your API. For example, in convex/users.ts: @@ -67,28 +71,43 @@ export function crud< > = internalMutationGeneric as any, ) { type DataModel = DataModelFromSchemaDefinition>; - const systemFields = { - _id: v.id(table), - _creationTime: v.number(), - }; - const validator = schema.tables[table]?.validator; + + const validator = schema.tables[table]?.validator if (!validator) { throw new Error( `Table ${table} not found in schema. Did you define it in defineSchema?`, ); } - if (validator.kind !== "object") { - throw new Error( - `CRUD only supports simple tables ${table} is a ${validator.type}`, - ); - } + + const systemFields = v.object({ + _id: v.id(table), + _creationTime: v.number(), + }); + + const partialSystemFields = partial(systemFields).fields; + + + const makeSystemFieldsOptional = ( + validator: Validator, + ): Validator => { + if (validator.kind === "object") { + return v.object({ + ...validator.fields, + ...partialSystemFields, + }) as any; + } else if (validator.kind === "union") { + return v.union( + ...validator.members.map((value) => makeSystemFieldsOptional(value) as any), + ) as any; + } else { + return validator; + } + }; + return { create: mutation({ - args: { - ...validator.fields, - ...partial(systemFields), - }, + args: makeSystemFieldsOptional(validator), handler: async (ctx, args) => { if ("_id" in args) delete args._id; if ("_creationTime" in args) delete args._creationTime; @@ -132,10 +151,7 @@ export function crud< id: v.id(table), // this could be partial(table.withSystemFields) but keeping // the api less coupled to Table - patch: v.object({ - ...partial(validator.fields), - ...partial(systemFields), - }), + patch: partial(v.union(doc(schema, table))) }, handler: async (ctx, args) => { await ctx.db.patch(