diff --git a/CHANGELOG.md b/CHANGELOG.md
index 204ffe4..2b5c841 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,23 @@ Versions follow [Semantic Versioning](https://semver.org/) — minor bump per su
---
+## [Unreleased] — v1.3.0 in progress
+
+### Added
+- `LOOKUP` column qualifier — language grammar and storage layer (#58). Any column can
+ declare a constraint on its legal values: `LOOKUP
. [DISPLAY ]`
+ for a live table-driven lookup, or `LOOKUP ("a","b",...)` for a literal list. Parsed by
+ `CREATE TABLE`/`ALTER TABLE ADD`/`ALTER TABLE ALTER`, persisted per-column in
+ `ColumnMetaStore` via an additive migration (existing declared types are never touched
+ or dropped), and resolvable to concrete `{value,label}` options via the new
+ `src/interpreter/LookupResolver.ts` (degrades to free entry — never truncates — when the
+ source is missing, empty, or exceeds 1000 distinct values). This is a WebBase-III
+ extension with no dBASE III ancestor.
+ **This PR is grammar + storage only** — nothing enforces or renders a `LOOKUP` yet.
+ Membership enforcement in `BROWSE`/`REPLACE` and the form/grid picker UI ship in
+ follow-up PRs (#59, #60) before the feature is documented in the README command
+ reference.
+
## [1.2.0] — 2026-07-09 — TIME columns, WEEK()/DATEADD(), BROWSE cell validation, Overtime demo
### Added
diff --git a/CLAUDE.md b/CLAUDE.md
index 841a324..7f7c0bd 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -63,6 +63,9 @@ src/
Executor.ts Async AST runner; manages state (db/table/filter/vars/rowPtr/activeIndex). Emits fire-and-forget client side-effects (CSV download, report preview, CSV upload picker) via onSideEffect so they work inside program blocks
IndexCommands.ts Index command handlers (extracted from Executor)
ReportCommands.ts Report command handlers delegating to ReportRunner
+ LookupResolver.ts Resolves a column's LOOKUP constraint (literal list, or live table+column+DISPLAY)
+ to concrete {value,label} options against IDatabaseBridge; degrades to null
+ (never truncates) on a missing source, empty result, or >1000 distinct values
terminal/
Terminal.ts REPL UI — command history, multi-line block accumulation
@@ -194,6 +197,25 @@ WebBase-III supports **unlimited work areas** (no DOS 10-area limit). Cross-area
Declared types are recorded per `(database, table, column)` in `server/ColumnMetaStore.ts`, because SQLite only keeps a storage affinity: `TIME`/`DATE`/`CHAR` are all `TEXT`, `LOGICAL`/`INT` are both `INTEGER`, and a `NUM(p,s)` qualifier is lost entirely.
+#### `LOOKUP` column qualifier (in progress — v1.3.0)
+
+Any column may add a `LOOKUP` clause after its type, constraining it to a set of legal
+values — a WebBase-III extension with no dBASE III ancestor (dBASE III+ only had
+`PICTURE "@M a,b,c"`, a literal spacebar-cycled list with no table-driven form):
+
+```
+SCHEDID CHAR(4) LOOKUP SCHEDULES.SCHEDID DISPLAY DESCR -- live table lookup
+STAGE CHAR(12) LOOKUP ("Lead","Won","Lost") -- literal list
+```
+
+`CREATE TABLE`/`ALTER TABLE ADD`/`ALTER TABLE ALTER` parse and store it (`ColumnMetaStore`,
+same additive-migration discipline as the type columns above). `src/interpreter/LookupResolver.ts`
+turns a stored `LOOKUP` into concrete `{value,label}` options against the live database,
+degrading to `null` (never truncating) when the source is missing, empty, or exceeds 1000
+distinct values. **Not yet enforced or rendered anywhere** — BROWSE/`REPLACE` membership
+checking and the form/grid picker UI land in follow-up PRs before this section is promoted
+to the README command reference.
+
#### Cell validation (`BROWSE`)
`src/shared/cellValidation.ts` holds the rules and runs on **both** sides: `Grid.ts` checks before commit (an invalid edit keeps the cell in edit mode, outlined red, with the reason shown; the error clears as the value becomes valid), and `Session`'s `grid-edit` handler re-checks authoritatively before writing — a WS message can reach the server without passing through the grid.
diff --git a/server/ColumnMetaStore.ts b/server/ColumnMetaStore.ts
index ce43b6a..93bfefa 100644
--- a/server/ColumnMetaStore.ts
+++ b/server/ColumnMetaStore.ts
@@ -1,17 +1,41 @@
import Database from 'better-sqlite3';
import fs from 'fs';
import path from 'path';
-import type { IColumnMetaStore, ColumnTypeInfo } from '../src/shared/types.js';
+import type { IColumnMetaStore, ColumnTypeInfo, Lookup } from '../src/shared/types.js';
const DATA_DIR = path.join(process.cwd(), 'data');
const DB_PATH = path.join(DATA_DIR, 'system.sqlite3');
+const LOOKUP_COLS = ['lookup_kind', 'lookup_table', 'lookup_col', 'lookup_display', 'lookup_values'] as const;
+
+interface Row {
+ baseType: string; qualifier: number | null; scale: number | null;
+ lookup_kind: string | null; lookup_table: string | null; lookup_col: string | null;
+ lookup_display: string | null; lookup_values: string | null;
+}
+
+function rowToMeta(r: Row): ColumnTypeInfo {
+ const meta: ColumnTypeInfo = { baseType: r.baseType, qualifier: r.qualifier, scale: r.scale };
+ if (r.lookup_kind === 'list') {
+ meta.lookup = { kind: 'list', values: JSON.parse(r.lookup_values ?? '[]') as string[] };
+ } else if (r.lookup_kind === 'table' && r.lookup_table && r.lookup_col) {
+ meta.lookup = r.lookup_display
+ ? { kind: 'table', table: r.lookup_table, column: r.lookup_col, display: r.lookup_display }
+ : { kind: 'table', table: r.lookup_table, column: r.lookup_col };
+ }
+ return meta;
+}
+
+const SELECT_COLS = `base_type AS baseType, qualifier, scale,
+ lookup_kind, lookup_table, lookup_col, lookup_display, lookup_values`;
+
/**
- * Declared column types, keyed by (database, table, column).
+ * Declared column types (+ optional lookup constraint), keyed by
+ * (database, table, column).
*
* SQLite only records a storage affinity (TEXT/REAL/INTEGER), which cannot tell
* TIME from DATE from CHAR, LOGICAL from INT, or recover a NUM(p,s) qualifier.
- * The grid and REPLACE need the declared type to validate writes.
+ * The grid, forms and REPLACE need the declared type + lookup to validate writes.
*
* Scoping by database matters: two databases may each hold a table of the same
* name with different column types.
@@ -25,57 +49,84 @@ export class ColumnMetaStore implements IColumnMetaStore {
this.db.pragma('journal_mode = WAL');
this.db.exec(`
CREATE TABLE IF NOT EXISTS column_types (
- db_name TEXT NOT NULL,
- table_name TEXT NOT NULL,
- col_name TEXT NOT NULL,
- base_type TEXT NOT NULL,
- qualifier INTEGER,
- scale INTEGER,
+ db_name TEXT NOT NULL,
+ table_name TEXT NOT NULL,
+ col_name TEXT NOT NULL,
+ base_type TEXT NOT NULL,
+ qualifier INTEGER,
+ scale INTEGER,
+ lookup_kind TEXT,
+ lookup_table TEXT,
+ lookup_col TEXT,
+ lookup_display TEXT,
+ lookup_values TEXT,
PRIMARY KEY (db_name, table_name, col_name)
);
`);
// v1.2.0 dev migration: the first cut of this table (#43) had neither db_name
- // nor scale. The rows only cache what CREATE TABLE re-records, so rebuilding
- // is cheaper and safer than back-filling an unscoped key.
- const cols = this.db.prepare('PRAGMA table_info(column_types)').all() as { name: string }[];
+ // nor scale. Those rows never shipped in a release, so rebuilding was safe.
+ let cols = this.db.prepare('PRAGMA table_info(column_types)').all() as { name: string }[];
if (!cols.some(c => c.name === 'db_name') || !cols.some(c => c.name === 'scale')) {
this.db.exec(`
DROP TABLE column_types;
CREATE TABLE column_types (
- db_name TEXT NOT NULL,
- table_name TEXT NOT NULL,
- col_name TEXT NOT NULL,
- base_type TEXT NOT NULL,
- qualifier INTEGER,
- scale INTEGER,
+ db_name TEXT NOT NULL,
+ table_name TEXT NOT NULL,
+ col_name TEXT NOT NULL,
+ base_type TEXT NOT NULL,
+ qualifier INTEGER,
+ scale INTEGER,
+ lookup_kind TEXT,
+ lookup_table TEXT,
+ lookup_col TEXT,
+ lookup_display TEXT,
+ lookup_values TEXT,
PRIMARY KEY (db_name, table_name, col_name)
);
`);
+ cols = this.db.prepare('PRAGMA table_info(column_types)').all() as { name: string }[];
+ }
+ // v1.3.0 lookup migration (#58): v1.2.0 SHIPPED, so this one must be
+ // additive — dropping the table here would silently erase every released
+ // user's declared TIME(15)/NUM(p,s) types.
+ for (const col of LOOKUP_COLS) {
+ if (!cols.some(c => c.name === col)) {
+ this.db.exec(`ALTER TABLE column_types ADD COLUMN ${col} TEXT`);
+ }
}
}
- setColumnType(dbName: string, tableName: string, colName: string, baseType: string, qualifier: number | null, scale: number | null): void {
+ setColumnType(dbName: string, tableName: string, colName: string, baseType: string, qualifier: number | null, scale: number | null, lookup: Lookup | null = null): void {
+ const kind = lookup?.kind ?? null;
+ const lTable = lookup?.kind === 'table' ? lookup.table : null;
+ const lCol = lookup?.kind === 'table' ? lookup.column : null;
+ const lDisplay = lookup?.kind === 'table' ? (lookup.display ?? null) : null;
+ const lValues = lookup?.kind === 'list' ? JSON.stringify(lookup.values) : null;
this.db.prepare(`
- INSERT INTO column_types (db_name, table_name, col_name, base_type, qualifier, scale)
- VALUES (?, ?, ?, ?, ?, ?)
+ INSERT INTO column_types (db_name, table_name, col_name, base_type, qualifier, scale,
+ lookup_kind, lookup_table, lookup_col, lookup_display, lookup_values)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(db_name, table_name, col_name) DO UPDATE SET
- base_type = excluded.base_type, qualifier = excluded.qualifier, scale = excluded.scale
- `).run(dbName, tableName, colName, baseType, qualifier, scale);
+ base_type = excluded.base_type, qualifier = excluded.qualifier, scale = excluded.scale,
+ lookup_kind = excluded.lookup_kind, lookup_table = excluded.lookup_table,
+ lookup_col = excluded.lookup_col, lookup_display = excluded.lookup_display,
+ lookup_values = excluded.lookup_values
+ `).run(dbName, tableName, colName, baseType, qualifier, scale, kind, lTable, lCol, lDisplay, lValues);
}
getColumnType(dbName: string, tableName: string, colName: string): ColumnTypeInfo | null {
const row = this.db.prepare(
- 'SELECT base_type AS baseType, qualifier, scale FROM column_types WHERE db_name = ? AND table_name = ? AND col_name = ?'
- ).get(dbName, tableName, colName) as ColumnTypeInfo | undefined;
- return row ?? null;
+ `SELECT ${SELECT_COLS} FROM column_types WHERE db_name = ? AND table_name = ? AND col_name = ?`
+ ).get(dbName, tableName, colName) as Row | undefined;
+ return row ? rowToMeta(row) : null;
}
listColumnTypes(dbName: string, tableName: string): Record {
const rows = this.db.prepare(
- 'SELECT col_name AS colName, base_type AS baseType, qualifier, scale FROM column_types WHERE db_name = ? AND table_name = ?'
- ).all(dbName, tableName) as Array;
+ `SELECT col_name AS colName, ${SELECT_COLS} FROM column_types WHERE db_name = ? AND table_name = ?`
+ ).all(dbName, tableName) as Array;
const out: Record = {};
- for (const r of rows) out[r.colName] = { baseType: r.baseType, qualifier: r.qualifier, scale: r.scale };
+ for (const r of rows) out[r.colName] = rowToMeta(r);
return out;
}
diff --git a/src/interpreter/Executor.ts b/src/interpreter/Executor.ts
index f08a627..c8173cd 100644
--- a/src/interpreter/Executor.ts
+++ b/src/interpreter/Executor.ts
@@ -781,7 +781,7 @@ export class Executor implements IndexCommandsHost {
// validate edits.
for (const c of cols) {
this.columnMetaStore?.setColumnType(
- this.metaDb, name, c.name, c.colType.toUpperCase(), c.size ?? null, c.scale ?? null,
+ this.metaDb, name, c.name, c.colType.toUpperCase(), c.size ?? null, c.scale ?? null, c.lookup ?? null,
);
}
this.area.table = name;
@@ -917,7 +917,7 @@ export class Executor implements IndexCommandsHost {
if (has(node.col)) return { output: [{ text: `ALTER TABLE: column already exists: ${node.col}`, cls: 'error' }] };
await this.db.exec(`ALTER TABLE ${q(name)} ADD COLUMN ${q(node.col)} ${mapType(node.colType)}`);
// ALTER TABLE drops any (n)/(p,s) qualifier — the parser skips it.
- this.columnMetaStore?.setColumnType(this.metaDb, name, node.col, node.colType.toUpperCase(), null, null);
+ this.columnMetaStore?.setColumnType(this.metaDb, name, node.col, node.colType.toUpperCase(), null, null, node.lookup);
await this.refreshIfActive(name);
return { output: [{ text: `Added column ${node.col} to ${name}.`, cls: 'ok' }] };
}
@@ -968,7 +968,7 @@ export class Executor implements IndexCommandsHost {
await this.db.exec(`CREATE TABLE ${q(name)} (${colDefs})`);
await this.db.exec(`INSERT INTO ${q(name)} SELECT ${colList} FROM ${q(tmp)}`);
await this.db.exec(`DROP TABLE ${q(tmp)}`);
- this.columnMetaStore?.setColumnType(this.metaDb, name, node.col, node.colType.toUpperCase(), null, null);
+ this.columnMetaStore?.setColumnType(this.metaDb, name, node.col, node.colType.toUpperCase(), null, null, node.lookup);
await this.refreshIfActive(name);
return { output: [
{ text: `Changed type of ${node.col} to ${node.colType} in ${name}.`, cls: 'ok' },
diff --git a/src/interpreter/LookupResolver.ts b/src/interpreter/LookupResolver.ts
new file mode 100644
index 0000000..e102aea
--- /dev/null
+++ b/src/interpreter/LookupResolver.ts
@@ -0,0 +1,43 @@
+import type { IDatabaseBridge } from '../shared/types';
+import type { Lookup, LookupOption } from '../shared/cellValidation';
+
+export const LOOKUP_MAX_VALUES = 1000;
+
+function q(name: string): string {
+ return `"${name.replace(/"/g, '""')}"`;
+}
+
+/**
+ * Resolve a lookup to concrete {value,label} options, or null when it cannot
+ * be honoured (missing table/column, empty source, or over the ceiling).
+ * Callers degrade to free text and skip membership enforcement on null —
+ * an unresolvable lookup is a warning, not a lock. Never truncates: a clipped
+ * option list would hide legal values while membership validation rejected them.
+ */
+export async function resolveLookup(db: IDatabaseBridge, lookup: Lookup): Promise {
+ if (lookup.kind === 'list') {
+ return lookup.values.map(v => ({ value: v, label: v }));
+ }
+ if (!(await db.tableExists(lookup.table))) return null;
+ const cols = await db.getStructure(lookup.table);
+ const valueCol = cols.find(c => c.name.toUpperCase() === lookup.column.toUpperCase());
+ if (!valueCol) return null;
+ let displayCol;
+ if (lookup.display) {
+ displayCol = cols.find(c => c.name.toUpperCase() === lookup.display!.toUpperCase());
+ if (!displayCol) return null;
+ }
+ const sel = displayCol ? `${q(valueCol.name)}, ${q(displayCol.name)}` : q(valueCol.name);
+ const rows = await db.query(
+ `SELECT DISTINCT ${sel} FROM ${q(lookup.table)} ORDER BY 1 LIMIT ${LOOKUP_MAX_VALUES + 1}`
+ );
+ const options: LookupOption[] = [];
+ for (const r of rows) {
+ const value = String(r[valueCol.name] ?? '');
+ if (value === '') continue; // NULL/empty is not a pickable value
+ const label = displayCol ? String(r[displayCol.name] ?? value) : value;
+ options.push({ value, label });
+ }
+ if (options.length === 0 || options.length > LOOKUP_MAX_VALUES) return null;
+ return options;
+}
diff --git a/src/interpreter/Parser.ts b/src/interpreter/Parser.ts
index 97993d9..73d9c20 100644
--- a/src/interpreter/Parser.ts
+++ b/src/interpreter/Parser.ts
@@ -1,4 +1,5 @@
import { Token, TType } from './Lexer';
+import type { Lookup } from '../shared/cellValidation';
// ── Built-in Functions ─────────────────────────────────────────────────────
@@ -59,8 +60,11 @@ export type ASTNode =
| { type: 'CREATE_TABLE'; name: string; cols: ColDef[] }
| { type: 'DROP_TABLE'; name: string }
| { type: 'MODIFY_STRUCTURE' }
- | { type: 'ALTER_TABLE'; name: string; op: 'ADD'; col: string; colType: string }
- | { type: 'ALTER_TABLE'; name: string; op: 'ALTER'; col: string; colType: string }
+ // lookup is always present here (null when no LOOKUP clause was written) so
+ // Executor can pass it straight to setColumnType without an existence check —
+ // unlike ColDef.lookup below, which CREATE TABLE omits entirely when absent.
+ | { type: 'ALTER_TABLE'; name: string; op: 'ADD'; col: string; colType: string; lookup: Lookup | null }
+ | { type: 'ALTER_TABLE'; name: string; op: 'ALTER'; col: string; colType: string; lookup: Lookup | null }
| { type: 'ALTER_TABLE'; name: string; op: 'DROP'; col: string }
| { type: 'ALTER_TABLE'; name: string; op: 'RENAME'; col: string; newName: string }
| { type: 'DO_PRG'; name: string }
@@ -76,7 +80,7 @@ export type ASTNode =
| { type: 'FIND'; value: string }
| { type: 'UNKNOWN'; raw: string };
-export interface ColDef { name: string; colType: string; size?: number; scale?: number; }
+export interface ColDef { name: string; colType: string; size?: number; scale?: number; lookup?: Lookup; }
export type Expr =
| { k: 'lit'; v: string | number | boolean }
@@ -488,7 +492,11 @@ export class Parser {
}
this.expectRParen(`type qualifier for column '${cname}'`);
}
- cols.push({ name: cname, colType: ctype, size, scale });
+ let lookup: Lookup | undefined;
+ if (this.peekKw('LOOKUP')) lookup = this.parseLookupClause(cname);
+ cols.push(lookup !== undefined
+ ? { name: cname, colType: ctype, size, scale, lookup }
+ : { name: cname, colType: ctype, size, scale });
if (this.peek().type === 'COMMA') this.adv();
else break; // no comma → the list must end here
}
@@ -531,6 +539,50 @@ export class Parser {
this.adv();
}
+ // LOOKUP . [DISPLAY ] | LOOKUP ("a","b",...)
+ // A WebBase-III extension (documented deviation — dBASE III had no lookup).
+ private parseLookupClause(colName: string): Lookup {
+ this.adv(); // LOOKUP
+ if (this.peek().type === 'LPAREN') {
+ this.adv();
+ const values: string[] = [];
+ while (!this.end() && this.peek().type !== 'RPAREN') {
+ if (this.peek().type !== 'STR') {
+ this.createErr(`expected a quoted string in the LOOKUP list for column '${colName}'`);
+ }
+ values.push(this.adv().val);
+ if (this.peek().type === 'COMMA') this.adv();
+ else break;
+ }
+ this.expectRParen(`LOOKUP list for column '${colName}'`);
+ if (!values.length) this.createErr(`the LOOKUP list for column '${colName}' is empty`);
+ return { kind: 'list', values };
+ }
+ const t = this.peek();
+ if (t.type !== 'ID' && t.type !== 'KW') {
+ this.createErr(`expected . or a ("…") list after LOOKUP for column '${colName}'`);
+ }
+ const table = this.adv().val;
+ if (this.peek().type !== 'DOT') {
+ this.createErr(`expected . after LOOKUP for column '${colName}'`);
+ }
+ this.adv(); // DOT
+ const cTok = this.peek();
+ if (cTok.type !== 'ID' && cTok.type !== 'KW') {
+ this.createErr(`expected a column after '${table}.' in the LOOKUP for column '${colName}'`);
+ }
+ const column = this.adv().val;
+ if (this.peekKw('DISPLAY')) {
+ this.adv();
+ const dTok = this.peek();
+ if (dTok.type !== 'ID' && dTok.type !== 'KW') {
+ this.createErr(`expected a display column after DISPLAY in the LOOKUP for column '${colName}'`);
+ }
+ return { kind: 'table', table, column, display: this.adv().val };
+ }
+ return { kind: 'table', table, column };
+ }
+
private parseDrop(): ASTNode {
this.adv();
this.skipKw('TABLE');
@@ -541,8 +593,8 @@ export class Parser {
this.adv(); // ALTER
this.skipKw('TABLE');
const name = this.ident();
- if (this.peekKw('ADD')) { this.adv(); this.skipKw('COLUMN'); const col = this.ident(); const colType = this.ident(); this.skipTypeSize(); return { type: 'ALTER_TABLE', name, op: 'ADD', col, colType }; }
- if (this.peekKw('ALTER')) { this.adv(); this.skipKw('COLUMN'); const col = this.ident(); const colType = this.ident(); this.skipTypeSize(); return { type: 'ALTER_TABLE', name, op: 'ALTER', col, colType }; }
+ if (this.peekKw('ADD')) { this.adv(); this.skipKw('COLUMN'); const col = this.ident(); const colType = this.ident(); this.skipTypeSize(); const lookup = this.peekKw('LOOKUP') ? this.parseLookupClause(col) : null; return { type: 'ALTER_TABLE', name, op: 'ADD', col, colType, lookup }; }
+ if (this.peekKw('ALTER')) { this.adv(); this.skipKw('COLUMN'); const col = this.ident(); const colType = this.ident(); this.skipTypeSize(); const lookup = this.peekKw('LOOKUP') ? this.parseLookupClause(col) : null; return { type: 'ALTER_TABLE', name, op: 'ALTER', col, colType, lookup }; }
if (this.peekKw('DROP')) { this.adv(); this.skipKw('COLUMN'); const col = this.ident(); return { type: 'ALTER_TABLE', name, op: 'DROP', col }; }
if (this.peekKw('RENAME')) { this.adv(); this.skipKw('COLUMN'); const col = this.ident(); this.skipKw('TO'); const newName = this.ident(); return { type: 'ALTER_TABLE', name, op: 'RENAME', col, newName }; }
throw new Error('Expected ADD, DROP, RENAME, or ALTER after ALTER TABLE ');
diff --git a/src/shared/cellValidation.ts b/src/shared/cellValidation.ts
index 6ad5579..bdf7c16 100644
--- a/src/shared/cellValidation.ts
+++ b/src/shared/cellValidation.ts
@@ -6,10 +6,19 @@
* Returns an error message, or null when the value is acceptable.
*/
+/** A column's legal-values constraint — a WebBase-III extension, no dBASE III ancestor. */
+export type Lookup =
+ | { kind: 'list'; values: string[] }
+ | { kind: 'table'; table: string; column: string; display?: string };
+
+export interface LookupOption { value: string; label: string }
+
export interface ColumnMeta {
baseType: string;
qualifier: number | null; // CHAR(n) length, TIME(n) minute granularity, NUM(p,s) precision
scale: number | null; // NUM(p,s) scale
+ lookup?: Lookup | null; // declared constraint (may be unresolvable)
+ options?: LookupOption[]; // resolved values — absent when the lookup degraded
}
const TIME_RE = /^([01]\d|2[0-3]):([0-5]\d)$/;
@@ -30,7 +39,17 @@ export function validateCellValue(
if (!meta) return null; // untracked column — no constraint
const v = value.trim();
if (v === '') return null; // clearing a cell is always allowed
+ const typeErr = declaredTypeError(colName, v, meta);
+ if (typeErr) return typeErr;
+ // Membership runs only when the lookup resolved; an unresolvable lookup
+ // degrades to free text rather than locking the column.
+ if (meta.options && meta.options.length && !meta.options.some(o => o.value === v)) {
+ return `${colName}: "${v}" is not one of the allowed values`;
+ }
+ return null;
+}
+function declaredTypeError(colName: string, v: string, meta: ColumnMeta): string | null {
switch (meta.baseType.toUpperCase()) {
case 'TIME': {
const m = TIME_RE.exec(v);
diff --git a/src/shared/types.ts b/src/shared/types.ts
index c9ce093..9801959 100644
--- a/src/shared/types.ts
+++ b/src/shared/types.ts
@@ -56,10 +56,11 @@ export interface IIndexStore {
// precision/scale). qualifier carries CHAR(n) length / TIME(n) granularity /
// NUM(p,s) precision; scale carries the NUM(p,s) scale.
export type ColumnTypeInfo = import('./cellValidation').ColumnMeta;
+export type { Lookup, LookupOption } from './cellValidation';
// Scoped by database: two databases can hold same-named tables with different types.
export interface IColumnMetaStore {
- setColumnType(dbName: string, tableName: string, colName: string, baseType: string, qualifier: number | null, scale: number | null): void;
+ setColumnType(dbName: string, tableName: string, colName: string, baseType: string, qualifier: number | null, scale: number | null, lookup?: import('./cellValidation').Lookup | null): void;
getColumnType(dbName: string, tableName: string, colName: string): ColumnTypeInfo | null;
listColumnTypes(dbName: string, tableName: string): Record;
renameColumn(dbName: string, tableName: string, oldName: string, newName: string): void;
diff --git a/tests/AlterTable.test.ts b/tests/AlterTable.test.ts
index 56523a7..2f24654 100644
--- a/tests/AlterTable.test.ts
+++ b/tests/AlterTable.test.ts
@@ -41,7 +41,7 @@ describe('Parser: MODIFY STRUCTURE / ALTER TABLE', () => {
it('parses ALTER TABLE ADD', () => {
expect(parse('ALTER TABLE customers ADD phone CHAR(20)')[0]).toEqual({
- type: 'ALTER_TABLE', name: 'CUSTOMERS', op: 'ADD', col: 'PHONE', colType: 'CHAR',
+ type: 'ALTER_TABLE', name: 'CUSTOMERS', op: 'ADD', col: 'PHONE', colType: 'CHAR', lookup: null,
});
});
@@ -59,7 +59,7 @@ describe('Parser: MODIFY STRUCTURE / ALTER TABLE', () => {
it('parses ALTER TABLE ALTER (type change)', () => {
expect(parse('ALTER TABLE customers ALTER age INT')[0]).toEqual({
- type: 'ALTER_TABLE', name: 'CUSTOMERS', op: 'ALTER', col: 'AGE', colType: 'INT',
+ type: 'ALTER_TABLE', name: 'CUSTOMERS', op: 'ALTER', col: 'AGE', colType: 'INT', lookup: null,
});
});
});
diff --git a/tests/CellValidation.test.ts b/tests/CellValidation.test.ts
index da4fbf3..24de8a7 100644
--- a/tests/CellValidation.test.ts
+++ b/tests/CellValidation.test.ts
@@ -105,3 +105,47 @@ describe('validateCellValue', () => {
});
});
});
+
+describe('lookup membership', () => {
+ const meta = {
+ baseType: 'CHAR', qualifier: 12 as number | null, scale: null as number | null,
+ lookup: { kind: 'list' as const, values: ['Lead', 'Won', 'Lost'] },
+ options: [
+ { value: 'Lead', label: 'Lead' },
+ { value: 'Won', label: 'Won' },
+ { value: 'Lost', label: 'Lost' },
+ ],
+ };
+
+ it('accepts a value that is in the resolved options', () => {
+ expect(validateCellValue('STAGE', 'Won', meta)).toBeNull();
+ });
+
+ it('rejects a value that is not in the resolved options', () => {
+ expect(validateCellValue('STAGE', 'Maybe', meta)).toMatch(/not one of the allowed values/);
+ });
+
+ it('is case-sensitive: "won" is not "Won"', () => {
+ expect(validateCellValue('STAGE', 'won', meta)).toMatch(/not one of the allowed values/);
+ });
+
+ it('still allows clearing the cell', () => {
+ expect(validateCellValue('STAGE', '', meta)).toBeNull();
+ });
+
+ it('skips membership when options are absent (unresolvable lookup degrades)', () => {
+ const degraded = { ...meta, options: undefined };
+ expect(validateCellValue('STAGE', 'Anything', degraded)).toBeNull();
+ });
+
+ it('runs the declared-type check before membership', () => {
+ const intMeta = {
+ baseType: 'INT', qualifier: null, scale: null,
+ lookup: { kind: 'list' as const, values: ['1', '2'] },
+ options: [{ value: '1', label: '1' }, { value: '2', label: '2' }],
+ };
+ expect(validateCellValue('N', 'abc', intMeta)).toMatch(/whole number/);
+ expect(validateCellValue('N', '3', intMeta)).toMatch(/not one of the allowed values/);
+ expect(validateCellValue('N', '2', intMeta)).toBeNull();
+ });
+});
diff --git a/tests/ColumnMetaStore.test.ts b/tests/ColumnMetaStore.test.ts
index 357c012..266eb42 100644
--- a/tests/ColumnMetaStore.test.ts
+++ b/tests/ColumnMetaStore.test.ts
@@ -80,3 +80,82 @@ describe('ColumnMetaStore', () => {
expect(store.getColumnType('OTHERDB', 'SHIFTS', 'STARTTIME')).toBeNull();
});
});
+
+describe('lookup persistence', () => {
+ it('round-trips a table lookup with display', () => {
+ const store = new ColumnMetaStore(tmpDbPath());
+ store.setColumnType('DB', 'EMPLOYEES', 'SCHEDID', 'CHAR', 4, null,
+ { kind: 'table', table: 'SCHEDULES', column: 'SCHEDID', display: 'DESCR' });
+ expect(store.getColumnType('DB', 'EMPLOYEES', 'SCHEDID')).toEqual({
+ baseType: 'CHAR', qualifier: 4, scale: null,
+ lookup: { kind: 'table', table: 'SCHEDULES', column: 'SCHEDID', display: 'DESCR' },
+ });
+ });
+
+ it('round-trips a literal list preserving order and case', () => {
+ const store = new ColumnMetaStore(tmpDbPath());
+ store.setColumnType('DB', 'DEALS', 'STAGE', 'CHAR', 12, null,
+ { kind: 'list', values: ['Lead', 'Won', 'lost'] });
+ expect(store.getColumnType('DB', 'DEALS', 'STAGE')?.lookup)
+ .toEqual({ kind: 'list', values: ['Lead', 'Won', 'lost'] });
+ });
+
+ it('omits the lookup key entirely when none was declared', () => {
+ const store = new ColumnMetaStore(tmpDbPath());
+ store.setColumnType('DB', 'T', 'PLAIN', 'CHAR', 10, null);
+ expect(store.getColumnType('DB', 'T', 'PLAIN')).toEqual({ baseType: 'CHAR', qualifier: 10, scale: null });
+ });
+
+ it('re-declaring a column without a lookup clears the stored one', () => {
+ const store = new ColumnMetaStore(tmpDbPath());
+ store.setColumnType('DB', 'T', 'C', 'CHAR', 4, null, { kind: 'list', values: ['A'] });
+ store.setColumnType('DB', 'T', 'C', 'CHAR', 4, null);
+ expect(store.getColumnType('DB', 'T', 'C')?.lookup).toBeUndefined();
+ });
+
+ it('scopes lookups per database (two DBs, same table+column)', () => {
+ const store = new ColumnMetaStore(tmpDbPath());
+ store.setColumnType('A', 'T', 'C', 'CHAR', 4, null, { kind: 'list', values: ['X'] });
+ store.setColumnType('B', 'T', 'C', 'CHAR', 4, null, { kind: 'list', values: ['Y'] });
+ expect(store.getColumnType('A', 'T', 'C')?.lookup).toEqual({ kind: 'list', values: ['X'] });
+ expect(store.getColumnType('B', 'T', 'C')?.lookup).toEqual({ kind: 'list', values: ['Y'] });
+ });
+
+ it('listColumnTypes carries lookups', () => {
+ const store = new ColumnMetaStore(tmpDbPath());
+ store.setColumnType('DB', 'T', 'C', 'CHAR', 4, null, { kind: 'list', values: ['A'] });
+ expect(store.listColumnTypes('DB', 'T').C.lookup).toEqual({ kind: 'list', values: ['A'] });
+ });
+
+ // A v1.2.0 store has db_name and scale but no lookup columns. It SHIPPED —
+ // it must be migrated additively, never dropped (dropping erases users'
+ // declared TIME(15)/NUM(p,s) types).
+ it('adds lookup columns to a v1.2.0 store without losing existing rows', () => {
+ const p = tmpDbPath();
+ const v120 = new Database(p);
+ v120.exec(`
+ CREATE TABLE column_types (
+ db_name TEXT NOT NULL,
+ table_name TEXT NOT NULL,
+ col_name TEXT NOT NULL,
+ base_type TEXT NOT NULL,
+ qualifier INTEGER,
+ scale INTEGER,
+ PRIMARY KEY (db_name, table_name, col_name)
+ );
+ `);
+ v120.prepare('INSERT INTO column_types VALUES (?,?,?,?,?,?)')
+ .run('OVERTIME', 'SCHEDULEDAYS', 'TIMEIN', 'TIME', 15, null);
+ v120.close();
+
+ const store = new ColumnMetaStore(p);
+ // The pre-existing row SURVIVES:
+ expect(store.getColumnType('OVERTIME', 'SCHEDULEDAYS', 'TIMEIN'))
+ .toEqual({ baseType: 'TIME', qualifier: 15, scale: null });
+ // And the new columns work:
+ store.setColumnType('OVERTIME', 'EMPLOYEES', 'SCHEDID', 'CHAR', 4, null,
+ { kind: 'table', table: 'SCHEDULES', column: 'SCHEDID' });
+ expect(store.getColumnType('OVERTIME', 'EMPLOYEES', 'SCHEDID')?.lookup)
+ .toEqual({ kind: 'table', table: 'SCHEDULES', column: 'SCHEDID' });
+ });
+});
diff --git a/tests/CreateTableParse.test.ts b/tests/CreateTableParse.test.ts
index 5da91bd..ad90187 100644
--- a/tests/CreateTableParse.test.ts
+++ b/tests/CreateTableParse.test.ts
@@ -86,3 +86,69 @@ describe('CREATE TABLE still accepts every valid form', () => {
.toEqual(['PRODID', 'CATID', 'NAME', 'STOCK', 'REORDER', 'PRICE', 'ACTIVE']);
});
});
+
+describe('LOOKUP column qualifier', () => {
+ it('parses a table lookup with DISPLAY', () => {
+ expect(cols('CREATE TABLE e (SCHEDID CHAR(4) LOOKUP SCHEDULES.SCHEDID DISPLAY DESCR)')).toEqual([
+ { name: 'SCHEDID', colType: 'CHAR', size: 4,
+ lookup: { kind: 'table', table: 'SCHEDULES', column: 'SCHEDID', display: 'DESCR' } },
+ ]);
+ });
+
+ it('parses a table lookup without DISPLAY', () => {
+ expect(cols('CREATE TABLE e (SCHEDID CHAR(4) LOOKUP SCHEDULES.SCHEDID)')).toEqual([
+ { name: 'SCHEDID', colType: 'CHAR', size: 4,
+ lookup: { kind: 'table', table: 'SCHEDULES', column: 'SCHEDID' } },
+ ]);
+ });
+
+ it('parses a literal list, preserving case', () => {
+ expect(cols('CREATE TABLE d (STAGE CHAR(12) LOOKUP ("Lead","Won","Lost"))')).toEqual([
+ { name: 'STAGE', colType: 'CHAR', size: 12,
+ lookup: { kind: 'list', values: ['Lead', 'Won', 'Lost'] } },
+ ]);
+ });
+
+ it('a LOOKUP column can be followed by more columns', () => {
+ expect(cols('CREATE TABLE d (STAGE CHAR(12) LOOKUP ("A","B"), VALUE NUM(8,2))').map((c: any) => c.name))
+ .toEqual(['STAGE', 'VALUE']);
+ });
+
+ it('rejects an empty LOOKUP list', () => {
+ expect(() => parse('CREATE TABLE d (STAGE CHAR(12) LOOKUP ())')).toThrow(/LOOKUP/i);
+ });
+
+ it('rejects unquoted values in a LOOKUP list', () => {
+ expect(() => parse('CREATE TABLE d (STAGE CHAR(12) LOOKUP (Lead,Won))')).toThrow(/LOOKUP/i);
+ });
+
+ it('rejects LOOKUP with no source at all', () => {
+ expect(() => parse('CREATE TABLE d (STAGE CHAR(12) LOOKUP)')).toThrow(/LOOKUP/i);
+ });
+
+ it('rejects a table lookup missing the .column part', () => {
+ expect(() => parse('CREATE TABLE d (STAGE CHAR(12) LOOKUP SCHEDULES)')).toThrow(/LOOKUP/i);
+ });
+
+ it('carries LOOKUP through ALTER TABLE ADD', () => {
+ const ast = parse('ALTER TABLE t ADD STAGE CHAR(12) LOOKUP ("A","B")')[0] as any;
+ expect(ast.lookup).toEqual({ kind: 'list', values: ['A', 'B'] });
+ });
+
+ it('carries LOOKUP through ALTER TABLE ALTER', () => {
+ const ast = parse('ALTER TABLE t ALTER STAGE CHAR LOOKUP S.C DISPLAY D')[0] as any;
+ expect(ast.lookup).toEqual({ kind: 'table', table: 'S', column: 'C', display: 'D' });
+ });
+
+ it('ALTER TABLE ADD without LOOKUP sets lookup to null, not undefined', () => {
+ const ast = parse('ALTER TABLE t ADD PLAINCOL CHAR')[0] as any;
+ expect(ast.lookup).toBeNull();
+ expect('lookup' in ast).toBe(true);
+ });
+
+ it('ALTER TABLE ALTER without LOOKUP sets lookup to null, not undefined', () => {
+ const ast = parse('ALTER TABLE t ALTER PLAINCOL CHAR')[0] as any;
+ expect(ast.lookup).toBeNull();
+ expect('lookup' in ast).toBe(true);
+ });
+});
diff --git a/tests/LookupEnforcement.test.ts b/tests/LookupEnforcement.test.ts
new file mode 100644
index 0000000..b231631
--- /dev/null
+++ b/tests/LookupEnforcement.test.ts
@@ -0,0 +1,97 @@
+import { describe, it, expect, afterEach } from 'vitest';
+import { Session } from '../server/Session';
+import type { ServerMessage } from '../src/shared/types';
+import fs from 'fs';
+import path from 'path';
+
+let dbCounter = 0;
+function uniqueDb() { return `test_lookupenf_${Date.now()}_${++dbCounter}`; }
+
+afterEach(() => {
+ const dataDir = path.join(process.cwd(), 'data');
+ if (fs.existsSync(dataDir)) {
+ fs.readdirSync(dataDir)
+ .filter(f => f.toLowerCase().startsWith('test_lookupenf_'))
+ .forEach(f => fs.unlinkSync(path.join(dataDir, f)));
+ }
+});
+
+async function setup() {
+ const sent: ServerMessage[] = [];
+ const session = new Session((m) => sent.push(m));
+ const run = async (text: string) => {
+ sent.length = 0;
+ await session.handleMessage({ type: 'command', text });
+ const out = sent.filter(m => m.type === 'output') as any[];
+ return out.flatMap(o => o.lines).map((l: any) => l.text).join('\n');
+ };
+ await run(`USE DATABASE ${uniqueDb()}`);
+ return { session, sent, run };
+}
+
+describe('CREATE/ALTER TABLE record the declared lookup', () => {
+ it('CREATE TABLE with a literal-list LOOKUP persists it (visible via BROWSE grid-open columnTypes)', async () => {
+ const { session, sent, run } = await setup();
+ await run('CREATE TABLE DEALS (TITLE CHAR(20), STAGE CHAR(12) LOOKUP ("Lead","Won","Lost"))');
+ await run('USE DEALS');
+ sent.length = 0;
+ await session.handleMessage({ type: 'command', text: 'BROWSE' });
+ const grid = sent.find(m => m.type === 'grid-open') as any;
+ expect(grid.columnTypes.STAGE.lookup).toEqual({ kind: 'list', values: ['Lead', 'Won', 'Lost'] });
+ });
+
+ it('CREATE TABLE with a table LOOKUP + DISPLAY persists it', async () => {
+ const { session, sent, run } = await setup();
+ await run('CREATE TABLE EMPLOYEES (SCHEDID CHAR(4) LOOKUP SCHEDULES.SCHEDID DISPLAY DESCR)');
+ await run('USE EMPLOYEES');
+ sent.length = 0;
+ await session.handleMessage({ type: 'command', text: 'BROWSE' });
+ const grid = sent.find(m => m.type === 'grid-open') as any;
+ expect(grid.columnTypes.SCHEDID.lookup).toEqual({
+ kind: 'table', table: 'SCHEDULES', column: 'SCHEDID', display: 'DESCR',
+ });
+ });
+
+ it('a column with no LOOKUP clause has no lookup key at all', async () => {
+ const { session, sent, run } = await setup();
+ await run('CREATE TABLE T (PLAIN CHAR(10))');
+ await run('USE T');
+ sent.length = 0;
+ await session.handleMessage({ type: 'command', text: 'BROWSE' });
+ const grid = sent.find(m => m.type === 'grid-open') as any;
+ expect(grid.columnTypes.PLAIN.lookup).toBeUndefined();
+ });
+
+ it('ALTER TABLE ADD with a LOOKUP clause persists it', async () => {
+ const { session, sent, run } = await setup();
+ await run('CREATE TABLE T (A CHAR(4))');
+ await run('USE T');
+ await run('ALTER TABLE T ADD STAGE CHAR LOOKUP ("X","Y")');
+ sent.length = 0;
+ await session.handleMessage({ type: 'command', text: 'BROWSE' });
+ const grid = sent.find(m => m.type === 'grid-open') as any;
+ expect(grid.columnTypes.STAGE.lookup).toEqual({ kind: 'list', values: ['X', 'Y'] });
+ });
+
+ it('ALTER TABLE ADD without a LOOKUP clause stores no lookup', async () => {
+ const { session, sent, run } = await setup();
+ await run('CREATE TABLE T (A CHAR(4))');
+ await run('USE T');
+ await run('ALTER TABLE T ADD PLAINCOL CHAR');
+ sent.length = 0;
+ await session.handleMessage({ type: 'command', text: 'BROWSE' });
+ const grid = sent.find(m => m.type === 'grid-open') as any;
+ expect(grid.columnTypes.PLAINCOL.lookup).toBeUndefined();
+ });
+
+ it('ALTER TABLE ALTER with a LOOKUP clause persists it', async () => {
+ const { session, sent, run } = await setup();
+ await run('CREATE TABLE T (STAGE CHAR(4))');
+ await run('USE T');
+ await run('ALTER TABLE T ALTER STAGE CHAR LOOKUP ("A","B")');
+ sent.length = 0;
+ await session.handleMessage({ type: 'command', text: 'BROWSE' });
+ const grid = sent.find(m => m.type === 'grid-open') as any;
+ expect(grid.columnTypes.STAGE.lookup).toEqual({ kind: 'list', values: ['A', 'B'] });
+ });
+});
diff --git a/tests/LookupResolver.test.ts b/tests/LookupResolver.test.ts
new file mode 100644
index 0000000..8d03841
--- /dev/null
+++ b/tests/LookupResolver.test.ts
@@ -0,0 +1,85 @@
+import { describe, it, expect, beforeEach, afterEach } from 'vitest';
+import { ServerDatabaseBridge, __closeAndEvictForTest } from '../server/ServerDatabaseBridge';
+import { resolveLookup, LOOKUP_MAX_VALUES } from '../src/interpreter/LookupResolver';
+import fs from 'fs';
+import path from 'path';
+
+const TEST_DB = 'test_lookupresolver';
+const DATA_DIR = path.join(process.cwd(), 'data');
+const DB_PATH = path.join(DATA_DIR, `${TEST_DB}.sqlite3`);
+
+describe('resolveLookup', () => {
+ let bridge: ServerDatabaseBridge;
+
+ beforeEach(async () => {
+ bridge = new ServerDatabaseBridge();
+ await bridge.openDatabase(TEST_DB);
+ });
+
+ afterEach(async () => {
+ await bridge.closeDatabase();
+ __closeAndEvictForTest(TEST_DB);
+ for (const f of [DB_PATH, DB_PATH + '-shm', DB_PATH + '-wal']) {
+ if (fs.existsSync(f)) fs.unlinkSync(f);
+ }
+ });
+
+ it('resolves a literal list verbatim, value === label', async () => {
+ expect(await resolveLookup(bridge, { kind: 'list', values: ['Lead', 'Won'] })).toEqual([
+ { value: 'Lead', label: 'Lead' },
+ { value: 'Won', label: 'Won' },
+ ]);
+ });
+
+ it('resolves a table lookup to DISTINCT ordered values', async () => {
+ await bridge.exec('CREATE TABLE SCHEDULES (SCHEDID TEXT, DESCR TEXT)');
+ await bridge.exec("INSERT INTO SCHEDULES VALUES ('S002','Short day'),('S001','Std day'),('S001','Std day')");
+ expect(await resolveLookup(bridge, { kind: 'table', table: 'SCHEDULES', column: 'SCHEDID' })).toEqual([
+ { value: 'S001', label: 'S001' },
+ { value: 'S002', label: 'S002' },
+ ]);
+ });
+
+ it('uses the DISPLAY column as the label, storing the value', async () => {
+ await bridge.exec('CREATE TABLE SCHEDULES (SCHEDID TEXT, DESCR TEXT)');
+ await bridge.exec("INSERT INTO SCHEDULES VALUES ('S001','Std day'),('S002','Short day')");
+ expect(await resolveLookup(bridge, { kind: 'table', table: 'SCHEDULES', column: 'SCHEDID', display: 'DESCR' })).toEqual([
+ { value: 'S001', label: 'Std day' },
+ { value: 'S002', label: 'Short day' },
+ ]);
+ });
+
+ it('returns null when the source table is missing', async () => {
+ expect(await resolveLookup(bridge, { kind: 'table', table: 'NOPE', column: 'X' })).toBeNull();
+ });
+
+ it('returns null when the source column is missing', async () => {
+ await bridge.exec('CREATE TABLE SCHEDULES (SCHEDID TEXT)');
+ expect(await resolveLookup(bridge, { kind: 'table', table: 'SCHEDULES', column: 'MISSING' })).toBeNull();
+ });
+
+ it('returns null when the display column is missing', async () => {
+ await bridge.exec('CREATE TABLE SCHEDULES (SCHEDID TEXT)');
+ expect(await resolveLookup(bridge, { kind: 'table', table: 'SCHEDULES', column: 'SCHEDID', display: 'MISSING' })).toBeNull();
+ });
+
+ it('returns null when the source is empty', async () => {
+ await bridge.exec('CREATE TABLE SCHEDULES (SCHEDID TEXT)');
+ expect(await resolveLookup(bridge, { kind: 'table', table: 'SCHEDULES', column: 'SCHEDID' })).toBeNull();
+ });
+
+ it('degrades (null), never truncates, over the ceiling', async () => {
+ await bridge.exec('CREATE TABLE BIG (V TEXT)');
+ const values = Array.from({ length: LOOKUP_MAX_VALUES + 1 }, (_, i) => `('v${String(i).padStart(5, '0')}')`);
+ await bridge.exec(`INSERT INTO BIG VALUES ${values.join(',')}`);
+ expect(await resolveLookup(bridge, { kind: 'table', table: 'BIG', column: 'V' })).toBeNull();
+ });
+
+ it('skips NULL/empty values from the source', async () => {
+ await bridge.exec('CREATE TABLE SCHEDULES (SCHEDID TEXT)');
+ await bridge.exec("INSERT INTO SCHEDULES VALUES ('S001'),(NULL),('')");
+ expect(await resolveLookup(bridge, { kind: 'table', table: 'SCHEDULES', column: 'SCHEDID' })).toEqual([
+ { value: 'S001', label: 'S001' },
+ ]);
+ });
+});