Spreadsheet-UX op een echte relationele engine

main

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

26.7 kB Ruw bestand
import {
  assertColumnName,
  assertTableName,
  slugifyColumnName,
  collectPaths,
  formatChoices,
  isComputed,
  isFormatAllowed,
  parseCached,
  q,
  validateSequence,
  type ColumnFormat,
  type ColumnMeta,
  type ColumnType,
  type Locale,
} from '@wellformed/shared';
import { badRequest, conflict, notFound } from '../errors.js';
import { invalidateMeta, transact, type Db } from '../db/driver.js';
import {
  findColumn,
  getTable,
  incomingLinks,
  loadMeta,
  storedColumns,
  type MetaSnapshot,
  type TableSchema,
} from '../db/meta.js';
import { recordAudit, type Actor } from './audit.js';
import { validateExpression } from './compile.js';
import { newId } from './ids.js';
import { dropSqlViews } from './sql-views.js';
import { uniqueIndexName } from './tighten.js';
import { sqlTypeFor } from './values.js';

/**
 * The derived table name, stepped past whatever already exists.
 *
 * People choose the label; the table name is derived from it (see
 * `slugifyTableName` in shared) and two labels can derive the same name.
 * "orders" being taken makes the next one "orders_2" rather than an error
 * about an identifier the person never typed.
 */
export function availableTableName(meta: MetaSnapshot, wanted: string): string {
  if (!meta.tables[wanted]) return wanted;
  for (let n = 2; ; n++) {
    const suffix = `_${n}`;
    const candidate = `${wanted.slice(0, 48 - suffix.length)}${suffix}`;
    if (!meta.tables[candidate]) return candidate;
  }
}

/**
 * The derived column name, stepped past whatever the table already has.
 *
 * The column-side twin of `availableTableName`: people choose the label, the
 * identifier is derived from it, and "price" being taken makes the next one
 * "price_2" rather than an error about a name the person never typed.
 */
export function availableColumnName(table: TableSchema, wanted: string): string {
  const taken = new Set(table.columns.map((c) => c.name));
  if (!taken.has(wanted)) return wanted;
  for (let n = 2; ; n++) {
    const suffix = `_${n}`;
    const candidate = `${wanted.slice(0, 48 - suffix.length)}${suffix}`;
    if (!taken.has(candidate)) return candidate;
  }
}

/**
 * The built-in everything view, named in the workspace's language.
 *
 * The engine's own fallback is English; routes pass this instead, so a Dutch
 * workspace does not grow an "All klanten" among its "Alle orders". Two words
 * inlined rather than a message catalog: the server has none, and one word
 * per language does not justify starting one.
 */
export function defaultViewName(label: string, locale: Locale): string {
  return `${locale === 'nl' ? 'Alle' : 'All'} ${label.toLowerCase()}`;
}

/** What the row-label column is called when nobody says: the word for a name. */
export function defaultDisplayLabel(locale: Locale): string {
  return locale === 'nl' ? 'Naam' : 'Name';
}

export interface CreateTableInput {
  name: string;
  label: string;
  /** What to call the text column that labels a row. Defaults to "Name". */
  displayLabel?: string;
  /** Its identifier, when the caller must know it up front (import, demo). */
  display?: string;
  /** The sidebar heading to list the table under. */
  group?: string;
  /** What to call the built-in everything view. Defaults to "All <label>". */
  defaultViewName?: string;
}

