diff --git a/docs/python-api.rst b/docs/python-api.rst index 130c4cb98..8fa25283e 100644 --- a/docs/python-api.rst +++ b/docs/python-api.rst @@ -383,6 +383,17 @@ You can leave off the third item in the tuple to have the referenced column auto ], foreign_keys=[ ("author_id", "authors") ]) + +Compound foreign keys can be created by passing in tuples of columns rather than strings: + +.. code-block:: python + + foreign_keys=[ + (("author_id", "person_id"), "authors", ("id", "person_id")) + ] + +This means that the ``author_id`` and ``person_id`` columns should be a compound foreign key that references the ``id`` and ``person_id`` columns in the ``authors`` table. + .. _python_api_table_configuration: @@ -902,6 +913,20 @@ The ``table.add_foreign_key(column, other_table, other_column)`` method takes th This method first checks that the specified foreign key references tables and columns that exist and does not clash with an existing foreign key. It will raise a ``sqlite_utils.db.AlterError`` exception if these checks fail. +You can add compound foreign keys by passing a tuple of column names. For example: + +.. code-block:: python + + db["authors"].insert_all([ + {"id": 1, "person_id": 1, "name": "Sally"}, + {"id": 2, "person_id": 2, "name": "Asheesh"} + ], pk="id") + db["books"].insert_all([ + {"title": "Hedgehogs of the world", "author_id": 1, "person_id": 1}, + {"title": "How to train your wolf", "author_id": 2, "person_id": 2}, + ]) + db["books"].add_foreign_key(("author_id", "person_id"), "authors", ("id", "person_id")) + To ignore the case where the key already exists, use ``ignore=True``: .. code-block:: python diff --git a/sqlite_utils/db.py b/sqlite_utils/db.py index 90427bf89..985a1bede 100644 --- a/sqlite_utils/db.py +++ b/sqlite_utils/db.py @@ -64,13 +64,46 @@ "least_common", ), ) -ForeignKey = namedtuple( - "ForeignKey", ("table", "column", "other_table", "other_column") -) Index = namedtuple("Index", ("seq", "name", "unique", "origin", "partial", "columns")) Trigger = namedtuple("Trigger", ("name", "table", "sql")) +class ForeignKey(ForeignKeyBase): + def __new__(cls, table, column, other_table, other_column): + # column and other_column should be a tuple + if isinstance(column, (tuple, list)): + column = tuple(column) + else: + column = (column,) + if isinstance(other_column, (tuple, list)): + other_column = tuple(other_column) + else: + other_column = (other_column,) + self = super(ForeignKey, cls).__new__( + cls, table, column, other_table, other_column + ) + return self + + @property + def column_str(self): + return ",".join(["[{}]".format(c) for c in self.column]) + + @property + def other_column_str(self): + return ",".join(["[{}]".format(c) for c in self.other_column]) + + @property + def sql(self): + return ( + "FOREIGN KEY({column}) REFERENCES [{other_table}]({other_column})".format( + table=self.table, + column=self.column_str, + other_table=self.other_table, + other_column=self.other_column_str, + ) + ) + + DEFAULT = object() COLUMN_TYPE_MAPPING = { @@ -375,12 +408,9 @@ def create_table_sql( pk = hash_id # Soundness check foreign_keys point to existing tables for fk in foreign_keys: - if not any( - c for c in self[fk.other_table].columns if c.name == fk.other_column - ): - raise AlterError( - "No such column: {}.{}".format(fk.other_table, fk.other_column) - ) + for oc in fk.other_column: + if not any(c for c in self[fk.other_table].columns if c.name == oc): + raise AlterError("No such column: {}.{}".format(fk.other_table, oc)) column_defs = [] # ensure pk is a tuple @@ -401,13 +431,6 @@ def create_table_sql( column_extras.append( "DEFAULT {}".format(self.escape(defaults[column_name])) ) - if column_name in foreign_keys_by_column: - column_extras.append( - "REFERENCES [{other_table}]([{other_column}])".format( - other_table=foreign_keys_by_column[column_name].other_table, - other_column=foreign_keys_by_column[column_name].other_column, - ) - ) column_defs.append( " [{column_name}] {column_type}{column_extras}".format( column_name=column_name, @@ -422,6 +445,10 @@ def create_table_sql( extra_pk = ",\n PRIMARY KEY ({pks})".format( pks=", ".join(["[{}]".format(p) for p in pk]) ) + for column_name in foreign_keys_by_column: + extra_pk += ",\n {}".format( + foreign_keys_by_column[column_name].sql, + ) columns_sql = ",\n".join(column_defs) sql = """CREATE TABLE [{table}] ( {columns_sql}{extra_pk} @@ -494,7 +521,7 @@ def m2m_table_candidates(self, table, other_table): candidates.append(table.name) return candidates - def add_foreign_keys(self, foreign_keys): + def add_foreign_keys(self, foreign_keys, ignore=True): # foreign_keys is a list of explicit 4-tuples assert all( len(fk) == 4 and isinstance(fk, (list, tuple)) for fk in foreign_keys @@ -503,43 +530,55 @@ def add_foreign_keys(self, foreign_keys): foreign_keys_to_create = [] # Verify that all tables and columns exist - for table, column, other_table, other_column in foreign_keys: - if not self[table].exists(): - raise AlterError("No such table: {}".format(table)) - if column not in self[table].columns_dict: - raise AlterError("No such column: {} in {}".format(column, table)) - if not self[other_table].exists(): - raise AlterError("No such other_table: {}".format(other_table)) - if ( - other_column != "rowid" - and other_column not in self[other_table].columns_dict - ): - raise AlterError( - "No such other_column: {} in {}".format(other_column, other_table) + for fk in foreign_keys: + if not isinstance(fk, ForeignKey): + fk = ForeignKey( + table=fk[0], + column=fk[1], + other_table=fk[2], + other_column=fk[3], ) + + if not self[fk.table].exists(): + raise AlterError("No such table: {}".format(fk.table)) + for c in fk.column: + if c not in self[fk.table].columns_dict: + raise AlterError("No such column: {} in {}".format(c, fk.table)) + if not self[fk.other_table].exists(): + raise AlterError("No such other_table: {}".format(fk.other_table)) + for c in fk.other_column: + if c != "rowid" and c not in self[fk.other_table].columns_dict: + raise AlterError( + "No such other_column: {} in {}".format(c, fk.other_table) + ) # We will silently skip foreign keys that exist already - if not any( - fk - for fk in self[table].foreign_keys - if fk.column == column - and fk.other_table == other_table - and fk.other_column == other_column + if any( + existing_fk + for existing_fk in self[fk.table].foreign_keys + if existing_fk.column == fk.column + and existing_fk.other_table == fk.other_table + and existing_fk.other_column == fk.other_column ): - foreign_keys_to_create.append( - (table, column, other_table, other_column) - ) + if ignore: + continue + else: + raise AlterError( + "Foreign key already exists for {} => {}.{}".format( + fk.column_str, other_table, fk.other_column_str + ) + ) + else: + foreign_keys_to_create.append(fk) # Construct SQL for use with "UPDATE sqlite_master SET sql = ? WHERE name = ?" table_sql = {} - for table, column, other_table, other_column in foreign_keys_to_create: - old_sql = table_sql.get(table, self[table].schema) - extra_sql = ",\n FOREIGN KEY({column}) REFERENCES {other_table}({other_column})\n".format( - column=column, other_table=other_table, other_column=other_column - ) + for fk in foreign_keys_to_create: + old_sql = table_sql.get(fk.table, self[fk.table].schema) + extra_sql = ",\n {}\n".format(fk.sql) # Stick that bit in at the very end just before the closing ')' last_paren = old_sql.rindex(")") new_sql = old_sql[:last_paren].strip() + extra_sql + old_sql[last_paren:] - table_sql[table] = new_sql + table_sql[fk.table] = new_sql # And execute it all within a single transaction with self.conn: @@ -560,12 +599,10 @@ def add_foreign_keys(self, foreign_keys): def index_foreign_keys(self): for table_name in self.table_names(): table = self[table_name] - existing_indexes = { - i.columns[0] for i in table.indexes if len(i.columns) == 1 - } + existing_indexes = {tuple(i.columns) for i in table.indexes} for fk in table.foreign_keys: if fk.column not in existing_indexes: - table.create_index([fk.column]) + table.create_index(fk.column) def vacuum(self): self.execute("VACUUM;") @@ -701,21 +738,30 @@ def get(self, pk_values): @property def foreign_keys(self): - fks = [] + fks = {} for row in self.db.execute( "PRAGMA foreign_key_list([{}])".format(self.name) ).fetchall(): if row is not None: id, seq, table_name, from_, to_, on_update, on_delete, match = row - fks.append( - ForeignKey( - table=self.name, - column=from_, - other_table=table_name, - other_column=to_, - ) - ) - return fks + if id not in fks: + fks[id] = { + "column": [], + "other_column": [], + } + fks[id]["table"] = self.name + fks[id]["column"].append(from_) + fks[id]["other_table"] = table_name + fks[id]["other_column"].append(to_) + return [ + ForeignKey( + table=fk["table"], + column=tuple(fk["column"]), + other_table=fk["other_table"], + other_column=tuple(fk["other_column"]), + ) + for fk in fks.values() + ] @property def virtual_table_using(self): @@ -899,10 +945,16 @@ def transform_sql( # foreign_keys create_table_foreign_keys = [] - for table, column, other_table, other_column in self.foreign_keys: - if (drop_foreign_keys is None) or (column not in drop_foreign_keys): + if drop_foreign_keys: + drop_foreign_keys = [ + (f,) if not isinstance(f, (tuple, list)) else tuple(f) + for f in drop_foreign_keys + ] + for fk in self.foreign_keys: + if (drop_foreign_keys is None) or (fk.column not in drop_foreign_keys): + column_names = tuple(rename.get(c) or c for c in fk.column) create_table_foreign_keys.append( - (rename.get(column) or column, other_table, other_column) + (column_names, fk.other_table, fk.other_column) ) if column_order is not None: @@ -1102,6 +1154,8 @@ def drop(self): self.db.execute("DROP TABLE [{}]".format(self.name)) def guess_foreign_table(self, column): + if isinstance(column, (tuple, list)): + column = column[0] column = column.lower() possibilities = [column] if column.endswith("_id"): @@ -1134,22 +1188,28 @@ def guess_foreign_column(self, other_table): def add_foreign_key( self, column, other_table=None, other_column=None, ignore=False ): + if not isinstance(column, (tuple, list)): + column = (column,) # Ensure column exists - if column not in self.columns_dict: - raise AlterError("No such column: {}".format(column)) + for c in column: + if c not in self.columns_dict: + raise AlterError("No such column: {}".format(c)) # If other_table is not specified, attempt to guess it from the column if other_table is None: other_table = self.guess_foreign_table(column) # If other_column is not specified, detect the primary key on other_table if other_column is None: other_column = self.guess_foreign_column(other_table) + if not isinstance(other_column, (tuple, list)): + other_column = (other_column,) # Soundness check that the other column exists - if ( - not [c for c in self.db[other_table].columns if c.name == other_column] - and other_column != "rowid" - ): - raise AlterError("No such column: {}.{}".format(other_table, other_column)) + for oc in other_column: + if ( + not [c for c in self.db[other_table].columns if c.name == oc] + and oc != "rowid" + ): + raise AlterError("No such column: {}.{}".format(other_table, oc)) # Check we do not already have an existing foreign key if any( fk @@ -1163,7 +1223,7 @@ def add_foreign_key( else: raise AlterError( "Foreign key already exists for {} => {}.{}".format( - column, other_table, other_column + ",".join(column), other_table, ",".join(other_column) ) ) self.db.add_foreign_keys([(self.name, column, other_table, other_column)]) diff --git a/tests/test_cli.py b/tests/test_cli.py index 0179b5085..54e061f78 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -321,7 +321,7 @@ def test_add_column_foreign_key(db_path): ) assert 0 == result.exit_code, result.output assert ( - "CREATE TABLE [books] ( [title] TEXT , [author_id] INTEGER, FOREIGN KEY(author_id) REFERENCES authors(id) )" + "CREATE TABLE [books] ( [title] TEXT , [author_id] INTEGER, FOREIGN KEY([author_id]) REFERENCES [authors]([id]) )" == collapse_whitespace(db["books"].schema) ) # Try it again with a custom --fk-col @@ -341,8 +341,8 @@ def test_add_column_foreign_key(db_path): assert 0 == result.exit_code, result.output assert ( "CREATE TABLE [books] ( [title] TEXT , [author_id] INTEGER, [author_name_ref] TEXT, " - "FOREIGN KEY(author_id) REFERENCES authors(id), " - "FOREIGN KEY(author_name_ref) REFERENCES authors(name) )" + "FOREIGN KEY([author_id]) REFERENCES [authors]([id]), " + "FOREIGN KEY([author_name_ref]) REFERENCES [authors]([name]) )" == collapse_whitespace(db["books"].schema) ) # Throw an error if the --fk table does not exist @@ -1225,7 +1225,8 @@ def test_create_table_foreign_key(): "CREATE TABLE [books] (\n" " [id] INTEGER PRIMARY KEY,\n" " [title] TEXT,\n" - " [author_id] INTEGER REFERENCES [authors]([id])\n" + " [author_id] INTEGER,\n" + " FOREIGN KEY([author_id]) REFERENCES [authors]([id])\n" ")" ) == db["books"].schema @@ -1597,7 +1598,7 @@ def test_transform_drop_foreign_key(db_path): schema = db["places"].schema assert ( schema - == 'CREATE TABLE "places" (\n [id] INTEGER PRIMARY KEY,\n [name] TEXT,\n [country] INTEGER,\n [city] INTEGER REFERENCES [city]([id])\n)' + == 'CREATE TABLE "places" (\n [id] INTEGER PRIMARY KEY,\n [name] TEXT,\n [country] INTEGER,\n [city] INTEGER,\n FOREIGN KEY([city]) REFERENCES [city]([id])\n)' ) @@ -1611,22 +1612,22 @@ def test_transform_drop_foreign_key(db_path): [ ( [], - 'CREATE TABLE "trees" (\n [id] INTEGER PRIMARY KEY,\n [address] TEXT,\n [species_id] INTEGER,\n FOREIGN KEY(species_id) REFERENCES species(id)\n)', + 'CREATE TABLE "trees" (\n [id] INTEGER PRIMARY KEY,\n [address] TEXT,\n [species_id] INTEGER,\n FOREIGN KEY([species_id]) REFERENCES [species]([id])\n)', _common_other_schema, ), ( ["--table", "custom_table"], - 'CREATE TABLE "trees" (\n [id] INTEGER PRIMARY KEY,\n [address] TEXT,\n [custom_table_id] INTEGER,\n FOREIGN KEY(custom_table_id) REFERENCES custom_table(id)\n)', + 'CREATE TABLE "trees" (\n [id] INTEGER PRIMARY KEY,\n [address] TEXT,\n [custom_table_id] INTEGER,\n FOREIGN KEY([custom_table_id]) REFERENCES [custom_table]([id])\n)', "CREATE TABLE [custom_table] (\n [id] INTEGER PRIMARY KEY,\n [species] TEXT\n)", ), ( ["--fk-column", "custom_fk"], - 'CREATE TABLE "trees" (\n [id] INTEGER PRIMARY KEY,\n [address] TEXT,\n [custom_fk] INTEGER,\n FOREIGN KEY(custom_fk) REFERENCES species(id)\n)', + 'CREATE TABLE "trees" (\n [id] INTEGER PRIMARY KEY,\n [address] TEXT,\n [custom_fk] INTEGER,\n FOREIGN KEY([custom_fk]) REFERENCES [species]([id])\n)', _common_other_schema, ), ( ["--rename", "name", "name2"], - 'CREATE TABLE "trees" (\n [id] INTEGER PRIMARY KEY,\n [address] TEXT,\n [species_id] INTEGER,\n FOREIGN KEY(species_id) REFERENCES species(id)\n)', + 'CREATE TABLE "trees" (\n [id] INTEGER PRIMARY KEY,\n [address] TEXT,\n [species_id] INTEGER,\n FOREIGN KEY([species_id]) REFERENCES [species]([id])\n)', "CREATE TABLE [species] (\n [id] INTEGER PRIMARY KEY,\n [species] TEXT\n)", ), ], diff --git a/tests/test_create.py b/tests/test_create.py index 83936e444..1fe6fc0ae 100644 --- a/tests/test_create.py +++ b/tests/test_create.py @@ -268,8 +268,8 @@ def do_it(): ] == [{"name": col.name, "type": col.type} for col in fresh_db["m2m"].columns] assert sorted( [ - {"column": "one_id", "other_table": "one", "other_column": "id"}, - {"column": "two_id", "other_table": "two", "other_column": "id"}, + {"column": ("one_id",), "other_table": "one", "other_column": ("id",)}, + {"column": ("two_id",), "other_table": "two", "other_column": ("id",)}, ], key=lambda s: repr(s), ) == sorted( @@ -360,6 +360,36 @@ def test_add_foreign_key(fresh_db): ] == fresh_db["books"].foreign_keys +def test_add_compound_foreign_key(fresh_db): + fresh_db["authors"].insert_all( + [ + {"id": 1, "person_id": 1, "name": "Sally"}, + {"id": 2, "person_id": 2, "name": "Asheesh"}, + ], + pk=("id", "person_id"), + ) + fresh_db["books"].insert_all( + [ + {"title": "Hedgehogs of the world", "author_id": 1, "author_person_id": 1}, + {"title": "How to train your wolf", "author_id": 2, "author_person_id": 2}, + ] + ) + assert [] == fresh_db["books"].foreign_keys + t = fresh_db["books"].add_foreign_key( + ("author_id", "author_person_id"), "authors", ("id", "person_id") + ) + # Ensure it returned self: + assert isinstance(t, Table) and t.name == "books" + assert [ + ForeignKey( + table="books", + column=("author_id", "author_person_id"), + other_table="authors", + other_column=("id", "person_id"), + ) + ] == fresh_db["books"].foreign_keys + + def test_add_foreign_key_error_if_column_does_not_exist(fresh_db): fresh_db["books"].insert( {"id": 1, "title": "Hedgehogs of the world", "author_id": 1} @@ -423,7 +453,7 @@ def test_add_column_foreign_key(fresh_db): fresh_db.create_table("breeds", {"name": str}) fresh_db["dogs"].add_column("breed_id", fk="breeds") assert ( - "CREATE TABLE [dogs] ( [name] TEXT , [breed_id] INTEGER, FOREIGN KEY(breed_id) REFERENCES breeds(rowid) )" + "CREATE TABLE [dogs] ( [name] TEXT , [breed_id] INTEGER, FOREIGN KEY([breed_id]) REFERENCES [breeds]([rowid]) )" == collapse_whitespace(fresh_db["dogs"].schema) ) # And again with an explicit primary key column @@ -431,8 +461,8 @@ def test_add_column_foreign_key(fresh_db): fresh_db["dogs"].add_column("subbreed_id", fk="subbreeds") assert ( "CREATE TABLE [dogs] ( [name] TEXT , [breed_id] INTEGER, [subbreed_id] TEXT, " - "FOREIGN KEY(breed_id) REFERENCES breeds(rowid), " - "FOREIGN KEY(subbreed_id) REFERENCES subbreeds(primkey) )" + "FOREIGN KEY([breed_id]) REFERENCES [breeds]([rowid]), " + "FOREIGN KEY([subbreed_id]) REFERENCES [subbreeds]([primkey]) )" == collapse_whitespace(fresh_db["dogs"].schema) ) @@ -443,7 +473,7 @@ def test_add_foreign_key_guess_table(fresh_db): fresh_db["dogs"].add_column("breed_id", int) fresh_db["dogs"].add_foreign_key("breed_id") assert ( - "CREATE TABLE [dogs] ( [name] TEXT , [breed_id] INTEGER, FOREIGN KEY(breed_id) REFERENCES breeds(id) )" + "CREATE TABLE [dogs] ( [name] TEXT , [breed_id] INTEGER, FOREIGN KEY([breed_id]) REFERENCES [breeds]([id]) )" == collapse_whitespace(fresh_db["dogs"].schema) ) diff --git a/tests/test_extract.py b/tests/test_extract.py index 25dd6e228..9eae70496 100644 --- a/tests/test_extract.py +++ b/tests/test_extract.py @@ -28,7 +28,9 @@ def test_extract_single_column(fresh_db, table, fk_column): " [name] TEXT,\n" " [{}] INTEGER,\n".format(expected_fk) + " [end] INTEGER,\n" - + " FOREIGN KEY({}) REFERENCES {}(id)\n".format(expected_fk, expected_table) + + " FOREIGN KEY([{}]) REFERENCES [{}]([id])\n".format( + expected_fk, expected_table + ) + ")" ) assert fresh_db[expected_table].schema == ( @@ -75,7 +77,7 @@ def test_extract_multiple_columns_with_rename(fresh_db): " [id] INTEGER PRIMARY KEY,\n" " [name] TEXT,\n" " [common_name_latin_name_id] INTEGER,\n" - " FOREIGN KEY(common_name_latin_name_id) REFERENCES common_name_latin_name(id)\n" + " FOREIGN KEY([common_name_latin_name_id]) REFERENCES [common_name_latin_name]([id])\n" ")" ) assert fresh_db["common_name_latin_name"].schema == ( @@ -127,7 +129,7 @@ def test_extract_rowid_table(fresh_db): " [rowid] INTEGER PRIMARY KEY,\n" " [name] TEXT,\n" " [common_name_latin_name_id] INTEGER,\n" - " FOREIGN KEY(common_name_latin_name_id) REFERENCES common_name_latin_name(id)\n" + " FOREIGN KEY([common_name_latin_name_id]) REFERENCES [common_name_latin_name]([id])\n" ")" ) @@ -144,7 +146,7 @@ def test_reuse_lookup_table(fresh_db): 'CREATE TABLE "sightings" (\n' " [id] INTEGER PRIMARY KEY,\n" " [species_id] INTEGER,\n" - " FOREIGN KEY(species_id) REFERENCES species(id)\n" + " FOREIGN KEY([species_id]) REFERENCES [species]([id])\n" ")" ) assert fresh_db["individuals"].schema == ( @@ -152,7 +154,7 @@ def test_reuse_lookup_table(fresh_db): " [id] INTEGER PRIMARY KEY,\n" " [name] TEXT,\n" " [species_id] INTEGER,\n" - " FOREIGN KEY(species_id) REFERENCES species(id)\n" + " FOREIGN KEY([species_id]) REFERENCES [species]([id])\n" ")" ) assert list(fresh_db["species"].rows) == [ diff --git a/tests/test_extracts.py b/tests/test_extracts.py index 0edd00283..222b31fa8 100644 --- a/tests/test_extracts.py +++ b/tests/test_extracts.py @@ -36,7 +36,7 @@ def test_extracts(fresh_db, kwargs, expected_table, use_table_factory): == fresh_db[expected_table].schema ) assert ( - "CREATE TABLE [Trees] (\n [id] INTEGER,\n [species_id] INTEGER REFERENCES [{}]([id])\n)".format( + "CREATE TABLE [Trees] (\n [id] INTEGER,\n [species_id] INTEGER,\n FOREIGN KEY([species_id]) REFERENCES [{}]([id])\n)".format( expected_table ) == fresh_db["Trees"].schema @@ -45,7 +45,7 @@ def test_extracts(fresh_db, kwargs, expected_table, use_table_factory): assert len(fresh_db["Trees"].foreign_keys) == 1 fk = fresh_db["Trees"].foreign_keys[0] assert fk.table == "Trees" - assert fk.column == "species_id" + assert fk.column == ("species_id",) # Should have unique index on Species assert [ diff --git a/tests/test_m2m.py b/tests/test_m2m.py index cbc9015b9..e40d33c64 100644 --- a/tests/test_m2m.py +++ b/tests/test_m2m.py @@ -30,17 +30,25 @@ def test_insert_m2m_list(fresh_db): assert [{"id": 1, "name": "Natalie D"}, {"id": 2, "name": "Simon W"}] == list( humans.rows ) - assert [ - ForeignKey( - table="dogs_humans", column="dogs_id", other_table="dogs", other_column="id" - ), - ForeignKey( - table="dogs_humans", - column="humans_id", - other_table="humans", - other_column="id", - ), - ] == dogs_humans.foreign_keys + assert ( + sorted( + [ + ForeignKey( + table="dogs_humans", + column="dogs_id", + other_table="dogs", + other_column="id", + ), + ForeignKey( + table="dogs_humans", + column="humans_id", + other_table="humans", + other_column="id", + ), + ] + ) + == sorted(dogs_humans.foreign_keys) + ) def test_insert_m2m_iterable(fresh_db): @@ -103,17 +111,25 @@ def test_m2m_lookup(fresh_db): tags = fresh_db["tags"] assert people_tags.exists() assert tags.exists() - assert [ - ForeignKey( - table="people_tags", - column="people_id", - other_table="people", - other_column="id", - ), - ForeignKey( - table="people_tags", column="tags_id", other_table="tags", other_column="id" - ), - ] == people_tags.foreign_keys + assert ( + sorted( + [ + ForeignKey( + table="people_tags", + column="people_id", + other_table="people", + other_column="id", + ), + ForeignKey( + table="people_tags", + column="tags_id", + other_table="tags", + other_column="id", + ), + ] + ) + == sorted(people_tags.foreign_keys) + ) assert [{"people_id": 1, "tags_id": 1}] == list(people_tags.rows) assert [{"id": 1, "name": "Wahyu"}] == list(people.rows) assert [{"id": 1, "tag": "Coworker"}] == list(tags.rows)