From c16edb2dc4a96490b69cdce2a5e8287ed3133924 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sun, 5 Jul 2026 15:03:23 -0700 Subject: [PATCH] Better type signatures for compound foreign key columns New ForeignKeyColumns type alias - a column name or a list/tuple of column names - used by add_foreign_key(), add_foreign_keys() (via the ForeignKeyTuple alias, replacing its Any slots) and the ForeignKeyIndicator union, which it also simplifies. Tuples now type-check anywhere lists are accepted. ForeignKey.__post_init__ normalizes tuple columns/other_columns to lists so direct construction with tuples compares equal to introspected foreign keys. Refs https://github.com/simonw/sqlite-utils/pull/770/changes#r3525703477 Co-Authored-By: Claude Fable 5 --- sqlite_utils/db.py | 33 ++++++++++++++++++++------------- tests/test_foreign_keys.py | 16 ++++++++++++++++ 2 files changed, 36 insertions(+), 13 deletions(-) diff --git a/sqlite_utils/db.py b/sqlite_utils/db.py index 009bafc..4e67a3c 100644 --- a/sqlite_utils/db.py +++ b/sqlite_utils/db.py @@ -197,10 +197,15 @@ class ForeignKey: on_update: str = "NO ACTION" def __post_init__(self): - # Populate columns/other_columns for single-column foreign keys - if not self.columns: + # Populate columns/other_columns for single-column foreign keys, + # normalizing any tuples to lists + if self.columns: + self.columns = list(self.columns) + else: self.columns = [self.column] if self.column is not None else [] - if not self.other_columns: + if self.other_columns: + self.other_columns = list(self.other_columns) + else: self.other_columns = ( [self.other_column] if self.other_column is not None else [] ) @@ -228,16 +233,18 @@ class TransformError(Exception): pass +# A single column name, or a list/tuple of columns for a compound foreign key +ForeignKeyColumns = Union[str, List[str], Tuple[str, ...]] + +# (table, column(s), other_table, other_column(s)) +ForeignKeyTuple = Tuple[str, ForeignKeyColumns, str, ForeignKeyColumns] + ForeignKeyIndicator = Union[ str, ForeignKey, - Tuple[str, str], - Tuple[str, str, str], - Tuple[str, str, str, str], - # Compound foreign keys use lists of columns: - Tuple[List[str], str], - Tuple[List[str], str, List[str]], - Tuple[str, List[str], str, List[str]], + Tuple[ForeignKeyColumns, str], + Tuple[ForeignKeyColumns, str, ForeignKeyColumns], + ForeignKeyTuple, ] ForeignKeysType = Union[Iterable[ForeignKeyIndicator], List[ForeignKeyIndicator]] @@ -1604,7 +1611,7 @@ class Database: return candidates def add_foreign_keys( - self, foreign_keys: Iterable[Union[ForeignKey, Tuple[str, Any, str, Any]]] + self, foreign_keys: Iterable[Union[ForeignKey, ForeignKeyTuple]] ) -> None: """ See :ref:`python_api_add_foreign_keys`. @@ -2911,9 +2918,9 @@ class Table(Queryable): def add_foreign_key( self, - column: Union[str, List[str]], + column: ForeignKeyColumns, other_table: Optional[str] = None, - other_column: Optional[Union[str, List[str]]] = None, + other_column: Optional[ForeignKeyColumns] = None, ignore: bool = False, ): """ diff --git a/tests/test_foreign_keys.py b/tests/test_foreign_keys.py index dc4ea68..54039b3 100644 --- a/tests/test_foreign_keys.py +++ b/tests/test_foreign_keys.py @@ -445,3 +445,19 @@ def test_implicit_compound_primary_key_reference_is_resolved(): fk = db["courses"].foreign_keys[0] assert fk.is_compound is True assert fk.other_columns == ["campus_name", "dept_code"] + + +def test_foreign_key_normalizes_tuple_columns_to_lists(): + # Compound columns passed as tuples are normalized to lists, so they + # compare equal to introspected ForeignKeys + fk = ForeignKey( + table="courses", + column=None, + other_table="departments", + other_column=None, + columns=("campus_name", "dept_code"), + other_columns=("campus_name", "dept_code"), + is_compound=True, + ) + assert fk.columns == ["campus_name", "dept_code"] + assert fk.other_columns == ["campus_name", "dept_code"]