Spreadsheet-UX op een echte relationele engine

main

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

13.5 kB Ruw bestand
import {
  AGGREGATE_NAMES,
  FUNCTIONS_BY_NAME,
  FormulaError,
  isComputed,
  parseCached,
  q,
  type ColumnMeta,
  type Node,
} from '@wellformed/shared';
import { findColumn, type MetaSnapshot } from '../db/meta.js';

/**
 * Compiles the Wellformed formula language into SQLite expressions.
 *
 * This is what makes the SQL bar honest: a formula column is not evaluated in
 * JavaScript after the fact, it becomes part of the SELECT list, so the same
 * expression can also be filtered, sorted and aggregated by the database.
 *
 * Values from the expression are always bound, never interpolated. Identifiers
 * are interpolated, which is safe because every name passed the identifier
 * rules in `@wellformed/shared` before it was ever written to the catalogue.
 */

export interface Fragment {
  sql: string;
  params: unknown[];
}

const MAX_COLUMN_NESTING = 8;

export interface JoinNode {
  alias: string;
  targetTable: string;
  fromAlias: string;
  fromColumn: string;
}

/** One FROM clause: the outer query, or a rollup's correlated subquery. */
export class Frame {
  readonly joins: JoinNode[] = [];
  private readonly byKey = new Map<string, JoinNode>();

  link(fromAlias: string, fromColumn: string, targetTable: string, aliases: Aliases): JoinNode {
    const key = `${fromAlias}\u0000${fromColumn}`;
    const existing = this.byKey.get(key);
    if (existing) return existing;
    const node: JoinNode = { alias: aliases.next('j'), targetTable, fromAlias, fromColumn };
    this.byKey.set(key, node);
    this.joins.push(node);
    return node;
  }

  /** Joins render in creation order, so `a.b.c` always joins `a.b` first. */
  joinSql(): string {
    return this.joins
      .map(
        (j) =>
          ` LEFT JOIN ${q(j.targetTable)} ${j.alias} ON ${j.fromAlias}.${q(j.fromColumn)} = ${j.alias}."id"`,
      )
      .join('');
  }
}

export class Aliases {
  private n = 0;
  next(prefix: string): string {
    return `${prefix}${this.n++}`;
  }
}

export interface Scope {
  table: string;
  alias: string;
  frame: Frame;
}

export class Compiler {
  /** `table.Column` entries currently being expanded, for cycle detection. */
  private readonly stack: string[] = [];

  constructor(
    private readonly meta: MetaSnapshot,
    readonly aliases: Aliases = new Aliases(),
  ) {}

  /** Compile a raw expression source in the given scope. */
  compileSource(source: string, scope: Scope): Fragment {
    return this.compile(parseCached(source), scope);
  }

  compile(node: Node, scope: Scope): Fragment {
    switch (node.k) {
      case 'num':
        return { sql: String(node.v), params: [] };
      case 'bool':
        return { sql: node.v ? '1' : '0', params: [] };
      case 'null':
        return { sql: 'NULL', params: [] };
      case 'str':
        return { sql: '?', params: [node.v] };
      case 'path':
        return this.resolvePath(node.v, scope);
      case 'unary': {
        const v = this.compile(node.v, scope);
        return node.op === '-'
          ? { sql: `(-COALESCE(${v.sql}, 0))`, params: v.params }
          : { sql: `(NOT (${v.sql}))`, params: v.params };
      }
      case 'binary':
        return this.compileBinary(node, scope);
      case 'call':
        return this.compileCall(node, scope);
    }
  }

  private compileBinary(
    node: Extract<Node, { k: 'binary' }>,
    scope: Scope,
  ): Fragment {
    const l = this.compile(node.l, scope);
    const r = this.compile(node.r, scope);
    const params = [...l.params, ...r.params];

    switch (node.op) {
      case '+':
      case '-':
      case '*':
        // Spreadsheet arithmetic: an empty cell counts as zero rather than
        // poisoning the whole expression with NULL.
        return {
          sql: `(COALESCE(${l.sql}, 0) ${node.op} COALESCE(${r.sql}, 0))`,
          params,
        };
      case '/':
        // SQLite yields NULL for division by zero; `* 1.0` forces real division.
        return {
          sql: `((COALESCE(${l.sql}, 0) * 1.0) / COALESCE(${r.sql}, 0))`,
          params,
        };
      case '&':
        return {
          sql: `(COALESCE(CAST(${l.sql} AS TEXT), '') || COALESCE(CAST(${r.sql} AS TEXT), ''))`,
          params,
        };
      case '=':
        // `IS` rather than `=` so blank compares equal to blank.
        return { sql: `(${l.sql} IS ${r.sql})`, params };
      case '!=':
        return { sql: `(${l.sql} IS NOT ${r.sql})`, params };
      case '>':
      case '<':
      case '>=':
      case '<=':
        return { sql: `(${l.sql} ${node.op} ${r.sql})`, params };
      case 'AND':
        return { sql: `(${l.sql} AND ${r.sql})`, params };
      case 'OR':
        return { sql: `(${l.sql} OR ${r.sql})`, params };
    }
  }

