diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b5c841..bdc601d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,10 +19,16 @@ Versions follow [Semantic Versioning](https://semver.org/) — minor bump per su `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. +- `LOOKUP` enforcement + BROWSE dropdown (#60). `REPLACE` and the BROWSE grid's `grid-edit` + now reject a value outside a column's declared `LOOKUP`, re-resolving the constraint fresh + against the live database on every write (so a value that only just became legal, or that + just stopped being legal, is judged correctly — never a stale cached list). An unresolvable + lookup (source table dropped, empty, or over 1000 values) degrades to free entry with a + warning rather than locking the column. BROWSE renders a lookup column as a dropdown — + `DISPLAY` labels shown while editing, the stored code shown once committed, matching + `LIST`/report output. + **Forms (`@ SAY GET`) do not honor `LOOKUP` yet** — that ships in #59, which is when the + full feature is documented in the README command reference. ## [1.2.0] — 2026-07-09 — TIME columns, WEEK()/DATEADD(), BROWSE cell validation, Overtime demo diff --git a/CLAUDE.md b/CLAUDE.md index 7f7c0bd..a31b516 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -197,7 +197,7 @@ 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) +#### `LOOKUP` column qualifier (BROWSE/REPLACE done; forms 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 @@ -212,9 +212,17 @@ STAGE CHAR(12) LOOKUP ("Lead","Won","Lost") -- literal list 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. +distinct values. + +BROWSE renders a lookup column as a dropdown (`Grid.ts`'s `startEdit`) fed by the options +`Session.sendGridData` resolves at `grid-open`; the stored value shows once committed, +matching `LIST`/report output — only the edit-mode dropdown shows `DISPLAY` labels. +`REPLACE` and `grid-edit` both enforce membership, re-resolving fresh at write time (not +trusting a client-held list) so a value that just became legal or just stopped being legal +is judged correctly; an unresolvable lookup degrades to free entry with a warning rather +than locking the column. **Forms (`@ SAY GET`) do not honor `LOOKUP` yet** — that lands in +a follow-up PR (#59, field-bound `GET`) before this section is promoted to the README +command reference. #### Cell validation (`BROWSE`) @@ -229,7 +237,7 @@ to the README command reference. | `LOGICAL` | `.T.`/`.F.`/`.TRUE.`/`.FALSE.`/`T`/`F`/`TRUE`/`FALSE`/`1`/`0` | | `CHAR` / `MEMO` | anything (length is not enforced) | -An empty value is always allowed (clears the cell). Columns with no recorded declared type are unconstrained. `REPLACE` enforces only `TIME` — widening it would change the semantics of existing programs. +An empty value is always allowed (clears the cell). Columns with no recorded declared type are unconstrained. `REPLACE` enforces `TIME` and, since v1.3.0, lookup membership on any column that declares one (additive — no pre-v1.3.0 column declares a `LOOKUP`, so no existing program's behavior changes); widening validation beyond that would change the semantics of existing programs. ### Indexing & search | Command | What it does | diff --git a/README.md b/README.md index bc951ad..ffaf3e1 100644 --- a/README.md +++ b/README.md @@ -244,6 +244,36 @@ WebBase-III supports **unlimited work areas** — each independently holding a t **Column types**: `CHAR(n)` (aliases `CHARACTER`/`VARCHAR`/`STRING`/`MEMO`), `NUM`/`NUM(p,s)` (`NUMERIC`/`FLOAT`/`DOUBLE`/`DECIMAL`), `INT`/`INTEGER`, `LOGICAL`/`BOOLEAN`, `DATE`, and `TIME`/`TIME(n)`. `TIME` stores `HH:MM` (24-hour); the optional `TIME(n)` qualifier (e.g. `TIME(15)`) requires minutes to be a multiple of `n`. `REPLACE ... WITH` rejects a malformed or off-granularity `TIME` value instead of silently coercing it, and `LIST STRUCTURE` prints the declared type (`NUM(8,2)`, `TIME(15)`) rather than SQLite's storage class. +### `LOOKUP` columns — constrain a column to legal values + +Any column may add a `LOOKUP` clause after its type, declaring the values it's allowed to hold: + +``` +CREATE TABLE EMPLOYEES (EMPID CHAR(4), NAME CHAR(30), + SCHEDID CHAR(4) LOOKUP SCHEDULES.SCHEDID DISPLAY DESCR) +CREATE TABLE DEALS (STAGE CHAR(12) LOOKUP ("Lead","Qualified","Proposal","Won","Lost")) +``` + +Two forms: `LOOKUP . [DISPLAY ]` looks up live values from another +table (optionally showing a friendlier label while storing the code), or +`LOOKUP ("a","b",...)` is a fixed list. `CREATE TABLE`/`ALTER TABLE ADD`/`ALTER TABLE ALTER` +all accept it. + +BROWSE renders a lookup column as a dropdown — `DISPLAY` labels while editing, the stored +code once committed, matching `LIST`/report output. `REPLACE` and BROWSE edits both reject +a value outside the list, re-checked fresh against the live database on every write (a +value that just became legal, or just stopped being legal, is judged correctly — never a +stale cached list). A lookup that can't be resolved (source table dropped, empty, or over +1000 distinct values) degrades to free entry with a warning instead of locking the column. + +> **Deviation from dBASE III:** `LOOKUP` has no dBASE III ancestor — dBASE III+ only offered +> `PICTURE "@M a,b,c"`, a literal value list cycled with the spacebar, with no table-driven +> form and no display label. This is a WebBase-III extension, in the same spirit as +> unlimited work areas and `alias.field` dot notation. +> +> Forms (`@ SAY GET`) don't offer a `LOOKUP` picker yet — only BROWSE and `REPLACE` enforce +> it today. + > **CSV format (`COPY TO` / `APPEND FROM`):** Unlike dBASE III's headerless, > positional `DELIMITED`/`SDF` formats, WebBase-III uses modern **header-based CSV** > (RFC-4180, mapped by column name). Export downloads through the browser and diff --git a/server/Session.ts b/server/Session.ts index fef543f..ac38137 100644 --- a/server/Session.ts +++ b/server/Session.ts @@ -8,7 +8,8 @@ import { reportStore } from './ReportStore.js'; import { indexStore } from './IndexStore.js'; import { columnMetaStore } from './ColumnMetaStore.js'; import { validateCellValue } from '../src/shared/cellValidation.js'; -import type { ClientMessage, ServerMessage, ColInfo } from '../src/shared/types.js'; +import { resolveLookup } from '../src/interpreter/LookupResolver.js'; +import type { ClientMessage, ServerMessage, ColInfo, OutputLine } from '../src/shared/types.js'; export class Session { private bridge: ServerDatabaseBridge; @@ -78,7 +79,14 @@ export class Session { // Authoritative check — the grid validates client-side for fast // feedback, but a message can reach here without passing through it. const db = this.executor.area.db ?? ''; - const err = validateCellValue(col, value, columnMetaStore.getColumnType(db, table, col)); + let meta = columnMetaStore.getColumnType(db, table, col); + if (meta?.lookup) { + // Fresh re-resolve at write time — the option list the client got + // at grid-open may be stale, and a forged message never saw one. + const options = await resolveLookup(this.bridge, meta.lookup); + meta = { ...meta, options: options ?? undefined }; + } + const err = validateCellValue(col, value, meta); if (err) { this.send({ type: 'output', lines: [{ text: `** ${err}`, cls: 'error' }] }); await this.sendGridData(); @@ -364,6 +372,17 @@ export class Session { } const columns = await this.bridge.getStructure(area.table); const columnTypes = columnMetaStore.listColumnTypes(area.db ?? '', area.table); + // Re-resolved on every BROWSE open, not cached: a table-kind lookup's source + // rows can change between opens, and grid-edit re-resolves again anyway — + // this just keeps the dropdown's initial contents from going stale. + const warns: OutputLine[] = []; + for (const [col, meta] of Object.entries(columnTypes)) { + if (!meta.lookup) continue; + const options = await resolveLookup(this.bridge, meta.lookup); + if (options) meta.options = options; + else warns.push({ text: `** Warning: lookup for ${col} could not be resolved — free entry`, cls: 'warn' }); + } + if (warns.length) this.send({ type: 'output', lines: warns }); const rows = await this.executor.getOrderedRowsWithIds(2000); this.send({ type: 'grid-open', table: area.table, filter: area.filter, columns, columnTypes, rows }); } diff --git a/src/interpreter/Executor.ts b/src/interpreter/Executor.ts index c8173cd..fdac08d 100644 --- a/src/interpreter/Executor.ts +++ b/src/interpreter/Executor.ts @@ -6,6 +6,7 @@ import { IndexCommands, IndexCommandsHost } from './IndexCommands'; import { ReportCommands } from './ReportCommands'; import { toCSV, parseCSV, MAX_EXPORT_ROWS, MAX_IMPORT_BYTES, MAX_IMPORT_SKIPS } from '../shared/csv'; import { validateCellValue } from '../shared/cellValidation'; +import { resolveLookup } from './LookupResolver'; export type { OutputLine, FormField } from '../shared/types'; @@ -428,16 +429,28 @@ export class Executor implements IndexCommandsHost { this.requireTable(); await this.refreshRecCount(); const pairs = fields.map(f => ({ field: f.field, value: this.evalExpr(f.value) })); - // TIME is the only declared type REPLACE enforces (#43). The grid validates - // every declared type (#45) because it has no other guard; widening REPLACE - // would change the semantics of existing programs. + // Declared-type enforcement on REPLACE is deliberately narrow: TIME since + // #43, plus lookup membership (#58) — membership is additive, because no + // pre-#58 column declares a lookup, so no existing program changes behavior. + // The grid still validates every declared type (#45). for (const p of pairs) { if (p.value === null || p.value === undefined) continue; const info = this.columnMetaStore?.getColumnType(this.metaDb, this.area.table!, p.field); - if (info?.baseType === 'TIME') { + if (!info) continue; + if (info.baseType === 'TIME') { const err = validateCellValue(p.field, String(p.value), info); if (err) throw new Error(err); } + if (info.lookup) { + // Re-resolve at write time: a value that became legal after any cached + // list was built is accepted; a vanished one is rejected. Unresolvable + // (null) skips membership — degradation, not a lock. + const options = await resolveLookup(this.db, info.lookup); + if (options) { + const err = validateCellValue(p.field, String(p.value), { ...info, options }); + if (err) throw new Error(err); + } + } } const setClauses = pairs.map(p => `${q(p.field)} = ?`).join(', '); const params = pairs.map(p => typeof p.value === 'boolean' ? (p.value ? 1 : 0) : p.value); diff --git a/src/styles/main.css b/src/styles/main.css index ebd78b4..530a953 100644 --- a/src/styles/main.css +++ b/src/styles/main.css @@ -247,12 +247,14 @@ html, body { background: #001428; border: 2px solid #0066cc; color: #ffffff; font-family: var(--font); font-size: 13px; outline: none; } +select.cell-ed { min-width: 110px; } /* A rejected edit (#45): the cell stays in edit mode and explains why. Cells clip their content (`overflow: hidden` above, for ellipsis), which would swallow the message entirely — the user would see only a red border. */ #grid-table td.cell-invalid { overflow: visible; } -#grid-table td.cell-invalid input.cell-ed { border-color: #cc0000; background: #2a0000; } +#grid-table td.cell-invalid input.cell-ed, +#grid-table td.cell-invalid select.cell-ed { border-color: #cc0000; background: #2a0000; } #grid-table td.cell-invalid .cell-error { position: absolute; left: 0; top: 100%; z-index: 20; max-width: 320px; padding: 3px 8px; diff --git a/src/ui/Grid.ts b/src/ui/Grid.ts index 5c9f7f1..751e7a6 100644 --- a/src/ui/Grid.ts +++ b/src/ui/Grid.ts @@ -172,6 +172,42 @@ export class Grid { const colName = this.cols[ci]; const cur = String(this.rows[ri][colName] ?? ''); td.classList.add('editing'); td.classList.remove('active-cell'); + + const meta = this.columnTypes[colName]; + if (meta?.options?.length) { + // Lookup column: the dropdown IS the validation on the happy path + // (the server still re-checks). Static cells keep showing the stored + // value — only this editor shows display labels. + const sel = document.createElement('select'); + sel.className = 'cell-ed'; + const blank = document.createElement('option'); + blank.value = ''; blank.textContent = ''; + sel.appendChild(blank); + for (const o of meta.options) { + const op = document.createElement('option'); + op.value = o.value; + op.textContent = o.label === o.value ? o.value : `${o.label} (${o.value})`; + sel.appendChild(op); + } + sel.value = cur; + td.textContent = ''; + td.appendChild(sel); + sel.focus(); + this.editingCell = { r: ri, c: ci }; + + sel.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === 'Tab') { + e.preventDefault(); e.stopPropagation(); + if (!this.commitEdit(sel.value)) return; + if (e.key === 'Tab') this.selectCell(ri, ci + 2); + } else if (e.key === 'Escape') { + e.preventDefault(); e.stopPropagation(); + this.cancelEdit(); + } + }); + return; + } + const inp = document.createElement('input'); inp.className = 'cell-ed'; inp.value = cur; td.textContent = ''; diff --git a/tests/LookupEnforcement.test.ts b/tests/LookupEnforcement.test.ts index b231631..66a3b3a 100644 --- a/tests/LookupEnforcement.test.ts +++ b/tests/LookupEnforcement.test.ts @@ -95,3 +95,156 @@ describe('CREATE/ALTER TABLE record the declared lookup', () => { expect(grid.columnTypes.STAGE.lookup).toEqual({ kind: 'list', values: ['A', 'B'] }); }); }); + +describe('REPLACE enforces lookup membership', () => { + it('rejects an off-list value on a literal-lookup column and does not write it', async () => { + const { run } = await setup(); + await run('CREATE TABLE DEALS (TITLE CHAR(20), STAGE CHAR(12) LOOKUP ("Lead","Won","Lost"))'); + await run('USE DEALS'); + await run('APPEND RECORD'); + const out = await run('REPLACE STAGE WITH "Maybe"'); + expect(out).toMatch(/not one of the allowed values/); + expect(await run('LIST')).not.toContain('Maybe'); + }); + + it('accepts an on-list value (exact case)', async () => { + const { run } = await setup(); + await run('CREATE TABLE DEALS (TITLE CHAR(20), STAGE CHAR(12) LOOKUP ("Lead","Won","Lost"))'); + await run('USE DEALS'); + await run('APPEND RECORD'); + expect(await run('REPLACE STAGE WITH "Won"')).toContain('Replaced'); + expect(await run('LIST')).toContain('Won'); + }); + + it('rejects the wrong case', async () => { + const { run } = await setup(); + await run('CREATE TABLE DEALS (STAGE CHAR(12) LOOKUP ("Won"))'); + await run('USE DEALS'); + await run('APPEND RECORD'); + expect(await run('REPLACE STAGE WITH "won"')).toMatch(/not one of the allowed values/); + }); + + it('enforces a table lookup against live source rows', async () => { + const { run } = await setup(); + await run('CREATE TABLE SCHEDULES (SCHEDID CHAR(4), DESCR CHAR(30))'); + await run('USE SCHEDULES'); + await run('APPEND RECORD'); + await run('REPLACE SCHEDID WITH "S001", DESCR WITH "Std day"'); + await run('CREATE TABLE EMPLOYEES (EMPID CHAR(4), SCHEDID CHAR(4) LOOKUP SCHEDULES.SCHEDID DISPLAY DESCR)'); + await run('USE EMPLOYEES'); + await run('APPEND RECORD'); + expect(await run('REPLACE SCHEDID WITH "S999"')).toMatch(/not one of the allowed values/); + expect(await run('REPLACE SCHEDID WITH "S001"')).toContain('Replaced'); + }); + + it('a new source row becomes legal immediately (fresh re-resolve at write time)', async () => { + const { run } = await setup(); + await run('CREATE TABLE SCHEDULES (SCHEDID CHAR(4))'); + await run('USE SCHEDULES'); + await run('APPEND RECORD'); + await run('REPLACE SCHEDID WITH "S001"'); + await run('CREATE TABLE EMPLOYEES (SCHEDID CHAR(4) LOOKUP SCHEDULES.SCHEDID)'); + await run('USE EMPLOYEES'); + await run('APPEND RECORD'); + expect(await run('REPLACE SCHEDID WITH "S002"')).toMatch(/not one of the allowed values/); + await run('USE SCHEDULES'); + await run('APPEND RECORD'); + await run('REPLACE SCHEDID WITH "S002"'); + await run('USE EMPLOYEES'); + expect(await run('REPLACE SCHEDID WITH "S002"')).toContain('Replaced'); + }); + + it('an unresolvable lookup degrades: the write is allowed', async () => { + const { run } = await setup(); + await run('CREATE TABLE EMPLOYEES (SCHEDID CHAR(4) LOOKUP GHOSTTABLE.SCHEDID)'); + await run('USE EMPLOYEES'); + await run('APPEND RECORD'); + expect(await run('REPLACE SCHEDID WITH "ANY"')).toContain('Replaced'); + }); + + it('ALTER TABLE ADD carries the lookup', async () => { + const { 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")'); + await run('APPEND RECORD'); + expect(await run('REPLACE STAGE WITH "Z"')).toMatch(/not one of the allowed values/); + expect(await run('REPLACE STAGE WITH "X"')).toContain('Replaced'); + }); + + it('two databases keep independent lookups for the same table+column', async () => { + 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'); + }; + const dbA = uniqueDb(); const dbB = uniqueDb(); + await run(`USE DATABASE ${dbA}`); + await run('CREATE TABLE T (C CHAR(4) LOOKUP ("A"))'); + await run(`USE DATABASE ${dbB}`); + await run('CREATE TABLE T (C CHAR(4) LOOKUP ("B"))'); + await run('USE T'); + await run('APPEND RECORD'); + expect(await run('REPLACE C WITH "A"')).toMatch(/not one of the allowed values/); + expect(await run('REPLACE C WITH "B"')).toContain('Replaced'); + }); +}); + +describe('grid messages with lookups', () => { + it('grid-open ships resolved options for a lookup column — exact list', async () => { + const { session, sent, run } = await setup(); + await run('CREATE TABLE DEALS (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.options).toEqual([ + { value: 'Lead', label: 'Lead' }, + { value: 'Won', label: 'Won' }, + { value: 'Lost', label: 'Lost' }, + ]); + }); + + it('grid-open degrades an unresolvable lookup: no options, a warning line', async () => { + const { session, sent, run } = await setup(); + await run('CREATE TABLE E (SCHEDID CHAR(4) LOOKUP GHOST.SCHEDID)'); + await run('USE E'); + 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.options).toBeUndefined(); + const out = sent.filter(m => m.type === 'output') as any[]; + expect(out.flatMap(o => o.lines).map((l: any) => l.text).join('\n')).toMatch(/lookup for SCHEDID/i); + }); + + it('grid-edit rejects an off-list value server-side (forged message path)', async () => { + const { session, sent, run } = await setup(); + await run('CREATE TABLE DEALS (STAGE CHAR(12) LOOKUP ("Lead","Won"))'); + await run('USE DEALS'); + await run('APPEND RECORD'); + sent.length = 0; + await session.handleMessage({ type: 'command', text: 'BROWSE' }); + const grid = sent.find(m => m.type === 'grid-open') as any; + + sent.length = 0; + await session.handleMessage({ type: 'grid-edit', rowid: grid.rows[0]._rowid, col: 'STAGE', value: 'Hacked' }); + const out = sent.find(m => m.type === 'output') as any; + expect(out.lines.map((l: any) => l.text).join('\n')).toMatch(/not one of the allowed values/); + expect(await run('LIST')).not.toContain('Hacked'); + }); + + it('grid-edit accepts an on-list value', async () => { + const { session, sent, run } = await setup(); + await run('CREATE TABLE DEALS (STAGE CHAR(12) LOOKUP ("Lead","Won"))'); + await run('USE DEALS'); + await run('APPEND RECORD'); + sent.length = 0; + await session.handleMessage({ type: 'command', text: 'BROWSE' }); + const grid = sent.find(m => m.type === 'grid-open') as any; + await session.handleMessage({ type: 'grid-edit', rowid: grid.rows[0]._rowid, col: 'STAGE', value: 'Won' }); + expect(await run('LIST')).toContain('Won'); + }); +});