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
+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);
}
}