mirror of
https://github.com/simonw/sqlite-utils.git
synced 2026-09-28 21:04:13 +02:00
Normalize compound foreign key columns to tuples, promote tuples in docs
ForeignKey.columns and .other_columns are now tuples rather than lists,
both when introspected and when constructed directly (lists are
normalized in __post_init__). Tuples are now the documented form for
specifying compound foreign keys everywhere - in create_table()
foreign_keys=, add_foreign_key() and drop_foreign_keys=:
foreign_keys=[(("campus_name", "dept_code"), "departments")]
Lists continue to work as input but are no longer documented.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
c16edb2dc4
commit
d100264e9c
5 changed files with 92 additions and 90 deletions
|
|
@ -11,13 +11,13 @@ Unreleased
|
||||||
|
|
||||||
Breaking changes:
|
Breaking changes:
|
||||||
|
|
||||||
- ``table.foreign_keys`` now returns ``ForeignKey`` objects that are dataclasses rather than ``namedtuple`` instances, so they can no longer be unpacked or indexed as ``(table, column, other_table, other_column)`` tuples - access their fields by name instead. Compound (multi-column) foreign keys are now represented as a single ``ForeignKey`` with ``is_compound=True`` and populated ``columns``/``other_columns`` lists, where ``column`` and ``other_column`` are ``None``. Previously they were returned as one ``ForeignKey`` per column, misleadingly suggesting several independent foreign keys. See :ref:`upgrading_3_to_4` for details. (:issue:`594`)
|
- ``table.foreign_keys`` now returns ``ForeignKey`` objects that are dataclasses rather than ``namedtuple`` instances, so they can no longer be unpacked or indexed as ``(table, column, other_table, other_column)`` tuples - access their fields by name instead. Compound (multi-column) foreign keys are now represented as a single ``ForeignKey`` with ``is_compound=True`` and populated ``columns``/``other_columns`` tuples, where ``column`` and ``other_column`` are ``None``. Previously they were returned as one ``ForeignKey`` per column, misleadingly suggesting several independent foreign keys. See :ref:`upgrading_3_to_4` for details. (:issue:`594`)
|
||||||
|
|
||||||
Compound foreign key support, everywhere:
|
Compound foreign key support:
|
||||||
|
|
||||||
- Tables can now be created with compound foreign keys, by passing lists of column names in ``foreign_keys=``: ``foreign_keys=[(["campus_name", "dept_code"], "departments")]``. The referenced columns default to the compound primary key of the other table. Compound keys are rendered as table-level ``FOREIGN KEY`` constraints in the generated schema. See :ref:`python_api_compound_foreign_keys`.
|
- Tables can now be created with compound foreign keys, by passing tuples of column names in ``foreign_keys=``: ``foreign_keys=[(("campus_name", "dept_code"), "departments")]``. The referenced columns default to the compound primary key of the other table. Compound keys are rendered as table-level ``FOREIGN KEY`` constraints in the generated schema. See :ref:`python_api_compound_foreign_keys`.
|
||||||
- ``table.transform()`` now preserves compound foreign keys, applying any column renames to them. Dropping a column that is part of a compound foreign key drops the whole constraint, matching the existing single-column behavior. ``drop_foreign_keys=`` accepts a bare column name - dropping any foreign key that column participates in - or a tuple of columns to target a compound key precisely.
|
- ``table.transform()`` now preserves compound foreign keys, applying any column renames to them. Dropping a column that is part of a compound foreign key drops the whole constraint, matching the existing single-column behavior. ``drop_foreign_keys=`` accepts a bare column name - dropping any foreign key that column participates in - or a tuple of columns to target a compound key precisely.
|
||||||
- ``table.add_foreign_key()`` and ``db.add_foreign_keys()`` accept lists of column names to add a compound foreign key to an existing table.
|
- ``table.add_foreign_key()`` and ``db.add_foreign_keys()`` accept tuples of column names to add a compound foreign key to an existing table.
|
||||||
- ``db.index_foreign_keys()`` creates a single composite index for a compound foreign key.
|
- ``db.index_foreign_keys()`` creates a single composite index for a compound foreign key.
|
||||||
|
|
||||||
Other foreign key improvements:
|
Other foreign key improvements:
|
||||||
|
|
|
||||||
|
|
@ -822,7 +822,7 @@ You can leave off the third item in the tuple to have the referenced column auto
|
||||||
Compound foreign keys
|
Compound foreign keys
|
||||||
~~~~~~~~~~~~~~~~~~~~~
|
~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
To create a compound (multi-column) foreign key, use lists of column names in place of the single column names:
|
To create a compound (multi-column) foreign key, use tuples of column names in place of the single column names:
|
||||||
|
|
||||||
.. code-block:: python
|
.. code-block:: python
|
||||||
|
|
||||||
|
|
@ -831,7 +831,7 @@ To create a compound (multi-column) foreign key, use lists of column names in pl
|
||||||
"campus_name": str,
|
"campus_name": str,
|
||||||
"dept_code": str,
|
"dept_code": str,
|
||||||
}, pk="course_code", foreign_keys=[
|
}, pk="course_code", foreign_keys=[
|
||||||
(["campus_name", "dept_code"], "departments", ["campus_name", "dept_code"])
|
(("campus_name", "dept_code"), "departments", ("campus_name", "dept_code"))
|
||||||
])
|
])
|
||||||
|
|
||||||
This creates a table-level constraint:
|
This creates a table-level constraint:
|
||||||
|
|
@ -845,12 +845,12 @@ This creates a table-level constraint:
|
||||||
FOREIGN KEY ("campus_name", "dept_code") REFERENCES "departments"("campus_name", "dept_code")
|
FOREIGN KEY ("campus_name", "dept_code") REFERENCES "departments"("campus_name", "dept_code")
|
||||||
)
|
)
|
||||||
|
|
||||||
As with single columns, you can leave off the list of other columns to reference the compound primary key of the other table:
|
As with single columns, you can leave off the tuple of other columns to reference the compound primary key of the other table:
|
||||||
|
|
||||||
.. code-block:: python
|
.. code-block:: python
|
||||||
|
|
||||||
foreign_keys=[
|
foreign_keys=[
|
||||||
(["campus_name", "dept_code"], "departments")
|
(("campus_name", "dept_code"), "departments")
|
||||||
]
|
]
|
||||||
|
|
||||||
To specify ``ON DELETE`` or ``ON UPDATE`` actions, pass ``ForeignKey`` objects instead:
|
To specify ``ON DELETE`` or ``ON UPDATE`` actions, pass ``ForeignKey`` objects instead:
|
||||||
|
|
@ -1581,12 +1581,12 @@ To ignore the case where the key already exists, use ``ignore=True``:
|
||||||
|
|
||||||
db.table("books").add_foreign_key("author_id", "authors", "id", ignore=True)
|
db.table("books").add_foreign_key("author_id", "authors", "id", ignore=True)
|
||||||
|
|
||||||
To add a compound foreign key, pass lists of columns:
|
To add a compound foreign key, pass tuples of columns:
|
||||||
|
|
||||||
.. code-block:: python
|
.. code-block:: python
|
||||||
|
|
||||||
db.table("courses").add_foreign_key(
|
db.table("courses").add_foreign_key(
|
||||||
["campus_name", "dept_code"], "departments", ["campus_name", "dept_code"]
|
("campus_name", "dept_code"), "departments", ("campus_name", "dept_code")
|
||||||
)
|
)
|
||||||
|
|
||||||
As with single columns, omitting the other columns will use the compound primary key of the other table. ``other_table`` must always be specified for a compound foreign key.
|
As with single columns, omitting the other columns will use the compound primary key of the other table. ``other_table`` must always be specified for a compound foreign key.
|
||||||
|
|
@ -2294,9 +2294,9 @@ Each ``ForeignKey`` has the following attributes:
|
||||||
``other_column``
|
``other_column``
|
||||||
The referenced column, or ``None`` for a compound foreign key.
|
The referenced column, or ``None`` for a compound foreign key.
|
||||||
``columns``
|
``columns``
|
||||||
A list of the columns on this table, always populated (a single-item list for single-column foreign keys).
|
A tuple of the columns on this table, always populated (a one-item tuple for single-column foreign keys).
|
||||||
``other_columns``
|
``other_columns``
|
||||||
A list of the referenced columns.
|
A tuple of the referenced columns.
|
||||||
``is_compound``
|
``is_compound``
|
||||||
``True`` if this is a compound (multi-column) foreign key.
|
``True`` if this is a compound (multi-column) foreign key.
|
||||||
``on_delete``
|
``on_delete``
|
||||||
|
|
@ -2309,11 +2309,11 @@ Each ``ForeignKey`` has the following attributes:
|
||||||
::
|
::
|
||||||
|
|
||||||
>>> db.table("Street_Tree_List").foreign_keys
|
>>> db.table("Street_Tree_List").foreign_keys
|
||||||
[ForeignKey(table='Street_Tree_List', column='qLegalStatus', other_table='qLegalStatus', other_column='id', columns=['qLegalStatus'], other_columns=['id'], is_compound=False, on_delete='NO ACTION', on_update='NO ACTION'),
|
[ForeignKey(table='Street_Tree_List', column='qLegalStatus', other_table='qLegalStatus', other_column='id', columns=('qLegalStatus',), other_columns=('id',), is_compound=False, on_delete='NO ACTION', on_update='NO ACTION'),
|
||||||
ForeignKey(table='Street_Tree_List', column='qCareAssistant', other_table='qCareAssistant', other_column='id', columns=['qCareAssistant'], other_columns=['id'], is_compound=False, on_delete='NO ACTION', on_update='NO ACTION'),
|
ForeignKey(table='Street_Tree_List', column='qCareAssistant', other_table='qCareAssistant', other_column='id', columns=('qCareAssistant',), other_columns=('id',), is_compound=False, on_delete='NO ACTION', on_update='NO ACTION'),
|
||||||
...]
|
...]
|
||||||
|
|
||||||
Compound foreign keys - defined with ``FOREIGN KEY (col_a, col_b) REFERENCES other(col_a, col_b)`` - are returned as a single ``ForeignKey`` with ``is_compound=True``, ``column`` and ``other_column`` set to ``None``, and the participating columns available in the ``columns`` and ``other_columns`` lists.
|
Compound foreign keys - defined with ``FOREIGN KEY (col_a, col_b) REFERENCES other(col_a, col_b)`` - are returned as a single ``ForeignKey`` with ``is_compound=True``, ``column`` and ``other_column`` set to ``None``, and the participating columns available in the ``columns`` and ``other_columns`` tuples.
|
||||||
|
|
||||||
.. _python_api_introspection_schema:
|
.. _python_api_introspection_schema:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -93,7 +93,7 @@ Python API changes
|
||||||
|
|
||||||
Attempting the old unpacking or ``fk[0]`` indexing now raises ``TypeError``, so any code using those patterns will fail loudly rather than silently misbehave.
|
Attempting the old unpacking or ``fk[0]`` indexing now raises ``TypeError``, so any code using those patterns will fail loudly rather than silently misbehave.
|
||||||
|
|
||||||
Compound foreign keys - previously returned as one ``ForeignKey`` per column, misleadingly suggesting several independent single-column keys - are now returned as a single ``ForeignKey`` with ``is_compound=True``. For these the scalar ``column`` and ``other_column`` fields are ``None``; use the ``columns`` and ``other_columns`` lists instead. Single-column foreign keys are unaffected apart from the class change: ``column``/``other_column`` behave as before and ``columns``/``other_columns`` are single-item lists.
|
Compound foreign keys - previously returned as one ``ForeignKey`` per column, misleadingly suggesting several independent single-column keys - are now returned as a single ``ForeignKey`` with ``is_compound=True``. For these the scalar ``column`` and ``other_column`` fields are ``None``; use the ``columns`` and ``other_columns`` tuples instead. Single-column foreign keys are unaffected apart from the class change: ``column``/``other_column`` behave as before and ``columns``/``other_columns`` are one-item tuples.
|
||||||
|
|
||||||
Two related behavior changes to ``table.transform()``: compound foreign keys now survive a transform (previously they were split into separate single-column keys), and ``ON DELETE``/``ON UPDATE`` actions such as ``ON DELETE CASCADE`` are now preserved (previously they were silently stripped from the schema).
|
Two related behavior changes to ``table.transform()``: compound foreign keys now survive a transform (previously they were split into separate single-column keys), and ``ON DELETE``/``ON UPDATE`` actions such as ``ON DELETE CASCADE`` are now preserved (previously they were silently stripped from the schema).
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -170,7 +170,7 @@ class ForeignKey:
|
||||||
A foreign key defined on a table.
|
A foreign key defined on a table.
|
||||||
|
|
||||||
For single-column foreign keys ``column`` and ``other_column`` hold the
|
For single-column foreign keys ``column`` and ``other_column`` hold the
|
||||||
column names, and ``columns``/``other_columns`` are single-item lists.
|
column names, and ``columns``/``other_columns`` are one-item tuples.
|
||||||
|
|
||||||
For compound (multi-column) foreign keys ``column`` and ``other_column``
|
For compound (multi-column) foreign keys ``column`` and ``other_column``
|
||||||
are ``None`` - use ``columns`` and ``other_columns`` instead, and check
|
are ``None`` - use ``columns`` and ``other_columns`` instead, and check
|
||||||
|
|
@ -190,24 +190,24 @@ class ForeignKey:
|
||||||
column: Optional[str] = field(compare=False)
|
column: Optional[str] = field(compare=False)
|
||||||
other_table: str
|
other_table: str
|
||||||
other_column: Optional[str] = field(compare=False)
|
other_column: Optional[str] = field(compare=False)
|
||||||
columns: List[str] = field(default_factory=list)
|
columns: Tuple[str, ...] = ()
|
||||||
other_columns: List[str] = field(default_factory=list)
|
other_columns: Tuple[str, ...] = ()
|
||||||
is_compound: bool = False
|
is_compound: bool = False
|
||||||
on_delete: str = "NO ACTION"
|
on_delete: str = "NO ACTION"
|
||||||
on_update: str = "NO ACTION"
|
on_update: str = "NO ACTION"
|
||||||
|
|
||||||
def __post_init__(self):
|
def __post_init__(self):
|
||||||
# Populate columns/other_columns for single-column foreign keys,
|
# Populate columns/other_columns for single-column foreign keys,
|
||||||
# normalizing any tuples to lists
|
# normalizing any lists to tuples
|
||||||
if self.columns:
|
if self.columns:
|
||||||
self.columns = list(self.columns)
|
self.columns = tuple(self.columns)
|
||||||
else:
|
else:
|
||||||
self.columns = [self.column] if self.column is not None else []
|
self.columns = (self.column,) if self.column is not None else ()
|
||||||
if self.other_columns:
|
if self.other_columns:
|
||||||
self.other_columns = list(self.other_columns)
|
self.other_columns = tuple(self.other_columns)
|
||||||
else:
|
else:
|
||||||
self.other_columns = (
|
self.other_columns = (
|
||||||
[self.other_column] if self.other_column is not None else []
|
(self.other_column,) if self.other_column is not None else ()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -233,8 +233,8 @@ class TransformError(Exception):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
# A single column name, or a list/tuple of columns for a compound foreign key
|
# A single column name, or a tuple of columns for a compound foreign key
|
||||||
ForeignKeyColumns = Union[str, List[str], Tuple[str, ...]]
|
ForeignKeyColumns = Union[str, Tuple[str, ...], List[str]]
|
||||||
|
|
||||||
# (table, column(s), other_table, other_column(s))
|
# (table, column(s), other_table, other_column(s))
|
||||||
ForeignKeyTuple = Tuple[str, ForeignKeyColumns, str, ForeignKeyColumns]
|
ForeignKeyTuple = Tuple[str, ForeignKeyColumns, str, ForeignKeyColumns]
|
||||||
|
|
@ -1171,9 +1171,9 @@ class Database:
|
||||||
string, a ForeignKey() object, a tuple of (column, other_table),
|
string, a ForeignKey() object, a tuple of (column, other_table),
|
||||||
or a tuple of (column, other_table, other_column), or a tuple of
|
or a tuple of (column, other_table, other_column), or a tuple of
|
||||||
(table, column, other_table, other_column). For compound foreign
|
(table, column, other_table, other_column). For compound foreign
|
||||||
keys the column elements can be lists of column names, e.g.
|
keys the column elements can be tuples of column names, e.g.
|
||||||
(["campus_name", "dept_code"], "departments") or
|
(("campus_name", "dept_code"), "departments") or
|
||||||
(["campus_name", "dept_code"], "departments", ["campus_name", "dept_code"])
|
(("campus_name", "dept_code"), "departments", ("campus_name", "dept_code"))
|
||||||
"""
|
"""
|
||||||
table = self.table(name)
|
table = self.table(name)
|
||||||
if all(isinstance(fk, ForeignKey) for fk in foreign_keys):
|
if all(isinstance(fk, ForeignKey) for fk in foreign_keys):
|
||||||
|
|
@ -1207,18 +1207,17 @@ class Database:
|
||||||
other_table = tuple_or_list[1]
|
other_table = tuple_or_list[1]
|
||||||
if isinstance(column_or_columns, (list, tuple)):
|
if isinstance(column_or_columns, (list, tuple)):
|
||||||
# Compound foreign key
|
# Compound foreign key
|
||||||
columns = list(column_or_columns)
|
columns = tuple(column_or_columns)
|
||||||
if len(tuple_or_list) == 3:
|
if len(tuple_or_list) == 3:
|
||||||
other_columns = tuple_or_list[2]
|
if not isinstance(tuple_or_list[2], (list, tuple)):
|
||||||
if not isinstance(other_columns, (list, tuple)):
|
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
"Compound foreign key {} should reference a list "
|
"Compound foreign key {} should reference a tuple "
|
||||||
"of other columns".format(tuple(tuple_or_list))
|
"of other columns".format(tuple(tuple_or_list))
|
||||||
)
|
)
|
||||||
other_columns = list(other_columns)
|
other_columns = tuple(tuple_or_list[2])
|
||||||
else:
|
else:
|
||||||
# Guess the compound primary key of the other table
|
# Guess the compound primary key of the other table
|
||||||
other_columns = self.table(other_table).pks
|
other_columns = tuple(self.table(other_table).pks)
|
||||||
if len(columns) != len(other_columns):
|
if len(columns) != len(other_columns):
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
"Compound foreign key {} should have the same number "
|
"Compound foreign key {} should have the same number "
|
||||||
|
|
@ -1618,7 +1617,7 @@ class Database:
|
||||||
|
|
||||||
:param foreign_keys: A list of ``(table, column, other_table, other_column)``
|
:param foreign_keys: A list of ``(table, column, other_table, other_column)``
|
||||||
tuples - for compound foreign keys, ``column`` and ``other_column`` can
|
tuples - for compound foreign keys, ``column`` and ``other_column`` can
|
||||||
be lists of column names
|
be tuples of column names
|
||||||
"""
|
"""
|
||||||
# foreign_keys is a list of explicit 4-tuples
|
# foreign_keys is a list of explicit 4-tuples
|
||||||
if not all(
|
if not all(
|
||||||
|
|
@ -1644,16 +1643,16 @@ class Database:
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
table, column_or_columns, other_table, other_column_or_columns = fk
|
table, column_or_columns, other_table, other_column_or_columns = fk
|
||||||
# Compound foreign keys use lists of columns
|
# Compound foreign keys use tuples of columns
|
||||||
columns = (
|
columns = (
|
||||||
[column_or_columns]
|
(column_or_columns,)
|
||||||
if isinstance(column_or_columns, str)
|
if isinstance(column_or_columns, str)
|
||||||
else list(column_or_columns)
|
else tuple(column_or_columns)
|
||||||
)
|
)
|
||||||
other_columns = (
|
other_columns = (
|
||||||
[other_column_or_columns]
|
(other_column_or_columns,)
|
||||||
if isinstance(other_column_or_columns, str)
|
if isinstance(other_column_or_columns, str)
|
||||||
else list(other_column_or_columns)
|
else tuple(other_column_or_columns)
|
||||||
)
|
)
|
||||||
if not self.table(table).exists():
|
if not self.table(table).exists():
|
||||||
raise AlterError("No such table: {}".format(table))
|
raise AlterError("No such table: {}".format(table))
|
||||||
|
|
@ -1708,9 +1707,9 @@ class Database:
|
||||||
existing_indexes = {tuple(i.columns) for i in table.indexes}
|
existing_indexes = {tuple(i.columns) for i in table.indexes}
|
||||||
for fk in table.foreign_keys:
|
for fk in table.foreign_keys:
|
||||||
# A compound foreign key gets a single composite index
|
# A compound foreign key gets a single composite index
|
||||||
if tuple(fk.columns) not in existing_indexes:
|
if fk.columns not in existing_indexes:
|
||||||
table.create_index(fk.columns, find_unique_name=True)
|
table.create_index(fk.columns, find_unique_name=True)
|
||||||
existing_indexes.add(tuple(fk.columns))
|
existing_indexes.add(fk.columns)
|
||||||
|
|
||||||
def vacuum(self) -> None:
|
def vacuum(self) -> None:
|
||||||
"Run a SQLite ``VACUUM`` against the database."
|
"Run a SQLite ``VACUUM`` against the database."
|
||||||
|
|
@ -2083,12 +2082,12 @@ class Table(Queryable):
|
||||||
for id in sorted(by_id):
|
for id in sorted(by_id):
|
||||||
rows = sorted(by_id[id]) # order columns by seq
|
rows = sorted(by_id[id]) # order columns by seq
|
||||||
other_table = rows[0][1]
|
other_table = rows[0][1]
|
||||||
columns = [row[2] for row in rows]
|
columns = tuple(row[2] for row in rows)
|
||||||
other_columns = [row[3] for row in rows]
|
other_columns = tuple(row[3] for row in rows)
|
||||||
if all(c is None for c in other_columns):
|
if all(c is None for c in other_columns):
|
||||||
# "REFERENCES other_table" with no columns - the pragma
|
# "REFERENCES other_table" with no columns - the pragma
|
||||||
# returns None, meaning the other table's primary key
|
# returns None, meaning the other table's primary key
|
||||||
other_table_pks = self.db.table(other_table).pks
|
other_table_pks = tuple(self.db.table(other_table).pks)
|
||||||
if len(other_table_pks) == len(columns):
|
if len(other_table_pks) == len(columns):
|
||||||
other_columns = other_table_pks
|
other_columns = other_table_pks
|
||||||
is_compound = len(rows) > 1
|
is_compound = len(rows) > 1
|
||||||
|
|
@ -2448,14 +2447,14 @@ class Table(Queryable):
|
||||||
# A column name matches any foreign key it participates in
|
# A column name matches any foreign key it participates in
|
||||||
if spec in fk.columns:
|
if spec in fk.columns:
|
||||||
return True
|
return True
|
||||||
elif list(spec) == fk.columns:
|
elif tuple(spec) == fk.columns:
|
||||||
# A tuple/list must match a compound key's columns exactly
|
# A tuple/list must match a compound key's columns exactly
|
||||||
return True
|
return True
|
||||||
# Dropping any of a foreign key's columns drops the whole key
|
# Dropping any of a foreign key's columns drops the whole key
|
||||||
return any(column in drop for column in fk.columns)
|
return any(column in drop for column in fk.columns)
|
||||||
|
|
||||||
def fk_with_renamed_columns(fk: ForeignKey) -> ForeignKey:
|
def fk_with_renamed_columns(fk: ForeignKey) -> ForeignKey:
|
||||||
columns = [rename.get(column) or column for column in fk.columns]
|
columns = tuple(rename.get(column) or column for column in fk.columns)
|
||||||
if fk.is_compound:
|
if fk.is_compound:
|
||||||
return ForeignKey(
|
return ForeignKey(
|
||||||
self.name,
|
self.name,
|
||||||
|
|
@ -2926,14 +2925,14 @@ class Table(Queryable):
|
||||||
"""
|
"""
|
||||||
Alter the schema to mark the specified column as a foreign key to another table.
|
Alter the schema to mark the specified column as a foreign key to another table.
|
||||||
|
|
||||||
:param column: The column to mark as a foreign key - use a list of columns
|
:param column: The column to mark as a foreign key - use a tuple of columns
|
||||||
for a compound foreign key.
|
for a compound foreign key.
|
||||||
:param other_table: The table it refers to - if omitted, will be guessed based on the column name.
|
:param other_table: The table it refers to - if omitted, will be guessed based on the column name.
|
||||||
:param other_column: The column on the other table it - if omitted, will be guessed.
|
:param other_column: The column on the other table it - if omitted, will be guessed.
|
||||||
Use a list of columns for a compound foreign key.
|
Use a tuple of columns for a compound foreign key.
|
||||||
:param ignore: Set this to ``True`` to ignore an existing foreign key - otherwise a ``AlterError`` will be raised.
|
:param ignore: Set this to ``True`` to ignore an existing foreign key - otherwise a ``AlterError`` will be raised.
|
||||||
"""
|
"""
|
||||||
columns = [column] if isinstance(column, str) else list(column)
|
columns = (column,) if isinstance(column, str) else tuple(column)
|
||||||
# Ensure columns exist
|
# Ensure columns exist
|
||||||
for col in columns:
|
for col in columns:
|
||||||
if col not in self.columns_dict:
|
if col not in self.columns_dict:
|
||||||
|
|
@ -2948,13 +2947,13 @@ class Table(Queryable):
|
||||||
# If other_column is not specified, detect the primary key on other_table
|
# If other_column is not specified, detect the primary key on other_table
|
||||||
if other_column is None:
|
if other_column is None:
|
||||||
if len(columns) > 1:
|
if len(columns) > 1:
|
||||||
other_columns = self.db.table(other_table).pks
|
other_columns = tuple(self.db.table(other_table).pks)
|
||||||
else:
|
else:
|
||||||
other_columns = [self.guess_foreign_column(other_table)]
|
other_columns = (self.guess_foreign_column(other_table),)
|
||||||
elif isinstance(other_column, str):
|
elif isinstance(other_column, str):
|
||||||
other_columns = [other_column]
|
other_columns = (other_column,)
|
||||||
else:
|
else:
|
||||||
other_columns = list(other_column)
|
other_columns = tuple(other_column)
|
||||||
if len(columns) != len(other_columns):
|
if len(columns) != len(other_columns):
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
"Compound foreign key must have the same number of columns "
|
"Compound foreign key must have the same number of columns "
|
||||||
|
|
|
||||||
|
|
@ -36,8 +36,8 @@ def test_compound_foreign_key(compound_db):
|
||||||
assert fk.is_compound is True
|
assert fk.is_compound is True
|
||||||
assert fk.table == "courses"
|
assert fk.table == "courses"
|
||||||
assert fk.other_table == "departments"
|
assert fk.other_table == "departments"
|
||||||
assert fk.columns == ["campus_name", "dept_code"]
|
assert fk.columns == ("campus_name", "dept_code")
|
||||||
assert fk.other_columns == ["campus_name", "dept_code"]
|
assert fk.other_columns == ("campus_name", "dept_code")
|
||||||
# Scalar column/other_column can't sensibly hold a compound key
|
# Scalar column/other_column can't sensibly hold a compound key
|
||||||
assert fk.column is None
|
assert fk.column is None
|
||||||
assert fk.other_column is None
|
assert fk.other_column is None
|
||||||
|
|
@ -51,8 +51,8 @@ def test_single_foreign_key_gets_columns_fields(fresh_db):
|
||||||
assert fk.is_compound is False
|
assert fk.is_compound is False
|
||||||
assert fk.column == "author_id"
|
assert fk.column == "author_id"
|
||||||
assert fk.other_column == "id"
|
assert fk.other_column == "id"
|
||||||
assert fk.columns == ["author_id"]
|
assert fk.columns == ("author_id",)
|
||||||
assert fk.other_columns == ["id"]
|
assert fk.other_columns == ("id",)
|
||||||
|
|
||||||
|
|
||||||
def test_foreign_key_no_longer_unpacks_as_tuple(fresh_db):
|
def test_foreign_key_no_longer_unpacks_as_tuple(fresh_db):
|
||||||
|
|
@ -142,14 +142,16 @@ EXPECTED_COURSES_SCHEMA = (
|
||||||
column=None,
|
column=None,
|
||||||
other_table="departments",
|
other_table="departments",
|
||||||
other_column=None,
|
other_column=None,
|
||||||
columns=["campus_name", "dept_code"],
|
columns=("campus_name", "dept_code"),
|
||||||
other_columns=["campus_name", "dept_code"],
|
other_columns=("campus_name", "dept_code"),
|
||||||
is_compound=True,
|
is_compound=True,
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
[(["campus_name", "dept_code"], "departments", ["campus_name", "dept_code"])],
|
[(("campus_name", "dept_code"), "departments", ("campus_name", "dept_code"))],
|
||||||
# Two-item form guesses the other table's primary key:
|
# Two-item form guesses the other table's primary key:
|
||||||
[(["campus_name", "dept_code"], "departments")],
|
[(("campus_name", "dept_code"), "departments")],
|
||||||
|
# Lists work too, though tuples are the documented form:
|
||||||
|
[(["campus_name", "dept_code"], "departments", ["campus_name", "dept_code"])],
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
def test_create_table_with_compound_foreign_key(departments_db, foreign_keys):
|
def test_create_table_with_compound_foreign_key(departments_db, foreign_keys):
|
||||||
|
|
@ -164,9 +166,9 @@ def test_create_table_with_compound_foreign_key(departments_db, foreign_keys):
|
||||||
assert len(fks) == 1
|
assert len(fks) == 1
|
||||||
fk = fks[0]
|
fk = fks[0]
|
||||||
assert fk.is_compound is True
|
assert fk.is_compound is True
|
||||||
assert fk.columns == ["campus_name", "dept_code"]
|
assert fk.columns == ("campus_name", "dept_code")
|
||||||
assert fk.other_table == "departments"
|
assert fk.other_table == "departments"
|
||||||
assert fk.other_columns == ["campus_name", "dept_code"]
|
assert fk.other_columns == ("campus_name", "dept_code")
|
||||||
|
|
||||||
|
|
||||||
def test_create_table_compound_foreign_key_enforced(departments_db):
|
def test_create_table_compound_foreign_key_enforced(departments_db):
|
||||||
|
|
@ -175,7 +177,7 @@ def test_create_table_compound_foreign_key_enforced(departments_db):
|
||||||
"courses",
|
"courses",
|
||||||
{"course_code": str, "campus_name": str, "dept_code": str},
|
{"course_code": str, "campus_name": str, "dept_code": str},
|
||||||
pk="course_code",
|
pk="course_code",
|
||||||
foreign_keys=[(["campus_name", "dept_code"], "departments")],
|
foreign_keys=[(("campus_name", "dept_code"), "departments")],
|
||||||
)
|
)
|
||||||
departments_db["departments"].insert(
|
departments_db["departments"].insert(
|
||||||
{"campus_name": "Berkeley", "dept_code": "CS", "dept_name": "Computer Science"}
|
{"campus_name": "Berkeley", "dept_code": "CS", "dept_name": "Computer Science"}
|
||||||
|
|
@ -199,7 +201,7 @@ def test_create_table_compound_foreign_key_missing_other_column(departments_db):
|
||||||
{"course_code": str, "campus_name": str, "dept_code": str},
|
{"course_code": str, "campus_name": str, "dept_code": str},
|
||||||
pk="course_code",
|
pk="course_code",
|
||||||
foreign_keys=[
|
foreign_keys=[
|
||||||
(["campus_name", "dept_code"], "departments", ["campus_name", "nope"])
|
(("campus_name", "dept_code"), "departments", ("campus_name", "nope"))
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -210,9 +212,9 @@ def test_transform_preserves_compound_foreign_key(compound_db):
|
||||||
assert len(fks) == 1
|
assert len(fks) == 1
|
||||||
fk = fks[0]
|
fk = fks[0]
|
||||||
assert fk.is_compound is True
|
assert fk.is_compound is True
|
||||||
assert fk.columns == ["campus_name", "dept_code"]
|
assert fk.columns == ("campus_name", "dept_code")
|
||||||
assert fk.other_table == "departments"
|
assert fk.other_table == "departments"
|
||||||
assert fk.other_columns == ["campus_name", "dept_code"]
|
assert fk.other_columns == ("campus_name", "dept_code")
|
||||||
|
|
||||||
|
|
||||||
def test_transform_rename_member_column_updates_compound_foreign_key(compound_db):
|
def test_transform_rename_member_column_updates_compound_foreign_key(compound_db):
|
||||||
|
|
@ -221,9 +223,9 @@ def test_transform_rename_member_column_updates_compound_foreign_key(compound_db
|
||||||
assert len(fks) == 1
|
assert len(fks) == 1
|
||||||
fk = fks[0]
|
fk = fks[0]
|
||||||
assert fk.is_compound is True
|
assert fk.is_compound is True
|
||||||
assert fk.columns == ["campus", "dept_code"]
|
assert fk.columns == ("campus", "dept_code")
|
||||||
# Referenced columns in the other table are unchanged
|
# Referenced columns in the other table are unchanged
|
||||||
assert fk.other_columns == ["campus_name", "dept_code"]
|
assert fk.other_columns == ("campus_name", "dept_code")
|
||||||
|
|
||||||
|
|
||||||
def test_transform_drop_member_column_drops_compound_foreign_key(compound_db):
|
def test_transform_drop_member_column_drops_compound_foreign_key(compound_db):
|
||||||
|
|
@ -264,7 +266,7 @@ def courses_db(departments_db):
|
||||||
|
|
||||||
def test_add_compound_foreign_key(courses_db):
|
def test_add_compound_foreign_key(courses_db):
|
||||||
t = courses_db["courses"].add_foreign_key(
|
t = courses_db["courses"].add_foreign_key(
|
||||||
["campus_name", "dept_code"], "departments", ["campus_name", "dept_code"]
|
("campus_name", "dept_code"), "departments", ("campus_name", "dept_code")
|
||||||
)
|
)
|
||||||
# Returns self
|
# Returns self
|
||||||
assert t.name == "courses"
|
assert t.name == "courses"
|
||||||
|
|
@ -272,33 +274,34 @@ def test_add_compound_foreign_key(courses_db):
|
||||||
assert len(fks) == 1
|
assert len(fks) == 1
|
||||||
fk = fks[0]
|
fk = fks[0]
|
||||||
assert fk.is_compound is True
|
assert fk.is_compound is True
|
||||||
assert fk.columns == ["campus_name", "dept_code"]
|
assert fk.columns == ("campus_name", "dept_code")
|
||||||
assert fk.other_table == "departments"
|
assert fk.other_table == "departments"
|
||||||
assert fk.other_columns == ["campus_name", "dept_code"]
|
assert fk.other_columns == ("campus_name", "dept_code")
|
||||||
|
|
||||||
|
|
||||||
def test_add_compound_foreign_key_guesses_other_columns(courses_db):
|
def test_add_compound_foreign_key_guesses_other_columns(courses_db):
|
||||||
|
# Lists work here too, though tuples are the documented form
|
||||||
courses_db["courses"].add_foreign_key(["campus_name", "dept_code"], "departments")
|
courses_db["courses"].add_foreign_key(["campus_name", "dept_code"], "departments")
|
||||||
fk = courses_db["courses"].foreign_keys[0]
|
fk = courses_db["courses"].foreign_keys[0]
|
||||||
assert fk.other_columns == ["campus_name", "dept_code"]
|
assert fk.other_columns == ("campus_name", "dept_code")
|
||||||
|
|
||||||
|
|
||||||
def test_add_compound_foreign_key_error_if_already_exists(courses_db):
|
def test_add_compound_foreign_key_error_if_already_exists(courses_db):
|
||||||
courses_db["courses"].add_foreign_key(["campus_name", "dept_code"], "departments")
|
courses_db["courses"].add_foreign_key(("campus_name", "dept_code"), "departments")
|
||||||
with pytest.raises(AlterError) as ex:
|
with pytest.raises(AlterError) as ex:
|
||||||
courses_db["courses"].add_foreign_key(
|
courses_db["courses"].add_foreign_key(
|
||||||
["campus_name", "dept_code"], "departments"
|
("campus_name", "dept_code"), "departments"
|
||||||
)
|
)
|
||||||
assert "already exists" in ex.value.args[0]
|
assert "already exists" in ex.value.args[0]
|
||||||
# ignore=True should not raise
|
# ignore=True should not raise
|
||||||
courses_db["courses"].add_foreign_key(
|
courses_db["courses"].add_foreign_key(
|
||||||
["campus_name", "dept_code"], "departments", ignore=True
|
("campus_name", "dept_code"), "departments", ignore=True
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_add_compound_foreign_key_error_if_column_missing(courses_db):
|
def test_add_compound_foreign_key_error_if_column_missing(courses_db):
|
||||||
with pytest.raises(AlterError):
|
with pytest.raises(AlterError):
|
||||||
courses_db["courses"].add_foreign_key(["campus_name", "nope"], "departments")
|
courses_db["courses"].add_foreign_key(("campus_name", "nope"), "departments")
|
||||||
|
|
||||||
|
|
||||||
def test_db_add_foreign_keys_compound(courses_db):
|
def test_db_add_foreign_keys_compound(courses_db):
|
||||||
|
|
@ -306,15 +309,15 @@ def test_db_add_foreign_keys_compound(courses_db):
|
||||||
[
|
[
|
||||||
(
|
(
|
||||||
"courses",
|
"courses",
|
||||||
["campus_name", "dept_code"],
|
("campus_name", "dept_code"),
|
||||||
"departments",
|
"departments",
|
||||||
["campus_name", "dept_code"],
|
("campus_name", "dept_code"),
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
fk = courses_db["courses"].foreign_keys[0]
|
fk = courses_db["courses"].foreign_keys[0]
|
||||||
assert fk.is_compound is True
|
assert fk.is_compound is True
|
||||||
assert fk.columns == ["campus_name", "dept_code"]
|
assert fk.columns == ("campus_name", "dept_code")
|
||||||
|
|
||||||
|
|
||||||
def test_index_foreign_keys_compound_creates_composite_index(compound_db):
|
def test_index_foreign_keys_compound_creates_composite_index(compound_db):
|
||||||
|
|
@ -424,7 +427,7 @@ def test_implicit_primary_key_reference_is_resolved():
|
||||||
fk = db["books"].foreign_keys[0]
|
fk = db["books"].foreign_keys[0]
|
||||||
assert fk.is_compound is False
|
assert fk.is_compound is False
|
||||||
assert fk.other_column == "author_id"
|
assert fk.other_column == "author_id"
|
||||||
assert fk.other_columns == ["author_id"]
|
assert fk.other_columns == ("author_id",)
|
||||||
|
|
||||||
|
|
||||||
def test_implicit_compound_primary_key_reference_is_resolved():
|
def test_implicit_compound_primary_key_reference_is_resolved():
|
||||||
|
|
@ -444,20 +447,20 @@ def test_implicit_compound_primary_key_reference_is_resolved():
|
||||||
""")
|
""")
|
||||||
fk = db["courses"].foreign_keys[0]
|
fk = db["courses"].foreign_keys[0]
|
||||||
assert fk.is_compound is True
|
assert fk.is_compound is True
|
||||||
assert fk.other_columns == ["campus_name", "dept_code"]
|
assert fk.other_columns == ("campus_name", "dept_code")
|
||||||
|
|
||||||
|
|
||||||
def test_foreign_key_normalizes_tuple_columns_to_lists():
|
def test_foreign_key_normalizes_list_columns_to_tuples():
|
||||||
# Compound columns passed as tuples are normalized to lists, so they
|
# Compound columns passed as lists are normalized to tuples, so they
|
||||||
# compare equal to introspected ForeignKeys
|
# compare equal to introspected ForeignKeys
|
||||||
fk = ForeignKey(
|
fk = ForeignKey(
|
||||||
table="courses",
|
table="courses",
|
||||||
column=None,
|
column=None,
|
||||||
other_table="departments",
|
other_table="departments",
|
||||||
other_column=None,
|
other_column=None,
|
||||||
columns=("campus_name", "dept_code"),
|
columns=["campus_name", "dept_code"],
|
||||||
other_columns=("campus_name", "dept_code"),
|
other_columns=["campus_name", "dept_code"],
|
||||||
is_compound=True,
|
is_compound=True,
|
||||||
)
|
)
|
||||||
assert fk.columns == ["campus_name", "dept_code"]
|
assert fk.columns == ("campus_name", "dept_code")
|
||||||
assert fk.other_columns == ["campus_name", "dept_code"]
|
assert fk.other_columns == ("campus_name", "dept_code")
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue