Spreadsheet-UX op een echte relationele engine
Deze repository wordt als momentopname gepubliceerd: een clone bevat de volledige inhoud, maar geen commitgeschiedenis.
import {
isValidDate,
q,
type CellValue,
type ColumnMeta,
} from '@wellformed/shared';
import { badRequest } from '../errors.js';
import type { Db } from '../db/driver.js';
const MAX_TEXT_LENGTH = 10_000;
/**
* Turn a value from the wire into something the column can actually store.
*
* The grid sends strings for almost everything, because that is what an input
* element produces. This is where a string becomes a number, a blank becomes
* NULL, and a value that cannot possibly be right becomes a 400 rather than a
* silently corrupted row.
*/
export function coerceForColumn(db: Db, col: ColumnMeta, input: unknown): CellValue {
if (col.type === 'formula' || col.type === 'rollup') {
throw badRequest(`"${col.label}" is a computed column and cannot be written to.`);
}
const blank = input === null || input === undefined || input === '';
if (blank) {
if (col.required) throw badRequest(`"${col.label}" is required.`);
return col.type === 'checkbox' ? 0 : null;
}
switch (col.type) {
case 'number':
case 'currency': {
// Money is pasted back in the shape it was copied out: with its sign in
// front. The dollar was the only one accepted for as long as the dollar
// was the only one shown; the rest of the signs are here for the same
// reason it was.
const n =
typeof input === 'number' ? input : Number(String(input).replace(/[\s,$€£¥₹]/g, ''));
if (!Number.isFinite(n)) {
throw badRequest(`"${String(input)}" is not a number, so it cannot go in "${col.label}".`);
}
return n;
}
case 'checkbox':
if (typeof input === 'boolean') return input ? 1 : 0;
return /^(1|true|yes|on|✓)$/i.test(String(input)) ? 1 : 0;
case 'date': {
const s = String(input).trim();
if (!isValidDate(s)) {
throw badRequest(`"${s}" is not a date. Use YYYY-MM-DD.`);
}
return s;
}
case 'select': {
const s = String(input);
const options = col.options ?? [];
if (options.length && !options.includes(s)) {
throw badRequest(`"${s}" is not one of the options for "${col.label}" (${options.join(', ')}).`);
}
return s;
}
case 'link': {
const id = String(input);
if (!col.target) throw badRequest(`Link column "${col.label}" has no target table.`);
const hit = db.sqlite
.prepare<[string], { id: string }>(`SELECT id FROM ${q(col.target)} WHERE id = ?`)
.get(id);
if (!hit) {
throw badRequest(`"${col.label}" must point at an existing ${col.target} row; "${id}" does not exist.`);
}
return id;
}
case 'text':
default: {
const s = String(input);
if (s.length > MAX_TEXT_LENGTH) {
throw badRequest(`"${col.label}" is limited to ${MAX_TEXT_LENGTH.toLocaleString()} characters.`);
}
return s;
}
}
}
/** SQLite column affinity for a stored column type. */
export function sqlTypeFor(type: ColumnMeta['type']): string {
switch (type) {
case 'number':
case 'currency':
return 'REAL';
case 'checkbox':
return 'INTEGER NOT NULL DEFAULT 0';
default:
return 'TEXT';
}
}
/** How a value reads in the audit trail. */
export function describeValue(value: CellValue, col: ColumnMeta, label?: string | null): string {
if (col.type === 'link') return label ?? (value === null ? '∅' : String(value));
if (col.type === 'checkbox') return value ? 'checked' : 'unchecked';
if (value === null || value === undefined || value === '') return '∅';
return String(value);
}