From 5e0cfa8b3019c09d2556d1601ccac3a03da8d2a4 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sat, 8 Nov 2025 16:43:47 -0800 Subject: [PATCH 001/176] Initial prototype of row side panel, refs #2589 --- datasette/static/table.js | 313 +++++++++++++++++++ datasette/templates/_table.html | 190 +++++++++++- pyproject.toml | 1 + tests/test_row_detail_panel.py | 531 ++++++++++++++++++++++++++++++++ 4 files changed, 1034 insertions(+), 1 deletion(-) create mode 100644 tests/test_row_detail_panel.py diff --git a/datasette/static/table.js b/datasette/static/table.js index 0caeeb91..be03673c 100644 --- a/datasette/static/table.js +++ b/datasette/static/table.js @@ -330,6 +330,316 @@ function initAutocompleteForFilterValues(manager) { }); } +/** Initialize row detail side panel functionality */ +function initRowDetailPanel() { + const dialog = document.getElementById('rowDetailPanel'); + const closeButton = document.getElementById('closeRowDetail'); + const contentDiv = document.getElementById('rowDetailContent'); + const prevButton = document.getElementById('prevRowButton'); + const nextButton = document.getElementById('nextRowButton'); + const positionSpan = document.getElementById('rowPosition'); + + if (!dialog || !closeButton || !contentDiv || !prevButton || !nextButton) { + // Not on a table page with the panel + return; + } + + // State for navigation + let currentRowIndex = 0; + let allRows = []; // Array of objects: { element: DOMElement, pkValues: [...] } + let nextPageUrl = null; + let isLoadingMore = false; + let hasMoreRows = true; + + // Get primary key column names + function getPrimaryKeyNames() { + const headers = document.querySelectorAll('.rows-and-columns thead th[data-is-pk="1"]'); + return Array.from(headers).map(th => th.getAttribute('data-column')); + } + + const primaryKeyNames = getPrimaryKeyNames(); + + // Initialize the row list + function initializeRows() { + const domRows = document.querySelectorAll('.table-row-clickable'); + allRows = Array.from(domRows).map(row => ({ + element: row, + pkValues: extractPkValues(row) + })); + + // Check if there's a next page link + const nextLink = document.querySelector('a[href*="_next="]'); + nextPageUrl = nextLink ? nextLink.getAttribute('href') : null; + hasMoreRows = !!nextPageUrl; + } + + // Extract primary key values from a DOM row + function extractPkValues(row) { + const pkColumns = getPrimaryKeyColumns(); + const cells = row.querySelectorAll('td'); + return pkColumns.map(pk => { + const cell = cells[pk.index]; + if (!cell) return null; + return cell.getAttribute('data-value') || cell.textContent.trim(); + }); + } + + initializeRows(); + + // Prevent default cancel behavior (ESC key) to handle animation + dialog.addEventListener('cancel', (event) => { + event.preventDefault(); + animateCloseDialog(); + }); + + function animateCloseDialog() { + dialog.style.transform = 'translateX(100%)'; + setTimeout(() => { + dialog.close(); + }, 100); + } + + closeButton.addEventListener('click', () => { + animateCloseDialog(); + }); + + // Close on backdrop click + dialog.addEventListener('click', (event) => { + if (event.target === dialog) { + animateCloseDialog(); + } + }); + + // Get primary key column indices + function getPrimaryKeyColumns() { + const headers = document.querySelectorAll('.rows-and-columns thead th[data-is-pk="1"]'); + return Array.from(headers).map(th => { + const columnName = th.getAttribute('data-column'); + const index = Array.from(th.parentElement.children).indexOf(th); + return { name: columnName, index: index }; + }); + } + + // Construct row URL from row object (which has pkValues) + function getRowUrl(rowObj) { + if (!rowObj || !rowObj.pkValues || rowObj.pkValues.length === 0) { + return null; + } + + const pkValues = rowObj.pkValues; + + if (pkValues.some(v => v === null || v === '')) { + return null; + } + + // Construct the row path by joining PK values + const rowPath = pkValues.map(v => encodeURIComponent(v)).join(','); + + // Get current path and construct row URL + const currentPath = window.location.pathname; + return currentPath + '/' + rowPath + '.json'; + } + + // Fetch more rows from the next page using JSON API + async function fetchMoreRows() { + if (!nextPageUrl || isLoadingMore) { + return false; + } + + isLoadingMore = true; + try { + // Convert URL to JSON by adding .json before query params + let jsonUrl = nextPageUrl; + const urlParts = nextPageUrl.split('?'); + if (urlParts.length === 2) { + jsonUrl = urlParts[0] + '.json?' + urlParts[1]; + } else { + jsonUrl = nextPageUrl + '.json'; + } + + const response = await fetch(jsonUrl); + if (!response.ok) { + throw new Error(`Failed to fetch next page: ${response.status}`); + } + + const data = await response.json(); + + // Extract new rows from JSON + if (data.rows && data.rows.length > 0) { + const newRowObjects = data.rows.map(rowData => { + // Extract primary key values from the row data + const pkValues = primaryKeyNames.map(pkName => { + const value = rowData[pkName]; + return value !== null && value !== undefined ? String(value) : null; + }); + + return { + element: null, // No DOM element for paginated rows + pkValues: pkValues + }; + }); + + allRows.push(...newRowObjects); + } + + // Update next page URL from the response + nextPageUrl = data.next_url || null; + hasMoreRows = !!nextPageUrl; + + isLoadingMore = false; + return data.rows && data.rows.length > 0; + } catch (error) { + console.error('Error fetching more rows:', error); + isLoadingMore = false; + hasMoreRows = false; + return false; + } + } + + // Update navigation button states + function updateNavigationState() { + prevButton.disabled = currentRowIndex === 0; + + // Disable next if we're at the end and there are no more pages + const isAtEnd = currentRowIndex >= allRows.length - 1; + nextButton.disabled = isAtEnd && !hasMoreRows; + + // Update position display + if (allRows.length > 0) { + const displayIndex = currentRowIndex + 1; + positionSpan.textContent = `Row ${displayIndex}`; + } else { + positionSpan.textContent = ''; + } + } + + // Fetch and display row details + async function showRowDetails(rowIndex) { + if (rowIndex < 0 || rowIndex >= allRows.length) { + return; + } + + currentRowIndex = rowIndex; + const rowObj = allRows[rowIndex]; + const rowUrl = getRowUrl(rowObj); + + if (!rowUrl) { + contentDiv.innerHTML = '

Cannot display row: No primary key found

'; + showDialog(); + updateNavigationState(); + return; + } + + // Show loading state + contentDiv.innerHTML = '

Loading...

'; + updateNavigationState(); + + try { + const response = await fetch(rowUrl); + + if (!response.ok) { + throw new Error(`Failed to fetch row: ${response.status} ${response.statusText}`); + } + + const data = await response.json(); + + // Display the row data + if (data.rows && data.rows.length > 0) { + const rowData = data.rows[0]; + let html = '
'; + + for (const [key, value] of Object.entries(rowData)) { + html += `
${escapeHtml(key)}
`; + + if (value === null) { + html += '
null
'; + } else if (typeof value === 'object') { + html += `
${escapeHtml(JSON.stringify(value, null, 2))}
`; + } else { + html += `
${escapeHtml(String(value))}
`; + } + } + + html += '
'; + contentDiv.innerHTML = html; + } else { + contentDiv.innerHTML = '

No row data found

'; + } + } catch (error) { + console.error('Error fetching row details:', error); + contentDiv.innerHTML = `

Error loading row details: ${escapeHtml(error.message)}

`; + } + + updateNavigationState(); + } + + // Handle previous button click + prevButton.addEventListener('click', () => { + if (currentRowIndex > 0) { + showRowDetails(currentRowIndex - 1); + } + }); + + // Handle next button click + nextButton.addEventListener('click', async () => { + const nextIndex = currentRowIndex + 1; + + // If we're at the end of current rows, try to fetch more + if (nextIndex >= allRows.length && hasMoreRows && !isLoadingMore) { + nextButton.disabled = true; + nextButton.textContent = 'Loading...'; + + const fetched = await fetchMoreRows(); + + nextButton.textContent = 'Next →'; + + if (fetched && nextIndex < allRows.length) { + showRowDetails(nextIndex); + } else { + updateNavigationState(); + } + } else if (nextIndex < allRows.length) { + showRowDetails(nextIndex); + } + }); + + function showDialog() { + // Reset transform before opening + dialog.style.transition = 'none'; + dialog.style.transform = 'translateX(100%)'; + + // Open the dialog + dialog.showModal(); + + // Trigger animation + void dialog.offsetWidth; + + dialog.style.transition = 'transform 0.1s cubic-bezier(0.2, 0, 0.38, 0.9)'; + dialog.style.transform = 'translateX(0)'; + } + + function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } + + // Add click handlers to all table rows (only for rows with DOM elements) + allRows.forEach((rowObj, index) => { + if (rowObj.element) { + rowObj.element.addEventListener('click', (event) => { + // Don't trigger if clicking on a link or button within the row + if (event.target.tagName === 'A' || event.target.tagName === 'BUTTON') { + return; + } + + showDialog(); + showRowDetails(index); + }); + } + }); +} + // Ensures Table UI is initialized only after the Manager is ready. document.addEventListener("datasette_init", function (evt) { const { detail: manager } = evt; @@ -340,4 +650,7 @@ document.addEventListener("datasette_init", function (evt) { // Other UI functions with interactive JS needs addButtonsToFilterRows(manager); initAutocompleteForFilterValues(manager); + + // Row detail panel + initRowDetailPanel(); }); diff --git a/datasette/templates/_table.html b/datasette/templates/_table.html index a1329ba7..3748c99c 100644 --- a/datasette/templates/_table.html +++ b/datasette/templates/_table.html @@ -22,7 +22,7 @@ {% for row in display_rows %} - + {% for cell in row %} {{ cell.value }} {% endfor %} @@ -34,3 +34,191 @@ {% else %}

0 records

{% endif %} + + + +
+
+

Row details

+ +
+
+ + + +
+
+

Loading...

+
+
+
+ + diff --git a/pyproject.toml b/pyproject.toml index 1395ce82..715ce3c7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -78,6 +78,7 @@ test = [ "pytest-timeout>=1.4.2", "trustme>=0.7", "cogapp>=3.3.0", + "pytest-playwright>=0.7.1" ] rich = ["rich"] diff --git a/tests/test_row_detail_panel.py b/tests/test_row_detail_panel.py new file mode 100644 index 00000000..01424938 --- /dev/null +++ b/tests/test_row_detail_panel.py @@ -0,0 +1,531 @@ +""" +Playwright tests for the row detail side panel feature. +""" + +import pytest +import subprocess +import sys +import tempfile +import time +import httpx +from playwright.sync_api import expect + + +def wait_until_responds(url, timeout=5.0): + """Wait until a URL responds to HTTP requests""" + start = time.time() + while time.time() - start < timeout: + try: + httpx.get(url) + return + except httpx.ConnectError: + time.sleep(0.1) + raise AssertionError(f"Timed out waiting for {url} to respond") + + +@pytest.fixture(scope="module") +def datasette_server(): + """Start a Datasette server for testing""" + # Create a simple test database + import sqlite3 + import os + + db_path = os.path.join(tempfile.gettempdir(), "test_products.db") + # Remove if exists + if os.path.exists(db_path): + os.remove(db_path) + + conn = sqlite3.connect(db_path) + conn.execute( + """ + CREATE TABLE products ( + id INTEGER PRIMARY KEY, + name TEXT, + description TEXT, + price REAL, + category TEXT + ) + """ + ) + conn.execute( + """ + INSERT INTO products (name, description, price, category) VALUES + ('Laptop', 'High-performance laptop', 999.99, 'Electronics'), + ('Mouse', 'Wireless mouse', 29.99, 'Electronics'), + ('Desk', 'Standing desk', 499.99, 'Furniture'), + ('Chair', 'Ergonomic chair', 299.99, 'Furniture'), + ('Notebook', 'Spiral notebook', 4.99, 'Stationery') + """ + ) + conn.commit() + conn.close() + + # Start Datasette server + ds_proc = subprocess.Popen( + [sys.executable, "-m", "datasette", db_path, "-p", "8042"], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + cwd=tempfile.gettempdir(), + ) + wait_until_responds("http://localhost:8042/") + + # Check it started successfully + assert not ds_proc.poll(), ds_proc.stdout.read().decode("utf-8") + + yield {"base_url": "http://localhost:8042", "db_name": "test_products"} + + # Shut down the server + ds_proc.terminate() + ds_proc.wait() + + # Clean up + if os.path.exists(db_path): + os.remove(db_path) + + +def test_row_detail_panel_elements_exist(page, datasette_server): + """Test that the row detail panel HTML elements exist""" + base_url = datasette_server["base_url"] + db_name = datasette_server["db_name"] + page.goto(f"{base_url}/{db_name}/products") + + # Wait for the page to load + page.wait_for_selector(".rows-and-columns") + + # Check that the dialog element exists + dialog = page.locator("#rowDetailPanel") + assert dialog.count() == 1 + + # Check that the close button exists + close_button = page.locator("#closeRowDetail") + assert close_button.count() == 1 + + # Check that the content div exists + content_div = page.locator("#rowDetailContent") + assert content_div.count() == 1 + + +def test_row_click_opens_panel(page, datasette_server): + """Test that clicking a table row opens the side panel""" + base_url = datasette_server["base_url"] + db_name = datasette_server["db_name"] + page.goto(f"{base_url}/{db_name}/products") + + # Wait for the table to load + page.wait_for_selector(".rows-and-columns tbody tr") + + # Get the dialog + dialog = page.locator("#rowDetailPanel") + + # Dialog should not be open initially + assert not dialog.evaluate("el => el.hasAttribute('open')") + + # Click the first row + first_row = page.locator(".table-row-clickable").first + first_row.click() + + # Wait for the dialog to open + page.wait_for_selector("#rowDetailPanel[open]", timeout=2000) + + # Dialog should now be open + assert dialog.evaluate("el => el.hasAttribute('open')") + + # Content should be loaded (not showing "Loading...") + content = page.locator("#rowDetailContent") + expect(content).not_to_contain_text("Loading...") + + +def test_row_panel_displays_data(page, datasette_server): + """Test that the row panel displays the correct data""" + base_url = datasette_server["base_url"] + db_name = datasette_server["db_name"] + page.goto(f"{base_url}/{db_name}/products") + + # Wait for the table to load + page.wait_for_selector(".rows-and-columns tbody tr") + + # Click the first row (Laptop) + first_row = page.locator(".table-row-clickable").first + first_row.click() + + # Wait for the dialog to open and content to load + page.wait_for_selector("#rowDetailPanel[open]") + page.wait_for_selector("#rowDetailContent dl") + + # Check that the content includes the expected data + content = page.locator("#rowDetailContent") + expect(content).to_contain_text("Laptop") + expect(content).to_contain_text("High-performance laptop") + expect(content).to_contain_text("999.99") + expect(content).to_contain_text("Electronics") + + +def test_close_button_closes_panel(page, datasette_server): + """Test that clicking the close button closes the panel""" + base_url = datasette_server["base_url"] + db_name = datasette_server["db_name"] + page.goto(f"{base_url}/{db_name}/products") + + # Wait for the table to load + page.wait_for_selector(".rows-and-columns tbody tr") + + # Click a row to open the panel + first_row = page.locator(".table-row-clickable").first + first_row.click() + + # Wait for the dialog to open + page.wait_for_selector("#rowDetailPanel[open]") + + # Click the close button + close_button = page.locator("#closeRowDetail") + close_button.click() + + # Wait for the dialog to close + page.wait_for_timeout(200) # Wait for animation + + # Dialog should be closed + dialog = page.locator("#rowDetailPanel") + assert not dialog.evaluate("el => el.hasAttribute('open')") + + +def test_escape_key_closes_panel(page, datasette_server): + """Test that pressing Escape closes the panel""" + base_url = datasette_server["base_url"] + db_name = datasette_server["db_name"] + page.goto(f"{base_url}/{db_name}/products") + + # Wait for the table to load + page.wait_for_selector(".rows-and-columns tbody tr") + + # Click a row to open the panel + first_row = page.locator(".table-row-clickable").first + first_row.click() + + # Wait for the dialog to open + page.wait_for_selector("#rowDetailPanel[open]") + + # Press Escape + page.keyboard.press("Escape") + + # Wait for the dialog to close + page.wait_for_timeout(200) # Wait for animation + + # Dialog should be closed + dialog = page.locator("#rowDetailPanel") + assert not dialog.evaluate("el => el.hasAttribute('open')") + + +@pytest.mark.skip( + reason="Backdrop click is difficult to test programmatically - works in manual testing" +) +def test_backdrop_click_closes_panel(page, datasette_server): + """Test that clicking the backdrop closes the panel""" + base_url = datasette_server["base_url"] + db_name = datasette_server["db_name"] + page.goto(f"{base_url}/{db_name}/products") + + # Wait for the table to load + page.wait_for_selector(".rows-and-columns tbody tr") + + # Click a row to open the panel + first_row = page.locator(".table-row-clickable").first + first_row.click() + + # Wait for the dialog to open + page.wait_for_selector("#rowDetailPanel[open]") + + # Click the dialog backdrop (the dialog element itself, not the content) + dialog = page.locator("#rowDetailPanel") + # Get the bounding box and click outside the content area + box = dialog.bounding_box() + if box: + # Click on the left side of the dialog (the backdrop) + page.mouse.click(box["x"] + 10, box["y"] + box["height"] / 2) + + # Wait for the dialog to close + page.wait_for_timeout(200) # Wait for animation + + # Dialog should be closed + assert not dialog.evaluate("el => el.hasAttribute('open')") + + +def test_multiple_rows_different_data(page, datasette_server): + """Test that clicking different rows shows different data""" + base_url = datasette_server["base_url"] + db_name = datasette_server["db_name"] + page.goto(f"{base_url}/{db_name}/products") + + # Wait for the table to load + page.wait_for_selector(".rows-and-columns tbody tr") + + # Click the first row + first_row = page.locator(".table-row-clickable").first + first_row.click() + + # Wait for the dialog to open + page.wait_for_selector("#rowDetailPanel[open]") + page.wait_for_selector("#rowDetailContent dl") + + # Check for first row data + content = page.locator("#rowDetailContent") + expect(content).to_contain_text("Laptop") + + # Close the panel + close_button = page.locator("#closeRowDetail") + close_button.click() + page.wait_for_timeout(200) + + # Click the second row + second_row = page.locator(".table-row-clickable").nth(1) + second_row.click() + + # Wait for the dialog to open again + page.wait_for_selector("#rowDetailPanel[open]") + page.wait_for_selector("#rowDetailContent dl") + + # Check for second row data + expect(content).to_contain_text("Mouse") + expect(content).to_contain_text("Wireless mouse") + + +def test_row_hover_state(page, datasette_server): + """Test that rows have hover state styling""" + base_url = datasette_server["base_url"] + db_name = datasette_server["db_name"] + page.goto(f"{base_url}/{db_name}/products") + + # Wait for the table to load + page.wait_for_selector(".rows-and-columns tbody tr") + + # Get the first row + first_row = page.locator(".table-row-clickable").first + + # Check that the row has cursor: pointer + cursor_style = first_row.evaluate("el => window.getComputedStyle(el).cursor") + assert cursor_style == "pointer" + + +def test_navigation_buttons_exist(page, datasette_server): + """Test that navigation buttons are present""" + base_url = datasette_server["base_url"] + db_name = datasette_server["db_name"] + page.goto(f"{base_url}/{db_name}/products") + + # Wait for the table to load + page.wait_for_selector(".rows-and-columns tbody tr") + + # Click a row to open the panel + first_row = page.locator(".table-row-clickable").first + first_row.click() + + # Wait for the dialog to open + page.wait_for_selector("#rowDetailPanel[open]") + + # Check that navigation buttons exist + prev_button = page.locator("#prevRowButton") + next_button = page.locator("#nextRowButton") + position = page.locator("#rowPosition") + + assert prev_button.count() == 1 + assert next_button.count() == 1 + assert position.count() == 1 + + +def test_previous_button_disabled_on_first_row(page, datasette_server): + """Test that previous button is disabled on the first row""" + base_url = datasette_server["base_url"] + db_name = datasette_server["db_name"] + page.goto(f"{base_url}/{db_name}/products") + + # Wait for the table to load + page.wait_for_selector(".rows-and-columns tbody tr") + + # Click the first row + first_row = page.locator(".table-row-clickable").first + first_row.click() + + # Wait for the dialog to open + page.wait_for_selector("#rowDetailPanel[open]") + + # Previous button should be disabled + prev_button = page.locator("#prevRowButton") + assert prev_button.is_disabled() + + # Next button should be enabled + next_button = page.locator("#nextRowButton") + assert not next_button.is_disabled() + + +def test_next_button_navigation(page, datasette_server): + """Test that next button navigates to the next row""" + base_url = datasette_server["base_url"] + db_name = datasette_server["db_name"] + page.goto(f"{base_url}/{db_name}/products") + + # Wait for the table to load + page.wait_for_selector(".rows-and-columns tbody tr") + + # Click the first row + first_row = page.locator(".table-row-clickable").first + first_row.click() + + # Wait for the dialog to open and content to load + page.wait_for_selector("#rowDetailPanel[open]") + page.wait_for_selector("#rowDetailContent dl") + + # Should show Laptop data + content = page.locator("#rowDetailContent") + expect(content).to_contain_text("Laptop") + + # Click next button + next_button = page.locator("#nextRowButton") + next_button.click() + + # Wait for content to update + page.wait_for_timeout(300) + + # Should now show Mouse data + expect(content).to_contain_text("Mouse") + expect(content).to_contain_text("29.99") + + +def test_previous_button_navigation(page, datasette_server): + """Test that previous button navigates to the previous row""" + base_url = datasette_server["base_url"] + db_name = datasette_server["db_name"] + page.goto(f"{base_url}/{db_name}/products") + + # Wait for the table to load + page.wait_for_selector(".rows-and-columns tbody tr") + + # Click the second row + second_row = page.locator(".table-row-clickable").nth(1) + second_row.click() + + # Wait for the dialog to open and content to load + page.wait_for_selector("#rowDetailPanel[open]") + page.wait_for_selector("#rowDetailContent dl") + + # Should show Mouse data + content = page.locator("#rowDetailContent") + expect(content).to_contain_text("Mouse") + + # Previous button should be enabled now + prev_button = page.locator("#prevRowButton") + assert not prev_button.is_disabled() + + # Click previous button + prev_button.click() + + # Wait for content to update + page.wait_for_timeout(300) + + # Should now show Laptop data + expect(content).to_contain_text("Laptop") + + # Previous button should now be disabled (we're at first row) + assert prev_button.is_disabled() + + +def test_row_position_updates(page, datasette_server): + """Test that row position indicator updates correctly""" + base_url = datasette_server["base_url"] + db_name = datasette_server["db_name"] + page.goto(f"{base_url}/{db_name}/products") + + # Wait for the table to load + page.wait_for_selector(".rows-and-columns tbody tr") + + # Click the first row + first_row = page.locator(".table-row-clickable").first + first_row.click() + + # Wait for the dialog to open + page.wait_for_selector("#rowDetailPanel[open]") + + # Check position indicator shows "Row 1" + position = page.locator("#rowPosition") + expect(position).to_have_text("Row 1") + + # Click next + next_button = page.locator("#nextRowButton") + next_button.click() + page.wait_for_timeout(300) + + # Position should update to "Row 2" + expect(position).to_have_text("Row 2") + + +def test_pagination_navigation(page, datasette_server): + """Test that navigation works across pagination boundaries""" + base_url = datasette_server["base_url"] + db_name = datasette_server["db_name"] + + # Add page_size parameter to force pagination + page.goto(f"{base_url}/{db_name}/products?_size=2") + + # Wait for the table to load + page.wait_for_selector(".rows-and-columns tbody tr") + + # Click the second (last visible) row + second_row = page.locator(".table-row-clickable").nth(1) + second_row.click() + + # Wait for the dialog to open and content to load + page.wait_for_selector("#rowDetailPanel[open]") + page.wait_for_selector("#rowDetailContent dl") + + # Should show Mouse data (second row) + content = page.locator("#rowDetailContent") + expect(content).to_contain_text("Mouse") + + # Next button should be enabled (there are more rows via pagination) + next_button = page.locator("#nextRowButton") + assert not next_button.is_disabled() + + # Click next button - should load the third row from the next page + next_button.click() + + # Wait for loading and content update + page.wait_for_timeout(1000) # Give time for pagination fetch + + # Should now show Desk data (third row, from next page) + expect(content).to_contain_text("Desk") + + # Previous button should work to go back + prev_button = page.locator("#prevRowButton") + assert not prev_button.is_disabled() + prev_button.click() + page.wait_for_timeout(300) + + # Should be back to Mouse + expect(content).to_contain_text("Mouse") + + +@pytest.mark.skip(reason="Mobile viewport test - enable if needed") +def test_panel_responsive_on_mobile(page, datasette_server): + """Test that the panel is responsive on mobile viewports""" + # Set mobile viewport + page.set_viewport_size({"width": 375, "height": 667}) + + base_url = datasette_server["base_url"] + db_name = datasette_server["db_name"] + page.goto(f"{base_url}/{db_name}/products") + + # Wait for the table to load + page.wait_for_selector(".rows-and-columns tbody tr") + + # Click a row + first_row = page.locator(".table-row-clickable").first + first_row.click() + + # Wait for the dialog to open + page.wait_for_selector("#rowDetailPanel[open]") + + # Check that the panel width is appropriate for mobile + dialog = page.locator("#rowDetailPanel") + width = dialog.evaluate("el => el.offsetWidth") + viewport_width = page.viewport_size["width"] + + # Panel should take most of the width on mobile (90%) + assert width > viewport_width * 0.85 # Allow some margin From 472caf4edf42f268d73c3d58cbf9d586edf1bf78 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sat, 8 Nov 2025 16:54:59 -0800 Subject: [PATCH 002/176] Install Playwright in CI --- .github/workflows/test.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1e5e03d2..5e294f93 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -27,6 +27,14 @@ jobs: run: | pip install -e '.[test]' pip freeze + - name: Cache Playwright browsers + uses: actions/cache@v4 + with: + path: ~/.cache/ms-playwright/ + key: ${{ runner.os }}-browsers + - name: Install Playwright dependencies + run: | + playwright install - name: Run tests run: | pytest -n auto -m "not serial" From a6681688356db4d96eaf6f7aaf4965cda405519d Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Mon, 9 Mar 2026 18:18:50 -0700 Subject: [PATCH 003/176] Fix bug with compound pks and row panel --- datasette/static/table.js | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/datasette/static/table.js b/datasette/static/table.js index 267246f4..98e20fa1 100644 --- a/datasette/static/table.js +++ b/datasette/static/table.js @@ -434,6 +434,25 @@ function initRowDetailPanel() { }); } + // Tilde-encode a string for Datasette row URLs. + // Characters outside A-Z a-z 0-9 _ - are encoded as ~XX hex pairs. + // Spaces become +. + function tildeEncode(s) { + const safe = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-'; + const bytes = new TextEncoder().encode(s); + let result = ''; + for (const b of bytes) { + if (b === 0x20) { + result += '+'; + } else if (safe.indexOf(String.fromCharCode(b)) !== -1) { + result += String.fromCharCode(b); + } else { + result += '~' + b.toString(16).toUpperCase().padStart(2, '0'); + } + } + return result; + } + // Construct row URL from row object (which has pkValues) function getRowUrl(rowObj) { if (!rowObj || !rowObj.pkValues || rowObj.pkValues.length === 0) { @@ -446,8 +465,8 @@ function initRowDetailPanel() { return null; } - // Construct the row path by joining PK values - const rowPath = pkValues.map(v => encodeURIComponent(v)).join(','); + // Construct the row path by joining tilde-encoded PK values + const rowPath = pkValues.map(v => tildeEncode(v)).join(','); // Get current path and construct row URL const currentPath = window.location.pathname; From 7f93353549a330f2c3d76ee5844dd4087db3efcb Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Mon, 16 Mar 2026 17:56:40 -0700 Subject: [PATCH 004/176] Fix startup hook to fire after metadata and schema tables are populated (#2666) * Fix startup hook to fire after metadata and schema tables are populated Previously, the startup() plugin hook fired before internal database tables were populated from metadata.yaml and before catalog schema tables were filled. This meant plugins couldn't read or modify metadata during startup. Now invoke_startup() calls refresh_schemas() before firing startup hooks, ensuring metadata and catalog tables are available. * Fix startup hook to fire after metadata and schema tables are populated Previously, the startup() plugin hook fired before internal database tables were populated from metadata.yaml and before catalog schema tables were filled. This meant plugins couldn't read or modify metadata during startup. Now invoke_startup() calls _refresh_schemas() before firing startup hooks, ensuring metadata and catalog tables are available. Updated test_tracer to reflect that internal DB creation SQL now runs during startup rather than during the first traced request. * Move check_databases before invoke_startup in CLI serve Since invoke_startup now calls _refresh_schemas() which queries each database, the spatialite connection check must run first to provide the friendly error message instead of a raw OperationalError. https://claude.ai/code/session_01KL4t5FZYb32rZY7xaqrrZU --- datasette/app.py | 2 ++ datasette/cli.py | 7 ++++--- tests/plugins/my_plugin_2.py | 12 ++++++++++++ tests/test_plugins.py | 12 ++++++++++++ tests/test_tracer.py | 24 +++++------------------- 5 files changed, 35 insertions(+), 22 deletions(-) diff --git a/datasette/app.py b/datasette/app.py index 2df6e4e8..f0349895 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -696,6 +696,8 @@ class Datasette: env=self._jinja_env, datasette=self ): await await_me_maybe(hook) + # Ensure internal tables and metadata are populated before startup hooks + await self._refresh_schemas() for hook in pm.hook.startup(datasette=self): await await_me_maybe(hook) self._startup_invoked = True diff --git a/datasette/cli.py b/datasette/cli.py index db777fe8..32a4d898 100644 --- a/datasette/cli.py +++ b/datasette/cli.py @@ -661,15 +661,16 @@ def serve( # Private utility mechanism for writing unit tests return ds + # Run async soundness checks before startup hooks, since invoke_startup + # now populates internal tables which requires querying each database + run_sync(lambda: check_databases(ds)) + # Run the "startup" plugin hooks try: run_sync(ds.invoke_startup) except StartupError as e: raise click.ClickException(e.args[0]) - # Run async soundness checks - but only if we're not under pytest - run_sync(lambda: check_databases(ds)) - if headers and not get: raise click.ClickException("--headers can only be used with --get") diff --git a/tests/plugins/my_plugin_2.py b/tests/plugins/my_plugin_2.py index 35775ef9..9e8d9b2b 100644 --- a/tests/plugins/my_plugin_2.py +++ b/tests/plugins/my_plugin_2.py @@ -127,6 +127,18 @@ def startup(datasette): internal_db = datasette.get_internal_database() result = await internal_db.execute("select 1 + 1") datasette._startup_hook_calculation = result.first()[0] + # Check that metadata tables have been populated before startup fires + metadata_rows = await internal_db.execute( + "select key, value from metadata_instance" + ) + datasette._startup_metadata_keys = [row["key"] for row in metadata_rows] + # Check that catalog/schema tables have been populated before startup fires + catalog_rows = await internal_db.execute( + "select database_name from catalog_databases" + ) + datasette._startup_catalog_databases = [ + row["database_name"] for row in catalog_rows + ] return inner diff --git a/tests/test_plugins.py b/tests/test_plugins.py index fa9d1a1f..f2a47ab4 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -862,6 +862,18 @@ async def test_hook_startup(ds_client): assert 2 == ds_client.ds._startup_hook_calculation +@pytest.mark.asyncio +async def test_hook_startup_metadata_available(ds_client): + # Metadata from metadata.yaml should be populated before startup() fires + assert "title" in ds_client.ds._startup_metadata_keys + + +@pytest.mark.asyncio +async def test_hook_startup_catalog_populated(ds_client): + # Internal catalog tables should be populated before startup() fires + assert "fixtures" in ds_client.ds._startup_catalog_databases + + @pytest.mark.asyncio async def test_hook_canned_queries(ds_client): queries = (await ds_client.get("/fixtures.json")).json()["queries"] diff --git a/tests/test_tracer.py b/tests/test_tracer.py index 1e0d7001..6cc80fc4 100644 --- a/tests/test_tracer.py +++ b/tests/test_tracer.py @@ -32,25 +32,11 @@ def test_trace(trace_debug): assert isinstance(trace.get("params"), (list, dict, None.__class__)) sqls = [trace["sql"] for trace in traces if "sql" in trace] - # There should be a mix of different types of SQL statement - expected = ( - "CREATE TABLE ", - "PRAGMA ", - "INSERT OR REPLACE INTO ", - "INSERT INTO", - "select ", - ) - for prefix in expected: - assert any( - sql.startswith(prefix) for sql in sqls - ), "No trace beginning with: {}".format(prefix) - - # Should be at least one executescript - assert any(trace for trace in traces if trace.get("executescript")) - # And at least one executemany - execute_manys = [trace for trace in traces if trace.get("executemany")] - assert execute_manys - assert all(isinstance(trace["count"], int) for trace in execute_manys) + # There should be SQL statements from request handling in the trace. + # Note: CREATE TABLE, INSERT OR REPLACE, executescript, and executemany + # are not expected here because internal tables are now created and + # populated during invoke_startup(), before the request is traced. + assert any(sql.startswith("select ") for sql in sqls), "No select statements traced" def test_trace_silently_fails_for_large_page(): From 73225ccad0581a4f505adf2f4590372246a7d9be Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 17 Mar 2026 02:40:37 +0000 Subject: [PATCH 005/176] Add column types system for semantic column annotations Implements the column types feature that lets Datasette and plugins annotate columns with semantic types beyond SQLite storage types (e.g. markdown, email, url, json, file, point). This enables type-appropriate rendering, validation, form widgets, and API behavior. Key changes: - New `column_types` internal DB table for storing assignments - `ColumnType` dataclass in datasette/column_types.py with render_cell, validate, and transform_value methods - `register_column_types` plugin hook for registering types - Built-in url, email, and json column types - Datasette API methods: get/set/remove_column_type(s), get_column_type_class - Config loading from datasette.json `column_types` table config key - `column_types` extra on the table JSON endpoint - Column type info in display_columns extra - Column type render_cell gets priority in rendering pipeline - column_type/column_type_config args added to render_cell hookspec - Write-path validation on insert and update https://claude.ai/code/session_01SvPEPqHgURTWESRp28pTC3 --- datasette/app.py | 103 +++++++++ datasette/column_types.py | 42 ++++ datasette/default_column_types.py | 82 +++++++ datasette/hookspecs.py | 10 +- datasette/plugins.py | 1 + datasette/utils/internal_db.py | 9 + datasette/views/database.py | 2 + datasette/views/row.py | 50 ++-- datasette/views/table.py | 160 +++++++++---- tests/test_column_types.py | 369 ++++++++++++++++++++++++++++++ tests/test_plugins.py | 11 + 11 files changed, 781 insertions(+), 58 deletions(-) create mode 100644 datasette/column_types.py create mode 100644 datasette/default_column_types.py create mode 100644 tests/test_column_types.py diff --git a/datasette/app.py b/datasette/app.py index f0349895..1a20dbd0 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -354,6 +354,7 @@ class Datasette: self.immutables = set(immutables or []) self.databases = collections.OrderedDict() self.actions = {} # .invoke_startup() will populate this + self._column_types = {} # .invoke_startup() will populate this try: self._refresh_schemas_lock = asyncio.Lock() except RuntimeError as rex: @@ -692,12 +693,25 @@ class Datasette: action_abbrs[action.abbr] = action self.actions[action.name] = action + # Register column types + self._column_types = {} + for hook in pm.hook.register_column_types(datasette=self): + if hook: + for ct in hook: + if ct.name in self._column_types: + raise StartupError( + f"Duplicate column type name: {ct.name}" + ) + self._column_types[ct.name] = ct + for hook in pm.hook.prepare_jinja2_environment( env=self._jinja_env, datasette=self ): await await_me_maybe(hook) # Ensure internal tables and metadata are populated before startup hooks await self._refresh_schemas() + # Load column_types from config into internal DB + await self._apply_column_types_config() for hook in pm.hook.startup(datasette=self): await await_me_maybe(hook) self._startup_invoked = True @@ -945,6 +959,95 @@ class Datasette: [database_name, resource_name, column_name, key, value], ) + # Column types API + + async def _apply_column_types_config(self): + """Load column_types from datasette.json config into the internal DB.""" + import logging + + for db_name, db_conf in (self.config or {}).get("databases", {}).items(): + for table_name, table_conf in db_conf.get("tables", {}).items(): + for col_name, ct in table_conf.get("column_types", {}).items(): + if isinstance(ct, str): + col_type, config = ct, None + else: + col_type = ct["type"] + config = ct.get("config") + if col_type not in self._column_types: + logging.warning( + "column_types config references unknown type %r " + "for %s.%s.%s", + col_type, db_name, table_name, col_name, + ) + await self.set_column_type( + db_name, table_name, col_name, col_type, config + ) + + async def get_column_type( + self, database: str, resource: str, column: str + ) -> tuple: + """ + Return (column_type_name, config_dict) for a specific column, + or (None, None) if no column type is assigned. + """ + row = await self.get_internal_database().execute( + "SELECT column_type, config FROM column_types " + "WHERE database_name = ? AND resource_name = ? AND column_name = ?", + [database, resource, column], + ) + rows = row.rows + if not rows: + return None, None + ct, config = rows[0] + return (ct, json.loads(config) if config else None) + + async def get_column_types( + self, database: str, resource: str + ) -> dict: + """ + Return {column_name: (column_type_name, config_dict_or_None)} + for all columns with assigned types on the given resource. + """ + rows = await self.get_internal_database().execute( + "SELECT column_name, column_type, config FROM column_types " + "WHERE database_name = ? AND resource_name = ?", + [database, resource], + ) + return { + row[0]: (row[1], json.loads(row[2]) if row[2] else None) + for row in rows.rows + } + + async def set_column_type( + self, database: str, resource: str, column: str, + column_type: str, config: dict = None + ) -> None: + """Assign a column type. Overwrites any existing assignment.""" + await self.get_internal_database().execute_write( + """INSERT OR REPLACE INTO column_types + (database_name, resource_name, column_name, column_type, config) + VALUES (?, ?, ?, ?, ?)""", + [database, resource, column, column_type, + json.dumps(config) if config else None], + ) + + async def remove_column_type( + self, database: str, resource: str, column: str + ) -> None: + """Remove a column type assignment.""" + await self.get_internal_database().execute_write( + "DELETE FROM column_types " + "WHERE database_name = ? AND resource_name = ? AND column_name = ?", + [database, resource, column], + ) + + def get_column_type_class(self, column_type_name: str): + """ + Return the registered ColumnType instance for a given name, + or None if no plugin has registered that name. + """ + return self._column_types.get(column_type_name) + def get_internal_database(self): return self._internal_database diff --git a/datasette/column_types.py b/datasette/column_types.py new file mode 100644 index 00000000..240bcc8f --- /dev/null +++ b/datasette/column_types.py @@ -0,0 +1,42 @@ +from dataclasses import dataclass + + +@dataclass(frozen=True, kw_only=True) +class ColumnType: + name: str + """ + Unique identifier string. Lowercase, no spaces. + Examples: "markdown", "file", "email", "url", "point", "image". + """ + + description: str + """ + Human-readable label for admin UI dropdowns. + Examples: "Markdown text", "File reference", "Email address". + """ + + async def render_cell( + self, value, column, table, database, datasette, request, config + ): + """ + Return an HTML string to render this cell value, or None to + fall through to the default render_cell plugin hook chain. + + ``config`` is the parsed JSON config dict for this specific + column assignment, or None. + """ + return None + + async def validate(self, value, config, datasette): + """ + Validate a value before it is written. Return None if valid, + or a string error message if invalid. + """ + return None + + async def transform_value(self, value, config, datasette): + """ + Transform a value before it appears in JSON API output. + Return the transformed value. Default: return unchanged. + """ + return value diff --git a/datasette/default_column_types.py b/datasette/default_column_types.py new file mode 100644 index 00000000..24e761ba --- /dev/null +++ b/datasette/default_column_types.py @@ -0,0 +1,82 @@ +import json +import re + +import markupsafe + +from datasette import hookimpl +from datasette.column_types import ColumnType + + +class UrlColumnType(ColumnType): + + async def render_cell( + self, value, column, table, database, datasette, request, config + ): + if not value or not isinstance(value, str): + return None + escaped = markupsafe.escape(value.strip()) + return markupsafe.Markup(f'{escaped}') + + async def validate(self, value, config, datasette): + if value is None or value == "": + return None + if not isinstance(value, str): + return "URL must be a string" + if not re.match(r"^https?://\S+$", value.strip()): + return "Invalid URL" + return None + + +class EmailColumnType(ColumnType): + + async def render_cell( + self, value, column, table, database, datasette, request, config + ): + if not value or not isinstance(value, str): + return None + escaped = markupsafe.escape(value.strip()) + return markupsafe.Markup(f'{escaped}') + + async def validate(self, value, config, datasette): + if value is None or value == "": + return None + if not isinstance(value, str): + return "Email must be a string" + if not re.match(r"^[^@\s]+@[^@\s]+\.[^@\s]+$", value.strip()): + return "Invalid email address" + return None + + +class JsonColumnType(ColumnType): + + async def render_cell( + self, value, column, table, database, datasette, request, config + ): + if value is None: + return None + try: + parsed = json.loads(value) if isinstance(value, str) else value + formatted = json.dumps(parsed, indent=2) + escaped = markupsafe.escape(formatted) + return markupsafe.Markup(f"
{escaped}
") + except (json.JSONDecodeError, TypeError): + return None + + async def validate(self, value, config, datasette): + if value is None or value == "": + return None + if isinstance(value, str): + try: + json.loads(value) + except json.JSONDecodeError: + return "Invalid JSON" + return None + + +@hookimpl +def register_column_types(datasette): + return [ + UrlColumnType(name="url", description="URL"), + EmailColumnType(name="email", description="Email address"), + JsonColumnType(name="json", description="JSON data"), + ] diff --git a/datasette/hookspecs.py b/datasette/hookspecs.py index 64901900..ec779659 100644 --- a/datasette/hookspecs.py +++ b/datasette/hookspecs.py @@ -55,7 +55,10 @@ def publish_subcommand(publish): @hookspec -def render_cell(row, value, column, table, pks, database, datasette, request): +def render_cell( + row, value, column, table, pks, database, datasette, request, + column_type, column_type_config +): """Customize rendering of HTML table cell values""" @@ -74,6 +77,11 @@ def register_actions(datasette): """Register actions: returns a list of datasette.permission.Action objects""" +@hookspec +def register_column_types(datasette): + """Return a list of ColumnType instances""" + + @hookspec def register_routes(datasette): """Register URL routes: return a list of (regex, view_function) pairs""" diff --git a/datasette/plugins.py b/datasette/plugins.py index 992137bd..b01b386c 100644 --- a/datasette/plugins.py +++ b/datasette/plugins.py @@ -25,6 +25,7 @@ DEFAULT_PLUGINS = ( "datasette.default_permissions", "datasette.default_permissions.tokens", "datasette.default_actions", + "datasette.default_column_types", "datasette.default_magic_parameters", "datasette.blob_renderer", "datasette.default_menu_links", diff --git a/datasette/utils/internal_db.py b/datasette/utils/internal_db.py index e4ebddde..cc5d7398 100644 --- a/datasette/utils/internal_db.py +++ b/datasette/utils/internal_db.py @@ -103,6 +103,15 @@ async def initialize_metadata_tables(db): value text, unique(database_name, resource_name, column_name, key) ); + + CREATE TABLE IF NOT EXISTS column_types ( + database_name TEXT, + resource_name TEXT, + column_name TEXT, + column_type TEXT NOT NULL, + config TEXT, + PRIMARY KEY (database_name, resource_name, column_name) + ); """)) diff --git a/datasette/views/database.py b/datasette/views/database.py index 93ad8eda..29533215 100644 --- a/datasette/views/database.py +++ b/datasette/views/database.py @@ -1205,6 +1205,8 @@ async def display_rows(datasette, database, request, rows, columns): database=database, datasette=datasette, request=request, + column_type=None, + column_type_config=None, ): candidate = await await_me_maybe(candidate) if candidate is not None: diff --git a/datasette/views/row.py b/datasette/views/row.py index 7cc46368..0702368d 100644 --- a/datasette/views/row.py +++ b/datasette/views/row.py @@ -179,26 +179,38 @@ class RowView(DataView): if "render_cell" in extras: # Call render_cell plugin hook for each cell + ct_map = await self.ds.get_column_types(database, table) rendered_rows = [] for row in rows: rendered_row = {} for value, column in zip(row, columns): - # Call render_cell plugin hook + ct_info = ct_map.get(column) + ct_name = ct_info[0] if ct_info else None + ct_config = ct_info[1] if ct_info else None plugin_display_value = None - for candidate in pm.hook.render_cell( - row=row, - value=value, - column=column, - table=table, - pks=resolved.pks, - database=database, - datasette=self.ds, - request=request, - ): - candidate = await await_me_maybe(candidate) - if candidate is not None: - plugin_display_value = candidate - break + # Try column type render_cell first + if ct_name: + ct_class = self.ds.get_column_type_class(ct_name) + if ct_class: + candidate = await ct_class.render_cell( + value=value, column=column, table=table, + database=database, datasette=self.ds, + request=request, config=ct_config, + ) + if candidate is not None: + plugin_display_value = candidate + if plugin_display_value is None: + for candidate in pm.hook.render_cell( + row=row, value=value, column=column, + table=table, pks=resolved.pks, + database=database, datasette=self.ds, + request=request, column_type=ct_name, + column_type_config=ct_config, + ): + candidate = await await_me_maybe(candidate) + if candidate is not None: + plugin_display_value = candidate + break if plugin_display_value: rendered_row[column] = str(plugin_display_value) rendered_rows.append(rendered_row) @@ -352,6 +364,14 @@ class RowUpdateView(BaseView): update = data["update"] + # Validate column types + from datasette.views.table import _validate_column_types + ct_errors = await _validate_column_types( + self.ds, resolved.db.name, resolved.table, [update] + ) + if ct_errors: + return _error(ct_errors, 400) + alter = data.get("alter") if alter and not await self.ds.allowed( action="alter-table", diff --git a/datasette/views/table.py b/datasette/views/table.py index 2ee86743..3c9b6656 100644 --- a/datasette/views/table.py +++ b/datasette/views/table.py @@ -134,6 +134,25 @@ async def _redirect_if_needed(datasette, request, resolved): ) +async def _validate_column_types(datasette, database_name, table_name, rows): + """Validate row values against assigned column types. Returns list of error strings.""" + ct_map = await datasette.get_column_types(database_name, table_name) + if not ct_map: + return [] + errors = [] + for row in rows: + for col_name, (ct_name, ct_config) in ct_map.items(): + if col_name not in row: + continue + ct_class = datasette.get_column_type_class(ct_name) + if ct_class is None: + continue + error = await ct_class.validate(row[col_name], ct_config, datasette) + if error: + errors.append(f"{col_name}: {error}") + return errors + + async def display_columns_and_rows( datasette, database_name, @@ -163,6 +182,9 @@ async def display_columns_and_rows( ) ) + # Look up column types for this table + column_types_map = await datasette.get_column_types(database_name, table_name) + column_details = { col.name: col for col in await db.table_column_details(table_name) } @@ -179,16 +201,22 @@ async def display_columns_and_rows( else: type_ = column_details[r[0]].type notnull = column_details[r[0]].notnull - columns.append( - { - "name": r[0], - "sortable": r[0] in sortable_columns, - "is_pk": r[0] in pks_for_display, - "type": type_, - "notnull": notnull, - "description": column_descriptions.get(r[0]), - } - ) + col_dict = { + "name": r[0], + "sortable": r[0] in sortable_columns, + "is_pk": r[0] in pks_for_display, + "type": type_, + "notnull": notnull, + "description": column_descriptions.get(r[0]), + } + ct_info = column_types_map.get(r[0]) + if ct_info: + col_dict["column_type"] = ct_info[0] + col_dict["column_type_config"] = ct_info[1] + else: + col_dict["column_type"] = None + col_dict["column_type_config"] = None + columns.append(col_dict) column_to_foreign_key_table = { fk["column"]: fk["other_table"] @@ -227,23 +255,42 @@ async def display_columns_and_rows( # already shown in the link column. continue - # First let the plugins have a go + # First try column type render_cell, then plugins # pylint: disable=no-member plugin_display_value = None - for candidate in pm.hook.render_cell( - row=row, - value=value, - column=column, - table=table_name, - pks=pks_for_display, - database=database_name, - datasette=datasette, - request=request, - ): - candidate = await await_me_maybe(candidate) - if candidate is not None: - plugin_display_value = candidate - break + ct_name = column_dict.get("column_type") + ct_config = column_dict.get("column_type_config") + if ct_name: + ct_class = datasette.get_column_type_class(ct_name) + if ct_class: + candidate = await ct_class.render_cell( + value=value, + column=column, + table=table_name, + database=database_name, + datasette=datasette, + request=request, + config=ct_config, + ) + if candidate is not None: + plugin_display_value = candidate + if plugin_display_value is None: + for candidate in pm.hook.render_cell( + row=row, + value=value, + column=column, + table=table_name, + pks=pks_for_display, + database=database_name, + datasette=datasette, + request=request, + column_type=ct_name, + column_type_config=ct_config, + ): + candidate = await await_me_maybe(candidate) + if candidate is not None: + plugin_display_value = candidate + break if plugin_display_value: display_value = plugin_display_value elif isinstance(value, bytes): @@ -484,6 +531,11 @@ class TableInsertView(BaseView): if errors: return _error(errors, 400) + # Validate column types + ct_errors = await _validate_column_types(self.ds, database_name, table_name, rows) + if ct_errors: + return _error(ct_errors, 400) + num_rows = len(rows) # No that we've passed pks to _validate_data it's safe to @@ -1500,27 +1552,39 @@ async def table_view_data( async def extra_render_cell(): "Rendered HTML for each cell using the render_cell plugin hook" pks_for_display = pks if pks else (["rowid"] if not is_view else []) - columns = [col[0] for col in results.description] + col_names = [col[0] for col in results.description] + ct_map = await datasette.get_column_types(database_name, table_name) rendered_rows = [] for row in rows: rendered_row = {} - for value, column in zip(row, columns): - # Call render_cell plugin hook + for value, column in zip(row, col_names): + ct_info = ct_map.get(column) + ct_name = ct_info[0] if ct_info else None + ct_config = ct_info[1] if ct_info else None plugin_display_value = None - for candidate in pm.hook.render_cell( - row=row, - value=value, - column=column, - table=table_name, - pks=pks_for_display, - database=database_name, - datasette=datasette, - request=request, - ): - candidate = await await_me_maybe(candidate) - if candidate is not None: - plugin_display_value = candidate - break + # Try column type render_cell first + if ct_name: + ct_class = datasette.get_column_type_class(ct_name) + if ct_class: + candidate = await ct_class.render_cell( + value=value, column=column, table=table_name, + database=database_name, datasette=datasette, + request=request, config=ct_config, + ) + if candidate is not None: + plugin_display_value = candidate + if plugin_display_value is None: + for candidate in pm.hook.render_cell( + row=row, value=value, column=column, + table=table_name, pks=pks_for_display, + database=database_name, datasette=datasette, + request=request, column_type=ct_name, + column_type_config=ct_config, + ): + candidate = await await_me_maybe(candidate) + if candidate is not None: + plugin_display_value = candidate + break if plugin_display_value: rendered_row[column] = str(plugin_display_value) rendered_rows.append(rendered_row) @@ -1533,6 +1597,17 @@ async def table_view_data( "params": params, } + async def extra_column_types(): + "Column type assignments for this table" + ct_map = await datasette.get_column_types(database_name, table_name) + return { + col_name: { + "type": ct_name, + "config": ct_config, + } + for col_name, (ct_name, ct_config) in ct_map.items() + } + async def extra_metadata(): "Metadata about the table and database" tablemetadata = await datasette.get_resource_metadata(database_name, table_name) @@ -1742,6 +1817,7 @@ async def table_view_data( extra_debug, extra_request, extra_query, + extra_column_types, extra_metadata, extra_extras, extra_database, diff --git a/tests/test_column_types.py b/tests/test_column_types.py new file mode 100644 index 00000000..3cbadf5e --- /dev/null +++ b/tests/test_column_types.py @@ -0,0 +1,369 @@ +from datasette.app import Datasette +from datasette.column_types import ColumnType +from datasette.utils import sqlite3 +import json +import pytest +import time + + +@pytest.fixture +def ds_ct(tmp_path_factory): + db_directory = tmp_path_factory.mktemp("dbs") + db_path = str(db_directory / "data.db") + db = sqlite3.connect(str(db_path)) + db.execute("vacuum") + db.execute( + "create table posts (id integer primary key, title text, body text, " + "author_email text, website text, metadata text)" + ) + db.execute( + "insert into posts values (1, 'Hello', '# World', 'test@example.com', " + "'https://example.com', '{\"key\": \"value\"}')" + ) + db.commit() + ds = Datasette( + [db_path], + config={ + "databases": { + "data": { + "tables": { + "posts": { + "column_types": { + "body": "markdown", + "author_email": "email", + "website": "url", + "metadata": "json", + } + } + } + } + } + }, + ) + ds.root_enabled = True + yield ds + db.close() + for database in ds.databases.values(): + if not database.is_memory: + database.close() + + +def write_token(ds, actor_id="root", permissions=None): + to_sign = {"a": actor_id, "token": "dstok", "t": int(time.time())} + if permissions: + to_sign["_r"] = {"a": permissions} + return "dstok_{}".format(ds.sign(to_sign, namespace="token")) + + +def _headers(token): + return { + "Authorization": "Bearer {}".format(token), + "Content-Type": "application/json", + } + + +# --- Internal DB and config loading --- + + +@pytest.mark.asyncio +async def test_column_types_table_created(ds_ct): + await ds_ct.invoke_startup() + internal = ds_ct.get_internal_database() + result = await internal.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name='column_types'" + ) + assert len(result.rows) == 1 + + +@pytest.mark.asyncio +async def test_config_loaded_into_internal_db(ds_ct): + await ds_ct.invoke_startup() + ct_map = await ds_ct.get_column_types("data", "posts") + assert "body" in ct_map + assert ct_map["body"] == ("markdown", None) + assert ct_map["author_email"] == ("email", None) + assert ct_map["website"] == ("url", None) + assert ct_map["metadata"] == ("json", None) + + +@pytest.mark.asyncio +async def test_config_with_type_and_config(tmp_path_factory): + db_directory = tmp_path_factory.mktemp("dbs") + db_path = str(db_directory / "data.db") + db = sqlite3.connect(str(db_path)) + db.execute("vacuum") + db.execute("create table geo (id integer primary key, location text)") + ds = Datasette( + [db_path], + config={ + "databases": { + "data": { + "tables": { + "geo": { + "column_types": { + "location": { + "type": "point", + "config": {"srid": 4326}, + } + } + } + } + } + } + }, + ) + await ds.invoke_startup() + ct, config = await ds.get_column_type("data", "geo", "location") + assert ct == "point" + assert config == {"srid": 4326} + db.close() + for database in ds.databases.values(): + if not database.is_memory: + database.close() + + +# --- Datasette API methods --- + + +@pytest.mark.asyncio +async def test_get_column_type(ds_ct): + await ds_ct.invoke_startup() + ct, config = await ds_ct.get_column_type("data", "posts", "author_email") + assert ct == "email" + assert config is None + + +@pytest.mark.asyncio +async def test_get_column_type_missing(ds_ct): + await ds_ct.invoke_startup() + ct, config = await ds_ct.get_column_type("data", "posts", "title") + assert ct is None + assert config is None + + +@pytest.mark.asyncio +async def test_set_and_remove_column_type(ds_ct): + await ds_ct.invoke_startup() + await ds_ct.set_column_type("data", "posts", "title", "markdown") + ct, config = await ds_ct.get_column_type("data", "posts", "title") + assert ct == "markdown" + assert config is None + + await ds_ct.remove_column_type("data", "posts", "title") + ct, config = await ds_ct.get_column_type("data", "posts", "title") + assert ct is None + + +@pytest.mark.asyncio +async def test_set_column_type_with_config(ds_ct): + await ds_ct.invoke_startup() + await ds_ct.set_column_type("data", "posts", "title", "file", {"accept": "image/*"}) + ct, config = await ds_ct.get_column_type("data", "posts", "title") + assert ct == "file" + assert config == {"accept": "image/*"} + + +# --- Plugin registration --- + + +@pytest.mark.asyncio +async def test_builtin_column_types_registered(ds_ct): + await ds_ct.invoke_startup() + assert ds_ct.get_column_type_class("url") is not None + assert ds_ct.get_column_type_class("email") is not None + assert ds_ct.get_column_type_class("json") is not None + assert ds_ct.get_column_type_class("nonexistent") is None + + +@pytest.mark.asyncio +async def test_column_type_class_attributes(ds_ct): + await ds_ct.invoke_startup() + url_type = ds_ct.get_column_type_class("url") + assert url_type.name == "url" + assert url_type.description == "URL" + email_type = ds_ct.get_column_type_class("email") + assert email_type.name == "email" + assert email_type.description == "Email address" + + +# --- JSON API --- + + +@pytest.mark.asyncio +async def test_column_types_extra(ds_ct): + await ds_ct.invoke_startup() + response = await ds_ct.client.get("/data/posts.json?_extra=column_types") + assert response.status_code == 200 + data = response.json() + assert "column_types" in data + assert data["column_types"]["body"] == {"type": "markdown", "config": None} + assert data["column_types"]["author_email"] == {"type": "email", "config": None} + assert data["column_types"]["website"] == {"type": "url", "config": None} + # title has no column type, should not appear + assert "title" not in data["column_types"] + + +@pytest.mark.asyncio +async def test_display_columns_include_column_type(ds_ct): + await ds_ct.invoke_startup() + response = await ds_ct.client.get("/data/posts.json?_extra=display_columns") + assert response.status_code == 200 + data = response.json() + cols = {c["name"]: c for c in data["display_columns"]} + assert cols["author_email"]["column_type"] == "email" + assert cols["author_email"]["column_type_config"] is None + assert cols["website"]["column_type"] == "url" + assert cols["title"]["column_type"] is None + + +# --- Rendering --- + + +@pytest.mark.asyncio +async def test_url_render_cell(ds_ct): + await ds_ct.invoke_startup() + response = await ds_ct.client.get("/data/posts.json?_extra=render_cell") + assert response.status_code == 200 + data = response.json() + rendered = data["render_cell"][0] + assert "href" in rendered["website"] + assert "https://example.com" in rendered["website"] + + +@pytest.mark.asyncio +async def test_email_render_cell(ds_ct): + await ds_ct.invoke_startup() + response = await ds_ct.client.get("/data/posts.json?_extra=render_cell") + assert response.status_code == 200 + data = response.json() + rendered = data["render_cell"][0] + assert "mailto:" in rendered["author_email"] + assert "test@example.com" in rendered["author_email"] + + +@pytest.mark.asyncio +async def test_json_render_cell(ds_ct): + await ds_ct.invoke_startup() + response = await ds_ct.client.get("/data/posts.json?_extra=render_cell") + assert response.status_code == 200 + data = response.json() + rendered = data["render_cell"][0] + assert "
" in rendered["metadata"]
+
+
+# --- Validation ---
+
+
+@pytest.mark.asyncio
+async def test_email_validation_on_insert(ds_ct):
+    await ds_ct.invoke_startup()
+    token = write_token(ds_ct)
+    response = await ds_ct.client.post(
+        "/data/posts/-/insert",
+        json={"row": {"title": "Test", "author_email": "not-an-email"}},
+        headers=_headers(token),
+    )
+    assert response.status_code == 400
+    assert "author_email" in response.json()["errors"][0]
+
+
+@pytest.mark.asyncio
+async def test_email_validation_passes_valid(ds_ct):
+    await ds_ct.invoke_startup()
+    token = write_token(ds_ct)
+    response = await ds_ct.client.post(
+        "/data/posts/-/insert",
+        json={"row": {"title": "Test", "author_email": "valid@example.com"}},
+        headers=_headers(token),
+    )
+    assert response.status_code == 201
+
+
+@pytest.mark.asyncio
+async def test_url_validation_on_insert(ds_ct):
+    await ds_ct.invoke_startup()
+    token = write_token(ds_ct)
+    response = await ds_ct.client.post(
+        "/data/posts/-/insert",
+        json={"row": {"title": "Test", "website": "not-a-url"}},
+        headers=_headers(token),
+    )
+    assert response.status_code == 400
+    assert "website" in response.json()["errors"][0]
+
+
+@pytest.mark.asyncio
+async def test_json_validation_on_insert(ds_ct):
+    await ds_ct.invoke_startup()
+    token = write_token(ds_ct)
+    response = await ds_ct.client.post(
+        "/data/posts/-/insert",
+        json={"row": {"title": "Test", "metadata": "not-json{"}},
+        headers=_headers(token),
+    )
+    assert response.status_code == 400
+    assert "metadata" in response.json()["errors"][0]
+
+
+@pytest.mark.asyncio
+async def test_validation_on_update(ds_ct):
+    await ds_ct.invoke_startup()
+    token = write_token(ds_ct)
+    response = await ds_ct.client.post(
+        "/data/posts/1/-/update",
+        json={"update": {"author_email": "invalid"}},
+        headers=_headers(token),
+    )
+    assert response.status_code == 400
+    assert "author_email" in response.json()["errors"][0]
+
+
+@pytest.mark.asyncio
+async def test_validation_allows_null(ds_ct):
+    await ds_ct.invoke_startup()
+    token = write_token(ds_ct)
+    response = await ds_ct.client.post(
+        "/data/posts/-/insert",
+        json={"row": {"title": "Test", "author_email": None}},
+        headers=_headers(token),
+    )
+    assert response.status_code == 201
+
+
+@pytest.mark.asyncio
+async def test_validation_allows_empty_string(ds_ct):
+    await ds_ct.invoke_startup()
+    token = write_token(ds_ct)
+    response = await ds_ct.client.post(
+        "/data/posts/-/insert",
+        json={"row": {"title": "Test", "author_email": ""}},
+        headers=_headers(token),
+    )
+    assert response.status_code == 201
+
+
+# --- ColumnType base class ---
+
+
+@pytest.mark.asyncio
+async def test_column_type_base_defaults():
+    ct = ColumnType(name="test", description="Test type")
+    assert await ct.render_cell(
+        "val", "col", "tbl", "db", None, None, None
+    ) is None
+    assert await ct.validate("val", None, None) is None
+    assert await ct.transform_value("val", None, None) == "val"
+
+
+# --- render_cell extra with column types ---
+
+
+@pytest.mark.asyncio
+async def test_render_cell_extra_with_column_types(ds_ct):
+    await ds_ct.invoke_startup()
+    response = await ds_ct.client.get("/data/posts.json?_extra=render_cell")
+    assert response.status_code == 200
+    data = response.json()
+    rendered = data["render_cell"][0]
+    assert "mailto:" in rendered["author_email"]
+    assert "href" in rendered["website"]
diff --git a/tests/test_plugins.py b/tests/test_plugins.py
index f2a47ab4..b3014275 100644
--- a/tests/test_plugins.py
+++ b/tests/test_plugins.py
@@ -1948,3 +1948,14 @@ def test_metadata_plugin_config_treated_as_config(
     assert "plugins" not in actual_metadata
     assert actual_metadata == expected_metadata
     assert ds.config == expected_config
+
+
+@pytest.mark.asyncio
+async def test_hook_register_column_types():
+    ds = Datasette()
+    await ds.invoke_startup()
+    # Built-in column types should be registered
+    assert ds.get_column_type_class("url") is not None
+    assert ds.get_column_type_class("email") is not None
+    assert ds.get_column_type_class("json") is not None
+    assert ds.get_column_type_class("nonexistent") is None

From e8472bc0cde0b2186587c7739e0722e459eb270f Mon Sep 17 00:00:00 2001
From: Claude 
Date: Tue, 17 Mar 2026 02:48:55 +0000
Subject: [PATCH 006/176] Add missing tests and transform_value integration

- Add transform_value integration in table JSON endpoint rows
- Add tests for: duplicate type name error, row endpoint rendering,
  transform_value in JSON output, column type priority over plugins,
  row detail HTML rendering, table HTML rendering, upsert validation,
  unknown type warning logging, config overwrite on restart, and
  no-config edge case
- Total: 34 column type tests, all passing

https://claude.ai/code/session_01SvPEPqHgURTWESRp28pTC3
---
 datasette/views/table.py   |  15 +-
 tests/test_column_types.py | 348 +++++++++++++++++++++++++++++++++++++
 2 files changed, 362 insertions(+), 1 deletion(-)

diff --git a/datasette/views/table.py b/datasette/views/table.py
index 3c9b6656..20d78164 100644
--- a/datasette/views/table.py
+++ b/datasette/views/table.py
@@ -1851,7 +1851,20 @@ async def table_view_data(
         }
     )
     raw_sqlite_rows = rows[:page_size]
-    data["rows"] = [dict(r) for r in raw_sqlite_rows]
+    # Apply transform_value for columns with assigned types
+    ct_map = await datasette.get_column_types(database_name, table_name)
+    transformed_rows = []
+    for r in raw_sqlite_rows:
+        row_dict = dict(r)
+        for col_name, (ct_name, ct_config) in ct_map.items():
+            if col_name in row_dict:
+                ct_class = datasette.get_column_type_class(ct_name)
+                if ct_class:
+                    row_dict[col_name] = await ct_class.transform_value(
+                        row_dict[col_name], ct_config, datasette
+                    )
+        transformed_rows.append(row_dict)
+    data["rows"] = transformed_rows
 
     if context_for_html_hack:
         data.update(extra_context_from_filters)
diff --git a/tests/test_column_types.py b/tests/test_column_types.py
index 3cbadf5e..efb8fbc7 100644
--- a/tests/test_column_types.py
+++ b/tests/test_column_types.py
@@ -1,7 +1,15 @@
+import logging
+
+import logging
+
 from datasette.app import Datasette
 from datasette.column_types import ColumnType
+from datasette.hookspecs import hookimpl
+from datasette.plugins import pm
 from datasette.utils import sqlite3
+from datasette.utils import StartupError
 import json
+import markupsafe
 import pytest
 import time
 
@@ -367,3 +375,343 @@ async def test_render_cell_extra_with_column_types(ds_ct):
     rendered = data["render_cell"][0]
     assert "mailto:" in rendered["author_email"]
     assert "href" in rendered["website"]
+
+
+# --- Duplicate column type name ---
+
+
+@pytest.mark.asyncio
+async def test_duplicate_column_type_name_raises_error():
+    class DuplicateUrlType(ColumnType):
+        async def render_cell(self, value, column, table, database, datasette, request, config):
+            return None
+
+    class _Plugin:
+        @hookimpl
+        def register_column_types(self, datasette):
+            return [DuplicateUrlType(name="url", description="Duplicate URL")]
+
+    plugin = _Plugin()
+    pm.register(plugin, name="test_duplicate_ct")
+    try:
+        ds = Datasette()
+        with pytest.raises(StartupError, match="Duplicate column type name: url"):
+            await ds.invoke_startup()
+    finally:
+        pm.unregister(plugin, name="test_duplicate_ct")
+
+
+# --- Row endpoint ---
+
+
+@pytest.mark.asyncio
+async def test_row_endpoint_render_cell_with_column_types(ds_ct):
+    await ds_ct.invoke_startup()
+    response = await ds_ct.client.get("/data/posts/1.json?_extra=render_cell")
+    assert response.status_code == 200
+    data = response.json()
+    rendered = data["render_cell"][0]
+    assert "mailto:" in rendered["author_email"]
+    assert "href" in rendered["website"]
+
+
+# --- transform_value in JSON output ---
+
+
+@pytest.mark.asyncio
+async def test_transform_value_in_json_output(tmp_path_factory):
+    """A column type with transform_value should modify rows in JSON API."""
+
+    class UpperColumnType(ColumnType):
+        async def transform_value(self, value, config, datasette):
+            if isinstance(value, str):
+                return value.upper()
+            return value
+
+    class _Plugin:
+        @hookimpl
+        def register_column_types(self, datasette):
+            return [UpperColumnType(name="upper", description="Uppercase")]
+
+    plugin = _Plugin()
+    pm.register(plugin, name="test_transform_ct")
+    try:
+        db_directory = tmp_path_factory.mktemp("dbs")
+        db_path = str(db_directory / "data.db")
+        db = sqlite3.connect(str(db_path))
+        db.execute("vacuum")
+        db.execute("create table t (id integer primary key, name text)")
+        db.execute("insert into t values (1, 'hello')")
+        db.commit()
+        ds = Datasette(
+            [db_path],
+            config={
+                "databases": {
+                    "data": {
+                        "tables": {
+                            "t": {
+                                "column_types": {"name": "upper"}
+                            }
+                        }
+                    }
+                }
+            },
+        )
+        await ds.invoke_startup()
+        response = await ds.client.get("/data/t.json")
+        assert response.status_code == 200
+        data = response.json()
+        assert data["rows"][0]["name"] == "HELLO"
+        db.close()
+        for database in ds.databases.values():
+            if not database.is_memory:
+                database.close()
+    finally:
+        pm.unregister(plugin, name="test_transform_ct")
+
+
+# --- Column type priority over plugins ---
+
+
+@pytest.mark.asyncio
+async def test_column_type_render_cell_has_priority_over_plugins(tmp_path_factory):
+    """Column type render_cell should take priority over render_cell plugin hook."""
+
+    class PriorityColumnType(ColumnType):
+        async def render_cell(self, value, column, table, database, datasette, request, config):
+            if value is not None:
+                return markupsafe.Markup(f"COLUMN_TYPE:{markupsafe.escape(value)}")
+            return None
+
+    class _ColumnTypePlugin:
+        @hookimpl
+        def register_column_types(self, datasette):
+            return [PriorityColumnType(name="priority_test", description="Priority test")]
+
+    class _RenderCellPlugin:
+        @hookimpl
+        def render_cell(self, row, value, column, table, pks, database, datasette, request,
+                        column_type, column_type_config):
+            if column == "name":
+                return markupsafe.Markup(f"PLUGIN:{markupsafe.escape(value)}")
+
+    ct_plugin = _ColumnTypePlugin()
+    rc_plugin = _RenderCellPlugin()
+    pm.register(ct_plugin, name="test_priority_ct")
+    pm.register(rc_plugin, name="test_priority_render")
+    try:
+        db_directory = tmp_path_factory.mktemp("dbs")
+        db_path = str(db_directory / "data.db")
+        db = sqlite3.connect(str(db_path))
+        db.execute("vacuum")
+        db.execute("create table t (id integer primary key, name text)")
+        db.execute("insert into t values (1, 'hello')")
+        db.commit()
+        ds = Datasette(
+            [db_path],
+            config={
+                "databases": {
+                    "data": {
+                        "tables": {
+                            "t": {
+                                "column_types": {"name": "priority_test"}
+                            }
+                        }
+                    }
+                }
+            },
+        )
+        await ds.invoke_startup()
+        response = await ds.client.get("/data/t.json?_extra=render_cell")
+        assert response.status_code == 200
+        data = response.json()
+        rendered = data["render_cell"][0]
+        # Column type should win over the plugin
+        assert "COLUMN_TYPE:" in rendered["name"]
+        assert "PLUGIN:" not in rendered["name"]
+        db.close()
+        for database in ds.databases.values():
+            if not database.is_memory:
+                database.close()
+    finally:
+        pm.unregister(ct_plugin, name="test_priority_ct")
+        pm.unregister(rc_plugin, name="test_priority_render")
+
+
+# --- Row detail page rendering ---
+
+
+@pytest.mark.asyncio
+async def test_row_detail_page_html_rendering(ds_ct):
+    """Row detail HTML page should use column type rendering."""
+    await ds_ct.invoke_startup()
+    response = await ds_ct.client.get("/data/posts/1")
+    assert response.status_code == 200
+    html = response.text
+    # The email column should be rendered with mailto: link
+    assert "mailto:test@example.com" in html
+    # The url column should be rendered with href
+    assert 'href="https://example.com"' in html
+
+
+# --- HTML table page rendering ---
+
+
+@pytest.mark.asyncio
+async def test_html_table_page_rendering(ds_ct):
+    """HTML table page should use column type rendering."""
+    await ds_ct.invoke_startup()
+    response = await ds_ct.client.get("/data/posts")
+    assert response.status_code == 200
+    html = response.text
+    assert "mailto:test@example.com" in html
+    assert 'href="https://example.com"' in html
+
+
+# --- Validation on upsert ---
+
+
+@pytest.mark.asyncio
+async def test_validation_on_upsert(ds_ct):
+    await ds_ct.invoke_startup()
+    token = write_token(ds_ct)
+    response = await ds_ct.client.post(
+        "/data/posts/-/upsert",
+        json={
+            "rows": [{"id": 1, "title": "Updated", "author_email": "invalid"}],
+        },
+        headers=_headers(token),
+    )
+    assert response.status_code == 400
+    assert "author_email" in response.json()["errors"][0]
+
+
+@pytest.mark.asyncio
+async def test_validation_on_upsert_passes_valid(ds_ct):
+    await ds_ct.invoke_startup()
+    token = write_token(ds_ct)
+    response = await ds_ct.client.post(
+        "/data/posts/-/upsert",
+        json={
+            "rows": [{"id": 1, "title": "Updated", "author_email": "valid@test.com"}],
+        },
+        headers=_headers(token),
+    )
+    assert response.status_code == 200
+
+
+# --- Unknown type warning logged ---
+
+
+@pytest.mark.asyncio
+async def test_unknown_type_warning_logged(tmp_path_factory, caplog):
+    db_directory = tmp_path_factory.mktemp("dbs")
+    db_path = str(db_directory / "data.db")
+    db = sqlite3.connect(str(db_path))
+    db.execute("vacuum")
+    db.execute("create table t (id integer primary key, col text)")
+    db.commit()
+    ds = Datasette(
+        [db_path],
+        config={
+            "databases": {
+                "data": {
+                    "tables": {
+                        "t": {
+                            "column_types": {"col": "nonexistent_type"}
+                        }
+                    }
+                }
+            }
+        },
+    )
+    with caplog.at_level(logging.WARNING):
+        await ds.invoke_startup()
+    assert "unknown type" in caplog.text.lower()
+    assert "nonexistent_type" in caplog.text
+    db.close()
+    for database in ds.databases.values():
+        if not database.is_memory:
+            database.close()
+
+
+# --- Config overwrites on restart ---
+
+
+@pytest.mark.asyncio
+async def test_config_overwrites_on_restart(tmp_path_factory):
+    """Config values should overwrite any existing column types in internal DB on startup."""
+    db_directory = tmp_path_factory.mktemp("dbs")
+    db_path = str(db_directory / "data.db")
+    db = sqlite3.connect(str(db_path))
+    db.execute("vacuum")
+    db.execute("create table t (id integer primary key, col text)")
+    db.commit()
+    ds = Datasette(
+        [db_path],
+        config={
+            "databases": {
+                "data": {
+                    "tables": {
+                        "t": {
+                            "column_types": {"col": "email"}
+                        }
+                    }
+                }
+            }
+        },
+    )
+    await ds.invoke_startup()
+    ct, _ = await ds.get_column_type("data", "t", "col")
+    assert ct == "email"
+
+    # Manually change the column type in the internal DB
+    await ds.set_column_type("data", "t", "col", "url")
+    ct, _ = await ds.get_column_type("data", "t", "col")
+    assert ct == "url"
+
+    # Re-apply config (simulating what happens on restart)
+    await ds._apply_column_types_config()
+    ct, _ = await ds.get_column_type("data", "t", "col")
+    assert ct == "email"  # Config wins
+
+    db.close()
+    for database in ds.databases.values():
+        if not database.is_memory:
+            database.close()
+
+
+# --- No column_types in config ---
+
+
+@pytest.mark.asyncio
+async def test_no_column_types_in_config(tmp_path_factory):
+    """Datasette should work fine without any column_types configuration."""
+    db_directory = tmp_path_factory.mktemp("dbs")
+    db_path = str(db_directory / "data.db")
+    db = sqlite3.connect(str(db_path))
+    db.execute("vacuum")
+    db.execute("create table t (id integer primary key, col text)")
+    db.execute("insert into t values (1, 'hello')")
+    db.commit()
+    ds = Datasette([db_path])
+    await ds.invoke_startup()
+
+    # No column types assigned
+    ct_map = await ds.get_column_types("data", "t")
+    assert ct_map == {}
+
+    # JSON endpoint should work without column_types extra
+    response = await ds.client.get("/data/t.json")
+    assert response.status_code == 200
+    assert response.json()["rows"][0]["col"] == "hello"
+
+    # column_types extra should return empty
+    response = await ds.client.get("/data/t.json?_extra=column_types")
+    assert response.status_code == 200
+    assert response.json()["column_types"] == {}
+
+    db.close()
+    for database in ds.databases.values():
+        if not database.is_memory:
+            database.close()

From de4269629bde2916d5fd0e3fedc6e4ceed9bfee5 Mon Sep 17 00:00:00 2001
From: Claude 
Date: Tue, 17 Mar 2026 03:55:51 +0000
Subject: [PATCH 007/176] Remove duplicate import logging line

https://claude.ai/code/session_01SvPEPqHgURTWESRp28pTC3
---
 tests/test_column_types.py | 2 --
 1 file changed, 2 deletions(-)

diff --git a/tests/test_column_types.py b/tests/test_column_types.py
index efb8fbc7..0c8a969d 100644
--- a/tests/test_column_types.py
+++ b/tests/test_column_types.py
@@ -1,7 +1,5 @@
 import logging
 
-import logging
-
 from datasette.app import Datasette
 from datasette.column_types import ColumnType
 from datasette.hookspecs import hookimpl

From 5db4f6953d19e0d89864852f02796ed554881427 Mon Sep 17 00:00:00 2001
From: Claude 
Date: Tue, 17 Mar 2026 03:56:20 +0000
Subject: [PATCH 008/176] Fix linting: remove unused import, apply black
 formatting

https://claude.ai/code/session_01SvPEPqHgURTWESRp28pTC3
---
 datasette/views/table.py   | 27 ++++++++++-----
 tests/test_column_types.py | 68 +++++++++++++++++---------------------
 2 files changed, 49 insertions(+), 46 deletions(-)

diff --git a/datasette/views/table.py b/datasette/views/table.py
index 20d78164..2b393087 100644
--- a/datasette/views/table.py
+++ b/datasette/views/table.py
@@ -532,7 +532,9 @@ class TableInsertView(BaseView):
             return _error(errors, 400)
 
         # Validate column types
-        ct_errors = await _validate_column_types(self.ds, database_name, table_name, rows)
+        ct_errors = await _validate_column_types(
+            self.ds, database_name, table_name, rows
+        )
         if ct_errors:
             return _error(ct_errors, 400)
 
@@ -1567,18 +1569,27 @@ async def table_view_data(
                     ct_class = datasette.get_column_type_class(ct_name)
                     if ct_class:
                         candidate = await ct_class.render_cell(
-                            value=value, column=column, table=table_name,
-                            database=database_name, datasette=datasette,
-                            request=request, config=ct_config,
+                            value=value,
+                            column=column,
+                            table=table_name,
+                            database=database_name,
+                            datasette=datasette,
+                            request=request,
+                            config=ct_config,
                         )
                         if candidate is not None:
                             plugin_display_value = candidate
                 if plugin_display_value is None:
                     for candidate in pm.hook.render_cell(
-                        row=row, value=value, column=column,
-                        table=table_name, pks=pks_for_display,
-                        database=database_name, datasette=datasette,
-                        request=request, column_type=ct_name,
+                        row=row,
+                        value=value,
+                        column=column,
+                        table=table_name,
+                        pks=pks_for_display,
+                        database=database_name,
+                        datasette=datasette,
+                        request=request,
+                        column_type=ct_name,
                         column_type_config=ct_config,
                     ):
                         candidate = await await_me_maybe(candidate)
diff --git a/tests/test_column_types.py b/tests/test_column_types.py
index 0c8a969d..7e16e6c2 100644
--- a/tests/test_column_types.py
+++ b/tests/test_column_types.py
@@ -6,7 +6,6 @@ from datasette.hookspecs import hookimpl
 from datasette.plugins import pm
 from datasette.utils import sqlite3
 from datasette.utils import StartupError
-import json
 import markupsafe
 import pytest
 import time
@@ -354,9 +353,7 @@ async def test_validation_allows_empty_string(ds_ct):
 @pytest.mark.asyncio
 async def test_column_type_base_defaults():
     ct = ColumnType(name="test", description="Test type")
-    assert await ct.render_cell(
-        "val", "col", "tbl", "db", None, None, None
-    ) is None
+    assert await ct.render_cell("val", "col", "tbl", "db", None, None, None) is None
     assert await ct.validate("val", None, None) is None
     assert await ct.transform_value("val", None, None) == "val"
 
@@ -381,7 +378,9 @@ async def test_render_cell_extra_with_column_types(ds_ct):
 @pytest.mark.asyncio
 async def test_duplicate_column_type_name_raises_error():
     class DuplicateUrlType(ColumnType):
-        async def render_cell(self, value, column, table, database, datasette, request, config):
+        async def render_cell(
+            self, value, column, table, database, datasette, request, config
+        ):
             return None
 
     class _Plugin:
@@ -445,13 +444,7 @@ async def test_transform_value_in_json_output(tmp_path_factory):
             [db_path],
             config={
                 "databases": {
-                    "data": {
-                        "tables": {
-                            "t": {
-                                "column_types": {"name": "upper"}
-                            }
-                        }
-                    }
+                    "data": {"tables": {"t": {"column_types": {"name": "upper"}}}}
                 }
             },
         )
@@ -476,20 +469,37 @@ async def test_column_type_render_cell_has_priority_over_plugins(tmp_path_factor
     """Column type render_cell should take priority over render_cell plugin hook."""
 
     class PriorityColumnType(ColumnType):
-        async def render_cell(self, value, column, table, database, datasette, request, config):
+        async def render_cell(
+            self, value, column, table, database, datasette, request, config
+        ):
             if value is not None:
-                return markupsafe.Markup(f"COLUMN_TYPE:{markupsafe.escape(value)}")
+                return markupsafe.Markup(
+                    f"COLUMN_TYPE:{markupsafe.escape(value)}"
+                )
             return None
 
     class _ColumnTypePlugin:
         @hookimpl
         def register_column_types(self, datasette):
-            return [PriorityColumnType(name="priority_test", description="Priority test")]
+            return [
+                PriorityColumnType(name="priority_test", description="Priority test")
+            ]
 
     class _RenderCellPlugin:
         @hookimpl
-        def render_cell(self, row, value, column, table, pks, database, datasette, request,
-                        column_type, column_type_config):
+        def render_cell(
+            self,
+            row,
+            value,
+            column,
+            table,
+            pks,
+            database,
+            datasette,
+            request,
+            column_type,
+            column_type_config,
+        ):
             if column == "name":
                 return markupsafe.Markup(f"PLUGIN:{markupsafe.escape(value)}")
 
@@ -510,11 +520,7 @@ async def test_column_type_render_cell_has_priority_over_plugins(tmp_path_factor
             config={
                 "databases": {
                     "data": {
-                        "tables": {
-                            "t": {
-                                "column_types": {"name": "priority_test"}
-                            }
-                        }
+                        "tables": {"t": {"column_types": {"name": "priority_test"}}}
                     }
                 }
             },
@@ -613,13 +619,7 @@ async def test_unknown_type_warning_logged(tmp_path_factory, caplog):
         [db_path],
         config={
             "databases": {
-                "data": {
-                    "tables": {
-                        "t": {
-                            "column_types": {"col": "nonexistent_type"}
-                        }
-                    }
-                }
+                "data": {"tables": {"t": {"column_types": {"col": "nonexistent_type"}}}}
             }
         },
     )
@@ -648,15 +648,7 @@ async def test_config_overwrites_on_restart(tmp_path_factory):
     ds = Datasette(
         [db_path],
         config={
-            "databases": {
-                "data": {
-                    "tables": {
-                        "t": {
-                            "column_types": {"col": "email"}
-                        }
-                    }
-                }
-            }
+            "databases": {"data": {"tables": {"t": {"column_types": {"col": "email"}}}}}
         },
     )
     await ds.invoke_startup()

From ad6a020e6d2b71607fd5b5facff834773b077711 Mon Sep 17 00:00:00 2001
From: Claude 
Date: Tue, 17 Mar 2026 03:58:18 +0000
Subject: [PATCH 009/176] Add NOT NULL constraints to column_types primary key
 columns

SQLite allows NULLs in primary key columns by default, so mark
database_name, resource_name, and column_name as NOT NULL explicitly.

https://claude.ai/code/session_01SvPEPqHgURTWESRp28pTC3
---
 datasette/utils/internal_db.py | 6 +++---
 1 file changed, 3 insertions(+), 3 deletions(-)

diff --git a/datasette/utils/internal_db.py b/datasette/utils/internal_db.py
index cc5d7398..df149928 100644
--- a/datasette/utils/internal_db.py
+++ b/datasette/utils/internal_db.py
@@ -105,9 +105,9 @@ async def initialize_metadata_tables(db):
         );
 
         CREATE TABLE IF NOT EXISTS column_types (
-            database_name TEXT,
-            resource_name TEXT,
-            column_name TEXT,
+            database_name TEXT NOT NULL,
+            resource_name TEXT NOT NULL,
+            column_name TEXT NOT NULL,
             column_type TEXT NOT NULL,
             config TEXT,
             PRIMARY KEY (database_name, resource_name, column_name)

From 32e4a319133182e5f5dfd7a787e901ebfab4077f Mon Sep 17 00:00:00 2001
From: Claude 
Date: Tue, 17 Mar 2026 04:03:52 +0000
Subject: [PATCH 010/176] Document register_column_types hook and updated
 render_cell signature

- Add register_column_types(datasette) hook documentation with example
- Update render_cell signature to include column_type and
  column_type_config parameters
- Fixes test_plugin_hooks_are_documented

https://claude.ai/code/session_01SvPEPqHgURTWESRp28pTC3
---
 docs/plugin_hooks.rst | 99 ++++++++++++++++++++++++++++++++++++++++++-
 1 file changed, 97 insertions(+), 2 deletions(-)

diff --git a/docs/plugin_hooks.rst b/docs/plugin_hooks.rst
index b9701f7c..b25403b3 100644
--- a/docs/plugin_hooks.rst
+++ b/docs/plugin_hooks.rst
@@ -474,8 +474,8 @@ Examples: `datasette-publish-fly ` assigned to this column, or ``None`` if no column type is assigned.
+
+``column_type_config`` - dict or None
+    The configuration dict for the assigned column type, or ``None``.
+
+If a column has a :ref:`column type ` assigned and that column type's ``render_cell`` method returns a non-``None`` value, it will take priority over this plugin hook.
+
 If your hook returns ``None``, it will be ignored. Use this to indicate that your hook is not able to custom render this particular value.
 
 If the hook returns a string, that string will be rendered in the table cell.
@@ -989,6 +997,93 @@ This tells Datasette "here's how to find all documents in the system - look in t
 
 The permission system then uses this query along with rules from plugins to determine which documents each user can access, all efficiently in SQL rather than loading everything into Python.
 
+.. _plugin_register_column_types:
+
+register_column_types(datasette)
+--------------------------------
+
+Return a list of :ref:`ColumnType ` instances to register custom column types. Column types define how values in specific columns are rendered, validated, and transformed.
+
+.. code-block:: python
+
+    from datasette import hookimpl
+    from datasette.column_types import ColumnType
+    import markupsafe
+
+
+    class ColorColumnType(ColumnType):
+        async def render_cell(
+            self, value, column, table, database,
+            datasette, request, config
+        ):
+            if value:
+                return markupsafe.Markup(
+                    ''
+                    "{color}"
+                ).format(color=markupsafe.escape(value))
+            return None
+
+        async def validate(self, value, config, datasette):
+            if value and not value.startswith("#"):
+                return "Color must start with #"
+            return None
+
+        async def transform_value(
+            self, value, config, datasette
+        ):
+            # Normalize to uppercase
+            if isinstance(value, str):
+                return value.upper()
+            return value
+
+
+    @hookimpl
+    def register_column_types(datasette):
+        return [
+            ColorColumnType(
+                name="color",
+                description="CSS color value",
+            )
+        ]
+
+Each ``ColumnType`` instance has the following attributes:
+
+``name`` - string
+    Unique identifier for the column type, e.g. ``"color"``. Must be unique across all plugins.
+
+``description`` - string
+    Human-readable label, e.g. ``"CSS color value"``.
+
+And the following methods, all optional:
+
+``render_cell(self, value, column, table, database, datasette, request, config)``
+    Return an HTML string to render this cell value, or ``None`` to fall through to the default ``render_cell`` plugin hook chain. When a column type provides rendering, it takes priority over the ``render_cell`` plugin hook.
+
+``validate(self, value, config, datasette)``
+    Validate a value before it is written via the insert, update, or upsert API endpoints. Return ``None`` if valid, or a string error message if invalid. Null values and empty strings skip validation.
+
+``transform_value(self, value, config, datasette)``
+    Transform a value before it appears in JSON API output. Return the transformed value. The default implementation returns the value unchanged.
+
+The ``config`` argument passed to these methods is the parsed JSON config dict for the specific column assignment, or ``None`` if no config was provided.
+
+Column types are assigned to columns via the ``column_types`` key in :ref:`table configuration `:
+
+.. code-block:: yaml
+
+    databases:
+      mydb:
+        tables:
+          mytable:
+            column_types:
+              bg_color: color
+              highlight:
+                type: color
+                config:
+                  format: rgb
+
+Datasette includes three built-in column types: ``url``, ``email``, and ``json``.
+
 .. _plugin_asgi_wrapper:
 
 asgi_wrapper(datasette)

From 77bbfb5f7edb60c2916eb148d1b63b111275e215 Mon Sep 17 00:00:00 2001
From: Claude 
Date: Tue, 17 Mar 2026 04:08:58 +0000
Subject: [PATCH 011/176] Document column type internal methods in
 internals.rst

Add documentation for get_column_type, get_column_types,
set_column_type, remove_column_type, and get_column_type_class
methods on the Datasette instance.

https://claude.ai/code/session_01SvPEPqHgURTWESRp28pTC3
---
 docs/internals.rst | 107 +++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 107 insertions(+)

diff --git a/docs/internals.rst b/docs/internals.rst
index 7d607bfe..f1064b8b 100644
--- a/docs/internals.rst
+++ b/docs/internals.rst
@@ -903,6 +903,113 @@ Adds a new metadata entry for the specified column.
 Any previous column-level metadata entry with the same ``key`` will be overwritten.
 Internally upserts the value into the  the ``metadata_columns`` table inside the :ref:`internal database `.
 
+.. _datasette_column_types:
+
+Column types
+------------
+
+Column types are stored in the ``column_types`` table in the :ref:`internal database `. The following methods provide the API for reading and modifying column type assignments.
+
+.. _datasette_get_column_type:
+
+await .get_column_type(database, resource, column)
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+``database`` - string
+    The name of the database.
+``resource`` - string
+    The name of the table or view.
+``column`` - string
+    The name of the column.
+
+Returns a ``(column_type_name, config)`` tuple for the specified column. ``column_type_name`` is a string like ``"email"`` or ``"url"``, and ``config`` is a dict or ``None``. If no column type is assigned, returns ``(None, None)``.
+
+.. code-block:: python
+
+    ct_name, config = await datasette.get_column_type(
+        "mydb", "mytable", "email_col"
+    )
+
+.. _datasette_get_column_types:
+
+await .get_column_types(database, resource)
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+``database`` - string
+    The name of the database.
+``resource`` - string
+    The name of the table or view.
+
+Returns a dictionary mapping column names to ``(column_type_name, config)`` tuples for all columns that have assigned types on the given resource.
+
+.. code-block:: python
+
+    ct_map = await datasette.get_column_types(
+        "mydb", "mytable"
+    )
+    # {"email_col": ("email", None), "site": ("url", None)}
+
+.. _datasette_set_column_type:
+
+await .set_column_type(database, resource, column, column_type, config=None)
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+``database`` - string
+    The name of the database.
+``resource`` - string
+    The name of the table or view.
+``column`` - string
+    The name of the column.
+``column_type`` - string
+    The column type name to assign, e.g. ``"email"``.
+``config`` - dict, optional
+    Optional configuration dict for the column type.
+
+Assigns a column type to a column. Overwrites any existing assignment for that column.
+
+.. code-block:: python
+
+    await datasette.set_column_type(
+        "mydb", "mytable", "location", "point",
+        config={"srid": 4326}
+    )
+
+.. _datasette_remove_column_type:
+
+await .remove_column_type(database, resource, column)
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+``database`` - string
+    The name of the database.
+``resource`` - string
+    The name of the table or view.
+``column`` - string
+    The name of the column.
+
+Removes the column type assignment for the specified column.
+
+.. code-block:: python
+
+    await datasette.remove_column_type(
+        "mydb", "mytable", "location"
+    )
+
+.. _datasette_get_column_type_class:
+
+.get_column_type_class(column_type_name)
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+``column_type_name`` - string
+    The name of the column type, e.g. ``"email"``.
+
+Returns the registered ``ColumnType`` instance for the given name, or ``None`` if no plugin has registered a column type with that name. This is a synchronous method.
+
+.. code-block:: python
+
+    ct = datasette.get_column_type_class("email")
+    if ct:
+        print(ct.description)  # "Email address"
+
 .. _datasette_add_database:
 
 .add_database(db, name=None, route=None)

From 9fe10cd1aadd0a22cef901d27dc6e95ddb66b1d7 Mon Sep 17 00:00:00 2001
From: Claude 
Date: Tue, 17 Mar 2026 04:50:58 +0000
Subject: [PATCH 012/176] Apply black and blacken-docs formatting

https://claude.ai/code/session_01SvPEPqHgURTWESRp28pTC3
---
 datasette/app.py       | 34 ++++++++++++++++++++--------------
 datasette/hookspecs.py | 12 ++++++++++--
 datasette/views/row.py | 24 +++++++++++++++++-------
 docs/internals.rst     | 11 ++++++-----
 docs/plugin_hooks.rst  | 10 ++++++++--
 5 files changed, 61 insertions(+), 30 deletions(-)

diff --git a/datasette/app.py b/datasette/app.py
index 1a20dbd0..3793cd55 100644
--- a/datasette/app.py
+++ b/datasette/app.py
@@ -699,9 +699,7 @@ class Datasette:
             if hook:
                 for ct in hook:
                     if ct.name in self._column_types:
-                        raise StartupError(
-                            f"Duplicate column type name: {ct.name}"
-                        )
+                        raise StartupError(f"Duplicate column type name: {ct.name}")
                     self._column_types[ct.name] = ct
 
         for hook in pm.hook.prepare_jinja2_environment(
@@ -977,15 +975,16 @@ class Datasette:
                         logging.warning(
                             "column_types config references unknown type %r "
                             "for %s.%s.%s",
-                            col_type, db_name, table_name, col_name,
+                            col_type,
+                            db_name,
+                            table_name,
+                            col_name,
                         )
                     await self.set_column_type(
                         db_name, table_name, col_name, col_type, config
                     )
 
-    async def get_column_type(
-        self, database: str, resource: str, column: str
-    ) -> tuple:
+    async def get_column_type(self, database: str, resource: str, column: str) -> tuple:
         """
         Return (column_type_name, config_dict) for a specific column,
         or (None, None) if no column type is assigned.
@@ -1001,9 +1000,7 @@ class Datasette:
         ct, config = rows[0]
         return (ct, json.loads(config) if config else None)
 
-    async def get_column_types(
-        self, database: str, resource: str
-    ) -> dict:
+    async def get_column_types(self, database: str, resource: str) -> dict:
         """
         Return {column_name: (column_type_name, config_dict_or_None)}
         for all columns with assigned types on the given resource.
@@ -1019,16 +1016,25 @@ class Datasette:
         }
 
     async def set_column_type(
-        self, database: str, resource: str, column: str,
-        column_type: str, config: dict = None
+        self,
+        database: str,
+        resource: str,
+        column: str,
+        column_type: str,
+        config: dict = None,
     ) -> None:
         """Assign a column type. Overwrites any existing assignment."""
         await self.get_internal_database().execute_write(
             """INSERT OR REPLACE INTO column_types
                (database_name, resource_name, column_name, column_type, config)
                VALUES (?, ?, ?, ?, ?)""",
-            [database, resource, column, column_type,
-             json.dumps(config) if config else None],
+            [
+                database,
+                resource,
+                column,
+                column_type,
+                json.dumps(config) if config else None,
+            ],
         )
 
     async def remove_column_type(
diff --git a/datasette/hookspecs.py b/datasette/hookspecs.py
index ec779659..86cd529e 100644
--- a/datasette/hookspecs.py
+++ b/datasette/hookspecs.py
@@ -56,8 +56,16 @@ def publish_subcommand(publish):
 
 @hookspec
 def render_cell(
-    row, value, column, table, pks, database, datasette, request,
-    column_type, column_type_config
+    row,
+    value,
+    column,
+    table,
+    pks,
+    database,
+    datasette,
+    request,
+    column_type,
+    column_type_config,
 ):
     """Customize rendering of HTML table cell values"""
 
diff --git a/datasette/views/row.py b/datasette/views/row.py
index 0702368d..d1713d4d 100644
--- a/datasette/views/row.py
+++ b/datasette/views/row.py
@@ -193,18 +193,27 @@ class RowView(DataView):
                         ct_class = self.ds.get_column_type_class(ct_name)
                         if ct_class:
                             candidate = await ct_class.render_cell(
-                                value=value, column=column, table=table,
-                                database=database, datasette=self.ds,
-                                request=request, config=ct_config,
+                                value=value,
+                                column=column,
+                                table=table,
+                                database=database,
+                                datasette=self.ds,
+                                request=request,
+                                config=ct_config,
                             )
                             if candidate is not None:
                                 plugin_display_value = candidate
                     if plugin_display_value is None:
                         for candidate in pm.hook.render_cell(
-                            row=row, value=value, column=column,
-                            table=table, pks=resolved.pks,
-                            database=database, datasette=self.ds,
-                            request=request, column_type=ct_name,
+                            row=row,
+                            value=value,
+                            column=column,
+                            table=table,
+                            pks=resolved.pks,
+                            database=database,
+                            datasette=self.ds,
+                            request=request,
+                            column_type=ct_name,
                             column_type_config=ct_config,
                         ):
                             candidate = await await_me_maybe(candidate)
@@ -366,6 +375,7 @@ class RowUpdateView(BaseView):
 
         # Validate column types
         from datasette.views.table import _validate_column_types
+
         ct_errors = await _validate_column_types(
             self.ds, resolved.db.name, resolved.table, [update]
         )
diff --git a/docs/internals.rst b/docs/internals.rst
index f1064b8b..e9c6454e 100644
--- a/docs/internals.rst
+++ b/docs/internals.rst
@@ -944,9 +944,7 @@ Returns a dictionary mapping column names to ``(column_type_name, config)`` tupl
 
 .. code-block:: python
 
-    ct_map = await datasette.get_column_types(
-        "mydb", "mytable"
-    )
+    ct_map = await datasette.get_column_types("mydb", "mytable")
     # {"email_col": ("email", None), "site": ("url", None)}
 
 .. _datasette_set_column_type:
@@ -970,8 +968,11 @@ Assigns a column type to a column. Overwrites any existing assignment for that c
 .. code-block:: python
 
     await datasette.set_column_type(
-        "mydb", "mytable", "location", "point",
-        config={"srid": 4326}
+        "mydb",
+        "mytable",
+        "location",
+        "point",
+        config={"srid": 4326},
     )
 
 .. _datasette_remove_column_type:
diff --git a/docs/plugin_hooks.rst b/docs/plugin_hooks.rst
index b25403b3..052c17b0 100644
--- a/docs/plugin_hooks.rst
+++ b/docs/plugin_hooks.rst
@@ -1013,8 +1013,14 @@ Return a list of :ref:`ColumnType ` instances to register custom c
 
     class ColorColumnType(ColumnType):
         async def render_cell(
-            self, value, column, table, database,
-            datasette, request, config
+            self,
+            value,
+            column,
+            table,
+            database,
+            datasette,
+            request,
+            config,
         ):
             if value:
                 return markupsafe.Markup(

From 72c8c715186b95c5ccca5a51d1328fb006fc6698 Mon Sep 17 00:00:00 2001
From: Claude 
Date: Tue, 17 Mar 2026 04:54:36 +0000
Subject: [PATCH 013/176] Move column_type defaults into dict literal

Set column_type and column_type_config to None in the initial
col_dict instead of using an else branch.

https://claude.ai/code/session_01SvPEPqHgURTWESRp28pTC3
---
 datasette/views/table.py | 5 ++---
 1 file changed, 2 insertions(+), 3 deletions(-)

diff --git a/datasette/views/table.py b/datasette/views/table.py
index 2b393087..30beb81f 100644
--- a/datasette/views/table.py
+++ b/datasette/views/table.py
@@ -208,14 +208,13 @@ async def display_columns_and_rows(
             "type": type_,
             "notnull": notnull,
             "description": column_descriptions.get(r[0]),
+            "column_type": None,
+            "column_type_config": None,
         }
         ct_info = column_types_map.get(r[0])
         if ct_info:
             col_dict["column_type"] = ct_info[0]
             col_dict["column_type_config"] = ct_info[1]
-        else:
-            col_dict["column_type"] = None
-            col_dict["column_type_config"] = None
         columns.append(col_dict)
 
     column_to_foreign_key_table = {

From 8af98c24c26b86e27c4f422f42c507f4104ede3b Mon Sep 17 00:00:00 2001
From: Claude 
Date: Tue, 17 Mar 2026 05:00:57 +0000
Subject: [PATCH 014/176] Move name and description to class attributes on
 ColumnType

Instead of passing name= and description= as constructor arguments,
define them as class attributes on each subclass. This better reflects
that they are intrinsic to the type, not configurable per-instance.

https://claude.ai/code/session_01SvPEPqHgURTWESRp28pTC3
---
 datasette/column_types.py         | 20 +++++++++-----------
 datasette/default_column_types.py | 12 +++++++++---
 docs/plugin_hooks.rst             | 12 +++++-------
 tests/test_column_types.py        | 23 +++++++++++++++++------
 4 files changed, 40 insertions(+), 27 deletions(-)

diff --git a/datasette/column_types.py b/datasette/column_types.py
index 240bcc8f..e5b54845 100644
--- a/datasette/column_types.py
+++ b/datasette/column_types.py
@@ -1,19 +1,17 @@
-from dataclasses import dataclass
-
-
-@dataclass(frozen=True, kw_only=True)
 class ColumnType:
-    name: str
     """
-    Unique identifier string. Lowercase, no spaces.
-    Examples: "markdown", "file", "email", "url", "point", "image".
+    Base class for column types.
+
+    Subclasses must define ``name`` and ``description`` as class attributes:
+
+    - ``name``: Unique identifier string. Lowercase, no spaces.
+      Examples: "markdown", "file", "email", "url", "point", "image".
+    - ``description``: Human-readable label for admin UI dropdowns.
+      Examples: "Markdown text", "File reference", "Email address".
     """
 
+    name: str
     description: str
-    """
-    Human-readable label for admin UI dropdowns.
-    Examples: "Markdown text", "File reference", "Email address".
-    """
 
     async def render_cell(
         self, value, column, table, database, datasette, request, config
diff --git a/datasette/default_column_types.py b/datasette/default_column_types.py
index 24e761ba..87d9713d 100644
--- a/datasette/default_column_types.py
+++ b/datasette/default_column_types.py
@@ -8,6 +8,8 @@ from datasette.column_types import ColumnType
 
 
 class UrlColumnType(ColumnType):
+    name = "url"
+    description = "URL"
 
     async def render_cell(
         self, value, column, table, database, datasette, request, config
@@ -28,6 +30,8 @@ class UrlColumnType(ColumnType):
 
 
 class EmailColumnType(ColumnType):
+    name = "email"
+    description = "Email address"
 
     async def render_cell(
         self, value, column, table, database, datasette, request, config
@@ -48,6 +52,8 @@ class EmailColumnType(ColumnType):
 
 
 class JsonColumnType(ColumnType):
+    name = "json"
+    description = "JSON data"
 
     async def render_cell(
         self, value, column, table, database, datasette, request, config
@@ -76,7 +82,7 @@ class JsonColumnType(ColumnType):
 @hookimpl
 def register_column_types(datasette):
     return [
-        UrlColumnType(name="url", description="URL"),
-        EmailColumnType(name="email", description="Email address"),
-        JsonColumnType(name="json", description="JSON data"),
+        UrlColumnType(),
+        EmailColumnType(),
+        JsonColumnType(),
     ]
diff --git a/docs/plugin_hooks.rst b/docs/plugin_hooks.rst
index 052c17b0..bab70edf 100644
--- a/docs/plugin_hooks.rst
+++ b/docs/plugin_hooks.rst
@@ -1012,6 +1012,9 @@ Return a list of :ref:`ColumnType ` instances to register custom c
 
 
     class ColorColumnType(ColumnType):
+        name = "color"
+        description = "CSS color value"
+
         async def render_cell(
             self,
             value,
@@ -1045,14 +1048,9 @@ Return a list of :ref:`ColumnType ` instances to register custom c
 
     @hookimpl
     def register_column_types(datasette):
-        return [
-            ColorColumnType(
-                name="color",
-                description="CSS color value",
-            )
-        ]
+        return [ColorColumnType()]
 
-Each ``ColumnType`` instance has the following attributes:
+Each ``ColumnType`` subclass must define the following class attributes:
 
 ``name`` - string
     Unique identifier for the column type, e.g. ``"color"``. Must be unique across all plugins.
diff --git a/tests/test_column_types.py b/tests/test_column_types.py
index 7e16e6c2..8315d603 100644
--- a/tests/test_column_types.py
+++ b/tests/test_column_types.py
@@ -352,7 +352,11 @@ async def test_validation_allows_empty_string(ds_ct):
 
 @pytest.mark.asyncio
 async def test_column_type_base_defaults():
-    ct = ColumnType(name="test", description="Test type")
+    class TestType(ColumnType):
+        name = "test"
+        description = "Test type"
+
+    ct = TestType()
     assert await ct.render_cell("val", "col", "tbl", "db", None, None, None) is None
     assert await ct.validate("val", None, None) is None
     assert await ct.transform_value("val", None, None) == "val"
@@ -378,6 +382,9 @@ async def test_render_cell_extra_with_column_types(ds_ct):
 @pytest.mark.asyncio
 async def test_duplicate_column_type_name_raises_error():
     class DuplicateUrlType(ColumnType):
+        name = "url"
+        description = "Duplicate URL"
+
         async def render_cell(
             self, value, column, table, database, datasette, request, config
         ):
@@ -386,7 +393,7 @@ async def test_duplicate_column_type_name_raises_error():
     class _Plugin:
         @hookimpl
         def register_column_types(self, datasette):
-            return [DuplicateUrlType(name="url", description="Duplicate URL")]
+            return [DuplicateUrlType()]
 
     plugin = _Plugin()
     pm.register(plugin, name="test_duplicate_ct")
@@ -420,6 +427,9 @@ async def test_transform_value_in_json_output(tmp_path_factory):
     """A column type with transform_value should modify rows in JSON API."""
 
     class UpperColumnType(ColumnType):
+        name = "upper"
+        description = "Uppercase"
+
         async def transform_value(self, value, config, datasette):
             if isinstance(value, str):
                 return value.upper()
@@ -428,7 +438,7 @@ async def test_transform_value_in_json_output(tmp_path_factory):
     class _Plugin:
         @hookimpl
         def register_column_types(self, datasette):
-            return [UpperColumnType(name="upper", description="Uppercase")]
+            return [UpperColumnType()]
 
     plugin = _Plugin()
     pm.register(plugin, name="test_transform_ct")
@@ -469,6 +479,9 @@ async def test_column_type_render_cell_has_priority_over_plugins(tmp_path_factor
     """Column type render_cell should take priority over render_cell plugin hook."""
 
     class PriorityColumnType(ColumnType):
+        name = "priority_test"
+        description = "Priority test"
+
         async def render_cell(
             self, value, column, table, database, datasette, request, config
         ):
@@ -481,9 +494,7 @@ async def test_column_type_render_cell_has_priority_over_plugins(tmp_path_factor
     class _ColumnTypePlugin:
         @hookimpl
         def register_column_types(self, datasette):
-            return [
-                PriorityColumnType(name="priority_test", description="Priority test")
-            ]
+            return [PriorityColumnType()]
 
     class _RenderCellPlugin:
         @hookimpl

From dd9b83301cf2cd7665c987aa170ef0e1849005e7 Mon Sep 17 00:00:00 2001
From: Claude 
Date: Tue, 17 Mar 2026 05:18:14 +0000
Subject: [PATCH 015/176] Refactor ColumnType: register classes, return
 instances with config

- register_column_types() now returns classes instead of instances
- ColumnType.__init__ takes optional config=, baking it into the instance
- get_column_type() returns a ColumnType instance (or None) instead of a
  (name, config) tuple
- get_column_types() returns {col: ColumnType instance} instead of tuples
- Remove get_column_type_class() - no longer needed
- render_cell/validate/transform_value methods no longer take config arg;
  use self.config instead
- render_cell hook takes column_type (ColumnType or None) instead of
  column_type + column_type_config

https://claude.ai/code/session_01SvPEPqHgURTWESRp28pTC3
---
 datasette/app.py                  |  45 ++++----
 datasette/column_types.py         |  20 ++--
 datasette/default_column_types.py |  24 ++--
 datasette/hookspecs.py            |   1 -
 datasette/views/database.py       |   1 -
 datasette/views/row.py            |  32 +++---
 datasette/views/table.py          |  94 +++++++---------
 docs/internals.rst                |  36 +++---
 docs/plugin_hooks.rst             |  30 ++---
 tests/test_column_types.py        | 179 +++++++++++++++++-------------
 tests/test_plugins.py             |   8 +-
 11 files changed, 227 insertions(+), 243 deletions(-)

diff --git a/datasette/app.py b/datasette/app.py
index 3793cd55..3790b340 100644
--- a/datasette/app.py
+++ b/datasette/app.py
@@ -693,14 +693,14 @@ class Datasette:
                         action_abbrs[action.abbr] = action
                     self.actions[action.name] = action
 
-        # Register column types
+        # Register column types (classes, not instances)
         self._column_types = {}
         for hook in pm.hook.register_column_types(datasette=self):
             if hook:
-                for ct in hook:
-                    if ct.name in self._column_types:
-                        raise StartupError(f"Duplicate column type name: {ct.name}")
-                    self._column_types[ct.name] = ct
+                for ct_cls in hook:
+                    if ct_cls.name in self._column_types:
+                        raise StartupError(f"Duplicate column type name: {ct_cls.name}")
+                    self._column_types[ct_cls.name] = ct_cls
 
         for hook in pm.hook.prepare_jinja2_environment(
             env=self._jinja_env, datasette=self
@@ -984,10 +984,10 @@ class Datasette:
                         db_name, table_name, col_name, col_type, config
                     )
 
-    async def get_column_type(self, database: str, resource: str, column: str) -> tuple:
+    async def get_column_type(self, database: str, resource: str, column: str):
         """
-        Return (column_type_name, config_dict) for a specific column,
-        or (None, None) if no column type is assigned.
+        Return a ColumnType instance (with config baked in) for a specific
+        column, or None if no column type is assigned.
         """
         row = await self.get_internal_database().execute(
             "SELECT column_type, config FROM column_types "
@@ -996,13 +996,16 @@ class Datasette:
         )
         rows = row.rows
         if not rows:
-            return None, None
-        ct, config = rows[0]
-        return (ct, json.loads(config) if config else None)
+            return None
+        ct_name, config = rows[0]
+        ct_cls = self._column_types.get(ct_name)
+        if ct_cls is None:
+            return None
+        return ct_cls(config=json.loads(config) if config else None)
 
     async def get_column_types(self, database: str, resource: str) -> dict:
         """
-        Return {column_name: (column_type_name, config_dict_or_None)}
+        Return {column_name: ColumnType instance (with config)}
         for all columns with assigned types on the given resource.
         """
         rows = await self.get_internal_database().execute(
@@ -1010,10 +1013,13 @@ class Datasette:
             "WHERE database_name = ? AND resource_name = ?",
             [database, resource],
         )
-        return {
-            row[0]: (row[1], json.loads(row[2]) if row[2] else None)
-            for row in rows.rows
-        }
+        result = {}
+        for row in rows.rows:
+            col_name, ct_name, config = row
+            ct_cls = self._column_types.get(ct_name)
+            if ct_cls is not None:
+                result[col_name] = ct_cls(config=json.loads(config) if config else None)
+        return result
 
     async def set_column_type(
         self,
@@ -1047,13 +1053,6 @@ class Datasette:
             [database, resource, column],
         )
 
-    def get_column_type_class(self, column_type_name: str):
-        """
-        Return the registered ColumnType instance for a given name,
-        or None if no plugin has registered that name.
-        """
-        return self._column_types.get(column_type_name)
-
     def get_internal_database(self):
         return self._internal_database
 
diff --git a/datasette/column_types.py b/datasette/column_types.py
index e5b54845..c4114294 100644
--- a/datasette/column_types.py
+++ b/datasette/column_types.py
@@ -8,31 +8,35 @@ class ColumnType:
       Examples: "markdown", "file", "email", "url", "point", "image".
     - ``description``: Human-readable label for admin UI dropdowns.
       Examples: "Markdown text", "File reference", "Email address".
+
+    Instantiate with an optional ``config`` dict to bind per-column
+    configuration::
+
+        ct = MyColumnType(config={"key": "value"})
+        ct.config  # {"key": "value"}
     """
 
     name: str
     description: str
 
-    async def render_cell(
-        self, value, column, table, database, datasette, request, config
-    ):
+    def __init__(self, config=None):
+        self.config = config
+
+    async def render_cell(self, value, column, table, database, datasette, request):
         """
         Return an HTML string to render this cell value, or None to
         fall through to the default render_cell plugin hook chain.
-
-        ``config`` is the parsed JSON config dict for this specific
-        column assignment, or None.
         """
         return None
 
-    async def validate(self, value, config, datasette):
+    async def validate(self, value, datasette):
         """
         Validate a value before it is written. Return None if valid,
         or a string error message if invalid.
         """
         return None
 
-    async def transform_value(self, value, config, datasette):
+    async def transform_value(self, value, datasette):
         """
         Transform a value before it appears in JSON API output.
         Return the transformed value. Default: return unchanged.
diff --git a/datasette/default_column_types.py b/datasette/default_column_types.py
index 87d9713d..b4ebfcc5 100644
--- a/datasette/default_column_types.py
+++ b/datasette/default_column_types.py
@@ -11,15 +11,13 @@ class UrlColumnType(ColumnType):
     name = "url"
     description = "URL"
 
-    async def render_cell(
-        self, value, column, table, database, datasette, request, config
-    ):
+    async def render_cell(self, value, column, table, database, datasette, request):
         if not value or not isinstance(value, str):
             return None
         escaped = markupsafe.escape(value.strip())
         return markupsafe.Markup(f'{escaped}')
 
-    async def validate(self, value, config, datasette):
+    async def validate(self, value, datasette):
         if value is None or value == "":
             return None
         if not isinstance(value, str):
@@ -33,15 +31,13 @@ class EmailColumnType(ColumnType):
     name = "email"
     description = "Email address"
 
-    async def render_cell(
-        self, value, column, table, database, datasette, request, config
-    ):
+    async def render_cell(self, value, column, table, database, datasette, request):
         if not value or not isinstance(value, str):
             return None
         escaped = markupsafe.escape(value.strip())
         return markupsafe.Markup(f'{escaped}')
 
-    async def validate(self, value, config, datasette):
+    async def validate(self, value, datasette):
         if value is None or value == "":
             return None
         if not isinstance(value, str):
@@ -55,9 +51,7 @@ class JsonColumnType(ColumnType):
     name = "json"
     description = "JSON data"
 
-    async def render_cell(
-        self, value, column, table, database, datasette, request, config
-    ):
+    async def render_cell(self, value, column, table, database, datasette, request):
         if value is None:
             return None
         try:
@@ -68,7 +62,7 @@ class JsonColumnType(ColumnType):
         except (json.JSONDecodeError, TypeError):
             return None
 
-    async def validate(self, value, config, datasette):
+    async def validate(self, value, datasette):
         if value is None or value == "":
             return None
         if isinstance(value, str):
@@ -81,8 +75,4 @@ class JsonColumnType(ColumnType):
 
 @hookimpl
 def register_column_types(datasette):
-    return [
-        UrlColumnType(),
-        EmailColumnType(),
-        JsonColumnType(),
-    ]
+    return [UrlColumnType, EmailColumnType, JsonColumnType]
diff --git a/datasette/hookspecs.py b/datasette/hookspecs.py
index 86cd529e..f7bb6ab6 100644
--- a/datasette/hookspecs.py
+++ b/datasette/hookspecs.py
@@ -65,7 +65,6 @@ def render_cell(
     datasette,
     request,
     column_type,
-    column_type_config,
 ):
     """Customize rendering of HTML table cell values"""
 
diff --git a/datasette/views/database.py b/datasette/views/database.py
index 29533215..916cdbc1 100644
--- a/datasette/views/database.py
+++ b/datasette/views/database.py
@@ -1206,7 +1206,6 @@ async def display_rows(datasette, database, request, rows, columns):
                 datasette=datasette,
                 request=request,
                 column_type=None,
-                column_type_config=None,
             ):
                 candidate = await await_me_maybe(candidate)
                 if candidate is not None:
diff --git a/datasette/views/row.py b/datasette/views/row.py
index d1713d4d..4eacfe49 100644
--- a/datasette/views/row.py
+++ b/datasette/views/row.py
@@ -184,25 +184,20 @@ class RowView(DataView):
             for row in rows:
                 rendered_row = {}
                 for value, column in zip(row, columns):
-                    ct_info = ct_map.get(column)
-                    ct_name = ct_info[0] if ct_info else None
-                    ct_config = ct_info[1] if ct_info else None
+                    ct = ct_map.get(column)
                     plugin_display_value = None
                     # Try column type render_cell first
-                    if ct_name:
-                        ct_class = self.ds.get_column_type_class(ct_name)
-                        if ct_class:
-                            candidate = await ct_class.render_cell(
-                                value=value,
-                                column=column,
-                                table=table,
-                                database=database,
-                                datasette=self.ds,
-                                request=request,
-                                config=ct_config,
-                            )
-                            if candidate is not None:
-                                plugin_display_value = candidate
+                    if ct:
+                        candidate = await ct.render_cell(
+                            value=value,
+                            column=column,
+                            table=table,
+                            database=database,
+                            datasette=self.ds,
+                            request=request,
+                        )
+                        if candidate is not None:
+                            plugin_display_value = candidate
                     if plugin_display_value is None:
                         for candidate in pm.hook.render_cell(
                             row=row,
@@ -213,8 +208,7 @@ class RowView(DataView):
                             database=database,
                             datasette=self.ds,
                             request=request,
-                            column_type=ct_name,
-                            column_type_config=ct_config,
+                            column_type=ct,
                         ):
                             candidate = await await_me_maybe(candidate)
                             if candidate is not None:
diff --git a/datasette/views/table.py b/datasette/views/table.py
index 30beb81f..035abb1b 100644
--- a/datasette/views/table.py
+++ b/datasette/views/table.py
@@ -141,13 +141,10 @@ async def _validate_column_types(datasette, database_name, table_name, rows):
         return []
     errors = []
     for row in rows:
-        for col_name, (ct_name, ct_config) in ct_map.items():
+        for col_name, ct in ct_map.items():
             if col_name not in row:
                 continue
-            ct_class = datasette.get_column_type_class(ct_name)
-            if ct_class is None:
-                continue
-            error = await ct_class.validate(row[col_name], ct_config, datasette)
+            error = await ct.validate(row[col_name], datasette)
             if error:
                 errors.append(f"{col_name}: {error}")
     return errors
@@ -211,10 +208,10 @@ async def display_columns_and_rows(
             "column_type": None,
             "column_type_config": None,
         }
-        ct_info = column_types_map.get(r[0])
-        if ct_info:
-            col_dict["column_type"] = ct_info[0]
-            col_dict["column_type_config"] = ct_info[1]
+        ct = column_types_map.get(r[0])
+        if ct:
+            col_dict["column_type"] = ct.name
+            col_dict["column_type_config"] = ct.config
         columns.append(col_dict)
 
     column_to_foreign_key_table = {
@@ -257,22 +254,18 @@ async def display_columns_and_rows(
             # First try column type render_cell, then plugins
             # pylint: disable=no-member
             plugin_display_value = None
-            ct_name = column_dict.get("column_type")
-            ct_config = column_dict.get("column_type_config")
-            if ct_name:
-                ct_class = datasette.get_column_type_class(ct_name)
-                if ct_class:
-                    candidate = await ct_class.render_cell(
-                        value=value,
-                        column=column,
-                        table=table_name,
-                        database=database_name,
-                        datasette=datasette,
-                        request=request,
-                        config=ct_config,
-                    )
-                    if candidate is not None:
-                        plugin_display_value = candidate
+            ct = column_types_map.get(column)
+            if ct:
+                candidate = await ct.render_cell(
+                    value=value,
+                    column=column,
+                    table=table_name,
+                    database=database_name,
+                    datasette=datasette,
+                    request=request,
+                )
+                if candidate is not None:
+                    plugin_display_value = candidate
             if plugin_display_value is None:
                 for candidate in pm.hook.render_cell(
                     row=row,
@@ -283,8 +276,7 @@ async def display_columns_and_rows(
                     database=database_name,
                     datasette=datasette,
                     request=request,
-                    column_type=ct_name,
-                    column_type_config=ct_config,
+                    column_type=ct,
                 ):
                     candidate = await await_me_maybe(candidate)
                     if candidate is not None:
@@ -1559,25 +1551,20 @@ async def table_view_data(
         for row in rows:
             rendered_row = {}
             for value, column in zip(row, col_names):
-                ct_info = ct_map.get(column)
-                ct_name = ct_info[0] if ct_info else None
-                ct_config = ct_info[1] if ct_info else None
+                ct = ct_map.get(column)
                 plugin_display_value = None
                 # Try column type render_cell first
-                if ct_name:
-                    ct_class = datasette.get_column_type_class(ct_name)
-                    if ct_class:
-                        candidate = await ct_class.render_cell(
-                            value=value,
-                            column=column,
-                            table=table_name,
-                            database=database_name,
-                            datasette=datasette,
-                            request=request,
-                            config=ct_config,
-                        )
-                        if candidate is not None:
-                            plugin_display_value = candidate
+                if ct:
+                    candidate = await ct.render_cell(
+                        value=value,
+                        column=column,
+                        table=table_name,
+                        database=database_name,
+                        datasette=datasette,
+                        request=request,
+                    )
+                    if candidate is not None:
+                        plugin_display_value = candidate
                 if plugin_display_value is None:
                     for candidate in pm.hook.render_cell(
                         row=row,
@@ -1588,8 +1575,7 @@ async def table_view_data(
                         database=database_name,
                         datasette=datasette,
                         request=request,
-                        column_type=ct_name,
-                        column_type_config=ct_config,
+                        column_type=ct,
                     ):
                         candidate = await await_me_maybe(candidate)
                         if candidate is not None:
@@ -1612,10 +1598,10 @@ async def table_view_data(
         ct_map = await datasette.get_column_types(database_name, table_name)
         return {
             col_name: {
-                "type": ct_name,
-                "config": ct_config,
+                "type": ct.name,
+                "config": ct.config,
             }
-            for col_name, (ct_name, ct_config) in ct_map.items()
+            for col_name, ct in ct_map.items()
         }
 
     async def extra_metadata():
@@ -1866,13 +1852,11 @@ async def table_view_data(
     transformed_rows = []
     for r in raw_sqlite_rows:
         row_dict = dict(r)
-        for col_name, (ct_name, ct_config) in ct_map.items():
+        for col_name, ct in ct_map.items():
             if col_name in row_dict:
-                ct_class = datasette.get_column_type_class(ct_name)
-                if ct_class:
-                    row_dict[col_name] = await ct_class.transform_value(
-                        row_dict[col_name], ct_config, datasette
-                    )
+                row_dict[col_name] = await ct.transform_value(
+                    row_dict[col_name], datasette
+                )
         transformed_rows.append(row_dict)
     data["rows"] = transformed_rows
 
diff --git a/docs/internals.rst b/docs/internals.rst
index e9c6454e..5adb4cac 100644
--- a/docs/internals.rst
+++ b/docs/internals.rst
@@ -922,13 +922,16 @@ await .get_column_type(database, resource, column)
 ``column`` - string
     The name of the column.
 
-Returns a ``(column_type_name, config)`` tuple for the specified column. ``column_type_name`` is a string like ``"email"`` or ``"url"``, and ``config`` is a dict or ``None``. If no column type is assigned, returns ``(None, None)``.
+Returns a :ref:`ColumnType ` instance with ``.config`` populated for the specified column, or ``None`` if no column type is assigned.
 
 .. code-block:: python
 
-    ct_name, config = await datasette.get_column_type(
+    ct = await datasette.get_column_type(
         "mydb", "mytable", "email_col"
     )
+    if ct:
+        print(ct.name)  # "email"
+        print(ct.config)  # None or {...}
 
 .. _datasette_get_column_types:
 
@@ -940,12 +943,13 @@ await .get_column_types(database, resource)
 ``resource`` - string
     The name of the table or view.
 
-Returns a dictionary mapping column names to ``(column_type_name, config)`` tuples for all columns that have assigned types on the given resource.
+Returns a dictionary mapping column names to :ref:`ColumnType ` instances (with ``.config`` populated) for all columns that have assigned types on the given resource.
 
 .. code-block:: python
 
     ct_map = await datasette.get_column_types("mydb", "mytable")
-    # {"email_col": ("email", None), "site": ("url", None)}
+    for col_name, ct in ct_map.items():
+        print(col_name, ct.name, ct.config)
 
 .. _datasette_set_column_type:
 
@@ -995,22 +999,6 @@ Removes the column type assignment for the specified column.
         "mydb", "mytable", "location"
     )
 
-.. _datasette_get_column_type_class:
-
-.get_column_type_class(column_type_name)
-~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
-
-``column_type_name`` - string
-    The name of the column type, e.g. ``"email"``.
-
-Returns the registered ``ColumnType`` instance for the given name, or ``None`` if no plugin has registered a column type with that name. This is a synchronous method.
-
-.. code-block:: python
-
-    ct = datasette.get_column_type_class("email")
-    if ct:
-        print(ct.description)  # "Email address"
-
 .. _datasette_add_database:
 
 .add_database(db, name=None, route=None)
@@ -2049,6 +2037,14 @@ The internal database schema is as follows:
         value text,
         unique(database_name, resource_name, column_name, key)
     );
+    CREATE TABLE column_types (
+        database_name TEXT NOT NULL,
+        resource_name TEXT NOT NULL,
+        column_name TEXT NOT NULL,
+        column_type TEXT NOT NULL,
+        config TEXT,
+        PRIMARY KEY (database_name, resource_name, column_name)
+    );
 
 .. [[[end]]]
 
diff --git a/docs/plugin_hooks.rst b/docs/plugin_hooks.rst
index bab70edf..916f3449 100644
--- a/docs/plugin_hooks.rst
+++ b/docs/plugin_hooks.rst
@@ -474,8 +474,8 @@ Examples: `datasette-publish-fly ` assigned to this column, or ``None`` if no column type is assigned.
-
-``column_type_config`` - dict or None
-    The configuration dict for the assigned column type, or ``None``.
+``column_type`` - :ref:`ColumnType ` instance or None
+    The :ref:`ColumnType ` instance assigned to this column (with ``.config`` populated), or ``None`` if no column type is assigned. You can access ``column_type.name``, ``column_type.config``, etc.
 
 If a column has a :ref:`column type ` assigned and that column type's ``render_cell`` method returns a non-``None`` value, it will take priority over this plugin hook.
 
@@ -1002,7 +999,7 @@ The permission system then uses this query along with rules from plugins to dete
 register_column_types(datasette)
 --------------------------------
 
-Return a list of :ref:`ColumnType ` instances to register custom column types. Column types define how values in specific columns are rendered, validated, and transformed.
+Return a list of :ref:`ColumnType ` **classes** (not instances) to register custom column types. Column types define how values in specific columns are rendered, validated, and transformed.
 
 .. code-block:: python
 
@@ -1023,7 +1020,6 @@ Return a list of :ref:`ColumnType ` instances to register custom c
             database,
             datasette,
             request,
-            config,
         ):
             if value:
                 return markupsafe.Markup(
@@ -1032,14 +1028,12 @@ Return a list of :ref:`ColumnType ` instances to register custom c
                 ).format(color=markupsafe.escape(value))
             return None
 
-        async def validate(self, value, config, datasette):
+        async def validate(self, value, datasette):
             if value and not value.startswith("#"):
                 return "Color must start with #"
             return None
 
-        async def transform_value(
-            self, value, config, datasette
-        ):
+        async def transform_value(self, value, datasette):
             # Normalize to uppercase
             if isinstance(value, str):
                 return value.upper()
@@ -1048,7 +1042,7 @@ Return a list of :ref:`ColumnType ` instances to register custom c
 
     @hookimpl
     def register_column_types(datasette):
-        return [ColorColumnType()]
+        return [ColorColumnType]
 
 Each ``ColumnType`` subclass must define the following class attributes:
 
@@ -1060,16 +1054,16 @@ Each ``ColumnType`` subclass must define the following class attributes:
 
 And the following methods, all optional:
 
-``render_cell(self, value, column, table, database, datasette, request, config)``
+``render_cell(self, value, column, table, database, datasette, request)``
     Return an HTML string to render this cell value, or ``None`` to fall through to the default ``render_cell`` plugin hook chain. When a column type provides rendering, it takes priority over the ``render_cell`` plugin hook.
 
-``validate(self, value, config, datasette)``
+``validate(self, value, datasette)``
     Validate a value before it is written via the insert, update, or upsert API endpoints. Return ``None`` if valid, or a string error message if invalid. Null values and empty strings skip validation.
 
-``transform_value(self, value, config, datasette)``
+``transform_value(self, value, datasette)``
     Transform a value before it appears in JSON API output. Return the transformed value. The default implementation returns the value unchanged.
 
-The ``config`` argument passed to these methods is the parsed JSON config dict for the specific column assignment, or ``None`` if no config was provided.
+Per-column configuration is available via ``self.config`` in all methods. When a column type is looked up for a specific column (via :ref:`get_column_type ` or :ref:`get_column_types `), the returned instance has ``config`` set to the parsed JSON config dict for that column assignment, or ``None`` if no config was provided.
 
 Column types are assigned to columns via the ``column_types`` key in :ref:`table configuration `:
 
diff --git a/tests/test_column_types.py b/tests/test_column_types.py
index 8315d603..0100a079 100644
--- a/tests/test_column_types.py
+++ b/tests/test_column_types.py
@@ -84,47 +84,62 @@ async def test_column_types_table_created(ds_ct):
 async def test_config_loaded_into_internal_db(ds_ct):
     await ds_ct.invoke_startup()
     ct_map = await ds_ct.get_column_types("data", "posts")
-    assert "body" in ct_map
-    assert ct_map["body"] == ("markdown", None)
-    assert ct_map["author_email"] == ("email", None)
-    assert ct_map["website"] == ("url", None)
-    assert ct_map["metadata"] == ("json", None)
+    # "markdown" is not a registered type, so it won't appear
+    assert "body" not in ct_map
+    assert ct_map["author_email"].name == "email"
+    assert ct_map["author_email"].config is None
+    assert ct_map["website"].name == "url"
+    assert ct_map["metadata"].name == "json"
 
 
 @pytest.mark.asyncio
 async def test_config_with_type_and_config(tmp_path_factory):
-    db_directory = tmp_path_factory.mktemp("dbs")
-    db_path = str(db_directory / "data.db")
-    db = sqlite3.connect(str(db_path))
-    db.execute("vacuum")
-    db.execute("create table geo (id integer primary key, location text)")
-    ds = Datasette(
-        [db_path],
-        config={
-            "databases": {
-                "data": {
-                    "tables": {
-                        "geo": {
-                            "column_types": {
-                                "location": {
-                                    "type": "point",
-                                    "config": {"srid": 4326},
+    class PointColumnType(ColumnType):
+        name = "point"
+        description = "Geographic point"
+
+    class _Plugin:
+        @hookimpl
+        def register_column_types(self, datasette):
+            return [PointColumnType]
+
+    plugin = _Plugin()
+    pm.register(plugin, name="test_point_ct")
+    try:
+        db_directory = tmp_path_factory.mktemp("dbs")
+        db_path = str(db_directory / "data.db")
+        db = sqlite3.connect(str(db_path))
+        db.execute("vacuum")
+        db.execute("create table geo (id integer primary key, location text)")
+        ds = Datasette(
+            [db_path],
+            config={
+                "databases": {
+                    "data": {
+                        "tables": {
+                            "geo": {
+                                "column_types": {
+                                    "location": {
+                                        "type": "point",
+                                        "config": {"srid": 4326},
+                                    }
                                 }
                             }
                         }
                     }
                 }
-            }
-        },
-    )
-    await ds.invoke_startup()
-    ct, config = await ds.get_column_type("data", "geo", "location")
-    assert ct == "point"
-    assert config == {"srid": 4326}
-    db.close()
-    for database in ds.databases.values():
-        if not database.is_memory:
-            database.close()
+            },
+        )
+        await ds.invoke_startup()
+        ct = await ds.get_column_type("data", "geo", "location")
+        assert ct.name == "point"
+        assert ct.config == {"srid": 4326}
+        db.close()
+        for database in ds.databases.values():
+            if not database.is_memory:
+                database.close()
+    finally:
+        pm.unregister(plugin, name="test_point_ct")
 
 
 # --- Datasette API methods ---
@@ -133,39 +148,39 @@ async def test_config_with_type_and_config(tmp_path_factory):
 @pytest.mark.asyncio
 async def test_get_column_type(ds_ct):
     await ds_ct.invoke_startup()
-    ct, config = await ds_ct.get_column_type("data", "posts", "author_email")
-    assert ct == "email"
-    assert config is None
+    ct = await ds_ct.get_column_type("data", "posts", "author_email")
+    assert isinstance(ct, ColumnType)
+    assert ct.name == "email"
+    assert ct.config is None
 
 
 @pytest.mark.asyncio
 async def test_get_column_type_missing(ds_ct):
     await ds_ct.invoke_startup()
-    ct, config = await ds_ct.get_column_type("data", "posts", "title")
+    ct = await ds_ct.get_column_type("data", "posts", "title")
     assert ct is None
-    assert config is None
 
 
 @pytest.mark.asyncio
 async def test_set_and_remove_column_type(ds_ct):
     await ds_ct.invoke_startup()
-    await ds_ct.set_column_type("data", "posts", "title", "markdown")
-    ct, config = await ds_ct.get_column_type("data", "posts", "title")
-    assert ct == "markdown"
-    assert config is None
+    await ds_ct.set_column_type("data", "posts", "title", "email")
+    ct = await ds_ct.get_column_type("data", "posts", "title")
+    assert ct.name == "email"
+    assert ct.config is None
 
     await ds_ct.remove_column_type("data", "posts", "title")
-    ct, config = await ds_ct.get_column_type("data", "posts", "title")
+    ct = await ds_ct.get_column_type("data", "posts", "title")
     assert ct is None
 
 
 @pytest.mark.asyncio
 async def test_set_column_type_with_config(ds_ct):
     await ds_ct.invoke_startup()
-    await ds_ct.set_column_type("data", "posts", "title", "file", {"accept": "image/*"})
-    ct, config = await ds_ct.get_column_type("data", "posts", "title")
-    assert ct == "file"
-    assert config == {"accept": "image/*"}
+    await ds_ct.set_column_type("data", "posts", "title", "url", {"max_length": 200})
+    ct = await ds_ct.get_column_type("data", "posts", "title")
+    assert ct.name == "url"
+    assert ct.config == {"max_length": 200}
 
 
 # --- Plugin registration ---
@@ -173,22 +188,23 @@ async def test_set_column_type_with_config(ds_ct):
 
 @pytest.mark.asyncio
 async def test_builtin_column_types_registered(ds_ct):
+    """register_column_types returns classes; _column_types stores them by name."""
     await ds_ct.invoke_startup()
-    assert ds_ct.get_column_type_class("url") is not None
-    assert ds_ct.get_column_type_class("email") is not None
-    assert ds_ct.get_column_type_class("json") is not None
-    assert ds_ct.get_column_type_class("nonexistent") is None
+    assert "url" in ds_ct._column_types
+    assert "email" in ds_ct._column_types
+    assert "json" in ds_ct._column_types
+    assert "nonexistent" not in ds_ct._column_types
 
 
 @pytest.mark.asyncio
 async def test_column_type_class_attributes(ds_ct):
     await ds_ct.invoke_startup()
-    url_type = ds_ct.get_column_type_class("url")
-    assert url_type.name == "url"
-    assert url_type.description == "URL"
-    email_type = ds_ct.get_column_type_class("email")
-    assert email_type.name == "email"
-    assert email_type.description == "Email address"
+    url_cls = ds_ct._column_types["url"]
+    assert url_cls.name == "url"
+    assert url_cls.description == "URL"
+    email_cls = ds_ct._column_types["email"]
+    assert email_cls.name == "email"
+    assert email_cls.description == "Email address"
 
 
 # --- JSON API ---
@@ -201,9 +217,11 @@ async def test_column_types_extra(ds_ct):
     assert response.status_code == 200
     data = response.json()
     assert "column_types" in data
-    assert data["column_types"]["body"] == {"type": "markdown", "config": None}
     assert data["column_types"]["author_email"] == {"type": "email", "config": None}
     assert data["column_types"]["website"] == {"type": "url", "config": None}
+    assert data["column_types"]["metadata"] == {"type": "json", "config": None}
+    # "markdown" is not a registered type, so body should not appear
+    assert "body" not in data["column_types"]
     # title has no column type, should not appear
     assert "title" not in data["column_types"]
 
@@ -357,9 +375,21 @@ async def test_column_type_base_defaults():
         description = "Test type"
 
     ct = TestType()
-    assert await ct.render_cell("val", "col", "tbl", "db", None, None, None) is None
-    assert await ct.validate("val", None, None) is None
-    assert await ct.transform_value("val", None, None) == "val"
+    assert ct.config is None
+    assert await ct.render_cell("val", "col", "tbl", "db", None, None) is None
+    assert await ct.validate("val", None) is None
+    assert await ct.transform_value("val", None) == "val"
+
+
+@pytest.mark.asyncio
+async def test_column_type_with_config():
+    class TestType(ColumnType):
+        name = "test"
+        description = "Test type"
+
+    ct = TestType(config={"key": "value"})
+    assert ct.config == {"key": "value"}
+    assert ct.name == "test"
 
 
 # --- render_cell extra with column types ---
@@ -385,15 +415,13 @@ async def test_duplicate_column_type_name_raises_error():
         name = "url"
         description = "Duplicate URL"
 
-        async def render_cell(
-            self, value, column, table, database, datasette, request, config
-        ):
+        async def render_cell(self, value, column, table, database, datasette, request):
             return None
 
     class _Plugin:
         @hookimpl
         def register_column_types(self, datasette):
-            return [DuplicateUrlType()]
+            return [DuplicateUrlType]
 
     plugin = _Plugin()
     pm.register(plugin, name="test_duplicate_ct")
@@ -430,7 +458,7 @@ async def test_transform_value_in_json_output(tmp_path_factory):
         name = "upper"
         description = "Uppercase"
 
-        async def transform_value(self, value, config, datasette):
+        async def transform_value(self, value, datasette):
             if isinstance(value, str):
                 return value.upper()
             return value
@@ -438,7 +466,7 @@ async def test_transform_value_in_json_output(tmp_path_factory):
     class _Plugin:
         @hookimpl
         def register_column_types(self, datasette):
-            return [UpperColumnType()]
+            return [UpperColumnType]
 
     plugin = _Plugin()
     pm.register(plugin, name="test_transform_ct")
@@ -482,9 +510,7 @@ async def test_column_type_render_cell_has_priority_over_plugins(tmp_path_factor
         name = "priority_test"
         description = "Priority test"
 
-        async def render_cell(
-            self, value, column, table, database, datasette, request, config
-        ):
+        async def render_cell(self, value, column, table, database, datasette, request):
             if value is not None:
                 return markupsafe.Markup(
                     f"COLUMN_TYPE:{markupsafe.escape(value)}"
@@ -494,7 +520,7 @@ async def test_column_type_render_cell_has_priority_over_plugins(tmp_path_factor
     class _ColumnTypePlugin:
         @hookimpl
         def register_column_types(self, datasette):
-            return [PriorityColumnType()]
+            return [PriorityColumnType]
 
     class _RenderCellPlugin:
         @hookimpl
@@ -509,7 +535,6 @@ async def test_column_type_render_cell_has_priority_over_plugins(tmp_path_factor
             datasette,
             request,
             column_type,
-            column_type_config,
         ):
             if column == "name":
                 return markupsafe.Markup(f"PLUGIN:{markupsafe.escape(value)}")
@@ -663,18 +688,18 @@ async def test_config_overwrites_on_restart(tmp_path_factory):
         },
     )
     await ds.invoke_startup()
-    ct, _ = await ds.get_column_type("data", "t", "col")
-    assert ct == "email"
+    ct = await ds.get_column_type("data", "t", "col")
+    assert ct.name == "email"
 
     # Manually change the column type in the internal DB
     await ds.set_column_type("data", "t", "col", "url")
-    ct, _ = await ds.get_column_type("data", "t", "col")
-    assert ct == "url"
+    ct = await ds.get_column_type("data", "t", "col")
+    assert ct.name == "url"
 
     # Re-apply config (simulating what happens on restart)
     await ds._apply_column_types_config()
-    ct, _ = await ds.get_column_type("data", "t", "col")
-    assert ct == "email"  # Config wins
+    ct = await ds.get_column_type("data", "t", "col")
+    assert ct.name == "email"  # Config wins
 
     db.close()
     for database in ds.databases.values():
diff --git a/tests/test_plugins.py b/tests/test_plugins.py
index b3014275..47d727f2 100644
--- a/tests/test_plugins.py
+++ b/tests/test_plugins.py
@@ -1955,7 +1955,7 @@ async def test_hook_register_column_types():
     ds = Datasette()
     await ds.invoke_startup()
     # Built-in column types should be registered
-    assert ds.get_column_type_class("url") is not None
-    assert ds.get_column_type_class("email") is not None
-    assert ds.get_column_type_class("json") is not None
-    assert ds.get_column_type_class("nonexistent") is None
+    assert "url" in ds._column_types
+    assert "email" in ds._column_types
+    assert "json" in ds._column_types
+    assert "nonexistent" not in ds._column_types

From da0ea4382ce6bd822431506ca93ccbaba5fd566a Mon Sep 17 00:00:00 2001
From: Claude 
Date: Tue, 17 Mar 2026 05:18:42 +0000
Subject: [PATCH 016/176] Update cog-generated plugin list to include
 default_column_types

https://claude.ai/code/session_01SvPEPqHgURTWESRp28pTC3
---
 docs/plugins.rst | 9 +++++++++
 1 file changed, 9 insertions(+)

diff --git a/docs/plugins.rst b/docs/plugins.rst
index 60bdc111..03cbedeb 100644
--- a/docs/plugins.rst
+++ b/docs/plugins.rst
@@ -207,6 +207,15 @@ If you run ``datasette plugins --all`` it will include default plugins that ship
                 "register_actions"
             ]
         },
+        {
+            "name": "datasette.default_column_types",
+            "static": false,
+            "templates": false,
+            "version": null,
+            "hooks": [
+                "register_column_types"
+            ]
+        },
         {
             "name": "datasette.default_magic_parameters",
             "static": false,

From c73a1c907a69783a2eeb99a7f41037979694256b Mon Sep 17 00:00:00 2001
From: Claude 
Date: Tue, 17 Mar 2026 05:22:53 +0000
Subject: [PATCH 017/176] Use 'subclass' instead of 'class' in ColumnType docs

https://claude.ai/code/session_01SvPEPqHgURTWESRp28pTC3
---
 docs/internals.rst    | 4 ++--
 docs/plugin_hooks.rst | 6 +++---
 2 files changed, 5 insertions(+), 5 deletions(-)

diff --git a/docs/internals.rst b/docs/internals.rst
index 5adb4cac..6bd3d41d 100644
--- a/docs/internals.rst
+++ b/docs/internals.rst
@@ -922,7 +922,7 @@ await .get_column_type(database, resource, column)
 ``column`` - string
     The name of the column.
 
-Returns a :ref:`ColumnType ` instance with ``.config`` populated for the specified column, or ``None`` if no column type is assigned.
+Returns a :ref:`ColumnType ` subclass instance with ``.config`` populated for the specified column, or ``None`` if no column type is assigned.
 
 .. code-block:: python
 
@@ -943,7 +943,7 @@ await .get_column_types(database, resource)
 ``resource`` - string
     The name of the table or view.
 
-Returns a dictionary mapping column names to :ref:`ColumnType ` instances (with ``.config`` populated) for all columns that have assigned types on the given resource.
+Returns a dictionary mapping column names to :ref:`ColumnType ` subclass instances (with ``.config`` populated) for all columns that have assigned types on the given resource.
 
 .. code-block:: python
 
diff --git a/docs/plugin_hooks.rst b/docs/plugin_hooks.rst
index 916f3449..69710bb6 100644
--- a/docs/plugin_hooks.rst
+++ b/docs/plugin_hooks.rst
@@ -503,8 +503,8 @@ Lets you customize the display of values within table cells in the HTML table vi
 ``request`` - :ref:`internals_request`
     The current request object
 
-``column_type`` - :ref:`ColumnType ` instance or None
-    The :ref:`ColumnType ` instance assigned to this column (with ``.config`` populated), or ``None`` if no column type is assigned. You can access ``column_type.name``, ``column_type.config``, etc.
+``column_type`` - :ref:`ColumnType ` subclass instance or None
+    The :ref:`ColumnType ` subclass instance assigned to this column (with ``.config`` populated), or ``None`` if no column type is assigned. You can access ``column_type.name``, ``column_type.config``, etc.
 
 If a column has a :ref:`column type ` assigned and that column type's ``render_cell`` method returns a non-``None`` value, it will take priority over this plugin hook.
 
@@ -999,7 +999,7 @@ The permission system then uses this query along with rules from plugins to dete
 register_column_types(datasette)
 --------------------------------
 
-Return a list of :ref:`ColumnType ` **classes** (not instances) to register custom column types. Column types define how values in specific columns are rendered, validated, and transformed.
+Return a list of :ref:`ColumnType ` **subclasses** (not instances) to register custom column types. Column types define how values in specific columns are rendered, validated, and transformed.
 
 .. code-block:: python
 

From b7578a48849effa6b5d15f17330ef7b29fc5cad9 Mon Sep 17 00:00:00 2001
From: Claude 
Date: Tue, 17 Mar 2026 05:24:09 +0000
Subject: [PATCH 018/176] Remove pointless test_column_type_with_config test

https://claude.ai/code/session_01SvPEPqHgURTWESRp28pTC3
---
 tests/test_column_types.py | 11 -----------
 1 file changed, 11 deletions(-)

diff --git a/tests/test_column_types.py b/tests/test_column_types.py
index 0100a079..2929c1f3 100644
--- a/tests/test_column_types.py
+++ b/tests/test_column_types.py
@@ -381,17 +381,6 @@ async def test_column_type_base_defaults():
     assert await ct.transform_value("val", None) == "val"
 
 
-@pytest.mark.asyncio
-async def test_column_type_with_config():
-    class TestType(ColumnType):
-        name = "test"
-        description = "Test type"
-
-    ct = TestType(config={"key": "value"})
-    assert ct.config == {"key": "value"}
-    assert ct.name == "test"
-
-
 # --- render_cell extra with column types ---
 
 

From 63d73a806fe0104b43d7debbe84026542173c432 Mon Sep 17 00:00:00 2001
From: Simon Willison 
Date: Tue, 17 Mar 2026 08:47:04 -0700
Subject: [PATCH 019/176] Move table configuration docs from metadata.rst to
 configuration.rst (#2668)

https://claude.ai/code/session_01UqboRB5Wt52BKPhxexUBEn
---
 docs/changelog.rst        |  10 +-
 docs/configuration.rst    | 575 ++++++++++++++++++++++++++++++++++++++
 docs/facets.rst           |  30 +-
 docs/full_text_search.rst |  10 +-
 docs/internals.rst        |   4 +-
 docs/json_api.rst         |   2 +-
 docs/metadata.rst         | 381 +------------------------
 docs/pages.rst            |   2 +-
 8 files changed, 613 insertions(+), 401 deletions(-)

diff --git a/docs/changelog.rst b/docs/changelog.rst
index 2c9b7170..71d63e33 100644
--- a/docs/changelog.rst
+++ b/docs/changelog.rst
@@ -934,7 +934,7 @@ Other small fixes
 0.59 (2021-10-14)
 -----------------
 
-- Columns can now have associated metadata descriptions in ``metadata.json``, see :ref:`metadata_column_descriptions`. (:issue:`942`)
+- Columns can now have associated metadata descriptions in ``metadata.json``, see :ref:`table_configuration_columns`. (:issue:`942`)
 - New :ref:`register_commands() ` plugin hook allows plugins to register additional Datasette CLI commands, e.g. ``datasette mycommand file.db``. (:issue:`1449`)
 - Adding ``?_facet_size=max`` to a table page now shows the number of unique values in each facet. (:issue:`1423`)
 - Upgraded dependency `httpx 0.20 `__ - the undocumented ``allow_redirects=`` parameter to :ref:`internals_datasette_client` is now ``follow_redirects=``, and defaults to ``False`` where it previously defaulted to ``True``. (:issue:`1488`)
@@ -1624,7 +1624,7 @@ The main focus of this release is a major upgrade to the :ref:`plugin_register_o
 * Redesign of :ref:`plugin_register_output_renderer` to provide more context to the render callback and support an optional ``"can_render"`` callback that controls if a suggested link to the output format is provided. (:issue:`581`, :issue:`770`)
 * Visually distinguish float and integer columns - useful for figuring out why order-by-column might be returning unexpected results. (:issue:`729`)
 * The :ref:`internals_request`, which is passed to several plugin hooks, is now documented. (:issue:`706`)
-* New ``metadata.json`` option for setting a custom default page size for specific tables and views, see :ref:`metadata_page_size`. (:issue:`751`)
+* New ``metadata.json`` option for setting a custom default page size for specific tables and views, see :ref:`table_configuration_size`. (:issue:`751`)
 * Canned queries can now be configured with a default URL fragment hash, useful when working with plugins such as `datasette-vega `__, see :ref:`canned_queries_options`. (:issue:`706`)
 * Fixed a bug in ``datasette publish`` when running on operating systems where the ``/tmp`` directory lives in a different volume, using a backport of the Python 3.8 ``shutil.copytree()`` function. (:issue:`744`)
 * Every plugin hook is now covered by the unit tests, and a new unit test checks that each plugin hook has at least one corresponding test. (:issue:`771`, :issue:`773`)
@@ -1688,7 +1688,7 @@ Also in this release:
 -----------------
 
 * New :ref:`setting_base_url` configuration setting for serving up the correct links while running Datasette under a different URL prefix. (:issue:`394`)
-* New metadata settings ``"sort"`` and ``"sort_desc"`` for setting the default sort order for a table. See :ref:`metadata_default_sort`. (:issue:`702`)
+* New metadata settings ``"sort"`` and ``"sort_desc"`` for setting the default sort order for a table. See :ref:`table_configuration_sort`. (:issue:`702`)
 * Sort direction arrow now displays by default on the primary key. This means you only have to click once (not twice) to sort in reverse order. (:issue:`677`)
 * New ``await Request(scope, receive).post_vars()`` method for accessing POST form variables. (:issue:`700`)
 * :ref:`plugin_hooks` documentation now links to example uses of each plugin. (:issue:`709`)
@@ -2123,7 +2123,7 @@ New plugin hooks, improved database view support and an easier way to use more r
 - New ``render_cell`` plugin hook. Plugins can now customize how values are displayed in the HTML tables produced by Datasette's browsable interface. `datasette-json-html `__ and `datasette-render-images `__ are two new plugins that use this hook. :ref:`render_cell documentation `. Closes :issue:`352`
 - New ``extra_body_script`` plugin hook, enabling plugins to provide additional JavaScript that should be added to the page footer. :ref:`extra_body_script documentation `.
 - ``extra_css_urls`` and ``extra_js_urls`` hooks now take additional optional parameters, allowing them to be more selective about which pages they apply to. :ref:`Documentation `.
-- You can now use the :ref:`sortable_columns metadata setting ` to explicitly enable sort-by-column in the interface for database views, as well as for specific tables.
+- You can now use the :ref:`sortable_columns metadata setting ` to explicitly enable sort-by-column in the interface for database views, as well as for specific tables.
 - The new ``fts_table`` and ``fts_pk`` metadata settings can now be used to :ref:`explicitly configure full-text search for a table or a view `, even if that table is not directly coupled to the SQLite FTS feature in the database schema itself.
 - Datasette will now use `pysqlite3 `__ in place of the standard library ``sqlite3`` module if it has been installed in the current environment. This makes it much easier to run Datasette against a more recent version of SQLite, including the just-released `SQLite 3.25.0 `__ which adds window function support. More details on how to use this in :issue:`360`
 - New mechanism that allows :ref:`plugin configuration options ` to be set using ``metadata.json``.
@@ -2208,7 +2208,7 @@ Foreign key expansions
 ~~~~~~~~~~~~~~~~~~~~~~
 
 When Datasette detects a foreign key reference it attempts to resolve a label
-for that reference (automatically or using the :ref:`label_columns` metadata
+for that reference (automatically or using the :ref:`table_configuration_label_column` metadata
 option) so it can display a link to the associated row.
 
 This expansion is now also available for JSON and CSV representations of the
diff --git a/docs/configuration.rst b/docs/configuration.rst
index 425024da..b61c3692 100644
--- a/docs/configuration.rst
+++ b/docs/configuration.rst
@@ -634,5 +634,580 @@ Will produce this HTML:
 
     
 
+.. _configuration_reference_table:
+
+Table configuration
+~~~~~~~~~~~~~~~~~~~
+
+Datasette supports a number of table-level configuration options inside ``datasette.yaml``. These are placed under ``databases.database_name.tables.table_name``.
+
+.. _table_configuration_sort:
+
+``sort`` / ``sort_desc``
+^^^^^^^^^^^^^^^^^^^^^^^^
+
+By default Datasette tables are sorted by primary key. You can set a default sort order for a specific table using the ``sort`` or ``sort_desc`` properties:
+
+.. [[[cog
+    from metadata_doc import config_example
+    import textwrap
+    config_example(cog, textwrap.dedent(
+      """
+        databases:
+          mydatabase:
+            tables:
+              example_table:
+                sort: created
+      """).strip()
+    )
+.. ]]]
+
+.. tab:: datasette.yaml
+
+    .. code-block:: yaml
+
+        databases:
+          mydatabase:
+            tables:
+              example_table:
+                sort: created
+
+.. tab:: datasette.json
+
+    .. code-block:: json
+
+        {
+          "databases": {
+            "mydatabase": {
+              "tables": {
+                "example_table": {
+                  "sort": "created"
+                }
+              }
+            }
+          }
+        }
+.. [[[end]]]
+
+Or use ``sort_desc`` to sort in descending order:
+
+.. [[[cog
+    config_example(cog, textwrap.dedent(
+      """
+        databases:
+          mydatabase:
+            tables:
+              example_table:
+                sort_desc: created
+      """).strip()
+    )
+.. ]]]
+
+.. tab:: datasette.yaml
+
+    .. code-block:: yaml
+
+        databases:
+          mydatabase:
+            tables:
+              example_table:
+                sort_desc: created
+
+.. tab:: datasette.json
+
+    .. code-block:: json
+
+        {
+          "databases": {
+            "mydatabase": {
+              "tables": {
+                "example_table": {
+                  "sort_desc": "created"
+                }
+              }
+            }
+          }
+        }
+.. [[[end]]]
+
+.. _table_configuration_size:
+
+``size``
+^^^^^^^^
+
+Datasette defaults to displaying 100 rows per page, for both tables and views. You can change this on a per-table or per-view basis using the ``size`` key:
+
+.. [[[cog
+    config_example(cog, textwrap.dedent(
+      """
+        databases:
+          mydatabase:
+            tables:
+              example_table:
+                size: 10
+      """).strip()
+    )
+.. ]]]
+
+.. tab:: datasette.yaml
+
+    .. code-block:: yaml
+
+        databases:
+          mydatabase:
+            tables:
+              example_table:
+                size: 10
+
+.. tab:: datasette.json
+
+    .. code-block:: json
+
+        {
+          "databases": {
+            "mydatabase": {
+              "tables": {
+                "example_table": {
+                  "size": 10
+                }
+              }
+            }
+          }
+        }
+.. [[[end]]]
+
+This size can still be over-ridden by passing e.g. ``?_size=50`` in the query string.
+
+.. _table_configuration_sortable_columns:
+
+``sortable_columns``
+^^^^^^^^^^^^^^^^^^^^
+
+Datasette allows any column to be used for sorting by default. If you need to control which columns are available for sorting you can do so using ``sortable_columns``:
+
+.. [[[cog
+    config_example(cog, textwrap.dedent(
+      """
+        databases:
+          mydatabase:
+            tables:
+              example_table:
+                sortable_columns:
+                - height
+                - weight
+      """).strip()
+    )
+.. ]]]
+
+.. tab:: datasette.yaml
+
+    .. code-block:: yaml
+
+        databases:
+          mydatabase:
+            tables:
+              example_table:
+                sortable_columns:
+                - height
+                - weight
+
+.. tab:: datasette.json
+
+    .. code-block:: json
+
+        {
+          "databases": {
+            "mydatabase": {
+              "tables": {
+                "example_table": {
+                  "sortable_columns": [
+                    "height",
+                    "weight"
+                  ]
+                }
+              }
+            }
+          }
+        }
+.. [[[end]]]
+
+This will restrict sorting of ``example_table`` to just the ``height`` and ``weight`` columns.
+
+You can also disable sorting entirely by setting ``"sortable_columns": []``
+
+You can use ``sortable_columns`` to enable specific sort orders for a view called ``name_of_view`` in the database ``my_database`` like so:
+
+.. [[[cog
+    config_example(cog, textwrap.dedent(
+      """
+        databases:
+          my_database:
+            tables:
+              name_of_view:
+                sortable_columns:
+                - clicks
+                - impressions
+      """).strip()
+    )
+.. ]]]
+
+.. tab:: datasette.yaml
+
+    .. code-block:: yaml
+
+        databases:
+          my_database:
+            tables:
+              name_of_view:
+                sortable_columns:
+                - clicks
+                - impressions
+
+.. tab:: datasette.json
+
+    .. code-block:: json
+
+        {
+          "databases": {
+            "my_database": {
+              "tables": {
+                "name_of_view": {
+                  "sortable_columns": [
+                    "clicks",
+                    "impressions"
+                  ]
+                }
+              }
+            }
+          }
+        }
+.. [[[end]]]
+
+.. _table_configuration_label_column:
+
+``label_column``
+^^^^^^^^^^^^^^^^
+
+Datasette's HTML interface attempts to display foreign key references as labelled hyperlinks. By default, it automatically detects a label column using the following rules (in order):
+
+1. If there is exactly one unique text column, use that.
+2. If there is a column called ``name`` or ``title`` (case-insensitive), use that.
+3. If the table has only two columns - a primary key and one other - use the non-primary-key column.
+
+You can override this automatic detection by specifying which column should be used for the link label with the ``label_column`` property:
+
+.. [[[cog
+    config_example(cog, textwrap.dedent(
+      """
+        databases:
+          mydatabase:
+            tables:
+              example_table:
+                label_column: title
+      """).strip()
+    )
+.. ]]]
+
+.. tab:: datasette.yaml
+
+    .. code-block:: yaml
+
+        databases:
+          mydatabase:
+            tables:
+              example_table:
+                label_column: title
+
+.. tab:: datasette.json
+
+    .. code-block:: json
+
+        {
+          "databases": {
+            "mydatabase": {
+              "tables": {
+                "example_table": {
+                  "label_column": "title"
+                }
+              }
+            }
+          }
+        }
+.. [[[end]]]
+
+.. _table_configuration_hidden:
+
+``hidden``
+^^^^^^^^^^
+
+You can hide tables from the database listing view (in the same way that FTS and SpatiaLite tables are automatically hidden) using ``"hidden": true``:
+
+.. [[[cog
+    config_example(cog, textwrap.dedent(
+      """
+        databases:
+          mydatabase:
+            tables:
+              example_table:
+                hidden: true
+      """).strip()
+    )
+.. ]]]
+
+.. tab:: datasette.yaml
+
+    .. code-block:: yaml
+
+        databases:
+          mydatabase:
+            tables:
+              example_table:
+                hidden: true
+
+.. tab:: datasette.json
+
+    .. code-block:: json
+
+        {
+          "databases": {
+            "mydatabase": {
+              "tables": {
+                "example_table": {
+                  "hidden": true
+                }
+              }
+            }
+          }
+        }
+.. [[[end]]]
+
+.. _table_configuration_facets:
+
+``facets`` / ``facet_size``
+^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+You can turn on facets by default for specific tables. ``facet_size`` controls how many unique values are shown for each facet on that table (the default is controlled by the :ref:`setting_default_facet_size` setting). See :ref:`facets_metadata` for full details.
+
+.. [[[cog
+    config_example(cog, textwrap.dedent(
+      """
+        databases:
+          sf-trees:
+            tables:
+              Street_Tree_List:
+                facets:
+                - qLegalStatus
+                facet_size: 10
+      """).strip()
+    )
+.. ]]]
+
+.. tab:: datasette.yaml
+
+    .. code-block:: yaml
+
+        databases:
+          sf-trees:
+            tables:
+              Street_Tree_List:
+                facets:
+                - qLegalStatus
+                facet_size: 10
+
+.. tab:: datasette.json
+
+    .. code-block:: json
+
+        {
+          "databases": {
+            "sf-trees": {
+              "tables": {
+                "Street_Tree_List": {
+                  "facets": [
+                    "qLegalStatus"
+                  ],
+                  "facet_size": 10
+                }
+              }
+            }
+          }
+        }
+.. [[[end]]]
+
+You can also specify :ref:`array ` or :ref:`date ` facets using JSON objects with a single key of ``array`` or ``date``:
+
+.. code-block:: yaml
+
+    facets:
+    - array: tags
+    - date: created
+
+.. _table_configuration_fts:
+
+``fts_table`` / ``fts_pk`` / ``searchmode``
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+These configure :ref:`full-text search ` for a table or view. See :ref:`full_text_search_table_or_view` for full details.
+
+``fts_table`` specifies which FTS table to use for search. ``fts_pk`` sets the primary key column if it is something other than ``rowid``. ``searchmode`` can be set to ``"raw"`` to enable `SQLite advanced search operators `__.
+
+.. [[[cog
+    config_example(cog, textwrap.dedent(
+      """
+        databases:
+          russian-ads:
+            tables:
+              display_ads:
+                fts_table: ads_fts
+                fts_pk: id
+                searchmode: raw
+      """).strip()
+    )
+.. ]]]
+
+.. tab:: datasette.yaml
+
+    .. code-block:: yaml
+
+        databases:
+          russian-ads:
+            tables:
+              display_ads:
+                fts_table: ads_fts
+                fts_pk: id
+                searchmode: raw
+
+.. tab:: datasette.json
+
+    .. code-block:: json
+
+        {
+          "databases": {
+            "russian-ads": {
+              "tables": {
+                "display_ads": {
+                  "fts_table": "ads_fts",
+                  "fts_pk": "id",
+                  "searchmode": "raw"
+                }
+              }
+            }
+          }
+        }
+.. [[[end]]]
+
+.. _table_configuration_column_types:
+
+``column_types``
+^^^^^^^^^^^^^^^^
+
+You can assign semantic column types to columns, which affect how values are rendered, validated, and transformed. Built-in column types include ``url``, ``email``, and ``json``. Plugins can register additional column types using the :ref:`register_column_types ` plugin hook.
+
+The simplest form maps column names to type name strings:
+
+.. [[[cog
+    config_example(cog, textwrap.dedent(
+      """
+        databases:
+          mydatabase:
+            tables:
+              example_table:
+                column_types:
+                  website: url
+                  contact: email
+                  extra_data: json
+      """).strip()
+    )
+.. ]]]
+
+.. tab:: datasette.yaml
+
+    .. code-block:: yaml
+
+        databases:
+          mydatabase:
+            tables:
+              example_table:
+                column_types:
+                  website: url
+                  contact: email
+                  extra_data: json
+
+.. tab:: datasette.json
+
+    .. code-block:: json
+
+        {
+          "databases": {
+            "mydatabase": {
+              "tables": {
+                "example_table": {
+                  "column_types": {
+                    "website": "url",
+                    "contact": "email",
+                    "extra_data": "json"
+                  }
+                }
+              }
+            }
+          }
+        }
+.. [[[end]]]
+
+For column types that accept additional configuration, use an object with ``type`` and ``config`` keys:
+
+.. [[[cog
+    config_example(cog, textwrap.dedent(
+      """
+        databases:
+          mydatabase:
+            tables:
+              example_table:
+                column_types:
+                  website:
+                    type: url
+                    config:
+                      prefix: "https://"
+      """).strip()
+    )
+.. ]]]
+
+.. tab:: datasette.yaml
+
+    .. code-block:: yaml
+
+        databases:
+          mydatabase:
+            tables:
+              example_table:
+                column_types:
+                  website:
+                    type: url
+                    config:
+                      prefix: "https://"
+
+.. tab:: datasette.json
+
+    .. code-block:: json
+
+        {
+          "databases": {
+            "mydatabase": {
+              "tables": {
+                "example_table": {
+                  "column_types": {
+                    "website": {
+                      "type": "url",
+                      "config": {
+                        "prefix": "https://"
+                      }
+                    }
+                  }
+                }
+              }
+            }
+          }
+        }
+.. [[[end]]]
 
 
diff --git a/docs/facets.rst b/docs/facets.rst
index 2a135b69..960ac03a 100644
--- a/docs/facets.rst
+++ b/docs/facets.rst
@@ -98,16 +98,16 @@ You can increase this on an individual page by adding ``?_facet_size=100`` to th
 
 .. _facets_metadata:
 
-Facets in metadata
-------------------
+Facets in configuration
+-----------------------
 
-You can turn facets on by default for specific tables by adding them to a ``"facets"`` key in a Datasette :ref:`metadata` file.
+You can turn facets on by default for specific tables by adding a ``"facets"`` key to the table configuration in ``datasette.yaml``. See also the :ref:`table configuration reference ` for a quick overview.
 
 Here's an example that turns on faceting by default for the ``qLegalStatus`` column in the ``Street_Tree_List`` table in the ``sf-trees`` database:
 
 .. [[[cog
-    from metadata_doc import metadata_example
-    metadata_example(cog, {
+    from metadata_doc import config_example
+    config_example(cog, {
       "databases": {
         "sf-trees": {
           "tables": {
@@ -120,7 +120,7 @@ Here's an example that turns on faceting by default for the ``qLegalStatus`` col
     })
 .. ]]]
 
-.. tab:: metadata.yaml
+.. tab:: datasette.yaml
 
     .. code-block:: yaml
 
@@ -132,7 +132,7 @@ Here's an example that turns on faceting by default for the ``qLegalStatus`` col
                 - qLegalStatus
 
 
-.. tab:: metadata.json
+.. tab:: datasette.json
 
     .. code-block:: json
 
@@ -153,12 +153,12 @@ Here's an example that turns on faceting by default for the ``qLegalStatus`` col
 
 Facets defined in this way will always be shown in the interface and returned in the API, regardless of the ``_facet`` arguments passed to the view.
 
-Facets defined in metadata will be displayed in the order they are listed in the configuration. Any additional facets added via query string parameters (e.g. ``?_facet=column_name``) will appear after the metadata-defined facets, sorted by the number of unique values.
+Facets defined in configuration will be displayed in the order they are listed. Any additional facets added via query string parameters (e.g. ``?_facet=column_name``) will appear after the configured facets, sorted by the number of unique values.
 
-You can specify :ref:`array ` or :ref:`date ` facets in metadata using JSON objects with a single key of ``array`` or ``date`` and a value specifying the column, like this:
+You can specify :ref:`array ` or :ref:`date ` facets using JSON objects with a single key of ``array`` or ``date`` and a value specifying the column, like this:
 
 .. [[[cog
-    metadata_example(cog, {
+    config_example(cog, {
       "facets": [
         {"array": "tags"},
         {"date": "created"}
@@ -166,7 +166,7 @@ You can specify :ref:`array ` or :ref:`date 
     })
 .. ]]]
 
-.. tab:: metadata.yaml
+.. tab:: datasette.yaml
 
     .. code-block:: yaml
 
@@ -175,7 +175,7 @@ You can specify :ref:`array ` or :ref:`date 
         - date: created
 
 
-.. tab:: metadata.json
+.. tab:: datasette.json
 
     .. code-block:: json
 
@@ -194,7 +194,7 @@ You can specify :ref:`array ` or :ref:`date 
 You can change the default facet size (the number of results shown for each facet) for a table using ``facet_size``:
 
 .. [[[cog
-    metadata_example(cog, {
+    config_example(cog, {
       "databases": {
         "sf-trees": {
           "tables": {
@@ -208,7 +208,7 @@ You can change the default facet size (the number of results shown for each face
     })
 .. ]]]
 
-.. tab:: metadata.yaml
+.. tab:: datasette.yaml
 
     .. code-block:: yaml
 
@@ -221,7 +221,7 @@ You can change the default facet size (the number of results shown for each face
                 facet_size: 10
 
 
-.. tab:: metadata.json
+.. tab:: datasette.json
 
     .. code-block:: json
 
diff --git a/docs/full_text_search.rst b/docs/full_text_search.rst
index 7bb73b93..349ad149 100644
--- a/docs/full_text_search.rst
+++ b/docs/full_text_search.rst
@@ -52,7 +52,7 @@ Configuring full-text search for a table or view
 
 If a table has a corresponding FTS table set up using the ``content=`` argument to ``CREATE VIRTUAL TABLE`` shown below, Datasette will detect it automatically and add a search interface to the table page for that table.
 
-You can also manually configure which table should be used for full-text search using query string parameters or :ref:`metadata`. You can set the associated FTS table for a specific table and you can also set one for a view - if you do that, the page for that SQL view will offer a search option.
+You can also manually configure which table should be used for full-text search using query string parameters or table configuration in ``datasette.yaml`` (see :ref:`table_configuration_fts`). You can set the associated FTS table for a specific table and you can also set one for a view - if you do that, the page for that SQL view will offer a search option.
 
 Use ``?_fts_table=x`` to over-ride the FTS table for a specific page. If the primary key was something other than ``rowid`` you can use ``?_fts_pk=col`` to set that as well. This is particularly useful for views, for example:
 
@@ -65,8 +65,8 @@ The ``"searchmode": "raw"`` property can be used to default the table to accepti
 Here is an example which enables full-text search (with SQLite advanced search operators) for a ``display_ads`` view which is defined against the ``ads`` table and hence needs to run FTS against the ``ads_fts`` table, using the ``id`` as the primary key:
 
 .. [[[cog
-    from metadata_doc import metadata_example
-    metadata_example(cog, {
+    from metadata_doc import config_example
+    config_example(cog, {
         "databases": {
             "russian-ads": {
                 "tables": {
@@ -81,7 +81,7 @@ Here is an example which enables full-text search (with SQLite advanced search o
     })
 .. ]]]
 
-.. tab:: metadata.yaml
+.. tab:: datasette.yaml
 
     .. code-block:: yaml
 
@@ -94,7 +94,7 @@ Here is an example which enables full-text search (with SQLite advanced search o
                 searchmode: raw
 
 
-.. tab:: metadata.json
+.. tab:: datasette.json
 
     .. code-block:: json
 
diff --git a/docs/internals.rst b/docs/internals.rst
index 6bd3d41d..544dd7fd 100644
--- a/docs/internals.rst
+++ b/docs/internals.rst
@@ -1820,13 +1820,13 @@ The ``Database`` class also provides properties and methods for introspecting th
     The name of the FTS table associated with this table, if one exists.
 
 ``await db.label_column_for_table(table)`` - string or None
-    The label column that is associated with this table - either automatically detected or using the ``"label_column"`` key from :ref:`metadata`, see :ref:`label_columns`.
+    The label column that is associated with this table - either automatically detected or using the ``"label_column"`` key in configuration, see :ref:`table_configuration_label_column`.
 
 ``await db.foreign_keys_for_table(table)`` - list of dictionaries
     Details of columns in this table which are foreign keys to other tables. A list of dictionaries where each dictionary is shaped like this: ``{"column": string, "other_table": string, "other_column": string}``.
 
 ``await db.hidden_table_names()`` - list of strings
-    List of tables which Datasette "hides" by default - usually these are tables associated with SQLite's full-text search feature, the SpatiaLite extension or tables hidden using the :ref:`metadata_hiding_tables` feature.
+    List of tables which Datasette "hides" by default - usually these are tables associated with SQLite's full-text search feature, the SpatiaLite extension or tables hidden using the :ref:`table_configuration_hidden` feature.
 
 ``await db.get_table_definition(table)`` - string
     Returns the SQL definition for the table - the ``CREATE TABLE`` statement and any associated ``CREATE INDEX`` statements.
diff --git a/docs/json_api.rst b/docs/json_api.rst
index 91a2bb15..891aa9e0 100644
--- a/docs/json_api.rst
+++ b/docs/json_api.rst
@@ -438,7 +438,7 @@ looks like:
     ]
 
 The column in the foreign key table that is used for the label can be specified
-in ``metadata.json`` - see :ref:`label_columns`.
+in ``datasette.yaml`` - see :ref:`table_configuration_label_column`.
 
 .. _json_api_discover_alternate:
 
diff --git a/docs/metadata.rst b/docs/metadata.rst
index a3fa4040..d9d10b8c 100644
--- a/docs/metadata.rst
+++ b/docs/metadata.rst
@@ -205,370 +205,14 @@ These will be displayed at the top of the table page, and will also show in the
 
 You can see an example of how these look at `latest.datasette.io/fixtures/roadside_attractions `__.
 
-.. _metadata_default_sort:
+.. _metadata_table_config:
 
-Setting a default sort order
-----------------------------
+Table configuration
+-------------------
 
-By default Datasette tables are sorted by primary key. You can over-ride this default for a specific table using the ``"sort"`` or ``"sort_desc"`` metadata properties:
+Datasette supports a range of table-level configuration options including sort order, page size, facets, full-text search, column types, and more. These are now documented in the :ref:`table configuration ` section of the configuration reference.
 
-.. [[[cog
-    metadata_example(cog, {
-        "databases": {
-            "mydatabase": {
-                "tables": {
-                    "example_table": {
-                        "sort": "created"
-                    }
-                }
-            }
-        }
-    })
-.. ]]]
-
-.. tab:: metadata.yaml
-
-    .. code-block:: yaml
-
-        databases:
-          mydatabase:
-            tables:
-              example_table:
-                sort: created
-
-
-.. tab:: metadata.json
-
-    .. code-block:: json
-
-        {
-          "databases": {
-            "mydatabase": {
-              "tables": {
-                "example_table": {
-                  "sort": "created"
-                }
-              }
-            }
-          }
-        }
-.. [[[end]]]
-
-Or use ``"sort_desc"`` to sort in descending order:
-
-.. [[[cog
-    metadata_example(cog, {
-        "databases": {
-            "mydatabase": {
-                "tables": {
-                    "example_table": {
-                        "sort_desc": "created"
-                    }
-                }
-            }
-        }
-    })
-.. ]]]
-
-.. tab:: metadata.yaml
-
-    .. code-block:: yaml
-
-        databases:
-          mydatabase:
-            tables:
-              example_table:
-                sort_desc: created
-
-
-.. tab:: metadata.json
-
-    .. code-block:: json
-
-        {
-          "databases": {
-            "mydatabase": {
-              "tables": {
-                "example_table": {
-                  "sort_desc": "created"
-                }
-              }
-            }
-          }
-        }
-.. [[[end]]]
-
-.. _metadata_page_size:
-
-Setting a custom page size
---------------------------
-
-Datasette defaults to displaying 100 rows per page, for both tables and views. You can change this default page size on a per-table or per-view basis using the ``"size"`` key in ``metadata.json``:
-
-.. [[[cog
-    metadata_example(cog, {
-        "databases": {
-            "mydatabase": {
-                "tables": {
-                    "example_table": {
-                        "size": 10
-                    }
-                }
-            }
-        }
-    })
-.. ]]]
-
-.. tab:: metadata.yaml
-
-    .. code-block:: yaml
-
-        databases:
-          mydatabase:
-            tables:
-              example_table:
-                size: 10
-
-
-.. tab:: metadata.json
-
-    .. code-block:: json
-
-        {
-          "databases": {
-            "mydatabase": {
-              "tables": {
-                "example_table": {
-                  "size": 10
-                }
-              }
-            }
-          }
-        }
-.. [[[end]]]
-
-This size can still be over-ridden by passing e.g. ``?_size=50`` in the query string.
-
-.. _metadata_sortable_columns:
-
-Setting which columns can be used for sorting
----------------------------------------------
-
-Datasette allows any column to be used for sorting by default. If you need to
-control which columns are available for sorting you can do so using the optional
-``sortable_columns`` key:
-
-.. [[[cog
-    metadata_example(cog, {
-        "databases": {
-            "database1": {
-                "tables": {
-                    "example_table": {
-                        "sortable_columns": [
-                            "height",
-                            "weight"
-                        ]
-                    }
-                }
-            }
-        }
-    })
-.. ]]]
-
-.. tab:: metadata.yaml
-
-    .. code-block:: yaml
-
-        databases:
-          database1:
-            tables:
-              example_table:
-                sortable_columns:
-                - height
-                - weight
-
-
-.. tab:: metadata.json
-
-    .. code-block:: json
-
-        {
-          "databases": {
-            "database1": {
-              "tables": {
-                "example_table": {
-                  "sortable_columns": [
-                    "height",
-                    "weight"
-                  ]
-                }
-              }
-            }
-          }
-        }
-.. [[[end]]]
-
-This will restrict sorting of ``example_table`` to just the ``height`` and
-``weight`` columns.
-
-You can also disable sorting entirely by setting ``"sortable_columns": []``
-
-You can use ``sortable_columns`` to enable specific sort orders for a view called ``name_of_view`` in the database ``my_database`` like so:
-
-.. [[[cog
-    metadata_example(cog, {
-        "databases": {
-            "my_database": {
-                "tables": {
-                    "name_of_view": {
-                        "sortable_columns": [
-                            "clicks",
-                            "impressions"
-                        ]
-                    }
-                }
-            }
-        }
-    })
-.. ]]]
-
-.. tab:: metadata.yaml
-
-    .. code-block:: yaml
-
-        databases:
-          my_database:
-            tables:
-              name_of_view:
-                sortable_columns:
-                - clicks
-                - impressions
-
-
-.. tab:: metadata.json
-
-    .. code-block:: json
-
-        {
-          "databases": {
-            "my_database": {
-              "tables": {
-                "name_of_view": {
-                  "sortable_columns": [
-                    "clicks",
-                    "impressions"
-                  ]
-                }
-              }
-            }
-          }
-        }
-.. [[[end]]]
-
-.. _label_columns:
-
-Specifying the label column for a table
----------------------------------------
-
-Datasette's HTML interface attempts to display foreign key references as
-labelled hyperlinks. By default, it looks for referenced tables that only have
-two columns: a primary key column and one other. It assumes that the second
-column should be used as the link label.
-
-If your table has more than two columns you can specify which column should be
-used for the link label with the ``label_column`` property:
-
-.. [[[cog
-    metadata_example(cog, {
-        "databases": {
-            "database1": {
-                "tables": {
-                    "example_table": {
-                        "label_column": "title"
-                    }
-                }
-            }
-        }
-    })
-.. ]]]
-
-.. tab:: metadata.yaml
-
-    .. code-block:: yaml
-
-        databases:
-          database1:
-            tables:
-              example_table:
-                label_column: title
-
-
-.. tab:: metadata.json
-
-    .. code-block:: json
-
-        {
-          "databases": {
-            "database1": {
-              "tables": {
-                "example_table": {
-                  "label_column": "title"
-                }
-              }
-            }
-          }
-        }
-.. [[[end]]]
-
-.. _metadata_hiding_tables:
-
-Hiding tables
--------------
-
-You can hide tables from the database listing view (in the same way that FTS and
-SpatiaLite tables are automatically hidden) using ``"hidden": true``:
-
-.. [[[cog
-    metadata_example(cog, {
-        "databases": {
-            "database1": {
-                "tables": {
-                    "example_table": {
-                        "hidden": True
-                    }
-                }
-            }
-        }
-    })
-.. ]]]
-
-.. tab:: metadata.yaml
-
-    .. code-block:: yaml
-
-        databases:
-          database1:
-            tables:
-              example_table:
-                hidden: true
-
-
-.. tab:: metadata.json
-
-    .. code-block:: json
-
-        {
-          "databases": {
-            "database1": {
-              "tables": {
-                "example_table": {
-                  "hidden": true
-                }
-              }
-            }
-          }
-        }
-.. [[[end]]]
+For backwards compatibility these options can be specified in either ``metadata.yaml`` or ``datasette.yaml``.
 
 .. _metadata_reference:
 
@@ -613,7 +257,7 @@ Table-level metadata
 
 "Table-level" metadata refers to fields that can be specified for each table in a Datasette instance. These attributes should be listed under a specific table using the `"tables"` field.
 
-The following are the full list of allowed table-level metadata fields:
+The following metadata fields are supported at the table level:
 
 - ``source``
 - ``source_url``
@@ -621,13 +265,6 @@ The following are the full list of allowed table-level metadata fields:
 - ``license_url``
 - ``about``
 - ``about_url``
-- ``hidden``
-- ``sort/sort_desc``
-- ``size``
-- ``sortable_columns``
-- ``label_column``
-- ``facets``
-- ``fts_table``
-- ``fts_pk``
-- ``searchmode``
-- ``columns``
+- ``columns`` (see :ref:`metadata_column_descriptions`)
+
+Additionally, tables support a number of configuration options (``sort``, ``sort_desc``, ``size``, ``sortable_columns``, ``label_column``, ``hidden``, ``facets``, ``facet_size``, ``fts_table``, ``fts_pk``, ``searchmode``, ``column_types``). See :ref:`table configuration ` for full details.
diff --git a/docs/pages.rst b/docs/pages.rst
index 2e54ce2f..a8a25fa7 100644
--- a/docs/pages.rst
+++ b/docs/pages.rst
@@ -50,7 +50,7 @@ Some tables listed on the database page are treated as hidden. Hidden tables are
 The following tables are hidden by default:
 
 - Any table with a name that starts with an underscore - this is a Datasette convention to help plugins easily hide their own internal tables.
-- Tables that have been configured as ``"hidden": true`` using :ref:`metadata_hiding_tables`.
+- Tables that have been configured as ``"hidden": true`` using :ref:`table_configuration_hidden`.
 - ``*_fts`` tables that implement SQLite full-text search indexes.
 - Tables relating to the inner workings of the SpatiaLite SQLite extension.
 - ``sqlite_stat`` tables used to store statistics used by the query optimizer.

From fd016f7986a13ac90e4b032d0af7f77f6503b6c1 Mon Sep 17 00:00:00 2001
From: Simon Willison 
Date: Wed, 18 Mar 2026 09:04:28 -0700
Subject: [PATCH 020/176] Column actions panel on mobile (#2669)

On mobile widths the column actions were no longer available.

This adds a new modal to help with that.

https://gisthost.github.io/?ec60eb27e22cf5d96642eec1715586b6
---
 datasette/static/app.css                  | 308 +++++++++++++++-
 datasette/static/mobile-column-actions.js | 318 +++++++++++++++++
 datasette/static/table.js                 | 410 +++++++++++++---------
 datasette/templates/table.html            |  10 +
 tests/test_table_html.py                  |  15 +
 5 files changed, 889 insertions(+), 172 deletions(-)
 create mode 100644 datasette/static/mobile-column-actions.js

diff --git a/datasette/static/app.css b/datasette/static/app.css
index 4183b58e..0a6efd4c 100644
--- a/datasette/static/app.css
+++ b/datasette/static/app.css
@@ -742,6 +742,284 @@ p.zero-results {
 .select-wrapper.small-screen-only {
     display: none;
 }
+
+@keyframes datasette-modal-slide-in {
+    from {
+        opacity: 0;
+        transform: translateY(-20px) scale(0.95);
+    }
+    to {
+        opacity: 1;
+        transform: translateY(0) scale(1);
+    }
+}
+
+@keyframes datasette-modal-fade-in {
+    from { opacity: 0; }
+    to { opacity: 1; }
+}
+
+dialog.mobile-column-actions-dialog {
+    --ink: #0f0f0f;
+    --paper: #f5f3ef;
+    --muted: #6b6b6b;
+    --rule: #e2dfd8;
+    --accent: #1a56db;
+    --card: #ffffff;
+    border: none;
+    border-radius: var(--modal-border-radius, 0.75rem);
+    padding: 0;
+    margin: auto;
+    width: min(420px, calc(100vw - 32px));
+    max-width: 95vw;
+    max-height: min(640px, 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.mobile-column-actions-dialog[open] {
+    display: flex;
+    flex-direction: column;
+}
+
+dialog.mobile-column-actions-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;
+}
+
+.mobile-column-actions-dialog .modal-header {
+    padding: 20px 24px 16px;
+    border-bottom: 1px solid var(--rule);
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    gap: 12px;
+    flex-shrink: 0;
+}
+
+.mobile-column-actions-dialog .modal-title {
+    font-size: 1rem;
+    font-weight: 600;
+    color: var(--ink);
+}
+
+.mobile-column-actions-dialog .modal-meta {
+    font-family: ui-monospace, monospace;
+    font-size: 0.7rem;
+    color: var(--muted);
+    background: var(--paper);
+    padding: 3px 9px;
+    border-radius: 20px;
+}
+
+.mobile-column-actions-dialog .list-wrap {
+    flex: 1;
+    overflow-y: auto;
+    overflow-x: hidden;
+    position: relative;
+    overscroll-behavior: contain;
+    -webkit-overflow-scrolling: touch;
+}
+
+.mobile-column-actions-dialog .list-wrap::before,
+.mobile-column-actions-dialog .list-wrap::after {
+    content: "";
+    position: sticky;
+    display: block;
+    left: 0;
+    right: 0;
+    height: 20px;
+    pointer-events: none;
+    z-index: 5;
+}
+
+.mobile-column-actions-dialog .list-wrap::before {
+    top: 0;
+    background: linear-gradient(to bottom, rgba(255,255,255,0.9), transparent);
+}
+
+.mobile-column-actions-dialog .list-wrap::after {
+    bottom: 0;
+    background: linear-gradient(to top, rgba(255,255,255,0.9), transparent);
+    margin-top: -20px;
+}
+
+.mobile-column-top-actions {
+    padding: 10px 24px 0;
+}
+
+.mobile-column-top-action {
+    display: inline-block;
+    text-decoration: none;
+}
+
+.mobile-column-section {
+    border-bottom: 1px solid var(--rule);
+}
+
+.mobile-column-actions-dialog .col-header {
+    width: 100%;
+    padding: 12px 24px;
+    font: inherit;
+    font-weight: 600;
+    border: 0;
+    background: none;
+    cursor: pointer;
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    text-align: left;
+}
+
+.mobile-column-header-text {
+    display: flex;
+    flex-direction: column;
+    gap: 0.15rem;
+}
+
+.mobile-column-name {
+    color: var(--ink);
+}
+
+.mobile-column-meta {
+    color: var(--muted);
+    font-size: 0.78em;
+    font-family: ui-monospace, monospace;
+    font-weight: normal;
+}
+
+.mobile-column-chevron {
+    color: var(--muted);
+    transition: transform 0.2s ease-out;
+}
+
+.mobile-column-actions-dialog .col-header[aria-expanded="true"] .mobile-column-chevron {
+    transform: rotate(180deg);
+}
+
+.mobile-column-actions-dialog .col-actions[hidden] {
+    display: none;
+}
+
+.mobile-column-actions-dialog .col-actions ul,
+.mobile-column-actions-dialog .col-actions li {
+    margin: 0;
+    padding: 0;
+    list-style-type: none;
+}
+
+.mobile-column-actions-dialog .col-actions a,
+.mobile-column-actions-dialog .col-actions button {
+    display: block;
+    width: 100%;
+    padding: 10px 24px 10px 40px;
+    color: var(--ink);
+    text-align: left;
+    font: inherit;
+    text-decoration: none;
+    background: none;
+    border: 0;
+    border-top: 1px solid #f5f5f5;
+    cursor: pointer;
+}
+
+.mobile-column-actions-dialog .col-actions a:hover,
+.mobile-column-actions-dialog .col-actions button:hover {
+    background: var(--paper);
+}
+
+.mobile-column-actions-dialog .col-actions a:active,
+.mobile-column-actions-dialog .col-actions button:active {
+    background: #eee;
+}
+
+.mobile-column-description,
+.mobile-column-no-actions {
+    margin: 0;
+    padding: 0 24px 12px 24px;
+    color: var(--muted);
+    font-size: 0.85em;
+}
+
+.mobile-column-actions-dialog .modal-footer {
+    padding: 14px 20px;
+    border-top: 1px solid var(--rule);
+    display: flex;
+    align-items: center;
+    gap: 10px;
+    flex-shrink: 0;
+    background: var(--paper);
+}
+
+.mobile-column-actions-dialog .footer-info {
+    flex: 1;
+    font-family: ui-monospace, monospace;
+    font-size: 0.68rem;
+    color: var(--muted);
+}
+
+.mobile-column-actions-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;
+}
+
+.mobile-column-actions-dialog .btn-ghost {
+    background: transparent;
+    color: var(--muted);
+    border: 1px solid var(--rule);
+}
+
+.mobile-column-actions-dialog .btn-ghost:hover {
+    background: var(--rule);
+    color: var(--ink);
+}
+
+@media (max-width: 640px) {
+    dialog.mobile-column-actions-dialog {
+        width: 95vw;
+        max-height: 85vh;
+        border-radius: 0.5rem;
+    }
+
+    .mobile-column-actions-dialog .modal-header {
+        padding: 16px 18px 14px;
+    }
+
+    .mobile-column-top-actions {
+        padding-left: 18px;
+        padding-right: 18px;
+    }
+
+    .mobile-column-actions-dialog .col-header {
+        padding-left: 18px;
+        padding-right: 18px;
+    }
+
+    .mobile-column-actions-dialog .col-actions a,
+    .mobile-column-actions-dialog .col-actions button {
+        padding-left: 34px;
+        padding-right: 18px;
+    }
+
+    .mobile-column-description,
+    .mobile-column-no-actions {
+        padding-left: 18px;
+        padding-right: 18px;
+    }
+}
+
 @media only screen and (max-width: 576px) {
 
     .small-screen-only {
@@ -803,16 +1081,42 @@ p.zero-results {
     .filters input.filter-value {
         width: 140px;
     }
-    button.choose-columns-mobile {
-        display: inline-block;
+    button.choose-columns-mobile,
+    button.column-actions-mobile {
+        display: inline-flex;
+        align-items: center;
+        justify-content: center;
         padding: 0.5rem 1rem;
         margin-bottom: 1em;
         font-size: 0.9rem;
+        line-height: 1.2;
         font-family: inherit;
         background: white;
         border: 1px solid #ccc;
         border-radius: 5px;
         cursor: pointer;
+        vertical-align: top;
+        box-sizing: border-box;
+        min-height: 2.5rem;
+    }
+
+    button.column-actions-mobile {
+        gap: 0.55rem;
+    }
+
+    button.column-actions-mobile svg {
+        display: block;
+        width: 16px;
+        height: 16px;
+        flex-shrink: 0;
+    }
+
+    button.column-actions-mobile span {
+        line-height: 1.2;
+    }
+
+    button.choose-columns-mobile {
+        margin-right: 0.5rem;
     }
 }
 
diff --git a/datasette/static/mobile-column-actions.js b/datasette/static/mobile-column-actions.js
new file mode 100644
index 00000000..0f407cd2
--- /dev/null
+++ b/datasette/static/mobile-column-actions.js
@@ -0,0 +1,318 @@
+var MOBILE_COLUMN_BREAKPOINT = 576;
+var MOBILE_COLUMN_DIALOG_ID = "mobile-column-actions-dialog";
+var MOBILE_COLUMN_DIALOG_TITLE_ID = "mobile-column-actions-title";
+
+function mobileColumnHeaders(manager) {
+  return Array.from(
+    document.querySelectorAll(manager.selectors.tableHeaders),
+  ).filter((th) => th.dataset.column);
+}
+
+function mobileColumnMetaText(th) {
+  var parts = [];
+  if (th.dataset.columnType) {
+    parts.push(th.dataset.columnType);
+  }
+  if (th.dataset.isPk === "1") {
+    parts.push("pk");
+  }
+  if (th.dataset.columnNotNull === "1") {
+    parts.push("not null");
+  }
+  return parts.join(", ");
+}
+
+function createMobileColumnActionNode(itemConfig, closeDialog) {
+  var actionNode;
+  if (itemConfig.href) {
+    actionNode = document.createElement("a");
+    actionNode.href = itemConfig.href;
+  } else {
+    actionNode = document.createElement("button");
+    actionNode.type = "button";
+  }
+  actionNode.textContent = itemConfig.label;
+
+  if (itemConfig.onClick) {
+    actionNode.addEventListener("click", function (ev) {
+      try {
+        itemConfig.onClick.call(actionNode, ev);
+      } finally {
+        closeDialog({ restoreFocus: false });
+      }
+    });
+  }
+
+  return actionNode;
+}
+
+function initMobileColumnActions(manager) {
+  var triggerButton = document.querySelector(".column-actions-mobile");
+  if (!triggerButton) {
+    return;
+  }
+
+  if (
+    !window.URLSearchParams ||
+    !window.HTMLDialogElement ||
+    !manager.columnActions
+  ) {
+    triggerButton.style.display = "none";
+    return;
+  }
+
+  if (!mobileColumnHeaders(manager).length) {
+    triggerButton.style.display = "none";
+    return;
+  }
+
+  var dialog = document.createElement("dialog");
+  dialog.className = "mobile-column-actions-dialog";
+  dialog.id = MOBILE_COLUMN_DIALOG_ID;
+  dialog.setAttribute("aria-labelledby", MOBILE_COLUMN_DIALOG_TITLE_ID);
+  dialog.innerHTML = `
+    
+    
+ + `; + document.body.appendChild(dialog); + + triggerButton.setAttribute("aria-haspopup", "dialog"); + triggerButton.setAttribute("aria-controls", MOBILE_COLUMN_DIALOG_ID); + triggerButton.setAttribute("aria-expanded", "false"); + + var countEl = dialog.querySelector(".modal-meta"); + var listWrap = dialog.querySelector(".mobile-column-list"); + var doneButton = dialog.querySelector(".mobile-column-actions-done"); + var expandedSectionId = null; + var shouldRestoreFocus = true; + + function updateExpandedSection() { + Array.from(dialog.querySelectorAll(".col-header")).forEach((button) => { + var controlsId = button.getAttribute("aria-controls"); + var actionList = dialog.querySelector("#" + controlsId); + var isExpanded = controlsId === expandedSectionId; + button.setAttribute("aria-expanded", isExpanded ? "true" : "false"); + actionList.hidden = !isExpanded; + actionList.classList.toggle("expanded", isExpanded); + }); + } + + function scrollExpandedSectionIntoView(section) { + var sectionTop = section.offsetTop; + var sectionBottom = sectionTop + section.offsetHeight; + var visibleTop = listWrap.scrollTop; + var visibleBottom = visibleTop + listWrap.clientHeight; + var sectionHeight = section.offsetHeight; + + if (sectionTop < visibleTop) { + listWrap.scrollTop = sectionTop; + return; + } + + if (sectionBottom <= visibleBottom) { + return; + } + + if (sectionHeight <= listWrap.clientHeight) { + listWrap.scrollTop = sectionBottom - listWrap.clientHeight; + } else { + listWrap.scrollTop = sectionTop; + } + } + + function closeDialog(options) { + options = options || {}; + shouldRestoreFocus = options.restoreFocus !== false; + if (dialog.open) { + dialog.close(); + } else { + triggerButton.setAttribute("aria-expanded", "false"); + if (shouldRestoreFocus) { + triggerButton.focus(); + } + } + } + + function renderDialog() { + var headers = mobileColumnHeaders(manager); + if (!headers.length) { + closeDialog({ restoreFocus: false }); + triggerButton.style.display = "none"; + return false; + } + + if ( + !headers.some( + (_th, index) => `mobile-column-actions-${index}` === expandedSectionId, + ) + ) { + expandedSectionId = null; + } + + countEl.textContent = `${headers.length} column${ + headers.length === 1 ? "" : "s" + }`; + listWrap.innerHTML = ""; + + if (manager.columnActions.shouldShowShowAllColumns()) { + var topActions = document.createElement("div"); + topActions.className = "mobile-column-top-actions"; + + var showAllColumns = document.createElement("a"); + showAllColumns.className = "btn btn-ghost mobile-column-top-action"; + showAllColumns.href = manager.columnActions.showAllColumnsUrl(); + showAllColumns.textContent = "Show all columns"; + + topActions.appendChild(showAllColumns); + listWrap.appendChild(topActions); + } + + headers.forEach((th, index) => { + var sectionId = `mobile-column-actions-${index}`; + var actionState = manager.columnActions.buildColumnActionState(th, { + includeChooseColumns: false, + includeShowAllColumns: false, + }); + var section = document.createElement("section"); + section.className = "mobile-column-section"; + + var headerButton = document.createElement("button"); + headerButton.type = "button"; + headerButton.className = "col-header"; + headerButton.setAttribute("aria-controls", sectionId); + headerButton.setAttribute("aria-expanded", "false"); + + var headerText = document.createElement("span"); + headerText.className = "mobile-column-header-text"; + + var name = document.createElement("span"); + name.className = "mobile-column-name"; + name.textContent = th.dataset.column; + headerText.appendChild(name); + + var metaText = mobileColumnMetaText(th); + if (metaText) { + var meta = document.createElement("span"); + meta.className = "mobile-column-meta"; + meta.textContent = metaText; + headerText.appendChild(meta); + } + + var chevron = document.createElement("span"); + chevron.className = "mobile-column-chevron"; + chevron.setAttribute("aria-hidden", "true"); + chevron.textContent = "▾"; + + headerButton.appendChild(headerText); + headerButton.appendChild(chevron); + headerButton.addEventListener("click", function () { + expandedSectionId = expandedSectionId === sectionId ? null : sectionId; + updateExpandedSection(); + if (expandedSectionId === sectionId) { + scrollExpandedSectionIntoView(section); + } + }); + + var actionContainer = document.createElement("div"); + actionContainer.id = sectionId; + actionContainer.className = "col-actions"; + actionContainer.hidden = true; + + if (actionState.columnDescription) { + var description = document.createElement("p"); + description.className = "mobile-column-description"; + description.textContent = actionState.columnDescription; + actionContainer.appendChild(description); + } + + if (actionState.actionItems.length) { + var actionList = document.createElement("ul"); + actionState.actionItems.forEach((itemConfig) => { + var actionItem = document.createElement("li"); + actionItem.appendChild( + createMobileColumnActionNode(itemConfig, closeDialog), + ); + actionList.appendChild(actionItem); + }); + actionContainer.appendChild(actionList); + } else { + var noActions = document.createElement("p"); + noActions.className = "mobile-column-no-actions"; + noActions.textContent = "No actions available"; + actionContainer.appendChild(noActions); + } + + section.appendChild(headerButton); + section.appendChild(actionContainer); + listWrap.appendChild(section); + }); + + updateExpandedSection(); + return true; + } + + function openDialog() { + if (window.innerWidth > MOBILE_COLUMN_BREAKPOINT) { + return; + } + if (!renderDialog()) { + return; + } + if (!dialog.open) { + dialog.showModal(); + } + triggerButton.setAttribute("aria-expanded", "true"); + var focusTarget = + dialog.querySelector(".mobile-column-top-action") || + dialog.querySelector(".col-header") || + doneButton; + focusTarget.focus(); + } + + triggerButton.addEventListener("click", function () { + if (dialog.open) { + closeDialog(); + } else { + openDialog(); + } + }); + + doneButton.addEventListener("click", function () { + closeDialog(); + }); + + dialog.addEventListener("click", function (ev) { + if (ev.target === dialog) { + closeDialog(); + } + }); + + dialog.addEventListener("cancel", function (ev) { + ev.preventDefault(); + closeDialog(); + }); + + dialog.addEventListener("close", function () { + triggerButton.setAttribute("aria-expanded", "false"); + if (shouldRestoreFocus) { + triggerButton.focus(); + } + }); + + window.addEventListener("resize", function () { + if (window.innerWidth > MOBILE_COLUMN_BREAKPOINT && dialog.open) { + closeDialog({ restoreFocus: false }); + } + }); +} + +document.addEventListener("datasette_init", function (evt) { + initMobileColumnActions(evt.detail); +}); diff --git a/datasette/static/table.js b/datasette/static/table.js index c26dda5a..707bfe86 100644 --- a/datasette/static/table.js +++ b/datasette/static/table.js @@ -1,14 +1,6 @@ var DROPDOWN_HTML = ``; @@ -18,54 +10,230 @@ var DROPDOWN_ICON_SVG = ` `; +function getParams() { + return new URLSearchParams(location.search); +} + +function paramsToUrl(params) { + var s = params.toString(); + return s ? "?" + s : location.pathname; +} + +function sortDescUrl(column) { + var params = getParams(); + params.set("_sort_desc", column); + params.delete("_sort"); + params.delete("_next"); + return paramsToUrl(params); +} + +function sortAscUrl(column) { + var params = getParams(); + params.set("_sort", column); + params.delete("_sort_desc"); + params.delete("_next"); + return paramsToUrl(params); +} + +function facetUrl(column) { + var params = getParams(); + params.append("_facet", column); + return paramsToUrl(params); +} + +function hideColumnUrl(column) { + var params = getParams(); + params.append("_nocol", column); + return paramsToUrl(params); +} + +function showAllColumnsUrl() { + var params = getParams(); + params.delete("_nocol"); + params.delete("_col"); + return paramsToUrl(params); +} + +function notBlankUrl(column) { + var params = getParams(); + params.set(`${column}__notblank`, "1"); + return paramsToUrl(params); +} + +function getDisplayedFacets() { + return Array.from(document.querySelectorAll(".facet-info")).map( + (el) => el.dataset.column, + ); +} + +function getColumnClassName(th) { + return Array.from(th.classList).find((className) => + className.startsWith("col-"), + ); +} + +function getColumnCells(th) { + var table = th.closest("table"); + var columnClassName = getColumnClassName(th); + if (!table || !columnClassName) { + return []; + } + return Array.from(table.querySelectorAll("td." + columnClassName)); +} + +function getColumnMeta(th) { + return { + columnName: th.dataset.column, + columnNotNull: th.dataset.columnNotNull === "1", + columnType: th.dataset.columnType, + isPk: th.dataset.isPk === "1", + }; +} + +function getColumnTypeText(th) { + var columnType = th.dataset.columnType; + if (!columnType) { + return null; + } + var notNull = th.dataset.columnNotNull === "1" ? " NOT NULL" : ""; + return `Type: ${columnType.toUpperCase()}${notNull}`; +} + +function canChooseColumns() { + return !!( + document.querySelector("column-chooser") && window._columnChooserData + ); +} + +function shouldShowShowAllColumns() { + var params = getParams(); + return params.getAll("_nocol").length || params.getAll("_col").length; +} + +function buildColumnActionItems(manager, th, options) { + options = options || {}; + var params = getParams(); + var column = th.dataset.column; + var columnActions = []; + var isSortable = !!th.querySelector("a"); + var isFirstColumn = th.parentElement.querySelector("th:first-of-type") === th; + var isSinglePk = + th.dataset.isPk === "1" && + document.querySelectorAll('th[data-is-pk="1"]').length === 1; + var hasBlankValues = getColumnCells(th).some( + (el) => el.innerText.trim() === "", + ); + + if (isSortable && params.get("_sort") !== column) { + columnActions.push({ + label: "Sort ascending", + href: sortAscUrl(column), + }); + } + + if (isSortable && params.get("_sort_desc") !== column) { + columnActions.push({ + label: "Sort descending", + href: sortDescUrl(column), + }); + } + + if ( + DATASETTE_ALLOW_FACET && + !isFirstColumn && + !getDisplayedFacets().includes(column) && + !isSinglePk + ) { + columnActions.push({ + label: "Facet by this", + href: facetUrl(column), + }); + } + + if (options.includeChooseColumns && canChooseColumns()) { + columnActions.push({ + label: "Choose columns", + href: "#", + onClick: + options.onChooseColumns || + function (ev) { + ev.preventDefault(); + openColumnChooser(); + }, + }); + } + + if (th.dataset.isPk !== "1") { + columnActions.push({ + label: "Hide this column", + href: hideColumnUrl(column), + }); + } + + if (options.includeShowAllColumns && shouldShowShowAllColumns()) { + columnActions.push({ + label: "Show all columns", + href: showAllColumnsUrl(), + }); + } + + if (params.get(`${column}__notblank`) !== "1" && hasBlankValues) { + columnActions.push({ + label: "Show not-blank rows", + href: notBlankUrl(column), + }); + } + + return columnActions.concat(manager.makeColumnActions(getColumnMeta(th))); +} + +function buildColumnActionState(manager, th, options) { + return { + column: th.dataset.column, + columnDescription: th.dataset.columnDescription || null, + columnMeta: getColumnMeta(th), + columnTypeText: getColumnTypeText(th), + actionItems: buildColumnActionItems(manager, th, options), + }; +} + +function initializeColumnActions(manager) { + manager.columnActions = { + buildColumnActionState: function (th, options) { + return buildColumnActionState(manager, th, options); + }, + buildColumnActionItems: function (th, options) { + return buildColumnActionItems(manager, th, options); + }, + canChooseColumns: canChooseColumns, + facetUrl: facetUrl, + getColumnMeta: getColumnMeta, + getColumnTypeText: getColumnTypeText, + hideColumnUrl: hideColumnUrl, + notBlankUrl: notBlankUrl, + shouldShowShowAllColumns: shouldShowShowAllColumns, + showAllColumnsUrl: showAllColumnsUrl, + sortAscUrl: sortAscUrl, + sortDescUrl: sortDescUrl, + }; +} + +function renderActionLink(itemConfig) { + var newLink = document.createElement("a"); + newLink.textContent = itemConfig.label; + newLink.href = itemConfig.href || "#"; + if (itemConfig.onClick) { + newLink.addEventListener("click", itemConfig.onClick); + } + return newLink; +} + /** Main initialization function for Datasette Table interactions */ const initDatasetteTable = function (manager) { // Feature detection if (!window.URLSearchParams) { return; } - function getParams() { - return new URLSearchParams(location.search); - } - function paramsToUrl(params) { - var s = params.toString(); - return s ? "?" + s : location.pathname; - } - function sortDescUrl(column) { - var params = getParams(); - params.set("_sort_desc", column); - params.delete("_sort"); - params.delete("_next"); - return paramsToUrl(params); - } - function sortAscUrl(column) { - var params = getParams(); - params.set("_sort", column); - params.delete("_sort_desc"); - params.delete("_next"); - return paramsToUrl(params); - } - function facetUrl(column) { - var params = getParams(); - params.append("_facet", column); - return paramsToUrl(params); - } - function hideColumnUrl(column) { - var params = getParams(); - params.append("_nocol", column); - return paramsToUrl(params); - } - function showAllColumnsUrl() { - var params = getParams(); - params.delete("_nocol"); - params.delete("_col"); - return paramsToUrl(params); - } - function notBlankUrl(column) { - var params = getParams(); - params.set(`${column}__notblank`, "1"); - return paramsToUrl(params); - } function closeMenu() { menu.style.display = "none"; menu.classList.remove("anim-scale-in"); @@ -97,100 +265,34 @@ const initDatasetteTable = function (manager) { var rect = th.getBoundingClientRect(); var menuTop = rect.bottom + window.scrollY; var menuLeft = rect.left + window.scrollX; - var column = th.getAttribute("data-column"); - var params = getParams(); - var sort = menu.querySelector("a.dropdown-sort-asc"); - var sortDesc = menu.querySelector("a.dropdown-sort-desc"); - var facetItem = menu.querySelector("a.dropdown-facet"); - var notBlank = menu.querySelector("a.dropdown-not-blank"); - var hideColumn = menu.querySelector("a.dropdown-hide-column"); - var showAllColumns = menu.querySelector("a.dropdown-show-all-columns"); - var selectColumns = menu.querySelector("a.dropdown-choose-columns"); - if (params.get("_sort") == column) { - sort.parentNode.style.display = "none"; - } else { - sort.parentNode.style.display = "block"; - sort.setAttribute("href", sortAscUrl(column)); - } - if (params.get("_sort_desc") == column) { - sortDesc.parentNode.style.display = "none"; - } else { - sortDesc.parentNode.style.display = "block"; - sortDesc.setAttribute("href", sortDescUrl(column)); - } - /* Show hide columns options */ - if (params.get("_nocol") || params.get("_col")) { - showAllColumns.parentNode.style.display = "block"; - showAllColumns.setAttribute("href", showAllColumnsUrl()); - } else { - showAllColumns.parentNode.style.display = "none"; - } - if (th.getAttribute("data-is-pk") != "1") { - hideColumn.parentNode.style.display = "block"; - hideColumn.setAttribute("href", hideColumnUrl(column)); - } else { - hideColumn.parentNode.style.display = "none"; - } - /* Choose columns - show if web component exists */ - var columnChooser = document.querySelector("column-chooser"); - if (columnChooser && window._columnChooserData) { - selectColumns.parentNode.style.display = "block"; - selectColumns.addEventListener("click", function (ev) { + var actionState = manager.columnActions.buildColumnActionState(th, { + includeChooseColumns: true, + includeShowAllColumns: true, + onChooseColumns: function (ev) { ev.preventDefault(); closeMenu(); openColumnChooser(); - }); - } else { - selectColumns.parentNode.style.display = "none"; - } - /* Only show "Facet by this" if it's not the first column, not selected, - not a single PK and the Datasette allow_facet setting is True */ - var displayedFacets = Array.from( - document.querySelectorAll(".facet-info"), - ).map((el) => el.dataset.column); - var isFirstColumn = - th.parentElement.querySelector("th:first-of-type") == th; - var isSinglePk = - th.getAttribute("data-is-pk") == "1" && - document.querySelectorAll('th[data-is-pk="1"]').length == 1; - if ( - !DATASETTE_ALLOW_FACET || - isFirstColumn || - displayedFacets.includes(column) || - isSinglePk - ) { - facetItem.parentNode.style.display = "none"; - } else { - facetItem.parentNode.style.display = "block"; - facetItem.setAttribute("href", facetUrl(column)); - } - /* Show notBlank option if not selected AND at least one visible blank value */ - var tdsForThisColumn = Array.from( - th.closest("table").querySelectorAll("td." + th.className), - ); - if ( - params.get(`${column}__notblank`) != "1" && - tdsForThisColumn.filter((el) => el.innerText.trim() == "").length - ) { - notBlank.parentNode.style.display = "block"; - notBlank.setAttribute("href", notBlankUrl(column)); - } else { - notBlank.parentNode.style.display = "none"; - } - var columnTypeP = menu.querySelector(".dropdown-column-type"); - var columnType = th.dataset.columnType; - var notNull = th.dataset.columnNotNull == 1 ? " NOT NULL" : ""; + }, + }); + var menuList = menu.querySelector("ul.dropdown-actions"); + menuList.innerHTML = ""; + actionState.actionItems.forEach((itemConfig) => { + var menuItem = document.createElement("li"); + menuItem.appendChild(renderActionLink(itemConfig)); + menuList.appendChild(menuItem); + }); - if (columnType) { + var columnTypeP = menu.querySelector(".dropdown-column-type"); + if (actionState.columnTypeText) { columnTypeP.style.display = "block"; - columnTypeP.innerText = `Type: ${columnType.toUpperCase()}${notNull}`; + columnTypeP.innerText = actionState.columnTypeText; } else { columnTypeP.style.display = "none"; } var columnDescriptionP = menu.querySelector(".dropdown-column-description"); - if (th.dataset.columnDescription) { - columnDescriptionP.innerText = th.dataset.columnDescription; + if (actionState.columnDescription) { + columnDescriptionP.innerText = actionState.columnDescription; columnDescriptionP.style.display = "block"; } else { columnDescriptionP.style.display = "none"; @@ -201,39 +303,6 @@ const initDatasetteTable = function (manager) { menu.style.display = "block"; menu.classList.add("anim-scale-in"); - // Custom menu items on each render - // Plugin hook: allow adding JS-based additional menu items - const columnActionsPayload = { - columnName: th.dataset.column, - columnNotNull: th.dataset.columnNotNull === "1", - columnType: th.dataset.columnType, - isPk: th.dataset.isPk === "1", - }; - const columnItemConfigs = manager.makeColumnActions(columnActionsPayload); - - const menuList = menu.querySelector("ul"); - columnItemConfigs.forEach((itemConfig) => { - // Remove items from previous render. We assume entries have unique labels. - const existingItems = menuList.querySelectorAll(`li`); - Array.from(existingItems) - .filter((item) => item.innerText === itemConfig.label) - .forEach((node) => { - node.remove(); - }); - - const newLink = document.createElement("a"); - newLink.textContent = itemConfig.label; - newLink.href = itemConfig.href ?? "#"; - if (itemConfig.onClick) { - newLink.onclick = itemConfig.onClick; - } - - // Attach new elements to DOM - const menuItem = document.createElement("li"); - menuItem.appendChild(newLink); - menuList.appendChild(menuItem); - }); - // Measure width of menu and adjust position if too far right const menuWidth = menu.offsetWidth; const windowWidth = window.innerWidth; @@ -383,7 +452,7 @@ function openColumnChooser() { } var qs = params.toString(); location.href = qs ? "?" + qs : location.pathname; - } + }, }); } @@ -391,11 +460,12 @@ function openColumnChooser() { document.addEventListener("datasette_init", function (evt) { const { detail: manager } = evt; + initializeColumnActions(manager); + // Main table initDatasetteTable(manager); // Other UI functions with interactive JS needs addButtonsToFilterRows(manager); initAutocompleteForFilterValues(manager); - }); diff --git a/datasette/templates/table.html b/datasette/templates/table.html index 9c930918..0df08a94 100644 --- a/datasette/templates/table.html +++ b/datasette/templates/table.html @@ -6,6 +6,7 @@ {{- super() -}} + - +
+

Jump to

+

Type to search. Use up and down arrow keys to move through results, Enter to select a result, and Escape to close this menu.

+
+
-
+
Navigate Enter Select @@ -253,6 +316,7 @@ class NavigationSearch extends HTMLElement { setupEventListeners() { const dialog = this.shadowRoot.querySelector("dialog"); const input = this.shadowRoot.querySelector(".search-input"); + const closeButton = this.shadowRoot.querySelector(".close-search"); const resultsContainer = this.shadowRoot.querySelector(".results-container"); @@ -268,8 +332,10 @@ class NavigationSearch extends HTMLElement { const trigger = e.target.closest("[data-navigation-search-open]"); if (trigger) { e.preventDefault(); - trigger.closest("details")?.removeAttribute("open"); - this.openMenu(); + const details = trigger.closest("details"); + const restoreTarget = details?.querySelector("summary") || trigger; + details?.removeAttribute("open"); + this.openMenu(restoreTarget); } }); @@ -294,6 +360,10 @@ class NavigationSearch extends HTMLElement { } }); + closeButton.addEventListener("click", () => { + this.closeMenu(); + }); + // Click on result item resultsContainer.addEventListener("click", (e) => { const clearRecent = e.target.closest("[data-clear-recent-items]"); @@ -317,6 +387,15 @@ class NavigationSearch extends HTMLElement { } }); + dialog.addEventListener("cancel", (e) => { + e.preventDefault(); + this.closeMenu(); + }); + + dialog.addEventListener("close", () => { + this.onMenuClosed(); + }); + // Initial load this.loadInitialData(); } @@ -331,6 +410,106 @@ class NavigationSearch extends HTMLElement { ); } + setElementAttribute(element, name, value) { + if (!element) { + return; + } + if (typeof element.setAttribute === "function") { + element.setAttribute(name, value); + } else { + element[name] = String(value); + } + } + + removeElementAttribute(element, name) { + if (!element) { + return; + } + if (typeof element.removeAttribute === "function") { + element.removeAttribute(name); + } else { + delete element[name]; + } + } + + focusRestoreTarget(trigger) { + if (trigger && typeof trigger.focus === "function") { + return trigger; + } + if ( + document.activeElement && + typeof document.activeElement.focus === "function" + ) { + return document.activeElement; + } + return null; + } + + setNavigationTriggersExpanded(expanded) { + if (typeof document.querySelectorAll !== "function") { + return; + } + document + .querySelectorAll("[data-navigation-search-open]") + .forEach((trigger) => { + this.setElementAttribute( + trigger, + "aria-expanded", + expanded ? "true" : "false", + ); + }); + } + + resultOptionId(index) { + return `${this.listboxId}-option-${index}`; + } + + updateComboboxState() { + const dialog = this.shadowRoot.querySelector("dialog"); + const input = this.shadowRoot.querySelector(".search-input"); + const matches = this.renderedMatches || []; + this.setElementAttribute( + input, + "aria-expanded", + dialog && dialog.open && matches.length > 0 ? "true" : "false", + ); + + if ( + dialog && + dialog.open && + this.selectedIndex >= 0 && + this.selectedIndex < matches.length + ) { + this.setElementAttribute( + input, + "aria-activedescendant", + this.resultOptionId(this.selectedIndex), + ); + } else { + this.removeElementAttribute(input, "aria-activedescendant"); + } + } + + setStatus(message) { + const status = this.shadowRoot.querySelector(`#${this.statusId}`); + if (status) { + status.textContent = message || ""; + } + } + + resultsStatus(count, truncated) { + if (truncated) { + return "More than 100 results. Keep typing to narrow the list."; + } + if (count === 0) { + return "No results found."; + } + if (count === 1) { + return "1 result."; + } + return `${count} results.`; + } + loadInitialData() { const itemsAttr = this.getAttribute("items"); if (itemsAttr) { @@ -347,6 +526,11 @@ class NavigationSearch extends HTMLElement { handleSearch(query) { clearTimeout(this.debounceTimer); + if (query.trim()) { + this.setStatus("Searching..."); + } else { + this.setStatus(""); + } this.debounceTimer = setTimeout(() => { const url = this.getAttribute("url"); @@ -369,10 +553,16 @@ class NavigationSearch extends HTMLElement { this.matches = data.matches || []; this.selectedIndex = this.matches.length > 0 ? 0 : -1; this.renderResults(); + if (query.trim()) { + this.setStatus(this.resultsStatus(this.matches.length, data.truncated)); + } else { + this.setStatus(""); + } } catch (e) { console.error("Failed to fetch search results:", e); this.matches = []; this.renderResults(); + this.setStatus("Search failed."); } } @@ -390,6 +580,11 @@ class NavigationSearch extends HTMLElement { } this.selectedIndex = this.matches.length > 0 ? 0 : -1; this.renderResults(); + if (query.trim()) { + this.setStatus(this.resultsStatus(this.matches.length, false)); + } else { + this.setStatus(""); + } } recentItemsStorageKey() { @@ -466,6 +661,7 @@ class NavigationSearch extends HTMLElement { localStorage.setItem(this.recentItemsStorageKey(), "[]"); } this.renderResults(); + this.setStatus("Recent items cleared."); } jumpSections() { @@ -526,6 +722,7 @@ class NavigationSearch extends HTMLElement { : ""; return `
`; if (renderedMatches.length) { if ( @@ -568,33 +766,43 @@ class NavigationSearch extends HTMLElement { if (renderedMatches.length === 0) { if (startBlock) { - container.innerHTML = startBlock; + container.innerHTML = startBlock + emptyListbox; this.renderJumpSections(container, jumpSections); } else if (showStartContent) { - container.innerHTML = ""; + container.innerHTML = emptyListbox; } else { const message = input.value.trim() ? "No results found" : "Start typing to search..."; - container.innerHTML = `
${message}
`; + container.innerHTML = `${emptyListbox}
${message}
`; } + this.updateComboboxState(); return; } - const recentHtml = recentItems.length - ? `
Recent
${recentItems + const recentHeading = recentItems.length + ? `
Recent
` + : ""; + const recentGroup = recentItems.length + ? `
${recentItems .map((match, index) => this.resultItemHtml(match, index)) - .join( - "", - )}
` + .join("")}
` + : ""; + const recentActions = recentItems.length + ? `
` : ""; const defaultHtml = defaultMatches .map((match, index) => this.resultItemHtml(match, recentItems.length + index), ) .join(""); - container.innerHTML = startBlock + recentHtml + defaultHtml; + container.innerHTML = + startBlock + + recentHeading + + `
${recentGroup}${defaultHtml}
` + + recentActions; this.renderJumpSections(container, jumpSections); + this.updateComboboxState(); // Scroll selected item into view if (this.selectedIndex >= 0) { @@ -641,17 +849,20 @@ class NavigationSearch extends HTMLElement { // Navigate to URL window.location.href = match.url; - this.closeMenu(); + this.closeMenu({ restoreFocus: false }); } } - openMenu() { + openMenu(trigger) { const dialog = this.shadowRoot.querySelector("dialog"); const input = this.shadowRoot.querySelector(".search-input"); + this.restoreFocusTarget = this.focusRestoreTarget(trigger); + this.shouldRestoreFocus = true; if (!dialog.open) { dialog.showModal(); } + this.setNavigationTriggersExpanded(true); input.value = ""; input.focus(); @@ -659,11 +870,33 @@ class NavigationSearch extends HTMLElement { this.matches = []; this.selectedIndex = -1; this.renderResults(); + this.setStatus(""); } - closeMenu() { + closeMenu(options = {}) { const dialog = this.shadowRoot.querySelector("dialog"); - dialog.close(); + this.shouldRestoreFocus = options.restoreFocus !== false; + if (dialog.open) { + dialog.close(); + } else { + this.onMenuClosed(); + } + } + + onMenuClosed() { + const input = this.shadowRoot.querySelector(".search-input"); + this.setElementAttribute(input, "aria-expanded", "false"); + this.removeElementAttribute(input, "aria-activedescendant"); + this.setNavigationTriggersExpanded(false); + this.setStatus(""); + if ( + this.shouldRestoreFocus && + this.restoreFocusTarget && + typeof this.restoreFocusTarget.focus === "function" + ) { + this.restoreFocusTarget.focus(); + } + this.restoreFocusTarget = null; } escapeHtml(text) { diff --git a/datasette/templates/base.html b/datasette/templates/base.html index 819715ba..e1767deb 100644 --- a/datasette/templates/base.html +++ b/datasette/templates/base.html @@ -30,7 +30,7 @@ + {% endblock %} diff --git a/datasette/views/database.py b/datasette/views/database.py index de02cd0f..3c660bc7 100644 --- a/datasette/views/database.py +++ b/datasette/views/database.py @@ -487,9 +487,9 @@ def _as_optional_bool(value, name): raise QueryValidationError("{} must be 0 or 1".format(name)) -def _query_list_limit(value): +def _query_list_limit(value, default=50): if value in (None, ""): - return 50 + return default try: return min(max(1, int(value)), 1000) except ValueError as ex: @@ -1136,7 +1136,10 @@ class QueryListView(BaseView): database = await self.database_name(request) format_ = request.url_vars.get("format") or "html" try: - limit = _query_list_limit(request.args.get("_size")) + limit = _query_list_limit( + request.args.get("_size"), + default=20 if format_ == "html" else 50, + ) is_write = _as_optional_bool(request.args.get("is_write"), "is_write") is_published = _as_optional_bool( request.args.get("is_published"), "is_published" @@ -1175,6 +1178,9 @@ class QueryListView(BaseView): data = { "ok": True, "database": database, + "database_color": ( + self.ds.get_database(database).color if database is not None else None + ), "queries": page["queries"], "next": page["next"], "next_url": next_url, diff --git a/tests/test_queries.py b/tests/test_queries.py index c31d7205..b7416ac7 100644 --- a/tests/test_queries.py +++ b/tests/test_queries.py @@ -451,12 +451,34 @@ async def test_query_list_search_filter_and_html(): assert html_response.status_code == 200 assert "Demo query 02" in html_response.text assert "Demo query 01" not in html_response.text + assert 'class="query-list-results"' in html_response.text + assert "Mode" in html_response.text + assert 'type="radio" name="is_published" value="1"' in html_response.text assert json_response.json()["queries"][0]["name"] == "demo_query_02" assert [query["name"] for query in filtered_response.json()["queries"]] == [ "private_query" ] +@pytest.mark.asyncio +async def test_query_list_html_defaults_to_twenty_and_shows_pagination(): + ds = Datasette(memory=True) + ds.root_enabled = True + ds.add_memory_database("query_list_html_pagination", name="data") + await ds.invoke_startup() + await add_numbered_queries(ds, "data", 25) + + response = await ds.client.get("/data/-/queries", actor={"id": "root"}) + json_response = await ds.client.get("/data/-/queries.json", actor={"id": "root"}) + + assert response.status_code == 200 + assert response.text.count('aria-label="Query pagination"') == 1 + assert "Demo query 20" in response.text + assert "Demo query 21" not in response.text + assert 'href="/data/-/queries?_next=' in response.text + assert len(json_response.json()["queries"]) == 25 + + @pytest.mark.asyncio async def test_global_query_list_api_and_html(): ds = Datasette(memory=True) @@ -519,7 +541,8 @@ async def test_global_query_list_api_and_html(): ("beta", "beta_first"), ] assert html_response.status_code == 200 - assert 'href="/beta">beta:' in html_response.text + assert 'Database' in html_response.text + assert 'class="query-list-database" href="/beta">beta' in html_response.text assert "Beta first" in html_response.text assert "Alpha first" not in html_response.text From f1dd86ebfb01644fead19f9f007b9b76f863d72e Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Mon, 25 May 2026 14:05:26 -0700 Subject: [PATCH 139/176] Tweak URL designs of new endpoints --- datasette/app.py | 6 +++--- datasette/templates/database.html | 2 +- datasette/templates/execute_write.html | 2 +- datasette/templates/query.html | 2 +- datasette/templates/query_create.html | 2 +- docs/json_api.rst | 6 +++--- queries-plan.md | 4 ++-- tests/test_html.py | 4 ++-- tests/test_queries.py | 22 +++++++++++----------- 9 files changed, 25 insertions(+), 25 deletions(-) diff --git a/datasette/app.py b/datasette/app.py index 90e41521..232aa0cf 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -2745,11 +2745,11 @@ class Datasette: ) add_route( QueryInsertView.as_view(self), - r"/(?P[^\/\.]+)/-/queries/-/insert$", + r"/(?P[^\/\.]+)/-/queries/insert$", ) add_route( ExecuteWriteAnalyzeView.as_view(self), - r"/(?P[^\/\.]+)/-/execute-write/-/analyze$", + r"/(?P[^\/\.]+)/-/execute-write/analyze$", ) add_route( ExecuteWriteView.as_view(self), @@ -2761,7 +2761,7 @@ class Datasette: ) add_route( QueryParametersView.as_view(self), - r"/(?P[^\/\.]+)/-/query/-/parameters$", + r"/(?P[^\/\.]+)/-/query/parameters$", ) add_route( wrap_view(QueryView, self), diff --git a/datasette/templates/database.html b/datasette/templates/database.html index 0c9ec94c..62f9c620 100644 --- a/datasette/templates/database.html +++ b/datasette/templates/database.html @@ -26,7 +26,7 @@ {% block description_source_license %}{% include "_description_source_license.html" %}{% endblock %} {% if allow_execute_sql %} -
+

Custom SQL query

{% set parameter_names = [] %} diff --git a/datasette/templates/execute_write.html b/datasette/templates/execute_write.html index 9b522f66..46f58c3b 100644 --- a/datasette/templates/execute_write.html +++ b/datasette/templates/execute_write.html @@ -95,7 +95,7 @@

{{ execution_message }}{% for link in execution_links %} {{ link.label }}{% endfor %}

{% endif %} - + {% if write_template_tables %}
diff --git a/datasette/templates/query.html b/datasette/templates/query.html index 3bcc7178..f74d21f1 100644 --- a/datasette/templates/query.html +++ b/datasette/templates/query.html @@ -37,7 +37,7 @@ {% block description_source_license %}{% include "_description_source_license.html" %}{% endblock %} - +

Custom SQL query{% if display_rows %} returning {% if truncated %}more than {% endif %}{{ "{:,}".format(display_rows|length) }} row{% if display_rows|length == 1 %}{% else %}s{% endif %}{% endif %}{% if not query_error %} ({{ show_hide_text }}) {% endif %}

diff --git a/datasette/templates/query_create.html b/datasette/templates/query_create.html index fb2599d2..3c027def 100644 --- a/datasette/templates/query_create.html +++ b/datasette/templates/query_create.html @@ -17,7 +17,7 @@

Create query

- +


diff --git a/docs/json_api.rst b/docs/json_api.rst index 91ed5306..dd54c459 100644 --- a/docs/json_api.rst +++ b/docs/json_api.rst @@ -525,7 +525,7 @@ Creating saved queries in the UI Creating saved queries ~~~~~~~~~~~~~~~~~~~~~~ -``POST //-/queries/-/insert`` creates a saved query. This requires ``execute-sql`` and ``insert-query`` for the database. +``POST //-/queries/insert`` creates a saved query. This requires ``execute-sql`` and ``insert-query`` for the database. .. _QueryParametersView: .. _ExecuteWriteView: @@ -534,13 +534,13 @@ Creating saved queries Executing write SQL ~~~~~~~~~~~~~~~~~~~ -``GET //-/query/-/parameters?sql=...`` returns the named parameters used by a SQL query. This requires ``execute-sql`` for the database. +``GET //-/query/parameters?sql=...`` returns the named parameters used by a SQL query. This requires ``execute-sql`` for the database. ``GET //-/execute-write`` displays a form for executing writable SQL. A ``?sql=`` query string pre-populates the form without executing it. ``POST //-/execute-write`` executes writable SQL. This requires ``execute-write-sql`` for the database plus the relevant table-level write permissions. -``GET //-/execute-write/-/analyze?sql=...`` returns the derived parameters plus the write operations that SQL would need in order to execute. +``GET //-/execute-write/analyze?sql=...`` returns the derived parameters plus the write operations that SQL would need in order to execute. .. _QueryDefinitionView: diff --git a/queries-plan.md b/queries-plan.md index a708e887..72427df2 100644 --- a/queries-plan.md +++ b/queries-plan.md @@ -211,7 +211,7 @@ JSON endpoints should follow Datasette's existing write API style: use `POST` pl Endpoints: - `GET /-/queries` and `GET /{database}/-/queries` show searchable HTML query browsers. `GET /-/queries.json` lists query definitions across every database the actor can view; `GET /{database}/-/queries.json` scopes that list to one database. Both JSON endpoints use cursor pagination with `_next` and `_size`. -- `POST /{database}/-/queries/-/insert` creates a query. +- `POST /{database}/-/queries/insert` creates a query. - `GET /{database}/{query}/-/definition` returns one query definition without executing it. - `POST /{database}/{query}/-/update` updates one query. - `POST /{database}/{query}/-/delete` deletes one query. @@ -388,7 +388,7 @@ The read methods should reconstruct the existing dictionary shape used by query On `/{database}/-/query`, if the actor has both `execute-sql` and `insert-query`, show a save control for valid read-only SQL. That page already executes read-only arbitrary SQL, so the first UI can stay read-only even though the JSON API can accept writable SQL after `Database.analyze_sql()` validation. -The save form should call `POST /{database}/-/queries/-/insert` and default to `is_published=false`. +The save form should call `POST /{database}/-/queries/insert` and default to `is_published=false`. If the actor also has `publish-query`, include a publish control. The UI copy should make it clear that publishing allows people without arbitrary SQL permission to run this query. diff --git a/tests/test_html.py b/tests/test_html.py index b49391a6..8cda6dba 100644 --- a/tests/test_html.py +++ b/tests/test_html.py @@ -329,7 +329,7 @@ async def test_query_parameter_form_fields(ds_client): ' ' in response.text ) - assert 'data-parameters-url="/fixtures/-/query/-/parameters"' in response.text + assert 'data-parameters-url="/fixtures/-/query/parameters"' in response.text assert 'id="sql-parameters-section"' in response.text assert "setupSqlParameterRefresh" in response.text response2 = await ds_client.get("/fixtures/-/query?sql=select+:name&name=hello") @@ -344,7 +344,7 @@ async def test_query_parameter_form_fields(ds_client): async def test_database_page_sql_parameter_refresh_markup(ds_client): response = await ds_client.get("/fixtures") assert response.status_code == 200 - assert 'data-parameters-url="/fixtures/-/query/-/parameters"' in response.text + assert 'data-parameters-url="/fixtures/-/query/parameters"' in response.text assert 'id="sql-parameters-section"' in response.text assert "setupSqlParameterRefresh" in response.text diff --git a/tests/test_queries.py b/tests/test_queries.py index b7416ac7..57920584 100644 --- a/tests/test_queries.py +++ b/tests/test_queries.py @@ -356,7 +356,7 @@ async def test_query_insert_api_creates_read_only_query(): await ds.invoke_startup() response = await ds.client.post( - "/data/-/queries/-/insert", + "/data/-/queries/insert", actor={"id": "root"}, json={ "query": { @@ -568,7 +568,7 @@ async def test_query_insert_api_publish_requires_publish_query(): await ds.invoke_startup() response = await ds.client.post( - "/data/-/queries/-/insert", + "/data/-/queries/insert", actor={"id": "writer"}, json={"query": {"name": "public", "sql": "select 1", "is_published": True}}, ) @@ -586,7 +586,7 @@ async def test_query_insert_api_creates_writable_query(): await ds.invoke_startup() response = await ds.client.post( - "/data/-/queries/-/insert", + "/data/-/queries/insert", actor={"id": "root"}, json={ "query": { @@ -603,7 +603,7 @@ async def test_query_insert_api_creates_writable_query(): assert query["parameters"] == ["name"] bad_response = await ds.client.post( - "/data/-/queries/-/insert", + "/data/-/queries/insert", actor={"id": "root"}, json={ "query": { @@ -671,7 +671,7 @@ async def test_query_insert_api_rejects_magic_parameters(): await ds.invoke_startup() response = await ds.client.post( - "/data/-/queries/-/insert", + "/data/-/queries/insert", actor={"id": "root"}, json={"query": {"name": "magic", "sql": "select :_actor_id"}}, ) @@ -742,7 +742,7 @@ async def test_execute_write_get_prepopulates_without_executing(): assert 'data-sql-template="insert"' in response.text assert 'data-sql-template="update"' in response.text assert 'data-sql-template="delete"' in response.text - assert 'data-analyze-url="/data/-/execute-write/-/analyze"' in response.text + assert 'data-analyze-url="/data/-/execute-write/analyze"' in response.text assert 'addEventListener("paste"' in response.text assert "setupSqlParameterRefresh" in response.text assert '' in response.text @@ -771,12 +771,12 @@ async def test_execute_write_analyze_endpoint_uses_sql_only(): await ds.invoke_startup() response = await ds.client.get( - "/data/-/execute-write/-/analyze", + "/data/-/execute-write/analyze", actor={"id": "root"}, params={"sql": "insert into dogs (name) values (:name)"}, ) read_only_response = await ds.client.get( - "/data/-/execute-write/-/analyze", + "/data/-/execute-write/analyze", actor={"id": "root"}, params={"sql": "select * from dogs where name = :name"}, ) @@ -818,19 +818,19 @@ async def test_query_parameters_endpoint_uses_get_sql_only(): await ds.invoke_startup() response = await ds.client.get( - "/data/-/query/-/parameters", + "/data/-/query/parameters", actor={"id": "root"}, params={ "sql": "select * from dogs where name = :name and id = :id", }, ) permission_denied_response = await ds.client.get( - "/data/-/query/-/parameters", + "/data/-/query/parameters", actor={"id": "not-root"}, params={"sql": "select * from dogs where name = :name"}, ) magic_parameter_response = await ds.client.get( - "/data/-/query/-/parameters", + "/data/-/query/parameters", actor={"id": "root"}, params={"sql": "select :_actor_id"}, ) From 4a1a4d7807fb99203b9053b6d270b265df61f0af Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 26 May 2026 11:59:49 -0700 Subject: [PATCH 140/176] Query is_trusted and is_private properties Refs https://github.com/simonw/datasette/issues/2735#issuecomment-4547270516 Diff explanation: https://gist.github.com/simonw/1e4de6c4b041a51968eb273ee96dec1f --- datasette/app.py | 39 ++-- datasette/default_actions.py | 7 - datasette/default_permissions/defaults.py | 100 +++++---- datasette/templates/query_create.html | 4 +- datasette/templates/query_list.html | 65 +++++- datasette/utils/internal_db.py | 3 +- datasette/views/database.py | 79 ++++--- docs/authentication.rst | 10 - docs/internals.rst | 3 +- queries-plan.md | 84 ++++---- tests/test_queries.py | 245 ++++++++++++++++++---- 11 files changed, 421 insertions(+), 218 deletions(-) diff --git a/datasette/app.py b/datasette/app.py index 232aa0cf..3329ee7e 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -618,7 +618,8 @@ class Datasette: fragment=query_config.get("fragment"), parameters=query_config.get("params"), is_write=bool(query_config.get("write")), - is_published=bool(query_config.get("is_published")), + is_private=bool(query_config.get("is_private")), + is_trusted=bool(query_config.get("is_trusted", True)), source="config", on_success_message=query_config.get("on_success_message"), on_success_message_sql=query_config.get("on_success_message_sql"), @@ -1084,7 +1085,8 @@ class Datasette: "parameters": parameters, "is_write": is_write, "write": is_write, - "is_published": bool(row["is_published"]), + "is_private": bool(row["is_private"]), + "is_trusted": bool(row["is_trusted"]), "source": row["source"], "owner_id": row["owner_id"], "on_success_message": options.get("on_success_message"), @@ -1119,7 +1121,8 @@ class Datasette: fragment=None, parameters=None, is_write=False, - is_published=False, + is_private=False, + is_trusted=False, source="plugin", owner_id=None, on_success_message=None, @@ -1144,8 +1147,8 @@ class Datasette: sql_statement = """ INSERT INTO queries ( database_name, name, sql, title, description, description_html, - options, parameters, is_write, is_published, source, owner_id - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + options, parameters, is_write, is_private, is_trusted, source, owner_id + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """ if replace: sql_statement += """ @@ -1157,7 +1160,8 @@ class Datasette: options = excluded.options, parameters = excluded.parameters, is_write = excluded.is_write, - is_published = excluded.is_published, + is_private = excluded.is_private, + is_trusted = excluded.is_trusted, source = excluded.source, owner_id = excluded.owner_id, updated_at = CURRENT_TIMESTAMP @@ -1174,7 +1178,8 @@ class Datasette: options_json, parameters_json, int(bool(is_write)), - int(bool(is_published)), + int(bool(is_private)), + int(bool(is_trusted)), source, owner_id, ], @@ -1193,7 +1198,8 @@ class Datasette: fragment=UNCHANGED, parameters=UNCHANGED, is_write=UNCHANGED, - is_published=UNCHANGED, + is_private=UNCHANGED, + is_trusted=UNCHANGED, source=UNCHANGED, owner_id=UNCHANGED, on_success_message=UNCHANGED, @@ -1209,7 +1215,8 @@ class Datasette: "description_html": description_html, "parameters": parameters, "is_write": is_write, - "is_published": is_published, + "is_private": is_private, + "is_trusted": is_trusted, "source": source, "owner_id": owner_id, } @@ -1227,7 +1234,7 @@ class Datasette: for field, value in fields.items(): if value is UNCHANGED: continue - if field in {"is_write", "is_published"}: + if field in {"is_write", "is_private", "is_trusted"}: value = int(bool(value)) elif field == "parameters": value = json.dumps(list(value or [])) @@ -1300,7 +1307,8 @@ class Datasette: cursor=None, q=None, is_write=None, - is_published=None, + is_private=None, + is_trusted=None, source=None, owner_id=None, include_private=False, @@ -1372,9 +1380,12 @@ class Datasette: if is_write is not None: where_clauses.append("q.is_write = :query_is_write") params["query_is_write"] = int(bool(is_write)) - if is_published is not None: - where_clauses.append("q.is_published = :query_is_published") - params["query_is_published"] = int(bool(is_published)) + if is_private is not None: + where_clauses.append("q.is_private = :query_is_private") + params["query_is_private"] = int(bool(is_private)) + if is_trusted is not None: + where_clauses.append("q.is_trusted = :query_is_trusted") + params["query_is_trusted"] = int(bool(is_trusted)) if source is not None: where_clauses.append("q.source = :query_source") params["query_source"] = source diff --git a/datasette/default_actions.py b/datasette/default_actions.py index 6787b80e..6a1f77b8 100644 --- a/datasette/default_actions.py +++ b/datasette/default_actions.py @@ -68,13 +68,6 @@ def register_actions(): resource_class=DatabaseResource, also_requires="execute-sql", ), - Action( - name="publish-query", - abbr="pq", - description="Publish saved queries for actors without execute-sql", - resource_class=DatabaseResource, - also_requires="insert-query", - ), # Table-level actions (child-level) Action( name="view-table", diff --git a/datasette/default_permissions/defaults.py b/datasette/default_permissions/defaults.py index 58deea01..dfd8d3e9 100644 --- a/datasette/default_permissions/defaults.py +++ b/datasette/default_permissions/defaults.py @@ -26,6 +26,32 @@ DEFAULT_ALLOW_ACTIONS = frozenset( ) +def _configured_query_restriction_selects(datasette: "Datasette") -> tuple[list[str], dict]: + selects = [] + params = {} + for index, (database_name, db_config) in enumerate( + ((datasette.config or {}).get("databases") or {}).items() + ): + for query_name, query_config in (db_config.get("queries") or {}).items(): + if isinstance(query_config, dict) and query_config.get("is_private"): + continue + parent_param = f"query_config_parent_{index}_{len(selects)}" + child_param = f"query_config_child_{index}_{len(selects)}" + selects.append( + f""" + SELECT :{parent_param} AS parent, :{child_param} AS child + WHERE NOT EXISTS ( + SELECT 1 FROM queries + WHERE database_name = :{parent_param} + AND name = :{child_param} + ) + """ + ) + params[parent_param] = database_name + params[child_param] = query_name + return selects, params + + @hookimpl(specname="permission_resources_sql") async def default_allow_sql_check( datasette: "Datasette", @@ -93,61 +119,45 @@ async def default_query_permissions_sql( if action != "view-query": return None - execute_sql = await datasette.allowed_resources_sql( - action="execute-sql", actor=actor - ) - sql = execute_sql.sql - params = {} - for key, value in execute_sql.params.items(): - new_key = f"query_execute_sql_{key}" - sql = sql.replace(f":{key}", f":{new_key}") - params[new_key] = value - - trusted_writable_sql = "" + params = {"query_owner_id": actor_id} + rule_sqls = [] if not datasette.default_deny: - trusted_writable_sql = """ - UNION ALL + rule_sqls.append( + """ SELECT database_name AS parent, name AS child, 1 AS allow, - 'trusted writable query' AS reason + 'non-private query' AS reason FROM queries - WHERE is_write = 1 - AND source IN ('config', 'plugin') - """ + WHERE is_private = 0 + """ + ) - user_writable_sql = "" if actor_id is not None: - params["query_owner_id"] = actor_id - user_writable_sql = """ - UNION ALL + rule_sqls.append( + """ SELECT database_name AS parent, name AS child, 1 AS allow, 'query owner' AS reason FROM queries - WHERE is_write = 1 - AND source = 'user' - AND owner_id = :query_owner_id + WHERE owner_id = :query_owner_id + """ + ) + + config_restriction_selects, config_restriction_params = ( + _configured_query_restriction_selects(datasette) + ) + + restriction_sqls = [ """ + SELECT database_name AS parent, name AS child + FROM queries + WHERE is_private = 0 + OR owner_id = :query_owner_id + """ + ] + restriction_sqls.extend(config_restriction_selects) + params.update(config_restriction_params) return PermissionSQL( - sql=f""" - WITH execute_sql_allowed AS ( - {sql} - ) - SELECT database_name AS parent, name AS child, 1 AS allow, - 'published query' AS reason - FROM queries - WHERE is_write = 0 - AND is_published = 1 - UNION ALL - SELECT q.database_name AS parent, q.name AS child, 1 AS allow, - 'execute-sql allows query' AS reason - FROM queries q - JOIN execute_sql_allowed es - ON es.parent = q.database_name - AND es.child IS NULL - WHERE q.is_write = 0 - AND q.is_published = 0 - {trusted_writable_sql} - {user_writable_sql} - """, + sql="\nUNION ALL\n".join(rule_sqls) if rule_sqls else None, + restriction_sql="\nUNION ALL\n".join(restriction_sqls), params=params, ) diff --git a/datasette/templates/query_create.html b/datasette/templates/query_create.html index 3c027def..686d971e 100644 --- a/datasette/templates/query_create.html +++ b/datasette/templates/query_create.html @@ -27,9 +27,7 @@

- {% if can_publish %} -

- {% endif %} +

{% if sql and analysis_is_write %}

Execute write SQL

{% endif %} diff --git a/datasette/templates/query_list.html b/datasette/templates/query_list.html index dbd607ab..25259b3d 100644 --- a/datasette/templates/query_list.html +++ b/datasette/templates/query_list.html @@ -73,7 +73,7 @@ border-collapse: collapse; font-size: 0.9rem; margin: 0.25rem 0 1rem; - min-width: 36rem; + min-width: 42rem; width: 100%; } .query-list-results th, @@ -100,6 +100,16 @@ font-size: 0.78rem; margin: 0.15rem 0 0; } +.query-list-owner { + color: #39445a; + font-family: var(--font-monospace, monospace); + white-space: nowrap; +} +.query-list-flags { + display: flex; + flex-wrap: wrap; + gap: 0.3rem; +} .query-list-pill { background-color: #eef1f5; border: 1px solid #d7dde5; @@ -116,15 +126,36 @@ background-color: #fff4db; border-color: #e2b64e; } -.query-list-pill-published { +.query-list-pill-public { background-color: #e7f5ec; border-color: #9ecfab; color: #267a3e; } -.query-list-pill-unpublished { +.query-list-pill-private { background-color: #f7edf0; border-color: #dbb8c1; } +.query-list-pill-trusted { + background-color: #e7f5ec; + border-color: #9ecfab; + color: #267a3e; +} +.query-list-empty { + color: #6b7280; +} +.query-list-footnotes { + border-top: 1px solid #d7dde5; + color: #4f5b6d; + font-size: 0.82rem; + margin: 0.35rem 0 1rem; + padding-top: 0.55rem; +} +.query-list-footnotes p { + margin: 0.25rem 0; +} +.query-list-footnotes .query-list-pill { + margin-right: 0.35rem; +} .query-list-pagination a { border: 1px solid #007bff; border-radius: 0.25rem; @@ -177,10 +208,10 @@
- Publication - - - + Visibility + + +
@@ -191,8 +222,8 @@
{% if show_database %}{% endif %} - - + + @@ -205,12 +236,24 @@ {{ query.title or query.name }}{% if query.private %} 🔒{% endif %} {% if query.description %}

{{ query.description }}

{% endif %} - - + + {% endfor %}
DatabaseQueryModePublicationOwnerFlags
{% if query.is_write %}Writable{% else %}Read-only{% endif %}{% if query.is_published %}Published{% else %}Unpublished{% endif %}{% if query.owner_id is not none %}{{ query.owner_id }}{% else %}-{% endif %} + + {% if query.is_write %}Writable{% else %}Read-only{% endif %} + {% if query.is_private %}Private{% endif %} + {% if query.is_trusted %}Trusted{% endif %} + +
+ {% if show_private_note or show_trusted_note %} +
+ {% if show_private_note %}

PrivateOnly the owning actor can view this query.

{% endif %} + {% if show_trusted_note %}

TrustedExecution skips the usual SQL and write permission checks after view-query allows access.

{% endif %} +
+ {% endif %} {% else %}

No queries found.

{% endif %} diff --git a/datasette/utils/internal_db.py b/datasette/utils/internal_db.py index 9c693b0a..bf172667 100644 --- a/datasette/utils/internal_db.py +++ b/datasette/utils/internal_db.py @@ -123,7 +123,8 @@ async def initialize_metadata_tables(db): options TEXT NOT NULL DEFAULT '{}', parameters TEXT NOT NULL DEFAULT '[]', is_write INTEGER NOT NULL DEFAULT 0 CHECK (is_write IN (0, 1)), - is_published INTEGER NOT NULL DEFAULT 0 CHECK (is_published IN (0, 1)), + is_private INTEGER NOT NULL DEFAULT 0 CHECK (is_private IN (0, 1)), + is_trusted INTEGER NOT NULL DEFAULT 0 CHECK (is_trusted IN (0, 1)), source TEXT NOT NULL DEFAULT 'user', owner_id TEXT, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, diff --git a/datasette/views/database.py b/datasette/views/database.py index 3c660bc7..91e9c350 100644 --- a/datasette/views/database.py +++ b/datasette/views/database.py @@ -428,7 +428,7 @@ _query_fields = { "fragment", "parameters", "params", - "is_published", + "is_private", "on_success_message", "on_success_message_sql", "on_success_redirect", @@ -571,7 +571,7 @@ async def _check_query_name(db, name, *, existing=False): raise QueryValidationError("Query name conflicts with a table or view") -async def _analyze_user_query(datasette, db, sql, *, actor, is_published): +async def _analyze_user_query(datasette, db, sql, *, actor): if not sql or not isinstance(sql, str): raise QueryValidationError("SQL is required") derived = _derived_query_parameters(sql) @@ -583,8 +583,6 @@ async def _analyze_user_query(datasette, db, sql, *, actor, is_published): is_write = _analysis_is_write(analysis) if is_write: - if is_published: - raise QueryValidationError("Writable queries cannot be published") try: await datasette.ensure_query_write_permissions( db.name, sql, actor=actor, analysis=analysis @@ -680,6 +678,26 @@ async def _prepare_execute_write(datasette, db, sql, params, actor): return parameter_names, params, analysis +async def _ensure_stored_query_execution_permissions(datasette, db, query, actor): + if query.get("is_trusted"): + return + if query.get("write"): + await datasette.ensure_permission( + action="execute-write-sql", + resource=DatabaseResource(db.name), + actor=actor, + ) + await datasette.ensure_query_write_permissions( + db.name, query["sql"], actor=actor + ) + else: + await datasette.ensure_permission( + action="execute-sql", + resource=DatabaseResource(db.name), + actor=actor, + ) + + async def _execute_write_analysis_data(datasette, db, sql, actor): parameter_names = [] analysis_rows = [] @@ -752,7 +770,7 @@ async def _inserted_row_url(datasette, db, analysis, cursor): def _apply_query_data_types(data): typed = dict(data) - for key in ("hide_sql", "is_published"): + for key in ("hide_sql", "is_private"): if key in typed: typed[key] = _as_bool(typed[key]) return typed @@ -769,20 +787,12 @@ async def _prepare_query_create(datasette, request, db, data): if await datasette.get_query(db.name, name) is not None: raise QueryValidationError("Query already exists") - is_published = _as_bool(data.get("is_published")) is_write, derived, analysis = await _analyze_user_query( datasette, db, data.get("sql"), actor=request.actor, - is_published=is_published, ) - if is_published and not await datasette.allowed( - action="publish-query", - resource=DatabaseResource(db.name), - actor=request.actor, - ): - raise QueryValidationError("Permission denied: need publish-query", status=403) if not is_write and any(data.get(field) for field in _query_write_fields): raise QueryValidationError("Writable query fields require writable SQL") @@ -800,7 +810,8 @@ async def _prepare_query_create(datasette, request, db, data): "fragment": data.get("fragment"), "parameters": parameters, "is_write": is_write, - "is_published": is_published, + "is_private": _as_bool(data.get("is_private", True)), + "is_trusted": False, "source": "user", "owner_id": _actor_id(request.actor), "on_success_message": data.get("on_success_message"), @@ -819,7 +830,6 @@ async def _prepare_query_update(datasette, request, db, existing, update): update = _apply_query_data_types(update) sql = update.get("sql", existing["sql"]) - is_published = update.get("is_published", existing["is_published"]) query_is_write = existing["is_write"] derived = _derived_query_parameters(sql) parameters = None @@ -830,19 +840,7 @@ async def _prepare_query_update(datasette, request, db, existing, update): db, sql, actor=request.actor, - is_published=is_published, ) - elif is_published and query_is_write: - raise QueryValidationError("Writable queries cannot be published") - if is_published and not existing["is_published"]: - if not await datasette.allowed( - action="publish-query", - resource=DatabaseResource(db.name), - actor=request.actor, - ): - raise QueryValidationError( - "Permission denied: need publish-query", status=403 - ) if "parameters" in update or "params" in update: parameters = _coerce_query_parameters( @@ -864,7 +862,7 @@ async def _prepare_query_update(datasette, request, db, existing, update): "fragment": update.get("fragment"), "parameters": parameters, "is_write": query_is_write, - "is_published": is_published, + "is_private": update.get("is_private"), "on_success_message": update.get("on_success_message"), "on_success_message_sql": update.get("on_success_message_sql"), "on_success_redirect": update.get("on_success_redirect"), @@ -1141,8 +1139,8 @@ class QueryListView(BaseView): default=20 if format_ == "html" else 50, ) is_write = _as_optional_bool(request.args.get("is_write"), "is_write") - is_published = _as_optional_bool( - request.args.get("is_published"), "is_published" + is_private = _as_optional_bool( + request.args.get("is_private"), "is_private" ) except QueryValidationError as ex: return _error([ex.message], ex.status) @@ -1154,7 +1152,7 @@ class QueryListView(BaseView): cursor=request.args.get("_next"), q=request.args.get("q") or None, is_write=is_write, - is_published=is_published, + is_private=is_private, source=request.args.get("source") or None, owner_id=request.args.get("owner_id") or None, include_private=True, @@ -1186,12 +1184,14 @@ class QueryListView(BaseView): "next_url": next_url, "has_more": page["has_more"], "limit": page["limit"], + "show_private_note": any(query["is_private"] for query in page["queries"]), + "show_trusted_note": any(query["is_trusted"] for query in page["queries"]), "query_list_path": query_list_path, "show_database": database is None, "filters": { "q": request.args.get("q") or "", "is_write": request.args.get("is_write") or "", - "is_published": request.args.get("is_published") or "", + "is_private": request.args.get("is_private") or "", "source": request.args.get("source") or "", "owner_id": request.args.get("owner_id") or "", }, @@ -1255,11 +1255,6 @@ class QueryCreateView(BaseView): "database_color": db.color, "sql": sql, "parameter_names": parameter_names, - "can_publish": await self.ds.allowed( - action="publish-query", - resource=DatabaseResource(db.name), - actor=request.actor, - ), "analysis_error": analysis_error, "analysis_rows": analysis_rows, "analysis_is_write": bool( @@ -1435,9 +1430,9 @@ class QueryView(View): ): raise Forbidden("You do not have permission to view this query") - if canned_query.get("write") and canned_query.get("source") == "user": - await datasette.ensure_query_write_permissions( - db.name, canned_query["sql"], actor=request.actor + if canned_query.get("write"): + await _ensure_stored_query_execution_permissions( + datasette, db, canned_query, request.actor ) # If database is immutable, return an error @@ -1558,6 +1553,10 @@ class QueryView(View): ) if not visible: raise Forbidden("You do not have permission to view this query") + if not canned_query_write: + await _ensure_stored_query_execution_permissions( + datasette, db, canned_query, request.actor + ) else: await datasette.ensure_permission( diff --git a/docs/authentication.rst b/docs/authentication.rst index b6a4cb7e..6e835c8d 100644 --- a/docs/authentication.rst +++ b/docs/authentication.rst @@ -1299,16 +1299,6 @@ insert-query Actor is allowed to create saved queries in a database. -``resource`` - ``datasette.resources.DatabaseResource(database)`` - ``database`` is the name of the database (string) - -.. _actions_publish_query: - -publish-query -------------- - -Actor is allowed to publish a saved read-only query so actors without ``execute-sql`` can run it. - ``resource`` - ``datasette.resources.DatabaseResource(database)`` ``database`` is the name of the database (string) diff --git a/docs/internals.rst b/docs/internals.rst index b5da7cbf..c76de487 100644 --- a/docs/internals.rst +++ b/docs/internals.rst @@ -2158,7 +2158,8 @@ The internal database schema is as follows: options TEXT NOT NULL DEFAULT '{}', parameters TEXT NOT NULL DEFAULT '[]', is_write INTEGER NOT NULL DEFAULT 0 CHECK (is_write IN (0, 1)), - is_published INTEGER NOT NULL DEFAULT 0 CHECK (is_published IN (0, 1)), + is_private INTEGER NOT NULL DEFAULT 0 CHECK (is_private IN (0, 1)), + is_trusted INTEGER NOT NULL DEFAULT 0 CHECK (is_trusted IN (0, 1)), source TEXT NOT NULL DEFAULT 'user', owner_id TEXT, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, diff --git a/queries-plan.md b/queries-plan.md index 72427df2..f4b8049c 100644 --- a/queries-plan.md +++ b/queries-plan.md @@ -13,9 +13,9 @@ Terminology change: these are now "queries", not "canned queries". Legacy code a - Internal table name: `queries`. - Query definitions should use real columns, not a JSON blob for all options. - Query parameter names live in a `parameters` text column as a JSON array. No default values for parameters in this pass. -- No `queries_database_is_published_idx` index. -- User-created queries require `execute-sql` and `insert-query` on the database. Writable queries additionally require matching table write permissions discovered by `Database.analyze_sql()`. -- `publish-query` is the permission for creating or updating a query so users without `execute-sql` can execute it. +- No separate index is needed for the privacy/trust flags yet. +- User-created queries require `execute-sql` and `insert-query` on the database. They default to private, and writable queries additionally require matching table write permissions discovered by `Database.analyze_sql()`. +- Configured queries default to trusted, which means actors who can view them can execute them without also holding `execute-sql` or the relevant write permissions. Config can opt out with `is_trusted: false`. - Add `update-query` and `delete-query`, so administrators can manage queries created by other users. - Remove the old `canned_queries()` hook from core. If we want compatibility later, build a separate `datasette-old-canned-queries` plugin. - Writable user-created queries can be supported using `Database.analyze_sql()`, provided we fail closed when analysis cannot prove the required permissions. @@ -45,7 +45,8 @@ CREATE TABLE IF NOT EXISTS queries ( options TEXT NOT NULL DEFAULT '{}', parameters TEXT NOT NULL DEFAULT '[]', is_write INTEGER NOT NULL DEFAULT 0 CHECK (is_write IN (0, 1)), - is_published INTEGER NOT NULL DEFAULT 0 CHECK (is_published IN (0, 1)), + is_private INTEGER NOT NULL DEFAULT 0 CHECK (is_private IN (0, 1)), + is_trusted INTEGER NOT NULL DEFAULT 0 CHECK (is_trusted IN (0, 1)), source TEXT NOT NULL DEFAULT 'user', owner_id TEXT, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, @@ -64,11 +65,12 @@ Column notes: - Less common presentation and writable-query behavior lives in `options`, stored as a JSON object. That covers `hide_sql`, `fragment`, `on_success_message`, `on_success_message_sql`, `on_success_redirect`, `on_error_message`, and `on_error_redirect`. - `parameters` is a JSON array of parameter names, stored as text. This preserves explicit parameter order, but does not support labels or default values. - Existing writable query behavior gets `is_write` as a column. Success/error messages, success/error redirects, and `on_success_message_sql` are stored in `options`. -- `is_published` only applies to read-only queries. A writable query can still be public through explicit `view-query` permissions, but the "publish for users without execute-sql" shortcut should be read-only. +- `is_private` means the query is only visible to its owning actor. This is enforced as a permission restriction, so broader `view-query` grants do not expose private rows. +- `is_trusted` means execution skips the usual `execute-sql` or write-permission checks after `view-query` has allowed access. - `source` distinguishes `user`, `config`, and `plugin` rows. - `owner_id` is the actor id for user-created rows. It is `NULL` for config/plugin rows. -No separate index is needed on `(database_name, name)` because the primary key already creates one. Do not add a `queries_database_is_published_idx` index for now. +No separate index is needed on `(database_name, name)` because the primary key already creates one. `QueryResource.resources_sql()` can become: @@ -104,7 +106,6 @@ Remove the old `canned_queries()` hookspec and all core calls to it. If compatib Add core actions: - `insert-query`, database-level, for creating queries in a database. -- `publish-query`, database-level, for marking read-only queries as executable by actors who lack `execute-sql`. - `update-query`, query-level, for modifying existing query definitions. - `delete-query`, query-level, for deleting existing query definitions. @@ -114,17 +115,11 @@ User-created query creation requires: - `insert-query` on `DatabaseResource(database)` - If analysis shows the query is writable, the table-level write permissions described in the writable query section. -Setting `is_published=1` requires: - -- `publish-query` on `DatabaseResource(database)` -- The query must be read-only according to `Database.analyze_sql()`. - Updating an existing query requires: - `update-query` on `QueryResource(database, query)` or default owner permission for a user-owned row. - If the SQL changes, also require `execute-sql` on the database. - If the changed SQL is writable, also require the table-level write permissions described in the writable query section. -- If `is_published` changes from `0` to `1`, also require `publish-query` on the database. Deleting an existing query requires: @@ -133,18 +128,18 @@ Deleting an existing query requires: Default owner permissions: - For `source='user' AND owner_id = actor.id`, grant `update-query` and `delete-query`. -- Do not automatically grant execution if the user no longer has the execution permission described below. +- For `source='user' AND owner_id = actor.id`, grant `view-query`. If the query is private, restriction SQL ensures no other actor sees it through a broader grant. ## Executing queries Default execution rule for read-only queries: -- If `is_published=0`, the actor needs `execute-sql` on the database. -- If `is_published=1`, the actor can execute the query without `execute-sql`. +- If `is_trusted=0`, the actor needs `execute-sql` on the database. +- If `is_trusted=1`, the actor can execute the query without `execute-sql`, provided `view-query` allows access. Default execution rule for user-created writable queries: -- `is_published` must be `0`. +- `is_trusted` must be `0`. - The actor must have `view-query`. - The actor must currently have every write permission required by fresh `Database.analyze_sql()` results for the query SQL. @@ -152,14 +147,14 @@ Implementation: - Remove `view-query` from the broad `DEFAULT_ALLOW_ACTIONS` set. - Replace it with query-aware default `view-query` permission SQL. -- For `is_published=1 AND is_write=0`, emit a child-level `view-query` allow. -- For `is_published=0 AND is_write=0`, emit child-level `view-query` allows for queries whose parent database is in the actor's `execute-sql` allowed resources. -- For `is_write=1 AND source='user'`, emit `view-query` only for the owner or actors with explicit `view-query` permission, then have `QueryView` perform the fresh analysis/table-permission check before execution. -- For trusted writable queries, preserve current behavior by emitting child-level `view-query` allows for `is_write=1 AND source IN ('config', 'plugin')` when Datasette is not running with `--default-deny`. +- Emit default `view-query` allows for non-private rows when Datasette is not running with `--default-deny`. +- Emit default `view-query` allows for the owning actor. +- Use `restriction_sql` to limit private rows to their owner even when broader `view-query` permissions exist. +- Have `QueryView` perform the fresh `execute-sql` or table-permission check before execution unless the row has `is_trusted=1`. -For read-only queries this keeps `QueryView` simple: it checks `view-query` for the query resource, and the default permission hook encodes the relationship with `execute-sql`. User-created writable queries need one additional runtime permission check because their required table permissions are derived from fresh SQL analysis. +For read-only queries this keeps `QueryView` explicit: it checks `view-query` for the query resource, then checks `execute-sql` unless the row is trusted. User-created writable queries need one additional runtime permission check because their required table permissions are derived from fresh SQL analysis. -Explicit deny rules should still be able to block a published query. +Explicit deny rules should still be able to block a query, and `--default-deny` still blocks trusted queries unless something grants `view-query`. ## Writable queries @@ -180,7 +175,7 @@ Validation flow for user-created queries: 1. Derive named parameters from the SQL and pass harmless placeholder values into `db.analyze_sql()` so SQLite can prepare statements with bindings. 2. If analysis raises a SQLite error, reject the query. 3. If every table access is `read`, treat the query as read-only and require `execute-sql` plus `insert-query`/`update-query` as described above. -4. If any table access is `insert`, `update`, or `delete`, treat the query as writable and force `is_published=0`. +4. If any table access is `insert`, `update`, or `delete`, treat the query as writable and force `is_trusted=0`. 5. Reject writable user-created queries that access a database other than the database they are being saved against, until `analyze_sql()` can reliably map attached SQLite schemas back to Datasette database names. 6. For every write access returned by analysis, require the corresponding permission on `TableResource(access.database, access.table)`: - `insert` -> `insert-row` @@ -200,7 +195,7 @@ Fail closed cases for user-created writable queries: - Analysis reports any write operation that cannot be mapped to a Datasette table resource. - Analysis reports writes outside the target database. - The actor lacks any required table write permission. -- `is_published=1` is requested. +- `is_trusted=1` is requested through the user-facing API. This gives us writable user-created queries without letting `execute-sql` alone become a path to create arbitrary write endpoints. @@ -225,7 +220,7 @@ Create request: "sql": "select * from customers order by revenue desc limit 20", "title": "Top customers", "description": "Highest revenue customers", - "is_published": false, + "is_private": true, "parameters": ["region"] } } @@ -242,7 +237,8 @@ Successful create returns `201` and the created query definition: "sql": "select * from customers order by revenue desc limit 20", "title": "Top customers", "description": "Highest revenue customers", - "is_published": false, + "is_private": true, + "is_trusted": false, "parameters": ["region"] } } @@ -254,7 +250,7 @@ Update request, imitating `RowUpdateView`: { "update": { "title": "Top customers by revenue", - "is_published": true + "is_private": false }, "return": true } @@ -270,7 +266,8 @@ Successful update returns `{"ok": true}` by default. With `"return": true`, retu "name": "top_customers", "sql": "select * from customers order by revenue desc limit 20", "title": "Top customers by revenue", - "is_published": true + "is_private": false, + "is_trusted": false } } ``` @@ -317,7 +314,8 @@ await datasette.add_query( fragment=None, parameters=None, is_write=False, - is_published=False, + is_private=False, + is_trusted=False, source="plugin", owner_id=None, on_success_message=None, @@ -340,7 +338,8 @@ await datasette.update_query( fragment=UNCHANGED, parameters=UNCHANGED, is_write=UNCHANGED, - is_published=UNCHANGED, + is_private=UNCHANGED, + is_trusted=UNCHANGED, source=UNCHANGED, owner_id=UNCHANGED, on_success_message=UNCHANGED, @@ -360,7 +359,8 @@ await datasette.list_queries( cursor=None, q=None, is_write=None, - is_published=None, + is_private=None, + is_trusted=None, source=None, owner_id=None, ) @@ -382,15 +382,13 @@ For column-backed fields, `None` should write SQL `NULL`. For option fields, `No Implementation detail: build the `UPDATE` statement dynamically from fields whose value is not `UNCHANGED`, validate non-nullable fields before writing, and update `updated_at` whenever at least one field changes. -The read methods should reconstruct the existing dictionary shape used by query execution and templates, with `name`, `sql`, display fields, write fields, `params`, `is_published`, `owner_id`, and `source`. `parameters` should be returned as the decoded JSON array and exposed as `params` where existing query execution code expects that key. Option values should be unpacked from the `options` JSON object and returned as the same top-level keys accepted by `add_query()` and `update_query()`. +The read methods should reconstruct the existing dictionary shape used by query execution and templates, with `name`, `sql`, display fields, write fields, `params`, `is_private`, `is_trusted`, `owner_id`, and `source`. `parameters` should be returned as the decoded JSON array and exposed as `params` where existing query execution code expects that key. Option values should be unpacked from the `options` JSON object and returned as the same top-level keys accepted by `add_query()` and `update_query()`. ## Query page save UI On `/{database}/-/query`, if the actor has both `execute-sql` and `insert-query`, show a save control for valid read-only SQL. That page already executes read-only arbitrary SQL, so the first UI can stay read-only even though the JSON API can accept writable SQL after `Database.analyze_sql()` validation. -The save form should call `POST /{database}/-/queries/insert` and default to `is_published=false`. - -If the actor also has `publish-query`, include a publish control. The UI copy should make it clear that publishing allows people without arbitrary SQL permission to run this query. +The save form should call `POST /{database}/-/queries/insert` and default to `is_private=true`. On `/{database}`, show a preview of the first 5 visible queries using `list_queries(..., limit=5)`. If the page has `has_more`, show a link to `/{database}/-/queries` rather than rendering hundreds or thousands of query links inline. The full `/{database}/-/queries` page provides search, filters, and cursor pagination. The global `/-/queries` page reuses the same interface and shows the database for each query. @@ -403,7 +401,7 @@ This page should require `execute-sql` and `insert-query` to access. It should p - Read-only - Writable -Read-only mode can share the same fields as the arbitrary SQL save flow: name, title, description, parameters, and optional published status if the actor has `publish-query`. +Read-only mode can share the same fields as the arbitrary SQL save flow: name, title, description, parameters, and privacy status. Writable mode should always run `Database.analyze_sql()` and show an analysis panel before saving: @@ -413,7 +411,7 @@ Writable mode should always run `Database.analyze_sql()` and show an analysis pa - whether the actor has that permission - source, when the operation comes from a trigger or view -The Save button should be disabled until analysis succeeds and every required table write permission is allowed. Writable mode should not show a publish control, because user-created writable queries cannot be published. +The Save button should be disabled until analysis succeeds and every required table write permission is allowed. The existing edit-SQL flow from query pages can continue to point back to arbitrary SQL. A later enhancement can add "update this query" when the actor owns it or has `update-query`. @@ -427,14 +425,16 @@ The existing edit-SQL flow from query pages can continue to point back to arbitr - `QueryResource.resources_sql()` returns rows from `queries`. - Database page and `/-/jump` list queries from the internal DB. - `view-query` is no longer globally default-allowed; default query permissions come from the query-aware hook. -- Unpublished read-only query requires `execute-sql` to execute. -- Published read-only query can be executed without `execute-sql`. -- Setting `is_published=true` requires `publish-query`. +- Private query is only visible to its owner, even when a broader `view-query` rule applies. +- Non-trusted read-only query requires `execute-sql` to execute. +- Trusted read-only query can be executed without `execute-sql` after `view-query` passes. +- Config queries default to trusted and can opt out with `is_trusted: false`. +- User API rejects client-supplied `is_trusted`. - User-created query requires both `execute-sql` and `insert-query`. - User-created writable query creation uses `Database.analyze_sql()` and requires matching `insert-row`, `update-row`, and/or `delete-row` permissions for every reported write access. - `/{database}/-/queries/-/create` provides the writable-query authoring UI with an analysis panel and disabled save until all required write permissions pass. - User-created writable query execution re-runs `Database.analyze_sql()` and re-checks table write permissions. -- User-created writable query cannot be published. +- User-created writable query cannot be trusted through the user API. - Query update uses `POST /{database}/{query}/-/update` with an `{"update": {...}}` body. - Query delete uses `POST /{database}/{query}/-/delete`. - There are no `PATCH` or HTTP `DELETE` routes for query management. diff --git a/tests/test_queries.py b/tests/test_queries.py index 57920584..c97b5733 100644 --- a/tests/test_queries.py +++ b/tests/test_queries.py @@ -15,7 +15,6 @@ async def add_numbered_queries(ds, database, count): "select {} as query_number".format(i), title="Demo query {:02d}".format(i), description="Seeded demo query number {:02d}".format(i), - is_published=True, source="user", owner_id="root", ) @@ -44,7 +43,8 @@ async def test_queries_internal_table_schema(): "options", "parameters", "is_write", - "is_published", + "is_private", + "is_trusted", "source", "owner_id", "created_at", @@ -67,7 +67,7 @@ async def test_add_get_and_remove_query(): hide_sql=True, fragment="chart", parameters=["region"], - is_published=True, + is_trusted=True, source="user", owner_id="alice", ) @@ -100,7 +100,8 @@ async def test_add_get_and_remove_query(): "parameters": ["region"], "is_write": False, "write": False, - "is_published": True, + "is_private": False, + "is_trusted": True, "source": "user", "owner_id": "alice", "on_success_message": None, @@ -161,7 +162,8 @@ async def test_update_query_only_updates_provided_fields(): assert query["params"] == [] assert query["on_success_redirect"] is None assert query["sql"] == "select 1" - assert query["is_published"] is False + assert query["is_private"] is False + assert query["is_trusted"] is False options_row = ( await ds.get_internal_database().execute( """ @@ -208,7 +210,8 @@ async def test_config_queries_imported_to_internal_table(): "parameters": ["name"], "is_write": False, "write": False, - "is_published": False, + "is_private": False, + "is_trusted": True, "source": "config", "owner_id": None, "on_success_message": None, @@ -232,30 +235,171 @@ async def test_query_resources_come_from_internal_table(): @pytest.mark.asyncio -async def test_unpublished_query_requires_execute_sql_but_published_does_not(): - ds = Datasette(memory=True, settings={"default_allow_sql": False}) +async def test_default_deny_blocks_view_query_even_for_trusted_query(): + ds = Datasette(memory=True, default_deny=True) ds.add_memory_database("query_permissions", name="data") await ds.invoke_startup() - await ds.add_query("data", "unpublished", "select 1", is_published=False) - await ds.add_query("data", "published", "select 1", is_published=True) + await ds.add_query("data", "trusted", "select 1", is_trusted=True) assert not await ds.allowed( - action="execute-sql", - resource=DatabaseResource("data"), + action="view-query", + resource=QueryResource("data", "trusted"), actor=None, ) + + +@pytest.mark.asyncio +async def test_private_query_restriction_blocks_broad_view_query_permission(): + ds = Datasette( + memory=True, + default_deny=True, + config={ + "databases": { + "data": { + "permissions": { + "view-query": {"id": "*"}, + } + } + } + }, + ) + ds.add_memory_database("private_query_permissions", name="data") + await ds.invoke_startup() + await ds.add_query( + "data", + "private_report", + "select 1", + is_private=True, + source="user", + owner_id="alice", + ) + await ds.add_query( + "data", + "shared_report", + "select 2", + is_private=False, + source="user", + owner_id="alice", + ) + + assert await ds.allowed( + action="view-query", + resource=QueryResource("data", "private_report"), + actor={"id": "alice"}, + ) assert not await ds.allowed( action="view-query", - resource=QueryResource("data", "unpublished"), - actor=None, + resource=QueryResource("data", "private_report"), + actor={"id": "bob"}, ) assert await ds.allowed( action="view-query", - resource=QueryResource("data", "published"), - actor=None, + resource=QueryResource("data", "shared_report"), + actor={"id": "bob"}, ) +@pytest.mark.asyncio +async def test_config_query_restriction_does_not_override_private_internal_query(): + ds = Datasette(memory=True, default_deny=True) + ds.add_memory_database("private_query_with_config_name", name="data") + await ds.invoke_startup() + await ds.add_query( + "data", + "private_report", + "select 1", + is_private=True, + source="user", + owner_id="alice", + ) + ds.config = { + "databases": { + "data": { + "permissions": {"view-query": {"id": "*"}}, + "queries": {"private_report": {"sql": "select 2"}}, + } + } + } + + assert not await ds.allowed( + action="view-query", + resource=QueryResource("data", "private_report"), + actor={"id": "bob"}, + ) + + +@pytest.mark.asyncio +async def test_untrusted_shared_query_execution_requires_execute_sql(): + ds = Datasette( + memory=True, + default_deny=True, + config={ + "databases": { + "data": { + "permissions": { + "view-database": {"id": "viewer"}, + "view-query": {"id": "viewer"}, + } + } + } + }, + ) + ds.add_memory_database("untrusted_query_execution", name="data") + await ds.invoke_startup() + await ds.add_query( + "data", + "shared_report", + "select 1 as one", + is_private=False, + is_trusted=False, + source="user", + owner_id="alice", + ) + + denied = await ds.client.get("/data/shared_report.json", actor={"id": "viewer"}) + assert denied.status_code == 403 + + ds.config["databases"]["data"]["permissions"]["execute-sql"] = {"id": "viewer"} + allowed = await ds.client.get("/data/shared_report.json", actor={"id": "viewer"}) + assert allowed.status_code == 200 + assert allowed.json()["rows"] == [{"one": 1}] + + +@pytest.mark.asyncio +async def test_config_queries_are_trusted_by_default_but_can_opt_out(): + ds = Datasette( + memory=True, + default_deny=True, + config={ + "databases": { + "data": { + "permissions": { + "view-query": {"id": "viewer"}, + }, + "queries": { + "trusted_report": {"sql": "select 1 as one"}, + "untrusted_report": { + "sql": "select 2 as two", + "is_trusted": False, + }, + }, + } + } + }, + ) + ds.add_memory_database("trusted_query_config", name="data") + await ds.invoke_startup() + + trusted = await ds.client.get("/data/trusted_report.json", actor={"id": "viewer"}) + untrusted = await ds.client.get( + "/data/untrusted_report.json", actor={"id": "viewer"} + ) + + assert trusted.status_code == 200 + assert trusted.json()["rows"] == [{"one": 1}] + assert untrusted.status_code == 403 + + @pytest.mark.asyncio async def test_database_page_query_preview_is_limited(): ds = Datasette(memory=True) @@ -281,7 +425,6 @@ async def test_query_actions_are_registered(): assert ds.get_action("execute-write-sql").resource_class is DatabaseResource assert ds.get_action("insert-query").resource_class is DatabaseResource - assert ds.get_action("publish-query").resource_class is DatabaseResource assert ds.get_action("update-query").resource_class is QueryResource assert ds.get_action("delete-query").resource_class is QueryResource @@ -430,21 +573,33 @@ async def test_query_list_search_filter_and_html(): "private_query", "select 'private'", title="Private query", - is_published=False, + is_private=True, source="user", owner_id="root", ) + await ds.add_query( + "data", + "trusted_query", + "select 'trusted'", + title="Trusted query", + is_trusted=True, + source="config", + ) html_response = await ds.client.get( "/data/-/queries?q=02", actor={"id": "root"}, ) + flags_response = await ds.client.get( + "/data/-/queries", + actor={"id": "root"}, + ) json_response = await ds.client.get( "/data/-/queries.json?q=02", actor={"id": "root"}, ) filtered_response = await ds.client.get( - "/data/-/queries.json?is_published=0", + "/data/-/queries.json?is_private=1", actor={"id": "root"}, ) @@ -453,7 +608,22 @@ async def test_query_list_search_filter_and_html(): assert "Demo query 01" not in html_response.text assert 'class="query-list-results"' in html_response.text assert "Mode" in html_response.text - assert 'type="radio" name="is_published" value="1"' in html_response.text + assert 'type="radio" name="is_private" value="1"' in html_response.text + assert "Only the owning actor can view this query." not in html_response.text + assert ( + "Execution skips the usual SQL and write permission checks" + not in html_response.text + ) + assert flags_response.status_code == 200 + assert 'Owner' in flags_response.text + assert 'Flags' in flags_response.text + assert 'Mode' not in flags_response.text + assert 'class="query-list-owner">root' in flags_response.text + assert 'class="query-list-pill">Read-only' in flags_response.text + assert 'class="query-list-pill query-list-pill-private">Private' in flags_response.text + assert 'class="query-list-pill query-list-pill-trusted">Trusted' in flags_response.text + assert "Only the owning actor can view this query." in flags_response.text + assert "Execution skips the usual SQL and write permission checks" in flags_response.text assert json_response.json()["queries"][0]["name"] == "demo_query_02" assert [query["name"] for query in filtered_response.json()["queries"]] == [ "private_query" @@ -491,7 +661,6 @@ async def test_global_query_list_api_and_html(): "alpha_first", "select 1", title="Alpha first", - is_published=True, source="user", owner_id="root", ) @@ -500,7 +669,6 @@ async def test_global_query_list_api_and_html(): "alpha_second", "select 2", title="Alpha second", - is_published=True, source="user", owner_id="root", ) @@ -509,7 +677,6 @@ async def test_global_query_list_api_and_html(): "beta_first", "select 3", title="Beta first", - is_published=True, source="user", owner_id="root", ) @@ -548,7 +715,7 @@ async def test_global_query_list_api_and_html(): @pytest.mark.asyncio -async def test_query_insert_api_publish_requires_publish_query(): +async def test_query_insert_api_rejects_is_trusted(): ds = Datasette( memory=True, default_deny=True, @@ -564,17 +731,17 @@ async def test_query_insert_api_publish_requires_publish_query(): } }, ) - ds.add_memory_database("query_publish_api", name="data") + ds.add_memory_database("query_trusted_api", name="data") await ds.invoke_startup() response = await ds.client.post( "/data/-/queries/insert", actor={"id": "writer"}, - json={"query": {"name": "public", "sql": "select 1", "is_published": True}}, + json={"query": {"name": "trusted", "sql": "select 1", "is_trusted": True}}, ) - assert response.status_code == 403 - assert response.json()["errors"] == ["Permission denied: need publish-query"] + assert response.status_code == 400 + assert response.json()["errors"] == ["Invalid keys: is_trusted"] @pytest.mark.asyncio @@ -599,24 +766,10 @@ async def test_query_insert_api_creates_writable_query(): assert response.status_code == 201 query = response.json()["query"] assert query["is_write"] is True - assert query["is_published"] is False + assert query["is_private"] is True + assert query["is_trusted"] is False assert query["parameters"] == ["name"] - bad_response = await ds.client.post( - "/data/-/queries/insert", - actor={"id": "root"}, - json={ - "query": { - "name": "published_insert", - "sql": "insert into dogs (name) values (:name)", - "is_published": True, - } - }, - ) - - assert bad_response.status_code == 400 - assert bad_response.json()["errors"] == ["Writable queries cannot be published"] - @pytest.mark.asyncio async def test_query_update_and_delete_api(): @@ -1103,6 +1256,10 @@ async def test_user_writable_query_execution_rechecks_table_permissions(): config={ "databases": { "data": { + "permissions": { + "view-database": {"id": ["alice", "bob"]}, + "execute-write-sql": {"id": ["alice", "bob"]}, + }, "tables": { "dogs": { "permissions": { From 1cd162e9da48b924c289ec9343e9d801b51a89f9 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 26 May 2026 12:07:30 -0700 Subject: [PATCH 141/176] Removed some no-longer-necessary code, simplified view-query is back in the default allow actions now. We have other mechanisms that work for controlling visibility, and the fact that queries default to running with the permissions of the actor makes this safe. --- datasette/default_permissions/defaults.py | 55 +++-------------------- tests/test_permissions.py | 9 +++- tests/test_queries.py | 39 ++++++++++++++++ 3 files changed, 51 insertions(+), 52 deletions(-) diff --git a/datasette/default_permissions/defaults.py b/datasette/default_permissions/defaults.py index dfd8d3e9..ed0a6d66 100644 --- a/datasette/default_permissions/defaults.py +++ b/datasette/default_permissions/defaults.py @@ -21,37 +21,12 @@ DEFAULT_ALLOW_ACTIONS = frozenset( "view-database", "view-database-download", "view-table", + "view-query", "execute-sql", } ) -def _configured_query_restriction_selects(datasette: "Datasette") -> tuple[list[str], dict]: - selects = [] - params = {} - for index, (database_name, db_config) in enumerate( - ((datasette.config or {}).get("databases") or {}).items() - ): - for query_name, query_config in (db_config.get("queries") or {}).items(): - if isinstance(query_config, dict) and query_config.get("is_private"): - continue - parent_param = f"query_config_parent_{index}_{len(selects)}" - child_param = f"query_config_child_{index}_{len(selects)}" - selects.append( - f""" - SELECT :{parent_param} AS parent, :{child_param} AS child - WHERE NOT EXISTS ( - SELECT 1 FROM queries - WHERE database_name = :{parent_param} - AND name = :{child_param} - ) - """ - ) - params[parent_param] = database_name - params[child_param] = query_name - return selects, params - - @hookimpl(specname="permission_resources_sql") async def default_allow_sql_check( datasette: "Datasette", @@ -121,16 +96,6 @@ async def default_query_permissions_sql( params = {"query_owner_id": actor_id} rule_sqls = [] - if not datasette.default_deny: - rule_sqls.append( - """ - SELECT database_name AS parent, name AS child, 1 AS allow, - 'non-private query' AS reason - FROM queries - WHERE is_private = 0 - """ - ) - if actor_id is not None: rule_sqls.append( """ @@ -141,23 +106,13 @@ async def default_query_permissions_sql( """ ) - config_restriction_selects, config_restriction_params = ( - _configured_query_restriction_selects(datasette) - ) - - restriction_sqls = [ - """ + return PermissionSQL( + sql="\nUNION ALL\n".join(rule_sqls) if rule_sqls else None, + restriction_sql=""" SELECT database_name AS parent, name AS child FROM queries WHERE is_private = 0 OR owner_id = :query_owner_id - """ - ] - restriction_sqls.extend(config_restriction_selects) - params.update(config_restriction_params) - - return PermissionSQL( - sql="\nUNION ALL\n".join(rule_sqls) if rule_sqls else None, - restriction_sql="\nUNION ALL\n".join(restriction_sqls), + """, params=params, ) diff --git a/tests/test_permissions.py b/tests/test_permissions.py index 22f294bb..4f342d8f 100644 --- a/tests/test_permissions.py +++ b/tests/test_permissions.py @@ -937,16 +937,20 @@ async def test_permissions_in_config( updated_config = copy.deepcopy(previous_config) updated_config.update(config) perms_ds.config = updated_config + await perms_ds.apply_queries_config() try: # Convert old-style resource to Resource object - from datasette.resources import DatabaseResource, TableResource + from datasette.resources import DatabaseResource, QueryResource, TableResource resource_obj = None if resource: if isinstance(resource, str): resource_obj = DatabaseResource(database=resource) elif isinstance(resource, tuple) and len(resource) == 2: - resource_obj = TableResource(database=resource[0], table=resource[1]) + if action == "view-query": + resource_obj = QueryResource(database=resource[0], query=resource[1]) + else: + resource_obj = TableResource(database=resource[0], table=resource[1]) result = await perms_ds.allowed( action=action, resource=resource_obj, actor=actor @@ -956,6 +960,7 @@ async def test_permissions_in_config( assert result == expected_result finally: perms_ds.config = previous_config + await perms_ds.apply_queries_config() @pytest.mark.asyncio diff --git a/tests/test_queries.py b/tests/test_queries.py index c97b5733..dde57dea 100644 --- a/tests/test_queries.py +++ b/tests/test_queries.py @@ -248,6 +248,45 @@ async def test_default_deny_blocks_view_query_even_for_trusted_query(): ) +@pytest.mark.asyncio +async def test_view_query_default_allow_still_respects_private_restriction(): + ds = Datasette(memory=True) + ds.add_memory_database("default_view_query_permissions", name="data") + await ds.invoke_startup() + await ds.add_query( + "data", + "private_report", + "select 1", + is_private=True, + source="user", + owner_id="alice", + ) + await ds.add_query( + "data", + "shared_report", + "select 2", + is_private=False, + source="user", + owner_id="alice", + ) + + assert await ds.allowed( + action="view-query", + resource=QueryResource("data", "shared_report"), + actor=None, + ) + assert await ds.allowed( + action="view-query", + resource=QueryResource("data", "private_report"), + actor={"id": "alice"}, + ) + assert not await ds.allowed( + action="view-query", + resource=QueryResource("data", "private_report"), + actor={"id": "bob"}, + ) + + @pytest.mark.asyncio async def test_private_query_restriction_blocks_broad_view_query_permission(): ds = Datasette( From 1ac4265ffd295ea62008b13b3e37af96f5450be4 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 26 May 2026 12:12:59 -0700 Subject: [PATCH 142/176] Require permissions for untrusted stored query execution, refs #2735 --- datasette/views/database.py | 7 +++---- docs/authentication.rst | 2 +- queries-plan.md | 8 +++----- tests/test_queries.py | 12 ++++++++++-- 4 files changed, 17 insertions(+), 12 deletions(-) diff --git a/datasette/views/database.py b/datasette/views/database.py index 91e9c350..bd939d87 100644 --- a/datasette/views/database.py +++ b/datasette/views/database.py @@ -1430,10 +1430,9 @@ class QueryView(View): ): raise Forbidden("You do not have permission to view this query") - if canned_query.get("write"): - await _ensure_stored_query_execution_permissions( - datasette, db, canned_query, request.actor - ) + await _ensure_stored_query_execution_permissions( + datasette, db, canned_query, request.actor + ) # If database is immutable, return an error if not db.is_mutable: diff --git a/docs/authentication.rst b/docs/authentication.rst index 6e835c8d..453aaa19 100644 --- a/docs/authentication.rst +++ b/docs/authentication.rst @@ -1285,7 +1285,7 @@ Actor is allowed to view a table (or view) page, e.g. https://latest.datasette.i view-query ---------- -Actor is allowed to view (and execute) a saved query page, e.g. https://latest.datasette.io/fixtures/pragma_cache_size - this includes executing :ref:`canned_queries_writable`. +Actor is allowed to view a saved query page, e.g. https://latest.datasette.io/fixtures/pragma_cache_size. Executing an untrusted saved query also requires ``execute-sql`` or the relevant write permissions; trusted saved queries can execute with ``view-query`` alone. ``resource`` - ``datasette.resources.QueryResource(database, query)`` ``database`` is the name of the database (string) diff --git a/queries-plan.md b/queries-plan.md index f4b8049c..da6b7c92 100644 --- a/queries-plan.md +++ b/queries-plan.md @@ -25,7 +25,7 @@ Terminology change: these are now "queries", not "canned queries". Legacy code a - Query definitions currently come from `datasette.yaml` or the `canned_queries()` plugin hook. - `Datasette.get_canned_queries(database_name, actor)` calls that hook every time it needs query definitions. - `QueryResource.resources_sql()` currently enumerates databases and calls the hook for each one, because permissions and `/-/jump` need query resources. -- Query pages execute if the actor has `view-query` for `QueryResource(database, query)`. +- Query pages are visible if the actor has `view-query` for `QueryResource(database, query)`. Executing an untrusted stored query also checks `execute-sql` or the relevant write permissions. - Arbitrary SQL executes if the actor has `execute-sql` for `DatabaseResource(database)`. The main performance and architecture win is making query resource enumeration a direct SQL query against the internal database. @@ -145,9 +145,7 @@ Default execution rule for user-created writable queries: Implementation: -- Remove `view-query` from the broad `DEFAULT_ALLOW_ACTIONS` set. -- Replace it with query-aware default `view-query` permission SQL. -- Emit default `view-query` allows for non-private rows when Datasette is not running with `--default-deny`. +- Keep `view-query` in the broad `DEFAULT_ALLOW_ACTIONS` set, so saved queries remain visible by default in all-public Datasette. - Emit default `view-query` allows for the owning actor. - Use `restriction_sql` to limit private rows to their owner even when broader `view-query` permissions exist. - Have `QueryView` perform the fresh `execute-sql` or table-permission check before execution unless the row has `is_trusted=1`. @@ -424,7 +422,7 @@ The existing edit-SQL flow from query pages can continue to point back to arbitr - The old `canned_queries()` hook is no longer called by core. - `QueryResource.resources_sql()` returns rows from `queries`. - Database page and `/-/jump` list queries from the internal DB. -- `view-query` is no longer globally default-allowed; default query permissions come from the query-aware hook. +- `view-query` remains globally default-allowed, with `restriction_sql` narrowing private queries to their owner. - Private query is only visible to its owner, even when a broader `view-query` rule applies. - Non-trusted read-only query requires `execute-sql` to execute. - Trusted read-only query can be executed without `execute-sql` after `view-query` passes. diff --git a/tests/test_queries.py b/tests/test_queries.py index dde57dea..997f8b39 100644 --- a/tests/test_queries.py +++ b/tests/test_queries.py @@ -395,8 +395,16 @@ async def test_untrusted_shared_query_execution_requires_execute_sql(): owner_id="alice", ) - denied = await ds.client.get("/data/shared_report.json", actor={"id": "viewer"}) - assert denied.status_code == 403 + denied_get = await ds.client.get( + "/data/shared_report.json", actor={"id": "viewer"} + ) + denied_post = await ds.client.post( + "/data/shared_report", + actor={"id": "viewer"}, + data={}, + ) + assert denied_get.status_code == 403 + assert denied_post.status_code == 403 ds.config["databases"]["data"]["permissions"]["execute-sql"] = {"id": "viewer"} allowed = await ds.client.get("/data/shared_report.json", actor={"id": "viewer"}) From 866852eff603c219b8bf7d13f2a69b5ff032fa67 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 26 May 2026 12:46:18 -0700 Subject: [PATCH 143/176] Clarifying comments --- datasette/default_permissions/defaults.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/datasette/default_permissions/defaults.py b/datasette/default_permissions/defaults.py index ed0a6d66..32ad4ef1 100644 --- a/datasette/default_permissions/defaults.py +++ b/datasette/default_permissions/defaults.py @@ -80,6 +80,7 @@ async def default_query_permissions_sql( if action in {"update-query", "delete-query"}: if actor_id is None: return None + # Query owner can update/delete query return PermissionSQL( sql=""" SELECT database_name AS parent, name AS child, 1 AS allow, @@ -97,15 +98,15 @@ async def default_query_permissions_sql( params = {"query_owner_id": actor_id} rule_sqls = [] if actor_id is not None: - rule_sqls.append( - """ + # Query owner can view-query + rule_sqls.append(""" SELECT database_name AS parent, name AS child, 1 AS allow, 'query owner' AS reason FROM queries WHERE owner_id = :query_owner_id - """ - ) + """) + # restriction_sql enforces private queries ONLY visible to owner return PermissionSQL( sql="\nUNION ALL\n".join(rule_sqls) if rule_sqls else None, restriction_sql=""" From 71c76e38534378cbce8576771238a788feccf3ad Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 26 May 2026 13:08:19 -0700 Subject: [PATCH 144/176] Better faceting on /-/queries Ref https://github.com/simonw/datasette/pull/2741#issuecomment-4548321815 --- datasette/app.py | 69 +++++++++++++++++ datasette/templates/query_list.html | 94 +++++++++++++---------- datasette/views/database.py | 99 +++++++++++++++++++++++- tests/test_permissions.py | 8 +- tests/test_queries.py | 115 +++++++++++++++++++++++++--- 5 files changed, 330 insertions(+), 55 deletions(-) diff --git a/datasette/app.py b/datasette/app.py index 3329ee7e..1acdfcd8 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -1298,6 +1298,75 @@ class Datasette: ) return self._query_row_to_dict(rows.first()) + async def count_queries( + self, + database=None, + *, + actor=None, + q=None, + is_write=None, + is_private=None, + is_trusted=None, + source=None, + owner_id=None, + ): + allowed_sql, allowed_params = await self.allowed_resources_sql( + action="view-query", + actor=actor, + parent=database, + ) + params = dict(allowed_params) + where_clauses = [] + if database is not None: + params["query_database"] = database + where_clauses.append("q.database_name = :query_database") + + if q: + where_clauses.append(""" + ( + q.name LIKE :query_search + OR q.title LIKE :query_search + OR q.description LIKE :query_search + OR q.sql LIKE :query_search + ) + """) + params["query_search"] = "%{}%".format(q) + if is_write is not None: + where_clauses.append("q.is_write = :query_is_write") + params["query_is_write"] = int(bool(is_write)) + if is_private is not None: + where_clauses.append("q.is_private = :query_is_private") + params["query_is_private"] = int(bool(is_private)) + if is_trusted is not None: + where_clauses.append("q.is_trusted = :query_is_trusted") + params["query_is_trusted"] = int(bool(is_trusted)) + if source is not None: + where_clauses.append("q.source = :query_source") + params["query_source"] = source + if owner_id is not None: + where_clauses.append("q.owner_id = :query_owner_id") + params["query_owner_id"] = owner_id + + row = ( + await self.get_internal_database().execute( + """ + SELECT count(*) AS count + FROM queries q + JOIN ( + {allowed_sql} + ) allowed + ON allowed.parent = q.database_name + AND allowed.child = q.name + WHERE {where} + """.format( + allowed_sql=allowed_sql, + where=" AND ".join(where_clauses) or "1 = 1", + ), + params, + ) + ).first() + return row["count"] + async def list_queries( self, database=None, diff --git a/datasette/templates/query_list.html b/datasette/templates/query_list.html index 25259b3d..fa4859b1 100644 --- a/datasette/templates/query_list.html +++ b/datasette/templates/query_list.html @@ -9,7 +9,7 @@ max-width: 64rem; } .query-list-filters { - margin: 0.5rem 0 1rem; + margin: 0.5rem 0 0.75rem; } .query-list-search { align-items: center; @@ -32,43 +32,63 @@ line-height: 1.1; padding: 0.35rem 0.65rem; } -.query-list-filter-groups { +.query-list-facets { align-items: flex-start; display: flex; flex-wrap: wrap; - gap: 0.8rem 1.4rem; + gap: 1rem 1.6rem; + margin: 0 0 1rem; } -.query-list-filter-group { - border: 0; +.query-list-facet { + margin: 0; +} +.query-list-facet h2 { + font-size: 0.9rem; + line-height: 1.2; + margin: 0 0 0.35rem; +} +.query-list-facet ul { display: flex; flex-wrap: wrap; gap: 0.35rem; margin: 0; - min-width: 0; padding: 0; + list-style: none; } -.query-list-filter-group legend { - font-weight: 700; - margin: 0 0.45rem 0 0; - padding: 0; -} -.query-list-filter-group label { +.query-list-facet-link, +.query-list-facet-link:link, +.query-list-facet-link:visited, +.query-list-facet-link:hover, +.query-list-facet-link:focus, +.query-list-facet-link:active { align-items: center; border: 1px solid #c8d1dc; border-radius: 0.25rem; - cursor: pointer; + color: #39445a; display: inline-flex; font-size: 0.82rem; - gap: 0.3rem; + gap: 0.4rem; line-height: 1.1; padding: 0.35rem 0.55rem; + text-decoration: none; } -.query-list-filter-group input { - margin: 0; +.query-list-facet-link:hover { + border-color: #7ca5c8; + color: #1f5d85; } -.query-list-filter-group input:checked + span { +.query-list-facet-link-active { + background-color: #edf6fb; + border-color: #6d9fc0; font-weight: 700; } +.query-list-facet-disabled { + color: #7b8794; + cursor: default; +} +.query-list-facet-count { + color: #4f5b6d; + font-variant-numeric: tabular-nums; +} .query-list-results { border-collapse: collapse; font-size: 0.9rem; @@ -169,15 +189,6 @@ .query-list-search input[type=search] { max-width: none; } - .query-list-filter-group { - display: block; - } - .query-list-filter-group legend { - margin-bottom: 0.3rem; - } - .query-list-filter-group label { - margin: 0 0.25rem 0.35rem 0; - } } {% endblock %} @@ -198,24 +209,27 @@ -
-
- Mode - - - -
-
- Visibility - - - -
-
+ + {% if queries %}
diff --git a/datasette/views/database.py b/datasette/views/database.py index bd939d87..2e77d36b 100644 --- a/datasette/views/database.py +++ b/datasette/views/database.py @@ -1121,6 +1121,21 @@ class QueryParametersView(BaseView): return _block_framing(Response.json({"ok": True, "parameters": parameters})) +def _query_list_url(path, query_string, *, set_args=None, remove_args=None): + set_args = set_args or {} + remove_args = set(remove_args or ()) + skip = set(set_args) | remove_args | {"_next"} + pairs = [ + (key, value) + for key, value in parse_qsl(query_string, keep_blank_values=True) + if key not in skip + ] + for key, value in set_args.items(): + if value not in (None, ""): + pairs.append((key, value)) + return path + (("?" + urlencode(pairs)) if pairs else "") + + class QueryListView(BaseView): name = "query-list" @@ -1139,9 +1154,7 @@ class QueryListView(BaseView): default=20 if format_ == "html" else 50, ) is_write = _as_optional_bool(request.args.get("is_write"), "is_write") - is_private = _as_optional_bool( - request.args.get("is_private"), "is_private" - ) + is_private = _as_optional_bool(request.args.get("is_private"), "is_private") except QueryValidationError as ex: return _error([ex.message], ex.status) @@ -1173,6 +1186,80 @@ class QueryListView(BaseView): urlencode(pairs), ) + current_filters = { + "actor": request.actor, + "q": request.args.get("q") or None, + "is_write": is_write, + "is_private": is_private, + "source": request.args.get("source") or None, + "owner_id": request.args.get("owner_id") or None, + } + + async def facet_count(field, value): + if current_filters[field] is not None and current_filters[field] != value: + return 0 + filters = dict(current_filters) + filters[field] = value + return await self.ds.count_queries(database, **filters) + + def facet_href(field, value): + if current_filters[field] == value: + return _query_list_url( + query_list_path, + request.query_string, + remove_args=[field], + ) + if current_filters[field] is not None: + return None + return _query_list_url( + query_list_path, + request.query_string, + set_args={field: str(int(value))}, + ) + + async def facet_item(label, field, value): + count = await facet_count(field, value) + active = current_filters[field] == value + if not active and not count: + return None + return { + "label": label, + "count": count, + "href": facet_href(field, value) if active or count else None, + "active": active, + } + + async def facet_items(items): + return [ + item + for item in [ + await facet_item(label, field, value) + for label, field, value in items + ] + if item is not None + ] + + facets = [ + { + "title": "Mode", + "items": await facet_items( + [ + ("Read-only", "is_write", False), + ("Writable", "is_write", True), + ] + ), + }, + { + "title": "Visibility", + "items": await facet_items( + [ + ("Not private", "is_private", False), + ("Private", "is_private", True), + ] + ), + }, + ] + data = { "ok": True, "database": database, @@ -1188,6 +1275,7 @@ class QueryListView(BaseView): "show_trusted_note": any(query["is_trusted"] for query in page["queries"]), "query_list_path": query_list_path, "show_database": database is None, + "facets": facets, "filters": { "q": request.args.get("q") or "", "is_write": request.args.get("is_write") or "", @@ -1715,6 +1803,9 @@ class QueryView(View): } ) metadata = await datasette.get_database_metadata(database) + if canned_query: + metadata = dict(canned_query) + metadata.pop("source", None) renderers = {} for key, (_, can_render) in datasette.renderers.items(): @@ -1865,7 +1956,7 @@ class QueryView(View): ) ), show_hide_hidden=markupsafe.Markup(show_hide_hidden), - metadata=canned_query or metadata, + metadata=metadata, alternate_url_json=alternate_url_json, select_templates=[ f"{'*' if template_name == template.name else ''}{template_name}" diff --git a/tests/test_permissions.py b/tests/test_permissions.py index 4f342d8f..eb6cee9f 100644 --- a/tests/test_permissions.py +++ b/tests/test_permissions.py @@ -948,9 +948,13 @@ async def test_permissions_in_config( resource_obj = DatabaseResource(database=resource) elif isinstance(resource, tuple) and len(resource) == 2: if action == "view-query": - resource_obj = QueryResource(database=resource[0], query=resource[1]) + resource_obj = QueryResource( + database=resource[0], query=resource[1] + ) else: - resource_obj = TableResource(database=resource[0], table=resource[1]) + resource_obj = TableResource( + database=resource[0], table=resource[1] + ) result = await perms_ds.allowed( action=action, resource=resource_obj, actor=actor diff --git a/tests/test_queries.py b/tests/test_queries.py index 997f8b39..36f7107a 100644 --- a/tests/test_queries.py +++ b/tests/test_queries.py @@ -395,9 +395,7 @@ async def test_untrusted_shared_query_execution_requires_execute_sql(): owner_id="alice", ) - denied_get = await ds.client.get( - "/data/shared_report.json", actor={"id": "viewer"} - ) + denied_get = await ds.client.get("/data/shared_report.json", actor={"id": "viewer"}) denied_post = await ds.client.post( "/data/shared_report", actor={"id": "viewer"}, @@ -608,6 +606,27 @@ async def test_query_list_and_definition_api(): assert definition_response.json()["query"]["title"] == "Demo query 01" +@pytest.mark.asyncio +async def test_query_page_does_not_show_internal_source(): + ds = Datasette(memory=True) + ds.add_memory_database("query_page_source", name="data") + await ds.invoke_startup() + await ds.add_query( + "data", + "stored_report", + "select 1 as one", + title="Stored report", + source="user", + owner_id="root", + ) + + response = await ds.client.get("/data/stored_report", actor={"id": "root"}) + + assert response.status_code == 200 + assert "Stored report" in response.text + assert "Data source:" not in response.text + + @pytest.mark.asyncio async def test_query_list_search_filter_and_html(): ds = Datasette(memory=True) @@ -632,6 +651,15 @@ async def test_query_list_search_filter_and_html(): is_trusted=True, source="config", ) + await ds.add_query( + "data", + "writable_query", + "insert into dogs (name) values (:name)", + title="Writable query", + is_write=True, + source="user", + owner_id="root", + ) html_response = await ds.client.get( "/data/-/queries?q=02", @@ -649,13 +677,21 @@ async def test_query_list_search_filter_and_html(): "/data/-/queries.json?is_private=1", actor={"id": "root"}, ) + filtered_write_response = await ds.client.get( + "/data/-/queries?is_write=1", + actor={"id": "root"}, + ) + filtered_private_response = await ds.client.get( + "/data/-/queries?is_private=1", + actor={"id": "root"}, + ) assert html_response.status_code == 200 assert "Demo query 02" in html_response.text assert "Demo query 01" not in html_response.text assert 'class="query-list-results"' in html_response.text - assert "Mode" in html_response.text - assert 'type="radio" name="is_private" value="1"' in html_response.text + assert 'class="query-list-facets"' in html_response.text + assert 'type="radio"' not in html_response.text assert "Only the owning actor can view this query." not in html_response.text assert ( "Execution skips the usual SQL and write permission checks" @@ -667,14 +703,75 @@ async def test_query_list_search_filter_and_html(): assert '' not in flags_response.text assert 'class="query-list-owner">root' in flags_response.text assert 'class="query-list-pill">Read-only' in flags_response.text - assert 'class="query-list-pill query-list-pill-private">Private' in flags_response.text - assert 'class="query-list-pill query-list-pill-trusted">Trusted' in flags_response.text + assert ( + 'class="query-list-pill query-list-pill-write">Writable' + in flags_response.text + ) + assert ( + 'class="query-list-pill query-list-pill-private">Private' + in flags_response.text + ) + assert ( + 'class="query-list-pill query-list-pill-trusted">Trusted' + in flags_response.text + ) + assert ( + 'href="/data/-/queries?is_write=0">Read-only5' + in flags_response.text + ) + assert ( + 'href="/data/-/queries?is_write=1">Writable1' + in flags_response.text + ) + assert ( + 'href="/data/-/queries?is_private=0">Not private5' + in flags_response.text + ) + assert ( + 'href="/data/-/queries?is_private=1">Private1' + in flags_response.text + ) assert "Only the owning actor can view this query." in flags_response.text - assert "Execution skips the usual SQL and write permission checks" in flags_response.text + assert ( + "Execution skips the usual SQL and write permission checks" + in flags_response.text + ) assert json_response.json()["queries"][0]["name"] == "demo_query_02" assert [query["name"] for query in filtered_response.json()["queries"]] == [ "private_query" ] + assert "Writable query" in filtered_write_response.text + assert "Demo query 01" not in filtered_write_response.text + assert ( + 'query-list-facet-link query-list-facet-link-active" href="/data/-/queries"' + in filtered_write_response.text + ) + assert ( + 'Read-only0' + not in filtered_write_response.text + ) + assert ( + 'href="/data/-/queries?is_write=1&is_private=0">Not private1' + in filtered_write_response.text + ) + assert ( + 'Private0' + not in filtered_write_response.text + ) + assert "Private query" in filtered_private_response.text + assert "Demo query 01" not in filtered_private_response.text + assert ( + 'href="/data/-/queries?is_private=1&is_write=0">Read-only1' + in filtered_private_response.text + ) + assert ( + 'Writable0' + not in filtered_private_response.text + ) + assert ( + 'Not private0' + not in filtered_private_response.text + ) @pytest.mark.asyncio @@ -1313,7 +1410,7 @@ async def test_user_writable_query_execution_rechecks_table_permissions(): "insert-row": {"id": "alice"}, } } - } + }, } } }, From 0fcaa5792ba73143661515af0088d7e5d968e96c Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 26 May 2026 13:12:07 -0700 Subject: [PATCH 145/176] Style query operations on create query Made it consistent with the SQL write page. --- .../_execute_write_analysis_styles.html | 37 +++++++++++++++++++ datasette/templates/execute_write.html | 36 +----------------- datasette/templates/query_create.html | 19 +++++----- tests/test_queries.py | 6 ++- 4 files changed, 52 insertions(+), 46 deletions(-) create mode 100644 datasette/templates/_execute_write_analysis_styles.html diff --git a/datasette/templates/_execute_write_analysis_styles.html b/datasette/templates/_execute_write_analysis_styles.html new file mode 100644 index 00000000..f20e67b2 --- /dev/null +++ b/datasette/templates/_execute_write_analysis_styles.html @@ -0,0 +1,37 @@ + diff --git a/datasette/templates/execute_write.html b/datasette/templates/execute_write.html index 46f58c3b..414d4af7 100644 --- a/datasette/templates/execute_write.html +++ b/datasette/templates/execute_write.html @@ -40,42 +40,8 @@ border-radius: 0.25rem; min-width: 13rem; } -.execute-write-analysis { - border-collapse: collapse; - font-size: 0.9rem; - margin: 0.25rem 0 1rem; - min-width: 44rem; -} -.execute-write-analysis th, -.execute-write-analysis td { - border-bottom: 1px solid #d7dde5; - padding: 0.45rem 0.7rem; - text-align: left; - vertical-align: top; -} -.execute-write-analysis th { - background-color: #edf6fb; - border-top: 1px solid #d7dde5; - color: #39445a; - font-weight: 700; -} -.execute-write-analysis tbody tr:nth-child(even) { - background-color: rgba(39, 104, 144, 0.05); -} -.execute-write-analysis code { - background: transparent; - font-size: 0.9em; - white-space: nowrap; -} -.execute-write-analysis-allowed { - color: #267a3e; - font-weight: 700; -} -.execute-write-analysis-denied { - color: #b00020; - font-weight: 700; -} +{% include "_execute_write_analysis_styles.html" %} {% include "_sql_parameter_styles.html" %} {% endblock %} diff --git a/datasette/templates/query_create.html b/datasette/templates/query_create.html index 686d971e..2d8a9122 100644 --- a/datasette/templates/query_create.html +++ b/datasette/templates/query_create.html @@ -5,6 +5,7 @@ {% block extra_head %} {{- super() -}} {% include "_codemirror.html" %} +{% include "_execute_write_analysis_styles.html" %} {% endblock %} {% block body_class %}query-create db-{{ database|to_css_class }}{% endblock %} @@ -32,30 +33,28 @@

Execute write SQL

{% endif %} -

Analysis

+

Query operations

{% if analysis_error %}

{{ analysis_error }}

{% elif analysis_rows %} -
Mode
+
- + - {% for row in analysis_rows %} - - - - - - + + + + + {% endfor %} diff --git a/tests/test_queries.py b/tests/test_queries.py index 36f7107a..c27c23da 100644 --- a/tests/test_queries.py +++ b/tests/test_queries.py @@ -998,7 +998,11 @@ async def test_create_query_ui_and_arbitrary_sql_save_link(): assert "Create query" in create_response.text assert "Read-only" in create_response.text assert "Writable" in create_response.text - assert "required permission" in create_response.text + assert "

Query operations

" in create_response.text + assert '
Operation Database Tablerequired permissionRequired permission AllowedSource
{{ row.operation }}{{ row.database }}{{ row.table }}{{ row.required_permission }}{% if row.allowed is none %}{% elif row.allowed %}yes{% else %}no{% endif %}{{ row.source or "" }}{{ row.operation }}{{ row.database }}{{ row.table }}{% if row.required_permission %}{{ row.required_permission }}{% endif %}{% if row.allowed is none %}{% elif row.allowed %}yes{% else %}no{% endif %}
' in create_response.text + assert '' in create_response.text + assert '' not in create_response.text + assert "" in create_response.text assert query_response.status_code == 200 assert "Save query" in query_response.text assert "/data/-/queries/-/create?sql=select+%2A+from+dogs" in query_response.text From 70b23ff4a55528083512fab96aa50725f415cbe4 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 26 May 2026 13:47:24 -0700 Subject: [PATCH 146/176] Tweaked save query link --- datasette/templates/query.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/datasette/templates/query.html b/datasette/templates/query.html index f74d21f1..1900bd31 100644 --- a/datasette/templates/query.html +++ b/datasette/templates/query.html @@ -66,7 +66,7 @@ {% if not hide_sql %}{% endif %} {{ show_hide_hidden }} - {% if save_query_url %}Save query{% endif %} + {% if save_query_url %}Save this query{% endif %} {% if canned_query and edit_sql_url %}Edit SQL{% endif %}

From eb7c25c57cf914629c08eaa477d0709b0f41efeb Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 26 May 2026 13:48:40 -0700 Subject: [PATCH 147/176] Major redesign of create saved query UI https://github.com/simonw/datasette/pull/2741#issuecomment-4548707129 --- datasette/app.py | 6 +- datasette/static/app.css | 4 + .../_execute_write_analysis_scripts.html | 111 +++++++ .../_execute_write_analysis_styles.html | 4 + .../templates/_sql_parameter_scripts.html | 17 +- datasette/templates/execute_write.html | 88 +----- datasette/templates/query_create.html | 296 +++++++++++++++--- datasette/views/database.py | 181 ++++++++--- tests/test_queries.py | 170 +++++++++- 9 files changed, 705 insertions(+), 172 deletions(-) create mode 100644 datasette/templates/_execute_write_analysis_scripts.html diff --git a/datasette/app.py b/datasette/app.py index 1acdfcd8..8936b099 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -50,7 +50,7 @@ from .views.database import ( ExecuteWriteView, TableCreateView, QueryView, - QueryCreateView, + QueryCreateAnalyzeView, QueryDeleteView, QueryDefinitionView, GlobalQueryListView, @@ -2820,8 +2820,8 @@ class Datasette: r"/(?P[^\/\.]+)/-/queries(\.(?Pjson))?$", ) add_route( - QueryCreateView.as_view(self), - r"/(?P[^\/\.]+)/-/queries/-/create$", + QueryCreateAnalyzeView.as_view(self), + r"/(?P[^\/\.]+)/-/queries/analyze$", ) add_route( QueryInsertView.as_view(self), diff --git a/datasette/static/app.css b/datasette/static/app.css index c21d0dc4..4f4db133 100644 --- a/datasette/static/app.css +++ b/datasette/static/app.css @@ -1414,6 +1414,10 @@ svg.dropdown-menu-icon { position: relative; top: 1px; } +.save-query { + display: inline-block; + margin-left: 0.45em; +} .blob-download { display: block; diff --git a/datasette/templates/_execute_write_analysis_scripts.html b/datasette/templates/_execute_write_analysis_scripts.html new file mode 100644 index 00000000..a19bae13 --- /dev/null +++ b/datasette/templates/_execute_write_analysis_scripts.html @@ -0,0 +1,111 @@ + diff --git a/datasette/templates/_execute_write_analysis_styles.html b/datasette/templates/_execute_write_analysis_styles.html index f20e67b2..165cfe9f 100644 --- a/datasette/templates/_execute_write_analysis_styles.html +++ b/datasette/templates/_execute_write_analysis_styles.html @@ -34,4 +34,8 @@ color: #b00020; font-weight: 700; } +.execute-write-analysis-na { + color: #687386; + font-style: italic; +} diff --git a/datasette/templates/_sql_parameter_scripts.html b/datasette/templates/_sql_parameter_scripts.html index 68e46069..159a141c 100644 --- a/datasette/templates/_sql_parameter_scripts.html +++ b/datasette/templates/_sql_parameter_scripts.html @@ -215,9 +215,10 @@ window.datasetteSqlParameters = (() => { if (!form) { return null; } + const shouldRenderParameters = options.renderParameters !== false; const section = options.section || form.querySelector("[data-sql-parameters-section]"); - if (!section) { + if (shouldRenderParameters && !section) { return null; } const manager = { @@ -225,12 +226,16 @@ window.datasetteSqlParameters = (() => { section, allowExpand: options.allowExpand === undefined - ? section.dataset.allowExpand === "1" + ? section + ? section.dataset.allowExpand === "1" + : false : options.allowExpand, parameterState: new Map(), }; - bindParameterControls(manager); - syncParameterState(manager); + if (section) { + bindParameterControls(manager); + syncParameterState(manager); + } const url = options.url || form.dataset.parametersUrl; let refreshTimer = null; @@ -254,7 +259,9 @@ window.datasetteSqlParameters = (() => { if (!response.ok) { throw new Error((data.errors || [response.statusText]).join("; ")); } - renderParameters(manager, data.parameters || []); + if (shouldRenderParameters) { + renderParameters(manager, data.parameters || []); + } if (options.onData) { options.onData(data, manager); } diff --git a/datasette/templates/execute_write.html b/datasette/templates/execute_write.html index 414d4af7..7a627a7a 100644 --- a/datasette/templates/execute_write.html +++ b/datasette/templates/execute_write.html @@ -131,6 +131,7 @@ if (executeWriteSqlInput && !executeWriteSqlInput.value) { {% include "_codemirror_foot.html" %} {% include "_sql_parameter_scripts.html" %} +{% include "_execute_write_analysis_scripts.html" %} + + {% endblock %} diff --git a/datasette/views/database.py b/datasette/views/database.py index 2e77d36b..aafcf40b 100644 --- a/datasette/views/database.py +++ b/datasette/views/database.py @@ -551,6 +551,17 @@ def _wants_json(request, is_json, data): ) +def _query_create_form_error_message(message): + return { + "Query name is required": "URL is required", + "Invalid query name": "Invalid URL", + "Query name conflicts with a table or view": ( + "URL conflicts with an existing table or view" + ), + "Query already exists": "A query already exists at that URL", + }.get(message, message) + + async def _json_or_form_payload(request): content_type = request.headers.get("content-type", "") if content_type.startswith("application/json"): @@ -731,6 +742,54 @@ async def _execute_write_analysis_data(datasette, db, sql, actor): } +async def _query_create_analysis_data(datasette, db, sql, actor): + has_sql = bool(sql and sql.strip()) + parameter_names = [] + analysis_rows = [] + analysis_error = None + if has_sql: + try: + parameter_names = _derived_query_parameters(sql) + params = {parameter: "" for parameter in parameter_names} + analysis = await db.analyze_sql(sql, params) + analysis_rows = await _analysis_rows_with_permissions( + datasette, analysis, actor + ) + except (QueryValidationError, sqlite3.DatabaseError) as ex: + analysis_error = getattr(ex, "message", str(ex)) + return { + "ok": analysis_error is None, + "parameters": parameter_names, + "analysis_error": analysis_error, + "analysis_rows": analysis_rows, + "has_sql": has_sql, + "analysis_is_write": bool( + analysis_rows and any(row["required_permission"] for row in analysis_rows) + ), + "save_disabled": bool( + (not has_sql) + or analysis_error + or any(row["allowed"] is False for row in analysis_rows) + ), + } + + +async def _query_create_form_context( + datasette, request, db, *, sql="", name="", title="", description="", is_private=True +): + analysis_data = await _query_create_analysis_data(datasette, db, sql, request.actor) + return { + "database": db.name, + "database_color": db.color, + "sql": sql, + "name": name, + "title": title, + "description": description, + "is_private": is_private, + **analysis_data, + } + + async def _inserted_row_url(datasette, db, analysis, cursor): if cursor.rowcount != 1: return None @@ -1307,6 +1366,35 @@ class QueryCreateView(BaseView): name = "query-create" has_json_alternate = False + async def _render_form( + self, + request, + db, + *, + sql="", + name="", + title="", + description="", + is_private=True, + status=200, + ): + response = await self.render( + ["query_create.html"], + request, + await _query_create_form_context( + self.ds, + request, + db, + sql=sql, + name=name, + title=title, + description=description, + is_private=is_private, + ), + ) + response.status = status + return response + async def get(self, request): db = await self.ds.resolve_database(request) await self.ds.ensure_permission( @@ -1320,46 +1408,61 @@ class QueryCreateView(BaseView): actor=request.actor, ) - sql = request.args.get("sql") or "" - analysis_error = None - analysis_rows = [] - parameter_names = [] - if sql: - try: - parameter_names = _derived_query_parameters(sql) - params = {parameter: "" for parameter in parameter_names} - analysis = await db.analyze_sql(sql, params) - analysis_rows = await _analysis_rows_with_permissions( - self.ds, analysis, request.actor - ) - except (QueryValidationError, sqlite3.DatabaseError) as ex: - analysis_error = getattr(ex, "message", str(ex)) + return await self._render_form(request, db, sql=request.args.get("sql") or "") - return await self.render( - ["query_create.html"], - request, - { - "database": db.name, - "database_color": db.color, - "sql": sql, - "parameter_names": parameter_names, - "analysis_error": analysis_error, - "analysis_rows": analysis_rows, - "analysis_is_write": bool( - analysis_rows - and any(row["required_permission"] for row in analysis_rows) - ), - "save_disabled": bool( - analysis_error - or any(row["allowed"] is False for row in analysis_rows) - ), - }, + +class QueryCreateAnalyzeView(BaseView): + name = "query-create-analyze" + has_json_alternate = False + + async def get(self, request): + db = await self.ds.resolve_database(request) + if not await self.ds.allowed( + action="execute-sql", + resource=DatabaseResource(db.name), + actor=request.actor, + ): + return _block_framing(_error(["Permission denied: need execute-sql"], 403)) + if not await self.ds.allowed( + action="insert-query", + resource=DatabaseResource(db.name), + actor=request.actor, + ): + return _block_framing(_error(["Permission denied: need insert-query"], 403)) + + invalid_keys = set(request.args) - {"sql"} + if invalid_keys: + return _block_framing( + _error( + ["Invalid keys: {}".format(", ".join(sorted(invalid_keys)))], + 400, + ) + ) + sql = request.args.get("sql") or "" + return _block_framing( + Response.json( + await _query_create_analysis_data(self.ds, db, sql, request.actor) + ) ) -class QueryInsertView(BaseView): +class QueryInsertView(QueryCreateView): name = "query-insert" + async def _error_response(self, request, db, query_data, message, status): + message = _query_create_form_error_message(message) + self.ds.add_message(request, message, self.ds.ERROR) + return await self._render_form( + request, + db, + sql=query_data.get("sql") or "", + name=query_data.get("name") or "", + title=query_data.get("title") or "", + description=query_data.get("description") or "", + is_private=_as_bool(query_data.get("is_private", True)), + status=status, + ) + async def post(self, request): db = await self.ds.resolve_database(request) if not await self.ds.allowed( @@ -1375,6 +1478,8 @@ class QueryInsertView(BaseView): ): return _error(["Permission denied: need insert-query"], 403) + is_json = False + query_data = {} try: data, is_json = await _json_or_form_payload(request) if not isinstance(data, dict): @@ -1384,6 +1489,10 @@ class QueryInsertView(BaseView): raise QueryValidationError("JSON must contain a query dictionary") prepared = await _prepare_query_create(self.ds, request, db, query_data) except QueryValidationError as ex: + if not is_json and isinstance(query_data, dict): + return await self._error_response( + request, db, query_data, ex.message, ex.status + ) return _error([ex.message], ex.status) prepared.pop("analysis") @@ -1391,6 +1500,8 @@ class QueryInsertView(BaseView): try: await self.ds.add_query(db.name, name, replace=False, **prepared) except sqlite3.IntegrityError as ex: + if not is_json and isinstance(query_data, dict): + return await self._error_response(request, db, query_data, str(ex), 400) return _error([str(ex)], 400) query = await self.ds.get_query(db.name, name) @@ -1896,7 +2007,7 @@ class QueryView(View): ): save_query_url = ( datasette.urls.database(database) - + "/-/queries/-/create?" + + "/-/queries/insert?" + urlencode({"sql": sql}) ) diff --git a/tests/test_queries.py b/tests/test_queries.py index c27c23da..32cdfae3 100644 --- a/tests/test_queries.py +++ b/tests/test_queries.py @@ -986,6 +986,14 @@ async def test_create_query_ui_and_arbitrary_sql_save_link(): await ds.invoke_startup() create_response = await ds.client.get( + "/data/-/queries/insert?sql=select+*+from+dogs", + actor={"id": "root"}, + ) + blank_create_response = await ds.client.get( + "/data/-/queries/insert", + actor={"id": "root"}, + ) + old_create_response = await ds.client.get( "/data/-/queries/-/create?sql=select+*+from+dogs", actor={"id": "root"}, ) @@ -996,16 +1004,171 @@ async def test_create_query_ui_and_arbitrary_sql_save_link(): assert create_response.status_code == 200 assert "Create query" in create_response.text - assert "Read-only" in create_response.text assert "Writable" in create_response.text + assert 'type="radio"' not in create_response.text + assert 'name="parameters"' not in create_response.text + assert 'id="query-parameters"' not in create_response.text + assert 'class="query-create-field"' in create_response.text + assert '' not in create_response.text + assert '' in create_response.text + assert '' in create_response.text + assert '/data/' in create_response.text + assert ( + '' + in create_response.text + ) + assert 'function slugify(value)' in create_response.text + assert 'data-analyze-url="/data/-/queries/analyze"' in create_response.text + assert "setupSqlParameterRefresh" in create_response.text + assert "renderParameters: false" in create_response.text + assert "datasetteSqlAnalysis.renderAnalysis" in create_response.text + assert "data-query-create-submit" in create_response.text + assert "data-query-create-writable" in create_response.text + assert ( + "Queries marked private can only be seen by you, their creator." + in create_response.text + ) assert "

Query operations

" in create_response.text assert '
Required permissionSourceread
' in create_response.text assert '' in create_response.text assert '' not in create_response.text assert "" in create_response.text + assert ( + create_response.text.count( + '' + ) + == 2 + ) + assert create_response.text.index('value="Save query"') < create_response.text.index( + "

Query operations

" + ) + assert blank_create_response.status_code == 200 + assert ( + '
Required permissionSourcereadn/a
' in response.text assert '' in response.text assert "" in response.text From 5dca2dc9beea96c52e6a9c806df66c9a1f2f7874 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 26 May 2026 13:54:47 -0700 Subject: [PATCH 148/176] Show query count on database page --- datasette/templates/database.html | 2 +- datasette/views/database.py | 18 +++++++++++++++++- tests/test_queries.py | 11 ++++++----- 3 files changed, 24 insertions(+), 7 deletions(-) diff --git a/datasette/templates/database.html b/datasette/templates/database.html index 62f9c620..371f6a22 100644 --- a/datasette/templates/database.html +++ b/datasette/templates/database.html @@ -59,7 +59,7 @@ {% endfor %} {% if queries_more %} -

View all queries

+

View {{ "{:,}".format(queries_count) }} quer{% if queries_count == 1 %}y{% else %}ies{% endif %}

{% endif %} {% endif %} diff --git a/datasette/views/database.py b/datasette/views/database.py index feb38619..d40d69d1 100644 --- a/datasette/views/database.py +++ b/datasette/views/database.py @@ -102,6 +102,11 @@ class DatabaseView(View): ) canned_queries = queries_page["queries"] queries_more = queries_page["has_more"] + queries_count = ( + await datasette.count_queries(database, actor=request.actor) + if queries_more + else len(canned_queries) + ) async def database_actions(): links = [] @@ -134,6 +139,7 @@ class DatabaseView(View): "views": sql_views, "queries": canned_queries, "queries_more": queries_more, + "queries_count": queries_count, "allow_execute_sql": allow_execute_sql, "table_columns": ( await _table_columns(datasette, database) if allow_execute_sql else {} @@ -168,6 +174,7 @@ class DatabaseView(View): views=sql_views, queries=canned_queries, queries_more=queries_more, + queries_count=queries_count, allow_execute_sql=allow_execute_sql, table_columns=( await _table_columns(datasette, database) @@ -219,6 +226,7 @@ class DatabaseContext(Context): queries_more: bool = field( metadata={"help": "Boolean indicating if more saved queries are available"} ) + queries_count: int = field(metadata={"help": "Count of visible saved queries"}) allow_execute_sql: bool = field( metadata={"help": "Boolean indicating if custom SQL can be executed"} ) @@ -775,7 +783,15 @@ async def _query_create_analysis_data(datasette, db, sql, actor): async def _query_create_form_context( - datasette, request, db, *, sql="", name="", title="", description="", is_private=True + datasette, + request, + db, + *, + sql="", + name="", + title="", + description="", + is_private=True, ): analysis_data = await _query_create_analysis_data(datasette, db, sql, request.actor) return { diff --git a/tests/test_queries.py b/tests/test_queries.py index 32cdfae3..09b41645 100644 --- a/tests/test_queries.py +++ b/tests/test_queries.py @@ -458,9 +458,10 @@ async def test_database_page_query_preview_is_limited(): assert html_response.status_code == 200 assert "Demo query 05" in html_response.text assert "Demo query 06" not in html_response.text - assert 'href="/data/-/queries"' in html_response.text + assert 'View 25 queries' in html_response.text assert len(json_response.json()["queries"]) == 5 assert json_response.json()["queries_more"] is True + assert json_response.json()["queries_count"] == 25 @pytest.mark.asyncio @@ -1017,7 +1018,7 @@ async def test_create_query_ui_and_arbitrary_sql_save_link(): '' in create_response.text ) - assert 'function slugify(value)' in create_response.text + assert "function slugify(value)" in create_response.text assert 'data-analyze-url="/data/-/queries/analyze"' in create_response.text assert "setupSqlParameterRefresh" in create_response.text assert "renderParameters: false" in create_response.text @@ -1039,9 +1040,9 @@ async def test_create_query_ui_and_arbitrary_sql_save_link(): ) == 2 ) - assert create_response.text.index('value="Save query"') < create_response.text.index( - "

Query operations

" - ) + assert create_response.text.index( + 'value="Save query"' + ) < create_response.text.index("

Query operations

") assert blank_create_response.status_code == 200 assert ( '
Required permissioninsert
' in create_response.text assert '' in create_response.text @@ -1053,6 +1067,12 @@ async def test_create_query_ui_and_arbitrary_sql_save_link(): "

Analysis will show each affected table and required permission.

" not in blank_create_response.text ) + assert "Enter SQL to analyze this query." in blank_create_response.text + assert write_create_response.status_code == 200 + assert ( + 'This query updates data in the database.' + in write_create_response.text + ) assert query_response.status_code == 200 assert "Save this query" in query_response.text assert "/data/-/queries/insert?sql=select+%2A+from+dogs" in query_response.text From 024b9117725bbed17396a5a4b3f48663c23337f5 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 26 May 2026 14:09:53 -0700 Subject: [PATCH 150/176] Clarifying comment https://github.com/simonw/datasette/pull/2741/changes#r3306856046 --- datasette/default_permissions/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/datasette/default_permissions/__init__.py b/datasette/default_permissions/__init__.py index a9f2d8bd..6cd46f04 100644 --- a/datasette/default_permissions/__init__.py +++ b/datasette/default_permissions/__init__.py @@ -26,6 +26,7 @@ from .restrictions import ( from .root import root_user_permissions_sql as root_user_permissions_sql from .config import config_permissions_sql as config_permissions_sql from .defaults import ( + # Avoid "datasette.default_permissions" does not explicitly export attribute default_allow_sql_check as default_allow_sql_check, default_action_permissions_sql as default_action_permissions_sql, default_query_permissions_sql as default_query_permissions_sql, From ac6ee097dd06050188d44c6d4b17a98a12c7b481 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 26 May 2026 14:10:48 -0700 Subject: [PATCH 151/176] Disallow update/delete of private queries If a user does not own a private query they cannot update or delete it either, even if they have global update-query. https://github.com/simonw/datasette/pull/2741/changes#r3306417463 --- datasette/default_permissions/defaults.py | 33 ++++----- tests/test_queries.py | 81 +++++++++++++++++++++++ 2 files changed, 95 insertions(+), 19 deletions(-) diff --git a/datasette/default_permissions/defaults.py b/datasette/default_permissions/defaults.py index 32ad4ef1..5bc74425 100644 --- a/datasette/default_permissions/defaults.py +++ b/datasette/default_permissions/defaults.py @@ -77,36 +77,31 @@ async def default_query_permissions_sql( ) -> Optional[PermissionSQL]: actor_id = actor.get("id") if isinstance(actor, dict) else None - if action in {"update-query", "delete-query"}: - if actor_id is None: - return None - # Query owner can update/delete query - return PermissionSQL( - sql=""" - SELECT database_name AS parent, name AS child, 1 AS allow, - 'query owner' AS reason - FROM queries - WHERE source = 'user' - AND owner_id = :query_owner_id - """, - params={"query_owner_id": actor_id}, - ) - - if action != "view-query": + if action not in {"view-query", "update-query", "delete-query"}: return None params = {"query_owner_id": actor_id} rule_sqls = [] if actor_id is not None: - # Query owner can view-query - rule_sqls.append(""" + if action in {"update-query", "delete-query"}: + # Query owner can update/delete query + rule_sqls.append(""" + SELECT database_name AS parent, name AS child, 1 AS allow, + 'query owner' AS reason + FROM queries + WHERE source = 'user' + AND owner_id = :query_owner_id + """) + else: + # Query owner can view-query + rule_sqls.append(""" SELECT database_name AS parent, name AS child, 1 AS allow, 'query owner' AS reason FROM queries WHERE owner_id = :query_owner_id """) - # restriction_sql enforces private queries ONLY visible to owner + # restriction_sql enforces private queries ONLY visible/mutable by owner return PermissionSQL( sql="\nUNION ALL\n".join(rule_sqls) if rule_sqls else None, restriction_sql=""" diff --git a/tests/test_queries.py b/tests/test_queries.py index f888dda0..26a0748c 100644 --- a/tests/test_queries.py +++ b/tests/test_queries.py @@ -1581,6 +1581,87 @@ async def test_query_owner_gets_update_delete_and_writable_view_defaults(): ) +@pytest.mark.asyncio +async def test_private_query_restricts_broad_update_delete_permissions(): + ds = Datasette( + memory=True, + default_deny=True, + config={ + "databases": { + "data": { + "permissions": { + "update-query": {"id": "bob"}, + "delete-query": {"id": "bob"}, + }, + }, + }, + }, + ) + ds.add_memory_database("query_broad_update_delete", name="data") + await ds.invoke_startup() + await ds.add_query( + "data", + "alice_private", + "select 1", + is_private=True, + source="user", + owner_id="alice", + ) + await ds.add_query( + "data", + "alice_public", + "select 2", + is_private=False, + source="user", + owner_id="alice", + ) + + for action in ("update-query", "delete-query"): + assert await ds.allowed( + action=action, + resource=QueryResource("data", "alice_private"), + actor={"id": "alice"}, + ) + assert not await ds.allowed( + action=action, + resource=QueryResource("data", "alice_private"), + actor={"id": "bob"}, + ) + assert await ds.allowed( + action=action, + resource=QueryResource("data", "alice_public"), + actor={"id": "bob"}, + ) + + private_update_response = await ds.client.post( + "/data/alice_private/-/update", + actor={"id": "bob"}, + json={"update": {"title": "Nope"}}, + ) + private_delete_response = await ds.client.post( + "/data/alice_private/-/delete", + actor={"id": "bob"}, + json={}, + ) + public_update_response = await ds.client.post( + "/data/alice_public/-/update", + actor={"id": "bob"}, + json={"update": {"title": "Bob can edit public queries"}}, + ) + public_delete_response = await ds.client.post( + "/data/alice_public/-/delete", + actor={"id": "bob"}, + json={}, + ) + + assert private_update_response.status_code == 403 + assert private_delete_response.status_code == 403 + assert public_update_response.status_code == 200 + assert public_delete_response.status_code == 200 + assert await ds.get_query("data", "alice_private") is not None + assert await ds.get_query("data", "alice_public") is None + + @pytest.mark.asyncio async def test_user_writable_query_execution_rechecks_table_permissions(): ds = Datasette( From 180a6a86fd77ac43f6cf3bfb7d7f9150003da419 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 26 May 2026 14:16:10 -0700 Subject: [PATCH 152/176] Remove queries-plan.md We do not need this any more. It can live forever in Git history. --- queries-plan.md | 446 ------------------------------------------------ 1 file changed, 446 deletions(-) delete mode 100644 queries-plan.md diff --git a/queries-plan.md b/queries-plan.md deleted file mode 100644 index da6b7c92..00000000 --- a/queries-plan.md +++ /dev/null @@ -1,446 +0,0 @@ -# Queries in the internal database - -Plan for . - -## Goal - -Move named query definitions into Datasette's internal database, so hundreds or thousands of queries can be listed, searched, permission-filtered, managed, and executed efficiently. - -Terminology change: these are now "queries", not "canned queries". Legacy code and documentation can mention the old name only when describing compatibility or migration. - -## Decisions so far - -- Internal table name: `queries`. -- Query definitions should use real columns, not a JSON blob for all options. -- Query parameter names live in a `parameters` text column as a JSON array. No default values for parameters in this pass. -- No separate index is needed for the privacy/trust flags yet. -- User-created queries require `execute-sql` and `insert-query` on the database. They default to private, and writable queries additionally require matching table write permissions discovered by `Database.analyze_sql()`. -- Configured queries default to trusted, which means actors who can view them can execute them without also holding `execute-sql` or the relevant write permissions. Config can opt out with `is_trusted: false`. -- Add `update-query` and `delete-query`, so administrators can manage queries created by other users. -- Remove the old `canned_queries()` hook from core. If we want compatibility later, build a separate `datasette-old-canned-queries` plugin. -- Writable user-created queries can be supported using `Database.analyze_sql()`, provided we fail closed when analysis cannot prove the required permissions. - -## Current shape - -- Query definitions currently come from `datasette.yaml` or the `canned_queries()` plugin hook. -- `Datasette.get_canned_queries(database_name, actor)` calls that hook every time it needs query definitions. -- `QueryResource.resources_sql()` currently enumerates databases and calls the hook for each one, because permissions and `/-/jump` need query resources. -- Query pages are visible if the actor has `view-query` for `QueryResource(database, query)`. Executing an untrusted stored query also checks `execute-sql` or the relevant write permissions. -- Arbitrary SQL executes if the actor has `execute-sql` for `DatabaseResource(database)`. - -The main performance and architecture win is making query resource enumeration a direct SQL query against the internal database. - -## Proposed internal schema - -Start with one `queries` table. - -```sql -CREATE TABLE IF NOT EXISTS queries ( - database_name TEXT NOT NULL, - name TEXT NOT NULL, - sql TEXT NOT NULL, - title TEXT, - description TEXT, - description_html TEXT, - options TEXT NOT NULL DEFAULT '{}', - parameters TEXT NOT NULL DEFAULT '[]', - is_write INTEGER NOT NULL DEFAULT 0 CHECK (is_write IN (0, 1)), - is_private INTEGER NOT NULL DEFAULT 0 CHECK (is_private IN (0, 1)), - is_trusted INTEGER NOT NULL DEFAULT 0 CHECK (is_trusted IN (0, 1)), - source TEXT NOT NULL DEFAULT 'user', - owner_id TEXT, - created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - PRIMARY KEY (database_name, name) -); - -CREATE INDEX IF NOT EXISTS queries_owner_idx - ON queries(owner_id); -``` - -Column notes: - -- `database_name`, `name`, and `sql` are the routing and execution core. -- Display fields become columns: `title`, `description`, and `description_html`. -- Less common presentation and writable-query behavior lives in `options`, stored as a JSON object. That covers `hide_sql`, `fragment`, `on_success_message`, `on_success_message_sql`, `on_success_redirect`, `on_error_message`, and `on_error_redirect`. -- `parameters` is a JSON array of parameter names, stored as text. This preserves explicit parameter order, but does not support labels or default values. -- Existing writable query behavior gets `is_write` as a column. Success/error messages, success/error redirects, and `on_success_message_sql` are stored in `options`. -- `is_private` means the query is only visible to its owning actor. This is enforced as a permission restriction, so broader `view-query` grants do not expose private rows. -- `is_trusted` means execution skips the usual `execute-sql` or write-permission checks after `view-query` has allowed access. -- `source` distinguishes `user`, `config`, and `plugin` rows. -- `owner_id` is the actor id for user-created rows. It is `NULL` for config/plugin rows. - -No separate index is needed on `(database_name, name)` because the primary key already creates one. - -`QueryResource.resources_sql()` can become: - -```sql -SELECT q.database_name AS parent, q.name AS child -FROM queries q -JOIN catalog_databases cd ON cd.database_name = q.database_name -``` - -The join keeps persisted queries for detached databases from appearing as live resources. - -## Config and plugin migration - -`datasette.yaml` can continue to support `databases: {db}: queries:` blocks, but core should import them directly into the internal `queries` tables at startup: - -1. Ensure the internal schema exists. -2. Delete previous `source='config'` rows. -3. Read configured query blocks for each live database. -4. Normalize string definitions to `{"sql": ...}`. -5. Insert rows into `queries`, storing explicit `params` as JSON in `parameters`. - -Plugins should move to: - -```python -await datasette.add_query(...) -await datasette.remove_query(...) -``` - -Remove the old `canned_queries()` hookspec and all core calls to it. If compatibility is needed, build `datasette-old-canned-queries` later as a plugin that restores the hook and imports old hook results using `datasette.add_query()`. - -## Permission model - -Add core actions: - -- `insert-query`, database-level, for creating queries in a database. -- `update-query`, query-level, for modifying existing query definitions. -- `delete-query`, query-level, for deleting existing query definitions. - -User-created query creation requires: - -- `execute-sql` on `DatabaseResource(database)` -- `insert-query` on `DatabaseResource(database)` -- If analysis shows the query is writable, the table-level write permissions described in the writable query section. - -Updating an existing query requires: - -- `update-query` on `QueryResource(database, query)` or default owner permission for a user-owned row. -- If the SQL changes, also require `execute-sql` on the database. -- If the changed SQL is writable, also require the table-level write permissions described in the writable query section. - -Deleting an existing query requires: - -- `delete-query` on `QueryResource(database, query)` or default owner permission for a user-owned row. - -Default owner permissions: - -- For `source='user' AND owner_id = actor.id`, grant `update-query` and `delete-query`. -- For `source='user' AND owner_id = actor.id`, grant `view-query`. If the query is private, restriction SQL ensures no other actor sees it through a broader grant. - -## Executing queries - -Default execution rule for read-only queries: - -- If `is_trusted=0`, the actor needs `execute-sql` on the database. -- If `is_trusted=1`, the actor can execute the query without `execute-sql`, provided `view-query` allows access. - -Default execution rule for user-created writable queries: - -- `is_trusted` must be `0`. -- The actor must have `view-query`. -- The actor must currently have every write permission required by fresh `Database.analyze_sql()` results for the query SQL. - -Implementation: - -- Keep `view-query` in the broad `DEFAULT_ALLOW_ACTIONS` set, so saved queries remain visible by default in all-public Datasette. -- Emit default `view-query` allows for the owning actor. -- Use `restriction_sql` to limit private rows to their owner even when broader `view-query` permissions exist. -- Have `QueryView` perform the fresh `execute-sql` or table-permission check before execution unless the row has `is_trusted=1`. - -For read-only queries this keeps `QueryView` explicit: it checks `view-query` for the query resource, then checks `execute-sql` unless the row is trusted. User-created writable queries need one additional runtime permission check because their required table permissions are derived from fresh SQL analysis. - -Explicit deny rules should still be able to block a query, and `--default-deny` still blocks trusted queries unless something grants `view-query`. - -## Writable queries - -Writable user-created queries should be in scope, guarded by `Database.analyze_sql()`. - -The secure rule: a user can create, update, or execute a writable user-created query only if they currently have the corresponding write permissions for every table the SQL can affect. - -`Database.analyze_sql(sql, params=None)` runs the SQL through SQLite's authorizer on an isolated connection and returns a `SQLAnalysis` object containing `SQLTableAccess` rows: - -- `operation`: `read`, `insert`, `update`, or `delete` -- `database`: Datasette database name for `main`, or SQLite schema name where no Datasette mapping exists -- `table`: affected table or view -- `columns`: read/updated columns where SQLite reports them -- `source`: trigger/view/CTE source when SQLite reports one - -Validation flow for user-created queries: - -1. Derive named parameters from the SQL and pass harmless placeholder values into `db.analyze_sql()` so SQLite can prepare statements with bindings. -2. If analysis raises a SQLite error, reject the query. -3. If every table access is `read`, treat the query as read-only and require `execute-sql` plus `insert-query`/`update-query` as described above. -4. If any table access is `insert`, `update`, or `delete`, treat the query as writable and force `is_trusted=0`. -5. Reject writable user-created queries that access a database other than the database they are being saved against, until `analyze_sql()` can reliably map attached SQLite schemas back to Datasette database names. -6. For every write access returned by analysis, require the corresponding permission on `TableResource(access.database, access.table)`: - - `insert` -> `insert-row` - - `update` -> `update-row` - - `delete` -> `delete-row` -7. Include write accesses reported from triggers and views, since those are real side effects. -8. Re-run the same analysis and permission checks when SQL changes through `update_query()` or `POST .../-/update`. -9. Re-run analysis before executing user-created writable queries, so schema or trigger changes cannot leave a previously saved query with stale permission assumptions. - -The user-facing API should not trust a submitted `is_write` value. It should derive `is_write` from analysis. - -Trusted configuration and plugin code can still call `datasette.add_query(..., is_write=True, ...)`. Those are treated as deployment/admin-authored queries. They keep the existing execution model: they require `view-query`, and the default `view-query` hook should preserve current default-open behavior for trusted writable queries while still respecting `--default-deny`. - -Fail closed cases for user-created writable queries: - -- Analysis fails. -- Analysis reports any write operation that cannot be mapped to a Datasette table resource. -- Analysis reports writes outside the target database. -- The actor lacks any required table write permission. -- `is_trusted=1` is requested through the user-facing API. - -This gives us writable user-created queries without letting `execute-sql` alone become a path to create arbitrary write endpoints. - -## HTTP API sketch - -JSON endpoints should follow Datasette's existing write API style: use `POST` plus action paths such as `/-/insert`, `/-/update`, and `/-/delete`, not HTTP `PATCH` or `DELETE`. - -Endpoints: - -- `GET /-/queries` and `GET /{database}/-/queries` show searchable HTML query browsers. `GET /-/queries.json` lists query definitions across every database the actor can view; `GET /{database}/-/queries.json` scopes that list to one database. Both JSON endpoints use cursor pagination with `_next` and `_size`. -- `POST /{database}/-/queries/insert` creates a query. -- `GET /{database}/{query}/-/definition` returns one query definition without executing it. -- `POST /{database}/{query}/-/update` updates one query. -- `POST /{database}/{query}/-/delete` deletes one query. - -Create request: - -```json -{ - "query": { - "name": "top_customers", - "sql": "select * from customers order by revenue desc limit 20", - "title": "Top customers", - "description": "Highest revenue customers", - "is_private": true, - "parameters": ["region"] - } -} -``` - -Successful create returns `201` and the created query definition: - -```json -{ - "ok": true, - "query": { - "database": "fixtures", - "name": "top_customers", - "sql": "select * from customers order by revenue desc limit 20", - "title": "Top customers", - "description": "Highest revenue customers", - "is_private": true, - "is_trusted": false, - "parameters": ["region"] - } -} -``` - -Update request, imitating `RowUpdateView`: - -```json -{ - "update": { - "title": "Top customers by revenue", - "is_private": false - }, - "return": true -} -``` - -Successful update returns `{"ok": true}` by default. With `"return": true`, return the updated query definition: - -```json -{ - "ok": true, - "query": { - "database": "fixtures", - "name": "top_customers", - "sql": "select * from customers order by revenue desc limit 20", - "title": "Top customers by revenue", - "is_private": false, - "is_trusted": false - } -} -``` - -Delete request: - -```http -POST /{database}/{query}/-/delete -Content-Type: application/json -``` - -Successful delete returns: - -```json -{ - "ok": true -} -``` - -Validation: - -- Update bodies must be dictionaries containing an `update` dictionary, with optional `return`; invalid keys return `{"ok": false, "errors": [...]}`. -- Validate route-safe query names. -- Reject names that collide with a table or view in the same database, since table routes currently win over query routes. -- Analyze user-created SQL with `Database.analyze_sql()`. -- Use `validate_sql_select(sql)` as the read-only fast path when analysis shows only reads, but do not require it for writable queries that pass analysis and permission checks. -- Reject magic parameters such as `:_actor_id`, `:_cookie_*`, and `:_header_*` for user-created queries. -- Reject client-supplied `is_write`; derive it from analysis. -- Reject writable-only success/error fields for read-only queries. - -## Python API sketch - -Add methods on `Datasette`: - -```python -await datasette.add_query( - database, - name, - sql, - title=None, - description=None, - description_html=None, - hide_sql=False, - fragment=None, - parameters=None, - is_write=False, - is_private=False, - is_trusted=False, - source="plugin", - owner_id=None, - on_success_message=None, - on_success_message_sql=None, - on_success_redirect=None, - on_error_message=None, - on_error_redirect=None, - replace=True, -) - -await datasette.update_query( - database, - name, - *, - sql=UNCHANGED, - title=UNCHANGED, - description=UNCHANGED, - description_html=UNCHANGED, - hide_sql=UNCHANGED, - fragment=UNCHANGED, - parameters=UNCHANGED, - is_write=UNCHANGED, - is_private=UNCHANGED, - is_trusted=UNCHANGED, - source=UNCHANGED, - owner_id=UNCHANGED, - on_success_message=UNCHANGED, - on_success_message_sql=UNCHANGED, - on_success_redirect=UNCHANGED, - on_error_message=UNCHANGED, - on_error_redirect=UNCHANGED, -) - -await datasette.remove_query(database, name, source=None) - -await datasette.get_query(database, name) -await datasette.list_queries( - database, - actor=None, - limit=50, - cursor=None, - q=None, - is_write=None, - is_private=None, - is_trusted=None, - source=None, - owner_id=None, -) -``` - -`list_queries()` should return a bounded page shaped like `{"queries": [...], "next": "...", "has_more": true, "limit": 50}`. The `next` value is an opaque cursor token, not an offset. Passing `database=None` lists visible queries across all live databases, still filtered through `view-query` permission SQL. - -`update_query()` should use an internal sentinel default such as `UNCHANGED = object()` so callers can distinguish "leave this column alone" from "set this column to `NULL`": - -```python -await datasette.update_query( - "fixtures", - "top_customers", - on_success_redirect=None, -) -``` - -For column-backed fields, `None` should write SQL `NULL`. For option fields, `None` should remove that key from the JSON object so `get_query()` returns `None`; omitting the field should leave the existing option unchanged. - -Implementation detail: build the `UPDATE` statement dynamically from fields whose value is not `UNCHANGED`, validate non-nullable fields before writing, and update `updated_at` whenever at least one field changes. - -The read methods should reconstruct the existing dictionary shape used by query execution and templates, with `name`, `sql`, display fields, write fields, `params`, `is_private`, `is_trusted`, `owner_id`, and `source`. `parameters` should be returned as the decoded JSON array and exposed as `params` where existing query execution code expects that key. Option values should be unpacked from the `options` JSON object and returned as the same top-level keys accepted by `add_query()` and `update_query()`. - -## Query page save UI - -On `/{database}/-/query`, if the actor has both `execute-sql` and `insert-query`, show a save control for valid read-only SQL. That page already executes read-only arbitrary SQL, so the first UI can stay read-only even though the JSON API can accept writable SQL after `Database.analyze_sql()` validation. - -The save form should call `POST /{database}/-/queries/insert` and default to `is_private=true`. - -On `/{database}`, show a preview of the first 5 visible queries using `list_queries(..., limit=5)`. If the page has `has_more`, show a link to `/{database}/-/queries` rather than rendering hundreds or thousands of query links inline. The full `/{database}/-/queries` page provides search, filters, and cursor pagination. The global `/-/queries` page reuses the same interface and shows the database for each query. - -## Dedicated create query UI - -Add `/{database}/-/queries/-/create` for the fuller query authoring flow, including writable queries. - -This page should require `execute-sql` and `insert-query` to access. It should provide a SQL editor and a mode control: - -- Read-only -- Writable - -Read-only mode can share the same fields as the arbitrary SQL save flow: name, title, description, parameters, and privacy status. - -Writable mode should always run `Database.analyze_sql()` and show an analysis panel before saving: - -- detected operation -- database and table -- required permission -- whether the actor has that permission -- source, when the operation comes from a trigger or view - -The Save button should be disabled until analysis succeeds and every required table write permission is allowed. - -The existing edit-SQL flow from query pages can continue to point back to arbitrary SQL. A later enhancement can add "update this query" when the actor owns it or has `update-query`. - -## Test plan - -- Internal schema creates `queries`. -- Query parameters are stored in the `queries.parameters` text column as a JSON array of names. -- Config `queries:` blocks import into internal tables. -- Legacy string query definitions normalize to SQL rows. -- The old `canned_queries()` hook is no longer called by core. -- `QueryResource.resources_sql()` returns rows from `queries`. -- Database page and `/-/jump` list queries from the internal DB. -- `view-query` remains globally default-allowed, with `restriction_sql` narrowing private queries to their owner. -- Private query is only visible to its owner, even when a broader `view-query` rule applies. -- Non-trusted read-only query requires `execute-sql` to execute. -- Trusted read-only query can be executed without `execute-sql` after `view-query` passes. -- Config queries default to trusted and can opt out with `is_trusted: false`. -- User API rejects client-supplied `is_trusted`. -- User-created query requires both `execute-sql` and `insert-query`. -- User-created writable query creation uses `Database.analyze_sql()` and requires matching `insert-row`, `update-row`, and/or `delete-row` permissions for every reported write access. -- `/{database}/-/queries/-/create` provides the writable-query authoring UI with an analysis panel and disabled save until all required write permissions pass. -- User-created writable query execution re-runs `Database.analyze_sql()` and re-checks table write permissions. -- User-created writable query cannot be trusted through the user API. -- Query update uses `POST /{database}/{query}/-/update` with an `{"update": {...}}` body. -- Query delete uses `POST /{database}/{query}/-/delete`. -- There are no `PATCH` or HTTP `DELETE` routes for query management. -- `datasette.update_query(..., field=None)` writes `NULL` for column-backed fields and removes JSON keys for option fields, while omitted fields are left unchanged. -- Owner gets default `update-query` and `delete-query` for their own user-created rows. -- Admin can manage other users' queries with `update-query` and `delete-query`. -- User API rejects magic parameters. -- User API rejects writable queries if analysis fails, reports writes outside the target database, or reports writes the actor is not allowed to perform. -- Trusted config/plugin writable queries still execute through `view-query`. -- Trusted config/plugin writable queries are not default-allowed under `--default-deny`. -- Persisted internal DB does not expose queries for detached databases. From 24887004cffd52fe801ecd73da78e13b246ddede Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 26 May 2026 14:51:57 -0700 Subject: [PATCH 153/176] Rename insert-query to store-query Also queries/insert to queries/store Refs https://github.com/simonw/datasette/pull/2741#issuecomment-4549103663 --- datasette/app.py | 6 ++--- datasette/default_actions.py | 6 ++--- datasette/templates/query_create.html | 2 +- datasette/views/database.py | 22 +++++++-------- docs/authentication.rst | 7 ++--- docs/json_api.rst | 5 ++-- tests/test_queries.py | 39 +++++++++++++++------------ 7 files changed, 47 insertions(+), 40 deletions(-) diff --git a/datasette/app.py b/datasette/app.py index 8936b099..42a2d27d 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -54,9 +54,9 @@ from .views.database import ( QueryDeleteView, QueryDefinitionView, GlobalQueryListView, - QueryInsertView, QueryListView, QueryParametersView, + QueryStoreView, QueryUpdateView, ) from .views.index import IndexView @@ -2824,8 +2824,8 @@ class Datasette: r"/(?P[^\/\.]+)/-/queries/analyze$", ) add_route( - QueryInsertView.as_view(self), - r"/(?P[^\/\.]+)/-/queries/insert$", + QueryStoreView.as_view(self), + r"/(?P[^\/\.]+)/-/queries/store$", ) add_route( ExecuteWriteAnalyzeView.as_view(self), diff --git a/datasette/default_actions.py b/datasette/default_actions.py index 6a1f77b8..0f4c25fa 100644 --- a/datasette/default_actions.py +++ b/datasette/default_actions.py @@ -62,9 +62,9 @@ def register_actions(): resource_class=DatabaseResource, ), Action( - name="insert-query", - abbr="iq", - description="Create saved queries", + name="store-query", + abbr="sq", + description="Create stored queries", resource_class=DatabaseResource, also_requires="execute-sql", ), diff --git a/datasette/templates/query_create.html b/datasette/templates/query_create.html index cb14ada4..f5dadbff 100644 --- a/datasette/templates/query_create.html +++ b/datasette/templates/query_create.html @@ -156,7 +156,7 @@ form.sql .query-create-sql textarea#sql-editor {

Create query

-
+

{{ urls.database(database) }}/

diff --git a/datasette/views/database.py b/datasette/views/database.py index d40d69d1..900b94ba 100644 --- a/datasette/views/database.py +++ b/datasette/views/database.py @@ -1419,7 +1419,7 @@ class QueryCreateView(BaseView): actor=request.actor, ) await self.ds.ensure_permission( - action="insert-query", + action="store-query", resource=DatabaseResource(db.name), actor=request.actor, ) @@ -1440,11 +1440,11 @@ class QueryCreateAnalyzeView(BaseView): ): return _block_framing(_error(["Permission denied: need execute-sql"], 403)) if not await self.ds.allowed( - action="insert-query", + action="store-query", resource=DatabaseResource(db.name), actor=request.actor, ): - return _block_framing(_error(["Permission denied: need insert-query"], 403)) + return _block_framing(_error(["Permission denied: need store-query"], 403)) invalid_keys = set(request.args) - {"sql"} if invalid_keys: @@ -1462,8 +1462,8 @@ class QueryCreateAnalyzeView(BaseView): ) -class QueryInsertView(QueryCreateView): - name = "query-insert" +class QueryStoreView(QueryCreateView): + name = "query-store" async def _error_response(self, request, db, query_data, message, status): message = _query_create_form_error_message(message) @@ -1488,11 +1488,11 @@ class QueryInsertView(QueryCreateView): ): return _error(["Permission denied: need execute-sql"], 403) if not await self.ds.allowed( - action="insert-query", + action="store-query", resource=DatabaseResource(db.name), actor=request.actor, ): - return _error(["Permission denied: need insert-query"], 403) + return _error(["Permission denied: need store-query"], 403) is_json = False query_data = {} @@ -1961,8 +1961,8 @@ class QueryView(View): resource=DatabaseResource(database=database), actor=request.actor, ) - allow_insert_query = await datasette.allowed( - action="insert-query", + allow_store_query = await datasette.allowed( + action="store-query", resource=DatabaseResource(database=database), actor=request.actor, ) @@ -2020,13 +2020,13 @@ class QueryView(View): if ( not canned_query and allow_execute_sql - and allow_insert_query + and allow_store_query and is_validated_sql and ":_" not in sql ): save_query_url = ( datasette.urls.database(database) - + "/-/queries/insert?" + + "/-/queries/store?" + urlencode({"sql": sql}) ) diff --git a/docs/authentication.rst b/docs/authentication.rst index 453aaa19..184fec5e 100644 --- a/docs/authentication.rst +++ b/docs/authentication.rst @@ -1293,11 +1293,12 @@ Actor is allowed to view a saved query page, e.g. https://latest.datasette.io/fi ``query`` is the name of the query (string) .. _actions_insert_query: +.. _actions_store_query: -insert-query ------------- +store-query +----------- -Actor is allowed to create saved queries in a database. +Actor is allowed to create stored queries in a database. ``resource`` - ``datasette.resources.DatabaseResource(database)`` ``database`` is the name of the database (string) diff --git a/docs/json_api.rst b/docs/json_api.rst index dd54c459..1a6c7021 100644 --- a/docs/json_api.rst +++ b/docs/json_api.rst @@ -518,14 +518,15 @@ Listing saved queries Creating saved queries in the UI ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -``GET //-/queries/-/create`` provides a form for creating saved queries. +``GET //-/queries/store`` provides a form for creating stored queries. +.. _QueryStoreView: .. _QueryInsertView: Creating saved queries ~~~~~~~~~~~~~~~~~~~~~~ -``POST //-/queries/insert`` creates a saved query. This requires ``execute-sql`` and ``insert-query`` for the database. +``POST //-/queries/store`` creates a stored query. This requires ``execute-sql`` and ``store-query`` for the database. .. _QueryParametersView: .. _ExecuteWriteView: diff --git a/tests/test_queries.py b/tests/test_queries.py index 26a0748c..5d4da9bb 100644 --- a/tests/test_queries.py +++ b/tests/test_queries.py @@ -470,7 +470,7 @@ async def test_query_actions_are_registered(): await ds.invoke_startup() assert ds.get_action("execute-write-sql").resource_class is DatabaseResource - assert ds.get_action("insert-query").resource_class is DatabaseResource + assert ds.get_action("store-query").resource_class is DatabaseResource assert ds.get_action("update-query").resource_class is QueryResource assert ds.get_action("delete-query").resource_class is QueryResource @@ -537,15 +537,15 @@ async def test_analyze_write_query_rejects_writes_to_attached_databases(): @pytest.mark.asyncio -async def test_query_insert_api_creates_read_only_query(): +async def test_query_store_api_creates_read_only_query(): ds = Datasette(memory=True, default_deny=True) ds.root_enabled = True - db = ds.add_memory_database("query_insert_api", name="data") + db = ds.add_memory_database("query_store_api", name="data") await db.execute_write("create table dogs (id integer primary key, name text)") await ds.invoke_startup() response = await ds.client.post( - "/data/-/queries/insert", + "/data/-/queries/store", actor={"id": "root"}, json={ "query": { @@ -860,7 +860,7 @@ async def test_global_query_list_api_and_html(): @pytest.mark.asyncio -async def test_query_insert_api_rejects_is_trusted(): +async def test_query_store_api_rejects_is_trusted(): ds = Datasette( memory=True, default_deny=True, @@ -870,7 +870,7 @@ async def test_query_insert_api_rejects_is_trusted(): "permissions": { "view-database": {"id": "writer"}, "execute-sql": {"id": "writer"}, - "insert-query": {"id": "writer"}, + "store-query": {"id": "writer"}, } } } @@ -880,7 +880,7 @@ async def test_query_insert_api_rejects_is_trusted(): await ds.invoke_startup() response = await ds.client.post( - "/data/-/queries/insert", + "/data/-/queries/store", actor={"id": "writer"}, json={"query": {"name": "trusted", "sql": "select 1", "is_trusted": True}}, ) @@ -890,7 +890,7 @@ async def test_query_insert_api_rejects_is_trusted(): @pytest.mark.asyncio -async def test_query_insert_api_creates_writable_query(): +async def test_query_store_api_creates_writable_query(): ds = Datasette(memory=True, default_deny=True) ds.root_enabled = True db = ds.add_memory_database("query_write_api", name="data") @@ -898,7 +898,7 @@ async def test_query_insert_api_creates_writable_query(): await ds.invoke_startup() response = await ds.client.post( - "/data/-/queries/insert", + "/data/-/queries/store", actor={"id": "root"}, json={ "query": { @@ -962,14 +962,14 @@ async def test_query_update_and_delete_api(): @pytest.mark.asyncio -async def test_query_insert_api_rejects_magic_parameters(): +async def test_query_store_api_rejects_magic_parameters(): ds = Datasette(memory=True, default_deny=True) ds.root_enabled = True ds.add_memory_database("query_magic_api", name="data") await ds.invoke_startup() response = await ds.client.post( - "/data/-/queries/insert", + "/data/-/queries/store", actor={"id": "root"}, json={"query": {"name": "magic", "sql": "select :_actor_id"}}, ) @@ -987,15 +987,19 @@ async def test_create_query_ui_and_arbitrary_sql_save_link(): await ds.invoke_startup() create_response = await ds.client.get( - "/data/-/queries/insert?sql=select+*+from+dogs", + "/data/-/queries/store?sql=select+*+from+dogs", actor={"id": "root"}, ) write_create_response = await ds.client.get( - "/data/-/queries/insert?sql=insert+into+dogs+(name)+values+('Cleo')", + "/data/-/queries/store?sql=insert+into+dogs+(name)+values+('Cleo')", actor={"id": "root"}, ) blank_create_response = await ds.client.get( - "/data/-/queries/insert", + "/data/-/queries/store", + actor={"id": "root"}, + ) + old_insert_response = await ds.client.get( + "/data/-/queries/insert?sql=select+*+from+dogs", actor={"id": "root"}, ) old_create_response = await ds.client.get( @@ -1075,7 +1079,8 @@ async def test_create_query_ui_and_arbitrary_sql_save_link(): ) assert query_response.status_code == 200 assert "Save this query" in query_response.text - assert "/data/-/queries/insert?sql=select+%2A+from+dogs" in query_response.text + assert "/data/-/queries/store?sql=select+%2A+from+dogs" in query_response.text + assert old_insert_response.status_code == 404 assert old_create_response.status_code == 404 @@ -1153,7 +1158,7 @@ async def test_create_query_form_error_redisplays_form_with_values(): await ds.invoke_startup() response = await ds.client.post( - "/data/-/queries/insert", + "/data/-/queries/store", actor={"id": "root"}, data={ "name": "dogs", @@ -1176,7 +1181,7 @@ async def test_create_query_form_error_redisplays_form_with_values(): assert 'name="is_private" value="1" checked' in response.text public_response = await ds.client.post( - "/data/-/queries/insert", + "/data/-/queries/store", actor={"id": "root"}, data={ "name": "dogs", From 0cadd071871ef0b33e4ce3a23e316a104b3137c3 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 26 May 2026 14:53:31 -0700 Subject: [PATCH 154/176] No need to document QueryCreateAnalyzeView --- tests/test_docs.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tests/test_docs.py b/tests/test_docs.py index 396ba1a2..0d0ef1e1 100644 --- a/tests/test_docs.py +++ b/tests/test_docs.py @@ -66,7 +66,14 @@ def documented_views(): if first_word.endswith("View"): view_labels.add(first_word) # We deliberately don't document these: - view_labels.update(("PatternPortfolioView", "AuthTokenView", "ApiExplorerView")) + view_labels.update( + ( + "PatternPortfolioView", + "AuthTokenView", + "ApiExplorerView", + "QueryCreateAnalyzeView", + ) + ) return view_labels From 4bf1c4b065fef64676abf5eabd04ff35e07188c5 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 26 May 2026 14:54:35 -0700 Subject: [PATCH 155/176] Rename canned queries to queries/stored queries in docs --- datasette/default_actions.py | 4 +- datasette/hookspecs.py | 4 +- datasette/resources.py | 2 +- datasette/views/database.py | 24 ++++----- datasette/views/table.py | 4 +- docs/authentication.rst | 16 +++--- docs/configuration.rst | 10 ++-- docs/custom_templates.rst | 8 +-- docs/internals.rst | 12 ++--- docs/introspection.rst | 2 +- docs/json_api.rst | 32 ++++++------ docs/pages.rst | 4 +- docs/plugin_hooks.rst | 16 +++--- docs/spatialite.rst | 2 +- docs/sql_queries.rst | 95 ++++++++++++++++++++++++++---------- tests/test_html.py | 6 +-- tests/test_permissions.py | 4 +- 17 files changed, 144 insertions(+), 101 deletions(-) diff --git a/datasette/default_actions.py b/datasette/default_actions.py index 0f4c25fa..2f78570b 100644 --- a/datasette/default_actions.py +++ b/datasette/default_actions.py @@ -121,13 +121,13 @@ def register_actions(): Action( name="update-query", abbr="uq", - description="Update saved queries", + description="Update stored queries", resource_class=QueryResource, ), Action( name="delete-query", abbr="dq", - description="Delete saved queries", + description="Delete stored queries", resource_class=QueryResource, ), ) diff --git a/datasette/hookspecs.py b/datasette/hookspecs.py index a4067eaa..22da02a4 100644 --- a/datasette/hookspecs.py +++ b/datasette/hookspecs.py @@ -174,7 +174,7 @@ def view_actions(datasette, actor, database, view, request): @hookspec def query_actions(datasette, actor, database, query_name, request, sql, params): - """Links for the query and canned query actions menu""" + """Links for the query and stored query actions menu""" @hookspec @@ -229,7 +229,7 @@ def top_query(datasette, request, database, sql): @hookspec def top_canned_query(datasette, request, database, query_name): - """HTML to include at the top of the canned query page""" + """HTML to include at the top of the stored query page""" @hookspec diff --git a/datasette/resources.py b/datasette/resources.py index 91a46d36..ee2e6d98 100644 --- a/datasette/resources.py +++ b/datasette/resources.py @@ -41,7 +41,7 @@ class TableResource(Resource): class QueryResource(Resource): - """A saved query in a database.""" + """A stored query in a database.""" name = "query" parent_class = DatabaseResource diff --git a/datasette/views/database.py b/datasette/views/database.py index 900b94ba..f30d3815 100644 --- a/datasette/views/database.py +++ b/datasette/views/database.py @@ -222,11 +222,11 @@ class DatabaseContext(Context): tables: list = field(metadata={"help": "List of table objects in the database"}) hidden_count: int = field(metadata={"help": "Count of hidden tables"}) views: list = field(metadata={"help": "List of view objects in the database"}) - queries: list = field(metadata={"help": "List of canned query objects"}) + queries: list = field(metadata={"help": "List of stored query objects"}) queries_more: bool = field( - metadata={"help": "Boolean indicating if more saved queries are available"} + metadata={"help": "Boolean indicating if more stored queries are available"} ) - queries_count: int = field(metadata={"help": "Count of visible saved queries"}) + queries_count: int = field(metadata={"help": "Count of visible stored queries"}) allow_execute_sql: bool = field( metadata={"help": "Boolean indicating if custom SQL can be executed"} ) @@ -272,7 +272,7 @@ class QueryContext(Context): metadata={"help": "The SQL query object containing the `sql` string"} ) canned_query: str = field( - metadata={"help": "The name of the canned query if this is a canned query"} + metadata={"help": "The name of the stored query if this is a stored query"} ) private: bool = field( metadata={"help": "Boolean indicating if this is a private database"} @@ -282,11 +282,11 @@ class QueryContext(Context): # ) canned_query_write: bool = field( metadata={ - "help": "Boolean indicating if this is a canned query that allows writes" + "help": "Boolean indicating if this is a stored query that allows writes" } ) metadata: dict = field( - metadata={"help": "Metadata about the database or the canned query"} + metadata={"help": "Metadata about the database or the stored query"} ) db_is_immutable: bool = field( metadata={"help": "Boolean indicating if this database is immutable"} @@ -315,7 +315,7 @@ class QueryContext(Context): metadata={"help": "Dictionary of parameter names/values"} ) edit_sql_url: str = field( - metadata={"help": "URL to edit the SQL for a canned query"} + metadata={"help": "URL to edit the SQL for a stored query"} ) display_rows: list = field(metadata={"help": "List of result rows to display"}) columns: list = field(metadata={"help": "List of column names"}) @@ -1623,7 +1623,7 @@ class QueryView(View): db = await datasette.resolve_database(request) - # We must be a canned query + # We must be a stored query table_found = False try: await datasette.resolve_table(request) @@ -1742,14 +1742,14 @@ class QueryView(View): # Create lookup dict for quick access allowed_dict = {r.child: r for r in allowed_tables_page.resources} - # Are we a canned query? + # Are we a stored query? canned_query = None canned_query_write = False if "table" in request.url_vars: try: await datasette.resolve_table(request) except TableNotFound as table_not_found: - # Was this actually a canned query? + # Was this actually a stored query? canned_query = await datasette.get_canned_query( table_not_found.database_name, table_not_found.table, request.actor ) @@ -1759,7 +1759,7 @@ class QueryView(View): private = False if canned_query: - # Respect canned query permissions + # Respect stored query permissions visible, private = await datasette.check_visibility( request.actor, action="view-query", @@ -1823,7 +1823,7 @@ class QueryView(View): # For regular queries we only allow SELECT, plus other rules validate_sql_select(sql) else: - # Canned queries can run magic parameters + # Stored queries can run magic parameters params_for_query = MagicParameters(sql, params, request, datasette) await params_for_query.execute_params() results = await datasette.execute( diff --git a/datasette/views/table.py b/datasette/views/table.py index 7027bb10..7b1a5a82 100644 --- a/datasette/views/table.py +++ b/datasette/views/table.py @@ -963,11 +963,11 @@ async def table_view_traced(datasette, request): try: resolved = await datasette.resolve_table(request) except TableNotFound as not_found: - # Was this actually a canned query? + # Was this actually a stored query? canned_query = await datasette.get_canned_query( not_found.database_name, not_found.table, request.actor ) - # If this is a canned query, not a table, then dispatch to QueryView instead + # If this is a stored query, not a table, then dispatch to QueryView instead if canned_query: return await QueryView()(request, datasette) else: diff --git a/docs/authentication.rst b/docs/authentication.rst index 184fec5e..22db41d8 100644 --- a/docs/authentication.rst +++ b/docs/authentication.rst @@ -468,7 +468,7 @@ You can control the following: * Access to the entire Datasette instance * Access to specific databases * Access to specific tables and views -* Access to specific :ref:`canned_queries` +* Access to specific :ref:`queries ` If a user has permission to view a table they will be able to view that table, independent of if they have permission to view the database or instance that the table exists within. @@ -641,12 +641,12 @@ This works for SQL views as well - you can list their names in the ``"tables"`` .. _authentication_permissions_query: -Access to specific canned queries ---------------------------------- +Access to specific queries +-------------------------- -:ref:`canned_queries` allow you to configure named SQL queries in your ``datasette.yaml`` that can be executed by users. These queries can be set up to both read and write to the database, so controlling who can execute them can be important. +:ref:`Queries ` allow you to configure named SQL queries in your ``datasette.yaml`` that can be executed by users. These queries can be set up to both read and write to the database, so controlling who can execute them can be important. -To limit access to the ``add_name`` canned query in your ``dogs.db`` database to just the :ref:`root user`: +To limit access to the ``add_name`` query in your ``dogs.db`` database to just the :ref:`root user`: .. [[[cog config_example(cog, """ @@ -1285,7 +1285,7 @@ Actor is allowed to view a table (or view) page, e.g. https://latest.datasette.i view-query ---------- -Actor is allowed to view a saved query page, e.g. https://latest.datasette.io/fixtures/pragma_cache_size. Executing an untrusted saved query also requires ``execute-sql`` or the relevant write permissions; trusted saved queries can execute with ``view-query`` alone. +Actor is allowed to view a stored query page, e.g. https://latest.datasette.io/fixtures/pragma_cache_size. Executing an untrusted stored query also requires ``execute-sql`` or the relevant write permissions; :ref:`trusted stored queries ` can execute with ``view-query`` alone. ``resource`` - ``datasette.resources.QueryResource(database, query)`` ``database`` is the name of the database (string) @@ -1308,7 +1308,7 @@ Actor is allowed to create stored queries in a database. update-query ------------ -Actor is allowed to update a saved query. +Actor is allowed to update a stored query. ``resource`` - ``datasette.resources.QueryResource(database, query)`` ``database`` is the name of the database (string) @@ -1320,7 +1320,7 @@ Actor is allowed to update a saved query. delete-query ------------ -Actor is allowed to delete a saved query. +Actor is allowed to delete a stored query. ``resource`` - ``datasette.resources.QueryResource(database, query)`` ``database`` is the name of the database (string) diff --git a/docs/configuration.rst b/docs/configuration.rst index 8c8c8a67..cf9590b8 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -87,6 +87,7 @@ This is equivalent to a ``datasette.yaml`` file containing the following: } .. [[[end]]] + .. _configuration_reference: ``datasette.yaml`` reference @@ -435,10 +436,10 @@ Here is a simple example: .. _configuration_reference_canned_queries: -Canned queries configuration -~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Queries configuration +~~~~~~~~~~~~~~~~~~~~~ -:ref:`Canned queries ` are named SQL queries that appear in the Datasette interface. They can be configured in ``datasette.yaml`` using the ``queries`` key at the database level: +:ref:`Queries ` are named SQL queries that appear in the Datasette interface. They can be configured in ``datasette.yaml`` using the ``queries`` key at the database level: .. [[[cog from metadata_doc import config_example, config_example @@ -483,7 +484,7 @@ Canned queries configuration } .. [[[end]]] -See the :ref:`canned queries documentation ` for more, including how to configure :ref:`writable canned queries `. +See the :ref:`queries documentation ` for more, including how to configure :ref:`writable queries `. .. _configuration_reference_css_js: @@ -1211,4 +1212,3 @@ For column types that accept additional configuration, use an object with ``type } } .. [[[end]]] - diff --git a/docs/custom_templates.rst b/docs/custom_templates.rst index 8cc40f0f..c324fb79 100644 --- a/docs/custom_templates.rst +++ b/docs/custom_templates.rst @@ -29,7 +29,7 @@ The custom SQL template (``/dbname?sql=...``) gets this: -A canned query template (``/dbname/queryname``) gets this: +A stored query template (``/dbname/queryname``) gets this: .. code-block:: html @@ -193,8 +193,8 @@ The lookup rules Datasette uses are as follows:: query-mydatabase.html query.html - Canned query page (/mydatabase/canned-query): - query-mydatabase-canned-query.html + Stored query page (/mydatabase/query-name): + query-mydatabase-query-name.html query-mydatabase.html query.html @@ -230,7 +230,7 @@ will look something like this:: -This example is from the canned query page for a query called "tz" in the +This example is from the stored query page for a query called "tz" in the database called "mydb". The asterisk shows which template was selected - so in this case, Datasette found a template file called ``query-mydb-tz.html`` and used that - but if that template had not been found, it would have tried for diff --git a/docs/internals.rst b/docs/internals.rst index c76de487..084922f8 100644 --- a/docs/internals.rst +++ b/docs/internals.rst @@ -725,7 +725,7 @@ The builder methods are: - ``allow_all(action)`` - allow an action across all databases and resources - ``allow_database(database, action)`` - allow an action on a specific database -- ``allow_resource(database, resource, action)`` - allow an action on a specific resource (table, SQL view or :ref:`canned query `) within a database +- ``allow_resource(database, resource, action)`` - allow an action on a specific resource (table, SQL view or :ref:`stored query `) within a database Each method returns the ``TokenRestrictions`` instance so calls can be chained. @@ -837,10 +837,10 @@ await .get_resource_metadata(self, database_name, resource_name) ``database_name`` - string The name of the database to query. ``resource_name`` - string - The name of the resource (table, view, or canned query) inside ``database_name`` to query. + The name of the resource (table, view, or stored query) inside ``database_name`` to query. Returns metadata keys and values for the specified "resource" as a dictionary. -A "resource" in this context can be a table, view, or canned query. +A "resource" in this context can be a table, view, or stored query. Internally queries the ``metadata_resources`` table inside the :ref:`internal database `. .. _datasette_get_column_metadata: @@ -851,7 +851,7 @@ await .get_column_metadata(self, database_name, resource_name, column_name) ``database_name`` - string The name of the database to query. ``resource_name`` - string - The name of the resource (table, view, or canned query) inside ``database_name`` to query. + The name of the resource (table, view, or stored query) inside ``database_name`` to query. ``column_name`` - string The name of the column inside ``resource_name`` to query. @@ -897,7 +897,7 @@ await .set_resource_metadata(self, database_name, resource_name, key, value) ``database_name`` - string The database the metadata entry belongs to. ``resource_name`` - string - The resource (table, view, or canned query) the metadata entry belongs to. + The resource (table, view, or stored query) the metadata entry belongs to. ``key`` - string The metadata entry key to insert (ex ``title``, ``description``, etc.) ``value`` - string @@ -915,7 +915,7 @@ await .set_column_metadata(self, database_name, resource_name, column_name, key, ``database_name`` - string The database the metadata entry belongs to. ``resource_name`` - string - The resource (table, view, or canned query) the metadata entry belongs to. + The resource (table, view, or stored query) the metadata entry belongs to. ``column-name`` - string The column the metadata entry belongs to. ``key`` - string diff --git a/docs/introspection.rst b/docs/introspection.rst index d2eb8efd..7702a4b5 100644 --- a/docs/introspection.rst +++ b/docs/introspection.rst @@ -149,7 +149,7 @@ Shows currently attached databases. `Databases example /-/queries.json`` returns saved query definitions for a specific database. Use ``?_size=50`` to set the page size and ``?_next=...`` with the cursor returned by the previous page to fetch the next page. +``GET /-/queries.json`` returns stored query definitions across every database that the actor can view. ``GET //-/queries.json`` returns stored query definitions for a specific database. Use ``?_size=50`` to set the page size and ``?_next=...`` with the cursor returned by the previous page to fetch the next page. .. _QueryCreateView: -Creating saved queries in the UI -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Creating stored queries in the UI +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ ``GET //-/queries/store`` provides a form for creating stored queries. .. _QueryStoreView: .. _QueryInsertView: -Creating saved queries -~~~~~~~~~~~~~~~~~~~~~~ +Creating stored queries +~~~~~~~~~~~~~~~~~~~~~~~ ``POST //-/queries/store`` creates a stored query. This requires ``execute-sql`` and ``store-query`` for the database. @@ -545,24 +545,24 @@ Executing write SQL .. _QueryDefinitionView: -Getting a saved query definition -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Getting a stored query definition +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -``GET ///-/definition`` returns a saved query definition without executing it. +``GET ///-/definition`` returns a stored query definition without executing it. .. _QueryUpdateView: -Updating saved queries -~~~~~~~~~~~~~~~~~~~~~~ +Updating stored queries +~~~~~~~~~~~~~~~~~~~~~~~ -``POST ///-/update`` updates a saved query using a JSON body with an ``"update"`` object. +``POST ///-/update`` updates a stored query using a JSON body with an ``"update"`` object. .. _QueryDeleteView: -Deleting saved queries -~~~~~~~~~~~~~~~~~~~~~~ +Deleting stored queries +~~~~~~~~~~~~~~~~~~~~~~~ -``POST ///-/delete`` deletes a saved query. +``POST ///-/delete`` deletes a stored query. .. _TableInsertView: diff --git a/docs/pages.rst b/docs/pages.rst index 34c851a5..e57c15e6 100644 --- a/docs/pages.rst +++ b/docs/pages.rst @@ -28,7 +28,7 @@ The index page can also be accessed at ``/-/``, useful for if the default index Database ======== -Each database has a page listing the tables, views and canned queries available for that database. If the :ref:`actions_execute_sql` permission is enabled (it's on by default) there will also be an interface for executing arbitrary SQL select queries against the data. +Each database has a page listing the tables, views and stored queries available for that database. If the :ref:`actions_execute_sql` permission is enabled (it's on by default) there will also be an interface for executing arbitrary SQL select queries against the data. Examples: @@ -68,7 +68,7 @@ This means you can link directly to a query by constructing the following URL: ``/database-name/-/query?sql=SELECT+*+FROM+table_name`` -Each configured :ref:`canned query ` has its own page, at ``/database-name/query-name``. Viewing this page will execute the query and display the results. +Each configured :ref:`stored query ` has its own page, at ``/database-name/query-name``. Viewing this page will execute the query and display the results. In both cases adding a ``.json`` extension to the URL will return the results as JSON. diff --git a/docs/plugin_hooks.rst b/docs/plugin_hooks.rst index b2676b3e..264b473e 100644 --- a/docs/plugin_hooks.rst +++ b/docs/plugin_hooks.rst @@ -609,7 +609,7 @@ When a request is received, the ``"render"`` callback function is called with ze The SQL query that was executed. ``query_name`` - string or None - If this was the execution of a :ref:`canned query `, the name of that query. + If this was the execution of a :ref:`stored query `, the name of that query. ``database`` - string The name of the database. @@ -1212,7 +1212,7 @@ Examples: `datasette-saved-queries `__ @@ -1635,7 +1635,7 @@ register_magic_parameters(datasette) ``datasette`` - :ref:`internals_datasette` You can use this to access plugin configuration options via ``datasette.plugin_config(your_plugin_name)``. -:ref:`canned_queries_magic_parameters` can be used to add automatic parameters to :ref:`canned queries `. This plugin hook allows additional magic parameters to be defined by plugins. +:ref:`canned_queries_magic_parameters` can be used to add automatic parameters to :ref:`configured queries `. This plugin hook allows additional magic parameters to be defined by plugins. Magic parameters all take this format: ``_prefix_rest_of_parameter``. The prefix indicates which magic parameter function should be called - the rest of the parameter is passed as an argument to that function. @@ -1828,7 +1828,7 @@ jump_items_sql(datasette, actor, request) This hook allows plugins to add extra results to Datasette's ``/`` jump menu, which is powered by the ``/-/jump`` JSON endpoint. -Return a ``datasette.jump.JumpSQL`` object, or a list of ``JumpSQL`` objects. Each ``JumpSQL`` object wraps a SQL query to be searched alongside Datasette's own databases, tables, views and canned query results. The hook can also be an ``async def`` function, or return an awaitable that resolves to one of these values. +Return a ``datasette.jump.JumpSQL`` object, or a list of ``JumpSQL`` objects. Each ``JumpSQL`` object wraps a SQL query to be searched alongside Datasette's own databases, tables, views and stored query results. The hook can also be an ``async def`` function, or return an awaitable that resolves to one of these values. ``JumpSQL`` queries run against Datasette's internal database by default. To run a query against another database, pass its name as the optional ``database=`` argument. For example, ``JumpSQL(database="content", sql="...")`` runs against the ``content`` database. @@ -2004,7 +2004,7 @@ query_actions(datasette, actor, database, query_name, request, sql, params) The name of the database. ``query_name`` - string or None - The name of the canned query, or ``None`` if this is an arbitrary SQL query. + The name of the stored query, or ``None`` if this is an arbitrary SQL query. ``request`` - :ref:`internals_request` The current HTTP request. @@ -2015,7 +2015,7 @@ query_actions(datasette, actor, database, query_name, request, sql, params) ``params`` - dictionary The parameters passed to the SQL query, if any. -Populates a "Query actions" menu on the canned query and arbitrary SQL query pages. +Populates a "Query actions" menu on the stored query and arbitrary SQL query pages. This example adds a new query action linking to a page for explaining a query: @@ -2294,9 +2294,9 @@ top_canned_query(datasette, request, database, query_name) The name of the database. ``query_name`` - string - The name of the canned query. + The name of the stored query. -Returns HTML to be displayed at the top of the canned query page. +Returns HTML to be displayed at the top of the stored query page. .. _plugin_event_tracking: diff --git a/docs/spatialite.rst b/docs/spatialite.rst index c93c1e00..1999ab78 100644 --- a/docs/spatialite.rst +++ b/docs/spatialite.rst @@ -30,7 +30,7 @@ Warning The following steps are recommended: - Disable arbitrary SQL queries by untrusted users. See :ref:`authentication_permissions_execute_sql` for ways to do this. The easiest is to start Datasette with the ``datasette --setting default_allow_sql off`` option. - - Define :ref:`canned_queries` with the SQL queries that use SpatiaLite functions that you want people to be able to execute. + - Define :ref:`queries ` with the SQL queries that use SpatiaLite functions that you want people to be able to execute. The `Datasette SpatiaLite tutorial `__ includes detailed instructions for running SpatiaLite safely using these techniques diff --git a/docs/sql_queries.rst b/docs/sql_queries.rst index 7c3cd4ac..d60656e3 100644 --- a/docs/sql_queries.rst +++ b/docs/sql_queries.rst @@ -68,10 +68,10 @@ You can also use the `sqlite-utils `__ tool .. _canned_queries: -Canned queries --------------- +Queries +------- -As an alternative to adding views to your database, you can define canned queries inside your ``datasette.yaml`` file. Here's an example: +As an alternative to adding views to your database, you can define named queries inside your ``datasette.yaml`` file. Here's an example: .. [[[cog from metadata_doc import config_example, config_example @@ -120,24 +120,67 @@ Then run Datasette like this:: datasette sf-trees.db -m metadata.json -Each canned query will be listed on the database index page, and will also get its own URL at:: +Each configured query will be listed on the database index page, and will also get its own URL at:: - /database-name/canned-query-name + /database-name/query-name For the above example, that URL would be:: /sf-trees/just_species -You can optionally include ``"title"`` and ``"description"`` keys to show a title and description on the canned query page. As with regular table metadata you can alternatively specify ``"description_html"`` to have your description rendered as HTML (rather than having HTML special characters escaped). +You can optionally include ``"title"`` and ``"description"`` keys to show a title and description on the query page. As with regular table metadata you can alternatively specify ``"description_html"`` to have your description rendered as HTML (rather than having HTML special characters escaped). + +.. _stored_queries: +.. _saved_queries: + +Stored queries +~~~~~~~~~~~~~~ + +Datasette stores both configured queries and user-created queries in the ``queries`` table in the :ref:`internal database `. Configured queries come from the ``queries`` section of ``datasette.yaml``. User-created stored queries can be created from the SQL query page by actors with the :ref:`actions_store_query` and :ref:`actions_execute_sql` permissions. Writable stored queries also require the permissions needed for the writes they perform. + +Stored queries created by users default to private. Private stored queries can only be viewed, updated or deleted by the actor that created them. Broad ``view-query``, ``update-query`` or ``delete-query`` permission grants still do not allow other actors to access another actor's private stored queries. + +Stored queries created by users are untrusted. This means they execute using the permissions of the actor who runs them, as if that actor had pasted the SQL into the regular custom SQL interface or write SQL interface. Read-only stored queries require ``execute-sql``. Writable stored queries require ``execute-write-sql`` plus the relevant table-level write permissions. + +.. _trusted_stored_queries: +.. _trusted_saved_queries: + +Trusted stored queries +++++++++++++++++++++++ + +A trusted stored query can execute with ``view-query`` permission alone. It skips the additional ``execute-sql`` and write permission checks that are applied to untrusted stored queries. + +Trusted stored queries should only be used for SQL that has been reviewed by someone trusted to configure the Datasette instance. For that reason, trusted stored queries can only be added using configuration. Users cannot create trusted stored queries through the web interface or the stored query JSON API. + +Queries defined in ``datasette.yaml`` are trusted by default: + +.. code-block:: yaml + + databases: + mydatabase: + queries: + report: + sql: select * from report + +You can opt out of this behavior for a configured query using ``is_trusted: false``: + +.. code-block:: yaml + + databases: + mydatabase: + queries: + report: + sql: select * from report + is_trusted: false .. _canned_queries_named_parameters: -Canned query parameters -~~~~~~~~~~~~~~~~~~~~~~~ +Query parameters +~~~~~~~~~~~~~~~~ -Canned queries support named parameters, so if you include those in the SQL you will then be able to enter them using the form fields on the canned query page or by adding them to the URL. This means canned queries can be used to create custom JSON APIs based on a carefully designed SQL statement. +Configured queries support named parameters, so if you include those in the SQL you will then be able to enter them using the form fields on the query page or by adding them to the URL. This means configured queries can be used to create custom JSON APIs based on a carefully designed SQL statement. -Here's an example of a canned query with a named parameter: +Here's an example of a configured query with a named parameter: .. code-block:: sql @@ -147,7 +190,7 @@ Here's an example of a canned query with a named parameter: where neighborhood like '%' || :text || '%' order by neighborhood; -In the canned query configuration looks like this: +The query configuration looks like this: .. [[[cog @@ -204,7 +247,7 @@ In the canned query configuration looks like this: Note that we are using SQLite string concatenation here - the ``||`` operator - to add wildcard ``%`` characters to the string provided by the user. -You can try this canned query out here: +You can try this query out here: https://latest.datasette.io/fixtures/neighborhood_search?text=town In this example the ``:text`` named parameter is automatically extracted from the query using a regular expression. @@ -272,15 +315,15 @@ You can alternatively provide an explicit list of named parameters using the ``" .. _canned_queries_options: -Additional canned query options -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Additional query options +~~~~~~~~~~~~~~~~~~~~~~~~ -Additional options can be specified for canned queries in the YAML or JSON configuration. +Additional options can be specified for configured queries in the YAML or JSON configuration. hide_sql ++++++++ -Canned queries default to displaying their SQL query at the top of the page. If the query is extremely long you may want to hide it by default, with a "show" link that can be used to make it visible. +Configured queries default to displaying their SQL query at the top of the page. If the query is extremely long you may want to hide it by default, with a "show" link that can be used to make it visible. Add the ``"hide_sql": true`` option to hide the SQL query by default. @@ -289,7 +332,7 @@ fragment Some plugins, such as `datasette-vega `__, can be configured by including additional data in the fragment hash of the URL - the bit that comes after a ``#`` symbol. -You can set a default fragment hash that will be included in the link to the canned query from the database index page using the ``"fragment"`` key. +You can set a default fragment hash that will be included in the link to the query from the database index page using the ``"fragment"`` key. This example demonstrates both ``fragment`` and ``hide_sql``: @@ -348,12 +391,12 @@ This example demonstrates both ``fragment`` and ``hide_sql``: .. _canned_queries_writable: -Writable canned queries -~~~~~~~~~~~~~~~~~~~~~~~ +Writable queries +~~~~~~~~~~~~~~~~ -Canned queries by default are read-only. You can use the ``"write": true`` key to indicate that a canned query can write to the database. +Configured queries are read-only by default. You can use the ``"write": true`` key to indicate that a query can write to the database. -See :ref:`authentication_permissions_query` for details on how to add permission checks to canned queries, using the ``"allow"`` key. +See :ref:`authentication_permissions_query` for details on how to add permission checks to queries, using the ``"allow"`` key. .. [[[cog config_example(cog, { @@ -488,7 +531,7 @@ Magic parameters Named parameters that start with an underscore are special: they can be used to automatically add values created by Datasette that are not contained in the incoming form fields or query string. -These magic parameters are only supported for canned queries: to avoid security issues (such as queries that extract the user's private cookies) they are not available to SQL that is executed by the user as a custom SQL query. +These magic parameters are only supported for configured queries: to avoid security issues (such as queries that extract the user's private cookies) they are not available to SQL that is executed by the user as a custom SQL query. Available magic parameters are: @@ -580,12 +623,12 @@ Additional custom magic parameters can be added by plugins using the :ref:`plugi .. _canned_queries_json_api: -JSON API for writable canned queries -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +JSON API for writable queries +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -Writable canned queries can also be accessed using a JSON API. You can POST data to them using JSON, and you can request that their response is returned to you as JSON. +Writable queries can also be accessed using a JSON API. You can POST data to them using JSON, and you can request that their response is returned to you as JSON. -To submit JSON to a writable canned query, encode key/value parameters as a JSON document:: +To submit JSON to a writable query, encode key/value parameters as a JSON document:: POST /mydatabase/add_message diff --git a/tests/test_html.py b/tests/test_html.py index 9e460da1..8edb9f6e 100644 --- a/tests/test_html.py +++ b/tests/test_html.py @@ -154,7 +154,7 @@ async def test_database_page(ds_client): ("/fixtures/simple_view", "simple_view"), ] == sorted([(a["href"], a.text) for a in views_ul.find_all("a")]) - # And a list of canned queries + # And a list of stored queries queries_ul = soup.find("h2", string="Queries").find_next_sibling("ul") assert queries_ul is not None assert [ @@ -701,7 +701,7 @@ async def test_show_hide_sql_query(ds_client): @pytest.mark.asyncio async def test_canned_query_with_hide_has_no_hidden_sql(ds_client): - # For a canned query the show/hide should NOT have a hidden SQL field + # For a stored query the show/hide should NOT have a hidden SQL field # https://github.com/simonw/datasette/issues/1411 response = await ds_client.get("/fixtures/pragma_cache_size?_hide_sql=1") soup = Soup(response.content, "html.parser") @@ -1106,7 +1106,7 @@ async def test_trace_correctly_escaped(ds_client): "/fixtures/-/query?sql=select+*+from+facetable", "http://localhost/fixtures/-/query.json?sql=select+*+from+facetable", ), - # Canned query page + # Stored query page ( "/fixtures/neighborhood_search?text=town", "http://localhost/fixtures/neighborhood_search.json?text=town", diff --git a/tests/test_permissions.py b/tests/test_permissions.py index eb6cee9f..0e38c876 100644 --- a/tests/test_permissions.py +++ b/tests/test_permissions.py @@ -890,7 +890,7 @@ PermConfigTestCase = collections.namedtuple( resource=("perms_ds_one", "t1"), expected_result=True, ), - # view-query on canned query, wrong actor + # view-query on stored query, wrong actor PermConfigTestCase( config={ "databases": { @@ -909,7 +909,7 @@ PermConfigTestCase = collections.namedtuple( resource=("perms_ds_one", "q1"), expected_result=False, ), - # view-query on canned query, right actor + # view-query on stored query, right actor PermConfigTestCase( config={ "databases": { From b1029acc68626c2fddf7b678adc3339be0fce6e0 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 26 May 2026 15:05:41 -0700 Subject: [PATCH 156/176] top_canned_query is now top_stored_query, closes #2747 --- datasette/hookspecs.py | 2 +- datasette/templates/query.html | 2 +- datasette/views/database.py | 8 ++++---- docs/changelog.rst | 1 + docs/plugin_hooks.rst | 4 ++-- tests/test_plugins.py | 10 ++++++---- 6 files changed, 15 insertions(+), 12 deletions(-) diff --git a/datasette/hookspecs.py b/datasette/hookspecs.py index 22da02a4..dcd502af 100644 --- a/datasette/hookspecs.py +++ b/datasette/hookspecs.py @@ -228,7 +228,7 @@ def top_query(datasette, request, database, sql): @hookspec -def top_canned_query(datasette, request, database, query_name): +def top_stored_query(datasette, request, database, query_name): """HTML to include at the top of the stored query page""" diff --git a/datasette/templates/query.html b/datasette/templates/query.html index 785b05af..3f03424a 100644 --- a/datasette/templates/query.html +++ b/datasette/templates/query.html @@ -33,7 +33,7 @@ {% set action_links, action_title = query_actions(), "Query actions" %} {% include "_action_menu.html" %} -{% if canned_query %}{{ top_canned_query() }}{% else %}{{ top_query() }}{% endif %} +{% if canned_query %}{{ top_stored_query() }}{% else %}{{ top_query() }}{% endif %} {% block description_source_license %}{% include "_description_source_license.html" %}{% endblock %} diff --git a/datasette/views/database.py b/datasette/views/database.py index f30d3815..def3c530 100644 --- a/datasette/views/database.py +++ b/datasette/views/database.py @@ -339,8 +339,8 @@ class QueryContext(Context): top_query: callable = field( metadata={"help": "Callable to render the top_query slot"} ) - top_canned_query: callable = field( - metadata={"help": "Callable to render the top_canned_query slot"} + top_stored_query: callable = field( + metadata={"help": "Callable to render the top_stored_query slot"} ) query_actions: callable = field( metadata={ @@ -2095,8 +2095,8 @@ class QueryView(View): top_query=make_slot_function( "top_query", datasette, request, database=database, sql=sql ), - top_canned_query=make_slot_function( - "top_canned_query", + top_stored_query=make_slot_function( + "top_stored_query", datasette, request, database=database, diff --git a/docs/changelog.rst b/docs/changelog.rst index dfb2a736..300ac02f 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -10,6 +10,7 @@ Unreleased ---------- - Fixed a bug where visiting ``//-/query`` without a ``?sql=`` parameter returned a 500 error. (:issue:`2743`) +- The ``top_canned_query()`` plugin hook has been renamed to :ref:`top_stored_query() `. (:issue:`2747`) .. _v1_0_a30: diff --git a/docs/plugin_hooks.rst b/docs/plugin_hooks.rst index 264b473e..4737ca03 100644 --- a/docs/plugin_hooks.rst +++ b/docs/plugin_hooks.rst @@ -2279,9 +2279,9 @@ top_query(datasette, request, database, sql) Returns HTML to be displayed at the top of the query results page. -.. _plugin_hook_top_canned_query: +.. _plugin_hook_top_stored_query: -top_canned_query(datasette, request, database, query_name) +top_stored_query(datasette, request, database, query_name) ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ ``datasette`` - :ref:`internals_datasette` diff --git a/tests/test_plugins.py b/tests/test_plugins.py index f7adbd66..32276437 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -1486,8 +1486,10 @@ class SlotPlugin: return "Xtop_query:{}:{}:{}".format(database, sql, request.args["z"]) @hookimpl - def top_canned_query(self, request, database, query_name): - return "Xtop_query:{}:{}:{}".format(database, query_name, request.args["z"]) + def top_stored_query(self, request, database, query_name): + return "Xtop_stored_query:{}:{}:{}".format( + database, query_name, request.args["z"] + ) @pytest.mark.asyncio @@ -1548,12 +1550,12 @@ async def test_hook_top_query(ds_client): @pytest.mark.asyncio -async def test_hook_top_canned_query(ds_client): +async def test_hook_top_stored_query(ds_client): try: pm.register(SlotPlugin(), name="SlotPlugin") response = await ds_client.get("/fixtures/magic_parameters?z=xyz") assert response.status_code == 200 - assert "Xtop_query:fixtures:magic_parameters:xyz" in response.text + assert "Xtop_stored_query:fixtures:magic_parameters:xyz" in response.text finally: pm.unregister(name="SlotPlugin") From 2f73869c09962e320e5f40f4691df70618cd052e Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 26 May 2026 15:09:48 -0700 Subject: [PATCH 157/176] Document that canned_queries() has been removed --- docs/changelog.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index 300ac02f..674ff5b3 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -11,6 +11,7 @@ Unreleased - Fixed a bug where visiting ``//-/query`` without a ``?sql=`` parameter returned a 500 error. (:issue:`2743`) - The ``top_canned_query()`` plugin hook has been renamed to :ref:`top_stored_query() `. (:issue:`2747`) +- The ``canned_queries()`` plugin hook has been removed. Plugins can use the new ``datasette.add_query()``, ``datasette.update_query()`` and ``datasette.remove_query()`` methods to managed stored queries instead. .. _v1_0_a30: From 56b14f37d547e03ba902516ac9ae13ef52765f77 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 26 May 2026 15:16:18 -0700 Subject: [PATCH 158/176] The stored queries do not live in that DB --- docs/authentication.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/authentication.rst b/docs/authentication.rst index 22db41d8..86df7f04 100644 --- a/docs/authentication.rst +++ b/docs/authentication.rst @@ -1298,7 +1298,7 @@ Actor is allowed to view a stored query page, e.g. https://latest.datasette.io/f store-query ----------- -Actor is allowed to create stored queries in a database. +Actor is allowed to create stored queries against a database. ``resource`` - ``datasette.resources.DatabaseResource(database)`` ``database`` is the name of the database (string) From 02a1468f1b3c8c14fb80037686b43de856e49c1f Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 26 May 2026 15:17:51 -0700 Subject: [PATCH 159/176] Renamed canned queries to queries / stored queries in docs And a few renames in code and YAML as well. --- .github/workflows/deploy-latest.yml | 33 +- datasette/app.py | 7 - datasette/facets.py | 2 +- datasette/static/app.css | 2 +- datasette/templates/query.html | 18 +- datasette/views/database.py | 92 +++--- datasette/views/table.py | 6 +- docs/authentication.rst | 10 +- docs/changelog.rst | 23 +- docs/configuration.rst | 6 +- docs/plugin_hooks.rst | 12 +- docs/spatialite.rst | 2 +- docs/sql_queries.rst | 12 +- docs/upgrade-1.0a20.md | 6 +- tests/test_canned_queries.py | 473 ---------------------------- tests/test_html.py | 12 +- tests/test_jump.py | 4 +- 17 files changed, 115 insertions(+), 605 deletions(-) delete mode 100644 tests/test_canned_queries.py diff --git a/.github/workflows/deploy-latest.yml b/.github/workflows/deploy-latest.yml index 7d8dd37d..166d33d0 100644 --- a/.github/workflows/deploy-latest.yml +++ b/.github/workflows/deploy-latest.yml @@ -57,7 +57,7 @@ jobs: db.route = "alternative-route" ' > plugins/alternative_route.py cp fixtures.db fixtures2.db - - name: And the counters writable canned query demo + - name: And the counters writable stored query demo run: | cat > plugins/counters.py <This query cannot be executed because the database is immutable.

{% endif %} -

{{ metadata.title or database }}{% if canned_query and not metadata.title %}: {{ canned_query }}{% endif %}{% if private %} 🔒{% endif %}

+

{{ metadata.title or database }}{% if stored_query and not metadata.title %}: {{ stored_query }}{% endif %}{% if private %} 🔒{% endif %}

{% set action_links, action_title = query_actions(), "Query actions" %} {% include "_action_menu.html" %} -{% if canned_query %}{{ top_stored_query() }}{% else %}{{ top_query() }}{% endif %} +{% if stored_query %}{{ top_stored_query() }}{% else %}{{ top_query() }}{% endif %} {% block description_source_license %}{% include "_description_source_license.html" %}{% endblock %} - +

Custom SQL query{% if display_rows %} returning {% if truncated %}more than {% endif %}{{ "{:,}".format(display_rows|length) }} row{% if display_rows|length == 1 %}{% else %}s{% endif %}{% endif %}{% if not query_error %} ({{ show_hide_text }}) {% endif %}

@@ -52,7 +52,7 @@
{% if query %}{{ query.sql }}{% endif %}
{% endif %} {% else %} - {% if not canned_query %} + {% if not stored_query %} @@ -64,10 +64,10 @@ {% include "_sql_parameters.html" %}

{% if not hide_sql %}{% endif %} - + {{ show_hide_hidden }} {% if save_query_url %}Save this query{% endif %} - {% if canned_query and edit_sql_url %}Edit SQL{% endif %} + {% if stored_query and edit_sql_url %}Edit SQL{% endif %}

@@ -90,7 +90,7 @@
Required permission
{% else %} - {% if not canned_query_write and not error %} + {% if not stored_query_write and not error %}

0 results

{% endif %} {% endif %} diff --git a/datasette/views/database.py b/datasette/views/database.py index def3c530..c36476f6 100644 --- a/datasette/views/database.py +++ b/datasette/views/database.py @@ -100,12 +100,12 @@ class DatabaseView(View): limit=5, include_private=True, ) - canned_queries = queries_page["queries"] + stored_queries = queries_page["queries"] queries_more = queries_page["has_more"] queries_count = ( await datasette.count_queries(database, actor=request.actor) if queries_more - else len(canned_queries) + else len(stored_queries) ) async def database_actions(): @@ -137,7 +137,7 @@ class DatabaseView(View): "tables": tables, "hidden_count": len([t for t in tables if t["hidden"]]), "views": sql_views, - "queries": canned_queries, + "queries": stored_queries, "queries_more": queries_more, "queries_count": queries_count, "allow_execute_sql": allow_execute_sql, @@ -172,7 +172,7 @@ class DatabaseView(View): tables=tables, hidden_count=len([t for t in tables if t["hidden"]]), views=sql_views, - queries=canned_queries, + queries=stored_queries, queries_more=queries_more, queries_count=queries_count, allow_execute_sql=allow_execute_sql, @@ -271,7 +271,7 @@ class QueryContext(Context): query: dict = field( metadata={"help": "The SQL query object containing the `sql` string"} ) - canned_query: str = field( + stored_query: str = field( metadata={"help": "The name of the stored query if this is a stored query"} ) private: bool = field( @@ -280,7 +280,7 @@ class QueryContext(Context): # urls: dict = field( # metadata={"help": "Object containing URL helpers like `database()`"} # ) - canned_query_write: bool = field( + stored_query_write: bool = field( metadata={ "help": "Boolean indicating if this is a stored query that allows writes" } @@ -1629,10 +1629,10 @@ class QueryView(View): await datasette.resolve_table(request) table_found = True except TableNotFound as table_not_found: - canned_query = await datasette.get_canned_query( - table_not_found.database_name, table_not_found.table, request.actor + stored_query = await datasette.get_query( + table_not_found.database_name, table_not_found.table ) - if canned_query is None: + if stored_query is None: raise if table_found: # That should not have happened @@ -1640,13 +1640,13 @@ class QueryView(View): if not await datasette.allowed( action="view-query", - resource=QueryResource(database=db.name, query=canned_query["name"]), + resource=QueryResource(database=db.name, query=stored_query["name"]), actor=request.actor, ): raise Forbidden("You do not have permission to view this query") await _ensure_stored_query_execution_permissions( - datasette, db, canned_query, request.actor + datasette, db, stored_query, request.actor ) # If database is immutable, return an error @@ -1674,19 +1674,19 @@ class QueryView(View): or params.get("_json") ) params_for_query = MagicParameters( - canned_query["sql"], params, request, datasette + stored_query["sql"], params, request, datasette ) await params_for_query.execute_params() ok = None redirect_url = None try: cursor = await db.execute_write( - canned_query["sql"], params_for_query, request=request + stored_query["sql"], params_for_query, request=request ) # success message can come from on_success_message or on_success_message_sql message = None message_type = datasette.INFO - on_success_message_sql = canned_query.get("on_success_message_sql") + on_success_message_sql = stored_query.get("on_success_message_sql") if on_success_message_sql: try: message_result = ( @@ -1698,18 +1698,18 @@ class QueryView(View): message = "Error running on_success_message_sql: {}".format(ex) message_type = datasette.ERROR if not message: - message = canned_query.get( + message = stored_query.get( "on_success_message" ) or "Query executed, {} row{} affected".format( cursor.rowcount, "" if cursor.rowcount == 1 else "s" ) - redirect_url = canned_query.get("on_success_redirect") + redirect_url = stored_query.get("on_success_redirect") ok = True except Exception as ex: - message = canned_query.get("on_error_message") or str(ex) + message = stored_query.get("on_error_message") or str(ex) message_type = datasette.ERROR - redirect_url = canned_query.get("on_error_redirect") + redirect_url = stored_query.get("on_error_redirect") ok = False if should_return_json: return Response.json( @@ -1743,33 +1743,33 @@ class QueryView(View): allowed_dict = {r.child: r for r in allowed_tables_page.resources} # Are we a stored query? - canned_query = None - canned_query_write = False + stored_query = None + stored_query_write = False if "table" in request.url_vars: try: await datasette.resolve_table(request) except TableNotFound as table_not_found: # Was this actually a stored query? - canned_query = await datasette.get_canned_query( - table_not_found.database_name, table_not_found.table, request.actor + stored_query = await datasette.get_query( + table_not_found.database_name, table_not_found.table ) - if canned_query is None: + if stored_query is None: raise - canned_query_write = bool(canned_query.get("write")) + stored_query_write = bool(stored_query.get("write")) private = False - if canned_query: + if stored_query: # Respect stored query permissions visible, private = await datasette.check_visibility( request.actor, action="view-query", - resource=QueryResource(database=database, query=canned_query["name"]), + resource=QueryResource(database=database, query=stored_query["name"]), ) if not visible: raise Forbidden("You do not have permission to view this query") - if not canned_query_write: + if not stored_query_write: await _ensure_stored_query_execution_permissions( - datasette, db, canned_query, request.actor + datasette, db, stored_query, request.actor ) else: @@ -1783,15 +1783,15 @@ class QueryView(View): params = {key: request.args.get(key) for key in request.args} sql = None - if canned_query: - sql = canned_query["sql"] + if stored_query: + sql = stored_query["sql"] elif "sql" in params: sql = params.pop("sql") # Extract any :named parameters named_parameters = [] - if canned_query and canned_query.get("params"): - named_parameters = canned_query["params"] + if stored_query and stored_query.get("params"): + named_parameters = stored_query["params"] if not named_parameters and sql: named_parameters = derive_named_parameters(sql) named_parameter_values = { @@ -1817,9 +1817,9 @@ class QueryView(View): params_for_query = params - if sql and not canned_query_write: + if sql and not stored_query_write: try: - if not canned_query: + if not stored_query: # For regular queries we only allow SELECT, plus other rules validate_sql_select(sql) else: @@ -1879,7 +1879,7 @@ class QueryView(View): columns=columns, rows=rows, sql=sql, - query_name=canned_query["name"] if canned_query else None, + query_name=stored_query["name"] if stored_query else None, database=database, table=None, request=request, @@ -1911,10 +1911,10 @@ class QueryView(View): elif format_ == "html": headers = {} templates = [f"query-{to_css_class(database)}.html", "query.html"] - if canned_query: + if stored_query: templates.insert( 0, - f"query-{to_css_class(database)}-{to_css_class(canned_query['name'])}.html", + f"query-{to_css_class(database)}-{to_css_class(stored_query['name'])}.html", ) environment = datasette.get_jinja_environment(request) @@ -1932,8 +1932,8 @@ class QueryView(View): } ) metadata = await datasette.get_database_metadata(database) - if canned_query: - metadata = dict(canned_query) + if stored_query: + metadata = dict(stored_query) metadata.pop("source", None) renderers = {} @@ -1968,7 +1968,7 @@ class QueryView(View): ) show_hide_hidden = "" - if canned_query and canned_query.get("hide_sql"): + if stored_query and stored_query.get("hide_sql"): if bool(params.get("_show_sql")): show_hide_link = path_with_removed_args(request, {"_show_sql"}) show_hide_text = "hide" @@ -2018,7 +2018,7 @@ class QueryView(View): ) save_query_url = None if ( - not canned_query + not stored_query and allow_execute_sql and allow_store_query and is_validated_sql @@ -2036,7 +2036,7 @@ class QueryView(View): datasette=datasette, actor=request.actor, database=database, - query_name=canned_query["name"] if canned_query else None, + query_name=stored_query["name"] if stored_query else None, request=request, sql=sql, params=params, @@ -2056,15 +2056,15 @@ class QueryView(View): "sql": sql, "params": params, }, - canned_query=canned_query["name"] if canned_query else None, + stored_query=stored_query["name"] if stored_query else None, private=private, - canned_query_write=canned_query_write, + stored_query_write=stored_query_write, db_is_immutable=not db.is_mutable, error=query_error, hide_sql=hide_sql, show_hide_link=datasette.urls.path(show_hide_link), show_hide_text=show_hide_text, - editable=not canned_query, + editable=not stored_query, allow_execute_sql=allow_execute_sql, save_query_url=save_query_url, tables=await get_tables(datasette, request, db, allowed_dict), @@ -2100,7 +2100,7 @@ class QueryView(View): datasette, request, database=database, - query_name=canned_query["name"] if canned_query else None, + query_name=stored_query["name"] if stored_query else None, ), query_actions=query_actions, ), diff --git a/datasette/views/table.py b/datasette/views/table.py index 7b1a5a82..da69c6b5 100644 --- a/datasette/views/table.py +++ b/datasette/views/table.py @@ -964,11 +964,11 @@ async def table_view_traced(datasette, request): resolved = await datasette.resolve_table(request) except TableNotFound as not_found: # Was this actually a stored query? - canned_query = await datasette.get_canned_query( - not_found.database_name, not_found.table, request.actor + stored_query = await datasette.get_query( + not_found.database_name, not_found.table ) # If this is a stored query, not a table, then dispatch to QueryView instead - if canned_query: + if stored_query: return await QueryView()(request, datasette) else: raise diff --git a/docs/authentication.rst b/docs/authentication.rst index 86df7f04..cec47f97 100644 --- a/docs/authentication.rst +++ b/docs/authentication.rst @@ -121,7 +121,7 @@ This configuration will deny access to everyone except the user with ``id`` of ` How permissions are resolved ---------------------------- -Datasette performs permission checks using the internal :ref:`datasette_allowed`, method which accepts keyword arguments for ``action``, ``resource`` and an optional ``actor``. +Datasette performs permission checks using the internal :ref:`datasette_allowed`, method which accepts keyword arguments for ``action``, ``resource`` and an optional ``actor``. ``resource`` should be an instance of the appropriate ``Resource`` subclass from :mod:`datasette.resources`—for example ``InstanceResource()``, ``DatabaseResource(database="...``)`` or ``TableResource(database="...", table="...")``. This defaults to ``InstanceResource()`` if not specified. @@ -468,7 +468,7 @@ You can control the following: * Access to the entire Datasette instance * Access to specific databases * Access to specific tables and views -* Access to specific :ref:`queries ` +* Access to specific :ref:`queries ` If a user has permission to view a table they will be able to view that table, independent of if they have permission to view the database or instance that the table exists within. @@ -496,7 +496,7 @@ Here's how to restrict access to your entire Datasette instance to just the ``"i title: My private Datasette instance allow: id: root - + .. tab:: datasette.json @@ -644,7 +644,7 @@ This works for SQL views as well - you can list their names in the ``"tables"`` Access to specific queries -------------------------- -:ref:`Queries ` allow you to configure named SQL queries in your ``datasette.yaml`` that can be executed by users. These queries can be set up to both read and write to the database, so controlling who can execute them can be important. +:ref:`Queries ` allow you to configure named SQL queries in your ``datasette.yaml`` that can be executed by users. These queries can be set up to both read and write to the database, so controlling who can execute them can be important. To limit access to the ``add_name`` query in your ``dogs.db`` database to just the :ref:`root user`: @@ -1020,7 +1020,7 @@ You can also restrict permissions such that they can only be used within specifi The resulting token will only be able to insert rows, and only to tables in the ``mydatabase`` database. -Finally, you can restrict permissions to individual resources - tables, SQL views and :ref:`named queries ` - within a specific database:: +Finally, you can restrict permissions to individual resources - tables, SQL views and :ref:`named queries ` - within a specific database:: datasette create-token root --resource mydatabase mytable insert-row diff --git a/docs/changelog.rst b/docs/changelog.rst index 674ff5b3..d15dec50 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -11,7 +11,8 @@ Unreleased - Fixed a bug where visiting ``//-/query`` without a ``?sql=`` parameter returned a 500 error. (:issue:`2743`) - The ``top_canned_query()`` plugin hook has been renamed to :ref:`top_stored_query() `. (:issue:`2747`) -- The ``canned_queries()`` plugin hook has been removed. Plugins can use the new ``datasette.add_query()``, ``datasette.update_query()`` and ``datasette.remove_query()`` methods to managed stored queries instead. +- The ``canned_queries()`` plugin hook has been removed. Plugins can use the new ``datasette.add_query()``, ``datasette.update_query()`` and ``datasette.remove_query()`` methods to manage stored queries instead. +- The ``datasette.get_canned_query()`` and ``datasette.get_canned_queries()`` methods have been removed. Plugins can use ``datasette.get_query()`` and ``datasette.list_queries()`` instead. .. _v1_0_a30: @@ -658,7 +659,7 @@ For more information and workarounds, read `the security advisory `` in a `` -

+

+ + {% if save_query_base_url %}Save this query{% endif %} +

", + "on_success_message_sql": "select 'secret'", + } + }, + ) + form_response = await ds.client.post( + "/data/-/queries/store", + actor={"id": "root"}, + data={ + "name": "unsafe_form", + "sql": "select 1", + "description_html": "", + }, + ) + + assert response.status_code == 400 + assert response.json()["errors"] == [ + "Invalid keys: description_html, on_success_message_sql" + ] + assert form_response.status_code == 400 + assert "Invalid keys: description_html" in form_response.text + assert await ds.get_query("data", "unsafe") is None + assert await ds.get_query("data", "unsafe_form") is None + + @pytest.mark.asyncio async def test_query_store_api_creates_writable_query(): ds = Datasette(memory=True, default_deny=True) @@ -959,6 +1000,42 @@ async def test_query_update_and_delete_api(): assert await ds.get_query("data", "editable") is None +@pytest.mark.asyncio +async def test_query_update_api_rejects_config_only_fields(): + ds = Datasette(memory=True, default_deny=True) + ds.root_enabled = True + db = ds.add_memory_database("query_update_config_only_fields", name="data") + await db.execute_write("create table dogs (id integer primary key, name text)") + await ds.invoke_startup() + await ds.add_query( + "data", + "editable", + "insert into dogs (name) values (:name)", + is_write=True, + source="user", + owner_id="root", + ) + + response = await ds.client.post( + "/data/editable/-/update", + actor={"id": "root"}, + json={ + "update": { + "description_html": "", + "on_success_message_sql": "select 'secret'", + } + }, + ) + + assert response.status_code == 400 + assert response.json()["errors"] == [ + "Invalid keys: description_html, on_success_message_sql" + ] + query = await ds.get_query("data", "editable") + assert query["description_html"] is None + assert query["on_success_message_sql"] is None + + @pytest.mark.asyncio async def test_query_update_api_rejects_trusted_queries_but_internal_update_allowed(): ds = Datasette( From b1289a73f9869e83a433a088c2a6c48285e67f2d Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 26 May 2026 16:51:00 -0700 Subject: [PATCH 176/176] stored_queries.StoredQuery dataclass --- datasette/app.py | 102 ++++++------ datasette/stored_queries.py | 258 ++++++++++++++++++++---------- datasette/views/database.py | 56 +++---- datasette/views/query_helpers.py | 19 +-- datasette/views/stored_queries.py | 37 +++-- docs/internals.rst | 14 +- tests/test_queries.py | 128 +++++++-------- 7 files changed, 357 insertions(+), 257 deletions(-) diff --git a/datasette/app.py b/datasette/app.py index 96683895..56b89789 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -1029,8 +1029,8 @@ class Datasette: ) @staticmethod - def _query_row_to_dict(row): - return stored_queries.query_row_to_dict(row) + def _query_row_to_stored_query(row) -> stored_queries.StoredQuery | None: + return stored_queries.query_row_to_stored_query(row) @staticmethod def _query_options_json(options): @@ -1038,28 +1038,28 @@ class Datasette: async def add_query( self, - database, - name, - sql, + database: str, + name: str, + sql: str, *, - title=None, - description=None, - description_html=None, - hide_sql=False, - fragment=None, - parameters=None, - is_write=False, - is_private=False, - is_trusted=False, - source="plugin", - owner_id=None, - on_success_message=None, - on_success_message_sql=None, - on_success_redirect=None, - on_error_message=None, - on_error_redirect=None, - replace=True, - ): + title: str | None = None, + description: str | None = None, + description_html: str | None = None, + hide_sql: bool = False, + fragment: str | None = None, + parameters: Iterable[str] | None = None, + is_write: bool = False, + is_private: bool = False, + is_trusted: bool = False, + source: str = "plugin", + owner_id: str | None = None, + on_success_message: str | None = None, + on_success_message_sql: str | None = None, + on_success_redirect: str | None = None, + on_error_message: str | None = None, + on_error_redirect: str | None = None, + replace: bool = True, + ) -> None: return await stored_queries.add_query( self, database, @@ -1086,8 +1086,8 @@ class Datasette: async def update_query( self, - database, - name, + database: str, + name: str, *, sql=stored_queries.UNCHANGED, title=stored_queries.UNCHANGED, @@ -1106,7 +1106,7 @@ class Datasette: on_success_redirect=stored_queries.UNCHANGED, on_error_message=stored_queries.UNCHANGED, on_error_redirect=stored_queries.UNCHANGED, - ): + ) -> None: return await stored_queries.update_query( self, database, @@ -1130,24 +1130,28 @@ class Datasette: on_error_redirect=on_error_redirect, ) - async def remove_query(self, database, name, source=None): + async def remove_query( + self, database: str, name: str, source: str | None = None + ) -> None: return await stored_queries.remove_query(self, database, name, source=source) - async def get_query(self, database, name): + async def get_query( + self, database: str, name: str + ) -> stored_queries.StoredQuery | None: return await stored_queries.get_query(self, database, name) async def count_queries( self, - database=None, + database: str | None = None, *, - actor=None, - q=None, - is_write=None, - is_private=None, - is_trusted=None, - source=None, - owner_id=None, - ): + actor: dict[str, Any] | None = None, + q: str | None = None, + is_write: bool | None = None, + is_private: bool | None = None, + is_trusted: bool | None = None, + source: str | None = None, + owner_id: str | None = None, + ) -> int: return await stored_queries.count_queries( self, database, @@ -1162,19 +1166,19 @@ class Datasette: async def list_queries( self, - database=None, + database: str | None = None, *, - actor=None, - limit=50, - cursor=None, - q=None, - is_write=None, - is_private=None, - is_trusted=None, - source=None, - owner_id=None, - include_private=False, - ): + actor: dict[str, Any] | None = None, + limit: int = 50, + cursor: str | None = None, + q: str | None = None, + is_write: bool | None = None, + is_private: bool | None = None, + is_trusted: bool | None = None, + source: str | None = None, + owner_id: str | None = None, + include_private: bool = False, + ) -> stored_queries.StoredQueryPage: return await stored_queries.list_queries( self, database, diff --git a/datasette/stored_queries.py b/datasette/stored_queries.py index a28b71bf..bcfdfdb4 100644 --- a/datasette/stored_queries.py +++ b/datasette/stored_queries.py @@ -1,6 +1,8 @@ from __future__ import annotations +from dataclasses import dataclass import json +from typing import Any, Iterable from .resources import TableResource from .utils import named_parameters, sqlite3, tilde_encode, urlsafe_components @@ -19,7 +21,76 @@ QUERY_OPTION_FIELDS = ( ) -async def save_queries_from_config(datasette): +@dataclass +class StoredQuery: + database: str + name: str + sql: str + title: str | None + description: str | None + description_html: str | None + hide_sql: bool + fragment: str | None + parameters: list[str] + is_write: bool + is_private: bool + is_trusted: bool + source: str + owner_id: str | None + on_success_message: str | None + on_success_message_sql: str | None + on_success_redirect: str | None + on_error_message: str | None + on_error_redirect: str | None + private: bool | None = None + + +@dataclass +class StoredQueryPage: + queries: list[StoredQuery] + next: str | None + has_more: bool + limit: int + + +def stored_query_to_dict(query: StoredQuery) -> dict[str, Any]: + data = { + "database": query.database, + "name": query.name, + "sql": query.sql, + "title": query.title, + "description": query.description, + "description_html": query.description_html, + "hide_sql": query.hide_sql, + "fragment": query.fragment, + "params": list(query.parameters), + "parameters": list(query.parameters), + "is_write": query.is_write, + "is_private": query.is_private, + "is_trusted": query.is_trusted, + "source": query.source, + "owner_id": query.owner_id, + "on_success_message": query.on_success_message, + "on_success_message_sql": query.on_success_message_sql, + "on_success_redirect": query.on_success_redirect, + "on_error_message": query.on_error_message, + "on_error_redirect": query.on_error_redirect, + } + if query.private is not None: + data["private"] = query.private + return data + + +def stored_query_page_to_dict(page: StoredQueryPage) -> dict[str, Any]: + return { + "queries": [stored_query_to_dict(query) for query in page.queries], + "next": page.next, + "has_more": page.has_more, + "limit": page.limit, + } + + +async def save_queries_from_config(datasette: Any) -> None: # Apply configured query entries from datasette.yaml to the internal table. await datasette.get_internal_database().execute_write( "DELETE FROM queries WHERE source = 'config'" @@ -50,36 +121,38 @@ async def save_queries_from_config(datasette): ) -def query_row_to_dict(row): +def query_row_to_stored_query( + row: Any, private: bool | None = None +) -> StoredQuery | None: if row is None: return None parameters = json.loads(row["parameters"] or "[]") options = json.loads(row["options"] or "{}") - return { - "database": row["database_name"], - "name": row["name"], - "sql": row["sql"], - "title": row["title"], - "description": row["description"], - "description_html": row["description_html"], - "hide_sql": bool(options.get("hide_sql")), - "fragment": options.get("fragment"), - "params": parameters, - "parameters": parameters, - "is_write": bool(row["is_write"]), - "is_private": bool(row["is_private"]), - "is_trusted": bool(row["is_trusted"]), - "source": row["source"], - "owner_id": row["owner_id"], - "on_success_message": options.get("on_success_message"), - "on_success_message_sql": options.get("on_success_message_sql"), - "on_success_redirect": options.get("on_success_redirect"), - "on_error_message": options.get("on_error_message"), - "on_error_redirect": options.get("on_error_redirect"), - } + return StoredQuery( + database=row["database_name"], + name=row["name"], + sql=row["sql"], + title=row["title"], + description=row["description"], + description_html=row["description_html"], + hide_sql=bool(options.get("hide_sql")), + fragment=options.get("fragment"), + parameters=parameters, + is_write=bool(row["is_write"]), + is_private=bool(row["is_private"]), + is_trusted=bool(row["is_trusted"]), + source=row["source"], + owner_id=row["owner_id"], + on_success_message=options.get("on_success_message"), + on_success_message_sql=options.get("on_success_message_sql"), + on_success_redirect=options.get("on_success_redirect"), + on_error_message=options.get("on_error_message"), + on_error_redirect=options.get("on_error_redirect"), + private=private, + ) -def query_options_json(options): +def query_options_json(options: dict[str, Any]) -> str: options_dict = {} for field in QUERY_OPTION_FIELDS: value = options.get(field) @@ -92,29 +165,29 @@ def query_options_json(options): async def add_query( - datasette, - database, - name, - sql, + datasette: Any, + database: str, + name: str, + sql: str, *, - title=None, - description=None, - description_html=None, - hide_sql=False, - fragment=None, - parameters=None, - is_write=False, - is_private=False, - is_trusted=False, - source="plugin", - owner_id=None, - on_success_message=None, - on_success_message_sql=None, - on_success_redirect=None, - on_error_message=None, - on_error_redirect=None, - replace=True, -): + title: str | None = None, + description: str | None = None, + description_html: str | None = None, + hide_sql: bool = False, + fragment: str | None = None, + parameters: Iterable[str] | None = None, + is_write: bool = False, + is_private: bool = False, + is_trusted: bool = False, + source: str = "plugin", + owner_id: str | None = None, + on_success_message: str | None = None, + on_success_message_sql: str | None = None, + on_success_redirect: str | None = None, + on_error_message: str | None = None, + on_error_redirect: str | None = None, + replace: bool = True, +) -> None: parameters_json = json.dumps(list(parameters or [])) options_json = query_options_json( { @@ -170,9 +243,9 @@ async def add_query( async def update_query( - datasette, - database, - name, + datasette: Any, + database: str, + name: str, *, sql=UNCHANGED, title=UNCHANGED, @@ -191,7 +264,7 @@ async def update_query( on_success_redirect=UNCHANGED, on_error_message=UNCHANGED, on_error_redirect=UNCHANGED, -): +) -> None: fields = { "sql": sql, "title": title, @@ -263,7 +336,9 @@ async def update_query( ) -async def remove_query(datasette, database, name, source=None): +async def remove_query( + datasette: Any, database: str, name: str, source: str | None = None +) -> None: sql = "DELETE FROM queries WHERE database_name = ? AND name = ?" params = [database, name] if source is not None: @@ -272,7 +347,7 @@ async def remove_query(datasette, database, name, source=None): await datasette.get_internal_database().execute_write(sql, params) -async def get_query(datasette, database, name): +async def get_query(datasette: Any, database: str, name: str) -> StoredQuery | None: rows = await datasette.get_internal_database().execute( """ SELECT * FROM queries @@ -280,21 +355,21 @@ async def get_query(datasette, database, name): """, [database, name], ) - return query_row_to_dict(rows.first()) + return query_row_to_stored_query(rows.first()) async def count_queries( - datasette, - database=None, + datasette: Any, + database: str | None = None, *, - actor=None, - q=None, - is_write=None, - is_private=None, - is_trusted=None, - source=None, - owner_id=None, -): + actor: dict[str, Any] | None = None, + q: str | None = None, + is_write: bool | None = None, + is_private: bool | None = None, + is_trusted: bool | None = None, + source: str | None = None, + owner_id: str | None = None, +) -> int: allowed_sql, allowed_params = await datasette.allowed_resources_sql( action="view-query", actor=actor, @@ -354,20 +429,20 @@ async def count_queries( async def list_queries( - datasette, - database=None, + datasette: Any, + database: str | None = None, *, - actor=None, - limit=50, - cursor=None, - q=None, - is_write=None, - is_private=None, - is_trusted=None, - source=None, - owner_id=None, - include_private=False, -): + actor: dict[str, Any] | None = None, + limit: int = 50, + cursor: str | None = None, + q: str | None = None, + is_write: bool | None = None, + is_private: bool | None = None, + is_trusted: bool | None = None, + source: str | None = None, + owner_id: str | None = None, + include_private: bool = False, +) -> StoredQueryPage: limit = min(max(1, int(limit)), 1000) allowed_sql, allowed_params = await datasette.allowed_resources_sql( action="view-query", @@ -480,9 +555,10 @@ async def list_queries( queries = [] for row in rows: - query = query_row_to_dict(row) - if include_private: - query["private"] = bool(row["private"]) + query = query_row_to_stored_query( + row, private=bool(row["private"]) if include_private else None + ) + assert query is not None queries.append(query) next_token = None @@ -499,17 +575,23 @@ async def list_queries( tilde_encode(last_row["sort_key"]), tilde_encode(last_row["name"]), ) - return { - "queries": queries, - "next": next_token, - "has_more": has_more, - "limit": limit, - } + return StoredQueryPage( + queries=queries, + next=next_token, + has_more=has_more, + limit=limit, + ) async def ensure_query_write_permissions( - datasette, database, sql, *, actor=None, params=None, analysis=None -): + datasette: Any, + database: str, + sql: str, + *, + actor: dict[str, Any] | None = None, + params: dict[str, Any] | None = None, + analysis: Any = None, +) -> Any: write_actions = { "insert": "insert-row", "update": "update-row", diff --git a/datasette/views/database.py b/datasette/views/database.py index 98ca989c..b558b002 100644 --- a/datasette/views/database.py +++ b/datasette/views/database.py @@ -13,6 +13,7 @@ import textwrap from datasette.events import AlterTableEvent, CreateTableEvent, InsertRowsEvent from datasette.database import QueryInterrupted from datasette.resources import DatabaseResource, QueryResource +from datasette.stored_queries import stored_query_to_dict from datasette.utils import ( add_cors_headers, await_me_maybe, @@ -99,8 +100,8 @@ class DatabaseView(View): limit=5, include_private=True, ) - stored_queries = queries_page["queries"] - queries_more = queries_page["has_more"] + stored_queries = queries_page.queries + queries_more = queries_page.has_more queries_count = ( await datasette.count_queries(database, actor=request.actor) if queries_more @@ -136,7 +137,7 @@ class DatabaseView(View): "tables": tables, "hidden_count": len([t for t in tables if t["hidden"]]), "views": sql_views, - "queries": stored_queries, + "queries": [stored_query_to_dict(query) for query in stored_queries], "queries_more": queries_more, "queries_count": queries_count, "allow_execute_sql": allow_execute_sql, @@ -447,7 +448,7 @@ class QueryView(View): if not await datasette.allowed( action="view-query", - resource=QueryResource(database=db.name, query=stored_query["name"]), + resource=QueryResource(database=db.name, query=stored_query.name), actor=request.actor, ): raise Forbidden("You do not have permission to view this query") @@ -480,20 +481,18 @@ class QueryView(View): or request.args.get("_json") or params.get("_json") ) - params_for_query = MagicParameters( - stored_query["sql"], params, request, datasette - ) + params_for_query = MagicParameters(stored_query.sql, params, request, datasette) await params_for_query.execute_params() ok = None redirect_url = None try: cursor = await db.execute_write( - stored_query["sql"], params_for_query, request=request + stored_query.sql, params_for_query, request=request ) # success message can come from on_success_message or on_success_message_sql message = None message_type = datasette.INFO - on_success_message_sql = stored_query.get("on_success_message_sql") + on_success_message_sql = stored_query.on_success_message_sql if on_success_message_sql: try: message_result = ( @@ -505,18 +504,19 @@ class QueryView(View): message = "Error running on_success_message_sql: {}".format(ex) message_type = datasette.ERROR if not message: - message = stored_query.get( - "on_success_message" - ) or "Query executed, {} row{} affected".format( - cursor.rowcount, "" if cursor.rowcount == 1 else "s" + message = ( + stored_query.on_success_message + or "Query executed, {} row{} affected".format( + cursor.rowcount, "" if cursor.rowcount == 1 else "s" + ) ) - redirect_url = stored_query.get("on_success_redirect") + redirect_url = stored_query.on_success_redirect ok = True except Exception as ex: - message = stored_query.get("on_error_message") or str(ex) + message = stored_query.on_error_message or str(ex) message_type = datasette.ERROR - redirect_url = stored_query.get("on_error_redirect") + redirect_url = stored_query.on_error_redirect ok = False if should_return_json: return Response.json( @@ -562,7 +562,7 @@ class QueryView(View): ) if stored_query is None: raise - stored_query_write = bool(stored_query.get("is_write")) + stored_query_write = stored_query.is_write private = False if stored_query: @@ -570,7 +570,7 @@ class QueryView(View): visible, private = await datasette.check_visibility( request.actor, action="view-query", - resource=QueryResource(database=database, query=stored_query["name"]), + resource=QueryResource(database=database, query=stored_query.name), ) if not visible: raise Forbidden("You do not have permission to view this query") @@ -591,14 +591,14 @@ class QueryView(View): sql = None if stored_query: - sql = stored_query["sql"] + sql = stored_query.sql elif "sql" in params: sql = params.pop("sql") # Extract any :named parameters named_parameters = [] - if stored_query and stored_query.get("params"): - named_parameters = stored_query["params"] + if stored_query and stored_query.parameters: + named_parameters = stored_query.parameters if not named_parameters and sql: named_parameters = derive_named_parameters(sql) named_parameter_values = { @@ -686,7 +686,7 @@ class QueryView(View): columns=columns, rows=rows, sql=sql, - query_name=stored_query["name"] if stored_query else None, + query_name=stored_query.name if stored_query else None, database=database, table=None, request=request, @@ -721,7 +721,7 @@ class QueryView(View): if stored_query: templates.insert( 0, - f"query-{to_css_class(database)}-{to_css_class(stored_query['name'])}.html", + f"query-{to_css_class(database)}-{to_css_class(stored_query.name)}.html", ) environment = datasette.get_jinja_environment(request) @@ -740,7 +740,7 @@ class QueryView(View): ) metadata = await datasette.get_database_metadata(database) if stored_query: - metadata = dict(stored_query) + metadata = stored_query_to_dict(stored_query) metadata.pop("source", None) renderers = {} @@ -775,7 +775,7 @@ class QueryView(View): ) show_hide_hidden = "" - if stored_query and stored_query.get("hide_sql"): + if stored_query and stored_query.hide_sql: if bool(params.get("_show_sql")): show_hide_link = path_with_removed_args(request, {"_show_sql"}) show_hide_text = "hide" @@ -843,7 +843,7 @@ class QueryView(View): datasette=datasette, actor=request.actor, database=database, - query_name=stored_query["name"] if stored_query else None, + query_name=stored_query.name if stored_query else None, request=request, sql=sql, params=params, @@ -863,7 +863,7 @@ class QueryView(View): "sql": sql, "params": params, }, - stored_query=stored_query["name"] if stored_query else None, + stored_query=stored_query.name if stored_query else None, private=private, stored_query_write=stored_query_write, db_is_immutable=not db.is_mutable, @@ -907,7 +907,7 @@ class QueryView(View): datasette, request, database=database, - query_name=stored_query["name"] if stored_query else None, + query_name=stored_query.name if stored_query else None, ), query_actions=query_actions, ), diff --git a/datasette/views/query_helpers.py b/datasette/views/query_helpers.py index de732431..46d71b8e 100644 --- a/datasette/views/query_helpers.py +++ b/datasette/views/query_helpers.py @@ -2,6 +2,7 @@ import json import re from datasette.resources import DatabaseResource, TableResource +from datasette.stored_queries import StoredQuery from datasette.utils import ( named_parameters as derive_named_parameters, escape_sqlite, @@ -281,18 +282,18 @@ async def _prepare_execute_write(datasette, db, sql, params, actor): return parameter_names, params, analysis -async def _ensure_stored_query_execution_permissions(datasette, db, query, actor): - if query.get("is_trusted"): +async def _ensure_stored_query_execution_permissions( + datasette, db, query: StoredQuery, actor +): + if query.is_trusted: return - if query.get("is_write"): + if query.is_write: await datasette.ensure_permission( action="execute-write-sql", resource=DatabaseResource(db.name), actor=actor, ) - await datasette.ensure_query_write_permissions( - db.name, query["sql"], actor=actor - ) + await datasette.ensure_query_write_permissions(db.name, query.sql, actor=actor) else: await datasette.ensure_permission( action="execute-sql", @@ -482,7 +483,7 @@ async def _prepare_query_create(datasette, request, db, data): } -async def _prepare_query_update(datasette, request, db, existing, update): +async def _prepare_query_update(datasette, request, db, existing: StoredQuery, update): invalid_keys = set(update) - _query_update_fields if invalid_keys: raise QueryValidationError( @@ -490,8 +491,8 @@ async def _prepare_query_update(datasette, request, db, existing, update): ) update = _apply_query_data_types(update) - sql = update.get("sql", existing["sql"]) - query_is_write = existing["is_write"] + sql = update.get("sql", existing.sql) + query_is_write = existing.is_write derived = _derived_query_parameters(sql) parameters = None diff --git a/datasette/views/stored_queries.py b/datasette/views/stored_queries.py index 1a2c5d00..8c4e849e 100644 --- a/datasette/views/stored_queries.py +++ b/datasette/views/stored_queries.py @@ -1,6 +1,7 @@ from urllib.parse import parse_qsl, urlencode from datasette.resources import DatabaseResource, QueryResource +from datasette.stored_queries import stored_query_to_dict from datasette.utils import sqlite3, tilde_decode from datasette.utils.asgi import Response @@ -100,7 +101,7 @@ class QueryListView(BaseView): ) query_list_path = self.query_list_path(database) next_url = None - if page["next"]: + if page.next: pairs = [ (key, value) for key, value in parse_qsl( @@ -108,7 +109,7 @@ class QueryListView(BaseView): ) if key != "_next" ] - pairs.append(("_next", page["next"])) + pairs.append(("_next", page.next)) next_url = "{}?{}".format( query_list_path, urlencode(pairs), @@ -194,13 +195,13 @@ class QueryListView(BaseView): "database_color": ( self.ds.get_database(database).color if database is not None else None ), - "queries": page["queries"], - "next": page["next"], + "queries": page.queries, + "next": page.next, "next_url": next_url, - "has_more": page["has_more"], - "limit": page["limit"], - "show_private_note": any(query["is_private"] for query in page["queries"]), - "show_trusted_note": any(query["is_trusted"] for query in page["queries"]), + "has_more": page.has_more, + "limit": page.limit, + "show_private_note": any(query.is_private for query in page.queries), + "show_trusted_note": any(query.is_trusted for query in page.queries), "query_list_path": query_list_path, "show_database": database is None, "facets": facets, @@ -213,7 +214,12 @@ class QueryListView(BaseView): }, } if format_ == "json": - return Response.json(data) + return Response.json( + { + **data, + "queries": [stored_query_to_dict(query) for query in page.queries], + } + ) return await self.render( ["query_list.html"], request, @@ -374,8 +380,11 @@ class QueryStoreView(QueryCreateView): return _error([str(ex)], 400) query = await self.ds.get_query(db.name, name) + assert query is not None if is_json: - return Response.json({"ok": True, "query": query}, status=201) + return Response.json( + {"ok": True, "query": stored_query_to_dict(query)}, status=201 + ) self.ds.add_message(request, "Query saved", self.ds.INFO) return Response.redirect(self.ds.urls.path(self.ds.urls.table(db.name, name))) @@ -395,7 +404,7 @@ class QueryDefinitionView(BaseView): actor=request.actor, ): return _error(["Permission denied"], 403) - return Response.json({"ok": True, "query": query}) + return Response.json({"ok": True, "query": stored_query_to_dict(query)}) class QueryUpdateView(BaseView): @@ -413,7 +422,7 @@ class QueryUpdateView(BaseView): actor=request.actor, ): return _error(["Permission denied: need update-query"], 403) - if existing.get("is_trusted"): + if existing.is_trusted: return _error(["Trusted queries cannot be updated using the API"], 403) try: @@ -444,10 +453,12 @@ class QueryUpdateView(BaseView): await self.ds.update_query(db.name, query_name, **update_kwargs) if data.get("return"): + query = await self.ds.get_query(db.name, query_name) + assert query is not None return Response.json( { "ok": True, - "query": await self.ds.get_query(db.name, query_name), + "query": stored_query_to_dict(query), } ) return Response.json({"ok": True}) diff --git a/docs/internals.rst b/docs/internals.rst index 66724aa9..4980ee8b 100644 --- a/docs/internals.rst +++ b/docs/internals.rst @@ -1039,11 +1039,11 @@ Example: await .get_query(database, name) ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -Returns a stored query dictionary, or ``None`` if the query does not exist. +Returns a ``StoredQuery`` dataclass instance, or ``None`` if the query does not exist. -The dictionary contains ``database``, ``name``, ``sql``, ``title``, ``description``, ``description_html``, ``hide_sql``, ``fragment``, ``parameters``, ``params``, ``is_write``, ``is_private``, ``is_trusted``, ``source``, ``owner_id``, ``on_success_message``, ``on_success_message_sql``, ``on_success_redirect``, ``on_error_message`` and ``on_error_redirect``. +``StoredQuery`` has the following attributes: ``database``, ``name``, ``sql``, ``title``, ``description``, ``description_html``, ``hide_sql``, ``fragment``, ``parameters``, ``is_write``, ``is_private``, ``is_trusted``, ``source``, ``owner_id``, ``on_success_message``, ``on_success_message_sql``, ``on_success_redirect``, ``on_error_message`` and ``on_error_redirect``. -``parameters`` and ``params`` contain the same list of explicit parameter names. +``parameters`` is a list of explicit parameter names. .. _datasette_list_queries: @@ -1087,12 +1087,12 @@ Lists stored queries visible to the specified actor. ``owner_id`` - string, optional Filter by owner actor ID. ``include_private`` - boolean, optional - Set to ``True`` to include a ``private`` boolean in each returned query dictionary indicating if anonymous users would be unable to view that query. + Set to ``True`` to populate a ``private`` boolean on each returned ``StoredQuery`` indicating if anonymous users would be unable to view that query. -The return value is a dictionary with these keys: +The return value is a ``StoredQueryPage`` dataclass instance with these attributes: -``queries`` - list of dictionaries - Stored query dictionaries, in the same format returned by :ref:`datasette_get_query`. +``queries`` - list of StoredQuery instances + Stored queries in the same format returned by :ref:`datasette_get_query`. ``next`` - string or None Pagination cursor for the next page, if one exists. ``has_more`` - boolean diff --git a/tests/test_queries.py b/tests/test_queries.py index 70fb7a03..59fab8c0 100644 --- a/tests/test_queries.py +++ b/tests/test_queries.py @@ -4,6 +4,7 @@ import pytest from datasette.app import Datasette from datasette.resources import DatabaseResource, QueryResource +from datasette.stored_queries import StoredQuery, StoredQueryPage from datasette.utils.asgi import Forbidden @@ -87,38 +88,41 @@ async def test_add_get_and_remove_query(): } query = await ds.get_query("data", "top_customers") - assert query == { - "database": "data", - "name": "top_customers", - "sql": "select * from customers where region = :region", - "title": "Top customers", - "description": "Customers by region", - "description_html": None, - "hide_sql": True, - "fragment": "chart", - "params": ["region"], - "parameters": ["region"], - "is_write": False, - "is_private": False, - "is_trusted": True, - "source": "user", - "owner_id": "alice", - "on_success_message": None, - "on_success_message_sql": None, - "on_success_redirect": None, - "on_error_message": None, - "on_error_redirect": None, - } + assert query == StoredQuery( + database="data", + name="top_customers", + sql="select * from customers where region = :region", + title="Top customers", + description="Customers by region", + description_html=None, + hide_sql=True, + fragment="chart", + parameters=["region"], + is_write=False, + is_private=False, + is_trusted=True, + source="user", + owner_id="alice", + on_success_message=None, + on_success_message_sql=None, + on_success_redirect=None, + on_error_message=None, + on_error_redirect=None, + ) queries_page = await ds.list_queries("data", actor=None) - assert queries_page["queries"] == [query] - assert queries_page["next"] is None + assert queries_page == StoredQueryPage( + queries=[query], + next=None, + has_more=False, + limit=50, + ) await ds.remove_query("data", "top_customers") assert await ds.get_query("data", "top_customers") is None queries_page = await ds.list_queries("data", actor=None) - assert queries_page["queries"] == [] - assert queries_page["next"] is None + assert queries_page.queries == [] + assert queries_page.next is None @pytest.mark.asyncio @@ -156,13 +160,12 @@ async def test_update_query_only_updates_provided_fields(): ) query = await ds.get_query("data", "redirect") - assert query["title"] == "Updated" - assert query["parameters"] == [] - assert query["params"] == [] - assert query["on_success_redirect"] is None - assert query["sql"] == "select 1" - assert query["is_private"] is False - assert query["is_trusted"] is False + assert query.title == "Updated" + assert query.parameters == [] + assert query.on_success_redirect is None + assert query.sql == "select 1" + assert query.is_private is False + assert query.is_trusted is False options_row = ( await ds.get_internal_database().execute( """ @@ -198,28 +201,27 @@ async def test_config_queries_imported_to_internal_table(): ds.add_memory_database("query_config", name="data") await ds.invoke_startup() - assert await ds.get_query("data", "configured") == { - "database": "data", - "name": "configured", - "sql": "select :name as name", - "title": "Configured query", - "description": None, - "description_html": "

Configured HTML

", - "hide_sql": False, - "fragment": None, - "params": ["name"], - "parameters": ["name"], - "is_write": False, - "is_private": False, - "is_trusted": True, - "source": "config", - "owner_id": None, - "on_success_message": None, - "on_success_message_sql": "select 'Hello ' || :name", - "on_success_redirect": None, - "on_error_message": None, - "on_error_redirect": None, - } + assert await ds.get_query("data", "configured") == StoredQuery( + database="data", + name="configured", + sql="select :name as name", + title="Configured query", + description=None, + description_html="

Configured HTML

", + hide_sql=False, + fragment=None, + parameters=["name"], + is_write=False, + is_private=False, + is_trusted=True, + source="config", + owner_id=None, + on_success_message=None, + on_success_message_sql="select 'Hello ' || :name", + on_success_redirect=None, + on_error_message=None, + on_error_redirect=None, + ) @pytest.mark.asyncio @@ -1032,8 +1034,8 @@ async def test_query_update_api_rejects_config_only_fields(): "Invalid keys: description_html, on_success_message_sql" ] query = await ds.get_query("data", "editable") - assert query["description_html"] is None - assert query["on_success_message_sql"] is None + assert query.description_html is None + assert query.on_success_message_sql is None @pytest.mark.asyncio @@ -1072,9 +1074,9 @@ async def test_query_update_api_rejects_trusted_queries_but_internal_update_allo "Trusted queries cannot be updated using the API" ] query = await ds.get_query("data", "trusted_report") - assert query["is_trusted"] is True - assert query["sql"] == "select 1 as one" - assert query["title"] == "Original" + assert query.is_trusted is True + assert query.sql == "select 1 as one" + assert query.title == "Original" await ds.update_query( "data", @@ -1083,9 +1085,9 @@ async def test_query_update_api_rejects_trusted_queries_but_internal_update_allo title="Internal", ) query = await ds.get_query("data", "trusted_report") - assert query["is_trusted"] is True - assert query["sql"] == "select 3 as three" - assert query["title"] == "Internal" + assert query.is_trusted is True + assert query.sql == "select 3 as three" + assert query.title == "Internal" @pytest.mark.asyncio