Fix bug where compound foreign keys produced broken links, closes #1098

This commit is contained in:
Simon Willison 2020-11-29 11:30:17 -08:00
commit deb0be4ae5
6 changed files with 88 additions and 29 deletions

View file

@ -1,7 +1,7 @@
import asyncio import asyncio
from contextlib import contextmanager from contextlib import contextmanager
import click import click
from collections import OrderedDict, namedtuple from collections import OrderedDict, namedtuple, Counter
import base64 import base64
import hashlib import hashlib
import inspect import inspect
@ -474,9 +474,25 @@ def get_outbound_foreign_keys(conn, table):
if info is not None: if info is not None:
id, seq, table_name, from_, to_, on_update, on_delete, match = info id, seq, table_name, from_, to_, on_update, on_delete, match = info
fks.append( fks.append(
{"column": from_, "other_table": table_name, "other_column": to_} {
"column": from_,
"other_table": table_name,
"other_column": to_,
"id": id,
"seq": seq,
}
) )
return fks # 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
]
def get_all_foreign_keys(conn): def get_all_foreign_keys(conn):
@ -487,20 +503,21 @@ def get_all_foreign_keys(conn):
for table in tables: for table in tables:
table_to_foreign_keys[table] = {"incoming": [], "outgoing": []} table_to_foreign_keys[table] = {"incoming": [], "outgoing": []}
for table in tables: for table in tables:
infos = conn.execute(f"PRAGMA foreign_key_list([{table}])").fetchall() fks = get_outbound_foreign_keys(conn, table)
for info in infos: for fk in fks:
if info is not None: table_name = fk["other_table"]
id, seq, table_name, from_, to_, on_update, on_delete, match = info from_ = fk["column"]
if table_name not in table_to_foreign_keys: to_ = fk["other_column"]
# Weird edge case where something refers to a table that does if table_name not in table_to_foreign_keys:
# not actually exist # Weird edge case where something refers to a table that does
continue # not actually exist
table_to_foreign_keys[table_name]["incoming"].append( continue
{"other_table": table, "column": to_, "other_column": from_} table_to_foreign_keys[table_name]["incoming"].append(
) {"other_table": table, "column": to_, "other_column": from_}
table_to_foreign_keys[table]["outgoing"].append( )
{"other_table": table_name, "column": from_, "other_column": to_} table_to_foreign_keys[table]["outgoing"].append(
) {"other_table": table_name, "column": from_, "other_column": to_}
)
return table_to_foreign_keys return table_to_foreign_keys

View file

