DSL parser, async DB worker, types, history, metadata, polish
Track 1 implementation plus polish round. Parser (chumsky): - Grammar-based DSL producing a typed Command AST. - create table X with pk [name:type[,name:type...]] supports arbitrary names, any user type, compound PKs natively. Bare form errors with a friendly hint pointing at `with pk`. - add column to table X: Name (type); drop table X. - Required clauses use keyword grammar; -- reserved for opt-in flags (ADR-0009). Custom Rich reasons preferred when surfacing chumsky errors so unknown-type messages list valid alternatives. Database (ADR-0010, ADR-0012): - rusqlite + STRICT tables + foreign_keys=ON. - Dedicated worker thread; mpsc Request inbox, oneshot replies. - Typed DbError with friendly_message() hook for H1. - Internal __rdbms_playground_columns metadata table preserves user-facing types across schema reads, atomically maintained alongside DDL via Connection transactions. list_tables hides it via the new __rdbms_ internal-table convention. Types (ADR-0005, ADR-0011): - All ten user-facing types: text, int, real, decimal, bool, date, datetime, blob, serial, shortid. - Type::fk_target_type() for FK-side column-type rule (Serial->Int, ShortId->Text, others identity) -- foundation for the FK iteration. App / Runtime / UI: - update() stays pure-sync; runtime dispatches DSL via spawned tasks, results post back as AppEvent::Dsl*. - Items panel renders live tables list; output panel shows the user-facing structure of the current table after each DDL. - In-memory command history (Up/Down, draft preservation, consecutive-duplicate dedup) -- I2 partial. - Mouse capture removed; terminal native text selection restored (toggle approach revisited when scroll/click features land). Docs: - ADRs 0009 (DSL syntax conventions), 0010 (DB worker), 0011 (FK type compat), 0012 (internal metadata table). - requirements.md progress notes; new V4 entry for the scrollable session-log + inline rich rendering + Markdown export direction. Tests: 103 passing (91 lib + 12 integration), 0 skipped. Clippy clean with nursery enabled.
This commit is contained in:
Generated
+162
@@ -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"
|
||||
|
||||
@@ -10,9 +10,11 @@ publish = false
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1.0.102"
|
||||
chumsky = "0.13.0"
|
||||
crossterm = { version = "0.29.0", features = ["event-stream"] }
|
||||
futures-util = "0.3.32"
|
||||
ratatui = "0.30.0"
|
||||
rusqlite = { version = "0.39.0", features = ["bundled"] }
|
||||
thiserror = "2.0.18"
|
||||
tokio = { version = "1.52.2", features = ["full"] }
|
||||
tracing = "0.1.44"
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
# ADR-0009: DSL command syntax conventions
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
## Context
|
||||
|
||||
As the DSL grows, its commands need consistent surface
|
||||
conventions. Without an explicit rule, every command would
|
||||
invent its own way of expressing optional vs. required parts,
|
||||
and the surface would drift toward an unreadable soup.
|
||||
|
||||
The decision is informed by experience from this iteration:
|
||||
when we initially proposed `create table X --pk` the most
|
||||
common form (a basic table with a primary key) required a `--`
|
||||
flag, which is cosmetically wrong — `--` reads as "extra
|
||||
option," and the most-used form should not look like one.
|
||||
|
||||
## Decision
|
||||
|
||||
The DSL surface follows three rules.
|
||||
|
||||
### 1. Required clauses use keyword grammar
|
||||
|
||||
Required parts of a command are written in plain words and
|
||||
read like English. Examples:
|
||||
|
||||
- `create table <Name> with pk <name>:<type>`
|
||||
- `add column to table <Name>: <Name> (<Type>)`
|
||||
- `drop table <Name>`
|
||||
|
||||
The `with` clause format is the canonical pattern for
|
||||
attaching required structural information to an entity-creating
|
||||
command, and is reusable: future iterations may add `with
|
||||
index`, `with check`, etc. Multiple `with` clauses on the same
|
||||
command are allowed in principle.
|
||||
|
||||
### 2. Optional flags use `--prefix`
|
||||
|
||||
Flags signal "I am asking for an extra capability or
|
||||
non-default behaviour." Examples planned for later iterations:
|
||||
|
||||
- `add 1:n relationship on Customers.Id=Orders.CustId --create-fk`
|
||||
(auto-creates the FK column instead of requiring it to exist)
|
||||
- *(future)* `--rename-on-clash`, `--no-strict`, etc.
|
||||
|
||||
A user reading "with pk id:serial" sees only what's needed; a
|
||||
user reading "...with pk id:serial --some-flag" sees that they
|
||||
have asked for something beyond default. The visual distinction
|
||||
is intentional.
|
||||
|
||||
### 3. One sigil only — `:` for the simple-mode advanced escape
|
||||
|
||||
Per ADR-0003, prefixing a single line with `:` in simple mode
|
||||
treats that one submission as if it were entered in advanced
|
||||
mode. This is the only sigil in the system. App-level commands,
|
||||
DSL commands, and SQL all use plain words.
|
||||
|
||||
### Lexical rules
|
||||
|
||||
- **Keywords are case-insensitive.** `CREATE TABLE Customers
|
||||
WITH PK email:TEXT` is equivalent to `create table Customers
|
||||
with pk email:text`.
|
||||
- **Identifiers are case-preserving.** `Customers` and
|
||||
`customers` are different identifiers if a backend would
|
||||
treat them as such (we follow SQLite's case-insensitive
|
||||
identifier rules at the schema level but preserve the user's
|
||||
written casing in display).
|
||||
- **Whitespace is liberal.** Any amount of horizontal whitespace
|
||||
between tokens is accepted, including around punctuation
|
||||
(`,`, `:`, `(`, `)`).
|
||||
|
||||
## Consequences
|
||||
|
||||
- The basic, most-common form of any command remains readable
|
||||
and free of cosmetic punctuation. New users see only words.
|
||||
- Optional adornments are visually distinct, encouraging
|
||||
discoverability of advanced features without forcing them on
|
||||
beginners.
|
||||
- New commands inherit a uniform shape: keyword-based clauses
|
||||
for required parts, `--` flags for opt-ins. Drift is bounded
|
||||
by this rule.
|
||||
- The grammar implementation (`chumsky`) maps cleanly onto this
|
||||
structure: a `with_clause` rule can be reused across
|
||||
commands, and flag parsing has a single representation when
|
||||
it lands.
|
||||
@@ -0,0 +1,93 @@
|
||||
# ADR-0010: Database access via a dedicated worker thread
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
## Context
|
||||
|
||||
`rusqlite::Connection` is `Send` but `!Sync`, and its API is
|
||||
synchronous. Our application loop is asynchronous (Tokio,
|
||||
ADR-0001). We need a clean async boundary that keeps:
|
||||
|
||||
- The `App::update` function pure — same input, same state
|
||||
transition, same outputs (per ADR-0001's Elm-style design);
|
||||
this is what makes ADR-0008's Tier 1 and Tier 3 tests
|
||||
tractable.
|
||||
- The path to future requirements (B3 query timeout/cancellation,
|
||||
U1 snapshot capture, P3 auto-save) free of architectural
|
||||
refactors.
|
||||
- Errors surfaceable in a way that the future H1 friendly-error
|
||||
layer can plug into without callsite changes.
|
||||
|
||||
Considered alternatives:
|
||||
|
||||
1. **Sync calls from async tasks** — call rusqlite directly
|
||||
inside `update`. Blocks the event loop on every DB call.
|
||||
Unworkable as soon as queries grow.
|
||||
2. **`tokio::task::spawn_blocking` per call** — uses tokio's
|
||||
blocking thread pool. Acceptable for short ops but the pool
|
||||
is sized for short tasks, and a long-lived "this is the
|
||||
database connection" task is not its intended use.
|
||||
3. **Dedicated worker thread with typed request/response
|
||||
channels** — the connection lives on a thread we own; the
|
||||
App holds a sender; replies come back via per-request
|
||||
`oneshot` channels.
|
||||
|
||||
## Decision
|
||||
|
||||
A single dedicated OS thread (`std::thread::Builder::new().name("rdbms-db-worker")`)
|
||||
owns the `rusqlite::Connection`. The App talks to it through:
|
||||
|
||||
- A bounded `tokio::sync::mpsc::Sender<Request>` carrying typed
|
||||
request enum variants, each of which embeds a
|
||||
`tokio::sync::oneshot::Sender` for the reply.
|
||||
- A typed `DbError` (`thiserror`-derived) wrapping the union of
|
||||
database-side failure modes, with a `friendly_message()`
|
||||
method whose body today is a passthrough — this is the hook
|
||||
point for the future H1 friendly-error layer.
|
||||
|
||||
The worker:
|
||||
|
||||
- Receives requests via `Receiver::blocking_recv` (no Tokio
|
||||
context required on the worker thread).
|
||||
- Performs the operation synchronously against the connection.
|
||||
- Sends the result to the per-request `oneshot` reply channel.
|
||||
- Exits when the last `Sender` is dropped (clean shutdown via
|
||||
Drop, no shutdown protocol).
|
||||
|
||||
The public `Database` handle is `Clone` (it's a wrapper around
|
||||
a `Sender`), so multiple components can hold it.
|
||||
|
||||
## Consequences
|
||||
|
||||
- `App::update` stays sync and pure. The runtime, on receiving
|
||||
an `Action::ExecuteDsl`, spawns a `tokio::spawn` task that
|
||||
awaits the DB worker and posts the result back as a new
|
||||
`AppEvent`. The Elm pattern is preserved.
|
||||
- New database operations are mechanical to add: define a
|
||||
`Request` variant with its `oneshot` reply type, add a method
|
||||
on `Database`, add a handler in the worker.
|
||||
- B3 (query timeout/cancellation) lands on top of this without
|
||||
architecture change: a cancellation token can be stored
|
||||
alongside the in-flight request, and the worker can interrupt
|
||||
via `rusqlite::Connection::interrupt`.
|
||||
- U1 (snapshot capture) and P3 (auto-save) are also additive:
|
||||
more `Request` variants, possibly using SQLite's online
|
||||
backup API.
|
||||
- Errors carry a typed kind (`UniqueViolation`, `NoSuchTable`,
|
||||
etc.). When H1's friendly-error layer lands, the body of
|
||||
`friendly_message()` becomes the translation table; callsites
|
||||
do not change.
|
||||
- Cost: an extra OS thread per database. For a desktop tool
|
||||
this is negligible; this would be different for a server
|
||||
application that hosts many databases.
|
||||
|
||||
## Implementation notes
|
||||
|
||||
- Channel capacity 64 is sufficient bursts head-room for an
|
||||
interactive tool.
|
||||
- The connection is opened in the spawning thread and *moved*
|
||||
into the worker. This gives us fail-fast behaviour: a bad
|
||||
path or permissions issue surfaces immediately to the caller
|
||||
before any worker is started.
|
||||
@@ -0,0 +1,78 @@
|
||||
# ADR-0011: Foreign-key column type compatibility
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
## Context
|
||||
|
||||
Two of our user-facing types (per ADR-0005) carry insert-time
|
||||
auto-generation semantics that only apply on the primary-key
|
||||
side of a relationship:
|
||||
|
||||
- `serial` is `INTEGER PRIMARY KEY` with rowid-alias /
|
||||
auto-increment behaviour.
|
||||
- `shortid` is `TEXT` populated client-side with a 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.
|
||||
@@ -0,0 +1,115 @@
|
||||
# ADR-0012: Internal metadata for user-facing column types
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
## Context
|
||||
|
||||
ADR-0005 commits to ten user-facing types (`text`, `int`, `real`,
|
||||
`decimal`, `bool`, `date`, `datetime`, `blob`, `serial`,
|
||||
`shortid`) and to mapping them transparently onto SQLite STRICT
|
||||
types so that learners do not see SQLite's erased forms (Q3 in
|
||||
the requirements checklist). The mapping is many-to-few:
|
||||
`shortid`, `decimal`, `date`, `datetime` all map to `TEXT`;
|
||||
`serial` and `bool` both map to `INTEGER`.
|
||||
|
||||
Reading the schema back via `PRAGMA table_info` therefore loses
|
||||
the original user-facing type. Rendering "id INTEGER" instead of
|
||||
"id serial", or "created TEXT" instead of "created datetime",
|
||||
breaks the Q3 promise even though we generated correct DDL.
|
||||
|
||||
The same information is needed by track 2 (project file format,
|
||||
ADR-0004): when serialising the schema to YAML, we need the
|
||||
user-facing types, not the SQLite-erased ones.
|
||||
|
||||
## Decision
|
||||
|
||||
Maintain an internal SQLite table that records the user-facing
|
||||
type each column was declared with. The table is part of the
|
||||
database itself, so it travels with the `playground.db` file
|
||||
and is the single source of truth for round-tripping types.
|
||||
|
||||
```sql
|
||||
CREATE TABLE __rdbms_playground_columns (
|
||||
table_name TEXT NOT NULL,
|
||||
column_name TEXT NOT NULL,
|
||||
user_type TEXT NOT NULL,
|
||||
PRIMARY KEY (table_name, column_name)
|
||||
) STRICT;
|
||||
```
|
||||
|
||||
- **Bootstrap.** The table is created on every connection open
|
||||
with `CREATE TABLE IF NOT EXISTS`, alongside `PRAGMA
|
||||
foreign_keys = ON`.
|
||||
- **Writes.** `create_table` and `add_column` insert one row per
|
||||
user column, atomically with the DDL via
|
||||
`Connection::unchecked_transaction`. `drop_table` deletes all
|
||||
rows for the dropped table. Every DDL operation that adds or
|
||||
removes user columns must keep this table in sync.
|
||||
- **Reads.** `describe_table` LEFT-JOINs `pragma_table_info`
|
||||
with this table to populate `ColumnDescription::user_type`.
|
||||
Callers prefer `user_type` for display; `sqlite_type` is
|
||||
retained for diagnostics and fallback.
|
||||
- **Visibility.** `list_tables` filters out any name starting
|
||||
with `__rdbms_` so the metadata table is hidden from the user.
|
||||
|
||||
### Naming convention for internal tables
|
||||
|
||||
All future internal tables use the `__rdbms_` prefix (double
|
||||
underscore plus `rdbms_`). This serves two purposes:
|
||||
|
||||
1. **Avoiding collisions.** SQLite reserves `sqlite_` for its
|
||||
own use; we reserve `__rdbms_` for ours. A user cannot
|
||||
create a table whose name conflicts with our internals
|
||||
without explicitly typing the prefix, which is unlikely by
|
||||
accident.
|
||||
2. **Single filter rule.** `list_tables` and any future
|
||||
schema-introspection code excludes names matching
|
||||
`sqlite_%` and `__rdbms_%`. New internal tables are
|
||||
automatically hidden as long as they follow the convention.
|
||||
|
||||
## Consequences
|
||||
|
||||
- The Q3 / ADR-0005 transparency promise is delivered. Users
|
||||
see `id serial`, not `id INTEGER`.
|
||||
- The DSL parser, type system, and database layer collectively
|
||||
control all user-facing type information; SQLite's erased
|
||||
view is never the authoritative source.
|
||||
- Track 2 (project file format) round-trips user types via
|
||||
this metadata. The YAML schema dump reads from
|
||||
`__rdbms_playground_columns`; loading reconstructs the
|
||||
metadata alongside the DDL.
|
||||
- DDL operations carry a small additional cost (one transaction
|
||||
per operation, one row per user column). Negligible for
|
||||
interactive use; would matter only for bulk schema operations,
|
||||
which are not in scope.
|
||||
- Adding new user-column-creating operations must extend this
|
||||
metadata in step. The single-source-of-truth invariant is
|
||||
worth defending — a future code review should reject any new
|
||||
DDL that touches columns without updating
|
||||
`__rdbms_playground_columns`.
|
||||
- The convention `__rdbms_<name>` for internal tables is
|
||||
established for any future internal-state needs (snapshot
|
||||
bookkeeping, replay log indexing, etc.).
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **In-memory cache in `App`.** Simpler, but doesn't survive
|
||||
a restart, and would have to be rebuilt by parsing past
|
||||
command history. Loses the single-source-of-truth property.
|
||||
- **Sentinel comments in DDL.** SQLite's
|
||||
`sqlite_master.sql` retains the original CREATE TABLE text;
|
||||
we could embed `/* user_type: serial */` markers and parse
|
||||
them back. Brittle and hard to keep correct under ALTER.
|
||||
- **Lossy reverse mapping.** Heuristically guess `serial` from
|
||||
`INTEGER PRIMARY KEY`, `text` from `TEXT`, etc. Cannot
|
||||
distinguish `text`/`shortid`/`decimal`/`date`/`datetime`,
|
||||
all backed by `TEXT`. Wrong by construction.
|
||||
|
||||
## See also
|
||||
|
||||
- ADR-0002 (database engine, STRICT tables, `foreign_keys` ON)
|
||||
- ADR-0005 (column type vocabulary, mapping commitment)
|
||||
- ADR-0010 (database access via dedicated worker thread —
|
||||
describes where this metadata is read and written from)
|
||||
@@ -14,3 +14,7 @@ This directory contains the project's ADRs, recorded per
|
||||
- [ADR-0006 — Undo snapshots and replay log](0006-undo-snapshots-and-replay-log.md)
|
||||
- [ADR-0007 — Sharing and export](0007-sharing-and-export.md)
|
||||
- [ADR-0008 — Testing approach](0008-testing-approach.md)
|
||||
- [ADR-0009 — DSL command syntax conventions](0009-dsl-command-syntax-conventions.md)
|
||||
- [ADR-0010 — Database access via a dedicated worker thread](0010-database-access-via-worker-thread.md)
|
||||
- [ADR-0011 — Foreign-key column type compatibility](0011-fk-column-type-compatibility.md)
|
||||
- [ADR-0012 — Internal metadata for user-facing column types](0012-internal-metadata-for-user-facing-types.md)
|
||||
|
||||
+45
-6
@@ -48,6 +48,8 @@ against it.
|
||||
- [ ] **S2** Items list shows tables and per-table indexes;
|
||||
designed to extend to additional element kinds (relations,
|
||||
views, etc.) without restructuring.
|
||||
*(Progress: tables are listed live from the database; indexes
|
||||
pending alongside C3 index support.)*
|
||||
- [ ] **S3** Output panel renders a visualization of the
|
||||
currently selected item and supports multiple tabs.
|
||||
- [ ] **S4** Hint area below the input field; keyboard-toggleable
|
||||
@@ -61,6 +63,10 @@ against it.
|
||||
equivalent) submits, plain Enter inserts a newline.
|
||||
- [ ] **I2** Persistent navigable input history (project-scoped,
|
||||
with a global rolling history also available).
|
||||
*(Progress: in-memory navigable history (Up/Down arrows, draft
|
||||
preservation, dedup of consecutive duplicates) is implemented;
|
||||
persistence across sessions arrives with track 2's project
|
||||
storage.)*
|
||||
- [ ] **I3** Tab completion for app commands, DSL keywords, table
|
||||
names, column names, and SQL keywords.
|
||||
- [ ] **I4** Syntax highlighting for both the DSL and SQL.
|
||||
@@ -85,16 +91,24 @@ against it.
|
||||
available in both modes: `save`, `save as`, `load`, `new`,
|
||||
`export`, `import`, `seed`, `replay`, `undo`, `redo`, `mode`,
|
||||
`help`, `hint`, `quit`.
|
||||
*(Progress: `quit`/`q` and `mode simple|advanced` implemented;
|
||||
the rest land alongside the features they belong to — `save`
|
||||
and friends in track 2, `seed` in the seeding iteration, etc.)*
|
||||
|
||||
## DSL data commands
|
||||
|
||||
- [ ] **C1** Table operations: create / drop / rename.
|
||||
*(Progress: create + drop done; rename pending.)*
|
||||
- [ ] **C2** Column operations: add / drop / rename / change
|
||||
type, including the rebuild-table dance behind the scenes
|
||||
where SQLite ALTER cannot do it directly.
|
||||
*(Progress: add done; drop/rename/change-type pending — the
|
||||
rebuild-table dance is the gating piece, B2.)*
|
||||
- [ ] **C3** Schema constraints: primary key (single and
|
||||
compound), foreign key with `ON DELETE` / `ON UPDATE` referential
|
||||
actions, indexes, `NOT NULL`, `UNIQUE`, `CHECK`, `DEFAULT`.
|
||||
*(Progress: PK including compound done at create-table time;
|
||||
FK/index/NOT NULL/UNIQUE/CHECK/DEFAULT pending.)*
|
||||
- [ ] **C4** Convenience: `create m:n relationship from <T1> to
|
||||
<T2>` produces an auto-named junction table the user can rename;
|
||||
pulls primary keys and FK definitions automatically.
|
||||
@@ -104,31 +118,43 @@ against it.
|
||||
|
||||
- [ ] **Q1** SQL parsed via `sqlparser-rs`; supported subset is
|
||||
defined (specifics deferred to a future ADR).
|
||||
*(Progress: DSL is parsed via `chumsky` (ADR-0009); SQL
|
||||
handling in advanced mode is still a placeholder echo.)*
|
||||
- [ ] **Q2** Non-standard syntax rejected with a clear message
|
||||
pointing at the supported subset.
|
||||
- [ ] **Q3** User-facing simplified types map transparently to
|
||||
SQLite STRICT types in generated DDL.
|
||||
- [x] **Q3** User-facing simplified types map transparently to
|
||||
SQLite STRICT types in generated DDL. *(All ten types implemented
|
||||
and tested.)*
|
||||
- [~] **Q4** Supported SQL subset specification — design and ADR
|
||||
pending. Q1 cannot be marked satisfied without it.
|
||||
|
||||
## Database backend (per ADR-0002)
|
||||
|
||||
- [ ] **B1** SQLite via `rusqlite`; all tables created `STRICT`;
|
||||
`PRAGMA foreign_keys = ON` per connection.
|
||||
- [x] **B1** SQLite via `rusqlite`; all tables created `STRICT`;
|
||||
`PRAGMA foreign_keys = ON` per connection. *(Database accessed
|
||||
through a dedicated worker thread per ADR-0010.)*
|
||||
- [ ] **B2** Schema evolution uses the rebuild-table technique
|
||||
internally where SQLite `ALTER TABLE` cannot.
|
||||
- [ ] **B3** Query timeout and cancellation supported (no
|
||||
cartesian-join-of-doom can hang the app).
|
||||
*(Progress: the worker-thread architecture is in place; the
|
||||
cancellation/timeout protocol on top of it is pending.)*
|
||||
|
||||
## Type system (per ADR-0005)
|
||||
|
||||
- [ ] **T1** All ten user-facing types implemented: `text`,
|
||||
- [x] **T1** All ten user-facing types implemented: `text`,
|
||||
`int`, `real`, `decimal`, `bool`, `date`, `datetime`, `blob`,
|
||||
`serial`, `shortid`.
|
||||
`serial`, `shortid`. *(Mapping to SQLite STRICT covered by
|
||||
ADR-0005; FK target type rule by ADR-0011.)*
|
||||
- [ ] **T2** `shortid` generation: base58, 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)
|
||||
|
||||
|
||||
+13
-5
@@ -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),
|
||||
}
|
||||
|
||||
+485
-67
@@ -1,18 +1,19 @@
|
||||
//! Application state and the single `update` entry point.
|
||||
//!
|
||||
//! The walking skeleton recognises a small subset of the
|
||||
//! canonical app-level commands from ADR-0003 — `quit` and
|
||||
//! `mode` — plus the `:` one-shot escape from simple to advanced
|
||||
//! per ADR-0003. Everything else is echoed to the output panel
|
||||
//! tagged with the mode it was submitted under, so that mode
|
||||
//! handling is visible end-to-end.
|
||||
//! `update` is pure with respect to the runtime: it mutates
|
||||
//! state in place and returns a list of `Action`s. Side effects
|
||||
//! (DB execution, quit, etc.) live in the runtime. This keeps
|
||||
//! every behaviour drivable from synthetic events in tests,
|
||||
//! which is what makes ADR-0008's Tier 1/3 testing tractable.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
|
||||
use tracing::trace;
|
||||
use tracing::{trace, warn};
|
||||
|
||||
use crate::action::Action;
|
||||
use crate::db::TableDescription;
|
||||
use crate::dsl::{Command, ParseError, parse_command};
|
||||
use crate::event::AppEvent;
|
||||
use crate::mode::Mode;
|
||||
|
||||
@@ -26,13 +27,20 @@ pub enum OutputKind {
|
||||
Error,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct OutputLine {
|
||||
pub text: String,
|
||||
pub kind: OutputKind,
|
||||
pub mode_at_submission: Mode,
|
||||
}
|
||||
|
||||
/// What mode the next submission would be evaluated in.
|
||||
///
|
||||
/// Derived from the persistent mode and the current input buffer.
|
||||
/// The UI uses this to give immediate visual feedback for the `:`
|
||||
/// one-shot escape: the moment a leading `:` is typed in simple
|
||||
/// mode, the prompt flips to advanced styling, and reverts as
|
||||
/// soon as the `:` is deleted.
|
||||
/// Derived from the persistent mode and the current input
|
||||
/// buffer. The UI uses this to give immediate visual feedback
|
||||
/// for the `:` one-shot escape: the moment a leading `:` is
|
||||
/// typed in simple mode, the prompt flips to advanced styling,
|
||||
/// and reverts as soon as the `:` is deleted.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum EffectiveMode {
|
||||
Simple,
|
||||
@@ -47,21 +55,32 @@ impl EffectiveMode {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct OutputLine {
|
||||
pub text: String,
|
||||
pub kind: OutputKind,
|
||||
pub mode_at_submission: Mode,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct App {
|
||||
pub mode: Mode,
|
||||
pub input: String,
|
||||
pub output: VecDeque<OutputLine>,
|
||||
pub hint: Option<String>,
|
||||
pub tables: Vec<String>,
|
||||
/// Last successfully described table, shown in the output
|
||||
/// pane until the next DDL operation.
|
||||
pub current_table: Option<TableDescription>,
|
||||
/// In-memory history of submitted lines, oldest first.
|
||||
/// Persistent history across sessions (I2 second half) lands
|
||||
/// when track 2's project storage is in place.
|
||||
pub history: Vec<String>,
|
||||
/// Position within `history` while navigating with Up/Down.
|
||||
/// `None` means "not navigating; `input` is the user's
|
||||
/// in-progress draft."
|
||||
history_cursor: Option<usize>,
|
||||
/// Snapshot of the user's in-progress draft taken when they
|
||||
/// start navigating history, restored if they navigate back
|
||||
/// past the most recent entry.
|
||||
history_draft: Option<String>,
|
||||
}
|
||||
|
||||
const HISTORY_CAPACITY: usize = 1000;
|
||||
|
||||
impl Default for App {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
@@ -76,6 +95,11 @@ impl App {
|
||||
input: String::new(),
|
||||
output: VecDeque::with_capacity(OUTPUT_CAPACITY),
|
||||
hint: None,
|
||||
tables: Vec::new(),
|
||||
current_table: None,
|
||||
history: Vec::new(),
|
||||
history_cursor: None,
|
||||
history_draft: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,13 +123,29 @@ impl App {
|
||||
match event {
|
||||
AppEvent::Key(key) => self.handle_key(key),
|
||||
AppEvent::Resize { .. } | AppEvent::Tick => Vec::new(),
|
||||
AppEvent::DslSucceeded {
|
||||
command,
|
||||
description,
|
||||
} => {
|
||||
self.handle_dsl_success(&command, description);
|
||||
Vec::new()
|
||||
}
|
||||
AppEvent::DslFailed { command, error } => {
|
||||
self.handle_dsl_failure(&command, &error);
|
||||
Vec::new()
|
||||
}
|
||||
AppEvent::TablesRefreshed(tables) => {
|
||||
trace!(count = tables.len(), "tables refreshed");
|
||||
self.tables = tables;
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_key(&mut self, key: KeyEvent) -> Vec<Action> {
|
||||
// On Windows, key events fire for both Press and Release; only
|
||||
// honour Press to avoid double-handling. Other platforms only
|
||||
// emit Press, so this is a no-op there.
|
||||
// On Windows, key events fire for both Press and Release;
|
||||
// honour only Press to avoid double-handling. Other
|
||||
// platforms emit Press only, so this is a no-op there.
|
||||
if key.kind != KeyEventKind::Press {
|
||||
return Vec::new();
|
||||
}
|
||||
@@ -113,11 +153,21 @@ impl App {
|
||||
match (key.code, key.modifiers) {
|
||||
(KeyCode::Char('c'), KeyModifiers::CONTROL) => vec![Action::Quit],
|
||||
(KeyCode::Enter, _) => self.submit(),
|
||||
(KeyCode::Up, _) => {
|
||||
self.history_back();
|
||||
Vec::new()
|
||||
}
|
||||
(KeyCode::Down, _) => {
|
||||
self.history_forward();
|
||||
Vec::new()
|
||||
}
|
||||
(KeyCode::Backspace, _) => {
|
||||
self.cancel_history_navigation();
|
||||
self.input.pop();
|
||||
Vec::new()
|
||||
}
|
||||
(KeyCode::Char(c), m) if !m.contains(KeyModifiers::CONTROL) => {
|
||||
self.cancel_history_navigation();
|
||||
let was_empty = self.input.is_empty();
|
||||
self.input.push(c);
|
||||
// Convenience: when `:` becomes the leading character in
|
||||
@@ -133,12 +183,76 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
/// Move backwards in history (towards older entries).
|
||||
fn history_back(&mut self) {
|
||||
if self.history.is_empty() {
|
||||
return;
|
||||
}
|
||||
let next_index = match self.history_cursor {
|
||||
None => {
|
||||
// Starting navigation: save the current draft so the
|
||||
// user can return to it.
|
||||
self.history_draft = Some(self.input.clone());
|
||||
self.history.len() - 1
|
||||
}
|
||||
Some(0) => 0,
|
||||
Some(i) => i - 1,
|
||||
};
|
||||
self.history_cursor = Some(next_index);
|
||||
self.input = self.history[next_index].clone();
|
||||
}
|
||||
|
||||
/// Move forwards in history (towards newer entries; eventually
|
||||
/// returning to the user's saved draft).
|
||||
fn history_forward(&mut self) {
|
||||
let Some(i) = self.history_cursor else {
|
||||
return;
|
||||
};
|
||||
if i + 1 < self.history.len() {
|
||||
self.history_cursor = Some(i + 1);
|
||||
self.input = self.history[i + 1].clone();
|
||||
} else {
|
||||
// Past the most recent entry — restore the draft and
|
||||
// exit navigation mode.
|
||||
self.history_cursor = None;
|
||||
self.input = self.history_draft.take().unwrap_or_default();
|
||||
}
|
||||
}
|
||||
|
||||
fn cancel_history_navigation(&mut self) {
|
||||
self.history_cursor = None;
|
||||
// Drop the saved draft: the user has begun editing again,
|
||||
// so what's in `input` *is* the new draft.
|
||||
self.history_draft = None;
|
||||
}
|
||||
|
||||
fn push_history(&mut self, line: &str) {
|
||||
// Skip empties and consecutive duplicates — the same
|
||||
// trick most shells use to keep navigation pleasant.
|
||||
if line.is_empty() {
|
||||
return;
|
||||
}
|
||||
if self.history.last().map(String::as_str) == Some(line) {
|
||||
return;
|
||||
}
|
||||
self.history.push(line.to_string());
|
||||
while self.history.len() > HISTORY_CAPACITY {
|
||||
self.history.remove(0);
|
||||
}
|
||||
self.history_cursor = None;
|
||||
self.history_draft = None;
|
||||
}
|
||||
|
||||
fn submit(&mut self) -> Vec<Action> {
|
||||
let raw = std::mem::take(&mut self.input);
|
||||
let trimmed = raw.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
// Record the original (trimmed) line in history regardless
|
||||
// of whether it parses, so users can recall and edit
|
||||
// typo'd commands.
|
||||
self.push_history(trimmed);
|
||||
|
||||
// `:` one-shot escape: in simple mode, a leading `:` means
|
||||
// treat *this single submission* as advanced. The persistent
|
||||
@@ -155,7 +269,7 @@ impl App {
|
||||
}
|
||||
|
||||
// Canonical app-level commands recognised in both modes.
|
||||
// The walking skeleton implements only `quit` and `mode`;
|
||||
// The current iteration implements `quit` and `mode`;
|
||||
// the rest of the canonical list lands in later iterations.
|
||||
match effective_input.as_str() {
|
||||
"quit" | "q" => return vec![Action::Quit],
|
||||
@@ -166,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<Action> {
|
||||
match parse_command(input) {
|
||||
Ok(cmd) => {
|
||||
self.push_output(OutputLine {
|
||||
text: format!("running: {input}"),
|
||||
kind: OutputKind::Echo,
|
||||
mode_at_submission: submission_mode,
|
||||
});
|
||||
vec![Action::ExecuteDsl(cmd)]
|
||||
}
|
||||
Err(ParseError::Empty) => Vec::new(),
|
||||
Err(err) => {
|
||||
self.note_error(format!("parse error: {}", parse_error_message(&err)));
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_dsl_success(&mut self, command: &Command, description: Option<TableDescription>) {
|
||||
let summary = format!("[ok] {} {}", command.verb(), command.target_table());
|
||||
self.note_system(summary);
|
||||
if let Some(desc) = description.as_ref() {
|
||||
self.note_system(format!(" {}", desc.name));
|
||||
for col in &desc.columns {
|
||||
let pk = if col.primary_key { " [PK]" } else { "" };
|
||||
let nn = if col.notnull { " NOT NULL" } else { "" };
|
||||
// Prefer the user-facing type recovered from our
|
||||
// metadata table; fall back to the SQLite type only
|
||||
// if metadata is missing (only happens for tables we
|
||||
// didn't create — not in the current flow).
|
||||
let type_display = col
|
||||
.user_type
|
||||
.map_or_else(|| col.sqlite_type.to_lowercase(), |t| t.keyword().to_string());
|
||||
self.note_system(format!(
|
||||
" {} {}{}{}",
|
||||
col.name, type_display, pk, nn
|
||||
));
|
||||
}
|
||||
}
|
||||
self.current_table = description;
|
||||
}
|
||||
|
||||
fn handle_dsl_failure(&mut self, command: &Command, error: &str) {
|
||||
warn!(verb = command.verb(), error, "dsl command failed");
|
||||
self.note_error(format!(
|
||||
"{} {} failed: {error}",
|
||||
command.verb(),
|
||||
command.target_table()
|
||||
));
|
||||
}
|
||||
|
||||
fn handle_mode_command(&mut self, raw: &str) {
|
||||
@@ -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,
|
||||
})]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,814 @@
|
||||
//! SQLite database access via an async worker.
|
||||
//!
|
||||
//! The application talks to SQLite through a single
|
||||
//! request/response channel. A dedicated OS thread owns the
|
||||
//! `rusqlite::Connection` (which is `Send` but `!Sync` and uses
|
||||
//! a synchronous API), receives `Request` messages, and replies
|
||||
//! on per-request `oneshot` channels.
|
||||
//!
|
||||
//! This shape was chosen up front in Phase 2 of the parser/DB
|
||||
//! iteration so that B3 (query timeout/cancellation) and U1
|
||||
//! (snapshot capture) drop in without an architectural refactor.
|
||||
//!
|
||||
//! ## STRICT and foreign keys
|
||||
//!
|
||||
//! Per ADR-0002, every table is created with the `STRICT`
|
||||
//! keyword and the connection-level `PRAGMA foreign_keys` is
|
||||
//! enabled at open time.
|
||||
//!
|
||||
//! ## Error handling
|
||||
//!
|
||||
//! Database errors flow through `DbError`, which carries a
|
||||
//! coarse `kind` to support the future friendly-error layer
|
||||
//! (H1). For now `friendly_message()` is a passthrough; when H1
|
||||
//! lands the body of that method becomes the translation table.
|
||||
|
||||
use std::fmt::Write as _;
|
||||
use std::path::Path;
|
||||
use std::thread;
|
||||
|
||||
use rusqlite::Connection;
|
||||
use tokio::sync::{mpsc, oneshot};
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
use crate::dsl::ColumnSpec;
|
||||
use crate::dsl::types::Type;
|
||||
|
||||
/// Inbox capacity. The worker is fast enough that this rarely
|
||||
/// matters; `64` is a generous head-room for bursts.
|
||||
const REQUEST_CHANNEL_CAPACITY: usize = 64;
|
||||
|
||||
/// In-process handle for the database. Cheap to clone.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Database {
|
||||
inbox: mpsc::Sender<Request>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct TableDescription {
|
||||
pub name: String,
|
||||
pub columns: Vec<ColumnDescription>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ColumnDescription {
|
||||
pub name: String,
|
||||
/// The user-facing type the column was declared as, recovered
|
||||
/// from our internal column-metadata table. Always populated
|
||||
/// for tables created through the DSL. `None` only for the
|
||||
/// edge case of a foreign-attached database whose tables we
|
||||
/// did not create — not achievable in the current flow.
|
||||
pub user_type: Option<Type>,
|
||||
/// The SQLite-side type as reported by `PRAGMA table_info`
|
||||
/// (e.g. `INTEGER`, `TEXT`). Kept for diagnostics and as a
|
||||
/// fall-back when `user_type` is not available; the UI
|
||||
/// prefers `user_type` when rendering.
|
||||
pub sqlite_type: String,
|
||||
pub notnull: bool,
|
||||
pub primary_key: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
||||
pub enum DbError {
|
||||
#[error("database error: {message}")]
|
||||
Sqlite { message: String, kind: SqliteErrorKind },
|
||||
#[error("operation not supported: {0}")]
|
||||
Unsupported(String),
|
||||
#[error("database worker is no longer available")]
|
||||
WorkerGone,
|
||||
#[error("io error: {0}")]
|
||||
Io(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SqliteErrorKind {
|
||||
/// `UNIQUE` constraint, including duplicate primary key.
|
||||
UniqueViolation,
|
||||
/// Referenced or operated-on table does not exist.
|
||||
NoSuchTable,
|
||||
/// Operated-on column does not exist.
|
||||
NoSuchColumn,
|
||||
/// Object (table, index, etc.) already exists.
|
||||
AlreadyExists,
|
||||
/// Catch-all.
|
||||
Other,
|
||||
}
|
||||
|
||||
impl DbError {
|
||||
/// Placeholder for the H1 friendly-error layer. Today this
|
||||
/// returns the same string as [`std::fmt::Display`]; when H1
|
||||
/// lands the body becomes the translation logic and
|
||||
/// callsites do not need to change.
|
||||
#[must_use]
|
||||
pub fn friendly_message(&self) -> String {
|
||||
self.to_string()
|
||||
}
|
||||
|
||||
fn from_rusqlite(err: rusqlite::Error) -> Self {
|
||||
let message = err.to_string();
|
||||
let kind = classify_sqlite_error(&err, &message);
|
||||
Self::Sqlite { message, kind }
|
||||
}
|
||||
}
|
||||
|
||||
fn classify_sqlite_error(err: &rusqlite::Error, message: &str) -> SqliteErrorKind {
|
||||
use rusqlite::ErrorCode;
|
||||
if let rusqlite::Error::SqliteFailure(code, _) = err
|
||||
&& code.code == ErrorCode::ConstraintViolation
|
||||
{
|
||||
return SqliteErrorKind::UniqueViolation;
|
||||
}
|
||||
let lowered = message.to_ascii_lowercase();
|
||||
if lowered.contains("no such table") {
|
||||
SqliteErrorKind::NoSuchTable
|
||||
} else if lowered.contains("no such column") {
|
||||
SqliteErrorKind::NoSuchColumn
|
||||
} else if lowered.contains("already exists") {
|
||||
SqliteErrorKind::AlreadyExists
|
||||
} else {
|
||||
SqliteErrorKind::Other
|
||||
}
|
||||
}
|
||||
|
||||
/// Internal request type — kept private so the channel protocol
|
||||
/// is not part of the public API.
|
||||
#[derive(Debug)]
|
||||
enum Request {
|
||||
CreateTable {
|
||||
name: String,
|
||||
columns: Vec<ColumnSpec>,
|
||||
primary_key: Vec<String>,
|
||||
reply: oneshot::Sender<Result<TableDescription, DbError>>,
|
||||
},
|
||||
DropTable {
|
||||
name: String,
|
||||
reply: oneshot::Sender<Result<(), DbError>>,
|
||||
},
|
||||
AddColumn {
|
||||
table: String,
|
||||
column: String,
|
||||
ty: Type,
|
||||
reply: oneshot::Sender<Result<TableDescription, DbError>>,
|
||||
},
|
||||
ListTables {
|
||||
reply: oneshot::Sender<Result<Vec<String>, DbError>>,
|
||||
},
|
||||
DescribeTable {
|
||||
name: String,
|
||||
reply: oneshot::Sender<Result<TableDescription, DbError>>,
|
||||
},
|
||||
}
|
||||
|
||||
impl Database {
|
||||
/// Open a database. The path may be a filesystem location
|
||||
/// or `":memory:"` for an ephemeral in-memory database. The
|
||||
/// connection is moved onto a dedicated worker thread.
|
||||
pub fn open<P: AsRef<Path> + Into<String>>(path: P) -> Result<Self, DbError> {
|
||||
let path_display = path.as_ref().to_string_lossy().into_owned();
|
||||
let conn = match path.as_ref().to_str() {
|
||||
Some(":memory:") => Connection::open_in_memory(),
|
||||
_ => Connection::open(path.as_ref()),
|
||||
}
|
||||
.map_err(DbError::from_rusqlite)?;
|
||||
info!(path = %path_display, "opened database");
|
||||
configure_connection(&conn).map_err(DbError::from_rusqlite)?;
|
||||
|
||||
let (tx, rx) = mpsc::channel::<Request>(REQUEST_CHANNEL_CAPACITY);
|
||||
thread::Builder::new()
|
||||
.name("rdbms-db-worker".to_string())
|
||||
.spawn(move || worker_loop(conn, rx))
|
||||
.map_err(|e| DbError::Io(e.to_string()))?;
|
||||
|
||||
Ok(Self { inbox: tx })
|
||||
}
|
||||
|
||||
pub async fn create_table(
|
||||
&self,
|
||||
name: String,
|
||||
columns: Vec<ColumnSpec>,
|
||||
primary_key: Vec<String>,
|
||||
) -> Result<TableDescription, DbError> {
|
||||
let (reply, recv) = oneshot::channel();
|
||||
self.send(Request::CreateTable {
|
||||
name,
|
||||
columns,
|
||||
primary_key,
|
||||
reply,
|
||||
})
|
||||
.await?;
|
||||
recv.await.map_err(|_| DbError::WorkerGone)?
|
||||
}
|
||||
|
||||
pub async fn drop_table(&self, name: String) -> Result<(), DbError> {
|
||||
let (reply, recv) = oneshot::channel();
|
||||
self.send(Request::DropTable { name, reply }).await?;
|
||||
recv.await.map_err(|_| DbError::WorkerGone)?
|
||||
}
|
||||
|
||||
pub async fn add_column(
|
||||
&self,
|
||||
table: String,
|
||||
column: String,
|
||||
ty: Type,
|
||||
) -> Result<TableDescription, DbError> {
|
||||
let (reply, recv) = oneshot::channel();
|
||||
self.send(Request::AddColumn {
|
||||
table,
|
||||
column,
|
||||
ty,
|
||||
reply,
|
||||
})
|
||||
.await?;
|
||||
recv.await.map_err(|_| DbError::WorkerGone)?
|
||||
}
|
||||
|
||||
pub async fn list_tables(&self) -> Result<Vec<String>, DbError> {
|
||||
let (reply, recv) = oneshot::channel();
|
||||
self.send(Request::ListTables { reply }).await?;
|
||||
recv.await.map_err(|_| DbError::WorkerGone)?
|
||||
}
|
||||
|
||||
pub async fn describe_table(&self, name: String) -> Result<TableDescription, DbError> {
|
||||
let (reply, recv) = oneshot::channel();
|
||||
self.send(Request::DescribeTable { name, reply }).await?;
|
||||
recv.await.map_err(|_| DbError::WorkerGone)?
|
||||
}
|
||||
|
||||
async fn send(&self, req: Request) -> Result<(), DbError> {
|
||||
self.inbox.send(req).await.map_err(|_| DbError::WorkerGone)
|
||||
}
|
||||
}
|
||||
|
||||
/// Internal table tracking the user-facing type each column was
|
||||
/// declared with. The structure view consults this so users see
|
||||
/// the names they typed (`serial`, `date`) rather than SQLite's
|
||||
/// erased forms (`INTEGER`, `TEXT`) — closing a Q3 / ADR-0005
|
||||
/// promise. Track 2's project file format is the long-term home
|
||||
/// for this metadata; this table is the in-database mirror that
|
||||
/// makes round-trip rendering work today.
|
||||
const META_TABLE: &str = "__rdbms_playground_columns";
|
||||
|
||||
fn configure_connection(conn: &Connection) -> Result<(), rusqlite::Error> {
|
||||
conn.execute_batch(&format!(
|
||||
"PRAGMA foreign_keys = ON;\n\
|
||||
CREATE TABLE IF NOT EXISTS {META_TABLE} (\n\
|
||||
table_name TEXT NOT NULL,\n\
|
||||
column_name TEXT NOT NULL,\n\
|
||||
user_type TEXT NOT NULL,\n\
|
||||
PRIMARY KEY (table_name, column_name)\n\
|
||||
) STRICT;"
|
||||
))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn worker_loop(conn: Connection, mut rx: mpsc::Receiver<Request>) {
|
||||
debug!("db worker started");
|
||||
while let Some(req) = rx.blocking_recv() {
|
||||
handle_request(&conn, req);
|
||||
}
|
||||
debug!("db worker exiting");
|
||||
}
|
||||
|
||||
fn handle_request(conn: &Connection, req: Request) {
|
||||
match req {
|
||||
Request::CreateTable {
|
||||
name,
|
||||
columns,
|
||||
primary_key,
|
||||
reply,
|
||||
} => {
|
||||
let _ = reply.send(do_create_table(conn, &name, &columns, &primary_key));
|
||||
}
|
||||
Request::DropTable { name, reply } => {
|
||||
let _ = reply.send(do_drop_table(conn, &name));
|
||||
}
|
||||
Request::AddColumn {
|
||||
table,
|
||||
column,
|
||||
ty,
|
||||
reply,
|
||||
} => {
|
||||
let _ = reply.send(do_add_column(conn, &table, &column, ty));
|
||||
}
|
||||
Request::ListTables { reply } => {
|
||||
let _ = reply.send(do_list_tables(conn));
|
||||
}
|
||||
Request::DescribeTable { name, reply } => {
|
||||
let _ = reply.send(do_describe_table(conn, &name));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Quote an identifier for safe inclusion in DDL. Doubles any
|
||||
/// embedded double-quotes per SQL convention.
|
||||
fn quote_ident(name: &str) -> String {
|
||||
let mut out = String::with_capacity(name.len() + 2);
|
||||
out.push('"');
|
||||
for c in name.chars() {
|
||||
if c == '"' {
|
||||
out.push_str("\"\"");
|
||||
} else {
|
||||
out.push(c);
|
||||
}
|
||||
}
|
||||
out.push('"');
|
||||
out
|
||||
}
|
||||
|
||||
fn do_create_table(
|
||||
conn: &Connection,
|
||||
name: &str,
|
||||
columns: &[ColumnSpec],
|
||||
primary_key: &[String],
|
||||
) -> Result<TableDescription, DbError> {
|
||||
if columns.is_empty() {
|
||||
// SQLite requires at least one column. The DSL grammar
|
||||
// already prevents this, but defending here too keeps
|
||||
// the executor honest if anyone synthesises a Command
|
||||
// directly (tests, future scripting).
|
||||
return Err(DbError::Unsupported(
|
||||
"tables need at least one column".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// Generate the column list. For a single-column PK we inline
|
||||
// `PRIMARY KEY` on the column itself, which is required for
|
||||
// SQLite STRICT tables to give an `INTEGER PRIMARY KEY`
|
||||
// column its rowid-alias semantics. For compound PKs (or
|
||||
// when the single PK is on a non-first column) we emit a
|
||||
// table-level constraint.
|
||||
let single_inline_pk = primary_key.len() == 1 && columns.len() == 1
|
||||
&& primary_key[0] == columns[0].name;
|
||||
|
||||
let mut column_clauses: Vec<String> = Vec::with_capacity(columns.len());
|
||||
for col in columns {
|
||||
let mut clause = format!(
|
||||
"{ident} {sqlite_type}",
|
||||
ident = quote_ident(&col.name),
|
||||
sqlite_type = col.ty.sqlite_strict_type(),
|
||||
);
|
||||
if single_inline_pk {
|
||||
clause.push_str(" PRIMARY KEY");
|
||||
}
|
||||
column_clauses.push(clause);
|
||||
}
|
||||
|
||||
let mut ddl = format!(
|
||||
"CREATE TABLE {ident} ({columns}",
|
||||
ident = quote_ident(name),
|
||||
columns = column_clauses.join(", "),
|
||||
);
|
||||
|
||||
if !single_inline_pk && !primary_key.is_empty() {
|
||||
let pk_idents: Vec<String> = primary_key.iter().map(|n| quote_ident(n)).collect();
|
||||
ddl.push_str(", PRIMARY KEY (");
|
||||
ddl.push_str(&pk_idents.join(", "));
|
||||
ddl.push(')');
|
||||
}
|
||||
|
||||
ddl.push_str(") STRICT;");
|
||||
debug!(ddl = %ddl, "create_table");
|
||||
|
||||
// Wrap the table-creation DDL and the metadata inserts in a
|
||||
// single transaction so they commit atomically — if either
|
||||
// step fails, neither side persists.
|
||||
let tx = conn
|
||||
.unchecked_transaction()
|
||||
.map_err(DbError::from_rusqlite)?;
|
||||
tx.execute_batch(&ddl).map_err(DbError::from_rusqlite)?;
|
||||
{
|
||||
let mut stmt = tx
|
||||
.prepare(&format!(
|
||||
"INSERT INTO {META_TABLE} (table_name, column_name, user_type) \
|
||||
VALUES (?1, ?2, ?3);"
|
||||
))
|
||||
.map_err(DbError::from_rusqlite)?;
|
||||
for col in columns {
|
||||
stmt.execute([name, col.name.as_str(), col.ty.keyword()])
|
||||
.map_err(DbError::from_rusqlite)?;
|
||||
}
|
||||
}
|
||||
tx.commit().map_err(DbError::from_rusqlite)?;
|
||||
do_describe_table(conn, name)
|
||||
}
|
||||
|
||||
fn do_drop_table(conn: &Connection, name: &str) -> Result<(), DbError> {
|
||||
let ddl = format!("DROP TABLE {ident};", ident = quote_ident(name));
|
||||
debug!(ddl = %ddl, "drop_table");
|
||||
let tx = conn
|
||||
.unchecked_transaction()
|
||||
.map_err(DbError::from_rusqlite)?;
|
||||
tx.execute_batch(&ddl).map_err(DbError::from_rusqlite)?;
|
||||
tx.execute(
|
||||
&format!("DELETE FROM {META_TABLE} WHERE table_name = ?1;"),
|
||||
[name],
|
||||
)
|
||||
.map_err(DbError::from_rusqlite)?;
|
||||
tx.commit().map_err(DbError::from_rusqlite)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn do_add_column(
|
||||
conn: &Connection,
|
||||
table: &str,
|
||||
column: &str,
|
||||
ty: Type,
|
||||
) -> Result<TableDescription, DbError> {
|
||||
if ty == Type::Serial {
|
||||
return Err(DbError::Unsupported(
|
||||
"the 'serial' type carries auto-increment primary-key semantics \
|
||||
that SQLite's ALTER TABLE ADD COLUMN cannot apply. Specify \
|
||||
`serial` at create-table time via `with pk` instead."
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
let mut ddl = String::new();
|
||||
write!(
|
||||
ddl,
|
||||
"ALTER TABLE {tbl} ADD COLUMN {col} {sqlite_type}{extra};",
|
||||
tbl = quote_ident(table),
|
||||
col = quote_ident(column),
|
||||
sqlite_type = ty.sqlite_strict_type(),
|
||||
extra = ty.sqlite_strict_extra(),
|
||||
)
|
||||
.expect("write to String never fails");
|
||||
debug!(ddl = %ddl, "add_column");
|
||||
let tx = conn
|
||||
.unchecked_transaction()
|
||||
.map_err(DbError::from_rusqlite)?;
|
||||
tx.execute_batch(&ddl).map_err(DbError::from_rusqlite)?;
|
||||
tx.execute(
|
||||
&format!(
|
||||
"INSERT INTO {META_TABLE} (table_name, column_name, user_type) \
|
||||
VALUES (?1, ?2, ?3);"
|
||||
),
|
||||
[table, column, ty.keyword()],
|
||||
)
|
||||
.map_err(DbError::from_rusqlite)?;
|
||||
tx.commit().map_err(DbError::from_rusqlite)?;
|
||||
do_describe_table(conn, table)
|
||||
}
|
||||
|
||||
fn do_list_tables(conn: &Connection) -> Result<Vec<String>, DbError> {
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT name FROM sqlite_schema \
|
||||
WHERE type = 'table' \
|
||||
AND name NOT LIKE 'sqlite_%' \
|
||||
AND name NOT LIKE '__rdbms_%' \
|
||||
ORDER BY name;",
|
||||
)
|
||||
.map_err(DbError::from_rusqlite)?;
|
||||
let rows = stmt
|
||||
.query_map([], |row| row.get::<_, String>(0))
|
||||
.map_err(DbError::from_rusqlite)?;
|
||||
let mut out = Vec::new();
|
||||
for row in rows {
|
||||
out.push(row.map_err(DbError::from_rusqlite)?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn do_describe_table(conn: &Connection, name: &str) -> Result<TableDescription, DbError> {
|
||||
// `pragma_table_info` is a table-valued function in modern
|
||||
// SQLite; using it as a SELECT lets us bind the table name
|
||||
// via ? rather than splicing it into a PRAGMA statement.
|
||||
// We LEFT JOIN our metadata table to recover the user-facing
|
||||
// type each column was declared as.
|
||||
let mut stmt = conn
|
||||
.prepare(&format!(
|
||||
"SELECT pti.name, pti.type, pti.\"notnull\", pti.pk, m.user_type \
|
||||
FROM pragma_table_info(?1) AS pti \
|
||||
LEFT JOIN {META_TABLE} AS m \
|
||||
ON m.table_name = ?1 AND m.column_name = pti.name \
|
||||
ORDER BY pti.cid;"
|
||||
))
|
||||
.map_err(DbError::from_rusqlite)?;
|
||||
let rows = stmt
|
||||
.query_map([name], |row| {
|
||||
let user_type_kw: Option<String> = row.get(4)?;
|
||||
let user_type = user_type_kw.and_then(|kw| kw.parse::<Type>().ok());
|
||||
Ok(ColumnDescription {
|
||||
name: row.get(0)?,
|
||||
user_type,
|
||||
sqlite_type: row.get(1)?,
|
||||
notnull: row.get::<_, i64>(2)? != 0,
|
||||
primary_key: row.get::<_, i64>(3)? != 0,
|
||||
})
|
||||
})
|
||||
.map_err(DbError::from_rusqlite)?;
|
||||
let mut columns = Vec::new();
|
||||
for row in rows {
|
||||
columns.push(row.map_err(DbError::from_rusqlite)?);
|
||||
}
|
||||
if columns.is_empty() {
|
||||
// pragma_table_info returns no rows for a non-existent
|
||||
// table, which we surface as a NoSuchTable error so
|
||||
// describe_table is not silently empty.
|
||||
warn!(name, "describe_table: no columns (table missing?)");
|
||||
return Err(DbError::Sqlite {
|
||||
message: format!("no such table: {name}"),
|
||||
kind: SqliteErrorKind::NoSuchTable,
|
||||
});
|
||||
}
|
||||
Ok(TableDescription {
|
||||
name: name.to_string(),
|
||||
columns,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
fn db() -> Database {
|
||||
Database::open(":memory:").expect("open in-memory")
|
||||
}
|
||||
|
||||
fn col(name: &str, ty: Type) -> ColumnSpec {
|
||||
ColumnSpec {
|
||||
name: name.to_string(),
|
||||
ty,
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience: a `serial`-PK table with a single `id` column.
|
||||
async fn make_id_table(db: &Database, name: &str) -> TableDescription {
|
||||
db.create_table(
|
||||
name.to_string(),
|
||||
vec![col("id", Type::Serial)],
|
||||
vec!["id".to_string()],
|
||||
)
|
||||
.await
|
||||
.expect("create table")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn open_in_memory_succeeds() {
|
||||
let _ = db();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_table_with_serial_pk_appears_in_list() {
|
||||
let db = db();
|
||||
make_id_table(&db, "Customers").await;
|
||||
let tables = db.list_tables().await.unwrap();
|
||||
assert_eq!(tables, vec!["Customers".to_string()]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_table_with_serial_pk_describes_correctly() {
|
||||
let db = db();
|
||||
let desc = make_id_table(&db, "Customers").await;
|
||||
assert_eq!(desc.name, "Customers");
|
||||
assert_eq!(desc.columns.len(), 1);
|
||||
let id = &desc.columns[0];
|
||||
assert_eq!(id.name, "id");
|
||||
assert!(id.primary_key);
|
||||
assert_eq!(id.user_type, Some(Type::Serial));
|
||||
assert_eq!(id.sqlite_type.to_uppercase(), "INTEGER");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_table_with_text_pk_works() {
|
||||
let db = db();
|
||||
let desc = db
|
||||
.create_table(
|
||||
"Customers".to_string(),
|
||||
vec![col("email", Type::Text)],
|
||||
vec!["email".to_string()],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(desc.columns.len(), 1);
|
||||
assert_eq!(desc.columns[0].name, "email");
|
||||
assert_eq!(desc.columns[0].user_type, Some(Type::Text));
|
||||
assert_eq!(desc.columns[0].sqlite_type.to_uppercase(), "TEXT");
|
||||
assert!(desc.columns[0].primary_key);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_table_with_compound_pk_works() {
|
||||
let db = db();
|
||||
let desc = db
|
||||
.create_table(
|
||||
"OrderLines".to_string(),
|
||||
vec![col("order_id", Type::Int), col("product_id", Type::Int)],
|
||||
vec!["order_id".to_string(), "product_id".to_string()],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(desc.columns.len(), 2);
|
||||
assert!(desc.columns.iter().all(|c| c.primary_key));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_table_with_pedagogically_unusual_pk_type_still_works() {
|
||||
// The grammar lets users try anything; the DB layer just
|
||||
// does what they ask.
|
||||
let db = db();
|
||||
let desc = db
|
||||
.create_table(
|
||||
"T".to_string(),
|
||||
vec![col("flag", Type::Bool)],
|
||||
vec!["flag".to_string()],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(desc.columns[0].primary_key);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_table_rejects_zero_columns() {
|
||||
let db = db();
|
||||
let err = db
|
||||
.create_table("T".to_string(), Vec::new(), Vec::new())
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, DbError::Unsupported(_)), "got {err:?}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn drop_table_removes_it_from_list() {
|
||||
let db = db();
|
||||
make_id_table(&db, "T").await;
|
||||
db.drop_table("T".to_string()).await.unwrap();
|
||||
let tables = db.list_tables().await.unwrap();
|
||||
assert!(tables.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn add_column_appends_to_existing_table() {
|
||||
let db = db();
|
||||
make_id_table(&db, "Customers").await;
|
||||
let desc = db
|
||||
.add_column("Customers".to_string(), "Name".to_string(), Type::Text)
|
||||
.await
|
||||
.unwrap();
|
||||
let names: Vec<_> = desc.columns.iter().map(|c| c.name.as_str()).collect();
|
||||
assert_eq!(names, vec!["id", "Name"]);
|
||||
let name_col = desc.columns.iter().find(|c| c.name == "Name").unwrap();
|
||||
assert_eq!(name_col.user_type, Some(Type::Text));
|
||||
assert_eq!(name_col.sqlite_type.to_uppercase(), "TEXT");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn user_facing_types_round_trip_through_metadata() {
|
||||
let db = db();
|
||||
// Create with a serial PK and add columns of every type
|
||||
// that would otherwise be erased by SQLite (date,
|
||||
// datetime, decimal — all backed by TEXT).
|
||||
make_id_table(&db, "T").await;
|
||||
for ty in [Type::Date, Type::DateTime, Type::Decimal, Type::ShortId] {
|
||||
db.add_column("T".to_string(), format!("c_{ty}"), ty)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
let desc = db.describe_table("T".to_string()).await.unwrap();
|
||||
let id_col = desc.columns.iter().find(|c| c.name == "id").unwrap();
|
||||
assert_eq!(id_col.user_type, Some(Type::Serial));
|
||||
for ty in [Type::Date, Type::DateTime, Type::Decimal, Type::ShortId] {
|
||||
let col_name = format!("c_{ty}");
|
||||
let c = desc.columns.iter().find(|c| c.name == col_name).unwrap();
|
||||
assert_eq!(c.user_type, Some(ty), "mismatch for {col_name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_tables_excludes_internal_metadata_table() {
|
||||
let db = db();
|
||||
make_id_table(&db, "Visible").await;
|
||||
let tables = db.list_tables().await.unwrap();
|
||||
assert_eq!(tables, vec!["Visible".to_string()]);
|
||||
// Metadata table is present in the underlying schema but
|
||||
// hidden from list_tables.
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn drop_table_clears_metadata_so_recreate_starts_fresh() {
|
||||
let db = db();
|
||||
// Create with a date column.
|
||||
db.create_table(
|
||||
"T".to_string(),
|
||||
vec![col("when", Type::Date)],
|
||||
vec!["when".to_string()],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let before = db.describe_table("T".to_string()).await.unwrap();
|
||||
assert_eq!(before.columns[0].user_type, Some(Type::Date));
|
||||
|
||||
// Drop it.
|
||||
db.drop_table("T".to_string()).await.unwrap();
|
||||
|
||||
// Recreate with a different type for the same-named column;
|
||||
// the metadata for the new table must reflect the new type
|
||||
// (i.e. metadata from the previous incarnation must not
|
||||
// bleed through).
|
||||
db.create_table(
|
||||
"T".to_string(),
|
||||
vec![col("when", Type::DateTime)],
|
||||
vec!["when".to_string()],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let after = db.describe_table("T".to_string()).await.unwrap();
|
||||
assert_eq!(after.columns[0].user_type, Some(Type::DateTime));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn add_column_for_each_value_type() {
|
||||
let db = db();
|
||||
make_id_table(&db, "T").await;
|
||||
for ty in [Type::Text, Type::Int, Type::Real, Type::Bool, Type::ShortId] {
|
||||
let col_name = format!("c_{ty}");
|
||||
db.add_column("T".to_string(), col_name.clone(), ty)
|
||||
.await
|
||||
.unwrap_or_else(|e| panic!("type {ty} failed: {e}"));
|
||||
}
|
||||
let desc = db.describe_table("T".to_string()).await.unwrap();
|
||||
// 5 user columns + the id PK column.
|
||||
assert_eq!(desc.columns.len(), 6);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn add_column_rejects_serial_with_unsupported_error() {
|
||||
let db = db();
|
||||
make_id_table(&db, "T").await;
|
||||
let err = db
|
||||
.add_column("T".to_string(), "id2".to_string(), Type::Serial)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, DbError::Unsupported(_)), "got {err:?}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_table_duplicate_returns_already_exists() {
|
||||
let db = db();
|
||||
make_id_table(&db, "T").await;
|
||||
let err = db
|
||||
.create_table(
|
||||
"T".to_string(),
|
||||
vec![col("id", Type::Serial)],
|
||||
vec!["id".to_string()],
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
match err {
|
||||
DbError::Sqlite { kind, .. } => assert_eq!(kind, SqliteErrorKind::AlreadyExists),
|
||||
other => panic!("unexpected error: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn drop_nonexistent_table_returns_no_such_table() {
|
||||
let db = db();
|
||||
let err = db.drop_table("Ghost".to_string()).await.unwrap_err();
|
||||
match err {
|
||||
DbError::Sqlite { kind, .. } => assert_eq!(kind, SqliteErrorKind::NoSuchTable),
|
||||
other => panic!("unexpected error: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn add_column_to_missing_table_returns_no_such_table() {
|
||||
let db = db();
|
||||
let err = db
|
||||
.add_column("Ghost".to_string(), "x".to_string(), Type::Text)
|
||||
.await
|
||||
.unwrap_err();
|
||||
match err {
|
||||
DbError::Sqlite { kind, .. } => assert_eq!(kind, SqliteErrorKind::NoSuchTable),
|
||||
other => panic!("unexpected error: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn describe_missing_table_returns_no_such_table() {
|
||||
let db = db();
|
||||
let err = db.describe_table("Ghost".to_string()).await.unwrap_err();
|
||||
match err {
|
||||
DbError::Sqlite { kind, .. } => assert_eq!(kind, SqliteErrorKind::NoSuchTable),
|
||||
other => panic!("unexpected error: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn quoted_table_names_round_trip() {
|
||||
let db = db();
|
||||
// Identifier with internal whitespace would not parse via the DSL
|
||||
// today, but the DB layer should still handle it correctly.
|
||||
db.create_table(
|
||||
"Order Lines".to_string(),
|
||||
vec![col("id", Type::Serial)],
|
||||
vec!["id".to_string()],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let tables = db.list_tables().await.unwrap();
|
||||
assert_eq!(tables, vec!["Order Lines".to_string()]);
|
||||
let desc = db.describe_table("Order Lines".to_string()).await.unwrap();
|
||||
assert_eq!(desc.name, "Order Lines");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
//! The Command AST.
|
||||
//!
|
||||
//! `Command` is the parser's output and the database worker's
|
||||
//! input. Each variant carries fully validated data — the parser
|
||||
//! is responsible for shape, the database worker for semantics
|
||||
//! (e.g. "table does not exist").
|
||||
//!
|
||||
//! The shape supports compound primary keys natively even though
|
||||
//! only the dedicated `with pk a:int,b:int` grammar exposes them
|
||||
//! today. Future grammar extensions (inline column specs, `set
|
||||
//! primary key`, junction-table convenience commands) emit into
|
||||
//! the same shape.
|
||||
|
||||
use crate::dsl::types::Type;
|
||||
|
||||
/// A column at table-creation time: a name and a user-facing
|
||||
/// type. Constraints beyond `PRIMARY KEY` (NOT NULL, UNIQUE,
|
||||
/// CHECK, DEFAULT) come in later iterations.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ColumnSpec {
|
||||
pub name: String,
|
||||
pub ty: Type,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Command {
|
||||
CreateTable {
|
||||
name: String,
|
||||
/// Columns to create, in declaration order.
|
||||
columns: Vec<ColumnSpec>,
|
||||
/// Names of columns forming the primary key. Length 1 is
|
||||
/// a single PK; length >= 2 is a compound PK; length 0
|
||||
/// indicates no primary key (a future grammar option,
|
||||
/// not produced by today's parser).
|
||||
primary_key: Vec<String>,
|
||||
},
|
||||
DropTable {
|
||||
name: String,
|
||||
},
|
||||
AddColumn {
|
||||
table: String,
|
||||
column: String,
|
||||
ty: Type,
|
||||
},
|
||||
}
|
||||
|
||||
impl Command {
|
||||
/// Short label for log output and result rendering.
|
||||
#[must_use]
|
||||
pub const fn verb(&self) -> &'static str {
|
||||
match self {
|
||||
Self::CreateTable { .. } => "create table",
|
||||
Self::DropTable { .. } => "drop table",
|
||||
Self::AddColumn { .. } => "add column",
|
||||
}
|
||||
}
|
||||
|
||||
/// The table this command targets — every Command in this
|
||||
/// iteration operates on exactly one table.
|
||||
#[must_use]
|
||||
pub fn target_table(&self) -> &str {
|
||||
match self {
|
||||
Self::CreateTable { name, .. } | Self::DropTable { name } => name,
|
||||
Self::AddColumn { table, .. } => table,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -0,0 +1,485 @@
|
||||
//! Grammar-based DSL parser built on chumsky.
|
||||
//!
|
||||
//! The parser produces a `Command` AST directly — there is no
|
||||
//! intermediate token tree to translate. Composable rules
|
||||
//! (identifier, type keyword, padded keyword) are defined once
|
||||
//! and reused across command variants, which is the point of
|
||||
//! choosing a grammar approach (see Phase 2/3 selection).
|
||||
//!
|
||||
//! Errors from chumsky are mapped to the local `ParseError` type
|
||||
//! so callers do not depend on chumsky's API surface — that
|
||||
//! keeps the parser swappable if we ever revisit the choice.
|
||||
|
||||
use chumsky::error::RichReason;
|
||||
use chumsky::prelude::*;
|
||||
|
||||
use crate::dsl::command::{ColumnSpec, Command};
|
||||
use crate::dsl::types::Type;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
||||
pub enum ParseError {
|
||||
#[error("could not parse command: {message}")]
|
||||
Invalid { message: String, position: usize },
|
||||
#[error("empty input")]
|
||||
Empty,
|
||||
}
|
||||
|
||||
impl ParseError {
|
||||
#[must_use]
|
||||
pub const fn position(&self) -> Option<usize> {
|
||||
match self {
|
||||
Self::Invalid { position, .. } => Some(*position),
|
||||
Self::Empty => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a single DSL command.
|
||||
pub fn parse_command(input: &str) -> Result<Command, ParseError> {
|
||||
let trimmed = input.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(ParseError::Empty);
|
||||
}
|
||||
match command_parser().parse(trimmed).into_result() {
|
||||
Ok(cmd) => Ok(cmd),
|
||||
Err(errs) => Err(into_parse_error(&errs, trimmed)),
|
||||
}
|
||||
}
|
||||
|
||||
fn into_parse_error(errs: &[Rich<'_, char>], input: &str) -> ParseError {
|
||||
// Prefer custom-reason errors over chumsky's structural
|
||||
// ones — those carry our friendly messages from `try_map`
|
||||
// (e.g. "unknown type 'varchar' (expected one of: ...)").
|
||||
let chosen = errs
|
||||
.iter()
|
||||
.find(|e| has_custom_reason(e.reason()))
|
||||
.unwrap_or_else(|| errs.first().expect("parser failure with no error"));
|
||||
let span = chosen.span();
|
||||
let position = span.start;
|
||||
let message = humanise(chosen, input);
|
||||
ParseError::Invalid { message, position }
|
||||
}
|
||||
|
||||
const fn has_custom_reason<T, C>(reason: &RichReason<'_, T, C>) -> bool {
|
||||
matches!(reason, RichReason::Custom(_))
|
||||
}
|
||||
|
||||
fn humanise(err: &Rich<'_, char>, input: &str) -> String {
|
||||
// For custom errors, the underlying message is what we want
|
||||
// to show, not chumsky's "found ... expected ..." rendering.
|
||||
if let Some(msg) = first_custom_message(err.reason()) {
|
||||
return msg;
|
||||
}
|
||||
let span = err.span();
|
||||
let snippet: String = input
|
||||
.chars()
|
||||
.skip(span.start)
|
||||
.take((span.end - span.start).max(1))
|
||||
.collect();
|
||||
if snippet.is_empty() {
|
||||
format!("{err}")
|
||||
} else {
|
||||
format!("{err} (near `{snippet}`)")
|
||||
}
|
||||
}
|
||||
|
||||
fn first_custom_message<T>(reason: &RichReason<'_, T, String>) -> Option<String> {
|
||||
match reason {
|
||||
RichReason::Custom(msg) => Some(msg.clone()),
|
||||
RichReason::ExpectedFound { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The top-level command parser.
|
||||
fn command_parser<'a>()
|
||||
-> impl Parser<'a, &'a str, Command, extra::Err<Rich<'a, char>>> + Clone {
|
||||
let create_table = keyword_ci("create")
|
||||
.ignore_then(keyword_ci("table"))
|
||||
.ignore_then(identifier())
|
||||
.then(with_pk_clause())
|
||||
.try_map(|(name, pk_specs), span| {
|
||||
if pk_specs.is_empty() {
|
||||
return Err(Rich::custom(
|
||||
span,
|
||||
"tables need at least one column. Add `with pk` for a default \
|
||||
`id INTEGER PRIMARY KEY`, or `with pk <name>:<type>` to choose. \
|
||||
Use a comma-separated list for compound primary keys."
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
let columns: Vec<ColumnSpec> = pk_specs
|
||||
.iter()
|
||||
.map(|(n, t)| ColumnSpec {
|
||||
name: n.clone(),
|
||||
ty: *t,
|
||||
})
|
||||
.collect();
|
||||
let primary_key = pk_specs.into_iter().map(|(n, _)| n).collect();
|
||||
Ok(Command::CreateTable {
|
||||
name,
|
||||
columns,
|
||||
primary_key,
|
||||
})
|
||||
});
|
||||
|
||||
let drop_table = keyword_ci("drop")
|
||||
.ignore_then(keyword_ci("table"))
|
||||
.ignore_then(identifier())
|
||||
.map(|name| Command::DropTable { name });
|
||||
|
||||
let add_column = keyword_ci("add")
|
||||
.ignore_then(keyword_ci("column"))
|
||||
.ignore_then(keyword_ci("to"))
|
||||
.ignore_then(keyword_ci("table"))
|
||||
.ignore_then(identifier())
|
||||
.then_ignore(just(':').padded())
|
||||
.then(identifier())
|
||||
.then_ignore(just('(').padded())
|
||||
.then(type_keyword())
|
||||
.then_ignore(just(')').padded())
|
||||
.map(|((table, column), ty)| Command::AddColumn { table, column, ty });
|
||||
|
||||
choice((create_table, drop_table, add_column))
|
||||
.padded()
|
||||
.then_ignore(end())
|
||||
}
|
||||
|
||||
/// Parse the optional `with pk [<spec>]` clause that may follow
|
||||
/// `create table <Name>`. Returns the list of (name, type) pairs
|
||||
/// that form the primary key. An absent clause returns an empty
|
||||
/// vector; a present `with pk` (no spec) returns the default
|
||||
/// `id:serial`. Compound PK is a comma-separated list of specs.
|
||||
fn with_pk_clause<'a>()
|
||||
-> impl Parser<'a, &'a str, Vec<(String, Type)>, extra::Err<Rich<'a, char>>> + Clone {
|
||||
let single = identifier()
|
||||
.then_ignore(just(':').padded())
|
||||
.then(type_keyword())
|
||||
.map(|(name, ty)| (name, ty));
|
||||
|
||||
let spec_list = single
|
||||
.clone()
|
||||
.separated_by(just(',').padded())
|
||||
.at_least(1)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
keyword_ci("with")
|
||||
.ignore_then(keyword_ci("pk"))
|
||||
.ignore_then(spec_list.or_not())
|
||||
.map(|maybe_specs| {
|
||||
// `with pk` alone defaults to a serial id PK.
|
||||
maybe_specs.unwrap_or_else(|| vec![("id".to_string(), Type::Serial)])
|
||||
})
|
||||
.or_not()
|
||||
.map(Option::unwrap_or_default)
|
||||
}
|
||||
|
||||
/// Identifier: a letter or underscore followed by letters,
|
||||
/// digits, or underscores. Returned as an owned `String` so the
|
||||
/// `Command` AST has no lifetime tying it to the input.
|
||||
fn identifier<'a>()
|
||||
-> impl Parser<'a, &'a str, String, extra::Err<Rich<'a, char>>> + Clone {
|
||||
any()
|
||||
.filter(|c: &char| c.is_ascii_alphabetic() || *c == '_')
|
||||
.then(
|
||||
any()
|
||||
.filter(|c: &char| c.is_ascii_alphanumeric() || *c == '_')
|
||||
.repeated()
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
.map(|(first, rest)| {
|
||||
let mut s = String::with_capacity(rest.len() + 1);
|
||||
s.push(first);
|
||||
s.extend(rest);
|
||||
s
|
||||
})
|
||||
.padded()
|
||||
}
|
||||
|
||||
/// One of the supported type keywords, mapped to `Type`. The
|
||||
/// `try_map` yields a `Custom` Rich error on unknown input,
|
||||
/// which carries the friendly "unknown type 'X' (expected one
|
||||
/// of: ...)" message — surfaced via `humanise()`. Note: no
|
||||
/// `.labelled` here, because that would replace the custom
|
||||
/// message with a generic "expected type".
|
||||
fn type_keyword<'a>()
|
||||
-> impl Parser<'a, &'a str, Type, extra::Err<Rich<'a, char>>> + Clone {
|
||||
let alphabetic = any()
|
||||
.filter(|c: &char| c.is_ascii_alphabetic())
|
||||
.repeated()
|
||||
.at_least(1)
|
||||
.collect::<String>();
|
||||
alphabetic.padded().try_map(|word, span| {
|
||||
word.parse::<Type>()
|
||||
.map_err(|e| Rich::custom(span, e.to_string()))
|
||||
})
|
||||
}
|
||||
|
||||
/// Case-insensitive keyword matcher. Consumes leading and
|
||||
/// trailing whitespace and, importantly, requires a word
|
||||
/// boundary so `create` does not match a prefix of `created`.
|
||||
fn keyword_ci<'a>(
|
||||
kw: &'static str,
|
||||
) -> impl Parser<'a, &'a str, (), extra::Err<Rich<'a, char>>> + Clone {
|
||||
let alphabetic = any()
|
||||
.filter(|c: &char| c.is_ascii_alphabetic())
|
||||
.repeated()
|
||||
.at_least(1)
|
||||
.collect::<String>();
|
||||
alphabetic.padded().try_map(move |word, span| {
|
||||
if word.eq_ignore_ascii_case(kw) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(Rich::custom(
|
||||
span,
|
||||
format!("expected '{kw}', found '{word}'"),
|
||||
))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
fn ok(input: &str) -> Command {
|
||||
parse_command(input).unwrap_or_else(|e| panic!("expected ok for {input:?}, got {e:?}"))
|
||||
}
|
||||
|
||||
fn err(input: &str) -> ParseError {
|
||||
parse_command(input).expect_err("expected parse error")
|
||||
}
|
||||
|
||||
fn col(name: &str, ty: Type) -> ColumnSpec {
|
||||
ColumnSpec {
|
||||
name: name.to_string(),
|
||||
ty,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bare_create_table_errors_with_helpful_message() {
|
||||
let e = err("create table Customers");
|
||||
match e {
|
||||
ParseError::Invalid { message, .. } => {
|
||||
assert!(
|
||||
message.contains("with pk"),
|
||||
"error should mention `with pk`:\n{message}"
|
||||
);
|
||||
}
|
||||
ParseError::Empty => panic!("unexpected empty error"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_table_with_pk_default_is_id_serial() {
|
||||
assert_eq!(
|
||||
ok("create table Customers with pk"),
|
||||
Command::CreateTable {
|
||||
name: "Customers".to_string(),
|
||||
columns: vec![col("id", Type::Serial)],
|
||||
primary_key: vec!["id".to_string()],
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_table_with_named_typed_pk() {
|
||||
assert_eq!(
|
||||
ok("create table Customers with pk email:text"),
|
||||
Command::CreateTable {
|
||||
name: "Customers".to_string(),
|
||||
columns: vec![col("email", Type::Text)],
|
||||
primary_key: vec!["email".to_string()],
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_table_with_compound_pk() {
|
||||
assert_eq!(
|
||||
ok("create table OrderLines with pk order_id:int,product_id:int"),
|
||||
Command::CreateTable {
|
||||
name: "OrderLines".to_string(),
|
||||
columns: vec![
|
||||
col("order_id", Type::Int),
|
||||
col("product_id", Type::Int),
|
||||
],
|
||||
primary_key: vec!["order_id".to_string(), "product_id".to_string()],
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_table_pk_accepts_any_user_type() {
|
||||
// Pedagogical freedom — the grammar imposes no
|
||||
// "sensible PK type" filter. Every user-facing type is
|
||||
// accepted; learners discover for themselves.
|
||||
for ty in Type::all() {
|
||||
let input = format!("create table T with pk col:{}", ty.keyword());
|
||||
let cmd = ok(&input);
|
||||
if let Command::CreateTable {
|
||||
columns,
|
||||
primary_key,
|
||||
..
|
||||
} = cmd
|
||||
{
|
||||
assert_eq!(columns[0].ty, *ty);
|
||||
assert_eq!(primary_key, vec!["col".to_string()]);
|
||||
} else {
|
||||
panic!("expected CreateTable for {input}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_table_pk_tolerates_whitespace() {
|
||||
assert_eq!(
|
||||
ok("create table T with pk id : serial"),
|
||||
Command::CreateTable {
|
||||
name: "T".to_string(),
|
||||
columns: vec![col("id", Type::Serial)],
|
||||
primary_key: vec!["id".to_string()],
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
ok("create table T with pk a : int , b : int"),
|
||||
Command::CreateTable {
|
||||
name: "T".to_string(),
|
||||
columns: vec![col("a", Type::Int), col("b", Type::Int)],
|
||||
primary_key: vec!["a".to_string(), "b".to_string()],
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_table_keywords_are_case_insensitive() {
|
||||
assert_eq!(
|
||||
ok("CREATE TABLE Customers WITH PK email:TEXT"),
|
||||
Command::CreateTable {
|
||||
name: "Customers".to_string(),
|
||||
columns: vec![col("email", Type::Text)],
|
||||
primary_key: vec!["email".to_string()],
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drop_table_simple() {
|
||||
assert_eq!(
|
||||
ok("drop table Customers"),
|
||||
Command::DropTable {
|
||||
name: "Customers".to_string()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_column_simple() {
|
||||
assert_eq!(
|
||||
ok("add column to table Customers: Name (text)"),
|
||||
Command::AddColumn {
|
||||
table: "Customers".to_string(),
|
||||
column: "Name".to_string(),
|
||||
ty: Type::Text,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_column_with_each_supported_type() {
|
||||
for ty in Type::all() {
|
||||
let input = format!("add column to table T: C ({})", ty.keyword());
|
||||
assert_eq!(
|
||||
ok(&input),
|
||||
Command::AddColumn {
|
||||
table: "T".to_string(),
|
||||
column: "C".to_string(),
|
||||
ty: *ty,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_column_tolerates_whitespace_around_punctuation() {
|
||||
assert_eq!(
|
||||
ok("add column to table T:Name(text)"),
|
||||
Command::AddColumn {
|
||||
table: "T".to_string(),
|
||||
column: "Name".to_string(),
|
||||
ty: Type::Text,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_input_is_an_explicit_empty_error() {
|
||||
assert_eq!(parse_command(""), Err(ParseError::Empty));
|
||||
assert_eq!(parse_command(" "), Err(ParseError::Empty));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_command_errors() {
|
||||
let e = err("frobulate Customers");
|
||||
assert!(matches!(e, ParseError::Invalid { .. }), "got {e:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_type_errors_with_alternatives_listed() {
|
||||
let e = err("add column to table T: Name (varchar)");
|
||||
match e {
|
||||
ParseError::Invalid { message, .. } => {
|
||||
assert!(
|
||||
message.contains("varchar"),
|
||||
"error should mention the bad type: {message}"
|
||||
);
|
||||
assert!(
|
||||
message.contains("expected one of"),
|
||||
"error should list valid alternatives: {message}"
|
||||
);
|
||||
assert!(
|
||||
message.contains("text") && message.contains("shortid"),
|
||||
"error should name the alternatives: {message}"
|
||||
);
|
||||
}
|
||||
ParseError::Empty => panic!("unexpected empty error"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_pk_type_errors_with_alternatives_listed() {
|
||||
let e = err("create table T with pk id:varchar");
|
||||
match e {
|
||||
ParseError::Invalid { message, .. } => {
|
||||
assert!(message.contains("varchar"), "{message}");
|
||||
assert!(message.contains("expected one of"), "{message}");
|
||||
}
|
||||
ParseError::Empty => panic!("unexpected empty error"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trailing_garbage_errors() {
|
||||
let e = err("create table Customers with pk and pickles");
|
||||
assert!(matches!(e, ParseError::Invalid { .. }), "got {e:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identifier_must_start_with_letter_or_underscore() {
|
||||
let e = err("create table 1Customers with pk");
|
||||
assert!(matches!(e, ParseError::Invalid { .. }), "got {e:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identifier_allows_underscores_and_digits_after_start() {
|
||||
assert_eq!(
|
||||
ok("create table customer_v2 with pk"),
|
||||
Command::CreateTable {
|
||||
name: "customer_v2".to_string(),
|
||||
columns: vec![col("id", Type::Serial)],
|
||||
primary_key: vec!["id".to_string()],
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<Self, Self::Err> {
|
||||
for &ty in Self::all() {
|
||||
if ty.keyword().eq_ignore_ascii_case(s) {
|
||||
return Ok(ty);
|
||||
}
|
||||
}
|
||||
Err(UnknownType {
|
||||
found: s.to_string(),
|
||||
expected: Self::all()
|
||||
.iter()
|
||||
.map(|t| t.keyword())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", "),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn keyword_round_trip_for_every_type() {
|
||||
for &ty in Type::all() {
|
||||
let parsed: Type = ty.keyword().parse().expect("round-trip");
|
||||
assert_eq!(parsed, ty);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parsing_is_case_insensitive() {
|
||||
assert_eq!("TEXT".parse::<Type>().unwrap(), Type::Text);
|
||||
assert_eq!("Int".parse::<Type>().unwrap(), Type::Int);
|
||||
assert_eq!("ShortId".parse::<Type>().unwrap(), Type::ShortId);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_type_lists_expected_alternatives() {
|
||||
let err = "varchar".parse::<Type>().unwrap_err();
|
||||
assert_eq!(err.found, "varchar");
|
||||
assert!(err.expected.contains("text"));
|
||||
assert!(err.expected.contains("shortid"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serial_maps_to_integer_with_primary_key() {
|
||||
assert_eq!(Type::Serial.sqlite_strict_type(), "INTEGER");
|
||||
assert_eq!(Type::Serial.sqlite_strict_extra(), " PRIMARY KEY");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shortid_maps_to_text() {
|
||||
assert_eq!(Type::ShortId.sqlite_strict_type(), "TEXT");
|
||||
assert_eq!(Type::ShortId.sqlite_strict_extra(), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fk_target_type_strips_auto_gen_semantics() {
|
||||
// The two non-identity mappings.
|
||||
assert_eq!(Type::Serial.fk_target_type(), Type::Int);
|
||||
assert_eq!(Type::ShortId.fk_target_type(), Type::Text);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fk_target_type_is_identity_for_plain_value_types() {
|
||||
for ty in [
|
||||
Type::Text,
|
||||
Type::Int,
|
||||
Type::Real,
|
||||
Type::Decimal,
|
||||
Type::Bool,
|
||||
Type::Date,
|
||||
Type::DateTime,
|
||||
Type::Blob,
|
||||
] {
|
||||
assert_eq!(ty.fk_target_type(), ty);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_ten_types_are_present_and_distinct() {
|
||||
let kws: Vec<&'static str> = Type::all().iter().map(|t| t.keyword()).collect();
|
||||
assert_eq!(kws.len(), 10);
|
||||
let mut sorted = kws.clone();
|
||||
sorted.sort_unstable();
|
||||
sorted.dedup();
|
||||
assert_eq!(sorted.len(), 10, "keywords must be unique");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn temporal_and_decimal_types_map_to_text_storage() {
|
||||
assert_eq!(Type::Decimal.sqlite_strict_type(), "TEXT");
|
||||
assert_eq!(Type::Date.sqlite_strict_type(), "TEXT");
|
||||
assert_eq!(Type::DateTime.sqlite_strict_type(), "TEXT");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blob_type_maps_to_blob_storage() {
|
||||
assert_eq!(Type::Blob.sqlite_strict_type(), "BLOB");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_type_message_lists_all_ten() {
|
||||
let err = "varchar".parse::<Type>().unwrap_err();
|
||||
for kw in [
|
||||
"text", "int", "real", "decimal", "bool", "date", "datetime", "blob", "serial",
|
||||
"shortid",
|
||||
] {
|
||||
assert!(
|
||||
err.expected.contains(kw),
|
||||
"expected list should contain `{kw}`: {}",
|
||||
err.expected
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
+22
-1
@@ -7,9 +7,30 @@
|
||||
|
||||
use crossterm::event::KeyEvent;
|
||||
|
||||
use crate::db::TableDescription;
|
||||
use crate::dsl::Command;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum AppEvent {
|
||||
Key(KeyEvent),
|
||||
Resize { cols: u16, rows: u16 },
|
||||
Resize {
|
||||
cols: u16,
|
||||
rows: u16,
|
||||
},
|
||||
Tick,
|
||||
/// A DSL command finished successfully. `description` is
|
||||
/// `Some` for commands that produce a table view (create,
|
||||
/// add column) and `None` for commands that don't (drop).
|
||||
DslSucceeded {
|
||||
command: Command,
|
||||
description: Option<TableDescription>,
|
||||
},
|
||||
/// A DSL command failed. `error` is already a friendly
|
||||
/// message produced via `DbError::friendly_message`.
|
||||
DslFailed {
|
||||
command: Command,
|
||||
error: String,
|
||||
},
|
||||
/// Refreshed list of tables in the database.
|
||||
TablesRefreshed(Vec<String>),
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
+109
-19
@@ -3,18 +3,17 @@
|
||||
//! A blocking task reads crossterm events and forwards them onto
|
||||
//! an `mpsc` channel as `AppEvent`s. The main loop awaits events,
|
||||
//! feeds them to `App::update`, enacts any returned `Action`s,
|
||||
//! and redraws the terminal. Future async work (query execution,
|
||||
//! snapshotting, auto-save) joins the same channel as additional
|
||||
//! producers, which is why we set the architecture up this way
|
||||
//! from day one.
|
||||
//! and redraws the terminal. DSL execution is dispatched onto
|
||||
//! the database worker (see `db::Database`), and its result is
|
||||
//! posted back as a new `AppEvent`. Future async work (snapshot
|
||||
//! capture, auto-save) joins the same event channel as
|
||||
//! additional producers.
|
||||
|
||||
use std::io;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use crossterm::event::{
|
||||
DisableMouseCapture, EnableMouseCapture, Event as CtEvent, EventStream,
|
||||
};
|
||||
use crossterm::event::{Event as CtEvent, EventStream};
|
||||
use crossterm::execute;
|
||||
use crossterm::terminal::{
|
||||
EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
|
||||
@@ -27,6 +26,8 @@ use tracing::{debug, error, info, warn};
|
||||
|
||||
use crate::action::Action;
|
||||
use crate::app::App;
|
||||
use crate::db::{Database, DbError, TableDescription};
|
||||
use crate::dsl::Command;
|
||||
use crate::event::AppEvent;
|
||||
use crate::theme::Theme;
|
||||
use crate::ui;
|
||||
@@ -37,8 +38,12 @@ const SHUTDOWN_GRACE: Duration = Duration::from_millis(100);
|
||||
/// Run the application until a `Quit` action is enacted or the
|
||||
/// terminal closes.
|
||||
pub async fn run(theme: Theme) -> Result<()> {
|
||||
// For this iteration, every session uses a fresh in-memory
|
||||
// database. Track 2 (project storage) wires up file-backed
|
||||
// databases with proper lifecycle management.
|
||||
let database = Database::open(":memory:").context("open database")?;
|
||||
let mut terminal = setup_terminal().context("setup terminal")?;
|
||||
let result = run_loop(&mut terminal, theme).await;
|
||||
let result = run_loop(&mut terminal, theme, database).await;
|
||||
if let Err(e) = teardown_terminal(&mut terminal) {
|
||||
// Teardown failures should not mask the primary error.
|
||||
warn!(error = %e, "terminal teardown failed");
|
||||
@@ -49,13 +54,19 @@ pub async fn run(theme: Theme) -> Result<()> {
|
||||
async fn run_loop(
|
||||
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
|
||||
theme: Theme,
|
||||
database: Database,
|
||||
) -> Result<()> {
|
||||
let (event_tx, mut event_rx) = mpsc::channel::<AppEvent>(EVENT_CHANNEL_CAPACITY);
|
||||
let reader_handle = spawn_event_reader(event_tx);
|
||||
let reader_handle = spawn_event_reader(event_tx.clone());
|
||||
|
||||
let mut app = App::new();
|
||||
|
||||
// Initial draw before any events arrive.
|
||||
// Seed the table list with whatever the database currently
|
||||
// shows. For a fresh in-memory DB this is empty, but doing
|
||||
// it explicitly means file-backed databases (track 2) will
|
||||
// show their tables on launch without changes here.
|
||||
seed_initial_tables(&database, &event_tx).await;
|
||||
|
||||
terminal
|
||||
.draw(|f| ui::render(&app, &theme, f))
|
||||
.context("initial draw")?;
|
||||
@@ -70,6 +81,9 @@ async fn run_loop(
|
||||
debug!("quit action received");
|
||||
should_quit = true;
|
||||
}
|
||||
Action::ExecuteDsl(command) => {
|
||||
spawn_dsl_dispatch(database.clone(), event_tx.clone(), command);
|
||||
}
|
||||
}
|
||||
}
|
||||
terminal
|
||||
@@ -80,13 +94,89 @@ async fn run_loop(
|
||||
}
|
||||
}
|
||||
|
||||
// Give the reader a moment to notice the dropped sender.
|
||||
let _ = tokio::time::timeout(SHUTDOWN_GRACE, reader_handle).await;
|
||||
|
||||
info!("event loop exited");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn seed_initial_tables(database: &Database, event_tx: &mpsc::Sender<AppEvent>) {
|
||||
match database.list_tables().await {
|
||||
Ok(tables) => {
|
||||
let _ = event_tx.send(AppEvent::TablesRefreshed(tables)).await;
|
||||
}
|
||||
Err(e) => {
|
||||
error!(error = %e, "failed to seed initial table list");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawn a task that runs a DSL command against the database
|
||||
/// and forwards the result back as an `AppEvent`.
|
||||
fn spawn_dsl_dispatch(
|
||||
database: Database,
|
||||
event_tx: mpsc::Sender<AppEvent>,
|
||||
command: Command,
|
||||
) {
|
||||
tokio::spawn(async move {
|
||||
let outcome = execute_command(&database, command.clone()).await;
|
||||
let event = match outcome {
|
||||
Ok(description) => AppEvent::DslSucceeded {
|
||||
command: command.clone(),
|
||||
description,
|
||||
},
|
||||
Err(error) => AppEvent::DslFailed {
|
||||
command: command.clone(),
|
||||
error,
|
||||
},
|
||||
};
|
||||
if event_tx.send(event).await.is_err() {
|
||||
return;
|
||||
}
|
||||
// Refresh the table list after every DDL operation so
|
||||
// the items panel reflects reality. A failed list_tables
|
||||
// here is logged but not surfaced to the user — they
|
||||
// already saw the primary outcome.
|
||||
match database.list_tables().await {
|
||||
Ok(tables) => {
|
||||
let _ = event_tx.send(AppEvent::TablesRefreshed(tables)).await;
|
||||
}
|
||||
Err(e) => warn!(error = %e, "post-DDL list_tables failed"),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async fn execute_command(
|
||||
database: &Database,
|
||||
command: Command,
|
||||
) -> Result<Option<TableDescription>, String> {
|
||||
match command {
|
||||
Command::CreateTable {
|
||||
name,
|
||||
columns,
|
||||
primary_key,
|
||||
} => database
|
||||
.create_table(name, columns, primary_key)
|
||||
.await
|
||||
.map(Some)
|
||||
.map_err(friendly),
|
||||
Command::DropTable { name } => database
|
||||
.drop_table(name)
|
||||
.await
|
||||
.map(|()| None)
|
||||
.map_err(friendly),
|
||||
Command::AddColumn { table, column, ty } => database
|
||||
.add_column(table, column, ty)
|
||||
.await
|
||||
.map(Some)
|
||||
.map_err(friendly),
|
||||
}
|
||||
}
|
||||
|
||||
fn friendly(err: DbError) -> String {
|
||||
err.friendly_message()
|
||||
}
|
||||
|
||||
fn spawn_event_reader(tx: mpsc::Sender<AppEvent>) -> tokio::task::JoinHandle<()> {
|
||||
tokio::spawn(async move {
|
||||
let mut stream = EventStream::new();
|
||||
@@ -118,8 +208,12 @@ fn spawn_event_reader(tx: mpsc::Sender<AppEvent>) -> tokio::task::JoinHandle<()>
|
||||
fn setup_terminal() -> Result<Terminal<CrosstermBackend<io::Stdout>>> {
|
||||
enable_raw_mode().context("enable raw mode")?;
|
||||
let mut stdout = io::stdout();
|
||||
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)
|
||||
.context("enter alternate screen")?;
|
||||
// Mouse capture is intentionally NOT enabled: it would prevent the
|
||||
// host terminal's native text selection (the cost of capturing every
|
||||
// mouse event), which we don't currently use for anything in-app.
|
||||
// If we ever want click-to-select panes or scroll wheel handling,
|
||||
// we'll need a different strategy than blanket capture.
|
||||
execute!(stdout, EnterAlternateScreen).context("enter alternate screen")?;
|
||||
let backend = CrosstermBackend::new(stdout);
|
||||
let terminal = Terminal::new(backend).context("construct terminal")?;
|
||||
Ok(terminal)
|
||||
@@ -129,12 +223,8 @@ fn teardown_terminal(
|
||||
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
|
||||
) -> 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(())
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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<Line<'_>> = app
|
||||
.tables
|
||||
.iter()
|
||||
.map(|name| {
|
||||
let style = if name == highlight {
|
||||
Style::default()
|
||||
.fg(theme.fg)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default().fg(theme.fg)
|
||||
};
|
||||
Line::from(Span::styled(name.as_str(), style))
|
||||
})
|
||||
.collect();
|
||||
let paragraph = Paragraph::new(lines).block(block);
|
||||
frame.render_widget(paragraph, area);
|
||||
}
|
||||
|
||||
fn render_output_panel(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rect) {
|
||||
@@ -300,4 +326,61 @@ mod tests {
|
||||
let snapshot = render_to_string(&app, &theme, 80, 24);
|
||||
insta::assert_snapshot!("one_shot_advanced_dark", snapshot);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn populated_with_table_snapshot() {
|
||||
// Items panel lists tables; output panel shows the
|
||||
// structure of the current table.
|
||||
use crate::app::{OutputKind, OutputLine};
|
||||
use crate::db::{ColumnDescription, TableDescription};
|
||||
|
||||
let mut app = App::new();
|
||||
app.tables = vec!["Customers".to_string(), "Orders".to_string()];
|
||||
use crate::dsl::Type;
|
||||
let desc = TableDescription {
|
||||
name: "Customers".to_string(),
|
||||
columns: vec![
|
||||
ColumnDescription {
|
||||
name: "id".to_string(),
|
||||
user_type: Some(Type::Serial),
|
||||
sqlite_type: "INTEGER".to_string(),
|
||||
notnull: false,
|
||||
primary_key: true,
|
||||
},
|
||||
ColumnDescription {
|
||||
name: "Name".to_string(),
|
||||
user_type: Some(Type::Text),
|
||||
sqlite_type: "TEXT".to_string(),
|
||||
notnull: false,
|
||||
primary_key: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
app.current_table = Some(desc);
|
||||
// Mirror what the App writes when a DSL command succeeds.
|
||||
app.output.push_back(OutputLine {
|
||||
text: "[ok] create table Customers".to_string(),
|
||||
kind: OutputKind::System,
|
||||
mode_at_submission: Mode::Simple,
|
||||
});
|
||||
app.output.push_back(OutputLine {
|
||||
text: " Customers".to_string(),
|
||||
kind: OutputKind::System,
|
||||
mode_at_submission: Mode::Simple,
|
||||
});
|
||||
app.output.push_back(OutputLine {
|
||||
text: " id serial [PK]".to_string(),
|
||||
kind: OutputKind::System,
|
||||
mode_at_submission: Mode::Simple,
|
||||
});
|
||||
app.output.push_back(OutputLine {
|
||||
text: " Name text".to_string(),
|
||||
kind: OutputKind::System,
|
||||
mode_at_submission: Mode::Simple,
|
||||
});
|
||||
|
||||
let theme = Theme::dark();
|
||||
let snapshot = render_to_string(&app, &theme, 80, 24);
|
||||
insta::assert_snapshot!("populated_with_table_dark", snapshot);
|
||||
}
|
||||
}
|
||||
|
||||
+205
-14
@@ -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}"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user