Spreadsheet-UX op een echte relationele engine
Deze repository wordt als momentopname gepubliceerd: een clone bevat de volledige inhoud, maar geen commitgeschiedenis.
import {
isComputed,
q,
type CellValue,
type ColumnMeta,
type Reading,
type StoredColumnType,
type TightenPlan,
type TightenProblem,
type TightenProblemKind,
type Tightening,
} from '@wellformed/shared';
import { badRequest, conflict, notFound } from '../errors.js';
import { invalidateMeta, transact, type Db } from '../db/driver.js';
import { findColumn, getTable, loadMeta, type TableSchema } from '../db/meta.js';
import { recordAudit, type Actor } from './audit.js';
import { FALSE_WORDS, TRUE_WORDS } from './import.js';
import { inferReading, readDate, readNumber } from './reading.js';
import { dropSqlViews } from './sql-views.js';
import { coerceForColumn, sqlTypeFor } from './values.js';
// The readers moved to `reading.ts` when the importer started using them too;
// re-exported so a caller of the tightening keeps finding them here.
export { readDate, readNumber } from './reading.js';
/**
* Tightening a column.
*
* A spreadsheet arrives with the loosest schema a table can have. One `n/a` in
* a thousand amounts makes the whole column text, and from then on nothing can
* be summed, sorted or trusted. The importer is right to be timid — it is
* guessing, at speed, about a file nobody has looked at yet — so the tightening
* has to happen afterwards, when somebody is looking.
*
* That is what this module is: `ALTER TABLE` with a dry run in front of it.
* `planTighten` reads the whole column, works out exactly which rows would not
* fit the proposed constraint and says so by row and by value; `applyTighten`
* does it, in one transaction, and refuses if anything the plan reported is
* still outstanding. Nothing here guesses on the operator's behalf without
* showing its guess first — how a number's decimals are marked and which end of
* a slashed date the day is on are both offered as answers that can be
* overruled, because neither can be read off a value on its own.
*
* The constraints themselves are ordinary SQL. A type is the affinity of a real
* column, so changing one rebuilds it; unique is a real `UNIQUE INDEX`, so the
* database refuses the second Acme Ltd rather than the server remembering to
* look. Required is the exception and says so: SQLite cannot add `NOT NULL` to
* a column in place, so it stays a check the server runs on every write — but
* it is no longer a promise about the future only, because it cannot be turned
* on until the rows already there keep it.
*/
/** Offenders shown per problem. Enough to recognise the pattern, not a report. */
const SAMPLES = 6;
/** Values are only offered as select options when there are this few of them. */
const MAX_OPTION_OFFER = 25;
/** Rewrites shown, so a conversion can be checked before it happens. */
const MAX_REWRITES = 6;
/** The scratch column a rebuild copies through. `_` puts it out of reach of any real name. */
const SCRATCH = '_wf_tighten';
/** The unique index behind `column.unique`. Named so it can be found again. */
export function uniqueIndexName(table: string, column: string): string {
return `_uq_${table}_${column}`;
}
/** One row's worth of the column being tightened. */
interface Cell {
id: string;
/** The row's display value, so a problem reads as a row rather than an id. */
label: string;
/** What is stored now. */
value: CellValue;
/** How it reads now — for a link, the label of the row it points at. */
text: string;
}
/** A value after conversion, or the reason it has none. */
type Read =
| { ok: true; value: CellValue }
| { ok: false; kind: TightenProblemKind; message: string };
// ---- reading what is there ------------------------------------------------
/**
* The whole column, with each row's label beside it.
*
* Read in full rather than sampled: a dry run that says "probably fine" is not
* a dry run. A link is read through to its target's display value, because that
* is what the column looks like on screen and what it must still say if it is
* converted to text.
*/
function readCells(db: Db, table: TableSchema, col: ColumnMeta): Cell[] {
const label = q(table.display);
const name = q(col.name);
if (col.type === 'link' && col.target) {
const target = loadMeta(db).tables[col.target];
if (target) {
const rows = db.sqlite
.prepare<[], { id: string; lbl: CellValue; val: CellValue; txt: CellValue }>(
`SELECT r."id" AS id, r.${label} AS lbl, r.${name} AS val, t.${q(target.display)} AS txt
FROM ${q(table.name)} r
LEFT JOIN ${q(col.target)} t ON t."id" = r.${name}`,
)
.all();
return rows.map((r) => ({
id: r.id,
label: display(r.lbl, r.id),
value: r.val,
text: r.txt === null || r.txt === undefined ? '' : String(r.txt),
}));
}
}
const rows = db.sqlite
.prepare<[], { id: string; lbl: CellValue; val: CellValue }>(
`SELECT "id" AS id, ${label} AS lbl, ${name} AS val FROM ${q(table.name)}`,
)
.all();
return rows.map((r) => ({
id: r.id,
label: display(r.lbl, r.id),
value: r.val,
text: asText(r.val, col),
}));
}
function display(value: CellValue, fallback: string): string {
if (value === null || value === undefined || value === '') return fallback;
return String(value);
}
function asText(value: CellValue, col: ColumnMeta): string {
if (value === null || value === undefined) return '';
if (col.type === 'checkbox') return value ? 'true' : 'false';
return String(value);
}
// ---- converting one cell --------------------------------------------------
/**
* What one cell becomes under the proposed column, or why it cannot.
*
* The lenient part is here and only here: a migration somebody is watching may
* read `€ 1.234,50` as a number, where an ordinary edit may not. Whatever comes
* out is then handed to `coerceForColumn` — the same gate every edit passes
* through — so this can never write a value the grid would later refuse.
*/
function readCell(
db: Db,
next: ColumnMeta,
cell: Cell,
reading: Required<Reading>,
linkIndex: Map<string, string | null> | null,
): Read {
const text = cell.text.trim();
if (text === '') {
if (next.required) {
return { ok: false, kind: 'blank', message: `"${next.name}" would be required, and this row is blank.` };
}
return { ok: true, value: next.type === 'checkbox' ? 0 : null };
}
let normalized: unknown = text;
switch (next.type) {
case 'number':
case 'currency': {
const n = readNumber(text, reading.decimal);
if (n === null) return { ok: false, kind: 'unreadable', message: 'not a number' };
normalized = n;
break;
}
case 'date': {
const d = readDate(text, reading.dayFirst);
if (d === null) return { ok: false, kind: 'unreadable', message: 'not a date' };
normalized = d;
break;
}
case 'checkbox': {
const word = text.toLowerCase();
if (TRUE_WORDS.has(word)) normalized = 1;
else if (FALSE_WORDS.has(word)) normalized = 0;
else return { ok: false, kind: 'unreadable', message: 'not a yes or a no' };
break;
}
case 'select': {
const options = next.options ?? [];
// No list yet is a question, not a failure — the plan's job here is to
// hand back the values so a list can be made out of them.
if (options.length) {
// Case and spacing are how the same answer gets typed twice; they
// should not cost a row. Anything else the list does not have.
const match = options.find((o) => o.toLowerCase() === text.toLowerCase());
if (!match) return { ok: false, kind: 'unknown_option', message: 'not one of the options' };
normalized = match;
}
break;
}
case 'link': {
const hit = linkIndex?.get(text.toLowerCase());
if (hit === undefined) {
return { ok: false, kind: 'no_match', message: `no ${next.target} row is called this` };
}
if (hit === null) {
return { ok: false, kind: 'no_match', message: `more than one ${next.target} row is called this` };
}
normalized = hit;
break;
}
default:
break;
}
try {
return { ok: true, value: coerceForColumn(db, next, normalized) };
} catch (error) {
return { ok: false, kind: 'unreadable', message: error instanceof Error ? error.message : 'cannot be stored' };
}
}
/**
* The target table by display value, for matching text against it.
*
* A column of company names is a foreign key nobody got to declare, and this is
* what turns it into one. Names that more than one row answers to map to null:
* an ambiguous match is a wrong link, and a wrong link is worse than a refusal.
* Row ids are accepted too, so a column that already holds them still converts.
*/
function buildLinkIndex(db: Db, targetName: string): Map<string, string | null> {
const meta = loadMeta(db);
const target = meta.tables[targetName];
if (!target) throw notFound(`No table named "${targetName}" to link to.`);
const index = new Map<string, string | null>();
const rows = db.sqlite
.prepare<[], { id: string; lbl: CellValue }>(
`SELECT "id" AS id, ${q(target.display)} AS lbl FROM ${q(targetName)}`,
)
.all();
for (const row of rows) {
index.set(row.id.toLowerCase(), row.id);
const label = row.lbl === null || row.lbl === undefined ? '' : String(row.lbl).trim().toLowerCase();
if (!label) continue;
index.set(label, index.has(label) ? null : row.id);
}
return index;
}
// ---- the plan -------------------------------------------------------------
/**
* The column as it would be if the proposal were applied.
*
* `strict` is the difference between planning and applying. A half-finished
* proposal — "make it a select", before any options have been named — is a
* perfectly good question to ask the dry run, and the answer to it is the list
* of values already in the column. Only the apply insists on a finished one.
*/
function proposedColumn(current: ColumnMeta, input: Tightening, strict: boolean): ColumnMeta {
const type = (input.type ?? current.type) as StoredColumnType;
const next: ColumnMeta = {
...current,
type,
required: input.required ?? current.required ?? false,
unique: input.unique ?? current.unique ?? false,
};
if (type === 'select') {
const options = (input.options ?? current.options ?? []).map((o) => o.trim()).filter(Boolean);
if (!options.length && strict) throw badRequest('A select column needs at least one option.');
if (options.length > 100) throw badRequest('A select column is limited to 100 options.');
next.options = [...new Set(options)];
} else {
delete next.options;
}
if (type === 'link') {
const target = input.target ?? current.target;
if (!target) throw badRequest('A link column needs a target table.');
next.target = target;
} else {
delete next.target;
}
// A type change starts the format over, so a date does not keep printing as
// money because the column used to be money.
if (input.type && input.type !== current.type) {
next.format = null;
}
return next;
}
/** Everything a tightening is not allowed to be, checked before any data is read. */
function assertAllowed(table: TableSchema, current: ColumnMeta, next: ColumnMeta): void {
if (isComputed(current.type)) {
throw badRequest(`"${current.label}" is computed; its type is its expression.`);
}
if (isComputed(next.type)) {
throw badRequest('A stored column cannot be turned into a formula. Add a formula column instead.');
}
if (next.type === 'link' && table.display === current.name) {
throw badRequest(`"${current.label}" labels this table's rows, so it cannot become a link.`);
}
// A self-link is deliberately not refused here: `employees.Manager` pointing
// at `employees` is a real shape, `addColumn` already allows it, and a
// tightening that refused what a new column allows would be a second policy.
if (next.unique && next.type === 'checkbox') {
throw badRequest('A checkbox has two values, so at most two rows could exist. Unique does not apply.');
}
}
/** The plan, and the value each row would end up with. */
interface Evaluation {
current: ColumnMeta;
next: ColumnMeta;
plan: TightenPlan;
/** One entry per row, in table order. `null` where the value would not convert. */
values: { id: string; value: CellValue; ok: boolean }[];
}
/**
* Read the column, convert every row, and count what happens.
*
* One pass answers both questions — what the plan says and what the apply
* writes — so the two can never disagree about a row.
*/
function evaluate(
db: Db,
tableName: string,
columnName: string,
input: Tightening,
strict: boolean,
): Evaluation {
const meta = loadMeta(db);
const table = getTable(meta, tableName);
const current = findColumn(table, columnName);
if (!current) throw notFound(`"${tableName}" has no column "${columnName}".`);
const next = proposedColumn(current, input, strict);
assertAllowed(table, current, next);
const cells = readCells(db, table, current);
const guessed = inferReading(cells.map((c) => c.text));
const reading: Required<Reading> = {
decimal: input.reading?.decimal ?? guessed.reading.decimal,
dayFirst: input.reading?.dayFirst ?? guessed.reading.dayFirst,
};
const linkIndex = next.type === 'link' && next.target ? buildLinkIndex(db, next.target) : null;
const converting = next.type !== current.type;
const problems = new Map<string, TightenProblem>();
const rewrites: TightenPlan['rewrites'] = [];
const seen = new Map<string, string>();
const duplicates: TightenProblem = {
kind: 'duplicate',
message: 'already used by another row',
count: 0,
samples: [],
clearable: false,
};
const values: Evaluation['values'] = [];
let fit = 0;
for (const cell of cells) {
const read = readCell(db, next, cell, reading, linkIndex);
if (!read.ok) {
note(problems, read.kind, read.message, cell);
// Clearing is the only remedy this can offer, and a cleared checkbox is
// unticked rather than absent — the column has no room for absent.
values.push({ id: cell.id, value: next.type === 'checkbox' ? 0 : null, ok: false });
continue;
}
if (next.unique && read.value !== null && read.value !== '') {
// The same comparison SQLite is about to make: on the stored value, not
// on the text it was written as.
const key = String(read.value);
if (seen.has(key)) {
duplicates.count++;
if (duplicates.samples.length < SAMPLES) {
duplicates.samples.push({ id: cell.id, label: cell.label, value: cell.text });
}
values.push({ id: cell.id, value: read.value, ok: false });
continue;
}
seen.set(key, cell.id);
}
// Not for a link: what it becomes is a row id, and `Acme Ltd → cus_7f2k`
// tells a reader nothing they can check. There the meaning is which names
// found a row and which did not, and the problems carry that.
if (converting && next.type !== 'link' && rewrites.length < MAX_REWRITES && cell.text !== '') {
const after = read.value === null ? '∅' : String(read.value);
if (after !== cell.text) rewrites.push({ from: cell.text, to: after });
}
values.push({ id: cell.id, value: read.value, ok: true });
fit++;
}
if (duplicates.count > 0) problems.set('duplicate', duplicates);
return {
current,
next,
values,
plan: {
total: cells.length,
fit,
problems: [...problems.values()],
rewrites,
distinct: distinctValues(cells),
reading,
ambiguousDates: guessed.ambiguousDates && next.type === 'date',
sql: tightenSql(table, current, next, input.onProblem ?? 'stop'),
},
};
}
export function planTighten(
db: Db,
tableName: string,
columnName: string,
input: Tightening,
): TightenPlan {
return evaluate(db, tableName, columnName, input, false).plan;
}
function note(
problems: Map<string, TightenProblem>,
kind: TightenProblemKind,
message: string,
cell: Cell,
): void {
const key = `${kind}:${message}`;
let problem = problems.get(key);
if (!problem) {
problem = {
kind,
message,
count: 0,
samples: [],
// Emptying a cell settles a value that will not convert. It cannot settle
// a blank one — that is what blank means — and it cannot settle a
// duplicate without deciding which row loses its value.
clearable: kind !== 'blank' && kind !== 'duplicate',
};
problems.set(key, problem);
}
problem.count++;
if (problem.samples.length < SAMPLES) {
problem.samples.push({ id: cell.id, label: cell.label, value: cell.text });
}
}
/**
* The distinct values, when they are a list of answers rather than a list of
* values that happen to be short.
*
* The offer this exists for: a column with six answers in three thousand rows
* is a select somebody had no way to declare. A column of eight invoice
* amounts, each appearing once, is not — it is eight numbers, and offering to
* freeze them into a dropdown would be this product misreading its own data at
* the exact moment it claims to be reading it. So the values have to repeat:
* on average twice over, which no column of distinct measurements ever does.
*/
function distinctValues(cells: Cell[]): TightenPlan['distinct'] {
const counts = new Map<string, number>();
let present = 0;
for (const cell of cells) {
const text = cell.text.trim();
if (!text) continue;
present++;
counts.set(text, (counts.get(text) ?? 0) + 1);
if (counts.size > MAX_OPTION_OFFER) return null;
}
if (counts.size === 0 || counts.size * 2 > present) return null;
return [...counts.entries()]
.map(([value, count]) => ({ value, count }))
.sort((a, b) => b.count - a.count || a.value.localeCompare(b.value));
}
// ---- the SQL it comes to --------------------------------------------------
/**
* The statements this would run.
*
* Shown for the same reason the grid shows its SELECT: this product's claim is
* that there is a real database underneath, and a claim like that is worth
* exactly as much as what it lets you read. The per-row copy is one prepared
* statement run once per row, and is written that way rather than pretended
* into a single UPDATE it is not.
*/
function tightenSql(
table: TableSchema,
current: ColumnMeta,
next: ColumnMeta,
onProblem: 'stop' | 'clear',
): string {
const lines: string[] = [];
const t = q(table.name);
const name = q(current.name);
if (current.unique) lines.push(`DROP INDEX ${q(uniqueIndexName(table.name, current.name))};`);
if (next.type !== current.type || next.target !== current.target) {
lines.push(
`ALTER TABLE ${t} ADD COLUMN ${q(SCRATCH)} ${declarationFor(next)};`,
`UPDATE ${t} SET ${q(SCRATCH)} = ? WHERE "id" = ?; -- once per row${
onProblem === 'clear' ? ', NULL where it would not convert' : ''
}`,
`ALTER TABLE ${t} DROP COLUMN ${name};`,
`ALTER TABLE ${t} RENAME COLUMN ${q(SCRATCH)} TO ${name};`,
);
} else if (onProblem === 'clear') {
lines.push(`UPDATE ${t} SET ${name} = NULL WHERE "id" = ?; -- once per row that will not convert`);
}
if (next.unique) {
lines.push(`CREATE UNIQUE INDEX ${q(uniqueIndexName(table.name, current.name))} ON ${t} (${name});`);
}
if (next.required !== (current.required ?? false)) {
// Said plainly rather than shown as SQL that will not be run: SQLite cannot
// add NOT NULL to a column in place, and pretending otherwise here would
// undo the only reason to print any of this.
lines.push(
next.required
? `-- ${current.name} becomes required: checked by the server on every write, because SQLite cannot add NOT NULL in place.`
: `-- ${current.name} stops being required.`,
);
}
return lines.length ? lines.join('\n') : '-- Nothing to do.';
}
function declarationFor(col: ColumnMeta): string {
if (col.type === 'link' && col.target) {
return `TEXT REFERENCES ${q(col.target)}("id") ON DELETE SET NULL`;
}
// Exactly what `addColumn` would have declared, so a column that was
// tightened into a type is indistinguishable from one created as it.
return sqlTypeFor(col.type);
}
// ---- doing it -------------------------------------------------------------
export interface TightenResult {
column: ColumnMeta;
plan: TightenPlan;
/** Rows whose value was emptied because it would not convert. */
cleared: number;
}
export function applyTighten(
db: Db,
tableName: string,
columnName: string,
input: Tightening,
actor: Actor,
): TightenResult {
const { current, next, plan, values } = evaluate(db, tableName, columnName, input, true);
const onProblem = input.onProblem ?? 'stop';
// Refused on the plan rather than on the first failing row, so the answer is
// the whole truth about the column and not the first thing that broke.
const blocking = plan.problems.filter((p) => onProblem === 'stop' || !p.clearable);
if (blocking.length) {
throw conflict(
`${blocking.reduce((n, p) => n + p.count, 0)} of ${plan.total} rows do not fit: ${blocking
.map((p) => `${p.count} ${p.message}`)
.join('; ')}.`,
);
}
const rebuilding = next.type !== current.type || next.target !== current.target;
const cleared = values.filter((v) => !v.ok).length;
if (
!rebuilding &&
!cleared &&
(next.required ?? false) === (current.required ?? false) &&
(next.unique ?? false) === (current.unique ?? false) &&
sameOptions(current.options, next.options)
) {
return { column: current, plan, cleared: 0 };
}
transact(db, () => {
/**
* The derived `v_*` views select this column by name, and SQLite refuses to
* drop a column any other part of the schema mentions. They are rebuilt
* from the catalogue on demand, so dropping them here costs nothing and is
* the difference between this working and failing on a workspace an
* assistant has read from.
*/
dropSqlViews(db);
if (current.unique) {
db.sqlite.exec(`DROP INDEX IF EXISTS ${q(uniqueIndexName(tableName, columnName))}`);
}
if (rebuilding) {
db.sqlite.exec(`ALTER TABLE ${q(tableName)} ADD COLUMN ${q(SCRATCH)} ${declarationFor(next)}`);
const write = db.sqlite.prepare(
`UPDATE ${q(tableName)} SET ${q(SCRATCH)} = ? WHERE "id" = ?`,
);
for (const row of values) write.run(row.value, row.id);
db.sqlite.exec(`ALTER TABLE ${q(tableName)} DROP COLUMN ${q(columnName)}`);
db.sqlite.exec(
`ALTER TABLE ${q(tableName)} RENAME COLUMN ${q(SCRATCH)} TO ${q(columnName)}`,
);
} else if (cleared > 0) {
const write = db.sqlite.prepare(`UPDATE ${q(tableName)} SET ${q(columnName)} = ? WHERE "id" = ?`);
for (const row of values) if (!row.ok) write.run(row.value, row.id);
}
if (next.unique) {
db.sqlite.exec(
`CREATE UNIQUE INDEX ${q(uniqueIndexName(tableName, columnName))} ON ${q(tableName)} (${q(columnName)})`,
);
}
db.sqlite
.prepare(
`UPDATE _wellformed_column SET type = ?, options = ?, target_table = ?, format = ?, required = ?, uniq = ?, sequence = ?
WHERE table_name = ? AND name = ?`,
)
.run(
next.type,
next.options ? JSON.stringify(next.options) : null,
next.target ?? null,
next.format ?? null,
next.required ? 1 : 0,
next.unique ? 1 : 0,
// Numbering writes text; a column converted to anything else sheds it.
next.type === 'text' ? (current.sequence ?? null) : null,
tableName,
columnName,
);
// A view that filtered on "Amount = 1,200" was filtering on text; once the
// column is a number that filter matches nothing and looks like data loss.
// The filter goes rather than the rows, and the audit line says so.
const strandedFilters = rebuilding ? stripFilters(db, tableName, columnName) : 0;
recordAudit(db, actor, {
kind: 'schema',
table: tableName,
column: columnName,
old: describe(current),
nw:
describe(next) +
(cleared ? ` — cleared ${cleared} value${cleared === 1 ? '' : 's'}` : '') +
(strandedFilters ? ` — dropped ${strandedFilters} stranded filter${strandedFilters === 1 ? '' : 's'}` : ''),
});
});
invalidateMeta(db);
const updated = findColumn(getTable(loadMeta(db), tableName), columnName);
if (!updated) throw notFound(`Column "${columnName}" disappeared while it was being tightened.`);
return { column: updated, plan, cleared };
}
function sameOptions(a: string[] | undefined, b: string[] | undefined): boolean {
if (!a && !b) return true;
if (!a || !b) return false;
return a.length === b.length && a.every((v, i) => v === b[i]);
}
/** Filters that were written against the old type, and now match nothing. */
function stripFilters(db: Db, tableName: string, columnName: string): number {
const views = loadMeta(db).views.filter(
(v) => v.table === tableName && v.filters.some((f) => f.col === columnName),
);
let dropped = 0;
for (const view of views) {
const kept = view.filters.filter((f) => f.col !== columnName);
dropped += view.filters.length - kept.length;
db.sqlite.prepare('UPDATE _wellformed_view SET filters = ? WHERE id = ?').run(JSON.stringify(kept), view.id);
}
return dropped;
}
/** How a column's constraints read in the audit trail. */
function describe(col: ColumnMeta): string {
const parts = [col.type === 'link' ? `link → ${col.target}` : col.type];
if (col.type === 'select' && col.options) parts.push(`(${col.options.join(' | ')})`);
if (col.required) parts.push('required');
if (col.unique) parts.push('unique');
return parts.join(' ');
}