Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 10 additions & 4 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
18 changes: 13 additions & 5 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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`)

Expand All @@ -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 |
Expand Down
30 changes: 30 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <table>.<column> [DISPLAY <column>]` 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
Expand Down
23 changes: 21 additions & 2 deletions server/Session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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 });
}
Expand Down
21 changes: 17 additions & 4 deletions src/interpreter/Executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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);
Expand Down
4 changes: 3 additions & 1 deletion src/styles/main.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
36 changes: 36 additions & 0 deletions src/ui/Grid.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = '';
Expand Down
Loading
Loading