INSERT/UPDATE/DELETE + value model + auto-show, with polish
DSL data operations (ADR-0014): - insert into T [(cols)] values (vals); short form insert into T (vals) omits values keyword for friendlier syntax. - update T set ... where col=val | --all-rows; delete from T where col=val | --all-rows; show data T. - Value AST (Number/Text/Bool/Null) with per-column-type validation in the executor: int/real/decimal/bool/date/ datetime/shortid each accept a documented literal shape and produce friendly format errors naming the column. - INSERT short form fills non-auto-generated columns in schema order; auto-fills serial via SQLite and shortid via the new generator (T2). - `add column [to table] T: c (type)` -- `to table` now optional. Database: - insert/update/delete via prepared statements with bound rusqlite::types::Value parameters. - InsertResult/UpdateResult/DeleteResult: writes return rows_affected plus the affected row(s) only (not the whole table), so users see exactly what changed. - INSERT shows the just-inserted row via last_insert_rowid. - UPDATE captures matching rowids up-front and fetches them post-update -- works even if the UPDATE changed the WHERE column. - DELETE reports per-relationship cascade effects by row- count diffing inbound child tables; UPDATE-side cascades are not yet detected (would need value diffing). - query_data formats cells (booleans true/false, NULLs as None). FK error enrichment: - Now lists both outbound (INSERT/UPDATE relevance) and inbound (DELETE/UPDATE on parent relevance) FKs from the metadata, so RESTRICT errors point at the children blocking the delete. - RelationshipSelector has a proper Display impl -- "no such relationship" reads cleanly. Relationship display: - target_table for AddRelationship/DropRelationship now returns the parent (1-side); structure rendering after add/drop shows that side's "Referenced by:" entry, matching the `from <Parent>` direction of the command. - [ok] summary uses display_subject so relationship commands show both endpoints (`from P.col to C.col`) rather than a single misleading table name. - Auto-name format `<Parent>_<pcol>_to_<Child>_<ccol>` (matches the from..to direction). Output rendering and scrolling: - Wrap-aware scroll: renderer reports both visible-row count and total wrapped-row count to App; scroll math caps against actual displayable rows. Long lines wrap; the bottom line is always reachable; PageUp/PageDown work correctly even after paging past the buffer top. - Multi-line messages (FK error enrichment, cascade summary) split into single-line OutputLines at creation time so wrap/scroll math agree. Runtime / events: - New AppEvent variants for Insert/Update/Delete success carrying typed result structs; DslDataSucceeded reserved for show-data queries. Docs: - ADR-0014 covers data-op grammar, value model, --all-rows safety, auto-show. - requirements.md: C5 done, T2 done, V2 partial (basic data view), V5 partial (show data added). New entries: C5a complex WHERE expressions; H1 progress note for FK enrichment; H1a (strong syntax-help in parse errors). Tests: 200 passing (183 lib + 17 integration), 0 skipped. Includes parser, type-validation, DB write/read, FK-failure enrichment, cascade-delete propagation, focused-auto-show behaviour, scroll-cap invariants. Clippy clean with nursery enabled.
This commit is contained in:
Generated
+42
-3
@@ -138,6 +138,17 @@ version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||
|
||||
[[package]]
|
||||
name = "chacha20"
|
||||
version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.3.0",
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chumsky"
|
||||
version = "0.13.0"
|
||||
@@ -195,6 +206,15 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossterm"
|
||||
version = "0.29.0"
|
||||
@@ -518,6 +538,7 @@ dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"r-efi 6.0.0",
|
||||
"rand_core 0.10.1",
|
||||
"wasip2",
|
||||
"wasip3",
|
||||
]
|
||||
@@ -990,7 +1011,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
|
||||
dependencies = [
|
||||
"phf_shared",
|
||||
"rand",
|
||||
"rand 0.8.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1105,7 +1126,18 @@ version = "0.8.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
|
||||
dependencies = [
|
||||
"rand_core",
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207"
|
||||
dependencies = [
|
||||
"chacha20",
|
||||
"getrandom 0.4.2",
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1114,6 +1146,12 @@ version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
|
||||
|
||||
[[package]]
|
||||
name = "ratatui"
|
||||
version = "0.30.0"
|
||||
@@ -1209,6 +1247,7 @@ dependencies = [
|
||||
"futures-util",
|
||||
"insta",
|
||||
"pretty_assertions",
|
||||
"rand 0.10.1",
|
||||
"ratatui",
|
||||
"rusqlite",
|
||||
"thiserror 2.0.18",
|
||||
@@ -1376,7 +1415,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"cpufeatures 0.2.17",
|
||||
"digest",
|
||||
]
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ anyhow = "1.0.102"
|
||||
chumsky = "0.13.0"
|
||||
crossterm = { version = "0.29.0", features = ["event-stream"] }
|
||||
futures-util = "0.3.32"
|
||||
rand = "0.10.1"
|
||||
ratatui = "0.30.0"
|
||||
rusqlite = { version = "0.39.0", features = ["bundled"] }
|
||||
thiserror = "2.0.18"
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
# ADR-0014: Data operations, value literals, and the auto-show pattern
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
## Context
|
||||
|
||||
Schema operations (ADRs 0002, 0005, 0011, 0013) gave us tables,
|
||||
columns, and relationships. Without INSERT / UPDATE / DELETE,
|
||||
foreign-key behaviour is observable but not demonstrable — a
|
||||
learner can't yet trigger a CASCADE or watch a constraint catch
|
||||
a bad write. C5 closes that gap.
|
||||
|
||||
Several coupled questions:
|
||||
|
||||
- **Value literals.** How does the user write `'2025-01-15'` for
|
||||
a date column versus `42` for an int column? What gets
|
||||
validated where?
|
||||
- **Safe defaults for destructive operations.** UPDATE and
|
||||
DELETE without WHERE are classic foot-guns.
|
||||
- **Auto-generation of `shortid`.** T2 commits to client-side
|
||||
generation at insert time, which now becomes load-bearing.
|
||||
- **FK error clarity.** SQLite reports `FOREIGN KEY constraint
|
||||
failed` with no detail; pedagogically that's nearly useless.
|
||||
- **Showing data back to the user.** Without a `SELECT`-like
|
||||
surface, the user has no way to see what changed.
|
||||
|
||||
## Decision
|
||||
|
||||
### Grammar
|
||||
|
||||
```
|
||||
insert into <Table> [(<col>, <col>...)] values (<val>, <val>...)
|
||||
update <Table> set <col>=<val>[, <col>=<val>...] (where <col>=<val> | --all-rows)
|
||||
delete from <Table> (where <col>=<val> | --all-rows)
|
||||
show data <Table>
|
||||
```
|
||||
|
||||
- **INSERT short form** (`insert into T values (...)`): values
|
||||
apply to non-auto-generated columns in schema declaration
|
||||
order. Serial columns are filled by SQLite; shortid columns
|
||||
are auto-generated by the executor.
|
||||
- **INSERT long form** (with explicit column list): user
|
||||
controls exactly which columns receive values; auto-generated
|
||||
columns the user didn't list are still auto-filled.
|
||||
- **WHERE clause** is required for UPDATE and DELETE by default.
|
||||
The `--all-rows` flag is the explicit opt-in to unfiltered
|
||||
operations, following ADR-0009 (`--` reserved for opt-in
|
||||
flags). Specifying both WHERE and `--all-rows` is a parse
|
||||
error.
|
||||
- **WHERE this iteration** is exactly `<col>=<val>`. Richer
|
||||
WHERE expressions (AND/OR/comparison/LIKE) are deferred — they
|
||||
are tracked as a future iteration and are intended as the
|
||||
bridge from DSL into real SQL fluency.
|
||||
- **`show data <Table>`** joins the V5 show-family (with
|
||||
`show table <Name>`); auto-show after writes (below) means
|
||||
most users won't need to call it explicitly.
|
||||
|
||||
### Value literals
|
||||
|
||||
The parser produces a small `Value` AST (`Number(String)`,
|
||||
`Text(String)`, `Bool(bool)`, `Null`). Per-column-type validation
|
||||
lives in the executor where the schema is known:
|
||||
|
||||
| User-facing type | Accepted literal |
|
||||
|------------------|-------------------------------------------------------------|
|
||||
| `text` | single-quoted string `'hello'` (`''` escapes a quote) |
|
||||
| `int` | integer literal `42`, `-7` |
|
||||
| `real` | numeric literal `3.14`, `-0.5` |
|
||||
| `decimal` | numeric literal; stored as text to preserve precision |
|
||||
| `bool` | `true` / `false` |
|
||||
| `date` | quoted `'YYYY-MM-DD'` (validated) |
|
||||
| `datetime` | quoted `'YYYY-MM-DDTHH:MM:SS[.fff][Z|±HH:MM]'` (validated) |
|
||||
| `blob` | DSL literal not supported this iteration |
|
||||
| `serial` | normally omitted (auto-fill); explicit integer accepted |
|
||||
| `shortid` | normally omitted (auto-generated); explicit base58 10–12 |
|
||||
| `null` | keyword `null` |
|
||||
|
||||
Validation produces friendly errors that name the column and
|
||||
expected shape — e.g. "column `Name` expects a quoted string for
|
||||
`text`, got number".
|
||||
|
||||
### Auto-generation for `shortid`
|
||||
|
||||
When an INSERT (in either form) does not provide a value for a
|
||||
`shortid` column, the executor calls the shortid generator and
|
||||
fills in a 10-character base58 value (no `0`/`O`/`I`/`l`).
|
||||
Explicit values are accepted but validated against the same
|
||||
alphabet and length range (10–12 chars). The `rand` crate is
|
||||
the source of randomness.
|
||||
|
||||
### FK error enrichment
|
||||
|
||||
SQLite reports `FOREIGN KEY constraint failed` without naming
|
||||
the offending constraint or value. The executor catches this
|
||||
class of error and appends the table's outbound relationships
|
||||
(via the metadata table from ADR-0013) to the message:
|
||||
|
||||
```
|
||||
FOREIGN KEY constraint failed. Foreign keys on this table:
|
||||
- Orders.CustId → Customers.id
|
||||
Check that each referenced value exists in the parent table.
|
||||
```
|
||||
|
||||
Identifying the *exact* offending row is left to the H1 friendly
|
||||
error layer when that lands.
|
||||
|
||||
### Auto-show after writes
|
||||
|
||||
INSERT, UPDATE, and DELETE successfully completing fetch the
|
||||
target table's full data and emit a `DslDataSucceeded` event
|
||||
carrying both `rows_affected: Some(n)` and the data view. The
|
||||
App renders both. Users see the result immediately without
|
||||
needing a follow-up `show data` command.
|
||||
|
||||
`show data <Table>` follows the same path with
|
||||
`rows_affected: None`.
|
||||
|
||||
The auto-show convention is reserved for DSL data ops. When
|
||||
advanced-mode SQL lands (Q1), arbitrary `SELECT` statements may
|
||||
opt out — large result sets shouldn't be implicitly inserted into
|
||||
the session log.
|
||||
|
||||
### Tabular rendering
|
||||
|
||||
The data view is rendered as simple aligned-column text:
|
||||
|
||||
```
|
||||
id | Name | Email
|
||||
----+-----------+-----------------
|
||||
1 | Alice | a@b.com
|
||||
2 | Bob | (null)
|
||||
```
|
||||
|
||||
Pretty box-drawing renderings (with truncation, scroll
|
||||
indicators, wide-table handling) are deferred to V4. NULL cells
|
||||
render as `(null)` to be explicitly visible; booleans render as
|
||||
`true`/`false` despite their integer storage.
|
||||
|
||||
## Consequences
|
||||
|
||||
- C5 is satisfied: INSERT/UPDATE/DELETE operate end-to-end with
|
||||
validation, auto-generation, FK enforcement, and visible
|
||||
feedback.
|
||||
- T2 is satisfied: shortid auto-generation runs on insert.
|
||||
- V2 partial: a usable tabular data view exists, with the
|
||||
pretty-rendering iteration still ahead.
|
||||
- V5 partial: `show data` joins `show table` in the show family.
|
||||
- H1 partial: FK-failure messages are enriched without
|
||||
introducing the full friendly-error layer.
|
||||
- The `--all-rows` opt-in convention is now established for
|
||||
destructive-without-filter operations — future commands of the
|
||||
same shape (`drop relationship --cascade`?) follow the
|
||||
pattern.
|
||||
- The runtime's `CommandOutcome` enum is extended with a `Data`
|
||||
variant. New data-emitting commands plug in there without
|
||||
reshaping the dispatch.
|
||||
- Complex WHERE expressions (AND/OR/comparison/LIKE), bulk
|
||||
INSERT, ORDER BY, LIMIT, JOIN, and SELECT in advanced mode are
|
||||
explicitly out of scope for this iteration; richer DSL WHERE
|
||||
is the bridge iteration toward Q1's full SQL handling.
|
||||
|
||||
## See also
|
||||
|
||||
- ADR-0005 (column types — value-literal mappings here mirror
|
||||
the storage choices)
|
||||
- ADR-0009 (DSL command syntax conventions — `--` flag rule)
|
||||
- ADR-0011 (FK column type compatibility — used during the
|
||||
validation that runs before writes)
|
||||
- ADR-0013 (relationships and rebuild-table — the FK metadata
|
||||
used by the error-enrichment path)
|
||||
@@ -19,3 +19,4 @@ This directory contains the project's ADRs, recorded per
|
||||
- [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)
|
||||
- [ADR-0013 — Relationships, naming, and the rebuild-table strategy](0013-relationships-and-rebuild-table.md)
|
||||
- [ADR-0014 — Data operations, value literals, and the auto-show pattern](0014-data-operations-and-value-model.md)
|
||||
|
||||
+34
-5
@@ -132,7 +132,18 @@ against it.
|
||||
- [ ] **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.
|
||||
- [ ] **C5** Data operations: insert / update / delete via DSL.
|
||||
- [x] **C5** Data operations: insert / update / delete via DSL.
|
||||
*(ADR-0014. INSERT short and long forms, UPDATE/DELETE with
|
||||
required WHERE plus `--all-rows` opt-in, `show data <T>`,
|
||||
per-column-type value-literal validation, FK enforcement
|
||||
with metadata-driven error enrichment, auto-show after
|
||||
writes. Bulk insert, complex WHERE expressions, and SELECT
|
||||
in advanced mode are explicitly tracked separately — see
|
||||
C5a below.)*
|
||||
- [~] **C5a** Complex WHERE expressions (AND/OR/comparison
|
||||
operators/LIKE) for UPDATE/DELETE/show-data filtering. Tracks
|
||||
the natural progression from DSL into real SQL fluency that
|
||||
motivates the playground; design and ADR pending.
|
||||
|
||||
## SQL handling
|
||||
|
||||
@@ -170,10 +181,11 @@ against it.
|
||||
`int`, `real`, `decimal`, `bool`, `date`, `datetime`, `blob`,
|
||||
`serial`, `shortid`. *(Mapping to SQLite STRICT covered by
|
||||
ADR-0005; FK target type rule by ADR-0011.)*
|
||||
- [ ] **T2** `shortid` generation: base58, 10–12 characters,
|
||||
- [x] **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.)*
|
||||
*(Implemented per ADR-0014; auto-fills omitted shortid
|
||||
columns and validates user-supplied values against the same
|
||||
alphabet and length range.)*
|
||||
- [ ] **T3** Compound primary keys handled end-to-end (DSL,
|
||||
storage, display, FK reference).
|
||||
*(Progress: DSL grammar (`with pk a:int,b:int`), storage, and
|
||||
@@ -193,6 +205,10 @@ against it.
|
||||
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.
|
||||
*(Progress: a basic aligned-column data view is rendered for
|
||||
`show data` and after every write (ADR-0014). Pretty
|
||||
box-drawing tables with truncation/scroll handling, plus
|
||||
multi-tab support, remain in V4 territory.)*
|
||||
- [~] **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
|
||||
@@ -210,7 +226,7 @@ against it.
|
||||
styling, Markdown export, scroll indicator — remains pending.)*
|
||||
- [ ] **V5** `show <kind> [<name>]` family of commands for
|
||||
redisplaying schema info on demand. *(Progress: `show table
|
||||
<name>` implemented and reuses the structure-render pipeline;
|
||||
<name>` and `show data <Table>` implemented;
|
||||
`show tables`, `show relationships`, etc. pending.)*
|
||||
|
||||
## Project lifecycle (per ADR-0004)
|
||||
@@ -281,6 +297,19 @@ against it.
|
||||
|
||||
- [ ] **H1** Friendly error-rewriting layer translates SQLite
|
||||
error messages into learner-friendly equivalents.
|
||||
*(Progress: foreign-key constraint failures are enriched
|
||||
with both inbound and outbound relationship listings (so
|
||||
RESTRICT errors point at the children that still reference
|
||||
this table); full SQL → English translation pending.)*
|
||||
- [ ] **H1a** Strong syntax-help in parse errors. When the user
|
||||
types something near-correct (e.g. `insert into T ('Oli')` —
|
||||
forgotten `values`; or `update T set x=1` — missing WHERE),
|
||||
the error should *name the missing keyword or clause* rather
|
||||
than just point at the unexpected character. This is a
|
||||
separate effort from H1 (which targets database errors); it
|
||||
targets parser errors. Pending — multiple targeted fixes
|
||||
shipping piecemeal so far (e.g. `values` becoming optional in
|
||||
INSERT removes one such case).
|
||||
- [ ] **H2** `hint` provides contextual help for the current
|
||||
input or the most recent error.
|
||||
- [ ] **H3** `help` provides general reference and per-command
|
||||
|
||||
+207
-28
@@ -12,7 +12,9 @@ use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
|
||||
use tracing::{trace, warn};
|
||||
|
||||
use crate::action::Action;
|
||||
use crate::db::TableDescription;
|
||||
use crate::db::{
|
||||
CascadeEffect, DataResult, DeleteResult, InsertResult, TableDescription, UpdateResult,
|
||||
};
|
||||
use crate::dsl::{Command, ParseError, parse_command};
|
||||
use crate::event::AppEvent;
|
||||
use crate::mode::Mode;
|
||||
@@ -94,6 +96,11 @@ pub struct App {
|
||||
/// the visible window off the top of the buffer and shrink
|
||||
/// what the user sees.
|
||||
pub last_output_visible: usize,
|
||||
/// The most recent total *wrapped* row count of the output
|
||||
/// panel — counted in display rows after wrapping, not in
|
||||
/// logical OutputLines. Required for accurate scroll capping
|
||||
/// when long lines wrap to multiple display rows.
|
||||
pub last_output_total_wrapped: usize,
|
||||
}
|
||||
|
||||
const PAGE_SCROLL_LINES: usize = 5;
|
||||
@@ -122,18 +129,22 @@ impl App {
|
||||
history_draft: None,
|
||||
output_scroll: 0,
|
||||
last_output_visible: 0,
|
||||
last_output_total_wrapped: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Called by the renderer with the current output-panel row
|
||||
/// count so subsequent scroll input is capped against the
|
||||
/// actual visible area, not the unrelated buffer length.
|
||||
pub fn note_output_viewport(&mut self, visible_rows: usize) {
|
||||
/// Called by the renderer with the current output-panel
|
||||
/// dimensions (row count + total wrapped-row count for the
|
||||
/// current buffer) so subsequent scroll input is capped
|
||||
/// correctly. Without `total_wrapped`, scroll math would
|
||||
/// incorrectly assume one logical line = one display row.
|
||||
pub const fn note_output_viewport(&mut self, visible_rows: usize, total_wrapped_rows: usize) {
|
||||
self.last_output_visible = visible_rows;
|
||||
self.last_output_total_wrapped = total_wrapped_rows;
|
||||
// If a previous PageUp drifted past the maximum useful
|
||||
// scroll (e.g. the user kept paging up past the top),
|
||||
// bring it back so the next PageDown is responsive.
|
||||
let max = self.output.len().saturating_sub(visible_rows);
|
||||
let max = total_wrapped_rows.saturating_sub(visible_rows);
|
||||
if self.output_scroll > max {
|
||||
self.output_scroll = max;
|
||||
}
|
||||
@@ -166,6 +177,22 @@ impl App {
|
||||
self.handle_dsl_success(&command, description);
|
||||
Vec::new()
|
||||
}
|
||||
AppEvent::DslDataSucceeded { command, data } => {
|
||||
self.handle_dsl_query_success(&command, &data);
|
||||
Vec::new()
|
||||
}
|
||||
AppEvent::DslInsertSucceeded { command, result } => {
|
||||
self.handle_dsl_insert_success(&command, &result);
|
||||
Vec::new()
|
||||
}
|
||||
AppEvent::DslUpdateSucceeded { command, result } => {
|
||||
self.handle_dsl_update_success(&command, &result);
|
||||
Vec::new()
|
||||
}
|
||||
AppEvent::DslDeleteSucceeded { command, result } => {
|
||||
self.handle_dsl_delete_success(&command, &result);
|
||||
Vec::new()
|
||||
}
|
||||
AppEvent::DslFailed { command, error } => {
|
||||
self.handle_dsl_failure(&command, &error);
|
||||
Vec::new()
|
||||
@@ -447,7 +474,7 @@ impl App {
|
||||
}
|
||||
|
||||
fn handle_dsl_success(&mut self, command: &Command, description: Option<TableDescription>) {
|
||||
let summary = format!("[ok] {} {}", command.verb(), command.target_table());
|
||||
let summary = format!("[ok] {} {}", command.verb(), command.display_subject());
|
||||
self.note_system(summary);
|
||||
if let Some(desc) = description.as_ref() {
|
||||
self.note_system(format!(" {}", desc.name));
|
||||
@@ -498,6 +525,50 @@ impl App {
|
||||
self.current_table = description;
|
||||
}
|
||||
|
||||
fn handle_dsl_query_success(&mut self, command: &Command, data: &DataResult) {
|
||||
let summary = format!("[ok] {} {}", command.verb(), command.display_subject());
|
||||
self.note_system(summary);
|
||||
for line in render_data_view(data) {
|
||||
self.note_system(line);
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_dsl_insert_success(&mut self, command: &Command, result: &InsertResult) {
|
||||
self.note_system(format!(
|
||||
"[ok] {} {}",
|
||||
command.verb(),
|
||||
command.display_subject()
|
||||
));
|
||||
self.note_system(format!(" {} row(s) inserted", result.rows_affected));
|
||||
for line in render_data_view(&result.data) {
|
||||
self.note_system(line);
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_dsl_update_success(&mut self, command: &Command, result: &UpdateResult) {
|
||||
self.note_system(format!(
|
||||
"[ok] {} {}",
|
||||
command.verb(),
|
||||
command.display_subject()
|
||||
));
|
||||
self.note_system(format!(" {} row(s) updated", result.rows_affected));
|
||||
for line in render_data_view(&result.data) {
|
||||
self.note_system(line);
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_dsl_delete_success(&mut self, command: &Command, result: &DeleteResult) {
|
||||
self.note_system(format!(
|
||||
"[ok] {} {}",
|
||||
command.verb(),
|
||||
command.display_subject()
|
||||
));
|
||||
self.note_system(format!(" {} row(s) deleted", result.rows_affected));
|
||||
for effect in &result.cascade {
|
||||
self.note_system(render_cascade_effect(effect));
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_dsl_failure(&mut self, command: &Command, error: &str) {
|
||||
warn!(verb = command.verb(), error, "dsl command failed");
|
||||
// Wrap the command portion in quotes so the message
|
||||
@@ -506,7 +577,7 @@ impl App {
|
||||
self.note_error(format!(
|
||||
"\"{} {}\" failed: {error}",
|
||||
command.verb(),
|
||||
command.target_table()
|
||||
command.display_subject()
|
||||
));
|
||||
}
|
||||
|
||||
@@ -529,19 +600,34 @@ impl App {
|
||||
}
|
||||
|
||||
fn note_system(&mut self, text: impl Into<String>) {
|
||||
self.push_output(OutputLine {
|
||||
text: text.into(),
|
||||
kind: OutputKind::System,
|
||||
mode_at_submission: self.mode,
|
||||
});
|
||||
self.push_multiline(text.into(), OutputKind::System);
|
||||
}
|
||||
|
||||
fn note_error(&mut self, text: impl Into<String>) {
|
||||
self.push_output(OutputLine {
|
||||
text: text.into(),
|
||||
kind: OutputKind::Error,
|
||||
mode_at_submission: self.mode,
|
||||
});
|
||||
self.push_multiline(text.into(), OutputKind::Error);
|
||||
}
|
||||
|
||||
/// Push possibly-multi-line `text` as a sequence of single-line
|
||||
/// `OutputLine`s. Keeping one display row per `OutputLine` is
|
||||
/// what makes the scroll-position math (line count = display
|
||||
/// rows) accurate; the renderer therefore truncates rather
|
||||
/// than wraps long lines.
|
||||
fn push_multiline(&mut self, text: String, kind: OutputKind) {
|
||||
if text.is_empty() {
|
||||
self.push_output(OutputLine {
|
||||
text,
|
||||
kind,
|
||||
mode_at_submission: self.mode,
|
||||
});
|
||||
return;
|
||||
}
|
||||
for line in text.split('\n') {
|
||||
self.push_output(OutputLine {
|
||||
text: line.to_string(),
|
||||
kind,
|
||||
mode_at_submission: self.mode,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn push_output(&mut self, line: OutputLine) {
|
||||
@@ -556,13 +642,12 @@ impl App {
|
||||
}
|
||||
|
||||
fn scroll_output_up(&mut self) {
|
||||
// Cap at `len - visible` so the topmost visible chunk
|
||||
// is the *first* `visible` lines of the buffer; going
|
||||
// past that would shrink the view by sliding the window
|
||||
// off the top.
|
||||
// Cap at `total_wrapped - visible` (display rows, not
|
||||
// logical lines) so the topmost visible chunk is the
|
||||
// first `visible` rendered rows; going past that would
|
||||
// shrink the view by sliding the window off the top.
|
||||
let max = self
|
||||
.output
|
||||
.len()
|
||||
.last_output_total_wrapped
|
||||
.saturating_sub(self.last_output_visible.max(1));
|
||||
self.output_scroll = (self.output_scroll + PAGE_SCROLL_LINES).min(max);
|
||||
}
|
||||
@@ -579,6 +664,94 @@ fn parse_error_message(err: &ParseError) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn render_cascade_effect(effect: &CascadeEffect) -> String {
|
||||
use crate::dsl::ReferentialAction;
|
||||
let what = match effect.action {
|
||||
ReferentialAction::Cascade => "deleted",
|
||||
ReferentialAction::SetNull => "had FK set to null",
|
||||
ReferentialAction::Restrict | ReferentialAction::NoAction => "blocked",
|
||||
};
|
||||
format!(
|
||||
" related: {} row(s) {} in `{}` for relationship `{}` (on delete {})",
|
||||
effect.rows_changed,
|
||||
what,
|
||||
effect.child_table,
|
||||
effect.relationship_name,
|
||||
effect.action,
|
||||
)
|
||||
}
|
||||
|
||||
/// Render a data result as a sequence of aligned-column text
|
||||
/// lines suitable for the output panel. Pretty box-drawing
|
||||
/// rendering is V4 territory; this version uses simple
|
||||
/// pipe-and-dash separators.
|
||||
fn render_data_view(data: &DataResult) -> Vec<String> {
|
||||
let header = data.columns.clone();
|
||||
let body: Vec<Vec<String>> = data
|
||||
.rows
|
||||
.iter()
|
||||
.map(|row| {
|
||||
row.iter()
|
||||
.map(|cell| {
|
||||
cell.as_ref()
|
||||
.map_or_else(|| "(null)".to_string(), Clone::clone)
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Column widths = max(header, all cells) per column.
|
||||
let mut widths: Vec<usize> = header.iter().map(String::len).collect();
|
||||
for row in &body {
|
||||
for (i, cell) in row.iter().enumerate() {
|
||||
if i < widths.len() && cell.chars().count() > widths[i] {
|
||||
widths[i] = cell.chars().count();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut out: Vec<String> = Vec::with_capacity(body.len() + 3);
|
||||
out.push(format!(" {}", join_padded(&header, &widths)));
|
||||
out.push(format!(" {}", separator_row(&widths)));
|
||||
if body.is_empty() {
|
||||
out.push(" (no rows)".to_string());
|
||||
} else {
|
||||
for row in &body {
|
||||
out.push(format!(" {}", join_padded(row, &widths)));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn join_padded(cells: &[String], widths: &[usize]) -> String {
|
||||
let mut s = String::new();
|
||||
for (i, cell) in cells.iter().enumerate() {
|
||||
if i > 0 {
|
||||
s.push_str(" | ");
|
||||
}
|
||||
let w = widths.get(i).copied().unwrap_or(0);
|
||||
s.push_str(cell);
|
||||
let pad = w.saturating_sub(cell.chars().count());
|
||||
for _ in 0..pad {
|
||||
s.push(' ');
|
||||
}
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
fn separator_row(widths: &[usize]) -> String {
|
||||
let mut s = String::new();
|
||||
for (i, w) in widths.iter().enumerate() {
|
||||
if i > 0 {
|
||||
s.push_str("-+-");
|
||||
}
|
||||
for _ in 0..*w {
|
||||
s.push('-');
|
||||
}
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -1033,6 +1206,8 @@ mod tests {
|
||||
for i in 0..30 {
|
||||
app.note_system(format!("line{i}"));
|
||||
}
|
||||
// Simulate a render establishing 10 visible / 30 wrapped.
|
||||
app.note_output_viewport(10, 30);
|
||||
assert_eq!(app.output_scroll, 0);
|
||||
app.update(key(KeyCode::PageUp));
|
||||
assert_eq!(app.output_scroll, super::PAGE_SCROLL_LINES);
|
||||
@@ -1044,6 +1219,7 @@ mod tests {
|
||||
for i in 0..30 {
|
||||
app.note_system(format!("line{i}"));
|
||||
}
|
||||
app.note_output_viewport(10, 30);
|
||||
for _ in 0..3 {
|
||||
app.update(key(KeyCode::PageUp));
|
||||
}
|
||||
@@ -1060,6 +1236,7 @@ mod tests {
|
||||
for i in 0..30 {
|
||||
app.note_system(format!("line{i}"));
|
||||
}
|
||||
app.note_output_viewport(10, 30);
|
||||
app.update(key(KeyCode::PageUp));
|
||||
assert!(app.output_scroll > 0);
|
||||
// Any new output line snaps the scroll back to bottom so
|
||||
@@ -1091,13 +1268,15 @@ mod tests {
|
||||
for i in 0..30 {
|
||||
app.note_system(format!("line{i}"));
|
||||
}
|
||||
// Simulate a render reporting 10 visible rows.
|
||||
app.note_output_viewport(10);
|
||||
// Simulate a render reporting 10 visible rows over a
|
||||
// 30-row wrapped buffer (every line fits in one row in
|
||||
// this test).
|
||||
app.note_output_viewport(10, 30);
|
||||
// Page up many times — past the maximum useful scroll.
|
||||
for _ in 0..20 {
|
||||
app.update(key(KeyCode::PageUp));
|
||||
}
|
||||
// Cap should be at len - visible = 30 - 10 = 20.
|
||||
// Cap should be at total_wrapped - visible = 30 - 10 = 20.
|
||||
assert_eq!(app.output_scroll, 20);
|
||||
}
|
||||
|
||||
@@ -1111,7 +1290,7 @@ mod tests {
|
||||
app.note_system(format!("line{i}"));
|
||||
}
|
||||
app.output_scroll = 100;
|
||||
app.note_output_viewport(10);
|
||||
app.note_output_viewport(10, 30);
|
||||
assert_eq!(app.output_scroll, 20);
|
||||
}
|
||||
|
||||
|
||||
+81
-7
@@ -13,6 +13,7 @@
|
||||
|
||||
use crate::dsl::action::ReferentialAction;
|
||||
use crate::dsl::types::Type;
|
||||
use crate::dsl::value::Value;
|
||||
|
||||
/// A column at table-creation time: a name and a user-facing
|
||||
/// type. Constraints beyond `PRIMARY KEY` (NOT NULL, UNIQUE,
|
||||
@@ -70,6 +71,38 @@ pub enum Command {
|
||||
ShowTable {
|
||||
name: String,
|
||||
},
|
||||
/// Insert a single row. `columns` is `None` for the natural-
|
||||
/// order short form (`insert into T values (...)`); the
|
||||
/// executor fills in the column list by walking the schema.
|
||||
Insert {
|
||||
table: String,
|
||||
columns: Option<Vec<String>>,
|
||||
values: Vec<Value>,
|
||||
},
|
||||
/// Update rows matching the WHERE clause (or all rows when
|
||||
/// `all_rows` is set, per ADR-0009 opt-in convention).
|
||||
Update {
|
||||
table: String,
|
||||
assignments: Vec<(String, Value)>,
|
||||
filter: RowFilter,
|
||||
},
|
||||
Delete {
|
||||
table: String,
|
||||
filter: RowFilter,
|
||||
},
|
||||
/// Render the rows of a table as a data view in the output.
|
||||
ShowData {
|
||||
name: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// How an UPDATE / DELETE selects which rows to operate on.
|
||||
/// `Where` is the default safe form. `AllRows` is the explicit
|
||||
/// `--all-rows` flag opt-in for unfiltered operations.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum RowFilter {
|
||||
Where { column: String, value: Value },
|
||||
AllRows,
|
||||
}
|
||||
|
||||
/// How a `drop relationship` command identifies the relationship
|
||||
@@ -114,6 +147,10 @@ impl Command {
|
||||
Self::AddRelationship { .. } => "add relationship",
|
||||
Self::DropRelationship { .. } => "drop relationship",
|
||||
Self::ShowTable { .. } => "show table",
|
||||
Self::Insert { .. } => "insert into",
|
||||
Self::Update { .. } => "update",
|
||||
Self::Delete { .. } => "delete from",
|
||||
Self::ShowData { .. } => "show data",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,16 +163,53 @@ impl Command {
|
||||
match self {
|
||||
Self::CreateTable { name, .. }
|
||||
| Self::DropTable { name }
|
||||
| Self::ShowTable { name } => name,
|
||||
Self::AddColumn { table, .. } => table,
|
||||
Self::AddRelationship { child_table, .. } => child_table,
|
||||
| Self::ShowTable { name }
|
||||
| Self::ShowData { name } => name,
|
||||
Self::AddColumn { table, .. }
|
||||
| Self::Insert { table, .. }
|
||||
| Self::Update { table, .. }
|
||||
| Self::Delete { table, .. } => table,
|
||||
// For relationships we focus on the parent (1-side):
|
||||
// the structure rendering after add/drop shows that
|
||||
// table's "Referenced by" entry, which is what the
|
||||
// user looks at to confirm the relationship.
|
||||
Self::AddRelationship { parent_table, .. } => parent_table,
|
||||
Self::DropRelationship { selector } => match selector {
|
||||
RelationshipSelector::Endpoints { child_table, .. } => child_table,
|
||||
// For a named drop we don't know the child table
|
||||
// until the executor resolves it; the verb is
|
||||
// still a sensible fallback for logging.
|
||||
RelationshipSelector::Endpoints { parent_table, .. } => parent_table,
|
||||
// For a named drop we don't know the parent table
|
||||
// until the executor resolves it; the name itself
|
||||
// is a sensible fallback for logging.
|
||||
RelationshipSelector::Named { name } => name,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Human-readable subject for the `[ok] <verb> <subject>`
|
||||
/// summary line. Most commands target a single table, but
|
||||
/// relationship commands are better described by their
|
||||
/// endpoints than by either side alone.
|
||||
#[must_use]
|
||||
pub fn display_subject(&self) -> String {
|
||||
match self {
|
||||
Self::AddRelationship {
|
||||
parent_table,
|
||||
parent_column,
|
||||
child_table,
|
||||
child_column,
|
||||
..
|
||||
} => format!("from {parent_table}.{parent_column} to {child_table}.{child_column}"),
|
||||
Self::DropRelationship { selector } => match selector {
|
||||
RelationshipSelector::Named { name } => name.clone(),
|
||||
RelationshipSelector::Endpoints {
|
||||
parent_table,
|
||||
parent_column,
|
||||
child_table,
|
||||
child_column,
|
||||
} => format!(
|
||||
"from {parent_table}.{parent_column} to {child_table}.{child_column}"
|
||||
),
|
||||
},
|
||||
_ => self.target_table().to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+4
-1
@@ -12,9 +12,12 @@
|
||||
pub mod action;
|
||||
pub mod command;
|
||||
pub mod parser;
|
||||
pub mod shortid;
|
||||
pub mod types;
|
||||
pub mod value;
|
||||
|
||||
pub use action::ReferentialAction;
|
||||
pub use command::{ColumnSpec, Command, RelationshipSelector};
|
||||
pub use command::{ColumnSpec, Command, RelationshipSelector, RowFilter};
|
||||
pub use parser::{ParseError, parse_command};
|
||||
pub use types::Type;
|
||||
pub use value::Value;
|
||||
|
||||
+378
-3
@@ -14,8 +14,9 @@ use chumsky::error::RichReason;
|
||||
use chumsky::prelude::*;
|
||||
|
||||
use crate::dsl::action::ReferentialAction;
|
||||
use crate::dsl::command::{ColumnSpec, Command, RelationshipSelector};
|
||||
use crate::dsl::command::{ColumnSpec, Command, RelationshipSelector, RowFilter};
|
||||
use crate::dsl::types::Type;
|
||||
use crate::dsl::value::Value;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
||||
pub enum ParseError {
|
||||
@@ -128,10 +129,14 @@ fn command_parser<'a>()
|
||||
.ignore_then(identifier())
|
||||
.map(|name| Command::DropTable { name });
|
||||
|
||||
// `to table` is optional — both `add column to table T: c (text)`
|
||||
// and `add column T: c (text)` parse identically.
|
||||
let to_table_optional = keyword_ci("to")
|
||||
.ignore_then(keyword_ci("table"))
|
||||
.or_not();
|
||||
let add_column = keyword_ci("add")
|
||||
.ignore_then(keyword_ci("column"))
|
||||
.ignore_then(keyword_ci("to"))
|
||||
.ignore_then(keyword_ci("table"))
|
||||
.ignore_then(to_table_optional)
|
||||
.ignore_then(identifier())
|
||||
.then_ignore(just(':').padded())
|
||||
.then(identifier())
|
||||
@@ -143,23 +148,211 @@ fn command_parser<'a>()
|
||||
let add_relationship = add_relationship_parser();
|
||||
let drop_relationship = drop_relationship_parser();
|
||||
|
||||
let show_data = keyword_ci("show")
|
||||
.ignore_then(keyword_ci("data"))
|
||||
.ignore_then(identifier())
|
||||
.map(|name| Command::ShowData { name });
|
||||
|
||||
let show_table = keyword_ci("show")
|
||||
.ignore_then(keyword_ci("table"))
|
||||
.ignore_then(identifier())
|
||||
.map(|name| Command::ShowTable { name });
|
||||
|
||||
let insert_cmd = insert_parser();
|
||||
let update_cmd = update_parser();
|
||||
let delete_cmd = delete_parser();
|
||||
|
||||
choice((
|
||||
create_table,
|
||||
drop_table,
|
||||
add_column,
|
||||
add_relationship,
|
||||
drop_relationship,
|
||||
// Order: `show data` before `show table` because both
|
||||
// start with `show` and the longer keyword is checked
|
||||
// first via this ordering.
|
||||
show_data,
|
||||
show_table,
|
||||
insert_cmd,
|
||||
update_cmd,
|
||||
delete_cmd,
|
||||
))
|
||||
.padded()
|
||||
.then_ignore(end())
|
||||
}
|
||||
|
||||
/// INSERT, accepting three shapes:
|
||||
/// `insert into T (cols) values (vals)` — explicit columns
|
||||
/// `insert into T values (vals)` — implicit column order
|
||||
/// `insert into T (vals)` — short form, omits `values`
|
||||
///
|
||||
/// The short form is disambiguated from the column-list form by
|
||||
/// trying both alternatives in order; chumsky's `choice`
|
||||
/// backtracks, and only the all-literals form parses without
|
||||
/// `values`.
|
||||
fn insert_parser<'a>()
|
||||
-> impl Parser<'a, &'a str, Command, extra::Err<Rich<'a, char>>> + Clone {
|
||||
let column_list = just('(')
|
||||
.padded()
|
||||
.ignore_then(
|
||||
identifier()
|
||||
.separated_by(just(',').padded())
|
||||
.at_least(1)
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
.then_ignore(just(')').padded());
|
||||
|
||||
let value_list = just('(')
|
||||
.padded()
|
||||
.ignore_then(
|
||||
value_literal()
|
||||
.separated_by(just(',').padded())
|
||||
.at_least(1)
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
.then_ignore(just(')').padded());
|
||||
|
||||
let with_columns_and_values = column_list
|
||||
.clone()
|
||||
.then_ignore(keyword_ci("values"))
|
||||
.then(value_list.clone())
|
||||
.map(|(cols, vals)| (Some(cols), vals));
|
||||
|
||||
let with_values_keyword_only = keyword_ci("values")
|
||||
.ignore_then(value_list.clone())
|
||||
.map(|vals| (None, vals));
|
||||
|
||||
let bare_value_list = value_list.map(|vals| (None, vals));
|
||||
|
||||
keyword_ci("insert")
|
||||
.ignore_then(keyword_ci("into"))
|
||||
.ignore_then(identifier())
|
||||
.then(choice((
|
||||
with_columns_and_values,
|
||||
with_values_keyword_only,
|
||||
bare_value_list,
|
||||
)))
|
||||
.map(|(table, (columns, values))| Command::Insert {
|
||||
table,
|
||||
columns,
|
||||
values,
|
||||
})
|
||||
}
|
||||
|
||||
/// `update <T> set <col>=<val>[, <col>=<val>...] (where <col>=<val> | --all-rows)`.
|
||||
fn update_parser<'a>()
|
||||
-> impl Parser<'a, &'a str, Command, extra::Err<Rich<'a, char>>> + Clone {
|
||||
let assignment = identifier()
|
||||
.then_ignore(just('=').padded())
|
||||
.then(value_literal());
|
||||
|
||||
let assignments = assignment
|
||||
.separated_by(just(',').padded())
|
||||
.at_least(1)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
keyword_ci("update")
|
||||
.ignore_then(identifier())
|
||||
.then_ignore(keyword_ci("set"))
|
||||
.then(assignments)
|
||||
.then(filter_clause())
|
||||
.map(|((table, assignments), filter)| Command::Update {
|
||||
table,
|
||||
assignments,
|
||||
filter,
|
||||
})
|
||||
}
|
||||
|
||||
/// `delete from <T> (where <col>=<val> | --all-rows)`.
|
||||
fn delete_parser<'a>()
|
||||
-> impl Parser<'a, &'a str, Command, extra::Err<Rich<'a, char>>> + Clone {
|
||||
keyword_ci("delete")
|
||||
.ignore_then(keyword_ci("from"))
|
||||
.ignore_then(identifier())
|
||||
.then(filter_clause())
|
||||
.map(|(table, filter)| Command::Delete { table, filter })
|
||||
}
|
||||
|
||||
/// Parse the row-filter portion of UPDATE/DELETE: either
|
||||
/// `where <col>=<val>` or the `--all-rows` flag, with the two
|
||||
/// being mutually exclusive (specifying both is a parse error).
|
||||
fn filter_clause<'a>()
|
||||
-> impl Parser<'a, &'a str, RowFilter, extra::Err<Rich<'a, char>>> + Clone {
|
||||
let where_clause = keyword_ci("where")
|
||||
.ignore_then(identifier())
|
||||
.then_ignore(just('=').padded())
|
||||
.then(value_literal())
|
||||
.map(|(column, value)| RowFilter::Where { column, value });
|
||||
|
||||
let all_rows = just("--all-rows").padded().to(RowFilter::AllRows);
|
||||
|
||||
where_clause.or(all_rows).labelled("where clause or --all-rows")
|
||||
}
|
||||
|
||||
/// Parse a value literal: number, single-quoted string, `null`,
|
||||
/// `true`, or `false`.
|
||||
fn value_literal<'a>()
|
||||
-> impl Parser<'a, &'a str, Value, extra::Err<Rich<'a, char>>> + Clone {
|
||||
choice((
|
||||
keyword_ci("null").to(Value::Null),
|
||||
keyword_ci("true").to(Value::Bool(true)),
|
||||
keyword_ci("false").to(Value::Bool(false)),
|
||||
number_literal(),
|
||||
string_literal(),
|
||||
))
|
||||
.padded()
|
||||
}
|
||||
|
||||
fn number_literal<'a>()
|
||||
-> impl Parser<'a, &'a str, Value, extra::Err<Rich<'a, char>>> + Clone {
|
||||
let sign = just('-').or_not();
|
||||
let digits = any()
|
||||
.filter(|c: &char| c.is_ascii_digit())
|
||||
.repeated()
|
||||
.at_least(1)
|
||||
.collect::<String>();
|
||||
let fraction = just('.')
|
||||
.ignore_then(
|
||||
any()
|
||||
.filter(|c: &char| c.is_ascii_digit())
|
||||
.repeated()
|
||||
.at_least(1)
|
||||
.collect::<String>(),
|
||||
)
|
||||
.or_not();
|
||||
sign.then(digits)
|
||||
.then(fraction)
|
||||
.map(|((s, whole), frac)| {
|
||||
let mut out = String::new();
|
||||
if s.is_some() {
|
||||
out.push('-');
|
||||
}
|
||||
out.push_str(&whole);
|
||||
if let Some(f) = frac {
|
||||
out.push('.');
|
||||
out.push_str(&f);
|
||||
}
|
||||
Value::Number(out)
|
||||
})
|
||||
}
|
||||
|
||||
fn string_literal<'a>()
|
||||
-> impl Parser<'a, &'a str, Value, extra::Err<Rich<'a, char>>> + Clone {
|
||||
// Single-quoted SQL string. `''` inside the literal escapes
|
||||
// a literal single quote.
|
||||
let body = just('\'')
|
||||
.ignore_then(
|
||||
choice((
|
||||
just("''").to('\''),
|
||||
any().filter(|c: &char| *c != '\''),
|
||||
))
|
||||
.repeated()
|
||||
.collect::<String>(),
|
||||
)
|
||||
.then_ignore(just('\''));
|
||||
body.map(Value::Text)
|
||||
}
|
||||
|
||||
/// `add 1:n relationship [<name>] from <P>.<col> to <C>.<col>
|
||||
/// [on delete <action>] [on update <action>] [--create-fk]`.
|
||||
fn add_relationship_parser<'a>()
|
||||
@@ -805,6 +998,188 @@ mod tests {
|
||||
assert!(matches!(e, ParseError::Invalid { .. }), "got {e:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_with_explicit_column_list() {
|
||||
assert_eq!(
|
||||
ok("insert into Customers (Name, Email) values ('Alice', 'a@b.com')"),
|
||||
Command::Insert {
|
||||
table: "Customers".to_string(),
|
||||
columns: Some(vec!["Name".to_string(), "Email".to_string()]),
|
||||
values: vec![
|
||||
Value::Text("Alice".to_string()),
|
||||
Value::Text("a@b.com".to_string()),
|
||||
],
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_short_form_omitting_values_keyword() {
|
||||
// User typed `insert into T (vals)` without `values`.
|
||||
// Equivalent to `insert into T values (vals)`.
|
||||
assert_eq!(
|
||||
ok("insert into Customers ('Alice')"),
|
||||
Command::Insert {
|
||||
table: "Customers".to_string(),
|
||||
columns: None,
|
||||
values: vec![Value::Text("Alice".to_string())],
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_short_form_without_column_list() {
|
||||
assert_eq!(
|
||||
ok("insert into Customers values ('Alice', 'a@b.com')"),
|
||||
Command::Insert {
|
||||
table: "Customers".to_string(),
|
||||
columns: None,
|
||||
values: vec![
|
||||
Value::Text("Alice".to_string()),
|
||||
Value::Text("a@b.com".to_string()),
|
||||
],
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_accepts_mixed_value_kinds() {
|
||||
assert_eq!(
|
||||
ok("insert into T values (1, 3.14, 'hi', true, null)"),
|
||||
Command::Insert {
|
||||
table: "T".to_string(),
|
||||
columns: None,
|
||||
values: vec![
|
||||
Value::Number("1".to_string()),
|
||||
Value::Number("3.14".to_string()),
|
||||
Value::Text("hi".to_string()),
|
||||
Value::Bool(true),
|
||||
Value::Null,
|
||||
],
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_supports_negative_numbers() {
|
||||
assert_eq!(
|
||||
ok("insert into T values (-5, -3.14)"),
|
||||
Command::Insert {
|
||||
table: "T".to_string(),
|
||||
columns: None,
|
||||
values: vec![
|
||||
Value::Number("-5".to_string()),
|
||||
Value::Number("-3.14".to_string()),
|
||||
],
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn string_literal_supports_escaped_single_quote() {
|
||||
// SQL convention: '' inside a quoted string is a literal '.
|
||||
assert_eq!(
|
||||
ok("insert into T values ('don''t panic')"),
|
||||
Command::Insert {
|
||||
table: "T".to_string(),
|
||||
columns: None,
|
||||
values: vec![Value::Text("don't panic".to_string())],
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_with_where() {
|
||||
assert_eq!(
|
||||
ok("update Customers set Name='Alice' where id=1"),
|
||||
Command::Update {
|
||||
table: "Customers".to_string(),
|
||||
assignments: vec![("Name".to_string(), Value::Text("Alice".to_string()))],
|
||||
filter: RowFilter::Where {
|
||||
column: "id".to_string(),
|
||||
value: Value::Number("1".to_string()),
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_with_multiple_assignments() {
|
||||
assert_eq!(
|
||||
ok("update Customers set Name='Alice', Email='a@b.com' where id=1"),
|
||||
Command::Update {
|
||||
table: "Customers".to_string(),
|
||||
assignments: vec![
|
||||
("Name".to_string(), Value::Text("Alice".to_string())),
|
||||
("Email".to_string(), Value::Text("a@b.com".to_string())),
|
||||
],
|
||||
filter: RowFilter::Where {
|
||||
column: "id".to_string(),
|
||||
value: Value::Number("1".to_string()),
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_with_all_rows_flag() {
|
||||
assert_eq!(
|
||||
ok("update Customers set Active=false --all-rows"),
|
||||
Command::Update {
|
||||
table: "Customers".to_string(),
|
||||
assignments: vec![("Active".to_string(), Value::Bool(false))],
|
||||
filter: RowFilter::AllRows,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_without_where_or_flag_errors() {
|
||||
let e = err("update Customers set Active=false");
|
||||
assert!(matches!(e, ParseError::Invalid { .. }), "got {e:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_with_where() {
|
||||
assert_eq!(
|
||||
ok("delete from Customers where id=1"),
|
||||
Command::Delete {
|
||||
table: "Customers".to_string(),
|
||||
filter: RowFilter::Where {
|
||||
column: "id".to_string(),
|
||||
value: Value::Number("1".to_string()),
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_with_all_rows_flag() {
|
||||
assert_eq!(
|
||||
ok("delete from Customers --all-rows"),
|
||||
Command::Delete {
|
||||
table: "Customers".to_string(),
|
||||
filter: RowFilter::AllRows,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_without_where_or_flag_errors() {
|
||||
let e = err("delete from Customers");
|
||||
assert!(matches!(e, ParseError::Invalid { .. }), "got {e:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn show_data_command() {
|
||||
assert_eq!(
|
||||
ok("show data Customers"),
|
||||
Command::ShowData {
|
||||
name: "Customers".to_string()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drop_relationship_by_name() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
//! Shortid generation and validation.
|
||||
//!
|
||||
//! Per ADR-0005, the `shortid` user-facing type is a base58
|
||||
//! random identifier of 10–12 characters with no ambiguous
|
||||
//! glyphs (no `0`/`O`/`I`/`l`). The generator is small enough
|
||||
//! to live in the DSL crate alongside the type definition.
|
||||
|
||||
use rand::RngExt;
|
||||
|
||||
/// Base58 alphabet — Bitcoin-style. 0 / O / I / l are excluded
|
||||
/// because they are easily confused in print.
|
||||
const ALPHABET: &[u8; 58] =
|
||||
b"123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
|
||||
|
||||
const DEFAULT_LEN: usize = 10;
|
||||
|
||||
/// Length bounds accepted on user-supplied shortid values.
|
||||
pub const MIN_LEN: usize = 10;
|
||||
pub const MAX_LEN: usize = 12;
|
||||
|
||||
/// Generate a fresh shortid using thread-local RNG.
|
||||
#[must_use]
|
||||
pub fn generate() -> String {
|
||||
generate_len(DEFAULT_LEN)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
fn generate_len(len: usize) -> String {
|
||||
let mut rng = rand::rng();
|
||||
let mut out = String::with_capacity(len);
|
||||
for _ in 0..len {
|
||||
let idx = rng.random_range(0..ALPHABET.len());
|
||||
out.push(ALPHABET[idx] as char);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Validate a user-supplied shortid value.
|
||||
pub fn validate(value: &str) -> Result<(), String> {
|
||||
if value.len() < MIN_LEN || value.len() > MAX_LEN {
|
||||
return Err(format!(
|
||||
"shortid must be {MIN_LEN}–{MAX_LEN} characters; got {} character(s)",
|
||||
value.chars().count()
|
||||
));
|
||||
}
|
||||
for c in value.chars() {
|
||||
if !ALPHABET.contains(&(c as u8)) {
|
||||
return Err(format!(
|
||||
"shortid contains '{c}', which is not in the base58 alphabet \
|
||||
(no 0, O, I, or l; ASCII letters and digits otherwise)"
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn generated_ids_have_default_length() {
|
||||
let id = generate();
|
||||
assert_eq!(id.len(), DEFAULT_LEN);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_ids_use_only_base58_alphabet() {
|
||||
for _ in 0..100 {
|
||||
let id = generate();
|
||||
for c in id.chars() {
|
||||
assert!(
|
||||
ALPHABET.contains(&(c as u8)),
|
||||
"char {c:?} not in base58 alphabet"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_ids_are_not_all_identical() {
|
||||
// Probabilistically extremely unlikely with a good RNG;
|
||||
// catches a wholly broken generator (constant output).
|
||||
let a = generate();
|
||||
let b = generate();
|
||||
let c = generate();
|
||||
assert!(
|
||||
a != b || b != c,
|
||||
"all three generated ids were identical: {a}, {b}, {c}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_accepts_well_formed_values() {
|
||||
assert!(validate("23456789Ab").is_ok()); // 10 chars
|
||||
assert!(validate("23456789AbCD").is_ok()); // 12 chars
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_rejects_too_short_or_too_long() {
|
||||
let err = validate("short").unwrap_err();
|
||||
assert!(err.contains("characters"));
|
||||
let err = validate("waytoolongafornow").unwrap_err();
|
||||
assert!(err.contains("characters"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_rejects_ambiguous_glyphs() {
|
||||
for bad in ["0aaaaaaaaa", "Oaaaaaaaaa", "Iaaaaaaaaa", "laaaaaaaaa"] {
|
||||
let err = validate(bad).unwrap_err();
|
||||
assert!(err.contains("base58"), "for {bad}: {err}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,390 @@
|
||||
//! User-facing value literals for INSERT / UPDATE / DELETE.
|
||||
//!
|
||||
//! The parser produces a small `Value` enum carrying just the
|
||||
//! shape of the literal as written. Per-column-type validation
|
||||
//! happens at execute time, where the schema is known and
|
||||
//! errors can name the offending column.
|
||||
|
||||
use std::fmt;
|
||||
|
||||
use crate::dsl::shortid;
|
||||
use crate::dsl::types::Type;
|
||||
|
||||
/// A literal value as parsed from DSL input.
|
||||
///
|
||||
/// `Number` carries the original string so a literal like
|
||||
/// `3.14` can be stored as a decimal (TEXT) or a real (f64)
|
||||
/// depending on the destination column. The conversion happens
|
||||
/// in `bind_for_column`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Value {
|
||||
Number(String),
|
||||
Text(String),
|
||||
Bool(bool),
|
||||
Null,
|
||||
}
|
||||
|
||||
impl fmt::Display for Value {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Number(n) => f.write_str(n),
|
||||
Self::Text(s) => write!(f, "'{}'", s.replace('\'', "''")),
|
||||
Self::Bool(b) => f.write_str(if *b { "true" } else { "false" }),
|
||||
Self::Null => f.write_str("null"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Validated value ready to be bound as a parameter to a SQLite
|
||||
/// statement. Mirrors the storage choices made in ADR-0005.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum Bound {
|
||||
Integer(i64),
|
||||
Real(f64),
|
||||
Text(String),
|
||||
Null,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
||||
pub enum ValueError {
|
||||
#[error("column `{column}` expects {expected_human}, got {got}")]
|
||||
TypeMismatch {
|
||||
column: String,
|
||||
expected_human: String,
|
||||
got: String,
|
||||
},
|
||||
#[error("column `{column}`: {message}")]
|
||||
Format { column: String, message: String },
|
||||
}
|
||||
|
||||
impl Value {
|
||||
/// Validate `self` against `column`'s user-facing type and
|
||||
/// produce a value ready for binding.
|
||||
pub fn bind_for_column(&self, column: &str, ty: Type) -> Result<Bound, ValueError> {
|
||||
if matches!(self, Self::Null) {
|
||||
return Ok(Bound::Null);
|
||||
}
|
||||
match ty {
|
||||
Type::Text | Type::ShortId => self.bind_text(column, ty),
|
||||
Type::Int | Type::Serial => self.bind_int(column, ty),
|
||||
Type::Real => self.bind_real(column),
|
||||
Type::Decimal => self.bind_decimal(column),
|
||||
Type::Bool => self.bind_bool(column),
|
||||
Type::Date => self.bind_date(column),
|
||||
Type::DateTime => self.bind_datetime(column),
|
||||
Type::Blob => Err(ValueError::Format {
|
||||
column: column.to_string(),
|
||||
message: "literal `blob` values are not supported in DSL yet".to_string(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn bind_text(&self, column: &str, ty: Type) -> Result<Bound, ValueError> {
|
||||
match self {
|
||||
Self::Text(s) => {
|
||||
if ty == Type::ShortId {
|
||||
shortid::validate(s).map_err(|message| ValueError::Format {
|
||||
column: column.to_string(),
|
||||
message,
|
||||
})?;
|
||||
}
|
||||
Ok(Bound::Text(s.clone()))
|
||||
}
|
||||
other => Err(ValueError::TypeMismatch {
|
||||
column: column.to_string(),
|
||||
expected_human: format!("a quoted string for `{ty}`"),
|
||||
got: other.kind_name().to_string(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn bind_int(&self, column: &str, ty: Type) -> Result<Bound, ValueError> {
|
||||
match self {
|
||||
Self::Number(n) => n
|
||||
.parse::<i64>()
|
||||
.map(Bound::Integer)
|
||||
.map_err(|_| ValueError::Format {
|
||||
column: column.to_string(),
|
||||
message: format!("`{n}` is not a valid {ty} (whole number expected)"),
|
||||
}),
|
||||
other => Err(ValueError::TypeMismatch {
|
||||
column: column.to_string(),
|
||||
expected_human: format!("a whole number for `{ty}`"),
|
||||
got: other.kind_name().to_string(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn bind_real(&self, column: &str) -> Result<Bound, ValueError> {
|
||||
match self {
|
||||
Self::Number(n) => n
|
||||
.parse::<f64>()
|
||||
.map(Bound::Real)
|
||||
.map_err(|_| ValueError::Format {
|
||||
column: column.to_string(),
|
||||
message: format!("`{n}` is not a valid real number"),
|
||||
}),
|
||||
other => Err(ValueError::TypeMismatch {
|
||||
column: column.to_string(),
|
||||
expected_human: "a real number".to_string(),
|
||||
got: other.kind_name().to_string(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn bind_decimal(&self, column: &str) -> Result<Bound, ValueError> {
|
||||
match self {
|
||||
Self::Number(n) => {
|
||||
// Validate parse-ability so a typo like `3..14` is rejected;
|
||||
// we still store the original string to preserve precision.
|
||||
if n.parse::<f64>().is_err() {
|
||||
return Err(ValueError::Format {
|
||||
column: column.to_string(),
|
||||
message: format!("`{n}` is not a valid decimal number"),
|
||||
});
|
||||
}
|
||||
Ok(Bound::Text(n.clone()))
|
||||
}
|
||||
other => Err(ValueError::TypeMismatch {
|
||||
column: column.to_string(),
|
||||
expected_human: "a decimal number".to_string(),
|
||||
got: other.kind_name().to_string(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn bind_bool(&self, column: &str) -> Result<Bound, ValueError> {
|
||||
match self {
|
||||
Self::Bool(b) => Ok(Bound::Integer(i64::from(*b))),
|
||||
other => Err(ValueError::TypeMismatch {
|
||||
column: column.to_string(),
|
||||
expected_human: "`true` or `false`".to_string(),
|
||||
got: other.kind_name().to_string(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn bind_date(&self, column: &str) -> Result<Bound, ValueError> {
|
||||
match self {
|
||||
Self::Text(s) => {
|
||||
validate_date(s).map_err(|message| ValueError::Format {
|
||||
column: column.to_string(),
|
||||
message,
|
||||
})?;
|
||||
Ok(Bound::Text(s.clone()))
|
||||
}
|
||||
other => Err(ValueError::TypeMismatch {
|
||||
column: column.to_string(),
|
||||
expected_human: "a quoted date 'YYYY-MM-DD'".to_string(),
|
||||
got: other.kind_name().to_string(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn bind_datetime(&self, column: &str) -> Result<Bound, ValueError> {
|
||||
match self {
|
||||
Self::Text(s) => {
|
||||
validate_datetime(s).map_err(|message| ValueError::Format {
|
||||
column: column.to_string(),
|
||||
message,
|
||||
})?;
|
||||
Ok(Bound::Text(s.clone()))
|
||||
}
|
||||
other => Err(ValueError::TypeMismatch {
|
||||
column: column.to_string(),
|
||||
expected_human: "a quoted datetime 'YYYY-MM-DDTHH:MM:SS'".to_string(),
|
||||
got: other.kind_name().to_string(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
const fn kind_name(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Number(_) => "number",
|
||||
Self::Text(_) => "string",
|
||||
Self::Bool(_) => "boolean",
|
||||
Self::Null => "null",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_date(s: &str) -> Result<(), String> {
|
||||
// Expect YYYY-MM-DD: 10 chars, two dashes at fixed positions.
|
||||
let bytes = s.as_bytes();
|
||||
if bytes.len() != 10 || bytes[4] != b'-' || bytes[7] != b'-' {
|
||||
return Err(format!(
|
||||
"`{s}` is not a date in `YYYY-MM-DD` form"
|
||||
));
|
||||
}
|
||||
let year = parse_digits(&s[0..4]).ok_or_else(|| format!("`{s}`: invalid year"))?;
|
||||
let month = parse_digits(&s[5..7]).ok_or_else(|| format!("`{s}`: invalid month"))?;
|
||||
let day = parse_digits(&s[8..10]).ok_or_else(|| format!("`{s}`: invalid day"))?;
|
||||
if !(1..=9999).contains(&year) {
|
||||
return Err(format!("`{s}`: year {year} out of range 1..=9999"));
|
||||
}
|
||||
if !(1..=12).contains(&month) {
|
||||
return Err(format!("`{s}`: month {month} out of range 1..=12"));
|
||||
}
|
||||
if !(1..=31).contains(&day) {
|
||||
return Err(format!("`{s}`: day {day} out of range 1..=31"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_datetime(s: &str) -> Result<(), String> {
|
||||
// Minimum: YYYY-MM-DDTHH:MM:SS = 19 chars. Allow optional
|
||||
// fractional seconds (.fff) and optional Z or ±HH:MM offset.
|
||||
if s.len() < 19 {
|
||||
return Err(format!(
|
||||
"`{s}` is too short for a datetime in `YYYY-MM-DDTHH:MM:SS` form"
|
||||
));
|
||||
}
|
||||
let date_part = &s[0..10];
|
||||
validate_date(date_part)?;
|
||||
let bytes = s.as_bytes();
|
||||
if bytes[10] != b'T' {
|
||||
return Err(format!("`{s}`: missing `T` separator between date and time"));
|
||||
}
|
||||
if bytes[13] != b':' || bytes[16] != b':' {
|
||||
return Err(format!("`{s}`: time portion must be `HH:MM:SS`"));
|
||||
}
|
||||
let hour = parse_digits(&s[11..13]).ok_or_else(|| format!("`{s}`: invalid hour"))?;
|
||||
let min = parse_digits(&s[14..16]).ok_or_else(|| format!("`{s}`: invalid minute"))?;
|
||||
let sec = parse_digits(&s[17..19]).ok_or_else(|| format!("`{s}`: invalid second"))?;
|
||||
if hour > 23 {
|
||||
return Err(format!("`{s}`: hour {hour} out of range 0..=23"));
|
||||
}
|
||||
if min > 59 {
|
||||
return Err(format!("`{s}`: minute {min} out of range 0..=59"));
|
||||
}
|
||||
if sec > 60 {
|
||||
return Err(format!(
|
||||
"`{s}`: second {sec} out of range 0..=60 (60 allowed for leap second)"
|
||||
));
|
||||
}
|
||||
// Anything after position 19 is optional fractional / timezone
|
||||
// suffix; we don't strictly validate it here (a future iteration
|
||||
// can tighten this if needed).
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_digits(s: &str) -> Option<u32> {
|
||||
if s.is_empty() || !s.chars().all(|c| c.is_ascii_digit()) {
|
||||
return None;
|
||||
}
|
||||
s.parse::<u32>().ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
fn n(s: &str) -> Value {
|
||||
Value::Number(s.to_string())
|
||||
}
|
||||
fn t(s: &str) -> Value {
|
||||
Value::Text(s.to_string())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn null_binds_to_null_for_any_type() {
|
||||
for ty in Type::all() {
|
||||
// Skip blob — null still works there too.
|
||||
assert_eq!(Value::Null.bind_for_column("c", *ty).unwrap(), Bound::Null);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn integer_for_int_column() {
|
||||
assert_eq!(n("42").bind_for_column("c", Type::Int).unwrap(), Bound::Integer(42));
|
||||
assert_eq!(n("-7").bind_for_column("c", Type::Int).unwrap(), Bound::Integer(-7));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_integer_for_int_column_is_format_error() {
|
||||
let err = n("3.14").bind_for_column("c", Type::Int).unwrap_err();
|
||||
match err {
|
||||
ValueError::Format { message, .. } => assert!(message.contains("whole number")),
|
||||
other => panic!("unexpected: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn string_for_int_column_is_type_mismatch() {
|
||||
let err = t("hello").bind_for_column("c", Type::Int).unwrap_err();
|
||||
assert!(matches!(err, ValueError::TypeMismatch { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_for_text_column() {
|
||||
assert_eq!(
|
||||
t("hi").bind_for_column("c", Type::Text).unwrap(),
|
||||
Bound::Text("hi".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shortid_validation_runs_on_text_for_shortid_column() {
|
||||
let err = t("toolong_xyz_more").bind_for_column("c", Type::ShortId).unwrap_err();
|
||||
assert!(matches!(err, ValueError::Format { .. }));
|
||||
|
||||
// Well-formed shortid binds fine.
|
||||
assert_eq!(
|
||||
t("23456789Ab").bind_for_column("c", Type::ShortId).unwrap(),
|
||||
Bound::Text("23456789Ab".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bool_for_bool_column_maps_to_zero_or_one() {
|
||||
assert_eq!(Value::Bool(true).bind_for_column("c", Type::Bool).unwrap(), Bound::Integer(1));
|
||||
assert_eq!(Value::Bool(false).bind_for_column("c", Type::Bool).unwrap(), Bound::Integer(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn date_iso_only() {
|
||||
assert_eq!(
|
||||
t("2025-01-15").bind_for_column("c", Type::Date).unwrap(),
|
||||
Bound::Text("2025-01-15".to_string())
|
||||
);
|
||||
let err = t("2025/01/15").bind_for_column("c", Type::Date).unwrap_err();
|
||||
assert!(matches!(err, ValueError::Format { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn date_range_check() {
|
||||
let err = t("2025-13-01").bind_for_column("c", Type::Date).unwrap_err();
|
||||
assert!(matches!(err, ValueError::Format { message, .. } if message.contains("month")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn datetime_iso_only() {
|
||||
assert_eq!(
|
||||
t("2025-01-15T14:30:00")
|
||||
.bind_for_column("c", Type::DateTime)
|
||||
.unwrap(),
|
||||
Bound::Text("2025-01-15T14:30:00".to_string())
|
||||
);
|
||||
let err = t("2025-01-15 14:30:00")
|
||||
.bind_for_column("c", Type::DateTime)
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, ValueError::Format { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decimal_validates_numeric_string() {
|
||||
assert_eq!(
|
||||
n("3.14").bind_for_column("c", Type::Decimal).unwrap(),
|
||||
Bound::Text("3.14".to_string())
|
||||
);
|
||||
let err = n("3..14").bind_for_column("c", Type::Decimal).unwrap_err();
|
||||
assert!(matches!(err, ValueError::Format { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blob_inserts_are_explicitly_unsupported_for_now() {
|
||||
let err = t("0xdead").bind_for_column("c", Type::Blob).unwrap_err();
|
||||
assert!(matches!(err, ValueError::Format { message, .. } if message.contains("blob")));
|
||||
}
|
||||
}
|
||||
+15
-1
@@ -7,7 +7,7 @@
|
||||
|
||||
use crossterm::event::KeyEvent;
|
||||
|
||||
use crate::db::TableDescription;
|
||||
use crate::db::{DataResult, DeleteResult, InsertResult, TableDescription, UpdateResult};
|
||||
use crate::dsl::Command;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -25,6 +25,20 @@ pub enum AppEvent {
|
||||
command: Command,
|
||||
description: Option<TableDescription>,
|
||||
},
|
||||
/// A `show data` query succeeded.
|
||||
DslDataSucceeded { command: Command, data: DataResult },
|
||||
DslInsertSucceeded {
|
||||
command: Command,
|
||||
result: InsertResult,
|
||||
},
|
||||
DslUpdateSucceeded {
|
||||
command: Command,
|
||||
result: UpdateResult,
|
||||
},
|
||||
DslDeleteSucceeded {
|
||||
command: Command,
|
||||
result: DeleteResult,
|
||||
},
|
||||
/// A DSL command failed. `error` is already a friendly
|
||||
/// message produced via `DbError::friendly_message`.
|
||||
DslFailed {
|
||||
|
||||
+64
-9
@@ -26,7 +26,9 @@ use tracing::{debug, error, info, warn};
|
||||
|
||||
use crate::action::Action;
|
||||
use crate::app::App;
|
||||
use crate::db::{Database, DbError, TableDescription};
|
||||
use crate::db::{
|
||||
DataResult, Database, DbError, DeleteResult, InsertResult, TableDescription, UpdateResult,
|
||||
};
|
||||
use crate::dsl::Command;
|
||||
use crate::event::AppEvent;
|
||||
use crate::theme::Theme;
|
||||
@@ -121,10 +123,26 @@ fn spawn_dsl_dispatch(
|
||||
tokio::spawn(async move {
|
||||
let outcome = execute_command(&database, command.clone()).await;
|
||||
let event = match outcome {
|
||||
Ok(description) => AppEvent::DslSucceeded {
|
||||
Ok(CommandOutcome::Schema(description)) => AppEvent::DslSucceeded {
|
||||
command: command.clone(),
|
||||
description,
|
||||
},
|
||||
Ok(CommandOutcome::Query(data)) => AppEvent::DslDataSucceeded {
|
||||
command: command.clone(),
|
||||
data,
|
||||
},
|
||||
Ok(CommandOutcome::Insert(result)) => AppEvent::DslInsertSucceeded {
|
||||
command: command.clone(),
|
||||
result,
|
||||
},
|
||||
Ok(CommandOutcome::Update(result)) => AppEvent::DslUpdateSucceeded {
|
||||
command: command.clone(),
|
||||
result,
|
||||
},
|
||||
Ok(CommandOutcome::Delete(result)) => AppEvent::DslDeleteSucceeded {
|
||||
command: command.clone(),
|
||||
result,
|
||||
},
|
||||
Err(error) => AppEvent::DslFailed {
|
||||
command: command.clone(),
|
||||
error,
|
||||
@@ -141,15 +159,23 @@ fn spawn_dsl_dispatch(
|
||||
Ok(tables) => {
|
||||
let _ = event_tx.send(AppEvent::TablesRefreshed(tables)).await;
|
||||
}
|
||||
Err(e) => warn!(error = %e, "post-DDL list_tables failed"),
|
||||
Err(e) => warn!(error = %e, "post-list_tables failed"),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
enum CommandOutcome {
|
||||
Schema(Option<TableDescription>),
|
||||
Query(DataResult),
|
||||
Insert(InsertResult),
|
||||
Update(UpdateResult),
|
||||
Delete(DeleteResult),
|
||||
}
|
||||
|
||||
async fn execute_command(
|
||||
database: &Database,
|
||||
command: Command,
|
||||
) -> Result<Option<TableDescription>, String> {
|
||||
) -> Result<CommandOutcome, String> {
|
||||
match command {
|
||||
Command::CreateTable {
|
||||
name,
|
||||
@@ -158,17 +184,17 @@ async fn execute_command(
|
||||
} => database
|
||||
.create_table(name, columns, primary_key)
|
||||
.await
|
||||
.map(Some)
|
||||
.map(|d| CommandOutcome::Schema(Some(d)))
|
||||
.map_err(friendly),
|
||||
Command::DropTable { name } => database
|
||||
.drop_table(name)
|
||||
.await
|
||||
.map(|()| None)
|
||||
.map(|()| CommandOutcome::Schema(None))
|
||||
.map_err(friendly),
|
||||
Command::AddColumn { table, column, ty } => database
|
||||
.add_column(table, column, ty)
|
||||
.await
|
||||
.map(Some)
|
||||
.map(|d| CommandOutcome::Schema(Some(d)))
|
||||
.map_err(friendly),
|
||||
Command::AddRelationship {
|
||||
name,
|
||||
@@ -191,16 +217,45 @@ async fn execute_command(
|
||||
create_fk,
|
||||
)
|
||||
.await
|
||||
.map(Some)
|
||||
.map(|d| CommandOutcome::Schema(Some(d)))
|
||||
.map_err(friendly),
|
||||
Command::DropRelationship { selector } => database
|
||||
.drop_relationship(selector)
|
||||
.await
|
||||
.map(CommandOutcome::Schema)
|
||||
.map_err(friendly),
|
||||
Command::ShowTable { name } => database
|
||||
.describe_table(name)
|
||||
.await
|
||||
.map(Some)
|
||||
.map(|d| CommandOutcome::Schema(Some(d)))
|
||||
.map_err(friendly),
|
||||
Command::Insert {
|
||||
table,
|
||||
columns,
|
||||
values,
|
||||
} => database
|
||||
.insert(table, columns, values)
|
||||
.await
|
||||
.map(CommandOutcome::Insert)
|
||||
.map_err(friendly),
|
||||
Command::Update {
|
||||
table,
|
||||
assignments,
|
||||
filter,
|
||||
} => database
|
||||
.update(table, assignments, filter)
|
||||
.await
|
||||
.map(CommandOutcome::Update)
|
||||
.map_err(friendly),
|
||||
Command::Delete { table, filter } => database
|
||||
.delete(table, filter)
|
||||
.await
|
||||
.map(CommandOutcome::Delete)
|
||||
.map_err(friendly),
|
||||
Command::ShowData { name } => database
|
||||
.query_data(name)
|
||||
.await
|
||||
.map(CommandOutcome::Query)
|
||||
.map_err(friendly),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,31 +129,73 @@ fn render_output_panel(app: &mut App, theme: &Theme, frame: &mut Frame<'_>, area
|
||||
vertical: 1,
|
||||
});
|
||||
|
||||
// Show a window of the buffer ending `output_scroll` lines
|
||||
// above the most recent entry. With scroll == 0 the last
|
||||
// line shown is the most recent; PageUp increases the
|
||||
// scroll, revealing older lines. We report the visible row
|
||||
// count back to App so input handling can cap scroll
|
||||
// correctly between renders (otherwise scroll could drift
|
||||
// past the top and slide the window off).
|
||||
// Render every output line into a wrapped Paragraph and let
|
||||
// ratatui handle the wrapping; we then use the wrapped row
|
||||
// count to cap scroll correctly. Bottom-anchoring (most
|
||||
// recent visible by default) is achieved by computing the
|
||||
// scroll offset relative to the bottom of the wrapped view.
|
||||
let visible = inner.height as usize;
|
||||
app.note_output_viewport(visible);
|
||||
let total = app.output.len();
|
||||
let max_scroll = total.saturating_sub(visible);
|
||||
let effective_scroll = app.output_scroll.min(max_scroll);
|
||||
let end = total - effective_scroll;
|
||||
let start = end.saturating_sub(visible);
|
||||
|
||||
// Compute the total wrapped row count first, working from
|
||||
// OutputLines directly (so the borrow ends before the
|
||||
// mutable `note_output_viewport` call below).
|
||||
let total_wrapped = approximate_wrapped_rows_from_output(&app.output, inner.width);
|
||||
app.note_output_viewport(visible, total_wrapped);
|
||||
|
||||
let lines: Vec<Line<'_>> = app
|
||||
.output
|
||||
.iter()
|
||||
.skip(start)
|
||||
.take(end - start)
|
||||
.map(|line| render_output_line(line, theme))
|
||||
.collect();
|
||||
let paragraph = Paragraph::new(lines).wrap(Wrap { trim: false });
|
||||
|
||||
let max_scroll = total_wrapped.saturating_sub(visible);
|
||||
let effective_scroll = app.output_scroll.min(max_scroll);
|
||||
// Paragraph::scroll((y, _)) sets the topmost visible row.
|
||||
// We want bottom-anchored: y = max_scroll - effective_scroll
|
||||
// so scroll==0 shows the bottom and PageUp moves y down to
|
||||
// reveal older content.
|
||||
let scroll_y = max_scroll.saturating_sub(effective_scroll);
|
||||
let scroll_y_u16 = u16::try_from(scroll_y).unwrap_or(u16::MAX);
|
||||
|
||||
frame.render_widget(block, area);
|
||||
let paragraph = Paragraph::new(lines).wrap(Wrap { trim: false });
|
||||
frame.render_widget(paragraph, inner);
|
||||
frame.render_widget(paragraph.scroll((scroll_y_u16, 0)), inner);
|
||||
}
|
||||
|
||||
/// Approximate the number of display rows the output buffer
|
||||
/// will occupy after width-wrapping. Computed directly from
|
||||
/// `OutputLine`s (rather than the rendered `Line` objects) so
|
||||
/// the calculation can run before we hand `&mut App` to the
|
||||
/// scroll-cap update. Ratatui's exact `line_count` is gated
|
||||
/// behind an unstable feature; this character-based
|
||||
/// approximation is close enough for scroll capping — off-by-one
|
||||
/// at boundaries is acceptable.
|
||||
fn approximate_wrapped_rows_from_output(
|
||||
output: &std::collections::VecDeque<OutputLine>,
|
||||
width: u16,
|
||||
) -> usize {
|
||||
if width == 0 {
|
||||
return output.len();
|
||||
}
|
||||
let w = usize::from(width);
|
||||
output
|
||||
.iter()
|
||||
.map(|line| {
|
||||
// Tag width matches `render_output_line` exactly so
|
||||
// the row count is right when the panel is too narrow
|
||||
// for the natural line.
|
||||
let tag_len = match line.kind {
|
||||
OutputKind::Echo => match line.mode_at_submission {
|
||||
Mode::Simple => "[simple] ".len(),
|
||||
Mode::Advanced => "[advanced] ".len(),
|
||||
},
|
||||
OutputKind::System => "[system] ".len(),
|
||||
OutputKind::Error => "[error] ".len(),
|
||||
};
|
||||
let total = tag_len + line.text.chars().count();
|
||||
if total == 0 { 1 } else { total.div_ceil(w) }
|
||||
})
|
||||
.sum()
|
||||
}
|
||||
|
||||
fn render_output_line<'a>(line: &'a OutputLine, theme: &Theme) -> Line<'a> {
|
||||
|
||||
+111
-31
@@ -12,8 +12,10 @@ use ratatui::backend::TestBackend;
|
||||
|
||||
use rdbms_playground::action::Action;
|
||||
use rdbms_playground::app::{App, OutputKind};
|
||||
use rdbms_playground::db::{ColumnDescription, RelationshipEnd, TableDescription};
|
||||
use rdbms_playground::dsl::{ColumnSpec, Command, ReferentialAction, Type};
|
||||
use rdbms_playground::db::{
|
||||
ColumnDescription, DataResult, InsertResult, RelationshipEnd, TableDescription,
|
||||
};
|
||||
use rdbms_playground::dsl::{ColumnSpec, Command, ReferentialAction, RowFilter, Type, Value};
|
||||
use rdbms_playground::event::AppEvent;
|
||||
use rdbms_playground::mode::Mode;
|
||||
use rdbms_playground::theme::Theme;
|
||||
@@ -357,7 +359,7 @@ fn drop_table_flow_clears_items_list() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_relationship_flow_shows_outbound_section() {
|
||||
fn add_relationship_flow_shows_parent_side_with_inbound_section() {
|
||||
let mut app = App::new();
|
||||
type_str(
|
||||
&mut app,
|
||||
@@ -378,35 +380,28 @@ fn add_relationship_flow_shows_outbound_section() {
|
||||
})]
|
||||
);
|
||||
|
||||
// Simulate the runtime feeding back a description with the
|
||||
// outbound relationship populated.
|
||||
let orders = TableDescription {
|
||||
name: "Orders".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: "CustId".to_string(),
|
||||
user_type: Some(Type::Int),
|
||||
sqlite_type: "INTEGER".to_string(),
|
||||
notnull: false,
|
||||
primary_key: false,
|
||||
},
|
||||
],
|
||||
outbound_relationships: vec![RelationshipEnd {
|
||||
// The runtime now feeds back the parent (Customers) so the
|
||||
// user sees the new relationship via the "Referenced by"
|
||||
// section — same direction as the command's `from <Parent>`
|
||||
// reading.
|
||||
let customers = 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,
|
||||
}],
|
||||
outbound_relationships: Vec::new(),
|
||||
inbound_relationships: vec![RelationshipEnd {
|
||||
name: "Customers_Id_to_Orders_CustId".to_string(),
|
||||
other_table: "Customers".to_string(),
|
||||
other_column: "Id".to_string(),
|
||||
local_column: "CustId".to_string(),
|
||||
other_table: "Orders".to_string(),
|
||||
other_column: "CustId".to_string(),
|
||||
local_column: "Id".to_string(),
|
||||
on_delete: ReferentialAction::Cascade,
|
||||
on_update: ReferentialAction::NoAction,
|
||||
}],
|
||||
inbound_relationships: Vec::new(),
|
||||
};
|
||||
app.update(AppEvent::DslSucceeded {
|
||||
command: Command::AddRelationship {
|
||||
@@ -419,13 +414,19 @@ fn add_relationship_flow_shows_outbound_section() {
|
||||
on_update: ReferentialAction::NoAction,
|
||||
create_fk: false,
|
||||
},
|
||||
description: Some(orders),
|
||||
description: Some(customers),
|
||||
});
|
||||
|
||||
let rendered = rendered_text(&mut app, &Theme::dark(), 80, 24);
|
||||
assert!(rendered.contains("References:"), "{rendered}");
|
||||
assert!(rendered.contains("CustId → Customers.Id"), "{rendered}");
|
||||
assert!(rendered.contains("Referenced by:"), "{rendered}");
|
||||
assert!(rendered.contains("Orders.CustId"), "{rendered}");
|
||||
assert!(rendered.contains("on delete cascade"), "{rendered}");
|
||||
// The [ok] subject lists the endpoints. Long lines wrap in
|
||||
// the panel, so we check the first half of the phrase only.
|
||||
assert!(
|
||||
rendered.contains("from Customers.Id"),
|
||||
"{rendered}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -463,6 +464,85 @@ fn add_relationship_flow_shows_inbound_section_on_parent() {
|
||||
assert!(rendered.contains("Orders.CustId → Id"), "{rendered}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_flow_emits_action_and_renders_data() {
|
||||
let mut app = App::new();
|
||||
|
||||
type_str(&mut app, "insert into Customers values ('Alice')");
|
||||
let actions = submit(&mut app);
|
||||
assert_eq!(
|
||||
actions,
|
||||
vec![Action::ExecuteDsl(Command::Insert {
|
||||
table: "Customers".to_string(),
|
||||
columns: None,
|
||||
values: vec![Value::Text("Alice".to_string())],
|
||||
})]
|
||||
);
|
||||
|
||||
// Simulate the runtime feeding back an InsertResult.
|
||||
let data = DataResult {
|
||||
table_name: "Customers".to_string(),
|
||||
columns: vec!["id".to_string(), "Name".to_string()],
|
||||
rows: vec![vec![Some("1".to_string()), Some("Alice".to_string())]],
|
||||
};
|
||||
app.update(AppEvent::DslInsertSucceeded {
|
||||
command: Command::Insert {
|
||||
table: "Customers".to_string(),
|
||||
columns: None,
|
||||
values: vec![Value::Text("Alice".to_string())],
|
||||
},
|
||||
result: InsertResult {
|
||||
rows_affected: 1,
|
||||
data,
|
||||
},
|
||||
});
|
||||
let rendered = rendered_text(&mut app, &Theme::dark(), 80, 24);
|
||||
assert!(
|
||||
rendered.contains("1 row(s) inserted"),
|
||||
"should show row count:\n{rendered}"
|
||||
);
|
||||
assert!(
|
||||
rendered.contains("Alice"),
|
||||
"should auto-show new row:\n{rendered}"
|
||||
);
|
||||
assert!(
|
||||
rendered.contains("id") && rendered.contains("Name"),
|
||||
"should show column headers:\n{rendered}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_with_all_rows_emits_correct_action() {
|
||||
let mut app = App::new();
|
||||
type_str(&mut app, "delete from Customers --all-rows");
|
||||
let actions = submit(&mut app);
|
||||
assert_eq!(
|
||||
actions,
|
||||
vec![Action::ExecuteDsl(Command::Delete {
|
||||
table: "Customers".to_string(),
|
||||
filter: RowFilter::AllRows,
|
||||
})]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn show_data_for_empty_table_renders_placeholder() {
|
||||
let mut app = App::new();
|
||||
let data = DataResult {
|
||||
table_name: "Customers".to_string(),
|
||||
columns: vec!["id".to_string(), "Name".to_string()],
|
||||
rows: Vec::new(),
|
||||
};
|
||||
app.update(AppEvent::DslDataSucceeded {
|
||||
command: Command::ShowData {
|
||||
name: "Customers".to_string(),
|
||||
},
|
||||
data,
|
||||
});
|
||||
let rendered = rendered_text(&mut app, &Theme::dark(), 80, 24);
|
||||
assert!(rendered.contains("(no rows)"), "{rendered}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dsl_failure_shows_friendly_error_in_output() {
|
||||
let mut app = App::new();
|
||||
|
||||
Reference in New Issue
Block a user