Spreadsheet-UX op een echte relationele engine

main

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

18.7 kB Ruw bestand
import {
  aggLabel,
  FormulaError,
  isAggAllowed,
  isComputed,
  isNumericColumn,
  isUnaryOp,
  q,
  type AggFn,
  type CellValue,
  type ColumnMeta,
  type Filter,
  type Row,
  type Sort,
  type SqlToken,
  type Summaries,
  type SummaryResponse,
  type ViewDef,
  type ViewMeta,
} from '@wellformed/shared';
import { badRequest } from '../errors.js';
import { findColumn, type MetaSnapshot, type TableSchema } from '../db/meta.js';
import { Aliases, Compiler, Frame, type Fragment, type Scope } from './compile.js';
import { addDef, displayTokens, inlineParams, type SqlDef } from './sql-display.js';
import type { Db } from '../db/driver.js';

export const MAX_PAGE_SIZE = 1000;
export const DEFAULT_PAGE_SIZE = 200;

/** Suffix marking the display label of a link column in the result set. */
const LABEL_SUFFIX = '::label';

export interface QueryRequest {
  table: string;
  filters: Filter[];
  sort: Sort | null;
  search?: string;
  limit: number;
  offset: number;
  /** Restrict to a single row — used to re-read a row after a write. */
  rowId?: string;
  /**
   * Columns to leave out of the SELECT entirely. The view's way of showing
   * less: the statement narrows, not just the rendering. Filters and sort may
   * still reference them — WHERE and ORDER BY never needed the SELECT list.
   */
  hidden?: string[];
}

export interface CompiledQuery {
  sql: string;
  params: unknown[];
  countSql: string;
  countParams: unknown[];
  displaySql: string;
  tokens: SqlToken[];
  /** Column name → why its expression could not be compiled. */
  columnErrors: Record<string, string>;
  selected: ColumnMeta[];
}

/**
 * Build the SELECT for a table view.
 *
 * Formula and rollup columns become expressions in the SELECT list, which is
 * why they can also appear in WHERE and ORDER BY — the grid, its filters and
 * its sort are one statement, not a pipeline of post-processing steps.
 *
 * A column whose expression no longer compiles degrades to NULL and is
 * reported in `columnErrors`, so one broken formula cannot blank the grid.
 */
export function buildQuery(meta: MetaSnapshot, req: QueryRequest): CompiledQuery {
  const table = meta.tables[req.table];
  if (!table) throw badRequest(`No table named "${req.table}".`);

  const aliases = new Aliases();
  const rootAlias = aliases.next('t');
  const frame = new Frame();
  const scope: Scope = { table: table.name, alias: rootAlias, frame };
  const compiler = new Compiler(meta, aliases);

  const columnErrors: Record<string, string> = {};
  const selectParts: string[] = [`${rootAlias}."id" AS "id"`];
  const selectParams: unknown[] = [];
  const selected: ColumnMeta[] = [];
  const hidden = new Set(req.hidden ?? []);
  // For the display layer: computed columns whose expression the statement
  // spells out, so the SQL bar can fold each back into its name.
  const defs: SqlDef[] = [];

  for (const col of table.columns) {
    if (hidden.has(col.name)) continue;
    selected.push(col);
    if (isComputed(col.type)) {
      try {
        const f = compiler.compileSource(col.expr ?? '', scope);
        selectParts.push(`${f.sql} AS ${q(col.name)}`);
        selectParams.push(...f.params);
        addDef(defs, col.name, inlineParams(f.sql, f.params));
      } catch (err) {
        columnErrors[col.name] = messageOf(err);
        selectParts.push(`NULL AS ${q(col.name)}`);
      }
      continue;
    }

    selectParts.push(`${rootAlias}.${q(col.name)} AS ${q(col.name)}`);

    if (col.type === 'link' && col.target) {
      const target = meta.tables[col.target];
      if (target) {
        const join = frame.link(rootAlias, col.name, col.target, aliases);
        selectParts.push(`${join.alias}.${q(target.display)} AS ${q(col.name + LABEL_SUFFIX)}`);
      }
    }
  }

  const where = buildWhere(table, compiler, scope, req, rootAlias, defs);
  const order = buildOrderBy(table, compiler, scope, req.sort, rootAlias, defs);

  const limit = clamp(req.limit, 1, MAX_PAGE_SIZE);
  const offset = Math.max(0, Math.floor(req.offset));

  // Joins must be rendered after every expression has had a chance to add one.
  const fromSql = ` FROM ${q(table.name)} ${rootAlias}${frame.joinSql()}`;

  const sql =
    `SELECT ${selectParts.join(', ')}${fromSql}${where.sql}${order.sql}` +
    ` LIMIT ${limit} OFFSET ${offset}`;
  const params = [...selectParams, ...where.params, ...order.params];

  const countSql = `SELECT COUNT(*) AS n${fromSql}${where.sql}`;

  const displaySql = inlineParams(sql, params);

  return {
    sql,
    params,
    countSql,
    countParams: [...where.params],
    displaySql,
    tokens: displayTokens(displaySql, defs),
    columnErrors,
    selected,
  };
}

