DSL parser, async DB worker, types, history, metadata, polish

Track 1 implementation plus polish round.

Parser (chumsky):
- Grammar-based DSL producing a typed Command AST.
- create table X with pk [name:type[,name:type...]] supports
  arbitrary names, any user type, compound PKs natively. Bare
  form errors with a friendly hint pointing at `with pk`.
- add column to table X: Name (type); drop table X.
- Required clauses use keyword grammar; -- reserved for opt-in
  flags (ADR-0009). Custom Rich reasons preferred when surfacing
  chumsky errors so unknown-type messages list valid alternatives.

Database (ADR-0010, ADR-0012):
- rusqlite + STRICT tables + foreign_keys=ON.
- Dedicated worker thread; mpsc Request inbox, oneshot replies.
- Typed DbError with friendly_message() hook for H1.
- Internal __rdbms_playground_columns metadata table preserves
  user-facing types across schema reads, atomically maintained
  alongside DDL via Connection transactions. list_tables hides
  it via the new __rdbms_ internal-table convention.

Types (ADR-0005, ADR-0011):
- All ten user-facing types: text, int, real, decimal, bool,
  date, datetime, blob, serial, shortid.
- Type::fk_target_type() for FK-side column-type rule
  (Serial->Int, ShortId->Text, others identity) -- foundation
  for the FK iteration.

App / Runtime / UI:
- update() stays pure-sync; runtime dispatches DSL via spawned
  tasks, results post back as AppEvent::Dsl*.
- Items panel renders live tables list; output panel shows the
  user-facing structure of the current table after each DDL.
- In-memory command history (Up/Down, draft preservation,
  consecutive-duplicate dedup) -- I2 partial.
- Mouse capture removed; terminal native text selection
  restored (toggle approach revisited when scroll/click
  features land).

Docs:
- ADRs 0009 (DSL syntax conventions), 0010 (DB worker),
  0011 (FK type compat), 0012 (internal metadata table).
- requirements.md progress notes; new V4 entry for the
  scrollable session-log + inline rich rendering + Markdown
  export direction.

