diff --git a/datasette/utils.py b/datasette/utils.py index 6253fb7a..92ff2eb4 100644 --- a/datasette/utils.py +++ b/datasette/utils.py @@ -70,8 +70,10 @@ def path_from_row_pks(row, pks, use_rowid, quote=True): if use_rowid: bits = [row['rowid']] else: - bits = [row[pk] for pk in pks] - + bits = [ + row[pk]["value"] if isinstance(row[pk], dict) else row[pk] + for pk in pks + ] if quote: bits = [urllib.parse.quote_plus(str(bit)) for bit in bits] else: @@ -817,8 +819,10 @@ def path_with_format(request, format, extra_qs=None): class CustomRow(OrderedDict): # Loose imitation of sqlite3.Row which offers # both index-based AND key-based lookups - def __init__(self, columns): + def __init__(self, columns, values=None): self.columns = columns + if values: + self.update(values) def __getitem__(self, key): if isinstance(key, int): diff --git a/tests/fixtures.py b/tests/fixtures.py index db57b140..077d4eb8 100644 --- a/tests/fixtures.py +++ b/tests/fixtures.py @@ -303,6 +303,10 @@ INSERT INTO units VALUES (1, 1, 100); INSERT INTO units VALUES (2, 5000, 2500); INSERT INTO units VALUES (3, 100000, 75000); +CREATE TABLE tags ( + tag TEXT PRIMARY KEY +); + CREATE TABLE searchable ( pk integer primary key, text1 text, @@ -310,9 +314,25 @@ CREATE TABLE searchable ( [name with . and spaces] text ); +CREATE TABLE searchable_tags ( + searchable_id integer, + tag text, + PRIMARY KEY (searchable_id, tag), + FOREIGN KEY (searchable_id) REFERENCES searchable(pk), + FOREIGN KEY (tag) REFERENCES tags(tag) +); + INSERT INTO searchable VALUES (1, 'barry cat', 'terry dog', 'panther'); INSERT INTO searchable VALUES (2, 'terry dog', 'sara weasel', 'puma'); +INSERT INTO tags VALUES ("canine"); +INSERT INTO tags VALUES ("feline"); + +INSERT INTO searchable_tags (searchable_id, tag) VALUES + (1, "feline"), + (2, "canine") +; + CREATE VIRTUAL TABLE "searchable_fts" USING FTS3 (text1, text2, [name with . and spaces], content="searchable"); INSERT INTO "searchable_fts" (rowid, text1, text2, [name with . and spaces]) diff --git a/tests/test_api.py b/tests/test_api.py index 2d6170d4..53329efd 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -17,7 +17,7 @@ def test_homepage(app_client): assert response.json.keys() == {'fixtures': 0}.keys() d = response.json['fixtures'] assert d['name'] == 'fixtures' - assert d['tables_count'] == 17 + assert d['tables_count'] == 19 def test_database_page(app_client): @@ -188,11 +188,38 @@ def test_database_page(app_client): 'columns': ['pk', 'text1', 'text2', 'name with . and spaces'], 'name': 'searchable', 'count': 2, - 'foreign_keys': {'incoming': [], 'outgoing': []}, + 'foreign_keys': {'incoming': [{ + "other_table": "searchable_tags", + "column": "pk", + "other_column": "searchable_id" + }], 'outgoing': []}, 'fts_table': 'searchable_fts', 'hidden': False, 'label_column': None, 'primary_keys': ['pk'], + }, { + "name": "searchable_tags", + "columns": ["searchable_id", "tag"], + "primary_keys": ["searchable_id", "tag"], + "count": 2, + "label_column": None, + "hidden": False, + "fts_table": None, + "foreign_keys": { + "incoming": [], + "outgoing": [ + { + "other_table": "tags", + "column": "tag", + "other_column": "tag", + }, + { + "other_table": "searchable", + "column": "searchable_id", + "other_column": "pk", + }, + ], + }, }, { 'columns': ['group', 'having', 'and'], 'name': 'select', @@ -251,6 +278,24 @@ def test_database_page(app_client): 'label_column': None, 'fts_table': None, 'primary_keys': ['pk'], + }, { + "name": "tags", + "columns": ["tag"], + "primary_keys": ["tag"], + "count": 2, + "label_column": None, + "hidden": False, + "fts_table": None, + "foreign_keys": { + "incoming": [ + { + "other_table": "searchable_tags", + "column": "tag", + "other_column": "tag", + } + ], + "outgoing": [], + }, }, { 'columns': ['pk', 'distance', 'frequency'], 'name': 'units', diff --git a/tests/test_html.py b/tests/test_html.py index 3354f878..5eaf2b6e 100644 --- a/tests/test_html.py +++ b/tests/test_html.py @@ -550,6 +550,26 @@ def test_row_html_compound_primary_key(app_client): assert expected == [[str(td) for td in tr.select('td')] for tr in table.select('tbody tr')] +def test_compound_primary_key_with_foreign_key_references(app_client): + # e.g. a many-to-many table with a compound primary key on the two columns + response = app_client.get('/fixtures/searchable_tags') + assert response.status == 200 + table = Soup(response.body, 'html.parser').find('table') + expected = [ + [ + '1,feline', + '1\xa01', + 'feline', + ], + [ + '2,canine', + '2\xa02', + 'canine', + ], + ] + assert expected == [[str(td) for td in tr.select('td')] for tr in table.select('tbody tr')] + + def test_view_html(app_client): response = app_client.get('/fixtures/simple_view') assert response.status == 200 diff --git a/tests/test_utils.py b/tests/test_utils.py index e168c3f2..50799b2e 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -75,11 +75,34 @@ def test_path_with_replaced_args(path, args, expected): assert expected == actual -@pytest.mark.parametrize('row,pks,expected_path', [ - ({'A': 'foo', 'B': 'bar'}, ['A', 'B'], 'foo,bar'), - ({'A': 'f,o', 'B': 'bar'}, ['A', 'B'], 'f%2Co,bar'), - ({'A': 123}, ['A'], '123'), -]) +@pytest.mark.parametrize( + "row,pks,expected_path", + [ + ({"A": "foo", "B": "bar"}, ["A", "B"], "foo,bar"), + ({"A": "f,o", "B": "bar"}, ["A", "B"], "f%2Co,bar"), + ({"A": 123}, ["A"], "123"), + ( + utils.CustomRow( + ["searchable_id", "tag"], + [ + ( + "searchable_id", + {"value": 1, "label": "1"}, + ), + ( + "tag", + { + "value": "feline", + "label": "feline", + }, + ), + ], + ), + ["searchable_id", "tag"], + "1,feline", + ), + ], +) def test_path_from_row_pks(row, pks, expected_path): actual_path = utils.path_from_row_pks(row, pks, False) assert expected_path == actual_path