export function createTable(db: Db, input: CreateTableInput, actor: Actor): string {
  const name = assertTableName(input.name);
  const label = input.label.trim() || name;
  if (label.length > 60) throw badRequest('Table labels are limited to 60 characters.');

  const displayLabel = input.displayLabel?.trim() || 'Name';
  if (displayLabel.length > 60) throw badRequest('Column labels are limited to 60 characters.');
  const display = assertColumnName(input.display?.trim() || slugifyColumnName(displayLabel));

  const meta = loadMeta(db);
  if (meta.tables[name]) throw conflict(`A table named "${name}" already exists.`);

  const physical = db.sqlite
    .prepare<[string], { name: string }>(`SELECT name FROM sqlite_master WHERE type='table' AND name = ?`)
    .get(name);
  if (physical) throw conflict(`"${name}" collides with an existing SQLite table.`);

  transact(db, () => {
    const position =
      (db.sqlite.prepare<[], { p: number | null }>('SELECT MAX(position) AS p FROM _wellformed_table').get()?.p ?? -1) + 1;

    db.sqlite.exec(
      `CREATE TABLE ${q(name)} (id TEXT PRIMARY KEY, ${q(display)} TEXT)`,
    );
    db.sqlite
      .prepare('INSERT INTO _wellformed_table (name, label, display_column, group_name, position) VALUES (?, ?, ?, ?, ?)')
      .run(name, label, display, input.group?.trim() || null, position);
    db.sqlite
      .prepare('INSERT INTO _wellformed_column (table_name, name, label, type, position) VALUES (?, ?, ?, ?, ?)')
      .run(name, display, displayLabel, 'text', 0);
    db.sqlite
      .prepare('INSERT INTO _wellformed_view (id, table_name, name, filters, sort, position, system) VALUES (?, ?, ?, ?, ?, ?, 1)')
      .run(newId('vw'), name, input.defaultViewName ?? `All ${label.toLowerCase()}`, '[]', null, 0);

    recordAudit(db, actor, {
      kind: 'schema',
      table: name,
      nw: `CREATE TABLE ${name}`,
    });
  });

  invalidateMeta(db);
  return name;
}

export function dropTable(db: Db, tableName: string, actor: Actor): void {
  const meta = loadMeta(db);
  getTable(meta, tableName);

  const referrers = incomingLinks(meta, tableName).filter((r) => r.table !== tableName);
  if (referrers.length) {
    throw conflict(
      `"${tableName}" is still linked from ${referrers
        .map((r) => `${r.table}.${r.column.name}`)
        .join(', ')}. Remove those columns first.`,
    );
  }

  transact(db, () => {
    db.sqlite.exec(`DROP TABLE ${q(tableName)}`);
    db.sqlite.prepare('DELETE FROM _wellformed_table WHERE name = ?').run(tableName);
    recordAudit(db, actor, { kind: 'schema', table: tableName, nw: `DROP TABLE ${tableName}` });
  });

  invalidateMeta(db);
}

export interface AddColumnInput {
  /** What the column is called. The identifier is derived from it. */
  label?: string;
  /** The identifier, when the caller must know it up front. Derived otherwise. */
  name?: string;
  type: ColumnType;
  options?: string[];
  target?: string;
  expr?: string;
  format?: ColumnFormat | null;
  required?: boolean;
  unique?: boolean;
  /** Numbering pattern for a text column, e.g. `INV-{yyyy}-{nnn}`. */
  sequence?: string | null;
}

