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:
@@ -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
@@ -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
@@ -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
@@ -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!(
|
||||
|
||||
Reference in New Issue
Block a user