Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 79 additions & 6 deletions datasette/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@
TableInsertView,
TableUpsertView,
TableSetColumnTypeView,
TableSetLabelColumnsView,
TableDropView,
TableFragmentView,
table_view,
Expand Down Expand Up @@ -119,6 +120,7 @@
module_from_path,
move_plugins_and_allow,
move_table_config,
normalize_label_columns,
parse_metadata,
resolve_env_secrets,
resolve_routes,
Expand Down Expand Up @@ -830,6 +832,8 @@ async def invoke_startup(self):
await self._save_queries_from_config()
# Load column_types from config into internal DB
await self._apply_column_types_config()
# Seed label_column config into internal DB (first run only)
await self._apply_label_columns_config()
for hook in pm.hook.startup(datasette=self):
await await_me_maybe(hook)
self._startup_invoked = True
Expand Down Expand Up @@ -1447,6 +1451,60 @@ async def remove_column_type(
[database, resource, column],
)

async def get_label_columns(self, database: str, resource: str):
"""
Return the explicit label_column override for this resource, as a
list of column names, or None if no override has been set (in which
case automatic detection should be used).
"""
row = await self.get_internal_database().execute(
"SELECT columns FROM label_columns "
"WHERE database_name = ? AND resource_name = ?",
[database, resource],
)
rows = row.rows
if not rows:
return None
return json.loads(rows[0][0])

async def set_label_columns(
self, database: str, resource: str, columns: list
) -> None:
"""Set the label column(s) for a resource. Overwrites any existing value."""
await self.get_internal_database().execute_write(
"""INSERT OR REPLACE INTO label_columns
(database_name, resource_name, columns)
VALUES (?, ?, ?)""",
[database, resource, json.dumps(columns)],
)

async def remove_label_columns(self, database: str, resource: str) -> None:
"""Remove the label column(s) override for a resource."""
await self.get_internal_database().execute_write(
"DELETE FROM label_columns "
"WHERE database_name = ? AND resource_name = ?",
[database, resource],
)

async def _apply_label_columns_config(self):
"""
Seed label_column config from datasette.json into the internal DB,
but only the first time a table is seen - once a row exists (whether
from config or from the set-label-columns API) it is left alone on
subsequent startups.
"""
for db_name, db_conf in (self.config or {}).get("databases", {}).items():
for table_name, table_conf in db_conf.get("tables", {}).items():
if "label_column" not in table_conf:
continue
columns = normalize_label_columns(table_conf["label_column"])
await self.get_internal_database().execute_write(
"""INSERT INTO label_columns (database_name, resource_name, columns)
VALUES (?, ?, ?)
ON CONFLICT(database_name, resource_name) DO NOTHING""",
[db_name, table_name, json.dumps(columns)],
)

def get_internal_database(self):
return self._internal_database

Expand Down Expand Up @@ -2125,17 +2183,17 @@ async def expand_foreign_keys(self, actor, database, table, column, values):
)
if not visible:
return {}
label_column = await db.label_column_for_table(other_table)
if not label_column:
label_columns = await db.label_columns_for_table(other_table)
if not label_columns:
return {(fk["column"], value): str(value) for value in values}
labeled_fks = {}
sql = """
select {other_column}, {label_column}
select {other_column}, {label_columns}
from {other_table}
where {other_column} in ({placeholders})
""".format(
other_column=escape_sqlite(other_column),
label_column=escape_sqlite(label_column),
label_columns=", ".join(escape_sqlite(column) for column in label_columns),
other_table=escape_sqlite(other_table),
placeholders=", ".join(["?"] * len(set(values))),
)
Expand All @@ -2144,8 +2202,19 @@ async def expand_foreign_keys(self, actor, database, table, column, values):
except QueryInterrupted:
pass
else:
for id, value in results:
labeled_fks[(fk["column"], id)] = value
for row in results:
id = row[0]
if len(label_columns) == 1:
labeled_fks[(fk["column"], id)] = row[1]
else:
label_values = [
str(value)
for value in row[1:]
if value is not None and value != ""
]
labeled_fks[(fk["column"], id)] = (
" ".join(label_values) if label_values else str(id)
)
return labeled_fks

