84 lines
4.7 KiB
TypeScript
84 lines
4.7 KiB
TypeScript
import { getTableColumns } from "drizzle-orm"
|
|
|
|
export type InputField = {
|
|
schema: Record<string, any>
|
|
parse: (value: unknown, path: string) => any
|
|
}
|
|
|
|
const invalid = (path: string, expected: string): never => { throw new Error(`${path}: ${expected}`) }
|
|
export const textField = (nonEmpty = false): InputField => ({
|
|
schema: { type: "string", ...(nonEmpty ? { minLength: 1 } : {}) },
|
|
parse: (value, path) => typeof value === "string" && (!nonEmpty || value.trim())
|
|
? value.trim() : invalid(path, "Text" + (nonEmpty ? " darf nicht leer sein" : " erwartet")),
|
|
})
|
|
export const numericField = (integer = false, minimum?: number, maximum?: number): InputField => ({
|
|
schema: { type: integer ? "integer" : "number", ...(minimum !== undefined ? { minimum } : {}), ...(maximum !== undefined ? { maximum } : {}) },
|
|
parse: (value, path) => {
|
|
if (typeof value !== "number" || !Number.isFinite(value) || (integer && !Number.isSafeInteger(value))
|
|
|| (minimum !== undefined && value < minimum) || (maximum !== undefined && value > maximum)) {
|
|
return invalid(path, `gültige ${integer ? "ganze " : ""}Zahl im angegebenen Bereich erwartet`)
|
|
}
|
|
return value
|
|
},
|
|
})
|
|
export const idField = numericField(true, 1)
|
|
export const uuidField: InputField = {
|
|
schema: { type: "string", format: "uuid" },
|
|
parse: (value, path) => typeof value === "string" && /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value)
|
|
? value.toLowerCase() : invalid(path, "gültige UUID erwartet"),
|
|
}
|
|
export const booleanField: InputField = {
|
|
schema: { type: "boolean" },
|
|
parse: (value, path) => typeof value === "boolean" ? value : invalid(path, "Boolean erwartet"),
|
|
}
|
|
export const dateField: InputField = {
|
|
schema: { type: "string", format: "date", description: "Kalenderdatum YYYY-MM-DD." },
|
|
parse: (value, path) => {
|
|
if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return invalid(path, "Datum YYYY-MM-DD erwartet")
|
|
const date = new Date(`${value}T00:00:00Z`)
|
|
if (!Number.isFinite(date.getTime()) || date.toISOString().slice(0, 10) !== value) return invalid(path, "ungültiges Kalenderdatum")
|
|
return value
|
|
},
|
|
}
|
|
export const nullable = (field: InputField): InputField => ({
|
|
schema: { ...field.schema, type: [field.schema.type, "null"] },
|
|
parse: (value, path) => value === null ? null : field.parse(value, path),
|
|
})
|
|
export const arrayField = (item: InputField): InputField => ({
|
|
schema: { type: "array", items: item.schema },
|
|
parse: (value, path) => Array.isArray(value) ? value.map((entry, index) => item.parse(entry, `${path}[${index}]`)) : invalid(path, "Array erwartet"),
|
|
})
|
|
export const objectField = (fields: Record<string, InputField>, additionalProperties = false): InputField => ({
|
|
schema: { type: "object", properties: Object.fromEntries(Object.entries(fields).map(([key, field]) => [key, field.schema])), additionalProperties },
|
|
parse: (value, path) => {
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) return invalid(path, "Objekt erwartet")
|
|
const entries = Object.entries(value).map(([key, entry]) => {
|
|
if (Object.prototype.hasOwnProperty.call(fields, key)) return [key, fields[key].parse(entry, `${path}.${key}`)]
|
|
if (!additionalProperties || ["__proto__", "constructor", "prototype"].includes(key)) return invalid(`${path}.${key}`, "unbekanntes Feld")
|
|
return [key, entry]
|
|
})
|
|
return Object.fromEntries(entries)
|
|
},
|
|
})
|
|
|
|
/** Explicit allowlist; scalar types and nullability follow the persisted columns. */
|
|
export function tableFields(table: any, names: string, overrides: Record<string, InputField> = {}) {
|
|
const columns = getTableColumns(table) as Record<string, any>
|
|
return Object.fromEntries(names.split(/\s+/).filter(Boolean).map((name) => {
|
|
const column = columns[name]
|
|
if (!column) throw new Error(`Unbekanntes Stammdatenfeld ${name}`)
|
|
let field = overrides[name]
|
|
if (!field) {
|
|
if (column.columnType === "PgUUID") field = uuidField
|
|
else if (column.columnType === "PgDateString") field = dateField
|
|
else if (column.dataType === "string") field = textField(name === "name")
|
|
else if (column.dataType === "boolean") field = booleanField
|
|
else if (column.dataType === "number") {
|
|
field = column.columnType === "PgSmallInt" ? numericField(true, -32768, 32767)
|
|
: numericField(column.columnType !== "PgDoublePrecision")
|
|
} else throw new Error(`Explizites Eingabeschema für ${name} erforderlich`)
|
|
}
|
|
return [name, column.notNull ? field : nullable(field)]
|
|
}))
|
|
}
|