UNPKG

cogsbox-shape

Version:

A TypeScript library for creating type-safe database schemas with Zod validation, SQL type definitions, and automatic client/server transformations. Unifies client, server, and database types through a single schema definition, with built-in support for r

363 lines (362 loc) 14 kB
import { Kysely, sql } from "kysely"; import { buildWhereConditions, buildPkConditions } from "./where-builder.js"; import { RecordNotFoundError } from "./errors.js"; export class TableDB { db; meta; transforms; reconcile; hydrateRows; constructor(db, meta, transforms, reconcile, hydrateRows) { this.db = db; this.meta = meta; this.transforms = transforms; this.reconcile = reconcile; this.hydrateRows = hydrateRows; } get kysely() { return this.db; } async findMany(opts) { const qb = this.db; let query = qb.selectFrom(this.meta.tableName).selectAll(); if (opts?.where) { const conditions = buildWhereConditions(opts.where, this.meta); if (conditions.length > 0) { query = query.where(sql.join(conditions, sql ` AND `)); } } if (opts?.orderBy) { for (const [col, dir] of Object.entries(opts.orderBy)) { if (dir) { const field = this.meta.dbFields.get(col); const dbCol = field?.dbName ?? col; query = query.orderBy(dbCol, dir); } } } if (opts?.limit !== undefined) { query = query.limit(opts.limit); } if (opts?.offset !== undefined) { query = query.offset(opts.offset); } const rows = (await query.execute()); const hydratedRows = this.hydrateRows ? await this.hydrateRows(rows) : rows; return hydratedRows.map((r) => this.transforms.parseFromDb(r)); } async findById(id) { const pkValues = Array.isArray(id) ? id : [id]; const pkFields = this.meta.pkFields.length > 0 ? this.meta.pkFields : Array.from(this.meta.dbFields.values()).map((f) => f.dbName); const conditions = buildPkConditions(pkValues, pkFields); const qb = this.db; const rows = await qb .selectFrom(this.meta.tableName) .selectAll() .where(sql.join(conditions, sql ` AND `)) .limit(1) .execute(); const row = rows[0] ?? null; if (!row) return null; const hydratedRows = this.hydrateRows ? await this.hydrateRows([row]) : [row]; return this.transforms.parseFromDb(hydratedRows[0]); } byId(id) { return { find: () => this.findById(id), update: (data, dbOnlyData) => this.update(id, data, dbOnlyData), delete: () => this.delete(id), }; } insert(data, ...args) { const dbOnlyData = args[0]; return { ids: () => this.insertIds(data, dbOnlyData), full: async () => { const ids = await this.insertIds(data, dbOnlyData); return this.reconcileIds(data, ids); }, }; } async create(data, ...args) { const dbOnlyData = args[0]; return this.insertIds(data, dbOnlyData); } async insertIds(data, dbOnlyData, opts) { const dbData = this.transforms.parseForDb(data); const parsedDbOnlyData = this.parseDbOnlyData(dbOnlyData, { requireRequired: true, presentDbData: dbData, }); const clientPkClientKeys = this.meta.clientPkFields; const pkDbNames = new Set(clientPkClientKeys.map((k) => { const field = this.meta.dbFields.get(k); return field?.dbName ?? k; })); const insertData = {}; for (const key of Object.keys(dbData)) { if (!pkDbNames.has(key) && this.isWritableDbColumn(key)) { insertData[key] = dbData[key]; } } Object.assign(insertData, parsedDbOnlyData); const qb = this.db; let query = qb.insertInto(this.meta.tableName).values(insertData); if (opts?.onConflict === "ignore") { query = query.onConflict((oc) => oc.doNothing()); } const result = await query.execute(); const insertId = result[0]?.insertId; if (insertId !== undefined && this.meta.pkFields.length > 0) { const dbPkField = this.meta.pkFields[0]; return { [dbPkField]: Number(insertId) }; } return {}; } update(id, data, dbOnlyData) { return { ids: () => this.updateIds(id, data, dbOnlyData), full: async () => { const ids = await this.updateIds(id, data, dbOnlyData); const idValue = this.firstPkValue(ids); const row = await this.findById(idValue); if (!row) { throw new RecordNotFoundError(this.meta.tableName, idValue); } return row; }, }; } async updateIds(id, data, dbOnlyData) { const pkValues = Array.isArray(id) ? id : [id]; const pkFields = this.meta.pkFields.length > 0 ? this.meta.pkFields : Array.from(this.meta.dbFields.values()).map((f) => f.dbName); const patchData = data; const deriveKeys = this.affectedDbBackedDerives(patchData); const missingDeps = this.missingDeriveDependencies(patchData, deriveKeys); const fetchedDeps = missingDeps.length > 0 ? await this.fetchClientFieldsById(pkValues, pkFields, missingDeps) : {}; const parseInput = { ...fetchedDeps, ...patchData }; const parsedDbData = this.transforms.parsePatchForDb(parseInput); const dbData = this.pickDbPatchFields(parsedDbData, [ ...Object.keys(patchData), ...deriveKeys, ]); Object.assign(dbData, this.parseDbOnlyData(dbOnlyData, { requireRequired: false })); const conditions = buildPkConditions(pkValues, pkFields); const qb = this.db; const result = await qb .updateTable(this.meta.tableName) .set(dbData) .where(sql.join(conditions, sql ` AND `)) .execute(); const numUpdated = result[0]?.numUpdatedRows ?? result[0]?.numAffectedRows ?? 0n; if (Number(numUpdated) === 0) { throw new RecordNotFoundError(this.meta.tableName, id); } const pkResult = {}; for (let i = 0; i < pkFields.length; i++) { pkResult[pkFields[i]] = pkValues[i]; } return pkResult; } affectedDbBackedDerives(patchData) { const patchKeys = new Set(Object.keys(patchData)); const affected = []; for (const [deriveKey, deps] of this.meta.deriveDependencies.entries()) { if (!this.meta.dbFields.has(deriveKey)) continue; if (deps.some((dep) => patchKeys.has(dep))) { affected.push(deriveKey); } } return affected; } missingDeriveDependencies(patchData, deriveKeys) { const patchKeys = new Set(Object.keys(patchData)); const missing = new Set(); for (const deriveKey of deriveKeys) { const deps = this.meta.deriveDependencies.get(deriveKey) ?? []; for (const dep of deps) { if (!patchKeys.has(dep)) missing.add(dep); } } return Array.from(missing).filter((dep) => this.meta.dbFields.has(dep)); } async fetchClientFieldsById(pkValues, pkFields, clientFields) { const dbColumns = clientFields.map((clientKey) => { const field = this.meta.dbFields.get(clientKey); return field?.dbName ?? clientKey; }); const conditions = buildPkConditions(pkValues, pkFields); const qb = this.db; const row = (await qb .selectFrom(this.meta.tableName) .select(dbColumns) .where(sql.join(conditions, sql ` AND `)) .limit(1) .executeTakeFirst()); if (!row) { throw new RecordNotFoundError(this.meta.tableName, pkValues); } const result = {}; for (const clientKey of clientFields) { const field = this.meta.dbFields.get(clientKey); const dbName = field?.dbName ?? clientKey; const value = row[dbName]; result[clientKey] = field?.toClient ? field.toClient(value) : value; } return result; } pickDbPatchFields(dbData, clientKeys) { const picked = {}; for (const clientKey of clientKeys) { const dbName = this.meta.clientToDbName.get(clientKey); if (!dbName) continue; if (dbData[dbName] !== undefined) { picked[dbName] = dbData[dbName]; } } return picked; } isWritableDbColumn(dbName) { for (const field of this.meta.dbFields.values()) { if (field.dbName === dbName) return true; } return false; } parseDbOnlyData(dbOnlyData, opts = { requireRequired: false }) { if (opts.requireRequired) { for (const requiredKey of this.meta.sqlOnlyRequiredClientFields) { const field = this.meta.dbFields.get(requiredKey); const dbName = field?.dbName ?? requiredKey; const alreadyPresent = opts.presentDbData?.[dbName] !== undefined; if (!alreadyPresent && (!dbOnlyData || dbOnlyData[requiredKey] === undefined)) { throw new Error(`Missing required sqlOnly field "${requiredKey}" for "${this.meta.tableName}".`); } } } if (!dbOnlyData) return {}; const parsed = {}; for (const [clientKey, value] of Object.entries(dbOnlyData)) { if (!this.meta.sqlOnlyClientFields.has(clientKey)) { throw new Error(`Field "${clientKey}" is not a sqlOnly field on "${this.meta.tableName}".`); } const validator = this.meta.sqlOnlyValidators.get(clientKey); const validValue = validator ? validator(value) : value; const field = this.meta.dbFields.get(clientKey); const dbName = field?.dbName ?? clientKey; parsed[dbName] = field?.toDb ? field.toDb(validValue) : validValue; } return parsed; } reconcileIds(clientData, ids) { if (this.reconcile) { return this.reconcile(clientData).withServer(ids); } return this.reconcileFlatIds(clientData, ids); } reconcileFlatIds(clientData, ids) { if (Array.isArray(clientData)) { if (!Array.isArray(ids)) return clientData; return clientData.map((item, index) => this.reconcileFlatIds(item, ids[index])); } if (typeof clientData !== "object" || clientData === null || typeof ids !== "object" || ids === null) { return clientData; } return { ...clientData, ...this.mapIdsToClientFields(ids), }; } mapIdsToClientFields(ids) { const mapped = {}; for (const [idKey, value] of Object.entries(ids)) { const clientKey = this.clientKeyForDbField(idKey); const field = this.meta.dbFields.get(clientKey); mapped[clientKey] = field?.toClient ? field.toClient(value) : value; } return mapped; } clientKeyForDbField(dbField) { for (const [clientKey, field] of this.meta.dbFields.entries()) { if (field.dbName === dbField) return clientKey; } return dbField; } firstPkValue(ids) { const pkField = this.meta.pkFields[0]; if (pkField && ids[pkField] !== undefined) { return ids[pkField]; } return Object.values(ids)[0]; } async delete(id) { const pkValues = Array.isArray(id) ? id : [id]; const pkFields = this.meta.pkFields.length > 0 ? this.meta.pkFields : Array.from(this.meta.dbFields.values()).map((f) => f.dbName); const conditions = buildPkConditions(pkValues, pkFields); const qb = this.db; const result = await qb .deleteFrom(this.meta.tableName) .where(sql.join(conditions, sql ` AND `)) .execute(); const numDeleted = result[0]?.numDeletedRows ?? result[0]?.numAffectedRows ?? 0n; return { deleted: Number(numDeleted) > 0 }; } async deleteMany(where) { const qb = this.db; let query = qb.deleteFrom(this.meta.tableName); const conditions = buildWhereConditions(where, this.meta); if (conditions.length > 0) { query = query.where(sql.join(conditions, sql ` AND `)); } const result = await query.execute(); const numDeleted = result[0]?.numDeletedRows ?? result[0]?.numAffectedRows ?? 0n; return { deleted: Number(numDeleted) }; } async insertOrIgnore(data, ...args) { const dbOnlyData = args[0]; return { ids: () => this.insertIds(data, dbOnlyData, { onConflict: "ignore" }), full: async () => { const ids = await this.insertIds(data, dbOnlyData, { onConflict: "ignore" }); return this.reconcileIds(data, ids); }, }; } async count(where) { const qb = this.db; let query = qb .selectFrom(this.meta.tableName) .select(sql `count(*)`.as("count")); if (where) { const conditions = buildWhereConditions(where, this.meta); if (conditions.length > 0) { query = query.where(sql.join(conditions, sql ` AND `)); } } const row = (await query.execute()); return Number(row[0]?.count ?? 0); } }