table.foreign_keys now handles compound foreign keys, refs #594

This is a breaking change, since the return type is now a dataclass
and not a namedtuple.
This commit is contained in:
Simon Willison 2026-07-05 13:59:25 -07:00
commit f10459cffb
5 changed files with 232 additions and 36 deletions

View file

@ -4,6 +4,15 @@
Changelog Changelog
=========== ===========
.. _v_unreleased:
Unreleased
----------
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`)
.. _v4_0rc2: .. _v4_0rc2:
4.0rc2 (2026-07-04) 4.0rc2 (2026-07-04)

View file

@ -2204,17 +2204,35 @@ Almost all SQLite tables have a ``rowid`` column, but a table with no explicitly
.foreign_keys .foreign_keys
------------- -------------
The ``.foreign_keys`` property returns any foreign key relationships for the table, as a list of ``ForeignKey(table, column, other_table, other_column)`` named tuples. It is not available on views. The ``.foreign_keys`` property returns any foreign key relationships for the table, as a list of ``ForeignKey`` objects. It is not available on views.
Each ``ForeignKey`` has the following attributes:
``table``
The table the foreign key is defined on.
``column``
The column on this table, or ``None`` for a compound foreign key.
``other_table``
The table being referenced.
``other_column``
The referenced column, or ``None`` for a compound foreign key.
``columns``
A list of the columns on this table, always populated (a single-item list for single-column foreign keys).
``other_columns``
A list of the referenced columns.
``is_compound``
``True`` if this is a compound (multi-column) foreign key.
``ForeignKey`` was a ``namedtuple`` prior to sqlite-utils 4.0. It is now a dataclass and can no longer be unpacked or indexed as a tuple - access its fields by name instead. See :ref:`upgrading_3_to_4` for details.
:: ::
>>> 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'), [ForeignKey(table='Street_Tree_List', column='qLegalStatus', other_table='qLegalStatus', other_column='id', columns=['qLegalStatus'], other_columns=['id'], is_compound=False),
ForeignKey(table='Street_Tree_List', column='qCareAssistant', other_table='qCareAssistant', other_column='id'), ForeignKey(table='Street_Tree_List', column='qCareAssistant', other_table='qCareAssistant', other_column='id', columns=['qCareAssistant'], other_columns=['id'], is_compound=False),
ForeignKey(table='Street_Tree_List', column='qSiteInfo', other_table='qSiteInfo', other_column='id'), ...]
ForeignKey(table='Street_Tree_List', column='qSpecies', other_table='qSpecies', other_column='id'),
ForeignKey(table='Street_Tree_List', column='qCaretaker', other_table='qCaretaker', other_column='id'), 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.
ForeignKey(table='Street_Tree_List', column='PlantType', other_table='PlantType', other_column='id')]
.. _python_api_introspection_schema: .. _python_api_introspection_schema:

View file

@ -79,6 +79,22 @@ Python API changes
**View.enable_fts() has been removed.** The ``View`` class previously had an ``enable_fts()`` method that existed only to raise ``NotImplementedError`` - full-text search is not supported for views. Calling it now raises ``AttributeError`` like any other missing method. **View.enable_fts() has been removed.** The ``View`` class previously had an ``enable_fts()`` method that existed only to raise ``NotImplementedError`` - full-text search is not supported for views. Calling it now raises ``AttributeError`` like any other missing method.
**ForeignKey is now a dataclass, not a namedtuple.** The ``ForeignKey`` objects returned by ``table.foreign_keys`` gained three new fields - ``columns``, ``other_columns`` and ``is_compound`` - so that compound (multi-column) foreign keys can be represented as a single object. To make room for those fields cleanly ``ForeignKey`` is now a dataclass rather than a ``namedtuple``, so it can no longer be unpacked or indexed as a tuple. Access its fields by name instead:
.. code-block:: python
# 3.x - tuple unpacking, no longer works:
for table, column, other_table, other_column in db["courses"].foreign_keys:
...
# 4.0 - access fields by name:
for fk in db["courses"].foreign_keys:
fk.table, fk.column, fk.other_table, fk.other_column
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.
**Validation errors raise ValueError.** Invalid arguments to Python API methods - for example ``create_table()`` with no columns, or ``ignore=True`` together with ``replace=True`` - now raise ``ValueError``. They previously raised ``AssertionError`` from bare ``assert`` statements, which were silently skipped under ``python -O``. **Validation errors raise ValueError.** Invalid arguments to Python API methods - for example ``create_table()`` with no columns, or ``ignore=True`` together with ``replace=True`` - now raise ``ValueError``. They previously raised ``AssertionError`` from bare ``assert`` statements, which were silently skipped under ``python -O``.
**Transaction behavior is now well-defined.** 4.0 introduces the :ref:`db.atomic() <python_api_atomic>` context manager and uses it consistently for every write operation - the full model is described in :ref:`python_api_transactions`. Changes you may notice: **Transaction behavior is now well-defined.** 4.0 introduces the :ref:`db.atomic() <python_api_atomic>` context manager and uses it consistently for every write operation - the full model is described in :ref:`python_api_transactions`. Changes you may notice:

View file

@ -11,6 +11,7 @@ from .utils import (
) )
import binascii import binascii
from collections import namedtuple from collections import namedtuple
from dataclasses import dataclass, field
from collections.abc import Mapping from collections.abc import Mapping
import contextlib import contextlib
import datetime import datetime
@ -161,9 +162,43 @@ Summary information about a column, see :ref:`python_api_analyze_column`.
The ``N`` least common values as a list of ``(value, count)`` tuples, or ``None`` if the table is entirely distinct The ``N`` least common values as a list of ``(value, count)`` tuples, or ``None`` if the table is entirely distinct
or if the number of distinct values is less than N (since they will already have been returned in ``most_common``) or if the number of distinct values is less than N (since they will already have been returned in ``most_common``)
""" """
ForeignKey = namedtuple(
"ForeignKey", ("table", "column", "other_table", "other_column")
) @dataclass(order=True)
class ForeignKey:
"""
A foreign key defined on a table.
For single-column foreign keys ``column`` and ``other_column`` hold the
column names, and ``columns``/``other_columns`` are single-item lists.
For compound (multi-column) foreign keys ``column`` and ``other_column``
are ``None`` - use ``columns`` and ``other_columns`` instead, and check
``is_compound``.
Prior to sqlite-utils 4.0 this was a ``namedtuple`` and could be unpacked
or indexed as ``(table, column, other_table, other_column)``. It is now a
dataclass - access its fields by name instead.
"""
table: str
column: Optional[str]
other_table: str
other_column: Optional[str]
columns: List[str] = field(default_factory=list)
other_columns: List[str] = field(default_factory=list)
is_compound: bool = False
def __post_init__(self):
# Populate columns/other_columns for single-column foreign keys
if not self.columns:
self.columns = [self.column] if self.column is not None else []
if not self.other_columns:
self.other_columns = (
[self.other_column] if self.other_column is not None else []
)
Index = namedtuple("Index", ("seq", "name", "unique", "origin", "partial", "columns")) Index = namedtuple("Index", ("seq", "name", "unique", "origin", "partial", "columns"))
XIndex = namedtuple("XIndex", ("name", "columns")) XIndex = namedtuple("XIndex", ("name", "columns"))
XIndexColumn = namedtuple( XIndexColumn = namedtuple(
@ -1124,7 +1159,7 @@ class Database:
if not all(isinstance(fk, (tuple, list)) for fk in foreign_keys): if not all(isinstance(fk, (tuple, list)) for fk in foreign_keys):
raise ValueError("foreign_keys= should be a list of tuples") raise ValueError("foreign_keys= should be a list of tuples")
fks = [] fks = []
for tuple_or_list in foreign_keys: for tuple_or_list in cast(Iterable[Sequence[str]], foreign_keys):
if len(tuple_or_list) == 4: if len(tuple_or_list) == 4:
if tuple_or_list[0] != name: if tuple_or_list[0] != name:
raise ValueError( raise ValueError(
@ -1234,7 +1269,7 @@ class Database:
pk = hash_id pk = hash_id
# Soundness check foreign_keys point to existing tables # Soundness check foreign_keys point to existing tables
for fk in foreign_keys: for fk in foreign_keys:
if fk.other_table == name and columns.get(fk.other_column): if fk.other_table == name and columns.get(cast(str, fk.other_column)):
continue continue
if fk.other_column != "rowid" and not any( if fk.other_column != "rowid" and not any(
c for c in self[fk.other_table].columns if c.name == fk.other_column c for c in self[fk.other_table].columns if c.name == fk.other_column
@ -1269,7 +1304,7 @@ class Database:
foreign_keys_by_column[column_name].other_table foreign_keys_by_column[column_name].other_table
), ),
quote_identifier( quote_identifier(
foreign_keys_by_column[column_name].other_column cast(str, foreign_keys_by_column[column_name].other_column)
), ),
) )
) )
@ -1499,7 +1534,9 @@ class Database:
""" """
# foreign_keys is a list of explicit 4-tuples # foreign_keys is a list of explicit 4-tuples
if not all( if not all(
len(fk) == 4 and isinstance(fk, (list, tuple)) for fk in foreign_keys isinstance(fk, ForeignKey)
or (isinstance(fk, (list, tuple)) and len(fk) == 4)
for fk in foreign_keys
): ):
raise ValueError( raise ValueError(
"foreign_keys must be a list of 4-tuples, " "foreign_keys must be a list of 4-tuples, "
@ -1509,7 +1546,16 @@ class Database:
foreign_keys_to_create = [] foreign_keys_to_create = []
# Verify that all tables and columns exist # Verify that all tables and columns exist
for table, column, other_table, other_column in foreign_keys: for fk in foreign_keys:
if isinstance(fk, ForeignKey):
table, column, other_table, other_column = (
fk.table,
fk.column,
fk.other_table,
fk.other_column,
)
else:
table, column, other_table, other_column = fk
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))
table_obj = self.table(table) table_obj = self.table(table)
@ -1555,8 +1601,11 @@ class Database:
i.columns[0] for i in table.indexes if len(i.columns) == 1 i.columns[0] for i in table.indexes if len(i.columns) == 1
} }
for fk in table.foreign_keys: for fk in table.foreign_keys:
if fk.column not in existing_indexes: # Compound foreign keys expose their columns via fk.columns;
table.create_index([fk.column], find_unique_name=True) # single-column keys yield a one-item list
for column in fk.columns:
if column not in existing_indexes:
table.create_index([column], find_unique_name=True)
def vacuum(self) -> None: def vacuum(self) -> None:
"Run a SQLite ``VACUUM`` against the database." "Run a SQLite ``VACUUM`` against the database."
@ -1907,21 +1956,40 @@ class Table(Queryable):
@property @property
def foreign_keys(self) -> List["ForeignKey"]: def foreign_keys(self) -> List["ForeignKey"]:
"List of foreign keys defined on this table." """
fks = [] List of foreign keys defined on this table.
Compound (multi-column) foreign keys are returned as a single
``ForeignKey`` with ``is_compound=True`` and populated
``columns``/``other_columns`` lists.
"""
# PRAGMA foreign_key_list returns one row per column, grouped by "id"
# with "seq" giving the column order within a compound foreign key.
by_id: Dict[int, list] = {}
for row in self.db.execute( for row in self.db.execute(
"PRAGMA foreign_key_list({})".format(quote_identifier(self.name)) "PRAGMA foreign_key_list({})".format(quote_identifier(self.name))
).fetchall(): ).fetchall():
if row is not None: if row is not None:
id, seq, table_name, from_, to_, on_update, on_delete, match = row id, seq, table_name, from_, to_, on_update, on_delete, match = row
fks.append( by_id.setdefault(id, []).append((seq, table_name, from_, to_))
ForeignKey( fks = []
table=self.name, for id in sorted(by_id):
column=from_, rows = sorted(by_id[id]) # order columns by seq
other_table=table_name, other_table = rows[0][1]
other_column=to_, columns = [row[2] for row in rows]
) other_columns = [row[3] for row in rows]
is_compound = len(rows) > 1
fks.append(
ForeignKey(
table=self.name,
column=None if is_compound else columns[0],
other_table=other_table,
other_column=None if is_compound else other_columns[0],
columns=columns,
other_columns=other_columns,
is_compound=is_compound,
) )
)
return fks return fks
@property @property
@ -2254,17 +2322,20 @@ class Table(Queryable):
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
create_table_foreign_keys = [] create_table_foreign_keys = []
for table, column, other_table, other_column in self.foreign_keys: for fk in self.foreign_keys:
# Copy over old foreign keys, unless we are dropping them # Expand compound foreign keys into per-column references;
if (drop_foreign_keys is None) or (column not in drop_foreign_keys): # for single-column keys this iterates exactly once
create_table_foreign_keys.append( for column, other_column in zip(fk.columns, fk.other_columns):
ForeignKey( # Copy over old foreign keys, unless we are dropping them
table, if (drop_foreign_keys is None) or (column not in drop_foreign_keys):
rename.get(column) or column, create_table_foreign_keys.append(
other_table, ForeignKey(
other_column, 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):

View file

@ -0,0 +1,82 @@
"""Tests for reading compound (multi-column) foreign keys - issue #594."""
import pytest
from sqlite_utils import Database
COMPOUND_SCHEMA = """
CREATE TABLE departments (
campus_name TEXT NOT NULL,
dept_code TEXT NOT NULL,
dept_name TEXT,
PRIMARY KEY (campus_name, dept_code)
);
CREATE TABLE courses (
course_code TEXT PRIMARY KEY,
course_name TEXT,
campus_name TEXT NOT NULL,
dept_code TEXT NOT NULL,
FOREIGN KEY (campus_name, dept_code)
REFERENCES departments(campus_name, dept_code)
);
"""
@pytest.fixture
def compound_db():
db = Database(memory=True)
db.executescript(COMPOUND_SCHEMA)
return db
def test_compound_foreign_key(compound_db):
fks = compound_db["courses"].foreign_keys
assert len(fks) == 1
fk = fks[0]
assert fk.is_compound is True
assert fk.table == "courses"
assert fk.other_table == "departments"
assert fk.columns == ["campus_name", "dept_code"]
assert fk.other_columns == ["campus_name", "dept_code"]
# Scalar column/other_column can't sensibly hold a compound key
assert fk.column is None
assert fk.other_column is None
def test_single_foreign_key_gets_columns_fields(fresh_db):
fresh_db["authors"].insert({"id": 1, "name": "Sally"}, pk="id")
fresh_db["books"].insert({"title": "Hedgehogs", "author_id": 1})
fresh_db["books"].add_foreign_key("author_id", "authors", "id")
fk = fresh_db["books"].foreign_keys[0]
assert fk.is_compound is False
assert fk.column == "author_id"
assert fk.other_column == "id"
assert fk.columns == ["author_id"]
assert fk.other_columns == ["id"]
def test_foreign_key_no_longer_unpacks_as_tuple(fresh_db):
# Clean break in 4.0: ForeignKey is a dataclass, not a namedtuple, so the
# old tuple unpacking and indexing patterns now fail hard.
fresh_db["authors"].insert({"id": 1, "name": "Sally"}, pk="id")
fresh_db["books"].insert({"title": "Hedgehogs", "author_id": 1})
fresh_db["books"].add_foreign_key("author_id", "authors", "id")
fk = fresh_db["books"].foreign_keys[0]
with pytest.raises(TypeError):
table, column, other_table, other_column = fk
with pytest.raises(TypeError):
fk[0]
def test_foreign_keys_are_sortable(fresh_db):
fresh_db["authors"].insert({"id": 1, "name": "Sally"}, pk="id")
fresh_db["categories"].insert({"id": 1, "name": "Wildlife"}, pk="id")
fresh_db["books"].insert({"title": "Hedgehogs", "author_id": 1, "category_id": 1})
fresh_db.add_foreign_keys(
[
("books", "author_id", "authors", "id"),
("books", "category_id", "categories", "id"),
]
)
fks = sorted(fresh_db["books"].foreign_keys)
assert fks[0].column == "author_id"
assert fks[1].column == "category_id"