From 1b04662585ea1539014bfbd616a8112b650d5699 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Fri, 17 Nov 2017 19:09:32 -0800 Subject: [PATCH] Table views now show expanded foreign key references, if possible If a table has foreign key columns, and those foreign key tables have label_columns, the TableView will now query those other tables for the corresponding values and display those values as links in the corresponding table cells. label_columns are currently detected by the inspect() function, which looks for any table that has just two columns - an ID column and one other - and sets the label_column to be that second non-ID column. --- datasette/app.py | 173 +++++++++++++++++++++++++++------------ datasette/static/app.css | 7 ++ 2 files changed, 126 insertions(+), 54 deletions(-) diff --git a/datasette/app.py b/datasette/app.py index c6ead0f9..120348a0 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -221,16 +221,23 @@ class BaseView(HTTPMethodView): headers=headers, ) else: - context = {**data, **dict( - extra_template_data() - if callable(extra_template_data) - else extra_template_data - ), **{ - 'url_json': path_with_ext(request, '.json'), - 'url_jsono': path_with_ext(request, '.jsono'), - 'metadata': self.ds.metadata, - 'datasette_version': __version__, - }} + extras = {} + if callable(extra_template_data): + extras = extra_template_data() + if asyncio.iscoroutine(extras): + extras = await extras + else: + extras = extra_template_data + context = { + **data, + **extras, + **{ + 'url_json': path_with_ext(request, '.json'), + 'url_jsono': path_with_ext(request, '.jsono'), + 'metadata': self.ds.metadata, + 'datasette_version': __version__, + } + } r = self.jinja.render( template, request, @@ -481,6 +488,8 @@ class TableView(BaseView): table_rows = None if not is_view: table_rows = info[name]['tables'][table]['count'] + + # Pagination next link next_value = None next_url = None if len(rows) > self.page_size: @@ -492,6 +501,14 @@ class TableView(BaseView): '_next': next_value, })) + async def extra_template(): + return { + 'database_hash': hash, + 'use_rowid': use_rowid, + 'display_columns': display_columns, + 'display_rows': await self.make_display_rows(name, hash, table, rows, display_columns, pks, is_view, use_rowid), + } + return { 'database': name, 'table': table, @@ -509,48 +526,81 @@ class TableView(BaseView): }, 'next': next_value and str(next_value) or None, 'next_url': next_url, - }, lambda: { - 'database_hash': hash, - 'use_rowid': use_rowid, - 'display_columns': display_columns, - 'display_rows': make_display_rows(name, hash, table, rows, display_columns, pks, is_view, use_rowid), - } + }, extra_template - -def make_display_rows(database, database_hash, table, rows, display_columns, pks, is_view, use_rowid): - for row in rows: - cells = [] - # Unless we are a view, the first column is a link - either to the rowid - # or to the simple or compound primary key - if not is_view: - display_value = jinja2.Markup( - '{flat_pks}'.format( - database=database, - database_hash=database_hash, - table=urllib.parse.quote_plus(table), - flat_pks=path_from_row_pks(row, pks, use_rowid), + async def make_display_rows(self, database, database_hash, table, rows, display_columns, pks, is_view, use_rowid): + # Get fancy with foreign keys + expanded = {} + tables = self.ds.inspect()[database]['tables'] + table_info = tables.get(table) or {} + if table_info: + foreign_keys = table_info['foreign_keys']['outgoing'] + for fk in foreign_keys: + label_column = tables.get(fk['other_table'], {}).get('label_column') + if not label_column: + # We only link cells to other tables with label columns defined + continue + ids_to_lookup = set([row[fk['column']] for row in rows]) + sql = 'select "{other_column}", "{label_column}" from {other_table} where "{other_column}" in ({placeholders})'.format( + other_column=fk['other_column'], + label_column=label_column, + other_table=escape_sqlite_table_name(fk['other_table']), + placeholders=', '.join(['?'] * len(ids_to_lookup)), ) - ) - cells.append({ - 'column': 'rowid' if use_rowid else 'Link', - 'value': display_value, - }) + try: + results = await self.execute(database, sql, list(set(ids_to_lookup))) + except sqlite3.OperationalError: + # Probably hit the timelimit + pass + else: + for id, value in results: + expanded[(fk['column'], id)] = (fk['other_table'], value) - for value, column in zip(row, display_columns): - if use_rowid and column == 'rowid': - # We already showed this in the linked first column - continue - if False: # TODO: This is where we will do foreign key linking - display_value = jinja2.Markup('{}'.format('foreign key')) - elif value is None: - display_value = jinja2.Markup(' ') - else: - display_value = str(value) - cells.append({ - 'column': column, - 'value': display_value, - }) - yield cells + to_return = [] + for row in rows: + cells = [] + # Unless we are a view, the first column is a link - either to the rowid + # or to the simple or compound primary key + if not is_view: + display_value = jinja2.Markup( + '{flat_pks}'.format( + database=database, + database_hash=database_hash, + table=urllib.parse.quote_plus(table), + flat_pks=path_from_row_pks(row, pks, use_rowid), + ) + ) + cells.append({ + 'column': 'rowid' if use_rowid else 'Link', + 'value': display_value, + }) + + for value, column in zip(row, display_columns): + if use_rowid and column == 'rowid': + # We already showed this in the linked first column + continue + elif (column, value) in expanded: + other_table, label = expanded[(column, value)] + display_value = jinja2.Markup( + # TODO: Escape id/label/etc so no XSS here + '{label}'.format( + database=database, + database_hash=database_hash, + table=escape_sqlite_table_name(other_table), + id=value, + label=label, + ) + ) + elif value is None: + display_value = jinja2.Markup(' ') + else: + display_value = str(value) + cells.append({ + 'column': column, + 'value': display_value, + }) + to_return.append(cells) + return to_return class RowView(BaseView): @@ -581,6 +631,13 @@ class RowView(BaseView): rows = list(rows) if not rows: raise NotFound('Record not found: {}'.format(pk_values)) + + async def template_data(): + return { + 'database_hash': hash, + 'foreign_key_tables': await self.foreign_key_tables(name, table, pk_values), + } + return { 'database': name, 'table': table, @@ -588,10 +645,7 @@ class RowView(BaseView): 'columns': columns, 'primary_keys': pks, 'primary_key_values': pk_values, - }, { - 'database_hash': hash, - 'foreign_key_tables': await self.foreign_key_tables(name, table, pk_values), - } + }, template_data async def foreign_key_tables(self, name, table, pk_values): if len(pk_values) != 1: @@ -666,8 +720,19 @@ class Datasette: for r in conn.execute('select * from sqlite_master where type="table"') ] for table in table_names: + count = conn.execute( + 'select count(*) from {}'.format(escape_sqlite_table_name(table)) + ).fetchone()[0] + label_column = None + # If table has two columns, one of which is ID, then label_column is the other one + column_names = [r[1] for r in conn.execute( + 'PRAGMA table_info({});'.format(escape_sqlite_table_name(table)) + ).fetchall()] + if column_names and len(column_names) == 2 and 'id' in column_names: + label_column = [c for c in column_names if c != 'id'][0] tables[table] = { - 'count': conn.execute('select count(*) from "{}"'.format(table)).fetchone()[0], + 'count': count, + 'label_column': label_column, } foreign_keys = get_all_foreign_keys(conn) diff --git a/datasette/static/app.css b/datasette/static/app.css index 9a3f153d..8df4fce4 100644 --- a/datasette/static/app.css +++ b/datasette/static/app.css @@ -21,6 +21,13 @@ td { th { padding-right: 1em; } +table a:link { + text-decoration: none; + color: #445ac8; +} +table a:visited { + color: #8f54c4; +} @media only screen and (max-width: 576px) { /* Force table to not be like tables anymore */ table, thead, tbody, th, td, tr {