Tests: 103 passing (91 lib + 12 integration), 0 skipped.
Clippy clean with nursery enabled.
This commit is contained in:
claude@clouddev1
2026-05-07 13:32:19 +00:00
parent 25a0f1260f
commit c1e52920eb
21 changed files with 3186 additions and 120 deletions
Generated
+162
View File
@@ -23,6 +23,15 @@ version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "ar_archive_writer"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7eb93bbb63b9c227414f6eb3a0adfddca591a8ce1e9b60661bb08969b87e340b"
dependencies = [
"object",
]
[[package]]
name = "atomic"
version = "0.6.1"
@@ -107,6 +116,16 @@ dependencies = [
"rustversion",
]
[[package]]
name = "cc"
version = "1.2.61"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d"
dependencies = [
"find-msvc-tools",
"shlex",
]
[[package]]
name = "cfg-if"
version = "1.0.4"
@@ -119,6 +138,20 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "chumsky"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e0d2bfadce76f963d776feff99db6dc33783829539258314776383b33e2a00f8"
dependencies = [
"hashbrown 0.15.5",
"regex-automata",
"serde",
"stacker",
"unicode-ident",
"unicode-segmentation",
]
[[package]]
name = "compact_str"
version = "0.9.0"
@@ -343,6 +376,18 @@ dependencies = [
"num-traits",
]
[[package]]
name = "fallible-iterator"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
[[package]]
name = "fallible-streaming-iterator"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
[[package]]
name = "fancy-regex"
version = "0.11.0"
@@ -370,6 +415,12 @@ dependencies = [
"winapi",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "finl_unicode"
version = "1.4.0"
@@ -477,6 +528,8 @@ version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
dependencies = [
"allocator-api2",
"equivalent",
"foldhash 0.1.5",
]
@@ -497,6 +550,15 @@ version = "0.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51"
[[package]]
name = "hashlink"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea0b22561a9c04a7cb1a302c013e0259cd3b4bb619f145b32f72b8b4bcbed230"
dependencies = [
"hashbrown 0.16.1",
]
[[package]]
name = "heck"
version = "0.5.0"
@@ -630,6 +692,17 @@ version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
[[package]]
name = "libsqlite3-sys"
version = "0.37.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1f111c8c41e7c61a49cd34e44c7619462967221a6443b0ec299e0ac30cfb9b1"
dependencies = [
"cc",
"pkg-config",
"vcpkg",
]
[[package]]
name = "line-clipping"
version = "0.3.7"
@@ -800,6 +873,15 @@ dependencies = [
"libc",
]
[[package]]
name = "object"
version = "0.37.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe"
dependencies = [
"memchr",
]
[[package]]
name = "once_cell"
version = "1.21.4"
@@ -939,6 +1021,12 @@ version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "pkg-config"
version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
[[package]]
name = "portable-atomic"
version = "1.13.1"
@@ -980,6 +1068,16 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "psm"
version = "0.1.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "645dbe486e346d9b5de3ef16ede18c26e6c70ad97418f4874b8b1889d6e761ea"
dependencies = [
"ar_archive_writer",
"cc",
]
[[package]]
name = "quote"
version = "1.0.45"
@@ -1106,11 +1204,13 @@ name = "rdbms-playground"
version = "0.1.0"
dependencies = [
"anyhow",
"chumsky",
"crossterm",
"futures-util",
"insta",
"pretty_assertions",
"ratatui",
"rusqlite",
"thiserror 2.0.18",
"tokio",
"tracing",
@@ -1155,6 +1255,31 @@ version = "0.8.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
[[package]]
name = "rsqlite-vfs"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8a1f2315036ef6b1fbacd1972e8ee7688030b0a2121edfc2a6550febd41574d"
dependencies = [
"hashbrown 0.16.1",
"thiserror 2.0.18",
]
[[package]]
name = "rusqlite"
version = "0.39.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a0d2b0146dd9661bf67bb107c0bb2a55064d556eeb3fc314151b957f313bcd4e"
dependencies = [
"bitflags 2.11.1",
"fallible-iterator",
"fallible-streaming-iterator",
"hashlink",
"libsqlite3-sys",
"smallvec",
"sqlite-wasm-rs",
]
[[package]]
name = "rustc_version"
version = "0.4.1"
@@ -1264,6 +1389,12 @@ dependencies = [
"lazy_static",
]
[[package]]
name = "shlex"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]]
name = "signal-hook"
version = "0.3.18"
@@ -1329,6 +1460,31 @@ dependencies = [
"windows-sys",
]
[[package]]
name = "sqlite-wasm-rs"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b2c760607300407ddeaee518acf28c795661b7108c75421303dbefb237d3a36"
dependencies = [
"cc",
"js-sys",
"rsqlite-vfs",
"wasm-bindgen",
]
[[package]]
name = "stacker"
version = "0.1.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "640c8cdd92b6b12f5bcb1803ca3bbf5ab96e5e6b6b96b9ab77dabe9e880b3190"
dependencies = [
"cc",
"cfg-if",
"libc",
"psm",
"windows-sys",
]
[[package]]
name = "static_assertions"
version = "1.1.0"
@@ -1690,6 +1846,12 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
[[package]]
name = "vcpkg"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]]
name = "version_check"
version = "0.9.5"
+2
View File
@@ -10,9 +10,11 @@ publish = false
[dependencies]
anyhow = "1.0.102"
chumsky = "0.13.0"
crossterm = { version = "0.29.0", features = ["event-stream"] }
futures-util = "0.3.32"
ratatui = "0.30.0"
rusqlite = { version = "0.39.0", features = ["bundled"] }
thiserror = "2.0.18"
tokio = { version = "1.52.2", features = ["full"] }
tracing = "0.1.44"
@@ -0,0 +1,87 @@
# ADR-0009: DSL command syntax conventions
## Status
Accepted
## Context
As the DSL grows, its commands need consistent surface
conventions. Without an explicit rule, every command would
invent its own way of expressing optional vs. required parts,
and the surface would drift toward an unreadable soup.
The decision is informed by experience from this iteration:
when we initially proposed `create table X --pk` the most
common form (a basic table with a primary key) required a `--`
flag, which is cosmetically wrong — `--` reads as "extra
option," and the most-used form should not look like one.
## Decision
The DSL surface follows three rules.
### 1. Required clauses use keyword grammar
Required parts of a command are written in plain words and
read like English. Examples:
- `create table <Name> with pk <name>:<type>`
- `add column to table <Name>: <Name> (<Type>)`
- `drop table <Name>`
The `with` clause format is the canonical pattern for
attaching required structural information to an entity-creating
command, and is reusable: future iterations may add `with
index`, `with check`, etc. Multiple `with` clauses on the same
command are allowed in principle.
### 2. Optional flags use `--prefix`
Flags signal "I am asking for an extra capability or
non-default behaviour." Examples planned for later iterations:
- `add 1:n relationship on Customers.Id=Orders.CustId --create-fk`
(auto-creates the FK column instead of requiring it to exist)
- *(future)* `--rename-on-clash`, `--no-strict`, etc.
A user reading "with pk id:serial" sees only what's needed; a
user reading "...with pk id:serial --some-flag" sees that they
have asked for something beyond default. The visual distinction
is intentional.
### 3. One sigil only — `:` for the simple-mode advanced escape
Per ADR-0003, prefixing a single line with `:` in simple mode
treats that one submission as if it were entered in advanced
mode. This is the only sigil in the system. App-level commands,
DSL commands, and SQL all use plain words.
### Lexical rules
- **Keywords are case-insensitive.** `CREATE TABLE Customers
WITH PK email:TEXT` is equivalent to `create table Customers
with pk email:text`.
- **Identifiers are case-preserving.** `Customers` and
`customers` are different identifiers if a backend would
treat them as such (we follow SQLite's case-insensitive
identifier rules at the schema level but preserve the user's
written casing in display).
- **Whitespace is liberal.** Any amount of horizontal whitespace
between tokens is accepted, including around punctuation
(`,`, `:`, `(`, `)`).
## Consequences
- The basic, most-common form of any command remains readable
and free of cosmetic punctuation. New users see only words.
- Optional adornments are visually distinct, encouraging
discoverability of advanced features without forcing them on
beginners.
- New commands inherit a uniform shape: keyword-based clauses
for required parts, `--` flags for opt-ins. Drift is bounded
by this rule.
- The grammar implementation (`chumsky`) maps cleanly onto this
structure: a `with_clause` rule can be reused across
commands, and flag parsing has a single representation when
it lands.
@@ -0,0 +1,93 @@
# ADR-0010: Database access via a dedicated worker thread
## Status
Accepted
## Context
`rusqlite::Connection` is `Send` but `!Sync`, and its API is
synchronous. Our application loop is asynchronous (Tokio,
ADR-0001). We need a clean async boundary that keeps:
- The `App::update` function pure — same input, same state
transition, same outputs (per ADR-0001's Elm-style design);
this is what makes ADR-0008's Tier 1 and Tier 3 tests
tractable.
- The path to future requirements (B3 query timeout/cancellation,
U1 snapshot capture, P3 auto-save) free of architectural
refactors.
- Errors surfaceable in a way that the future H1 friendly-error
layer can plug into without callsite changes.
Considered alternatives:
1. **Sync calls from async tasks** — call rusqlite directly
inside `update`. Blocks the event loop on every DB call.
Unworkable as soon as queries grow.
2. **`tokio::task::spawn_blocking` per call** — uses tokio's
blocking thread pool. Acceptable for short ops but the pool
is sized for short tasks, and a long-lived "this is the
database connection" task is not its intended use.
3. **Dedicated worker thread with typed request/response
channels** — the connection lives on a thread we own; the
App holds a sender; replies come back via per-request
`oneshot` channels.
## Decision
A single dedicated OS thread (`std::thread::Builder::new().name("rdbms-db-worker")`)
owns the `rusqlite::Connection`. The App talks to it through:
- A bounded `tokio::sync::mpsc::Sender<Request>` carrying typed
request enum variants, each of which embeds a
`tokio::sync::oneshot::Sender` for the reply.
- A typed `DbError` (`thiserror`-derived) wrapping the union of
database-side failure modes, with a `friendly_message()`
method whose body today is a passthrough — this is the hook
point for the future H1 friendly-error layer.
The worker:
- Receives requests via `Receiver::blocking_recv` (no Tokio
context required on the worker thread).
- Performs the operation synchronously against the connection.
- Sends the result to the per-request `oneshot` reply channel.
- Exits when the last `Sender` is dropped (clean shutdown via
Drop, no shutdown protocol).
The public `Database` handle is `Clone` (it's a wrapper around
a `Sender`), so multiple components can hold it.
## Consequences
- `App::update` stays sync and pure. The runtime, on receiving
an `Action::ExecuteDsl`, spawns a `tokio::spawn` task that
awaits the DB worker and posts the result back as a new
`AppEvent`. The Elm pattern is preserved.
- New database operations are mechanical to add: define a
`Request` variant with its `oneshot` reply type, add a method
on `Database`, add a handler in the worker.
- B3 (query timeout/cancellation) lands on top of this without
architecture change: a cancellation token can be stored
alongside the in-flight request, and the worker can interrupt
via `rusqlite::Connection::interrupt`.
- U1 (snapshot capture) and P3 (auto-save) are also additive:
more `Request` variants, possibly using SQLite's online
backup API.
- Errors carry a typed kind (`UniqueViolation`, `NoSuchTable`,
etc.). When H1's friendly-error layer lands, the body of
`friendly_message()` becomes the translation table; callsites
do not change.
- Cost: an extra OS thread per database. For a desktop tool
this is negligible; this would be different for a server
application that hosts many databases.
## Implementation notes
- Channel capacity 64 is sufficient bursts head-room for an
interactive tool.
- The connection is opened in the spawning thread and *moved*
into the worker. This gives us fail-fast behaviour: a bad
path or permissions issue surfaces immediately to the caller
before any worker is started.
@@ -0,0 +1,78 @@
# ADR-0011: Foreign-key column type compatibility
## Status
Accepted
## Context
Two of our user-facing types (per ADR-0005) carry insert-time
auto-generation semantics that only apply on the primary-key
side of a relationship:
- `serial` is `INTEGER PRIMARY KEY` with rowid-alias /
auto-increment behaviour.
- `shortid` is `TEXT` populated client-side with a 1012 char
base58 random value when no value is supplied.
A foreign-key column referencing such a primary key does *not*
auto-generate values — it stores the looked-up value of the
primary key. Asking the user to declare the FK column with the
same user-facing type as the PK would be wrong: `serial` on the
FK side would imply the FK column has its own auto-increment
counter (it doesn't), and similar for `shortid`.
Without this rule, the future FK declaration grammar (C3, C4)
would either generate incorrect DDL or rely on the user to
remember to use a different type on the FK side — an easy
foot-gun.
## Decision
Define `Type::fk_target_type(self) -> Type` returning the
appropriate user-facing type for a foreign-key column that
references a primary key of `self`:
| User-facing type | `fk_target_type()` |
|------------------|--------------------|
| `text` | `text` |
| `int` | `int` |
| `real` | `real` |
| `decimal` | `decimal` |
| `bool` | `bool` |
| `date` | `date` |
| `datetime` | `datetime` |
| `blob` | `blob` |
| `serial` | **`int`** |
| `shortid` | **`text`** |
All types other than `serial` and `shortid` are identity
mappings. The two exceptions strip the auto-generation
semantics by mapping to the underlying value type.
## Consequences
- The future FK declaration grammar uses `fk_target_type()` in
one of two ways:
- **Auto-typing** — when the FK column does not yet exist and
`--create-fk` is given (or whatever the equivalent flag
becomes), the column is created with
`pk_column.ty.fk_target_type()`.
- **Validation** — when the FK column already exists, its
type is compared against `fk_target_type()`. A mismatch
yields a clear diagnostic ("Customers.id is `serial`; the
FK column should be `int`, not `text`"). This is the
teaching moment ADR-0009's design philosophy targets.
- Adding a new user-facing type forces an explicit decision
about its `fk_target_type()`. The type system is therefore
closed under FK declaration.
- The advisory feedback module (foreshadowed in V4 and the
hint surface) can use the same mapping to surface
recommendations during command typing — e.g. "you used
`serial` for an FK; conventionally `int` is the right fit
here." This is *advice* not gating, consistent with
ADR-0009's separation of required grammar from optional
guidance.
- `fk_target_type()` is implemented and tested *before* the FK
declaration grammar lands, so the FK iteration is grammar
work only.
@@ -0,0 +1,115 @@
# ADR-0012: Internal metadata for user-facing column types
## Status
Accepted
## Context
ADR-0005 commits to ten user-facing types (`text`, `int`, `real`,
`decimal`, `bool`, `date`, `datetime`, `blob`, `serial`,
`shortid`) and to mapping them transparently onto SQLite STRICT
types so that learners do not see SQLite's erased forms (Q3 in
the requirements checklist). The mapping is many-to-few:
`shortid`, `decimal`, `date`, `datetime` all map to `TEXT`;
`serial` and `bool` both map to `INTEGER`.
Reading the schema back via `PRAGMA table_info` therefore loses
the original user-facing type. Rendering "id INTEGER" instead of
"id serial", or "created TEXT" instead of "created datetime",
breaks the Q3 promise even though we generated correct DDL.
The same information is needed by track 2 (project file format,
ADR-0004): when serialising the schema to YAML, we need the
user-facing types, not the SQLite-erased ones.
## Decision
Maintain an internal SQLite table that records the user-facing
type each column was declared with. The table is part of the
database itself, so it travels with the `playground.db` file
and is the single source of truth for round-tripping types.
```sql
CREATE TABLE __rdbms_playground_columns (
table_name TEXT NOT NULL,
column_name TEXT NOT NULL,
user_type TEXT NOT NULL,
PRIMARY KEY (table_name, column_name)
) STRICT;
```
- **Bootstrap.** The table is created on every connection open
with `CREATE TABLE IF NOT EXISTS`, alongside `PRAGMA
foreign_keys = ON`.
- **Writes.** `create_table` and `add_column` insert one row per
user column, atomically with the DDL via
`Connection::unchecked_transaction`. `drop_table` deletes all
rows for the dropped table. Every DDL operation that adds or
removes user columns must keep this table in sync.
- **Reads.** `describe_table` LEFT-JOINs `pragma_table_info`
with this table to populate `ColumnDescription::user_type`.
Callers prefer `user_type` for display; `sqlite_type` is
retained for diagnostics and fallback.
- **Visibility.** `list_tables` filters out any name starting
with `__rdbms_` so the metadata table is hidden from the user.
### Naming convention for internal tables
All future internal tables use the `__rdbms_` prefix (double
underscore plus `rdbms_`). This serves two purposes:
1. **Avoiding collisions.** SQLite reserves `sqlite_` for its
own use; we reserve `__rdbms_` for ours. A user cannot
create a table whose name conflicts with our internals
without explicitly typing the prefix, which is unlikely by
accident.
2. **Single filter rule.** `list_tables` and any future
schema-introspection code excludes names matching
`sqlite_%` and `__rdbms_%`. New internal tables are
automatically hidden as long as they follow the convention.
## Consequences
- The Q3 / ADR-0005 transparency promise is delivered. Users
see `id serial`, not `id INTEGER`.
- The DSL parser, type system, and database layer collectively
control all user-facing type information; SQLite's erased
view is never the authoritative source.
- Track 2 (project file format) round-trips user types via
this metadata. The YAML schema dump reads from
`__rdbms_playground_columns`; loading reconstructs the
metadata alongside the DDL.
- DDL operations carry a small additional cost (one transaction
per operation, one row per user column). Negligible for
interactive use; would matter only for bulk schema operations,
which are not in scope.
- Adding new user-column-creating operations must extend this
metadata in step. The single-source-of-truth invariant is
worth defending — a future code review should reject any new
DDL that touches columns without updating
`__rdbms_playground_columns`.
- The convention `__rdbms_<name>` for internal tables is
established for any future internal-state needs (snapshot
bookkeeping, replay log indexing, etc.).
## Alternatives considered
- **In-memory cache in `App`.** Simpler, but doesn't survive
a restart, and would have to be rebuilt by parsing past
command history. Loses the single-source-of-truth property.
- **Sentinel comments in DDL.** SQLite's
`sqlite_master.sql` retains the original CREATE TABLE text;
we could embed `/* user_type: serial */` markers and parse
them back. Brittle and hard to keep correct under ALTER.
- **Lossy reverse mapping.** Heuristically guess `serial` from
`INTEGER PRIMARY KEY`, `text` from `TEXT`, etc. Cannot
distinguish `text`/`shortid`/`decimal`/`date`/`datetime`,
all backed by `TEXT`. Wrong by construction.
## See also
- ADR-0002 (database engine, STRICT tables, `foreign_keys` ON)
- ADR-0005 (column type vocabulary, mapping commitment)
- ADR-0010 (database access via dedicated worker thread —
describes where this metadata is read and written from)
+4
View File
@@ -14,3 +14,7 @@ This directory contains the project's ADRs, recorded per
- [ADR-0006 — Undo snapshots and replay log](0006-undo-snapshots-and-replay-log.md)
- [ADR-0007 — Sharing and export](0007-sharing-and-export.md)
- [ADR-0008 — Testing approach](0008-testing-approach.md)
- [ADR-0009 — DSL command syntax conventions](0009-dsl-command-syntax-conventions.md)
- [ADR-0010 — Database access via a dedicated worker thread](0010-database-access-via-worker-thread.md)
- [ADR-0011 — Foreign-key column type compatibility](0011-fk-column-type-compatibility.md)
- [ADR-0012 — Internal metadata for user-facing column types](0012-internal-metadata-for-user-facing-types.md)
+45 -6
View File
@@ -48,6 +48,8 @@ against it.
- [ ] **S2** Items list shows tables and per-table indexes;
designed to extend to additional element kinds (relations,
views, etc.) without restructuring.
*(Progress: tables are listed live from the database; indexes
pending alongside C3 index support.)*
- [ ] **S3** Output panel renders a visualization of the
currently selected item and supports multiple tabs.
- [ ] **S4** Hint area below the input field; keyboard-toggleable
@@ -61,6 +63,10 @@ against it.
equivalent) submits, plain Enter inserts a newline.
- [ ] **I2** Persistent navigable input history (project-scoped,
with a global rolling history also available).
*(Progress: in-memory navigable history (Up/Down arrows, draft
preservation, dedup of consecutive duplicates) is implemented;
persistence across sessions arrives with track 2's project
storage.)*
- [ ] **I3** Tab completion for app commands, DSL keywords, table
names, column names, and SQL keywords.
- [ ] **I4** Syntax highlighting for both the DSL and SQL.
@@ -85,16 +91,24 @@ against it.
available in both modes: `save`, `save as`, `load`, `new`,
`export`, `import`, `seed`, `replay`, `undo`, `redo`, `mode`,
`help`, `hint`, `quit`.
*(Progress: `quit`/`q` and `mode simple|advanced` implemented;
the rest land alongside the features they belong to — `save`
and friends in track 2, `seed` in the seeding iteration, etc.)*
## DSL data commands
- [ ] **C1** Table operations: create / drop / rename.
*(Progress: create + drop done; rename pending.)*
- [ ] **C2** Column operations: add / drop / rename / change
type, including the rebuild-table dance behind the scenes
where SQLite ALTER cannot do it directly.
*(Progress: add done; drop/rename/change-type pending — the
rebuild-table dance is the gating piece, B2.)*
- [ ] **C3** Schema constraints: primary key (single and
compound), foreign key with `ON DELETE` / `ON UPDATE` referential
actions, indexes, `NOT NULL`, `UNIQUE`, `CHECK`, `DEFAULT`.
*(Progress: PK including compound done at create-table time;
FK/index/NOT NULL/UNIQUE/CHECK/DEFAULT pending.)*
- [ ] **C4** Convenience: `create m:n relationship from <T1> to
<T2>` produces an auto-named junction table the user can rename;
pulls primary keys and FK definitions automatically.
@@ -104,31 +118,43 @@ against it.
- [ ] **Q1** SQL parsed via `sqlparser-rs`; supported subset is
defined (specifics deferred to a future ADR).
*(Progress: DSL is parsed via `chumsky` (ADR-0009); SQL
handling in advanced mode is still a placeholder echo.)*
- [ ] **Q2** Non-standard syntax rejected with a clear message
pointing at the supported subset.
- [ ] **Q3** User-facing simplified types map transparently to
SQLite STRICT types in generated DDL.
- [x] **Q3** User-facing simplified types map transparently to
SQLite STRICT types in generated DDL. *(All ten types implemented
and tested.)*
- [~] **Q4** Supported SQL subset specification — design and ADR
pending. Q1 cannot be marked satisfied without it.
## Database backend (per ADR-0002)
- [ ] **B1** SQLite via `rusqlite`; all tables created `STRICT`;
`PRAGMA foreign_keys = ON` per connection.
- [x] **B1** SQLite via `rusqlite`; all tables created `STRICT`;
`PRAGMA foreign_keys = ON` per connection. *(Database accessed
through a dedicated worker thread per ADR-0010.)*
- [ ] **B2** Schema evolution uses the rebuild-table technique
internally where SQLite `ALTER TABLE` cannot.
- [ ] **B3** Query timeout and cancellation supported (no
cartesian-join-of-doom can hang the app).
*(Progress: the worker-thread architecture is in place; the
cancellation/timeout protocol on top of it is pending.)*
## Type system (per ADR-0005)
- [ ] **T1** All ten user-facing types implemented: `text`,
- [x] **T1** All ten user-facing types implemented: `text`,
`int`, `real`, `decimal`, `bool`, `date`, `datetime`, `blob`,
`serial`, `shortid`.
`serial`, `shortid`. *(Mapping to SQLite STRICT covered by
ADR-0005; FK target type rule by ADR-0011.)*
- [ ] **T2** `shortid` generation: base58, 1012 characters,
omits ambiguous characters; generated client-side at insert.
*(Type exists; insert-time generation arrives with the data
insertion path.)*
- [ ] **T3** Compound primary keys handled end-to-end (DSL,
storage, display, FK reference).
*(Progress: DSL grammar (`with pk a:int,b:int`), storage, and
table-info description are all present; pretty display of the
PK in the structure view and FK reference still pending.)*
## Visualizations
@@ -136,10 +162,23 @@ against it.
selected table as its structure (columns, types, keys,
constraints); a selected relationship as two tables joined by
a line.
*(Progress: a basic structure view (column rows with SQLite
type names) is rendered after each successful DDL; pretty
rendering, selection nav, and relationship line-art pending —
see V4 for the broader direction.)*
- [ ] **V2** SQL query results render as a dynamic table view in
the output pane, with multiple result tabs supported.
- [~] **V3** Full ER-diagram export (whole-database graph, viewed
outside the TUI) — low priority; design and ADR pending.
- [~] **V4** Output panel as a *scrollable per-session log* with
inline rich rendering. Direction agreed in conversation: the
output area is a chronological journal of operations and
selections (e.g. a "selected table X" entry with the rendered
structure underneath); structure renderings choose between a
compact ASCII-table form and a vertical line-per-column form
based on dimensions; the log is exportable to Markdown so
learners can keep a record of their session. Design and ADR
pending before any implementation.
## Project lifecycle (per ADR-0004)
+13 -5
View File
@@ -1,12 +1,20 @@
//! Actions returned by the application's update function.
//!
//! `update` is pure with respect to the runtime: it mutates state
//! in place and returns a list of `Action`s for the runtime to
//! enact (e.g. quit the event loop). Side effects belong here,
//! not in the update logic itself, which keeps `update` directly
//! testable without a Tokio runtime or a real terminal.
//! `update` is pure with respect to the runtime: it mutates
//! state in place and returns a list of `Action`s for the
//! runtime to enact (quit, dispatch a DSL command to the
//! database, etc.). Side effects belong here, not in update
//! itself, which keeps update directly testable without a Tokio
//! runtime, a real terminal, or a database.
use crate::dsl::Command;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Action {
/// Stop the event loop and exit cleanly.
Quit,
/// Hand a parsed DSL command to the database worker. The
/// runtime executes it and feeds the result back as
/// `AppEvent::DslSucceeded` or `AppEvent::DslFailed`.
ExecuteDsl(Command),
}
+479 -61
View File
@@ -1,18 +1,19 @@
//! Application state and the single `update` entry point.
//!
//! The walking skeleton recognises a small subset of the
//! canonical app-level commands from ADR-0003 — `quit` and
//! `mode` — plus the `:` one-shot escape from simple to advanced
//! per ADR-0003. Everything else is echoed to the output panel
//! tagged with the mode it was submitted under, so that mode
//! handling is visible end-to-end.
//! `update` is pure with respect to the runtime: it mutates
//! state in place and returns a list of `Action`s. Side effects
//! (DB execution, quit, etc.) live in the runtime. This keeps
//! every behaviour drivable from synthetic events in tests,
//! which is what makes ADR-0008's Tier 1/3 testing tractable.
use std::collections::VecDeque;
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use tracing::trace;
use tracing::{trace, warn};
use crate::action::Action;
use crate::db::TableDescription;
use crate::dsl::{Command, ParseError, parse_command};
use crate::event::AppEvent;
use crate::mode::Mode;
@@ -26,13 +27,20 @@ pub enum OutputKind {
Error,
}
#[derive(Debug, Clone)]
pub struct OutputLine {
pub text: String,
pub kind: OutputKind,
pub mode_at_submission: Mode,
}
/// What mode the next submission would be evaluated in.
///
/// Derived from the persistent mode and the current input buffer.
/// The UI uses this to give immediate visual feedback for the `:`
/// one-shot escape: the moment a leading `:` is typed in simple
/// mode, the prompt flips to advanced styling, and reverts as
/// soon as the `:` is deleted.
/// Derived from the persistent mode and the current input
/// buffer. The UI uses this to give immediate visual feedback
/// for the `:` one-shot escape: the moment a leading `:` is
/// typed in simple mode, the prompt flips to advanced styling,
/// and reverts as soon as the `:` is deleted.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EffectiveMode {
Simple,
@@ -47,21 +55,32 @@ impl EffectiveMode {
}
}
#[derive(Debug, Clone)]
pub struct OutputLine {
pub text: String,
pub kind: OutputKind,
pub mode_at_submission: Mode,
}
#[derive(Debug)]
pub struct App {
pub mode: Mode,
pub input: String,
pub output: VecDeque<OutputLine>,
pub hint: Option<String>,
pub tables: Vec<String>,
/// Last successfully described table, shown in the output
/// pane until the next DDL operation.
pub current_table: Option<TableDescription>,
/// In-memory history of submitted lines, oldest first.
/// Persistent history across sessions (I2 second half) lands
/// when track 2's project storage is in place.
pub history: Vec<String>,
/// Position within `history` while navigating with Up/Down.
/// `None` means "not navigating; `input` is the user's
/// in-progress draft."
history_cursor: Option<usize>,
/// Snapshot of the user's in-progress draft taken when they
/// start navigating history, restored if they navigate back
/// past the most recent entry.
history_draft: Option<String>,
}
const HISTORY_CAPACITY: usize = 1000;
impl Default for App {
fn default() -> Self {
Self::new()
@@ -76,6 +95,11 @@ impl App {
input: String::new(),
output: VecDeque::with_capacity(OUTPUT_CAPACITY),
hint: None,
tables: Vec::new(),
current_table: None,
history: Vec::new(),
history_cursor: None,
history_draft: None,
}
}
@@ -99,13 +123,29 @@ impl App {
match event {
AppEvent::Key(key) => self.handle_key(key),
AppEvent::Resize { .. } | AppEvent::Tick => Vec::new(),
AppEvent::DslSucceeded {
command,
description,
} => {
self.handle_dsl_success(&command, description);
Vec::new()
}
AppEvent::DslFailed { command, error } => {
self.handle_dsl_failure(&command, &error);
Vec::new()
}
AppEvent::TablesRefreshed(tables) => {
trace!(count = tables.len(), "tables refreshed");
self.tables = tables;
Vec::new()
}
}
}
fn handle_key(&mut self, key: KeyEvent) -> Vec<Action> {
// On Windows, key events fire for both Press and Release; only
// honour Press to avoid double-handling. Other platforms only
// emit Press, so this is a no-op there.
// On Windows, key events fire for both Press and Release;
// honour only Press to avoid double-handling. Other
// platforms emit Press only, so this is a no-op there.
if key.kind != KeyEventKind::Press {
return Vec::new();
}
@@ -113,11 +153,21 @@ impl App {
match (key.code, key.modifiers) {
(KeyCode::Char('c'), KeyModifiers::CONTROL) => vec![Action::Quit],
(KeyCode::Enter, _) => self.submit(),
(KeyCode::Up, _) => {
self.history_back();
Vec::new()
}
(KeyCode::Down, _) => {
self.history_forward();
Vec::new()
}
(KeyCode::Backspace, _) => {
self.cancel_history_navigation();
self.input.pop();
Vec::new()
}
(KeyCode::Char(c), m) if !m.contains(KeyModifiers::CONTROL) => {
self.cancel_history_navigation();
let was_empty = self.input.is_empty();
self.input.push(c);
// Convenience: when `:` becomes the leading character in
@@ -133,12 +183,76 @@ impl App {
}
}
/// Move backwards in history (towards older entries).
fn history_back(&mut self) {
if self.history.is_empty() {
return;
}
let next_index = match self.history_cursor {
None => {
// Starting navigation: save the current draft so the
// user can return to it.
self.history_draft = Some(self.input.clone());
self.history.len() - 1
}
Some(0) => 0,
Some(i) => i - 1,
};
self.history_cursor = Some(next_index);
self.input = self.history[next_index].clone();
}
/// Move forwards in history (towards newer entries; eventually
/// returning to the user's saved draft).
fn history_forward(&mut self) {
let Some(i) = self.history_cursor else {
return;
};
if i + 1 < self.history.len() {
self.history_cursor = Some(i + 1);
self.input = self.history[i + 1].clone();
} else {
// Past the most recent entry — restore the draft and
// exit navigation mode.
self.history_cursor = None;
self.input = self.history_draft.take().unwrap_or_default();
}
}
fn cancel_history_navigation(&mut self) {
self.history_cursor = None;
// Drop the saved draft: the user has begun editing again,
// so what's in `input` *is* the new draft.
self.history_draft = None;
}
fn push_history(&mut self, line: &str) {
// Skip empties and consecutive duplicates — the same
// trick most shells use to keep navigation pleasant.
if line.is_empty() {
return;
}
if self.history.last().map(String::as_str) == Some(line) {
return;
}
self.history.push(line.to_string());
while self.history.len() > HISTORY_CAPACITY {
self.history.remove(0);
}
self.history_cursor = None;
self.history_draft = None;
}
fn submit(&mut self) -> Vec<Action> {
let raw = std::mem::take(&mut self.input);
let trimmed = raw.trim();
if trimmed.is_empty() {
return Vec::new();
}
// Record the original (trimmed) line in history regardless
// of whether it parses, so users can recall and edit
// typo'd commands.
self.push_history(trimmed);
// `:` one-shot escape: in simple mode, a leading `:` means
// treat *this single submission* as advanced. The persistent
@@ -155,7 +269,7 @@ impl App {
}
// Canonical app-level commands recognised in both modes.
// The walking skeleton implements only `quit` and `mode`;
// The current iteration implements `quit` and `mode`;
// the rest of the canonical list lands in later iterations.
match effective_input.as_str() {
"quit" | "q" => return vec![Action::Quit],
@@ -166,7 +280,17 @@ impl App {
_ => {}
}
// Default: echo the line tagged with its effective mode.
// For everything else: dispatch by effective mode.
match effective_mode {
Mode::Simple => self.dispatch_dsl(&effective_input, effective_mode),
Mode::Advanced => {
// SQL handling is not implemented yet; show a placeholder
// until the advanced-mode SQL path lands. Once it does,
// this branch parses with sqlparser-rs and dispatches
// analogously to the DSL path below.
self.note_system(format!(
"advanced mode SQL not implemented yet — echo: {effective_input}"
));
self.push_output(OutputLine {
text: effective_input,
kind: OutputKind::Echo,
@@ -174,6 +298,59 @@ impl App {
});
Vec::new()
}
}
}
fn dispatch_dsl(&mut self, input: &str, submission_mode: Mode) -> Vec<Action> {
match parse_command(input) {
Ok(cmd) => {
self.push_output(OutputLine {
text: format!("running: {input}"),
kind: OutputKind::Echo,
mode_at_submission: submission_mode,
});
vec![Action::ExecuteDsl(cmd)]
}
Err(ParseError::Empty) => Vec::new(),
Err(err) => {
self.note_error(format!("parse error: {}", parse_error_message(&err)));
Vec::new()
}
}
}
fn handle_dsl_success(&mut self, command: &Command, description: Option<TableDescription>) {
let summary = format!("[ok] {} {}", command.verb(), command.target_table());
self.note_system(summary);
if let Some(desc) = description.as_ref() {
self.note_system(format!(" {}", desc.name));
for col in &desc.columns {
let pk = if col.primary_key { " [PK]" } else { "" };
let nn = if col.notnull { " NOT NULL" } else { "" };
// Prefer the user-facing type recovered from our
// metadata table; fall back to the SQLite type only
// if metadata is missing (only happens for tables we
// didn't create — not in the current flow).
let type_display = col
.user_type
.map_or_else(|| col.sqlite_type.to_lowercase(), |t| t.keyword().to_string());
self.note_system(format!(
" {} {}{}{}",
col.name, type_display, pk, nn
));
}
}
self.current_table = description;
}
fn handle_dsl_failure(&mut self, command: &Command, error: &str) {
warn!(verb = command.verb(), error, "dsl command failed");
self.note_error(format!(
"{} {} failed: {error}",
command.verb(),
command.target_table()
));
}
fn handle_mode_command(&mut self, raw: &str) {
let arg = raw.strip_prefix("mode").unwrap_or(raw).trim();
@@ -217,9 +394,18 @@ impl App {
}
}
fn parse_error_message(err: &ParseError) -> String {
match err {
ParseError::Invalid { message, .. } => message.clone(),
ParseError::Empty => "empty input".to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::ColumnDescription;
use crate::dsl::Type;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use pretty_assertions::assert_eq;
@@ -241,6 +427,19 @@ mod tests {
app.update(key(KeyCode::Enter))
}
fn sample_description(name: &str) -> TableDescription {
TableDescription {
name: name.to_string(),
columns: vec![ColumnDescription {
name: "id".to_string(),
user_type: Some(Type::Serial),
sqlite_type: "INTEGER".to_string(),
notnull: false,
primary_key: true,
}],
}
}
#[test]
fn typing_accumulates_in_input_buffer() {
let mut app = App::new();
@@ -258,17 +457,50 @@ mod tests {
}
#[test]
fn enter_in_simple_mode_echoes_with_simple_tag() {
fn valid_dsl_in_simple_mode_emits_execute_action() {
let mut app = App::new();
type_str(&mut app, "create table foo");
type_str(&mut app, "create table Customers with pk");
let actions = submit(&mut app);
assert_eq!(
actions,
vec![Action::ExecuteDsl(Command::CreateTable {
name: "Customers".to_string(),
columns: vec![crate::dsl::ColumnSpec {
name: "id".to_string(),
ty: Type::Serial,
}],
primary_key: vec!["id".to_string()],
})]
);
// The input is echoed back as a "running:" notice so the
// user sees something happened while the DB worker runs.
assert!(!app.output.is_empty());
}
#[test]
fn bare_create_table_emits_friendly_parse_error() {
let mut app = App::new();
type_str(&mut app, "create table Customers");
let actions = submit(&mut app);
assert!(actions.is_empty());
assert_eq!(app.input, "");
assert_eq!(app.output.len(), 1);
let line = &app.output[0];
assert_eq!(line.text, "create table foo");
assert_eq!(line.kind, OutputKind::Echo);
assert_eq!(line.mode_at_submission, Mode::Simple);
let last = app.output.back().unwrap();
assert_eq!(last.kind, OutputKind::Error);
assert!(
last.text.contains("with pk"),
"error should mention `with pk`: {}",
last.text
);
}
#[test]
fn invalid_dsl_in_simple_mode_produces_parse_error_in_output() {
let mut app = App::new();
type_str(&mut app, "frobulate widgets");
let actions = submit(&mut app);
assert!(actions.is_empty());
let last = app.output.back().unwrap();
assert_eq!(last.kind, OutputKind::Error);
assert!(last.text.starts_with("parse error"));
}
#[test]
@@ -277,8 +509,14 @@ mod tests {
app.mode = Mode::Advanced;
type_str(&mut app, "select 1");
submit(&mut app);
let line = &app.output[0];
assert_eq!(line.mode_at_submission, Mode::Advanced);
// We expect a placeholder system line plus the echoed line.
let echoed = app
.output
.iter()
.rfind(|l| l.kind == OutputKind::Echo)
.unwrap();
assert_eq!(echoed.mode_at_submission, Mode::Advanced);
assert_eq!(echoed.text, "select 1");
}
#[test]
@@ -304,21 +542,20 @@ mod tests {
}
#[test]
fn colon_prefix_in_simple_mode_is_one_shot_advanced() {
fn colon_prefix_in_simple_mode_evaluates_as_advanced_one_shot() {
let mut app = App::new();
type_str(&mut app, ":select 1");
submit(&mut app);
// The persistent mode is unchanged.
assert_eq!(app.mode, Mode::Simple);
let line = &app.output[0];
// The submitted line was tagged advanced for this submission.
assert_eq!(line.mode_at_submission, Mode::Advanced);
// The leading `:` is stripped before echoing.
assert_eq!(line.text, "select 1");
// Subsequent submissions revert to simple.
type_str(&mut app, "list tables");
submit(&mut app);
assert_eq!(app.output[1].mode_at_submission, Mode::Simple);
// The advanced echo line is present.
let echoed = app
.output
.iter()
.rfind(|l| l.kind == OutputKind::Echo)
.unwrap();
assert_eq!(echoed.mode_at_submission, Mode::Advanced);
assert_eq!(echoed.text, "select 1");
}
#[test]
@@ -344,6 +581,17 @@ mod tests {
assert!(app.output.is_empty());
}
#[test]
fn output_buffer_is_capped() {
let mut app = App::new();
for i in 0..(OUTPUT_CAPACITY + 50) {
app.note_system(format!("line{i}"));
}
assert_eq!(app.output.len(), OUTPUT_CAPACITY);
// Oldest entries were dropped.
assert!(app.output.front().unwrap().text.starts_with("line50"));
}
#[test]
fn effective_mode_reflects_persistent_mode_when_no_input() {
let mut app = App::new();
@@ -357,8 +605,6 @@ mod tests {
let mut app = App::new();
type_str(&mut app, ":sel");
assert_eq!(app.effective_mode(), EffectiveMode::AdvancedOneShot);
// Backspace through the colon reverts. The auto-inserted space
// after the colon counts as one extra character to clear.
while !app.input.is_empty() {
app.update(key(KeyCode::Backspace));
}
@@ -395,35 +641,207 @@ mod tests {
assert_eq!(app.input, ": ");
app.update(key(KeyCode::Backspace));
assert_eq!(app.input, ":");
// The colon alone still triggers the one-shot.
assert_eq!(app.effective_mode(), EffectiveMode::AdvancedOneShot);
}
#[test]
fn effective_mode_in_advanced_mode_ignores_leading_colon() {
fn dsl_success_event_records_table_view_and_appends_summary() {
let mut app = App::new();
app.mode = Mode::Advanced;
type_str(&mut app, ":hello");
// Leading `:` carries no special meaning in advanced mode.
assert_eq!(app.effective_mode(), EffectiveMode::AdvancedPersistent);
let cmd = Command::CreateTable {
name: "Customers".to_string(),
columns: vec![crate::dsl::ColumnSpec {
name: "id".to_string(),
ty: Type::Serial,
}],
primary_key: vec!["id".to_string()],
};
let desc = sample_description("Customers");
app.update(AppEvent::DslSucceeded {
command: cmd,
description: Some(desc.clone()),
});
assert_eq!(app.current_table, Some(desc));
let last = app.output.back().unwrap();
// Last line is the column row of the structure summary.
assert!(last.text.contains("id"));
// Earlier line is the [ok] header.
assert!(app.output.iter().any(|l| l.text.starts_with("[ok]")));
}
#[test]
fn effective_mode_tolerates_leading_whitespace_before_colon() {
fn dsl_failure_event_writes_error_with_friendly_message() {
let mut app = App::new();
type_str(&mut app, " :select 1");
assert_eq!(app.effective_mode(), EffectiveMode::AdvancedOneShot);
let cmd = Command::DropTable {
name: "Ghost".to_string(),
};
app.update(AppEvent::DslFailed {
command: cmd,
error: "no such table: Ghost".to_string(),
});
let last = app.output.back().unwrap();
assert_eq!(last.kind, OutputKind::Error);
assert!(last.text.contains("Ghost"));
assert!(last.text.contains("no such table"));
}
#[test]
fn output_buffer_is_capped() {
fn tables_refreshed_event_replaces_cached_list() {
let mut app = App::new();
for i in 0..(OUTPUT_CAPACITY + 50) {
type_str(&mut app, &format!("line{i}"));
app.update(AppEvent::TablesRefreshed(vec![
"A".to_string(),
"B".to_string(),
]));
assert_eq!(app.tables, vec!["A".to_string(), "B".to_string()]);
app.update(AppEvent::TablesRefreshed(vec!["C".to_string()]));
assert_eq!(app.tables, vec!["C".to_string()]);
}
#[test]
fn add_column_command_with_unknown_type_reports_parse_error() {
let mut app = App::new();
type_str(&mut app, "add column to table T: c (varchar)");
let actions = submit(&mut app);
assert!(actions.is_empty());
let last = app.output.back().unwrap();
assert_eq!(last.kind, OutputKind::Error);
assert!(last.text.contains("varchar"));
}
#[test]
fn drop_table_command_emits_execute_action() {
let mut app = App::new();
type_str(&mut app, "drop table T");
let actions = submit(&mut app);
assert_eq!(
actions,
vec![Action::ExecuteDsl(Command::DropTable {
name: "T".to_string()
})]
);
}
#[test]
fn history_records_submitted_lines() {
let mut app = App::new();
type_str(&mut app, "drop table A");
submit(&mut app);
type_str(&mut app, "drop table B");
submit(&mut app);
assert_eq!(
app.history,
vec!["drop table A".to_string(), "drop table B".to_string()]
);
}
#[test]
fn history_skips_consecutive_duplicates() {
let mut app = App::new();
type_str(&mut app, "drop table A");
submit(&mut app);
type_str(&mut app, "drop table A");
submit(&mut app);
type_str(&mut app, "drop table B");
submit(&mut app);
type_str(&mut app, "drop table A");
submit(&mut app);
assert_eq!(
app.history,
vec![
"drop table A".to_string(),
"drop table B".to_string(),
"drop table A".to_string(),
]
);
}
#[test]
fn up_arrow_recalls_most_recent_history_entry() {
let mut app = App::new();
type_str(&mut app, "drop table A");
submit(&mut app);
type_str(&mut app, "drop table B");
submit(&mut app);
app.update(key(KeyCode::Up));
assert_eq!(app.input, "drop table B");
}
#[test]
fn up_arrow_walks_backwards_through_history() {
let mut app = App::new();
for line in ["drop table A", "drop table B", "drop table C"] {
type_str(&mut app, line);
submit(&mut app);
}
assert_eq!(app.output.len(), OUTPUT_CAPACITY);
// Oldest entries were dropped.
assert!(app.output.front().unwrap().text.starts_with("line50"));
app.update(key(KeyCode::Up));
assert_eq!(app.input, "drop table C");
app.update(key(KeyCode::Up));
assert_eq!(app.input, "drop table B");
app.update(key(KeyCode::Up));
assert_eq!(app.input, "drop table A");
// Going past the oldest holds at the oldest.
app.update(key(KeyCode::Up));
assert_eq!(app.input, "drop table A");
}
#[test]
fn down_arrow_returns_through_history_to_the_draft() {
let mut app = App::new();
for line in ["drop table A", "drop table B"] {
type_str(&mut app, line);
submit(&mut app);
}
// Type a draft, then start navigating.
type_str(&mut app, "in progress");
app.update(key(KeyCode::Up));
assert_eq!(app.input, "drop table B");
app.update(key(KeyCode::Up));
assert_eq!(app.input, "drop table A");
app.update(key(KeyCode::Down));
assert_eq!(app.input, "drop table B");
app.update(key(KeyCode::Down));
// Past the newest, restore the draft.
assert_eq!(app.input, "in progress");
}
#[test]
fn down_arrow_with_no_history_navigation_is_a_noop() {
let mut app = App::new();
type_str(&mut app, "draft");
app.update(key(KeyCode::Down));
assert_eq!(app.input, "draft");
}
#[test]
fn editing_during_history_navigation_cancels_it() {
let mut app = App::new();
type_str(&mut app, "drop table A");
submit(&mut app);
app.update(key(KeyCode::Up));
assert_eq!(app.input, "drop table A");
// Editing the recalled line cancels navigation: another
// Up press should re-enter navigation from the new draft.
type_str(&mut app, "X");
assert_eq!(app.input, "drop table AX");
app.update(key(KeyCode::Up));
// Up brings the most recent history back, saving the
// edited draft.
assert_eq!(app.input, "drop table A");
app.update(key(KeyCode::Down));
assert_eq!(app.input, "drop table AX");
}
#[test]
fn add_column_with_text_type_emits_execute_action() {
let mut app = App::new();
type_str(&mut app, "add column to table T: Name (text)");
let actions = submit(&mut app);
assert_eq!(
actions,
vec![Action::ExecuteDsl(Command::AddColumn {
table: "T".to_string(),
column: "Name".to_string(),
ty: Type::Text,
})]
);
}
}
+814
View File
@@ -0,0 +1,814 @@
//! SQLite database access via an async worker.
//!
//! The application talks to SQLite through a single
//! request/response channel. A dedicated OS thread owns the
//! `rusqlite::Connection` (which is `Send` but `!Sync` and uses
//! a synchronous API), receives `Request` messages, and replies
//! on per-request `oneshot` channels.
//!
//! This shape was chosen up front in Phase 2 of the parser/DB
//! iteration so that B3 (query timeout/cancellation) and U1
//! (snapshot capture) drop in without an architectural refactor.
//!
//! ## STRICT and foreign keys
//!
//! Per ADR-0002, every table is created with the `STRICT`
//! keyword and the connection-level `PRAGMA foreign_keys` is
//! enabled at open time.
//!
//! ## Error handling
//!
//! Database errors flow through `DbError`, which carries a
//! coarse `kind` to support the future friendly-error layer
//! (H1). For now `friendly_message()` is a passthrough; when H1
//! lands the body of that method becomes the translation table.
use std::fmt::Write as _;
use std::path::Path;
use std::thread;
use rusqlite::Connection;
use tokio::sync::{mpsc, oneshot};
use tracing::{debug, info, warn};
use crate::dsl::ColumnSpec;
use crate::dsl::types::Type;
/// Inbox capacity. The worker is fast enough that this rarely
/// matters; `64` is a generous head-room for bursts.
const REQUEST_CHANNEL_CAPACITY: usize = 64;
/// In-process handle for the database. Cheap to clone.
#[derive(Debug, Clone)]
pub struct Database {
inbox: mpsc::Sender<Request>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TableDescription {
pub name: String,
pub columns: Vec<ColumnDescription>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ColumnDescription {
pub name: String,
/// The user-facing type the column was declared as, recovered
/// from our internal column-metadata table. Always populated
/// for tables created through the DSL. `None` only for the
/// edge case of a foreign-attached database whose tables we
/// did not create — not achievable in the current flow.
pub user_type: Option<Type>,
/// The SQLite-side type as reported by `PRAGMA table_info`
/// (e.g. `INTEGER`, `TEXT`). Kept for diagnostics and as a
/// fall-back when `user_type` is not available; the UI
/// prefers `user_type` when rendering.
pub sqlite_type: String,
pub notnull: bool,
pub primary_key: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum DbError {
#[error("database error: {message}")]
Sqlite { message: String, kind: SqliteErrorKind },
#[error("operation not supported: {0}")]
Unsupported(String),
#[error("database worker is no longer available")]
WorkerGone,
#[error("io error: {0}")]
Io(String),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SqliteErrorKind {
/// `UNIQUE` constraint, including duplicate primary key.
UniqueViolation,
/// Referenced or operated-on table does not exist.
NoSuchTable,
/// Operated-on column does not exist.
NoSuchColumn,
/// Object (table, index, etc.) already exists.
AlreadyExists,
/// Catch-all.
Other,
}
impl DbError {
/// Placeholder for the H1 friendly-error layer. Today this
/// returns the same string as [`std::fmt::Display`]; when H1
/// lands the body becomes the translation logic and
/// callsites do not need to change.
#[must_use]
pub fn friendly_message(&self) -> String {
self.to_string()
}
fn from_rusqlite(err: rusqlite::Error) -> Self {
let message = err.to_string();
let kind = classify_sqlite_error(&err, &message);
Self::Sqlite { message, kind }
}
}
fn classify_sqlite_error(err: &rusqlite::Error, message: &str) -> SqliteErrorKind {
use rusqlite::ErrorCode;
if let rusqlite::Error::SqliteFailure(code, _) = err
&& code.code == ErrorCode::ConstraintViolation
{
return SqliteErrorKind::UniqueViolation;
}
let lowered = message.to_ascii_lowercase();
if lowered.contains("no such table") {
SqliteErrorKind::NoSuchTable
} else if lowered.contains("no such column") {
SqliteErrorKind::NoSuchColumn
} else if lowered.contains("already exists") {
SqliteErrorKind::AlreadyExists
} else {
SqliteErrorKind::Other
}
}
/// Internal request type — kept private so the channel protocol
/// is not part of the public API.
#[derive(Debug)]
enum Request {
CreateTable {
name: String,
columns: Vec<ColumnSpec>,
primary_key: Vec<String>,
reply: oneshot::Sender<Result<TableDescription, DbError>>,
},
DropTable {
name: String,
reply: oneshot::Sender<Result<(), DbError>>,
},
AddColumn {
table: String,
column: String,
ty: Type,
reply: oneshot::Sender<Result<TableDescription, DbError>>,
},
ListTables {
reply: oneshot::Sender<Result<Vec<String>, DbError>>,
},
DescribeTable {
name: String,
reply: oneshot::Sender<Result<TableDescription, DbError>>,
},
}
impl Database {
/// Open a database. The path may be a filesystem location
/// or `":memory:"` for an ephemeral in-memory database. The
/// connection is moved onto a dedicated worker thread.
pub fn open<P: AsRef<Path> + Into<String>>(path: P) -> Result<Self, DbError> {
let path_display = path.as_ref().to_string_lossy().into_owned();
let conn = match path.as_ref().to_str() {
Some(":memory:") => Connection::open_in_memory(),
_ => Connection::open(path.as_ref()),
}
.map_err(DbError::from_rusqlite)?;
info!(path = %path_display, "opened database");
configure_connection(&conn).map_err(DbError::from_rusqlite)?;
let (tx, rx) = mpsc::channel::<Request>(REQUEST_CHANNEL_CAPACITY);
thread::Builder::new()
.name("rdbms-db-worker".to_string())
.spawn(move || worker_loop(conn, rx))
.map_err(|e| DbError::Io(e.to_string()))?;
Ok(Self { inbox: tx })
}
pub async fn create_table(
&self,
name: String,
columns: Vec<ColumnSpec>,
primary_key: Vec<String>,
) -> Result<TableDescription, DbError> {
let (reply, recv) = oneshot::channel();
self.send(Request::CreateTable {
name,
columns,
primary_key,
reply,
})
.await?;
recv.await.map_err(|_| DbError::WorkerGone)?
}
pub async fn drop_table(&self, name: String) -> Result<(), DbError> {
let (reply, recv) = oneshot::channel();
self.send(Request::DropTable { name, reply }).await?;
recv.await.map_err(|_| DbError::WorkerGone)?
}
pub async fn add_column(
&self,
table: String,
column: String,
ty: Type,
) -> Result<TableDescription, DbError> {
let (reply, recv) = oneshot::channel();
self.send(Request::AddColumn {
table,
column,
ty,
reply,
})
.await?;
recv.await.map_err(|_| DbError::WorkerGone)?
}
pub async fn list_tables(&self) -> Result<Vec<String>, DbError> {
let (reply, recv) = oneshot::channel();
self.send(Request::ListTables { reply }).await?;
recv.await.map_err(|_| DbError::WorkerGone)?
}
pub async fn describe_table(&self, name: String) -> Result<TableDescription, DbError> {
let (reply, recv) = oneshot::channel();
self.send(Request::DescribeTable { name, reply }).await?;
recv.await.map_err(|_| DbError::WorkerGone)?
}
async fn send(&self, req: Request) -> Result<(), DbError> {
self.inbox.send(req).await.map_err(|_| DbError::WorkerGone)
}
}
/// Internal table tracking the user-facing type each column was
/// declared with. The structure view consults this so users see
/// the names they typed (`serial`, `date`) rather than SQLite's
/// erased forms (`INTEGER`, `TEXT`) — closing a Q3 / ADR-0005
/// promise. Track 2's project file format is the long-term home
/// for this metadata; this table is the in-database mirror that
/// makes round-trip rendering work today.
const META_TABLE: &str = "__rdbms_playground_columns";
fn configure_connection(conn: &Connection) -> Result<(), rusqlite::Error> {
conn.execute_batch(&format!(
"PRAGMA foreign_keys = ON;\n\
CREATE TABLE IF NOT EXISTS {META_TABLE} (\n\
table_name TEXT NOT NULL,\n\
column_name TEXT NOT NULL,\n\
user_type TEXT NOT NULL,\n\
PRIMARY KEY (table_name, column_name)\n\
) STRICT;"
))?;
Ok(())
}
fn worker_loop(conn: Connection, mut rx: mpsc::Receiver<Request>) {
debug!("db worker started");
while let Some(req) = rx.blocking_recv() {
handle_request(&conn, req);
}
debug!("db worker exiting");
}
fn handle_request(conn: &Connection, req: Request) {
match req {
Request::CreateTable {
name,
columns,
primary_key,
reply,
} => {
let _ = reply.send(do_create_table(conn, &name, &columns, &primary_key));
}
Request::DropTable { name, reply } => {
let _ = reply.send(do_drop_table(conn, &name));
}
Request::AddColumn {
table,
column,
ty,
reply,
} => {
let _ = reply.send(do_add_column(conn, &table, &column, ty));
}
Request::ListTables { reply } => {
let _ = reply.send(do_list_tables(conn));
}
Request::DescribeTable { name, reply } => {
let _ = reply.send(do_describe_table(conn, &name));
}
}
}
/// Quote an identifier for safe inclusion in DDL. Doubles any
/// embedded double-quotes per SQL convention.
fn quote_ident(name: &str) -> String {
let mut out = String::with_capacity(name.len() + 2);
out.push('"');
for c in name.chars() {
if c == '"' {
out.push_str("\"\"");
} else {
out.push(c);
}
}
out.push('"');
out
}
fn do_create_table(
conn: &Connection,
name: &str,
columns: &[ColumnSpec],
primary_key: &[String],
) -> Result<TableDescription, DbError> {
if columns.is_empty() {
// SQLite requires at least one column. The DSL grammar
// already prevents this, but defending here too keeps
// the executor honest if anyone synthesises a Command
// directly (tests, future scripting).
return Err(DbError::Unsupported(
"tables need at least one column".to_string(),
));
}
// Generate the column list. For a single-column PK we inline
// `PRIMARY KEY` on the column itself, which is required for
// SQLite STRICT tables to give an `INTEGER PRIMARY KEY`
// column its rowid-alias semantics. For compound PKs (or
// when the single PK is on a non-first column) we emit a
// table-level constraint.
let single_inline_pk = primary_key.len() == 1 && columns.len() == 1
&& primary_key[0] == columns[0].name;
let mut column_clauses: Vec<String> = Vec::with_capacity(columns.len());
for col in columns {
let mut clause = format!(
"{ident} {sqlite_type}",
ident = quote_ident(&col.name),
sqlite_type = col.ty.sqlite_strict_type(),
);
if single_inline_pk {
clause.push_str(" PRIMARY KEY");
}
column_clauses.push(clause);
}
let mut ddl = format!(
"CREATE TABLE {ident} ({columns}",
ident = quote_ident(name),
columns = column_clauses.join(", "),
);
if !single_inline_pk && !primary_key.is_empty() {
let pk_idents: Vec<String> = primary_key.iter().map(|n| quote_ident(n)).collect();
ddl.push_str(", PRIMARY KEY (");
ddl.push_str(&pk_idents.join(", "));
ddl.push(')');
}
ddl.push_str(") STRICT;");
debug!(ddl = %ddl, "create_table");
// Wrap the table-creation DDL and the metadata inserts in a
// single transaction so they commit atomically — if either
// step fails, neither side persists.
let tx = conn
.unchecked_transaction()
.map_err(DbError::from_rusqlite)?;
tx.execute_batch(&ddl).map_err(DbError::from_rusqlite)?;
{
let mut stmt = tx
.prepare(&format!(
"INSERT INTO {META_TABLE} (table_name, column_name, user_type) \
VALUES (?1, ?2, ?3);"
))
.map_err(DbError::from_rusqlite)?;
for col in columns {
stmt.execute([name, col.name.as_str(), col.ty.keyword()])
.map_err(DbError::from_rusqlite)?;
}
}
tx.commit().map_err(DbError::from_rusqlite)?;
do_describe_table(conn, name)
}
fn do_drop_table(conn: &Connection, name: &str) -> Result<(), DbError> {
let ddl = format!("DROP TABLE {ident};", ident = quote_ident(name));
debug!(ddl = %ddl, "drop_table");
let tx = conn
.unchecked_transaction()
.map_err(DbError::from_rusqlite)?;
tx.execute_batch(&ddl).map_err(DbError::from_rusqlite)?;
tx.execute(
&format!("DELETE FROM {META_TABLE} WHERE table_name = ?1;"),
[name],
)
.map_err(DbError::from_rusqlite)?;
tx.commit().map_err(DbError::from_rusqlite)?;
Ok(())
}
fn do_add_column(
conn: &Connection,
table: &str,
column: &str,
ty: Type,
) -> Result<TableDescription, DbError> {
if ty == Type::Serial {
return Err(DbError::Unsupported(
"the 'serial' type carries auto-increment primary-key semantics \
that SQLite's ALTER TABLE ADD COLUMN cannot apply. Specify \
`serial` at create-table time via `with pk` instead."
.to_string(),
));
}
let mut ddl = String::new();
write!(
ddl,
"ALTER TABLE {tbl} ADD COLUMN {col} {sqlite_type}{extra};",
tbl = quote_ident(table),
col = quote_ident(column),
sqlite_type = ty.sqlite_strict_type(),
extra = ty.sqlite_strict_extra(),
)
.expect("write to String never fails");
debug!(ddl = %ddl, "add_column");
let tx = conn
.unchecked_transaction()
.map_err(DbError::from_rusqlite)?;
tx.execute_batch(&ddl).map_err(DbError::from_rusqlite)?;
tx.execute(
&format!(
"INSERT INTO {META_TABLE} (table_name, column_name, user_type) \
VALUES (?1, ?2, ?3);"
),
[table, column, ty.keyword()],
)
.map_err(DbError::from_rusqlite)?;
tx.commit().map_err(DbError::from_rusqlite)?;
do_describe_table(conn, table)
}
fn do_list_tables(conn: &Connection) -> Result<Vec<String>, DbError> {
let mut stmt = conn
.prepare(
"SELECT name FROM sqlite_schema \
WHERE type = 'table' \
AND name NOT LIKE 'sqlite_%' \
AND name NOT LIKE '__rdbms_%' \
ORDER BY name;",
)
.map_err(DbError::from_rusqlite)?;
let rows = stmt
.query_map([], |row| row.get::<_, String>(0))
.map_err(DbError::from_rusqlite)?;
let mut out = Vec::new();
for row in rows {
out.push(row.map_err(DbError::from_rusqlite)?);
}
Ok(out)
}
fn do_describe_table(conn: &Connection, name: &str) -> Result<TableDescription, DbError> {
// `pragma_table_info` is a table-valued function in modern
// SQLite; using it as a SELECT lets us bind the table name
// via ? rather than splicing it into a PRAGMA statement.
// We LEFT JOIN our metadata table to recover the user-facing
// type each column was declared as.
let mut stmt = conn
.prepare(&format!(
"SELECT pti.name, pti.type, pti.\"notnull\", pti.pk, m.user_type \
FROM pragma_table_info(?1) AS pti \
LEFT JOIN {META_TABLE} AS m \
ON m.table_name = ?1 AND m.column_name = pti.name \
ORDER BY pti.cid;"
))
.map_err(DbError::from_rusqlite)?;
let rows = stmt
.query_map([name], |row| {
let user_type_kw: Option<String> = row.get(4)?;
let user_type = user_type_kw.and_then(|kw| kw.parse::<Type>().ok());
Ok(ColumnDescription {
name: row.get(0)?,
user_type,
sqlite_type: row.get(1)?,
notnull: row.get::<_, i64>(2)? != 0,
primary_key: row.get::<_, i64>(3)? != 0,
})
})
.map_err(DbError::from_rusqlite)?;
let mut columns = Vec::new();
for row in rows {
columns.push(row.map_err(DbError::from_rusqlite)?);
}
if columns.is_empty() {
// pragma_table_info returns no rows for a non-existent
// table, which we surface as a NoSuchTable error so
// describe_table is not silently empty.
warn!(name, "describe_table: no columns (table missing?)");
return Err(DbError::Sqlite {
message: format!("no such table: {name}"),
kind: SqliteErrorKind::NoSuchTable,
});
}
Ok(TableDescription {
name: name.to_string(),
columns,
})
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
fn db() -> Database {
Database::open(":memory:").expect("open in-memory")
}
fn col(name: &str, ty: Type) -> ColumnSpec {
ColumnSpec {
name: name.to_string(),
ty,
}
}
/// Convenience: a `serial`-PK table with a single `id` column.
async fn make_id_table(db: &Database, name: &str) -> TableDescription {
db.create_table(
name.to_string(),
vec![col("id", Type::Serial)],
vec!["id".to_string()],
)
.await
.expect("create table")
}
#[tokio::test]
async fn open_in_memory_succeeds() {
let _ = db();
}
#[tokio::test]
async fn create_table_with_serial_pk_appears_in_list() {
let db = db();
make_id_table(&db, "Customers").await;
let tables = db.list_tables().await.unwrap();
assert_eq!(tables, vec!["Customers".to_string()]);
}
#[tokio::test]
async fn create_table_with_serial_pk_describes_correctly() {
let db = db();
let desc = make_id_table(&db, "Customers").await;
assert_eq!(desc.name, "Customers");
assert_eq!(desc.columns.len(), 1);
let id = &desc.columns[0];
assert_eq!(id.name, "id");
assert!(id.primary_key);
assert_eq!(id.user_type, Some(Type::Serial));
assert_eq!(id.sqlite_type.to_uppercase(), "INTEGER");
}
#[tokio::test]
async fn create_table_with_text_pk_works() {
let db = db();
let desc = db
.create_table(
"Customers".to_string(),
vec![col("email", Type::Text)],
vec!["email".to_string()],
)
.await
.unwrap();
assert_eq!(desc.columns.len(), 1);
assert_eq!(desc.columns[0].name, "email");
assert_eq!(desc.columns[0].user_type, Some(Type::Text));
assert_eq!(desc.columns[0].sqlite_type.to_uppercase(), "TEXT");
assert!(desc.columns[0].primary_key);
}
#[tokio::test]
async fn create_table_with_compound_pk_works() {
let db = db();
let desc = db
.create_table(
"OrderLines".to_string(),
vec![col("order_id", Type::Int), col("product_id", Type::Int)],
vec!["order_id".to_string(), "product_id".to_string()],
)
.await
.unwrap();
assert_eq!(desc.columns.len(), 2);
assert!(desc.columns.iter().all(|c| c.primary_key));
}
#[tokio::test]
async fn create_table_with_pedagogically_unusual_pk_type_still_works() {
// The grammar lets users try anything; the DB layer just
// does what they ask.
let db = db();
let desc = db
.create_table(
"T".to_string(),
vec![col("flag", Type::Bool)],
vec!["flag".to_string()],
)
.await
.unwrap();
assert!(desc.columns[0].primary_key);
}
#[tokio::test]
async fn create_table_rejects_zero_columns() {
let db = db();
let err = db
.create_table("T".to_string(), Vec::new(), Vec::new())
.await
.unwrap_err();
assert!(matches!(err, DbError::Unsupported(_)), "got {err:?}");
}
#[tokio::test]
async fn drop_table_removes_it_from_list() {
let db = db();
make_id_table(&db, "T").await;
db.drop_table("T".to_string()).await.unwrap();
let tables = db.list_tables().await.unwrap();
assert!(tables.is_empty());
}
#[tokio::test]
async fn add_column_appends_to_existing_table() {
let db = db();
make_id_table(&db, "Customers").await;
let desc = db
.add_column("Customers".to_string(), "Name".to_string(), Type::Text)
.await
.unwrap();
let names: Vec<_> = desc.columns.iter().map(|c| c.name.as_str()).collect();
assert_eq!(names, vec!["id", "Name"]);
let name_col = desc.columns.iter().find(|c| c.name == "Name").unwrap();
assert_eq!(name_col.user_type, Some(Type::Text));
assert_eq!(name_col.sqlite_type.to_uppercase(), "TEXT");
}
#[tokio::test]
async fn user_facing_types_round_trip_through_metadata() {
let db = db();
// Create with a serial PK and add columns of every type
// that would otherwise be erased by SQLite (date,
// datetime, decimal — all backed by TEXT).
make_id_table(&db, "T").await;
for ty in [Type::Date, Type::DateTime, Type::Decimal, Type::ShortId] {
db.add_column("T".to_string(), format!("c_{ty}"), ty)
.await
.unwrap();
}
let desc = db.describe_table("T".to_string()).await.unwrap();
let id_col = desc.columns.iter().find(|c| c.name == "id").unwrap();
assert_eq!(id_col.user_type, Some(Type::Serial));
for ty in [Type::Date, Type::DateTime, Type::Decimal, Type::ShortId] {
let col_name = format!("c_{ty}");
let c = desc.columns.iter().find(|c| c.name == col_name).unwrap();
assert_eq!(c.user_type, Some(ty), "mismatch for {col_name}");
}
}
#[tokio::test]
async fn list_tables_excludes_internal_metadata_table() {
let db = db();
make_id_table(&db, "Visible").await;
let tables = db.list_tables().await.unwrap();
assert_eq!(tables, vec!["Visible".to_string()]);
// Metadata table is present in the underlying schema but
// hidden from list_tables.
}
#[tokio::test]
async fn drop_table_clears_metadata_so_recreate_starts_fresh() {
let db = db();
// Create with a date column.
db.create_table(
"T".to_string(),
vec![col("when", Type::Date)],
vec!["when".to_string()],
)
.await
.unwrap();
let before = db.describe_table("T".to_string()).await.unwrap();
assert_eq!(before.columns[0].user_type, Some(Type::Date));
// Drop it.
db.drop_table("T".to_string()).await.unwrap();
// Recreate with a different type for the same-named column;
// the metadata for the new table must reflect the new type
// (i.e. metadata from the previous incarnation must not
// bleed through).
db.create_table(
"T".to_string(),
vec![col("when", Type::DateTime)],
vec!["when".to_string()],
)
.await
.unwrap();
let after = db.describe_table("T".to_string()).await.unwrap();
assert_eq!(after.columns[0].user_type, Some(Type::DateTime));
}
#[tokio::test]
async fn add_column_for_each_value_type() {
let db = db();
make_id_table(&db, "T").await;
for ty in [Type::Text, Type::Int, Type::Real, Type::Bool, Type::ShortId] {
let col_name = format!("c_{ty}");
db.add_column("T".to_string(), col_name.clone(), ty)
.await
.unwrap_or_else(|e| panic!("type {ty} failed: {e}"));
}
let desc = db.describe_table("T".to_string()).await.unwrap();
// 5 user columns + the id PK column.
assert_eq!(desc.columns.len(), 6);
}
#[tokio::test]
async fn add_column_rejects_serial_with_unsupported_error() {
let db = db();
make_id_table(&db, "T").await;
let err = db
.add_column("T".to_string(), "id2".to_string(), Type::Serial)
.await
.unwrap_err();
assert!(matches!(err, DbError::Unsupported(_)), "got {err:?}");
}
#[tokio::test]
async fn create_table_duplicate_returns_already_exists() {
let db = db();
make_id_table(&db, "T").await;
let err = db
.create_table(
"T".to_string(),
vec![col("id", Type::Serial)],
vec!["id".to_string()],
)
.await
.unwrap_err();
match err {
DbError::Sqlite { kind, .. } => assert_eq!(kind, SqliteErrorKind::AlreadyExists),
other => panic!("unexpected error: {other:?}"),
}
}
#[tokio::test]
async fn drop_nonexistent_table_returns_no_such_table() {
let db = db();
let err = db.drop_table("Ghost".to_string()).await.unwrap_err();
match err {
DbError::Sqlite { kind, .. } => assert_eq!(kind, SqliteErrorKind::NoSuchTable),
other => panic!("unexpected error: {other:?}"),
}
}
#[tokio::test]
async fn add_column_to_missing_table_returns_no_such_table() {
let db = db();
let err = db
.add_column("Ghost".to_string(), "x".to_string(), Type::Text)
.await
.unwrap_err();
match err {
DbError::Sqlite { kind, .. } => assert_eq!(kind, SqliteErrorKind::NoSuchTable),
other => panic!("unexpected error: {other:?}"),
}
}
#[tokio::test]
async fn describe_missing_table_returns_no_such_table() {
let db = db();
let err = db.describe_table("Ghost".to_string()).await.unwrap_err();
match err {
DbError::Sqlite { kind, .. } => assert_eq!(kind, SqliteErrorKind::NoSuchTable),
other => panic!("unexpected error: {other:?}"),
}
}
#[tokio::test]
async fn quoted_table_names_round_trip() {
let db = db();
// Identifier with internal whitespace would not parse via the DSL
// today, but the DB layer should still handle it correctly.
db.create_table(
"Order Lines".to_string(),
vec![col("id", Type::Serial)],
vec!["id".to_string()],
)
.await
.unwrap();
let tables = db.list_tables().await.unwrap();
assert_eq!(tables, vec!["Order Lines".to_string()]);
let desc = db.describe_table("Order Lines".to_string()).await.unwrap();
assert_eq!(desc.name, "Order Lines");
}
}
+67
View File
@@ -0,0 +1,67 @@
//! The Command AST.
//!
//! `Command` is the parser's output and the database worker's
//! input. Each variant carries fully validated data — the parser
//! is responsible for shape, the database worker for semantics
//! (e.g. "table does not exist").
//!
//! The shape supports compound primary keys natively even though
//! only the dedicated `with pk a:int,b:int` grammar exposes them
//! today. Future grammar extensions (inline column specs, `set
//! primary key`, junction-table convenience commands) emit into
//! the same shape.
use crate::dsl::types::Type;
/// A column at table-creation time: a name and a user-facing
/// type. Constraints beyond `PRIMARY KEY` (NOT NULL, UNIQUE,
/// CHECK, DEFAULT) come in later iterations.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ColumnSpec {
pub name: String,
pub ty: Type,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Command {
CreateTable {
name: String,
/// Columns to create, in declaration order.
columns: Vec<ColumnSpec>,
/// Names of columns forming the primary key. Length 1 is
/// a single PK; length >= 2 is a compound PK; length 0
/// indicates no primary key (a future grammar option,
/// not produced by today's parser).
primary_key: Vec<String>,
},
DropTable {
name: String,
},
AddColumn {
table: String,
column: String,
ty: Type,
},
}
impl Command {
/// Short label for log output and result rendering.
#[must_use]
pub const fn verb(&self) -> &'static str {
match self {
Self::CreateTable { .. } => "create table",
Self::DropTable { .. } => "drop table",
Self::AddColumn { .. } => "add column",
}
}
/// The table this command targets — every Command in this
/// iteration operates on exactly one table.
#[must_use]
pub fn target_table(&self) -> &str {
match self {
Self::CreateTable { name, .. } | Self::DropTable { name } => name,
Self::AddColumn { table, .. } => table,
}
}
}
+18
View File
@@ -0,0 +1,18 @@
//! The Playground DSL.
//!
//! The DSL is the simplified, beginner-friendly command surface
//! described in ADR-0003. This module owns its grammar
//! (`parser`), its abstract syntax tree (`command`), and the
//! user-facing type vocabulary (`types`).
//!
//! Raw SQL handling for advanced mode is intentionally *not* in
//! this module — that path uses `sqlparser-rs` and lives
//! elsewhere when it lands.
pub mod command;
pub mod parser;
pub mod types;
pub use command::{ColumnSpec, Command};
pub use parser::{ParseError, parse_command};
pub use types::Type;
+485
View File
@@ -0,0 +1,485 @@
//! Grammar-based DSL parser built on chumsky.
//!
//! The parser produces a `Command` AST directly — there is no
//! intermediate token tree to translate. Composable rules
//! (identifier, type keyword, padded keyword) are defined once
//! and reused across command variants, which is the point of
//! choosing a grammar approach (see Phase 2/3 selection).
//!
//! Errors from chumsky are mapped to the local `ParseError` type
//! so callers do not depend on chumsky's API surface — that
//! keeps the parser swappable if we ever revisit the choice.
use chumsky::error::RichReason;
use chumsky::prelude::*;
use crate::dsl::command::{ColumnSpec, Command};
use crate::dsl::types::Type;
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum ParseError {
#[error("could not parse command: {message}")]
Invalid { message: String, position: usize },
#[error("empty input")]
Empty,
}
impl ParseError {
#[must_use]
pub const fn position(&self) -> Option<usize> {
match self {
Self::Invalid { position, .. } => Some(*position),
Self::Empty => None,
}
}
}
/// Parse a single DSL command.
pub fn parse_command(input: &str) -> Result<Command, ParseError> {
let trimmed = input.trim();
if trimmed.is_empty() {
return Err(ParseError::Empty);
}
match command_parser().parse(trimmed).into_result() {
Ok(cmd) => Ok(cmd),
Err(errs) => Err(into_parse_error(&errs, trimmed)),
}
}
fn into_parse_error(errs: &[Rich<'_, char>], input: &str) -> ParseError {
// Prefer custom-reason errors over chumsky's structural
// ones — those carry our friendly messages from `try_map`
// (e.g. "unknown type 'varchar' (expected one of: ...)").
let chosen = errs
.iter()
.find(|e| has_custom_reason(e.reason()))
.unwrap_or_else(|| errs.first().expect("parser failure with no error"));
let span = chosen.span();
let position = span.start;
let message = humanise(chosen, input);
ParseError::Invalid { message, position }
}
const fn has_custom_reason<T, C>(reason: &RichReason<'_, T, C>) -> bool {
matches!(reason, RichReason::Custom(_))
}
fn humanise(err: &Rich<'_, char>, input: &str) -> String {
// For custom errors, the underlying message is what we want
// to show, not chumsky's "found ... expected ..." rendering.
if let Some(msg) = first_custom_message(err.reason()) {
return msg;
}
let span = err.span();
let snippet: String = input
.chars()
.skip(span.start)
.take((span.end - span.start).max(1))
.collect();
if snippet.is_empty() {
format!("{err}")
} else {
format!("{err} (near `{snippet}`)")
}
}
fn first_custom_message<T>(reason: &RichReason<'_, T, String>) -> Option<String> {
match reason {
RichReason::Custom(msg) => Some(msg.clone()),
RichReason::ExpectedFound { .. } => None,
}
}
/// The top-level command parser.
fn command_parser<'a>()
-> impl Parser<'a, &'a str, Command, extra::Err<Rich<'a, char>>> + Clone {
let create_table = keyword_ci("create")
.ignore_then(keyword_ci("table"))
.ignore_then(identifier())
.then(with_pk_clause())
.try_map(|(name, pk_specs), span| {
if pk_specs.is_empty() {
return Err(Rich::custom(
span,
"tables need at least one column. Add `with pk` for a default \
`id INTEGER PRIMARY KEY`, or `with pk <name>:<type>` to choose. \
Use a comma-separated list for compound primary keys."
.to_string(),
));
}
let columns: Vec<ColumnSpec> = pk_specs
.iter()
.map(|(n, t)| ColumnSpec {
name: n.clone(),
ty: *t,
})
.collect();
let primary_key = pk_specs.into_iter().map(|(n, _)| n).collect();
Ok(Command::CreateTable {
name,
columns,
primary_key,
})
});
let drop_table = keyword_ci("drop")
.ignore_then(keyword_ci("table"))
.ignore_then(identifier())
.map(|name| Command::DropTable { name });
let add_column = keyword_ci("add")
.ignore_then(keyword_ci("column"))
.ignore_then(keyword_ci("to"))
.ignore_then(keyword_ci("table"))
.ignore_then(identifier())
.then_ignore(just(':').padded())
.then(identifier())
.then_ignore(just('(').padded())
.then(type_keyword())
.then_ignore(just(')').padded())
.map(|((table, column), ty)| Command::AddColumn { table, column, ty });
choice((create_table, drop_table, add_column))
.padded()
.then_ignore(end())
}
/// Parse the optional `with pk [<spec>]` clause that may follow
/// `create table <Name>`. Returns the list of (name, type) pairs
/// that form the primary key. An absent clause returns an empty
/// vector; a present `with pk` (no spec) returns the default
/// `id:serial`. Compound PK is a comma-separated list of specs.
fn with_pk_clause<'a>()
-> impl Parser<'a, &'a str, Vec<(String, Type)>, extra::Err<Rich<'a, char>>> + Clone {
let single = identifier()
.then_ignore(just(':').padded())
.then(type_keyword())
.map(|(name, ty)| (name, ty));
let spec_list = single
.clone()
.separated_by(just(',').padded())
.at_least(1)
.collect::<Vec<_>>();
keyword_ci("with")
.ignore_then(keyword_ci("pk"))
.ignore_then(spec_list.or_not())
.map(|maybe_specs| {
// `with pk` alone defaults to a serial id PK.
maybe_specs.unwrap_or_else(|| vec![("id".to_string(), Type::Serial)])
})
.or_not()
.map(Option::unwrap_or_default)
}
/// Identifier: a letter or underscore followed by letters,
/// digits, or underscores. Returned as an owned `String` so the
/// `Command` AST has no lifetime tying it to the input.
fn identifier<'a>()
-> impl Parser<'a, &'a str, String, extra::Err<Rich<'a, char>>> + Clone {
any()
.filter(|c: &char| c.is_ascii_alphabetic() || *c == '_')
.then(
any()
.filter(|c: &char| c.is_ascii_alphanumeric() || *c == '_')
.repeated()
.collect::<Vec<_>>(),
)
.map(|(first, rest)| {
let mut s = String::with_capacity(rest.len() + 1);
s.push(first);
s.extend(rest);
s
})
.padded()
}
/// One of the supported type keywords, mapped to `Type`. The
/// `try_map` yields a `Custom` Rich error on unknown input,
/// which carries the friendly "unknown type 'X' (expected one
/// of: ...)" message — surfaced via `humanise()`. Note: no
/// `.labelled` here, because that would replace the custom
/// message with a generic "expected type".
fn type_keyword<'a>()
-> impl Parser<'a, &'a str, Type, extra::Err<Rich<'a, char>>> + Clone {
let alphabetic = any()
.filter(|c: &char| c.is_ascii_alphabetic())
.repeated()
.at_least(1)
.collect::<String>();
alphabetic.padded().try_map(|word, span| {
word.parse::<Type>()
.map_err(|e| Rich::custom(span, e.to_string()))
})
}
/// Case-insensitive keyword matcher. Consumes leading and
/// trailing whitespace and, importantly, requires a word
/// boundary so `create` does not match a prefix of `created`.
fn keyword_ci<'a>(
kw: &'static str,
) -> impl Parser<'a, &'a str, (), extra::Err<Rich<'a, char>>> + Clone {
let alphabetic = any()
.filter(|c: &char| c.is_ascii_alphabetic())
.repeated()
.at_least(1)
.collect::<String>();
alphabetic.padded().try_map(move |word, span| {
if word.eq_ignore_ascii_case(kw) {
Ok(())
} else {
Err(Rich::custom(
span,
format!("expected '{kw}', found '{word}'"),
))
}
})
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
fn ok(input: &str) -> Command {
parse_command(input).unwrap_or_else(|e| panic!("expected ok for {input:?}, got {e:?}"))
}
fn err(input: &str) -> ParseError {
parse_command(input).expect_err("expected parse error")
}
fn col(name: &str, ty: Type) -> ColumnSpec {
ColumnSpec {
name: name.to_string(),
ty,
}
}
#[test]
fn bare_create_table_errors_with_helpful_message() {
let e = err("create table Customers");
match e {
ParseError::Invalid { message, .. } => {
assert!(
message.contains("with pk"),
"error should mention `with pk`:\n{message}"
);
}
ParseError::Empty => panic!("unexpected empty error"),
}
}
#[test]
fn create_table_with_pk_default_is_id_serial() {
assert_eq!(
ok("create table Customers with pk"),
Command::CreateTable {
name: "Customers".to_string(),
columns: vec![col("id", Type::Serial)],
primary_key: vec!["id".to_string()],
}
);
}
#[test]
fn create_table_with_named_typed_pk() {
assert_eq!(
ok("create table Customers with pk email:text"),
Command::CreateTable {
name: "Customers".to_string(),
columns: vec![col("email", Type::Text)],
primary_key: vec!["email".to_string()],
}
);
}
#[test]
fn create_table_with_compound_pk() {
assert_eq!(
ok("create table OrderLines with pk order_id:int,product_id:int"),
Command::CreateTable {
name: "OrderLines".to_string(),
columns: vec![
col("order_id", Type::Int),
col("product_id", Type::Int),
],
primary_key: vec!["order_id".to_string(), "product_id".to_string()],
}
);
}
#[test]
fn create_table_pk_accepts_any_user_type() {
// Pedagogical freedom — the grammar imposes no
// "sensible PK type" filter. Every user-facing type is
// accepted; learners discover for themselves.
for ty in Type::all() {
let input = format!("create table T with pk col:{}", ty.keyword());
let cmd = ok(&input);
if let Command::CreateTable {
columns,
primary_key,
..
} = cmd
{
assert_eq!(columns[0].ty, *ty);
assert_eq!(primary_key, vec!["col".to_string()]);
} else {
panic!("expected CreateTable for {input}");
}
}
}
#[test]
fn create_table_pk_tolerates_whitespace() {
assert_eq!(
ok("create table T with pk id : serial"),
Command::CreateTable {
name: "T".to_string(),
columns: vec![col("id", Type::Serial)],
primary_key: vec!["id".to_string()],
}
);
assert_eq!(
ok("create table T with pk a : int , b : int"),
Command::CreateTable {
name: "T".to_string(),
columns: vec![col("a", Type::Int), col("b", Type::Int)],
primary_key: vec!["a".to_string(), "b".to_string()],
}
);
}
#[test]
fn create_table_keywords_are_case_insensitive() {
assert_eq!(
ok("CREATE TABLE Customers WITH PK email:TEXT"),
Command::CreateTable {
name: "Customers".to_string(),
columns: vec![col("email", Type::Text)],
primary_key: vec!["email".to_string()],
}
);
}
#[test]
fn drop_table_simple() {
assert_eq!(
ok("drop table Customers"),
Command::DropTable {
name: "Customers".to_string()
}
);
}
#[test]
fn add_column_simple() {
assert_eq!(
ok("add column to table Customers: Name (text)"),
Command::AddColumn {
table: "Customers".to_string(),
column: "Name".to_string(),
ty: Type::Text,
}
);
}
#[test]
fn add_column_with_each_supported_type() {
for ty in Type::all() {
let input = format!("add column to table T: C ({})", ty.keyword());
assert_eq!(
ok(&input),
Command::AddColumn {
table: "T".to_string(),
column: "C".to_string(),
ty: *ty,
}
);
}
}
#[test]
fn add_column_tolerates_whitespace_around_punctuation() {
assert_eq!(
ok("add column to table T:Name(text)"),
Command::AddColumn {
table: "T".to_string(),
column: "Name".to_string(),
ty: Type::Text,
}
);
}
#[test]
fn empty_input_is_an_explicit_empty_error() {
assert_eq!(parse_command(""), Err(ParseError::Empty));
assert_eq!(parse_command(" "), Err(ParseError::Empty));
}
#[test]
fn unknown_command_errors() {
let e = err("frobulate Customers");
assert!(matches!(e, ParseError::Invalid { .. }), "got {e:?}");
}
#[test]
fn unknown_type_errors_with_alternatives_listed() {
let e = err("add column to table T: Name (varchar)");
match e {
ParseError::Invalid { message, .. } => {
assert!(
message.contains("varchar"),
"error should mention the bad type: {message}"
);
assert!(
message.contains("expected one of"),
"error should list valid alternatives: {message}"
);
assert!(
message.contains("text") && message.contains("shortid"),
"error should name the alternatives: {message}"
);
}
ParseError::Empty => panic!("unexpected empty error"),
}
}
#[test]
fn unknown_pk_type_errors_with_alternatives_listed() {
let e = err("create table T with pk id:varchar");
match e {
ParseError::Invalid { message, .. } => {
assert!(message.contains("varchar"), "{message}");
assert!(message.contains("expected one of"), "{message}");
}
ParseError::Empty => panic!("unexpected empty error"),
}
}
#[test]
fn trailing_garbage_errors() {
let e = err("create table Customers with pk and pickles");
assert!(matches!(e, ParseError::Invalid { .. }), "got {e:?}");
}
#[test]
fn identifier_must_start_with_letter_or_underscore() {
let e = err("create table 1Customers with pk");
assert!(matches!(e, ParseError::Invalid { .. }), "got {e:?}");
}
#[test]
fn identifier_allows_underscores_and_digits_after_start() {
assert_eq!(
ok("create table customer_v2 with pk"),
Command::CreateTable {
name: "customer_v2".to_string(),
columns: vec![col("id", Type::Serial)],
primary_key: vec!["id".to_string()],
}
);
}
}
+261
View File
@@ -0,0 +1,261 @@
//! User-facing column types and their mapping to SQLite STRICT.
//!
//! Implements the full ten-type vocabulary committed to in
//! ADR-0005. Storage choices for the text-backed types
//! (`decimal`, `date`, `datetime`) preserve precision and ISO
//! readability; comparisons rely on lexicographic ordering or
//! explicit casts at query time, which is acceptable for a
//! teaching tool and is documented in user-facing help.
use std::fmt;
use std::str::FromStr;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Type {
/// UTF-8 text of any length.
Text,
/// 64-bit signed integer.
Int,
/// IEEE-754 double-precision float.
Real,
/// Arbitrary-precision decimal stored as a string. Sorts and
/// compares lexicographically; numeric ops require casts.
Decimal,
/// Boolean stored as 0/1; rendered `true`/`false`.
Bool,
/// ISO 8601 date stored as `YYYY-MM-DD` (TEXT).
Date,
/// ISO 8601 datetime stored as `YYYY-MM-DDTHH:MM:SS[.fff][Z]` (TEXT).
DateTime,
/// Arbitrary binary data.
Blob,
/// Auto-incrementing integer; intended as a default primary key.
Serial,
/// 1012 character base58 random identifier (no ambiguous chars).
ShortId,
}
impl Type {
/// The user-facing keyword as it appears in DSL input.
#[must_use]
pub const fn keyword(self) -> &'static str {
match self {
Self::Text => "text",
Self::Int => "int",
Self::Real => "real",
Self::Decimal => "decimal",
Self::Bool => "bool",
Self::Date => "date",
Self::DateTime => "datetime",
Self::Blob => "blob",
Self::Serial => "serial",
Self::ShortId => "shortid",
}
}
/// The SQLite STRICT type clause for this column. The
/// `serial` type also implies `PRIMARY KEY` semantics in
/// `sqlite_strict_extra` — see [`Self::sqlite_strict_extra`].
#[must_use]
pub const fn sqlite_strict_type(self) -> &'static str {
match self {
Self::Text
| Self::ShortId
| Self::Decimal
| Self::Date
| Self::DateTime => "TEXT",
Self::Int | Self::Serial | Self::Bool => "INTEGER",
Self::Real => "REAL",
Self::Blob => "BLOB",
}
}
/// Extra clause appended after the type in DDL — e.g.
/// `PRIMARY KEY` for `serial`. Empty when no extra clause
/// applies.
#[must_use]
pub const fn sqlite_strict_extra(self) -> &'static str {
match self {
Self::Serial => " PRIMARY KEY",
_ => "",
}
}
/// All types known in this iteration, in stable order.
/// Ordering groups numeric types together, then boolean,
/// then temporal, then binary, then identity-flavoured
/// auto-generated types.
#[must_use]
pub const fn all() -> &'static [Self] {
&[
Self::Text,
Self::Int,
Self::Real,
Self::Decimal,
Self::Bool,
Self::Date,
Self::DateTime,
Self::Blob,
Self::Serial,
Self::ShortId,
]
}
/// The user-facing type that an FK column should use to
/// reference a primary key of *this* type. For most types
/// the answer is the same type; for `serial` and `shortid`
/// it differs, because those types carry insert-time
/// auto-generation semantics that only apply on the PK
/// side. The FK side stores plain looked-up values, so:
///
/// - `serial` → `int` (FK holds a plain integer value)
/// - `shortid` → `text` (FK holds a plain text value)
///
/// Consumed by the FK declaration grammar in a later
/// iteration; defined here so the type system is complete
/// before that work begins.
#[must_use]
pub const fn fk_target_type(self) -> Self {
match self {
Self::Serial => Self::Int,
Self::ShortId => Self::Text,
other => other,
}
}
}
impl fmt::Display for Type {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.keyword())
}
}
/// Error returned when parsing a type keyword that isn't
/// recognised.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error("unknown type '{found}' (expected one of: {expected})")]
pub struct UnknownType {
pub found: String,
pub expected: String,
}
impl FromStr for Type {
type Err = UnknownType;
fn from_str(s: &str) -> Result<Self, Self::Err> {
for &ty in Self::all() {
if ty.keyword().eq_ignore_ascii_case(s) {
return Ok(ty);
}
}
Err(UnknownType {
found: s.to_string(),
expected: Self::all()
.iter()
.map(|t| t.keyword())
.collect::<Vec<_>>()
.join(", "),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn keyword_round_trip_for_every_type() {
for &ty in Type::all() {
let parsed: Type = ty.keyword().parse().expect("round-trip");
assert_eq!(parsed, ty);
}
}
#[test]
fn parsing_is_case_insensitive() {
assert_eq!("TEXT".parse::<Type>().unwrap(), Type::Text);
assert_eq!("Int".parse::<Type>().unwrap(), Type::Int);
assert_eq!("ShortId".parse::<Type>().unwrap(), Type::ShortId);
}
#[test]
fn unknown_type_lists_expected_alternatives() {
let err = "varchar".parse::<Type>().unwrap_err();
assert_eq!(err.found, "varchar");
assert!(err.expected.contains("text"));
assert!(err.expected.contains("shortid"));
}
#[test]
fn serial_maps_to_integer_with_primary_key() {
assert_eq!(Type::Serial.sqlite_strict_type(), "INTEGER");
assert_eq!(Type::Serial.sqlite_strict_extra(), " PRIMARY KEY");
}
#[test]
fn shortid_maps_to_text() {
assert_eq!(Type::ShortId.sqlite_strict_type(), "TEXT");
assert_eq!(Type::ShortId.sqlite_strict_extra(), "");
}
#[test]
fn fk_target_type_strips_auto_gen_semantics() {
// The two non-identity mappings.
assert_eq!(Type::Serial.fk_target_type(), Type::Int);
assert_eq!(Type::ShortId.fk_target_type(), Type::Text);
}
#[test]
fn fk_target_type_is_identity_for_plain_value_types() {
for ty in [
Type::Text,
Type::Int,
Type::Real,
Type::Decimal,
Type::Bool,
Type::Date,
Type::DateTime,
Type::Blob,
] {
assert_eq!(ty.fk_target_type(), ty);
}
}
#[test]
fn all_ten_types_are_present_and_distinct() {
let kws: Vec<&'static str> = Type::all().iter().map(|t| t.keyword()).collect();
assert_eq!(kws.len(), 10);
let mut sorted = kws.clone();
sorted.sort_unstable();
sorted.dedup();
assert_eq!(sorted.len(), 10, "keywords must be unique");
}
#[test]
fn temporal_and_decimal_types_map_to_text_storage() {
assert_eq!(Type::Decimal.sqlite_strict_type(), "TEXT");
assert_eq!(Type::Date.sqlite_strict_type(), "TEXT");
assert_eq!(Type::DateTime.sqlite_strict_type(), "TEXT");
}
#[test]
fn blob_type_maps_to_blob_storage() {
assert_eq!(Type::Blob.sqlite_strict_type(), "BLOB");
}
#[test]
fn unknown_type_message_lists_all_ten() {
let err = "varchar".parse::<Type>().unwrap_err();
for kw in [
"text", "int", "real", "decimal", "bool", "date", "datetime", "blob", "serial",
"shortid",
] {
assert!(
err.expected.contains(kw),
"expected list should contain `{kw}`: {}",
err.expected
);
}
}
}
+22 -1
View File
@@ -7,9 +7,30 @@
use crossterm::event::KeyEvent;
use crate::db::TableDescription;
use crate::dsl::Command;
#[derive(Debug, Clone)]
pub enum AppEvent {
Key(KeyEvent),
Resize { cols: u16, rows: u16 },
Resize {
cols: u16,
rows: u16,
},
Tick,
/// A DSL command finished successfully. `description` is
/// `Some` for commands that produce a table view (create,
/// add column) and `None` for commands that don't (drop).
DslSucceeded {
command: Command,
description: Option<TableDescription>,
},
/// A DSL command failed. `error` is already a friendly
/// message produced via `DbError::friendly_message`.
DslFailed {
command: Command,
error: String,
},
/// Refreshed list of tables in the database.
TablesRefreshed(Vec<String>),
}
+2
View File
@@ -8,6 +8,8 @@
pub mod action;
pub mod app;
pub mod cli;
pub mod db;
pub mod dsl;
pub mod event;
pub mod logging;
pub mod mode;
+108 -18
View File
@@ -3,18 +3,17 @@
//! A blocking task reads crossterm events and forwards them onto
//! an `mpsc` channel as `AppEvent`s. The main loop awaits events,
//! feeds them to `App::update`, enacts any returned `Action`s,
//! and redraws the terminal. Future async work (query execution,
//! snapshotting, auto-save) joins the same channel as additional
//! producers, which is why we set the architecture up this way
//! from day one.
//! and redraws the terminal. DSL execution is dispatched onto
//! the database worker (see `db::Database`), and its result is
//! posted back as a new `AppEvent`. Future async work (snapshot
//! capture, auto-save) joins the same event channel as
//! additional producers.
use std::io;
use std::time::Duration;
use anyhow::{Context, Result};
use crossterm::event::{
DisableMouseCapture, EnableMouseCapture, Event as CtEvent, EventStream,
};
use crossterm::event::{Event as CtEvent, EventStream};
use crossterm::execute;
use crossterm::terminal::{
EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
@@ -27,6 +26,8 @@ use tracing::{debug, error, info, warn};
use crate::action::Action;
use crate::app::App;
use crate::db::{Database, DbError, TableDescription};
use crate::dsl::Command;
use crate::event::AppEvent;
use crate::theme::Theme;
use crate::ui;
@@ -37,8 +38,12 @@ const SHUTDOWN_GRACE: Duration = Duration::from_millis(100);
/// Run the application until a `Quit` action is enacted or the
/// terminal closes.
pub async fn run(theme: Theme) -> Result<()> {
// For this iteration, every session uses a fresh in-memory
// database. Track 2 (project storage) wires up file-backed
// databases with proper lifecycle management.
let database = Database::open(":memory:").context("open database")?;
let mut terminal = setup_terminal().context("setup terminal")?;
let result = run_loop(&mut terminal, theme).await;
let result = run_loop(&mut terminal, theme, database).await;
if let Err(e) = teardown_terminal(&mut terminal) {
// Teardown failures should not mask the primary error.
warn!(error = %e, "terminal teardown failed");
@@ -49,13 +54,19 @@ pub async fn run(theme: Theme) -> Result<()> {
async fn run_loop(
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
theme: Theme,
database: Database,
) -> Result<()> {
let (event_tx, mut event_rx) = mpsc::channel::<AppEvent>(EVENT_CHANNEL_CAPACITY);
let reader_handle = spawn_event_reader(event_tx);
let reader_handle = spawn_event_reader(event_tx.clone());
let mut app = App::new();
// Initial draw before any events arrive.
// Seed the table list with whatever the database currently
// shows. For a fresh in-memory DB this is empty, but doing
// it explicitly means file-backed databases (track 2) will
// show their tables on launch without changes here.
seed_initial_tables(&database, &event_tx).await;
terminal
.draw(|f| ui::render(&app, &theme, f))
.context("initial draw")?;
@@ -70,6 +81,9 @@ async fn run_loop(
debug!("quit action received");
should_quit = true;
}
Action::ExecuteDsl(command) => {
spawn_dsl_dispatch(database.clone(), event_tx.clone(), command);
}
}
}
terminal
@@ -80,13 +94,89 @@ async fn run_loop(
}
}
// Give the reader a moment to notice the dropped sender.
let _ = tokio::time::timeout(SHUTDOWN_GRACE, reader_handle).await;
info!("event loop exited");
Ok(())
}
async fn seed_initial_tables(database: &Database, event_tx: &mpsc::Sender<AppEvent>) {
match database.list_tables().await {
Ok(tables) => {
let _ = event_tx.send(AppEvent::TablesRefreshed(tables)).await;
}
Err(e) => {
error!(error = %e, "failed to seed initial table list");
}
}
}
/// Spawn a task that runs a DSL command against the database
/// and forwards the result back as an `AppEvent`.
fn spawn_dsl_dispatch(
database: Database,
event_tx: mpsc::Sender<AppEvent>,
command: Command,
) {
tokio::spawn(async move {
let outcome = execute_command(&database, command.clone()).await;
let event = match outcome {
Ok(description) => AppEvent::DslSucceeded {
command: command.clone(),
description,
},
Err(error) => AppEvent::DslFailed {
command: command.clone(),
error,
},
};
if event_tx.send(event).await.is_err() {
return;
}
// Refresh the table list after every DDL operation so
// the items panel reflects reality. A failed list_tables
// here is logged but not surfaced to the user — they
// already saw the primary outcome.
match database.list_tables().await {
Ok(tables) => {
let _ = event_tx.send(AppEvent::TablesRefreshed(tables)).await;
}
Err(e) => warn!(error = %e, "post-DDL list_tables failed"),
}
});
}
async fn execute_command(
database: &Database,
command: Command,
) -> Result<Option<TableDescription>, String> {
match command {
Command::CreateTable {
name,
columns,
primary_key,
} => database
.create_table(name, columns, primary_key)
.await
.map(Some)
.map_err(friendly),
Command::DropTable { name } => database
.drop_table(name)
.await
.map(|()| None)
.map_err(friendly),
Command::AddColumn { table, column, ty } => database
.add_column(table, column, ty)
.await
.map(Some)
.map_err(friendly),
}
}
fn friendly(err: DbError) -> String {
err.friendly_message()
}
fn spawn_event_reader(tx: mpsc::Sender<AppEvent>) -> tokio::task::JoinHandle<()> {
tokio::spawn(async move {
let mut stream = EventStream::new();
@@ -118,8 +208,12 @@ fn spawn_event_reader(tx: mpsc::Sender<AppEvent>) -> tokio::task::JoinHandle<()>
fn setup_terminal() -> Result<Terminal<CrosstermBackend<io::Stdout>>> {
enable_raw_mode().context("enable raw mode")?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)
.context("enter alternate screen")?;
// Mouse capture is intentionally NOT enabled: it would prevent the
// host terminal's native text selection (the cost of capturing every
// mouse event), which we don't currently use for anything in-app.
// If we ever want click-to-select panes or scroll wheel handling,
// we'll need a different strategy than blanket capture.
execute!(stdout, EnterAlternateScreen).context("enter alternate screen")?;
let backend = CrosstermBackend::new(stdout);
let terminal = Terminal::new(backend).context("construct terminal")?;
Ok(terminal)
@@ -129,11 +223,7 @@ fn teardown_terminal(
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
) -> Result<()> {
disable_raw_mode().context("disable raw mode")?;
execute!(
terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
)
execute!(terminal.backend_mut(), LeaveAlternateScreen)
.context("leave alternate screen")?;
terminal.show_cursor().context("show cursor")?;
Ok(())
@@ -0,0 +1,28 @@
---
source: src/ui.rs
expression: snapshot
---
╭ Tables ──────────────────╮╭ Output ──────────────────────────────────────────╮
│Customers ││[system] [ok] create table Customers │
│Orders ││[system] Customers │
│ ││[system] id serial [PK] │
│ ││[system] Name text │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ │╰──────────────────────────────────────────────────╯
│ │╭ SIMPLE ──────────────────────────────────────────╮
│ ││ │
│ │╰──────────────────────────────────────────────────╯
│ │╭ Hint ────────────────────────────────────────────╮
│ ││(no active hint) │
╰──────────────────────────╯╰──────────────────────────────────────────────────╯
Enter submit · : advanced once · mode advanced switch · Ctrl-C quit
+87 -4
View File
@@ -32,7 +32,7 @@ pub fn render(app: &App, theme: &Theme, frame: &mut Frame<'_>) {
.constraints([Constraint::Length(28), Constraint::Min(20)])
.split(outer[0]);
render_items_panel(theme, frame, columns[0]);
render_items_panel(app, theme, frame, columns[0]);
render_right_column(app, theme, frame, columns[1]);
render_status_bar(app, theme, frame, outer[1]);
}
@@ -57,7 +57,7 @@ fn paint_background(theme: &Theme, frame: &mut Frame<'_>, area: Rect) {
frame.render_widget(block, area);
}
fn render_items_panel(theme: &Theme, frame: &mut Frame<'_>, area: Rect) {
fn render_items_panel(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rect) {
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
@@ -70,13 +70,39 @@ fn render_items_panel(theme: &Theme, frame: &mut Frame<'_>, area: Rect) {
))
.style(Style::default().bg(theme.bg).fg(theme.fg));
if app.tables.is_empty() {
let placeholder = Paragraph::new(Line::from(Span::styled(
"(none yet)",
Style::default().fg(theme.muted).add_modifier(Modifier::ITALIC),
Style::default()
.fg(theme.muted)
.add_modifier(Modifier::ITALIC),
)))
.block(block);
frame.render_widget(placeholder, area);
return;
}
let highlight = app
.current_table
.as_ref()
.map(|t| t.name.as_str())
.unwrap_or_default();
let lines: Vec<Line<'_>> = app
.tables
.iter()
.map(|name| {
let style = if name == highlight {
Style::default()
.fg(theme.fg)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme.fg)
};
Line::from(Span::styled(name.as_str(), style))
})
.collect();
let paragraph = Paragraph::new(lines).block(block);
frame.render_widget(paragraph, area);
}
fn render_output_panel(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rect) {
@@ -300,4 +326,61 @@ mod tests {
let snapshot = render_to_string(&app, &theme, 80, 24);
insta::assert_snapshot!("one_shot_advanced_dark", snapshot);
}
#[test]
fn populated_with_table_snapshot() {
// Items panel lists tables; output panel shows the
// structure of the current table.
use crate::app::{OutputKind, OutputLine};
use crate::db::{ColumnDescription, TableDescription};
let mut app = App::new();
app.tables = vec!["Customers".to_string(), "Orders".to_string()];
use crate::dsl::Type;
let desc = TableDescription {
name: "Customers".to_string(),
columns: vec![
ColumnDescription {
name: "id".to_string(),
user_type: Some(Type::Serial),
sqlite_type: "INTEGER".to_string(),
notnull: false,
primary_key: true,
},
ColumnDescription {
name: "Name".to_string(),
user_type: Some(Type::Text),
sqlite_type: "TEXT".to_string(),
notnull: false,
primary_key: false,
},
],
};
app.current_table = Some(desc);
// Mirror what the App writes when a DSL command succeeds.
app.output.push_back(OutputLine {
text: "[ok] create table Customers".to_string(),
kind: OutputKind::System,
mode_at_submission: Mode::Simple,
});
app.output.push_back(OutputLine {
text: " Customers".to_string(),
kind: OutputKind::System,
mode_at_submission: Mode::Simple,
});
app.output.push_back(OutputLine {
text: " id serial [PK]".to_string(),
kind: OutputKind::System,
mode_at_submission: Mode::Simple,
});
app.output.push_back(OutputLine {
text: " Name text".to_string(),
kind: OutputKind::System,
mode_at_submission: Mode::Simple,
});
let theme = Theme::dark();
let snapshot = render_to_string(&app, &theme, 80, 24);
insta::assert_snapshot!("populated_with_table_dark", snapshot);
}
}
+205 -14
View File
@@ -12,6 +12,8 @@ use ratatui::backend::TestBackend;
use rdbms_playground::action::Action;
use rdbms_playground::app::{App, OutputKind};
use rdbms_playground::db::{ColumnDescription, TableDescription};
use rdbms_playground::dsl::{ColumnSpec, Command, Type};
use rdbms_playground::event::AppEvent;
use rdbms_playground::mode::Mode;
use rdbms_playground::theme::Theme;
@@ -56,30 +58,48 @@ fn rendered_text(app: &App, theme: &Theme, width: u16, height: u16) -> String {
}
#[test]
fn typing_then_submitting_produces_an_echo_in_the_output_panel() {
fn typing_then_submitting_a_dsl_command_emits_execute_action() {
let mut app = App::new();
let theme = Theme::dark();
type_str(&mut app, "hello world");
type_str(&mut app, "create table Customers with pk");
let pre_render = rendered_text(&app, &theme, 80, 24);
assert!(
pre_render.contains("hello world"),
pre_render.contains("create table Customers"),
"input field should display the typed text:\n{pre_render}"
);
let actions = submit(&mut app);
assert!(actions.is_empty());
assert_eq!(
actions,
vec![Action::ExecuteDsl(Command::CreateTable {
name: "Customers".to_string(),
columns: vec![ColumnSpec {
name: "id".to_string(),
ty: Type::Serial,
}],
primary_key: vec!["id".to_string()],
})]
);
assert!(app.input.is_empty(), "input buffer cleared on submit");
assert_eq!(app.output.len(), 1);
let post_render = rendered_text(&app, &theme, 80, 24);
assert!(
post_render.contains("hello world"),
"output panel should display the echoed line:\n{post_render}"
post_render.contains("running:"),
"output panel should show the running notice:\n{post_render}"
);
}
#[test]
fn typing_invalid_simple_input_shows_a_parse_error_not_an_echo() {
let mut app = App::new();
let theme = Theme::dark();
type_str(&mut app, "hello world");
let actions = submit(&mut app);
assert!(actions.is_empty());
let rendered = rendered_text(&app, &theme, 80, 24);
assert!(
post_render.contains("[simple]"),
"echo should be tagged with the submission mode:\n{post_render}"
rendered.contains("parse error"),
"output panel should show the parse error:\n{rendered}"
);
}
@@ -112,12 +132,23 @@ fn colon_escape_in_simple_mode_is_one_shot() {
type_str(&mut app, ":select 1");
submit(&mut app);
assert_eq!(app.mode, Mode::Simple);
assert_eq!(app.output[0].mode_at_submission, Mode::Advanced);
assert_eq!(app.output[0].text, "select 1");
// Advanced mode currently echoes (SQL handling lands later);
// the echoed line should carry the advanced submission mode.
let echoed = app
.output
.iter()
.rfind(|l| l.kind == OutputKind::Echo)
.expect("echo output present");
assert_eq!(echoed.mode_at_submission, Mode::Advanced);
assert_eq!(echoed.text, "select 1");
type_str(&mut app, "another line");
// Subsequent submission (unrecognised in simple mode) parse-errors,
// not echoes — confirming the mode reverted.
type_str(&mut app, "list things");
submit(&mut app);
assert_eq!(app.output[1].mode_at_submission, Mode::Simple);
let last = app.output.back().unwrap();
assert_eq!(last.kind, OutputKind::Error);
assert_eq!(last.mode_at_submission, Mode::Simple);
}
#[test]
@@ -184,3 +215,163 @@ fn status_bar_lists_quit_and_submit_in_all_modes() {
assert!(advanced.contains("Ctrl-C"));
assert!(advanced.contains("mode simple"));
}
// ---------------------------------------------------------------
// Full DSL flow tests.
//
// These tests simulate the runtime by feeding the AppEvent::Dsl*
// events that the runtime would post after dispatching a command
// to the database. That keeps these tests deterministic and runtime
// agnostic — the actual database is exercised in the db module's
// own #[tokio::test] suite.
// ---------------------------------------------------------------
fn fake_table(name: &str, columns: &[(&str, Type, bool)]) -> TableDescription {
TableDescription {
name: name.to_string(),
columns: columns
.iter()
.map(|(n, t, pk)| ColumnDescription {
name: (*n).to_string(),
user_type: Some(*t),
sqlite_type: t.sqlite_strict_type().to_string(),
notnull: false,
primary_key: *pk,
})
.collect(),
}
}
#[test]
fn create_table_flow_updates_tables_list_and_structure_view() {
let mut app = App::new();
let theme = Theme::dark();
// User types and submits.
type_str(&mut app, "create table Customers with pk");
let actions = submit(&mut app);
let expected_cmd = Command::CreateTable {
name: "Customers".to_string(),
columns: vec![ColumnSpec {
name: "id".to_string(),
ty: Type::Serial,
}],
primary_key: vec!["id".to_string()],
};
assert_eq!(actions, vec![Action::ExecuteDsl(expected_cmd.clone())]);
// Runtime would now dispatch and feed back DslSucceeded + TablesRefreshed.
let desc = fake_table("Customers", &[("id", Type::Serial, true)]);
app.update(AppEvent::DslSucceeded {
command: expected_cmd,
description: Some(desc.clone()),
});
app.update(AppEvent::TablesRefreshed(vec!["Customers".to_string()]));
assert_eq!(app.tables, vec!["Customers".to_string()]);
assert_eq!(app.current_table, Some(desc));
let rendered = rendered_text(&app, &theme, 80, 24);
assert!(
rendered.contains("Customers"),
"items panel should list Customers:\n{rendered}"
);
assert!(
rendered.contains("[ok] create table Customers"),
"output should confirm success:\n{rendered}"
);
assert!(
rendered.contains("id serial"),
"output should show the structure with the user-facing type:\n{rendered}"
);
}
#[test]
fn add_column_flow_updates_structure_view() {
let mut app = App::new();
// Simulate the prior create_table state.
app.tables = vec!["Customers".to_string()];
app.current_table = Some(fake_table(
"Customers",
&[("id", Type::Serial, true)],
));
type_str(&mut app, "add column to table Customers: Name (text)");
let actions = submit(&mut app);
assert_eq!(
actions,
vec![Action::ExecuteDsl(Command::AddColumn {
table: "Customers".to_string(),
column: "Name".to_string(),
ty: Type::Text,
})]
);
let updated = fake_table(
"Customers",
&[("id", Type::Serial, true), ("Name", Type::Text, false)],
);
app.update(AppEvent::DslSucceeded {
command: Command::AddColumn {
table: "Customers".to_string(),
column: "Name".to_string(),
ty: Type::Text,
},
description: Some(updated.clone()),
});
assert_eq!(app.current_table, Some(updated));
let rendered = rendered_text(&app, &Theme::dark(), 80, 24);
assert!(rendered.contains("Name text"));
}
#[test]
fn drop_table_flow_clears_items_list() {
let mut app = App::new();
app.tables = vec!["Customers".to_string()];
app.current_table = Some(fake_table("Customers", &[("id", Type::Serial, true)]));
type_str(&mut app, "drop table Customers");
let actions = submit(&mut app);
assert_eq!(
actions,
vec![Action::ExecuteDsl(Command::DropTable {
name: "Customers".to_string()
})]
);
app.update(AppEvent::DslSucceeded {
command: Command::DropTable {
name: "Customers".to_string(),
},
description: None,
});
app.update(AppEvent::TablesRefreshed(Vec::new()));
assert!(app.tables.is_empty());
assert!(app.current_table.is_none());
let rendered = rendered_text(&app, &Theme::dark(), 80, 24);
assert!(rendered.contains("(none yet)"));
assert!(rendered.contains("[ok] drop table Customers"));
}
#[test]
fn dsl_failure_shows_friendly_error_in_output() {
let mut app = App::new();
type_str(&mut app, "drop table Ghost");
submit(&mut app);
app.update(AppEvent::DslFailed {
command: Command::DropTable {
name: "Ghost".to_string(),
},
error: "no such table: Ghost".to_string(),
});
let rendered = rendered_text(&app, &Theme::dark(), 80, 24);
assert!(
rendered.contains("Ghost"),
"error should mention the table:\n{rendered}"
);
assert!(
rendered.contains("no such table"),
"error should include the friendly message:\n{rendered}"
);
}