Spreadsheet-UX op een echte relationele engine
Deze repository wordt als momentopname gepubliceerd: een clone bevat de volledige inhoud, maar geen commitgeschiedenis.
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;
}