  /** Resolve `Column`, `Link.Column`, `Link.Link.Column`, or `Link.id`. */
  resolvePath(parts: string[], scope: Scope): Fragment {
    let table = scope.table;
    let alias = scope.alias;

    for (let i = 0; i < parts.length; i++) {
      const segment = parts[i]!;
      const schema = this.meta.tables[table];
      if (!schema) throw new FormulaError(`Unknown table "${table}".`);
      const last = i === parts.length - 1;

      if (last) {
        if (segment === 'id') return { sql: `${alias}."id"`, params: [] };
        const col = findColumn(schema, segment);
        if (!col) throw new FormulaError(`"${table}" has no column named "${segment}".`);
        return this.columnRef(table, alias, col, scope.frame);
      }

      const col = findColumn(schema, segment);
      if (!col) throw new FormulaError(`"${table}" has no column named "${segment}".`);
      if (col.type !== 'link' || !col.target) {
        throw new FormulaError(
          `"${segment}" is a ${col.type} column, not a link, so "${parts.join('.')}" cannot follow it.`,
        );
      }
      const join = scope.frame.link(alias, col.name, col.target, this.aliases);
      alias = join.alias;
      table = col.target;
    }

    // Unreachable: the loop always returns on its final iteration.
    throw new FormulaError('Empty reference.');
  }

  private columnRef(table: string, alias: string, col: ColumnMeta, frame: Frame): Fragment {
    if (col.type === 'link') {
      // A bare link reference reads as the linked row's label, which is what
      // the grid shows and therefore what a filter on it should mean.
      if (!col.target) throw new FormulaError(`Link column "${col.name}" has no target table.`);
      const target = this.meta.tables[col.target];
      if (!target) throw new FormulaError(`Link column "${col.name}" points at a missing table.`);
      const join = frame.link(alias, col.name, col.target, this.aliases);
      return { sql: `${join.alias}.${q(target.display)}`, params: [] };
    }

    if (isComputed(col.type)) {
      const key = `${table}.${col.name}`;
      if (this.stack.includes(key)) {
        throw new FormulaError(`Circular reference: ${[...this.stack, key].join(' → ')}.`);
      }
      if (this.stack.length >= MAX_COLUMN_NESTING) {
        throw new FormulaError(
          `Computed columns are nested more than ${MAX_COLUMN_NESTING} deep at "${key}".`,
        );
      }
      if (!col.expr) throw new FormulaError(`Column "${key}" has no expression.`);
      this.stack.push(key);
      try {
        const inner = this.compile(parseCached(col.expr), { table, alias, frame });
        return { sql: `(${inner.sql})`, params: inner.params };
      } finally {
        this.stack.pop();
      }
    }

    return { sql: `${alias}.${q(col.name)}`, params: [] };
  }

  private compileCall(node: Extract<Node, { k: 'call' }>, scope: Scope): Fragment {
    const spec = FUNCTIONS_BY_NAME.get(node.name);
    if (!spec) {
      throw new FormulaError(
        `Unknown function ${node.name}(). Available: ${[...FUNCTIONS_BY_NAME.keys()].join(', ')}.`,
      );
    }
    if (node.args.length < spec.minArgs || node.args.length > spec.maxArgs) {
      throw new FormulaError(
        `${node.name}() takes ${describeArity(spec.minArgs, spec.maxArgs)}, but got ${node.args.length}. Signature: ${spec.signature}`,
      );
    }

    if (AGGREGATE_NAMES.has(node.name)) {
      const rollup = this.tryRollup(node, scope);
      if (rollup) return rollup;
      if (node.name === 'SUM' || node.name === 'COUNT' || node.name === 'AVG') {
        throw new FormulaError(
          `${node.name}() aggregates rows from a linked table; its first argument must name one, e.g. ${spec.signature}.`,
        );
      }
      if (node.args.length < 2) {
        throw new FormulaError(
          `${node.name}() needs either a linked column (${node.name}(orders.Total)) or two values to compare (${node.name}(a, b)).`,
        );
      }
    }

    const args = node.args.map((a) => this.compile(a, scope));
    const s = args.map((a) => a.sql);
    const params = args.flatMap((a) => a.params);
    const text = (x: string) => `COALESCE(CAST(${x} AS TEXT), '')`;

    switch (node.name) {
      case 'IF':
        return { sql: `(CASE WHEN ${s[0]} THEN ${s[1]} ELSE ${s[2] ?? 'NULL'} END)`, params };
      case 'COALESCE':
        return { sql: `COALESCE(${s.join(', ')})`, params };
      case 'ISBLANK':
        return { sql: `(${text(s[0]!)} = '')`, params };
      case 'ROUND':
        return { sql: `ROUND(${s[0]}${s[1] ? `, ${s[1]}` : ''})`, params };
      case 'ABS':
        return { sql: `ABS(${s[0]})`, params };
      case 'MIN':
      case 'MAX':
        return { sql: `${node.name}(${s.join(', ')})`, params };
      case 'CONCAT':
        return { sql: `(${s.map(text).join(' || ')})`, params };
      case 'UPPER':
      case 'LOWER':
      case 'TRIM':
        return { sql: `${node.name}(${s[0]})`, params };
      case 'LENGTH':
        return { sql: `LENGTH(${text(s[0]!)})`, params };
      case 'TODAY':
        return { sql: `date('now')`, params: [] };
      case 'NOW':
        return { sql: `datetime('now')`, params: [] };
      case 'DATEDIFF':
        return {
          sql: `CAST(julianday(${s[0]}) - julianday(${s[1]}) AS INTEGER)`,
          params,
        };
      case 'YEAR':
        return { sql: `CAST(strftime('%Y', ${s[0]}) AS INTEGER)`, params };
      case 'MONTH':
        return { sql: `CAST(strftime('%m', ${s[0]}) AS INTEGER)`, params };
      case 'DAY':
        return { sql: `CAST(strftime('%d', ${s[0]}) AS INTEGER)`, params };
      default:
        throw new FormulaError(`Function ${node.name}() is declared but not implemented.`);
    }
  }