function buildWhere(
  table: TableSchema,
  compiler: Compiler,
  scope: Scope,
  req: QueryRequest,
  rootAlias: string,
  defs: SqlDef[],
): Fragment {
  const clauses: string[] = [];
  const params: unknown[] = [];

  if (req.rowId !== undefined) {
    clauses.push(`${rootAlias}."id" = ?`);
    params.push(req.rowId);
  }

  for (const filter of req.filters) {
    const col = findColumn(table, filter.col);
    if (!col) throw badRequest(`Cannot filter on "${filter.col}": no such column in "${table.name}".`);

    let expr: Fragment;
    try {
      expr = compiler.resolvePath([col.name], scope);
    } catch (err) {
      throw badRequest(`Cannot filter on "${filter.col}": ${messageOf(err)}`);
    }
    // A hidden computed column can still be filtered on; its definition must
    // reach the display layer from here or its fold would have no legend.
    if (isComputed(col.type)) addDef(defs, col.name, inlineParams(expr.sql, expr.params));
    params.push(...expr.params);

    const blank = `COALESCE(CAST(${expr.sql} AS TEXT), '')`;

    if (isUnaryOp(filter.op)) {
      clauses.push(filter.op === 'is empty' ? `${blank} = ''` : `${blank} <> ''`);
      continue;
    }

    const raw = filter.val ?? '';

    switch (filter.op) {
      case '=':
        clauses.push(`${expr.sql} IS ?`);
        params.push(coerceFilterValue(col, raw));
        break;
      case '≠':
        clauses.push(`${expr.sql} IS NOT ?`);
        params.push(coerceFilterValue(col, raw));
        break;
      case '>':
      case '<':
        clauses.push(`${comparable(col, expr.sql)} ${filter.op} ?`);
        params.push(coerceComparand(col, raw));
        break;
      case '≥':
        clauses.push(`${comparable(col, expr.sql)} >= ?`);
        params.push(coerceComparand(col, raw));
        break;
      case '≤':
        clauses.push(`${comparable(col, expr.sql)} <= ?`);
        params.push(coerceComparand(col, raw));
        break;
      case 'contains':
        clauses.push(`${blank} LIKE ? ESCAPE '\\'`);
        params.push(`%${escapeLike(raw)}%`);
        break;
      default:
        throw badRequest(`Unsupported filter operator "${String(filter.op)}".`);
    }
  }

  const search = req.search?.trim();
  if (search) {
    // Hidden columns do not participate: a match you cannot see is a row that
    // appears for no visible reason.
    const hidden = new Set(req.hidden ?? []);
    const searchable = table.columns.filter(
      (c) => !hidden.has(c.name) && c.type !== 'checkbox' && !(isComputed(c.type) && !c.expr),
    );
    const ors: string[] = [];
    for (const col of searchable) {
      let expr: Fragment;
      try {
        expr = compiler.resolvePath([col.name], scope);
      } catch {
        continue; // A broken formula simply does not participate in search.
      }
      if (isComputed(col.type)) addDef(defs, col.name, inlineParams(expr.sql, expr.params));
      params.push(...expr.params);
      ors.push(`COALESCE(CAST(${expr.sql} AS TEXT), '') LIKE ? ESCAPE '\\'`);
      params.push(`%${escapeLike(search)}%`);
    }
    if (ors.length) clauses.push(`(${ors.join(' OR ')})`);
    else clauses.push('0');
  }

  return clauses.length ? { sql: ` WHERE ${clauses.join(' AND ')}`, params } : { sql: '', params: [] };
}

