Skip to content

Commit 509857e

Browse files
authored
.add_foreign_keys() uses .transform() instead of PRAGMA writable_schema
Closes #577 This should solve all sorts of problems seen by users of platforms that throw errors on writable_schema. Also added `add_foreign_keys=` and `foreign_keys=` parameters to `table.transform()`.
1 parent 993029f commit 509857e

File tree

6 files changed

+336
-143
lines changed

6 files changed

+336
-143
lines changed

docs/python-api.rst

Lines changed: 55 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1281,9 +1281,7 @@ Adding foreign key constraints
12811281

12821282
The SQLite ``ALTER TABLE`` statement doesn't have the ability to add foreign key references to an existing column.
12831283

1284-
It's possible to add these references through very careful manipulation of SQLite's ``sqlite_master`` table, using ``PRAGMA writable_schema``.
1285-
1286-
``sqlite-utils`` can do this for you, though there is a significant risk of data corruption if something goes wrong so it is advisable to create a fresh copy of your database file before attempting this.
1284+
The ``add_foreign_key()`` method here is a convenient wrapper around :ref:`table.transform() <python_api_transform>`.
12871285

12881286
Here's an example of this mechanism in action:
12891287

@@ -1317,11 +1315,7 @@ To ignore the case where the key already exists, use ``ignore=True``:
13171315
Adding multiple foreign key constraints at once
13181316
-----------------------------------------------
13191317

1320-
The final step in adding a new foreign key to a SQLite database is to run ``VACUUM``, to ensure the new foreign key is available in future introspection queries.
1321-
1322-
``VACUUM`` against a large (multi-GB) database can take several minutes or longer. If you are adding multiple foreign keys using ``table.add_foreign_key(...)`` these can quickly add up.
1323-
1324-
Instead, you can use ``db.add_foreign_keys(...)`` to add multiple foreign keys within a single transaction. This method takes a list of four-tuples, each one specifying a ``table``, ``column``, ``other_table`` and ``other_column``.
1318+
You can use ``db.add_foreign_keys(...)`` to add multiple foreign keys in one go. This method takes a list of four-tuples, each one specifying a ``table``, ``column``, ``other_table`` and ``other_column``.
13251319

13261320
Here's an example adding two foreign keys at once:
13271321

@@ -1388,6 +1382,8 @@ To keep the original table around instead of dropping it, pass the ``keep_table=
13881382
13891383
table.transform(types={"age": int}, keep_table="original_table")
13901384
1385+
.. _python_api_transform_alter_column_types:
1386+
13911387
Altering column types
13921388
---------------------
13931389

@@ -1400,6 +1396,8 @@ To alter the type of a column, use the ``types=`` argument:
14001396
14011397
See :ref:`python_api_add_column` for a list of available types.
14021398

1399+
.. _python_api_transform_rename_columns:
1400+
14031401
Renaming columns
14041402
----------------
14051403

@@ -1410,6 +1408,8 @@ The ``rename=`` parameter can rename columns:
14101408
# Rename 'age' to 'initial_age':
14111409
table.transform(rename={"age": "initial_age"})
14121410
1411+
.. _python_api_transform_drop_columns:
1412+
14131413
Dropping columns
14141414
----------------
14151415

@@ -1420,6 +1420,8 @@ To drop columns, pass them in the ``drop=`` set:
14201420
# Drop the 'age' column:
14211421
table.transform(drop={"age"})
14221422
1423+
.. _python_api_transform_change_primary_keys:
1424+
14231425
Changing primary keys
14241426
---------------------
14251427

@@ -1430,6 +1432,8 @@ To change the primary key for a table, use ``pk=``. This can be passed a single
14301432
# Make `user_id` the new primary key
14311433
table.transform(pk="user_id")
14321434
1435+
.. _python_api_transform_change_not_null:
1436+
14331437
Changing not null status
14341438
------------------------
14351439

@@ -1450,6 +1454,8 @@ If you want to take existing ``NOT NULL`` columns and change them to allow null
14501454
# Make age allow NULL and switch weight to being NOT NULL:
14511455
table.transform(not_null={"age": False, "weight": True})
14521456
1457+
.. _python_api_transform_alter_column_defaults:
1458+
14531459
Altering column defaults
14541460
------------------------
14551461

