diff --git a/datasette/static/app.css b/datasette/static/app.css index 90d7e7e9..32b7a4c2 100644 --- a/datasette/static/app.css +++ b/datasette/static/app.css @@ -1883,12 +1883,14 @@ select.table-create-input { } .table-create-foreign-key-target option, -.table-create-custom-column-type option { +.table-create-custom-column-type option, +.table-create-default-expr option { color: var(--ink); } .table-create-foreign-key-target option[value=""], -.table-create-custom-column-type option[value=""] { +.table-create-custom-column-type option[value=""], +.table-create-default-expr option[value=""] { color: var(--muted); } @@ -1978,11 +1980,44 @@ select.table-create-input { white-space: normal; } +.table-create-not-null, .table-create-primary-key, -.table-create-foreign-key-field { +.table-create-foreign-key-field, +.table-create-default-options { grid-column: 1 / -1; } +.table-create-default-options, +.table-alter-default-options { + border: 1px solid var(--rule); + border-radius: 5px; + background: #fff; + color: var(--ink); + min-width: 0; +} + +.table-create-default-options > summary, +.table-alter-default-options > summary { + cursor: pointer; + color: var(--accent); + font-size: 0.85rem; + padding: 8px 10px; +} + +.table-create-default-options > summary:focus, +.table-alter-default-options > summary:focus { + outline: 3px solid rgba(26, 86, 219, 0.12); + outline-offset: 1px; +} + +.table-create-default-grid, +.table-alter-default-grid { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 12px 16px; + padding: 0 10px 10px; +} + .table-create-detail-check input { flex: 0 0 auto; margin: 0.15rem 0 0; @@ -2221,6 +2256,7 @@ dialog.table-alter-dialog::backdrop { overflow-y: auto; } + .table-alter-fields[hidden], .table-alter-dialog .modal-footer [hidden] { display: none; @@ -2358,12 +2394,14 @@ select.table-alter-input { } .table-alter-default-expr option, -.table-alter-custom-column-type option { +.table-alter-custom-column-type option, +.table-alter-foreign-key-target option { color: var(--ink); } .table-alter-default-expr option[value=""], -.table-alter-custom-column-type option[value=""] { +.table-alter-custom-column-type option[value=""], +.table-alter-foreign-key-target option[value=""] { color: var(--muted); } @@ -2410,7 +2448,9 @@ select.table-alter-input { } .table-alter-not-null, -.table-alter-primary-key { +.table-alter-primary-key, +.table-alter-foreign-key-field, +.table-alter-default-options { grid-column: 1 / -1; } @@ -2657,6 +2697,10 @@ select.table-alter-input { grid-template-columns: 1fr; } + .table-alter-default-grid { + grid-template-columns: 1fr; + } + .table-alter-dialog .modal-footer { padding-left: 18px; padding-right: 18px; @@ -2878,6 +2922,10 @@ select.table-alter-input { grid-template-columns: 1fr; } + .table-create-default-grid { + grid-template-columns: 1fr; + } + .table-create-dialog .modal-footer { padding-left: 18px; padding-right: 18px; diff --git a/datasette/static/edit-tools.js b/datasette/static/edit-tools.js index c4982e54..2e4331e6 100644 --- a/datasette/static/edit-tools.js +++ b/datasette/static/edit-tools.js @@ -60,6 +60,11 @@ function tableCreateColumnTypes() { : ["text", "integer", "float", "blob"]; } +function tableCreateDefaultExpressions() { + var data = databaseCreateTableData() || {}; + return data.defaultExpressions || []; +} + var SQLITE_COLUMN_TYPE_LABELS = { float: "floating point number", real: "floating point number", @@ -104,6 +109,112 @@ function createCustomColumnTypeSelect(options, className, placeholderClass) { return select; } +var DEFAULT_EXPRESSION_LABELS = { + current_timestamp: "Current timestamp in UTC, e.g. 2026-05-01 13:34:00", + current_date: "Current date in UTC, e.g. 2026-05-01", + current_time: "Current time in UTC, e.g. 13:34:00", +}; + +function defaultExpressionLabel(value) { + return DEFAULT_EXPRESSION_LABELS[value] || value.replace(/_/g, " "); +} + +function createDefaultExpressionSelect( + options, + className, + placeholderClass, + value, +) { + var select = document.createElement("select"); + select.className = className; + var blankOption = document.createElement("option"); + blankOption.value = ""; + blankOption.textContent = "- default expr -"; + select.appendChild(blankOption); + options.forEach(function (option) { + var optionElement = document.createElement("option"); + optionElement.value = option; + optionElement.textContent = defaultExpressionLabel(option); + select.appendChild(optionElement); + }); + select.value = value || ""; + updateSelectPlaceholder(select, placeholderClass); + return select; +} + +function createSchemaDialogDefaultControls(prefix, index, expressions, column) { + var defaultDetails = document.createElement("details"); + defaultDetails.className = prefix + "-default-options"; + defaultDetails.open = !!( + column && + (column.defaultValue || column.defaultExpr) + ); + var summary = document.createElement("summary"); + summary.textContent = "Set a default value"; + defaultDetails.appendChild(summary); + + var defaultGrid = document.createElement("div"); + defaultGrid.className = prefix + "-default-grid"; + + var defaultExprId = prefix + "-column-default-expr-" + index; + var defaultExprField = document.createElement("div"); + defaultExprField.className = prefix + "-detail-field"; + var defaultExprLabel = document.createElement("label"); + defaultExprLabel.className = prefix + "-detail-label"; + defaultExprLabel.setAttribute("for", defaultExprId); + defaultExprLabel.textContent = "Default expression"; + var defaultExprSelect = createDefaultExpressionSelect( + expressions, + prefix + "-input " + prefix + "-default-expr", + prefix + "-input-placeholder", + column && column.defaultExpr, + ); + defaultExprSelect.id = defaultExprId; + defaultExprSelect.setAttribute("aria-label", "Default expression"); + defaultExprField.appendChild(defaultExprLabel); + defaultExprField.appendChild(defaultExprSelect); + + var defaultId = prefix + "-column-default-" + index; + var defaultField = document.createElement("div"); + defaultField.className = prefix + "-detail-field"; + var defaultLabel = document.createElement("label"); + defaultLabel.className = prefix + "-detail-label"; + defaultLabel.setAttribute("for", defaultId); + defaultLabel.textContent = "or default to a specific value"; + var defaultInput = document.createElement("input"); + defaultInput.id = defaultId; + defaultInput.className = prefix + "-input " + prefix + "-default"; + defaultInput.type = "text"; + defaultInput.autocomplete = "off"; + defaultInput.placeholder = "default"; + defaultInput.setAttribute("aria-label", "or default to a specific value"); + defaultInput.value = column && column.defaultValue ? column.defaultValue : ""; + defaultField.appendChild(defaultLabel); + defaultField.appendChild(defaultInput); + + defaultGrid.appendChild(defaultExprField); + defaultGrid.appendChild(defaultField); + defaultDetails.appendChild(defaultGrid); + + return { + controls: defaultDetails, + defaultInput: defaultInput, + defaultExprSelect: defaultExprSelect, + }; +} + +function syncSchemaDialogDefaultControls(row, prefix) { + if (!row) { + return; + } + var defaultInput = row.querySelector("." + prefix + "-default"); + var defaultExprSelect = row.querySelector("." + prefix + "-default-expr"); + if (!defaultInput || !defaultExprSelect) { + return; + } + updateSelectPlaceholder(defaultExprSelect, prefix + "-input-placeholder"); +} + var COLUMN_MOVE_ICONS = { top: '', up: '', @@ -316,7 +427,7 @@ function updateTableCreateMoveButtons(state) { updateSchemaDialogMoveButtons(state, "table-create"); } -function tableCreateTypeAffinity(type) { +function schemaDialogTypeAffinity(type) { if (type === "float") { return "real"; } @@ -337,11 +448,11 @@ function foreignKeyTypesCompatible(sourceAffinity, targetAffinity) { return false; } -function tableCreateForeignKeyTargetKey(target) { +function foreignKeyTargetKey(target) { return target.fk_table + "\u001f" + target.fk_column; } -function tableCreateForeignKeyTargetLabel(target) { +function foreignKeyTargetLabel(target) { return ( target.fk_table + "." + @@ -352,6 +463,54 @@ function tableCreateForeignKeyTargetLabel(target) { ); } +function appendForeignKeyTargetOption(select, target) { + var optionElement = document.createElement("option"); + optionElement.value = foreignKeyTargetKey(target); + optionElement.dataset.fkTable = target.fk_table; + optionElement.dataset.fkColumn = target.fk_column; + optionElement.dataset.fkType = target.type; + optionElement.textContent = foreignKeyTargetLabel(target); + select.appendChild(optionElement); + return optionElement; +} + +function sqliteColumnTypeForForeignKeyTarget(type) { + var affinity = schemaDialogTypeAffinity(type); + if (affinity === "real" || affinity === "numeric") { + return "float"; + } + if (["text", "integer", "blob"].indexOf(affinity) !== -1) { + return affinity; + } + return ""; +} + +function selectedSchemaDialogForeignKeyOption(foreignKeySelect) { + return foreignKeySelect && foreignKeySelect.selectedOptions + ? foreignKeySelect.selectedOptions[0] + : null; +} + +function setBlankSchemaDialogColumnNameFromForeignKey( + row, + prefix, + foreignKeyOption, +) { + var nameInput = row.querySelector("." + prefix + "-column-name"); + if ( + nameInput && + !nameInput.value.trim() && + foreignKeyOption && + foreignKeyOption.dataset.fkTable && + foreignKeyOption.dataset.fkColumn + ) { + nameInput.value = + foreignKeyOption.dataset.fkTable + + "_" + + foreignKeyOption.dataset.fkColumn; + } +} + function tableCreateForeignKeyTargetsUrl() { var data = databaseCreateTableData() || {}; if (data.foreignKeyTargetsPath) { @@ -363,7 +522,14 @@ function tableCreateForeignKeyTargetsUrl() { return data.path.replace(/\/-\/create$/, "/-/foreign-key-targets"); } -function populateTableCreateForeignKeySelect(select, state, sourceType) { +function populateSchemaDialogForeignKeySelect( + select, + state, + prefix, + sourceType, + options, +) { + options = options || {}; var previousKey = select.value || select.dataset.selectedKey || ""; select.textContent = ""; @@ -385,45 +551,64 @@ function populateTableCreateForeignKeySelect(select, state, sourceType) { errorOption.textContent = "Could not load foreign keys"; select.appendChild(errorOption); } else { - var sourceAffinity = tableCreateTypeAffinity(sourceType); + var sourceAffinity = schemaDialogTypeAffinity(sourceType); (state.foreignKeyTargets || []).forEach(function (target) { - if (!foreignKeyTypesCompatible(sourceAffinity, target.type)) { + if ( + options.filterByType !== false && + !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); + appendForeignKeyTargetOption(select, target); }); } select.value = previousKey; + if ( + previousKey && + select.value !== previousKey && + select.dataset.currentFkTable && + select.dataset.currentFkColumn + ) { + appendForeignKeyTargetOption(select, { + fk_table: select.dataset.currentFkTable, + fk_column: select.dataset.currentFkColumn, + type: select.dataset.currentFkType || sourceType, + }); + 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"); + updateSelectPlaceholder(select, prefix + "-input-placeholder"); } -function syncTableCreateForeignKeyOptions(row, state) { - var typeSelect = row.querySelector(".table-create-column-type"); - var foreignKeySelect = row.querySelector(".table-create-foreign-key-target"); +function syncSchemaDialogForeignKeyOptions(row, state, prefix, options) { + var typeSelect = row.querySelector("." + prefix + "-column-type"); + var foreignKeySelect = row.querySelector( + "." + prefix + "-foreign-key-target", + ); if (!typeSelect || !foreignKeySelect) { return; } - populateTableCreateForeignKeySelect( + populateSchemaDialogForeignKeySelect( foreignKeySelect, state, + prefix, typeSelect.value, + options, ); } -function syncTableCreateCustomTypeAndForeignKey(row, state, isFirstColumn) { - var customTypeSelect = row.querySelector(".table-create-custom-column-type"); - var foreignKeySelect = row.querySelector(".table-create-foreign-key-target"); +function syncSchemaDialogCustomTypeAndForeignKey(row, state, prefix) { + var customTypeSelect = row.querySelector( + "." + prefix + "-custom-column-type", + ); + var foreignKeySelect = row.querySelector( + "." + prefix + "-foreign-key-target", + ); if (!foreignKeySelect) { return; } @@ -433,14 +618,14 @@ function syncTableCreateCustomTypeAndForeignKey(row, state, isFirstColumn) { if (customTypeSelect && hasForeignKey) { customTypeSelect.value = ""; - updateTableCreateCustomColumnTypePlaceholder(customTypeSelect); + updateSelectPlaceholder(customTypeSelect, prefix + "-input-placeholder"); hasCustomType = false; } - if (isFirstColumn || hasCustomType) { + if (hasCustomType) { foreignKeySelect.value = ""; foreignKeySelect.dataset.selectedKey = ""; - updateTableCreateCustomColumnTypePlaceholder(foreignKeySelect); + updateSelectPlaceholder(foreignKeySelect, prefix + "-input-placeholder"); hasForeignKey = false; } @@ -448,63 +633,73 @@ function syncTableCreateCustomTypeAndForeignKey(row, state, isFirstColumn) { customTypeSelect.disabled = state.isSaving; } foreignKeySelect.disabled = - state.isSaving || isFirstColumn || foreignKeySelect.options.length <= 1; + state.isSaving || foreignKeySelect.options.length <= 1; } -function refreshTableCreateForeignKeyControls(state) { - tableCreateDialogRows(state).forEach(function (row, index) { - if (index > 0) { - syncTableCreateForeignKeyOptions(row, state); - } - syncTableCreateCustomTypeAndForeignKey(row, state, index === 0); - }); -} +function handleSchemaDialogForeignKeyChange(row, state, prefix, options) { + options = options || {}; + var foreignKeySelect = row.querySelector( + "." + prefix + "-foreign-key-target", + ); + var typeSelect = row.querySelector("." + prefix + "-column-type"); + var customTypeSelect = row.querySelector( + "." + prefix + "-custom-column-type", + ); + if (!foreignKeySelect) { + return; + } + foreignKeySelect.dataset.selectedKey = foreignKeySelect.value; + updateSelectPlaceholder(foreignKeySelect, prefix + "-input-placeholder"); -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", + var foreignKeyOption = selectedSchemaDialogForeignKeyOption(foreignKeySelect); + setBlankSchemaDialogColumnNameFromForeignKey(row, prefix, foreignKeyOption); + + var columnTypes = options.columnTypes || []; + var foreignKeyColumnType = + foreignKeyOption && foreignKeyOption.dataset.fkType + ? sqliteColumnTypeForForeignKeyTarget(foreignKeyOption.dataset.fkType) + : ""; + if ( + options.matchType && + typeSelect && + foreignKeyColumnType && + columnTypes.indexOf(foreignKeyColumnType) !== -1 && + typeSelect.value !== foreignKeyColumnType + ) { + typeSelect.value = foreignKeyColumnType; + syncSchemaDialogForeignKeyOptions( + row, + state, + prefix, + options.foreignKeyOptions, ); + } - 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); - } - syncTableCreateCustomTypeAndForeignKey(row, state, isFirstColumn); - } - }); - updateTableCreateMoveButtons(state); + if (customTypeSelect && foreignKeySelect.value) { + customTypeSelect.value = ""; + updateSelectPlaceholder(customTypeSelect, prefix + "-input-placeholder"); + } + syncSchemaDialogCustomTypeAndForeignKey(row, state, prefix); } -async function loadTableCreateForeignKeyTargets(state) { - var url = tableCreateForeignKeyTargetsUrl(); +function refreshSchemaDialogForeignKeyControls(state, prefix, options) { + schemaDialogRows(state, prefix).forEach(function (row) { + syncSchemaDialogForeignKeyOptions(row, state, prefix, options); + syncSchemaDialogCustomTypeAndForeignKey(row, state, prefix); + }); +} + +async function loadSchemaDialogForeignKeyTargets(state, prefix, url, options) { if (!url || !window.fetch) { state.foreignKeyTargets = []; state.foreignKeyTargetsLoading = false; - refreshTableCreateForeignKeyControls(state); + refreshSchemaDialogForeignKeyControls(state, prefix, options); return; } state.foreignKeyTargets = []; state.foreignKeyTargetsError = null; state.foreignKeyTargetsLoading = true; - refreshTableCreateForeignKeyControls(state); + refreshSchemaDialogForeignKeyControls(state, prefix, options); try { var response = await fetch(url, { headers: { @@ -521,10 +716,46 @@ async function loadTableCreateForeignKeyTargets(state) { state.foreignKeyTargetsError = error; } finally { state.foreignKeyTargetsLoading = false; - refreshTableCreateForeignKeyControls(state); + refreshSchemaDialogForeignKeyControls(state, prefix, options); } } +function syncTableCreateForeignKeyOptions(row, state) { + syncSchemaDialogForeignKeyOptions(row, state, "table-create", { + filterByType: false, + }); +} + +function syncTableCreateCustomTypeAndForeignKey(row, state) { + syncSchemaDialogCustomTypeAndForeignKey(row, state, "table-create"); +} + +function refreshTableCreateForeignKeyControls(state) { + tableCreateDialogRows(state).forEach(function (row) { + syncTableCreateForeignKeyOptions(row, state); + syncTableCreateCustomTypeAndForeignKey(row, state); + }); +} + +function updateTableCreateColumnRules(state) { + normalizeSchemaDialogPrimaryKeyRows(state, "table-create"); + tableCreateDialogRows(state).forEach(function (row) { + syncTableCreateForeignKeyOptions(row, state); + syncTableCreateCustomTypeAndForeignKey(row, state); + syncSchemaDialogDefaultControls(row, "table-create"); + }); + updateTableCreateMoveButtons(state); +} + +async function loadTableCreateForeignKeyTargets(state) { + return loadSchemaDialogForeignKeyTargets( + state, + "table-create", + tableCreateForeignKeyTargetsUrl(), + { filterByType: false }, + ); +} + function tableCreateDialogSignature(state) { if (!state || !state.form) { return ""; @@ -542,6 +773,9 @@ function tableCreateDialogSignature(state) { } ).value || "", pk: row.querySelector(".table-create-primary-key-input").checked, + notNull: row.querySelector(".table-create-not-null-input").checked, + defaultValue: row.querySelector(".table-create-default").value, + defaultExpr: row.querySelector(".table-create-default-expr").value, foreignKey: ( row.querySelector(".table-create-foreign-key-target") || { @@ -654,6 +888,7 @@ function createTableColumnRow(state, column) { nameInput.type = "text"; nameInput.required = true; nameInput.autocomplete = "off"; + nameInput.placeholder = "column name"; nameInput.value = column && column.name ? column.name : ""; var typeSelect = document.createElement("select"); @@ -728,6 +963,32 @@ function createTableColumnRow(state, column) { foreignKeyField.appendChild(foreignKeyHelp); foreignKeyField.appendChild(foreignKeySelect); + var notNullLabel = document.createElement("label"); + notNullLabel.className = "table-create-detail-check table-create-not-null"; + var notNullInput = document.createElement("input"); + notNullInput.type = "checkbox"; + notNullInput.className = "table-create-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 defaultControls = createSchemaDialogDefaultControls( + "table-create", + index, + tableCreateDefaultExpressions(), + { + defaultValue: column && column.defaultValue, + defaultExpr: column && column.defaultExpr, + }, + ); + var moveControls = createSchemaDialogMoveControls("table-create"); var removeButton = createSchemaDialogIconButton( @@ -748,8 +1009,10 @@ function createTableColumnRow(state, column) { if (customTypeField) { details.appendChild(customTypeField); } - details.appendChild(foreignKeyField); + details.appendChild(defaultControls.controls); + details.appendChild(notNullLabel); details.appendChild(pkLabel); + details.appendChild(foreignKeyField); row.appendChild(main); row.appendChild(details); @@ -775,11 +1038,7 @@ function createTableColumnRow(state, column) { clearTableCreateDialogError(state); syncTableCreateCustomTypeForSqliteType(row); syncTableCreateForeignKeyOptions(row, state); - syncTableCreateCustomTypeAndForeignKey( - row, - state, - row === state.columnList.firstElementChild, - ); + syncTableCreateCustomTypeAndForeignKey(row, state); }); if (customTypeSelect) { customTypeSelect.addEventListener("change", function () { @@ -798,30 +1057,37 @@ function createTableColumnRow(state, column) { typeSelect.value = option.fixedSqliteType; syncTableCreateForeignKeyOptions(row, state); } - syncTableCreateCustomTypeAndForeignKey( - row, - state, - row === state.columnList.firstElementChild, - ); + syncTableCreateCustomTypeAndForeignKey(row, state); }); } pkInput.addEventListener("change", function () { clearTableCreateDialogError(state); updateTableCreateColumnRules(state); }); - foreignKeySelect.addEventListener("change", function () { - foreignKeySelect.dataset.selectedKey = foreignKeySelect.value; + notNullInput.addEventListener("change", function () { clearTableCreateDialogError(state); - updateTableCreateCustomColumnTypePlaceholder(foreignKeySelect); - if (customTypeSelect && foreignKeySelect.value) { - customTypeSelect.value = ""; - updateTableCreateCustomColumnTypePlaceholder(customTypeSelect); + }); + defaultControls.defaultInput.addEventListener("input", function () { + if (defaultControls.defaultInput.value) { + defaultControls.defaultExprSelect.value = ""; + syncSchemaDialogDefaultControls(row, "table-create"); } - syncTableCreateCustomTypeAndForeignKey( - row, - state, - row === state.columnList.firstElementChild, - ); + clearTableCreateDialogError(state); + }); + defaultControls.defaultExprSelect.addEventListener("change", function () { + if (defaultControls.defaultExprSelect.value) { + defaultControls.defaultInput.value = ""; + } + syncSchemaDialogDefaultControls(row, "table-create"); + clearTableCreateDialogError(state); + }); + foreignKeySelect.addEventListener("change", function () { + clearTableCreateDialogError(state); + handleSchemaDialogForeignKeyChange(row, state, "table-create", { + columnTypes: tableCreateColumnTypes(), + foreignKeyOptions: { filterByType: false }, + matchType: true, + }); }); expandButton.addEventListener("click", function () { @@ -887,6 +1153,7 @@ function createTableColumnRow(state, column) { row.querySelector(".table-create-column-name").focus(); }); + syncSchemaDialogDefaultControls(row, "table-create"); return row; } @@ -921,7 +1188,7 @@ function collectTableCreatePayload(state) { columns: [], }; var primaryKeys = []; - tableCreateDialogRows(state).forEach(function (row, index) { + tableCreateDialogRows(state).forEach(function (row) { 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 }; @@ -933,7 +1200,6 @@ function collectTableCreatePayload(state) { ? foreignKeySelect.selectedOptions[0] : null; if ( - index > 0 && foreignKeyOption && foreignKeyOption.dataset.fkTable && foreignKeyOption.dataset.fkColumn @@ -941,11 +1207,18 @@ function collectTableCreatePayload(state) { column.fk_table = foreignKeyOption.dataset.fkTable; column.fk_column = foreignKeyOption.dataset.fkColumn; } + if (row.querySelector(".table-create-not-null-input").checked) { + column.not_null = true; + } + var defaultExpr = row.querySelector(".table-create-default-expr").value; + var defaultValue = row.querySelector(".table-create-default").value; + if (defaultExpr) { + column.default_expr = defaultExpr; + } else if (defaultValue) { + column.default = defaultValue; + } payload.columns.push(column); - if ( - index === 0 && - row.querySelector(".table-create-primary-key-input").checked - ) { + if (row.querySelector(".table-create-primary-key-input").checked) { primaryKeys.push(name); } }); @@ -1006,6 +1279,9 @@ function validateTableCreatePayload(payload) { if (supportedTypes.indexOf(column.type) === -1) { return "Unsupported column type: " + column.type; } + if (column.default && column.default_expr) { + return "Use either a default value or a default expression."; + } } return null; } @@ -1467,6 +1743,11 @@ function tableAlterDefaultExpressions() { return data.defaultExpressions || []; } +function tableAlterForeignKeyTargetsUrl() { + var data = tableAlterData() || {}; + return data.foreignKeyTargetsPath || null; +} + function tableAlterCustomColumnTypes() { var data = tableAlterData() || {}; return data.customColumnTypes || []; @@ -1494,7 +1775,21 @@ function tableAlterDialogRows(state) { return schemaDialogRows(state, "table-alter"); } +function syncTableAlterForeignKeyOptions(row, state) { + syncSchemaDialogForeignKeyOptions(row, state, "table-alter", { + filterByType: false, + }); +} + function tableAlterRowSignature(row) { + var foreignKeySelect = row.querySelector(".table-alter-foreign-key-target"); + var foreignKeyValue = foreignKeySelect + ? foreignKeySelect.value || foreignKeySelect.dataset.selectedKey || "" + : ""; + var foreignKeyOption = + foreignKeySelect && foreignKeySelect.selectedOptions + ? foreignKeySelect.selectedOptions[0] + : null; return { existing: row.dataset.existing === "1", originalName: row.dataset.originalName || "", @@ -1510,6 +1805,15 @@ function tableAlterRowSignature(row) { defaultValue: row.querySelector(".table-alter-default").value, defaultExpr: row.querySelector(".table-alter-default-expr").value, pk: row.querySelector(".table-alter-primary-key-input").checked, + foreignKey: foreignKeyValue, + foreignKeyTable: + foreignKeyOption && foreignKeyOption.dataset.fkTable + ? foreignKeyOption.dataset.fkTable + : "", + foreignKeyColumn: + foreignKeyOption && foreignKeyOption.dataset.fkColumn + ? foreignKeyOption.dataset.fkColumn + : "", }; } @@ -1581,7 +1885,7 @@ function setTableAlterDialogSaving(state, isSaving) { ? "Applying..." : "Preparing..." : tableAlterSaveButtonText(state); - state.columnList + state.fields .querySelectorAll("input, select, button") .forEach(function (control) { control.disabled = isSaving; @@ -1590,10 +1894,12 @@ function setTableAlterDialogSaving(state, isSaving) { state.columnList .querySelectorAll(".table-alter-default-expr") .forEach(function (select) { - syncTableAlterDefaultControls( + syncSchemaDialogDefaultControls( select.closest(".table-alter-column-row"), + "table-alter", ); }); + refreshSchemaDialogForeignKeyControls(state, "table-alter"); } updateTableAlterMoveButtons(state); updateTableAlterSaveButtonState(state); @@ -1634,38 +1940,6 @@ function syncTableAlterCustomTypeForSqliteType(row) { } } -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) { - updateSelectPlaceholder(select, "table-alter-input-placeholder"); -} - -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; @@ -1677,6 +1951,10 @@ function createTableAlterColumnRow(state, column) { existing && column.has_default && column.default !== null ? String(column.default) : ""; + var originalForeignKey = + existing && column.foreign_key + ? foreignKeyTargetKey(column.foreign_key) + : ""; var row = document.createElement("div"); row.className = "table-alter-column-row"; @@ -1688,6 +1966,7 @@ function createTableAlterColumnRow(state, column) { row.dataset.originalDefault = originalDefault; row.dataset.originalPk = existing && column.is_pk ? "1" : "0"; row.dataset.originalCustomType = originalCustomType; + row.dataset.originalForeignKey = originalForeignKey; var main = document.createElement("div"); main.className = "table-alter-column-main"; @@ -1714,6 +1993,7 @@ function createTableAlterColumnRow(state, column) { nameInput.type = "text"; nameInput.required = true; nameInput.autocomplete = "off"; + nameInput.placeholder = "column name"; nameInput.value = column && column.name ? column.name : ""; var typeSelect = document.createElement("select"); @@ -1764,38 +2044,51 @@ function createTableAlterColumnRow(state, column) { 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 defaultControls = createSchemaDialogDefaultControls( + "table-alter", + index, + tableAlterDefaultExpressions(), + { + defaultValue: originalDefault, + defaultExpr: "", + }, + ); + var defaultInput = defaultControls.defaultInput; + var defaultExprSelect = defaultControls.defaultExprSelect; - 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 foreignKeyId = "table-alter-column-foreign-key-" + index; + var foreignKeyHelpId = "table-alter-column-foreign-key-help-" + index; + var foreignKeyField = document.createElement("div"); + foreignKeyField.className = + "table-alter-detail-field table-alter-foreign-key-field"; + var foreignKeyLabel = document.createElement("label"); + foreignKeyLabel.className = "table-alter-detail-label"; + foreignKeyLabel.setAttribute("for", foreignKeyId); + foreignKeyLabel.textContent = "Foreign key"; + var foreignKeyHelp = document.createElement("p"); + foreignKeyHelp.id = foreignKeyHelpId; + foreignKeyHelp.className = "table-alter-detail-help"; + foreignKeyHelp.textContent = "Link this column to another table."; + var foreignKeySelect = document.createElement("select"); + foreignKeySelect.id = foreignKeyId; + foreignKeySelect.className = + "table-alter-input table-alter-foreign-key-target"; + foreignKeySelect.setAttribute("aria-label", "Foreign key target"); + foreignKeySelect.setAttribute("aria-describedby", foreignKeyHelpId); + foreignKeySelect.dataset.selectedKey = originalForeignKey; + if (column && column.foreign_key) { + foreignKeySelect.dataset.currentFkTable = column.foreign_key.fk_table; + foreignKeySelect.dataset.currentFkColumn = column.foreign_key.fk_column; + appendForeignKeyTargetOption(foreignKeySelect, { + fk_table: column.foreign_key.fk_table, + fk_column: column.foreign_key.fk_column, + type: column.type || "text", + }); + foreignKeySelect.value = originalForeignKey; + } + foreignKeyField.appendChild(foreignKeyLabel); + foreignKeyField.appendChild(foreignKeyHelp); + foreignKeyField.appendChild(foreignKeySelect); var pkLabel = document.createElement("label"); pkLabel.className = "table-alter-detail-check table-alter-primary-key"; @@ -1832,10 +2125,10 @@ function createTableAlterColumnRow(state, column) { if (customTypeField) { details.appendChild(customTypeField); } + details.appendChild(defaultControls.controls); details.appendChild(notNullLabel); - details.appendChild(defaultField); - details.appendChild(defaultExprField); details.appendChild(pkLabel); + details.appendChild(foreignKeyField); row.appendChild(main); row.appendChild(details); @@ -1846,6 +2139,7 @@ function createTableAlterColumnRow(state, column) { defaultInput, defaultExprSelect, pkInput, + foreignKeySelect, ]; if (customTypeSelect) { controls.push(customTypeSelect); @@ -1864,7 +2158,7 @@ function createTableAlterColumnRow(state, column) { defaultInput.addEventListener("input", function () { if (defaultInput.value) { defaultExprSelect.value = ""; - syncTableAlterDefaultControls(row); + syncSchemaDialogDefaultControls(row, "table-alter"); } updateTableAlterSaveButtonState(state); }); @@ -1872,7 +2166,7 @@ function createTableAlterColumnRow(state, column) { if (defaultExprSelect.value) { defaultInput.value = ""; } - syncTableAlterDefaultControls(row); + syncSchemaDialogDefaultControls(row, "table-alter"); updateTableAlterSaveButtonState(state); }); pkInput.addEventListener("change", function () { @@ -1887,6 +2181,8 @@ function createTableAlterColumnRow(state, column) { typeSelect.addEventListener("change", function () { syncTableAlterCustomTypeForSqliteType(row); + syncTableAlterForeignKeyOptions(row, state); + syncSchemaDialogCustomTypeAndForeignKey(row, state, "table-alter"); updateTableAlterSaveButtonState(state); }); if (customTypeSelect) { @@ -1899,10 +2195,20 @@ function createTableAlterColumnRow(state, column) { tableAlterColumnTypes().indexOf(option.fixedSqliteType) !== -1 ) { typeSelect.value = option.fixedSqliteType; + syncTableAlterForeignKeyOptions(row, state); } + syncSchemaDialogCustomTypeAndForeignKey(row, state, "table-alter"); updateTableAlterSaveButtonState(state); }); } + foreignKeySelect.addEventListener("change", function () { + handleSchemaDialogForeignKeyChange(row, state, "table-alter", { + columnTypes: tableAlterColumnTypes(), + foreignKeyOptions: { filterByType: false }, + matchType: true, + }); + updateTableAlterSaveButtonState(state); + }); moveControls.topButton.addEventListener("click", function () { var first = tableAlterFirstNonPrimaryRow(state); @@ -1986,7 +2292,9 @@ function createTableAlterColumnRow(state, column) { } }); - syncTableAlterDefaultControls(row); + syncSchemaDialogDefaultControls(row, "table-alter"); + syncTableAlterForeignKeyOptions(row, state); + syncSchemaDialogCustomTypeAndForeignKey(row, state, "table-alter"); return row; } @@ -2026,6 +2334,7 @@ function collectTableAlterRows(state) { signature.originalDefault = row.dataset.originalDefault || ""; signature.originalPk = row.dataset.originalPk === "1"; signature.originalCustomType = row.dataset.originalCustomType || ""; + signature.originalForeignKey = row.dataset.originalForeignKey || ""; return signature; }); } @@ -2156,6 +2465,46 @@ function tableAlterColumnsReordered(state, rows) { ); } +function tableAlterForeignKeyIdentity(row) { + return [ + row.name.trim(), + row.foreignKeyTable || "", + row.foreignKeyColumn || "", + ].join("\u001f"); +} + +function tableAlterOriginalForeignKeyIdentity(row) { + return [row.originalName || "", row.originalForeignKey].join("\u001f"); +} + +function tableAlterForeignKeyRows(rows) { + return rows + .filter(function (row) { + return row.foreignKey && row.foreignKeyTable && row.foreignKeyColumn; + }) + .map(function (row) { + return { + column: row.name.trim(), + fk_table: row.foreignKeyTable, + fk_column: row.foreignKeyColumn, + }; + }); +} + +function tableAlterForeignKeysChanged(rows) { + var original = rows + .filter(function (row) { + return row.existing && row.originalForeignKey; + }) + .map(tableAlterOriginalForeignKeyIdentity); + var final = rows + .filter(function (row) { + return row.foreignKey && row.foreignKeyTable && row.foreignKeyColumn; + }) + .map(tableAlterForeignKeyIdentity); + return JSON.stringify(original) !== JSON.stringify(final); +} + function collectTableAlterPayload(state) { var rows = collectTableAlterRows(state); var validationError = validateTableAlterRows(state, rows); @@ -2237,6 +2586,15 @@ function collectTableAlterPayload(state) { }); } + if (tableAlterForeignKeysChanged(rows)) { + operations.push({ + op: "set_foreign_keys", + args: { + foreign_keys: tableAlterForeignKeyRows(rows), + }, + }); + } + if (!operations.length && !columnTypeAssignments.length) { return { error: "No changes to apply." }; } @@ -2354,6 +2712,27 @@ function tableAlterOperationSummary(operation) { damaging: false, }; } + if (operation.op === "set_foreign_keys") { + var foreignKeys = args.foreign_keys || []; + return { + text: foreignKeys.length + ? "Set foreign keys to " + + foreignKeys + .map(function (foreignKey) { + return ( + tableAlterQuotedName(foreignKey.column) + + " -> " + + foreignKey.fk_table + + "." + + foreignKey.fk_column + ); + }) + .join(", ") + + "." + : "Remove all foreign keys.", + damaging: false, + }; + } return { text: "Run " + operation.op + ".", damaging: false, @@ -2742,6 +3121,9 @@ function ensureTableAlterDialog(manager) { deletedColumns: [], originalColumnNames: [], originalPrimaryKeys: [], + foreignKeyTargets: [], + foreignKeyTargetsError: null, + foreignKeyTargetsLoading: false, mode: "edit", reviewResult: null, manager: manager, @@ -2844,6 +3226,12 @@ function openTableAlterDialog(button, manager) { state.title.textContent = "Alter table " + data.tableName; clearTableAlterDialogError(state); resetTableAlterDialog(state, data); + loadSchemaDialogForeignKeyTargets( + state, + "table-alter", + tableAlterForeignKeyTargetsUrl(), + { filterByType: false }, + ); if (!state.dialog.open) { state.dialog.showModal(); } diff --git a/datasette/views/table_create_alter.py b/datasette/views/table_create_alter.py index fa89aae4..111dfab1 100644 --- a/datasette/views/table_create_alter.py +++ b/datasette/views/table_create_alter.py @@ -266,6 +266,7 @@ async def _create_table_ui_context( ), "databaseName": database_name, "columnTypes": CREATE_TABLE_COLUMN_TYPES, + "defaultExpressions": list(DEFAULT_EXPR_SQL), } can_set_column_type = await datasette.allowed( action="set-column-type", diff --git a/tests/test_playwright.py b/tests/test_playwright.py index eefec118..5719039a 100644 --- a/tests/test_playwright.py +++ b/tests/test_playwright.py @@ -295,6 +295,10 @@ def test_create_table_flow(page, datasette_server): placeholder_select.locator("option:checked").inner_text() == "- custom type -" ) assert "table-create-input-placeholder" in placeholder_select.get_attribute("class") + assert ( + dialog.locator(".table-create-column-name").nth(0).get_attribute("placeholder") + == "column name" + ) assert dialog.locator(".table-create-column-main").first.evaluate("""node => { const inputHeight = node.querySelector( ".table-create-column-name" @@ -306,6 +310,22 @@ def test_create_table_flow(page, datasette_server): }""") dialog.locator('input[name="table"]').fill("playwright_created") dialog.locator(".table-create-column-name").nth(1).fill("title") + dialog.locator(".table-create-more-options").nth(1).click() + dialog.locator(".table-create-not-null-input").nth(1).check() + title_defaults = dialog.locator(".table-create-default-options").nth(1) + assert title_defaults.locator("summary").inner_text() == "Set a default value" + title_defaults.locator("summary").click() + assert "or default to a specific value" in title_defaults.inner_text() + title_default_expr = title_defaults.locator(".table-create-default-expr") + title_default_input = title_defaults.locator(".table-create-default") + assert ( + "Current timestamp in UTC, e.g. 2026-05-01 13:34:00" + in title_default_expr.locator("option").nth(1).inner_text() + ) + title_default_expr.select_option("current_timestamp") + assert title_default_input.is_enabled() + title_default_input.fill("Untitled") + assert title_default_expr.input_value() == "" 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") @@ -337,6 +357,63 @@ def test_create_table_flow(page, datasette_server): assert data["column_types"] == { "metadata": {"type": "json", "config": None}, } + schema_response = httpx.get( + f"{datasette_server}data/-/query.json", + params={ + "sql": ( + "select sql from sqlite_master where type = 'table' " + "and name = 'playwright_created'" + ) + }, + ) + schema_response.raise_for_status() + schema = schema_response.json()["rows"][0]["sql"] + assert "title" in schema + assert "NOT NULL DEFAULT 'Untitled'" in schema + + +@pytest.mark.playwright +def test_create_table_foreign_key_selection_updates_column_type(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() + dialog.locator(".table-create-more-options").nth(1).click() + + column_name = dialog.locator(".table-create-column-name").nth(1) + type_select = dialog.locator(".table-create-column-type").nth(1) + foreign_key_select = dialog.locator(".table-create-foreign-key-target").nth(1) + assert column_name.input_value() == "" + assert type_select.input_value() == "text" + + foreign_key_select.select_option("projects\u001fid") + assert column_name.input_value() == "projects_id" + assert type_select.input_value() == "integer" + assert foreign_key_select.input_value() == "projects\u001fid" + + +@pytest.mark.playwright +def test_alter_table_foreign_key_selection_updates_blank_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() + dialog.locator(".table-alter-add-column").click() + + column_name = dialog.locator(".table-alter-column-name").last + type_select = dialog.locator(".table-alter-column-type").last + foreign_key_select = dialog.locator(".table-alter-foreign-key-target").last + assert column_name.input_value() == "" + assert type_select.input_value() == "text" + + foreign_key_select.select_option("projects\u001fid") + assert column_name.input_value() == "projects_id" + assert type_select.input_value() == "integer" + assert foreign_key_select.input_value() == "projects\u001fid" @pytest.mark.playwright @@ -349,6 +426,10 @@ def test_alter_table_flow(page, datasette_server): dialog.wait_for() assert dialog.locator(".modal-title").inner_text() == "Alter table projects" assert dialog.locator(".table-alter-save").is_disabled() + assert ( + dialog.locator(".table-alter-column-name").first.get_attribute("placeholder") + == "column name" + ) assert dialog.locator(".table-alter-column-main").first.evaluate("""node => { const inputHeight = node.querySelector( ".table-alter-column-name" @@ -377,15 +458,29 @@ def test_alter_table_flow(page, datasette_server): ) 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 "Set a default value" in expanded_options_text assert "Primary key" in expanded_options_text assert "This ID uniquely identifies the record" in expanded_options_text + assert "Foreign key" in expanded_options_text + first_defaults = dialog.locator(".table-alter-default-options").first + first_defaults.locator("summary").click() + assert "or default to a specific value" in first_defaults.inner_text() + first_default_expr = first_defaults.locator(".table-alter-default-expr") + first_default_input = first_defaults.locator(".table-alter-default") + assert ( + "Current timestamp in UTC, e.g. 2026-05-01 13:34:00" + in first_default_expr.locator("option").nth(1).inner_text() + ) + first_default_expr.select_option("current_timestamp") + assert first_default_input.is_enabled() + first_default_input.fill("manual") + assert first_default_expr.input_value() == "" 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-options").last.locator("summary").click() dialog.locator(".table-alter-default").last.fill("planned") dialog.locator(".table-alter-save").click() review = dialog.locator(".table-alter-review") diff --git a/tests/test_table_html.py b/tests/test_table_html.py index ba9c03a5..9d1d1245 100644 --- a/tests/test_table_html.py +++ b/tests/test_table_html.py @@ -997,6 +997,11 @@ async def test_database_create_table_action_button_and_data(): "foreignKeyTargetsPath": "/data/-/foreign-key-targets", "databaseName": "data", "columnTypes": ["text", "integer", "float", "blob"], + "defaultExpressions": [ + "current_timestamp", + "current_date", + "current_time", + ], }, } assert "customColumnTypes" not in database_data_from_soup(soup)["createTable"]