function buildOrderBy(
  table: TableSchema,
  compiler: Compiler,
  scope: Scope,
  sort: Sort | null,
  rootAlias: string,
  defs: SqlDef[],
): Fragment {
  if (!sort) return { sql: ` ORDER BY ${rootAlias}."id"`, params: [] };

  const col = findColumn(table, sort.col);
  if (!col) throw badRequest(`Cannot sort by "${sort.col}": no such column in "${table.name}".`);

  let expr: Fragment;
  try {
    expr = compiler.resolvePath([col.name], scope);
  } catch (err) {
    throw badRequest(`Cannot sort by "${sort.col}": ${messageOf(err)}`);
  }
  if (isComputed(col.type)) addDef(defs, col.name, inlineParams(expr.sql, expr.params));

  const dir = sort.dir === 'desc' ? 'DESC' : 'ASC';
  // Numeric columns must not sort as text, and blanks belong at the end
  // regardless of direction. The id tiebreak keeps pagination stable.
  const key = isNumericColumn(col) ? `CAST(${expr.sql} AS REAL)` : expr.sql;
  return {
    sql: ` ORDER BY ${key} ${dir} NULLS LAST, ${rootAlias}."id" ASC`,
    params: expr.params,
  };
}

function comparable(col: ColumnMeta, sql: string): string {
  return isNumericColumn(col) ? `CAST(${sql} AS REAL)` : sql;
}

function coerceFilterValue(col: ColumnMeta, raw: string): CellValue {
  if (raw === '') return null;
  if (col.type === 'checkbox') return /^(1|true|yes|✓)$/i.test(raw) ? 1 : 0;
  if (isNumericColumn(col)) {
    const n = Number(raw);
    return Number.isFinite(n) ? n : raw;
  }
  return raw;
}

function coerceComparand(col: ColumnMeta, raw: string): CellValue {
  if (isNumericColumn(col)) {
    const n = Number(raw);
    if (!Number.isFinite(n)) throw badRequest(`"${raw}" is not a number, so it cannot be compared with "${col.name}".`);
    return n;
  }
  return raw;
}

function escapeLike(value: string): string {
  return value.replace(/[\\%_]/g, (m) => `\\${m}`);
}

function clamp(n: number, min: number, max: number): number {
  if (!Number.isFinite(n)) return min;
  return Math.min(max, Math.max(min, Math.floor(n)));
}

function messageOf(err: unknown): string {
  if (err instanceof FormulaError) return err.message;
  return err instanceof Error ? err.message : String(err);
}

/* ---- the aggregate row --------------------------------------------------- */

export interface SummaryRequest {
  table: string;
  filters: Filter[];
  search?: string;
  /** Column name → the function to compute under it. */
  summaries: Summaries;
  /** The grid's hidden columns, so a search scopes to the same rows it shows. */
  hidden?: string[];
}

export interface CompiledSummary {
  sql: string;
  params: unknown[];
  displaySql: string;
  tokens: SqlToken[];
  /** Columns that made it into the SELECT, in the order they appear there. */
  names: string[];
  /** Column name → why its summary could not be compiled. */
  errors: Record<string, string>;
}

/** Alias of the row count. Column names must start with a letter, so it cannot collide. */
const TOTAL_ALIAS = '__total';

/**
 * Build the one statement behind the aggregate row.
 *
 * It is the grid's own query with the SELECT list replaced: same table, same
 * joins, same WHERE, no ORDER BY and no page. That is the whole point — a
 * footer computed from the rows the browser happens to hold would change as you
 * scrolled, so the aggregate is taken by the database over everything the view
 * matches, in a single round trip for every column at once.
 *
 * A column that has been dropped, or whose expression stopped compiling, is
 * reported in `errors` and left out rather than taking the statement down with
 * it — the same bargain `buildQuery` makes for a broken formula.
 */
