diff --git a/Cargo.lock b/Cargo.lock index 4042dff..b201af2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index 99a63e6..4c5e235 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/docs/adr/0009-dsl-command-syntax-conventions.md b/docs/adr/0009-dsl-command-syntax-conventions.md new file mode 100644 index 0000000..070279b --- /dev/null +++ b/docs/adr/0009-dsl-command-syntax-conventions.md @@ -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 with pk :` +- `add column to table : ()` +- `drop table ` + +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. \ No newline at end of file diff --git a/docs/adr/0010-database-access-via-worker-thread.md b/docs/adr/0010-database-access-via-worker-thread.md new file mode 100644 index 0000000..4357050 --- /dev/null +++ b/docs/adr/0010-database-access-via-worker-thread.md @@ -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` 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. \ No newline at end of file diff --git a/docs/adr/0011-fk-column-type-compatibility.md b/docs/adr/0011-fk-column-type-compatibility.md new file mode 100644 index 0000000..d5371b8 --- /dev/null +++ b/docs/adr/0011-fk-column-type-compatibility.md @@ -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 10–12 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. \ No newline at end of file diff --git a/docs/adr/0012-internal-metadata-for-user-facing-types.md b/docs/adr/0012-internal-metadata-for-user-facing-types.md new file mode 100644 index 0000000..1c3f1cc --- /dev/null +++ b/docs/adr/0012-internal-metadata-for-user-facing-types.md @@ -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_` 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) diff --git a/docs/adr/README.md b/docs/adr/README.md index c075913..80ce8af 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -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) diff --git a/docs/requirements.md b/docs/requirements.md index 39862d1..3b4fe1c 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -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 to ` 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, 10–12 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) diff --git a/src/action.rs b/src/action.rs index ba4d168..675fbca 100644 --- a/src/action.rs +++ b/src/action.rs @@ -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), } diff --git a/src/app.rs b/src/app.rs index ca2ef8f..bc7aed0 100644 --- a/src/app.rs +++ b/src/app.rs @@ -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, pub hint: Option, + pub tables: Vec, + /// Last successfully described table, shown in the output + /// pane until the next DDL operation. + pub current_table: Option, + /// 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, + /// Position within `history` while navigating with Up/Down. + /// `None` means "not navigating; `input` is the user's + /// in-progress draft." + history_cursor: Option, + /// 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, } +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 { - // 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 { 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,13 +280,76 @@ impl App { _ => {} } - // Default: echo the line tagged with its effective mode. - self.push_output(OutputLine { - text: effective_input, - kind: OutputKind::Echo, - mode_at_submission: effective_mode, - }); - Vec::new() + // 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, + mode_at_submission: effective_mode, + }); + Vec::new() + } + } + } + + fn dispatch_dsl(&mut self, input: &str, submission_mode: Mode) -> Vec { + 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) { + 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) { @@ -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, + })] + ); } } diff --git a/src/db.rs b/src/db.rs new file mode 100644 index 0000000..1ea17ce --- /dev/null +++ b/src/db.rs @@ -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, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TableDescription { + pub name: String, + pub columns: Vec, +} + +#[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, + /// 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, + primary_key: Vec, + reply: oneshot::Sender>, + }, + DropTable { + name: String, + reply: oneshot::Sender>, + }, + AddColumn { + table: String, + column: String, + ty: Type, + reply: oneshot::Sender>, + }, + ListTables { + reply: oneshot::Sender, DbError>>, + }, + DescribeTable { + name: String, + reply: oneshot::Sender>, + }, +} + +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 + Into>(path: P) -> Result { + 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_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, + primary_key: Vec, + ) -> Result { + 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 { + 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, 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 { + 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) { + 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 { + 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 = 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 = 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 { + 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, 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 { + // `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 = row.get(4)?; + let user_type = user_type_kw.and_then(|kw| kw.parse::().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"); + } +} diff --git a/src/dsl/command.rs b/src/dsl/command.rs new file mode 100644 index 0000000..57ff7a2 --- /dev/null +++ b/src/dsl/command.rs @@ -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, + /// 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, + }, + 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, + } + } +} diff --git a/src/dsl/mod.rs b/src/dsl/mod.rs new file mode 100644 index 0000000..91d0c12 --- /dev/null +++ b/src/dsl/mod.rs @@ -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; diff --git a/src/dsl/parser.rs b/src/dsl/parser.rs new file mode 100644 index 0000000..37b895e --- /dev/null +++ b/src/dsl/parser.rs @@ -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 { + match self { + Self::Invalid { position, .. } => Some(*position), + Self::Empty => None, + } + } +} + +/// Parse a single DSL command. +pub fn parse_command(input: &str) -> Result { + 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(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(reason: &RichReason<'_, T, String>) -> Option { + 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>> + 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 :` to choose. \ + Use a comma-separated list for compound primary keys." + .to_string(), + )); + } + let columns: Vec = 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 []` clause that may follow +/// `create table `. 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>> + 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::>(); + + 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>> + Clone { + any() + .filter(|c: &char| c.is_ascii_alphabetic() || *c == '_') + .then( + any() + .filter(|c: &char| c.is_ascii_alphanumeric() || *c == '_') + .repeated() + .collect::>(), + ) + .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>> + Clone { + let alphabetic = any() + .filter(|c: &char| c.is_ascii_alphabetic()) + .repeated() + .at_least(1) + .collect::(); + alphabetic.padded().try_map(|word, span| { + word.parse::() + .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>> + Clone { + let alphabetic = any() + .filter(|c: &char| c.is_ascii_alphabetic()) + .repeated() + .at_least(1) + .collect::(); + 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()], + } + ); + } +} \ No newline at end of file diff --git a/src/dsl/types.rs b/src/dsl/types.rs new file mode 100644 index 0000000..476a0d9 --- /dev/null +++ b/src/dsl/types.rs @@ -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, + /// 10–12 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 { + 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::>() + .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::().unwrap(), Type::Text); + assert_eq!("Int".parse::().unwrap(), Type::Int); + assert_eq!("ShortId".parse::().unwrap(), Type::ShortId); + } + + #[test] + fn unknown_type_lists_expected_alternatives() { + let err = "varchar".parse::().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::().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 + ); + } + } +} diff --git a/src/event.rs b/src/event.rs index 569a511..5966f20 100644 --- a/src/event.rs +++ b/src/event.rs @@ -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, + }, + /// 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), } diff --git a/src/lib.rs b/src/lib.rs index ec5f357..48bf96a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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; diff --git a/src/runtime.rs b/src/runtime.rs index 3df4741..bb6db88 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -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>, theme: Theme, + database: Database, ) -> Result<()> { let (event_tx, mut event_rx) = mpsc::channel::(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) { + 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, + 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, 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) -> tokio::task::JoinHandle<()> { tokio::spawn(async move { let mut stream = EventStream::new(); @@ -118,8 +208,12 @@ fn spawn_event_reader(tx: mpsc::Sender) -> tokio::task::JoinHandle<()> fn setup_terminal() -> Result>> { 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,12 +223,8 @@ fn teardown_terminal( terminal: &mut Terminal>, ) -> Result<()> { disable_raw_mode().context("disable raw mode")?; - execute!( - terminal.backend_mut(), - LeaveAlternateScreen, - DisableMouseCapture - ) - .context("leave alternate screen")?; + execute!(terminal.backend_mut(), LeaveAlternateScreen) + .context("leave alternate screen")?; terminal.show_cursor().context("show cursor")?; Ok(()) } diff --git a/src/snapshots/rdbms_playground__ui__tests__populated_with_table_dark.snap b/src/snapshots/rdbms_playground__ui__tests__populated_with_table_dark.snap new file mode 100644 index 0000000..0f6651c --- /dev/null +++ b/src/snapshots/rdbms_playground__ui__tests__populated_with_table_dark.snap @@ -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 diff --git a/src/ui.rs b/src/ui.rs index 4885941..850fef4 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -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)); - let placeholder = Paragraph::new(Line::from(Span::styled( - "(none yet)", - Style::default().fg(theme.muted).add_modifier(Modifier::ITALIC), - ))) - .block(block); + if app.tables.is_empty() { + let placeholder = Paragraph::new(Line::from(Span::styled( + "(none yet)", + Style::default() + .fg(theme.muted) + .add_modifier(Modifier::ITALIC), + ))) + .block(block); + frame.render_widget(placeholder, area); + return; + } - frame.render_widget(placeholder, area); + let highlight = app + .current_table + .as_ref() + .map(|t| t.name.as_str()) + .unwrap_or_default(); + let lines: Vec> = 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); + } } diff --git a/tests/walking_skeleton.rs b/tests/walking_skeleton.rs index 45202e9..f46a716 100644 --- a/tests/walking_skeleton.rs +++ b/tests/walking_skeleton.rs @@ -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}" + ); +}