diff --git a/datasette/app.py b/datasette/app.py index df33386bbb..0cd929e852 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -899,8 +899,11 @@ async def expand_foreign_keys(self, database, table, column, values): fk = [ foreign_key for foreign_key in foreign_keys - if foreign_key["column"] == column + if foreign_key["columns"][0] == column + and len(foreign_key["columns"]) == 1 ][0] + fk["column"] = fk["columns"][0] + fk["other_column"] = fk["other_columns"][0] except IndexError: return {} label_column = await db.label_column_for_table(fk["other_table"]) diff --git a/datasette/filters.py b/datasette/filters.py index 5ea3488bd7..58399e0985 100644 --- a/datasette/filters.py +++ b/datasette/filters.py @@ -135,8 +135,12 @@ async def inner(): outgoing_foreign_keys = await db.foreign_keys_for_table(through_table) try: fk_to_us = [ - fk for fk in outgoing_foreign_keys if fk["other_table"] == table + fk + for fk in outgoing_foreign_keys + if fk["other_table"] == table and len(fk["other_columns"]) == 1 ][0] + fk_to_us["column"] = fk_to_us["columns"][0] + fk_to_us["other_column"] = fk_to_us["other_columns"][0] except IndexError: raise DatasetteError( "Invalid _through - could not find corresponding foreign key" diff --git a/datasette/templates/row.html b/datasette/templates/row.html index 1d1b0bfd4f..ef2eff8155 100644 --- a/datasette/templates/row.html +++ b/datasette/templates/row.html @@ -35,7 +35,7 @@

Links from other tables

  • {{ "{:,}".format(other.count) }} row{% if other.count == 1 %}{% else %}s{% endif %} - from {{ other.other_column }} in {{ other.other_table }} + from {{ other.other_columns_reference }} in {{ other.other_table }}
  • {% endfor %} diff --git a/datasette/utils/__init__.py b/datasette/utils/__init__.py index 841d6c6c78..8f016ec2c5 100644 --- a/datasette/utils/__init__.py +++ b/datasette/utils/__init__.py @@ -523,30 +523,21 @@ def detect_primary_keys(conn, table): def get_outbound_foreign_keys(conn, table): infos = conn.execute(f"PRAGMA foreign_key_list([{table}])").fetchall() - fks = [] + fks = {} for info in infos: if info is not None: id, seq, table_name, from_, to_, on_update, on_delete, match = info - fks.append( - { - "column": from_, + if id in fks: + fk_info = fks[id] + fk_info["columns"] += (from_,) + fk_info["other_columns"] += (to_,) + else: + fks[id] = { "other_table": table_name, - "other_column": to_, - "id": id, - "seq": seq, + "columns": (from_,), + "other_columns": (to_,), } - ) - # Filter out compound foreign keys by removing any where "id" is not unique - id_counts = Counter(fk["id"] for fk in fks) - return [ - { - "column": fk["column"], - "other_table": fk["other_table"], - "other_column": fk["other_column"], - } - for fk in fks - if id_counts[fk["id"]] == 1 - ] + return list(fks.values()) def get_all_foreign_keys(conn): @@ -560,17 +551,17 @@ def get_all_foreign_keys(conn): fks = get_outbound_foreign_keys(conn, table) for fk in fks: table_name = fk["other_table"] - from_ = fk["column"] - to_ = fk["other_column"] + from_ = fk["columns"] + to_ = fk["other_columns"] if table_name not in table_to_foreign_keys: # Weird edge case where something refers to a table that does # not actually exist continue table_to_foreign_keys[table_name]["incoming"].append( - {"other_table": table, "column": to_, "other_column": from_} + {"other_table": table, "columns": to_, "other_columns": from_} ) table_to_foreign_keys[table]["outgoing"].append( - {"other_table": table_name, "column": from_, "other_column": to_} + {"other_table": table_name, "columns": from_, "other_columns": to_} ) return table_to_foreign_keys diff --git a/datasette/views/row.py b/datasette/views/row.py index b3c4574632..0e4adfe273 100644 --- a/datasette/views/row.py +++ b/datasette/views/row.py @@ -97,8 +97,6 @@ async def template_data(): ) async def foreign_key_tables(self, database, table, pk_values): - if len(pk_values) != 1: - return [] db = self.ds.databases[database] all_foreign_keys = await db.get_all_foreign_keys() foreign_keys = all_foreign_keys[table]["incoming"] @@ -107,39 +105,59 @@ async def foreign_key_tables(self, database, table, pk_values): sql = "select " + ", ".join( [ - "(select count(*) from {table} where {column}=:id)".format( + "(select count(*) from {table} where {condition})".format( table=escape_sqlite(fk["other_table"]), - column=escape_sqlite(fk["other_column"]), + condition=" and ".join( + "{column}=:id{i}".format(column=escape_sqlite(column), i=i) + for i, column in enumerate(fk["other_columns"]) + ), ) for fk in foreign_keys ] ) try: - rows = list(await db.execute(sql, {"id": pk_values[0]})) + rows = list( + await db.execute( + sql, {"id{i}".format(i=i): pk for i, pk in enumerate(pk_values)} + ) + ) except QueryInterrupted: # Almost certainly hit the timeout return [] foreign_table_counts = dict( zip( - [(fk["other_table"], fk["other_column"]) for fk in foreign_keys], + [(fk["other_table"], fk["other_columns"]) for fk in foreign_keys], list(rows[0]), ) ) foreign_key_tables = [] for fk in foreign_keys: count = ( - foreign_table_counts.get((fk["other_table"], fk["other_column"])) or 0 + foreign_table_counts.get((fk["other_table"], fk["other_columns"])) or 0 + ) + query_pairs = zip(fk["other_columns"], pk_values) + query = "&".join( + "{}={}".format(col + "__exact" if col.startswith("_") else col, pk) + for col, pk in query_pairs + ) + link = "{}?{}".format( + self.ds.urls.table(database, fk["other_table"]), query ) - key = fk["other_column"] - if key.startswith("_"): - key += "__exact" - link = "{}?{}={}".format( - self.ds.urls.table(database, fk["other_table"]), - key, - ",".join(pk_values), + if len(pk_values) == 1: + other_columns_reference = fk["other_columns"][0] + else: + other_columns_reference = "({})".format(", ".join(fk["other_columns"])) + foreign_key_tables.append( + { + **fk, + **{ + "count": count, + "link": link, + "other_columns_reference": other_columns_reference, + }, + } ) - foreign_key_tables.append({**fk, **{"count": count, "link": link}}) return foreign_key_tables diff --git a/datasette/views/table.py b/datasette/views/table.py index ad45ecd365..7483e5bd93 100644 --- a/datasette/views/table.py +++ b/datasette/views/table.py @@ -88,8 +88,15 @@ async def expandable_columns(self, database_name, table_name): expandables = [] db = self.ds.databases[database_name] for fk in await db.foreign_keys_for_table(table_name): + if len(fk["other_columns"]) > 1: + continue label_column = await db.label_column_for_table(fk["other_table"]) - expandables.append((fk, label_column)) + singleton_fk = { + "other_table": fk["other_table"], + "other_column": fk["other_columns"][0], + "column": fk["columns"][0], + } + expandables.append((singleton_fk, label_column)) return expandables async def post(self, request): @@ -920,8 +927,9 @@ async def display_columns_and_rows( ) column_to_foreign_key_table = { - fk["column"]: fk["other_table"] + fk["columns"][0]: fk["other_table"] for fk in await db.foreign_keys_for_table(table_name) + if len(fk["columns"]) == 1 } cell_rows = [] diff --git a/tests/test_api.py b/tests/test_api.py index 5a75148701..b48b2a8e08 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -96,8 +96,8 @@ async def test_database_page(ds_client): "incoming": [ { "other_table": "roadside_attraction_characteristics", - "column": "pk", - "other_column": "characteristic_id", + "columns": ["pk"], + "other_columns": ["characteristic_id"], } ], "outgoing": [], @@ -126,18 +126,18 @@ async def test_database_page(ds_client): "outgoing": [ { "other_table": "simple_primary_key", - "column": "f3", - "other_column": "id", + "columns": ["f3"], + "other_columns": ["id"], }, { "other_table": "simple_primary_key", - "column": "f2", - "other_column": "id", + "columns": ["f2"], + "other_columns": ["id"], }, { "other_table": "simple_primary_key", - "column": "f1", - "other_column": "id", + "columns": ["f1"], + "other_columns": ["id"], }, ], }, @@ -150,7 +150,19 @@ async def test_database_page(ds_client): "count": 2, "hidden": False, "fts_table": None, - "foreign_keys": {"incoming": [], "outgoing": []}, + "foreign_keys": { + "incoming": [ + { + "other_table": "foreign_key_references", + "columns": ["pk1", "pk2"], + "other_columns": [ + "foreign_key_compound_pk1", + "foreign_key_compound_pk2", + ], + } + ], + "outgoing": [], + }, "private": False, }, { @@ -175,8 +187,8 @@ async def test_database_page(ds_client): "outgoing": [ { "other_table": "primary_key_multiple_columns_explicit_label", - "column": "foreign_key_with_custom_label", - "other_column": "id", + "columns": ["foreign_key_with_custom_label"], + "other_columns": ["id"], } ], }, @@ -193,8 +205,8 @@ async def test_database_page(ds_client): "incoming": [ { "other_table": "facetable", - "column": "id", - "other_column": "_city_id", + "columns": ["id"], + "other_columns": ["_city_id"], } ], "outgoing": [], @@ -225,8 +237,8 @@ async def test_database_page(ds_client): "outgoing": [ { "other_table": "facet_cities", - "column": "_city_id", - "other_column": "id", + "columns": ["_city_id"], + "other_columns": ["id"], } ], }, @@ -249,20 +261,28 @@ async def test_database_page(ds_client): "foreign_keys": { "incoming": [], "outgoing": [ + { + "other_table": "compound_primary_key", + "columns": [ + "foreign_key_compound_pk1", + "foreign_key_compound_pk2", + ], + "other_columns": ["pk1", "pk2"], + }, { "other_table": "primary_key_multiple_columns", - "column": "foreign_key_with_no_label", - "other_column": "id", + "columns": ["foreign_key_with_no_label"], + "other_columns": ["id"], }, { "other_table": "simple_primary_key", - "column": "foreign_key_with_blank_label", - "other_column": "id", + "columns": ["foreign_key_with_blank_label"], + "other_columns": ["id"], }, { "other_table": "simple_primary_key", - "column": "foreign_key_with_label", - "other_column": "id", + "columns": ["foreign_key_with_label"], + "other_columns": ["id"], }, ], }, @@ -290,8 +310,8 @@ async def test_database_page(ds_client): "incoming": [ { "other_table": "foreign_key_references", - "column": "id", - "other_column": "foreign_key_with_no_label", + "columns": ["id"], + "other_columns": ["foreign_key_with_no_label"], } ], "outgoing": [], @@ -309,8 +329,8 @@ async def test_database_page(ds_client): "incoming": [ { "other_table": "custom_foreign_key_label", - "column": "id", - "other_column": "foreign_key_with_custom_label", + "columns": ["id"], + "other_columns": ["foreign_key_with_custom_label"], } ], "outgoing": [], @@ -329,13 +349,13 @@ async def test_database_page(ds_client): "outgoing": [ { "other_table": "attraction_characteristic", - "column": "characteristic_id", - "other_column": "pk", + "columns": ["characteristic_id"], + "other_columns": ["pk"], }, { "other_table": "roadside_attractions", - "column": "attraction_id", - "other_column": "pk", + "columns": ["attraction_id"], + "other_columns": ["pk"], }, ], }, @@ -352,8 +372,8 @@ async def test_database_page(ds_client): "incoming": [ { "other_table": "roadside_attraction_characteristics", - "column": "pk", - "other_column": "attraction_id", + "columns": ["pk"], + "other_columns": ["attraction_id"], } ], "outgoing": [], @@ -371,8 +391,8 @@ async def test_database_page(ds_client): "incoming": [ { "other_table": "searchable_tags", - "column": "pk", - "other_column": "searchable_id", + "columns": ["pk"], + "other_columns": ["searchable_id"], } ], "outgoing": [], @@ -389,11 +409,15 @@ async def test_database_page(ds_client): "foreign_keys": { "incoming": [], "outgoing": [ - {"other_table": "tags", "column": "tag", "other_column": "tag"}, + { + "other_table": "tags", + "columns": ["tag"], + "other_columns": ["tag"], + }, { "other_table": "searchable", - "column": "searchable_id", - "other_column": "pk", + "columns": ["searchable_id"], + "other_columns": ["pk"], }, ], }, @@ -420,28 +444,28 @@ async def test_database_page(ds_client): "incoming": [ { "other_table": "foreign_key_references", - "column": "id", - "other_column": "foreign_key_with_blank_label", + "columns": ["id"], + "other_columns": ["foreign_key_with_blank_label"], }, { "other_table": "foreign_key_references", - "column": "id", - "other_column": "foreign_key_with_label", + "columns": ["id"], + "other_columns": ["foreign_key_with_label"], }, { "other_table": "complex_foreign_keys", - "column": "id", - "other_column": "f3", + "columns": ["id"], + "other_columns": ["f3"], }, { "other_table": "complex_foreign_keys", - "column": "id", - "other_column": "f2", + "columns": ["id"], + "other_columns": ["f2"], }, { "other_table": "complex_foreign_keys", - "column": "id", - "other_column": "f1", + "columns": ["id"], + "other_columns": ["f1"], }, ], "outgoing": [], @@ -487,8 +511,8 @@ async def test_database_page(ds_client): "incoming": [ { "other_table": "searchable_tags", - "column": "tag", - "other_column": "tag", + "columns": ["tag"], + "other_columns": ["tag"], } ], "outgoing": [], @@ -517,11 +541,7 @@ async def test_database_page(ds_client): }, { "name": "searchable_fts", - "columns": [ - "text1", - "text2", - "name with . and spaces", - ] + "columns": ["text1", "text2", "name with . and spaces"] + ( [ "searchable_fts", @@ -720,38 +740,43 @@ async def test_row_foreign_key_tables(ds_client): assert response.json()["foreign_key_tables"] == [ { "other_table": "foreign_key_references", - "column": "id", - "other_column": "foreign_key_with_blank_label", + "columns": ["id"], + "other_columns": ["foreign_key_with_blank_label"], "count": 0, "link": "/fixtures/foreign_key_references?foreign_key_with_blank_label=1", + "other_columns_reference": "foreign_key_with_blank_label", }, { "other_table": "foreign_key_references", - "column": "id", - "other_column": "foreign_key_with_label", + "columns": ["id"], + "other_columns": ["foreign_key_with_label"], "count": 1, "link": "/fixtures/foreign_key_references?foreign_key_with_label=1", + "other_columns_reference": "foreign_key_with_label", }, { "other_table": "complex_foreign_keys", - "column": "id", - "other_column": "f3", + "columns": ["id"], + "other_columns": ["f3"], "count": 1, "link": "/fixtures/complex_foreign_keys?f3=1", + "other_columns_reference": "f3", }, { "other_table": "complex_foreign_keys", - "column": "id", - "other_column": "f2", + "columns": ["id"], + "other_columns": ["f2"], "count": 0, "link": "/fixtures/complex_foreign_keys?f2=1", + "other_columns_reference": "f2", }, { "other_table": "complex_foreign_keys", - "column": "id", - "other_column": "f1", + "columns": ["id"], + "other_columns": ["f1"], "count": 1, "link": "/fixtures/complex_foreign_keys?f1=1", + "other_columns_reference": "f1", }, ] diff --git a/tests/test_internals_database.py b/tests/test_internals_database.py index 647ae7bdc7..10d5ebd2c2 100644 --- a/tests/test_internals_database.py +++ b/tests/test_internals_database.py @@ -329,13 +329,13 @@ async def test_get_all_foreign_keys(db): "outgoing": [ { "other_table": "attraction_characteristic", - "column": "characteristic_id", - "other_column": "pk", + "columns": ("characteristic_id",), + "other_columns": ("pk",), }, { "other_table": "roadside_attractions", - "column": "attraction_id", - "other_column": "pk", + "columns": ("attraction_id",), + "other_columns": ("pk",), }, ], } @@ -343,34 +343,47 @@ async def test_get_all_foreign_keys(db): "incoming": [ { "other_table": "roadside_attraction_characteristics", - "column": "pk", - "other_column": "characteristic_id", + "columns": ("pk",), + "other_columns": ("characteristic_id",), } ], "outgoing": [], } assert all_foreign_keys["compound_primary_key"] == { - # No incoming because these are compound foreign keys, which we currently ignore - "incoming": [], + "incoming": [ + { + "other_table": "foreign_key_references", + "columns": ("pk1", "pk2"), + "other_columns": ( + "foreign_key_compound_pk1", + "foreign_key_compound_pk2", + ), + } + ], "outgoing": [], } assert all_foreign_keys["foreign_key_references"] == { "incoming": [], "outgoing": [ + { + "other_table": "compound_primary_key", + "columns": ("foreign_key_compound_pk1", "foreign_key_compound_pk2"), + "other_columns": ("pk1", "pk2"), + }, { "other_table": "primary_key_multiple_columns", - "column": "foreign_key_with_no_label", - "other_column": "id", + "columns": ("foreign_key_with_no_label",), + "other_columns": ("id",), }, { "other_table": "simple_primary_key", - "column": "foreign_key_with_blank_label", - "other_column": "id", + "columns": ("foreign_key_with_blank_label",), + "other_columns": ("id",), }, { "other_table": "simple_primary_key", - "column": "foreign_key_with_label", - "other_column": "id", + "columns": ("foreign_key_with_label",), + "other_columns": ("id",), }, ], }