export function buildSummary(meta: MetaSnapshot, req: SummaryRequest): CompiledSummary {
  const table = meta.tables[req.table];
  if (!table) throw badRequest(`No table named "${req.table}".`);

  const aliases = new Aliases();
  const rootAlias = aliases.next('t');
  const frame = new Frame();
  const scope: Scope = { table: table.name, alias: rootAlias, frame };
  const compiler = new Compiler(meta, aliases);

  const errors: Record<string, string> = {};
  const names: string[] = [];
  const selectParts: string[] = [`COUNT(*) AS ${q(TOTAL_ALIAS)}`];
  const selectParams: unknown[] = [];
  const defs: SqlDef[] = [];

  for (const [name, fn] of Object.entries(req.summaries)) {
    const col = findColumn(table, name);
    if (!col) {
      errors[name] = `"${name}" is no longer a column in "${table.name}".`;
      continue;
    }
    if (!isAggAllowed(col, fn)) {
      errors[name] = `${aggLabel(fn, col)} does not apply to a ${col.type} column.`;
      continue;
    }

    let expr: Fragment;
    try {
      expr = compiler.resolvePath([col.name], scope);
    } catch (err) {
      errors[name] = messageOf(err);
      continue;
    }
    if (isComputed(col.type)) addDef(defs, col.name, inlineParams(expr.sql, expr.params));

    selectParts.push(`${aggregateSql(col, fn, expr.sql)} AS ${q(name)}`);
    selectParams.push(...expr.params);
    names.push(name);
  }

  const where = buildWhere(
    table,
    compiler,
    scope,
    {
      table: req.table,
      filters: req.filters,
      sort: null,
      search: req.search,
      hidden: req.hidden,
      limit: 1,
      offset: 0,
    },
    rootAlias,
    defs,
  );

  // Joins must be rendered after every expression has had a chance to add one.
  const sql =
    `SELECT ${selectParts.join(', ')} FROM ${q(table.name)} ${rootAlias}${frame.joinSql()}` +
    where.sql;
  const params = [...selectParams, ...where.params];
  const displaySql = inlineParams(sql, params);

  return { sql, params, displaySql, tokens: displayTokens(displaySql, defs), names, errors };
}

/**
 * The SQL for one function.
 *
 * Counting functions coalesce to zero because "none of them" is a number a
 * spreadsheet states plainly; `AVG`, `MIN` and `MAX` stay null over no rows,
 * because there is no average of nothing to state.
 */
function aggregateSql(col: ColumnMeta, fn: AggFn, sql: string): string {
  const num = `CAST(${sql} AS REAL)`;
  const blank = `COALESCE(CAST(${sql} AS TEXT), '')`;
  const counted = (when: string) => `COALESCE(SUM(CASE WHEN ${when} THEN 1 ELSE 0 END), 0)`;

  switch (fn) {
    case 'sum':
      return `COALESCE(SUM(${num}), 0)`;
    case 'avg':
      return `AVG(${num})`;
    case 'min':
    case 'max':
      // Text dates sort correctly as text; numbers must not sort as text.
      return `${fn.toUpperCase()}(${isNumericColumn(col) ? num : sql})`;
    case 'filled':
      return counted(`${blank} <> ''`);
    case 'empty':
      return col.type === 'checkbox'
        ? counted(`COALESCE(${num}, 0) = 0`)
        : counted(`${blank} = ''`);
    case 'unique':
      return `COUNT(DISTINCT ${sql})`;
    case 'checked':
      return counted(`COALESCE(${num}, 0) <> 0`);
  }
}

export function runSummary(db: Db, meta: MetaSnapshot, req: SummaryRequest): SummaryResponse {
  const compiled = buildSummary(meta, req);
  const raw =
    db.sqlite
      .prepare<unknown[], Record<string, unknown>>(compiled.sql)
      .get(...compiled.params) ?? {};

  const values: Record<string, CellValue> = {};
  for (const name of compiled.names) values[name] = scalar(raw[name]);

  return {
    total: Number(raw[TOTAL_ALIAS] ?? 0),
    values,
    errors: compiled.errors,
    sql: compiled.displaySql,
    tokens: compiled.tokens,
  };
}

