Compare commits

...

1 commit

Author SHA1 Message Date
Claude
8592ce2ed4
Support multi-column label_column config, plus a runtime API and UI for setting it
- label_column config can now be a string or an ordered list of columns,
  joined with a space to build a row's display label. Database.label_column_for_table()
  is renamed to label_columns_for_table() and always returns a list.
- Adds a label_columns internal DB table, get/set/remove_label_columns()
  methods, and a set-label-columns permission so the label can be overridden
  at runtime independently of config (config only seeds the value the first
  time a table is seen).
- Adds a POST /<database>/<table>/-/set-label-columns JSON endpoint, modeled
  on the existing set-column-type endpoint.
- Adds a "Set label column(s)" table action and modal for calling that
  endpoint from the table page.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019oYhPa7o24kd8Cwx3i4GiD
2026-07-01 01:05:28 +00:00
20 changed files with 1437 additions and 113 deletions

View file

@ -92,6 +92,7 @@ from .views.table import (
TableInsertView,
TableUpsertView,
TableSetColumnTypeView,
TableSetLabelColumnsView,
TableDropView,
TableFragmentView,
table_view,
@ -119,6 +120,7 @@ from .utils import (
module_from_path,
move_plugins_and_allow,
move_table_config,
normalize_label_columns,
parse_metadata,
resolve_env_secrets,
resolve_routes,
@ -830,6 +832,8 @@ class Datasette:
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
@ -1447,6 +1451,60 @@ class Datasette:
[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
@ -2125,17 +2183,17 @@ class Datasette:
)
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))),
)
@ -2144,8 +2202,19 @@ class Datasette:
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):
@ -2736,6 +2805,10 @@ class Datasette:
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$",

View file

