Spreadsheet-UX op een echte relationele engine

main

Deze repository wordt als momentopname gepubliceerd: een clone bevat de volledige inhoud, maar geen commitgeschiedenis.

3.5 kB Ruw bestand
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);
}