/** An aggregate is a bare value: no column type to normalise it against. */
function scalar(v: unknown): CellValue {
  if (v === null || v === undefined) return null;
  if (typeof v === 'bigint') return Number(v);
  if (typeof v === 'number' || typeof v === 'string' || typeof v === 'boolean') return v;
  return String(v);
}

/** Reshape a raw SQLite row into the `{ values, labels }` wire format. */
export function shapeRow(raw: Record<string, unknown>, columns: ColumnMeta[]): Row {
  const values: Record<string, CellValue> = {};
  const labels: Record<string, string | null> = {};

  for (const col of columns) {
    const v = raw[col.name];
    values[col.name] = normalize(v, col);
    if (col.type === 'link') {
      const label = raw[col.name + LABEL_SUFFIX];
      labels[col.name] = label === null || label === undefined ? null : String(label);
    }
  }

  return { id: String(raw.id), values, labels };
}

function normalize(v: unknown, col: ColumnMeta): CellValue {
  if (v === null || v === undefined) return null;
  if (col.type === 'checkbox') return v === 1 || v === true || v === '1';
  if (typeof v === 'bigint') return Number(v);
  if (v instanceof Buffer) return v.toString('utf8');
  if (typeof v === 'number' || typeof v === 'string' || typeof v === 'boolean') return v;
  return String(v);
}

export interface RunQueryResult {
  rows: Row[];
  total: number;
  sql: string;
  tokens: SqlToken[];
  columnErrors: Record<string, string>;
}

export function runQuery(db: Db, meta: MetaSnapshot, req: QueryRequest): RunQueryResult {
  const compiled = buildQuery(meta, req);
  const raw = db.sqlite
    .prepare<unknown[], Record<string, unknown>>(compiled.sql)
    .all(...compiled.params);
  const countRow = db.sqlite
    .prepare<unknown[], { n: number }>(compiled.countSql)
    .get(...compiled.countParams);

  return {
    rows: raw.map((r) => shapeRow(r, compiled.selected)),
    total: countRow?.n ?? 0,
    sql: compiled.displaySql,
    tokens: compiled.tokens,
    columnErrors: compiled.columnErrors,
  };
}

/**
 * How many rows a view shows right now.
 *
 * A view *is* its filters, so the only honest count is the one the database
 * gives for those filters — the same WHERE the grid runs, without the page.
 *
 * A view whose filters stopped compiling counts as `null` rather than throwing:
 * dropping a column rewrites the views that mention it, but a formula can break
 * under one, and one broken view must not take the whole catalogue down.
 */
export function countView(db: Db, meta: MetaSnapshot, view: ViewDef): number | null {
  try {
    const compiled = buildQuery(meta, {
      table: view.table,
      filters: view.filters,
      sort: null,
      limit: 1,
      offset: 0,
    });
    const row = db.sqlite
      .prepare<unknown[], { n: number }>(compiled.countSql)
      .get(...compiled.countParams);
    return row?.n ?? 0;
  } catch {
    return null;
  }
}

/** The wire shape for `GET /api/meta`: view definitions plus live row counts. */
export function viewsWithCounts(db: Db, meta: MetaSnapshot): ViewMeta[] {
  return meta.views.map((v) => ({ ...v, rowCount: countView(db, meta, v) }));
}

/** Read one row through the same pipeline the grid uses, labels and all. */
export function fetchRow(db: Db, meta: MetaSnapshot, table: string, id: string): Row | null {
  const result = runQuery(db, meta, {
    table,
    filters: [],
    sort: null,
    limit: 1,
    offset: 0,
    rowId: id,
  });
  return result.rows[0] ?? null;
}

/** The human label for a row, used in audit lines and link chips. */
export function rowLabel(row: Row | null, table: TableSchema): string {
  if (!row) return '';
  const raw = row.values[table.display];
  if (raw === null || raw === undefined || raw === '') return row.id;
  return String(raw);
}