feat: create m:n relationship convenience command (C4, ADR-0045)
`create m:n relationship from <T1> to <T2> [as <name>]` generates a
junction table with one FK column per parent PK column ({table}_{pkcol},
typed via fk_target_type), a compound PK over them, and two CASCADE 1:n
relationships -- all in one do_create_table call = one undo step.
Auto-named {T1}_{T2} (optional `as`), both modes, compound-parent PKs
supported (ADR-0043). Self-referential m:n / PK-less parent / internal
junction name / name collision all refused.
Wired across every surface: grammar (separate CREATE_M2N node), worker
executor, runtime dispatch, completion ("m:n" composite), hints,
highlighting, help + usage catalog + disambiguator, and the advanced-mode
DSL->SQL teaching echo (render_create_m2n, round-trips as valid SQL).
Generalized/fixed framework assumptions the build + two /runda passes
surfaced (all behaviour-preserving for existing commands):
- simple-mode dispatch committed simple.first() unconditionally -> tries
candidates, so `create table` no longer shadows `create m:n`.
- the completion continuation-merge was advanced-only -> runs in simple
mode too when an entry word has >1 DSL form (gated simple_count>1).
- do_create_table now rejects internal `__rdbms_*` names (closes a
pre-existing hole on the DSL create-table path too, not just m:n).
- usage disambiguator now recognizes the `m:n` opener.
Tests: 14 integration (tests/it/m2n.rs), 7 typing-surface matrix, echo /
highlight / usage / internal-name units. Closes C4.
2237 pass / 0 fail / 1 ignored. Clippy clean.
This commit is contained in:
+18
-10
@@ -2058,6 +2058,10 @@ impl App {
|
||||
// column for a compound FK (ADR-0043).
|
||||
parent_columns.first().map(String::as_str),
|
||||
),
|
||||
// m:n builds a junction table; its errors (missing parent,
|
||||
// no PK, self-reference, name collision) name the relevant
|
||||
// table in the message, so no fallback table/column here.
|
||||
C::CreateM2nRelationship { .. } => (Operation::CreateTable, None, None),
|
||||
C::DropRelationship { selector } => match selector {
|
||||
RelationshipSelector::Endpoints {
|
||||
parent_table,
|
||||
@@ -2927,13 +2931,15 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn tab_at_word_boundary_inserts_next_expected_keyword() {
|
||||
// `create ` → expects only `table`. Single candidate;
|
||||
// insert "table " with space, no memo.
|
||||
// `change ` → expects only `column`. Single candidate;
|
||||
// insert "column " with space, no memo. (Uses `change`, not
|
||||
// `create`: ADR-0045 made `create ` ambiguous — `table` vs
|
||||
// `m:n` — so it is no longer a single-candidate boundary.)
|
||||
let mut app = App::new();
|
||||
type_str(&mut app, "create ");
|
||||
type_str(&mut app, "change ");
|
||||
let actions = app.update(key(KeyCode::Tab));
|
||||
assert!(actions.is_empty());
|
||||
assert_eq!(app.input, "create table ");
|
||||
assert_eq!(app.input, "change column ");
|
||||
assert!(app.last_completion.is_none());
|
||||
}
|
||||
|
||||
@@ -3080,17 +3086,19 @@ mod tests {
|
||||
// Stage-8 follow-up #2 (testing-round-2): the
|
||||
// single-candidate-no-memo design lets the user chain
|
||||
// Tabs through unique completions without getting
|
||||
// stuck. From "cr", Tab → "create ", Tab → "create
|
||||
// table ". (Round 5 added the app-lifecycle commands —
|
||||
// stuck. From "ch", Tab → "change ", Tab → "change
|
||||
// column ". (Round 5 added the app-lifecycle commands —
|
||||
// single-letter prefixes like `i` are now ambiguous
|
||||
// (`insert` vs. `import`), so the test starts from a
|
||||
// disambiguated two-letter prefix.)
|
||||
// disambiguated two-letter prefix. `change` is used rather
|
||||
// than `create`: ADR-0045 made `create ` ambiguous (`table`
|
||||
// vs `m:n`), so it no longer chains as a unique completion.)
|
||||
let mut app = App::new();
|
||||
type_str(&mut app, "cr");
|
||||
type_str(&mut app, "ch");
|
||||
app.update(key(KeyCode::Tab));
|
||||
assert_eq!(app.input, "create ");
|
||||
assert_eq!(app.input, "change ");
|
||||
app.update(key(KeyCode::Tab));
|
||||
assert_eq!(app.input, "create table ");
|
||||
assert_eq!(app.input, "change column ");
|
||||
assert!(app.last_completion.is_none());
|
||||
}
|
||||
|
||||
|
||||
+11
-3
@@ -31,6 +31,7 @@ use crate::mode::Mode;
|
||||
/// fragments the user thinks of as a single phrase:
|
||||
///
|
||||
/// - `1:n` — the opener for `add 1:n relationship`.
|
||||
/// - `m:n` — the opener for `create m:n relationship` (ADR-0045).
|
||||
/// - `double precision` — the lone two-word SQL type alias
|
||||
/// (ADR-0035 §6.3; the grammar has a dedicated branch so the per-word
|
||||
/// `Ident` validator never has to make sense of `double` alone).
|
||||
@@ -40,7 +41,7 @@ use crate::mode::Mode;
|
||||
/// composite replaces the bare opener rather than appearing
|
||||
/// alongside it.
|
||||
const COMPOSITE_CANDIDATES: &[(&str, &str)] =
|
||||
&[("1", "1:n"), ("double", "double precision")];
|
||||
&[("1", "1:n"), ("m", "m:n"), ("double", "double precision")];
|
||||
|
||||
/// Per-project schema lookup cache (ADR-0022 §9, ADR-0024 §Phase D).
|
||||
///
|
||||
@@ -1346,12 +1347,19 @@ mod tests {
|
||||
fn at_token_boundary_offers_next_expected_keyword() {
|
||||
// After `create ` advanced mode offers `table` (valid in both
|
||||
// modes) plus the SQL-only `unique` (`create unique index`) and
|
||||
// `index` — the shared-entry-word merge (ADR-0035 §4i d).
|
||||
// `index` — the shared-entry-word merge (ADR-0035 §4i d) — and
|
||||
// `m:n` (`create m:n relationship`, ADR-0045), surfaced as the
|
||||
// composite (the bare `m` opener is filtered).
|
||||
// `table` (Both) blocks before the Advanced-only `unique`/`index`.
|
||||
let cs = cands("create ", 7);
|
||||
assert_eq!(
|
||||
cs,
|
||||
vec!["table".to_string(), "unique".to_string(), "index".to_string()]
|
||||
vec![
|
||||
"table".to_string(),
|
||||
"unique".to_string(),
|
||||
"index".to_string(),
|
||||
"m:n".to_string()
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -605,6 +605,13 @@ enum Request {
|
||||
source: Option<String>,
|
||||
reply: oneshot::Sender<Result<TableDescription, DbError>>,
|
||||
},
|
||||
CreateM2nRelationship {
|
||||
t1: String,
|
||||
t2: String,
|
||||
name: Option<String>,
|
||||
source: Option<String>,
|
||||
reply: oneshot::Sender<Result<TableDescription, DbError>>,
|
||||
},
|
||||
DropRelationship {
|
||||
selector: RelationshipSelector,
|
||||
source: Option<String>,
|
||||
@@ -1420,6 +1427,29 @@ impl Database {
|
||||
recv.await.map_err(|_| DbError::WorkerGone)?
|
||||
}
|
||||
|
||||
/// Generate a junction table for an m:n relationship between
|
||||
/// `t1` and `t2` (ADR-0045 / C4). One worker request = one undo
|
||||
/// step (the junction + both relationships are built in a single
|
||||
/// `do_create_table`).
|
||||
pub async fn create_m2n_relationship(
|
||||
&self,
|
||||
t1: String,
|
||||
t2: String,
|
||||
name: Option<String>,
|
||||
source: Option<String>,
|
||||
) -> Result<TableDescription, DbError> {
|
||||
let (reply, recv) = oneshot::channel();
|
||||
self.send(Request::CreateM2nRelationship {
|
||||
t1,
|
||||
t2,
|
||||
name,
|
||||
source,
|
||||
reply,
|
||||
})
|
||||
.await?;
|
||||
recv.await.map_err(|_| DbError::WorkerGone)?
|
||||
}
|
||||
|
||||
pub async fn drop_relationship(
|
||||
&self,
|
||||
selector: RelationshipSelector,
|
||||
@@ -2347,6 +2377,24 @@ fn handle_request(
|
||||
create_fk,
|
||||
));
|
||||
}
|
||||
Request::CreateM2nRelationship {
|
||||
t1,
|
||||
t2,
|
||||
name,
|
||||
source,
|
||||
reply,
|
||||
} => {
|
||||
snapshot_then(snap, batch, conn, source.as_deref(), reply, || {
|
||||
do_create_m2n_relationship(
|
||||
conn,
|
||||
persistence,
|
||||
source.as_deref(),
|
||||
&t1,
|
||||
&t2,
|
||||
name.as_deref(),
|
||||
)
|
||||
});
|
||||
}
|
||||
Request::DropRelationship {
|
||||
selector,
|
||||
source,
|
||||
@@ -3394,6 +3442,14 @@ fn do_create_table(
|
||||
foreign_keys: &[SqlForeignKey],
|
||||
) -> Result<TableDescription, DbError> {
|
||||
debug!(table = %name, cols = columns.len(), pk = ?primary_key, "create_table");
|
||||
// A new table may not take an internal `__rdbms_*` name (it would be
|
||||
// filtered out of `list_tables` — a hidden orphan). The advanced-SQL
|
||||
// create path rejects this at parse, but the simple-mode DSL
|
||||
// `TABLE_NAME_NEW` slot has no validator, and `create m:n … as
|
||||
// <name>` (ADR-0045) reaches here too — so the shared executor is the
|
||||
// single place that closes every path (issue raised by the ADR-0045
|
||||
// /runda pass).
|
||||
reject_internal_table_name(name)?;
|
||||
if columns.is_empty() {
|
||||
// SQLite requires at least one column. The DSL grammar
|
||||
// already prevents this, but defending here too keeps
|
||||
@@ -7277,6 +7333,101 @@ fn resolve_create_table_fks(
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Generate a junction table for an m:n relationship between `t1` and
|
||||
/// `t2` (ADR-0045 / C4). Builds one FK column per parent PK column
|
||||
/// (`{table}_{pkcol}`, typed via `fk_target_type` — ADR-0011), a
|
||||
/// compound PK over all of them, and two `CASCADE` foreign keys, then
|
||||
/// hands the whole thing to [`do_create_table`] — so the junction table
|
||||
/// and both relationships are created in one transaction = one undo
|
||||
/// step. Self-referential m:n is refused (column-name collision); a
|
||||
/// PK-less parent is refused (nothing to reference).
|
||||
fn do_create_m2n_relationship(
|
||||
conn: &Connection,
|
||||
persistence: Option<&Persistence>,
|
||||
source: Option<&str>,
|
||||
t1: &str,
|
||||
t2: &str,
|
||||
name: Option<&str>,
|
||||
) -> Result<TableDescription, DbError> {
|
||||
debug!(t1 = %t1, t2 = %t2, name = ?name, "create_m2n_relationship");
|
||||
// Canonicalize both parents (refuse non-existent / internal tables).
|
||||
let canon_t1 = require_canonical_table(conn, t1)?;
|
||||
let t1 = canon_t1.as_str();
|
||||
let canon_t2 = require_canonical_table(conn, t2)?;
|
||||
let t2 = canon_t2.as_str();
|
||||
|
||||
// Self-referential m:n is OOS (ADR-0045): the two FK column sets
|
||||
// would collide on `{T}_{pkcol}`, needing directional names this
|
||||
// beginner convenience deliberately avoids.
|
||||
if t1.eq_ignore_ascii_case(t2) {
|
||||
return Err(DbError::Unsupported(format!(
|
||||
"an m:n relationship needs two different tables (got `{t1}` twice). \
|
||||
To link a table to itself, build the junction table by hand."
|
||||
)));
|
||||
}
|
||||
|
||||
let schema1 = read_schema(conn, t1)?;
|
||||
let schema2 = read_schema(conn, t2)?;
|
||||
|
||||
// Build one FK column per parent PK column (compound parents
|
||||
// contribute one each, ADR-0043) + the compound PK + the two FKs.
|
||||
let mut columns: Vec<ColumnSpec> = Vec::new();
|
||||
let mut primary_key: Vec<String> = Vec::new();
|
||||
let mut foreign_keys: Vec<SqlForeignKey> = Vec::new();
|
||||
for (tbl, schema) in [(t1, &schema1), (t2, &schema2)] {
|
||||
// D7 parent-PK guard: advanced-mode SQL can create a PK-less
|
||||
// table; it cannot anchor an m:n relationship.
|
||||
if schema.primary_key.is_empty() {
|
||||
return Err(DbError::Unsupported(format!(
|
||||
"`{tbl}` has no primary key, so it cannot anchor an m:n relationship."
|
||||
)));
|
||||
}
|
||||
let mut child_columns: Vec<String> = Vec::new();
|
||||
for pkcol in &schema.primary_key {
|
||||
let pcol = schema
|
||||
.columns
|
||||
.iter()
|
||||
.find(|c| &c.name == pkcol)
|
||||
.ok_or_else(|| DbError::Sqlite {
|
||||
message: format!("no such column: {tbl}.{pkcol}"),
|
||||
kind: SqliteErrorKind::NoSuchColumn,
|
||||
})?;
|
||||
let pty = pcol.user_type.ok_or_else(|| {
|
||||
DbError::Unsupported("primary-key column has no user type metadata".to_string())
|
||||
})?;
|
||||
let col_name = format!("{tbl}_{pkcol}");
|
||||
columns.push(ColumnSpec::new(col_name.clone(), pty.fk_target_type()));
|
||||
primary_key.push(col_name.clone());
|
||||
child_columns.push(col_name);
|
||||
}
|
||||
foreign_keys.push(SqlForeignKey {
|
||||
name: None,
|
||||
child_columns,
|
||||
parent_table: tbl.to_string(),
|
||||
parent_columns: Some(schema.primary_key.clone()),
|
||||
on_delete: ReferentialAction::Cascade,
|
||||
on_update: ReferentialAction::Cascade,
|
||||
inline: false,
|
||||
});
|
||||
}
|
||||
|
||||
// Junction name: explicit `as <name>` or the auto-name `{t1}_{t2}`.
|
||||
let junction = name.map_or_else(|| format!("{t1}_{t2}"), str::to_string);
|
||||
debug!(junction = %junction, cols = columns.len(), "create_m2n_relationship: building junction table");
|
||||
|
||||
do_create_table(
|
||||
conn,
|
||||
persistence,
|
||||
source,
|
||||
&junction,
|
||||
&columns,
|
||||
&primary_key,
|
||||
&[],
|
||||
&[],
|
||||
&foreign_keys,
|
||||
)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn do_add_relationship(
|
||||
conn: &Connection,
|
||||
@@ -10397,6 +10548,26 @@ mod tests {
|
||||
assert!(matches!(err, DbError::Unsupported(_)), "got {err:?}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_table_rejects_an_internal_name() {
|
||||
// A new table may not take an internal `__rdbms_*` name — it would
|
||||
// be hidden from `list_tables`. The advanced-SQL path rejects this
|
||||
// at parse; the shared executor guards every other path (the
|
||||
// simple-mode DSL slot and `create m:n … as`, ADR-0045).
|
||||
let db = db();
|
||||
let err = db
|
||||
.create_table(
|
||||
"__rdbms_sneaky".to_string(),
|
||||
vec![col("id", Type::Int)],
|
||||
vec!["id".to_string()],
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, DbError::Sqlite { kind: SqliteErrorKind::NoSuchTable, .. }), "got {err:?}");
|
||||
assert!(db.list_tables().await.unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn drop_table_removes_it_from_list() {
|
||||
let db = db();
|
||||
|
||||
@@ -277,6 +277,18 @@ pub enum Command {
|
||||
on_update: ReferentialAction,
|
||||
create_fk: bool,
|
||||
},
|
||||
/// Convenience: generate a junction table for a many-to-many
|
||||
/// relationship between `t1` and `t2` (ADR-0045 / C4). The
|
||||
/// executor builds a table with one FK column per parent PK
|
||||
/// column (named `{table}_{pkcol}`, typed via `fk_target_type`),
|
||||
/// a compound PK over all of them, and two `CASCADE` 1:n
|
||||
/// relationships — all in one `create table` (one undo step).
|
||||
/// `name` overrides the auto-generated junction name `{t1}_{t2}`.
|
||||
CreateM2nRelationship {
|
||||
t1: String,
|
||||
t2: String,
|
||||
name: Option<String>,
|
||||
},
|
||||
/// Drop a relationship by either user-given/auto-generated
|
||||
/// name, or by positional reference to the FK endpoints.
|
||||
DropRelationship {
|
||||
@@ -915,6 +927,7 @@ impl Command {
|
||||
Self::RenameColumn { .. } => "rename column",
|
||||
Self::ChangeColumnType { .. } => "change column",
|
||||
Self::AddRelationship { .. } => "add relationship",
|
||||
Self::CreateM2nRelationship { .. } => "create m:n relationship",
|
||||
Self::DropRelationship { .. } => "drop relationship",
|
||||
Self::AddIndex { .. } => "add index",
|
||||
Self::DropIndex { .. } => "drop index",
|
||||
@@ -991,6 +1004,9 @@ impl Command {
|
||||
// table's "Referenced by" entry, which is what the
|
||||
// user looks at to confirm the relationship.
|
||||
Self::AddRelationship { parent_table, .. } => parent_table,
|
||||
// For m:n we focus on the first table; the executor builds
|
||||
// and returns the junction's structure regardless.
|
||||
Self::CreateM2nRelationship { t1, .. } => t1,
|
||||
Self::DropRelationship { selector } => match selector {
|
||||
RelationshipSelector::Endpoints { parent_table, .. } => parent_table,
|
||||
// For a named drop we don't know the parent table
|
||||
|
||||
@@ -1362,6 +1362,75 @@ pub static CREATE: CommandNode = CommandNode {
|
||||
help_id: Some("ddl.create"),
|
||||
usage_ids: &["parse.usage.create_table"],};
|
||||
|
||||
// =================================================================
|
||||
// create_m2n — `create m:n relationship from <T1> to <T2> [as <name>]`
|
||||
// (ADR-0045 / C4). Generates an auto-named junction table with two FKs
|
||||
// + two 1:n relationships. A *separate* `CommandNode` under the shared
|
||||
// `create` entry word (the walker dispatches both); the `m` opener is a
|
||||
// `Literal` (not a keyword) so it never shadows an identifier, mirroring
|
||||
// the `1` in `add 1:n relationship`.
|
||||
// =================================================================
|
||||
|
||||
const M2N_T1: Node = Node::Ident {
|
||||
source: IdentSource::Tables,
|
||||
role: "m2n_t1",
|
||||
validator: None,
|
||||
highlight_override: None,
|
||||
writes_table: false,
|
||||
writes_column: false,
|
||||
writes_user_listed_column: false,
|
||||
writes_table_alias: false,
|
||||
writes_cte_name: false,
|
||||
writes_projection_alias: false,
|
||||
};
|
||||
const M2N_T2: Node = Node::Ident {
|
||||
source: IdentSource::Tables,
|
||||
role: "m2n_t2",
|
||||
validator: None,
|
||||
highlight_override: None,
|
||||
writes_table: false,
|
||||
writes_column: false,
|
||||
writes_user_listed_column: false,
|
||||
writes_table_alias: false,
|
||||
writes_cte_name: false,
|
||||
writes_projection_alias: false,
|
||||
};
|
||||
// Optional `as <junction name>` — a *new* table name (the junction),
|
||||
// so it reuses `TABLE_NAME_NEW` (role `table_name`, `NewName` source +
|
||||
// hint). The only `table_name` role in this path, so the builder reads
|
||||
// it directly as the junction name.
|
||||
const M2N_AS_NAME_NODES: &[Node] = &[Node::Word(Word::keyword("as")), TABLE_NAME_NEW];
|
||||
const M2N_AS_NAME_OPT: Node = Node::Optional(&Node::Seq(M2N_AS_NAME_NODES));
|
||||
|
||||
const CREATE_M2N_NODES: &[Node] = &[
|
||||
Node::Literal("m"),
|
||||
Node::Punct(':'),
|
||||
Node::Word(Word::keyword("n")),
|
||||
Node::Word(Word::keyword("relationship")),
|
||||
Node::Word(Word::keyword("from")),
|
||||
M2N_T1,
|
||||
Node::Word(Word::keyword("to")),
|
||||
M2N_T2,
|
||||
M2N_AS_NAME_OPT,
|
||||
];
|
||||
const CREATE_M2N_SHAPE: Node = Node::Seq(CREATE_M2N_NODES);
|
||||
|
||||
fn build_create_m2n(path: &MatchedPath, _source: &str) -> Result<Command, ValidationError> {
|
||||
Ok(Command::CreateM2nRelationship {
|
||||
t1: require_ident(path, "m2n_t1")?,
|
||||
t2: require_ident(path, "m2n_t2")?,
|
||||
name: ident(path, "table_name").map(str::to_string),
|
||||
})
|
||||
}
|
||||
|
||||
pub static CREATE_M2N: CommandNode = CommandNode {
|
||||
entry: Word::keyword("create"),
|
||||
shape: CREATE_M2N_SHAPE,
|
||||
ast_builder: build_create_m2n,
|
||||
help_id: Some("ddl.create_m2n"),
|
||||
usage_ids: &["parse.usage.create_m2n"],
|
||||
};
|
||||
|
||||
/// The friendly error for a column type without a preceding name —
|
||||
/// a structural impossibility given the grammar, defended anyway.
|
||||
fn sql_col_type_without_name() -> ValidationError {
|
||||
|
||||
@@ -657,6 +657,12 @@ pub fn usage_key_for_input_in_mode(
|
||||
if source.as_bytes().get(after).is_some_and(u8::is_ascii_digit) {
|
||||
return keys.iter().copied().find(|k| k.ends_with("relationship"));
|
||||
}
|
||||
// The `create m:n relationship` form (ADR-0045) opens with `m:n`
|
||||
// — a letter, so the digit branch misses it, and its usage key ends
|
||||
// `…create_m2n` (not `relationship`).
|
||||
if source[after..].get(..3).is_some_and(|s| s.eq_ignore_ascii_case("m:n")) {
|
||||
return keys.iter().copied().find(|k| k.ends_with("m2n"));
|
||||
}
|
||||
// Otherwise the form word is an identifier — `column`,
|
||||
// `index`, `table`, `relationship` — matched against the
|
||||
// usage key's suffix.
|
||||
@@ -706,6 +712,7 @@ pub static REGISTRY: &[(&CommandNode, CommandCategory)] = &[
|
||||
(&ddl::RENAME, CommandCategory::Simple),
|
||||
(&ddl::CHANGE, CommandCategory::Simple),
|
||||
(&ddl::CREATE, CommandCategory::Simple),
|
||||
(&ddl::CREATE_M2N, CommandCategory::Simple),
|
||||
(&data::SHOW, CommandCategory::Simple),
|
||||
(&data::INSERT, CommandCategory::Simple),
|
||||
(&data::UPDATE, CommandCategory::Simple),
|
||||
@@ -852,6 +859,13 @@ mod usage_key_tests {
|
||||
),
|
||||
("show data T", "parse.usage.show_data"),
|
||||
("show table T", "parse.usage.show_table"),
|
||||
// `create` is multi-form (table vs m:n, ADR-0045): each typed
|
||||
// form resolves to its own usage key.
|
||||
("create table T with pk id(int)", "parse.usage.create_table"),
|
||||
(
|
||||
"create m:n relationship from A to B",
|
||||
"parse.usage.create_m2n",
|
||||
),
|
||||
];
|
||||
for (input, expected) in cases {
|
||||
assert_eq!(
|
||||
|
||||
@@ -211,6 +211,21 @@ mod tests {
|
||||
assert_eq!(run("quit"), vec![(0, 4, HighlightClass::Keyword)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_m2n_relationship_highlights_cleanly() {
|
||||
// ADR-0045: a valid `create m:n relationship` line classifies
|
||||
// with no Error runs; keywords are keywords and the table names
|
||||
// are identifiers (the `m:n` opener is a Literal, keyword-classed).
|
||||
let runs = run("create m:n relationship from A to B");
|
||||
assert!(
|
||||
!runs.iter().any(|(_, _, c)| *c == HighlightClass::Error),
|
||||
"no Error highlight on a valid m:n line: {runs:?}"
|
||||
);
|
||||
let kinds: Vec<HighlightClass> = runs.iter().map(|(_, _, c)| *c).collect();
|
||||
assert!(kinds.contains(&HighlightClass::Keyword), "keywords highlighted: {runs:?}");
|
||||
assert!(kinds.contains(&HighlightClass::Identifier), "table names highlighted: {runs:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keyword_plus_identifier_via_walker() {
|
||||
// `show data Customers` walks end-to-end.
|
||||
|
||||
+63
-8
@@ -406,13 +406,28 @@ pub fn completion_probe_in_mode(
|
||||
// Mismatch and is naturally skipped — the viability check is the
|
||||
// gate, not the cursor depth.
|
||||
let mut expected_modes = vec![crate::completion::ModeClass::Both; expected.len()];
|
||||
if mode == crate::mode::Mode::Advanced {
|
||||
{
|
||||
let s = skip_whitespace(source, 0);
|
||||
if let Some((kw_start, kw_end)) = consume_ident(source, s) {
|
||||
let entry = &source[kw_start..kw_end];
|
||||
let candidates = grammar::commands_for_entry_word(entry);
|
||||
if candidates.len() > 1 {
|
||||
use crate::dsl::grammar::CommandCategory;
|
||||
use crate::dsl::grammar::CommandCategory;
|
||||
// Advanced mode merges DSL + SQL continuations across all
|
||||
// candidate nodes; Simple mode merges only when an entry word
|
||||
// has more than one DSL form (e.g. `create table` vs
|
||||
// `create m:n relationship`, ADR-0045). With a single DSL form
|
||||
// the committed node already carries every continuation, so
|
||||
// that case is left untouched (its `Both` mode-class too) —
|
||||
// keeping this zero-ripple for every existing command.
|
||||
let simple_count = candidates
|
||||
.iter()
|
||||
.filter(|(_, _, c)| *c == CommandCategory::Simple)
|
||||
.count();
|
||||
let run_merge = match mode {
|
||||
crate::mode::Mode::Advanced => candidates.len() > 1,
|
||||
crate::mode::Mode::Simple => simple_count > 1,
|
||||
};
|
||||
if run_merge {
|
||||
// (continuation word, produced-by-simple, produced-by-advanced)
|
||||
let mut tally: Vec<(&'static str, bool, bool)> = Vec::new();
|
||||
// Continuations that aren't keyword/literal-shaped
|
||||
@@ -422,6 +437,13 @@ pub fn completion_probe_in_mode(
|
||||
// for punctuation defaults to `Both`.
|
||||
let mut punct_tally: Vec<char> = Vec::new();
|
||||
for (_, node, category) in candidates {
|
||||
// Simple mode never offers advanced SQL continuations
|
||||
// (ADR-0030 §2); only DSL forms contribute.
|
||||
if mode == crate::mode::Mode::Simple
|
||||
&& category == CommandCategory::Advanced
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let mut sctx = context::WalkContext::with_schema(schema);
|
||||
sctx.mode = mode;
|
||||
let (res, _) =
|
||||
@@ -2720,13 +2742,46 @@ fn decide(
|
||||
// appended at the rendering layer (see
|
||||
// `advanced_alternative_note`), combining the DSL fix with
|
||||
// the mode hint.
|
||||
match simple.first() {
|
||||
Some(&(sidx, snode)) => Decision::Commit { idx: sidx, node: snode },
|
||||
None => {
|
||||
let primary = candidates.first().map_or("", |(_, n, _)| n.entry.primary);
|
||||
Decision::ThisIsSql { primary }
|
||||
if simple.is_empty() {
|
||||
let primary = candidates.first().map_or("", |(_, n, _)| n.entry.primary);
|
||||
return Decision::ThisIsSql { primary };
|
||||
}
|
||||
// An entry word may register more than one DSL form
|
||||
// (e.g. `create table` and `create m:n relationship`,
|
||||
// ADR-0045). Commit the first that fully matches or is
|
||||
// content-rejected (a `ValidationFailed` means the shape
|
||||
// fits but the content is invalid — that error must
|
||||
// surface), mirroring the advanced branch below. With a
|
||||
// single DSL form this reduces to "commit it": a lone
|
||||
// non-matching candidate falls through to the
|
||||
// furthest-progress step and is committed anyway, so its
|
||||
// positioned DSL error still surfaces (unchanged behaviour).
|
||||
for &(idx, node) in &simple {
|
||||
if matches!(
|
||||
scratch_outcome(effective_source, kw_start, kw_end, node, mode, schema),
|
||||
WalkOutcome::Match { .. } | WalkOutcome::ValidationFailed { .. }
|
||||
) {
|
||||
return Decision::Commit { idx, node };
|
||||
}
|
||||
}
|
||||
// None matched — commit the furthest-progress candidate
|
||||
// (first on ties) so the surfaced DSL error is the most
|
||||
// informative.
|
||||
let mut best = simple[0];
|
||||
let mut best_progress =
|
||||
scratch_progress(effective_source, kw_start, kw_end, best.1, mode, schema);
|
||||
for &(idx, node) in &simple[1..] {
|
||||
let progress =
|
||||
scratch_progress(effective_source, kw_start, kw_end, node, mode, schema);
|
||||
if progress > best_progress {
|
||||
best = (idx, node);
|
||||
best_progress = progress;
|
||||
}
|
||||
}
|
||||
Decision::Commit {
|
||||
idx: best.0,
|
||||
node: best.1,
|
||||
}
|
||||
}
|
||||
crate::mode::Mode::Advanced => {
|
||||
// Advanced candidates first, DSL as the fallback.
|
||||
|
||||
+54
@@ -15,6 +15,7 @@
|
||||
|
||||
use crate::app::EffectiveMode;
|
||||
use crate::dsl::ReferentialAction;
|
||||
use crate::dsl::types::Type;
|
||||
use crate::dsl::Command;
|
||||
use crate::dsl::command::{
|
||||
ColumnSpec, CompareOp, Constraint, ConstraintKind, Expr, Operand, Predicate, RowFilter,
|
||||
@@ -286,6 +287,31 @@ pub(crate) fn render_add_relationship(
|
||||
s
|
||||
}
|
||||
|
||||
/// The advanced-mode DSL→SQL teaching echo (ADR-0038) for `create m:n
|
||||
/// relationship` (ADR-0045): the single `CREATE TABLE` the junction
|
||||
/// expands to — every FK column, the compound primary key over them,
|
||||
/// and the two `CASCADE` foreign keys (m:n always cascades, D2). Built
|
||||
/// from the post-exec junction description (the resolved columns don't
|
||||
/// exist on the command), so it shows exactly what was created.
|
||||
pub(crate) fn render_create_m2n(
|
||||
junction: &str,
|
||||
columns: &[(String, Type)],
|
||||
primary_key: &[String],
|
||||
foreign_keys: &[(Vec<String>, String, Vec<String>)],
|
||||
) -> String {
|
||||
let mut parts: Vec<String> =
|
||||
columns.iter().map(|(n, ty)| format!("{n} {}", ty.keyword())).collect();
|
||||
parts.push(format!("PRIMARY KEY ({})", primary_key.join(", ")));
|
||||
for (child_columns, parent_table, parent_columns) in foreign_keys {
|
||||
parts.push(format!(
|
||||
"FOREIGN KEY ({}) REFERENCES {parent_table} ({}) ON DELETE CASCADE ON UPDATE CASCADE",
|
||||
child_columns.join(", "),
|
||||
parent_columns.join(", "),
|
||||
));
|
||||
}
|
||||
format!("CREATE TABLE {junction} ({})", parts.join(", "))
|
||||
}
|
||||
|
||||
/// `ALTER TABLE <C> DROP CONSTRAINT <name>` — the `drop relationship`
|
||||
/// echo (ADR-0038 §7 Bucket B). The runtime resolves both `name` (for an
|
||||
/// `Endpoints` selector) and `child_table` (for a `Named` selector) **pre-
|
||||
@@ -1077,6 +1103,34 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_m2n_echo_renders_junction_and_round_trips() {
|
||||
// The advanced-mode teaching echo for `create m:n relationship`
|
||||
// (ADR-0045): the single CREATE TABLE the junction expands to,
|
||||
// compound PK + the two CASCADE FKs — and it is valid SQL.
|
||||
let sql = render_create_m2n(
|
||||
"Students_Courses",
|
||||
&[
|
||||
("Students_id".to_string(), Type::Int),
|
||||
("Courses_id".to_string(), Type::Int),
|
||||
],
|
||||
&["Students_id".to_string(), "Courses_id".to_string()],
|
||||
&[
|
||||
(vec!["Students_id".to_string()], "Students".to_string(), vec!["id".to_string()]),
|
||||
(vec!["Courses_id".to_string()], "Courses".to_string(), vec!["id".to_string()]),
|
||||
],
|
||||
);
|
||||
assert_eq!(
|
||||
sql,
|
||||
"CREATE TABLE Students_Courses (Students_id int, Courses_id int, \
|
||||
PRIMARY KEY (Students_id, Courses_id), \
|
||||
FOREIGN KEY (Students_id) REFERENCES Students (id) ON DELETE CASCADE ON UPDATE CASCADE, \
|
||||
FOREIGN KEY (Courses_id) REFERENCES Courses (id) ON DELETE CASCADE ON UPDATE CASCADE)"
|
||||
);
|
||||
// The echoed SQL is valid advanced-mode SQL (round-trips).
|
||||
assert!(matches!(reparse(&sql), Ok(Command::SqlCreateTable { .. })));
|
||||
}
|
||||
|
||||
// --- expr / literal rendering ------------------------------------
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -190,6 +190,7 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
|
||||
("help.app.redo", &[]),
|
||||
("help.app.copy", &[]),
|
||||
("help.ddl.create", &[]),
|
||||
("help.ddl.create_m2n", &[]),
|
||||
("help.ddl.sql_create_table", &[]),
|
||||
("help.ddl.sql_drop_table", &[]),
|
||||
("help.ddl.sql_create_index", &[]),
|
||||
@@ -277,6 +278,7 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
|
||||
("parse.usage.add_relationship", &[]),
|
||||
("parse.usage.change_column", &[]),
|
||||
("parse.usage.create_table", &[]),
|
||||
("parse.usage.create_m2n", &[]),
|
||||
("parse.usage.sql_create_table", &[]),
|
||||
("parse.usage.sql_drop_table", &[]),
|
||||
("parse.usage.sql_create_index", &[]),
|
||||
|
||||
@@ -279,6 +279,9 @@ help:
|
||||
ddl:
|
||||
create: |-
|
||||
create table <T> with pk [<col>(<type>), ...] — create a table
|
||||
create_m2n: |-
|
||||
create m:n relationship from <T1> to <T2> [as <name>]
|
||||
— build a junction table linking two tables
|
||||
sql_create_table: |-
|
||||
create table [if not exists] <T> (
|
||||
<col> <type> [not null] [unique] [primary key] [default <expr>] [check (<expr>)] [references <P>[(<col>)]], ...
|
||||
@@ -523,6 +526,7 @@ parse:
|
||||
# placeholders. ADR-0009's surface conventions apply.
|
||||
usage:
|
||||
create_table: "create table <Name> with pk [<col>(<type>)[, ...]]"
|
||||
create_m2n: "create m:n relationship from <Table1> to <Table2> [as <Name>]"
|
||||
# Terse one-line synopsis (issue #12): the full grammar — every
|
||||
# column- and table-level constraint — lives in `help.ddl.sql_create_table`.
|
||||
sql_create_table: "create table [if not exists] <Name> (<col> <type> [constraints], ...)"
|
||||
|
||||
@@ -1832,6 +1832,24 @@ fn build_schema_echo(
|
||||
.map(|(name, child_table)| {
|
||||
vec![crate::echo::render_drop_relationship(name, child_table)]
|
||||
}),
|
||||
// `create m:n relationship` (ADR-0045): the resolved junction
|
||||
// columns/FKs only exist on the post-exec description, so the
|
||||
// teaching echo is rendered from it (not `command_to_sql`).
|
||||
Command::CreateM2nRelationship { .. } => description.map(|desc| {
|
||||
let columns: Vec<(String, crate::dsl::types::Type)> = desc
|
||||
.columns
|
||||
.iter()
|
||||
.filter_map(|c| c.user_type.map(|ty| (c.name.clone(), ty)))
|
||||
.collect();
|
||||
let primary_key: Vec<String> =
|
||||
desc.columns.iter().filter(|c| c.primary_key).map(|c| c.name.clone()).collect();
|
||||
let foreign_keys: Vec<(Vec<String>, String, Vec<String>)> = desc
|
||||
.outbound_relationships
|
||||
.iter()
|
||||
.map(|r| (r.local_columns.clone(), r.other_table.clone(), r.other_columns.clone()))
|
||||
.collect();
|
||||
vec![crate::echo::render_create_m2n(&desc.name, &columns, &primary_key, &foreign_keys)]
|
||||
}),
|
||||
// Everything else (Bucket A pure-Command, plus the no-echo Bucket C
|
||||
// variants like `Sql*` / `ShowTable`) routes through the existing
|
||||
// `echo::command_to_sql` — wrapping its `Option<String>` to fit the
|
||||
@@ -2657,6 +2675,10 @@ async fn execute_command_typed(
|
||||
)
|
||||
.await
|
||||
.map(|d| CommandOutcome::Schema(Some(d))),
|
||||
Command::CreateM2nRelationship { t1, t2, name } => database
|
||||
.create_m2n_relationship(t1, t2, name, src)
|
||||
.await
|
||||
.map(|d| CommandOutcome::Schema(Some(d))),
|
||||
Command::DropRelationship { selector } => database
|
||||
.drop_relationship(selector, src)
|
||||
.await
|
||||
|
||||
Reference in New Issue
Block a user