@ -388,9 +388,12 @@ CREATE TABLE foreign_key_references (
foreign_key_with_label varchar(30), foreign_key_with_label varchar(30),
foreign_key_with_blank_label varchar(30), foreign_key_with_blank_label varchar(30),
foreign_key_with_no_label varchar(30), foreign_key_with_no_label varchar(30),
foreign_key_compound_pk1 varchar(30),
foreign_key_compound_pk2 varchar(30),
FOREIGN KEY (foreign_key_with_label) REFERENCES simple_primary_key(id), FOREIGN KEY (foreign_key_with_label) REFERENCES simple_primary_key(id),
FOREIGN KEY (foreign_key_with_blank_label) REFERENCES simple_primary_key(id), FOREIGN KEY (foreign_key_with_blank_label) REFERENCES simple_primary_key(id),
FOREIGN KEY (foreign_key_with_no_label) REFERENCES primary_key_multiple_columns(id) FOREIGN KEY (foreign_key_with_no_label) REFERENCES primary_key_multiple_columns(id)
FOREIGN KEY (foreign_key_compound_pk1, foreign_key_compound_pk2) REFERENCES compound_primary_key(pk1, pk2)
); );
CREATE TABLE sortable ( CREATE TABLE sortable (
@ -624,8 +627,8 @@ INSERT INTO simple_primary_key VALUES (4, 'RENDER_CELL_DEMO');
INSERT INTO primary_key_multiple_columns VALUES (1, 'hey', 'world'); INSERT INTO primary_key_multiple_columns VALUES (1, 'hey', 'world');
INSERT INTO primary_key_multiple_columns_explicit_label VALUES (1, 'hey', 'world2'); INSERT INTO primary_key_multiple_columns_explicit_label VALUES (1, 'hey', 'world2');
INSERT INTO foreign_key_references VALUES (1, 1, 3, 1); INSERT INTO foreign_key_references VALUES (1, 1, 3, 1, 'a', 'b');
INSERT INTO foreign_key_references VALUES (2, null, null, null); INSERT INTO foreign_key_references VALUES (2, null, null, null, null, null);
INSERT INTO complex_foreign_keys VALUES (1, 1, 2, 1); INSERT INTO complex_foreign_keys VALUES (1, 1, 2, 1);
INSERT INTO custom_foreign_key_label VALUES (1, 1); INSERT INTO custom_foreign_key_label VALUES (1, 1);

View file

@ -237,6 +237,8 @@ def test_database_page(app_client):
"foreign_key_with_label", "foreign_key_with_label",
"foreign_key_with_blank_label", "foreign_key_with_blank_label",
"foreign_key_with_no_label", "foreign_key_with_no_label",
"foreign_key_compound_pk1",
"foreign_key_compound_pk2",
], ],
"primary_keys": ["pk"], "primary_keys": ["pk"],
"count": 2, "count": 2,
@ -1637,6 +1639,8 @@ def test_expand_label(app_client):
"foreign_key_with_label": {"value": "1", "label": "hello"}, "foreign_key_with_label": {"value": "1", "label": "hello"},
"foreign_key_with_blank_label": "3", "foreign_key_with_blank_label": "3",
"foreign_key_with_no_label": "1", "foreign_key_with_no_label": "1",
"foreign_key_compound_pk1": "a",
"foreign_key_compound_pk2": "b",
} }
} }
@ -1821,24 +1825,28 @@ def test_common_prefix_database_names(app_client_conflicting_database_names):
assert db_name == data["database"] assert db_name == data["database"]
def test_null_foreign_keys_are_not_expanded(app_client): def test_null_and_compound_foreign_keys_are_not_expanded(app_client):
response = app_client.get( response = app_client.get(
"/fixtures/foreign_key_references.json?_shape=array&_labels=on" "/fixtures/foreign_key_references.json?_shape=array&_labels=on"
) )
assert [ assert response.json == [
{ {
"pk": "1", "pk": "1",
"foreign_key_with_label": {"value": "1", "label": "hello"}, "foreign_key_with_label": {"value": "1", "label": "hello"},
"foreign_key_with_blank_label": {"value": "3", "label": ""}, "foreign_key_with_blank_label": {"value": "3", "label": ""},
"foreign_key_with_no_label": {"value": "1", "label": "1"}, "foreign_key_with_no_label": {"value": "1", "label": "1"},
"foreign_key_compound_pk1": "a",
"foreign_key_compound_pk2": "b",
}, },
{ {
"pk": "2", "pk": "2",
"foreign_key_with_label": None, "foreign_key_with_label": None,
"foreign_key_with_blank_label": None, "foreign_key_with_blank_label": None,
"foreign_key_with_no_label": None, "foreign_key_with_no_label": None,
"foreign_key_compound_pk1": None,
"foreign_key_compound_pk2": None,
}, },
] == response.json ]
def test_inspect_file_used_for_count(app_client_immutable_and_inspect_file): def test_inspect_file_used_for_count(app_client_immutable_and_inspect_file):

View file

@ -42,9 +42,9 @@ pk,created,planet_int,on_earth,state,city_id,city_id_label,neighborhood,tags,com
) )
EXPECTED_TABLE_WITH_NULLABLE_LABELS_CSV = """ EXPECTED_TABLE_WITH_NULLABLE_LABELS_CSV = """
pk,foreign_key_with_label,foreign_key_with_label_label,foreign_key_with_blank_label,foreign_key_with_blank_label_label,foreign_key_with_no_label,foreign_key_with_no_label_label pk,foreign_key_with_label,foreign_key_with_label_label,foreign_key_with_blank_label,foreign_key_with_blank_label_label,foreign_key_with_no_label,foreign_key_with_no_label_label,foreign_key_compound_pk1,foreign_key_compound_pk2
1,1,hello,3,,1,1 1,1,hello,3,,1,1,a,b
2,,,,,, 2,,,,,,,,
""".lstrip().replace( """.lstrip().replace(
"\n", "\r\n" "\n", "\r\n"
) )

View file

