mirror of
https://github.com/simonw/sqlite-utils.git
synced 2026-10-08 02:17:08 +02:00
transform() now round-trips compound foreign keys, refs #594
Compound foreign keys are passed through table.transform() intact instead of being degraded to per-column single foreign keys: - rename= applies to each member column of a compound key - drop= of any member column drops the whole constraint, matching the existing single-column behavior - drop_foreign_keys= accepts a bare column name (drops any foreign key that column participates in) or a tuple of columns (drops the compound key with exactly those columns) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
577f3011e5
commit
7d43fd50e1
3 changed files with 98 additions and 23 deletions
|
|
@ -1793,6 +1793,16 @@ This example drops two foreign keys - the one from ``places.country`` to ``count
|
||||||
drop_foreign_keys=("country", "continent")
|
drop_foreign_keys=("country", "continent")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
A bare column name drops any foreign key that column participates in, including compound foreign keys. To target a compound foreign key precisely, pass a tuple of its columns:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
db.table("courses").transform(
|
||||||
|
drop_foreign_keys=[("campus_name", "dept_code")]
|
||||||
|
)
|
||||||
|
|
||||||
|
Renaming a column with ``rename=`` updates any foreign keys that use it, and dropping a column with ``drop=`` also drops any foreign keys it participates in - for a compound foreign key this removes the whole constraint.
|
||||||
|
|
||||||
.. _python_api_transform_sql:
|
.. _python_api_transform_sql:
|
||||||
|
|
||||||
Custom transformations with .transform_sql()
|
Custom transformations with .transform_sql()
|
||||||
|
|
|
||||||
|
|
@ -2275,7 +2275,9 @@ class Table(Queryable):
|
||||||
:param pk: New primary key for the table
|
:param pk: New primary key for the table
|
||||||
:param not_null: Columns to set as ``NOT NULL``
|
:param not_null: Columns to set as ``NOT NULL``
|
||||||
:param defaults: Default values for columns
|
:param defaults: Default values for columns
|
||||||
:param drop_foreign_keys: Names of columns that should have their foreign key constraints removed
|
:param drop_foreign_keys: Foreign key constraints to remove - a column name
|
||||||
|
drops any foreign key that column participates in, a tuple of column names
|
||||||
|
drops the compound foreign key with exactly those columns
|
||||||
:param add_foreign_keys: List of foreign keys to add to the table
|
:param add_foreign_keys: List of foreign keys to add to the table
|
||||||
:param foreign_keys: List of foreign keys to set for the table, replacing any existing foreign keys
|
:param foreign_keys: List of foreign keys to set for the table, replacing any existing foreign keys
|
||||||
:param column_order: List of strings specifying a full or partial column order
|
:param column_order: List of strings specifying a full or partial column order
|
||||||
|
|
@ -2360,7 +2362,9 @@ class Table(Queryable):
|
||||||
:param pk: New primary key for the table
|
:param pk: New primary key for the table
|
||||||
:param not_null: Columns to set as ``NOT NULL``
|
:param not_null: Columns to set as ``NOT NULL``
|
||||||
:param defaults: Default values for columns
|
:param defaults: Default values for columns
|
||||||
:param drop_foreign_keys: Names of columns that should have their foreign key constraints removed
|
:param drop_foreign_keys: Foreign key constraints to remove - a column name
|
||||||
|
drops any foreign key that column participates in, a tuple of column names
|
||||||
|
drops the compound foreign key with exactly those columns
|
||||||
:param add_foreign_keys: List of foreign keys to add to the table
|
:param add_foreign_keys: List of foreign keys to add to the table
|
||||||
:param foreign_keys: List of foreign keys to set for the table, replacing any existing foreign keys
|
:param foreign_keys: List of foreign keys to set for the table, replacing any existing foreign keys
|
||||||
:param column_order: List of strings specifying a full or partial column order
|
:param column_order: List of strings specifying a full or partial column order
|
||||||
|
|
@ -2387,32 +2391,45 @@ class Table(Queryable):
|
||||||
create_table_foreign_keys.extend(foreign_keys)
|
create_table_foreign_keys.extend(foreign_keys)
|
||||||
else:
|
else:
|
||||||
# Construct foreign_keys from current, plus add_foreign_keys, minus drop_foreign_keys
|
# Construct foreign_keys from current, plus add_foreign_keys, minus drop_foreign_keys
|
||||||
|
|
||||||
|
def fk_should_be_dropped(fk: ForeignKey) -> bool:
|
||||||
|
if drop_foreign_keys is not None:
|
||||||
|
for spec in drop_foreign_keys:
|
||||||
|
if isinstance(spec, str):
|
||||||
|
# A column name matches any foreign key it participates in
|
||||||
|
if spec in fk.columns:
|
||||||
|
return True
|
||||||
|
elif list(spec) == fk.columns:
|
||||||
|
# A tuple/list must match a compound key's columns exactly
|
||||||
|
return True
|
||||||
|
# Dropping any of a foreign key's columns drops the whole key
|
||||||
|
return any(column in drop for column in fk.columns)
|
||||||
|
|
||||||
|
def fk_with_renamed_columns(fk: ForeignKey) -> ForeignKey:
|
||||||
|
columns = [rename.get(column) or column for column in fk.columns]
|
||||||
|
if fk.is_compound:
|
||||||
|
return ForeignKey(
|
||||||
|
self.name,
|
||||||
|
None,
|
||||||
|
fk.other_table,
|
||||||
|
None,
|
||||||
|
columns=columns,
|
||||||
|
other_columns=fk.other_columns,
|
||||||
|
is_compound=True,
|
||||||
|
)
|
||||||
|
return ForeignKey(
|
||||||
|
self.name, columns[0], fk.other_table, fk.other_columns[0]
|
||||||
|
)
|
||||||
|
|
||||||
create_table_foreign_keys = []
|
create_table_foreign_keys = []
|
||||||
|
# Copy over old foreign keys, unless we are dropping them
|
||||||
for fk in self.foreign_keys:
|
for fk in self.foreign_keys:
|
||||||
# Expand compound foreign keys into per-column references;
|
if not fk_should_be_dropped(fk):
|
||||||
# for single-column keys this iterates exactly once
|
create_table_foreign_keys.append(fk_with_renamed_columns(fk))
|
||||||
for column, other_column in zip(fk.columns, fk.other_columns):
|
|
||||||
# Copy over old foreign keys, unless we are dropping them
|
|
||||||
if (drop_foreign_keys is None) or (column not in drop_foreign_keys):
|
|
||||||
create_table_foreign_keys.append(
|
|
||||||
ForeignKey(
|
|
||||||
fk.table,
|
|
||||||
rename.get(column) or column,
|
|
||||||
fk.other_table,
|
|
||||||
other_column,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
# Add new foreign keys
|
# Add new foreign keys
|
||||||
if add_foreign_keys is not None:
|
if add_foreign_keys is not None:
|
||||||
for fk in self.db.resolve_foreign_keys(self.name, add_foreign_keys):
|
for fk in self.db.resolve_foreign_keys(self.name, add_foreign_keys):
|
||||||
create_table_foreign_keys.append(
|
create_table_foreign_keys.append(fk_with_renamed_columns(fk))
|
||||||
ForeignKey(
|
|
||||||
self.name,
|
|
||||||
rename.get(fk.column) or fk.column,
|
|
||||||
fk.other_table,
|
|
||||||
fk.other_column,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
new_table_name = "{}_new_{}".format(
|
new_table_name = "{}_new_{}".format(
|
||||||
self.name, tmp_suffix or os.urandom(6).hex()
|
self.name, tmp_suffix or os.urandom(6).hex()
|
||||||
|
|
|
||||||
|
|
@ -202,3 +202,51 @@ def test_create_table_compound_foreign_key_missing_other_column(departments_db):
|
||||||
(["campus_name", "dept_code"], "departments", ["campus_name", "nope"])
|
(["campus_name", "dept_code"], "departments", ["campus_name", "nope"])
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_transform_preserves_compound_foreign_key(compound_db):
|
||||||
|
compound_db["courses"].transform(rename={"course_name": "title"})
|
||||||
|
fks = compound_db["courses"].foreign_keys
|
||||||
|
assert len(fks) == 1
|
||||||
|
fk = fks[0]
|
||||||
|
assert fk.is_compound is True
|
||||||
|
assert fk.columns == ["campus_name", "dept_code"]
|
||||||
|
assert fk.other_table == "departments"
|
||||||
|
assert fk.other_columns == ["campus_name", "dept_code"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_transform_rename_member_column_updates_compound_foreign_key(compound_db):
|
||||||
|
compound_db["courses"].transform(rename={"campus_name": "campus"})
|
||||||
|
fks = compound_db["courses"].foreign_keys
|
||||||
|
assert len(fks) == 1
|
||||||
|
fk = fks[0]
|
||||||
|
assert fk.is_compound is True
|
||||||
|
assert fk.columns == ["campus", "dept_code"]
|
||||||
|
# Referenced columns in the other table are unchanged
|
||||||
|
assert fk.other_columns == ["campus_name", "dept_code"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_transform_drop_member_column_drops_compound_foreign_key(compound_db):
|
||||||
|
# Matches single-column behavior: dropping the column silently
|
||||||
|
# drops the foreign key that used it
|
||||||
|
compound_db["courses"].transform(drop={"dept_code"})
|
||||||
|
assert compound_db["courses"].foreign_keys == []
|
||||||
|
assert "FOREIGN KEY" not in compound_db["courses"].schema
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"drop_foreign_keys",
|
||||||
|
(
|
||||||
|
# A bare column name matches any foreign key it participates in:
|
||||||
|
["campus_name"],
|
||||||
|
# A tuple must match the full compound key:
|
||||||
|
[("campus_name", "dept_code")],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
def test_transform_drop_compound_foreign_key(compound_db, drop_foreign_keys):
|
||||||
|
compound_db["courses"].transform(drop_foreign_keys=drop_foreign_keys)
|
||||||
|
assert compound_db["courses"].foreign_keys == []
|
||||||
|
# The columns themselves survive
|
||||||
|
assert {"campus_name", "dept_code"} <= set(
|
||||||
|
compound_db["courses"].columns_dict.keys()
|
||||||
|
)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue