Skip to content

Commit f1fcdf9

Browse files
DDecoeneclaude
andauthored
feat: SORT ON <field>[/D] TO <newtable> (#8) (#14)
* docs: SORT TO design spec (thin alias) (#8) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat: SORT ON <field>[/D] TO <newtable> (#8) Writes a sorted copy of the active table to a new table. /D sorts descending; the active SET FILTER is honoured. Errors when no table is in use, the field is unknown, or the target already exists. Thin alias over SQLite's CREATE TABLE AS SELECT ... ORDER BY — the new table is a plain snapshot (inferred affinities, no source PK/constraints). SORT is largely redundant given live indexes + ORDER BY; it exists for dBASE III dialect fidelity. Bumps version to 0.6.3; adds parser + integration tests, HELP text, and README/CLAUDE command-table rows. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs: mark SORT TO spec as implemented (#8) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 692f9dc commit f1fcdf9

9 files changed

Lines changed: 209 additions & 2 deletions

File tree

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,16 @@ Versions follow [Semantic Versioning](https://semver.org/) — minor bump per su
77

88
---
99

10+
## [0.6.3] — 2026-06-24 — `SORT TO`
11+
12+
### Added
13+
- **`SORT ON <field>[/D] TO <newtable>`** (#8) — writes a sorted copy of the active table to a new table. `/D` sorts descending (default ascending), and the active `SET FILTER` is honoured. Errors if no table is in use, the field doesn't exist, or the target table already exists.
14+
15+
### Notes
16+
- Implemented as a thin alias over SQLite's `CREATE TABLE … AS SELECT … ORDER BY`. The new table is therefore a plain snapshot — column affinities are inferred and the source PK/constraints are **not** carried over. `SORT` is largely redundant given live indexes + `ORDER BY`; it exists for dBASE III dialect fidelity.
17+
18+
---
19+
1020
## [0.6.2] — 2026-06-24 — Suspended-program fix
1121

1222
### Fixed

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,7 @@ WebBase-III supports **unlimited work areas** (no DOS 10-area limit). Cross-area
137137
| `LIST INDEXES` | Print all indexes for current table with active marker |
138138
| `SEEK <expr>` | Position record pointer at first index match |
139139
| `FIND <string>` | Alias for SEEK (unquoted string — dBASE III legacy) |
140+
| `SORT ON <field>[/D] TO <newtable>` | Sorted copy of the table into a new table (`/D` descending); honours active filter. Thin alias over `CREATE TABLE AS SELECT … ORDER BY` |
140141

141142
> Index expressions support built-in functions: `INDEX ON UPPER(lastname) TO BYUPPER`
142143

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,7 @@ WebBase-III supports **unlimited work areas** — each independently holding a t
203203
| `LIST INDEXES` | Print all indexes for current table with `*` active marker |
204204
| `SEEK <expr>` | Position record pointer at first index match |
205205
| `FIND <string>` | Alias for SEEK (unquoted string — dBASE III legacy form) |
206+
| `SORT ON <field>[/D] TO <newtable>` | Write a sorted copy of the table to a new table; `/D` = descending; honours the active filter |
206207

207208
### Reports
208209

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
# SORT TO — design (thin alias)
2+
3+
Issue: #8`SORT TO` — physically sorted copy of a table.
4+
5+
**Status:** Implemented in v0.6.3 (PR #14). Built as designed; the thin-alias
6+
trade-off below is the one accepted deviation from faithful dBASE behaviour.
7+
8+
## Decision
9+
10+
`SORT` is authentic dBASE III but largely redundant in WebBase-III's SQLite model
11+
(live indexes + `ORDER BY` already cover ordering). We implement it for dialect
12+
fidelity / nostalgia, but **thinly** — leaning on SQLite's `CREATE TABLE … AS
13+
SELECT … ORDER BY` rather than a faithful schema clone.
14+
15+
## Syntax
16+
17+
```
18+
SORT ON <field>[/D] TO <newtable>
19+
```
20+
21+
- Single sort key (matches the issue spec; multi-key left for later if anyone asks).
22+
- `/D` → descending. Default ascending.
23+
24+
## Parser
25+
26+
- New `parseSort()`.
27+
- AST node: `{ type: 'SORT'; field: string; descending: boolean; target: string }`.
28+
29+
## Executor — `doSort()`
30+
31+
1. No active table → error.
32+
2. `<field>` not in the table's structure (`getStructure`) → error.
33+
(Also guards the column name against injection.)
34+
3. `<newtable>` already exists → error (refuse to clobber; safer than dBASE's
35+
silent overwrite and consistent with our other commands).
36+
4. Execute:
37+
`CREATE TABLE <target> AS SELECT * FROM <source> [WHERE <area.filter>] ORDER BY <field> [DESC]`
38+
- Honors the active `SET FILTER`.
39+
- Ignores any active index — SORT defines its own order (matches dBASE III).
40+
5. Output: `Sorted N record(s) into <target>.`
41+
42+
## Accepted trade-off
43+
44+
`CREATE TABLE AS SELECT` produces a plain table: column affinities are inferred
45+
and PK/constraints are **not** carried over. Acceptable for a sorted snapshot;
46+
this is the explicit cost of the thin-alias approach. Noted in CHANGELOG.
47+
48+
## Tests (`tests/Session.test.ts`)
49+
50+
- Ascending order into new table.
51+
- `/D` descending.
52+
- Active filter honored.
53+
- Error on missing field.
54+
- Error on pre-existing target.
55+
56+
## Definition of done
57+
58+
- Tests pass.
59+
- Version `0.6.2 → 0.6.3` (patch — small command addition).
60+
- CHANGELOG entry.
61+
- README + CLAUDE.md command tables gain a `SORT ON … TO …` row.
62+
- Tag `v0.6.3` after the PR merges to `main`.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "webbase-iii",
3-
"version": "0.6.2",
3+
"version": "0.6.3",
44
"description": "dBASE III is back. In your browser. USE customers like it's 1984.",
55
"private": true,
66
"type": "module",

src/interpreter/Executor.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,7 @@ export class Executor implements IndexCommandsHost {
178178
case 'SET_INDEX': return this.indexCmds.doSetIndex(node.tag);
179179
case 'REINDEX': return this.indexCmds.doReindex();
180180
case 'LIST_INDEXES':return this.indexCmds.doListIndexes();
181+
case 'SORT': return this.doSort(node.field, node.descending, node.target);
181182
case 'SEEK': return this.indexCmds.doSeek(node.value);
182183
case 'FIND': return this.indexCmds.doFind(node.value);
183184
case 'DO_CASE': return this.doCase(node.cases, node.otherwise);
@@ -601,6 +602,33 @@ export class Executor implements IndexCommandsHost {
601602
return { output: [{ text: `Table created: ${name}`, cls: 'ok' }] };
602603
}
603604

605+
private async doSort(field: string, descending: boolean, target: string): Promise<ExecResult> {
606+
const source = this.area.table;
607+
if (!source) {
608+
return { output: [{ text: 'SORT: no table in use', cls: 'error' }] };
609+
}
610+
// Validate the sort field against the table's columns (also guards the
611+
// column name we splice into the ORDER BY clause). SQLite column matching
612+
// is case-insensitive, so compare uppercased names.
613+
const cols = await this.db.getStructure(source);
614+
const col = cols.find(c => c.name.toUpperCase() === field.toUpperCase());
615+
if (!col) {
616+
return { output: [{ text: `SORT: no such field: ${field}`, cls: 'error' }] };
617+
}
618+
if (await this.db.tableExists(target)) {
619+
return { output: [{ text: `SORT: target table already exists: ${target}`, cls: 'error' }] };
620+
}
621+
// Thin alias: lean on SQLite's CREATE TABLE ... AS SELECT ... ORDER BY.
622+
// Honors the active filter; ignores any active index (SORT defines its own order).
623+
const where = this.area.filter ? ` WHERE ${this.area.filter}` : '';
624+
const dir = descending ? ' DESC' : '';
625+
await this.db.exec(
626+
`CREATE TABLE ${q(target)} AS SELECT * FROM ${q(source)}${where} ORDER BY ${q(col.name)}${dir}`
627+
);
628+
const count = await this.db.getRowCount(target);
629+
return { output: [{ text: `Sorted ${count} record(s) into ${target}.`, cls: 'ok' }] };
630+
}
631+
604632
private async doDropTable(name: string): Promise<ExecResult> {
605633
await this.db.exec(`DROP TABLE IF EXISTS ${q(name)}`);
606634
this.indexStore?.dropTable(name);
@@ -645,6 +673,7 @@ export class Executor implements IndexCommandsHost {
645673
{ text: 'REINDEX — rebuild SQLite indexes' },
646674
{ text: 'SEEK <value> — position to first match in active index' },
647675
{ text: 'FIND <string> — same as SEEK (legacy string form)' },
676+
{ text: 'SORT ON <field>[/D] TO <newtable> — sorted copy of the table' },
648677
{ text: 'CREATE REPORT <name> — create a new report definition (opens editor)' },
649678
{ text: 'MODIFY REPORT <name> — edit an existing report definition' },
650679
{ text: 'REPORT FORM <name> — run report: ASCII + HTML preview' },

src/interpreter/Lexer.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ const KWS = new Set([
1313
'COUNT','LOCATE','CONTINUE','QUIT','FIELDS','HELP',
1414
'AND','OR','NOT','TRUE','FALSE','CREATE','TABLE','DROP','INDEX','ON',
1515
'INPUT','ACCEPT','DISPLAY','DATABASE','FOR','NEXT',
16-
'SEEK','FIND','REINDEX','INDEXES',
16+
'SEEK','FIND','REINDEX','INDEXES','SORT',
1717
// Multi-work-area
1818
'SELECT','RELATION','ALIAS','AREAS','INTO',
1919
// DO CASE control flow

src/interpreter/Parser.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ export type ASTNode =
5757
| { type: 'SET_INDEX'; tag: string | null }
5858
| { type: 'REINDEX' }
5959
| { type: 'LIST_INDEXES' }
60+
| { type: 'SORT'; field: string; descending: boolean; target: string }
6061
| { type: 'SEEK'; value: Expr }
6162
| { type: 'FIND'; value: string }
6263
| { type: 'UNKNOWN'; raw: string };
@@ -143,6 +144,7 @@ export class Parser {
143144
throw new Error('Expected FORM after REPORT');
144145
}
145146
case 'INDEX': return this.parseIndexOn();
147+
case 'SORT': return this.parseSort();
146148
case 'REINDEX': this.adv(); return { type: 'REINDEX' };
147149
case 'SEEK': this.adv(); return { type: 'SEEK', value: this.expr() };
148150
case 'FIND': { this.adv(); const val = this.peek().val; this.adv(); return { type: 'FIND', value: val }; }
@@ -242,6 +244,25 @@ export class Parser {
242244
return { type: 'SET_FILTER', expr: parts.length ? parts.join(' ') : null };
243245
}
244246

247+
private parseSort(): ASTNode {
248+
this.adv(); // SORT
249+
this.expectKw('ON');
250+
const field = this.ident();
251+
// Optional direction qualifier: /D (descending) or /A (ascending).
252+
// The lexer splits `field/D` into ID, OP('/'), ID('D').
253+
let descending = false;
254+
if (this.peek().type === 'OP' && this.peek().val === '/') {
255+
this.adv(); // '/'
256+
const dir = this.ident().toUpperCase();
257+
descending = dir === 'D';
258+
}
259+
if (!field) throw new Error('SORT requires a field before TO');
260+
this.expectKw('TO');
261+
const target = this.ident();
262+
if (!target) throw new Error('SORT requires a target table after TO');
263+
return { type: 'SORT', field, descending, target };
264+
}
265+
245266
private parseIndexOn(): ASTNode {
246267
this.adv(); // INDEX
247268
this.expectKw('ON');

tests/Session.test.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -742,6 +742,89 @@ describe('Parser: multi-work-area nodes', () => {
742742
const nodes = parse('DELETE ALL');
743743
expect(nodes[0]).toEqual({ type: 'DELETE', scope: 'ALL' });
744744
});
745+
746+
it('parses SORT ON field TO target', () => {
747+
const nodes = parse('SORT ON name TO sorted');
748+
expect(nodes[0]).toEqual({ type: 'SORT', field: 'NAME', descending: false, target: 'SORTED' });
749+
});
750+
751+
it('parses SORT ON field/D TO target as descending', () => {
752+
const nodes = parse('SORT ON age/D TO sorted');
753+
expect(nodes[0]).toEqual({ type: 'SORT', field: 'AGE', descending: true, target: 'SORTED' });
754+
});
755+
});
756+
757+
describe('SORT TO integration', () => {
758+
async function withTable() {
759+
const { session, sent } = makeSession();
760+
const db = uniqueDb();
761+
await session.handleMessage({ type: 'command', text: `USE DATABASE ${db}` });
762+
await session.handleMessage({ type: 'command', text: 'CREATE TABLE people (name TEXT, age NUMERIC)' });
763+
await session.handleMessage({ type: 'command', text: 'USE people' });
764+
for (const [name, age] of [['Carol', 40], ['Alice', 30], ['Bob', 25]] as const) {
765+
await session.handleMessage({ type: 'command', text: 'APPEND RECORD' });
766+
await session.handleMessage({ type: 'command', text: `REPLACE name WITH "${name}", age WITH ${age}` });
767+
}
768+
return { session, sent };
769+
}
770+
771+
function listText(sent: any[]) {
772+
return sent.filter(m => m.type === 'output')
773+
.flatMap((m: any) => m.lines.map((l: any) => l.text)).join(' | ');
774+
}
775+
776+
it('SORT ON name TO creates a table ordered ascending', async () => {
777+
const { session, sent } = await withTable();
778+
await session.handleMessage({ type: 'command', text: 'SORT ON name TO people_sorted' });
779+
sent.length = 0;
780+
await session.handleMessage({ type: 'command', text: 'USE people_sorted' });
781+
await session.handleMessage({ type: 'command', text: 'LIST' });
782+
const text = listText(sent);
783+
expect(text.indexOf('Alice')).toBeLessThan(text.indexOf('Bob'));
784+
expect(text.indexOf('Bob')).toBeLessThan(text.indexOf('Carol'));
785+
});
786+
787+
it('SORT ON field/D produces descending order', async () => {
788+
const { session, sent } = await withTable();
789+
await session.handleMessage({ type: 'command', text: 'SORT ON age/D TO people_desc' });
790+
sent.length = 0;
791+
await session.handleMessage({ type: 'command', text: 'USE people_desc' });
792+
await session.handleMessage({ type: 'command', text: 'LIST' });
793+
const text = listText(sent);
794+
expect(text.indexOf('Carol')).toBeLessThan(text.indexOf('Alice'));
795+
expect(text.indexOf('Alice')).toBeLessThan(text.indexOf('Bob'));
796+
});
797+
798+
it('SORT honors the active filter', async () => {
799+
const { session, sent } = await withTable();
800+
await session.handleMessage({ type: 'command', text: 'SET FILTER TO age > 28' });
801+
await session.handleMessage({ type: 'command', text: 'SORT ON name TO people_filtered' });
802+
sent.length = 0;
803+
await session.handleMessage({ type: 'command', text: 'USE people_filtered' });
804+
await session.handleMessage({ type: 'command', text: 'LIST' });
805+
const text = listText(sent);
806+
expect(text).toContain('Alice');
807+
expect(text).toContain('Carol');
808+
expect(text).not.toContain('Bob');
809+
});
810+
811+
it('SORT on a missing field reports an error', async () => {
812+
const { session, sent } = await withTable();
813+
sent.length = 0;
814+
await session.handleMessage({ type: 'command', text: 'SORT ON nosuch TO people_bad' });
815+
const err = sent.filter(m => m.type === 'output')
816+
.flatMap((m: any) => m.lines).some((l: any) => l.cls === 'error');
817+
expect(err).toBe(true);
818+
});
819+
820+
it('SORT to an existing table reports an error', async () => {
821+
const { session, sent } = await withTable();
822+
sent.length = 0;
823+
await session.handleMessage({ type: 'command', text: 'SORT ON name TO people' });
824+
const err = sent.filter(m => m.type === 'output')
825+
.flatMap((m: any) => m.lines).some((l: any) => l.cls === 'error');
826+
expect(err).toBe(true);
827+
});
745828
});
746829

747830
describe('Multi-work-area integration', () => {

0 commit comments

Comments
 (0)