def absolute_url(self, request, path):
Expand Down Expand Up @@ -2736,6 +2805,10 @@ def add_route(view, regex):
TableSetColumnTypeView.as_view(self),
r"/(?P<database>[^\/\.]+)/(?P<table>[^\/\.]+)/-/set-column-type$",
)
add_route(
TableSetLabelColumnsView.as_view(self),
r"/(?P<database>[^\/\.]+)/(?P<table>[^\/\.]+)/-/set-label-columns$",
)
add_route(
TableFragmentView.as_view(self),
r"/(?P<database>[^\/\.]+)/(?P<table>[^\/\.]+)/-/fragment$",
Expand Down
18 changes: 8 additions & 10 deletions datasette/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -665,12 +665,10 @@ async def primary_keys(self, table):
async def fts_table(self, table):
return await self.execute_fn(lambda conn: detect_fts(conn, table))

async def label_column_for_table(self, table):
explicit_label_column = (await self.ds.table_config(self.name, table)).get(
"label_column"
)
if explicit_label_column:
return explicit_label_column
async def label_columns_for_table(self, table):
explicit_label_columns = await self.ds.get_label_columns(self.name, table)
if explicit_label_columns is not None:
return explicit_label_columns

def column_details(conn):
# Returns {column_name: (type, is_unique)}
Expand All @@ -695,23 +693,23 @@ def column_details(conn):
if is_unique and type_ is str
]
if len(unique_text_columns) == 1:
return unique_text_columns[0]
return [unique_text_columns[0]]

column_names = list(column_details.keys())
# Is there a name or title column?
name_or_title = [c for c in column_names if c.lower() in ("name", "title")]
if name_or_title:
return name_or_title[0]
return [name_or_title[0]]
# If a table has two columns, one of which is ID, then label_column is the other one
if (
column_names
and len(column_names) == 2
and ("id" in column_names or "pk" in column_names)
and not set(column_names) == {"id", "pk"}
):
return [c for c in column_names if c not in ("id", "pk")][0]
return [c for c in column_names if c not in ("id", "pk")][0:1]
# Couldn't find a label:
return None
return []

async def foreign_keys_for_table(self, table):
return await self.execute_fn(
Expand Down
6 changes: 6 additions & 0 deletions datasette/default_actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,12 @@ def register_actions():
description="Set column type",
resource_class=TableResource,
),
Action(
name="set-label-columns",
abbr="slc",
description="Set label column(s)",
resource_class=TableResource,
),
Action(
name="drop-table",
abbr="dt",
Expand Down
47 changes: 32 additions & 15 deletions datasette/default_table_actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,24 +6,41 @@
def table_actions(datasette, actor, database, table, request):
async def inner():
db = datasette.get_database(database)
if not db.is_mutable:
return []
if not await datasette.allowed(
actions = []
if db.is_mutable and await datasette.allowed(
action="alter-table",
resource=TableResource(database=database, table=table),
actor=actor,
):
return []
return [
{
"type": "button",
"label": "Alter table",
"description": "Change columns and primary key for this table.",
"attrs": {
"aria-label": "Alter table {}".format(table),
"data-table-action": "alter-table",
},
}
]
actions.append(
{
"type": "button",
"label": "Alter table",
"description": "Change columns and primary key for this table.",
"attrs": {
"aria-label": "Alter table {}".format(table),
"data-table-action": "alter-table",
},
}
)
# Not gated on db.is_mutable - label configuration is a display
# preference stored in the internal DB, not a schema change.
if await datasette.allowed(
action="set-label-columns",
resource=TableResource(database=database, table=table),
actor=actor,
):
actions.append(
{
"type": "button",
"label": "Set label column(s)",
"description": "Choose which column(s) are used to label this table's rows.",
"attrs": {
"aria-label": "Set label columns for {}".format(table),
"data-table-action": "set-label-columns",
},
}
)
return actions

return inner
Loading
Loading