feat: ADR-0035 4g — ALTER TABLE add/drop constraint + add FK
ALTER TABLE <T> ADD [CONSTRAINT <name>] (CHECK | UNIQUE | FOREIGN KEY)
and DROP CONSTRAINT <name>. ADD = table-CHECK + composite UNIQUE + FK
(ADD PRIMARY KEY and a named UNIQUE refused — composite UNIQUE is
anonymous in our model). Each ADD reuses a low-level path with a dry-run
guard (table-CHECK/UNIQUE rebuild; FK -> add_relationship, bare
REFERENCES -> parent single PK). DROP CONSTRAINT resolves the name to a
named table-CHECK then a child-side FK, else refuses. One undo step each.
Named table-CHECKs round-trip: a nullable `name` column on
__rdbms_playground_table_checks (rebuild-only arrival; a named add on a
pre-4g project is refused with a "rebuild first" hint) plus a project.yaml
check_constraints {expr, name} extension (bare-string form still reads).
The internal-__rdbms_* guard was folded into do_add_constraint /
do_add_relationship, completing that guard class.
Grammar: the action Choice keeps one branch per verb (add/drop/rename/
alter) with an inner Choice fanning out on the distinct second keyword,
since the walker's Choice does not backtrack between same-led branches.
Tests: 7 Tier-1 parse + 2 yaml round-trip + 1 internal-guard + 9 Tier-3
e2e. Help/usage refreshed; ADR-0035 §13 4g + README + requirements.md in
lockstep.
This commit is contained in:
@@ -43,7 +43,7 @@ use crate::output_render::{Alignment, render_diagnostic_table};
|
||||
use crate::type_change;
|
||||
use crate::persistence::{
|
||||
CellValue, ColumnSchema, IndexSchema, Persistence, PersistenceError, RelationshipSchema,
|
||||
SchemaSnapshot, TableSchema, TableSnapshot, decode_cell, parse_csv, parse_schema,
|
||||
SchemaSnapshot, TableCheck, TableSchema, TableSnapshot, decode_cell, parse_csv, parse_schema,
|
||||
};
|
||||
use crate::project::{DATA_DIR, PROJECT_YAML};
|
||||
use crate::undo::{DEFAULT_RING_CAPACITY, SnapshotError, SnapshotMeta, SnapshotStore, Staged};
|
||||
@@ -594,6 +594,40 @@ enum Request {
|
||||
source: Option<String>,
|
||||
reply: oneshot::Sender<Result<TableDescription, DbError>>,
|
||||
},
|
||||
/// `ALTER TABLE … ADD [CONSTRAINT <name>] CHECK (<expr>)` — a
|
||||
/// table-level CHECK, named or unnamed (ADR-0035 §4g).
|
||||
AlterAddTableCheck {
|
||||
table: String,
|
||||
name: Option<String>,
|
||||
expr_sql: String,
|
||||
source: Option<String>,
|
||||
reply: oneshot::Sender<Result<TableDescription, DbError>>,
|
||||
},
|
||||
/// `ALTER TABLE … ADD UNIQUE (<col>, …)` — a composite UNIQUE
|
||||
/// constraint (ADR-0035 §4g).
|
||||
AlterAddUnique {
|
||||
table: String,
|
||||
columns: Vec<String>,
|
||||
source: Option<String>,
|
||||
reply: oneshot::Sender<Result<TableDescription, DbError>>,
|
||||
},
|
||||
/// `ALTER TABLE … DROP CONSTRAINT <name>` — drop a named table-level
|
||||
/// CHECK or a named FK (ADR-0035 §4g).
|
||||
AlterDropConstraint {
|
||||
table: String,
|
||||
name: String,
|
||||
source: Option<String>,
|
||||
reply: oneshot::Sender<Result<Option<TableDescription>, DbError>>,
|
||||
},
|
||||
/// `ALTER TABLE <child> ADD [CONSTRAINT <name>] FOREIGN KEY (…)
|
||||
/// REFERENCES …` — a relationship on an existing table (ADR-0035 §4g).
|
||||
AlterAddForeignKey {
|
||||
child_table: String,
|
||||
name: Option<String>,
|
||||
fk: Box<SqlForeignKey>,
|
||||
source: Option<String>,
|
||||
reply: oneshot::Sender<Result<TableDescription, DbError>>,
|
||||
},
|
||||
Insert {
|
||||
table: String,
|
||||
columns: Option<Vec<String>>,
|
||||
@@ -1075,6 +1109,87 @@ impl Database {
|
||||
recv.await.map_err(|_| DbError::WorkerGone)?
|
||||
}
|
||||
|
||||
/// `ALTER TABLE … ADD [CONSTRAINT <name>] CHECK (<expr>)` — a
|
||||
/// table-level CHECK (ADR-0035 §4g).
|
||||
pub async fn alter_add_table_check(
|
||||
&self,
|
||||
table: String,
|
||||
name: Option<String>,
|
||||
expr_sql: String,
|
||||
source: Option<String>,
|
||||
) -> Result<TableDescription, DbError> {
|
||||
let (reply, recv) = oneshot::channel();
|
||||
self.send(Request::AlterAddTableCheck {
|
||||
table,
|
||||
name,
|
||||
expr_sql,
|
||||
source,
|
||||
reply,
|
||||
})
|
||||
.await?;
|
||||
recv.await.map_err(|_| DbError::WorkerGone)?
|
||||
}
|
||||
|
||||
/// `ALTER TABLE … ADD UNIQUE (<col>, …)` — a composite UNIQUE
|
||||
/// constraint (ADR-0035 §4g).
|
||||
pub async fn alter_add_unique(
|
||||
&self,
|
||||
table: String,
|
||||
columns: Vec<String>,
|
||||
source: Option<String>,
|
||||
) -> Result<TableDescription, DbError> {
|
||||
let (reply, recv) = oneshot::channel();
|
||||
self.send(Request::AlterAddUnique {
|
||||
table,
|
||||
columns,
|
||||
source,
|
||||
reply,
|
||||
})
|
||||
.await?;
|
||||
recv.await.map_err(|_| DbError::WorkerGone)?
|
||||
}
|
||||
|
||||
/// `ALTER TABLE … DROP CONSTRAINT <name>` — drop a named table-level
|
||||
/// CHECK or a named FK (ADR-0035 §4g).
|
||||
pub async fn alter_drop_constraint(
|
||||
&self,
|
||||
table: String,
|
||||
name: String,
|
||||
source: Option<String>,
|
||||
) -> Result<Option<TableDescription>, DbError> {
|
||||
let (reply, recv) = oneshot::channel();
|
||||
self.send(Request::AlterDropConstraint {
|
||||
table,
|
||||
name,
|
||||
source,
|
||||
reply,
|
||||
})
|
||||
.await?;
|
||||
recv.await.map_err(|_| DbError::WorkerGone)?
|
||||
}
|
||||
|
||||
/// `ALTER TABLE <child> ADD [CONSTRAINT <name>] FOREIGN KEY (…)
|
||||
/// REFERENCES …` — add a relationship to an existing table (ADR-0035
|
||||
/// §4g).
|
||||
pub async fn alter_add_foreign_key(
|
||||
&self,
|
||||
child_table: String,
|
||||
name: Option<String>,
|
||||
fk: SqlForeignKey,
|
||||
source: Option<String>,
|
||||
) -> Result<TableDescription, DbError> {
|
||||
let (reply, recv) = oneshot::channel();
|
||||
self.send(Request::AlterAddForeignKey {
|
||||
child_table,
|
||||
name,
|
||||
fk: Box::new(fk),
|
||||
source,
|
||||
reply,
|
||||
})
|
||||
.await?;
|
||||
recv.await.map_err(|_| DbError::WorkerGone)?
|
||||
}
|
||||
|
||||
pub async fn rename_column(
|
||||
&self,
|
||||
table: String,
|
||||
@@ -1547,6 +1662,7 @@ fn configure_connection(conn: &Connection) -> Result<(), rusqlite::Error> {
|
||||
table_name TEXT NOT NULL,\n\
|
||||
seq INTEGER NOT NULL,\n\
|
||||
check_expr TEXT NOT NULL,\n\
|
||||
name TEXT,\n\
|
||||
PRIMARY KEY (table_name, seq)\n\
|
||||
) STRICT;\n\
|
||||
CREATE TABLE IF NOT EXISTS {META_PROJECT_TABLE} (\n\
|
||||
@@ -2139,6 +2255,62 @@ fn handle_request(
|
||||
kind,
|
||||
));
|
||||
}
|
||||
Request::AlterAddTableCheck {
|
||||
table,
|
||||
name,
|
||||
expr_sql,
|
||||
source,
|
||||
reply,
|
||||
} => {
|
||||
snapshot_then(snap, batch, conn, source.as_deref(), reply, || {
|
||||
do_alter_add_table_check(
|
||||
conn,
|
||||
persistence,
|
||||
source.as_deref(),
|
||||
&table,
|
||||
name.as_deref(),
|
||||
&expr_sql,
|
||||
)
|
||||
});
|
||||
}
|
||||
Request::AlterAddUnique {
|
||||
table,
|
||||
columns,
|
||||
source,
|
||||
reply,
|
||||
} => {
|
||||
snapshot_then(snap, batch, conn, source.as_deref(), reply, || {
|
||||
do_alter_add_unique(conn, persistence, source.as_deref(), &table, &columns)
|
||||
});
|
||||
}
|
||||
Request::AlterDropConstraint {
|
||||
table,
|
||||
name,
|
||||
source,
|
||||
reply,
|
||||
} => {
|
||||
snapshot_then(snap, batch, conn, source.as_deref(), reply, || {
|
||||
do_drop_constraint_by_name(conn, persistence, source.as_deref(), &table, &name)
|
||||
});
|
||||
}
|
||||
Request::AlterAddForeignKey {
|
||||
child_table,
|
||||
name,
|
||||
fk,
|
||||
source,
|
||||
reply,
|
||||
} => {
|
||||
snapshot_then(snap, batch, conn, source.as_deref(), reply, || {
|
||||
do_alter_add_foreign_key(
|
||||
conn,
|
||||
persistence,
|
||||
source.as_deref(),
|
||||
&child_table,
|
||||
name.as_deref(),
|
||||
&fk,
|
||||
)
|
||||
});
|
||||
}
|
||||
Request::Insert {
|
||||
table,
|
||||
columns,
|
||||
@@ -3468,6 +3640,11 @@ fn do_add_constraint(
|
||||
column: &str,
|
||||
constraint: &Constraint,
|
||||
) -> Result<TableDescription, DbError> {
|
||||
// Refuse the internal `__rdbms_*` tables up-front (as "no such
|
||||
// table"), like the sibling schema-mutation executors. Closes the
|
||||
// simple `add constraint` exposure and the SQL `ALTER TABLE … ADD
|
||||
// CONSTRAINT` decomposition target (ADR-0035 §4g).
|
||||
reject_internal_table_name(table)?;
|
||||
let old_schema = read_schema(conn, table)?;
|
||||
let (col_is_pk, col_user_type) = {
|
||||
let col = old_schema
|
||||
@@ -5107,11 +5284,13 @@ struct ReadSchema {
|
||||
/// read from the UNIQUE-constraint indexes (`origin = 'u'`).
|
||||
/// Single-column UNIQUE rides on `ReadColumn::unique` instead.
|
||||
unique_constraints: Vec<Vec<String>>,
|
||||
/// Table-level CHECK constraints as raw SQL text, in declaration
|
||||
/// order (ADR-0035 §4a.3). The engine reports no CHECK constraints,
|
||||
/// so these are read from `__rdbms_playground_table_checks` rather
|
||||
/// than PRAGMA, and echoed verbatim by `schema_to_ddl` on rebuild.
|
||||
check_constraints: Vec<String>,
|
||||
/// Table-level CHECK constraints as raw SQL text with an optional
|
||||
/// name, in declaration order (ADR-0035 §4a.3, named in §4g). The
|
||||
/// engine reports no CHECK constraints, so these are read from
|
||||
/// `__rdbms_playground_table_checks` rather than PRAGMA, and echoed
|
||||
/// verbatim by `schema_to_ddl` on rebuild (`CONSTRAINT <name>` when
|
||||
/// named).
|
||||
check_constraints: Vec<TableCheck>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -5245,19 +5424,32 @@ fn read_schema(conn: &Connection, table: &str) -> Result<ReadSchema, DbError> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Read a table's table-level CHECK constraints (ADR-0035 §4a.3) from
|
||||
/// `CHECK_TABLE`, in declaration order (`seq`). The engine exposes no
|
||||
/// PRAGMA for CHECK constraints, so this metadata table is their only
|
||||
/// source of truth.
|
||||
fn read_table_checks(conn: &Connection, table: &str) -> Result<Vec<String>, DbError> {
|
||||
let mut stmt = conn
|
||||
.prepare(&format!(
|
||||
/// Read a table's table-level CHECK constraints (ADR-0035 §4a.3, named
|
||||
/// in §4g) from `CHECK_TABLE`, in declaration order (`seq`). The engine
|
||||
/// exposes no PRAGMA for CHECK constraints, so this metadata table is
|
||||
/// their only source of truth. Tolerates a pre-4g project whose table
|
||||
/// predates the `name` column (rebuild-only migration) by reading the
|
||||
/// name as `None`.
|
||||
fn read_table_checks(conn: &Connection, table: &str) -> Result<Vec<TableCheck>, DbError> {
|
||||
let has_name = check_table_has_name_column(conn)?;
|
||||
let sql = if has_name {
|
||||
format!(
|
||||
"SELECT check_expr, name FROM {CHECK_TABLE} \
|
||||
WHERE table_name = ?1 ORDER BY seq;"
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"SELECT check_expr FROM {CHECK_TABLE} \
|
||||
WHERE table_name = ?1 ORDER BY seq;"
|
||||
))
|
||||
.map_err(DbError::from_rusqlite)?;
|
||||
)
|
||||
};
|
||||
let mut stmt = conn.prepare(&sql).map_err(DbError::from_rusqlite)?;
|
||||
let rows = stmt
|
||||
.query_map([table], |row| row.get::<_, String>(0))
|
||||
.query_map([table], |row| {
|
||||
let expr: String = row.get(0)?;
|
||||
let name: Option<String> = if has_name { row.get(1)? } else { None };
|
||||
Ok(TableCheck { name, expr })
|
||||
})
|
||||
.map_err(DbError::from_rusqlite)?;
|
||||
let mut out = Vec::new();
|
||||
for row in rows {
|
||||
@@ -5266,6 +5458,23 @@ fn read_table_checks(conn: &Connection, table: &str) -> Result<Vec<String>, DbEr
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Whether `CHECK_TABLE` carries the `name` column (ADR-0035 §4g). A
|
||||
/// pre-4g project's metadata table predates it — the column arrives on
|
||||
/// `rebuild` (the rebuild-only migration, user-confirmed 2026-05-25).
|
||||
/// Used to read names tolerantly and to refuse a *named* CHECK add on an
|
||||
/// un-upgraded project with a friendly "rebuild first" message rather
|
||||
/// than a raw engine error.
|
||||
fn check_table_has_name_column(conn: &Connection) -> Result<bool, DbError> {
|
||||
let count: i64 = conn
|
||||
.query_row(
|
||||
&format!("SELECT COUNT(*) FROM pragma_table_info('{CHECK_TABLE}') WHERE name = 'name';"),
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.map_err(DbError::from_rusqlite)?;
|
||||
Ok(count > 0)
|
||||
}
|
||||
|
||||
/// Whether the raw CHECK expression `check_expr` references the column
|
||||
/// `column` (ADR-0035 §4e — the 4a.3-deferred drop/rename guard).
|
||||
///
|
||||
@@ -5349,8 +5558,8 @@ fn column_referenced_by_check(
|
||||
column: &str,
|
||||
include_self: bool,
|
||||
) -> Result<bool, DbError> {
|
||||
for expr in read_table_checks(conn, table)? {
|
||||
if check_references_column(&expr, column) {
|
||||
for check in read_table_checks(conn, table)? {
|
||||
if check_references_column(&check.expr, column) {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
@@ -5561,9 +5770,18 @@ fn schema_to_ddl(table: &str, schema: &ReadSchema) -> String {
|
||||
|
||||
// Table-level CHECK constraints (ADR-0035 §4a.3) — echoed verbatim
|
||||
// from the raw SQL stored in the metadata table, emitted identically
|
||||
// to `do_create_table` (the §6.1 two-generators rule).
|
||||
for expr in &schema.check_constraints {
|
||||
clauses.push(format!("CHECK ({expr})"));
|
||||
// to `do_create_table` (the §6.1 two-generators rule). A named CHECK
|
||||
// (ADR-0035 §4g) re-emits its `CONSTRAINT <name>` prefix so the name
|
||||
// round-trips through a rebuild.
|
||||
for check in &schema.check_constraints {
|
||||
match &check.name {
|
||||
Some(name) => clauses.push(format!(
|
||||
"CONSTRAINT {ident} CHECK ({expr})",
|
||||
ident = quote_ident(name),
|
||||
expr = check.expr,
|
||||
)),
|
||||
None => clauses.push(format!("CHECK ({expr})", expr = check.expr)),
|
||||
}
|
||||
}
|
||||
|
||||
for fk in &schema.foreign_keys {
|
||||
@@ -5981,6 +6199,12 @@ fn do_add_relationship(
|
||||
on_update: ReferentialAction,
|
||||
create_fk: bool,
|
||||
) -> Result<TableDescription, DbError> {
|
||||
// Refuse the internal `__rdbms_*` tables on either endpoint (as "no
|
||||
// such table"), like the sibling schema-mutation executors. Closes
|
||||
// the simple `add 1:n relationship` exposure and the SQL `ALTER
|
||||
// TABLE … ADD FOREIGN KEY` decomposition target (ADR-0035 §4g).
|
||||
reject_internal_table_name(parent_table)?;
|
||||
reject_internal_table_name(child_table)?;
|
||||
// 1. Read parent schema; verify the referenced column is a PK.
|
||||
let parent_schema = read_schema(conn, parent_table)?;
|
||||
let parent_col = parent_schema
|
||||
@@ -6180,6 +6404,305 @@ fn do_drop_relationship(
|
||||
Ok(Some(do_describe_table(conn, &parent_table)?))
|
||||
}
|
||||
|
||||
/// `ALTER TABLE <T> ADD [CONSTRAINT <name>] CHECK (<expr>)` (ADR-0035
|
||||
/// §4g). A dry-run refuses the add if any existing row fails the
|
||||
/// predicate; the rebuild then re-emits the table with the new CHECK in
|
||||
/// its DDL and records it in `CHECK_TABLE`. A *named* CHECK on a pre-4g
|
||||
/// project (whose metadata table predates the `name` column — the
|
||||
/// rebuild-only migration) is refused with a friendly "rebuild first"
|
||||
/// message rather than a raw engine error.
|
||||
fn do_alter_add_table_check(
|
||||
conn: &Connection,
|
||||
persistence: Option<&Persistence>,
|
||||
source: Option<&str>,
|
||||
table: &str,
|
||||
name: Option<&str>,
|
||||
expr_sql: &str,
|
||||
) -> Result<TableDescription, DbError> {
|
||||
reject_internal_table_name(table)?;
|
||||
let old_schema = read_schema(conn, table)?;
|
||||
|
||||
if name.is_some() && !check_table_has_name_column(conn)? {
|
||||
return Err(DbError::Unsupported(
|
||||
"this project predates named constraints; run `rebuild` to \
|
||||
upgrade it, then add the named constraint again."
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// A named CHECK must not collide with an existing CHECK name on this
|
||||
// table NOR with a relationship name (FKs are also `DROP CONSTRAINT`
|
||||
// targets) — keeps `drop constraint <name>` unambiguous.
|
||||
if let Some(n) = name {
|
||||
let collides_check = old_schema
|
||||
.check_constraints
|
||||
.iter()
|
||||
.any(|c| c.name.as_deref() == Some(n));
|
||||
let collides_rel: i64 = conn
|
||||
.query_row(
|
||||
&format!("SELECT COUNT(*) FROM {REL_TABLE} WHERE name = ?1;"),
|
||||
[n],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.map_err(DbError::from_rusqlite)?;
|
||||
if collides_check || collides_rel > 0 {
|
||||
return Err(DbError::Unsupported(format!(
|
||||
"a constraint named `{n}` already exists on `{table}`."
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
// Dry-run: a CHECK passes on TRUE or NULL; only FALSE fails, so
|
||||
// `WHERE NOT (expr)` counts the genuine violations.
|
||||
let violating: i64 = conn
|
||||
.query_row(
|
||||
&format!(
|
||||
"SELECT COUNT(*) FROM {tbl} WHERE NOT ({expr_sql});",
|
||||
tbl = quote_ident(table),
|
||||
),
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.map_err(DbError::from_rusqlite)?;
|
||||
if violating > 0 {
|
||||
return Err(DbError::Unsupported(format!(
|
||||
"cannot add CHECK ({expr_sql}) to `{table}`: {violating} existing \
|
||||
row(s) do not satisfy it."
|
||||
)));
|
||||
}
|
||||
|
||||
let mut new_schema = old_schema.clone();
|
||||
new_schema.check_constraints.push(TableCheck {
|
||||
name: name.map(ToString::to_string),
|
||||
expr: expr_sql.to_string(),
|
||||
});
|
||||
|
||||
let table_owned = table.to_string();
|
||||
let name_owned = name.map(ToString::to_string);
|
||||
let expr_owned = expr_sql.to_string();
|
||||
rebuild_table(conn, table, &old_schema, &new_schema, |tx| {
|
||||
// MAX(seq)+1 avoids colliding with a gap a prior DROP left.
|
||||
let next_seq: i64 = tx
|
||||
.query_row(
|
||||
&format!(
|
||||
"SELECT COALESCE(MAX(seq), -1) + 1 FROM {CHECK_TABLE} \
|
||||
WHERE table_name = ?1;"
|
||||
),
|
||||
[table_owned.as_str()],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.map_err(DbError::from_rusqlite)?;
|
||||
tx.execute(
|
||||
&format!(
|
||||
"INSERT INTO {CHECK_TABLE} (table_name, seq, check_expr, name) \
|
||||
VALUES (?1, ?2, ?3, ?4);"
|
||||
),
|
||||
rusqlite::params![table_owned, next_seq, expr_owned, name_owned],
|
||||
)
|
||||
.map_err(DbError::from_rusqlite)?;
|
||||
let changes = Changes {
|
||||
schema_dirty: true,
|
||||
rewritten_tables: vec![table_owned.clone()],
|
||||
..Changes::default()
|
||||
};
|
||||
finalize_persistence(tx, persistence, source, &changes)?;
|
||||
Ok(())
|
||||
})?;
|
||||
do_describe_table(conn, table)
|
||||
}
|
||||
|
||||
/// `ALTER TABLE <T> ADD UNIQUE (<col>, …)` (ADR-0035 §4g) — a composite
|
||||
/// UNIQUE constraint (anonymous: composite UNIQUE is PRAGMA-detected on
|
||||
/// read, ADR-0035 §4a.2, so it carries no name). A dry-run refuses the
|
||||
/// add if existing rows already contain a duplicate non-NULL tuple
|
||||
/// (NULLs are distinct under SQL's UNIQUE semantics).
|
||||
fn do_alter_add_unique(
|
||||
conn: &Connection,
|
||||
persistence: Option<&Persistence>,
|
||||
source: Option<&str>,
|
||||
table: &str,
|
||||
columns: &[String],
|
||||
) -> Result<TableDescription, DbError> {
|
||||
reject_internal_table_name(table)?;
|
||||
let old_schema = read_schema(conn, table)?;
|
||||
for c in columns {
|
||||
if !old_schema.columns.iter().any(|oc| &oc.name == c) {
|
||||
return Err(DbError::Sqlite {
|
||||
message: format!("no such column: {table}.{c}"),
|
||||
kind: SqliteErrorKind::NoSuchColumn,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let non_null = columns
|
||||
.iter()
|
||||
.map(|c| format!("{} IS NOT NULL", quote_ident(c)))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" AND ");
|
||||
let group = columns
|
||||
.iter()
|
||||
.map(|c| quote_ident(c))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
let dup_groups: i64 = conn
|
||||
.query_row(
|
||||
&format!(
|
||||
"SELECT COUNT(*) FROM (SELECT 1 FROM {tbl} WHERE {non_null} \
|
||||
GROUP BY {group} HAVING COUNT(*) > 1);",
|
||||
tbl = quote_ident(table),
|
||||
),
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.map_err(DbError::from_rusqlite)?;
|
||||
if dup_groups > 0 {
|
||||
return Err(DbError::Unsupported(format!(
|
||||
"cannot add UNIQUE ({}) to `{table}`: existing rows contain \
|
||||
duplicate values.",
|
||||
columns.join(", "),
|
||||
)));
|
||||
}
|
||||
|
||||
let mut new_schema = old_schema.clone();
|
||||
new_schema.unique_constraints.push(columns.to_vec());
|
||||
let table_owned = table.to_string();
|
||||
rebuild_table(conn, table, &old_schema, &new_schema, |tx| {
|
||||
let changes = Changes {
|
||||
schema_dirty: true,
|
||||
rewritten_tables: vec![table_owned.clone()],
|
||||
..Changes::default()
|
||||
};
|
||||
finalize_persistence(tx, persistence, source, &changes)?;
|
||||
Ok(())
|
||||
})?;
|
||||
do_describe_table(conn, table)
|
||||
}
|
||||
|
||||
/// `ALTER TABLE <T> DROP CONSTRAINT <name>` (ADR-0035 §4g). Resolves
|
||||
/// `name` to a named table-level CHECK on `T` (rebuild without it +
|
||||
/// delete the metadata row), else to a named relationship (FK) whose
|
||||
/// child is `T` (via `do_drop_relationship`), else refuses.
|
||||
fn do_drop_constraint_by_name(
|
||||
conn: &Connection,
|
||||
persistence: Option<&Persistence>,
|
||||
source: Option<&str>,
|
||||
table: &str,
|
||||
name: &str,
|
||||
) -> Result<Option<TableDescription>, DbError> {
|
||||
reject_internal_table_name(table)?;
|
||||
|
||||
// 1. A named table-level CHECK on this table?
|
||||
if check_table_has_name_column(conn)? {
|
||||
let check_count: i64 = conn
|
||||
.query_row(
|
||||
&format!(
|
||||
"SELECT COUNT(*) FROM {CHECK_TABLE} \
|
||||
WHERE table_name = ?1 AND name = ?2;"
|
||||
),
|
||||
[table, name],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.map_err(DbError::from_rusqlite)?;
|
||||
if check_count > 0 {
|
||||
let old_schema = read_schema(conn, table)?;
|
||||
let mut new_schema = old_schema.clone();
|
||||
new_schema
|
||||
.check_constraints
|
||||
.retain(|c| c.name.as_deref() != Some(name));
|
||||
let (t, n) = (table.to_string(), name.to_string());
|
||||
rebuild_table(conn, table, &old_schema, &new_schema, |tx| {
|
||||
tx.execute(
|
||||
&format!(
|
||||
"DELETE FROM {CHECK_TABLE} WHERE table_name = ?1 AND name = ?2;"
|
||||
),
|
||||
[t.as_str(), n.as_str()],
|
||||
)
|
||||
.map_err(DbError::from_rusqlite)?;
|
||||
let changes = Changes {
|
||||
schema_dirty: true,
|
||||
rewritten_tables: vec![t.clone()],
|
||||
..Changes::default()
|
||||
};
|
||||
finalize_persistence(tx, persistence, source, &changes)?;
|
||||
Ok(())
|
||||
})?;
|
||||
return Ok(Some(do_describe_table(conn, table)?));
|
||||
}
|
||||
}
|
||||
|
||||
// 2. A named relationship (FK) whose child is this table?
|
||||
let rel_count: i64 = conn
|
||||
.query_row(
|
||||
&format!("SELECT COUNT(*) FROM {REL_TABLE} WHERE name = ?1 AND child_table = ?2;"),
|
||||
[name, table],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.map_err(DbError::from_rusqlite)?;
|
||||
if rel_count > 0 {
|
||||
return do_drop_relationship(
|
||||
conn,
|
||||
persistence,
|
||||
source,
|
||||
&RelationshipSelector::Named {
|
||||
name: name.to_string(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// 3. Not a known named constraint on this table.
|
||||
Err(DbError::Sqlite {
|
||||
message: format!("no such constraint: {name} on {table}"),
|
||||
kind: SqliteErrorKind::Other,
|
||||
})
|
||||
}
|
||||
|
||||
/// `ALTER TABLE <child> ADD [CONSTRAINT <name>] FOREIGN KEY (<col>)
|
||||
/// REFERENCES <P>[(<col>)] [ON …]` (ADR-0035 §4g). Resolves a bare
|
||||
/// `REFERENCES <P>` to the parent's single-column PK, then delegates to
|
||||
/// `do_add_relationship` (the same machinery `add 1:n relationship`
|
||||
/// uses) with `create_fk = false` — the child column must already exist
|
||||
/// (an `ALTER … ADD FOREIGN KEY` references an existing column).
|
||||
fn do_alter_add_foreign_key(
|
||||
conn: &Connection,
|
||||
persistence: Option<&Persistence>,
|
||||
source: Option<&str>,
|
||||
child_table: &str,
|
||||
name: Option<&str>,
|
||||
fk: &SqlForeignKey,
|
||||
) -> Result<TableDescription, DbError> {
|
||||
reject_internal_table_name(child_table)?;
|
||||
reject_internal_table_name(&fk.parent_table)?;
|
||||
let parent_column = match &fk.parent_column {
|
||||
Some(c) => c.clone(),
|
||||
None => {
|
||||
let ps = read_schema(conn, &fk.parent_table)?;
|
||||
if ps.primary_key.len() == 1 {
|
||||
ps.primary_key[0].clone()
|
||||
} else {
|
||||
return Err(DbError::Unsupported(format!(
|
||||
"`{parent}` has a composite primary key, so a bare reference \
|
||||
is ambiguous — name the referenced column, e.g. \
|
||||
`REFERENCES {parent}(<col>)`.",
|
||||
parent = fk.parent_table,
|
||||
)));
|
||||
}
|
||||
}
|
||||
};
|
||||
do_add_relationship(
|
||||
conn,
|
||||
persistence,
|
||||
source,
|
||||
name,
|
||||
&fk.parent_table,
|
||||
&parent_column,
|
||||
child_table,
|
||||
&fk.child_column,
|
||||
fk.on_delete,
|
||||
fk.on_update,
|
||||
false,
|
||||
)
|
||||
}
|
||||
|
||||
/// Create an index on `table` over `columns` (ADR-0025).
|
||||
///
|
||||
/// Refuses a redundant index on an already-indexed column set
|
||||
|
||||
Reference in New Issue
Block a user