@ -804,12 +804,16 @@ def test_table_html_foreign_key_links(app_client):
'<td class="col-foreign_key_with_label type-str"><a href="/fixtures/simple_primary_key/1">hello</a>\xa0<em>1</em></td>', '<td class="col-foreign_key_with_label type-str"><a href="/fixtures/simple_primary_key/1">hello</a>\xa0<em>1</em></td>',
'<td class="col-foreign_key_with_blank_label type-str"><a href="/fixtures/simple_primary_key/3">-</a>\xa0<em>3</em></td>', '<td class="col-foreign_key_with_blank_label type-str"><a href="/fixtures/simple_primary_key/3">-</a>\xa0<em>3</em></td>',
'<td class="col-foreign_key_with_no_label type-str"><a href="/fixtures/primary_key_multiple_columns/1">1</a></td>', '<td class="col-foreign_key_with_no_label type-str"><a href="/fixtures/primary_key_multiple_columns/1">1</a></td>',
'<td class="col-foreign_key_compound_pk1 type-str">a</td>',
'<td class="col-foreign_key_compound_pk2 type-str">b</td>',
], ],
[ [
'<td class="col-pk type-pk"><a href="/fixtures/foreign_key_references/2">2</a></td>', '<td class="col-pk type-pk"><a href="/fixtures/foreign_key_references/2">2</a></td>',
'<td class="col-foreign_key_with_label type-none">\xa0</td>', '<td class="col-foreign_key_with_label type-none">\xa0</td>',
'<td class="col-foreign_key_with_blank_label type-none">\xa0</td>', '<td class="col-foreign_key_with_blank_label type-none">\xa0</td>',
'<td class="col-foreign_key_with_no_label type-none">\xa0</td>', '<td class="col-foreign_key_with_no_label type-none">\xa0</td>',
'<td class="col-foreign_key_compound_pk1 type-none">\xa0</td>',
'<td class="col-foreign_key_compound_pk2 type-none">\xa0</td>',
], ],
] ]
@ -836,6 +840,8 @@ def test_table_html_disable_foreign_key_links_with_labels(app_client):
'<td class="col-foreign_key_with_label type-str">1</td>', '<td class="col-foreign_key_with_label type-str">1</td>',
'<td class="col-foreign_key_with_blank_label type-str">3</td>', '<td class="col-foreign_key_with_blank_label type-str">3</td>',
'<td class="col-foreign_key_with_no_label type-str">1</td>', '<td class="col-foreign_key_with_no_label type-str">1</td>',
'<td class="col-foreign_key_compound_pk1 type-str">a</td>',
'<td class="col-foreign_key_compound_pk2 type-str">b</td>',
] ]
] ]

View file

@ -267,7 +267,7 @@ async def test_table_column_details(db, table, expected):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_all_foreign_keys(db): async def test_get_all_foreign_keys(db):
all_foreign_keys = await db.get_all_foreign_keys() all_foreign_keys = await db.get_all_foreign_keys()
assert { assert all_foreign_keys["roadside_attraction_characteristics"] == {
"incoming": [], "incoming": [],
"outgoing": [ "outgoing": [
{ {
@ -281,8 +281,8 @@ async def test_get_all_foreign_keys(db):
"other_column": "pk", "other_column": "pk",
}, },
], ],
} == all_foreign_keys["roadside_attraction_characteristics"] }
assert { assert all_foreign_keys["attraction_characteristic"] == {
"incoming": [ "incoming": [
{ {
"other_table": "roadside_attraction_characteristics", "other_table": "roadside_attraction_characteristics",
@ -291,7 +291,32 @@ async def test_get_all_foreign_keys(db):
} }
], ],
"outgoing": [], "outgoing": [],
} == all_foreign_keys["attraction_characteristic"] }
assert all_foreign_keys["compound_primary_key"] == {
# No incoming because these are compound foreign keys, which we currently ignore
"incoming": [],
"outgoing": [],
}
assert all_foreign_keys["foreign_key_references"] == {
"incoming": [],
"outgoing": [
{
"other_table": "primary_key_multiple_columns",
"column": "foreign_key_with_no_label",
"other_column": "id",
},
{
"other_table": "simple_primary_key",
"column": "foreign_key_with_blank_label",
"other_column": "id",
},
{
"other_table": "simple_primary_key",
"column": "foreign_key_with_label",
"other_column": "id",
},
],
}
@pytest.mark.asyncio @pytest.mark.asyncio