import { type Expression, type FilterBuilder, type GenericTableInfo, mutationGeneric, queryGeneric, } from "convex/server"; import { ConvexError, type GenericId, v } from "convex/values"; import { type Condition, ConditionType } from "../query/condition-builder"; import { type AnyColumn, type AnySchema, type AnyTable, Column, } from "../schema"; import { deserializeSelect, deserializeWhere } from "./deserialize"; import { serializedSelect, serializedWhere } from "./serialize"; const mutationArgs = v.object({ tableName: v.string(), secret: v.string(), action: v.union( v.object({ type: v.literal("create"), data: v.array(v.record(v.string(), v.any())), returning: v.boolean(), }), v.object({ type: v.literal("update"), where: v.optional(v.any()), set: v.record(v.string(), v.any()), }), v.object({ type: v.literal("delete"), where: v.optional(v.any()), }), v.object({ type: v.literal("upsert"), where: v.any(), create: v.record(v.string(), v.any()), update: v.record(v.string(), v.any()), }) ), }); const queryArgs = v.object({ tableName: v.string(), secret: v.string(), query: v.union( v.object({ type: v.literal("find"), where: v.optional(v.any()), select: v.any(), limit: v.optional(v.number()), offset: v.optional(v.number()), }), v.object({ type: v.literal("count"), where: v.optional(v.any()), }) ), }); enum ValuesMode { Insert, Update, } function mapValues( mode: ValuesMode, values: Record, table: AnyTable ): Record { const out: Record = {}; for (const k in table.columns) { if (mode === ValuesMode.Update && values[k] === undefined) continue; out[k] = values[k] ?? null; } return out; } class DeferredFilter { filter: (record: Record) => boolean; constructor(filter: (record: Record) => boolean) { this.filter = filter; } static onField(colName: string, filter: (v: unknown) => boolean) { return new DeferredFilter((record) => filter(record[colName])); } inverse() { const filter = this.filter; return new DeferredFilter((record) => !filter(record)); } } type Filter = (builder: FilterBuilder) => Expression; function buildFilter(where: Condition, defer = false): Filter | DeferredFilter { function autoField( builder: FilterBuilder, v: AnyColumn | unknown ) { if (v instanceof Column) return builder.field(v.ormName); return v; } if (where.type === ConditionType.Compare) { const left = where.a; const right = where.b; const fieldName = left.ormName; let inverse = false; switch (where.operator) { case "=": case "is": if (defer) return DeferredFilter.onField(fieldName, (v) => v === right); return (b) => b.eq(autoField(b, left), autoField(b, right)); case "!=": case "is not": if (defer) return DeferredFilter.onField(fieldName, (v) => v !== right); return (b) => b.neq(autoField(b, left), autoField(b, right)); case "<": if (defer) return DeferredFilter.onField( fieldName, (v) => typeof v === "number" && typeof right === "number" && v < right ); return (b) => b.lt(autoField(b, left), autoField(b, right)); case "<=": if (defer) return DeferredFilter.onField( fieldName, (v) => typeof v === "number" && typeof right === "number" && v <= right ); return (b) => b.lte(autoField(b, left), autoField(b, right)); case ">": if (defer) return DeferredFilter.onField( fieldName, (v) => typeof v === "number" && typeof right === "number" && v > right ); return (b) => b.gt(autoField(b, left), autoField(b, right)); case ">=": if (defer) return DeferredFilter.onField( fieldName, (v) => typeof v === "number" && typeof right === "number" && v >= right ); return (b) => b.gte(autoField(b, left), autoField(b, right)); case "not in": inverse = true; case "in": if (!Array.isArray(right)) throw new ConvexError( "FumaDB doesn't support using `in` operator against non-literal values" ); if (defer) return DeferredFilter.onField(fieldName, (v) => inverse ? !right.includes(v) : right.includes(v) ); return (b) => { const leftField = autoField(b, left); const filter = b.or(...right.map((item) => b.eq(leftField, item))); return inverse ? b.not(filter) : filter; }; case "not contains": inverse = true; case "contains": return DeferredFilter.onField(fieldName, (v) => { if (typeof v !== "string" || typeof right !== "string") return false; const result = v.includes(right); return inverse ? !result : result; }); case "not ends with": inverse = true; case "ends with": return DeferredFilter.onField(fieldName, (v) => { if (typeof v !== "string" || typeof right !== "string") return false; const result = v.endsWith(right); return inverse ? !result : result; }); case "not starts with": inverse = true; case "starts with": return DeferredFilter.onField(fieldName, (v) => { if (typeof v !== "string" || typeof right !== "string") return false; const result = v.startsWith(right); return inverse ? !result : result; }); } } if (where.type === ConditionType.Not) { const filter = buildFilter(where.item, defer); if (filter instanceof DeferredFilter) return filter.inverse(); return (b) => b.not(filter(b)); } const filters: Filter[] = []; const deferredFilters: DeferredFilter[] = []; let deferItem = defer; for (const item of where.items) { const filter = buildFilter(item, deferItem); if (filter instanceof DeferredFilter) { if (!deferItem) { deferItem = true; // add previous items back for (const prev of where.items) { if (prev === item) break; deferredFilters.push(buildFilter(prev, true) as DeferredFilter); } } deferredFilters.push(filter); } else { filters.push(filter); } } if (deferItem) return where.type === ConditionType.And ? new DeferredFilter((record) => { for (const item of deferredFilters) { if (!item.filter(record)) return false; } return true; }) : new DeferredFilter((record) => { for (const item of deferredFilters) { if (item.filter(record)) return true; } return false; }); return (b) => where.type === ConditionType.And ? b.and(...filters.map((f) => f(b))) : b.or(...filters.map((f) => f(b))); } export function createHandler(options: { schema: AnySchema; /** * A secret key to ensure this action is only accessible for your backend server. * * **Please be careful, anyone with the secret may access your database**. */ secret: string; }) { const { schema, secret } = options; if (!secret) throw new ConvexError("`secret` must be provided."); return { mutationHandler: mutationGeneric({ args: mutationArgs, handler: async (ctx, { tableName, action, ...args }) => { if (args.secret !== secret) throw new ConvexError("Invalid secret"); const table = schema.tables[tableName]; if (!table) throw new ConvexError(`Unknown table: ${tableName}`); if (action.type === "create") { const ids = await Promise.all( action.data.map((values) => ctx.db.insert(tableName, values)) ); if (action.returning) { return await Promise.all(ids.map((id) => ctx.db.get(id))); } return; } if (action.type === "update") { const query = ctx.db.query(tableName); const filter = action.where ? buildFilter(deserializeWhere(action.where, { schema })) : undefined; let targets: Record[]; if (filter instanceof DeferredFilter) { targets = (await query.collect()).filter((v) => filter.filter(v)); } else if (filter) { targets = await query.filter(filter).collect(); } else { targets = await query.collect(); } const mappedValues = mapValues(ValuesMode.Update, action.set, table); await Promise.all( targets.map((target) => ctx.db.patch(target._id, mappedValues)) ); return; } if (action.type === "upsert") { const query = ctx.db.query(tableName); const filter = buildFilter( deserializeWhere(action.where, { schema }) ); let target: Record; if (filter instanceof DeferredFilter) { target = (await query.collect()).filter((v) => filter.filter(v))[0]; } else { target = await query.filter(filter).first(); } if (target) { await ctx.db.patch( target._id as GenericId, mapValues(ValuesMode.Update, action.update, table) ); } else { await ctx.db.insert( tableName, mapValues(ValuesMode.Insert, action.create, table) ); } return; } if (action.type === "delete") { const query = ctx.db.query(tableName); const filter = action.where ? buildFilter(deserializeWhere(action.where, { schema })) : undefined; let targets: Record[]; if (filter instanceof DeferredFilter) { targets = (await query.collect()).filter((v) => filter.filter(v)); } else if (filter) { targets = await query.filter(filter).collect(); } else { targets = await query.collect(); } await Promise.all( targets.map((target) => ctx.db.delete(target._id as GenericId) ) ); return; } throw new ConvexError("Unhandled action type"); }, }), queryHandler: queryGeneric({ args: queryArgs, handler: async (ctx, { tableName, query: options, ...args }) => { if (args.secret !== secret) throw new ConvexError("Invalid secret"); const table = schema.tables[tableName]; if (!table) throw new ConvexError(`Unknown table: ${tableName}`); if (options.type === "find") { const { where, offset = 0, limit } = options; const _select = deserializeSelect( serializedSelect.parse(options.select) ); const filter = where ? buildFilter( deserializeWhere(serializedWhere.parse(where), { schema }) ) : undefined; let query = ctx.db.query(tableName); let records: unknown[]; if (filter instanceof DeferredFilter) { records = (await query.collect()).filter((v) => filter.filter(v)); if (limit !== undefined) records = records.slice(offset, limit); else if (offset > 0) records = records.slice(offset); } else { if (filter) query = query.filter(filter); if (limit !== undefined) { records = await query.take(limit + offset); } else { records = await query.collect(); } if (offset > 0) records = records.slice(offset); } return records; } const { where } = options; const filter = where ? buildFilter( deserializeWhere(serializedWhere.parse(where), { schema }) ) : undefined; let query = ctx.db.query(tableName); if (filter instanceof DeferredFilter) { let count = 0; for (const v of await query.collect()) { if (filter.filter(v)) count++; } return count; } else { if (filter) query = query.filter(filter); // TODO: consider aggregate, it currently needs some extra configure for user to enable it and with too much complexity return (await query.collect()).length; } }, }), }; }