From 2d3c85dfc0b802dcb6736f17cb15e84f67d4f921 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 16 Jun 2026 18:02:58 -0700 Subject: [PATCH 01/31] Add create table UI Adds a permission-gated database action that opens a create table modal on database pages, backed by the existing create-table JSON API. The modal starts with an id integer primary key column plus a blank text column, supports SQLite type selection, and shows custom column type controls only when the actor can set column types. Selected custom column types are applied after table creation with follow-up set-column-type API calls. Includes styling plus HTML and Playwright coverage for the action payload and create-table flow. --- datasette/static/app.css | 323 ++++++++++++++ datasette/static/edit-tools.js | 679 ++++++++++++++++++++++++++++++ datasette/templates/database.html | 4 + datasette/views/database.py | 120 +++++- tests/test_playwright.py | 53 +++ tests/test_table_html.py | 144 +++++++ 6 files changed, 1303 insertions(+), 20 deletions(-) diff --git a/datasette/static/app.css b/datasette/static/app.css index 5fe4502d..ce0c46a8 100644 --- a/datasette/static/app.css +++ b/datasette/static/app.css @@ -1749,6 +1749,289 @@ datasette-autocomplete input[type="text"], cursor: not-allowed; } +dialog.table-create-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(760px, calc(100vw - 32px)); + max-width: 95vw; + max-height: min(780px, 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-create-dialog[open] { + display: flex; + flex-direction: column; +} + +dialog.table-create-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-create-dialog .modal-header { + padding: 20px 24px 12px; + border-bottom: 1px solid var(--rule); + display: flex; + align-items: center; + gap: 12px; + flex-shrink: 0; + min-width: 0; +} + +.table-create-dialog .modal-title { + display: flex; + align-items: center; + min-width: 0; + max-width: 100%; + font-size: 1rem; + font-weight: 600; + color: var(--ink); +} + +.table-create-form { + display: flex; + flex: 1 1 auto; + min-height: 0; + flex-direction: column; +} + +.table-create-error { + border-left: 4px solid #b91c1c; + border-radius: 4px; + background: #fff1f1; + color: #7f1d1d; + font-size: 0.9rem; + margin: 12px 24px 0; + padding: 10px 12px; +} + +.table-create-error:focus { + outline: 3px solid rgba(185, 28, 28, 0.18); + outline-offset: 2px; +} + +.table-create-fields { + display: grid; + gap: 18px; + padding: 16px 24px 24px; + overflow-y: auto; +} + +.table-create-field { + display: grid; + grid-template-columns: minmax(120px, 180px) minmax(0, 1fr); + gap: 12px; + align-items: start; +} + +.table-create-label, +.table-create-columns-heading { + color: var(--ink); + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.82rem; +} + +.table-create-label { + padding-top: 8px; +} + +.table-create-column-label { + border: 0; + clip: rect(0 0 0 0); + height: 1px; + margin: -1px; + overflow: hidden; + padding: 0; + position: absolute; + white-space: nowrap; + width: 1px; +} + +.table-create-input { + box-sizing: border-box; + min-width: 0; + border: 1px solid var(--rule); + border-radius: 5px; + padding: 8px 10px; + color: var(--ink); + background: #fff; + font: inherit; +} + +.table-create-input-placeholder { + color: var(--muted); +} + +.table-create-custom-column-type option { + color: var(--ink); +} + +.table-create-custom-column-type option[value=""] { + color: var(--muted); +} + +.table-create-table-name { + width: 100%; +} + +.table-create-input:focus { + border-color: var(--accent); + outline: 3px solid rgba(26, 86, 219, 0.12); +} + +.table-create-columns { + display: grid; + gap: 10px; +} + +.table-create-columns-heading { + font-weight: 600; +} + +.table-create-column-list { + display: grid; + gap: 8px; +} + +.table-create-column-row { + display: grid; + grid-template-columns: minmax(140px, 1.2fr) minmax(7.5rem, 0.7fr) minmax(12rem, 1fr) minmax(3.5rem, max-content) 32px; + align-items: center; + gap: 8px; + min-width: 0; + position: relative; +} + +.table-create-primary-key { + display: inline-flex; + align-items: center; + gap: 6px; + color: var(--ink); + font-size: 0.85rem; + min-width: 0; + white-space: nowrap; + justify-self: center; +} + +.table-create-primary-key-input { + margin: 0; +} + +.table-create-remove-column { + appearance: none; + border: 1px solid rgba(74, 85, 104, 0.24); + background: transparent; + color: #4a5568; + border-radius: 4px; + cursor: pointer; + display: inline-grid; + place-items: center; + height: 32px; + width: 32px; + padding: 0; +} + +.table-create-remove-column:hover, +.table-create-remove-column:focus { + background: rgba(74, 85, 104, 0.07); +} + +.table-create-remove-column:focus { + outline: 3px solid #b3d4ff; + outline-offset: 1px; +} + +.table-create-remove-column svg { + display: block; +} + +.table-create-add-column { + appearance: none; + justify-self: start; + border: 1px solid var(--rule); + border-radius: 5px; + background: #fff; + color: var(--accent); + cursor: pointer; + font: inherit; + font-size: 0.85rem; + padding: 7px 10px; +} + +.table-create-add-column:hover, +.table-create-add-column:focus { + background: #f8fafc; +} + +.table-create-add-column:focus { + outline: 3px solid rgba(26, 86, 219, 0.12); + outline-offset: 1px; +} + +.table-create-dialog .modal-footer { + padding: 14px 20px; + border-top: 1px solid var(--rule); + display: flex; + align-items: center; + justify-content: flex-end; + gap: 10px; + flex-shrink: 0; + background: var(--paper); +} + +.table-create-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-create-dialog .btn-ghost { + background: transparent; + color: var(--muted); + border: 1px solid var(--rule); +} + +.table-create-dialog .btn-ghost:hover { + background: var(--rule); + color: var(--ink); +} + +.table-create-dialog .btn-primary { + background: var(--accent); + color: #fff; +} + +.table-create-dialog .btn-primary:hover { + background: #1949b8; +} + +.table-create-dialog .btn:disabled, +.table-create-add-column:disabled, +.table-create-remove-column:disabled { + opacity: 0.55; + cursor: not-allowed; +} + .row-link-with-actions { display: inline-flex; align-items: center; @@ -1892,6 +2175,46 @@ datasette-autocomplete input[type="text"], padding-right: 18px; } + dialog.table-create-dialog { + width: 95vw; + max-height: 85vh; + border-radius: 0.5rem; + } + + .table-create-dialog .modal-header, + .table-create-fields { + padding-left: 18px; + padding-right: 18px; + } + + .table-create-error { + margin-left: 18px; + margin-right: 18px; + } + + .table-create-field { + grid-template-columns: 1fr; + gap: 5px; + } + + .table-create-label { + padding-top: 0; + } + + .table-create-column-row { + grid-template-columns: minmax(0, 1fr) 8.5rem 3.5rem 32px; + align-items: end; + } + + .table-create-custom-column-type { + grid-column: 1 / -1; + } + + .table-create-dialog .modal-footer { + padding-left: 18px; + padding-right: 18px; + } + .row-inline-action { min-height: 30px; min-width: 30px; diff --git a/datasette/static/edit-tools.js b/datasette/static/edit-tools.js index 8142d02b..4b93c4fd 100644 --- a/datasette/static/edit-tools.js +++ b/datasette/static/edit-tools.js @@ -2,6 +2,8 @@ var ROW_DELETE_DIALOG_ID = "row-delete-dialog"; var rowDeleteDialogState = null; var ROW_EDIT_DIALOG_ID = "row-edit-dialog"; var rowEditDialogState = null; +var TABLE_CREATE_DIALOG_ID = "table-create-dialog"; +var tableCreateDialogState = null; function ensureRowMutationStatus(manager) { var status = document.querySelector(".row-mutation-status"); @@ -43,6 +45,682 @@ function hideRowMutationStatus() { status.textContent = ""; } +function databaseCreateTableData() { + return ( + window._datasetteDatabaseData && + window._datasetteDatabaseData.createTable + ); +} + +function tableCreateColumnTypes() { + var data = databaseCreateTableData() || {}; + return data.columnTypes && data.columnTypes.length + ? data.columnTypes + : ["text", "integer", "float", "blob"]; +} + +function tableCreateCustomColumnTypes() { + var data = databaseCreateTableData() || {}; + return data.customColumnTypes || []; +} + +function tableCreateCustomColumnType(name) { + var options = tableCreateCustomColumnTypes(); + for (var i = 0; i < options.length; i += 1) { + if (options[i].name === name) { + return options[i]; + } + } + return null; +} + +function tableCreateCustomTypeAppliesToSqliteType(option, sqliteType) { + return ( + option && + option.sqliteTypes && + option.sqliteTypes.indexOf(sqliteType) !== -1 + ); +} + +function tableCreateDialogSignature(state) { + if (!state || !state.form) { + return ""; + } + var columns = []; + state.columnList + .querySelectorAll(".table-create-column-row") + .forEach(function (row) { + columns.push({ + name: row.querySelector(".table-create-column-name").value, + type: row.querySelector(".table-create-column-type").value, + customType: + ( + row.querySelector(".table-create-custom-column-type") || { + value: "", + } + ).value || "", + pk: row.querySelector(".table-create-primary-key-input").checked, + }); + }); + return JSON.stringify({ + table: state.tableName.value, + columns: columns, + }); +} + +function tableCreateDialogHasChanges(state) { + return ( + !!state && + !state.isSaving && + tableCreateDialogSignature(state) !== state.initialSignature + ); +} + +function clearTableCreateDialogError(state) { + state.error.hidden = true; + state.error.textContent = ""; + state.dialog.removeAttribute("aria-describedby"); +} + +function showTableCreateDialogError(state, message) { + state.error.hidden = false; + state.error.textContent = message; + state.dialog.setAttribute("aria-describedby", "table-create-error"); + state.error.focus(); +} + +function setTableCreateDialogSaving(state, isSaving) { + state.isSaving = isSaving; + state.cancelButton.disabled = isSaving; + state.saveButton.disabled = isSaving; + state.addColumnButton.disabled = isSaving; + state.saveButton.textContent = isSaving ? "Creating..." : "Create table"; + state.columnList + .querySelectorAll("input, select, button") + .forEach(function (control) { + control.disabled = isSaving; + }); +} + +function tableCreateSelectTypeValue(select, type) { + var options = tableCreateColumnTypes(); + options.forEach(function (option) { + var optionElement = document.createElement("option"); + optionElement.value = option; + optionElement.textContent = option; + select.appendChild(optionElement); + }); + select.value = options.indexOf(type) === -1 ? options[0] : type; +} + +function updateTableCreateCustomColumnTypePlaceholder(select) { + select.classList.toggle( + "table-create-input-placeholder", + !select.value, + ); +} + +function createTableCustomColumnTypeSelect() { + var options = tableCreateCustomColumnTypes(); + var select = document.createElement("select"); + select.className = "table-create-input table-create-custom-column-type"; + select.setAttribute("aria-label", "Custom column type"); + var blankOption = document.createElement("option"); + blankOption.value = ""; + blankOption.textContent = "- custom type -"; + select.appendChild(blankOption); + options.forEach(function (option) { + var optionElement = document.createElement("option"); + optionElement.value = option.name; + optionElement.textContent = option.description + ? option.description + " (" + option.name + ")" + : option.name; + select.appendChild(optionElement); + }); + updateTableCreateCustomColumnTypePlaceholder(select); + return select; +} + +function syncTableCreateCustomTypeForSqliteType(row) { + var typeSelect = row.querySelector(".table-create-column-type"); + var customTypeSelect = row.querySelector(".table-create-custom-column-type"); + if (!typeSelect || !customTypeSelect || !customTypeSelect.value) { + return; + } + var option = tableCreateCustomColumnType(customTypeSelect.value); + if (!tableCreateCustomTypeAppliesToSqliteType(option, typeSelect.value)) { + customTypeSelect.value = ""; + updateTableCreateCustomColumnTypePlaceholder(customTypeSelect); + } +} + +function createTableColumnRow(state, column) { + var index = state.nextColumnIndex; + state.nextColumnIndex += 1; + + var row = document.createElement("div"); + row.className = "table-create-column-row"; + + var nameId = "table-create-column-name-" + index; + var nameLabel = document.createElement("label"); + nameLabel.className = "table-create-column-label"; + nameLabel.setAttribute("for", nameId); + nameLabel.textContent = "Column"; + + var nameInput = document.createElement("input"); + nameInput.id = nameId; + nameInput.className = "table-create-input table-create-column-name"; + nameInput.type = "text"; + nameInput.required = true; + nameInput.autocomplete = "off"; + nameInput.value = column && column.name ? column.name : ""; + + var typeSelect = document.createElement("select"); + typeSelect.className = "table-create-input table-create-column-type"; + typeSelect.setAttribute("aria-label", "Column type"); + tableCreateSelectTypeValue(typeSelect, column && column.type); + + var customTypeSelect = createTableCustomColumnTypeSelect(); + if (column && column.customType) { + customTypeSelect.value = column.customType; + } + updateTableCreateCustomColumnTypePlaceholder(customTypeSelect); + + var pkLabel = document.createElement("label"); + pkLabel.className = "table-create-primary-key"; + var pkInput = document.createElement("input"); + pkInput.type = "checkbox"; + pkInput.className = "table-create-primary-key-input"; + pkInput.checked = !!(column && column.primaryKey); + var pkText = document.createElement("span"); + pkText.textContent = "PK"; + pkText.title = "Primary key"; + pkLabel.appendChild(pkInput); + pkLabel.appendChild(pkText); + + var removeButton = document.createElement("button"); + removeButton.type = "button"; + removeButton.className = "table-create-remove-column"; + removeButton.setAttribute("aria-label", "Remove column"); + removeButton.title = "Remove column"; + removeButton.innerHTML = + ''; + + row.appendChild(nameLabel); + row.appendChild(nameInput); + row.appendChild(typeSelect); + if (tableCreateCustomColumnTypes().length) { + row.appendChild(customTypeSelect); + } + row.appendChild(pkLabel); + row.appendChild(removeButton); + + removeButton.addEventListener("click", function () { + if (state.isSaving) { + return; + } + row.remove(); + clearTableCreateDialogError(state); + var nextInput = state.columnList.querySelector( + ".table-create-column-name", + ); + if (nextInput) { + nextInput.focus(); + } else { + state.addColumnButton.focus(); + } + }); + + nameInput.addEventListener("input", function () { + clearTableCreateDialogError(state); + }); + typeSelect.addEventListener("change", function () { + clearTableCreateDialogError(state); + syncTableCreateCustomTypeForSqliteType(row); + }); + customTypeSelect.addEventListener("change", function () { + clearTableCreateDialogError(state); + updateTableCreateCustomColumnTypePlaceholder(customTypeSelect); + var option = tableCreateCustomColumnType(customTypeSelect.value); + if ( + option && + option.fixedSqliteType && + tableCreateColumnTypes().indexOf(option.fixedSqliteType) !== -1 + ) { + typeSelect.value = option.fixedSqliteType; + } + }); + pkInput.addEventListener("change", function () { + clearTableCreateDialogError(state); + }); + + return row; +} + +function addTableCreateColumn(state, column) { + var row = createTableColumnRow(state, column || { type: "text" }); + state.columnList.appendChild(row); + return row; +} + +function resetTableCreateDialog(state) { + state.nextColumnIndex = 0; + state.tableName.value = ""; + state.columnList.textContent = ""; + addTableCreateColumn(state, { + name: "id", + type: "integer", + primaryKey: true, + }); + addTableCreateColumn(state, { + name: "", + type: "text", + primaryKey: false, + }); + state.initialSignature = tableCreateDialogSignature(state); +} + +function collectTableCreatePayload(state) { + var payload = { + table: state.tableName.value.trim(), + columns: [], + }; + var primaryKeys = []; + state.columnList + .querySelectorAll(".table-create-column-row") + .forEach(function (row) { + var name = row.querySelector(".table-create-column-name").value.trim(); + var type = row.querySelector(".table-create-column-type").value; + payload.columns.push({ name: name, type: type }); + if (row.querySelector(".table-create-primary-key-input").checked) { + primaryKeys.push(name); + } + }); + if (primaryKeys.length === 1) { + payload.pk = primaryKeys[0]; + } else if (primaryKeys.length > 1) { + payload.pks = primaryKeys; + } + return payload; +} + +function collectTableCreateColumnTypeAssignments(state) { + var assignments = []; + state.columnList + .querySelectorAll(".table-create-column-row") + .forEach(function (row) { + var customTypeSelect = row.querySelector( + ".table-create-custom-column-type", + ); + if (!customTypeSelect || !customTypeSelect.value) { + return; + } + assignments.push({ + column: row.querySelector(".table-create-column-name").value.trim(), + columnType: customTypeSelect.value, + sqliteType: row.querySelector(".table-create-column-type").value, + }); + }); + return assignments; +} + +function validateTableCreatePayload(payload) { + if (!payload.table) { + return "Table name is required."; + } + if (payload.table.indexOf("\n") !== -1) { + return "Table name cannot contain newlines."; + } + if (/^sqlite_/i.test(payload.table)) { + return "Table name cannot start with sqlite_."; + } + if (!payload.columns.length) { + return "At least one column is required."; + } + var seen = {}; + var supportedTypes = tableCreateColumnTypes(); + for (var i = 0; i < payload.columns.length; i += 1) { + var column = payload.columns[i]; + if (!column.name) { + return "Column name is required."; + } + if (column.name.indexOf("\n") !== -1) { + return "Column names cannot contain newlines."; + } + var columnKey = column.name.toLowerCase(); + if (seen[columnKey]) { + return "Duplicate column name: " + column.name; + } + seen[columnKey] = true; + if (supportedTypes.indexOf(column.type) === -1) { + return "Unsupported column type: " + column.type; + } + } + return null; +} + +function validateTableCreateColumnTypeAssignments(assignments) { + for (var i = 0; i < assignments.length; i += 1) { + var assignment = assignments[i]; + var option = tableCreateCustomColumnType(assignment.columnType); + if (!option) { + return "Unknown custom column type: " + assignment.columnType; + } + if (!tableCreateCustomTypeAppliesToSqliteType(option, assignment.sqliteType)) { + return ( + "Custom type " + + assignment.columnType + + " cannot be used with SQLite type " + + assignment.sqliteType + + "." + ); + } + } + return null; +} + +function fallbackTableUrl(tableName) { + var data = databaseCreateTableData() || {}; + if (!data.path) { + return null; + } + return data.path.replace(/\/-\/create$/, "/" + encodeURIComponent(tableName)); +} + +function tableCreateSetColumnTypeUrl(responseData, payload) { + var tableUrl = + responseData.table_url || fallbackTableUrl(responseData.table || payload.table); + if (!tableUrl) { + return null; + } + var url = new URL(tableUrl, location.href); + url.hash = ""; + url.search = ""; + url.pathname = url.pathname.replace(/\/$/, "") + "/-/set-column-type"; + return url.toString(); +} + +async function assignTableCreateColumnTypes(responseData, payload, assignments) { + if (!assignments.length) { + return; + } + var url = tableCreateSetColumnTypeUrl(responseData, payload); + if (!url) { + throw new Error("Could not find the set column type URL."); + } + for (var i = 0; i < assignments.length; i += 1) { + var assignment = assignments[i]; + var response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify({ + column: assignment.column, + column_type: { + type: assignment.columnType, + }, + }), + }); + var data = null; + try { + data = await response.json(); + } catch (_error) { + data = null; + } + if (!response.ok || (data && data.ok === false)) { + var error = rowMutationRequestError(response, data); + throw new Error( + "Created table, but could not set custom type for " + + assignment.column + + ": " + + error.message, + ); + } + } +} + +async function saveTableCreateDialog(state) { + if (state.isSaving) { + return; + } + var data = databaseCreateTableData(); + if (!data || !data.path) { + showTableCreateDialogError(state, "Could not find the create table URL."); + return; + } + clearTableCreateDialogError(state); + var payload = collectTableCreatePayload(state); + var columnTypeAssignments = collectTableCreateColumnTypeAssignments(state); + var validationError = validateTableCreatePayload(payload); + if (validationError) { + showTableCreateDialogError(state, validationError); + return; + } + var columnTypeValidationError = validateTableCreateColumnTypeAssignments( + columnTypeAssignments, + ); + if (columnTypeValidationError) { + showTableCreateDialogError(state, columnTypeValidationError); + return; + } + setTableCreateDialogSaving(state, true); + try { + var response = await fetch(data.path, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify(payload), + }); + var responseData = null; + try { + responseData = await response.json(); + } catch (_error) { + responseData = null; + } + if (!response.ok || (responseData && responseData.ok === false)) { + throw rowMutationRequestError(response, responseData); + } + await assignTableCreateColumnTypes( + responseData, + payload, + columnTypeAssignments, + ); + var tableUrl = + responseData.table_url || fallbackTableUrl(responseData.table || payload.table); + state.shouldRestoreFocus = false; + state.dialog.close(); + if (tableUrl) { + location.href = tableUrl; + } else { + location.reload(); + } + } catch (error) { + setTableCreateDialogSaving(state, false); + showTableCreateDialogError( + state, + error.message || "Could not create table", + ); + } +} + +function confirmDiscardTableCreateChanges(state) { + if (!tableCreateDialogHasChanges(state)) { + return true; + } + return window.confirm("Discard this new table?"); +} + +function closeTableCreateDialogIfConfirmed(state) { + if (!state || state.isSaving) { + return false; + } + if (!confirmDiscardTableCreateChanges(state)) { + return false; + } + state.shouldRestoreFocus = true; + state.dialog.close(); + return true; +} + +function ensureTableCreateDialog(manager) { + if (tableCreateDialogState) { + return tableCreateDialogState; + } + if (!window.HTMLDialogElement) { + return null; + } + + var dialog = document.createElement("dialog"); + dialog.id = TABLE_CREATE_DIALOG_ID; + dialog.className = "table-create-dialog"; + dialog.setAttribute("aria-labelledby", "table-create-title"); + dialog.innerHTML = ` + +
+ +
+
+ + +
+
+
Columns
+
+ +
+
+ +
+ `; + document.body.appendChild(dialog); + + tableCreateDialogState = { + dialog: dialog, + form: dialog.querySelector(".table-create-form"), + title: dialog.querySelector(".modal-title"), + error: dialog.querySelector(".table-create-error"), + fields: dialog.querySelector(".table-create-fields"), + tableName: dialog.querySelector(".table-create-table-name"), + columnList: dialog.querySelector(".table-create-column-list"), + addColumnButton: dialog.querySelector(".table-create-add-column"), + cancelButton: dialog.querySelector(".table-create-cancel"), + saveButton: dialog.querySelector(".table-create-save"), + currentButton: null, + shouldRestoreFocus: true, + isSaving: false, + initialSignature: "", + nextColumnIndex: 0, + manager: manager, + }; + + tableCreateDialogState.form.addEventListener("submit", function (ev) { + ev.preventDefault(); + saveTableCreateDialog(tableCreateDialogState); + }); + + tableCreateDialogState.addColumnButton.addEventListener("click", function () { + if (tableCreateDialogState.isSaving) { + return; + } + var row = addTableCreateColumn(tableCreateDialogState, { type: "text" }); + clearTableCreateDialogError(tableCreateDialogState); + row.querySelector(".table-create-column-name").focus(); + }); + + tableCreateDialogState.cancelButton.addEventListener("click", function () { + closeTableCreateDialogIfConfirmed(tableCreateDialogState); + }); + + tableCreateDialogState.tableName.addEventListener("input", function () { + clearTableCreateDialogError(tableCreateDialogState); + }); + + dialog.addEventListener("click", function (ev) { + if (ev.target === dialog) { + closeTableCreateDialogIfConfirmed(tableCreateDialogState); + } + }); + + dialog.addEventListener("keydown", function (ev) { + if (ev.key !== "Escape") { + return; + } + ev.preventDefault(); + closeTableCreateDialogIfConfirmed(tableCreateDialogState); + }); + + dialog.addEventListener("cancel", function (ev) { + ev.preventDefault(); + closeTableCreateDialogIfConfirmed(tableCreateDialogState); + }); + + dialog.addEventListener("close", function () { + var state = tableCreateDialogState; + clearTableCreateDialogError(state); + setTableCreateDialogSaving(state, false); + if ( + state.shouldRestoreFocus && + state.currentButton && + document.contains(state.currentButton) + ) { + state.currentButton.focus(); + } + }); + + return tableCreateDialogState; +} + +function openTableCreateDialog(button, manager) { + var data = databaseCreateTableData(); + if (!data) { + return; + } + var state = ensureTableCreateDialog(manager); + if (!state) { + return; + } + + var menu = button.closest("details"); + if (menu) { + menu.open = false; + } + state.manager = manager; + state.currentButton = button; + state.shouldRestoreFocus = true; + state.title.textContent = "Create a table in " + data.databaseName; + clearTableCreateDialogError(state); + resetTableCreateDialog(state); + if (!state.dialog.open) { + state.dialog.showModal(); + } + state.tableName.focus(); +} + +function initTableCreateActions(manager) { + if (!window.fetch || !window.HTMLDialogElement || !databaseCreateTableData()) { + return; + } + document.addEventListener("click", function (ev) { + var button = ev.target.closest( + 'button[data-database-action="create-table"]', + ); + if (!button) { + return; + } + ev.preventDefault(); + openTableCreateDialog(button, manager); + }); +} + function setRowDeleteDialogBusy(state, isBusy) { state.isBusy = isBusy; state.confirmButton.disabled = isBusy; @@ -2017,6 +2695,7 @@ document.addEventListener("datasette_init", function (evt) { const { detail: manager } = evt; registerBuiltinColumnFieldPlugins(manager); + initTableCreateActions(manager); initRowInsertActions(manager); initRowEditActions(manager); initRowDeleteActions(manager); diff --git a/datasette/templates/database.html b/datasette/templates/database.html index 371f6a22..23eeb571 100644 --- a/datasette/templates/database.html +++ b/datasette/templates/database.html @@ -6,6 +6,10 @@ {{- super() -}} {% include "_codemirror.html" %} {% include "_sql_parameter_styles.html" %} +{% if database_page_data.createTable %} + + +{% endif %} {% endblock %} {% block body_class %}db db-{{ database|to_css_class }}{% endblock %} diff --git a/datasette/views/database.py b/datasette/views/database.py index cd9565c6..db70b135 100644 --- a/datasette/views/database.py +++ b/datasette/views/database.py @@ -13,7 +13,8 @@ import textwrap from datasette.events import AlterTableEvent, CreateTableEvent, InsertRowsEvent from datasette.extras import extra_names_from_request from datasette.database import QueryInterrupted -from datasette.resources import DatabaseResource, QueryResource +from datasette.column_types import SQLiteType +from datasette.resources import DatabaseResource, QueryResource, TableResource from datasette.stored_queries import stored_query_to_dict from datasette.write_sql import QueryWriteRejected from datasette.utils import ( @@ -46,6 +47,18 @@ from .table_extras import ( ) from . import Context +CREATE_TABLE_COLUMN_TYPES = ["text", "integer", "float", "blob"] +CREATE_TABLE_SQLITE_TYPES = { + "text": SQLiteType.TEXT, + "integer": SQLiteType.INTEGER, + "float": SQLiteType.REAL, + "blob": SQLiteType.BLOB, +} +CREATE_TABLE_TYPE_FOR_SQLITE_TYPE = { + sqlite_type: column_type + for column_type, sqlite_type in CREATE_TABLE_SQLITE_TYPES.items() +} + class DatabaseView(View): async def get(self, request, datasette): @@ -117,21 +130,36 @@ class DatabaseView(View): else len(stored_queries) ) + # Resolve the registered database-level actions for this database in + # one batched query, seeding the request permission cache so allowed() + # calls made inside plugin hooks below are served from the cache. + database_action_permissions = await datasette.allowed_many( + actions=[ + name + for name, action in datasette.actions.items() + if action.resource_class is DatabaseResource + ], + resource=DatabaseResource(database), + actor=request.actor, + ) + create_table_ui = await _database_create_table_ui( + datasette, request, db, database, database_action_permissions + ) + async def database_actions(): - # Resolve the registered database-level actions for this - # database in one batched query, seeding the request permission - # cache so that allowed() calls made inside the plugin hooks - # below are served from the cache - await datasette.allowed_many( - actions=[ - name - for name, action in datasette.actions.items() - if action.resource_class is DatabaseResource - ], - resource=DatabaseResource(database), - actor=request.actor, - ) links = [] + if create_table_ui: + links.append( + { + "type": "button", + "label": "Create table", + "description": "Create a new table in this database.", + "attrs": { + "aria-label": "Create table in {}".format(database), + "data-database-action": "create-table", + }, + } + ) for hook in pm.hook.database_actions( datasette=datasette, database=database, @@ -211,6 +239,9 @@ class DatabaseView(View): ), metadata=metadata, database_color=db.color, + database_page_data=( + {"createTable": create_table_ui} if create_table_ui else {} + ), database_actions=database_actions, show_hidden=request.args.get("_show_hidden"), editable=True, @@ -263,6 +294,9 @@ class DatabaseContext(Context): ) metadata: dict = field(metadata={"help": "Metadata for the database"}) database_color: str = field(metadata={"help": "The color assigned to the database"}) + database_page_data: dict = field( + metadata={"help": "JSON data used by JavaScript on the database page"} + ) database_actions: callable = field( metadata={ "help": "Callable returning list of action links for the database menu" @@ -292,6 +326,57 @@ class DatabaseContext(Context): ) +async def _database_create_table_ui( + datasette, request, db, database_name, database_action_permissions +): + if not db.is_mutable: + return None + if not database_action_permissions.get("create-table"): + return None + data = { + "path": "{}/-/create".format(datasette.urls.database(database_name)), + "databaseName": database_name, + "columnTypes": CREATE_TABLE_COLUMN_TYPES, + } + can_set_column_type = await datasette.allowed( + action="set-column-type", + resource=TableResource(database=database_name, table="__new_table__"), + actor=request.actor, + ) + if can_set_column_type: + data["customColumnTypes"] = _custom_column_type_options_for_create_table( + datasette + ) + return data + + +def _custom_column_type_options_for_create_table(datasette): + options = [] + for name, ct_cls in sorted(datasette._column_types.items()): + sqlite_types = getattr(ct_cls, "sqlite_types", None) + if sqlite_types is None: + option_sqlite_types = CREATE_TABLE_COLUMN_TYPES[:] + else: + option_sqlite_types = [ + create_table_type + for create_table_type, sqlite_type in CREATE_TABLE_SQLITE_TYPES.items() + if sqlite_type in sqlite_types + ] + if not option_sqlite_types: + continue + option = { + "name": name, + "description": ct_cls.description, + "sqliteTypes": option_sqlite_types, + } + if sqlite_types is not None and len(sqlite_types) == 1: + fixed_sqlite_type = CREATE_TABLE_TYPE_FOR_SQLITE_TYPE.get(sqlite_types[0]) + if fixed_sqlite_type is not None: + option["fixedSqliteType"] = fixed_sqlite_type + options.append(option) + return options + + @dataclass class QueryContext(Context): database: str = field(metadata={"help": "The name of the database being queried"}) @@ -1069,12 +1154,7 @@ class TableCreateView(BaseView): "replace", "alter", } - _supported_column_types = { - "text", - "integer", - "float", - "blob", - } + _supported_column_types = set(CREATE_TABLE_COLUMN_TYPES) # Any string that does not contain a newline or start with sqlite_ _table_name_re = re.compile(r"^(?!sqlite_)[^\n]+$") diff --git a/tests/test_playwright.py b/tests/test_playwright.py index a8c5aa4b..0c7042f0 100644 --- a/tests/test_playwright.py +++ b/tests/test_playwright.py @@ -117,6 +117,10 @@ def write_playwright_config(config_path): { "databases": { "data": { + "permissions": { + "create-table": True, + "set-column-type": True, + }, "tables": { "projects": { "label_column": "title", @@ -275,6 +279,55 @@ def test_datasette_homepage_contains_datasette(page, datasette_server): assert "Datasette" in page.locator("body").inner_text() +@pytest.mark.playwright +def test_create_table_flow(page, datasette_server): + page.goto(f"{datasette_server}data") + page.locator("details.actions-menu-links summary").click() + page.locator('button[data-database-action="create-table"]').click() + + dialog = page.locator("#table-create-dialog") + dialog.wait_for() + assert dialog.locator(".modal-title").inner_text() == "Create a table in data" + placeholder_select = dialog.locator(".table-create-custom-column-type").nth(0) + assert placeholder_select.input_value() == "" + assert ( + placeholder_select.locator("option:checked").inner_text() == "- custom type -" + ) + assert "table-create-input-placeholder" in placeholder_select.get_attribute("class") + dialog.locator('input[name="table"]').fill("playwright_created") + dialog.locator(".table-create-column-name").nth(1).fill("title") + dialog.locator(".table-create-add-column").click() + dialog.locator(".table-create-column-name").nth(2).fill("score") + dialog.locator(".table-create-column-type").nth(2).select_option("integer") + dialog.locator(".table-create-add-column").click() + dialog.locator(".table-create-column-name").nth(3).fill("metadata") + dialog.locator(".table-create-column-type").nth(3).select_option("integer") + dialog.locator(".table-create-custom-column-type").nth(3).select_option("json") + assert dialog.locator(".table-create-column-type").nth(3).input_value() == "text" + assert "table-create-input-placeholder" not in dialog.locator( + ".table-create-custom-column-type" + ).nth(3).get_attribute("class") + + dialog.locator(".table-create-save").click() + page.wait_for_url("**/data/playwright_created") + assert "playwright_created" in page.locator("h1").inner_text() + + response = httpx.get( + f"{datasette_server}data/playwright_created.json?_extra=columns,column_types" + ) + response.raise_for_status() + data = response.json() + assert data["columns"] == [ + "id", + "title", + "score", + "metadata", + ] + assert data["column_types"] == { + "metadata": {"type": "json", "config": None}, + } + + @pytest.mark.playwright def test_navigation_search_tracks_and_renders_recent_items(page, datasette_server): page.goto(datasette_server) diff --git a/tests/test_table_html.py b/tests/test_table_html.py index aa67bb3f..887fdf50 100644 --- a/tests/test_table_html.py +++ b/tests/test_table_html.py @@ -23,6 +23,23 @@ def table_data_from_soup(soup): return json.loads(match.group(1)) +def database_data_from_soup(soup): + import json + import re + + database_script = [ + s + for s in soup.find_all("script") + if "_datasetteDatabaseData" in (s.string or "") + ][0] + match = re.search( + r"window\._datasetteDatabaseData\s*=\s*({.*?});", + database_script.string, + re.DOTALL, + ) + return json.loads(match.group(1)) + + @pytest.mark.asyncio @pytest.mark.parametrize( "path,expected_definition_sql", @@ -934,6 +951,133 @@ async def test_row_delete_action_data_attributes(): ds.close() +@pytest.mark.asyncio +async def test_database_create_table_action_button_and_data(): + ds = Datasette( + [], + config={ + "databases": { + "data": { + "permissions": { + "create-table": {"id": "root"}, + }, + }, + }, + }, + ) + try: + db = ds.add_database( + Database(ds, memory_name="test_database_create_table_action"), name="data" + ) + await db.execute_write_script(""" + create table items (id integer primary key, name text); + """) + + response = await ds.client.get("/data", actor={"id": "root"}) + assert response.status_code == 200 + soup = Soup(response.text, "html.parser") + + button = soup.select_one( + 'button.action-menu-button[data-database-action="create-table"]' + ) + assert button is not None + assert button["aria-label"] == "Create table in data" + assert button["role"] == "menuitem" + description = button.find("span", class_="dropdown-description") + assert description.text.strip() == "Create a new table in this database." + description.extract() + assert button.text.strip() == "Create table" + assert any( + "edit-tools.js" in script.get("src", "") + for script in soup.find_all("script") + ) + assert database_data_from_soup(soup) == { + "createTable": { + "path": "/data/-/create", + "databaseName": "data", + "columnTypes": ["text", "integer", "float", "blob"], + }, + } + assert "customColumnTypes" not in database_data_from_soup(soup)["createTable"] + + response_without_permission = await ds.client.get( + "/data", actor={"id": "someone-else"} + ) + assert response_without_permission.status_code == 200 + soup_without_permission = Soup(response_without_permission.text, "html.parser") + assert ( + soup_without_permission.select_one( + 'button[data-database-action="create-table"]' + ) + is None + ) + assert not any( + "_datasetteDatabaseData" in (script.string or "") + for script in soup_without_permission.find_all("script") + ) + finally: + ds.close() + + +@pytest.mark.asyncio +async def test_database_create_table_data_includes_custom_column_types(): + ds = Datasette( + [], + config={ + "databases": { + "data": { + "permissions": { + "create-table": {"id": "root"}, + "set-column-type": {"id": "root"}, + }, + }, + }, + }, + ) + try: + db = ds.add_database( + Database(ds, memory_name="test_database_create_table_custom_types"), + name="data", + ) + await db.execute_write_script(""" + create table items (id integer primary key, name text); + """) + + response = await ds.client.get("/data", actor={"id": "root"}) + assert response.status_code == 200 + create_table_data = database_data_from_soup(Soup(response.text, "html.parser"))[ + "createTable" + ] + assert create_table_data["customColumnTypes"] == [ + { + "name": "email", + "description": "Email address", + "sqliteTypes": ["text"], + "fixedSqliteType": "text", + }, + { + "name": "json", + "description": "JSON data", + "sqliteTypes": ["text"], + "fixedSqliteType": "text", + }, + { + "name": "textarea", + "description": "Multiline text", + "sqliteTypes": ["text"], + "fixedSqliteType": "text", + }, + { + "name": "url", + "description": "URL", + "sqliteTypes": ["text"], + "fixedSqliteType": "text", + }, + ] + finally: + ds.close() + + @pytest.mark.asyncio async def test_table_insert_action_button_and_data(): ds = Datasette( From b40665dd143a81b5cea99ff039b3bc8a41c41d1f Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 17 Jun 2026 09:14:19 -0700 Subject: [PATCH 02/31] Add alter table JSON API - Add POST ///-/alter with Pydantic validation and dry-run support. - Support add, rename, alter, drop, primary-key and reorder operations, including allow-listed default expressions. - Document the endpoint and cover schema changes, validation, permissions, events and dry runs. Refs #2788 --- datasette/app.py | 5 + datasette/views/table.py | 353 +++++++++++++++++++++++++++++++++++++++ docs/json_api.rst | 103 ++++++++++++ pyproject.toml | 1 + tests/test_api_write.py | 205 +++++++++++++++++++++++ 5 files changed, 667 insertions(+) diff --git a/datasette/app.py b/datasette/app.py index 139e4c34..6ea3d5a4 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -84,6 +84,7 @@ from .views.special import ( ) from .views.table import ( TableAutocompleteView, + TableAlterView, TableInsertView, TableUpsertView, TableSetColumnTypeView, @@ -2626,6 +2627,10 @@ class Datasette: TableUpsertView.as_view(self), r"/(?P[^\/\.]+)/(?P
[^\/\.]+)/-/upsert$", ) + add_route( + TableAlterView.as_view(self), + r"/(?P[^\/\.]+)/(?P
[^\/\.]+)/-/alter$", + ) add_route( TableSetColumnTypeView.as_view(self), r"/(?P[^\/\.]+)/(?P
[^\/\.]+)/-/set-column-type$", diff --git a/datasette/views/table.py b/datasette/views/table.py index c5448c85..11a28323 100644 --- a/datasette/views/table.py +++ b/datasette/views/table.py @@ -1,10 +1,12 @@ import asyncio import itertools import json +from typing import Annotated, Any, Literal, Union import urllib import urllib.parse import markupsafe +from pydantic import BaseModel, ConfigDict, Field, ValidationError, model_validator from datasette.column_types import SQLiteType from datasette.extras import extra_names_from_request @@ -46,6 +48,7 @@ from datasette.utils import ( from datasette.utils.asgi import BadRequest, Forbidden, NotFound, Request, Response from datasette.filters import Filters import sqlite_utils +from sqlite_utils.db import DEFAULT as SQLITE_UTILS_DEFAULT from .base import BaseView, DatasetteError, _error, stream_csv from .database import QueryView from .table_extras import ( @@ -649,6 +652,154 @@ async def display_columns_and_rows( return columns, cell_rows +SqliteApiType = Literal["text", "integer", "float", "blob"] +DefaultExpr = Literal["current_timestamp", "current_date", "current_time"] +DEFAULT_EXPR_SQL = { + "current_timestamp": "CURRENT_TIMESTAMP", + "current_date": "CURRENT_DATE", + "current_time": "CURRENT_TIME", +} + + +class _StrictPydanticModel(BaseModel): + model_config = ConfigDict(extra="forbid") + + +class _DefaultArgsMixin(_StrictPydanticModel): + default: Any | None = None + default_expr: DefaultExpr | None = None + + @model_validator(mode="after") + def validate_default_fields(self): + has_default = "default" in self.model_fields_set + has_default_expr = "default_expr" in self.model_fields_set + if has_default and has_default_expr: + raise ValueError("default and default_expr cannot both be provided") + if has_default_expr and self.default_expr is None: + raise ValueError("default_expr cannot be null") + return self + + +class AddColumnArgs(_DefaultArgsMixin): + name: str + type: SqliteApiType = "text" + not_null: bool = False + + +class RenameColumnArgs(_StrictPydanticModel): + name: str + to: str + + +class AlterColumnArgs(_DefaultArgsMixin): + name: str + type: SqliteApiType | None = None + not_null: bool | None = None + + @model_validator(mode="after") + def require_change(self): + if not ( + {"type", "not_null", "default", "default_expr"} & self.model_fields_set + ): + raise ValueError( + "At least one of type, not_null, default or default_expr must be provided" + ) + return self + + +class DropColumnArgs(_StrictPydanticModel): + name: str + + +class SetPrimaryKeyArgs(_StrictPydanticModel): + columns: list[str] = Field(min_length=1) + + +class ReorderColumnsArgs(_StrictPydanticModel): + columns: list[str] = Field(min_length=1) + + +class AddColumnOperation(_StrictPydanticModel): + op: Literal["add_column"] + args: AddColumnArgs + + +class RenameColumnOperation(_StrictPydanticModel): + op: Literal["rename_column"] + args: RenameColumnArgs + + +class AlterColumnOperation(_StrictPydanticModel): + op: Literal["alter_column"] + args: AlterColumnArgs + + +class DropColumnOperation(_StrictPydanticModel): + op: Literal["drop_column"] + args: DropColumnArgs + + +class SetPrimaryKeyOperation(_StrictPydanticModel): + op: Literal["set_primary_key"] + args: SetPrimaryKeyArgs + + +class ReorderColumnsOperation(_StrictPydanticModel): + op: Literal["reorder_columns"] + args: ReorderColumnsArgs + + +AlterTableOperation = Annotated[ + Union[ + AddColumnOperation, + RenameColumnOperation, + AlterColumnOperation, + DropColumnOperation, + SetPrimaryKeyOperation, + ReorderColumnsOperation, + ], + Field(discriminator="op"), +] + + +class AlterTableRequest(_StrictPydanticModel): + operations: list[AlterTableOperation] = Field(min_length=1) + dry_run: bool = False + + +def _pydantic_errors(validation_error): + errors = [] + for error in validation_error.errors(): + location = ".".join(str(item) for item in error["loc"]) + message = error["msg"] + errors.append("{}: {}".format(location, message) if location else message) + return errors + + +def _table_schema_from_conn(conn, table_name): + row = conn.execute( + "select sql from sqlite_master where type = 'table' and name = ?", + [table_name], + ).fetchone() + return row[0] if row else None + + +def _primary_key_value(columns): + if len(columns) == 1: + return columns[0] + return tuple(columns) + + +def _default_expression_sql(default_expr): + return DEFAULT_EXPR_SQL[default_expr] + + +def _literal_default(db, value): + if isinstance(value, str): + return db.quote(value) + return value + + class TableInsertView(BaseView): name = "table-insert" @@ -946,6 +1097,208 @@ class TableUpsertView(TableInsertView): return await super().post(request, upsert=True) +class TableAlterView(BaseView): + name = "table-alter" + + 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) + + db = resolved.db + database_name = db.name + table_name = resolved.table + + if not await self.ds.allowed( + action="alter-table", + resource=TableResource(database=database_name, table=table_name), + actor=request.actor, + ): + return _error(["Permission denied: need alter-table"], 403) + + if not db.is_mutable: + return _error(["Database is immutable"], 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) + + try: + alter_request = AlterTableRequest.model_validate(data) + except ValidationError as e: + return _error(_pydantic_errors(e), 400) + + def alter_table(conn): + before_schema = _table_schema_from_conn(conn, table_name) + + def apply_operations(operation_conn): + db_for_write = sqlite_utils.Database(operation_conn) + table = db_for_write[table_name] + + add_columns = [] + types = {} + rename = {} + drop = set() + not_null = {} + defaults = {} + column_order = None + pk = SQLITE_UTILS_DEFAULT + + for operation in alter_request.operations: + args = operation.args + if operation.op == "add_column": + if args.not_null and not ( + ( + "default" in args.model_fields_set + and args.default is not None + ) + or "default_expr" in args.model_fields_set + ): + raise ValueError( + "add_column args.default or args.default_expr is required when not_null is true" + ) + add_columns.append(args) + if "default" in args.model_fields_set and not args.not_null: + defaults[args.name] = _literal_default( + db_for_write, args.default + ) + if "default_expr" in args.model_fields_set and not args.not_null: + defaults[args.name] = _default_expression_sql( + args.default_expr + ) + elif operation.op == "rename_column": + rename[args.name] = args.to + elif operation.op == "alter_column": + if args.type is not None: + types[args.name] = args.type + if args.not_null is not None: + not_null[args.name] = args.not_null + if "default" in args.model_fields_set: + defaults[args.name] = ( + None + if args.default is None + else _literal_default(db_for_write, args.default) + ) + if "default_expr" in args.model_fields_set: + defaults[args.name] = _default_expression_sql( + args.default_expr + ) + elif operation.op == "drop_column": + drop.add(args.name) + elif operation.op == "set_primary_key": + pk = _primary_key_value(args.columns) + elif operation.op == "reorder_columns": + column_order = args.columns + + with operation_conn: + for column in add_columns: + not_null_default = None + if column.not_null: + if "default_expr" in column.model_fields_set: + not_null_default = _default_expression_sql( + column.default_expr + ) + else: + not_null_default = _literal_default( + db_for_write, column.default + ) + table.add_column( + column.name, + column.type, + not_null_default=not_null_default, + ) + + should_transform = any( + ( + types, + rename, + drop, + not_null, + defaults, + column_order is not None, + pk is not SQLITE_UTILS_DEFAULT, + ) + ) + if should_transform: + table.transform( + types=types or None, + rename=rename or None, + drop=drop or None, + pk=pk, + not_null=not_null or None, + defaults=defaults or None, + column_order=column_order, + ) + + return _table_schema_from_conn(operation_conn, table_name) + + if alter_request.dry_run: + memory_conn = sqlite3.connect(":memory:") + try: + conn.backup(memory_conn) + return before_schema, apply_operations(memory_conn) + finally: + memory_conn.close() + + after_schema = apply_operations(conn) + return before_schema, after_schema + + try: + before_schema, after_schema = await db.execute_write_fn( + alter_table, request=request + ) + except Exception as e: + return _error([str(e)], 400) + + altered = before_schema != after_schema + if altered and not alter_request.dry_run: + await self.ds.track_event( + AlterTableEvent( + request.actor, + database=database_name, + table=table_name, + before_schema=before_schema, + after_schema=after_schema, + ) + ) + + table_url = self.ds.absolute_url( + request, self.ds.urls.table(database_name, table_name) + ) + table_api_url = self.ds.absolute_url( + request, self.ds.urls.table(database_name, table_name, format="json") + ) + return Response.json( + { + "ok": True, + "database": database_name, + "table": table_name, + "table_url": table_url, + "table_api_url": table_api_url, + "altered": altered, + "schema": after_schema, + "before_schema": before_schema, + "operations_applied": 0 + if alter_request.dry_run + else len(alter_request.operations), + "dry_run": alter_request.dry_run, + }, + status=200, + ) + + class TableSetColumnTypeView(BaseView): name = "table-set-column-type" diff --git a/docs/json_api.rst b/docs/json_api.rst index f7a0caae..4074b479 100644 --- a/docs/json_api.rst +++ b/docs/json_api.rst @@ -2072,6 +2072,109 @@ To use the ``"replace": true`` option you will also need the :ref:`actions_updat Pass ``"alter": true`` to automatically add any missing columns to the existing table that are present in the rows you are submitting. This requires the :ref:`actions_alter_table` permission. +.. _TableAlterView: + +Altering tables +~~~~~~~~~~~~~~~ + +To alter an existing table, make a ``POST`` to ``//
/-/alter``. This requires the :ref:`actions_alter_table` permission. + +:: + + POST //
/-/alter + Content-Type: application/json + Authorization: Bearer dstok_ + +The request body should include an ``operations`` array. Each operation has the same top-level shape: an ``op`` string and an ``args`` object. + +.. code-block:: json + + { + "operations": [ + { + "op": "add_column", + "args": { + "name": "slug", + "type": "text", + "not_null": true, + "default": "" + } + }, + { + "op": "add_column", + "args": { + "name": "created", + "type": "text", + "default_expr": "current_timestamp" + } + }, + { + "op": "rename_column", + "args": { + "name": "title", + "to": "headline" + } + }, + { + "op": "alter_column", + "args": { + "name": "score", + "type": "float" + } + }, + { + "op": "drop_column", + "args": { + "name": "draft_notes" + } + }, + { + "op": "set_primary_key", + "args": { + "columns": ["id"] + } + }, + { + "op": "reorder_columns", + "args": { + "columns": ["id", "headline", "slug", "created", "score"] + } + } + ] + } + +Set ``"dry_run": true`` to validate the operations and return the schema that would be created without modifying the table. + +Supported operations: + +* ``add_column`` adds a new column. ``args`` accepts ``name``, optional ``type`` of ``text``, ``integer``, ``float`` or ``blob``, optional ``not_null``, optional literal ``default`` and optional ``default_expr``. If ``not_null`` is ``true`` either a non-null ``default`` or ``default_expr`` is required. +* ``rename_column`` renames a column. ``args`` accepts ``name`` and ``to``. +* ``alter_column`` changes column properties. ``args`` accepts ``name`` and at least one of ``type``, ``not_null``, literal ``default`` or ``default_expr``. Passing ``"default": null`` removes an existing default. +* ``drop_column`` drops a column. ``args`` accepts ``name``. +* ``set_primary_key`` changes the table primary key. ``args`` accepts ``columns``, a list of one or more column names. +* ``reorder_columns`` reorders columns. ``args`` accepts ``columns``, a list of one or more column names. Columns omitted from this list will appear afterwards in their existing order. + +``default`` is always treated as a literal value. ``default_expr`` accepts one of ``current_timestamp``, ``current_date`` or ``current_time`` and is rendered as the corresponding SQLite default expression. + +A successful response returns the new schema and the previous schema: + +.. code-block:: json + + { + "ok": true, + "database": "data", + "table": "posts", + "table_url": "http://127.0.0.1:8001/data/posts", + "table_api_url": "http://127.0.0.1:8001/data/posts.json", + "altered": true, + "schema": "CREATE TABLE ...", + "before_schema": "CREATE TABLE ...", + "operations_applied": 7, + "dry_run": false + } + +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. + .. _TableSetColumnTypeView: Setting a column type diff --git a/pyproject.toml b/pyproject.toml index a19dc957..38776b2c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,6 +39,7 @@ dependencies = [ "asyncinject>=0.7", "setuptools", "pip", + "pydantic>=2", ] [project.urls] diff --git a/tests/test_api_write.py b/tests/test_api_write.py index b7ceb6b2..f117c06e 100644 --- a/tests/test_api_write.py +++ b/tests/test_api_write.py @@ -794,6 +794,211 @@ async def test_update_row_alter(ds_write): assert response.json() == {"ok": True} +@pytest.mark.asyncio +async def test_alter_table_operations(ds_write): + token = write_token(ds_write, permissions=["at"]) + db = ds_write.get_database("data") + before_schema = await db.execute_fn( + lambda conn: conn.execute( + "select sql from sqlite_master where type = 'table' and name = 'docs'" + ).fetchone()[0] + ) + + response = await ds_write.client.post( + "/data/docs/-/alter", + json={ + "operations": [ + { + "op": "add_column", + "args": { + "name": "slug", + "type": "text", + "not_null": True, + "default": "", + }, + }, + { + "op": "add_column", + "args": { + "name": "created", + "type": "text", + "default_expr": "current_timestamp", + }, + }, + { + "op": "add_column", + "args": { + "name": "literal_default", + "type": "text", + "default": "hello)", + }, + }, + {"op": "rename_column", "args": {"name": "title", "to": "headline"}}, + { + "op": "alter_column", + "args": {"name": "age", "type": "text", "default": "0"}, + }, + {"op": "drop_column", "args": {"name": "score"}}, + { + "op": "reorder_columns", + "args": { + "columns": [ + "id", + "headline", + "slug", + "created", + "literal_default", + "age", + ] + }, + }, + {"op": "set_primary_key", "args": {"columns": ["id"]}}, + ] + }, + headers=_headers(token), + ) + + assert response.status_code == 200, response.text + data = response.json() + assert data["ok"] is True + assert data["database"] == "data" + assert data["table"] == "docs" + assert data["altered"] is True + assert data["operations_applied"] == 8 + assert data["before_schema"] == before_schema + assert "headline" in data["schema"] + assert "score" not in data["schema"] + assert "DEFAULT CURRENT_TIMESTAMP" in data["schema"] + assert "DEFAULT 'hello)'" in data["schema"] + + columns = ( + await db.execute("select * from pragma_table_info('docs') order by cid") + ).dicts() + assert [column["name"] for column in columns] == [ + "id", + "headline", + "slug", + "created", + "literal_default", + "age", + ] + assert columns[0]["pk"] == 1 + assert columns[2]["notnull"] == 1 + assert columns[2]["dflt_value"] == "''" + assert columns[3]["dflt_value"] == "CURRENT_TIMESTAMP" + assert columns[4]["dflt_value"] == "'hello)'" + assert columns[5]["type"] == "TEXT" + assert columns[5]["dflt_value"] == "'0'" + + event = last_event(ds_write) + assert event.name == "alter-table" + assert event.database == "data" + assert event.table == "docs" + assert event.before_schema == before_schema + assert event.after_schema == data["schema"] + + +@pytest.mark.asyncio +async def test_alter_table_dry_run(ds_write): + token = write_token(ds_write, permissions=["at"]) + db = ds_write.get_database("data") + response = await ds_write.client.post( + "/data/docs/-/alter", + json={ + "dry_run": True, + "operations": [ + {"op": "add_column", "args": {"name": "slug", "type": "text"}} + ], + }, + headers=_headers(token), + ) + assert response.status_code == 200, response.text + data = response.json() + assert data["ok"] is True + assert data["dry_run"] is True + assert data["altered"] is True + assert data["operations_applied"] == 0 + assert "slug" in data["schema"] + columns = ( + await db.execute("select name from pragma_table_info('docs') order by cid") + ).dicts() + assert [column["name"] for column in columns] == ["id", "title", "score", "age"] + assert last_event(ds_write) is None + + +@pytest.mark.asyncio +async def test_alter_table_permission_denied(ds_write): + token = write_token(ds_write, permissions=["ir"]) + response = await ds_write.client.post( + "/data/docs/-/alter", + json={"operations": [{"op": "add_column", "args": {"name": "slug"}}]}, + headers=_headers(token), + ) + assert response.status_code == 403 + assert response.json() == { + "ok": False, + "errors": ["Permission denied: need alter-table"], + } + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "body,expected_error", + ( + ( + {"operations": [{"op": "add_column", "args": {"type": "text"}}]}, + "operations.0.add_column.args.name: Field required", + ), + ( + { + "operations": [ + {"op": "add_column", "args": {"name": "x", "type": "bad"}} + ] + }, + "operations.0.add_column.args.type: Input should be 'text', 'integer', 'float' or 'blob'", + ), + ( + { + "operations": [ + { + "op": "add_column", + "args": { + "name": "x", + "default_expr": "datetime('now')", + }, + } + ] + }, + "operations.0.add_column.args.default_expr: Input should be 'current_timestamp', 'current_date' or 'current_time'", + ), + ( + { + "operations": [ + { + "op": "add_column", + "args": { + "name": "x", + "default": "x", + "default_expr": "current_timestamp", + }, + } + ] + }, + "operations.0.add_column.args: Value error, default and default_expr cannot both be provided", + ), + ), +) +async def test_alter_table_validation_errors(ds_write, body, expected_error): + response = await ds_write.client.post( + "/data/docs/-/alter", + json=body, + headers=_headers(write_token(ds_write, permissions=["at"])), + ) + assert response.status_code == 400 + assert response.json()["ok"] is False + assert response.json()["errors"] == [expected_error] + + @pytest.mark.asyncio async def test_execute_write_form_parameter_called_sql(): ds = Datasette(memory=True, default_deny=True) From fdd1b61a3e4bacba7e792554c5376856160f1dba Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 17 Jun 2026 09:21:09 -0700 Subject: [PATCH 03/31] Add alter table modal - Register a built-in table action and expose alter-table metadata to table pages. - Build the client-side modal for editing columns, defaults, ordering, primary keys, and custom column types. - Add a review/apply confirmation flow with HTML and Playwright coverage. Refs #2788 --- datasette/default_table_actions.py | 29 + datasette/plugins.py | 1 + datasette/static/app.css | 499 ++++++++++ datasette/static/edit-tools.js | 1478 +++++++++++++++++++++++++++- datasette/views/row.py | 1 + datasette/views/table.py | 82 +- tests/test_playwright.py | 210 ++++ tests/test_table_html.py | 117 +++ 8 files changed, 2414 insertions(+), 3 deletions(-) create mode 100644 datasette/default_table_actions.py diff --git a/datasette/default_table_actions.py b/datasette/default_table_actions.py new file mode 100644 index 00000000..e41434ef --- /dev/null +++ b/datasette/default_table_actions.py @@ -0,0 +1,29 @@ +from datasette import hookimpl +from datasette.resources import TableResource + + +@hookimpl +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( + 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", + }, + } + ] + + return inner diff --git a/datasette/plugins.py b/datasette/plugins.py index f0fbc7f8..ae2cb17d 100644 --- a/datasette/plugins.py +++ b/datasette/plugins.py @@ -31,6 +31,7 @@ DEFAULT_PLUGINS = ( "datasette.default_debug_menu", "datasette.default_jump_items", "datasette.default_database_actions", + "datasette.default_table_actions", "datasette.default_query_actions", "datasette.handle_exception", "datasette.forbidden", diff --git a/datasette/static/app.css b/datasette/static/app.css index ce0c46a8..f9ebe5ac 100644 --- a/datasette/static/app.css +++ b/datasette/static/app.css @@ -2032,6 +2032,505 @@ dialog.table-create-dialog::backdrop { cursor: not-allowed; } +dialog.table-alter-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(980px, calc(100vw - 32px)); + max-width: 95vw; + max-height: min(780px, 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-alter-dialog[open] { + display: flex; + flex-direction: column; +} + +dialog.table-alter-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-alter-dialog .modal-header { + padding: 20px 24px 12px; + border-bottom: 1px solid var(--rule); + display: flex; + align-items: center; + gap: 12px; + flex-shrink: 0; + min-width: 0; +} + +.table-alter-dialog .modal-title { + display: flex; + align-items: center; + min-width: 0; + max-width: 100%; + font-size: 1rem; + font-weight: 600; + color: var(--ink); +} + +.table-alter-form { + display: flex; + flex: 1 1 auto; + min-height: 0; + flex-direction: column; +} + +.table-alter-error { + border-left: 4px solid #b91c1c; + border-radius: 4px; + background: #fff1f1; + color: #7f1d1d; + font-size: 0.9rem; + margin: 12px 24px 0; + padding: 10px 12px; +} + +.table-alter-error:focus { + outline: 3px solid rgba(185, 28, 28, 0.18); + outline-offset: 2px; +} + +.table-alter-fields { + display: grid; + gap: 18px; + padding: 16px 24px 24px; + overflow-y: auto; +} + +.table-alter-fields[hidden], +.table-alter-dialog .modal-footer [hidden] { + display: none; +} + +.table-alter-review { + display: grid; + gap: 12px; + overflow-y: auto; + padding: 16px 24px 24px; +} + +.table-alter-review[hidden] { + display: none; +} + +.table-alter-review-title { + color: var(--ink); + font-size: 1rem; + line-height: 1.35; + margin: 0; +} + +.table-alter-review-title:focus { + outline: 3px solid rgba(26, 86, 219, 0.12); + outline-offset: 2px; +} + +.table-alter-review-intro { + color: var(--muted); + font-size: 0.9rem; + margin: 0; +} + +.table-alter-review-warning { + border-left: 4px solid #b91c1c; + border-radius: 4px; + background: #fff1f1; + color: #7f1d1d; + font-size: 0.9rem; + margin: 0; + padding: 10px 12px; +} + +.table-alter-review-list { + display: grid; + gap: 8px; + margin: 0; + padding-left: 1.4rem; +} + +.table-alter-review-list li { + color: var(--ink); + line-height: 1.4; +} + +.table-alter-review-damaging { + font-weight: 600; +} + +.table-alter-review-name { + background: #eef6ff; + border: 1px solid #c9ddf2; + border-radius: 4px; + color: var(--ink); + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.85em; + padding: 1px 4px; + white-space: nowrap; +} + +.table-alter-columns { + display: grid; + gap: 10px; +} + +.table-alter-column-list { + display: grid; + gap: 8px; +} + +.table-alter-column-headings, +.table-alter-column-main { + display: grid; + grid-template-columns: minmax(140px, 1.2fr) minmax(7.5rem, 0.7fr) minmax(12rem, 1fr) max-content 32px; + align-items: center; + gap: 8px; + min-width: 0; +} + +.table-alter-column-row { + display: grid; + gap: 8px; + min-width: 0; +} + +.table-alter-column-headings { + color: var(--muted); + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.72rem; + padding: 0 1px; +} + +.table-alter-column-label { + border: 0; + clip: rect(0 0 0 0); + height: 1px; + margin: -1px; + overflow: hidden; + padding: 0; + position: absolute; + white-space: nowrap; + width: 1px; +} + +.table-alter-input { + box-sizing: border-box; + min-width: 0; + border: 1px solid var(--rule); + border-radius: 5px; + padding: 8px 10px; + color: var(--ink); + background: #fff; + font: inherit; +} + +.table-alter-input-placeholder { + color: var(--muted); +} + +.table-alter-default-expr option, +.table-alter-custom-column-type option { + color: var(--ink); +} + +.table-alter-default-expr option[value=""], +.table-alter-custom-column-type option[value=""] { + color: var(--muted); +} + +.table-alter-input:focus { + border-color: var(--accent); + outline: 3px solid rgba(26, 86, 219, 0.12); +} + +.table-alter-column-details { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + align-items: start; + gap: 12px 16px; + padding: 12px; + border-left: 3px solid var(--rule); + background: #f8fafc; +} + +.table-alter-column-details[hidden] { + display: none; +} + +.table-alter-detail-field { + display: grid; + gap: 4px; + min-width: 0; +} + +.table-alter-detail-label { + color: var(--muted); + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.72rem; +} + +.table-alter-detail-check { + display: inline-flex; + align-items: flex-start; + gap: 8px; + color: var(--ink); + font-size: 0.85rem; + line-height: 1.35; + min-width: 0; + white-space: normal; +} + +.table-alter-not-null, +.table-alter-primary-key { + grid-column: 1 / -1; +} + +.table-alter-detail-check input { + flex: 0 0 auto; + margin: 0.15rem 0 0; +} + +.table-alter-detail-check span { + min-width: 0; + overflow-wrap: break-word; +} + +.table-alter-move-controls { + display: grid; + grid-template-columns: repeat(4, 32px); + gap: 4px; + justify-content: start; +} + +.table-alter-more-options { + appearance: none; + border: 0; + background: transparent; + color: var(--accent); + cursor: pointer; + font: inherit; + font-size: 0.85rem; + justify-self: start; + padding: 0; + grid-column: 1 / -1; + text-align: left; +} + +.table-alter-more-options:hover, +.table-alter-more-options:focus { + text-decoration: underline; +} + +.table-alter-more-options:focus { + outline: 3px solid rgba(26, 86, 219, 0.12); + outline-offset: 2px; +} + +.table-alter-more-options:disabled { + color: var(--muted); + cursor: default; + text-decoration: none; +} + +.table-alter-icon-button { + appearance: none; + border: 1px solid rgba(74, 85, 104, 0.24); + background: transparent; + color: #4a5568; + border-radius: 4px; + cursor: pointer; + display: inline-grid; + place-items: center; + height: 32px; + width: 32px; + padding: 0; +} + +.table-alter-icon-button:hover, +.table-alter-icon-button:focus { + background: rgba(74, 85, 104, 0.07); +} + +.table-alter-icon-button:focus { + outline: 3px solid #b3d4ff; + outline-offset: 1px; +} + +.table-alter-icon-button svg { + display: block; +} + +.table-alter-add-column { + appearance: none; + justify-self: start; + border: 1px solid var(--rule); + border-radius: 5px; + background: #fff; + color: var(--accent); + cursor: pointer; + font: inherit; + font-size: 0.85rem; + padding: 7px 10px; +} + +.table-alter-add-column:hover, +.table-alter-add-column:focus { + background: #f8fafc; +} + +.table-alter-add-column:focus { + outline: 3px solid rgba(26, 86, 219, 0.12); + outline-offset: 1px; +} + +.table-alter-dialog .modal-footer { + padding: 14px 20px; + border-top: 1px solid var(--rule); + display: flex; + align-items: center; + justify-content: flex-end; + gap: 10px; + flex-shrink: 0; + background: var(--paper); +} + +.table-alter-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-alter-dialog .btn-ghost { + background: transparent; + color: var(--muted); + border: 1px solid var(--rule); +} + +.table-alter-dialog .btn-ghost:hover { + background: var(--rule); + color: var(--ink); +} + +.table-alter-dialog .btn-primary { + background: var(--accent); + color: #fff; +} + +.table-alter-dialog .btn-primary:hover { + background: #1949b8; +} + +.table-alter-dialog .btn-primary:disabled, +.table-alter-dialog .btn-primary:disabled:hover { + background: #a0aec0; + color: #fff; +} + +.table-alter-dialog .btn:disabled, +.table-alter-add-column:disabled, +.table-alter-icon-button:disabled { + opacity: 0.55; + cursor: not-allowed; +} + +@media (max-width: 900px) { + dialog.table-alter-dialog { + width: 95vw; + max-height: 85vh; + border-radius: 0.5rem; + } + + .table-alter-dialog .modal-header, + .table-alter-fields, + .table-alter-review { + padding-left: 18px; + padding-right: 18px; + } + + .table-alter-error { + margin-left: 18px; + margin-right: 18px; + } + + .table-alter-column-headings { + display: none; + } + + .table-alter-column-row { + padding-bottom: 8px; + border-bottom: 1px solid var(--rule); + } + + .table-alter-column-main { + grid-template-columns: minmax(0, 1fr) minmax(7.5rem, 0.8fr) 32px; + align-items: end; + } + + .table-alter-column-name { + grid-column: 1; + grid-row: 1; + } + + .table-alter-column-type { + grid-column: 2; + grid-row: 1; + } + + .table-alter-remove-column { + grid-column: 3; + grid-row: 1; + justify-self: end; + } + + .table-alter-custom-column-type { + grid-column: 1 / 3; + grid-row: 2; + } + + .table-alter-move-controls { + grid-column: 1; + grid-row: 3; + justify-self: start; + } + + .table-alter-more-options { + align-self: center; + grid-column: 2 / 4; + grid-row: 3; + } + + .table-alter-column-details { + grid-template-columns: 1fr; + } + + .table-alter-dialog .modal-footer { + padding-left: 18px; + padding-right: 18px; + } +} + .row-link-with-actions { display: inline-flex; align-items: center; diff --git a/datasette/static/edit-tools.js b/datasette/static/edit-tools.js index 4b93c4fd..284d6bde 100644 --- a/datasette/static/edit-tools.js +++ b/datasette/static/edit-tools.js @@ -4,6 +4,8 @@ var ROW_EDIT_DIALOG_ID = "row-edit-dialog"; var rowEditDialogState = null; var TABLE_CREATE_DIALOG_ID = "table-create-dialog"; var tableCreateDialogState = null; +var TABLE_ALTER_DIALOG_ID = "table-alter-dialog"; +var tableAlterDialogState = null; function ensureRowMutationStatus(manager) { var status = document.querySelector(".row-mutation-status"); @@ -59,6 +61,16 @@ function tableCreateColumnTypes() { : ["text", "integer", "float", "blob"]; } +function sqliteColumnTypeLabel(type) { + if (type === "float") { + return "floating point number"; + } + if (type === "blob") { + return "blob - binary data"; + } + return type; +} + function tableCreateCustomColumnTypes() { var data = databaseCreateTableData() || {}; return data.customColumnTypes || []; @@ -147,7 +159,7 @@ function tableCreateSelectTypeValue(select, type) { options.forEach(function (option) { var optionElement = document.createElement("option"); optionElement.value = option; - optionElement.textContent = option; + optionElement.textContent = sqliteColumnTypeLabel(option); select.appendChild(optionElement); }); select.value = options.indexOf(type) === -1 ? options[0] : type; @@ -819,6 +831,1469 @@ function tableInsertData() { return tablePageData().insertRow; } +function tableAlterData() { + return tablePageData().alterTable; +} + +function tableAlterColumnTypes() { + var data = tableAlterData() || {}; + return data.columnTypes && data.columnTypes.length + ? data.columnTypes + : ["text", "integer", "float", "blob"]; +} + +function tableAlterDefaultExpressions() { + var data = tableAlterData() || {}; + return data.defaultExpressions || []; +} + +function tableAlterCustomColumnTypes() { + var data = tableAlterData() || {}; + return data.customColumnTypes || []; +} + +function tableAlterCustomColumnType(name) { + var options = tableAlterCustomColumnTypes(); + for (var i = 0; i < options.length; i += 1) { + if (options[i].name === name) { + return options[i]; + } + } + return null; +} + +function tableAlterCustomTypeAppliesToSqliteType(option, sqliteType) { + return ( + option && + option.sqliteTypes && + option.sqliteTypes.indexOf(sqliteType) !== -1 + ); +} + +function tableAlterDialogRows(state) { + return Array.prototype.slice.call( + state.columnList.querySelectorAll(".table-alter-column-row"), + ); +} + +function tableAlterRowSignature(row) { + return { + existing: row.dataset.existing === "1", + originalName: row.dataset.originalName || "", + name: row.querySelector(".table-alter-column-name").value, + type: row.querySelector(".table-alter-column-type").value, + customType: + ( + row.querySelector(".table-alter-custom-column-type") || { + value: "", + } + ).value || "", + notNull: row.querySelector(".table-alter-not-null-input").checked, + defaultValue: row.querySelector(".table-alter-default").value, + defaultExpr: row.querySelector(".table-alter-default-expr").value, + pk: row.querySelector(".table-alter-primary-key-input").checked, + }; +} + +function tableAlterDialogSignature(state) { + if (!state || !state.form) { + return ""; + } + return JSON.stringify({ + columns: tableAlterDialogRows(state).map(tableAlterRowSignature), + deletedColumns: state.deletedColumns.slice(), + }); +} + +function tableAlterDialogHasChanges(state) { + return ( + !!state && + !state.isSaving && + tableAlterDialogSignature(state) !== state.initialSignature + ); +} + +function updateTableAlterSaveButtonState(state) { + if (!state || !state.saveButton) { + return; + } + state.saveButton.disabled = + state.isSaving || + (state.mode !== "review" && + tableAlterDialogSignature(state) === state.initialSignature); +} + +function tableAlterRowIsPrimaryKey(row) { + var input = row && row.querySelector(".table-alter-primary-key-input"); + return !!(input && input.checked); +} + +function tableAlterFirstNonPrimaryRow(state) { + var rows = tableAlterDialogRows(state); + for (var i = 0; i < rows.length; i += 1) { + if (!tableAlterRowIsPrimaryKey(rows[i])) { + return rows[i]; + } + } + return null; +} + +function updateTableAlterMoveButtons(state) { + if (!state || !state.columnList) { + return; + } + var firstNonPrimary = tableAlterFirstNonPrimaryRow(state); + var rows = tableAlterDialogRows(state); + var hasPrimaryKeys = rows.some(function (row) { + return tableAlterRowIsPrimaryKey(row); + }); + var primaryKeyMoveTitle = "Primary key columns are always listed first"; + rows.forEach(function (row) { + var isPrimaryKey = tableAlterRowIsPrimaryKey(row); + var previous = row.previousElementSibling; + var next = row.nextElementSibling; + row.querySelectorAll(".table-alter-move-controls button").forEach( + function (button) { + button.title = button.dataset.defaultTitle || button.title; + button.disabled = state.isSaving || isPrimaryKey; + if (isPrimaryKey) { + button.title = primaryKeyMoveTitle; + } + }, + ); + if (!isPrimaryKey) { + var topButton = row.querySelector(".table-alter-move-top"); + var upButton = row.querySelector(".table-alter-move-up"); + var downButton = row.querySelector(".table-alter-move-down"); + var bottomButton = row.querySelector(".table-alter-move-bottom"); + topButton.disabled = + state.isSaving || !firstNonPrimary || row === firstNonPrimary; + upButton.disabled = + state.isSaving || !previous || tableAlterRowIsPrimaryKey(previous); + downButton.disabled = state.isSaving || !next; + bottomButton.disabled = state.isSaving || !next; + if (hasPrimaryKeys && row === firstNonPrimary) { + topButton.title = primaryKeyMoveTitle; + upButton.title = primaryKeyMoveTitle; + } + } + }); +} + +function normalizeTableAlterPrimaryKeyRows(state) { + var rows = tableAlterDialogRows(state); + rows + .filter(function (row) { + return tableAlterRowIsPrimaryKey(row); + }) + .concat( + rows.filter(function (row) { + return !tableAlterRowIsPrimaryKey(row); + }), + ) + .forEach(function (row) { + state.columnList.appendChild(row); + }); +} + +function clearTableAlterDialogError(state) { + state.error.hidden = true; + state.error.textContent = ""; + state.dialog.removeAttribute("aria-describedby"); +} + +function showTableAlterDialogError(state, message) { + state.error.hidden = false; + state.error.textContent = message; + state.dialog.setAttribute("aria-describedby", "table-alter-error"); + state.error.focus(); +} + +function setTableAlterDialogSaving(state, isSaving) { + state.isSaving = isSaving; + state.cancelButton.disabled = isSaving; + state.addColumnButton.disabled = isSaving; + state.backButton.disabled = isSaving; + state.saveButton.textContent = isSaving + ? state.mode === "review" + ? "Applying..." + : "Preparing..." + : tableAlterSaveButtonText(state); + state.columnList + .querySelectorAll("input, select, button") + .forEach(function (control) { + control.disabled = isSaving; + }); + if (!isSaving) { + state.columnList + .querySelectorAll(".table-alter-default-expr") + .forEach(function (select) { + syncTableAlterDefaultControls(select.closest(".table-alter-column-row")); + }); + } + updateTableAlterMoveButtons(state); + updateTableAlterSaveButtonState(state); +} + +function tableAlterSaveButtonText(state) { + return state && state.mode === "review" ? "Apply changes" : "Review changes"; +} + +function tableAlterSelectTypeValue(select, type) { + var options = tableAlterColumnTypes(); + options.forEach(function (option) { + var optionElement = document.createElement("option"); + optionElement.value = option; + optionElement.textContent = sqliteColumnTypeLabel(option); + select.appendChild(optionElement); + }); + select.value = options.indexOf(type) === -1 ? options[0] : type; +} + +function updateTableAlterCustomColumnTypePlaceholder(select) { + select.classList.toggle( + "table-alter-input-placeholder", + !select.value, + ); +} + +function createTableAlterCustomColumnTypeSelect() { + var options = tableAlterCustomColumnTypes(); + var select = document.createElement("select"); + select.className = "table-alter-input table-alter-custom-column-type"; + select.setAttribute("aria-label", "Custom column type"); + var blankOption = document.createElement("option"); + blankOption.value = ""; + blankOption.textContent = "- custom type -"; + select.appendChild(blankOption); + options.forEach(function (option) { + var optionElement = document.createElement("option"); + optionElement.value = option.name; + optionElement.textContent = option.description + ? option.description + " (" + option.name + ")" + : option.name; + select.appendChild(optionElement); + }); + updateTableAlterCustomColumnTypePlaceholder(select); + return select; +} + +function syncTableAlterCustomTypeForSqliteType(row) { + var typeSelect = row.querySelector(".table-alter-column-type"); + var customTypeSelect = row.querySelector(".table-alter-custom-column-type"); + if (!typeSelect || !customTypeSelect || !customTypeSelect.value) { + return; + } + var option = tableAlterCustomColumnType(customTypeSelect.value); + if (!tableAlterCustomTypeAppliesToSqliteType(option, typeSelect.value)) { + customTypeSelect.value = ""; + updateTableAlterCustomColumnTypePlaceholder(customTypeSelect); + } +} + +function tableAlterSelectDefaultExprValue(select, value) { + var blankOption = document.createElement("option"); + blankOption.value = ""; + blankOption.textContent = "- default expr -"; + select.appendChild(blankOption); + tableAlterDefaultExpressions().forEach(function (option) { + var optionElement = document.createElement("option"); + optionElement.value = option; + optionElement.textContent = option.replace(/_/g, " "); + select.appendChild(optionElement); + }); + select.value = value || ""; + updateTableAlterDefaultExprPlaceholder(select); +} + +function updateTableAlterDefaultExprPlaceholder(select) { + select.classList.toggle("table-alter-input-placeholder", !select.value); +} + +function syncTableAlterDefaultControls(row) { + if (!row) { + return; + } + var defaultInput = row.querySelector(".table-alter-default"); + var defaultExprSelect = row.querySelector(".table-alter-default-expr"); + if (!defaultInput || !defaultExprSelect) { + return; + } + defaultInput.disabled = !!defaultExprSelect.value; + updateTableAlterDefaultExprPlaceholder(defaultExprSelect); +} + +function createTableAlterColumnRow(state, column) { + var index = state.nextColumnIndex; + state.nextColumnIndex += 1; + var existing = !!(column && column.existing); + var originalName = existing ? column.name || "" : ""; + var originalCustomType = + existing && column.column_type ? column.column_type.type || "" : ""; + var originalDefault = + existing && column.has_default && column.default !== null + ? String(column.default) + : ""; + + var row = document.createElement("div"); + row.className = "table-alter-column-row"; + row.dataset.existing = existing ? "1" : "0"; + row.dataset.originalName = originalName; + row.dataset.originalType = existing ? column.type || "text" : ""; + row.dataset.originalNotNull = existing && column.notnull ? "1" : "0"; + row.dataset.originalHasDefault = existing && column.has_default ? "1" : "0"; + row.dataset.originalDefault = originalDefault; + row.dataset.originalPk = existing && column.is_pk ? "1" : "0"; + row.dataset.originalCustomType = originalCustomType; + + var main = document.createElement("div"); + main.className = "table-alter-column-main"; + + var details = document.createElement("div"); + details.className = "table-alter-column-details"; + details.id = "table-alter-column-details-" + index; + details.hidden = !(column && column.expanded); + + var expandButton = document.createElement("button"); + expandButton.type = "button"; + expandButton.className = "table-alter-more-options"; + expandButton.setAttribute("aria-label", "Toggle column settings"); + expandButton.setAttribute("aria-controls", details.id); + expandButton.setAttribute( + "aria-expanded", + details.hidden ? "false" : "true", + ); + function updateExpandButton() { + var isExpanded = expandButton.getAttribute("aria-expanded") === "true"; + expandButton.textContent = isExpanded + ? "v Hide options" + : "> Advanced options"; + expandButton.title = isExpanded + ? "Hide column settings" + : "Show column settings"; + } + updateExpandButton(); + + var nameId = "table-alter-column-name-" + index; + var nameLabel = document.createElement("label"); + nameLabel.className = "table-alter-column-label"; + nameLabel.setAttribute("for", nameId); + nameLabel.textContent = "Column"; + + var nameInput = document.createElement("input"); + nameInput.id = nameId; + nameInput.className = "table-alter-input table-alter-column-name"; + nameInput.type = "text"; + nameInput.required = true; + nameInput.autocomplete = "off"; + nameInput.value = column && column.name ? column.name : ""; + + var typeSelect = document.createElement("select"); + typeSelect.className = "table-alter-input table-alter-column-type"; + typeSelect.setAttribute("aria-label", "Column type"); + tableAlterSelectTypeValue(typeSelect, column && column.type); + + var customTypeSelect = null; + if (tableAlterCustomColumnTypes().length) { + customTypeSelect = createTableAlterCustomColumnTypeSelect(); + customTypeSelect.value = originalCustomType; + updateTableAlterCustomColumnTypePlaceholder(customTypeSelect); + } + + var notNullLabel = document.createElement("label"); + notNullLabel.className = "table-alter-detail-check table-alter-not-null"; + var notNullInput = document.createElement("input"); + notNullInput.type = "checkbox"; + notNullInput.className = "table-alter-not-null-input"; + notNullInput.checked = !!(column && column.notnull); + var notNullText = document.createElement("span"); + var notNullStrong = document.createElement("strong"); + notNullStrong.textContent = "Not null"; + notNullText.appendChild(notNullStrong); + notNullText.appendChild( + document.createTextNode(" This value cannot be left unset"), + ); + notNullLabel.appendChild(notNullInput); + notNullLabel.appendChild(notNullText); + + var defaultId = "table-alter-column-default-" + index; + var defaultField = document.createElement("div"); + defaultField.className = "table-alter-detail-field"; + var defaultLabel = document.createElement("label"); + defaultLabel.className = "table-alter-detail-label"; + defaultLabel.setAttribute("for", defaultId); + defaultLabel.textContent = "Default value"; + var defaultInput = document.createElement("input"); + defaultInput.id = defaultId; + defaultInput.className = "table-alter-input table-alter-default"; + defaultInput.type = "text"; + defaultInput.autocomplete = "off"; + defaultInput.placeholder = "default"; + defaultInput.setAttribute("aria-label", "Default value"); + defaultInput.value = originalDefault; + defaultField.appendChild(defaultLabel); + defaultField.appendChild(defaultInput); + + var defaultExprId = "table-alter-column-default-expr-" + index; + var defaultExprField = document.createElement("div"); + defaultExprField.className = "table-alter-detail-field"; + var defaultExprLabel = document.createElement("label"); + defaultExprLabel.className = "table-alter-detail-label"; + defaultExprLabel.setAttribute("for", defaultExprId); + defaultExprLabel.textContent = "or default to a specific time"; + var defaultExprSelect = document.createElement("select"); + defaultExprSelect.id = defaultExprId; + defaultExprSelect.className = + "table-alter-input table-alter-default-expr"; + defaultExprSelect.setAttribute("aria-label", "or default to a specific time"); + tableAlterSelectDefaultExprValue(defaultExprSelect, ""); + defaultExprField.appendChild(defaultExprLabel); + defaultExprField.appendChild(defaultExprSelect); + + var pkLabel = document.createElement("label"); + pkLabel.className = "table-alter-detail-check table-alter-primary-key"; + var pkInput = document.createElement("input"); + pkInput.type = "checkbox"; + pkInput.className = "table-alter-primary-key-input"; + pkInput.checked = !!(column && column.is_pk); + var pkText = document.createElement("span"); + var pkStrong = document.createElement("strong"); + pkStrong.textContent = "Primary key"; + pkText.appendChild(pkStrong); + pkText.appendChild( + document.createTextNode(" An ID that uniquely identifies this record"), + ); + pkLabel.appendChild(pkInput); + pkLabel.appendChild(pkText); + + var moveControls = document.createElement("div"); + moveControls.className = "table-alter-move-controls"; + + var moveTopButton = document.createElement("button"); + moveTopButton.type = "button"; + moveTopButton.className = "table-alter-icon-button table-alter-move-top"; + moveTopButton.setAttribute("aria-label", "Move column to top"); + moveTopButton.title = "Move column to top"; + moveTopButton.dataset.defaultTitle = moveTopButton.title; + moveTopButton.innerHTML = + ''; + + var moveUpButton = document.createElement("button"); + moveUpButton.type = "button"; + moveUpButton.className = "table-alter-icon-button table-alter-move-up"; + moveUpButton.setAttribute("aria-label", "Move column up"); + moveUpButton.title = "Move column up"; + moveUpButton.dataset.defaultTitle = moveUpButton.title; + moveUpButton.innerHTML = + ''; + + var moveDownButton = document.createElement("button"); + moveDownButton.type = "button"; + moveDownButton.className = "table-alter-icon-button table-alter-move-down"; + moveDownButton.setAttribute("aria-label", "Move column down"); + moveDownButton.title = "Move column down"; + moveDownButton.dataset.defaultTitle = moveDownButton.title; + moveDownButton.innerHTML = + ''; + + var moveBottomButton = document.createElement("button"); + moveBottomButton.type = "button"; + moveBottomButton.className = "table-alter-icon-button table-alter-move-bottom"; + moveBottomButton.setAttribute("aria-label", "Move column to bottom"); + moveBottomButton.title = "Move column to bottom"; + moveBottomButton.dataset.defaultTitle = moveBottomButton.title; + moveBottomButton.innerHTML = + ''; + + var removeButton = document.createElement("button"); + removeButton.type = "button"; + removeButton.className = "table-alter-icon-button table-alter-remove-column"; + removeButton.setAttribute( + "aria-label", + existing ? "Drop column" : "Remove column", + ); + removeButton.title = existing ? "Drop column" : "Remove column"; + removeButton.innerHTML = + ''; + + moveControls.appendChild(moveTopButton); + moveControls.appendChild(moveUpButton); + moveControls.appendChild(moveDownButton); + moveControls.appendChild(moveBottomButton); + main.appendChild(nameLabel); + main.appendChild(nameInput); + main.appendChild(typeSelect); + if (customTypeSelect) { + main.appendChild(customTypeSelect); + } + main.appendChild(moveControls); + main.appendChild(removeButton); + main.appendChild(expandButton); + + details.appendChild(notNullLabel); + details.appendChild(defaultField); + details.appendChild(defaultExprField); + details.appendChild(pkLabel); + row.appendChild(main); + row.appendChild(details); + + var controls = [ + nameInput, + typeSelect, + notNullInput, + defaultInput, + defaultExprSelect, + pkInput, + ]; + if (customTypeSelect) { + controls.push(customTypeSelect); + } + controls.forEach(function (control) { + control.addEventListener("input", function () { + clearTableAlterDialogError(state); + updateTableAlterSaveButtonState(state); + }); + control.addEventListener("change", function () { + clearTableAlterDialogError(state); + updateTableAlterSaveButtonState(state); + }); + }); + + defaultInput.addEventListener("input", function () { + if (defaultInput.value) { + defaultExprSelect.value = ""; + syncTableAlterDefaultControls(row); + } + updateTableAlterSaveButtonState(state); + }); + defaultExprSelect.addEventListener("change", function () { + if (defaultExprSelect.value) { + defaultInput.value = ""; + } + syncTableAlterDefaultControls(row); + updateTableAlterSaveButtonState(state); + }); + pkInput.addEventListener("change", function () { + normalizeTableAlterPrimaryKeyRows(state); + updateTableAlterMoveButtons(state); + updateTableAlterSaveButtonState(state); + }); + + expandButton.addEventListener("click", function () { + var isExpanded = expandButton.getAttribute("aria-expanded") === "true"; + details.hidden = isExpanded; + expandButton.setAttribute("aria-expanded", isExpanded ? "false" : "true"); + updateExpandButton(); + }); + + typeSelect.addEventListener("change", function () { + syncTableAlterCustomTypeForSqliteType(row); + updateTableAlterSaveButtonState(state); + }); + if (customTypeSelect) { + customTypeSelect.addEventListener("change", function () { + updateTableAlterCustomColumnTypePlaceholder(customTypeSelect); + var option = tableAlterCustomColumnType(customTypeSelect.value); + if ( + option && + option.fixedSqliteType && + tableAlterColumnTypes().indexOf(option.fixedSqliteType) !== -1 + ) { + typeSelect.value = option.fixedSqliteType; + } + updateTableAlterSaveButtonState(state); + }); + } + + moveTopButton.addEventListener("click", function () { + var first = tableAlterFirstNonPrimaryRow(state); + if (state.isSaving || tableAlterRowIsPrimaryKey(row) || !first || first === row) { + return; + } + state.columnList.insertBefore(row, first); + clearTableAlterDialogError(state); + updateTableAlterMoveButtons(state); + updateTableAlterSaveButtonState(state); + row.querySelector(".table-alter-column-name").focus(); + }); + + moveUpButton.addEventListener("click", function () { + var previous = row.previousElementSibling; + if ( + state.isSaving || + tableAlterRowIsPrimaryKey(row) || + !previous || + tableAlterRowIsPrimaryKey(previous) + ) { + return; + } + state.columnList.insertBefore(row, previous); + clearTableAlterDialogError(state); + updateTableAlterMoveButtons(state); + updateTableAlterSaveButtonState(state); + row.querySelector(".table-alter-column-name").focus(); + }); + + moveDownButton.addEventListener("click", function () { + var next = row.nextElementSibling; + if (state.isSaving || tableAlterRowIsPrimaryKey(row) || !next) { + return; + } + state.columnList.insertBefore(next, row); + clearTableAlterDialogError(state); + updateTableAlterMoveButtons(state); + updateTableAlterSaveButtonState(state); + row.querySelector(".table-alter-column-name").focus(); + }); + + moveBottomButton.addEventListener("click", function () { + var last = state.columnList.lastElementChild; + if (state.isSaving || tableAlterRowIsPrimaryKey(row) || !last || last === row) { + return; + } + state.columnList.appendChild(row); + clearTableAlterDialogError(state); + updateTableAlterMoveButtons(state); + updateTableAlterSaveButtonState(state); + row.querySelector(".table-alter-column-name").focus(); + }); + + removeButton.addEventListener("click", function () { + if (state.isSaving) { + return; + } + if (row.dataset.existing === "1") { + state.deletedColumns.push(row.dataset.originalName); + } + row.remove(); + clearTableAlterDialogError(state); + updateTableAlterMoveButtons(state); + updateTableAlterSaveButtonState(state); + var nextInput = state.columnList.querySelector(".table-alter-column-name"); + if (nextInput) { + nextInput.focus(); + } else { + state.addColumnButton.focus(); + } + }); + + syncTableAlterDefaultControls(row); + return row; +} + +function addTableAlterColumn(state, column) { + var row = createTableAlterColumnRow(state, column || { type: "text" }); + state.columnList.appendChild(row); + return row; +} + +function resetTableAlterDialog(state, data) { + state.nextColumnIndex = 0; + state.deletedColumns = []; + state.originalPrimaryKeys = (data.primaryKeys || []).slice(); + state.originalColumnNames = (data.columns || []).map(function (column) { + return column.name; + }); + state.columnList.textContent = ""; + (data.columns || []).forEach(function (column) { + addTableAlterColumn( + state, + Object.assign({}, column, { + existing: true, + }), + ); + }); + normalizeTableAlterPrimaryKeyRows(state); + state.initialSignature = tableAlterDialogSignature(state); + showTableAlterEditor(state); +} + +function collectTableAlterRows(state) { + return tableAlterDialogRows(state).map(function (row) { + var signature = tableAlterRowSignature(row); + signature.originalType = row.dataset.originalType || ""; + signature.originalNotNull = row.dataset.originalNotNull === "1"; + signature.originalHasDefault = row.dataset.originalHasDefault === "1"; + signature.originalDefault = row.dataset.originalDefault || ""; + signature.originalPk = row.dataset.originalPk === "1"; + signature.originalCustomType = row.dataset.originalCustomType || ""; + return signature; + }); +} + +function validateTableAlterRows(state, rows) { + if (!rows.length) { + return "At least one column is required."; + } + var seen = {}; + var supportedTypes = tableAlterColumnTypes(); + for (var i = 0; i < rows.length; i += 1) { + var row = rows[i]; + var name = row.name.trim(); + if (!name) { + return "Column name is required."; + } + if (name.indexOf("\n") !== -1) { + return "Column names cannot contain newlines."; + } + var columnKey = name.toLowerCase(); + if (seen[columnKey]) { + return "Duplicate column name: " + name; + } + seen[columnKey] = true; + if (supportedTypes.indexOf(row.type) === -1) { + return "Unsupported column type: " + row.type; + } + if (row.customType) { + var option = tableAlterCustomColumnType(row.customType); + if (!option) { + return "Unknown custom column type: " + row.customType; + } + if (!tableAlterCustomTypeAppliesToSqliteType(option, row.type)) { + return ( + "Custom type " + + row.customType + + " cannot be used with SQLite type " + + row.type + + "." + ); + } + } + if (row.defaultValue && row.defaultExpr) { + return "Use either a default value or a default expression."; + } + if (!row.existing && row.notNull && !row.defaultValue && !row.defaultExpr) { + return "New NOT NULL columns need a default or default expression."; + } + } + var pkColumns = rows.filter(function (row) { + return row.pk; + }); + if (state.originalPrimaryKeys.length && !pkColumns.length) { + return "At least one primary key column is required."; + } + return null; +} + +function collectTableAlterColumnTypeAssignments(rows) { + var assignments = []; + if (!tableAlterCustomColumnTypes().length) { + return assignments; + } + rows.forEach(function (row) { + var renamed = row.existing && row.name.trim() !== row.originalName; + if (row.customType === row.originalCustomType && !renamed) { + return; + } + if (!row.customType && !row.originalCustomType) { + return; + } + assignments.push({ + column: row.name.trim(), + columnType: row.customType || null, + sqliteType: row.type, + }); + }); + return assignments; +} + +function tableAlterPkIdentityColumns(rows) { + return rows + .filter(function (row) { + return row.pk; + }) + .map(function (row) { + return row.existing ? row.originalName : row.name.trim(); + }); +} + +function tableAlterPkChanged(state, rows) { + return ( + JSON.stringify(tableAlterPkIdentityColumns(rows)) !== + JSON.stringify(state.originalPrimaryKeys) + ); +} + +function tableAlterNaturalColumnOrder(state, rows) { + var existingRowsByOriginalName = {}; + var newRows = []; + rows.forEach(function (row) { + if (row.existing) { + existingRowsByOriginalName[row.originalName] = row; + } else { + newRows.push(row); + } + }); + var naturalOrder = []; + state.originalColumnNames.forEach(function (originalName) { + var row = existingRowsByOriginalName[originalName]; + if (row) { + naturalOrder.push(row.name.trim()); + } + }); + newRows.forEach(function (row) { + naturalOrder.push(row.name.trim()); + }); + return naturalOrder; +} + +function tableAlterColumnsReordered(state, rows) { + var finalOrder = rows.map(function (row) { + return row.name.trim(); + }); + return ( + JSON.stringify(finalOrder) !== + JSON.stringify(tableAlterNaturalColumnOrder(state, rows)) + ); +} + +function collectTableAlterPayload(state) { + var rows = collectTableAlterRows(state); + var validationError = validateTableAlterRows(state, rows); + if (validationError) { + return { error: validationError }; + } + + var operations = []; + var columnTypeAssignments = collectTableAlterColumnTypeAssignments(rows); + rows.forEach(function (row) { + var name = row.name.trim(); + if (!row.existing) { + var addArgs = { + name: name, + type: row.type, + not_null: row.notNull, + }; + if (row.defaultExpr) { + addArgs.default_expr = row.defaultExpr; + } else if (row.defaultValue || row.notNull) { + addArgs.default = row.defaultValue; + } + operations.push({ op: "add_column", args: addArgs }); + return; + } + + var originalName = row.originalName; + if (name !== originalName) { + operations.push({ + op: "rename_column", + args: { name: originalName, to: name }, + }); + } + + var alterArgs = { name: originalName }; + if (row.type !== row.originalType) { + alterArgs.type = row.type; + } + if (row.notNull !== row.originalNotNull) { + alterArgs.not_null = row.notNull; + } + if (row.defaultExpr) { + alterArgs.default_expr = row.defaultExpr; + } else if (row.originalHasDefault) { + if (row.defaultValue !== row.originalDefault) { + alterArgs.default = row.defaultValue === "" ? null : row.defaultValue; + } + } else if (row.defaultValue) { + alterArgs.default = row.defaultValue; + } + if (Object.keys(alterArgs).length > 1) { + operations.push({ op: "alter_column", args: alterArgs }); + } + }); + + state.deletedColumns.forEach(function (name) { + operations.push({ op: "drop_column", args: { name: name } }); + }); + + var pkColumns = rows + .filter(function (row) { + return row.pk; + }) + .map(function (row) { + return row.name.trim(); + }); + if (tableAlterPkChanged(state, rows)) { + operations.push({ op: "set_primary_key", args: { columns: pkColumns } }); + } + + if (tableAlterColumnsReordered(state, rows)) { + operations.push({ + op: "reorder_columns", + args: { + columns: rows.map(function (row) { + return row.name.trim(); + }), + }, + }); + } + + if (!operations.length && !columnTypeAssignments.length) { + return { error: "No changes to apply." }; + } + return { + payload: operations.length ? { operations: operations } : null, + columnTypeAssignments: columnTypeAssignments, + }; +} + +function tableAlterQuotedName(name) { + return '"' + name + '"'; +} + +function tableAlterReadableDefaultExpression(value) { + return value ? value.replace(/_/g, " ") : ""; +} + +function tableAlterReadableValue(value) { + if (value === null) { + return "NULL"; + } + return '"' + String(value) + '"'; +} + +function tableAlterOperationSummary(operation) { + var args = operation.args || {}; + if (operation.op === "add_column") { + var addDetails = ["as " + args.type]; + if (args.not_null) { + addDetails.push("with values required"); + } + if (args.default_expr) { + addDetails.push( + "defaulting to " + + tableAlterReadableDefaultExpression(args.default_expr), + ); + } else if (Object.prototype.hasOwnProperty.call(args, "default")) { + addDetails.push( + "with default value " + tableAlterReadableValue(args.default), + ); + } + return { + text: + "Add column " + + tableAlterQuotedName(args.name) + + " " + + addDetails.join(", ") + + ".", + damaging: false, + }; + } + if (operation.op === "rename_column") { + return { + text: + "Rename column " + + tableAlterQuotedName(args.name) + + " to " + + tableAlterQuotedName(args.to) + + ".", + damaging: false, + }; + } + if (operation.op === "alter_column") { + var changes = []; + if (args.type) { + changes.push("set type to " + args.type); + } + if (Object.prototype.hasOwnProperty.call(args, "not_null")) { + changes.push( + args.not_null + ? "not null (require values)" + : "allow unset values", + ); + } + if (args.default_expr) { + changes.push( + "default to " + tableAlterReadableDefaultExpression(args.default_expr), + ); + } else if (Object.prototype.hasOwnProperty.call(args, "default")) { + changes.push( + args.default === null + ? "remove the default value" + : "set default value to " + tableAlterReadableValue(args.default), + ); + } + return { + text: + "Change column " + + tableAlterQuotedName(args.name) + + ": " + + changes.join(", ") + + ".", + damaging: false, + }; + } + if (operation.op === "drop_column") { + return { + text: "Drop column " + tableAlterQuotedName(args.name) + ".", + damaging: true, + }; + } + if (operation.op === "set_primary_key") { + return { + text: + "Set primary key to " + + (args.columns || []).map(tableAlterQuotedName).join(", ") + + ".", + damaging: false, + }; + } + if (operation.op === "reorder_columns") { + return { + text: + "Set column order to " + + (args.columns || []).map(tableAlterQuotedName).join(", ") + + ".", + damaging: false, + }; + } + return { + text: "Run " + operation.op + ".", + damaging: false, + }; +} + +function tableAlterColumnTypeAssignmentSummary(assignment) { + return { + text: assignment.columnType + ? "Set custom type for column " + + tableAlterQuotedName(assignment.column) + + " to " + + assignment.columnType + + "." + : "Remove custom type from column " + + tableAlterQuotedName(assignment.column) + + ".", + damaging: false, + }; +} + +function tableAlterReviewItems(result) { + var items = []; + var operations = result.payload ? result.payload.operations || [] : []; + operations.forEach(function (operation) { + items.push(tableAlterOperationSummary(operation)); + }); + (result.columnTypeAssignments || []).forEach(function (assignment) { + items.push(tableAlterColumnTypeAssignmentSummary(assignment)); + }); + return items; +} + +function tableAlterReviewHasDamagingItems(items) { + return items.some(function (item) { + return item.damaging; + }); +} + +function appendTableAlterReviewText(element, text) { + text.split(/("[^"]+")/g).forEach(function (part) { + if (!part) { + return; + } + if (part.charAt(0) === '"' && part.charAt(part.length - 1) === '"') { + var name = document.createElement("code"); + name.className = "table-alter-review-name"; + name.textContent = part.slice(1, -1); + element.appendChild(name); + } else { + element.appendChild(document.createTextNode(part)); + } + }); +} + +function tableAlterSetColumnTypeUrl() { + var data = tableAlterData(); + if (!data || !data.path) { + return null; + } + var url = new URL(data.path, location.href); + url.pathname = url.pathname.replace(/\/-\/alter\/?$/, "/-/set-column-type"); + return url.toString(); +} + +async function assignTableAlterColumnTypes(assignments) { + if (!assignments.length) { + return; + } + var url = tableAlterSetColumnTypeUrl(); + if (!url) { + throw new Error("Could not find the set column type URL."); + } + for (var i = 0; i < assignments.length; i += 1) { + var assignment = assignments[i]; + var response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify({ + column: assignment.column, + column_type: assignment.columnType + ? { + type: assignment.columnType, + } + : null, + }), + }); + var data = null; + try { + data = await response.json(); + } catch (_error) { + data = null; + } + if (!response.ok || (data && data.ok === false)) { + var error = rowMutationRequestError(response, data); + throw new Error( + "Saved schema changes, but could not set custom type for " + + assignment.column + + ": " + + error.message, + ); + } + } +} + +function showTableAlterEditor(state) { + state.mode = "edit"; + state.reviewResult = null; + state.dialog.classList.remove("table-alter-reviewing"); + state.fields.hidden = false; + state.review.hidden = true; + state.review.textContent = ""; + state.backButton.hidden = true; + state.saveButton.textContent = tableAlterSaveButtonText(state); + updateTableAlterMoveButtons(state); + updateTableAlterSaveButtonState(state); +} + +function showTableAlterReview(state, result) { + var items = tableAlterReviewItems(result); + state.mode = "review"; + state.reviewResult = result; + state.dialog.classList.add("table-alter-reviewing"); + state.fields.hidden = true; + state.review.hidden = false; + state.review.textContent = ""; + state.backButton.hidden = false; + state.saveButton.textContent = tableAlterSaveButtonText(state); + updateTableAlterSaveButtonState(state); + + var heading = document.createElement("h3"); + heading.className = "table-alter-review-title"; + heading.tabIndex = -1; + heading.textContent = "Review changes"; + state.review.appendChild(heading); + + var intro = document.createElement("p"); + intro.className = "table-alter-review-intro"; + intro.textContent = "These changes will be applied to the table."; + state.review.appendChild(intro); + + if (tableAlterReviewHasDamagingItems(items)) { + var warning = document.createElement("p"); + warning.className = "table-alter-review-warning"; + warning.setAttribute("role", "alert"); + warning.textContent = + "Warning: data in dropped columns will be permanently lost."; + state.review.appendChild(warning); + } + + var list = document.createElement("ol"); + list.className = "table-alter-review-list"; + items.forEach(function (item) { + var listItem = document.createElement("li"); + appendTableAlterReviewText(listItem, item.text); + if (item.damaging) { + listItem.className = "table-alter-review-damaging"; + } + list.appendChild(listItem); + }); + state.review.appendChild(list); + heading.focus(); +} + +async function applyTableAlterChanges(state, result) { + if (state.isSaving) { + return; + } + if (!result) { + showTableAlterDialogError(state, "Could not find the reviewed changes."); + return; + } + var data = tableAlterData(); + if (!data || !data.path) { + showTableAlterDialogError(state, "Could not find the alter table URL."); + return; + } + clearTableAlterDialogError(state); + if (result.error) { + showTableAlterDialogError(state, result.error); + return; + } + setTableAlterDialogSaving(state, true); + try { + if (result.payload) { + var response = await fetch(data.path, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify(result.payload), + }); + var responseData = null; + try { + responseData = await response.json(); + } catch (_error) { + responseData = null; + } + if (!response.ok || (responseData && responseData.ok === false)) { + throw rowMutationRequestError(response, responseData); + } + } + await assignTableAlterColumnTypes(result.columnTypeAssignments || []); + state.shouldRestoreFocus = false; + state.dialog.close(); + location.reload(); + } catch (error) { + setTableAlterDialogSaving(state, false); + showTableAlterDialogError(state, error.message || "Could not alter table"); + } +} + +async function saveTableAlterDialog(state) { + if (state.isSaving) { + return; + } + if (state.mode === "review") { + if (!state.reviewResult) { + showTableAlterDialogError(state, "Could not find the reviewed changes."); + return; + } + await applyTableAlterChanges(state, state.reviewResult); + return; + } + clearTableAlterDialogError(state); + var result = collectTableAlterPayload(state); + if (result.error) { + showTableAlterDialogError(state, result.error); + return; + } + showTableAlterReview(state, result); +} + +function confirmDiscardTableAlterChanges(state) { + if (!tableAlterDialogHasChanges(state)) { + return true; + } + return window.confirm("Discard table changes?"); +} + +function closeTableAlterDialogIfConfirmed(state) { + if (!state || state.isSaving) { + return false; + } + if (!confirmDiscardTableAlterChanges(state)) { + return false; + } + state.shouldRestoreFocus = true; + state.dialog.close(); + return true; +} + +function closeTableAlterDialog(state) { + if (!state || state.isSaving) { + return false; + } + state.shouldRestoreFocus = true; + state.dialog.close(); + return true; +} + +function ensureTableAlterDialog(manager) { + if (tableAlterDialogState) { + return tableAlterDialogState; + } + if (!window.HTMLDialogElement) { + return null; + } + + var dialog = document.createElement("dialog"); + dialog.id = TABLE_ALTER_DIALOG_ID; + dialog.className = "table-alter-dialog"; + dialog.setAttribute("aria-labelledby", "table-alter-title"); + dialog.innerHTML = ` + +
+ +
+
+ +
+ +
+
+ + + + `; + document.body.appendChild(dialog); + + tableAlterDialogState = { + dialog: dialog, + form: dialog.querySelector(".table-alter-form"), + title: dialog.querySelector(".modal-title"), + error: dialog.querySelector(".table-alter-error"), + fields: dialog.querySelector(".table-alter-fields"), + review: dialog.querySelector(".table-alter-review"), + columnList: dialog.querySelector(".table-alter-column-list"), + addColumnButton: dialog.querySelector(".table-alter-add-column"), + backButton: dialog.querySelector(".table-alter-back"), + cancelButton: dialog.querySelector(".table-alter-cancel"), + saveButton: dialog.querySelector(".table-alter-save"), + currentButton: null, + shouldRestoreFocus: true, + isSaving: false, + initialSignature: "", + nextColumnIndex: 0, + deletedColumns: [], + originalColumnNames: [], + originalPrimaryKeys: [], + mode: "edit", + reviewResult: null, + manager: manager, + }; + + tableAlterDialogState.form.addEventListener("submit", function (ev) { + ev.preventDefault(); + saveTableAlterDialog(tableAlterDialogState); + }); + + tableAlterDialogState.addColumnButton.addEventListener("click", function () { + if (tableAlterDialogState.isSaving) { + return; + } + var row = addTableAlterColumn(tableAlterDialogState, { + type: "text", + existing: false, + expanded: true, + }); + clearTableAlterDialogError(tableAlterDialogState); + updateTableAlterMoveButtons(tableAlterDialogState); + updateTableAlterSaveButtonState(tableAlterDialogState); + row.querySelector(".table-alter-column-name").focus(); + }); + + tableAlterDialogState.cancelButton.addEventListener("click", function () { + closeTableAlterDialog(tableAlterDialogState); + }); + + tableAlterDialogState.backButton.addEventListener("click", function () { + if (tableAlterDialogState.isSaving) { + return; + } + clearTableAlterDialogError(tableAlterDialogState); + showTableAlterEditor(tableAlterDialogState); + var firstName = tableAlterDialogState.columnList.querySelector( + ".table-alter-column-name", + ); + if (firstName) { + firstName.focus(); + } + }); + + dialog.addEventListener("click", function (ev) { + if (ev.target === dialog) { + closeTableAlterDialogIfConfirmed(tableAlterDialogState); + } + }); + + dialog.addEventListener("keydown", function (ev) { + if (ev.key !== "Escape") { + return; + } + ev.preventDefault(); + closeTableAlterDialogIfConfirmed(tableAlterDialogState); + }); + + dialog.addEventListener("cancel", function (ev) { + ev.preventDefault(); + closeTableAlterDialogIfConfirmed(tableAlterDialogState); + }); + + dialog.addEventListener("close", function () { + var state = tableAlterDialogState; + clearTableAlterDialogError(state); + setTableAlterDialogSaving(state, false); + if ( + state.shouldRestoreFocus && + state.currentButton && + document.contains(state.currentButton) + ) { + state.currentButton.focus(); + } + }); + + return tableAlterDialogState; +} + +function openTableAlterDialog(button, manager) { + var data = tableAlterData(); + if (!data) { + return; + } + var state = ensureTableAlterDialog(manager); + if (!state) { + return; + } + + var menu = button.closest("details"); + if (menu) { + menu.open = false; + } + state.manager = manager; + state.currentButton = button; + state.shouldRestoreFocus = true; + state.title.textContent = "Alter table " + data.tableName; + clearTableAlterDialogError(state); + resetTableAlterDialog(state, data); + if (!state.dialog.open) { + state.dialog.showModal(); + } + var firstName = state.columnList.querySelector(".table-alter-column-name"); + if (firstName) { + firstName.focus(); + } +} + +function initTableAlterActions(manager) { + if (!window.fetch || !window.HTMLDialogElement || !tableAlterData()) { + return; + } + document.addEventListener("click", function (ev) { + var button = ev.target.closest('button[data-table-action="alter-table"]'); + if (!button) { + return; + } + ev.preventDefault(); + openTableAlterDialog(button, manager); + }); +} + function tableForeignKeys() { return tablePageData().foreignKeys || {}; } @@ -2696,6 +4171,7 @@ document.addEventListener("datasette_init", function (evt) { registerBuiltinColumnFieldPlugins(manager); initTableCreateActions(manager); + initTableAlterActions(manager); initRowInsertActions(manager); initRowEditActions(manager); initRowDeleteActions(manager); diff --git a/datasette/views/row.py b/datasette/views/row.py index 4d61eb91..34773d48 100644 --- a/datasette/views/row.py +++ b/datasette/views/row.py @@ -212,6 +212,7 @@ class RowView(DataView): table, not is_table, None, + None, ), "row_actions": row_actions, "top_row": make_slot_function( diff --git a/datasette/views/table.py b/datasette/views/table.py index 11a28323..5175c7d9 100644 --- a/datasette/views/table.py +++ b/datasette/views/table.py @@ -50,7 +50,7 @@ from datasette.filters import Filters import sqlite_utils from sqlite_utils.db import DEFAULT as SQLITE_UTILS_DEFAULT from .base import BaseView, DatasetteError, _error, stream_csv -from .database import QueryView +from .database import QueryView, _custom_column_type_options_for_create_table from .table_extras import ( TABLE_EXTRA_BUNDLES, TableExtraContext, @@ -62,6 +62,13 @@ LINK_WITH_LABEL = ( '{label} {id}' ) LINK_WITH_VALUE = '{id}' +ALTER_TABLE_COLUMN_TYPES = ["text", "integer", "float", "blob"] +ALTER_TABLE_TYPE_FOR_SQLITE_TYPE = { + SQLiteType.TEXT: "text", + SQLiteType.INTEGER: "integer", + SQLiteType.REAL: "float", + SQLiteType.BLOB: "blob", +} class Row: @@ -283,7 +290,14 @@ async def _foreign_key_autocomplete_urls( async def _table_page_data( - datasette, request, db, database_name, table_name, is_view, table_insert_ui + datasette, + request, + db, + database_name, + table_name, + is_view, + table_insert_ui, + table_alter_ui, ): data = { "database": database_name, @@ -292,6 +306,8 @@ async def _table_page_data( } if table_insert_ui: data["insertRow"] = table_insert_ui + if table_alter_ui: + data["alterTable"] = table_alter_ui if not is_view: foreign_keys = await _foreign_key_autocomplete_urls( datasette, request, db, database_name, table_name @@ -354,6 +370,63 @@ async def _table_insert_ui( } +async def _table_alter_ui( + datasette, request, db, database_name, table_name, is_view, pks +): + if is_view or not db.is_mutable: + return None + + if not await datasette.allowed( + action="alter-table", + resource=TableResource(database=database_name, table=table_name), + actor=request.actor, + ): + return None + + column_types_map = await datasette.get_column_types(database_name, table_name) + columns = [] + for column in await db.table_column_details(table_name): + if column.hidden: + continue + sqlite_type = SQLiteType.from_declared_type(column.type) + column_type = column_types_map.get(column.name) + columns.append( + { + "name": column.name, + "type": ALTER_TABLE_TYPE_FOR_SQLITE_TYPE.get(sqlite_type, "text"), + "sqlite_type": sqlite_type.value, + "notnull": column.notnull, + "default": column.default_value, + "has_default": column.default_value is not None, + "is_pk": column.name in pks, + "column_type": ( + {"type": column_type.name, "config": column_type.config} + if column_type is not None + else None + ), + } + ) + + data = { + "path": "{}/-/alter".format(datasette.urls.table(database_name, table_name)), + "tableName": table_name, + "columns": columns, + "primaryKeys": pks, + "columnTypes": ALTER_TABLE_COLUMN_TYPES, + "defaultExpressions": list(DEFAULT_EXPR_SQL), + } + can_set_column_type = await datasette.allowed( + action="set-column-type", + resource=TableResource(database=database_name, table=table_name), + actor=request.actor, + ) + if can_set_column_type: + data["customColumnTypes"] = _custom_column_type_options_for_create_table( + datasette + ) + return data + + async def display_columns_and_rows( datasette, database_name, @@ -2421,7 +2494,11 @@ async def table_view_data( table_insert_ui = await _table_insert_ui( datasette, request, db, database_name, table_name, is_view, pks ) + table_alter_ui = await _table_alter_ui( + datasette, request, db, database_name, table_name, is_view, pks + ) data["table_insert_ui"] = table_insert_ui + data["table_alter_ui"] = table_alter_ui data["table_page_data"] = await _table_page_data( datasette, request, @@ -2430,6 +2507,7 @@ async def table_view_data( table_name, is_view, table_insert_ui, + table_alter_ui, ) return data, rows[:page_size], columns, expanded_columns, sql, next_url diff --git a/tests/test_playwright.py b/tests/test_playwright.py index 0c7042f0..b7990009 100644 --- a/tests/test_playwright.py +++ b/tests/test_playwright.py @@ -130,6 +130,7 @@ def write_playwright_config(config_path): "notes": "textarea", }, "permissions": { + "alter-table": True, "insert-row": True, "update-row": True, "delete-row": True, @@ -328,6 +329,215 @@ def test_create_table_flow(page, datasette_server): } +@pytest.mark.playwright +def test_alter_table_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="alter-table"]').click() + + dialog = page.locator("#table-alter-dialog") + dialog.wait_for() + assert dialog.locator(".modal-title").inner_text() == "Alter table projects" + assert dialog.locator(".table-alter-save").is_disabled() + type_options = dialog.locator(".table-alter-column-type").first.locator("option") + assert type_options.all_inner_texts() == [ + "text", + "integer", + "floating point number", + "blob - binary data", + ] + first_more_options = dialog.locator(".table-alter-more-options").first + assert first_more_options.inner_text() == "> Advanced options" + first_more_options.click() + assert first_more_options.inner_text() == "v Hide options" + expanded_options_text = dialog.locator(".table-alter-column-details").first.inner_text() + assert dialog.locator(".table-alter-fields").evaluate( + "node => node.scrollWidth <= node.clientWidth + 1" + ) + assert "Not null" in expanded_options_text + assert "This value cannot be left unset" in expanded_options_text + assert "Default value" in expanded_options_text + assert "or default to a specific time" in expanded_options_text + assert "Primary key" in expanded_options_text + assert "An ID that uniquely identifies this record" in expanded_options_text + + dialog.locator(".table-alter-add-column").click() + assert dialog.locator(".table-alter-save").is_enabled() + dialog.locator(".table-alter-column-name").last.fill("status") + dialog.locator(".table-alter-column-type").last.select_option("text") + dialog.locator(".table-alter-default").last.fill("planned") + dialog.locator(".table-alter-save").click() + review = dialog.locator(".table-alter-review") + review.wait_for() + assert not dialog.locator(".table-alter-column-list").is_visible() + review_text = review.inner_text() + assert "Add column status as text, with default value planned." in review_text + assert "Set column order to" not in review_text + assert dialog.locator(".table-alter-back").is_visible() + assert dialog.locator(".table-alter-save").inner_text() == "Apply changes" + dialog.locator(".table-alter-save").click() + + columns = [] + for _ in range(20): + response = httpx.get(f"{datasette_server}data/projects.json?_extra=columns") + response.raise_for_status() + columns = response.json()["columns"] + if "status" in columns: + break + time.sleep(0.1) + assert "status" in columns + + +@pytest.mark.playwright +def test_alter_table_primary_key_columns_stay_at_top(page, datasette_server): + page.goto(f"{datasette_server}data/projects") + page.locator("details.actions-menu-links summary").click() + page.locator('button[data-table-action="alter-table"]').click() + + dialog = page.locator("#table-alter-dialog") + dialog.wait_for() + rows = dialog.locator(".table-alter-column-row") + assert rows.nth(0).locator(".table-alter-column-name").input_value() == "id" + first_row_move_buttons = rows.nth(0).locator(".table-alter-move-controls button") + for i in range(first_row_move_buttons.count()): + assert first_row_move_buttons.nth(i).is_disabled() + assert ( + first_row_move_buttons.nth(i).get_attribute("title") + == "Primary key columns are always listed first" + ) + + assert rows.nth(1).locator(".table-alter-move-up").is_disabled() + assert rows.nth(1).locator(".table-alter-move-top").get_attribute("title") == ( + "Primary key columns are always listed first" + ) + assert rows.nth(1).locator(".table-alter-move-up").get_attribute("title") == ( + "Primary key columns are always listed first" + ) + last_row = rows.nth(rows.count() - 1) + assert last_row.locator(".table-alter-column-name").input_value() == "score" + last_row.locator(".table-alter-move-top").click() + assert rows.nth(0).locator(".table-alter-column-name").input_value() == "id" + assert rows.nth(1).locator(".table-alter-column-name").input_value() == "score" + + +@pytest.mark.playwright +def test_alter_table_review_rename_primary_key_column(page, datasette_server): + page.goto(f"{datasette_server}data/projects") + page.locator("details.actions-menu-links summary").click() + page.locator('button[data-table-action="alter-table"]').click() + + dialog = page.locator("#table-alter-dialog") + dialog.wait_for() + save = dialog.locator(".table-alter-save") + assert save.is_disabled() + dialog.locator(".table-alter-column-name").first.fill("id3") + assert save.is_enabled() + save.click() + + review = dialog.locator(".table-alter-review") + review.wait_for() + review_text = review.inner_text() + assert "Rename column id to id3." in review_text + assert "Set primary key to" not in review_text + assert dialog.locator(".table-alter-review-name").all_inner_texts() == [ + "id", + "id3", + ] + + +@pytest.mark.playwright +def test_alter_table_review_not_null_wording(page, datasette_server): + page.goto(f"{datasette_server}data/projects") + page.locator("details.actions-menu-links summary").click() + page.locator('button[data-table-action="alter-table"]').click() + + dialog = page.locator("#table-alter-dialog") + dialog.wait_for() + dialog.locator(".table-alter-more-options").first.click() + dialog.locator(".table-alter-not-null-input").first.check() + dialog.locator(".table-alter-save").click() + + review = dialog.locator(".table-alter-review") + review.wait_for() + assert "Change column id: not null (require values)." in review.inner_text() + + +@pytest.mark.playwright +def test_alter_table_review_warns_when_dropping_column(page, datasette_server): + page.goto(f"{datasette_server}data/projects") + page.locator("details.actions-menu-links summary").click() + page.locator('button[data-table-action="alter-table"]').click() + + dialog = page.locator("#table-alter-dialog") + dialog.wait_for() + remove_buttons = dialog.locator(".table-alter-remove-column") + remove_buttons.nth(remove_buttons.count() - 1).click() + dialog.locator(".table-alter-save").click() + + review = dialog.locator(".table-alter-review") + review.wait_for() + assert not dialog.locator(".table-alter-column-list").is_visible() + review_text = review.inner_text() + assert "Warning: data in dropped columns will be permanently lost." in review_text + assert "Drop column score." in review_text + assert "Set column order to" not in review_text + assert dialog.locator(".table-alter-review-damaging").inner_text() == ( + "Drop column score." + ) + + dialog.locator(".table-alter-back").click() + assert dialog.locator(".table-alter-column-list").is_visible() + assert dialog.locator(".table-alter-save").inner_text() == "Review changes" + + +@pytest.mark.playwright +def test_alter_table_cancel_skips_discard_prompt(page, datasette_server): + def open_alter_dialog(): + page.locator("details.actions-menu-links").evaluate("node => node.open = true") + page.locator('button[data-table-action="alter-table"]').click() + dialog = page.locator("#table-alter-dialog") + dialog.wait_for() + return dialog + + page.goto(f"{datasette_server}data/projects") + page.evaluate( + """ + () => { + window.__discardConfirmMessages = []; + window.confirm = (message) => { + window.__discardConfirmMessages.push(message); + return false; + }; + } + """ + ) + + dialog = open_alter_dialog() + dialog.locator(".table-alter-add-column").click() + dialog.locator(".table-alter-column-name").last.fill("cancel_me") + dialog.locator(".table-alter-cancel").click() + assert dialog.evaluate("node => node.open") is False + assert page.evaluate("() => window.__discardConfirmMessages") == [] + + dialog = open_alter_dialog() + dialog.locator(".table-alter-add-column").click() + dialog.locator(".table-alter-column-name").last.fill("escape_me") + page.keyboard.press("Escape") + assert page.evaluate("() => window.__discardConfirmMessages") == [ + "Discard table changes?" + ] + assert dialog.evaluate("node => node.open") is True + + page.evaluate("() => window.__discardConfirmMessages = []") + dialog.evaluate( + """node => node.dispatchEvent(new MouseEvent("click", {bubbles: true}))""" + ) + assert page.evaluate("() => window.__discardConfirmMessages") == [ + "Discard table changes?" + ] + assert dialog.evaluate("node => node.open") is True + + @pytest.mark.playwright def test_navigation_search_tracks_and_renders_recent_items(page, datasette_server): page.goto(datasette_server) diff --git a/tests/test_table_html.py b/tests/test_table_html.py index 887fdf50..374cc08d 100644 --- a/tests/test_table_html.py +++ b/tests/test_table_html.py @@ -1078,6 +1078,123 @@ async def test_database_create_table_data_includes_custom_column_types(): ds.close() +@pytest.mark.asyncio +async def test_table_alter_action_button_and_data(): + ds = Datasette( + [], + config={ + "databases": { + "data": { + "tables": { + "items": { + "permissions": { + "alter-table": {"id": "root"}, + "set-column-type": {"id": "root"}, + }, + "column_types": {"name": "textarea"}, + }, + }, + }, + }, + }, + ) + try: + db = ds.add_database( + Database(ds, memory_name="test_table_alter_action"), name="data" + ) + await db.execute_write_script(""" + create table items ( + id integer primary key, + name text not null, + score integer default 5 + ); + """) + response = await ds.client.get("/data/items", 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="alter-table"]' + ) + assert button is not None + assert button["aria-label"] == "Alter table items" + assert button["role"] == "menuitem" + description = button.find("span", class_="dropdown-description") + assert description.text.strip() == ( + "Change columns and primary key for this table." + ) + description.extract() + assert button.text.strip() == "Alter table" + assert any( + "edit-tools.js" in script.get("src", "") + for script in soup.find_all("script") + ) + + alter_data = table_data_from_soup(soup)["alterTable"] + assert alter_data["path"] == "/data/items/-/alter" + assert alter_data["tableName"] == "items" + assert alter_data["primaryKeys"] == ["id"] + assert alter_data["columnTypes"] == ["text", "integer", "float", "blob"] + assert alter_data["defaultExpressions"] == [ + "current_timestamp", + "current_date", + "current_time", + ] + assert [option["name"] for option in alter_data["customColumnTypes"]] == [ + "email", + "json", + "textarea", + "url", + ] + assert alter_data["columns"] == [ + { + "name": "id", + "type": "integer", + "sqlite_type": "INTEGER", + "notnull": 0, + "default": None, + "has_default": False, + "is_pk": True, + "column_type": None, + }, + { + "name": "name", + "type": "text", + "sqlite_type": "TEXT", + "notnull": 1, + "default": None, + "has_default": False, + "is_pk": False, + "column_type": {"type": "textarea", "config": None}, + }, + { + "name": "score", + "type": "integer", + "sqlite_type": "INTEGER", + "notnull": 0, + "default": "5", + "has_default": True, + "is_pk": False, + "column_type": None, + }, + ] + + response_without_permission = await ds.client.get( + "/data/items", actor={"id": "someone-else"} + ) + assert response_without_permission.status_code == 200 + soup_without_permission = Soup(response_without_permission.text, "html.parser") + assert ( + soup_without_permission.select_one( + 'button[data-table-action="alter-table"]' + ) + is None + ) + assert "alterTable" not in table_data_from_soup(soup_without_permission) + finally: + ds.close() + + @pytest.mark.asyncio async def test_table_insert_action_button_and_data(): ds = Datasette( From 15a3ac58cc10a4004e21e67845e53b2ead26a3b3 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 17 Jun 2026 09:24:28 -0700 Subject: [PATCH 04/31] Ran Prettier --- datasette/static/edit-tools.js | 96 +++++++++++++++++++--------------- 1 file changed, 53 insertions(+), 43 deletions(-) diff --git a/datasette/static/edit-tools.js b/datasette/static/edit-tools.js index 284d6bde..cb964365 100644 --- a/datasette/static/edit-tools.js +++ b/datasette/static/edit-tools.js @@ -49,8 +49,7 @@ function hideRowMutationStatus() { function databaseCreateTableData() { return ( - window._datasetteDatabaseData && - window._datasetteDatabaseData.createTable + window._datasetteDatabaseData && window._datasetteDatabaseData.createTable ); } @@ -166,10 +165,7 @@ function tableCreateSelectTypeValue(select, type) { } function updateTableCreateCustomColumnTypePlaceholder(select) { - select.classList.toggle( - "table-create-input-placeholder", - !select.value, - ); + select.classList.toggle("table-create-input-placeholder", !select.value); } function createTableCustomColumnTypeSelect() { @@ -273,9 +269,7 @@ function createTableColumnRow(state, column) { } row.remove(); clearTableCreateDialogError(state); - var nextInput = state.columnList.querySelector( - ".table-create-column-name", - ); + var nextInput = state.columnList.querySelector(".table-create-column-name"); if (nextInput) { nextInput.focus(); } else { @@ -418,7 +412,9 @@ function validateTableCreateColumnTypeAssignments(assignments) { if (!option) { return "Unknown custom column type: " + assignment.columnType; } - if (!tableCreateCustomTypeAppliesToSqliteType(option, assignment.sqliteType)) { + if ( + !tableCreateCustomTypeAppliesToSqliteType(option, assignment.sqliteType) + ) { return ( "Custom type " + assignment.columnType + @@ -441,7 +437,8 @@ function fallbackTableUrl(tableName) { function tableCreateSetColumnTypeUrl(responseData, payload) { var tableUrl = - responseData.table_url || fallbackTableUrl(responseData.table || payload.table); + responseData.table_url || + fallbackTableUrl(responseData.table || payload.table); if (!tableUrl) { return null; } @@ -452,7 +449,11 @@ function tableCreateSetColumnTypeUrl(responseData, payload) { return url.toString(); } -async function assignTableCreateColumnTypes(responseData, payload, assignments) { +async function assignTableCreateColumnTypes( + responseData, + payload, + assignments, +) { if (!assignments.length) { return; } @@ -542,7 +543,8 @@ async function saveTableCreateDialog(state) { columnTypeAssignments, ); var tableUrl = - responseData.table_url || fallbackTableUrl(responseData.table || payload.table); + responseData.table_url || + fallbackTableUrl(responseData.table || payload.table); state.shouldRestoreFocus = false; state.dialog.close(); if (tableUrl) { @@ -718,7 +720,11 @@ function openTableCreateDialog(button, manager) { } function initTableCreateActions(manager) { - if (!window.fetch || !window.HTMLDialogElement || !databaseCreateTableData()) { + if ( + !window.fetch || + !window.HTMLDialogElement || + !databaseCreateTableData() + ) { return; } document.addEventListener("click", function (ev) { @@ -952,15 +958,15 @@ function updateTableAlterMoveButtons(state) { var isPrimaryKey = tableAlterRowIsPrimaryKey(row); var previous = row.previousElementSibling; var next = row.nextElementSibling; - row.querySelectorAll(".table-alter-move-controls button").forEach( - function (button) { + row + .querySelectorAll(".table-alter-move-controls button") + .forEach(function (button) { button.title = button.dataset.defaultTitle || button.title; button.disabled = state.isSaving || isPrimaryKey; if (isPrimaryKey) { button.title = primaryKeyMoveTitle; } - }, - ); + }); if (!isPrimaryKey) { var topButton = row.querySelector(".table-alter-move-top"); var upButton = row.querySelector(".table-alter-move-up"); @@ -1028,7 +1034,9 @@ function setTableAlterDialogSaving(state, isSaving) { state.columnList .querySelectorAll(".table-alter-default-expr") .forEach(function (select) { - syncTableAlterDefaultControls(select.closest(".table-alter-column-row")); + syncTableAlterDefaultControls( + select.closest(".table-alter-column-row"), + ); }); } updateTableAlterMoveButtons(state); @@ -1051,10 +1059,7 @@ function tableAlterSelectTypeValue(select, type) { } function updateTableAlterCustomColumnTypePlaceholder(select) { - select.classList.toggle( - "table-alter-input-placeholder", - !select.value, - ); + select.classList.toggle("table-alter-input-placeholder", !select.value); } function createTableAlterCustomColumnTypeSelect() { @@ -1159,10 +1164,7 @@ function createTableAlterColumnRow(state, column) { expandButton.className = "table-alter-more-options"; expandButton.setAttribute("aria-label", "Toggle column settings"); expandButton.setAttribute("aria-controls", details.id); - expandButton.setAttribute( - "aria-expanded", - details.hidden ? "false" : "true", - ); + expandButton.setAttribute("aria-expanded", details.hidden ? "false" : "true"); function updateExpandButton() { var isExpanded = expandButton.getAttribute("aria-expanded") === "true"; expandButton.textContent = isExpanded @@ -1243,8 +1245,7 @@ function createTableAlterColumnRow(state, column) { defaultExprLabel.textContent = "or default to a specific time"; var defaultExprSelect = document.createElement("select"); defaultExprSelect.id = defaultExprId; - defaultExprSelect.className = - "table-alter-input table-alter-default-expr"; + defaultExprSelect.className = "table-alter-input table-alter-default-expr"; defaultExprSelect.setAttribute("aria-label", "or default to a specific time"); tableAlterSelectDefaultExprValue(defaultExprSelect, ""); defaultExprField.appendChild(defaultExprLabel); @@ -1298,7 +1299,8 @@ function createTableAlterColumnRow(state, column) { var moveBottomButton = document.createElement("button"); moveBottomButton.type = "button"; - moveBottomButton.className = "table-alter-icon-button table-alter-move-bottom"; + moveBottomButton.className = + "table-alter-icon-button table-alter-move-bottom"; moveBottomButton.setAttribute("aria-label", "Move column to bottom"); moveBottomButton.title = "Move column to bottom"; moveBottomButton.dataset.defaultTitle = moveBottomButton.title; @@ -1349,15 +1351,15 @@ function createTableAlterColumnRow(state, column) { controls.push(customTypeSelect); } controls.forEach(function (control) { - control.addEventListener("input", function () { - clearTableAlterDialogError(state); - updateTableAlterSaveButtonState(state); - }); - control.addEventListener("change", function () { - clearTableAlterDialogError(state); - updateTableAlterSaveButtonState(state); - }); + control.addEventListener("input", function () { + clearTableAlterDialogError(state); + updateTableAlterSaveButtonState(state); }); + control.addEventListener("change", function () { + clearTableAlterDialogError(state); + updateTableAlterSaveButtonState(state); + }); + }); defaultInput.addEventListener("input", function () { if (defaultInput.value) { @@ -1407,7 +1409,12 @@ function createTableAlterColumnRow(state, column) { moveTopButton.addEventListener("click", function () { var first = tableAlterFirstNonPrimaryRow(state); - if (state.isSaving || tableAlterRowIsPrimaryKey(row) || !first || first === row) { + if ( + state.isSaving || + tableAlterRowIsPrimaryKey(row) || + !first || + first === row + ) { return; } state.columnList.insertBefore(row, first); @@ -1448,7 +1455,12 @@ function createTableAlterColumnRow(state, column) { moveBottomButton.addEventListener("click", function () { var last = state.columnList.lastElementChild; - if (state.isSaving || tableAlterRowIsPrimaryKey(row) || !last || last === row) { + if ( + state.isSaving || + tableAlterRowIsPrimaryKey(row) || + !last || + last === row + ) { return; } state.columnList.appendChild(row); @@ -1797,9 +1809,7 @@ function tableAlterOperationSummary(operation) { } if (Object.prototype.hasOwnProperty.call(args, "not_null")) { changes.push( - args.not_null - ? "not null (require values)" - : "allow unset values", + args.not_null ? "not null (require values)" : "allow unset values", ); } if (args.default_expr) { From c9c79fdfc8395fe49d0e9d262548f2b337bec3bc Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 17 Jun 2026 09:58:39 -0700 Subject: [PATCH 05/31] Isolate Unix domain socket test server paths - Use a per-process socket path for the UDS test fixture. - Clean up stale socket files before and after the fixture runs. - Close the HTTP client and wait for the Datasette subprocess to exit. --- tests/conftest.py | 34 ++++++++++++++++++++++++++-------- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 8860d54c..7ec03146 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -260,8 +260,12 @@ def ds_unix_domain_socket_server(tmp_path_factory): # This used to use tmp_path_factory.mktemp("uds") but that turned out to # produce paths that were too long to use as UDS on macOS, see # https://github.com/simonw/datasette/issues/1407 - so I switched to - # using tempfile.gettempdir() - uds = str(pathlib.Path(tempfile.gettempdir()) / "datasette.sock") + # using tempfile.gettempdir() with a per-process filename. + uds = str(pathlib.Path(tempfile.gettempdir()) / f"datasette-{os.getpid()}.sock") + try: + os.unlink(uds) + except FileNotFoundError: + pass ds_proc = subprocess.Popen( [sys.executable, "-m", "datasette", "--memory", "--uds", uds], stdout=subprocess.PIPE, @@ -271,12 +275,26 @@ def ds_unix_domain_socket_server(tmp_path_factory): # Poll until available transport = httpx.HTTPTransport(uds=uds) client = httpx.Client(transport=transport) - wait_until_responds("http://localhost/_memory.json", client=client) - # Check it started successfully - assert not ds_proc.poll(), ds_proc.stdout.read().decode("utf-8") - yield ds_proc, uds - # Shut it down at the end of the pytest session - ds_proc.terminate() + try: + wait_until_responds( + "http://localhost/_memory.json", timeout=30.0, client=client + ) + # Check it started successfully + assert not ds_proc.poll(), ds_proc.stdout.read().decode("utf-8") + yield ds_proc, uds + finally: + client.close() + # Shut it down at the end of the pytest session + ds_proc.terminate() + try: + ds_proc.wait(timeout=5) + except subprocess.TimeoutExpired: + ds_proc.kill() + ds_proc.wait() + try: + os.unlink(uds) + except FileNotFoundError: + pass # Import fixtures from fixtures.py to make them available From 4115213e17d23cdf3286bc4583e7005991892568 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 17 Jun 2026 10:22:42 -0700 Subject: [PATCH 06/31] Precompute action permissions for table pages - Extract reusable helpers for database and table action permission preloading. - Precompute those permissions before building table-page HTML data. - Document the default table actions plugin. --- datasette/views/table.py | 22 ++++++++++++--- datasette/views/table_extras.py | 49 ++++++++++++++++++++++----------- docs/plugins.rst | 9 ++++++ tests/test_playwright.py | 10 +++---- 4 files changed, 65 insertions(+), 25 deletions(-) diff --git a/datasette/views/table.py b/datasette/views/table.py index 5175c7d9..82c7e03d 100644 --- a/datasette/views/table.py +++ b/datasette/views/table.py @@ -54,6 +54,8 @@ from .database import QueryView, _custom_column_type_options_for_create_table from .table_extras import ( TABLE_EXTRA_BUNDLES, TableExtraContext, + precompute_database_action_permissions, + precompute_table_action_permissions, resolve_table_extras, table_extra_registry, ) @@ -1247,7 +1249,10 @@ class TableAlterView(BaseView): defaults[args.name] = _literal_default( db_for_write, args.default ) - if "default_expr" in args.model_fields_set and not args.not_null: + if ( + "default_expr" in args.model_fields_set + and not args.not_null + ): defaults[args.name] = _default_expression_sql( args.default_expr ) @@ -1363,9 +1368,9 @@ class TableAlterView(BaseView): "altered": altered, "schema": after_schema, "before_schema": before_schema, - "operations_applied": 0 - if alter_request.dry_run - else len(alter_request.operations), + "operations_applied": ( + 0 if alter_request.dry_run else len(alter_request.operations) + ), "dry_run": alter_request.dry_run, }, status=200, @@ -2068,6 +2073,15 @@ async def table_view_data( if redirect_response: return redirect_response + if context_for_html_hack: + await precompute_database_action_permissions( + datasette, request.actor, database_name + ) + if not is_view: + await precompute_table_action_permissions( + datasette, request.actor, database_name, table_name + ) + # Introspect columns and primary keys for table pks = await db.primary_keys(table_name) table_columns = await db.table_columns(table_name) diff --git a/datasette/views/table_extras.py b/datasette/views/table_extras.py index a0308e49..7cb4d8f0 100644 --- a/datasette/views/table_extras.py +++ b/datasette/views/table_extras.py @@ -367,23 +367,14 @@ class ActionsExtra(Extra): # that allowed() calls made inside the plugin hooks below # are served from the cache datasette = context.datasette - await datasette.allowed_many( - actions=[ - name - for name, action in datasette.actions.items() - if action.resource_class is TableResource - ], - resource=TableResource(context.database_name, context.table_name), - actor=context.request.actor, + await precompute_table_action_permissions( + datasette, + context.request.actor, + context.database_name, + context.table_name, ) - await datasette.allowed_many( - actions=[ - name - for name, action in datasette.actions.items() - if action.resource_class is DatabaseResource - ], - resource=DatabaseResource(context.database_name), - actor=context.request.actor, + await precompute_database_action_permissions( + datasette, context.request.actor, context.database_name ) for hook in method(**kwargs): extra_links = await await_me_maybe(hook) @@ -394,6 +385,32 @@ class ActionsExtra(Extra): return actions +async def precompute_table_action_permissions( + datasette, actor, database_name, table_name +): + await datasette.allowed_many( + actions=[ + name + for name, action in datasette.actions.items() + if action.resource_class is TableResource + ], + resource=TableResource(database_name, table_name), + actor=actor, + ) + + +async def precompute_database_action_permissions(datasette, actor, database_name): + await datasette.allowed_many( + actions=[ + name + for name, action in datasette.actions.items() + if action.resource_class is DatabaseResource + ], + resource=DatabaseResource(database_name), + actor=actor, + ) + + class IsViewExtra(Extra): description = "Whether this resource is a view instead of a table" example = ExtraExample("/fixtures/simple_view.json?_extra=is_view") diff --git a/docs/plugins.rst b/docs/plugins.rst index c2eb282a..d2b5c20a 100644 --- a/docs/plugins.rst +++ b/docs/plugins.rst @@ -280,6 +280,15 @@ If you run ``datasette plugins --all`` it will include default plugins that ship "query_actions" ] }, + { + "name": "datasette.default_table_actions", + "static": false, + "templates": false, + "version": null, + "hooks": [ + "table_actions" + ] + }, { "name": "datasette.events", "static": false, diff --git a/tests/test_playwright.py b/tests/test_playwright.py index b7990009..70b5a8c1 100644 --- a/tests/test_playwright.py +++ b/tests/test_playwright.py @@ -350,7 +350,9 @@ def test_alter_table_flow(page, datasette_server): assert first_more_options.inner_text() == "> Advanced options" first_more_options.click() assert first_more_options.inner_text() == "v Hide options" - expanded_options_text = dialog.locator(".table-alter-column-details").first.inner_text() + expanded_options_text = dialog.locator( + ".table-alter-column-details" + ).first.inner_text() assert dialog.locator(".table-alter-fields").evaluate( "node => node.scrollWidth <= node.clientWidth + 1" ) @@ -500,8 +502,7 @@ def test_alter_table_cancel_skips_discard_prompt(page, datasette_server): return dialog page.goto(f"{datasette_server}data/projects") - page.evaluate( - """ + page.evaluate(""" () => { window.__discardConfirmMessages = []; window.confirm = (message) => { @@ -509,8 +510,7 @@ def test_alter_table_cancel_skips_discard_prompt(page, datasette_server): return false; }; } - """ - ) + """) dialog = open_alter_dialog() dialog.locator(".table-alter-add-column").click() From 8cec528eebd253994fbb41c85d571ab649d723e9 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 17 Jun 2026 10:32:21 -0700 Subject: [PATCH 07/31] Test against pyodide/v314.0.0 Now that we depend on pydantic we need a more recent pyodide in order to load the emscripten build of pydantic-core. Refs https://github.com/simonw/datasette/pull/2789#issuecomment-4733412763 --- test-in-pyodide-with-shot-scraper.sh | 29 +++++++++++++--------------- 1 file changed, 13 insertions(+), 16 deletions(-) diff --git a/test-in-pyodide-with-shot-scraper.sh b/test-in-pyodide-with-shot-scraper.sh index 4d1c4968..8d7fa08e 100755 --- a/test-in-pyodide-with-shot-scraper.sh +++ b/test-in-pyodide-with-shot-scraper.sh @@ -1,40 +1,37 @@ #!/bin/bash -set -e -# So the script fails if there are any errors +set -euo pipefail + +read -r -a PYTHON_CMD <<< "${PYTHON:-python3}" +read -r -a SHOT_SCRAPER_CMD <<< "${SHOT_SCRAPER:-shot-scraper}" # Build the wheel -python3 -m build +"${PYTHON_CMD[@]}" -m build -# Find name of wheel, strip off the dist/ -wheel=$(basename $(ls dist/*.whl) | head -n 1) +# Find name of most recently built wheel, strip off the dist/ +wheel=$(basename "$(ls -t dist/*.whl | head -n 1)") # Create a blank index page echo ' - + ' > dist/index.html # Run a server for that dist/ folder -cd dist -python3 -m http.server 8529 & -cd .. +"${PYTHON_CMD[@]}" -m http.server 8529 --directory dist & +server_pid=$! # Register the kill_server function to be called on script exit kill_server() { - pkill -f 'http.server 8529' + kill "$server_pid" 2>/dev/null || true } trap kill_server EXIT -shot-scraper javascript http://localhost:8529/ " +"${SHOT_SCRAPER_CMD[@]}" javascript http://localhost:8529/ " async () => { let pyodide = await loadPyodide(); - await pyodide.loadPackage(['micropip', 'ssl', 'setuptools']); + await pyodide.loadPackage(['micropip', 'setuptools']); let output = await pyodide.runPythonAsync(\` import micropip - await micropip.install('h11==0.12.0') - await micropip.install('httpx==0.23') - # To avoid 'from typing_extensions import deprecated' error: - await micropip.install('typing-extensions>=4.12.2') await micropip.install('http://localhost:8529/$wheel') import ssl import setuptools From 1972ba8952f0e10fc13054c4214df2473c448daf Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 17 Jun 2026 10:54:25 -0700 Subject: [PATCH 08/31] Split table create and alter views - Move create-table and alter-table API views into table_create_alter.py. - Keep create and alter schema-editing constants and helpers together. - Rename the create table modal context helper. --- datasette/app.py | 3 +- datasette/views/database.py | 323 +----------- datasette/views/table.py | 371 +------------- datasette/views/table_create_alter.py | 687 ++++++++++++++++++++++++++ 4 files changed, 699 insertions(+), 685 deletions(-) create mode 100644 datasette/views/table_create_alter.py diff --git a/datasette/app.py b/datasette/app.py index 6ea3d5a4..13e25d4e 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -47,9 +47,9 @@ from .views import Context from .views.database import ( database_download, DatabaseView, - TableCreateView, QueryView, ) +from .views.table_create_alter import TableAlterView, TableCreateView from .views.execute_write import ExecuteWriteAnalyzeView, ExecuteWriteView from .views.stored_queries import ( QueryCreateAnalyzeView, @@ -84,7 +84,6 @@ from .views.special import ( ) from .views.table import ( TableAutocompleteView, - TableAlterView, TableInsertView, TableUpsertView, TableSetColumnTypeView, diff --git a/datasette/views/database.py b/datasette/views/database.py index db70b135..8e9c1361 100644 --- a/datasette/views/database.py +++ b/datasette/views/database.py @@ -6,15 +6,11 @@ import itertools import json import markupsafe import os -import re -import sqlite_utils import textwrap -from datasette.events import AlterTableEvent, CreateTableEvent, InsertRowsEvent from datasette.extras import extra_names_from_request from datasette.database import QueryInterrupted -from datasette.column_types import SQLiteType -from datasette.resources import DatabaseResource, QueryResource, TableResource +from datasette.resources import DatabaseResource, QueryResource from datasette.stored_queries import stored_query_to_dict from datasette.write_sql import QueryWriteRejected from datasette.utils import ( @@ -38,27 +34,16 @@ from datasette.utils import ( from datasette.utils.asgi import AsgiFileDownload, NotFound, Response, Forbidden from datasette.plugins import pm -from .base import BaseView, DatasetteError, View, _error, stream_csv +from .base import DatasetteError, View, stream_csv from .query_helpers import _ensure_stored_query_execution_permissions, _table_columns from .table_extras import ( QueryExtraContext, resolve_query_extras, table_extra_registry, ) +from .table_create_alter import _create_table_ui_context from . import Context -CREATE_TABLE_COLUMN_TYPES = ["text", "integer", "float", "blob"] -CREATE_TABLE_SQLITE_TYPES = { - "text": SQLiteType.TEXT, - "integer": SQLiteType.INTEGER, - "float": SQLiteType.REAL, - "blob": SQLiteType.BLOB, -} -CREATE_TABLE_TYPE_FOR_SQLITE_TYPE = { - sqlite_type: column_type - for column_type, sqlite_type in CREATE_TABLE_SQLITE_TYPES.items() -} - class DatabaseView(View): async def get(self, request, datasette): @@ -142,7 +127,7 @@ class DatabaseView(View): resource=DatabaseResource(database), actor=request.actor, ) - create_table_ui = await _database_create_table_ui( + create_table_ui = await _create_table_ui_context( datasette, request, db, database, database_action_permissions ) @@ -326,57 +311,6 @@ class DatabaseContext(Context): ) -async def _database_create_table_ui( - datasette, request, db, database_name, database_action_permissions -): - if not db.is_mutable: - return None - if not database_action_permissions.get("create-table"): - return None - data = { - "path": "{}/-/create".format(datasette.urls.database(database_name)), - "databaseName": database_name, - "columnTypes": CREATE_TABLE_COLUMN_TYPES, - } - can_set_column_type = await datasette.allowed( - action="set-column-type", - resource=TableResource(database=database_name, table="__new_table__"), - actor=request.actor, - ) - if can_set_column_type: - data["customColumnTypes"] = _custom_column_type_options_for_create_table( - datasette - ) - return data - - -def _custom_column_type_options_for_create_table(datasette): - options = [] - for name, ct_cls in sorted(datasette._column_types.items()): - sqlite_types = getattr(ct_cls, "sqlite_types", None) - if sqlite_types is None: - option_sqlite_types = CREATE_TABLE_COLUMN_TYPES[:] - else: - option_sqlite_types = [ - create_table_type - for create_table_type, sqlite_type in CREATE_TABLE_SQLITE_TYPES.items() - if sqlite_type in sqlite_types - ] - if not option_sqlite_types: - continue - option = { - "name": name, - "description": ct_cls.description, - "sqliteTypes": option_sqlite_types, - } - if sqlite_types is not None and len(sqlite_types) == 1: - fixed_sqlite_type = CREATE_TABLE_TYPE_FOR_SQLITE_TYPE.get(sqlite_types[0]) - if fixed_sqlite_type is not None: - option["fixedSqliteType"] = fixed_sqlite_type - options.append(option) - return options - - @dataclass class QueryContext(Context): database: str = field(metadata={"help": "The name of the database being queried"}) @@ -1140,255 +1074,6 @@ class MagicParameters(dict): return super().__getitem__(key) -class TableCreateView(BaseView): - name = "table-create" - - _valid_keys = { - "table", - "rows", - "row", - "columns", - "pk", - "pks", - "ignore", - "replace", - "alter", - } - _supported_column_types = set(CREATE_TABLE_COLUMN_TYPES) - # Any string that does not contain a newline or start with sqlite_ - _table_name_re = re.compile(r"^(?!sqlite_)[^\n]+$") - - def __init__(self, datasette): - self.ds = datasette - - async def post(self, request): - db = await self.ds.resolve_database(request) - database_name = db.name - - # Must have create-table permission - if not await self.ds.allowed( - action="create-table", - resource=DatabaseResource(database=database_name), - actor=request.actor, - ): - return _error(["Permission denied"], 403) - - try: - data = await request.json() - except json.JSONDecodeError as e: - return _error(["Invalid JSON: {}".format(e)]) - - if not isinstance(data, dict): - return _error(["JSON must be an object"]) - - invalid_keys = set(data.keys()) - self._valid_keys - if invalid_keys: - return _error(["Invalid keys: {}".format(", ".join(invalid_keys))]) - - # ignore and replace are mutually exclusive - if data.get("ignore") and data.get("replace"): - return _error(["ignore and replace are mutually exclusive"]) - - # ignore and replace only allowed with row or rows - if "ignore" in data or "replace" in data: - if not data.get("row") and not data.get("rows"): - return _error(["ignore and replace require row or rows"]) - - # ignore and replace require pk or pks - if "ignore" in data or "replace" in data: - if not data.get("pk") and not data.get("pks"): - return _error(["ignore and replace require pk or pks"]) - - ignore = data.get("ignore") - replace = data.get("replace") - - if replace: - # Must have update-row permission - if not await self.ds.allowed( - action="update-row", - resource=DatabaseResource(database=database_name), - actor=request.actor, - ): - return _error(["Permission denied: need update-row"], 403) - - table_name = data.get("table") - if not table_name: - return _error(["Table is required"]) - - if not self._table_name_re.match(table_name): - return _error(["Invalid table name"]) - - table_exists = await db.table_exists(data["table"]) - columns = data.get("columns") - rows = data.get("rows") - row = data.get("row") - if not columns and not rows and not row: - return _error(["columns, rows or row is required"]) - - if rows and row: - return _error(["Cannot specify both rows and row"]) - - if rows or row: - # Must have insert-row permission - if not await self.ds.allowed( - action="insert-row", - resource=DatabaseResource(database=database_name), - actor=request.actor, - ): - return _error(["Permission denied: need insert-row"], 403) - - alter = False - if rows or row: - if not table_exists: - # if table is being created for the first time, alter=True - alter = True - else: - # alter=True only if they request it AND they have permission - if data.get("alter"): - if not await self.ds.allowed( - action="alter-table", - resource=DatabaseResource(database=database_name), - actor=request.actor, - ): - return _error(["Permission denied: need alter-table"], 403) - alter = True - - if columns: - if rows or row: - return _error(["Cannot specify columns with rows or row"]) - if not isinstance(columns, list): - return _error(["columns must be a list"]) - for column in columns: - if not isinstance(column, dict): - return _error(["columns must be a list of objects"]) - if not column.get("name") or not isinstance(column.get("name"), str): - return _error(["Column name is required"]) - if not column.get("type"): - column["type"] = "text" - if column["type"] not in self._supported_column_types: - return _error( - ["Unsupported column type: {}".format(column["type"])] - ) - # No duplicate column names - dupes = {c["name"] for c in columns if columns.count(c) > 1} - if dupes: - return _error(["Duplicate column name: {}".format(", ".join(dupes))]) - - if row: - rows = [row] - - if rows: - if not isinstance(rows, list): - return _error(["rows must be a list"]) - for row in rows: - if not isinstance(row, dict): - return _error(["rows must be a list of objects"]) - - pk = data.get("pk") - pks = data.get("pks") - - if pk and pks: - return _error(["Cannot specify both pk and pks"]) - if pk: - if not isinstance(pk, str): - return _error(["pk must be a string"]) - if pks: - if not isinstance(pks, list): - return _error(["pks must be a list"]) - for pk in pks: - if not isinstance(pk, str): - return _error(["pks must be a list of strings"]) - - # If table exists already, read pks from that instead - if table_exists: - actual_pks = await db.primary_keys(table_name) - # if pk passed and table already exists check it does not change - bad_pks = False - if len(actual_pks) == 1 and data.get("pk") and data["pk"] != actual_pks[0]: - bad_pks = True - elif ( - len(actual_pks) > 1 - and data.get("pks") - and set(data["pks"]) != set(actual_pks) - ): - bad_pks = True - if bad_pks: - return _error(["pk cannot be changed for existing table"]) - pks = actual_pks - - initial_schema = None - if table_exists: - initial_schema = await db.execute_fn( - lambda conn: sqlite_utils.Database(conn)[table_name].schema - ) - - def create_table(conn): - table = sqlite_utils.Database(conn)[table_name] - if rows: - table.insert_all( - rows, pk=pks or pk, ignore=ignore, replace=replace, alter=alter - ) - else: - table.create( - {c["name"]: c["type"] for c in columns}, - pk=pks or pk, - ) - return table.schema - - try: - schema = await db.execute_write_fn(create_table, request=request) - except Exception as e: - return _error([str(e)]) - - if initial_schema is not None and initial_schema != schema: - await self.ds.track_event( - AlterTableEvent( - request.actor, - database=database_name, - table=table_name, - before_schema=initial_schema, - after_schema=schema, - ) - ) - - table_url = self.ds.absolute_url( - request, self.ds.urls.table(db.name, table_name) - ) - table_api_url = self.ds.absolute_url( - request, self.ds.urls.table(db.name, table_name, format="json") - ) - details = { - "ok": True, - "database": db.name, - "table": table_name, - "table_url": table_url, - "table_api_url": table_api_url, - "schema": schema, - } - if rows: - details["row_count"] = len(rows) - - if not table_exists: - # Only log creation if we created a table - await self.ds.track_event( - CreateTableEvent( - request.actor, database=db.name, table=table_name, schema=schema - ) - ) - if rows: - await self.ds.track_event( - InsertRowsEvent( - request.actor, - database=db.name, - table=table_name, - num_rows=len(rows), - ignore=ignore, - replace=replace, - ) - ) - return Response.json(details, status=201) - - async def display_rows(datasette, database, request, rows, columns): display_rows = [] truncate_cells = datasette.setting("truncate_cells_html") diff --git a/datasette/views/table.py b/datasette/views/table.py index 82c7e03d..6c0d2914 100644 --- a/datasette/views/table.py +++ b/datasette/views/table.py @@ -1,12 +1,10 @@ import asyncio import itertools import json -from typing import Annotated, Any, Literal, Union import urllib import urllib.parse import markupsafe -from pydantic import BaseModel, ConfigDict, Field, ValidationError, model_validator from datasette.column_types import SQLiteType from datasette.extras import extra_names_from_request @@ -48,9 +46,14 @@ from datasette.utils import ( from datasette.utils.asgi import BadRequest, Forbidden, NotFound, Request, Response from datasette.filters import Filters import sqlite_utils -from sqlite_utils.db import DEFAULT as SQLITE_UTILS_DEFAULT from .base import BaseView, DatasetteError, _error, stream_csv -from .database import QueryView, _custom_column_type_options_for_create_table +from .database import QueryView +from .table_create_alter import ( + ALTER_TABLE_COLUMN_TYPES, + ALTER_TABLE_TYPE_FOR_SQLITE_TYPE, + DEFAULT_EXPR_SQL, + _custom_column_type_options_for_create_table, +) from .table_extras import ( TABLE_EXTRA_BUNDLES, TableExtraContext, @@ -64,13 +67,6 @@ LINK_WITH_LABEL = ( '{label} {id}' ) LINK_WITH_VALUE = '{id}' -ALTER_TABLE_COLUMN_TYPES = ["text", "integer", "float", "blob"] -ALTER_TABLE_TYPE_FOR_SQLITE_TYPE = { - SQLiteType.TEXT: "text", - SQLiteType.INTEGER: "integer", - SQLiteType.REAL: "float", - SQLiteType.BLOB: "blob", -} class Row: @@ -727,154 +723,6 @@ async def display_columns_and_rows( return columns, cell_rows -SqliteApiType = Literal["text", "integer", "float", "blob"] -DefaultExpr = Literal["current_timestamp", "current_date", "current_time"] -DEFAULT_EXPR_SQL = { - "current_timestamp": "CURRENT_TIMESTAMP", - "current_date": "CURRENT_DATE", - "current_time": "CURRENT_TIME", -} - - -class _StrictPydanticModel(BaseModel): - model_config = ConfigDict(extra="forbid") - - -class _DefaultArgsMixin(_StrictPydanticModel): - default: Any | None = None - default_expr: DefaultExpr | None = None - - @model_validator(mode="after") - def validate_default_fields(self): - has_default = "default" in self.model_fields_set - has_default_expr = "default_expr" in self.model_fields_set - if has_default and has_default_expr: - raise ValueError("default and default_expr cannot both be provided") - if has_default_expr and self.default_expr is None: - raise ValueError("default_expr cannot be null") - return self - - -class AddColumnArgs(_DefaultArgsMixin): - name: str - type: SqliteApiType = "text" - not_null: bool = False - - -class RenameColumnArgs(_StrictPydanticModel): - name: str - to: str - - -class AlterColumnArgs(_DefaultArgsMixin): - name: str - type: SqliteApiType | None = None - not_null: bool | None = None - - @model_validator(mode="after") - def require_change(self): - if not ( - {"type", "not_null", "default", "default_expr"} & self.model_fields_set - ): - raise ValueError( - "At least one of type, not_null, default or default_expr must be provided" - ) - return self - - -class DropColumnArgs(_StrictPydanticModel): - name: str - - -class SetPrimaryKeyArgs(_StrictPydanticModel): - columns: list[str] = Field(min_length=1) - - -class ReorderColumnsArgs(_StrictPydanticModel): - columns: list[str] = Field(min_length=1) - - -class AddColumnOperation(_StrictPydanticModel): - op: Literal["add_column"] - args: AddColumnArgs - - -class RenameColumnOperation(_StrictPydanticModel): - op: Literal["rename_column"] - args: RenameColumnArgs - - -class AlterColumnOperation(_StrictPydanticModel): - op: Literal["alter_column"] - args: AlterColumnArgs - - -class DropColumnOperation(_StrictPydanticModel): - op: Literal["drop_column"] - args: DropColumnArgs - - -class SetPrimaryKeyOperation(_StrictPydanticModel): - op: Literal["set_primary_key"] - args: SetPrimaryKeyArgs - - -class ReorderColumnsOperation(_StrictPydanticModel): - op: Literal["reorder_columns"] - args: ReorderColumnsArgs - - -AlterTableOperation = Annotated[ - Union[ - AddColumnOperation, - RenameColumnOperation, - AlterColumnOperation, - DropColumnOperation, - SetPrimaryKeyOperation, - ReorderColumnsOperation, - ], - Field(discriminator="op"), -] - - -class AlterTableRequest(_StrictPydanticModel): - operations: list[AlterTableOperation] = Field(min_length=1) - dry_run: bool = False - - -def _pydantic_errors(validation_error): - errors = [] - for error in validation_error.errors(): - location = ".".join(str(item) for item in error["loc"]) - message = error["msg"] - errors.append("{}: {}".format(location, message) if location else message) - return errors - - -def _table_schema_from_conn(conn, table_name): - row = conn.execute( - "select sql from sqlite_master where type = 'table' and name = ?", - [table_name], - ).fetchone() - return row[0] if row else None - - -def _primary_key_value(columns): - if len(columns) == 1: - return columns[0] - return tuple(columns) - - -def _default_expression_sql(default_expr): - return DEFAULT_EXPR_SQL[default_expr] - - -def _literal_default(db, value): - if isinstance(value, str): - return db.quote(value) - return value - - class TableInsertView(BaseView): name = "table-insert" @@ -1172,211 +1020,6 @@ class TableUpsertView(TableInsertView): return await super().post(request, upsert=True) -class TableAlterView(BaseView): - name = "table-alter" - - 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) - - db = resolved.db - database_name = db.name - table_name = resolved.table - - if not await self.ds.allowed( - action="alter-table", - resource=TableResource(database=database_name, table=table_name), - actor=request.actor, - ): - return _error(["Permission denied: need alter-table"], 403) - - if not db.is_mutable: - return _error(["Database is immutable"], 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) - - try: - alter_request = AlterTableRequest.model_validate(data) - except ValidationError as e: - return _error(_pydantic_errors(e), 400) - - def alter_table(conn): - before_schema = _table_schema_from_conn(conn, table_name) - - def apply_operations(operation_conn): - db_for_write = sqlite_utils.Database(operation_conn) - table = db_for_write[table_name] - - add_columns = [] - types = {} - rename = {} - drop = set() - not_null = {} - defaults = {} - column_order = None - pk = SQLITE_UTILS_DEFAULT - - for operation in alter_request.operations: - args = operation.args - if operation.op == "add_column": - if args.not_null and not ( - ( - "default" in args.model_fields_set - and args.default is not None - ) - or "default_expr" in args.model_fields_set - ): - raise ValueError( - "add_column args.default or args.default_expr is required when not_null is true" - ) - add_columns.append(args) - if "default" in args.model_fields_set and not args.not_null: - defaults[args.name] = _literal_default( - db_for_write, args.default - ) - if ( - "default_expr" in args.model_fields_set - and not args.not_null - ): - defaults[args.name] = _default_expression_sql( - args.default_expr - ) - elif operation.op == "rename_column": - rename[args.name] = args.to - elif operation.op == "alter_column": - if args.type is not None: - types[args.name] = args.type - if args.not_null is not None: - not_null[args.name] = args.not_null - if "default" in args.model_fields_set: - defaults[args.name] = ( - None - if args.default is None - else _literal_default(db_for_write, args.default) - ) - if "default_expr" in args.model_fields_set: - defaults[args.name] = _default_expression_sql( - args.default_expr - ) - elif operation.op == "drop_column": - drop.add(args.name) - elif operation.op == "set_primary_key": - pk = _primary_key_value(args.columns) - elif operation.op == "reorder_columns": - column_order = args.columns - - with operation_conn: - for column in add_columns: - not_null_default = None - if column.not_null: - if "default_expr" in column.model_fields_set: - not_null_default = _default_expression_sql( - column.default_expr - ) - else: - not_null_default = _literal_default( - db_for_write, column.default - ) - table.add_column( - column.name, - column.type, - not_null_default=not_null_default, - ) - - should_transform = any( - ( - types, - rename, - drop, - not_null, - defaults, - column_order is not None, - pk is not SQLITE_UTILS_DEFAULT, - ) - ) - if should_transform: - table.transform( - types=types or None, - rename=rename or None, - drop=drop or None, - pk=pk, - not_null=not_null or None, - defaults=defaults or None, - column_order=column_order, - ) - - return _table_schema_from_conn(operation_conn, table_name) - - if alter_request.dry_run: - memory_conn = sqlite3.connect(":memory:") - try: - conn.backup(memory_conn) - return before_schema, apply_operations(memory_conn) - finally: - memory_conn.close() - - after_schema = apply_operations(conn) - return before_schema, after_schema - - try: - before_schema, after_schema = await db.execute_write_fn( - alter_table, request=request - ) - except Exception as e: - return _error([str(e)], 400) - - altered = before_schema != after_schema - if altered and not alter_request.dry_run: - await self.ds.track_event( - AlterTableEvent( - request.actor, - database=database_name, - table=table_name, - before_schema=before_schema, - after_schema=after_schema, - ) - ) - - table_url = self.ds.absolute_url( - request, self.ds.urls.table(database_name, table_name) - ) - table_api_url = self.ds.absolute_url( - request, self.ds.urls.table(database_name, table_name, format="json") - ) - return Response.json( - { - "ok": True, - "database": database_name, - "table": table_name, - "table_url": table_url, - "table_api_url": table_api_url, - "altered": altered, - "schema": after_schema, - "before_schema": before_schema, - "operations_applied": ( - 0 if alter_request.dry_run else len(alter_request.operations) - ), - "dry_run": alter_request.dry_run, - }, - status=200, - ) - - class TableSetColumnTypeView(BaseView): name = "table-set-column-type" diff --git a/datasette/views/table_create_alter.py b/datasette/views/table_create_alter.py new file mode 100644 index 00000000..7decfad2 --- /dev/null +++ b/datasette/views/table_create_alter.py @@ -0,0 +1,687 @@ +import json +import re +from typing import Annotated, Any, Literal, Union + +from pydantic import BaseModel, ConfigDict, Field, ValidationError, model_validator +import sqlite_utils +from sqlite_utils.db import DEFAULT as SQLITE_UTILS_DEFAULT + +from datasette.column_types import SQLiteType +from datasette.events import AlterTableEvent, CreateTableEvent, InsertRowsEvent +from datasette.resources import DatabaseResource, TableResource +from datasette.utils import sqlite3 +from datasette.utils.asgi import NotFound, Response + +from .base import BaseView, _error + +CREATE_TABLE_COLUMN_TYPES = ["text", "integer", "float", "blob"] +CREATE_TABLE_SQLITE_TYPES = { + "text": SQLiteType.TEXT, + "integer": SQLiteType.INTEGER, + "float": SQLiteType.REAL, + "blob": SQLiteType.BLOB, +} +CREATE_TABLE_TYPE_FOR_SQLITE_TYPE = { + sqlite_type: column_type + for column_type, sqlite_type in CREATE_TABLE_SQLITE_TYPES.items() +} +ALTER_TABLE_COLUMN_TYPES = CREATE_TABLE_COLUMN_TYPES +ALTER_TABLE_TYPE_FOR_SQLITE_TYPE = { + SQLiteType.TEXT: "text", + SQLiteType.INTEGER: "integer", + SQLiteType.REAL: "float", + SQLiteType.BLOB: "blob", +} + + +async def _create_table_ui_context( + datasette, request, db, database_name, database_action_permissions +): + if not db.is_mutable: + return None + if not database_action_permissions.get("create-table"): + return None + data = { + "path": "{}/-/create".format(datasette.urls.database(database_name)), + "databaseName": database_name, + "columnTypes": CREATE_TABLE_COLUMN_TYPES, + } + can_set_column_type = await datasette.allowed( + action="set-column-type", + resource=TableResource(database=database_name, table="__new_table__"), + actor=request.actor, + ) + if can_set_column_type: + data["customColumnTypes"] = _custom_column_type_options_for_create_table( + datasette + ) + return data + + +def _custom_column_type_options_for_create_table(datasette): + options = [] + for name, ct_cls in sorted(datasette._column_types.items()): + sqlite_types = getattr(ct_cls, "sqlite_types", None) + if sqlite_types is None: + option_sqlite_types = CREATE_TABLE_COLUMN_TYPES[:] + else: + option_sqlite_types = [ + create_table_type + for create_table_type, sqlite_type in CREATE_TABLE_SQLITE_TYPES.items() + if sqlite_type in sqlite_types + ] + if not option_sqlite_types: + continue + option = { + "name": name, + "description": ct_cls.description, + "sqliteTypes": option_sqlite_types, + } + if sqlite_types is not None and len(sqlite_types) == 1: + fixed_sqlite_type = CREATE_TABLE_TYPE_FOR_SQLITE_TYPE.get(sqlite_types[0]) + if fixed_sqlite_type is not None: + option["fixedSqliteType"] = fixed_sqlite_type + options.append(option) + return options + + +SqliteApiType = Literal["text", "integer", "float", "blob"] +DefaultExpr = Literal["current_timestamp", "current_date", "current_time"] +DEFAULT_EXPR_SQL = { + "current_timestamp": "CURRENT_TIMESTAMP", + "current_date": "CURRENT_DATE", + "current_time": "CURRENT_TIME", +} + + +class _StrictPydanticModel(BaseModel): + model_config = ConfigDict(extra="forbid") + + +class _DefaultArgsMixin(_StrictPydanticModel): + default: Any | None = None + default_expr: DefaultExpr | None = None + + @model_validator(mode="after") + def validate_default_fields(self): + has_default = "default" in self.model_fields_set + has_default_expr = "default_expr" in self.model_fields_set + if has_default and has_default_expr: + raise ValueError("default and default_expr cannot both be provided") + if has_default_expr and self.default_expr is None: + raise ValueError("default_expr cannot be null") + return self + + +class AddColumnArgs(_DefaultArgsMixin): + name: str + type: SqliteApiType = "text" + not_null: bool = False + + +class RenameColumnArgs(_StrictPydanticModel): + name: str + to: str + + +class AlterColumnArgs(_DefaultArgsMixin): + name: str + type: SqliteApiType | None = None + not_null: bool | None = None + + @model_validator(mode="after") + def require_change(self): + if not ( + {"type", "not_null", "default", "default_expr"} & self.model_fields_set + ): + raise ValueError( + "At least one of type, not_null, default or default_expr must be provided" + ) + return self + + +class DropColumnArgs(_StrictPydanticModel): + name: str + + +class SetPrimaryKeyArgs(_StrictPydanticModel): + columns: list[str] = Field(min_length=1) + + +class ReorderColumnsArgs(_StrictPydanticModel): + columns: list[str] = Field(min_length=1) + + +class AddColumnOperation(_StrictPydanticModel): + op: Literal["add_column"] + args: AddColumnArgs + + +class RenameColumnOperation(_StrictPydanticModel): + op: Literal["rename_column"] + args: RenameColumnArgs + + +class AlterColumnOperation(_StrictPydanticModel): + op: Literal["alter_column"] + args: AlterColumnArgs + + +class DropColumnOperation(_StrictPydanticModel): + op: Literal["drop_column"] + args: DropColumnArgs + + +class SetPrimaryKeyOperation(_StrictPydanticModel): + op: Literal["set_primary_key"] + args: SetPrimaryKeyArgs + + +class ReorderColumnsOperation(_StrictPydanticModel): + op: Literal["reorder_columns"] + args: ReorderColumnsArgs + + +AlterTableOperation = Annotated[ + Union[ + AddColumnOperation, + RenameColumnOperation, + AlterColumnOperation, + DropColumnOperation, + SetPrimaryKeyOperation, + ReorderColumnsOperation, + ], + Field(discriminator="op"), +] + + +class AlterTableRequest(_StrictPydanticModel): + operations: list[AlterTableOperation] = Field(min_length=1) + dry_run: bool = False + + +def _pydantic_errors(validation_error): + errors = [] + for error in validation_error.errors(): + location = ".".join(str(item) for item in error["loc"]) + message = error["msg"] + errors.append("{}: {}".format(location, message) if location else message) + return errors + + +def _table_schema_from_conn(conn, table_name): + row = conn.execute( + "select sql from sqlite_master where type = 'table' and name = ?", + [table_name], + ).fetchone() + return row[0] if row else None + + +def _primary_key_value(columns): + if len(columns) == 1: + return columns[0] + return tuple(columns) + + +def _default_expression_sql(default_expr): + return DEFAULT_EXPR_SQL[default_expr] + + +def _literal_default(db, value): + if isinstance(value, str): + return db.quote(value) + return value + + +class TableCreateView(BaseView): + name = "table-create" + + _valid_keys = { + "table", + "rows", + "row", + "columns", + "pk", + "pks", + "ignore", + "replace", + "alter", + } + _supported_column_types = set(CREATE_TABLE_COLUMN_TYPES) + # Any string that does not contain a newline or start with sqlite_ + _table_name_re = re.compile(r"^(?!sqlite_)[^\n]+$") + + def __init__(self, datasette): + self.ds = datasette + + async def post(self, request): + db = await self.ds.resolve_database(request) + database_name = db.name + + # Must have create-table permission + if not await self.ds.allowed( + action="create-table", + resource=DatabaseResource(database=database_name), + actor=request.actor, + ): + return _error(["Permission denied"], 403) + + try: + data = await request.json() + except json.JSONDecodeError as e: + return _error(["Invalid JSON: {}".format(e)]) + + if not isinstance(data, dict): + return _error(["JSON must be an object"]) + + invalid_keys = set(data.keys()) - self._valid_keys + if invalid_keys: + return _error(["Invalid keys: {}".format(", ".join(invalid_keys))]) + + # ignore and replace are mutually exclusive + if data.get("ignore") and data.get("replace"): + return _error(["ignore and replace are mutually exclusive"]) + + # ignore and replace only allowed with row or rows + if "ignore" in data or "replace" in data: + if not data.get("row") and not data.get("rows"): + return _error(["ignore and replace require row or rows"]) + + # ignore and replace require pk or pks + if "ignore" in data or "replace" in data: + if not data.get("pk") and not data.get("pks"): + return _error(["ignore and replace require pk or pks"]) + + ignore = data.get("ignore") + replace = data.get("replace") + + if replace: + # Must have update-row permission + if not await self.ds.allowed( + action="update-row", + resource=DatabaseResource(database=database_name), + actor=request.actor, + ): + return _error(["Permission denied: need update-row"], 403) + + table_name = data.get("table") + if not table_name: + return _error(["Table is required"]) + + if not self._table_name_re.match(table_name): + return _error(["Invalid table name"]) + + table_exists = await db.table_exists(data["table"]) + columns = data.get("columns") + rows = data.get("rows") + row = data.get("row") + if not columns and not rows and not row: + return _error(["columns, rows or row is required"]) + + if rows and row: + return _error(["Cannot specify both rows and row"]) + + if rows or row: + # Must have insert-row permission + if not await self.ds.allowed( + action="insert-row", + resource=DatabaseResource(database=database_name), + actor=request.actor, + ): + return _error(["Permission denied: need insert-row"], 403) + + alter = False + if rows or row: + if not table_exists: + # if table is being created for the first time, alter=True + alter = True + else: + # alter=True only if they request it AND they have permission + if data.get("alter"): + if not await self.ds.allowed( + action="alter-table", + resource=DatabaseResource(database=database_name), + actor=request.actor, + ): + return _error(["Permission denied: need alter-table"], 403) + alter = True + + if columns: + if rows or row: + return _error(["Cannot specify columns with rows or row"]) + if not isinstance(columns, list): + return _error(["columns must be a list"]) + for column in columns: + if not isinstance(column, dict): + return _error(["columns must be a list of objects"]) + if not column.get("name") or not isinstance(column.get("name"), str): + return _error(["Column name is required"]) + if not column.get("type"): + column["type"] = "text" + if column["type"] not in self._supported_column_types: + return _error( + ["Unsupported column type: {}".format(column["type"])] + ) + # No duplicate column names + dupes = {c["name"] for c in columns if columns.count(c) > 1} + if dupes: + return _error(["Duplicate column name: {}".format(", ".join(dupes))]) + + if row: + rows = [row] + + if rows: + if not isinstance(rows, list): + return _error(["rows must be a list"]) + for row in rows: + if not isinstance(row, dict): + return _error(["rows must be a list of objects"]) + + pk = data.get("pk") + pks = data.get("pks") + + if pk and pks: + return _error(["Cannot specify both pk and pks"]) + if pk: + if not isinstance(pk, str): + return _error(["pk must be a string"]) + if pks: + if not isinstance(pks, list): + return _error(["pks must be a list"]) + for pk in pks: + if not isinstance(pk, str): + return _error(["pks must be a list of strings"]) + + # If table exists already, read pks from that instead + if table_exists: + actual_pks = await db.primary_keys(table_name) + # if pk passed and table already exists check it does not change + bad_pks = False + if len(actual_pks) == 1 and data.get("pk") and data["pk"] != actual_pks[0]: + bad_pks = True + elif ( + len(actual_pks) > 1 + and data.get("pks") + and set(data["pks"]) != set(actual_pks) + ): + bad_pks = True + if bad_pks: + return _error(["pk cannot be changed for existing table"]) + pks = actual_pks + + initial_schema = None + if table_exists: + initial_schema = await db.execute_fn( + lambda conn: sqlite_utils.Database(conn)[table_name].schema + ) + + def create_table(conn): + table = sqlite_utils.Database(conn)[table_name] + if rows: + table.insert_all( + rows, pk=pks or pk, ignore=ignore, replace=replace, alter=alter + ) + else: + table.create( + {c["name"]: c["type"] for c in columns}, + pk=pks or pk, + ) + return table.schema + + try: + schema = await db.execute_write_fn(create_table, request=request) + except Exception as e: + return _error([str(e)]) + + if initial_schema is not None and initial_schema != schema: + await self.ds.track_event( + AlterTableEvent( + request.actor, + database=database_name, + table=table_name, + before_schema=initial_schema, + after_schema=schema, + ) + ) + + table_url = self.ds.absolute_url( + request, self.ds.urls.table(db.name, table_name) + ) + table_api_url = self.ds.absolute_url( + request, self.ds.urls.table(db.name, table_name, format="json") + ) + details = { + "ok": True, + "database": db.name, + "table": table_name, + "table_url": table_url, + "table_api_url": table_api_url, + "schema": schema, + } + if rows: + details["row_count"] = len(rows) + + if not table_exists: + # Only log creation if we created a table + await self.ds.track_event( + CreateTableEvent( + request.actor, database=db.name, table=table_name, schema=schema + ) + ) + if rows: + await self.ds.track_event( + InsertRowsEvent( + request.actor, + database=db.name, + table=table_name, + num_rows=len(rows), + ignore=ignore, + replace=replace, + ) + ) + return Response.json(details, status=201) + + +class TableAlterView(BaseView): + name = "table-alter" + + 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) + + db = resolved.db + database_name = db.name + table_name = resolved.table + + if not await self.ds.allowed( + action="alter-table", + resource=TableResource(database=database_name, table=table_name), + actor=request.actor, + ): + return _error(["Permission denied: need alter-table"], 403) + + if not db.is_mutable: + return _error(["Database is immutable"], 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) + + try: + alter_request = AlterTableRequest.model_validate(data) + except ValidationError as e: + return _error(_pydantic_errors(e), 400) + + def alter_table(conn): + before_schema = _table_schema_from_conn(conn, table_name) + + def apply_operations(operation_conn): + db_for_write = sqlite_utils.Database(operation_conn) + table = db_for_write[table_name] + + add_columns = [] + types = {} + rename = {} + drop = set() + not_null = {} + defaults = {} + column_order = None + pk = SQLITE_UTILS_DEFAULT + + for operation in alter_request.operations: + args = operation.args + if operation.op == "add_column": + if args.not_null and not ( + ( + "default" in args.model_fields_set + and args.default is not None + ) + or "default_expr" in args.model_fields_set + ): + raise ValueError( + "add_column args.default or args.default_expr is required when not_null is true" + ) + add_columns.append(args) + if "default" in args.model_fields_set and not args.not_null: + defaults[args.name] = _literal_default( + db_for_write, args.default + ) + if ( + "default_expr" in args.model_fields_set + and not args.not_null + ): + defaults[args.name] = _default_expression_sql( + args.default_expr + ) + elif operation.op == "rename_column": + rename[args.name] = args.to + elif operation.op == "alter_column": + if args.type is not None: + types[args.name] = args.type + if args.not_null is not None: + not_null[args.name] = args.not_null + if "default" in args.model_fields_set: + defaults[args.name] = ( + None + if args.default is None + else _literal_default(db_for_write, args.default) + ) + if "default_expr" in args.model_fields_set: + defaults[args.name] = _default_expression_sql( + args.default_expr + ) + elif operation.op == "drop_column": + drop.add(args.name) + elif operation.op == "set_primary_key": + pk = _primary_key_value(args.columns) + elif operation.op == "reorder_columns": + column_order = args.columns + + with operation_conn: + for column in add_columns: + not_null_default = None + if column.not_null: + if "default_expr" in column.model_fields_set: + not_null_default = _default_expression_sql( + column.default_expr + ) + else: + not_null_default = _literal_default( + db_for_write, column.default + ) + table.add_column( + column.name, + column.type, + not_null_default=not_null_default, + ) + + should_transform = any( + ( + types, + rename, + drop, + not_null, + defaults, + column_order is not None, + pk is not SQLITE_UTILS_DEFAULT, + ) + ) + if should_transform: + table.transform( + types=types or None, + rename=rename or None, + drop=drop or None, + pk=pk, + not_null=not_null or None, + defaults=defaults or None, + column_order=column_order, + ) + + return _table_schema_from_conn(operation_conn, table_name) + + if alter_request.dry_run: + memory_conn = sqlite3.connect(":memory:") + try: + conn.backup(memory_conn) + return before_schema, apply_operations(memory_conn) + finally: + memory_conn.close() + + after_schema = apply_operations(conn) + return before_schema, after_schema + + try: + before_schema, after_schema = await db.execute_write_fn( + alter_table, request=request + ) + except Exception as e: + return _error([str(e)], 400) + + altered = before_schema != after_schema + if altered and not alter_request.dry_run: + await self.ds.track_event( + AlterTableEvent( + request.actor, + database=database_name, + table=table_name, + before_schema=before_schema, + after_schema=after_schema, + ) + ) + + table_url = self.ds.absolute_url( + request, self.ds.urls.table(database_name, table_name) + ) + table_api_url = self.ds.absolute_url( + request, self.ds.urls.table(database_name, table_name, format="json") + ) + return Response.json( + { + "ok": True, + "database": database_name, + "table": table_name, + "table_url": table_url, + "table_api_url": table_api_url, + "altered": altered, + "schema": after_schema, + "before_schema": before_schema, + "operations_applied": ( + 0 if alter_request.dry_run else len(alter_request.operations) + ), + "dry_run": alter_request.dry_run, + }, + status=200, + ) From 9766a9c0876351a5c60430c4582a7686cb24ad79 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 17 Jun 2026 12:38:51 -0700 Subject: [PATCH 09/31] Add foreign keys to create table API - Add fk_table and optional fk_column support to create-table columns. - Validate create-table requests with Pydantic while preserving existing errors. - Document the API and cover inferred primary-key and validation cases. Refs https://github.com/simonw/datasette/pull/2789#issuecomment-4733544452 --- datasette/views/table_create_alter.py | 287 ++++++++++++++++---------- docs/json_api.rst | 25 +++ tests/test_api_write.py | 115 +++++++++++ 3 files changed, 321 insertions(+), 106 deletions(-) diff --git a/datasette/views/table_create_alter.py b/datasette/views/table_create_alter.py index 7decfad2..e8264a6f 100644 --- a/datasette/views/table_create_alter.py +++ b/datasette/views/table_create_alter.py @@ -2,7 +2,15 @@ import json import re from typing import Annotated, Any, Literal, Union -from pydantic import BaseModel, ConfigDict, Field, ValidationError, model_validator +from pydantic import ( + BaseModel, + ConfigDict, + Field, + ValidationError, + field_validator, + model_validator, +) +from pydantic_core import PydanticCustomError import sqlite_utils from sqlite_utils.db import DEFAULT as SQLITE_UTILS_DEFAULT @@ -25,6 +33,7 @@ CREATE_TABLE_TYPE_FOR_SQLITE_TYPE = { sqlite_type: column_type for column_type, sqlite_type in CREATE_TABLE_SQLITE_TYPES.items() } +TABLE_NAME_RE = re.compile(r"^(?!sqlite_)[^\n]+$") ALTER_TABLE_COLUMN_TYPES = CREATE_TABLE_COLUMN_TYPES ALTER_TABLE_TYPE_FOR_SQLITE_TYPE = { SQLiteType.TEXT: "text", @@ -98,6 +107,137 @@ class _StrictPydanticModel(BaseModel): model_config = ConfigDict(extra="forbid") +class CreateTableColumn(BaseModel): + model_config = ConfigDict(extra="forbid") + + name: Any = None + type: Any = "text" + fk_table: str | None = None + fk_column: str | None = None + + @model_validator(mode="after") + def validate_column(self): + if not self.name or not isinstance(self.name, str): + raise PydanticCustomError("create_table", "Column name is required") + if not self.type: + self.type = "text" + elif self.type not in CREATE_TABLE_COLUMN_TYPES: + raise PydanticCustomError( + "create_table", "Unsupported column type: {type}", {"type": self.type} + ) + if self.fk_column and not self.fk_table: + raise PydanticCustomError( + "create_table_with_location", + "fk_column requires fk_table", + ) + return self + + +class CreateTableRequest(_StrictPydanticModel): + table: Any = None + rows: Any = None + row: Any = None + columns: list[CreateTableColumn] | None = None + pk: Any = None + pks: Any = None + ignore: bool | None = None + replace: bool | None = None + alter: bool | None = None + + @field_validator("columns", mode="before") + @classmethod + def validate_columns_list(cls, value): + if value is None: + return value + if not isinstance(value, list): + raise PydanticCustomError("create_table", "columns must be a list") + if not all(isinstance(column, dict) for column in value): + raise PydanticCustomError( + "create_table", "columns must be a list of objects" + ) + return value + + @model_validator(mode="after") + def validate_request(self): + if not self.table: + raise PydanticCustomError("create_table", "Table is required") + if not isinstance(self.table, str) or not TABLE_NAME_RE.match(self.table): + raise PydanticCustomError("create_table", "Invalid table name") + if not self.columns and not self.rows and not self.row: + raise PydanticCustomError( + "create_table", "columns, rows or row is required" + ) + if self.rows and self.row: + raise PydanticCustomError( + "create_table", "Cannot specify both rows and row" + ) + if self.columns and (self.rows or self.row): + raise PydanticCustomError( + "create_table", "Cannot specify columns with rows or row" + ) + if self.columns is not None: + seen = set() + duplicates = [] + for column in self.columns: + if column.name in seen and column.name not in duplicates: + duplicates.append(column.name) + seen.add(column.name) + if duplicates: + raise PydanticCustomError( + "create_table", + "Duplicate column name: {names}", + {"names": ", ".join(duplicates)}, + ) + if self.rows is not None: + if not isinstance(self.rows, list): + raise PydanticCustomError("create_table", "rows must be a list") + if not all(isinstance(row, dict) for row in self.rows): + raise PydanticCustomError( + "create_table", "rows must be a list of objects" + ) + if self.pk is not None and not isinstance(self.pk, str): + raise PydanticCustomError("create_table", "pk must be a string") + if self.pk and self.pks: + raise PydanticCustomError("create_table", "Cannot specify both pk and pks") + if self.pks is not None: + if not isinstance(self.pks, list): + raise PydanticCustomError("create_table", "pks must be a list") + if not all(isinstance(pk, str) for pk in self.pks): + raise PydanticCustomError( + "create_table", "pks must be a list of strings" + ) + if self.ignore and self.replace: + raise PydanticCustomError( + "create_table", "ignore and replace are mutually exclusive" + ) + if {"ignore", "replace"} & self.model_fields_set: + if not self.row and not self.rows: + raise PydanticCustomError( + "create_table", "ignore and replace require row or rows" + ) + if not self.pk and not self.pks: + raise PydanticCustomError( + "create_table", "ignore and replace require pk or pks" + ) + return self + + @property + def rows_list(self): + return [self.row] if self.row else self.rows + + @property + def foreign_keys(self): + if not self.columns: + return None + foreign_keys = [] + for column in self.columns: + if column.fk_table and column.fk_column: + foreign_keys.append((column.name, column.fk_table, column.fk_column)) + elif column.fk_table: + foreign_keys.append((column.name, column.fk_table)) + return foreign_keys or None + + class _DefaultArgsMixin(_StrictPydanticModel): default: Any | None = None default_expr: DefaultExpr | None = None @@ -209,6 +349,27 @@ def _pydantic_errors(validation_error): return errors +def _create_table_pydantic_errors(validation_error): + errors = validation_error.errors() + invalid_keys = sorted( + str(error["loc"][0]) + for error in errors + if error["type"] == "extra_forbidden" and len(error["loc"]) == 1 + ) + if invalid_keys: + return ["Invalid keys: {}".format(", ".join(invalid_keys))] + + output = [] + for error in errors: + message = error["msg"] + if error["type"] == "create_table": + output.append(message) + continue + location = ".".join(str(item) for item in error["loc"]) + output.append("{}: {}".format(location, message) if location else message) + return output + + def _table_schema_from_conn(conn, table_name): row = conn.execute( "select sql from sqlite_master where type = 'table' and name = ?", @@ -236,21 +397,6 @@ def _literal_default(db, value): class TableCreateView(BaseView): name = "table-create" - _valid_keys = { - "table", - "rows", - "row", - "columns", - "pk", - "pks", - "ignore", - "replace", - "alter", - } - _supported_column_types = set(CREATE_TABLE_COLUMN_TYPES) - # Any string that does not contain a newline or start with sqlite_ - _table_name_re = re.compile(r"^(?!sqlite_)[^\n]+$") - def __init__(self, datasette): self.ds = datasette @@ -274,26 +420,13 @@ class TableCreateView(BaseView): if not isinstance(data, dict): return _error(["JSON must be an object"]) - invalid_keys = set(data.keys()) - self._valid_keys - if invalid_keys: - return _error(["Invalid keys: {}".format(", ".join(invalid_keys))]) + try: + create_request = CreateTableRequest.model_validate(data) + except ValidationError as e: + return _error(_create_table_pydantic_errors(e)) - # ignore and replace are mutually exclusive - if data.get("ignore") and data.get("replace"): - return _error(["ignore and replace are mutually exclusive"]) - - # ignore and replace only allowed with row or rows - if "ignore" in data or "replace" in data: - if not data.get("row") and not data.get("rows"): - return _error(["ignore and replace require row or rows"]) - - # ignore and replace require pk or pks - if "ignore" in data or "replace" in data: - if not data.get("pk") and not data.get("pks"): - return _error(["ignore and replace require pk or pks"]) - - ignore = data.get("ignore") - replace = data.get("replace") + ignore = create_request.ignore + replace = create_request.replace if replace: # Must have update-row permission @@ -304,24 +437,12 @@ class TableCreateView(BaseView): ): return _error(["Permission denied: need update-row"], 403) - table_name = data.get("table") - if not table_name: - return _error(["Table is required"]) + table_name = create_request.table + table_exists = await db.table_exists(table_name) + columns = create_request.columns + rows = create_request.rows_list - if not self._table_name_re.match(table_name): - return _error(["Invalid table name"]) - - table_exists = await db.table_exists(data["table"]) - columns = data.get("columns") - rows = data.get("rows") - row = data.get("row") - if not columns and not rows and not row: - return _error(["columns, rows or row is required"]) - - if rows and row: - return _error(["Cannot specify both rows and row"]) - - if rows or row: + if rows: # Must have insert-row permission if not await self.ds.allowed( action="insert-row", @@ -331,13 +452,13 @@ class TableCreateView(BaseView): return _error(["Permission denied: need insert-row"], 403) alter = False - if rows or row: + if rows: if not table_exists: # if table is being created for the first time, alter=True alter = True else: # alter=True only if they request it AND they have permission - if data.get("alter"): + if create_request.alter: if not await self.ds.allowed( action="alter-table", resource=DatabaseResource(database=database_name), @@ -346,64 +467,17 @@ class TableCreateView(BaseView): return _error(["Permission denied: need alter-table"], 403) alter = True - if columns: - if rows or row: - return _error(["Cannot specify columns with rows or row"]) - if not isinstance(columns, list): - return _error(["columns must be a list"]) - for column in columns: - if not isinstance(column, dict): - return _error(["columns must be a list of objects"]) - if not column.get("name") or not isinstance(column.get("name"), str): - return _error(["Column name is required"]) - if not column.get("type"): - column["type"] = "text" - if column["type"] not in self._supported_column_types: - return _error( - ["Unsupported column type: {}".format(column["type"])] - ) - # No duplicate column names - dupes = {c["name"] for c in columns if columns.count(c) > 1} - if dupes: - return _error(["Duplicate column name: {}".format(", ".join(dupes))]) - - if row: - rows = [row] - - if rows: - if not isinstance(rows, list): - return _error(["rows must be a list"]) - for row in rows: - if not isinstance(row, dict): - return _error(["rows must be a list of objects"]) - - pk = data.get("pk") - pks = data.get("pks") - - if pk and pks: - return _error(["Cannot specify both pk and pks"]) - if pk: - if not isinstance(pk, str): - return _error(["pk must be a string"]) - if pks: - if not isinstance(pks, list): - return _error(["pks must be a list"]) - for pk in pks: - if not isinstance(pk, str): - return _error(["pks must be a list of strings"]) + pk = create_request.pk + pks = create_request.pks # If table exists already, read pks from that instead if table_exists: actual_pks = await db.primary_keys(table_name) # if pk passed and table already exists check it does not change bad_pks = False - if len(actual_pks) == 1 and data.get("pk") and data["pk"] != actual_pks[0]: + if len(actual_pks) == 1 and pk and pk != actual_pks[0]: bad_pks = True - elif ( - len(actual_pks) > 1 - and data.get("pks") - and set(data["pks"]) != set(actual_pks) - ): + elif len(actual_pks) > 1 and pks and set(pks) != set(actual_pks): bad_pks = True if bad_pks: return _error(["pk cannot be changed for existing table"]) @@ -423,8 +497,9 @@ class TableCreateView(BaseView): ) else: table.create( - {c["name"]: c["type"] for c in columns}, + {column.name: column.type for column in columns}, pk=pks or pk, + foreign_keys=create_request.foreign_keys, ) return table.schema diff --git a/docs/json_api.rst b/docs/json_api.rst index 4074b479..1b4a196e 100644 --- a/docs/json_api.rst +++ b/docs/json_api.rst @@ -1981,6 +1981,7 @@ The JSON here describes the table that will be created: - ``name`` is the name of the column. This is required. - ``type`` is the type of the column. This is optional - if not provided, ``text`` will be assumed. The valid types are ``text``, ``integer``, ``float`` and ``blob``. + - ``fk_table`` can be used to create a single-column foreign key constraint referencing another table. ``fk_column`` is optional and can be used to specify the referenced column - if omitted, Datasette will use the single primary key of ``fk_table``. * ``pk`` is the primary key for the table. This is optional - if not provided, Datasette will create a SQLite table with a hidden ``rowid`` column. @@ -1993,6 +1994,30 @@ The JSON here describes the table that will be created: * ``replace`` can be set to ``true`` to replace existing rows by primary key if the table already exists. This requires the :ref:`actions_update_row` permission. * ``alter`` can be set to ``true`` if you want to automatically add any missing columns to the table. This requires the :ref:`actions_alter_table` permission. +This example creates a foreign key from ``projects.owner_id`` to the single primary key of ``owners``: + +.. code-block:: json + + { + "table": "projects", + "columns": [ + { + "name": "id", + "type": "integer" + }, + { + "name": "owner_id", + "type": "integer", + "fk_table": "owners" + }, + { + "name": "title", + "type": "text" + } + ], + "pk": "id" + } + If the table is successfully created this will return a ``201`` status code and the following response: .. code-block:: json diff --git a/tests/test_api_write.py b/tests/test_api_write.py index f117c06e..627b1ac1 100644 --- a/tests/test_api_write.py +++ b/tests/test_api_write.py @@ -1614,6 +1614,121 @@ async def test_create_table( assert [e.name for e in events] == expected_events +@pytest.mark.asyncio +async def test_create_table_with_foreign_key(ds_write): + token = write_token(ds_write) + response = await ds_write.client.post( + "/data/-/create", + json={ + "table": "owners", + "columns": [ + {"name": "id", "type": "integer"}, + {"name": "name", "type": "text"}, + ], + "pk": "id", + }, + headers=_headers(token), + ) + assert response.status_code == 201 + + response = await ds_write.client.post( + "/data/-/create", + json={ + "table": "projects", + "columns": [ + {"name": "id", "type": "integer"}, + { + "name": "owner_id", + "type": "integer", + "fk_table": "owners", + }, + {"name": "title", "type": "text"}, + ], + "pk": "id", + }, + headers=_headers(token), + ) + assert response.status_code == 201 + data = response.json() + assert "[owner_id] INTEGER REFERENCES [owners]([id])" in data["schema"] + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "column,expected_error", + ( + ( + {"name": "owner_id", "type": "integer", "fk_table": "owners"}, + None, + ), + ( + {"name": "owner_id", "type": "integer", "fk_column": "id"}, + "columns.0: fk_column requires fk_table", + ), + ), +) +async def test_create_table_foreign_key_validation(ds_write, column, expected_error): + token = write_token(ds_write) + response = await ds_write.client.post( + "/data/-/create", + json={ + "table": "projects", + "columns": [column], + }, + headers=_headers(token), + ) + if expected_error: + assert response.status_code == 400 + assert response.json() == {"ok": False, "errors": [expected_error]} + else: + assert response.status_code == 400 + assert response.json() == { + "ok": False, + "errors": ["Could not detect single primary key for table 'owners'"], + } + + +@pytest.mark.asyncio +async def test_create_table_foreign_key_without_fk_column_requires_single_pk(ds_write): + token = write_token(ds_write) + response = await ds_write.client.post( + "/data/-/create", + json={ + "table": "accounts", + "columns": [ + {"name": "tenant_id", "type": "integer"}, + {"name": "id", "type": "integer"}, + {"name": "name", "type": "text"}, + ], + "pks": ["tenant_id", "id"], + }, + headers=_headers(token), + ) + assert response.status_code == 201 + + response = await ds_write.client.post( + "/data/-/create", + json={ + "table": "projects", + "columns": [ + {"name": "id", "type": "integer"}, + { + "name": "account_id", + "type": "integer", + "fk_table": "accounts", + }, + ], + "pk": "id", + }, + headers=_headers(token), + ) + assert response.status_code == 400 + assert response.json() == { + "ok": False, + "errors": ["Could not detect single primary key for table 'accounts'"], + } + + @pytest.mark.asyncio @pytest.mark.parametrize( "permissions,body,expected_status,expected_errors", From 9d9a2d3ff379b1f1ee3db4d24b53310ef2a71023 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 17 Jun 2026 12:51:19 -0700 Subject: [PATCH 10/31] Add foreign keys to alter table API - Add add_foreign_key, drop_foreign_key, and set_foreign_keys operations. - Validate flat fk_table and fk_column arguments with Pydantic. - Document the API and cover inferred primary-key and validation cases. --- datasette/views/table_create_alter.py | 67 +++++++++++++++ docs/json_api.rst | 30 +++++++ tests/test_api_write.py | 118 ++++++++++++++++++++++++++ 3 files changed, 215 insertions(+) diff --git a/datasette/views/table_create_alter.py b/datasette/views/table_create_alter.py index e8264a6f..20dcc03e 100644 --- a/datasette/views/table_create_alter.py +++ b/datasette/views/table_create_alter.py @@ -292,6 +292,40 @@ class ReorderColumnsArgs(_StrictPydanticModel): columns: list[str] = Field(min_length=1) +class ForeignKeyArgs(_StrictPydanticModel): + column: str + fk_table: str | None = None + fk_column: str | None = None + + @model_validator(mode="after") + def validate_foreign_key(self): + if self.fk_column and not self.fk_table: + raise PydanticCustomError( + "alter_table_foreign_key", + "fk_column requires fk_table", + ) + if not self.fk_table: + raise PydanticCustomError( + "alter_table_foreign_key", + "fk_table is required", + ) + return self + + @property + def tuple(self): + if self.fk_column: + return (self.column, self.fk_table, self.fk_column) + return (self.column, self.fk_table) + + +class DropForeignKeyArgs(_StrictPydanticModel): + column: str + + +class SetForeignKeysArgs(_StrictPydanticModel): + foreign_keys: list[ForeignKeyArgs] + + class AddColumnOperation(_StrictPydanticModel): op: Literal["add_column"] args: AddColumnArgs @@ -322,6 +356,21 @@ class ReorderColumnsOperation(_StrictPydanticModel): args: ReorderColumnsArgs +class AddForeignKeyOperation(_StrictPydanticModel): + op: Literal["add_foreign_key"] + args: ForeignKeyArgs + + +class DropForeignKeyOperation(_StrictPydanticModel): + op: Literal["drop_foreign_key"] + args: DropForeignKeyArgs + + +class SetForeignKeysOperation(_StrictPydanticModel): + op: Literal["set_foreign_keys"] + args: SetForeignKeysArgs + + AlterTableOperation = Annotated[ Union[ AddColumnOperation, @@ -330,6 +379,9 @@ AlterTableOperation = Annotated[ DropColumnOperation, SetPrimaryKeyOperation, ReorderColumnsOperation, + AddForeignKeyOperation, + DropForeignKeyOperation, + SetForeignKeysOperation, ], Field(discriminator="op"), ] @@ -615,6 +667,9 @@ class TableAlterView(BaseView): defaults = {} column_order = None pk = SQLITE_UTILS_DEFAULT + add_foreign_keys = [] + drop_foreign_keys = [] + foreign_keys = None for operation in alter_request.operations: args = operation.args @@ -664,6 +719,12 @@ class TableAlterView(BaseView): pk = _primary_key_value(args.columns) elif operation.op == "reorder_columns": column_order = args.columns + elif operation.op == "add_foreign_key": + add_foreign_keys.append(args.tuple) + elif operation.op == "drop_foreign_key": + drop_foreign_keys.append(args.column) + elif operation.op == "set_foreign_keys": + foreign_keys = [fk.tuple for fk in args.foreign_keys] with operation_conn: for column in add_columns: @@ -692,6 +753,9 @@ class TableAlterView(BaseView): defaults, column_order is not None, pk is not SQLITE_UTILS_DEFAULT, + add_foreign_keys, + drop_foreign_keys, + foreign_keys is not None, ) ) if should_transform: @@ -703,6 +767,9 @@ class TableAlterView(BaseView): not_null=not_null or None, defaults=defaults or None, column_order=column_order, + add_foreign_keys=add_foreign_keys or None, + drop_foreign_keys=drop_foreign_keys or None, + foreign_keys=foreign_keys, ) return _table_schema_from_conn(operation_conn, table_name) diff --git a/docs/json_api.rst b/docs/json_api.rst index 1b4a196e..af16626f 100644 --- a/docs/json_api.rst +++ b/docs/json_api.rst @@ -2159,6 +2159,31 @@ The request body should include an ``operations`` array. Each operation has the "columns": ["id"] } }, + { + "op": "add_foreign_key", + "args": { + "column": "owner_id", + "fk_table": "owners" + } + }, + { + "op": "drop_foreign_key", + "args": { + "column": "old_owner_id" + } + }, + { + "op": "set_foreign_keys", + "args": { + "foreign_keys": [ + { + "column": "owner_id", + "fk_table": "owners", + "fk_column": "id" + } + ] + } + }, { "op": "reorder_columns", "args": { @@ -2177,10 +2202,15 @@ Supported operations: * ``alter_column`` changes column properties. ``args`` accepts ``name`` and at least one of ``type``, ``not_null``, literal ``default`` or ``default_expr``. Passing ``"default": null`` removes an existing default. * ``drop_column`` drops a column. ``args`` accepts ``name``. * ``set_primary_key`` changes the table primary key. ``args`` accepts ``columns``, a list of one or more column names. +* ``add_foreign_key`` adds a single-column foreign key constraint. ``args`` accepts ``column``, ``fk_table`` and optional ``fk_column``. If ``fk_column`` is omitted, Datasette will use the single primary key of ``fk_table``. +* ``drop_foreign_key`` removes the foreign key constraint for a column. ``args`` accepts ``column``. +* ``set_foreign_keys`` replaces all foreign key constraints on the table. ``args`` accepts ``foreign_keys``, a list of objects that each have ``column``, ``fk_table`` and optional ``fk_column``. An empty list removes all foreign key constraints. * ``reorder_columns`` reorders columns. ``args`` accepts ``columns``, a list of one or more column names. Columns omitted from this list will appear afterwards in their existing order. ``default`` is always treated as a literal value. ``default_expr`` accepts one of ``current_timestamp``, ``current_date`` or ``current_time`` and is rendered as the corresponding SQLite default expression. +For foreign key operations that omit ``fk_column``, the referenced ``fk_table`` must have a single-column primary key. Datasette will return an error if it cannot identify a single primary key column for that table. + A successful response returns the new schema and the previous schema: .. code-block:: json diff --git a/tests/test_api_write.py b/tests/test_api_write.py index 627b1ac1..046cf695 100644 --- a/tests/test_api_write.py +++ b/tests/test_api_write.py @@ -926,6 +926,124 @@ async def test_alter_table_dry_run(ds_write): assert last_event(ds_write) is None +@pytest.mark.asyncio +async def test_alter_table_foreign_key_operations(ds_write): + token = write_token(ds_write, permissions=["at"]) + db = ds_write.get_database("data") + await db.execute_write("create table owners (id integer primary key)") + await db.execute_write("create table categories (id integer primary key)") + + response = await ds_write.client.post( + "/data/docs/-/alter", + json={ + "operations": [ + {"op": "add_column", "args": {"name": "owner_id", "type": "integer"}}, + { + "op": "add_foreign_key", + "args": {"column": "owner_id", "fk_table": "owners"}, + }, + ] + }, + headers=_headers(token), + ) + assert response.status_code == 200, response.text + data = response.json() + assert data["operations_applied"] == 2 + assert "[owner_id] INTEGER REFERENCES [owners]([id])" in data["schema"] + + response = await ds_write.client.post( + "/data/docs/-/alter", + json={ + "operations": [{"op": "drop_foreign_key", "args": {"column": "owner_id"}}] + }, + headers=_headers(token), + ) + assert response.status_code == 200, response.text + data = response.json() + assert "[owner_id] INTEGER REFERENCES" not in data["schema"] + + response = await ds_write.client.post( + "/data/docs/-/alter", + json={ + "operations": [ + { + "op": "set_foreign_keys", + "args": { + "foreign_keys": [ + { + "column": "owner_id", + "fk_table": "categories", + "fk_column": "id", + } + ] + }, + } + ] + }, + headers=_headers(token), + ) + assert response.status_code == 200, response.text + data = response.json() + assert "[owner_id] INTEGER REFERENCES [categories]([id])" in data["schema"] + + response = await ds_write.client.post( + "/data/docs/-/alter", + json={"operations": [{"op": "set_foreign_keys", "args": {"foreign_keys": []}}]}, + headers=_headers(token), + ) + assert response.status_code == 200, response.text + data = response.json() + assert "[owner_id] INTEGER REFERENCES" not in data["schema"] + + +@pytest.mark.asyncio +async def test_alter_table_foreign_key_requires_fk_table_for_fk_column(ds_write): + response = await ds_write.client.post( + "/data/docs/-/alter", + json={ + "operations": [ + { + "op": "add_foreign_key", + "args": {"column": "age", "fk_column": "id"}, + } + ] + }, + headers=_headers(write_token(ds_write, permissions=["at"])), + ) + assert response.status_code == 400 + assert response.json() == { + "ok": False, + "errors": ["operations.0.add_foreign_key.args: fk_column requires fk_table"], + } + + +@pytest.mark.asyncio +async def test_alter_table_foreign_key_without_fk_column_requires_single_pk(ds_write): + token = write_token(ds_write, permissions=["at"]) + db = ds_write.get_database("data") + await db.execute_write( + "create table accounts (tenant_id integer, id integer, primary key (tenant_id, id))" + ) + + response = await ds_write.client.post( + "/data/docs/-/alter", + json={ + "operations": [ + { + "op": "add_foreign_key", + "args": {"column": "age", "fk_table": "accounts"}, + } + ] + }, + headers=_headers(token), + ) + assert response.status_code == 400 + assert response.json() == { + "ok": False, + "errors": ["Could not detect single primary key for table 'accounts'"], + } + + @pytest.mark.asyncio async def test_alter_table_permission_denied(ds_write): token = write_token(ds_write, permissions=["ir"]) From 2900efb32d5348fdd4b3ecb33e5f8b1ee836f5c3 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 17 Jun 2026 14:47:25 -0700 Subject: [PATCH 11/31] /db/table/-/foreign-key-suggestions API Improved version of the implementation datasette-edit-schema --- datasette/app.py | 10 +- datasette/views/table_create_alter.py | 303 +++++++++++++++++++++++++- docs/json_api.rst | 58 +++++ tests/test_api_write.py | 100 +++++++++ 4 files changed, 469 insertions(+), 2 deletions(-) diff --git a/datasette/app.py b/datasette/app.py index 13e25d4e..6b9f47ba 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -49,7 +49,11 @@ from .views.database import ( DatabaseView, QueryView, ) -from .views.table_create_alter import TableAlterView, TableCreateView +from .views.table_create_alter import ( + TableAlterView, + TableCreateView, + TableForeignKeySuggestionsView, +) from .views.execute_write import ExecuteWriteAnalyzeView, ExecuteWriteView from .views.stored_queries import ( QueryCreateAnalyzeView, @@ -2630,6 +2634,10 @@ class Datasette: TableAlterView.as_view(self), r"/(?P[^\/\.]+)/(?P
[^\/\.]+)/-/alter$", ) + add_route( + TableForeignKeySuggestionsView.as_view(self), + r"/(?P[^\/\.]+)/(?P
[^\/\.]+)/-/foreign-key-suggestions$", + ) add_route( TableSetColumnTypeView.as_view(self), r"/(?P[^\/\.]+)/(?P
[^\/\.]+)/-/set-column-type$", diff --git a/datasette/views/table_create_alter.py b/datasette/views/table_create_alter.py index 20dcc03e..2cb59ac1 100644 --- a/datasette/views/table_create_alter.py +++ b/datasette/views/table_create_alter.py @@ -1,7 +1,9 @@ import json import re +import time from typing import Annotated, Any, Literal, Union +from datasette.database import QueryInterrupted from pydantic import ( BaseModel, ConfigDict, @@ -17,8 +19,14 @@ from sqlite_utils.db import DEFAULT as SQLITE_UTILS_DEFAULT from datasette.column_types import SQLiteType from datasette.events import AlterTableEvent, CreateTableEvent, InsertRowsEvent from datasette.resources import DatabaseResource, TableResource -from datasette.utils import sqlite3 +from datasette.utils import ( + escape_sqlite, + get_outbound_foreign_keys, + sqlite3, + table_column_details, +) from datasette.utils.asgi import NotFound, Response +from datasette.utils.sqlite import sqlite_hidden_table_names from .base import BaseView, _error @@ -41,6 +49,177 @@ ALTER_TABLE_TYPE_FOR_SQLITE_TYPE = { SQLiteType.REAL: "float", SQLiteType.BLOB: "blob", } +FOREIGN_KEY_SUGGESTION_ROW_LIMIT = 500 +FOREIGN_KEY_SUGGESTION_TIME_LIMIT_MS = 50 +FOREIGN_KEY_SUGGESTION_TOTAL_TIME_LIMIT_MS = 200 + + +class ForeignKeySuggestionTimedOut(Exception): + pass + + +def _sqlite_type_affinity(type_name): + type_name = (type_name or "").upper() + if "INT" in type_name: + return "integer" + if any(token in type_name for token in ("CHAR", "CLOB", "TEXT")): + return "text" + if "BLOB" in type_name or not type_name: + return "blob" + if any(token in type_name for token in ("REAL", "FLOA", "DOUB")): + return "real" + return "numeric" + + +def _foreign_key_type_compatible(source_affinity, target_affinity): + if source_affinity == target_affinity: + return True + numeric_affinities = {"integer", "real", "numeric"} + if source_affinity == "numeric": + return target_affinity in numeric_affinities + if target_affinity == "numeric": + return source_affinity in numeric_affinities + return False + + +def _public_foreign_key_target(target): + return { + "fk_table": target["fk_table"], + "fk_column": target["fk_column"], + "type": target["type"], + } + + +def _singular(name): + if name.endswith("ies") and len(name) > 3: + return name[:-3] + "y" + if name.endswith("s") and len(name) > 1: + return name[:-1] + return name + + +def _foreign_key_name_reasons(source_column, target): + source = source_column.lower() + table = target["fk_table"].lower() + singular_table = _singular(table) + column = target["fk_column"].lower() + possible_names = { + "{}_{}".format(table, column), + "{}_{}".format(singular_table, column), + } + if column == "id": + possible_names.update( + { + "{}_id".format(table), + "{}_id".format(singular_table), + } + ) + return ["name_match"] if source in possible_names else [] + + +def _foreign_key_option_sort_key(source_column, target): + has_name_match = bool(_foreign_key_name_reasons(source_column, target)) + return ( + 0 if has_name_match else 1, + target["fk_table"], + target["fk_column"], + ) + + +def _foreign_key_suggestion_metadata(conn, table_name): + hidden_tables = set(sqlite_hidden_table_names(conn)) + source_columns = [ + { + "column": column.name, + "type": (column.type or "").upper(), + "affinity": _sqlite_type_affinity(column.type), + } + for column in table_column_details(conn, table_name) + if not column.hidden + ] + current_by_column = { + fk["column"]: { + "fk_table": fk["other_table"], + "fk_column": fk["other_column"], + } + for fk in get_outbound_foreign_keys(conn, table_name) + } + table_names = [ + row[0] + for row in conn.execute( + "select name from sqlite_master where type = 'table' order by name" + ).fetchall() + if not row[0].startswith("sqlite_") + ] + targets = [] + for candidate_table in table_names: + if candidate_table == table_name or candidate_table in hidden_tables: + continue + columns = [column for column in table_column_details(conn, candidate_table)] + pks = [column for column in columns if column.is_pk and not column.hidden] + pks.sort(key=lambda column: column.is_pk) + if len(pks) != 1: + continue + pk = pks[0] + targets.append( + { + "fk_table": candidate_table, + "fk_column": pk.name, + "type": (pk.type or "").upper(), + "affinity": _sqlite_type_affinity(pk.type), + } + ) + return source_columns, targets, current_by_column + + +async def _foreign_key_suggestion_samples(db, table_name, columns): + if not columns: + return 0, {} + sql = "select {} from {} limit {}".format( + ", ".join(escape_sqlite(column) for column in columns), + escape_sqlite(table_name), + FOREIGN_KEY_SUGGESTION_ROW_LIMIT, + ) + try: + results = await db.execute( + sql, + custom_time_limit=FOREIGN_KEY_SUGGESTION_TIME_LIMIT_MS, + log_sql_errors=False, + ) + except QueryInterrupted as e: + raise ForeignKeySuggestionTimedOut from e + values_by_column = {column: [] for column in columns} + seen_by_column = {column: set() for column in columns} + for row in results.rows: + for column in columns: + value = row[column] + if value is None or value in seen_by_column[column]: + continue + seen_by_column[column].add(value) + values_by_column[column].append(value) + return len(results.rows), values_by_column + + +async def _foreign_key_suggestion_values_exist(db, target, values, time_limit_ms): + if not values: + return False + sql = "select {} from {} where {} in ({})".format( + escape_sqlite(target["fk_column"]), + escape_sqlite(target["fk_table"]), + escape_sqlite(target["fk_column"]), + ", ".join("?" for _ in values), + ) + try: + results = await db.execute( + sql, + params=values, + custom_time_limit=time_limit_ms, + log_sql_errors=False, + ) + except QueryInterrupted as e: + raise ForeignKeySuggestionTimedOut from e + found = {row[0] for row in results.rows} + return all(value in found for value in values) async def _create_table_ui_context( @@ -609,6 +788,128 @@ class TableCreateView(BaseView): return Response.json(details, status=201) +class TableForeignKeySuggestionsView(BaseView): + name = "table-foreign-key-suggestions" + + def __init__(self, datasette): + self.ds = datasette + + async def get(self, request): + try: + resolved = await self.ds.resolve_table(request) + except NotFound as e: + return _error([e.args[0]], 404) + + db = resolved.db + database_name = db.name + table_name = resolved.table + + if resolved.is_view: + return _error(["Cannot suggest foreign keys for a view"], 400) + + if not await self.ds.allowed( + action="alter-table", + resource=TableResource(database=database_name, table=table_name), + actor=request.actor, + ): + return _error(["Permission denied: need alter-table"], 403) + + source_columns, targets, current_by_column = await db.execute_fn( + lambda conn: _foreign_key_suggestion_metadata(conn, table_name) + ) + + columns = [] + options_by_column = {} + for source_column in source_columns: + options = sorted( + [ + target + for target in targets + if _foreign_key_type_compatible( + source_column["affinity"], target["affinity"] + ) + ], + key=lambda target: _foreign_key_option_sort_key( + source_column["column"], target + ), + ) + options_by_column[source_column["column"]] = options + columns.append( + { + "column": source_column["column"], + "type": source_column["type"], + "affinity": source_column["affinity"], + "current": current_by_column.get(source_column["column"]), + "suggestions": [], + "options": [ + _public_foreign_key_target(option) for option in options + ], + } + ) + + columns_to_sample = [ + column["column"] + for column in columns + if options_by_column[column["column"]] + ] + row_check = { + "attempted": bool(columns_to_sample), + "status": "completed" if columns_to_sample else "skipped", + "row_limit": FOREIGN_KEY_SUGGESTION_ROW_LIMIT, + "sampled_rows": 0, + "checked_options": 0, + } + + try: + sampled_rows, values_by_column = await _foreign_key_suggestion_samples( + db, table_name, columns_to_sample + ) + row_check["sampled_rows"] = sampled_rows + deadline = time.perf_counter() + ( + FOREIGN_KEY_SUGGESTION_TOTAL_TIME_LIMIT_MS / 1000 + ) + for column_info in columns: + values = values_by_column.get(column_info["column"]) or [] + if not values: + continue + for option in options_by_column[column_info["column"]]: + remaining_ms = int((deadline - time.perf_counter()) * 1000) + if remaining_ms <= 0: + raise ForeignKeySuggestionTimedOut + if await _foreign_key_suggestion_values_exist( + db, + option, + values, + min(FOREIGN_KEY_SUGGESTION_TIME_LIMIT_MS, remaining_ms), + ): + reasons = [ + "type_match", + "sample_values_exist", + ] + _foreign_key_name_reasons(column_info["column"], option) + column_info["suggestions"].append( + { + "fk_table": option["fk_table"], + "fk_column": option["fk_column"], + "confidence": "sampled", + "sampled_values": len(values), + "reasons": reasons, + } + ) + row_check["checked_options"] += 1 + except ForeignKeySuggestionTimedOut: + row_check["status"] = "timed_out" + + return Response.json( + { + "ok": True, + "database": database_name, + "table": table_name, + "row_check": row_check, + "columns": columns, + } + ) + + class TableAlterView(BaseView): name = "table-alter" diff --git a/docs/json_api.rst b/docs/json_api.rst index af16626f..5b05e920 100644 --- a/docs/json_api.rst +++ b/docs/json_api.rst @@ -2097,6 +2097,64 @@ To use the ``"replace": true`` option you will also need the :ref:`actions_updat Pass ``"alter": true`` to automatically add any missing columns to the existing table that are present in the rows you are submitting. This requires the :ref:`actions_alter_table` permission. +.. _TableForeignKeySuggestionsView: + +Table foreign key suggestions +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The ``//
/-/foreign-key-suggestions`` endpoint suggests possible single-column foreign key relationships for a table. This requires the :ref:`actions_alter_table` permission. + +:: + + GET //
/-/foreign-key-suggestions + +The response includes every type-compatible single-column primary key target for each column in ``options``. Datasette also performs a bounded data check against up to 500 rows in the table: if the sampled non-null values for a column all exist in a target primary key, that target is included in ``suggestions``. + +If the bounded check takes too long, the endpoint fails open. It still returns the type-compatible ``options`` for each column, but ``row_check.status`` will be ``"timed_out"`` and there may be no ``suggestions``. + +.. code-block:: json + + { + "ok": true, + "database": "data", + "table": "projects", + "row_check": { + "attempted": true, + "status": "completed", + "row_limit": 500, + "sampled_rows": 3, + "checked_options": 4 + }, + "columns": [ + { + "column": "owner_id", + "type": "INTEGER", + "affinity": "integer", + "current": null, + "suggestions": [ + { + "fk_table": "owners", + "fk_column": "id", + "confidence": "sampled", + "sampled_values": 3, + "reasons": [ + "type_match", + "sample_values_exist", + "name_match" + ] + } + ], + "options": [ + { + "fk_table": "owners", + "fk_column": "id", + "type": "INTEGER" + } + ] + } + ] + } + .. _TableAlterView: Altering tables diff --git a/tests/test_api_write.py b/tests/test_api_write.py index 046cf695..36fe40e9 100644 --- a/tests/test_api_write.py +++ b/tests/test_api_write.py @@ -1044,6 +1044,106 @@ async def test_alter_table_foreign_key_without_fk_column_requires_single_pk(ds_w } +@pytest.mark.asyncio +async def test_foreign_key_suggestions(ds_write): + token = write_token(ds_write, permissions=["at"]) + db = ds_write.get_database("data") + await db.execute_write("create table owners (id integer primary key)") + await db.execute_write("insert into owners (id) values (1), (2), (3)") + await db.execute_write("create table categories (slug text primary key)") + await db.execute_write("insert into categories (slug) values ('one'), ('two')") + await db.execute_write("create table numbers (id integer primary key)") + await db.execute_write("insert into numbers (id) values (10), (20)") + await db.execute_write("create table weights (id real primary key)") + await db.execute_write("insert into weights (id) values (1.5), (2.5)") + await db.execute_write( + "insert into docs (id, title, score, age) values " + "(1, 'one', 1.5, 1), (2, 'two', 999.5, 2), (3, null, null, null)" + ) + + response = await ds_write.client.get( + "/data/docs/-/foreign-key-suggestions", + headers=_headers(token), + ) + assert response.status_code == 200, response.text + data = response.json() + assert data["ok"] is True + assert data["database"] == "data" + assert data["table"] == "docs" + assert data["row_check"]["attempted"] is True + assert data["row_check"]["status"] == "completed" + assert data["row_check"]["row_limit"] == 500 + assert data["row_check"]["sampled_rows"] == 3 + + columns = {column["column"]: column for column in data["columns"]} + assert columns["age"]["options"] == [ + {"fk_table": "numbers", "fk_column": "id", "type": "INTEGER"}, + {"fk_table": "owners", "fk_column": "id", "type": "INTEGER"}, + ] + assert columns["age"]["suggestions"] == [ + { + "fk_table": "owners", + "fk_column": "id", + "confidence": "sampled", + "sampled_values": 2, + "reasons": ["type_match", "sample_values_exist"], + } + ] + assert columns["title"]["options"] == [ + {"fk_table": "categories", "fk_column": "slug", "type": "TEXT"} + ] + assert columns["title"]["suggestions"][0]["fk_table"] == "categories" + assert columns["score"]["options"] == [ + {"fk_table": "weights", "fk_column": "id", "type": "REAL"} + ] + assert columns["score"]["suggestions"] == [] + + +@pytest.mark.asyncio +async def test_foreign_key_suggestions_permission_denied(ds_write): + token = write_token(ds_write, permissions=["ir"]) + response = await ds_write.client.get( + "/data/docs/-/foreign-key-suggestions", + headers=_headers(token), + ) + assert response.status_code == 403 + assert response.json() == { + "ok": False, + "errors": ["Permission denied: need alter-table"], + } + + +@pytest.mark.asyncio +async def test_foreign_key_suggestions_fail_open(ds_write, monkeypatch): + token = write_token(ds_write, permissions=["at"]) + db = ds_write.get_database("data") + await db.execute_write("create table owners (id integer primary key)") + + async def raise_timeout(*args, **kwargs): + raise table_create_alter.ForeignKeySuggestionTimedOut + + from datasette.views import table_create_alter + + monkeypatch.setattr( + table_create_alter, + "_foreign_key_suggestion_samples", + raise_timeout, + ) + + response = await ds_write.client.get( + "/data/docs/-/foreign-key-suggestions", + headers=_headers(token), + ) + assert response.status_code == 200, response.text + data = response.json() + assert data["row_check"]["status"] == "timed_out" + columns = {column["column"]: column for column in data["columns"]} + assert columns["age"]["options"] == [ + {"fk_table": "owners", "fk_column": "id", "type": "INTEGER"} + ] + assert columns["age"]["suggestions"] == [] + + @pytest.mark.asyncio async def test_alter_table_permission_denied(ds_write): token = write_token(ds_write, permissions=["ir"]) From a6ef65f90da8b7789a41972fbe89aba37e1b6a30 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 17 Jun 2026 16:29:59 -0700 Subject: [PATCH 12/31] //-/foreign-key-targets API endpoint Returns a list of tables with a single primary key, and for each one the name of that primary key column and its SQLite type affinity. This will be used by the create table UI to suggest foreign keys. --- datasette/app.py | 5 ++ datasette/views/table_create_alter.py | 60 ++++++++++++++++++++- docs/json_api.rst | 34 ++++++++++++ tests/test_api_write.py | 75 +++++++++++++++++++++++++++ 4 files changed, 173 insertions(+), 1 deletion(-) diff --git a/datasette/app.py b/datasette/app.py index 6b9f47ba..79dffb66 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -50,6 +50,7 @@ from .views.database import ( QueryView, ) from .views.table_create_alter import ( + DatabaseForeignKeyTargetsView, TableAlterView, TableCreateView, TableForeignKeySuggestionsView, @@ -2566,6 +2567,10 @@ class Datasette: r"/(?P[^\/\.]+)(\.(?P\w+))?$", ) add_route(TableCreateView.as_view(self), r"/(?P[^\/\.]+)/-/create$") + add_route( + DatabaseForeignKeyTargetsView.as_view(self), + r"/(?P[^\/\.]+)/-/foreign-key-targets$", + ) add_route( QueryListView.as_view(self), r"/(?P[^\/\.]+)/-/queries(\.(?Pjson))?$", diff --git a/datasette/views/table_create_alter.py b/datasette/views/table_create_alter.py index 2cb59ac1..ce43199c 100644 --- a/datasette/views/table_create_alter.py +++ b/datasette/views/table_create_alter.py @@ -52,6 +52,34 @@ ALTER_TABLE_TYPE_FOR_SQLITE_TYPE = { FOREIGN_KEY_SUGGESTION_ROW_LIMIT = 500 FOREIGN_KEY_SUGGESTION_TIME_LIMIT_MS = 50 FOREIGN_KEY_SUGGESTION_TOTAL_TIME_LIMIT_MS = 200 +FOREIGN_KEY_TARGETS_SQL = """ +select + m.name as fk_table, + p.name as fk_column, + case + when upper(coalesce(p.type, '')) like '%INT%' then 'integer' + when upper(coalesce(p.type, '')) like '%CHAR%' + or upper(coalesce(p.type, '')) like '%CLOB%' + or upper(coalesce(p.type, '')) like '%TEXT%' then 'text' + when upper(coalesce(p.type, '')) like '%BLOB%' + or coalesce(p.type, '') = '' then 'blob' + when upper(coalesce(p.type, '')) like '%REAL%' + or upper(coalesce(p.type, '')) like '%FLOA%' + or upper(coalesce(p.type, '')) like '%' || 'DOU' || 'B' || '%' then 'real' + else 'numeric' + end as type +from sqlite_master as m +cross join pragma_table_info(m.name) as p +where m.type = 'table' + and m.name not like 'sqlite_%' + and p.pk > 0 + and ( + select count(*) + from pragma_table_info(m.name) as p2 + where p2.pk > 0 + ) = 1 +order by m.name +""" class ForeignKeySuggestionTimedOut(Exception): @@ -66,7 +94,10 @@ def _sqlite_type_affinity(type_name): return "text" if "BLOB" in type_name or not type_name: return "blob" - if any(token in type_name for token in ("REAL", "FLOA", "DOUB")): + if any( + token in type_name + for token in ("REAL", "FLOA", "DOUB") # codespell:ignore doub + ): return "real" return "numeric" @@ -788,6 +819,33 @@ class TableCreateView(BaseView): return Response.json(details, status=201) +class DatabaseForeignKeyTargetsView(BaseView): + name = "database-foreign-key-targets" + + def __init__(self, datasette): + self.ds = datasette + + async def get(self, request): + db = await self.ds.resolve_database(request) + database_name = db.name + + if not await self.ds.allowed( + action="create-table", + resource=DatabaseResource(database=database_name), + actor=request.actor, + ): + return _error(["Permission denied: need create-table"], 403) + + targets = (await db.execute(FOREIGN_KEY_TARGETS_SQL)).dicts() + return Response.json( + { + "ok": True, + "database": database_name, + "targets": targets, + } + ) + + class TableForeignKeySuggestionsView(BaseView): name = "table-foreign-key-suggestions" diff --git a/docs/json_api.rst b/docs/json_api.rst index 5b05e920..dee98ef2 100644 --- a/docs/json_api.rst +++ b/docs/json_api.rst @@ -2097,6 +2097,40 @@ To use the ``"replace": true`` option you will also need the :ref:`actions_updat Pass ``"alter": true`` to automatically add any missing columns to the existing table that are present in the rows you are submitting. This requires the :ref:`actions_alter_table` permission. +.. _DatabaseForeignKeyTargetsView: + +Database foreign key targets +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The ``//-/foreign-key-targets`` endpoint returns the list of tables in a database that can be referenced by a single-column foreign key. This requires the :ref:`actions_create_table` permission. + +:: + + GET //-/foreign-key-targets + +The response includes only tables with exactly one primary key column. Tables with compound primary keys and tables with no explicit primary key are omitted. + +Each target includes the normalized SQLite type affinity for the primary key column in ``type``. The type is calculated using SQLite's documented affinity rules: ``INT`` maps to ``integer``; ``CHAR``, ``CLOB`` or ``TEXT`` maps to ``text``; ``BLOB`` or no type maps to ``blob``; ``REAL`` and floating-point declared types map to ``real``; everything else maps to ``numeric``. + +.. code-block:: json + + { + "ok": true, + "database": "data", + "targets": [ + { + "fk_table": "owners", + "fk_column": "id", + "type": "integer" + }, + { + "fk_table": "categories", + "fk_column": "slug", + "type": "text" + } + ] + } + .. _TableForeignKeySuggestionsView: Table foreign key suggestions diff --git a/tests/test_api_write.py b/tests/test_api_write.py index 36fe40e9..18ffe43d 100644 --- a/tests/test_api_write.py +++ b/tests/test_api_write.py @@ -1144,6 +1144,81 @@ async def test_foreign_key_suggestions_fail_open(ds_write, monkeypatch): assert columns["age"]["suggestions"] == [] +@pytest.mark.asyncio +async def test_foreign_key_targets(ds_write): + token = write_token(ds_write, permissions=["ct"]) + db = ds_write.get_database("data") + await db.execute_write("create table owners (id integer primary key)") + await db.execute_write("create table categories (slug varchar(30) primary key)") + await db.execute_write("create table blob_things (hash blob primary key)") + await db.execute_write( + "create table numeric_codes (code decimal(10,5) primary key)" + ) + await db.execute_write( + 'create table floating_point (value "FLOATING POINT" primary key)' + ) + await db.execute_write( + "create table compound (a integer, b integer, primary key (a, b))" + ) + await db.execute_write("create table no_pk (name text)") + + response = await ds_write.client.get( + "/data/-/foreign-key-targets", + headers=_headers(token), + ) + assert response.status_code == 200, response.text + assert response.json() == { + "ok": True, + "database": "data", + "targets": [ + { + "fk_table": "blob_things", + "fk_column": "hash", + "type": "blob", + }, + { + "fk_table": "categories", + "fk_column": "slug", + "type": "text", + }, + { + "fk_table": "docs", + "fk_column": "id", + "type": "integer", + }, + { + "fk_table": "floating_point", + "fk_column": "value", + "type": "integer", + }, + { + "fk_table": "numeric_codes", + "fk_column": "code", + "type": "numeric", + }, + { + "fk_table": "owners", + "fk_column": "id", + "type": "integer", + }, + ], + } + + +@pytest.mark.asyncio +async def test_foreign_key_targets_permission_denied(ds_write): + token = write_token(ds_write, permissions=["ir"]) + response = await ds_write.client.get( + "/data/-/foreign-key-targets", + headers=_headers(token), + ) + assert response.status_code == 403 + assert response.json() == { + "ok": False, + "errors": ["Permission denied: need create-table"], + } + + @pytest.mark.asyncio async def test_alter_table_permission_denied(ds_write): token = write_token(ds_write, permissions=["ir"]) From 21c156dfb1ca76d8e8c2e04c044766a155e22edc Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 18 Jun 2026 08:13:28 -0700 Subject: [PATCH 13/31] Expose foreign key targets to create table UI - Add foreignKeyTargetsPath to create table page data - Filter hidden tables from database-level foreign key target results - Update JSON API docs and tests for filtered targets --- datasette/views/table_create_alter.py | 12 +++++++++++- docs/json_api.rst | 2 +- tests/test_api_write.py | 8 ++++++++ tests/test_table_html.py | 1 + 4 files changed, 21 insertions(+), 2 deletions(-) diff --git a/datasette/views/table_create_alter.py b/datasette/views/table_create_alter.py index ce43199c..f7775479 100644 --- a/datasette/views/table_create_alter.py +++ b/datasette/views/table_create_alter.py @@ -262,6 +262,9 @@ async def _create_table_ui_context( return None data = { "path": "{}/-/create".format(datasette.urls.database(database_name)), + "foreignKeyTargetsPath": "{}/-/foreign-key-targets".format( + datasette.urls.database(database_name) + ), "databaseName": database_name, "columnTypes": CREATE_TABLE_COLUMN_TYPES, } @@ -836,7 +839,14 @@ class DatabaseForeignKeyTargetsView(BaseView): ): return _error(["Permission denied: need create-table"], 403) - targets = (await db.execute(FOREIGN_KEY_TARGETS_SQL)).dicts() + hidden_tables = await db.execute_fn( + lambda conn: set(sqlite_hidden_table_names(conn)) + ) + targets = [ + target + for target in (await db.execute(FOREIGN_KEY_TARGETS_SQL)).dicts() + if target["fk_table"] not in hidden_tables + ] return Response.json( { "ok": True, diff --git a/docs/json_api.rst b/docs/json_api.rst index dee98ef2..1db46dd2 100644 --- a/docs/json_api.rst +++ b/docs/json_api.rst @@ -2108,7 +2108,7 @@ The ``//-/foreign-key-targets`` endpoint returns the list of tables in GET //-/foreign-key-targets -The response includes only tables with exactly one primary key column. Tables with compound primary keys and tables with no explicit primary key are omitted. +The response includes only tables with exactly one primary key column. Hidden tables, tables with compound primary keys and tables with no explicit primary key are omitted. Each target includes the normalized SQLite type affinity for the primary key column in ``type``. The type is calculated using SQLite's documented affinity rules: ``INT`` maps to ``integer``; ``CHAR``, ``CLOB`` or ``TEXT`` maps to ``text``; ``BLOB`` or no type maps to ``blob``; ``REAL`` and floating-point declared types map to ``real``; everything else maps to ``numeric``. diff --git a/tests/test_api_write.py b/tests/test_api_write.py index 18ffe43d..1d16ad26 100644 --- a/tests/test_api_write.py +++ b/tests/test_api_write.py @@ -1161,6 +1161,10 @@ async def test_foreign_key_targets(ds_write): "create table compound (a integer, b integer, primary key (a, b))" ) await db.execute_write("create table no_pk (name text)") + try: + await db.execute_write("create virtual table search_docs using fts5(body)") + except Exception: + pass response = await ds_write.client.get( "/data/-/foreign-key-targets", @@ -1203,6 +1207,10 @@ async def test_foreign_key_targets(ds_write): }, ], } + assert not any( + target["fk_table"].startswith("search_docs_") + for target in response.json()["targets"] + ) @pytest.mark.asyncio diff --git a/tests/test_table_html.py b/tests/test_table_html.py index 374cc08d..fa01c8ec 100644 --- a/tests/test_table_html.py +++ b/tests/test_table_html.py @@ -994,6 +994,7 @@ async def test_database_create_table_action_button_and_data(): assert database_data_from_soup(soup) == { "createTable": { "path": "/data/-/create", + "foreignKeyTargetsPath": "/data/-/foreign-key-targets", "databaseName": "data", "columnTypes": ["text", "integer", "float", "blob"], }, From 1f863def5e410cc3f836f1e07404aa68b578db9b Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 18 Jun 2026 08:13:37 -0700 Subject: [PATCH 14/31] Add foreign key controls to create table dialog - Add create table advanced controls for foreign keys and first-column primary keys - Share schema dialog row helpers between create and alter dialogs - Move custom type into advanced options and add Add column icons --- datasette/static/app.css | 210 ++++++-- datasette/static/edit-tools.js | 954 ++++++++++++++++++++++++--------- 2 files changed, 862 insertions(+), 302 deletions(-) diff --git a/datasette/static/app.css b/datasette/static/app.css index f9ebe5ac..49f070b1 100644 --- a/datasette/static/app.css +++ b/datasette/static/app.css @@ -1760,7 +1760,7 @@ dialog.table-create-dialog { border-radius: var(--modal-border-radius, 0.75rem); padding: 0; margin: auto; - width: min(760px, calc(100vw - 32px)); + width: min(980px, calc(100vw - 32px)); max-width: 95vw; max-height: min(780px, 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)); @@ -1839,7 +1839,7 @@ dialog.table-create-dialog::backdrop { } .table-create-label, -.table-create-columns-heading { +.table-create-column-headings { color: var(--ink); font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 0.82rem; @@ -1876,10 +1876,12 @@ dialog.table-create-dialog::backdrop { color: var(--muted); } +.table-create-foreign-key-target option, .table-create-custom-column-type option { color: var(--ink); } +.table-create-foreign-key-target option[value=""], .table-create-custom-column-type option[value=""] { color: var(--muted); } @@ -1898,40 +1900,130 @@ dialog.table-create-dialog::backdrop { gap: 10px; } -.table-create-columns-heading { - font-weight: 600; -} - .table-create-column-list { display: grid; gap: 8px; } -.table-create-column-row { +.table-create-column-headings, +.table-create-column-main { display: grid; - grid-template-columns: minmax(140px, 1.2fr) minmax(7.5rem, 0.7fr) minmax(12rem, 1fr) minmax(3.5rem, max-content) 32px; + grid-template-columns: minmax(140px, 1.2fr) minmax(7.5rem, 0.7fr) max-content 32px; align-items: center; gap: 8px; min-width: 0; - position: relative; } -.table-create-primary-key { - display: inline-flex; - align-items: center; - gap: 6px; - color: var(--ink); - font-size: 0.85rem; +.table-create-column-row { + display: grid; + gap: 8px; min-width: 0; - white-space: nowrap; - justify-self: center; } -.table-create-primary-key-input { +.table-create-column-headings { + color: var(--muted); + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.72rem; + padding: 0 1px; +} + +.table-create-column-details { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + align-items: start; + gap: 12px 16px; + padding: 12px; + border-left: 3px solid var(--rule); + background: #f8fafc; +} + +.table-create-column-details[hidden] { + display: none; +} + +.table-create-detail-field { + display: grid; + gap: 4px; + min-width: 0; +} + +.table-create-detail-label { + color: var(--muted); + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.72rem; +} + +.table-create-detail-help { + color: var(--muted); + font-size: 0.82rem; + line-height: 1.35; margin: 0; } -.table-create-remove-column { +.table-create-detail-check { + display: inline-flex; + align-items: flex-start; + gap: 8px; + color: var(--ink); + font-size: 0.85rem; + line-height: 1.35; + min-width: 0; + white-space: normal; +} + +.table-create-primary-key, +.table-create-foreign-key-field { + grid-column: 1 / -1; +} + +.table-create-detail-check input { + flex: 0 0 auto; + margin: 0.15rem 0 0; +} + +.table-create-detail-check span { + min-width: 0; + overflow-wrap: break-word; +} + +.table-create-move-controls { + display: grid; + grid-template-columns: repeat(4, 32px); + gap: 4px; + justify-content: start; +} + +.table-create-more-options { + appearance: none; + border: 0; + background: transparent; + color: var(--accent); + cursor: pointer; + font: inherit; + font-size: 0.85rem; + justify-self: start; + padding: 0; + grid-column: 1 / -1; + text-align: left; +} + +.table-create-more-options:hover, +.table-create-more-options:focus { + text-decoration: underline; +} + +.table-create-more-options:focus { + outline: 3px solid rgba(26, 86, 219, 0.12); + outline-offset: 2px; +} + +.table-create-more-options:disabled { + color: var(--muted); + cursor: default; + text-decoration: none; +} + +.table-create-icon-button { appearance: none; border: 1px solid rgba(74, 85, 104, 0.24); background: transparent; @@ -1945,17 +2037,17 @@ dialog.table-create-dialog::backdrop { padding: 0; } -.table-create-remove-column:hover, -.table-create-remove-column:focus { +.table-create-icon-button:hover, +.table-create-icon-button:focus { background: rgba(74, 85, 104, 0.07); } -.table-create-remove-column:focus { +.table-create-icon-button:focus { outline: 3px solid #b3d4ff; outline-offset: 1px; } -.table-create-remove-column svg { +.table-create-icon-button svg { display: block; } @@ -1967,11 +2059,19 @@ dialog.table-create-dialog::backdrop { background: #fff; color: var(--accent); cursor: pointer; + display: inline-flex; + align-items: center; + gap: 6px; font: inherit; font-size: 0.85rem; padding: 7px 10px; } +.table-create-add-column svg { + display: block; + flex: 0 0 auto; +} + .table-create-add-column:hover, .table-create-add-column:focus { background: #f8fafc; @@ -2027,7 +2127,7 @@ dialog.table-create-dialog::backdrop { .table-create-dialog .btn:disabled, .table-create-add-column:disabled, -.table-create-remove-column:disabled { +.table-create-icon-button:disabled { opacity: 0.55; cursor: not-allowed; } @@ -2198,7 +2298,7 @@ dialog.table-alter-dialog::backdrop { .table-alter-column-headings, .table-alter-column-main { display: grid; - grid-template-columns: minmax(140px, 1.2fr) minmax(7.5rem, 0.7fr) minmax(12rem, 1fr) max-content 32px; + grid-template-columns: minmax(140px, 1.2fr) minmax(7.5rem, 0.7fr) max-content 32px; align-items: center; gap: 8px; min-width: 0; @@ -2384,11 +2484,19 @@ dialog.table-alter-dialog::backdrop { background: #fff; color: var(--accent); cursor: pointer; + display: inline-flex; + align-items: center; + gap: 6px; font: inherit; font-size: 0.85rem; padding: 7px 10px; } +.table-alter-add-column svg { + display: block; + flex: 0 0 auto; +} + .table-alter-add-column:hover, .table-alter-add-column:focus { background: #f8fafc; @@ -2504,21 +2612,16 @@ dialog.table-alter-dialog::backdrop { justify-self: end; } - .table-alter-custom-column-type { - grid-column: 1 / 3; - grid-row: 2; - } - .table-alter-move-controls { grid-column: 1; - grid-row: 3; + grid-row: 2; justify-self: start; } .table-alter-more-options { align-self: center; grid-column: 2 / 4; - grid-row: 3; + grid-row: 2; } .table-alter-column-details { @@ -2700,13 +2803,50 @@ dialog.table-alter-dialog::backdrop { padding-top: 0; } + .table-create-column-headings { + display: none; + } + .table-create-column-row { - grid-template-columns: minmax(0, 1fr) 8.5rem 3.5rem 32px; + padding-bottom: 8px; + border-bottom: 1px solid var(--rule); + } + + .table-create-column-main { + grid-template-columns: minmax(0, 1fr) minmax(7.5rem, 0.8fr) 32px; align-items: end; } - .table-create-custom-column-type { - grid-column: 1 / -1; + .table-create-column-name { + grid-column: 1; + grid-row: 1; + } + + .table-create-column-type { + grid-column: 2; + grid-row: 1; + } + + .table-create-remove-column { + grid-column: 3; + grid-row: 1; + justify-self: end; + } + + .table-create-move-controls { + grid-column: 1; + grid-row: 2; + justify-self: start; + } + + .table-create-more-options { + align-self: center; + grid-column: 2 / 4; + grid-row: 2; + } + + .table-create-column-details { + grid-template-columns: 1fr; } .table-create-dialog .modal-footer { diff --git a/datasette/static/edit-tools.js b/datasette/static/edit-tools.js index cb964365..5a9d0962 100644 --- a/datasette/static/edit-tools.js +++ b/datasette/static/edit-tools.js @@ -64,12 +64,220 @@ function sqliteColumnTypeLabel(type) { if (type === "float") { return "floating point number"; } + if (type === "real") { + return "floating point number"; + } if (type === "blob") { return "blob - binary data"; } return type; } +function populateSqliteColumnTypeSelect(select, type, options) { + options.forEach(function (option) { + var optionElement = document.createElement("option"); + optionElement.value = option; + optionElement.textContent = sqliteColumnTypeLabel(option); + select.appendChild(optionElement); + }); + select.value = options.indexOf(type) === -1 ? options[0] : type; +} + +function updateSelectPlaceholder(select, placeholderClass) { + select.classList.toggle(placeholderClass, !select.value); +} + +function createCustomColumnTypeSelect(options, className, placeholderClass) { + var select = document.createElement("select"); + select.className = className; + select.setAttribute("aria-label", "Custom column type"); + var blankOption = document.createElement("option"); + blankOption.value = ""; + blankOption.textContent = "- custom type -"; + select.appendChild(blankOption); + options.forEach(function (option) { + var optionElement = document.createElement("option"); + optionElement.value = option.name; + optionElement.textContent = option.description + ? option.description + " (" + option.name + ")" + : option.name; + select.appendChild(optionElement); + }); + updateSelectPlaceholder(select, placeholderClass); + return select; +} + +var COLUMN_MOVE_ICONS = { + top: '', + up: '', + down: '', + bottom: + '', + remove: + '', +}; + +function createSchemaDialogIconButton(prefix, modifier, ariaLabel, title, svg) { + var button = document.createElement("button"); + button.type = "button"; + button.className = prefix + "-icon-button " + prefix + "-" + modifier; + button.setAttribute("aria-label", ariaLabel); + button.title = title; + button.dataset.defaultTitle = title; + button.innerHTML = svg; + return button; +} + +function createSchemaDialogMoveControls(prefix) { + var moveControls = document.createElement("div"); + moveControls.className = prefix + "-move-controls"; + + var moveTopButton = createSchemaDialogIconButton( + prefix, + "move-top", + "Move column to top", + "Move column to top", + COLUMN_MOVE_ICONS.top, + ); + var moveUpButton = createSchemaDialogIconButton( + prefix, + "move-up", + "Move column up", + "Move column up", + COLUMN_MOVE_ICONS.up, + ); + var moveDownButton = createSchemaDialogIconButton( + prefix, + "move-down", + "Move column down", + "Move column down", + COLUMN_MOVE_ICONS.down, + ); + var moveBottomButton = createSchemaDialogIconButton( + prefix, + "move-bottom", + "Move column to bottom", + "Move column to bottom", + COLUMN_MOVE_ICONS.bottom, + ); + + moveControls.appendChild(moveTopButton); + moveControls.appendChild(moveUpButton); + moveControls.appendChild(moveDownButton); + moveControls.appendChild(moveBottomButton); + + return { + controls: moveControls, + topButton: moveTopButton, + upButton: moveUpButton, + downButton: moveDownButton, + bottomButton: moveBottomButton, + }; +} + +function createSchemaDialogMoreOptionsButton(prefix, details) { + var expandButton = document.createElement("button"); + expandButton.type = "button"; + expandButton.className = prefix + "-more-options"; + expandButton.setAttribute("aria-label", "Toggle column settings"); + expandButton.setAttribute("aria-controls", details.id); + expandButton.setAttribute("aria-expanded", details.hidden ? "false" : "true"); + updateSchemaDialogMoreOptionsButton(expandButton); + return expandButton; +} + +function updateSchemaDialogMoreOptionsButton(button) { + var isExpanded = button.getAttribute("aria-expanded") === "true"; + button.textContent = isExpanded ? "v Hide options" : "> Advanced options"; + button.title = isExpanded ? "Hide column settings" : "Show column settings"; +} + +function toggleSchemaDialogMoreOptions(button, details) { + var isExpanded = button.getAttribute("aria-expanded") === "true"; + details.hidden = isExpanded; + button.setAttribute("aria-expanded", isExpanded ? "false" : "true"); + updateSchemaDialogMoreOptionsButton(button); +} + +function schemaDialogRows(state, prefix) { + return Array.prototype.slice.call( + state.columnList.querySelectorAll("." + prefix + "-column-row"), + ); +} + +function schemaDialogRowIsPrimaryKey(row, prefix) { + var input = row && row.querySelector("." + prefix + "-primary-key-input"); + return !!(input && input.checked); +} + +function schemaDialogFirstNonPrimaryRow(state, prefix) { + var rows = schemaDialogRows(state, prefix); + for (var i = 0; i < rows.length; i += 1) { + if (!schemaDialogRowIsPrimaryKey(rows[i], prefix)) { + return rows[i]; + } + } + return null; +} + +function updateSchemaDialogMoveButtons(state, prefix) { + if (!state || !state.columnList) { + return; + } + var firstNonPrimary = schemaDialogFirstNonPrimaryRow(state, prefix); + var rows = schemaDialogRows(state, prefix); + var hasPrimaryKeys = rows.some(function (row) { + return schemaDialogRowIsPrimaryKey(row, prefix); + }); + var primaryKeyMoveTitle = "Primary key columns are always listed first"; + rows.forEach(function (row) { + var isPrimaryKey = schemaDialogRowIsPrimaryKey(row, prefix); + var previous = row.previousElementSibling; + var next = row.nextElementSibling; + row + .querySelectorAll("." + prefix + "-move-controls button") + .forEach(function (button) { + button.title = button.dataset.defaultTitle || button.title; + button.disabled = state.isSaving || isPrimaryKey; + if (isPrimaryKey) { + button.title = primaryKeyMoveTitle; + } + }); + if (!isPrimaryKey) { + var topButton = row.querySelector("." + prefix + "-move-top"); + var upButton = row.querySelector("." + prefix + "-move-up"); + var downButton = row.querySelector("." + prefix + "-move-down"); + var bottomButton = row.querySelector("." + prefix + "-move-bottom"); + topButton.disabled = + state.isSaving || !firstNonPrimary || row === firstNonPrimary; + upButton.disabled = + state.isSaving || !previous || schemaDialogRowIsPrimaryKey(previous, prefix); + downButton.disabled = state.isSaving || !next; + bottomButton.disabled = state.isSaving || !next; + if (hasPrimaryKeys && row === firstNonPrimary) { + topButton.title = primaryKeyMoveTitle; + upButton.title = primaryKeyMoveTitle; + } + } + }); +} + +function normalizeSchemaDialogPrimaryKeyRows(state, prefix) { + var rows = schemaDialogRows(state, prefix); + rows + .filter(function (row) { + return schemaDialogRowIsPrimaryKey(row, prefix); + }) + .concat( + rows.filter(function (row) { + return !schemaDialogRowIsPrimaryKey(row, prefix); + }), + ) + .forEach(function (row) { + state.columnList.appendChild(row); + }); +} + function tableCreateCustomColumnTypes() { var data = databaseCreateTableData() || {}; return data.customColumnTypes || []; @@ -93,15 +301,205 @@ function tableCreateCustomTypeAppliesToSqliteType(option, sqliteType) { ); } +function tableCreateDialogRows(state) { + return schemaDialogRows(state, "table-create"); +} + +function tableCreateRowIsPrimaryKey(row) { + return schemaDialogRowIsPrimaryKey(row, "table-create"); +} + +function tableCreateFirstNonPrimaryRow(state) { + return schemaDialogFirstNonPrimaryRow(state, "table-create"); +} + +function updateTableCreateMoveButtons(state) { + updateSchemaDialogMoveButtons(state, "table-create"); +} + +function tableCreateTypeAffinity(type) { + if (type === "float") { + return "real"; + } + return type; +} + +function foreignKeyTypesCompatible(sourceAffinity, targetAffinity) { + if (sourceAffinity === targetAffinity) { + return true; + } + var numericAffinities = ["integer", "real", "numeric"]; + if (sourceAffinity === "numeric") { + return numericAffinities.indexOf(targetAffinity) !== -1; + } + if (targetAffinity === "numeric") { + return numericAffinities.indexOf(sourceAffinity) !== -1; + } + return false; +} + +function tableCreateForeignKeyTargetKey(target) { + return target.fk_table + "\u001f" + target.fk_column; +} + +function tableCreateForeignKeyTargetLabel(target) { + return ( + target.fk_table + + "." + + target.fk_column + + " (" + + sqliteColumnTypeLabel(target.type) + + ")" + ); +} + +function tableCreateForeignKeyTargetsUrl() { + var data = databaseCreateTableData() || {}; + if (data.foreignKeyTargetsPath) { + return data.foreignKeyTargetsPath; + } + if (!data.path) { + return null; + } + return data.path.replace(/\/-\/create$/, "/-/foreign-key-targets"); +} + +function populateTableCreateForeignKeySelect(select, state, sourceType) { + var previousKey = select.value || select.dataset.selectedKey || ""; + select.textContent = ""; + + var blankOption = document.createElement("option"); + blankOption.value = ""; + blankOption.textContent = "- no foreign key -"; + select.appendChild(blankOption); + + if (state.foreignKeyTargetsLoading) { + var loadingOption = document.createElement("option"); + loadingOption.value = ""; + loadingOption.disabled = true; + loadingOption.textContent = "Loading foreign keys..."; + select.appendChild(loadingOption); + } else if (state.foreignKeyTargetsError) { + var errorOption = document.createElement("option"); + errorOption.value = ""; + errorOption.disabled = true; + errorOption.textContent = "Could not load foreign keys"; + select.appendChild(errorOption); + } else { + var sourceAffinity = tableCreateTypeAffinity(sourceType); + (state.foreignKeyTargets || []).forEach(function (target) { + if (!foreignKeyTypesCompatible(sourceAffinity, target.type)) { + return; + } + var optionElement = document.createElement("option"); + optionElement.value = tableCreateForeignKeyTargetKey(target); + optionElement.dataset.fkTable = target.fk_table; + optionElement.dataset.fkColumn = target.fk_column; + optionElement.textContent = tableCreateForeignKeyTargetLabel(target); + select.appendChild(optionElement); + }); + } + + select.value = previousKey; + if (select.value !== previousKey) { + select.value = ""; + } + select.dataset.selectedKey = select.value; + select.disabled = state.isSaving || select.options.length <= 1; + updateSelectPlaceholder(select, "table-create-input-placeholder"); +} + +function syncTableCreateForeignKeyOptions(row, state) { + var typeSelect = row.querySelector(".table-create-column-type"); + var foreignKeySelect = row.querySelector(".table-create-foreign-key-target"); + if (!typeSelect || !foreignKeySelect) { + return; + } + populateTableCreateForeignKeySelect( + foreignKeySelect, + state, + typeSelect.value, + ); +} + +function refreshTableCreateForeignKeyControls(state) { + tableCreateDialogRows(state).forEach(function (row, index) { + if (index > 0) { + syncTableCreateForeignKeyOptions(row, state); + } + }); +} + +function updateTableCreateColumnRules(state) { + tableCreateDialogRows(state).forEach(function (row, index) { + var isFirstColumn = index === 0; + var pkLabel = row.querySelector(".table-create-primary-key"); + var pkInput = row.querySelector(".table-create-primary-key-input"); + var foreignKeyField = row.querySelector(".table-create-foreign-key-field"); + var foreignKeySelect = row.querySelector(".table-create-foreign-key-target"); + + if (pkLabel && pkInput) { + pkLabel.hidden = !isFirstColumn; + if (!isFirstColumn) { + pkInput.checked = false; + } + } + + if (foreignKeyField && foreignKeySelect) { + foreignKeyField.hidden = isFirstColumn; + if (isFirstColumn) { + foreignKeySelect.value = ""; + foreignKeySelect.dataset.selectedKey = ""; + foreignKeySelect.disabled = true; + updateTableCreateCustomColumnTypePlaceholder(foreignKeySelect); + } else { + syncTableCreateForeignKeyOptions(row, state); + } + } + }); + updateTableCreateMoveButtons(state); +} + +async function loadTableCreateForeignKeyTargets(state) { + var url = tableCreateForeignKeyTargetsUrl(); + if (!url || !window.fetch) { + state.foreignKeyTargets = []; + state.foreignKeyTargetsLoading = false; + refreshTableCreateForeignKeyControls(state); + return; + } + state.foreignKeyTargets = []; + state.foreignKeyTargetsError = null; + state.foreignKeyTargetsLoading = true; + refreshTableCreateForeignKeyControls(state); + try { + var response = await fetch(url, { + headers: { + Accept: "application/json", + }, + }); + var data = await response.json(); + if (!response.ok || data.ok === false) { + throw rowMutationRequestError(response, data); + } + state.foreignKeyTargets = data.targets || []; + } catch (error) { + state.foreignKeyTargets = []; + state.foreignKeyTargetsError = error; + } finally { + state.foreignKeyTargetsLoading = false; + refreshTableCreateForeignKeyControls(state); + } +} + function tableCreateDialogSignature(state) { if (!state || !state.form) { return ""; } - var columns = []; - state.columnList - .querySelectorAll(".table-create-column-row") - .forEach(function (row) { - columns.push({ + return JSON.stringify({ + table: state.tableName.value, + columns: tableCreateDialogRows(state).map(function (row) { + return { name: row.querySelector(".table-create-column-name").value, type: row.querySelector(".table-create-column-type").value, customType: @@ -111,11 +509,14 @@ function tableCreateDialogSignature(state) { } ).value || "", pk: row.querySelector(".table-create-primary-key-input").checked, - }); - }); - return JSON.stringify({ - table: state.tableName.value, - columns: columns, + foreignKey: + ( + row.querySelector(".table-create-foreign-key-target") || { + value: "", + } + ).value || "", + }; + }), }); } @@ -151,42 +552,28 @@ function setTableCreateDialogSaving(state, isSaving) { .forEach(function (control) { control.disabled = isSaving; }); + if (!isSaving) { + updateTableCreateColumnRules(state); + } + updateTableCreateMoveButtons(state); } function tableCreateSelectTypeValue(select, type) { var options = tableCreateColumnTypes(); - options.forEach(function (option) { - var optionElement = document.createElement("option"); - optionElement.value = option; - optionElement.textContent = sqliteColumnTypeLabel(option); - select.appendChild(optionElement); - }); - select.value = options.indexOf(type) === -1 ? options[0] : type; + populateSqliteColumnTypeSelect(select, type, options); } function updateTableCreateCustomColumnTypePlaceholder(select) { - select.classList.toggle("table-create-input-placeholder", !select.value); + updateSelectPlaceholder(select, "table-create-input-placeholder"); } function createTableCustomColumnTypeSelect() { var options = tableCreateCustomColumnTypes(); - var select = document.createElement("select"); - select.className = "table-create-input table-create-custom-column-type"; - select.setAttribute("aria-label", "Custom column type"); - var blankOption = document.createElement("option"); - blankOption.value = ""; - blankOption.textContent = "- custom type -"; - select.appendChild(blankOption); - options.forEach(function (option) { - var optionElement = document.createElement("option"); - optionElement.value = option.name; - optionElement.textContent = option.description - ? option.description + " (" + option.name + ")" - : option.name; - select.appendChild(optionElement); - }); - updateTableCreateCustomColumnTypePlaceholder(select); - return select; + return createCustomColumnTypeSelect( + options, + "table-create-input table-create-custom-column-type", + "table-create-input-placeholder", + ); } function syncTableCreateCustomTypeForSqliteType(row) { @@ -209,6 +596,19 @@ function createTableColumnRow(state, column) { var row = document.createElement("div"); row.className = "table-create-column-row"; + var main = document.createElement("div"); + main.className = "table-create-column-main"; + + var details = document.createElement("div"); + details.className = "table-create-column-details"; + details.id = "table-create-column-details-" + index; + details.hidden = !(column && column.expanded); + + var expandButton = createSchemaDialogMoreOptionsButton( + "table-create", + details, + ); + var nameId = "table-create-column-name-" + index; var nameLabel = document.createElement("label"); nameLabel.className = "table-create-column-label"; @@ -228,40 +628,88 @@ function createTableColumnRow(state, column) { typeSelect.setAttribute("aria-label", "Column type"); tableCreateSelectTypeValue(typeSelect, column && column.type); - var customTypeSelect = createTableCustomColumnTypeSelect(); - if (column && column.customType) { - customTypeSelect.value = column.customType; + var customTypeSelect = null; + var customTypeField = null; + if (tableCreateCustomColumnTypes().length) { + var customTypeId = "table-create-column-custom-type-" + index; + customTypeField = document.createElement("div"); + customTypeField.className = + "table-create-detail-field table-create-custom-type-field"; + var customTypeLabel = document.createElement("label"); + customTypeLabel.className = "table-create-detail-label"; + customTypeLabel.setAttribute("for", customTypeId); + customTypeLabel.textContent = "Custom type"; + customTypeSelect = createTableCustomColumnTypeSelect(); + customTypeSelect.id = customTypeId; + customTypeSelect.value = column && column.customType ? column.customType : ""; + updateTableCreateCustomColumnTypePlaceholder(customTypeSelect); + customTypeField.appendChild(customTypeLabel); + customTypeField.appendChild(customTypeSelect); } - updateTableCreateCustomColumnTypePlaceholder(customTypeSelect); var pkLabel = document.createElement("label"); - pkLabel.className = "table-create-primary-key"; + pkLabel.className = "table-create-detail-check table-create-primary-key"; var pkInput = document.createElement("input"); pkInput.type = "checkbox"; pkInput.className = "table-create-primary-key-input"; pkInput.checked = !!(column && column.primaryKey); var pkText = document.createElement("span"); - pkText.textContent = "PK"; - pkText.title = "Primary key"; + var pkStrong = document.createElement("strong"); + pkStrong.textContent = "Primary key"; + pkText.appendChild(pkStrong); + pkText.appendChild( + document.createTextNode(" This ID uniquely identifies the record"), + ); pkLabel.appendChild(pkInput); pkLabel.appendChild(pkText); - var removeButton = document.createElement("button"); - removeButton.type = "button"; - removeButton.className = "table-create-remove-column"; - removeButton.setAttribute("aria-label", "Remove column"); - removeButton.title = "Remove column"; - removeButton.innerHTML = - ''; + var foreignKeyId = "table-create-column-foreign-key-" + index; + var foreignKeyHelpId = "table-create-column-foreign-key-help-" + index; + var foreignKeyField = document.createElement("div"); + foreignKeyField.className = + "table-create-detail-field table-create-foreign-key-field"; + var foreignKeyLabel = document.createElement("label"); + foreignKeyLabel.className = "table-create-detail-label"; + foreignKeyLabel.setAttribute("for", foreignKeyId); + foreignKeyLabel.textContent = "Foreign key"; + var foreignKeyHelp = document.createElement("p"); + foreignKeyHelp.id = foreignKeyHelpId; + foreignKeyHelp.className = "table-create-detail-help"; + foreignKeyHelp.textContent = "Link this column to another table."; + var foreignKeySelect = document.createElement("select"); + foreignKeySelect.id = foreignKeyId; + foreignKeySelect.className = + "table-create-input table-create-foreign-key-target"; + foreignKeySelect.setAttribute("aria-label", "Foreign key target"); + foreignKeySelect.setAttribute("aria-describedby", foreignKeyHelpId); + foreignKeyField.appendChild(foreignKeyLabel); + foreignKeyField.appendChild(foreignKeyHelp); + foreignKeyField.appendChild(foreignKeySelect); - row.appendChild(nameLabel); - row.appendChild(nameInput); - row.appendChild(typeSelect); - if (tableCreateCustomColumnTypes().length) { - row.appendChild(customTypeSelect); + var moveControls = createSchemaDialogMoveControls("table-create"); + + var removeButton = createSchemaDialogIconButton( + "table-create", + "remove-column", + "Remove column", + "Remove column", + COLUMN_MOVE_ICONS.remove, + ); + + main.appendChild(nameLabel); + main.appendChild(nameInput); + main.appendChild(typeSelect); + main.appendChild(moveControls.controls); + main.appendChild(removeButton); + main.appendChild(expandButton); + + if (customTypeField) { + details.appendChild(customTypeField); } - row.appendChild(pkLabel); - row.appendChild(removeButton); + details.appendChild(foreignKeyField); + details.appendChild(pkLabel); + row.appendChild(main); + row.appendChild(details); removeButton.addEventListener("click", function () { if (state.isSaving) { @@ -269,6 +717,7 @@ function createTableColumnRow(state, column) { } row.remove(); clearTableCreateDialogError(state); + updateTableCreateColumnRules(state); var nextInput = state.columnList.querySelector(".table-create-column-name"); if (nextInput) { nextInput.focus(); @@ -283,21 +732,94 @@ function createTableColumnRow(state, column) { typeSelect.addEventListener("change", function () { clearTableCreateDialogError(state); syncTableCreateCustomTypeForSqliteType(row); + syncTableCreateForeignKeyOptions(row, state); }); - customTypeSelect.addEventListener("change", function () { - clearTableCreateDialogError(state); - updateTableCreateCustomColumnTypePlaceholder(customTypeSelect); - var option = tableCreateCustomColumnType(customTypeSelect.value); - if ( - option && - option.fixedSqliteType && - tableCreateColumnTypes().indexOf(option.fixedSqliteType) !== -1 - ) { - typeSelect.value = option.fixedSqliteType; - } - }); + if (customTypeSelect) { + customTypeSelect.addEventListener("change", function () { + clearTableCreateDialogError(state); + updateTableCreateCustomColumnTypePlaceholder(customTypeSelect); + var option = tableCreateCustomColumnType(customTypeSelect.value); + if ( + option && + option.fixedSqliteType && + tableCreateColumnTypes().indexOf(option.fixedSqliteType) !== -1 + ) { + typeSelect.value = option.fixedSqliteType; + syncTableCreateForeignKeyOptions(row, state); + } + }); + } pkInput.addEventListener("change", function () { clearTableCreateDialogError(state); + updateTableCreateColumnRules(state); + }); + foreignKeySelect.addEventListener("change", function () { + foreignKeySelect.dataset.selectedKey = foreignKeySelect.value; + clearTableCreateDialogError(state); + updateTableCreateCustomColumnTypePlaceholder(foreignKeySelect); + }); + + expandButton.addEventListener("click", function () { + toggleSchemaDialogMoreOptions(expandButton, details); + }); + + moveControls.topButton.addEventListener("click", function () { + var first = tableCreateFirstNonPrimaryRow(state); + if ( + state.isSaving || + tableCreateRowIsPrimaryKey(row) || + !first || + first === row + ) { + return; + } + state.columnList.insertBefore(row, first); + clearTableCreateDialogError(state); + updateTableCreateColumnRules(state); + row.querySelector(".table-create-column-name").focus(); + }); + + moveControls.upButton.addEventListener("click", function () { + var previous = row.previousElementSibling; + if ( + state.isSaving || + tableCreateRowIsPrimaryKey(row) || + !previous || + tableCreateRowIsPrimaryKey(previous) + ) { + return; + } + state.columnList.insertBefore(row, previous); + clearTableCreateDialogError(state); + updateTableCreateColumnRules(state); + row.querySelector(".table-create-column-name").focus(); + }); + + moveControls.downButton.addEventListener("click", function () { + var next = row.nextElementSibling; + if (state.isSaving || tableCreateRowIsPrimaryKey(row) || !next) { + return; + } + state.columnList.insertBefore(next, row); + clearTableCreateDialogError(state); + updateTableCreateColumnRules(state); + row.querySelector(".table-create-column-name").focus(); + }); + + moveControls.bottomButton.addEventListener("click", function () { + var last = state.columnList.lastElementChild; + if ( + state.isSaving || + tableCreateRowIsPrimaryKey(row) || + !last || + last === row + ) { + return; + } + state.columnList.appendChild(row); + clearTableCreateDialogError(state); + updateTableCreateColumnRules(state); + row.querySelector(".table-create-column-name").focus(); }); return row; @@ -306,6 +828,7 @@ function createTableColumnRow(state, column) { function addTableCreateColumn(state, column) { var row = createTableColumnRow(state, column || { type: "text" }); state.columnList.appendChild(row); + updateTableCreateColumnRules(state); return row; } @@ -323,6 +846,7 @@ function resetTableCreateDialog(state) { type: "text", primaryKey: false, }); + updateTableCreateColumnRules(state); state.initialSignature = tableCreateDialogSignature(state); } @@ -332,16 +856,32 @@ function collectTableCreatePayload(state) { columns: [], }; var primaryKeys = []; - state.columnList - .querySelectorAll(".table-create-column-row") - .forEach(function (row) { - var name = row.querySelector(".table-create-column-name").value.trim(); - var type = row.querySelector(".table-create-column-type").value; - payload.columns.push({ name: name, type: type }); - if (row.querySelector(".table-create-primary-key-input").checked) { - primaryKeys.push(name); - } - }); + tableCreateDialogRows(state).forEach(function (row, index) { + var name = row.querySelector(".table-create-column-name").value.trim(); + var type = row.querySelector(".table-create-column-type").value; + var column = { name: name, type: type }; + var foreignKeySelect = row.querySelector(".table-create-foreign-key-target"); + var foreignKeyOption = + foreignKeySelect && foreignKeySelect.selectedOptions + ? foreignKeySelect.selectedOptions[0] + : null; + if ( + index > 0 && + foreignKeyOption && + foreignKeyOption.dataset.fkTable && + foreignKeyOption.dataset.fkColumn + ) { + column.fk_table = foreignKeyOption.dataset.fkTable; + column.fk_column = foreignKeyOption.dataset.fkColumn; + } + payload.columns.push(column); + if ( + index === 0 && + row.querySelector(".table-create-primary-key-input").checked + ) { + primaryKeys.push(name); + } + }); if (primaryKeys.length === 1) { payload.pk = primaryKeys[0]; } else if (primaryKeys.length > 1) { @@ -352,21 +892,19 @@ function collectTableCreatePayload(state) { function collectTableCreateColumnTypeAssignments(state) { var assignments = []; - state.columnList - .querySelectorAll(".table-create-column-row") - .forEach(function (row) { - var customTypeSelect = row.querySelector( - ".table-create-custom-column-type", - ); - if (!customTypeSelect || !customTypeSelect.value) { - return; - } - assignments.push({ - column: row.querySelector(".table-create-column-name").value.trim(), - columnType: customTypeSelect.value, - sqliteType: row.querySelector(".table-create-column-type").value, - }); + tableCreateDialogRows(state).forEach(function (row) { + var customTypeSelect = row.querySelector( + ".table-create-custom-column-type", + ); + if (!customTypeSelect || !customTypeSelect.value) { + return; + } + assignments.push({ + column: row.querySelector(".table-create-column-name").value.trim(), + columnType: customTypeSelect.value, + sqliteType: row.querySelector(".table-create-column-type").value, }); + }); return assignments; } @@ -604,9 +1142,14 @@ function ensureTableCreateDialog(manager) {
-
Columns
+
- +
From c77dad910b47c370c2b4c2cf16bebf3d74b51069 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 18 Jun 2026 21:16:56 -0700 Subject: [PATCH 15/31] More robust test_datasette_https_server.sh test --- tests/test_datasette_https_server.sh | 31 ++++++++++++++-------------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/tests/test_datasette_https_server.sh b/tests/test_datasette_https_server.sh index aee262cc..a544b9a8 100755 --- a/tests/test_datasette_https_server.sh +++ b/tests/test_datasette_https_server.sh @@ -40,22 +40,23 @@ curl -f --cacert client.pem $test_url curl_exit_code=$? # Shut down the server -kill $server_pid -waiting=0 -# show all pids -# | find just the $server_pid -# | | don’t match on the previous grep -# | | | we don’t need the output -# | | | | -until ( ! ps ax | grep $server_pid | grep -v grep > /dev/null ); do - if [ $waiting -eq 4 ]; then - echo "$server_pid does still exist, server failed to stop" - cleanup - exit 1 +kill $server_pid 2>/dev/null || true +( + sleep 5 + if kill -0 $server_pid 2>/dev/null; then + kill -9 $server_pid 2>/dev/null || true fi - let waiting=waiting+1 - sleep 1 -done +) & +killer_pid=$! +wait_status=0 +wait $server_pid 2>/dev/null || wait_status=$? +kill $killer_pid 2>/dev/null || true +wait $killer_pid 2>/dev/null || true +if [ $wait_status -eq 137 ]; then + echo "$server_pid did not stop after SIGTERM, server failed to stop" + cleanup + exit 1 +fi # Clean up the certificates cleanup From e834008075eead04c142a57275dcaa3f08d44d38 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Fri, 19 Jun 2026 22:32:37 -0700 Subject: [PATCH 16/31] Make custom type and foreign key mutually exclusive In the create table dialog a column can now have either a custom display type or a foreign key target, but not both - a foreign key column's type is determined by the referenced primary key, so a custom type doesn't apply. Setting one clears and disables the other, and the foreign key select stays disabled on the primary key column and when no targets exist. Also add "Controls how Datasette displays and edits this column" help text (with aria-describedby) under the custom type selector in both the create and alter dialogs, and style the alter dialog help text. --- datasette/static/app.css | 3 +- datasette/static/edit-tools.js | 71 ++++++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 1 deletion(-) diff --git a/datasette/static/app.css b/datasette/static/app.css index 49f070b1..06919444 100644 --- a/datasette/static/app.css +++ b/datasette/static/app.css @@ -1953,7 +1953,8 @@ dialog.table-create-dialog::backdrop { font-size: 0.72rem; } -.table-create-detail-help { +.table-create-detail-help, +.table-alter-detail-help { color: var(--muted); font-size: 0.82rem; line-height: 1.35; diff --git a/datasette/static/edit-tools.js b/datasette/static/edit-tools.js index 5a9d0962..2a0a29b2 100644 --- a/datasette/static/edit-tools.js +++ b/datasette/static/edit-tools.js @@ -422,11 +422,42 @@ function syncTableCreateForeignKeyOptions(row, state) { ); } +function syncTableCreateCustomTypeAndForeignKey(row, state, isFirstColumn) { + var customTypeSelect = row.querySelector(".table-create-custom-column-type"); + var foreignKeySelect = row.querySelector(".table-create-foreign-key-target"); + if (!foreignKeySelect) { + return; + } + + var hasCustomType = customTypeSelect && !!customTypeSelect.value; + var hasForeignKey = !!foreignKeySelect.value; + + if (customTypeSelect && hasForeignKey) { + customTypeSelect.value = ""; + updateTableCreateCustomColumnTypePlaceholder(customTypeSelect); + hasCustomType = false; + } + + if (isFirstColumn || hasCustomType) { + foreignKeySelect.value = ""; + foreignKeySelect.dataset.selectedKey = ""; + updateTableCreateCustomColumnTypePlaceholder(foreignKeySelect); + hasForeignKey = false; + } + + if (customTypeSelect) { + customTypeSelect.disabled = state.isSaving; + } + foreignKeySelect.disabled = + state.isSaving || isFirstColumn || foreignKeySelect.options.length <= 1; +} + function refreshTableCreateForeignKeyControls(state) { tableCreateDialogRows(state).forEach(function (row, index) { if (index > 0) { syncTableCreateForeignKeyOptions(row, state); } + syncTableCreateCustomTypeAndForeignKey(row, state, index === 0); }); } @@ -455,6 +486,7 @@ function updateTableCreateColumnRules(state) { } else { syncTableCreateForeignKeyOptions(row, state); } + syncTableCreateCustomTypeAndForeignKey(row, state, isFirstColumn); } }); updateTableCreateMoveButtons(state); @@ -639,11 +671,19 @@ function createTableColumnRow(state, column) { customTypeLabel.className = "table-create-detail-label"; customTypeLabel.setAttribute("for", customTypeId); customTypeLabel.textContent = "Custom type"; + var customTypeHelpId = "table-create-column-custom-type-help-" + index; + var customTypeHelp = document.createElement("p"); + customTypeHelp.id = customTypeHelpId; + customTypeHelp.className = "table-create-detail-help"; + customTypeHelp.textContent = + "Controls how Datasette displays and edits this column"; customTypeSelect = createTableCustomColumnTypeSelect(); customTypeSelect.id = customTypeId; + customTypeSelect.setAttribute("aria-describedby", customTypeHelpId); customTypeSelect.value = column && column.customType ? column.customType : ""; updateTableCreateCustomColumnTypePlaceholder(customTypeSelect); customTypeField.appendChild(customTypeLabel); + customTypeField.appendChild(customTypeHelp); customTypeField.appendChild(customTypeSelect); } @@ -733,11 +773,20 @@ function createTableColumnRow(state, column) { clearTableCreateDialogError(state); syncTableCreateCustomTypeForSqliteType(row); syncTableCreateForeignKeyOptions(row, state); + syncTableCreateCustomTypeAndForeignKey( + row, + state, + row === state.columnList.firstElementChild, + ); }); if (customTypeSelect) { customTypeSelect.addEventListener("change", function () { clearTableCreateDialogError(state); updateTableCreateCustomColumnTypePlaceholder(customTypeSelect); + if (customTypeSelect.value) { + foreignKeySelect.value = ""; + foreignKeySelect.dataset.selectedKey = ""; + } var option = tableCreateCustomColumnType(customTypeSelect.value); if ( option && @@ -747,6 +796,11 @@ function createTableColumnRow(state, column) { typeSelect.value = option.fixedSqliteType; syncTableCreateForeignKeyOptions(row, state); } + syncTableCreateCustomTypeAndForeignKey( + row, + state, + row === state.columnList.firstElementChild, + ); }); } pkInput.addEventListener("change", function () { @@ -757,6 +811,15 @@ function createTableColumnRow(state, column) { foreignKeySelect.dataset.selectedKey = foreignKeySelect.value; clearTableCreateDialogError(state); updateTableCreateCustomColumnTypePlaceholder(foreignKeySelect); + if (customTypeSelect && foreignKeySelect.value) { + customTypeSelect.value = ""; + updateTableCreateCustomColumnTypePlaceholder(customTypeSelect); + } + syncTableCreateCustomTypeAndForeignKey( + row, + state, + row === state.columnList.firstElementChild, + ); }); expandButton.addEventListener("click", function () { @@ -1664,11 +1727,19 @@ function createTableAlterColumnRow(state, column) { customTypeLabel.className = "table-alter-detail-label"; customTypeLabel.setAttribute("for", customTypeId); customTypeLabel.textContent = "Custom type"; + var customTypeHelpId = "table-alter-column-custom-type-help-" + index; + var customTypeHelp = document.createElement("p"); + customTypeHelp.id = customTypeHelpId; + customTypeHelp.className = "table-alter-detail-help"; + customTypeHelp.textContent = + "Controls how Datasette displays and edits this column"; customTypeSelect = createTableAlterCustomColumnTypeSelect(); customTypeSelect.id = customTypeId; + customTypeSelect.setAttribute("aria-describedby", customTypeHelpId); customTypeSelect.value = originalCustomType; updateTableAlterCustomColumnTypePlaceholder(customTypeSelect); customTypeField.appendChild(customTypeLabel); + customTypeField.appendChild(customTypeHelp); customTypeField.appendChild(customTypeSelect); } From 354780a1366bbfa6c5c9c34fdec3ccf24163e099 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Fri, 19 Jun 2026 22:40:14 -0700 Subject: [PATCH 17/31] Drop table button in alter dialog --- datasette/static/app.css | 16 ++++++++ datasette/static/edit-tools.js | 68 ++++++++++++++++++++++++++++++++++ datasette/views/table.py | 14 +++++++ tests/test_table_html.py | 14 ++++++- 4 files changed, 111 insertions(+), 1 deletion(-) diff --git a/datasette/static/app.css b/datasette/static/app.css index 06919444..1cca8b80 100644 --- a/datasette/static/app.css +++ b/datasette/static/app.css @@ -2542,6 +2542,22 @@ dialog.table-alter-dialog::backdrop { color: var(--ink); } +.table-alter-dialog .btn-danger { + background: #b91c1c; + color: #fff; + margin-right: auto; +} + +.table-alter-dialog .btn-danger:hover { + background: #991b1b; +} + +.table-alter-dialog .btn-danger:disabled, +.table-alter-dialog .btn-danger:disabled:hover { + background: #d98c8c; + color: #fff; +} + .table-alter-dialog .btn-primary { background: var(--accent); color: #fff; diff --git a/datasette/static/edit-tools.js b/datasette/static/edit-tools.js index 2a0a29b2..779ec702 100644 --- a/datasette/static/edit-tools.js +++ b/datasette/static/edit-tools.js @@ -1571,6 +1571,7 @@ function setTableAlterDialogSaving(state, isSaving) { state.cancelButton.disabled = isSaving; state.addColumnButton.disabled = isSaving; state.backButton.disabled = isSaving; + state.dropButton.disabled = isSaving; state.saveButton.textContent = isSaving ? state.mode === "review" ? "Applying..." @@ -2465,6 +2466,8 @@ function showTableAlterEditor(state) { state.review.hidden = true; state.review.textContent = ""; state.backButton.hidden = true; + var data = tableAlterData(); + state.dropButton.hidden = !(data && data.dropPath); state.saveButton.textContent = tableAlterSaveButtonText(state); updateTableAlterMoveButtons(state); updateTableAlterSaveButtonState(state); @@ -2479,6 +2482,7 @@ function showTableAlterReview(state, result) { state.review.hidden = false; state.review.textContent = ""; state.backButton.hidden = false; + state.dropButton.hidden = true; state.saveButton.textContent = tableAlterSaveButtonText(state); updateTableAlterSaveButtonState(state); @@ -2565,6 +2569,64 @@ async function applyTableAlterChanges(state, result) { } } +function tableAlterDatabaseUrl() { + var data = tableAlterData(); + if (!data || !data.path) { + return null; + } + var url = new URL(data.path, location.href); + url.pathname = url.pathname.replace(/\/[^/]+\/-\/alter\/?$/, ""); + url.search = ""; + url.hash = ""; + return url.toString(); +} + +async function dropTableFromAlterDialog(state) { + if (state.isSaving) { + return; + } + var data = tableAlterData(); + if (!data || !data.dropPath) { + return; + } + if ( + !window.confirm( + 'Permanently delete the table "' + + data.tableName + + '"? This will delete all of its data and cannot be undone.', + ) + ) { + return; + } + clearTableAlterDialogError(state); + setTableAlterDialogSaving(state, true); + try { + var response = await fetch(data.dropPath, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify({ confirm: true }), + }); + 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(); + window.location.href = tableAlterDatabaseUrl() || "/"; + } catch (error) { + setTableAlterDialogSaving(state, false); + showTableAlterDialogError(state, error.message || "Could not drop table"); + } +} + async function saveTableAlterDialog(state) { if (state.isSaving) { return; @@ -2646,6 +2708,7 @@ function ensureTableAlterDialog(manager) { +
+ Rename table +
+ + +
+
/-/alter`` :ref:`JSON API ` for changing existing tables: add, rename, reorder and drop columns; change column types, defaults, ``NOT NULL`` constraints, primary keys and foreign keys; and rename the table. The alter table dialog also includes a "Drop table" button. (:issue:`2788`) +- New ``//-/foreign-key-targets`` and ``//
/-/foreign-key-suggestions`` JSON APIs for discovering valid single-column foreign key targets and suggested relationships. +- Create and alter table dialogs share their column-editing controls, including literal and expression defaults, custom column types, foreign keys and column ordering. + .. _v1_0_a34: 1.0a34 (2026-06-16)