f1e9484af3
End-to-end `seed <table> [count]` path, both modes: - Command::Seed AST + grammar node (show-data table slot + optional positional count) + REGISTRY registration + build_seed. - Runtime dispatch -> Database::seed -> Request::Seed worker arm -> do_seed. - do_seed (Phase-1 skeleton): generates whole rows for non-FK, non-autogen columns via the seed library and inserts them one at a time through do_insert (reusing validation / autogen autofill / FK-error / persistence). One undo step (snapshot_then wraps it) and one history.log line (only the first row carries the source); default count 20. - help (`help seed`) + parse-usage catalog entries. - Reuses CommandOutcome::Insert for the auto-show; a dedicated SeedResult (capped preview + advisory) replaces it in P1.3. 5 Tier-3 integration tests (parse, populate+persist, default-20, reproducible --seed, one history line). 2327 pass / 0 fail / 0 skip, clippy all-targets clean. Deferred to P1.3: FK sampling, identifier/constraint uniqueness, CHECK derivation, block guard, capped preview, advisory, multi-row path. Deferred to P1.4: completion/highlight/hint/validity wiring + --seed flag.
444 lines
16 KiB
Rust
444 lines
16 KiB
Rust
//! Shared helpers + canonical schema shapes for the
|
|
//! typing-surface matrix.
|
|
//!
|
|
//! See `tests/typing_surface.rs` for the entry point and
|
|
//! `docs/handoff/20260515-handoff-12.md` §1 for the design
|
|
//! rationale.
|
|
|
|
// Helpers below are `pub` so submodules can use them. The
|
|
// `unreachable_pub` lint trips because Cargo sees the test
|
|
// binary's crate root as the only consumer, but submodule
|
|
// access requires `pub`. `dead_code` similarly trips during
|
|
// incremental work when not every helper has callers yet.
|
|
#![allow(dead_code, unreachable_pub)]
|
|
|
|
use rdbms_playground::completion::{
|
|
Completion, SchemaCache, TableColumn, candidates_at_cursor_in_mode,
|
|
};
|
|
use rdbms_playground::dsl::parser::parse_command_with_schema_in_mode;
|
|
use rdbms_playground::dsl::types::Type;
|
|
use rdbms_playground::input_render::{
|
|
AmbientHint, InputState, ambient_hint_in_mode, classify_input_with_schema_in_mode,
|
|
};
|
|
use rdbms_playground::mode::Mode;
|
|
|
|
pub mod insert_form_a;
|
|
pub mod insert_form_b;
|
|
pub mod insert_form_c;
|
|
pub mod update_with_where;
|
|
pub mod update_all_rows;
|
|
pub mod delete_with_where;
|
|
pub mod where_expression;
|
|
pub mod delete_all_rows;
|
|
pub mod explain;
|
|
pub mod create_table;
|
|
pub mod drop_column;
|
|
pub mod drop_relationship;
|
|
pub mod add_relationship;
|
|
pub mod create_m2n;
|
|
pub mod index_ops;
|
|
pub mod constraints;
|
|
pub mod rename_change_column;
|
|
pub mod app_commands;
|
|
pub mod candidate_ordering;
|
|
|
|
// =========================================================
|
|
// Canonical schema shapes (handoff §1 — CANONICAL_SCHEMA_SHAPES)
|
|
// =========================================================
|
|
//
|
|
// Used in snapshot names so failing cases identify the schema.
|
|
|
|
pub const SHAPE_EMPTY: &str = "empty";
|
|
pub const SHAPE_SERIAL_PK: &str = "serial_pk";
|
|
pub const SHAPE_TEXT_PK: &str = "text_pk";
|
|
pub const SHAPE_MULTI_TABLE: &str = "multi_table";
|
|
pub const SHAPE_EVERY_TYPE: &str = "every_type";
|
|
|
|
/// Empty schema — no tables at all.
|
|
pub fn schema_empty() -> SchemaCache {
|
|
SchemaCache::default()
|
|
}
|
|
|
|
/// Single table with a `serial` PK + two text columns.
|
|
/// `Customers(id:serial, Name:text, Email:text)`.
|
|
///
|
|
/// Exercises auto-gen-skip behaviour: in Form B `id` is omitted
|
|
/// from the value list; in Form A the user can list it explicitly.
|
|
pub fn schema_serial_pk() -> SchemaCache {
|
|
build_schema(&[(
|
|
"Customers",
|
|
&[
|
|
("id", Type::Serial),
|
|
("Name", Type::Text),
|
|
("Email", Type::Text),
|
|
],
|
|
)])
|
|
}
|
|
|
|
/// Single table with a text PK and no auto-gen columns.
|
|
/// `Items(Code:text, Title:text)`.
|
|
///
|
|
/// Exercises the no-auto-gen path: Form A and Form B both
|
|
/// require values for every column.
|
|
pub fn schema_text_pk() -> SchemaCache {
|
|
build_schema(&[(
|
|
"Items",
|
|
&[("Code", Type::Text), ("Title", Type::Text)],
|
|
)])
|
|
}
|
|
|
|
/// Two tables sharing no column names.
|
|
/// `Customers(id:serial, Name:text)` +
|
|
/// `Orders(OrderId:serial, CustId:int, Total:real)`.
|
|
///
|
|
/// Exercises column-name-leakage detection: column candidates at
|
|
/// a position scoped to one table must not include columns from
|
|
/// the other table.
|
|
pub fn schema_multi_table() -> SchemaCache {
|
|
build_schema(&[
|
|
(
|
|
"Customers",
|
|
&[("id", Type::Serial), ("Name", Type::Text)],
|
|
),
|
|
(
|
|
"Orders",
|
|
&[
|
|
("OrderId", Type::Serial),
|
|
("CustId", Type::Int),
|
|
("Total", Type::Real),
|
|
],
|
|
),
|
|
])
|
|
}
|
|
|
|
/// One table with one column of every `Type` variant.
|
|
/// Exercises per-type slot prose at every value position.
|
|
pub fn schema_every_type() -> SchemaCache {
|
|
build_schema(&[(
|
|
"Things",
|
|
&[
|
|
("k", Type::Int),
|
|
("r", Type::Real),
|
|
("d", Type::Decimal),
|
|
("b", Type::Bool),
|
|
("dt", Type::Date),
|
|
("ts", Type::DateTime),
|
|
("data", Type::Blob),
|
|
("sid", Type::ShortId),
|
|
("auto", Type::Serial),
|
|
("note", Type::Text),
|
|
],
|
|
)])
|
|
}
|
|
|
|
fn build_schema(tables: &[(&str, &[(&str, Type)])]) -> SchemaCache {
|
|
let mut cache = SchemaCache::default();
|
|
for (table, cols) in tables {
|
|
let table_cols: Vec<TableColumn> = cols
|
|
.iter()
|
|
.map(|(n, t)| TableColumn {
|
|
name: (*n).to_string(),
|
|
user_type: *t,
|
|
not_null: false,
|
|
has_default: false,
|
|
})
|
|
.collect();
|
|
cache.tables.push((*table).to_string());
|
|
for c in &table_cols {
|
|
if !cache.columns.contains(&c.name) {
|
|
cache.columns.push(c.name.clone());
|
|
}
|
|
}
|
|
cache.table_columns.insert((*table).to_string(), table_cols);
|
|
}
|
|
cache
|
|
}
|
|
|
|
// =========================================================
|
|
// Assessment helper — runs the four typing-surface APIs at a
|
|
// (input, cursor, schema) cell and packages the results for
|
|
// snapshot + property assertions.
|
|
// =========================================================
|
|
|
|
/// Compact, Debug-printable assessment of the typing surface
|
|
/// at one cell.
|
|
#[derive(Debug)]
|
|
pub struct Assessment {
|
|
pub input: String,
|
|
pub cursor: usize,
|
|
pub state: InputState,
|
|
pub hint: Option<AmbientHint>,
|
|
pub completion: Option<Completion>,
|
|
/// Parse result rendered as `Ok(<short label>)` or
|
|
/// `Err(<short error label>)` to keep snapshots stable as
|
|
/// the `Command` AST evolves.
|
|
pub parse_result: Result<String, String>,
|
|
}
|
|
|
|
/// Assess the typing surface at the given cell.
|
|
///
|
|
/// The whole typing-surface matrix exercises the **DSL** surface
|
|
/// (insert Forms A/B/C, `update`/`delete` with `where` / `--all-rows`,
|
|
/// the DSL expression grammar, DDL, app commands) — which is the
|
|
/// **Simple-mode** surface (ADR-0003). So every facet here is computed
|
|
/// in Simple mode, and consistently so: `ambient_hint` already
|
|
/// defaults to Simple, and the others are pinned to Simple via their
|
|
/// `*_in_mode` variants. This matters since sub-phase 3j made
|
|
/// `insert`/`update`/`delete` shared entry words (ADR-0033
|
|
/// Amendment 3): in Advanced mode those route to the SQL grammar
|
|
/// (different completion / hints / parse), whereas the DSL forms this
|
|
/// matrix documents live in Simple mode. The SQL surface is covered
|
|
/// by `tests/sql_*.rs` and the advanced-mode walker diagnostics.
|
|
pub fn assess(input: &str, cursor: usize, schema: &SchemaCache) -> Assessment {
|
|
let state = classify_input_with_schema_in_mode(input, schema, Mode::Simple);
|
|
let hint = ambient_hint_in_mode(input, cursor, None, schema, Mode::Simple);
|
|
let completion = candidates_at_cursor_in_mode(input, cursor, schema, Mode::Simple);
|
|
let parse_result = match parse_command_with_schema_in_mode(input, schema, Mode::Simple) {
|
|
Ok(cmd) => Ok(command_kind_label(&cmd)),
|
|
Err(e) => Err(parse_error_label(&e)),
|
|
};
|
|
Assessment {
|
|
input: input.into(),
|
|
cursor,
|
|
state,
|
|
hint,
|
|
completion,
|
|
parse_result,
|
|
}
|
|
}
|
|
|
|
/// Convenience: assess at the end of input.
|
|
pub fn assess_at_end(input: &str, schema: &SchemaCache) -> Assessment {
|
|
assess(input, input.len(), schema)
|
|
}
|
|
|
|
fn command_kind_label(cmd: &rdbms_playground::dsl::Command) -> String {
|
|
use rdbms_playground::dsl::AppCommand;
|
|
use rdbms_playground::dsl::Command::*;
|
|
match cmd {
|
|
CreateTable { .. } => "CreateTable".into(),
|
|
SqlCreateTable { .. } => "SqlCreateTable".into(),
|
|
DropTable { .. } => "DropTable".into(),
|
|
SqlDropTable { .. } => "SqlDropTable".into(),
|
|
AddColumn { .. } => "AddColumn".into(),
|
|
DropColumn { .. } => "DropColumn".into(),
|
|
RenameColumn { .. } => "RenameColumn".into(),
|
|
ChangeColumnType { .. } => "ChangeColumnType".into(),
|
|
AddRelationship { .. } => "AddRelationship".into(),
|
|
CreateM2nRelationship { .. } => "CreateM2nRelationship".into(),
|
|
DropRelationship { .. } => "DropRelationship".into(),
|
|
AddIndex { .. } => "AddIndex".into(),
|
|
DropIndex { .. } => "DropIndex".into(),
|
|
SqlDropIndex { .. } => "SqlDropIndex".into(),
|
|
SqlCreateIndex { .. } => "SqlCreateIndex".into(),
|
|
SqlAlterTable { .. } => "SqlAlterTable".into(),
|
|
AddConstraint { .. } => "AddConstraint".into(),
|
|
DropConstraint { .. } => "DropConstraint".into(),
|
|
ShowTable { .. } => "ShowTable".into(),
|
|
ShowList { kind, name } => format!("ShowList({kind:?}, {})", name.is_some()),
|
|
Insert { .. } => "Insert".into(),
|
|
Seed { .. } => "Seed".into(),
|
|
Update { .. } => "Update".into(),
|
|
Delete { .. } => "Delete".into(),
|
|
ShowData { .. } => "ShowData".into(),
|
|
Replay { .. } => "Replay".into(),
|
|
Explain { .. } => "Explain".into(),
|
|
Select { .. } => "Select".into(),
|
|
SqlInsert { .. } => "SqlInsert".into(),
|
|
SqlUpdate { .. } => "SqlUpdate".into(),
|
|
SqlDelete { .. } => "SqlDelete".into(),
|
|
App(app) => match app {
|
|
AppCommand::Quit => "App(Quit)".into(),
|
|
AppCommand::Help { .. } => "App(Help)".into(),
|
|
AppCommand::Rebuild => "App(Rebuild)".into(),
|
|
AppCommand::Save => "App(Save)".into(),
|
|
AppCommand::SaveAs => "App(SaveAs)".into(),
|
|
AppCommand::New => "App(New)".into(),
|
|
AppCommand::Load => "App(Load)".into(),
|
|
AppCommand::Export { .. } => "App(Export)".into(),
|
|
AppCommand::Import { .. } => "App(Import)".into(),
|
|
AppCommand::Mode { .. } => "App(Mode)".into(),
|
|
AppCommand::Messages { .. } => "App(Messages)".into(),
|
|
AppCommand::Undo => "App(Undo)".into(),
|
|
AppCommand::Redo => "App(Redo)".into(),
|
|
AppCommand::Copy { .. } => "App(Copy)".into(),
|
|
},
|
|
}
|
|
}
|
|
|
|
fn parse_error_label(err: &rdbms_playground::dsl::ParseError) -> String {
|
|
use rdbms_playground::dsl::ParseError::*;
|
|
match err {
|
|
Empty => "Empty".into(),
|
|
Invalid { at_eof, .. } => {
|
|
if *at_eof {
|
|
"Invalid(at_eof)".into()
|
|
} else {
|
|
"Invalid(definite)".into()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// =========================================================
|
|
// Property-assertion helpers.
|
|
//
|
|
// Inline tests in submodules combine these with insta
|
|
// snapshots — the snapshot detects drift; the assertions
|
|
// detect bug-class regressions (no column leakage, hint
|
|
// surfaces the right column, etc.).
|
|
// =========================================================
|
|
|
|
/// Returns the list of candidate texts from the ambient hint,
|
|
/// or an empty Vec if the hint isn't in Candidates mode.
|
|
pub fn hint_candidate_texts(a: &Assessment) -> Vec<String> {
|
|
match &a.hint {
|
|
Some(AmbientHint::Candidates { items, .. }) => {
|
|
items.iter().map(|c| c.text.clone()).collect()
|
|
}
|
|
_ => Vec::new(),
|
|
}
|
|
}
|
|
|
|
/// Returns the list of completion candidate texts (from the
|
|
/// `candidates_at_cursor` surface), or an empty Vec if there
|
|
/// is no completion.
|
|
pub fn completion_candidate_texts(a: &Assessment) -> Vec<String> {
|
|
a.completion.as_ref().map_or_else(Vec::new, |c| {
|
|
c.candidates.iter().map(|c| c.text.clone()).collect()
|
|
})
|
|
}
|
|
|
|
/// True if the hint is Prose containing the given substring.
|
|
pub fn hint_prose_contains(a: &Assessment, needle: &str) -> bool {
|
|
matches!(&a.hint, Some(AmbientHint::Prose(p)) if p.contains(needle))
|
|
}
|
|
|
|
/// True if the hint is Prose; returns the prose string.
|
|
pub const fn hint_prose(a: &Assessment) -> Option<&str> {
|
|
match &a.hint {
|
|
Some(AmbientHint::Prose(p)) => Some(p.as_str()),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// Assert state matches the given variant (by Debug name).
|
|
pub fn assert_state(a: &Assessment, expected: &str) {
|
|
let actual = format!("{:?}", a.state);
|
|
// DefiniteErrorAt carries a position — compare prefix.
|
|
assert!(
|
|
actual.starts_with(expected),
|
|
"state: expected {expected}, got {actual} (input: {:?}, cursor: {})",
|
|
a.input,
|
|
a.cursor,
|
|
);
|
|
}
|
|
|
|
/// Assert that no candidate (either hint Candidates or
|
|
/// completion) contains any of the given names. Used to detect
|
|
/// column-name leakage from foreign tables.
|
|
pub fn assert_no_candidate_named(a: &Assessment, forbidden: &[&str]) {
|
|
let hint_cands = hint_candidate_texts(a);
|
|
let comp_cands = completion_candidate_texts(a);
|
|
for name in forbidden {
|
|
assert!(
|
|
!hint_cands.iter().any(|c| c == name),
|
|
"hint candidates contain forbidden {name:?}: {hint_cands:?} (input: {:?})",
|
|
a.input,
|
|
);
|
|
assert!(
|
|
!comp_cands.iter().any(|c| c == name),
|
|
"completion candidates contain forbidden {name:?}: {comp_cands:?} (input: {:?})",
|
|
a.input,
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Assert that at least one candidate matches each expected name.
|
|
pub fn assert_candidate_present(a: &Assessment, expected: &[&str]) {
|
|
let hint_cands = hint_candidate_texts(a);
|
|
let comp_cands = completion_candidate_texts(a);
|
|
let all: Vec<&String> = hint_cands.iter().chain(comp_cands.iter()).collect();
|
|
for name in expected {
|
|
assert!(
|
|
all.iter().any(|c| *c == name),
|
|
"expected candidate {name:?} not present. hint: {hint_cands:?}, completion: {comp_cands:?} (input: {:?})",
|
|
a.input,
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Snapshot helper — takes a stable name + the Assessment.
|
|
/// Wraps `insta::assert_debug_snapshot!` with a deterministic
|
|
/// snapshot suffix so each cell has its own file.
|
|
#[macro_export]
|
|
macro_rules! snap {
|
|
($name:expr, $assessment:expr) => {
|
|
::insta::with_settings!({
|
|
snapshot_suffix => $name,
|
|
description => format!("input={:?} cursor={}", $assessment.input, $assessment.cursor),
|
|
}, {
|
|
::insta::assert_debug_snapshot!(&$assessment);
|
|
});
|
|
};
|
|
}
|
|
|
|
// =========================================================
|
|
// Infrastructure smoke tests — verify the helper itself
|
|
// behaves before submodules rely on it.
|
|
// =========================================================
|
|
|
|
#[test]
|
|
fn smoke_each_schema_shape_builds() {
|
|
// Each canonical schema must produce a SchemaCache the
|
|
// walker can probe without panicking. Run a trivial
|
|
// assess() against each.
|
|
for (label, schema) in [
|
|
(SHAPE_EMPTY, schema_empty()),
|
|
(SHAPE_SERIAL_PK, schema_serial_pk()),
|
|
(SHAPE_TEXT_PK, schema_text_pk()),
|
|
(SHAPE_MULTI_TABLE, schema_multi_table()),
|
|
(SHAPE_EVERY_TYPE, schema_every_type()),
|
|
] {
|
|
let a = assess("insert", 6, &schema);
|
|
// `insert` alone is incomplete regardless of schema —
|
|
// a useful invariant to pin so any regression in the
|
|
// entry-word path surfaces immediately.
|
|
assert!(
|
|
matches!(a.state, InputState::IncompleteAtEof),
|
|
"{label}: expected IncompleteAtEof for 'insert', got {:?}",
|
|
a.state,
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn smoke_assess_at_end_returns_each_field() {
|
|
let schema = schema_serial_pk();
|
|
let a = assess_at_end("insert into Customers values (", &schema);
|
|
// The four surfaces are populated:
|
|
assert_eq!(a.input, "insert into Customers values (");
|
|
assert_eq!(a.cursor, "insert into Customers values (".len());
|
|
// The walker recognises this as incomplete (more tokens
|
|
// wanted), not a definite error.
|
|
assert!(matches!(a.state, InputState::IncompleteAtEof));
|
|
// The hint surfaces per-column prose for the first
|
|
// non-auto-gen column (Name).
|
|
assert!(
|
|
hint_prose_contains(&a, "Name"),
|
|
"expected hint prose to mention `Name`, got {:?}",
|
|
a.hint,
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn smoke_assess_parse_label_round_trips() {
|
|
let schema = schema_serial_pk();
|
|
let a = assess_at_end(
|
|
"insert into Customers values ('Alice', 'a@b.c')",
|
|
&schema,
|
|
);
|
|
assert_eq!(a.parse_result.as_deref(), Ok("Insert"));
|
|
assert!(matches!(a.state, InputState::Valid));
|
|
}
|