@@ -1463,6 +1469,8 @@ The ``defaults=`` parameter can be used to set or change the defaults for differ
14631469
# Now remove the default from that column:
14641470
table.transform(defaults={"age": None})
14651471
1472+
.. _python_api_transform_change_column_order:
1473+
14661474
Changing column order
14671475
---------------------
14681476

@@ -1473,6 +1481,45 @@ The ``column_order=`` parameter can be used to change the order of the columns.
14731481
# Change column order
14741482
table.transform(column_order=("name", "age", "id")
14751483
1484+
.. _python_api_transform_add_foreign_key_constraints:
1485+
1486+
Adding foreign key constraints
1487+
------------------------------
1488+
1489+
You can add one or more foreign key constraints to a table using the ``add_foreign_keys=`` parameter:
1490+
1491+
.. code-block:: python
1492+
1493+
db["places"].transform(
1494+
add_foreign_keys=(
1495+
("country", "country", "id"),
1496+
("continent", "continent", "id")
1497+
)
1498+
)
1499+
1500+
This accepts the same arguments described in :ref:`specifying foreign keys <python_api_foreign_keys>` - so you can specify them as a full tuple of ``(column, other_table, other_column)``, or you can take a shortcut and pass just the name of the column, provided the table can be automatically derived from the column name:
1501+
1502+
.. code-block:: python
1503+
1504+
db["places"].transform(
1505+
add_foreign_keys=(("country", "continent"))
1506+
)
1507+
1508+
.. _python_api_transform_replace_foreign_key_constraints:
1509+
1510+
Replacing foreign key constraints
1511+
---------------------------------
1512+
1513+
The ``foreign_keys=`` parameter is similar to to ``add_foreign_keys=`` but can be be used to replace all foreign key constraints on a table, dropping any that are not explicitly mentioned:
1514+
1515+
.. code-block:: python
1516+
1517+
db["places"].transform(
1518+
add_foreign_keys=(("continent",))
1519+
)
1520+
1521+
.. _python_api_transform_drop_foreign_key_constraints:
1522+
14761523
Dropping foreign key constraints
14771524
--------------------------------
14781525

sqlite_utils/db.py

Lines changed: 86 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -156,14 +156,16 @@
156156
Trigger = namedtuple("Trigger", ("name", "table", "sql"))
157157

158158

159-
ForeignKeysType = Union[
160-
Iterable[str],
161-
Iterable[ForeignKey],
162-
Iterable[Tuple[str, str]],
163-
Iterable[Tuple[str, str, str]],
164-
Iterable[Tuple[str, str, str, str]],
159+
ForeignKeyIndicator = Union[
160+
str,
161+
ForeignKey,
162+
Tuple[str, str],
163+
Tuple[str, str, str],
164+
Tuple[str, str, str, str],
165165
]
166166

167+
ForeignKeysType = Union[Iterable[ForeignKeyIndicator], List[ForeignKeyIndicator]]
168+
167169

168170
class Default:
169171
pass
@@ -747,9 +749,16 @@ def execute_returning_dicts(
747749
def resolve_foreign_keys(
748750
self, name: str, foreign_keys: ForeignKeysType
749751
) -> List[ForeignKey]:
750-
# foreign_keys may be a list of column names, a list of ForeignKey tuples,
751-
# a list of tuple-pairs or a list of tuple-triples. We want to turn
752-
# it into a list of ForeignKey tuples
752+
"""
753+
Given a list of differing foreign_keys definitions, return a list of
754+
fully resolved ForeignKey() named tuples.
755+
756+
:param name: Name of table that foreign keys are being defined for
757+
:param foreign_keys: List of foreign keys, each of which can be a
758+
string, a ForeignKey() named tuple, a tuple of (column, other_table),
759+
or a tuple of (column, other_table, other_column), or a tuple of
760+
(table, column, other_table, other_column)
761+
"""
753762
table = cast(Table, self[name])
754763
if all(isinstance(fk, ForeignKey) for fk in foreign_keys):
755764
return cast(List[ForeignKey], foreign_keys)
@@ -767,12 +776,20 @@ def resolve_foreign_keys(
767776
), "foreign_keys= should be a list of tuples"
768777
fks = []
769778
for tuple_or_list in foreign_keys:
779+
if len(tuple_or_list) == 4:
780+
assert (
781+
tuple_or_list[0] == name
782+
), "First item in {} should have been {}".format(tuple_or_list, name)
770783
assert len(tuple_or_list) in (
771784
2,
772785
3,
786+
4,
773787
), "foreign_keys= should be a list of tuple pairs or triples"
774-
if len(tuple_or_list) == 3:
775-
tuple_or_list = cast(Tuple[str, str, str], tuple_or_list)
788+
if len(tuple_or_list) in (3, 4):
789+
if len(tuple_or_list) == 4:
790+
tuple_or_list = cast(Tuple[str, str, str], tuple_or_list[1:])
791+
else:
792+
tuple_or_list = cast(Tuple[str, str, str], tuple_or_list)
776793
fks.append(
777794
ForeignKey(
778795
name, tuple_or_list[0], tuple_or_list[1], tuple_or_list[2]
@@ -864,7 +881,7 @@ def sort_key(p):
864881
for fk in foreign_keys:
865882
if fk.other_table == name and columns.get(fk.other_column):
866883
continue
867-
if not any(
884+
if fk.other_column != "rowid" and not any(
868885
c for c in self[fk.other_table].columns if c.name == fk.other_column
869886
):
870887
raise AlterError(
@@ -1148,32 +1165,14 @@ def add_foreign_keys(self, foreign_keys: Iterable[Tuple[str, str, str, str]]):
11481165
(table, column, other_table, other_column)
11491166
)
11501167

1151-
# Construct SQL for use with "UPDATE sqlite_master SET sql = ? WHERE name = ?"
1152-
table_sql: Dict[str, str] = {}
1153-
for table, column, other_table, other_column in foreign_keys_to_create:
1154-
old_sql = table_sql.get(table, self[table].schema)
1155-
extra_sql = ",\n FOREIGN KEY([{column}]) REFERENCES [{other_table}]([{other_column}])\n".format(
1156-
column=column, other_table=other_table, other_column=other_column
1157-
)
1158-
# Stick that bit in at the very end just before the closing ')'
1159-
last_paren = old_sql.rindex(")")
1160-
new_sql = old_sql[:last_paren].strip() + extra_sql + old_sql[last_paren:]
1161-
table_sql[table] = new_sql
1168+
# Group them by table
1169+
by_table: Dict[str, List] = {}
1170+
for fk in foreign_keys_to_create:
1171+
by_table.setdefault(fk[0], []).append(fk)
1172+
1173+
for table, fks in by_table.items():
1174+
cast(Table, self[table]).transform(add_foreign_keys=fks)
11621175

1163-
# And execute it all within a single transaction
1164-
with self.conn:
1165-
cursor = self.conn.cursor()
1166-
schema_version = cursor.execute("PRAGMA schema_version").fetchone()[0]
1167-
cursor.execute("PRAGMA writable_schema = 1")
1168-
for table_name, new_sql in table_sql.items():
1169-
cursor.execute(
1170-
"UPDATE sqlite_master SET sql = ? WHERE name = ?",
1171-
(new_sql, table_name),
1172-
)
1173-
cursor.execute("PRAGMA schema_version = %d" % (schema_version + 1))
1174-
cursor.execute("PRAGMA writable_schema = 0")
1175-
# Have to VACUUM outside the transaction to ensure .foreign_keys property
1176-
# can see the newly created foreign key.
11771176
self.vacuum()
11781177

11791178
def index_foreign_keys(self):
@@ -1704,7 +1703,9 @@ def transform(
17041703
pk: Optional[Any] = DEFAULT,
17051704
not_null: Optional[Iterable[str]] = None,
17061705
defaults: Optional[Dict[str, Any]] = None,
1707-
drop_foreign_keys: Optional[Iterable] = None,
1706+
drop_foreign_keys: Optional[Iterable[str]] = None,
1707+
add_foreign_keys: Optional[ForeignKeysType] = None,
1708+
foreign_keys: Optional[ForeignKeysType] = None,
17081709
column_order: Optional[List[str]] = None,
17091710
keep_table: Optional[str] = None,
17101711
) -> "Table":
@@ -1721,6 +1722,8 @@ def transform(
17211722
:param not_null: Columns to set as ``NOT NULL``
17221723
:param defaults: Default values for columns
17231724
:param drop_foreign_keys: Names of columns that should have their foreign key constraints removed
1725+
:param add_foreign_keys: List of foreign keys to add to the table
1726+
:param foreign_keys: List of foreign keys to set for the table, replacing any existing foreign keys
17241727
:param column_order: List of strings specifying a full or partial column order
17251728
to use when creating the table
17261729
:param keep_table: If specified, the existing table will be renamed to this and will not be
@@ -1735,6 +1738,8 @@ def transform(
17351738
not_null=not_null,
17361739
defaults=defaults,
17371740
drop_foreign_keys=drop_foreign_keys,
1741+
add_foreign_keys=add_foreign_keys,
1742+
foreign_keys=foreign_keys,
17381743
column_order=column_order,
17391744
keep_table=keep_table,
17401745
)
@@ -1765,6 +1770,8 @@ def transform_sql(
17651770
not_null: Optional[Iterable[str]] = None,
17661771
defaults: Optional[Dict[str, Any]] = None,
17671772
drop_foreign_keys: Optional[Iterable] = None,
1773+
add_foreign_keys: Optional[ForeignKeysType] = None,
1774+
foreign_keys: Optional[ForeignKeysType] = None,
17681775
column_order: Optional[List[str]] = None,
17691776
tmp_suffix: Optional[str] = None,
17701777
keep_table: Optional[str] = None,
@@ -1779,6 +1786,8 @@ def transform_sql(
17791786
:param not_null: Columns to set as ``NOT NULL``
17801787
:param defaults: Default values for columns
17811788
:param drop_foreign_keys: Names of columns that should have their foreign key constraints removed
1789+
:param add_foreign_keys: List of foreign keys to add to the table
1790+
:param foreign_keys: List of foreign keys to set for the table, replacing any existing foreign keys
17821791
:param column_order: List of strings specifying a full or partial column order
17831792
to use when creating the table
17841793
:param tmp_suffix: Suffix to use for the temporary table name
@@ -1788,6 +1797,45 @@ def transform_sql(
17881797
types = types or {}
17891798
rename = rename or {}
17901799
drop = drop or set()
1800+
1801+
create_table_foreign_keys: List[ForeignKeyIndicator] = []
1802+
1803+
if foreign_keys is not None:
1804+
if add_foreign_keys is not None:
1805+
raise ValueError(
1806+
"Cannot specify both foreign_keys and add_foreign_keys"
1807+
)
1808+
if drop_foreign_keys is not None:
1809+
raise ValueError(
1810+
"Cannot specify both foreign_keys and drop_foreign_keys"
1811+
)
1812+
create_table_foreign_keys.extend(foreign_keys)
1813+
else:
1814+
# Construct foreign_keys from current, plus add_foreign_keys, minus drop_foreign_keys
1815+
create_table_foreign_keys = []
1816+
for table, column, other_table, other_column in self.foreign_keys:
1817+
# Copy over old foreign keys, unless we are dropping them
1818+
if (drop_foreign_keys is None) or (column not in drop_foreign_keys):
1819+
create_table_foreign_keys.append(
1820+
ForeignKey(
1821+
table,
1822+
rename.get(column) or column,
1823+
other_table,
1824+
other_column,
1825+
)
1826+
)
1827+
# Add new foreign keys
1828+
if add_foreign_keys is not None:
1829+
for fk in self.db.resolve_foreign_keys(self.name, add_foreign_keys):
1830+
create_table_foreign_keys.append(
1831+
ForeignKey(
1832+
self.name,
1833+
rename.get(fk.column) or fk.column,
1834+
fk.other_table,
1835+
fk.other_column,
1836+
)
1837+
)
1838+
17911839
new_table_name = "{}_new_{}".format(
17921840
self.name, tmp_suffix or os.urandom(6).hex()
17931841
)
@@ -1847,14 +1895,6 @@ def transform_sql(
18471895
{rename.get(c) or c: v for c, v in defaults.items()}
18481896
)
18491897

1850-
# foreign_keys
1851-
create_table_foreign_keys = []
1852-
for table, column, other_table, other_column in self.foreign_keys:
1853-
if (drop_foreign_keys is None) or (column not in drop_foreign_keys):
1854-
create_table_foreign_keys.append(
1855-
(rename.get(column) or column, other_table, other_column)
1856-
)
1857-
18581898
if column_order is not None:
18591899
column_order = [rename.get(col) or col for col in column_order]
18601900

0 commit comments

Comments
 (0)