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:
+409
-4
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user