Foreign-key relationships, rebuild-table, polish round

DSL:
- add 1:n relationship [as <name>] from <P>.<col> to <C>.<col>
  [on delete <action>] [on update <action>] [--create-fk]
- drop relationship <name> | from <P>.<col> to <C>.<col>
- show table <name> for re-displaying a structure on demand

Database (ADR-0013):
- Rebuild-table primitive following SQLite's
  ALTER-via-rebuild recipe (foreign_keys=OFF outside tx,
  copy-by-name, foreign_key_check before commit). Reusable for
  B2 (column drops/renames/type changes).
- ReferentialAction enum (no action / restrict / set null /
  cascade); SET DEFAULT awaits column DEFAULTs.
- __rdbms_playground_relationships metadata table -- names,
  auto-generated as <Parent>_<pcol>_to_<Child>_<ccol>.
- Type::fk_target_type() validation at declaration; friendly
  errors for type mismatch, non-PK target, missing column,
  duplicate name.
- describe_table populates symmetric outbound + inbound
  relationship lists. drop_table refuses while inbound
  references exist; outbound metadata cleaned up alongside drop.

App / UI:
- In-line cursor editing in the input field: Left, Right,
  Home, End, Delete, Backspace honoring UTF-8 boundaries.
- PageUp / PageDown scrolls the output buffer; viewport row
  count fed back from the renderer via App::note_output_viewport
  so scroll is capped against the actual visible area
  (regression-tested) and snaps to the bottom on new output.
- Failure messages quote the command portion ("verb target"
  failed: ...) for visual clarity; RelationshipSelector has a
  proper Display impl so "no such relationship" reads cleanly.
- Structure rendering shows References / Referenced by sections.

Docs:
- ADR-0013 covers naming, metadata table, symmetric view, and
  the rebuild-table strategy.
- requirements.md updates: C3 (FK done), B2 (primitive in),
  T3 (compound-PK FK still pending). New entries: I1a (cursor
  editing -- landed), I1b (Ctrl-A/E and readline shortcuts --
  pending), V4 partial scroll, V5 (show family), C3a (modify
  relationship -- deferred).

