From 4bd9d41c432934d369586ffd3c558448710128c3 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Fri, 3 Jul 2026 09:45:34 -0700 Subject: [PATCH] Preserve SQLite defaults in bulk insert Omit columns that are absent from pasted bulk insert data so SQLite defaults can fire during insert. Keep preview cells blank for omitted values while preserving explicit null rendering. Refs [https://github.com/simonw/datasette/pull/2813#issuecomment-4878063728](https://github.com/simonw/datasette/pull/2813#issuecomment-4878063728) --- datasette/static/edit-tools.js | 18 +++++------ tests/test_playwright.py | 59 ++++++++++++++++++++++++++++++++-- 2 files changed, 66 insertions(+), 11 deletions(-) diff --git a/datasette/static/edit-tools.js b/datasette/static/edit-tools.js index 87b63838..9e2f2d01 100644 --- a/datasette/static/edit-tools.js +++ b/datasette/static/edit-tools.js @@ -5586,8 +5586,8 @@ function resetBulkInsertPreview(state) { syncRowEditInsertModeUi(state); } -function normalizeBulkInsertCell(column, value, isMissing) { - if (isMissing || typeof value === "undefined") { +function normalizeBulkInsertCell(column, value) { + if (typeof value === "undefined") { return column.notnull ? "" : null; } if (value === null) { @@ -5609,11 +5609,10 @@ function rowObjectForBulkInsert(valuesByColumn, columns) { valuesByColumn, column.name, ); - row[column.name] = normalizeBulkInsertCell( - column, - hasValue ? valuesByColumn[column.name] : undefined, - !hasValue, - ); + if (!hasValue) { + return; + } + row[column.name] = normalizeBulkInsertCell(column, valuesByColumn[column.name]); }); return row; } @@ -5869,8 +5868,9 @@ function renderBulkInsertPreview(state, rows) { var tr = document.createElement("tr"); state.bulkInsertColumnDetails.forEach(function (column) { var td = document.createElement("td"); - var value = row[column.name]; - td.textContent = bulkInsertPreviewValue(value); + var hasValue = Object.prototype.hasOwnProperty.call(row, column.name); + var value = hasValue ? row[column.name] : ""; + td.textContent = hasValue ? bulkInsertPreviewValue(value) : ""; if (value === null) { td.className = "row-edit-bulk-preview-null"; } diff --git a/tests/test_playwright.py b/tests/test_playwright.py index 60c81315..9bededc6 100644 --- a/tests/test_playwright.py +++ b/tests/test_playwright.py @@ -102,6 +102,12 @@ def write_playwright_database(db_path): id integer primary key, created_ms integer default (CAST((julianday('now') - 2440587.5) * 86400000 AS INTEGER)) ); + create table bulk_defaults ( + id integer primary key, + title text not null, + status text not null default 'todo', + score integer default 5 + ); insert into projects (title, metadata, logo, notes, score) values ( 'Build Datasette', @@ -146,6 +152,11 @@ def write_playwright_config(config_path): "alter-table": True, }, }, + "bulk_defaults": { + "permissions": { + "insert-row": True, + }, + }, }, }, }, @@ -279,6 +290,16 @@ def project_row(datasette_server, pk): return rows[0] +def bulk_default_rows(datasette_server, **filters): + params = { + "_shape": "objects", + **{key: str(value) for key, value in filters.items()}, + } + response = httpx.get(f"{datasette_server}data/bulk_defaults.json", params=params) + response.raise_for_status() + return response.json()["rows"] + + def open_jump_menu(page): page.keyboard.press("/") page.locator("navigation-search .search-input").wait_for() @@ -1007,7 +1028,8 @@ def test_insert_row_flow_uses_custom_column_field(page, datasette_server): assert "metadata" in preview_text assert "id" not in preview_text assert "From CSV" in preview_text - assert "null" in preview_text + assert "null" not in preview_text + assert "undefined" not in preview_text preview_cell_style = dialog.locator( ".row-edit-bulk-preview-table td" ).first.evaluate( @@ -1097,6 +1119,38 @@ def test_bulk_insert_preview_inserts_rows(page, datasette_server): assert project_rows(datasette_server, title="Bulk two") +@pytest.mark.playwright +def test_bulk_insert_omits_columns_absent_from_pasted_input(page, datasette_server): + page.goto(f"{datasette_server}data/bulk_defaults") + page.locator('button[data-table-action="insert-row"]').click() + + dialog = page.locator("#row-edit-dialog") + dialog.wait_for() + dialog.locator(".row-edit-bulk-insert").click() + dialog.locator(".row-edit-bulk-textarea").fill("title\nOnly title") + dialog.locator(".row-edit-save").click() + + assert dialog.locator(".row-edit-save").inner_text() == "Insert these rows" + preview_text = dialog.locator(".row-edit-bulk-preview-table").inner_text() + assert "Only title" in preview_text + assert "undefined" not in preview_text + + dialog.locator(".row-edit-save").click() + dialog.locator( + ".row-edit-bulk-progress-status", has_text="1 row inserted." + ).wait_for() + + rows = bulk_default_rows(datasette_server, title="Only title") + assert rows == [ + { + "id": 1, + "title": "Only title", + "status": "todo", + "score": 5, + } + ] + + @pytest.mark.playwright def test_bulk_insert_preview_accepts_single_column_input(page, datasette_server): page.goto(f"{datasette_server}data/projects") @@ -1116,7 +1170,8 @@ def test_bulk_insert_preview_accepts_single_column_input(page, datasette_server) assert "one" in preview_text assert "two" in preview_text assert "three" in preview_text - assert "null" in preview_text + assert "null" not in preview_text + assert "undefined" not in preview_text @pytest.mark.playwright