  /**
   * `SUM(orders.Total)` becomes a correlated subquery over the rows of
   * `orders` whose link column points back at the current row. Returns null
   * when the call is not shaped like a rollup, so MIN/MAX can fall through to
   * their scalar meaning.
   */
  private tryRollup(node: Extract<Node, { k: 'call' }>, scope: Scope): Fragment | null {
    const first = node.args[0];
    if (!first || first.k !== 'path') return null;

    const [head, ...rest] = first.v;
    if (!head) return null;
    const target = this.meta.tables[head];
    if (!target) return null;

    let backLink: ColumnMeta | undefined;
    const candidates = target.columns.filter((c) => c.type === 'link' && c.target === scope.table);
    const second = node.args[1];

    if (second) {
      if (second.k !== 'str') {
        throw new FormulaError(
          `The second argument to ${node.name}() must be a quoted link column name, e.g. ${node.name}(${head}.Total, "Customer").`,
        );
      }
      backLink = candidates.find((c) => c.name === second.v);
      if (!backLink) {
        throw new FormulaError(
          `"${head}.${second.v}" is not a link column pointing at "${scope.table}".`,
        );
      }
    } else if (candidates.length === 0) {
      throw new FormulaError(
        `"${head}" has no link column pointing at "${scope.table}", so ${node.name}() has nothing to group by.`,
      );
    } else if (candidates.length > 1) {
      throw new FormulaError(
        `"${head}" links to "${scope.table}" more than once (${candidates
          .map((c) => c.name)
          .join(', ')}). Say which to use: ${node.name}(${head}.…, "${candidates[0]!.name}").`,
      );
    } else {
      backLink = candidates[0]!;
    }

    const subAlias = this.aliases.next('r');
    const subFrame = new Frame();
    const subScope: Scope = { table: head, alias: subAlias, frame: subFrame };

    let inner: Fragment | null = null;
    if (rest.length) {
      inner = this.resolvePath(rest, subScope);
    } else if (node.name !== 'COUNT') {
      throw new FormulaError(`${node.name}(${head}) needs a column, e.g. ${node.name}(${head}.Total).`);
    }

    const agg =
      node.name === 'COUNT'
        ? inner
          ? `COUNT(${inner.sql})`
          : 'COUNT(*)'
        : `${node.name}(${inner!.sql})`;

    // An empty set sums to zero and counts to zero; min/max/avg stay blank.
    const wrapped = node.name === 'SUM' || node.name === 'COUNT' ? `COALESCE(${agg}, 0)` : agg;

    const sql =
      `(SELECT ${wrapped} FROM ${q(head)} ${subAlias}${subFrame.joinSql()}` +
      ` WHERE ${subAlias}.${q(backLink.name)} = ${scope.alias}."id")`;

    return { sql, params: inner?.params ?? [] };
  }
}

function describeArity(min: number, max: number): string {
  if (min === max) return `${min} argument${min === 1 ? '' : 's'}`;
  return `${min}–${max} arguments`;
}

/**
 * Type-check an expression against the catalogue without running it.
 * Returns the error message, or null when it compiles.
 */
export function validateExpression(meta: MetaSnapshot, table: string, source: string): string | null {
  try {
    const compiler = new Compiler(meta);
    compiler.compileSource(source, { table, alias: 't0', frame: new Frame() });
    return null;
  } catch (err) {
    if (err instanceof FormulaError) return err.message;
    throw err;
  }
}