export function addColumn(db: Db, tableName: string, input: AddColumnInput, actor: Actor): ColumnMeta {
  const meta = loadMeta(db);
  const table = getTable(meta, tableName);

  // People send a label; the identifier is derived and stepped past what
  // exists. An explicit name is the API's affair, and then the label defaults
  // to the name so no column is ever without one.
  const label = input.label?.trim() || input.name?.trim() || '';
  if (!label) throw badRequest('A column needs a name.');
  if (label.length > 60) throw badRequest('Column labels are limited to 60 characters.');
  const name = input.name?.trim()
    ? assertColumnName(input.name.trim())
    : availableColumnName(table, slugifyColumnName(label));

  if (findColumn(table, name)) throw conflict(`"${tableName}" already has a column named "${name}".`);
  if (table.columns.some((c) => c.label === label)) {
    throw conflict(`"${table.label}" already has a column called "${label}".`);
  }

  const col: ColumnMeta = {
    name,
    label,
    type: input.type,
    position: table.columns.length,
    format: input.format ?? defaultFormat(input.type, label),
    required: input.required ?? false,
    // A column that does not exist yet holds nothing, so unique is the one
    // constraint that needs no dry run on the way in.
    unique: input.unique ?? false,
  };

  if (col.unique && isComputed(input.type)) {
    throw badRequest('A computed column is derived, so uniqueness would be a claim about the formula.');
  }

  if (!isFormatAllowed(col.type, col.format ?? null)) {
    throw badRequest(formatRefusal(name, col.type, col.format!));
  }

  if (input.type === 'select') {
    const options = (input.options ?? []).map((o) => o.trim()).filter(Boolean);
    if (!options.length) throw badRequest('A select column needs at least one option.');
    if (options.length > 100) throw badRequest('A select column is limited to 100 options.');
    col.options = [...new Set(options)];
  }

  if (input.type === 'link') {
    if (!input.target) throw badRequest('A link column needs a target table.');
    if (!meta.tables[input.target]) throw notFound(`No table named "${input.target}" to link to.`);
    col.target = input.target;
  }

  if (input.sequence) {
    if (input.type !== 'text') {
      throw badRequest(`Numbering writes text, so a ${input.type} column cannot carry it.`);
    }
    const pattern = input.sequence.trim();
    const problem = validateSequence(pattern);
    if (problem) throw badRequest(problem);
    col.sequence = pattern;
  }

  if (isComputed(input.type)) {
    const expr = (input.expr ?? '').trim();
    if (!expr) throw badRequest('A formula column needs an expression.');
    // Validate against a catalogue that already contains this column, so an
    // expression cannot be accepted only to break on the next read.
    const provisional = withColumn(meta, tableName, { ...col, expr });
    const error = validateExpression(provisional, tableName, expr);
    if (error) throw badRequest(error);
    col.expr = expr;
  } else if (input.expr) {
    throw badRequest(`A ${input.type} column cannot have an expression.`);
  }

  transact(db, () => {
    if (!isComputed(col.type)) {
      const decl =
        col.type === 'link'
          ? `${q(name)} TEXT REFERENCES ${q(col.target!)}("id") ON DELETE SET NULL`
          : `${q(name)} ${sqlTypeFor(col.type)}`;
      db.sqlite.exec(`ALTER TABLE ${q(tableName)} ADD COLUMN ${decl}`);
      if (col.unique) {
        db.sqlite.exec(
          `CREATE UNIQUE INDEX ${q(uniqueIndexName(tableName, name))} ON ${q(tableName)} (${q(name)})`,
        );
      }
    }

    db.sqlite
      .prepare(
        `INSERT INTO _wellformed_column (table_name, name, label, type, position, options, target_table, expr, format, required, uniq, sequence)
         VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
      )
      .run(
        tableName,
        col.name,
        col.label,
        col.type,
        col.position,
        col.options ? JSON.stringify(col.options) : null,
        col.target ?? null,
        col.expr ?? null,
        col.format ?? null,
        col.required ? 1 : 0,
        col.unique ? 1 : 0,
        col.sequence ?? null,
      );

    recordAudit(db, actor, {
      kind: 'schema',
      table: tableName,
      column: col.name,
      nw: isComputed(col.type)
        ? `ADD COMPUTED ${col.name} = ${col.expr}`
        : `ALTER TABLE ${tableName} ADD COLUMN ${col.name} ${sqlTypeFor(col.type)}`,
    });
  });

  invalidateMeta(db);
  return col;
}

export interface UpdateColumnInput {
  /** What the column is called from now on. The identifier does not move. */
  label?: string;
  options?: string[];
  expr?: string;
  format?: ColumnFormat | null;
  required?: boolean;
  position?: number;
  /** A numbering pattern, or `null` to stop numbering. Absent means keep. */
  sequence?: string | null;
}

export function updateColumn(
  db: Db,
  tableName: string,
  columnName: string,
  input: UpdateColumnInput,
  actor: Actor,
): ColumnMeta {
  const meta = loadMeta(db);
  const table = getTable(meta, tableName);
  const col = findColumn(table, columnName);
  if (!col) throw notFound(`"${tableName}" has no column "${columnName}".`);

  const next: ColumnMeta = { ...col };
  const changes: string[] = [];

  if (input.label !== undefined) {
    const label = input.label.trim();
    if (!label) throw badRequest('A column needs a name.');
    if (label.length > 60) throw badRequest('Column labels are limited to 60 characters.');
    if (label !== col.label) {
      if (table.columns.some((c) => c.name !== col.name && c.label === label)) {
        throw conflict(`"${table.label}" already has a column called "${label}".`);
      }
      next.label = label;
      changes.push(`label = ${label}`);
    }
  }

  if (input.options !== undefined) {
    if (col.type !== 'select') throw badRequest(`Only select columns have options.`);
    const options = input.options.map((o) => o.trim()).filter(Boolean);
    if (!options.length) throw badRequest('A select column needs at least one option.');
    next.options = [...new Set(options)];
    changes.push(`options = ${next.options.join(' | ')}`);
  }

  if (input.expr !== undefined) {
    if (!isComputed(col.type)) throw badRequest('Only formula and rollup columns have an expression.');
    const expr = input.expr.trim();
    if (!expr) throw badRequest('A formula column needs an expression.');
    const provisional = withColumn(meta, tableName, { ...next, expr });
    const error = validateExpression(provisional, tableName, expr);
    if (error) throw badRequest(error);
    next.expr = expr;
    changes.push(`expression = ${expr}`);
  }

  if (input.format !== undefined) {
    // `null` is always allowed — it is the way back to the type's own default.
    if (!isFormatAllowed(col.type, input.format)) {
      throw badRequest(formatRefusal(columnName, col.type, input.format!));
    }
    next.format = input.format;
    changes.push(`format = ${input.format ?? 'default'}`);
  }

  if (input.required !== undefined) {
    if (isComputed(col.type)) throw badRequest('Computed columns cannot be required.');
    // A constraint that the rows already there do not keep is not a constraint,
    // it is a note. Counted here as well as in the tightening dry run, so no
    // path into the product — grid, API or assistant — can assert one.
    if (input.required && !col.required) {
      const blank = db.sqlite
        .prepare<[], { n: number }>(
          `SELECT COUNT(*) AS n FROM ${q(tableName)} WHERE ${q(columnName)} IS NULL OR ${q(columnName)} = ''`,
        )
        .get()?.n ?? 0;
      if (blank > 0) {
        throw conflict(
          `${blank} row${blank === 1 ? '' : 's'} in "${table.label}" leave "${col.label}" blank, so it cannot be required yet. Fill them in, or clear the constraint check first.`,
        );
      }
    }
    next.required = input.required;
    changes.push(input.required ? 'required' : 'optional');
  }

  if (input.position !== undefined) {
    next.position = Math.max(0, Math.floor(input.position));
    changes.push(`moved to position ${next.position + 1}`);
  }

  if (input.sequence !== undefined) {
    const pattern = input.sequence?.trim() || null;
    if (pattern) {
      if (col.type !== 'text') {
        throw badRequest(`Numbering writes text, so a ${col.type} column cannot carry it.`);
      }
      const problem = validateSequence(pattern);
      if (problem) throw badRequest(problem);
      next.sequence = pattern;
      changes.push(`numbering = ${pattern}`);
    } else if (col.sequence) {
      delete next.sequence;
      changes.push('no numbering');
    }
  }

  if (!changes.length) return col;

  transact(db, () => {
    db.sqlite
      .prepare(
        `UPDATE _wellformed_column SET label = ?, options = ?, expr = ?, format = ?, required = ?, position = ?, sequence = ?
         WHERE table_name = ? AND name = ?`,
      )
      .run(
        next.label,
        next.options ? JSON.stringify(next.options) : null,
        next.expr ?? null,
        next.format ?? null,
        next.required ? 1 : 0,
        next.position,
        next.sequence ?? null,
        tableName,
        columnName,
      );

    recordAudit(db, actor, {
      kind: 'schema',
      table: tableName,
      column: columnName,
      nw: `ALTER COLUMN ${columnName}: ${changes.join(', ')}`,
    });
  });

  invalidateMeta(db);
  return next;
}

/**
 * One flat order for every table in the workspace. The sidebar's groups are
 * runs of consecutive tables in this order, so reordering a group and
 * reordering a table are the same write: a new permutation of positions.
 */
export function reorderTables(db: Db, order: string[], actor: Actor): void {
  const meta = loadMeta(db);
  const known = new Set(meta.tableOrder);

  if (order.length !== known.size || order.some((n) => !known.has(n)) || new Set(order).size !== order.length) {
    throw badRequest('The new table order must list every table exactly once.');
  }

  transact(db, () => {
    const stmt = db.sqlite.prepare('UPDATE _wellformed_table SET position = ? WHERE name = ?');
    order.forEach((name, i) => stmt.run(i, name));
    recordAudit(db, actor, {
      kind: 'schema',
      table: order[0]!,
      nw: `REORDER TABLES ${order.join(', ')}`,
    });
  });

  invalidateMeta(db);
}

export function reorderColumns(db: Db, tableName: string, order: string[], actor: Actor): void {
  const meta = loadMeta(db);
  const table = getTable(meta, tableName);
  const known = new Set(table.columns.map((c) => c.name));

  if (order.length !== known.size || order.some((n) => !known.has(n)) || new Set(order).size !== order.length) {
    throw badRequest('The new column order must list every column of the table exactly once.');
  }

  transact(db, () => {
    const stmt = db.sqlite.prepare(
      'UPDATE _wellformed_column SET position = ? WHERE table_name = ? AND name = ?',
    );
    order.forEach((name, i) => stmt.run(i, tableName, name));
    recordAudit(db, actor, {
      kind: 'schema',
      table: tableName,
      nw: `REORDER COLUMNS ${order.join(', ')}`,
    });
  });

  invalidateMeta(db);
}

export function dropColumn(db: Db, tableName: string, columnName: string, actor: Actor): void {
  const meta = loadMeta(db);
  const table = getTable(meta, tableName);
  const col = findColumn(table, columnName);
  if (!col) throw notFound(`"${tableName}" has no column "${columnName}".`);

  if (table.display === columnName) {
    throw conflict(
      `"${columnName}" is the display column for "${tableName}". Point the table at another column first.`,
    );
  }
  if (storedColumns(table).length === 1 && !isComputed(col.type)) {
    throw conflict(`"${tableName}" would be left with no data columns.`);
  }

  const dependents = findDependents(meta, tableName, columnName);
  if (dependents.length) {
    throw conflict(
      `"${columnName}" is used by ${dependents.join(', ')}. Update those formulas first.`,
    );
  }

  const usedByViews = meta.views.filter(
    (v) =>
      v.table === tableName &&
      (v.sort?.col === columnName ||
        v.filters.some((f) => f.col === columnName) ||
        columnName in v.summaries ||
        v.hidden.includes(columnName)),
  );

  transact(db, () => {
    if (!isComputed(col.type)) {
      // SQLite refuses to drop a column anything else in the schema mentions —
      // an index on it, or a derived view selecting it. Both are ours and both
      // are rebuilt from the catalogue, so both give way.
      dropSqlViews(db);
      if (col.unique) {
        db.sqlite.exec(`DROP INDEX IF EXISTS ${q(uniqueIndexName(tableName, columnName))}`);
      }
      db.sqlite.exec(`ALTER TABLE ${q(tableName)} DROP COLUMN ${q(columnName)}`);
    }
    db.sqlite
      .prepare('DELETE FROM _wellformed_column WHERE table_name = ? AND name = ?')
      .run(tableName, columnName);

    // A view that filtered, sorted or summarised on the column would 400
    // forever; strip the dangling references rather than leave it unusable.
    for (const view of usedByViews) {
      const summaries = { ...view.summaries };
      delete summaries[columnName];
      db.sqlite
        .prepare('UPDATE _wellformed_view SET filters = ?, sort = ?, summaries = ?, hidden = ? WHERE id = ?')
        .run(
          JSON.stringify(view.filters.filter((f) => f.col !== columnName)),
          view.sort?.col === columnName ? null : JSON.stringify(view.sort),
          JSON.stringify(summaries),
          JSON.stringify(view.hidden.filter((n) => n !== columnName)),
          view.id,
        );
    }

    db.sqlite
      .prepare('DELETE FROM _wellformed_rule WHERE table_name = ? AND column_name = ?')
      .run(tableName, columnName);

    recordAudit(db, actor, {
      kind: 'schema',
      table: tableName,
      column: columnName,
      nw: `ALTER TABLE ${tableName} DROP COLUMN ${columnName}`,
    });
  });

  invalidateMeta(db);
}

export function renameColumn(
  db: Db,
  tableName: string,
  columnName: string,
  newName: string,
  actor: Actor,
): void {
  const meta = loadMeta(db);
  const table = getTable(meta, tableName);
  const col = findColumn(table, columnName);
  if (!col) throw notFound(`"${tableName}" has no column "${columnName}".`);

  const target = assertColumnName(newName.trim());
  if (target === columnName) return;
  if (findColumn(table, target)) throw conflict(`"${tableName}" already has a column named "${target}".`);

  const dependents = findDependents(meta, tableName, columnName);
  if (dependents.length) {
    throw conflict(
      `"${columnName}" is referenced by ${dependents.join(', ')}. Rewrite those formulas before renaming it.`,
    );
  }

  transact(db, () => {
    if (!isComputed(col.type)) {
      dropSqlViews(db);
      db.sqlite.exec(`ALTER TABLE ${q(tableName)} RENAME COLUMN ${q(columnName)} TO ${q(target)}`);
      // SQLite follows the rename into the index definition, but the index's
      // own name still says the old column. Renamed too, so it can be found.
      if (col.unique) {
        db.sqlite.exec(`DROP INDEX IF EXISTS ${q(uniqueIndexName(tableName, columnName))}`);
        db.sqlite.exec(
          `CREATE UNIQUE INDEX ${q(uniqueIndexName(tableName, target))} ON ${q(tableName)} (${q(target)})`,
        );
      }
    }
    db.sqlite
      .prepare('UPDATE _wellformed_column SET name = ? WHERE table_name = ? AND name = ?')
      .run(target, tableName, columnName);

    if (table.display === columnName) {
      db.sqlite.prepare('UPDATE _wellformed_table SET display_column = ? WHERE name = ?').run(target, tableName);
    }

    for (const view of meta.views.filter((v) => v.table === tableName)) {
      const filters = view.filters.map((f) => (f.col === columnName ? { ...f, col: target } : f));
      const sort = view.sort?.col === columnName ? { ...view.sort, col: target } : view.sort;
      const summaries = Object.fromEntries(
        Object.entries(view.summaries).map(([name, fn]) => [name === columnName ? target : name, fn]),
      );
      const hidden = view.hidden.map((n) => (n === columnName ? target : n));
      db.sqlite
        .prepare('UPDATE _wellformed_view SET filters = ?, sort = ?, summaries = ?, hidden = ? WHERE id = ?')
        .run(
          JSON.stringify(filters),
          sort ? JSON.stringify(sort) : null,
          JSON.stringify(summaries),
          JSON.stringify(hidden),
          view.id,
        );
    }

    db.sqlite
      .prepare('UPDATE _wellformed_rule SET column_name = ? WHERE table_name = ? AND column_name = ?')
      .run(target, tableName, columnName);

    recordAudit(db, actor, {
      kind: 'schema',
      table: tableName,
      column: columnName,
      old: columnName,
      nw: `RENAME COLUMN ${columnName} TO ${target}`,
    });
  });

  invalidateMeta(db);
}

export function setDisplayColumn(db: Db, tableName: string, columnName: string, actor: Actor): void {
  const meta = loadMeta(db);
  const table = getTable(meta, tableName);
  const col = findColumn(table, columnName);
  if (!col) throw notFound(`"${tableName}" has no column "${columnName}".`);
  if (col.type === 'link') throw badRequest('A link column cannot label its own rows.');

  transact(db, () => {
    db.sqlite.prepare('UPDATE _wellformed_table SET display_column = ? WHERE name = ?').run(columnName, tableName);
    recordAudit(db, actor, {
      kind: 'schema',
      table: tableName,
      old: table.display,
      nw: `DISPLAY COLUMN → ${columnName}`,
    });
  });

  invalidateMeta(db);
}

/**
 * Every computed column whose expression mentions `table.column`, including
 * references reached through a link from another table.
 */
function findDependents(meta: MetaSnapshot, tableName: string, columnName: string): string[] {
  const hits: string[] = [];

  for (const name of meta.tableOrder) {
    const table = meta.tables[name]!;
    for (const col of table.columns) {
      if (!isComputed(col.type) || !col.expr) continue;
      let paths: string[][];
      try {
        paths = collectPaths(parseCached(col.expr));
      } catch {
        continue; // Already broken; dropping the column cannot make it worse.
      }
      if (paths.some((p) => pathTouches(meta, name, p, tableName, columnName))) {
        hits.push(`${name}.${col.name}`);
      }
    }
  }

  return hits;
}

/** Walk a dotted path from `fromTable` and report whether it lands on the target. */
function pathTouches(
  meta: MetaSnapshot,
  fromTable: string,
  path: string[],
  targetTable: string,
  targetColumn: string,
): boolean {
  // Rollup form: `orders.Total` starts at a table name, not a column.
  const head = path[0];
  if (head && meta.tables[head] && !findColumn(meta.tables[fromTable]!, head)) {
    return pathTouches(meta, head, path.slice(1), targetTable, targetColumn);
  }

  let table = fromTable;
  for (let i = 0; i < path.length; i++) {
    const schema = meta.tables[table];
    if (!schema) return false;
    const segment = path[i]!;
    const col = findColumn(schema, segment);
    if (!col) return false;
    if (i === path.length - 1) return table === targetTable && segment === targetColumn;
    // Traversing through a link also depends on the link column itself.
    if (table === targetTable && segment === targetColumn) return true;
    if (col.type !== 'link' || !col.target) return false;
    table = col.target;
  }
  return false;
}

/** A catalogue with one column added or replaced, for validating expressions. */
function withColumn(meta: MetaSnapshot, tableName: string, col: ColumnMeta): MetaSnapshot {
  const table = meta.tables[tableName];
  if (!table) return meta;
  const columns = table.columns.filter((c) => c.name !== col.name).concat(col);
  return {
    ...meta,
    tables: { ...meta.tables, [tableName]: { ...table, columns } },
  };
}

/**
 * Why a format was refused, in terms of what the column renders as.
 *
 * A link shows the label of the row it points at and a checkbox shows a tick,
 * whatever anyone sets here — so the honest complaint is not "that is not
 * allowed" but "there is nothing for it to apply to".
 */
function formatRefusal(name: string, type: ColumnType, format: ColumnFormat): string {
  return `"${name}" is a ${type} column, so a ${format} format has nothing to apply to. ${
    formatChoices(type).length
      ? `It can be ${formatChoices(type).join(', ')} or left to the type.`
      : 'It renders the way its type renders.'
  }`;
}

function defaultFormat(type: ColumnType, name: string): ColumnFormat | null {
  if (type === 'currency') return 'currency';
  if (type === 'number') return 'number';
  if (type === 'date') return 'date';
  if (isComputed(type) && /price|total|amount|cost|revenue|value|balance/i.test(name)) {
    return 'currency';
  }
  if (isComputed(type) && /count|qty|quantity|number|days/i.test(name)) return 'number';
  return null;
}