@ -665,12 +665,10 @@ class Database:
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)}
@ -695,13 +693,13 @@ class Database:
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
@ -709,9 +707,9 @@ class Database:
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(

View file

@ -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",

View file

@ -6,24 +6,41 @@ from datasette.resources import TableResource
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

View file

@ -1518,6 +1518,183 @@ dialog.row-delete-dialog::backdrop {
cursor: wait;
}
dialog.table-label-columns-dialog {
--ink: #0f0f0f;
--paper: #eef6ff;
--muted: #6b6b6b;
--rule: #d8e6f5;
--accent: #1a56db;
--card: #ffffff;
border: none;
border-radius: var(--modal-border-radius, 0.75rem);
padding: 0;
margin: auto;
width: min(440px, calc(100vw - 32px));
max-width: 95vw;
max-height: min(560px, calc(100vh - 32px));
box-shadow: var(--modal-shadow, 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04));
animation: datasette-modal-slide-in var(--modal-animation-duration, 0.2s) ease-out;
overflow: hidden;
font-family: system-ui, -apple-system, sans-serif;
background: var(--card);
}
dialog.table-label-columns-dialog[open] {
display: flex;
flex-direction: column;
}
dialog.table-label-columns-dialog::backdrop {
background: var(--modal-backdrop-bg, rgba(0, 0, 0, 0.5));
backdrop-filter: var(--modal-backdrop-blur, blur(4px));
-webkit-backdrop-filter: var(--modal-backdrop-blur, blur(4px));
animation: datasette-modal-fade-in var(--modal-animation-duration, 0.2s) ease-out;
}
.table-label-columns-dialog .table-label-columns-form {
display: flex;
flex-direction: column;
min-height: 0;
}
.table-label-columns-dialog .modal-header {
padding: 20px 24px 12px;
border-bottom: 1px solid var(--rule);
display: flex;
align-items: center;
justify-content: flex-start;
gap: 12px;
flex-shrink: 0;
min-width: 0;
}
.table-label-columns-dialog .modal-title {
font-size: 1rem;
font-weight: 600;
color: var(--ink);
}
.table-label-columns-help,
.table-label-columns-error {
margin: 0;
padding: 16px 24px 0;
}
.table-label-columns-help {
color: var(--muted);
font-size: 0.85rem;
}
.table-label-columns-error {
color: #b91c1c;
font-size: 0.9rem;
}
.table-label-columns-list {
list-style: none;
margin: 12px 0 0;
padding: 0 24px;
overflow-y: auto;
}
.table-label-columns-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 6px 0;
border-bottom: 1px solid var(--rule);
}
.table-label-columns-label {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
color: var(--ink);
font-size: 0.9rem;
}
.table-label-columns-name {
overflow-wrap: anywhere;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
.table-label-columns-move-controls {
display: flex;
gap: 4px;
flex-shrink: 0;
}
.table-label-columns-move-controls button {
border: 1px solid var(--rule);
background: var(--paper);
border-radius: 4px;
width: 26px;
height: 26px;
cursor: pointer;
color: var(--ink);
}
.table-label-columns-move-controls button:disabled {
opacity: 0.4;
cursor: default;
}
.table-label-columns-dialog .modal-footer {
padding: 18px 20px 14px;
border-top: 1px solid var(--rule);
display: flex;
align-items: center;
justify-content: flex-end;
gap: 10px;
flex-shrink: 0;
background: var(--paper);
margin-top: 18px;
}
.table-label-columns-dialog .btn {
border: none;
border-radius: 5px;
padding: 9px 20px;
font-size: 0.85rem;
font-weight: 500;
cursor: pointer;
touch-action: manipulation;
font-family: inherit;
transition: background 0.12s;
}
.table-label-columns-dialog .btn-ghost {
background: transparent;
color: var(--muted);
border: 1px solid var(--rule);
margin-right: auto;
}
.table-label-columns-dialog .btn-ghost:hover {
background: var(--rule);
color: var(--ink);
}
.table-label-columns-dialog .btn-ghost.table-label-columns-cancel {
margin-right: 0;
}
.table-label-columns-dialog .btn-primary {
background: var(--accent);
color: #fff;
}
.table-label-columns-dialog .btn-primary:hover {
background: #1949b8;
}
.table-label-columns-dialog .btn:disabled {
opacity: 0.65;
cursor: wait;
}
dialog.row-edit-dialog {
--ink: #0f0f0f;
--paper: #eef6ff;

View file

@ -6,6 +6,8 @@ var TABLE_CREATE_DIALOG_ID = "table-create-dialog";
var tableCreateDialogState = null;
var TABLE_ALTER_DIALOG_ID = "table-alter-dialog";
var tableAlterDialogState = null;
var TABLE_LABEL_COLUMNS_DIALOG_ID = "table-label-columns-dialog";
var tableLabelColumnsDialogState = null;
function ensureRowMutationStatus(manager) {
var status = document.querySelector(".row-mutation-status");
@ -1797,6 +1799,10 @@ function tableAlterData() {
return tablePageData().alterTable;
}
function tableLabelColumnsData() {
return tablePageData().labelColumns;
}
function tableAlterColumnTypes() {
var data = tableAlterData() || {};
return data.columnTypes && data.columnTypes.length
@ -5281,6 +5287,333 @@ function initRowInsertActions(manager) {
});
}
function clearTableLabelColumnsDialogError(state) {
state.error.hidden = true;
state.error.textContent = "";
state.dialog.removeAttribute("aria-describedby");
}
function showTableLabelColumnsDialogError(state, message) {
state.error.hidden = false;
state.error.textContent = message;
state.dialog.setAttribute("aria-describedby", "table-label-columns-error");
state.error.focus();
}
function setTableLabelColumnsDialogBusy(state, isBusy) {
state.isBusy = isBusy;
state.cancelButton.disabled = isBusy;
state.saveButton.disabled = isBusy;
state.resetButton.disabled = isBusy || !state.isOverridden;
state.saveButton.textContent = isBusy ? "Saving..." : "Save";
state.list
.querySelectorAll("input, button")
.forEach(function (control) {
control.disabled = isBusy;
});
if (!isBusy) {
updateTableLabelColumnsMoveButtons(state);
state.resetButton.disabled = !state.isOverridden;
}
}
function updateTableLabelColumnsMoveButtons(state) {
var rows = Array.prototype.slice.call(
state.list.querySelectorAll(".table-label-columns-row"),
);
rows.forEach(function (row, index) {
row.querySelector(".table-label-columns-move-up").disabled =
state.isBusy || index === 0;
row.querySelector(".table-label-columns-move-down").disabled =
state.isBusy || index === rows.length - 1;
});
}
function moveTableLabelColumnsRow(state, row, direction) {
if (direction === "up") {
var previous = row.previousElementSibling;
if (previous) {
state.list.insertBefore(row, previous);
}
} else {
var next = row.nextElementSibling;
if (next) {
state.list.insertBefore(next, row);
}
}
updateTableLabelColumnsMoveButtons(state);
}
function addTableLabelColumnsRow(state, column, checked) {
var row = document.createElement("li");
row.className = "table-label-columns-row";
row.innerHTML =
'<label class="table-label-columns-label">' +
'<input type="checkbox" class="table-label-columns-checkbox">' +
'<span class="table-label-columns-name"></span>' +
"</label>" +
'<div class="table-label-columns-move-controls">' +
'<button type="button" class="table-label-columns-move-up" aria-label="Move column up">↑</button>' +
'<button type="button" class="table-label-columns-move-down" aria-label="Move column down">↓</button>' +
"</div>";
row.querySelector(".table-label-columns-checkbox").checked = !!checked;
row.querySelector(".table-label-columns-checkbox").value = column;
row.querySelector(".table-label-columns-name").textContent = column;
row
.querySelector(".table-label-columns-move-up")
.addEventListener("click", function () {
moveTableLabelColumnsRow(state, row, "up");
});
row
.querySelector(".table-label-columns-move-down")
.addEventListener("click", function () {
moveTableLabelColumnsRow(state, row, "down");
});
state.list.appendChild(row);
return row;
}
function renderTableLabelColumnsFields(state, data) {
state.list.innerHTML = "";
var current = data.current || [];
var remaining = (data.columns || []).filter(function (column) {
return current.indexOf(column) === -1;
});
current.forEach(function (column) {
addTableLabelColumnsRow(state, column, true);
});
remaining.forEach(function (column) {
addTableLabelColumnsRow(state, column, false);
});
updateTableLabelColumnsMoveButtons(state);
}
function collectTableLabelColumnsPayload(state) {
var rows = Array.prototype.slice.call(
state.list.querySelectorAll(".table-label-columns-row"),
);
var columns = [];
rows.forEach(function (row) {
var checkbox = row.querySelector(".table-label-columns-checkbox");
if (checkbox.checked) {
columns.push(checkbox.value);
}
});
return columns;
}
async function submitTableLabelColumns(state, columns) {
if (state.isBusy) {
return;
}
var data = tableLabelColumnsData();
if (!data || !data.path) {
showTableLabelColumnsDialogError(
state,
"Could not find the set label columns URL.",
);
return;
}
clearTableLabelColumnsDialogError(state);
setTableLabelColumnsDialogBusy(state, true);
try {
var response = await fetch(data.path, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify({ columns: columns }),
});
var responseData = null;
try {
responseData = await response.json();
} catch (_error) {
responseData = null;
}
if (!response.ok || (responseData && responseData.ok === false)) {
throw rowMutationRequestError(response, responseData);
}
state.shouldRestoreFocus = false;
state.dialog.close();
location.reload();
} catch (error) {
setTableLabelColumnsDialogBusy(state, false);
showTableLabelColumnsDialogError(
state,
error.message || "Could not save label columns",
);
}
}
function ensureTableLabelColumnsDialog(manager) {
if (tableLabelColumnsDialogState) {
return tableLabelColumnsDialogState;
}
if (!window.HTMLDialogElement) {
return null;
}
var dialog = document.createElement("dialog");
dialog.id = TABLE_LABEL_COLUMNS_DIALOG_ID;
dialog.className = "table-label-columns-dialog";
dialog.setAttribute("aria-labelledby", "table-label-columns-title");
dialog.innerHTML = `
<div class="modal-header">
<span class="modal-title" id="table-label-columns-title">Set label column(s)</span>
</div>
<form class="table-label-columns-form" novalidate>
<p class="table-label-columns-error" id="table-label-columns-error" role="alert" tabindex="-1" hidden></p>
<p class="table-label-columns-help">Choose one or more columns to use as the display label for this table's rows. Checked columns are used in the order shown below - use the arrows to reorder them.</p>
<ul class="table-label-columns-list"></ul>
<div class="modal-footer">
<button type="button" class="btn btn-ghost table-label-columns-reset">Reset to automatic</button>
<button type="button" class="btn btn-ghost table-label-columns-cancel">Cancel</button>
<button type="submit" class="btn btn-primary table-label-columns-save">Save</button>
</div>
</form>
`;
document.body.appendChild(dialog);
tableLabelColumnsDialogState = {
dialog: dialog,
form: dialog.querySelector(".table-label-columns-form"),
title: dialog.querySelector(".modal-title"),
error: dialog.querySelector(".table-label-columns-error"),
list: dialog.querySelector(".table-label-columns-list"),
resetButton: dialog.querySelector(".table-label-columns-reset"),
cancelButton: dialog.querySelector(".table-label-columns-cancel"),
saveButton: dialog.querySelector(".table-label-columns-save"),
currentButton: null,
isBusy: false,
isOverridden: false,
shouldRestoreFocus: true,
manager: manager,
};
tableLabelColumnsDialogState.form.addEventListener("submit", function (ev) {
ev.preventDefault();
var state = tableLabelColumnsDialogState;
submitTableLabelColumns(state, collectTableLabelColumnsPayload(state));
});
tableLabelColumnsDialogState.resetButton.addEventListener(
"click",
function () {
submitTableLabelColumns(tableLabelColumnsDialogState, null);
},
);
tableLabelColumnsDialogState.cancelButton.addEventListener(
"click",
function () {
var state = tableLabelColumnsDialogState;
if (!state.isBusy) {
state.shouldRestoreFocus = true;
dialog.close();
}
},
);
dialog.addEventListener("click", function (ev) {
var state = tableLabelColumnsDialogState;
if (ev.target === dialog && !state.isBusy) {
state.shouldRestoreFocus = true;
dialog.close();
}
});
dialog.addEventListener("keydown", function (ev) {
if (ev.key !== "Escape") {
return;
}
var state = tableLabelColumnsDialogState;
if (state.isBusy) {
ev.preventDefault();
return;
}
ev.preventDefault();
state.shouldRestoreFocus = true;
dialog.close();
});
dialog.addEventListener("cancel", function (ev) {
var state = tableLabelColumnsDialogState;
if (state.isBusy) {
ev.preventDefault();
} else {
state.shouldRestoreFocus = true;
}
});
dialog.addEventListener("close", function () {
var state = tableLabelColumnsDialogState;
clearTableLabelColumnsDialogError(state);
setTableLabelColumnsDialogBusy(state, false);
if (
state.shouldRestoreFocus &&
state.currentButton &&
document.contains(state.currentButton)
) {
state.currentButton.focus();
}
});
return tableLabelColumnsDialogState;
}
function openTableLabelColumnsDialog(button, manager) {
var data = tableLabelColumnsData();
if (!data) {
return;
}
var state = ensureTableLabelColumnsDialog(manager);
if (!state) {
return;
}
var menu = button.closest("details");
if (menu) {
menu.open = false;
}
state.manager = manager;
state.currentButton = button;
state.isOverridden = !!data.isOverridden;
state.shouldRestoreFocus = true;
state.title.textContent = data.tableName
? "Set label column(s) for " + data.tableName
: "Set label column(s)";
clearTableLabelColumnsDialogError(state);
renderTableLabelColumnsFields(state, data);
setTableLabelColumnsDialogBusy(state, false);
if (!state.dialog.open) {
state.dialog.showModal();
}
var firstCheckbox = state.list.querySelector(
".table-label-columns-checkbox",
);
if (firstCheckbox) {
firstCheckbox.focus();
}
}
function initTableLabelColumnsActions(manager) {
if (!window.fetch || !window.HTMLDialogElement || !tableLabelColumnsData()) {
return;
}
document.addEventListener("click", function (ev) {
var button = ev.target.closest(
'button[data-table-action="set-label-columns"]',
);
if (!button) {
return;
}
ev.preventDefault();
openTableLabelColumnsDialog(button, manager);
});
}
document.addEventListener("datasette_init", function (evt) {
const { detail: manager } = evt;
@ -5290,4 +5623,5 @@ document.addEventListener("datasette_init", function (evt) {
initRowInsertActions(manager);
initRowEditActions(manager);
initRowDeleteActions(manager);
initTableLabelColumnsActions(manager);
});

View file

@ -26,8 +26,8 @@
<p class="message-error">{{ error }}</p>
{% elif autocomplete_url %}
<h2>{{ database_name }} / {{ table_name }}</h2>
{% if label_column %}
<p>Label column: <code>{{ label_column }}</code></p>
{% if label_columns %}
<p>Label column{{ "s" if label_columns|length > 1 else "" }}: <code>{{ label_columns|join(", ") }}</code></p>
{% else %}
<p>No label column detected. Results will use primary key values.</p>
{% endif %}
@ -64,7 +64,7 @@
<tr>
<td>{{ suggestion.database }}</td>
<td><a href="{{ suggestion.url }}">{{ suggestion.table }}</a></td>
<td><code>{{ suggestion.label_column }}</code></td>
<td><code>{{ suggestion.label_columns|join(", ") }}</code></td>
</tr>
{% endfor %}
</tbody>

View file

@ -1504,6 +1504,18 @@ _table_config_keys = (
)
def normalize_label_columns(value):
"""
Normalize a ``label_column`` config value (a string, a list of strings,
or None) to a list of strings.
"""
if value is None:
return []
if isinstance(value, str):
return [value]
return list(value)
def move_table_config(metadata: dict, config: dict):
"""
Move all known table configuration keys from metadata to config.

View file

@ -113,6 +113,13 @@ async def initialize_metadata_tables(db):
PRIMARY KEY (database_name, resource_name, column_name)
);
CREATE TABLE IF NOT EXISTS label_columns (
database_name TEXT NOT NULL,
resource_name TEXT NOT NULL,
columns TEXT NOT NULL, -- JSON list of column names, order = label order
PRIMARY KEY (database_name, resource_name)
);
CREATE TABLE IF NOT EXISTS queries (
database_name TEXT NOT NULL,
name TEXT NOT NULL,

View file

@ -34,7 +34,7 @@ from . import Context, from_extra
from .table import (
display_columns_and_rows,
_table_page_data,
row_label_from_label_column,
row_label_from_label_columns,
)
from .table_extras import RowExtraContext, resolve_row_extras, table_extra_registry
@ -495,10 +495,12 @@ class RowView(BaseView):
"<strong>{}</strong>".format(cell["value"])
)
label_column = await db.label_column_for_table(table) if is_table else None
label_columns = (
await db.label_columns_for_table(table) if is_table else None
)
row_path = path_from_row_pks(rows[0], pks, False)
pk_path = path_from_row_pks(rows[0], pks, False, False)
row_label = row_label_from_label_column(expanded_rows[0], label_column)
row_label = row_label_from_label_columns(expanded_rows[0], label_columns)
for display_row in display_rows:
display_row.pk_path = pk_path
display_row.row_path = row_path
@ -585,6 +587,7 @@ class RowView(BaseView):
is_view=not is_table,
table_insert_ui=None,
table_alter_ui=None,
label_columns_ui=None,
),
"row_actions": row_actions,
"top_row": make_slot_function(
@ -703,8 +706,8 @@ def _truncated_row_flash_label(label):
async def _row_flash_message(db, action, resolved, row=None):
pk_label = ", ".join(resolved.pk_values)
label_column = await db.label_column_for_table(resolved.table)
label = row_label_from_label_column(row or resolved.row, label_column)
label_columns = await db.label_columns_for_table(resolved.table)
label = row_label_from_label_columns(row or resolved.row, label_columns)
if label:
label = _truncated_row_flash_label(label)
if label and label != pk_label:

View file

@ -121,13 +121,13 @@ class AutocompleteDebugView(BaseView):
if scanned >= 100:
break
continue
label_column = await db.label_column_for_table(table_name)
if label_column:
label_columns = await db.label_columns_for_table(table_name)
if label_columns:
suggestions.append(
{
"database": database_name,
"table": table_name,
"label_column": label_column,
"label_columns": label_columns,
"url": self.ds.urls.path(
"-/debug/autocomplete?"
+ urllib.parse.urlencode(
@ -177,7 +177,9 @@ class AutocompleteDebugView(BaseView):
"autocomplete_url": "{}/-/autocomplete".format(
self.ds.urls.table(database_name, table_name)
),
"label_column": await db.label_column_for_table(table_name),
"label_columns": await db.label_columns_for_table(
table_name
),
}
)
else:

View file

@ -259,18 +259,23 @@ class Row:
return json.dumps(d, default=repr, indent=2)
def row_label_from_label_column(row, label_column):
if not label_column:
def row_label_from_label_columns(row, label_columns):
if not label_columns:
return None
try:
value = row[label_column]
except (KeyError, IndexError):
values = []
for label_column in label_columns:
try:
value = row[label_column]
except (KeyError, IndexError):
continue
if isinstance(value, dict):
value = value.get("label")
if value is None or value == "":
continue
values.append(str(value))
if not values:
return None
if isinstance(value, dict):
value = value.get("label")
if value is None or value == "":
return None
return str(value)
return " ".join(values)
async def run_sequential(*args):
@ -448,6 +453,7 @@ async def _table_page_data(
is_view,
table_insert_ui,
table_alter_ui,
label_columns_ui,
):
data = {
"database": database_name,
@ -458,6 +464,8 @@ async def _table_page_data(
data["insertRow"] = table_insert_ui
if table_alter_ui:
data["alterTable"] = table_alter_ui
if label_columns_ui:
data["labelColumns"] = label_columns_ui
if not is_view:
foreign_keys = await _foreign_key_autocomplete_urls(
datasette, request, db, database_name, table_name
@ -606,6 +614,29 @@ async def _table_alter_ui(
return data
async def _table_label_columns_ui(datasette, request, db, database_name, table_name):
if not await datasette.allowed(
action="set-label-columns",
resource=TableResource(database=database_name, table=table_name),
actor=request.actor,
):
return None
column_details = await db.table_column_details(table_name)
columns = [column.name for column in column_details if not column.hidden]
current = await db.label_columns_for_table(table_name)
explicit = await datasette.get_label_columns(database_name, table_name)
return {
"path": "{}/-/set-label-columns".format(
datasette.urls.table(database_name, table_name)
),
"tableName": table_name,
"columns": columns,
"current": current,
"isOverridden": explicit is not None,
}
async def display_columns_and_rows(
datasette,
database_name,
@ -645,9 +676,9 @@ async def display_columns_and_rows(
pks_for_display = pks
if not pks_for_display:
pks_for_display = ["rowid"]
label_column = None
label_columns = None
if link_column:
label_column = await db.label_column_for_table(table_name)
label_columns = await db.label_columns_for_table(table_name)
row_action_permissions = {}
if link_column and request is not None and db.is_mutable:
row_action_permissions = await datasette.allowed_many(
@ -695,7 +726,7 @@ async def display_columns_and_rows(
is_special_link_column = len(pks) != 1
pk_path = path_from_row_pks(row, pks, not pks, False)
row_path = path_from_row_pks(row, pks, not pks)
row_label = row_label_from_label_column(row, label_column)
row_label = row_label_from_label_columns(row, label_columns)
row_action_label = pk_path
if row_label and row_label != pk_path:
row_action_label = "{} {}".format(pk_path, row_label)
@ -1317,6 +1348,95 @@ class TableSetColumnTypeView(BaseView):
)
class TableSetLabelColumnsView(BaseView):
name = "table-set-label-columns"
def __init__(self, datasette):
self.ds = datasette
async def post(self, request):
try:
resolved = await self.ds.resolve_table(request)
except NotFound as e:
return _error([e.args[0]], 404)
database_name = resolved.db.name
table_name = resolved.table
if not await self.ds.allowed(
action="set-label-columns",
resource=TableResource(database=database_name, table=table_name),
actor=request.actor,
):
return _error(["Permission denied"], 403)
content_type = request.headers.get("content-type") or ""
if not content_type.startswith("application/json"):
return _error(["Invalid content-type, must be application/json"], 400)
try:
data = await request.json()
except json.JSONDecodeError as e:
return _error(["Invalid JSON: {}".format(e)], 400)
if not isinstance(data, dict):
return _error(["JSON must be a dictionary"], 400)
invalid_keys = set(data.keys()) - {"columns"}
if invalid_keys:
return _error(
['Invalid parameter: "{}"'.format('", "'.join(sorted(invalid_keys)))],
400,
)
if "columns" not in data:
return _error(['"columns" is required'], 400)
columns = data["columns"]
if columns is None:
await self.ds.remove_label_columns(database_name, table_name)
return Response.json(
{
"ok": True,
"database": database_name,
"table": table_name,
"columns": None,
},
status=200,
)
if (
not isinstance(columns, list)
or not columns
or not all(isinstance(c, str) for c in columns)
):
return _error(
['"columns" must be a non-empty list of strings, or null'], 400
)
if len(set(columns)) != len(columns):
return _error(['"columns" must not contain duplicates'], 400)
column_details = await self.ds._get_resource_column_details(
database_name, table_name
)
for column in columns:
if column not in column_details:
return _error(["Column not found: {}".format(column)], 400)
await self.ds.set_label_columns(database_name, table_name, columns)
return Response.json(
{
"ok": True,
"database": database_name,
"table": table_name,
"columns": columns,
},
status=200,
)
class TableDropView(BaseView):
name = "table-drop"
@ -1443,7 +1563,7 @@ def _autocomplete_prefix_like(column):
return "{} like :prefix escape char(92)".format(escape_sqlite(column))
def _autocomplete_order_by(pks, label_column, exact_pk, label_matches_first=True):
def _autocomplete_order_by(pks, label_columns, exact_pk, label_matches_first=True):
clauses = []
if exact_pk:
clauses.append(
@ -1451,15 +1571,18 @@ def _autocomplete_order_by(pks, label_column, exact_pk, label_matches_first=True
escape_sqlite(pks[0])
)
)
if label_column:
label_like = _autocomplete_like(label_column)
if label_columns:
label_likes = [_autocomplete_like(column) for column in label_columns]
any_label_like = " or ".join(label_likes)
if label_matches_first:
clauses.append("case when {} then 0 else 1 end".format(label_like))
clauses.append(
"case when {} then length(cast({} as text)) end".format(
label_like, escape_sqlite(label_column)
clauses.append("case when {} then 0 else 1 end".format(any_label_like))
total_length = " + ".join(
"case when {} then length(cast({} as text)) else 0 end".format(
label_like, escape_sqlite(column)
)
for label_like, column in zip(label_likes, label_columns)
)
clauses.append("case when {} then {} end".format(any_label_like, total_length))
else:
clauses.append("length(cast({} as text))".format(escape_sqlite(pks[0])))
clauses.extend(escape_sqlite(pk) for pk in pks)
@ -1476,12 +1599,15 @@ def _autocomplete_initial_order_by(pks):
return ", ".join(order_by)
def _autocomplete_response_rows(rows, pks, label_column):
def _autocomplete_response_rows(rows, pks, label_columns):
response_rows = []
for row in rows:
item = {"pks": {pk: row[pk] for pk in pks}}
if label_column:
item["label"] = row[label_column]
if label_columns:
if len(label_columns) == 1:
item["label"] = row[label_columns[0]]
else:
item["label"] = row_label_from_label_columns(row, label_columns)
response_rows.append(item)
return response_rows
@ -1511,10 +1637,8 @@ class TableAutocompleteView(BaseView):
pks = await db.primary_keys(table_name)
if not pks:
pks = ["rowid"]
label_column = await db.label_column_for_table(table_name)
select_columns = list(
dict.fromkeys(pks + ([label_column] if label_column else []))
)
label_columns = await db.label_columns_for_table(table_name)
select_columns = list(dict.fromkeys(pks + label_columns))
select_sql = ", ".join(escape_sqlite(column) for column in select_columns)
q = request.args.get("q") or ""
initial_arg = request.args.get("_initial")
@ -1533,11 +1657,12 @@ class TableAutocompleteView(BaseView):
}
like_columns = pks[:]
if label_column and label_column not in like_columns:
like_columns.append(label_column)
for label_column in label_columns:
if label_column not in like_columns:
like_columns.append(label_column)
where_sql = " or ".join(_autocomplete_like(column) for column in like_columns)
exact_pk = len(pks) == 1
order_by = _autocomplete_order_by(pks, label_column, exact_pk)
order_by = _autocomplete_order_by(pks, label_columns, exact_pk)
if initial:
where_sql = "1 = 1"
@ -1591,7 +1716,7 @@ class TableAutocompleteView(BaseView):
return Response.json({"rows": []})
return Response.json(
{"rows": _autocomplete_response_rows(results.rows, pks, label_column)}
{"rows": _autocomplete_response_rows(results.rows, pks, label_columns)}
)
@ -2176,11 +2301,11 @@ async def table_view_data(
# Expand labeled columns if requested
expanded_columns = []
# List of (fk_dict, label_column-or-None) pairs for that table
# List of (fk_dict, label_columns-or-None) pairs for that table
expandable_columns = []
for fk in await db.foreign_keys_for_table(table_name):
label_column = await db.label_column_for_table(fk["other_table"])
expandable_columns.append((fk, label_column))
label_columns = await db.label_columns_for_table(fk["other_table"])
expandable_columns.append((fk, label_columns or None))
columns_to_expand = None
try:
@ -2384,6 +2509,9 @@ async def table_view_data(
table_alter_ui = await _table_alter_ui(
datasette, request, db, database_name, table_name, is_view, pks
)
label_columns_ui = await _table_label_columns_ui(
datasette, request, db, database_name, table_name
)
data["table_insert_ui"] = table_insert_ui
data["table_alter_ui"] = table_alter_ui
data["table_page_data"] = await _table_page_data(
@ -2395,6 +2523,7 @@ async def table_view_data(
is_view=is_view,
table_insert_ui=table_insert_ui,
table_alter_ui=table_alter_ui,
label_columns_ui=label_columns_ui,
)
return data, rows[:page_size], columns, expanded_columns, sql, next_url

View file

@ -1070,14 +1070,15 @@ class PrivateExtra(Extra):
class ExpandableColumnsExtra(Extra):
description = "List of foreign key columns that can be expanded with labels. Each item is a ``(foreign_key, label_column)`` pair where ``foreign_key`` is the SQLite foreign key dictionary and ``label_column`` is the label column in the referenced table, or ``None``."
description = "List of foreign key columns that can be expanded with labels. Each item is a ``(foreign_key, label_columns)`` pair where ``foreign_key`` is the SQLite foreign key dictionary and ``label_columns`` is a list of label column(s) in the referenced table, or ``None``."
docs_note = "See :ref:`expand_foreign_keys` for how to expand these labels."
example = ExtraExample(
"/fixtures/facetable.json?_extra=expandable_columns",
note=(
"Each item is a ``[foreign_key, label_column]`` pair: the "
"foreign key relationship, then the column in the other table "
"that would be used as the label for each expanded value."
"Each item is a ``[foreign_key, label_columns]`` pair: the "
"foreign key relationship, then the list of column(s) in the "
"other table that would be used as the label for each expanded "
"value."
),
)
scopes = {ExtraScope.TABLE}
@ -1086,8 +1087,8 @@ class ExpandableColumnsExtra(Extra):
expandables = []
db = context.datasette.databases[context.database_name]
for fk in await db.foreign_keys_for_table(context.table_name):
label_column = await db.label_column_for_table(fk["other_table"])
expandables.append((fk, label_column))
label_columns = await db.label_columns_for_table(fk["other_table"])
expandables.append((fk, label_columns or None))
return expandables

View file

@ -50,7 +50,7 @@ The one exception is the "root" account, which you can sign into while using Dat
The ``--root`` flag is designed for local development and testing. When you start Datasette with ``--root``, the root user automatically receives every permission, including:
* All view permissions (``view-instance``, ``view-database``, ``view-table``, etc.)
* All write permissions (``insert-row``, ``update-row``, ``delete-row``, ``create-table``, ``alter-table``, ``set-column-type``, ``drop-table``)
* All write permissions (``insert-row``, ``update-row``, ``delete-row``, ``create-table``, ``alter-table``, ``set-column-type``, ``set-label-columns``, ``drop-table``)
* Debug permissions (``permissions-debug``, ``debug-menu``)
* Any custom permissions defined by plugins
@ -903,7 +903,7 @@ To grant ``create-table`` to the user with ``id`` of ``editor`` for the ``docs``
}
.. [[[end]]]
Other table-scoped write permissions, including ``set-column-type``, can be configured in the same place.
Other table-scoped write permissions, including ``set-column-type`` and ``set-label-columns``, can be configured in the same place.
And for ``insert-row`` against the ``reports`` table in that ``docs`` database:
@ -1408,6 +1408,18 @@ set-column-type
Actor is allowed to set assigned :ref:`column types <table_configuration_column_types>` for columns in a table.
``resource`` - ``datasette.resources.TableResource(database, table)``
``database`` is the name of the database (string)
``table`` is the name of the table (string)
.. _actions_set_label_columns:
set-label-columns
-----------------
Actor is allowed to set the :ref:`label column(s) <table_configuration_label_column>` used to build a display label for rows in a table.
``resource`` - ``datasette.resources.TableResource(database, table)``
``database`` is the name of the database (string)

View file

@ -936,6 +936,50 @@ You can override this automatic detection by specifying which column should be u
}
.. [[[end]]]
``label_column`` can also be set to a list of columns, in which case the values of those columns will be joined with a space to build the label:
.. [[[cog
config_example(cog, textwrap.dedent(
"""
databases:
mydatabase:
tables:
example_table:
label_column: [first_name, last_name]
""").strip()
)
.. ]]]
.. tab:: datasette.yaml
.. code-block:: yaml
databases:
mydatabase:
tables:
example_table:
label_column: [first_name, last_name]
.. tab:: datasette.json
.. code-block:: json
{
"databases": {
"mydatabase": {
"tables": {
"example_table": {
"label_column": [
"first_name",
"last_name"
]
}
}
}
}
}
.. [[[end]]]
.. _table_configuration_hidden:
``hidden``

View file

@ -2228,8 +2228,8 @@ The ``Database`` class also provides properties and methods for introspecting th
``await db.fts_table(table)`` - string or None
The name of the FTS table associated with this table, if one exists.
``await db.label_column_for_table(table)`` - string or None
The label column that is associated with this table - either automatically detected or using the ``"label_column"`` key in configuration, see :ref:`table_configuration_label_column`.
``await db.label_columns_for_table(table)`` - list of strings
The label column(s) associated with this table - either automatically detected, set using the ``"label_column"`` key in configuration, or configured at runtime via the ``set-label-columns`` API, see :ref:`table_configuration_label_column`. Returns an empty list if no label column could be determined.
``await db.foreign_keys_for_table(table)`` - list of dictionaries
Details of columns in this table which are foreign keys to other tables. A list of dictionaries where each dictionary is shaped like this: ``{"column": string, "other_table": string, "other_column": string}``.

View file

@ -738,11 +738,11 @@ The available table extras are listed below.
false
``expandable_columns``
List of foreign key columns that can be expanded with labels. Each item is a ``(foreign_key, label_column)`` pair where ``foreign_key`` is the SQLite foreign key dictionary and ``label_column`` is the label column in the referenced table, or ``None``. (See :ref:`expand_foreign_keys` for how to expand these labels.)
List of foreign key columns that can be expanded with labels. Each item is a ``(foreign_key, label_columns)`` pair where ``foreign_key`` is the SQLite foreign key dictionary and ``label_columns`` is a list of label column(s) in the referenced table, or ``None``. (See :ref:`expand_foreign_keys` for how to expand these labels.)
``GET /fixtures/facetable.json?_extra=expandable_columns``
Each item is a ``[foreign_key, label_column]`` pair: the foreign key relationship, then the column in the other table that would be used as the label for each expanded value.
Each item is a ``[foreign_key, label_columns]`` pair: the foreign key relationship, then the list of column(s) in the other table that would be used as the label for each expanded value.
.. code-block:: json
@ -753,7 +753,7 @@ The available table extras are listed below.
"other_table": "facet_cities",
"other_column": "id"
},
"name"
["name"]
]
]
@ -2426,6 +2426,48 @@ This API stores the assignment in Datasette's internal database, so it can be us
Any errors will return ``{"errors": ["... descriptive message ..."], "ok": false}``, and a ``400`` status code for a bad input or a ``403`` status code for an authentication or permission error.
.. _TableSetLabelColumnsView:
Setting label columns
~~~~~~~~~~~~~~~~~~~~~
To set the label column(s) for a table, make a ``POST`` to ``/<database>/<table>/-/set-label-columns``. This requires the :ref:`actions_set_label_columns` permission.
::
POST /<database>/<table>/-/set-label-columns
Content-Type: application/json
Authorization: Bearer dstok_<rest-of-token>
.. code-block:: json
{
"columns": ["first_name", "last_name"]
}
This will return a ``200`` response like this:
.. code-block:: json
{
"ok": true,
"database": "data",
"table": "people",
"columns": ["first_name", "last_name"]
}
To revert to Datasette's automatic label column detection, set ``columns`` to ``null``:
.. code-block:: json
{
"columns": null
}
This API stores the assignment in Datasette's internal database, so it can be used with immutable databases as well as mutable ones. See :ref:`table_configuration_label_column` for more about label columns.
Any errors will return ``{"errors": ["... descriptive message ..."], "ok": false}``, and a ``400`` status code for a bad input or a ``403`` status code for an authentication or permission error.
.. _TableDropView:
Dropping tables

View file

@ -5,93 +5,154 @@ from datasette.app import Datasette
@pytest.mark.asyncio
@pytest.mark.parametrize(
"create_sql,table_name,config,expected_label_column",
"create_sql,table_name,config,expected_label_columns",
[
# Explicit label_column
# Explicit label_column, single string
(
"create table t1 (id integer primary key, name text, title text);",
"t1",
{"t1": {"label_column": "title"}},
"title",
["title"],
),
# Explicit label_column, list of columns
(
"create table t1b (id integer primary key, first_name text, last_name text);",
"t1b",
{"t1b": {"label_column": ["first_name", "last_name"]}},
["first_name", "last_name"],
),
# Single unique text column
(
"create table t2 (id integer primary key, name2 text unique, title text);",
"t2",
{},
"name2",
["name2"],
),
(
"create table t3 (id integer primary key, title2 text unique, name text);",
"t3",
{},
"title2",
["title2"],
),
# Two unique text columns means it cannot decide on one
(
"create table t3x (id integer primary key, name2 text unique, title2 text unique);",
"t3x",
{},
None,
[],
),
# Name or title column
(
"create table t4 (id integer primary key, name text);",
"t4",
{},
"name",
["name"],
),
(
"create table t5 (id integer primary key, title text);",
"t5",
{},
"title",
["title"],
),
# But not if there are multiple non-unique text that are not called title
(
"create table t5x (id integer primary key, other1 text, other2 text);",
"t5x",
{},
None,
[],
),
(
"create table t6 (id integer primary key, Name text);",
"t6",
{},
"Name",
["Name"],
),
(
"create table t7 (id integer primary key, Title text);",
"t7",
{},
"Title",
["Title"],
),
# Two columns, one of which is id
(
"create table t8 (id integer primary key, content text);",
"t8",
{},
"content",
["content"],
),
(
"create table t9 (pk integer primary key, content text);",
"t9",
{},
"content",
["content"],
),
],
)
async def test_label_column_for_table(
create_sql, table_name, config, expected_label_column
async def test_label_columns_for_table(
create_sql, table_name, config, expected_label_columns
):
"""Test cases for label_column_for_table method"""
"""Test cases for label_columns_for_table method"""
ds = Datasette()
db = ds.add_database(Database(ds, memory_name="test_label_column_for_table"))
db = ds.add_database(Database(ds, memory_name="test_label_columns_for_table"))
await db.execute_write_script(create_sql)
await ds.invoke_startup()
if config:
ds.config = {"databases": {"test_label_column_for_table": {"tables": config}}}
actual_label_column = await db.label_column_for_table(table_name)
if expected_label_column is None:
assert actual_label_column is None
else:
assert actual_label_column == expected_label_column
ds.config = {"databases": {"test_label_columns_for_table": {"tables": config}}}
# label_column config is only seeded into the internal DB once, so
# re-apply it explicitly for tables configured after startup.
await ds._apply_label_columns_config()
actual_label_columns = await db.label_columns_for_table(table_name)
assert actual_label_columns == expected_label_columns
@pytest.mark.asyncio
async def test_internal_db_override_wins_over_config():
ds = Datasette()
db = ds.add_database(Database(ds, memory_name="test_label_columns_override"))
await db.execute_write_script(
"create table t1 (id integer primary key, name text, title text);"
)
await ds.invoke_startup()
ds.config = {
"databases": {
"test_label_columns_override": {"tables": {"t1": {"label_column": "title"}}}
}
}
await ds._apply_label_columns_config()
assert await db.label_columns_for_table("t1") == ["title"]
await ds.set_label_columns("test_label_columns_override", "t1", ["name"])
assert await db.label_columns_for_table("t1") == ["name"]
# Re-applying config should NOT stomp the override (seed-once semantics)
await ds._apply_label_columns_config()
assert await db.label_columns_for_table("t1") == ["name"]
@pytest.mark.asyncio
async def test_removing_label_columns_override_reverts_to_config():
ds = Datasette()
db = ds.add_database(Database(ds, memory_name="test_label_columns_remove"))
await db.execute_write_script(
"create table t1 (id integer primary key, name text, title text);"
)
await ds.invoke_startup()
ds.config = {
"databases": {
"test_label_columns_remove": {"tables": {"t1": {"label_column": "title"}}}
}
}
await ds._apply_label_columns_config()
await ds.set_label_columns("test_label_columns_remove", "t1", ["name"])
assert await db.label_columns_for_table("t1") == ["name"]
await ds.remove_label_columns("test_label_columns_remove", "t1")
# No row left in the internal DB, so falls through to auto-detection
# (both "name" and "title" columns exist, but "name" wins the "name or
# title" heuristic tie-break since it is listed first).
assert await db.label_columns_for_table("t1") == ["name"]
# A subsequent startup re-seeds the config value, since no row exists
await ds._apply_label_columns_config()
assert await db.label_columns_for_table("t1") == ["title"]

View file

@ -0,0 +1,363 @@
import time
import pytest
from bs4 import BeautifulSoup as Soup
from datasette.app import Datasette
from datasette.utils import sqlite3
def write_token(ds, actor_id="root", permissions=None):
to_sign = {"a": actor_id, "token": "dstok", "t": int(time.time())}
if permissions:
to_sign["_r"] = {"a": permissions}
return "dstok_{}".format(ds.sign(to_sign, namespace="token"))
def _headers(token):
return {
"Authorization": "Bearer {}".format(token),
"Content-Type": "application/json",
}
def _window_data_from_html(html, variable_name):
soup = Soup(html, "html.parser")
scripts = soup.find_all("script")
matching_scripts = [
script for script in scripts if variable_name in (script.string or "")
]
assert len(matching_scripts) == 1
script_text = matching_scripts[0].string.strip()
prefix = f"window.{variable_name} = "
assert script_text.startswith(prefix)
import json
return json.loads(script_text[len(prefix) :].rstrip(";"))
@pytest.fixture
def ds_lc(tmp_path_factory):
db_directory = tmp_path_factory.mktemp("dbs")
db_path = str(db_directory / "data.db")
db = sqlite3.connect(str(db_path))
db.execute("vacuum")
db.execute(
"create table people (id integer primary key, first_name text, last_name text)"
)
db.execute("insert into people values (1, 'Alice', 'Smith')")
db.commit()
ds = Datasette([db_path])
ds.root_enabled = True
yield ds
ds.close()
@pytest.fixture
def ds_lc_editor_permission(tmp_path_factory):
db_directory = tmp_path_factory.mktemp("dbs")
db_path = str(db_directory / "data.db")
db = sqlite3.connect(str(db_path))
db.execute("vacuum")
db.execute(
"create table people (id integer primary key, first_name text, last_name text)"
)
db.execute("insert into people values (1, 'Alice', 'Smith')")
db.commit()
ds = Datasette(
[db_path],
config={
"databases": {
"data": {
"tables": {
"people": {
"permissions": {"set-label-columns": {"id": "editor"}},
}
}
}
}
},
)
ds.root_enabled = True
yield ds
ds.close()
@pytest.mark.asyncio
async def test_set_label_columns_api_single_column(ds_lc):
await ds_lc.invoke_startup()
token = write_token(ds_lc, permissions=["slc"])
response = await ds_lc.client.post(
"/data/people/-/set-label-columns",
json={"columns": ["first_name"]},
headers=_headers(token),
)
assert response.status_code == 200
assert response.json() == {
"ok": True,
"database": "data",
"table": "people",
"columns": ["first_name"],
}
assert await ds_lc.get_label_columns("data", "people") == ["first_name"]
@pytest.mark.asyncio
async def test_set_label_columns_api_multiple_columns(ds_lc):
await ds_lc.invoke_startup()
token = write_token(ds_lc, permissions=["slc"])
response = await ds_lc.client.post(
"/data/people/-/set-label-columns",
json={"columns": ["first_name", "last_name"]},
headers=_headers(token),
)
assert response.status_code == 200
assert response.json()["columns"] == ["first_name", "last_name"]
db = ds_lc.databases["data"]
assert await db.label_columns_for_table("people") == ["first_name", "last_name"]
@pytest.mark.asyncio
async def test_clear_label_columns_api(ds_lc):
await ds_lc.invoke_startup()
await ds_lc.set_label_columns("data", "people", ["first_name"])
token = write_token(ds_lc, permissions=["slc"])
response = await ds_lc.client.post(
"/data/people/-/set-label-columns",
json={"columns": None},
headers=_headers(token),
)
assert response.status_code == 200
assert response.json() == {
"ok": True,
"database": "data",
"table": "people",
"columns": None,
}
assert await ds_lc.get_label_columns("data", "people") is None
@pytest.mark.asyncio
async def test_set_label_columns_reflected_in_foreign_key_labels(tmp_path_factory):
db_directory = tmp_path_factory.mktemp("dbs")
db_path = str(db_directory / "data.db")
db = sqlite3.connect(str(db_path))
db.execute("vacuum")
db.execute(
"create table people (id integer primary key, first_name text, last_name text)"
)
db.execute("insert into people values (1, 'Alice', 'Smith')")
db.execute(
"create table orders (id integer primary key, person_id integer, "
"foreign key (person_id) references people(id))"
)
db.execute("insert into orders values (1, 1)")
db.commit()
ds = Datasette([db_path])
ds.root_enabled = True
try:
await ds.invoke_startup()
await ds.set_label_columns("data", "people", ["first_name", "last_name"])
response = await ds.client.get(
"/data/orders.json?_labels=on", actor={"id": "root"}
)
assert response.status_code == 200
data = response.json()
assert data["rows"][0]["person_id"] == {"value": 1, "label": "Alice Smith"}
finally:
db.close()
for database in ds.databases.values():
if not database.is_memory:
database.close()
@pytest.mark.asyncio
@pytest.mark.parametrize(
"body,special_case,expected_status,expected_errors",
(
(
{"columns": ["first_name"]},
"no_permission",
403,
["Permission denied"],
),
(
None,
"invalid_json",
400,
[
"Invalid JSON: Expecting property name enclosed in double quotes: line 1 column 2 (char 1)"
],
),
(
{"columns": ["first_name"]},
"invalid_content_type",
400,
["Invalid content-type, must be application/json"],
),
(
[],
None,
400,
["JSON must be a dictionary"],
),
(
{},
None,
400,
['"columns" is required'],
),
(
{"columns": "first_name"},
None,
400,
['"columns" must be a non-empty list of strings, or null'],
),
(
{"columns": []},
None,
400,
['"columns" must be a non-empty list of strings, or null'],
),
(
{"columns": [1]},
None,
400,
['"columns" must be a non-empty list of strings, or null'],
),
(
{"columns": ["first_name", "first_name"]},
None,
400,
['"columns" must not contain duplicates'],
),
(
{"columns": ["not_a_column"]},
None,
400,
["Column not found: not_a_column"],
),
(
{"columns": ["first_name"], "extra": True},
None,
400,
['Invalid parameter: "extra"'],
),
),
)
async def test_set_label_columns_api_errors(
ds_lc, body, special_case, expected_status, expected_errors
):
await ds_lc.invoke_startup()
token = write_token(
ds_lc,
permissions=(["slc"] if special_case != "no_permission" else ["vi"]),
)
kwargs = {
"headers": {
"Authorization": f"Bearer {token}",
"Content-Type": (
"text/plain"
if special_case == "invalid_content_type"
else "application/json"
),
}
}
if special_case == "invalid_json":
kwargs["content"] = "{bad json"
else:
kwargs["json"] = body
response = await ds_lc.client.post("/data/people/-/set-label-columns", **kwargs)
assert response.status_code == expected_status
assert response.json() == {"ok": False, "errors": expected_errors}
@pytest.mark.asyncio
async def test_set_label_columns_api_works_for_immutable_database(tmp_path_factory):
db_directory = tmp_path_factory.mktemp("dbs")
db_path = str(db_directory / "immutable.db")
db = sqlite3.connect(str(db_path))
db.execute("vacuum")
db.execute(
"create table people (id integer primary key, first_name text, last_name text)"
)
db.commit()
ds = Datasette([], immutables=[db_path])
ds.root_enabled = True
try:
await ds.invoke_startup()
token = write_token(ds, permissions=["slc"])
response = await ds.client.post(
"/immutable/people/-/set-label-columns",
json={"columns": ["first_name"]},
headers=_headers(token),
)
assert response.status_code == 200
assert await ds.get_label_columns("immutable", "people") == ["first_name"]
finally:
db.close()
for database in ds.databases.values():
if not database.is_memory:
database.close()
def table_data_from_soup(soup):
import json
import re
table_script = [
s for s in soup.find_all("script") if "_datasetteTableData" in (s.string or "")
][0]
match = re.search(
r"window\._datasetteTableData\s*=\s*({.*?});",
table_script.string,
re.DOTALL,
)
return json.loads(match.group(1))
@pytest.mark.asyncio
async def test_set_label_columns_action_button_hidden_without_permission(ds_lc):
await ds_lc.invoke_startup()
response = await ds_lc.client.get("/data/people")
assert response.status_code == 200
soup = Soup(response.text, "html.parser")
assert soup.select_one('button[data-table-action="set-label-columns"]') is None
assert "labelColumns" not in table_data_from_soup(soup)
@pytest.mark.asyncio
async def test_set_label_columns_action_button_and_data(ds_lc):
await ds_lc.invoke_startup()
response = await ds_lc.client.get("/data/people", actor={"id": "root"})
assert response.status_code == 200
soup = Soup(response.text, "html.parser")
button = soup.select_one(
'button.action-menu-button[data-table-action="set-label-columns"]'
)
assert button is not None
assert button["aria-label"] == "Set label columns for people"
description = button.find("span", class_="dropdown-description")
assert description.text.strip() == (
"Choose which column(s) are used to label this table's rows."
)
data = table_data_from_soup(soup)["labelColumns"]
assert data["path"] == "/data/people/-/set-label-columns"
assert data["tableName"] == "people"
assert data["columns"] == ["id", "first_name", "last_name"]
assert data["current"] == []
assert data["isOverridden"] is False
@pytest.mark.asyncio
async def test_set_label_columns_ui_data_reflects_override(ds_lc):
await ds_lc.invoke_startup()
await ds_lc.set_label_columns("data", "people", ["first_name", "last_name"])
response = await ds_lc.client.get("/data/people", actor={"id": "root"})
assert response.status_code == 200
soup = Soup(response.text, "html.parser")
data = table_data_from_soup(soup)["labelColumns"]
assert data["current"] == ["first_name", "last_name"]
assert data["isOverridden"] is True

View file

@ -138,6 +138,7 @@ def write_playwright_config(config_path):
"insert-row": True,
"update-row": True,
"delete-row": True,
"set-label-columns": True,
},
},
"defaults_demo": {
@ -890,3 +891,45 @@ def test_delete_row_flow_removes_row(page, datasette_server):
page.locator(".row-mutation-status", has_text="Deleted row 1").wait_for()
page.locator('tr[data-row="1"]').wait_for(state="detached")
assert project_rows(datasette_server, id=1) == []
@pytest.mark.playwright
def test_set_label_columns_flow(page, datasette_server):
page.goto(f"{datasette_server}data/projects")
page.locator("details.actions-menu-links summary").click()
page.locator('button[data-table-action="set-label-columns"]').click()
dialog = page.locator("#table-label-columns-dialog")
dialog.wait_for()
assert (
dialog.locator(".modal-title").inner_text()
== "Set label column(s) for projects"
)
assert dialog.locator(".table-label-columns-reset").is_enabled()
def checked_column_names():
return dialog.locator(
".table-label-columns-row:has(.table-label-columns-checkbox:checked) "
".table-label-columns-name"
).all_inner_texts()
assert checked_column_names() == ["title"]
notes_row = dialog.locator(".table-label-columns-row", has_text="notes")
notes_row.locator(".table-label-columns-checkbox").check()
dialog.locator(".table-label-columns-save").click()
dialog.wait_for(state="hidden")
page.locator("details.actions-menu-links summary").click()
page.locator('button[data-table-action="set-label-columns"]').click()
dialog.wait_for()
assert checked_column_names() == ["title", "notes"]
dialog.locator(".table-label-columns-reset").click()
dialog.wait_for(state="hidden")
page.locator("details.actions-menu-links summary").click()
page.locator('button[data-table-action="set-label-columns"]').click()
dialog.wait_for()
assert checked_column_names() == ["title"]
assert dialog.locator(".table-label-columns-reset").is_disabled()