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
6 changes: 5 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,11 @@ Versions follow [Semantic Versioning](https://semver.org/) — minor bump per su
## [Unreleased] — v1.2.0 — TIME columns, WEEK(), grid validation, Overtime demo

### Added
- (pending)
- `TIME` column type — `CREATE TABLE ... (col TIME)` / `TIME(n)` for a minute-granularity
qualifier (e.g. `TIME(15)` for quarter-hour increments). Stores canonical `HH:MM`,
validated on `REPLACE ... WITH` (rejects malformed or off-granularity values —
no silent coercion), and `LIST STRUCTURE` prints the declared type instead of the
raw SQLite storage class. (#43)

---

Expand Down
15 changes: 15 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,21 @@ WebBase-III supports **unlimited work areas** (no DOS 10-area limit). Cross-area

> Column ops that can invalidate an index (DROP, RENAME, ALTER type) drop all of the table's indexes and warn to rebuild with `INDEX ON`.

#### Column types

`CREATE TABLE`/`ALTER TABLE ADD`/`ALTER TABLE ALTER` accept:

| Type | Aliases | Storage |
|---|---|---|
| `CHAR(n)` | `CHARACTER`, `VARCHAR`, `STRING`, `MEMO` | `TEXT` |
| `NUM` | `NUMERIC`, `FLOAT`, `DOUBLE`, `DECIMAL` | `REAL` |
| `INT` | `INTEGER` | `INTEGER` |
| `LOGICAL` | `BOOLEAN` | `INTEGER` |
| `DATE` | | `TEXT` |
| `TIME` / `TIME(n)` | | `TEXT` |

`TIME` stores `HH:MM` (24-hour). The optional `TIME(n)` qualifier (only via `CREATE TABLE` — not yet carried through `ALTER TABLE`) requires minutes to be a multiple of `n`, e.g. `TIME(15)` only accepts `:00`/`:15`/`:30`/`:45`. `APPEND RECORD` leaves new fields `NULL` (unvalidated); `REPLACE ... WITH` rejects a malformed or off-granularity `TIME` value with `** Error: ...` and does not write it. `LIST STRUCTURE` prints the declared type (`TIME`, `TIME(15)`) rather than the raw SQLite storage class.

### Indexing & search
| Command | What it does |
|---|---|
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,8 @@ WebBase-III supports **unlimited work areas** — each independently holding a t

> Column ops that can invalidate an index (DROP, RENAME, ALTER type) drop all of the table's indexes and warn you to rebuild with `INDEX ON`.

**Column types**: `CHAR(n)` (aliases `CHARACTER`/`VARCHAR`/`STRING`/`MEMO`), `NUM` (`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.

> **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
66 changes: 66 additions & 0 deletions server/ColumnMetaStore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import Database from 'better-sqlite3';
import fs from 'fs';
import path from 'path';
import type { IColumnMetaStore, ColumnTypeInfo } from '../src/shared/types.js';

const DATA_DIR = path.join(process.cwd(), 'data');
const DB_PATH = path.join(DATA_DIR, 'system.sqlite3');

export class ColumnMetaStore implements IColumnMetaStore {
private db: Database.Database;

constructor(dbPath = DB_PATH) {
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
this.db = new Database(dbPath);
this.db.pragma('journal_mode = WAL');
this.db.exec(`
CREATE TABLE IF NOT EXISTS column_types (
table_name TEXT NOT NULL,
col_name TEXT NOT NULL,
base_type TEXT NOT NULL,
qualifier INTEGER,
PRIMARY KEY (table_name, col_name)
);
`);
}

setColumnType(tableName: string, colName: string, baseType: string, qualifier: number | null): void {
this.db.prepare(`
INSERT INTO column_types (table_name, col_name, base_type, qualifier)
VALUES (?, ?, ?, ?)
ON CONFLICT(table_name, col_name) DO UPDATE SET base_type = excluded.base_type, qualifier = excluded.qualifier
`).run(tableName, colName, baseType, qualifier);
}

getColumnType(tableName: string, colName: string): ColumnTypeInfo | null {
const row = this.db.prepare(
'SELECT base_type AS baseType, qualifier FROM column_types WHERE table_name = ? AND col_name = ?'
).get(tableName, colName) as ColumnTypeInfo | undefined;
return row ?? null;
}

listColumnTypes(tableName: string): Record<string, ColumnTypeInfo> {
const rows = this.db.prepare(
'SELECT col_name AS colName, base_type AS baseType, qualifier FROM column_types WHERE table_name = ?'
).all(tableName) as Array<ColumnTypeInfo & { colName: string }>;
const out: Record<string, ColumnTypeInfo> = {};
for (const r of rows) out[r.colName] = { baseType: r.baseType, qualifier: r.qualifier };
return out;
}

renameColumn(tableName: string, oldName: string, newName: string): void {
this.db.prepare(
'UPDATE column_types SET col_name = ? WHERE table_name = ? AND col_name = ?'
).run(newName, tableName, oldName);
}

dropColumn(tableName: string, colName: string): void {
this.db.prepare('DELETE FROM column_types WHERE table_name = ? AND col_name = ?').run(tableName, colName);
}

dropTable(tableName: string): void {
this.db.prepare('DELETE FROM column_types WHERE table_name = ?').run(tableName);
}
}

export const columnMetaStore = new ColumnMetaStore();
3 changes: 2 additions & 1 deletion server/Session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { ServerDatabaseBridge } from './ServerDatabaseBridge.js';
import { programStore } from './ProgramStore.js';
import { reportStore } from './ReportStore.js';
import { indexStore } from './IndexStore.js';
import { columnMetaStore } from './ColumnMetaStore.js';
import type { ClientMessage, ServerMessage, ColInfo } from '../src/shared/types.js';

export class Session {
Expand All @@ -23,7 +24,7 @@ export class Session {
private notifyChange?: (db: string, table: string) => void,
) {
this.bridge = new ServerDatabaseBridge();
this.executor = new Executor(this.bridge, indexStore);
this.executor = new Executor(this.bridge, indexStore, columnMetaStore);
this.bridge.onMutate = () => { this.dirty = true; };
// Fire-and-forget client side-effects (CSV download, report preview, CSV
// upload picker) are emitted immediately so they work at any nesting depth,
Expand Down
51 changes: 48 additions & 3 deletions src/interpreter/Executor.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { IDatabaseBridge, IIndexStore, OutputLine, FormField, WorkArea, ClientSideEffect } from '../shared/types';
import { IDatabaseBridge, IIndexStore, IColumnMetaStore, OutputLine, FormField, WorkArea, ClientSideEffect } from '../shared/types';
import { ASTNode, Expr, ColDef, Parser } from './Parser';
import { Lexer } from './Lexer';
import { callStateless } from './Builtins';
Expand Down Expand Up @@ -35,13 +35,30 @@ type DbType = 'TEXT' | 'REAL' | 'INTEGER' | 'BLOB';

function mapType(t: string): DbType {
switch (t.toUpperCase()) {
case 'CHAR': case 'CHARACTER': case 'VARCHAR': case 'STRING': case 'MEMO': case 'DATE': return 'TEXT';
case 'CHAR': case 'CHARACTER': case 'VARCHAR': case 'STRING': case 'MEMO': case 'DATE': case 'TIME': return 'TEXT';
case 'NUM': case 'NUMERIC': case 'FLOAT': case 'DOUBLE': case 'DECIMAL': return 'REAL';
case 'INT': case 'INTEGER': case 'LOGICAL': case 'BOOLEAN': return 'INTEGER';
default: return 'TEXT';
}
}

const TIME_RE = /^([01]\d|2[0-3]):([0-5]\d)$/;

// Throws if `value` is not a well-formed HH:MM string satisfying `qualifier`
// (a minute-granularity requirement, e.g. 15 for TIME(15)).
function validateTimeValue(field: string, value: unknown, qualifier: number | null): void {
if (value === null || value === undefined) return;
const s = String(value);
const m = TIME_RE.exec(s);
if (!m) {
throw new Error(`${field}: invalid TIME value "${s}" — expected HH:MM (00-23:00-59)`);
}
const minutes = Number(m[2]);
if (qualifier && minutes % qualifier !== 0) {
throw new Error(`${field}: TIME value "${s}" violates TIME(${qualifier}) granularity — minutes must be a multiple of ${qualifier}`);
}
}

function makeArea(alias: string): WorkArea {
return {
alias,
Expand Down Expand Up @@ -69,6 +86,7 @@ export class Executor implements IndexCommandsHost {
constructor(
public db: IDatabaseBridge,
public indexStore: IIndexStore | null = null,
public columnMetaStore: IColumnMetaStore | null = null,
) {
this.areas = new Map([['1', makeArea('1')]]);
this.activeAlias = '1';
Expand Down Expand Up @@ -367,13 +385,16 @@ export class Executor implements IndexCommandsHost {
private async doListStruct(): Promise<ExecResult> {
this.requireTable();
const cols = await this.db.getStructure(this.area.table!);
const meta = this.columnMetaStore?.listColumnTypes(this.area.table!) ?? {};
const out: OutputLine[] = [
{ text: `Structure of table: ${this.area.table}`, cls: 'hdr' },
{ text: `${'#'.padEnd(4)} ${'Field'.padEnd(20)} ${'Type'.padEnd(10)} ${'Null'.padEnd(5)} ${'PK'}`, cls: 'hdr' },
{ text: `${'─'.repeat(55)}`, cls: 'sep' },
];
cols.forEach(c => {
out.push({ text: `${String(c.cid + 1).padEnd(4)} ${c.name.padEnd(20)} ${c.type.padEnd(10)} ${c.notnull ? 'NO' : 'YES'.padEnd(5)} ${c.pk ? 'PK' : ''}` });
const info = meta[c.name];
const typeText = info ? (info.qualifier ? `${info.baseType}(${info.qualifier})` : info.baseType) : c.type;
out.push({ text: `${String(c.cid + 1).padEnd(4)} ${c.name.padEnd(20)} ${typeText.padEnd(10)} ${c.notnull ? 'NO' : 'YES'.padEnd(5)} ${c.pk ? 'PK' : ''}` });
});
return { output: out };
}
Expand Down Expand Up @@ -411,6 +432,12 @@ export class Executor implements IndexCommandsHost {
this.requireTable();
await this.refreshRecCount();
const pairs = fields.map(f => ({ field: f.field, value: this.evalExpr(f.value) }));
for (const p of pairs) {
const info = this.columnMetaStore?.getColumnType(this.area.table!, p.field);
if (info?.baseType === 'TIME') {
validateTimeValue(p.field, p.value, info.qualifier);
}
}
const setClauses = pairs.map(p => `${q(p.field)} = ?`).join(', ');
const params = pairs.map(p => typeof p.value === 'boolean' ? (p.value ? 1 : 0) : p.value);
if (scope === 'ALL') {
Expand Down Expand Up @@ -740,8 +767,18 @@ export class Executor implements IndexCommandsHost {
const colsSql = cols.length
? cols.map(c => `${q(c.name)} ${mapType(c.colType)}`).join(', ')
: '"id" INTEGER PRIMARY KEY AUTOINCREMENT';
for (const c of cols) {
if (c.colType.toUpperCase() === 'TIME' && c.size !== undefined && (!Number.isInteger(c.size) || c.size < 1 || c.size > 59)) {
throw new Error(`CREATE TABLE: invalid TIME granularity qualifier TIME(${c.size}) — must be an integer between 1 and 59`);
}
}
const sql = `CREATE TABLE IF NOT EXISTS ${q(name)} (${colsSql})`;
await this.db.exec(sql);
for (const c of cols) {
if (c.colType.toUpperCase() === 'TIME') {
this.columnMetaStore?.setColumnType(name, c.name, 'TIME', c.size ?? null);
}
}
this.area.table = name;
this.area.filter = null;
this.area.rowPtr = 1;
Expand Down Expand Up @@ -848,6 +885,7 @@ export class Executor implements IndexCommandsHost {
private async doDropTable(name: string): Promise<ExecResult> {
await this.db.exec(`DROP TABLE IF EXISTS ${q(name)}`);
this.indexStore?.dropTable(name);
this.columnMetaStore?.dropTable(name);
if (this.area.table === name) {
this.area.table = null;
this.area.activeIndex = null;
Expand Down Expand Up @@ -881,6 +919,7 @@ export class Executor implements IndexCommandsHost {
if (cols.length <= 1) return { output: [{ text: 'ALTER TABLE: cannot drop the only column', cls: 'error' }] };
const dropped = await this.dropAllIndexes(name);
await this.db.exec(`ALTER TABLE ${q(name)} DROP COLUMN ${q(node.col)}`);
this.columnMetaStore?.dropColumn(name, node.col);
await this.refreshIfActive(name);
return { output: [
{ text: `Dropped column ${node.col} from ${name}.`, cls: 'ok' },
Expand All @@ -893,6 +932,7 @@ export class Executor implements IndexCommandsHost {
if (has(node.newName)) return { output: [{ text: `ALTER TABLE: column already exists: ${node.newName}`, cls: 'error' }] };
const dropped = await this.dropAllIndexes(name);
await this.db.exec(`ALTER TABLE ${q(name)} RENAME COLUMN ${q(node.col)} TO ${q(node.newName)}`);
this.columnMetaStore?.renameColumn(name, node.col, node.newName);
await this.refreshIfActive(name);
return { output: [
{ text: `Renamed ${node.col} to ${node.newName} in ${name}.`, cls: 'ok' },
Expand Down Expand Up @@ -921,6 +961,11 @@ 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)}`);
if (node.colType.toUpperCase() === 'TIME') {
this.columnMetaStore?.setColumnType(name, node.col, 'TIME', null);
} else {
this.columnMetaStore?.dropColumn(name, node.col);
}
await this.refreshIfActive(name);
return { output: [
{ text: `Changed type of ${node.col} to ${node.colType} in ${name}.`, cls: 'ok' },
Expand Down
17 changes: 17 additions & 0 deletions src/shared/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,23 @@ export interface IIndexStore {
dropTable(tableName: string): void;
}

// Metadata for column types SQLite's own affinity can't distinguish (e.g. TIME
// vs CHAR — both store as TEXT). qualifier carries a type-specific parameter,
// e.g. the minute-granularity in TIME(15).
export interface ColumnTypeInfo {
baseType: string;
qualifier: number | null;
}

export interface IColumnMetaStore {
setColumnType(tableName: string, colName: string, baseType: string, qualifier: number | null): void;
getColumnType(tableName: string, colName: string): ColumnTypeInfo | null;
listColumnTypes(tableName: string): Record<string, ColumnTypeInfo>;
renameColumn(tableName: string, oldName: string, newName: string): void;
dropColumn(tableName: string, colName: string): void;
dropTable(tableName: string): void;
}

export interface ReportColumn {
field: string;
heading: string;
Expand Down
2 changes: 1 addition & 1 deletion src/ui/wizards/ModStructWizard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { WizardShell } from './WizardShell';
import type { ColInfo } from '../../shared/types';

const NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
const TYPES = ['CHAR', 'NUM', 'INT', 'DATE', 'LOGICAL', 'MEMO'] as const;
const TYPES = ['CHAR', 'NUM', 'INT', 'DATE', 'TIME', 'LOGICAL', 'MEMO'] as const;

// Map an existing SQLite storage type back to a W3Script type for the picker.
function w3type(sqlType: string): string {
Expand Down
14 changes: 12 additions & 2 deletions src/ui/wizards/TableWizard.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { WizardShell } from './WizardShell';

const NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
const TYPES = ['CHAR', 'NUM', 'INT', 'DATE', 'LOGICAL', 'MEMO'] as const;
const TYPES = ['CHAR', 'NUM', 'INT', 'DATE', 'TIME', 'LOGICAL', 'MEMO'] as const;
const NEEDS_LEN = new Set(['CHAR', 'NUM']);
const OPTIONAL_LEN = new Set(['TIME']);

interface ColRow { name: HTMLInputElement; type: HTMLSelectElement; len: HTMLInputElement; }

Expand Down Expand Up @@ -30,6 +31,15 @@ export function openTableWizard(run: (cmd: string) => void, onClose: () => void)
const len = parseInt(r.len.value, 10);
if (!len || len < 1) return { cmd: null, err: `Length required for ${n} (${t})` };
cols.push(`${n} ${t}(${len})`);
} else if (OPTIONAL_LEN.has(t)) {
const raw = r.len.value.trim();
if (raw) {
const len = parseInt(raw, 10);
if (!len || len < 1) return { cmd: null, err: `Invalid granularity for ${n} (${t})` };
cols.push(`${n} ${t}(${len})`);
} else {
cols.push(`${n} ${t}`);
}
} else {
cols.push(`${n} ${t}`);
}
Expand Down Expand Up @@ -65,7 +75,7 @@ export function openTableWizard(run: (cmd: string) => void, onClose: () => void)

shell = new WizardShell(
'New table',
'Define columns; blank rows are ignored. CHAR and NUM need a length.',
'Define columns; blank rows are ignored. CHAR and NUM need a length; TIME takes an optional minute-granularity (e.g. 15).',
{ okLabel: 'Create table', onOk: () => {
const { cmd } = buildCommand();
if (cmd) { run(cmd); shell.close(); }
Expand Down
Loading
Loading