Tests: 154 passing (140 lib + 14 integration), 0 skipped.
Clippy clean with nursery enabled.
This commit is contained in:
claude@clouddev1
2026-05-07 14:52:51 +00:00
parent c1e52920eb
commit 165068269b
12 changed files with 2632 additions and 56 deletions
+409 -4
View File
@@ -59,6 +59,9 @@ impl EffectiveMode {
pub struct App {
pub mode: Mode,
pub input: String,
/// Byte offset into `input` where the next character will be
/// inserted. Always lies on a UTF-8 character boundary.
pub input_cursor: usize,
pub output: VecDeque<OutputLine>,
pub hint: Option<String>,
pub tables: Vec<String>,
@@ -77,8 +80,24 @@ pub struct App {
/// start navigating history, restored if they navigate back
/// past the most recent entry.
history_draft: Option<String>,
/// Number of lines from the bottom we've scrolled up. `0`
/// means "showing the most recent lines"; positive values
/// reveal older lines. Reset to `0` whenever a new output
/// line is appended so newly-arrived results are always
/// visible after a command. The full V4 session-log spec
/// supersedes this; we ship a minimal subset now to address
/// the immediate "ran out of space" UX problem.
pub output_scroll: usize,
/// The most recent visible-row count of the output panel,
/// reported by the renderer. Used to cap `output_scroll` —
/// without this, scrolling past `len - visible` would slide
/// the visible window off the top of the buffer and shrink
/// what the user sees.
pub last_output_visible: usize,
}
const PAGE_SCROLL_LINES: usize = 5;
const HISTORY_CAPACITY: usize = 1000;
impl Default for App {
@@ -93,6 +112,7 @@ impl App {
Self {
mode: Mode::Simple,
input: String::new(),
input_cursor: 0,
output: VecDeque::with_capacity(OUTPUT_CAPACITY),
hint: None,
tables: Vec::new(),
@@ -100,6 +120,22 @@ impl App {
history: Vec::new(),
history_cursor: None,
history_draft: None,
output_scroll: 0,
last_output_visible: 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) {
self.last_output_visible = visible_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);
if self.output_scroll > max {
self.output_scroll = max;
}
}
@@ -161,21 +197,50 @@ impl App {
self.history_forward();
Vec::new()
}
(KeyCode::Left, _) => {
self.cursor_left();
Vec::new()
}
(KeyCode::Right, _) => {
self.cursor_right();
Vec::new()
}
(KeyCode::Home, _) => {
self.input_cursor = 0;
Vec::new()
}
(KeyCode::End, _) => {
self.input_cursor = self.input.len();
Vec::new()
}
(KeyCode::Backspace, _) => {
self.cancel_history_navigation();
self.input.pop();
self.delete_before_cursor();
Vec::new()
}
(KeyCode::Delete, _) => {
self.cancel_history_navigation();
self.delete_at_cursor();
Vec::new()
}
(KeyCode::PageUp, _) => {
self.scroll_output_up();
Vec::new()
}
(KeyCode::PageDown, _) => {
self.scroll_output_down();
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);
self.insert_at_cursor(c);
// Convenience: when `:` becomes the leading character in
// simple mode, auto-insert a space after it so the input
// reads ": foo" rather than ":foo". The trailing space is
// an ordinary character — backspace removes it normally.
if c == ':' && was_empty && self.mode == Mode::Simple {
self.input.push(' ');
self.insert_at_cursor(' ');
}
Vec::new()
}
@@ -183,6 +248,65 @@ impl App {
}
}
fn cursor_left(&mut self) {
let mut idx = self.input_cursor;
while idx > 0 {
idx -= 1;
if self.input.is_char_boundary(idx) {
self.input_cursor = idx;
return;
}
}
self.input_cursor = 0;
}
fn cursor_right(&mut self) {
let mut idx = self.input_cursor;
while idx < self.input.len() {
idx += 1;
if self.input.is_char_boundary(idx) {
self.input_cursor = idx;
return;
}
}
self.input_cursor = self.input.len();
}
fn insert_at_cursor(&mut self, c: char) {
// Defensive clamp: callers (and tests) may mutate
// `input` directly; keep the cursor inside the buffer.
if self.input_cursor > self.input.len() {
self.input_cursor = self.input.len();
}
self.input.insert(self.input_cursor, c);
self.input_cursor += c.len_utf8();
}
fn delete_before_cursor(&mut self) {
if self.input_cursor == 0 {
return;
}
// Find the start of the previous character.
let mut idx = self.input_cursor - 1;
while !self.input.is_char_boundary(idx) {
idx -= 1;
}
self.input.replace_range(idx..self.input_cursor, "");
self.input_cursor = idx;
}
fn delete_at_cursor(&mut self) {
if self.input_cursor >= self.input.len() {
return;
}
// Find the end of the character at the cursor.
let mut idx = self.input_cursor + 1;
while idx < self.input.len() && !self.input.is_char_boundary(idx) {
idx += 1;
}
self.input.replace_range(self.input_cursor..idx, "");
}
/// Move backwards in history (towards older entries).
fn history_back(&mut self) {
if self.history.is_empty() {
@@ -200,6 +324,7 @@ impl App {
};
self.history_cursor = Some(next_index);
self.input = self.history[next_index].clone();
self.input_cursor = self.input.len();
}
/// Move forwards in history (towards newer entries; eventually
@@ -217,6 +342,7 @@ impl App {
self.history_cursor = None;
self.input = self.history_draft.take().unwrap_or_default();
}
self.input_cursor = self.input.len();
}
fn cancel_history_navigation(&mut self) {
@@ -245,6 +371,7 @@ impl App {
fn submit(&mut self) -> Vec<Action> {
let raw = std::mem::take(&mut self.input);
self.input_cursor = 0;
let trimmed = raw.trim();
if trimmed.is_empty() {
return Vec::new();
@@ -339,14 +466,45 @@ impl App {
col.name, type_display, pk, nn
));
}
if !desc.outbound_relationships.is_empty() {
self.note_system(" References:");
for r in &desc.outbound_relationships {
self.note_system(format!(
" {}{}.{} ({}, on delete {}, on update {})",
r.local_column,
r.other_table,
r.other_column,
r.name,
r.on_delete,
r.on_update,
));
}
}
if !desc.inbound_relationships.is_empty() {
self.note_system(" Referenced by:");
for r in &desc.inbound_relationships {
self.note_system(format!(
" {}.{}{} ({}, on delete {}, on update {})",
r.other_table,
r.other_column,
r.local_column,
r.name,
r.on_delete,
r.on_update,
));
}
}
}
self.current_table = description;
}
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
// reads cleanly: "...failed: <reason>" rather than the
// command running into "failed: ..." with no break.
self.note_error(format!(
"{} {} failed: {error}",
"\"{} {}\" failed: {error}",
command.verb(),
command.target_table()
));
@@ -391,6 +549,26 @@ impl App {
while self.output.len() > OUTPUT_CAPACITY {
self.output.pop_front();
}
// Any new line resets the scroll so freshly-arrived
// output is always visible. The user can PageUp again
// to inspect history.
self.output_scroll = 0;
}
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.
let max = self
.output
.len()
.saturating_sub(self.last_output_visible.max(1));
self.output_scroll = (self.output_scroll + PAGE_SCROLL_LINES).min(max);
}
const fn scroll_output_down(&mut self) {
self.output_scroll = self.output_scroll.saturating_sub(PAGE_SCROLL_LINES);
}
}
@@ -437,6 +615,8 @@ mod tests {
notnull: false,
primary_key: true,
}],
outbound_relationships: Vec::new(),
inbound_relationships: Vec::new(),
}
}
@@ -720,6 +900,231 @@ mod tests {
);
}
#[test]
fn typing_moves_cursor_to_end_of_input() {
let mut app = App::new();
type_str(&mut app, "hello");
assert_eq!(app.input, "hello");
assert_eq!(app.input_cursor, 5);
}
#[test]
fn left_arrow_moves_cursor_back_one_char() {
let mut app = App::new();
type_str(&mut app, "hello");
app.update(key(KeyCode::Left));
assert_eq!(app.input_cursor, 4);
app.update(key(KeyCode::Left));
assert_eq!(app.input_cursor, 3);
}
#[test]
fn left_arrow_at_zero_does_not_underflow() {
let mut app = App::new();
app.update(key(KeyCode::Left));
assert_eq!(app.input_cursor, 0);
}
#[test]
fn right_arrow_moves_cursor_forward() {
let mut app = App::new();
type_str(&mut app, "hello");
app.input_cursor = 0;
app.update(key(KeyCode::Right));
assert_eq!(app.input_cursor, 1);
}
#[test]
fn home_and_end_jump_to_extremes() {
let mut app = App::new();
type_str(&mut app, "hello");
app.update(key(KeyCode::Home));
assert_eq!(app.input_cursor, 0);
app.update(key(KeyCode::End));
assert_eq!(app.input_cursor, 5);
}
#[test]
fn typing_inserts_at_cursor_position() {
let mut app = App::new();
type_str(&mut app, "hello");
// Cursor between 'h' and 'e'.
app.input_cursor = 1;
type_str(&mut app, "X");
assert_eq!(app.input, "hXello");
assert_eq!(app.input_cursor, 2);
}
#[test]
fn backspace_removes_char_before_cursor() {
let mut app = App::new();
type_str(&mut app, "hello");
// Cursor at end.
app.update(key(KeyCode::Backspace));
assert_eq!(app.input, "hell");
assert_eq!(app.input_cursor, 4);
// Cursor in the middle.
app.input_cursor = 2; // between 'e' and 'l'
app.update(key(KeyCode::Backspace));
assert_eq!(app.input, "hll");
assert_eq!(app.input_cursor, 1);
}
#[test]
fn backspace_at_start_is_a_noop() {
let mut app = App::new();
type_str(&mut app, "hello");
app.input_cursor = 0;
app.update(key(KeyCode::Backspace));
assert_eq!(app.input, "hello");
assert_eq!(app.input_cursor, 0);
}
#[test]
fn delete_removes_char_at_cursor() {
let mut app = App::new();
type_str(&mut app, "hello");
app.input_cursor = 1; // between 'h' and 'e'
app.update(key(KeyCode::Delete));
assert_eq!(app.input, "hllo");
assert_eq!(app.input_cursor, 1);
}
#[test]
fn delete_at_end_is_a_noop() {
let mut app = App::new();
type_str(&mut app, "hello");
app.update(key(KeyCode::Delete));
assert_eq!(app.input, "hello");
assert_eq!(app.input_cursor, 5);
}
#[test]
fn cursor_handles_multibyte_chars() {
let mut app = App::new();
type_str(&mut app, "héllo"); // 'é' is 2 bytes
// input length is 6 bytes, 5 chars
assert_eq!(app.input.len(), 6);
assert_eq!(app.input_cursor, 6);
// Move left across the 2-byte char.
app.update(key(KeyCode::Left));
assert_eq!(app.input_cursor, 5);
app.update(key(KeyCode::Left));
assert_eq!(app.input_cursor, 4);
app.update(key(KeyCode::Left));
assert_eq!(app.input_cursor, 3);
app.update(key(KeyCode::Left));
// Now at the byte before 'é' — must skip the multi-byte char.
assert_eq!(app.input_cursor, 1);
}
#[test]
fn submit_resets_cursor_to_zero() {
let mut app = App::new();
type_str(&mut app, "drop table T");
submit(&mut app);
assert_eq!(app.input_cursor, 0);
}
#[test]
fn page_up_scrolls_output_back() {
let mut app = App::new();
for i in 0..30 {
app.note_system(format!("line{i}"));
}
assert_eq!(app.output_scroll, 0);
app.update(key(KeyCode::PageUp));
assert_eq!(app.output_scroll, super::PAGE_SCROLL_LINES);
}
#[test]
fn page_down_scrolls_output_back_to_bottom() {
let mut app = App::new();
for i in 0..30 {
app.note_system(format!("line{i}"));
}
for _ in 0..3 {
app.update(key(KeyCode::PageUp));
}
assert!(app.output_scroll > 0);
for _ in 0..10 {
app.update(key(KeyCode::PageDown));
}
assert_eq!(app.output_scroll, 0);
}
#[test]
fn new_output_resets_scroll_to_zero() {
let mut app = App::new();
for i in 0..30 {
app.note_system(format!("line{i}"));
}
app.update(key(KeyCode::PageUp));
assert!(app.output_scroll > 0);
// Any new output line snaps the scroll back to bottom so
// the user always sees the latest result after a command.
app.note_system("fresh");
assert_eq!(app.output_scroll, 0);
}
#[test]
fn page_up_caps_at_top_of_buffer() {
let mut app = App::new();
app.note_system("only line");
// Many PageUps in a row should not push past the buffer.
for _ in 0..50 {
app.update(key(KeyCode::PageUp));
}
// With 1 line in the buffer, the maximum scroll is 0
// (since there's nothing older to reveal).
assert_eq!(app.output_scroll, 0);
}
#[test]
fn page_up_at_top_of_buffer_does_not_shrink_visible_window() {
// Regression: extra PageUps past the top used to drift
// `output_scroll` higher than `len - visible`, which
// then made the rendered window slide off the top and
// appeared to "eat" lines from the bottom.
let mut app = App::new();
for i in 0..30 {
app.note_system(format!("line{i}"));
}
// Simulate a render reporting 10 visible rows.
app.note_output_viewport(10);
// 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.
assert_eq!(app.output_scroll, 20);
}
#[test]
fn note_output_viewport_clamps_a_drifted_scroll_value() {
// If the scroll value was set high while the viewport
// was unknown (e.g. before the first render), the next
// render's report should bring it back into range.
let mut app = App::new();
for i in 0..30 {
app.note_system(format!("line{i}"));
}
app.output_scroll = 100;
app.note_output_viewport(10);
assert_eq!(app.output_scroll, 20);
}
#[test]
fn history_recall_places_cursor_at_end() {
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");
assert_eq!(app.input_cursor, "drop table A".len());
}
#[test]
fn history_records_submitted_lines() {
let mut app = App::new();
+1126 -1
View File
File diff suppressed because it is too large Load Diff
+154
View File
@@ -0,0 +1,154 @@
//! Referential actions for foreign-key relationships.
//!
//! These map directly onto SQLite's `ON DELETE` / `ON UPDATE`
//! clause vocabulary. `SET DEFAULT` is intentionally omitted
//! until column DEFAULTs (C3 partial) are supported.
use std::fmt;
use std::str::FromStr;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ReferentialAction {
/// Default — referenced rows can't be deleted while
/// referencing rows exist (deferred check).
NoAction,
/// Like NoAction but immediate.
Restrict,
/// On parent row delete/update, set the FK column to NULL.
SetNull,
/// On parent row delete/update, propagate to dependent rows.
Cascade,
}
impl ReferentialAction {
/// The user-facing keyword as it appears in DSL input. Note
/// `set null` is two words; the parser handles that as a
/// single phrase.
#[must_use]
pub const fn keyword(self) -> &'static str {
match self {
Self::NoAction => "no action",
Self::Restrict => "restrict",
Self::SetNull => "set null",
Self::Cascade => "cascade",
}
}
/// The corresponding SQL clause as written in DDL.
#[must_use]
pub const fn sql_clause(self) -> &'static str {
match self {
Self::NoAction => "NO ACTION",
Self::Restrict => "RESTRICT",
Self::SetNull => "SET NULL",
Self::Cascade => "CASCADE",
}
}
/// All actions, in stable order.
#[must_use]
pub const fn all() -> &'static [Self] {
&[Self::NoAction, Self::Restrict, Self::SetNull, Self::Cascade]
}
/// Default action when none is specified — matches the SQL
/// standard.
#[must_use]
pub const fn default_action() -> Self {
Self::NoAction
}
}
impl Default for ReferentialAction {
fn default() -> Self {
Self::default_action()
}
}
impl fmt::Display for ReferentialAction {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.keyword())
}
}
/// Error returned when parsing an unknown action keyword.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error("unknown referential action '{found}' (expected one of: {expected})")]
pub struct UnknownAction {
pub found: String,
pub expected: String,
}
impl FromStr for ReferentialAction {
type Err = UnknownAction;
fn from_str(s: &str) -> Result<Self, Self::Err> {
// Case-insensitive comparison; tolerates internal
// whitespace ("set null") by collapsing it.
let normalised: String = s
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
.to_ascii_lowercase();
for &action in Self::all() {
if normalised == action.keyword() {
return Ok(action);
}
}
Err(UnknownAction {
found: s.to_string(),
expected: Self::all()
.iter()
.map(|a| a.keyword())
.collect::<Vec<_>>()
.join(", "),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn keyword_round_trip() {
for &a in ReferentialAction::all() {
assert_eq!(a.keyword().parse::<ReferentialAction>().unwrap(), a);
}
}
#[test]
fn parsing_is_case_insensitive_and_whitespace_tolerant() {
assert_eq!(
"Cascade".parse::<ReferentialAction>().unwrap(),
ReferentialAction::Cascade
);
assert_eq!(
"SET NULL".parse::<ReferentialAction>().unwrap(),
ReferentialAction::SetNull
);
assert_eq!(
"set null".parse::<ReferentialAction>().unwrap(),
ReferentialAction::SetNull
);
assert_eq!(
"No Action".parse::<ReferentialAction>().unwrap(),
ReferentialAction::NoAction
);
}
#[test]
fn unknown_action_lists_alternatives() {
let err = "destroy".parse::<ReferentialAction>().unwrap_err();
assert_eq!(err.found, "destroy");
assert!(err.expected.contains("cascade"));
assert!(err.expected.contains("set null"));
}
#[test]
fn sql_clause_mapping() {
assert_eq!(ReferentialAction::SetNull.sql_clause(), "SET NULL");
assert_eq!(ReferentialAction::NoAction.sql_clause(), "NO ACTION");
}
}
+77 -3
View File
@@ -11,6 +11,7 @@
//! primary key`, junction-table convenience commands) emit into
//! the same shape.
use crate::dsl::action::ReferentialAction;
use crate::dsl::types::Type;
/// A column at table-creation time: a name and a user-facing
@@ -42,6 +43,64 @@ pub enum Command {
column: String,
ty: Type,
},
/// Establish a 1:n relationship: parent_table.parent_column
/// is the primary-key side; child_table.child_column is the
/// foreign-key side. `name` is optional — when `None`, the
/// executor auto-generates one (`<Child>_<column>_to_<Parent>`).
/// `create_fk` requests the child column be created
/// automatically with the appropriate type if it is missing.
AddRelationship {
name: Option<String>,
parent_table: String,
parent_column: String,
child_table: String,
child_column: String,
on_delete: ReferentialAction,
on_update: ReferentialAction,
create_fk: bool,
},
/// Drop a relationship by either user-given/auto-generated
/// name, or by positional reference to the FK endpoints.
DropRelationship {
selector: RelationshipSelector,
},
/// Re-display a table's structure in the output. Doesn't
/// change schema; useful when the user wants to look at a
/// table they aren't currently DDL'ing on.
ShowTable {
name: String,
},
}
/// How a `drop relationship` command identifies the relationship
/// to remove. Both forms are accepted; the executor resolves to
/// a single row in the metadata table.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RelationshipSelector {
Named { name: String },
Endpoints {
parent_table: String,
parent_column: String,
child_table: String,
child_column: String,
},
}
impl std::fmt::Display for RelationshipSelector {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Named { name } => write!(f, "{name}"),
Self::Endpoints {
parent_table,
parent_column,
child_table,
child_column,
} => write!(
f,
"from {parent_table}.{parent_column} to {child_table}.{child_column}"
),
}
}
}
impl Command {
@@ -52,16 +111,31 @@ impl Command {
Self::CreateTable { .. } => "create table",
Self::DropTable { .. } => "drop table",
Self::AddColumn { .. } => "add column",
Self::AddRelationship { .. } => "add relationship",
Self::DropRelationship { .. } => "drop relationship",
Self::ShowTable { .. } => "show table",
}
}
/// The table this command targets — every Command in this
/// iteration operates on exactly one table.
/// The table whose structure most directly reflects the
/// outcome of this command. For relationships this is the
/// child table, since the FK constraint physically belongs
/// there and our describe view shows both sides anyway.
#[must_use]
pub fn target_table(&self) -> &str {
match self {
Self::CreateTable { name, .. } | Self::DropTable { name } => name,
Self::CreateTable { name, .. }
| Self::DropTable { name }
| Self::ShowTable { name } => name,
Self::AddColumn { table, .. } => table,
Self::AddRelationship { child_table, .. } => child_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::Named { name } => name,
},
}
}
}
+3 -1
View File
@@ -9,10 +9,12 @@
//! this module — that path uses `sqlparser-rs` and lives
//! elsewhere when it lands.
pub mod action;
pub mod command;
pub mod parser;
pub mod types;
pub use command::{ColumnSpec, Command};
pub use action::ReferentialAction;
pub use command::{ColumnSpec, Command, RelationshipSelector};
pub use parser::{ParseError, parse_command};
pub use types::Type;
+374 -3
View File
@@ -13,7 +13,8 @@
use chumsky::error::RichReason;
use chumsky::prelude::*;
use crate::dsl::command::{ColumnSpec, Command};
use crate::dsl::action::ReferentialAction;
use crate::dsl::command::{ColumnSpec, Command, RelationshipSelector};
use crate::dsl::types::Type;
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
@@ -139,9 +140,178 @@ fn command_parser<'a>()
.then_ignore(just(')').padded())
.map(|((table, column), ty)| Command::AddColumn { table, column, ty });
choice((create_table, drop_table, add_column))
let add_relationship = add_relationship_parser();
let drop_relationship = drop_relationship_parser();
let show_table = keyword_ci("show")
.ignore_then(keyword_ci("table"))
.ignore_then(identifier())
.map(|name| Command::ShowTable { name });
choice((
create_table,
drop_table,
add_column,
add_relationship,
drop_relationship,
show_table,
))
.padded()
.then_ignore(end())
}
/// `add 1:n relationship [<name>] from <P>.<col> to <C>.<col>
/// [on delete <action>] [on update <action>] [--create-fk]`.
fn add_relationship_parser<'a>()
-> impl Parser<'a, &'a str, Command, extra::Err<Rich<'a, char>>> + Clone {
let one_to_n = just('1').padded().ignore_then(just(':').padded()).ignore_then(
any()
.filter(|c: &char| *c == 'n' || *c == 'N')
.padded(),
);
let optional_name = keyword_ci("as").ignore_then(identifier()).or_not();
keyword_ci("add")
.ignore_then(one_to_n)
.ignore_then(keyword_ci("relationship"))
.ignore_then(optional_name)
.then_ignore(keyword_ci("from"))
.then(qualified_column())
.then_ignore(keyword_ci("to"))
.then(qualified_column())
.then(referential_clauses())
.then(create_fk_flag())
.map(
|((((name, parent), child), (on_delete, on_update)), create_fk)| {
Command::AddRelationship {
name,
parent_table: parent.0,
parent_column: parent.1,
child_table: child.0,
child_column: child.1,
on_delete,
on_update,
create_fk,
}
},
)
}
/// `drop relationship <name>` or
/// `drop relationship from <P>.<col> to <C>.<col>`.
fn drop_relationship_parser<'a>()
-> impl Parser<'a, &'a str, Command, extra::Err<Rich<'a, char>>> + Clone {
let endpoints_form = keyword_ci("from")
.ignore_then(qualified_column())
.then_ignore(keyword_ci("to"))
.then(qualified_column())
.map(|(parent, child)| RelationshipSelector::Endpoints {
parent_table: parent.0,
parent_column: parent.1,
child_table: child.0,
child_column: child.1,
});
let named_form = identifier().map(|name| RelationshipSelector::Named { name });
keyword_ci("drop")
.ignore_then(keyword_ci("relationship"))
.ignore_then(choice((endpoints_form, named_form)))
.map(|selector| Command::DropRelationship { selector })
}
/// Parse `<Table>.<Column>` returning (table, column).
fn qualified_column<'a>()
-> impl Parser<'a, &'a str, (String, String), extra::Err<Rich<'a, char>>> + Clone {
identifier()
.then_ignore(just('.').padded())
.then(identifier())
}
/// Optional `on delete <action>` and/or `on update <action>`,
/// in either order. Default to `NoAction` when omitted.
fn referential_clauses<'a>() -> impl Parser<
'a,
&'a str,
(ReferentialAction, ReferentialAction),
extra::Err<Rich<'a, char>>,
> + Clone {
let target = keyword_ci("delete")
.to(ReferentialActionTarget::Delete)
.or(keyword_ci("update").to(ReferentialActionTarget::Update));
let clause = keyword_ci("on")
.ignore_then(target)
.then(action_keyword())
.map(|(t, a)| (t, a));
clause
.repeated()
.at_most(2)
.collect::<Vec<_>>()
.try_map(|clauses, span| {
let mut on_delete = None;
let mut on_update = None;
for (target, action) in clauses {
let slot = match target {
ReferentialActionTarget::Delete => &mut on_delete,
ReferentialActionTarget::Update => &mut on_update,
};
if slot.is_some() {
return Err(Rich::custom(
span,
format!("`on {target}` specified twice"),
));
}
*slot = Some(action);
}
Ok((
on_delete.unwrap_or_else(ReferentialAction::default_action),
on_update.unwrap_or_else(ReferentialAction::default_action),
))
})
}
#[derive(Debug, Clone, Copy)]
enum ReferentialActionTarget {
Delete,
Update,
}
impl std::fmt::Display for ReferentialActionTarget {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
Self::Delete => "delete",
Self::Update => "update",
})
}
}
/// Parse a referential-action keyword: `cascade`, `restrict`,
/// `set null`, or `no action`. The two-word forms come first in
/// the alternatives so they're tried before the one-word forms;
/// because the first words are unique to each phrase
/// (`set`/`no` for two-word, `cascade`/`restrict` for one-word)
/// there is no ambiguity.
fn action_keyword<'a>()
-> impl Parser<'a, &'a str, ReferentialAction, extra::Err<Rich<'a, char>>> + Clone {
choice((
keyword_ci("set")
.ignore_then(keyword_ci("null"))
.to(ReferentialAction::SetNull),
keyword_ci("no")
.ignore_then(keyword_ci("action"))
.to(ReferentialAction::NoAction),
keyword_ci("cascade").to(ReferentialAction::Cascade),
keyword_ci("restrict").to(ReferentialAction::Restrict),
))
}
fn create_fk_flag<'a>()
-> impl Parser<'a, &'a str, bool, extra::Err<Rich<'a, char>>> + Clone {
just("--create-fk")
.padded()
.then_ignore(end())
.or_not()
.map(|opt| opt.is_some())
}
/// Parse the optional `with pk [<spec>]` clause that may follow
@@ -471,6 +641,207 @@ mod tests {
assert!(matches!(e, ParseError::Invalid { .. }), "got {e:?}");
}
fn rel(
name: Option<&str>,
parent: (&str, &str),
child: (&str, &str),
on_delete: ReferentialAction,
on_update: ReferentialAction,
create_fk: bool,
) -> Command {
Command::AddRelationship {
name: name.map(String::from),
parent_table: parent.0.to_string(),
parent_column: parent.1.to_string(),
child_table: child.0.to_string(),
child_column: child.1.to_string(),
on_delete,
on_update,
create_fk,
}
}
#[test]
fn add_relationship_minimal() {
assert_eq!(
ok("add 1:n relationship from Customers.Id to Orders.CustId"),
rel(
None,
("Customers", "Id"),
("Orders", "CustId"),
ReferentialAction::NoAction,
ReferentialAction::NoAction,
false,
)
);
}
#[test]
fn add_relationship_with_name() {
assert_eq!(
ok("add 1:n relationship as cust_orders from Customers.Id to Orders.CustId"),
rel(
Some("cust_orders"),
("Customers", "Id"),
("Orders", "CustId"),
ReferentialAction::NoAction,
ReferentialAction::NoAction,
false,
)
);
}
#[test]
fn add_relationship_with_on_delete() {
assert_eq!(
ok("add 1:n relationship from Customers.Id to Orders.CustId on delete cascade"),
rel(
None,
("Customers", "Id"),
("Orders", "CustId"),
ReferentialAction::Cascade,
ReferentialAction::NoAction,
false,
)
);
}
#[test]
fn add_relationship_with_on_delete_set_null() {
assert_eq!(
ok("add 1:n relationship from Customers.Id to Orders.CustId on delete set null"),
rel(
None,
("Customers", "Id"),
("Orders", "CustId"),
ReferentialAction::SetNull,
ReferentialAction::NoAction,
false,
)
);
}
#[test]
fn add_relationship_with_both_actions_in_either_order() {
let expected = rel(
None,
("Customers", "Id"),
("Orders", "CustId"),
ReferentialAction::Cascade,
ReferentialAction::SetNull,
false,
);
assert_eq!(
ok("add 1:n relationship from Customers.Id to Orders.CustId on delete cascade on update set null"),
expected
);
assert_eq!(
ok("add 1:n relationship from Customers.Id to Orders.CustId on update set null on delete cascade"),
expected
);
}
#[test]
fn add_relationship_repeated_clause_errors() {
let e = err(
"add 1:n relationship from C.id to O.cid on delete cascade on delete restrict",
);
match e {
ParseError::Invalid { message, .. } => {
assert!(message.contains("specified twice"), "{message}");
}
ParseError::Empty => panic!("unexpected empty error"),
}
}
#[test]
fn add_relationship_with_create_fk_flag() {
assert_eq!(
ok("add 1:n relationship from Customers.Id to Orders.CustId --create-fk"),
rel(
None,
("Customers", "Id"),
("Orders", "CustId"),
ReferentialAction::NoAction,
ReferentialAction::NoAction,
true,
)
);
}
#[test]
fn add_relationship_with_name_actions_and_flag() {
assert_eq!(
ok("add 1:n relationship as cust_orders from Customers.Id to Orders.CustId on delete cascade on update no action --create-fk"),
rel(
Some("cust_orders"),
("Customers", "Id"),
("Orders", "CustId"),
ReferentialAction::Cascade,
ReferentialAction::NoAction,
true,
)
);
}
#[test]
fn add_relationship_keywords_are_case_insensitive() {
assert_eq!(
ok("ADD 1:N RELATIONSHIP FROM Customers.Id TO Orders.CustId ON DELETE CASCADE"),
rel(
None,
("Customers", "Id"),
("Orders", "CustId"),
ReferentialAction::Cascade,
ReferentialAction::NoAction,
false,
)
);
}
#[test]
fn add_relationship_unknown_action_errors() {
let e = err("add 1:n relationship from C.id to O.cid on delete obliterate");
assert!(matches!(e, ParseError::Invalid { .. }), "got {e:?}");
}
#[test]
fn drop_relationship_by_name() {
assert_eq!(
ok("drop relationship cust_orders"),
Command::DropRelationship {
selector: RelationshipSelector::Named {
name: "cust_orders".to_string()
}
}
);
}
#[test]
fn show_table_simple() {
assert_eq!(
ok("show table Customers"),
Command::ShowTable {
name: "Customers".to_string()
}
);
}
#[test]
fn drop_relationship_by_endpoints() {
assert_eq!(
ok("drop relationship from Customers.Id to Orders.CustId"),
Command::DropRelationship {
selector: RelationshipSelector::Endpoints {
parent_table: "Customers".to_string(),
parent_column: "Id".to_string(),
child_table: "Orders".to_string(),
child_column: "CustId".to_string(),
}
}
);
}
#[test]
fn identifier_allows_underscores_and_digits_after_start() {
assert_eq!(
+34 -2
View File
@@ -68,7 +68,7 @@ async fn run_loop(
seed_initial_tables(&database, &event_tx).await;
terminal
.draw(|f| ui::render(&app, &theme, f))
.draw(|f| ui::render(&mut app, &theme, f))
.context("initial draw")?;
info!("entering main event loop");
@@ -87,7 +87,7 @@ async fn run_loop(
}
}
terminal
.draw(|f| ui::render(&app, &theme, f))
.draw(|f| ui::render(&mut app, &theme, f))
.context("redraw")?;
if should_quit {
break;
@@ -170,6 +170,38 @@ async fn execute_command(
.await
.map(Some)
.map_err(friendly),
Command::AddRelationship {
name,
parent_table,
parent_column,
child_table,
child_column,
on_delete,
on_update,
create_fk,
} => database
.add_relationship(
name,
parent_table,
parent_column,
child_table,
child_column,
on_delete,
on_update,
create_fk,
)
.await
.map(Some)
.map_err(friendly),
Command::DropRelationship { selector } => database
.drop_relationship(selector)
.await
.map_err(friendly),
Command::ShowTable { name } => database
.describe_table(name)
.await
.map(Some)
.map_err(friendly),
}
}
+56 -20
View File
@@ -17,7 +17,13 @@ use crate::mode::Mode;
use crate::theme::Theme;
/// Render the entire application frame.
pub fn render(app: &App, theme: &Theme, frame: &mut Frame<'_>) {
///
/// Takes `&mut App` because the renderer reports the current
/// output-panel row count back to the App for scroll-cap
/// computation — without that feedback, scrolling past the top
/// of the buffer would slide the visible window off and
/// "eat" lines from the bottom on subsequent renders.
pub fn render(app: &mut App, theme: &Theme, frame: &mut Frame<'_>) {
let area = frame.area();
paint_background(theme, frame, area);
@@ -37,7 +43,7 @@ pub fn render(app: &App, theme: &Theme, frame: &mut Frame<'_>) {
render_status_bar(app, theme, frame, outer[1]);
}
fn render_right_column(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rect) {
fn render_right_column(app: &mut App, theme: &Theme, frame: &mut Frame<'_>, area: Rect) {
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([
@@ -105,7 +111,7 @@ fn render_items_panel(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rec
frame.render_widget(paragraph, area);
}
fn render_output_panel(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rect) {
fn render_output_panel(app: &mut App, theme: &Theme, frame: &mut Frame<'_>, area: Rect) {
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
@@ -123,15 +129,25 @@ fn render_output_panel(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Re
vertical: 1,
});
// Show the most recent lines that fit. The output buffer is
// append-only, so taking from the back gives "most recent".
// 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).
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);
let lines: Vec<Line<'_>> = app
.output
.iter()
.rev()
.take(visible)
.rev()
.skip(start)
.take(end - start)
.map(|line| render_output_line(line, theme))
.collect();
@@ -193,11 +209,29 @@ fn render_input_panel(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rec
.title(title)
.style(Style::default().bg(theme.bg).fg(theme.fg));
// Cursor block: the character at the cursor position is rendered
// inverted so it is visible without enabling a real terminal cursor.
// Cursor block: render the character at the cursor position
// inverted so the cursor is visible without enabling a real
// terminal cursor. When the cursor is at end-of-input we
// append an inverted space.
let cursor = app.input_cursor.min(app.input.len());
let before = &app.input[..cursor];
let (under, after) = if cursor < app.input.len() {
// Find the end of the character under the cursor.
let mut end = cursor + 1;
while end < app.input.len() && !app.input.is_char_boundary(end) {
end += 1;
}
(&app.input[cursor..end], &app.input[end..])
} else {
(" ", "")
};
let spans = vec![
Span::styled(app.input.as_str(), Style::default().fg(theme.fg)),
Span::styled(" ", Style::default().add_modifier(Modifier::REVERSED)),
Span::styled(before, Style::default().fg(theme.fg)),
Span::styled(
under,
Style::default().fg(theme.fg).add_modifier(Modifier::REVERSED),
),
Span::styled(after, Style::default().fg(theme.fg)),
];
let paragraph = Paragraph::new(Line::from(spans)).block(block);
frame.render_widget(paragraph, area);
@@ -273,7 +307,7 @@ mod tests {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
fn render_to_string(app: &App, theme: &Theme, width: u16, height: u16) -> String {
fn render_to_string(app: &mut App, theme: &Theme, width: u16, height: u16) -> String {
let backend = TestBackend::new(width, height);
let mut terminal = Terminal::new(backend).expect("create terminal");
terminal
@@ -292,17 +326,17 @@ mod tests {
#[test]
fn dark_theme_default_view_snapshot() {
let app = App::new();
let mut app = App::new();
let theme = Theme::dark();
let snapshot = render_to_string(&app, &theme, 80, 24);
let snapshot = render_to_string(&mut app, &theme, 80, 24);
insta::assert_snapshot!("default_simple_dark", snapshot);
}
#[test]
fn light_theme_default_view_snapshot() {
let app = App::new();
let mut app = App::new();
let theme = Theme::light();
let snapshot = render_to_string(&app, &theme, 80, 24);
let snapshot = render_to_string(&mut app, &theme, 80, 24);
insta::assert_snapshot!("default_simple_light", snapshot);
}
@@ -311,7 +345,7 @@ mod tests {
let mut app = App::new();
app.mode = Mode::Advanced;
let theme = Theme::dark();
let snapshot = render_to_string(&app, &theme, 80, 24);
let snapshot = render_to_string(&mut app, &theme, 80, 24);
insta::assert_snapshot!("default_advanced_dark", snapshot);
}
@@ -323,7 +357,7 @@ mod tests {
let mut app = App::new();
app.input.push_str(": sel");
let theme = Theme::dark();
let snapshot = render_to_string(&app, &theme, 80, 24);
let snapshot = render_to_string(&mut app, &theme, 80, 24);
insta::assert_snapshot!("one_shot_advanced_dark", snapshot);
}
@@ -355,6 +389,8 @@ mod tests {
primary_key: false,
},
],
outbound_relationships: Vec::new(),
inbound_relationships: Vec::new(),
};
app.current_table = Some(desc);
// Mirror what the App writes when a DSL command succeeds.
@@ -380,7 +416,7 @@ mod tests {
});
let theme = Theme::dark();
let snapshot = render_to_string(&app, &theme, 80, 24);
let snapshot = render_to_string(&mut app, &theme, 80, 24);
insta::assert_snapshot!("populated_with_table_dark", snapshot);
}
}