Display column type in column action menu, closes #993

Also added new documented db.table_column_details() introspection method.
This commit is contained in:
Simon Willison 2020-10-05 17:32:10 -07:00
commit 5a184a5d21
8 changed files with 103 additions and 28 deletions

View file

@ -16,6 +16,7 @@ from .utils import (
sqlite_timelimit, sqlite_timelimit,
sqlite3, sqlite3,
table_columns, table_columns,
table_column_details,
) )
from .inspect import inspect_hash from .inspect import inspect_hash
@ -231,6 +232,9 @@ class Database:
async def table_columns(self, table): async def table_columns(self, table):
return await self.execute_fn(lambda conn: table_columns(conn, table)) return await self.execute_fn(lambda conn: table_columns(conn, table))
async def table_column_details(self, table):
return await self.execute_fn(lambda conn: table_column_details(conn, table))
async def primary_keys(self, table): async def primary_keys(self, table):
return await self.execute_fn(lambda conn: detect_primary_keys(conn, table)) return await self.execute_fn(lambda conn: detect_primary_keys(conn, table))

View file

@ -396,7 +396,6 @@ svg.dropdown-menu-icon {
opacity: 0.8; opacity: 0.8;
} }
.dropdown-menu { .dropdown-menu {
display: inline-flex;
border: 1px solid #ccc; border: 1px solid #ccc;
border-radius: 4px; border-radius: 4px;
line-height: 1.4; line-height: 1.4;
@ -410,6 +409,13 @@ svg.dropdown-menu-icon {
margin: 0; margin: 0;
padding: 0; padding: 0;
} }
.dropdown-menu .dropdown-column-type {
font-size: 0.7em;
color: #666;
margin: 0;
padding: 0;
padding: 4px 8px 4px 8px;
}
.dropdown-menu li { .dropdown-menu li {
border-bottom: 1px solid #ccc; border-bottom: 1px solid #ccc;
} }

View file

@ -6,6 +6,7 @@ var DROPDOWN_HTML = `<div class="dropdown-menu">
<li><a class="dropdown-facet" href="#">Facet by this</a></li> <li><a class="dropdown-facet" href="#">Facet by this</a></li>
<li><a class="dropdown-not-blank" href="#">Show not-blank rows</a></li> <li><a class="dropdown-not-blank" href="#">Show not-blank rows</a></li>
</ul> </ul>
<p class="dropdown-column-type"></p>
</div>`; </div>`;
var DROPDOWN_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> var DROPDOWN_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
@ -115,10 +116,20 @@ var DROPDOWN_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="14" heig
} else { } else {
notBlank.style.display = 'none'; notBlank.style.display = 'none';
} }
var columnTypeP = menu.querySelector('.dropdown-column-type');
var columnType = th.dataset.columnType;
var notNull = th.dataset.columnNotNull == 1 ? ' NOT NULL' : '';
if (columnType) {
columnTypeP.style.display = 'block';
columnTypeP.innerText = `Type: ${columnType.toUpperCase()}${notNull}`;
} else {
columnTypeP.style.display = 'none';
}
menu.style.position = 'absolute'; menu.style.position = 'absolute';
menu.style.top = (menuTop + 6) + 'px'; menu.style.top = (menuTop + 6) + 'px';
menu.style.left = menuLeft + 'px'; menu.style.left = menuLeft + 'px';
menu.style.display = 'inline-flex'; menu.style.display = 'block';
} }
var svg = document.createElement('div'); var svg = document.createElement('div');
svg.innerHTML = DROPDOWN_ICON_SVG; svg.innerHTML = DROPDOWN_ICON_SVG;

View file

@ -3,7 +3,7 @@
<thead> <thead>
<tr> <tr>
{% for column in display_columns %} {% for column in display_columns %}
<th class="col-{{ column.name|to_css_class }}" scope="col" data-column="{{ column.name }}" data-is-pk="{% if column.is_pk %}1{% else %}0{% endif %}"> <th class="col-{{ column.name|to_css_class }}" scope="col" data-column="{{ column.name }}" data-column-type="{{ column.type }}" data-column-not-null="{{ column.notnull }}" data-is-pk="{% if column.is_pk %}1{% else %}0{% endif %}">
{% if not column.sortable %} {% if not column.sortable %}
{{ column.name }} {{ column.name }}
{% else %} {% else %}

View file

@ -1,6 +1,6 @@
import asyncio import asyncio
from contextlib import contextmanager from contextlib import contextmanager
from collections import OrderedDict from collections import OrderedDict, namedtuple
import base64 import base64
import click import click
import hashlib import hashlib
@ -54,6 +54,11 @@ RUN apt-get update && \
ENV SQLITE_EXTENSIONS /usr/lib/x86_64-linux-gnu/mod_spatialite.so ENV SQLITE_EXTENSIONS /usr/lib/x86_64-linux-gnu/mod_spatialite.so
""" """
# Can replace this with Column from sqlite_utils when I add that dependency
Column = namedtuple(
"Column", ("cid", "name", "type", "notnull", "default_value", "is_pk")
)
async def await_me_maybe(value): async def await_me_maybe(value):
if callable(value): if callable(value):
@ -525,8 +530,12 @@ def detect_json1(conn=None):
def table_columns(conn, table): def table_columns(conn, table):
return [column.name for column in table_column_details(conn, table)]
def table_column_details(conn, table):
return [ return [
r[1] Column(*r)
for r in conn.execute( for r in conn.execute(
"PRAGMA table_info({});".format(escape_sqlite(table)) "PRAGMA table_info({});".format(escape_sqlite(table))
).fetchall() ).fetchall()

View file

@ -90,19 +90,31 @@ class RowTableShared(DataView):
"Returns columns, rows for specified table - including fancy foreign key treatment" "Returns columns, rows for specified table - including fancy foreign key treatment"
db = self.ds.databases[database] db = self.ds.databases[database]
table_metadata = self.ds.table_metadata(database, table) table_metadata = self.ds.table_metadata(database, table)
column_details = {col.name: col for col in await db.table_column_details(table)}
sortable_columns = await self.sortable_columns_for_table(database, table, True) sortable_columns = await self.sortable_columns_for_table(database, table, True)
pks = await db.primary_keys(table) pks = await db.primary_keys(table)
pks_for_display = pks pks_for_display = pks
if not pks_for_display: if not pks_for_display:
pks_for_display = ["rowid"] pks_for_display = ["rowid"]
columns = [
{ columns = []
"name": r[0], for r in description:
"sortable": r[0] in sortable_columns, if r[0] == "rowid" and "rowid" not in column_details:
"is_pk": r[0] in pks_for_display, type_ = "integer"
} notnull = 0
for r in description else:
] type_ = column_details[r[0]].type
notnull = column_details[r[0]].notnull
columns.append(
{
"name": r[0],
"sortable": r[0] in sortable_columns,
"is_pk": r[0] in pks_for_display,
"type": type_,
"notnull": notnull,
}
)
column_to_foreign_key_table = { column_to_foreign_key_table = {
fk["column"]: fk["other_table"] fk["column"]: fk["other_table"]
for fk in await db.foreign_keys_for_table(table) for fk in await db.foreign_keys_for_table(table)
@ -217,12 +229,25 @@ class RowTableShared(DataView):
# Add the link column header. # Add the link column header.
# If it's a simple primary key, we have to remove and re-add that column name at # If it's a simple primary key, we have to remove and re-add that column name at
# the beginning of the header row. # the beginning of the header row.
first_column = None
if len(pks) == 1: if len(pks) == 1:
columns = [col for col in columns if col["name"] != pks[0]] columns = [col for col in columns if col["name"] != pks[0]]
first_column = {
columns = [ "name": pks[0],
{"name": pks[0] if len(pks) == 1 else "Link", "sortable": len(pks) == 1} "sortable": len(pks) == 1,
] + columns "is_pk": True,
"type": column_details[pks[0]].type,
"notnull": column_details[pks[0]].notnull,
}
else:
first_column = {
"name": "Link",
"sortable": False,
"is_pk": False,
"type": "",
"notnull": 0,
}
columns = [first_column] + columns
return columns, cell_rows return columns, cell_rows
@ -291,7 +316,8 @@ class TableView(RowTableShared):
) )
pks = await db.primary_keys(table) pks = await db.primary_keys(table)
table_columns = await db.table_columns(table) table_column_details = await db.table_column_details(table)
table_columns = [column.name for column in table_column_details]
select_columns = ", ".join(escape_sqlite(t) for t in table_columns) select_columns = ", ".join(escape_sqlite(t) for t in table_columns)

View file

@ -500,6 +500,9 @@ The ``Database`` class also provides properties and methods for introspecting th
``await db.table_columns(table)`` - list of strings ``await db.table_columns(table)`` - list of strings
Names of columns in a specific table. Names of columns in a specific table.
``await db.table_column_details(table)`` - list of named tuples
Full details of the columns in a specific table. Each column is represented by a ``Column`` named tuple with fields ``cid`` (integer representing the column position), ``name`` (string), ``type`` (string, e.g. ``REAL`` or ``VARCHAR(30)``), ``notnull`` (integer 1 or 0), ``default_value`` (string or None), ``is_pk`` (integer 1 or 0).
``await db.primary_keys(table)`` - list of strings ``await db.primary_keys(table)`` - list of strings
Names of the columns that are part of the primary key for this table. Names of the columns that are part of the primary key for this table.

View file

@ -342,80 +342,96 @@ def test_sort_links(app_client):
} }
for th in ths for th in ths
] ]
assert [ assert attrs_and_link_attrs == [
{ {
"attrs": { "attrs": {
"class": ["col-Link"], "class": ["col-Link"],
"data-column": "Link",
"data-is-pk": "0",
"scope": "col", "scope": "col",
"data-column": "Link",
"data-column-type": "",
"data-column-not-null": "0",
"data-is-pk": "0",
}, },
"a_href": None, "a_href": None,
}, },
{ {
"attrs": { "attrs": {
"class": ["col-pk1"], "class": ["col-pk1"],
"data-column": "pk1",
"data-is-pk": "1",
"scope": "col", "scope": "col",
"data-column": "pk1",
"data-column-type": "varchar(30)",
"data-column-not-null": "0",
"data-is-pk": "1",
}, },
"a_href": None, "a_href": None,
}, },
{ {
"attrs": { "attrs": {
"class": ["col-pk2"], "class": ["col-pk2"],
"data-column": "pk2",
"data-is-pk": "1",
"scope": "col", "scope": "col",
"data-column": "pk2",
"data-column-type": "varchar(30)",
"data-column-not-null": "0",
"data-is-pk": "1",
}, },
"a_href": None, "a_href": None,
}, },
{ {
"attrs": { "attrs": {
"class": ["col-content"], "class": ["col-content"],
"data-column": "content",
"data-is-pk": "0",
"scope": "col", "scope": "col",
"data-column": "content",
"data-column-type": "text",
"data-column-not-null": "0",
"data-is-pk": "0",
}, },
"a_href": None, "a_href": None,
}, },
{ {
"attrs": { "attrs": {
"class": ["col-sortable"], "class": ["col-sortable"],
"data-column": "sortable",
"data-is-pk": "0",
"scope": "col", "scope": "col",
"data-column": "sortable",
"data-column-type": "integer",
"data-column-not-null": "0",
"data-is-pk": "0",
}, },
"a_href": "sortable?_sort_desc=sortable", "a_href": "sortable?_sort_desc=sortable",
}, },
{ {
"attrs": { "attrs": {
"class": ["col-sortable_with_nulls"], "class": ["col-sortable_with_nulls"],
"data-column": "sortable_with_nulls",
"data-is-pk": "0",
"scope": "col", "scope": "col",
"data-column": "sortable_with_nulls",
"data-column-type": "real",
"data-column-not-null": "0",
"data-is-pk": "0",
}, },
"a_href": "sortable?_sort=sortable_with_nulls", "a_href": "sortable?_sort=sortable_with_nulls",
}, },
{ {
"attrs": { "attrs": {
"class": ["col-sortable_with_nulls_2"], "class": ["col-sortable_with_nulls_2"],
"data-column": "sortable_with_nulls_2",
"data-is-pk": "0",
"scope": "col", "scope": "col",
"data-column": "sortable_with_nulls_2",
"data-column-type": "real",
"data-column-not-null": "0",
"data-is-pk": "0",
}, },
"a_href": "sortable?_sort=sortable_with_nulls_2", "a_href": "sortable?_sort=sortable_with_nulls_2",
}, },
{ {
"attrs": { "attrs": {
"class": ["col-text"], "class": ["col-text"],
"data-column": "text",
"data-is-pk": "0",
"scope": "col", "scope": "col",
"data-column": "text",
"data-column-type": "text",
"data-column-not-null": "0",
"data-is-pk": "0",
}, },
"a_href": "sortable?_sort=text", "a_href": "sortable?_sort=text",
}, },
] == attrs_and_link_attrs ]
def test_facet_display(app_client): def test_facet_display(app_client):