diff --git a/.github/actions/setup-sqlite-version/action.yml b/.github/actions/setup-sqlite-version/action.yml
new file mode 100644
index 00000000..fdbc71c9
--- /dev/null
+++ b/.github/actions/setup-sqlite-version/action.yml
@@ -0,0 +1,39 @@
+name: "Setup SQLite version"
+description: "Build and activate a specific SQLite version from its amalgamation archive"
+inputs:
+ version:
+ description: "The SQLite version to install"
+ required: true
+ cflags:
+ description: "CFLAGS to use when compiling SQLite"
+ required: false
+ default: ""
+ skip-activate:
+ description: "Set to true to skip modifying the library path"
+ required: false
+ default: "false"
+ fallback-urls:
+ description: "Whitespace-separated fallback download URLs to try after sqlite.org"
+ required: false
+ default: ""
+outputs:
+ sqlite-location:
+ description: "Directory containing the compiled SQLite library"
+ value: ${{ steps.build.outputs.sqlite-location }}
+runs:
+ using: "composite"
+ steps:
+ - shell: bash
+ run: mkdir -p "$RUNNER_TEMP/sqlite-versions/downloads"
+ - uses: actions/cache@v6
+ with:
+ path: ${{ runner.temp }}/sqlite-versions/downloads
+ key: setup-sqlite-version-${{ inputs.version }}-amalgamation-v1
+ - id: build
+ shell: bash
+ run: bash "$GITHUB_ACTION_PATH/setup-sqlite-version.sh"
+ env:
+ SQLITE_VERSION: ${{ inputs.version }}
+ SQLITE_CFLAGS: ${{ inputs.cflags }}
+ SQLITE_SKIP_ACTIVATE: ${{ inputs.skip-activate }}
+ SQLITE_EXTRA_FALLBACK_URLS: ${{ inputs.fallback-urls }}
diff --git a/.github/actions/setup-sqlite-version/setup-sqlite-version.sh b/.github/actions/setup-sqlite-version/setup-sqlite-version.sh
new file mode 100644
index 00000000..03d6a68f
--- /dev/null
+++ b/.github/actions/setup-sqlite-version/setup-sqlite-version.sh
@@ -0,0 +1,144 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+version_spec="${SQLITE_VERSION:?SQLITE_VERSION is required}"
+cflags="${SQLITE_CFLAGS:-}"
+skip_activate="${SQLITE_SKIP_ACTIVATE:-false}"
+extra_fallback_urls="${SQLITE_EXTRA_FALLBACK_URLS:-}"
+
+case "$version_spec" in
+ 3.46 | 3.46.0)
+ sqlite_version="3.46.0"
+ sqlite_year="2024"
+ amalgamation_id="3460000"
+ builtin_fallback_urls="https://static.simonwillison.net/static/2026/sqlite-amalgamation-3460000.zip"
+ ;;
+ 3.25 | 3.25.0)
+ sqlite_version="3.25.0"
+ sqlite_year="2018"
+ amalgamation_id="3250000"
+ builtin_fallback_urls="https://static.simonwillison.net/static/2026/sqlite-amalgamation-3250000.zip?v=1"
+ ;;
+ *)
+ echo "::error::Unsupported SQLite version '$version_spec'. Add its release year and amalgamation id to $GITHUB_ACTION_PATH/setup-sqlite-version.sh."
+ exit 1
+ ;;
+esac
+
+case "$(uname -s)" in
+ Linux)
+ library_name="libsqlite3.so.0"
+ library_path_var="LD_LIBRARY_PATH"
+ ;;
+ Darwin)
+ library_name="libsqlite3.dylib"
+ library_path_var="DYLD_LIBRARY_PATH"
+ ;;
+ *)
+ echo "::error::Unsupported platform $(uname -s)"
+ exit 1
+ ;;
+esac
+
+runner_temp="${RUNNER_TEMP:-}"
+if [ -z "$runner_temp" ]; then
+ runner_temp="$(mktemp -d)"
+fi
+
+filename="sqlite-amalgamation-${amalgamation_id}"
+official_url="https://www.sqlite.org/${sqlite_year}/${filename}.zip"
+download_dir="${runner_temp}/sqlite-versions/downloads"
+source_root="${runner_temp}/sqlite-versions/source"
+source_dir="${source_root}/${filename}"
+build_dir="${runner_temp}/sqlite-versions/build/${sqlite_version}"
+archive_path="${download_dir}/${filename}.zip"
+
+mkdir -p "$download_dir" "$source_root" "$build_dir"
+
+download_archive() {
+ local url
+ local candidate_path="${archive_path}.tmp"
+ local urls=("$official_url")
+
+ for url in $builtin_fallback_urls $extra_fallback_urls; do
+ urls+=("$url")
+ done
+
+ rm -f "$candidate_path"
+ for url in "${urls[@]}"; do
+ echo "Downloading SQLite ${sqlite_version} amalgamation from ${url}"
+ if curl \
+ --fail \
+ --location \
+ --show-error \
+ --retry 5 \
+ --retry-delay 2 \
+ --retry-max-time 180 \
+ --retry-all-errors \
+ --connect-timeout 20 \
+ --max-time 240 \
+ --output "$candidate_path" \
+ "$url"; then
+ mv "$candidate_path" "$archive_path"
+ return 0
+ fi
+
+ echo "::warning::Download failed from ${url}"
+ rm -f "$candidate_path"
+ done
+
+ echo "::error::Could not download SQLite ${sqlite_version} amalgamation"
+ return 1
+}
+
+if [ ! -f "${source_dir}/sqlite3.c" ]; then
+ if [ ! -f "$archive_path" ]; then
+ download_archive
+ fi
+
+ rm -rf "$source_dir"
+ unzip -q "$archive_path" -d "$source_root"
+fi
+
+if [ ! -f "${source_dir}/sqlite3.c" ]; then
+ echo "::error::Expected ${source_dir}/sqlite3.c after extracting ${archive_path}"
+ exit 1
+fi
+
+read -r -a cflag_args <<< "$cflags"
+
+echo "Compiling SQLite ${sqlite_version} to ${build_dir}/${library_name}"
+gcc \
+ -fPIC \
+ -shared \
+ "${cflag_args[@]}" \
+ "${source_dir}/sqlite3.c" \
+ "-I${source_dir}" \
+ -o "${build_dir}/${library_name}"
+
+if [ "$library_name" = "libsqlite3.so.0" ]; then
+ ln -sf "$library_name" "${build_dir}/libsqlite3.so"
+fi
+
+if [ -n "${GITHUB_OUTPUT:-}" ]; then
+ echo "sqlite-location=${build_dir}" >> "$GITHUB_OUTPUT"
+else
+ echo "sqlite-location=${build_dir}"
+fi
+
+case "$(printf '%s' "$skip_activate" | tr '[:upper:]' '[:lower:]')" in
+ true | 1 | yes)
+ echo "Skipping ${library_path_var} activation"
+ ;;
+ *)
+ existing_value="${!library_path_var:-}"
+ if [ -n "${GITHUB_ENV:-}" ]; then
+ if [ -n "$existing_value" ]; then
+ echo "${library_path_var}=${build_dir}:${existing_value}" >> "$GITHUB_ENV"
+ else
+ echo "${library_path_var}=${build_dir}" >> "$GITHUB_ENV"
+ fi
+ fi
+ echo "Added ${build_dir} to ${library_path_var}"
+ ;;
+esac
diff --git a/.github/workflows/test-sqlite-support.yml b/.github/workflows/test-sqlite-support.yml
index 23fce459..d86000bf 100644
--- a/.github/workflows/test-sqlite-support.yml
+++ b/.github/workflows/test-sqlite-support.yml
@@ -34,7 +34,7 @@ jobs:
cache: pip
cache-dependency-path: pyproject.toml
- name: Set up SQLite ${{ matrix.sqlite-version }}
- uses: asg017/sqlite-versions@71ea0de37ae739c33e447af91ba71dda8fcf22e6
+ uses: ./.github/actions/setup-sqlite-version
with:
version: ${{ matrix.sqlite-version }}
cflags: "-DSQLITE_ENABLE_DESERIALIZE -DSQLITE_ENABLE_FTS5 -DSQLITE_ENABLE_FTS4 -DSQLITE_ENABLE_FTS3_PARENTHESIS -DSQLITE_ENABLE_RTREE -DSQLITE_ENABLE_JSON1"
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 9e47db6f..acc2d6b6 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -51,3 +51,25 @@ jobs:
run: |
pip install datasette-init datasette-json-html
tests/test-datasette-load-plugins.sh
+ test-sqlite-utils-4:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v6
+ - name: Set up Python
+ uses: actions/setup-python@v6
+ with:
+ python-version: "3.13"
+ cache: pip
+ cache-dependency-path: pyproject.toml
+ - name: Build extension for --load-extension test
+ run: |-
+ (cd tests && gcc ext.c -fPIC -shared -o ext.so)
+ - name: Install dependencies
+ run: |
+ pip install . --group dev
+ pip install --pre 'sqlite-utils>=4.0rc3'
+ pip freeze
+ - name: Run tests
+ run: |
+ pytest -n auto -m "not serial"
+ pytest -m "serial"
diff --git a/Justfile b/Justfile
index 5fcd9afd..6ffff870 100644
--- a/Justfile
+++ b/Justfile
@@ -33,10 +33,11 @@ export DATASETTE_SECRET := "not_a_secret"
uv run codespell datasette -S datasette/static --ignore-words docs/codespell-ignore-words.txt
uv run codespell tests --ignore-words docs/codespell-ignore-words.txt
-# Run linters: black, ruff, cog
+# Run linters: black, ruff, prettier, cog
@lint: codespell
uv run black datasette tests --check
uv run ruff check datasette tests
+ npm run prettier -- --check
uv run cog --check README.md docs/*.rst
# Apply ruff fixes
diff --git a/datasette/__init__.py b/datasette/__init__.py
index de46861c..e0022178 100644
--- a/datasette/__init__.py
+++ b/datasette/__init__.py
@@ -2,7 +2,13 @@ from datasette.permissions import Permission # noqa
from datasette.version import __version_info__, __version__ # noqa
from datasette.events import Event # noqa
from datasette.tokens import TokenHandler, TokenInvalid, TokenRestrictions # noqa
-from datasette.utils.asgi import Forbidden, NotFound, Request, Response # noqa
+from datasette.utils.asgi import ( # noqa
+ Forbidden,
+ NotFound,
+ PayloadTooLarge,
+ Request,
+ Response,
+)
from datasette.utils import actor_matches_allow # noqa
from datasette.views import Context # noqa
from .hookspecs import hookimpl # noqa
diff --git a/datasette/app.py b/datasette/app.py
index daa3848b..463c9be2 100644
--- a/datasette/app.py
+++ b/datasette/app.py
@@ -209,6 +209,11 @@ SETTINGS = (
100,
"Maximum rows that can be inserted at a time using the bulk insert API",
),
+ Setting(
+ "max_post_body_bytes",
+ 2 * 1024 * 1024,
+ "Maximum size in bytes for a POST body read into memory, e.g. JSON API requests - set 0 to disable this limit",
+ ),
Setting(
"num_sql_threads",
3,
@@ -2874,7 +2879,11 @@ class DatasetteRouter:
if base_url != "/" and path.startswith(base_url):
path = "/" + path[len(base_url) :]
scope = dict(scope, route_path=path)
- request = Request(scope, receive)
+ request = Request(
+ scope,
+ receive,
+ max_post_body_bytes=self.ds.setting("max_post_body_bytes"),
+ )
# Populate request_messages if ds_messages cookie is present
try:
request._messages = self.ds.unsign(
diff --git a/datasette/default_actions.py b/datasette/default_actions.py
index 2f78570b..602e0df4 100644
--- a/datasette/default_actions.py
+++ b/datasette/default_actions.py
@@ -61,6 +61,12 @@ def register_actions():
description="Create tables",
resource_class=DatabaseResource,
),
+ Action(
+ name="create-view",
+ abbr="cv",
+ description="Create views",
+ resource_class=DatabaseResource,
+ ),
Action(
name="store-query",
abbr="sq",
@@ -111,6 +117,12 @@ def register_actions():
description="Drop tables",
resource_class=TableResource,
),
+ Action(
+ name="drop-view",
+ abbr="dv",
+ description="Drop views",
+ resource_class=TableResource,
+ ),
# Query-level actions (child-level)
Action(
name="view-query",
diff --git a/datasette/static/app.css b/datasette/static/app.css
index ce800f61..d101e4b7 100644
--- a/datasette/static/app.css
+++ b/datasette/static/app.css
@@ -1641,6 +1641,11 @@ dialog.row-edit-dialog::backdrop {
overflow-y: auto;
}
+.row-edit-fields[hidden],
+.row-edit-bulk[hidden] {
+ display: none;
+}
+
.row-edit-field {
display: grid;
grid-template-columns: minmax(120px, 180px) minmax(0, 1fr);
@@ -1700,6 +1705,118 @@ textarea.row-edit-input {
background: var(--paper);
}
+.row-edit-binary-control {
+ display: grid;
+ gap: 8px;
+ box-sizing: border-box;
+ width: 100%;
+ min-width: 0;
+ border: 1px solid var(--rule);
+ border-radius: 5px;
+ padding: 10px;
+ background: #fff;
+}
+
+.row-edit-binary-control:focus {
+ border-color: var(--accent);
+ outline: 3px solid rgba(26, 86, 219, 0.12);
+}
+
+.row-edit-binary-preview[hidden] {
+ display: none;
+}
+
+.row-edit-binary-preview img {
+ display: block;
+ max-width: min(240px, 100%);
+ max-height: 180px;
+ border: 1px solid var(--rule);
+ border-radius: 4px;
+ background: var(--paper);
+}
+
+.row-edit-binary-status {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: baseline;
+ gap: 8px;
+ min-width: 0;
+}
+
+.row-edit-binary-size {
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+ font-size: 0.86rem;
+}
+
+.row-edit-binary-name {
+ color: var(--muted);
+ font-size: 0.82rem;
+ overflow-wrap: anywhere;
+}
+
+.row-edit-binary-name[hidden] {
+ display: none;
+}
+
+.row-edit-binary-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+}
+
+.row-edit-binary-file-button,
+.row-edit-binary-clear {
+ appearance: none;
+ border: 1px solid var(--rule);
+ border-radius: 4px;
+ background: #fff;
+ color: var(--accent);
+ cursor: pointer;
+ font: inherit;
+ font-size: 0.78rem;
+ line-height: 1.2;
+ padding: 6px 8px;
+}
+
+.row-edit-binary-file-button:hover,
+.row-edit-binary-file-button:focus-within,
+.row-edit-binary-clear:hover,
+.row-edit-binary-clear:focus {
+ background: #f8fafc;
+}
+
+.row-edit-binary-file-button:focus-within,
+.row-edit-binary-clear:focus {
+ outline: 3px solid rgba(26, 86, 219, 0.12);
+ outline-offset: 1px;
+}
+
+.row-edit-binary-file-button input[type="file"] {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ opacity: 0;
+ overflow: hidden;
+}
+
+.row-edit-binary-clear[hidden] {
+ display: none;
+}
+
+.row-edit-binary-drop-target {
+ border: 1px dashed var(--rule);
+ border-radius: 4px;
+ padding: 7px 8px;
+ color: var(--muted);
+ font-size: 0.78rem;
+}
+
+.row-edit-binary-dragover .row-edit-binary-drop-target {
+ border-color: var(--accent);
+ background: var(--paper);
+ color: var(--ink);
+}
+
.row-edit-default {
display: grid;
grid-template-columns: minmax(0, 1fr) 7.25rem;
@@ -1798,6 +1915,207 @@ textarea.row-edit-input {
margin: 0;
}
+.row-edit-bulk {
+ display: grid;
+ gap: 8px;
+ padding: 16px 24px 24px;
+ overflow-y: auto;
+}
+
+.row-edit-bulk-editor {
+ display: grid;
+ gap: 8px;
+}
+
+.row-edit-bulk-editor[hidden] {
+ display: none;
+}
+
+.row-edit-bulk-actions {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 8px;
+ justify-content: flex-start;
+}
+
+.row-edit-bulk-actions .btn {
+ padding-left: 12px;
+ padding-right: 12px;
+}
+
+.row-edit-bulk-conflict {
+ display: grid;
+ grid-template-columns: minmax(120px, 180px) minmax(0, 1fr);
+ gap: 8px 12px;
+ align-items: start;
+}
+
+.row-edit-bulk-conflict[hidden] {
+ display: none;
+}
+
+.row-edit-bulk-conflict-label {
+ color: var(--ink);
+ font-size: 0.82rem;
+ padding-top: 8px;
+}
+
+.row-edit-bulk-conflict-control {
+ display: grid;
+ gap: 4px;
+}
+
+.row-edit-bulk-conflict-help {
+ color: var(--muted);
+ font-size: 0.78rem;
+ margin: 0;
+}
+
+.row-edit-copy-template-label-narrow {
+ display: none;
+}
+
+.row-edit-bulk-template-note {
+ color: var(--muted);
+ font-size: 0.82rem;
+}
+
+.row-edit-bulk-template-note-narrow {
+ display: none;
+}
+
+.row-edit-bulk-textarea {
+ min-height: 16rem;
+ resize: vertical;
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+ font-size: 0.82rem;
+ line-height: 1.45;
+}
+
+.row-edit-bulk-textarea.row-edit-bulk-drop-target {
+ border-color: var(--accent);
+ background: #f8fbff;
+ outline: 3px solid rgba(26, 86, 219, 0.12);
+}
+
+.row-edit-bulk-note {
+ color: var(--muted);
+ font-size: 0.82rem;
+ margin: 0;
+}
+
+.row-edit-bulk-note label,
+.row-edit-bulk-note .button-as-link {
+ font: inherit;
+}
+
+@media (max-width: 640px) {
+ .row-edit-copy-template-label-wide {
+ display: none;
+ }
+
+ .row-edit-copy-template-label-narrow {
+ display: inline;
+ }
+
+ .row-edit-bulk-template-note-wide {
+ display: none;
+ }
+
+ .row-edit-bulk-template-note-narrow {
+ display: inline;
+ }
+}
+
+.row-edit-bulk-preview {
+ display: grid;
+ gap: 8px;
+ margin-top: 8px;
+}
+
+.row-edit-bulk-preview[hidden] {
+ display: none;
+}
+
+.row-edit-bulk-preview-summary {
+ color: var(--ink);
+ font-size: 0.9rem;
+ font-weight: 600;
+ margin: 0;
+}
+
+.row-edit-bulk-preview-table-wrap {
+ border: 1px solid var(--rule);
+ border-radius: 5px;
+ max-height: 18rem;
+ overflow: auto;
+ background: #fff;
+}
+
+.row-edit-bulk-preview-table {
+ border-collapse: collapse;
+ font-size: 0.78rem;
+ min-width: 100%;
+ width: max-content;
+}
+
+.row-edit-bulk-preview-table th,
+.row-edit-bulk-preview-table td {
+ border-bottom: 1px solid var(--rule);
+ border-right: 1px solid var(--rule);
+ max-width: 18rem;
+ overflow-wrap: anywhere;
+ padding: 6px 8px;
+ text-align: left;
+ vertical-align: top;
+ white-space: normal;
+}
+
+.row-edit-bulk-preview-table th {
+ background: var(--paper);
+ color: var(--ink);
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+ font-weight: 600;
+ position: sticky;
+ top: 0;
+ z-index: 1;
+}
+
+.row-edit-bulk-preview-table tr:last-child td {
+ border-bottom: none;
+}
+
+.row-edit-bulk-preview-table th:last-child,
+.row-edit-bulk-preview-table td:last-child {
+ border-right: none;
+}
+
+.row-edit-bulk-preview-null,
+.row-edit-bulk-preview-auto {
+ color: var(--muted);
+ font-style: italic;
+}
+
+.row-edit-bulk-progress {
+ display: grid;
+ gap: 6px;
+}
+
+.row-edit-bulk-progress[hidden] {
+ display: none;
+}
+
+.row-edit-bulk-progress-bar {
+ width: 100%;
+}
+
+.row-edit-bulk-progress-status {
+ color: var(--ink);
+ font-size: 0.9rem;
+ margin: 0;
+}
+
datasette-autocomplete {
display: block;
position: relative;
@@ -1876,6 +2194,16 @@ datasette-autocomplete input[type="text"],
background: var(--paper);
}
+.row-edit-mode-link {
+ color: var(--accent);
+ font-size: 0.9rem;
+ margin-right: auto;
+}
+
+.row-edit-mode-link[hidden] {
+ display: none;
+}
+
.row-edit-dialog .btn {
border: none;
border-radius: 5px;
@@ -2072,6 +2400,120 @@ select.table-create-input {
gap: 10px;
}
+.table-create-columns[hidden],
+.table-create-data[hidden],
+.table-create-data-editor[hidden],
+.table-create-data-preview[hidden] {
+ display: none;
+}
+
+.table-create-data,
+.table-create-data-editor {
+ display: grid;
+ gap: 8px;
+}
+
+.table-create-data-label {
+ color: var(--ink);
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+ font-size: 0.82rem;
+}
+
+.table-create-data-textarea {
+ min-height: 16rem;
+ resize: vertical;
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+ font-size: 0.82rem;
+ line-height: 1.45;
+}
+
+.table-create-data-textarea.table-create-data-drop-target {
+ border-color: var(--accent);
+ background: #f8fbff;
+ outline: 3px solid rgba(26, 86, 219, 0.12);
+}
+
+.table-create-data-note {
+ color: var(--muted);
+ font-size: 0.82rem;
+ margin: 0;
+}
+
+.table-create-data-note label,
+.table-create-data-note .button-as-link {
+ font: inherit;
+}
+
+.table-create-data-preview {
+ display: grid;
+ gap: 10px;
+}
+
+.table-create-data-preview-summary {
+ color: var(--ink);
+ font-size: 0.9rem;
+ font-weight: 600;
+ margin: 0;
+}
+
+.table-create-data-pk-field {
+ display: grid;
+ grid-template-columns: minmax(120px, 180px) minmax(0, 1fr);
+ gap: 12px;
+ align-items: center;
+}
+
+.table-create-data-preview-table-wrap {
+ border: 1px solid var(--rule);
+ border-radius: 5px;
+ max-height: 18rem;
+ overflow: auto;
+ background: #fff;
+}
+
+.table-create-data-preview-table {
+ border-collapse: collapse;
+ font-size: 0.78rem;
+ min-width: 100%;
+ width: max-content;
+}
+
+.table-create-data-preview-table th,
+.table-create-data-preview-table td {
+ border-bottom: 1px solid var(--rule);
+ border-right: 1px solid var(--rule);
+ max-width: 18rem;
+ overflow-wrap: anywhere;
+ padding: 6px 8px;
+ text-align: left;
+ vertical-align: top;
+ white-space: normal;
+}
+
+.table-create-data-preview-table th {
+ background: var(--paper);
+ color: var(--ink);
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+ font-weight: 600;
+ position: sticky;
+ top: 0;
+ z-index: 1;
+}
+
+.table-create-data-preview-table tr:last-child td {
+ border-bottom: none;
+}
+
+.table-create-data-preview-table th:last-child,
+.table-create-data-preview-table td:last-child {
+ border-right: none;
+}
+
+.table-create-data-preview-null {
+ color: var(--muted);
+ font-style: italic;
+}
+
.table-create-column-list {
display: grid;
gap: 8px;
@@ -2299,6 +2741,16 @@ select.table-create-input {
background: var(--paper);
}
+.table-create-mode-link {
+ color: var(--accent);
+ font-size: 0.9rem;
+ margin-right: auto;
+}
+
+.table-create-mode-link[hidden] {
+ display: none;
+}
+
.table-create-dialog .btn {
border: none;
border-radius: 5px;
@@ -3012,7 +3464,8 @@ select.table-alter-input {
.row-edit-dialog .modal-header,
.row-edit-summary,
.row-edit-loading,
- .row-edit-fields {
+ .row-edit-fields,
+ .row-edit-bulk {
padding-left: 18px;
padding-right: 18px;
}
@@ -3031,6 +3484,15 @@ select.table-alter-input {
padding-top: 0;
}
+ .row-edit-bulk-conflict {
+ grid-template-columns: 1fr;
+ gap: 5px;
+ }
+
+ .row-edit-bulk-conflict-label {
+ padding-top: 0;
+ }
+
.row-edit-dialog .modal-footer {
padding-left: 18px;
padding-right: 18px;
@@ -3062,6 +3524,11 @@ select.table-alter-input {
padding-top: 0;
}
+ .table-create-data-pk-field {
+ grid-template-columns: 1fr;
+ gap: 5px;
+ }
+
.table-create-column-headings {
display: none;
}
diff --git a/datasette/static/edit-tools.js b/datasette/static/edit-tools.js
index 9f4f89b9..9e8b93f6 100644
--- a/datasette/static/edit-tools.js
+++ b/datasette/static/edit-tools.js
@@ -2,8 +2,10 @@ var ROW_DELETE_DIALOG_ID = "row-delete-dialog";
var rowDeleteDialogState = null;
var ROW_EDIT_DIALOG_ID = "row-edit-dialog";
var rowEditDialogState = null;
+var ROW_EDIT_BINARY_IMAGE_PREVIEW_MAX_BYTES = 10 * 1024 * 1024;
var TABLE_CREATE_DIALOG_ID = "table-create-dialog";
var tableCreateDialogState = null;
+var TABLE_CREATE_AUTOMATIC_PK = "__datasette_automatic_pk__";
var TABLE_ALTER_DIALOG_ID = "table-alter-dialog";
var tableAlterDialogState = null;
@@ -810,12 +812,60 @@ async function loadTableCreateForeignKeyTargets(state) {
);
}
+function tableCreateIsDataMode(state) {
+ return state && state.mode === "data";
+}
+
+function tableCreateSaveButtonText(state) {
+ if (tableCreateIsDataMode(state)) {
+ return state.dataPreviewReady ? "Create table" : "Preview rows";
+ }
+ return "Create table";
+}
+
+function tableCreateCanInsertRows() {
+ var data = databaseCreateTableData() || {};
+ return !!data.canInsertRows;
+}
+
+function syncTableCreateModeUi(state) {
+ if (!state) {
+ return;
+ }
+ var isDataMode = tableCreateIsDataMode(state);
+ state.columnsPanel.hidden = isDataMode;
+ state.dataPanel.hidden = !isDataMode;
+ state.dataEditor.hidden = !isDataMode || state.dataPreviewReady;
+ state.dataPreview.hidden = !isDataMode || !state.dataPreviewReady;
+ state.createFromDataLink.hidden = isDataMode || !tableCreateCanInsertRows();
+ state.manualCreateLink.hidden = !isDataMode;
+}
+
+function updateTableCreateDialogButtons(state) {
+ if (!state) {
+ return;
+ }
+ syncTableCreateModeUi(state);
+ state.cancelButton.disabled = state.isSaving;
+ state.saveButton.disabled = state.isSaving;
+ state.addColumnButton.disabled = state.isSaving;
+ state.cancelButton.textContent =
+ tableCreateIsDataMode(state) && state.dataPreviewReady ? "Back" : "Cancel";
+ state.saveButton.textContent = state.isSaving
+ ? "Creating..."
+ : tableCreateSaveButtonText(state);
+}
+
function tableCreateDialogSignature(state) {
if (!state || !state.form) {
return "";
}
- return JSON.stringify({
+ var signature = {
table: state.tableName.value,
+ data: state.dataTextarea ? state.dataTextarea.value : "",
+ dataPrimaryKey: state.dataPkSelect
+ ? state.dataPkSelect.value
+ : TABLE_CREATE_AUTOMATIC_PK,
columns: tableCreateDialogRows(state).map(function (row) {
return {
name: row.querySelector(".table-create-column-name").value,
@@ -838,7 +888,8 @@ function tableCreateDialogSignature(state) {
).value || "",
};
}),
- });
+ };
+ return JSON.stringify(signature);
}
function tableCreateDialogHasChanges(state) {
@@ -864,18 +915,23 @@ function showTableCreateDialogError(state, message) {
function setTableCreateDialogSaving(state, isSaving) {
state.isSaving = isSaving;
- state.cancelButton.disabled = isSaving;
- state.saveButton.disabled = isSaving;
- state.addColumnButton.disabled = isSaving;
- state.saveButton.textContent = isSaving ? "Creating..." : "Create table";
state.columnList
.querySelectorAll("input, select, button")
.forEach(function (control) {
control.disabled = isSaving;
});
+ state.fields
+ .querySelectorAll(
+ ".table-create-data input, .table-create-data select, .table-create-data textarea, .table-create-data button",
+ )
+ .forEach(function (control) {
+ control.disabled = isSaving;
+ });
+ state.tableName.disabled = isSaving;
if (!isSaving) {
updateTableCreateColumnRules(state);
}
+ updateTableCreateDialogButtons(state);
updateTableCreateMoveButtons(state);
}
@@ -1231,8 +1287,11 @@ function addTableCreateColumn(state, column) {
}
function resetTableCreateDialog(state) {
+ state.mode = "manual";
state.nextColumnIndex = 0;
state.tableName.value = "";
+ state.dataTextarea.value = "";
+ resetTableCreateDataPreview(state);
state.columnList.textContent = "";
addTableCreateColumn(state, {
name: "id",
@@ -1245,9 +1304,39 @@ function resetTableCreateDialog(state) {
primaryKey: false,
});
updateTableCreateColumnRules(state);
+ updateTableCreateDialogButtons(state);
state.initialSignature = tableCreateDialogSignature(state);
}
+function showTableCreateDataMode(state) {
+ if (!state || state.isSaving || !tableCreateCanInsertRows()) {
+ return;
+ }
+ state.mode = "data";
+ clearTableCreateDialogError(state);
+ updateTableCreateDialogButtons(state);
+ if (state.dataPreviewReady && state.dataPkSelect) {
+ state.dataPkSelect.focus();
+ } else {
+ state.dataTextarea.focus();
+ }
+}
+
+function showTableCreateManualMode(state) {
+ if (!state || state.isSaving) {
+ return;
+ }
+ state.mode = "manual";
+ clearTableCreateDialogError(state);
+ updateTableCreateDialogButtons(state);
+ var firstInput = state.columnList.querySelector(".table-create-column-name");
+ if (firstInput) {
+ firstInput.focus();
+ } else {
+ state.tableName.focus();
+ }
+}
+
function collectTableCreatePayload(state) {
var payload = {
table: state.tableName.value.trim(),
@@ -1315,14 +1404,9 @@ function collectTableCreateColumnTypeAssignments(state) {
}
function validateTableCreatePayload(payload) {
- if (!payload.table) {
- return "Table name is required.";
- }
- if (payload.table.indexOf("\n") !== -1) {
- return "Table name cannot contain newlines.";
- }
- if (/^sqlite_/i.test(payload.table)) {
- return "Table name cannot start with sqlite_.";
+ var tableNameError = validateTableCreateTableName(payload.table);
+ if (tableNameError) {
+ return tableNameError;
}
if (!payload.columns.length) {
return "At least one column is required.";
@@ -1352,6 +1436,19 @@ function validateTableCreatePayload(payload) {
return null;
}
+function validateTableCreateTableName(tableName) {
+ if (!tableName) {
+ return "Table name is required.";
+ }
+ if (tableName.indexOf("\n") !== -1) {
+ return "Table name cannot contain newlines.";
+ }
+ if (/^sqlite_/i.test(tableName)) {
+ return "Table name cannot start with sqlite_.";
+ }
+ return null;
+}
+
function validateTableCreateColumnTypeAssignments(assignments) {
for (var i = 0; i < assignments.length; i += 1) {
var assignment = assignments[i];
@@ -1374,6 +1471,476 @@ function validateTableCreateColumnTypeAssignments(assignments) {
return null;
}
+function normalizeCreateTableDataJsonValue(value) {
+ if (typeof value === "undefined") {
+ return null;
+ }
+ if (Array.isArray(value) || (value && typeof value === "object")) {
+ return JSON.stringify(value);
+ }
+ return value;
+}
+
+function createTableDataColumnObjects(names) {
+ return names.map(function (name) {
+ return { name: name };
+ });
+}
+
+function validateCreateTableDataHeaders(headers) {
+ if (!headers.length) {
+ throw new Error("No columns found to preview.");
+ }
+ var seen = {};
+ headers.forEach(function (name, index) {
+ if (!name) {
+ throw new Error("Column header " + (index + 1) + " is blank.");
+ }
+ if (name.indexOf("\n") !== -1) {
+ throw new Error("Column names cannot contain newlines.");
+ }
+ var key = name.toLowerCase();
+ if (seen[key]) {
+ throw new Error("Duplicate column name: " + name);
+ }
+ seen[key] = true;
+ });
+}
+
+function jsonRowIsObject(item) {
+ return !!(item && typeof item === "object" && !Array.isArray(item));
+}
+
+function extractJsonObjectRows(parsed) {
+ if (Array.isArray(parsed)) {
+ return parsed;
+ }
+ if (!jsonRowIsObject(parsed)) {
+ throw new Error(
+ "JSON must be an array of objects, or an object containing an array of objects.",
+ );
+ }
+
+ var bestRows = null;
+ Object.keys(parsed).forEach(function (key) {
+ var value = parsed[key];
+ if (!Array.isArray(value) || !value.every(jsonRowIsObject)) {
+ return;
+ }
+ if (!bestRows || value.length > bestRows.length) {
+ bestRows = value;
+ }
+ });
+ if (!bestRows) {
+ throw new Error(
+ "JSON object must contain at least one root key with an array of objects.",
+ );
+ }
+ return bestRows;
+}
+
+function parseJsonObjectRows(text) {
+ var parsed;
+ try {
+ parsed = JSON.parse(text);
+ } catch (error) {
+ throw new Error("Invalid JSON: " + error.message);
+ }
+ var rows = extractJsonObjectRows(parsed);
+ if (!rows.length) {
+ throw new Error("No rows found to preview.");
+ }
+ return rows;
+}
+
+function parseJsonCreateTableRows(text) {
+ var parsed = parseJsonObjectRows(text);
+
+ var columnNames = [];
+ var columnMap = {};
+ parsed.forEach(function (item, index) {
+ if (!item || typeof item !== "object" || Array.isArray(item)) {
+ throw new Error("JSON row " + (index + 1) + " must be an object.");
+ }
+ Object.keys(item).forEach(function (name) {
+ if (!columnMap[name]) {
+ columnMap[name] = true;
+ columnNames.push(name);
+ }
+ });
+ });
+ validateCreateTableDataHeaders(columnNames);
+
+ var rows = parsed.map(function (item) {
+ var row = {};
+ columnNames.forEach(function (name) {
+ row[name] = Object.prototype.hasOwnProperty.call(item, name)
+ ? normalizeCreateTableDataJsonValue(item[name])
+ : null;
+ });
+ return row;
+ });
+ return {
+ columns: createTableDataColumnObjects(columnNames),
+ rows: rows,
+ };
+}
+
+function createTableDelimitedValueIsInteger(value) {
+ return /^[-+]?\d+$/.test(String(value).trim());
+}
+
+function createTableDelimitedValueIsFloat(value) {
+ var trimmed = String(value).trim();
+ if (!trimmed) {
+ return false;
+ }
+ var numberValue = Number(trimmed);
+ return Number.isFinite(numberValue);
+}
+
+function inferCreateTableDelimitedColumnType(values) {
+ var nonBlankValues = values.filter(function (value) {
+ return String(value).trim() !== "";
+ });
+ if (!nonBlankValues.length) {
+ return "text";
+ }
+ if (nonBlankValues.every(createTableDelimitedValueIsInteger)) {
+ return "integer";
+ }
+ if (nonBlankValues.every(createTableDelimitedValueIsFloat)) {
+ return "float";
+ }
+ return "text";
+}
+
+function coerceCreateTableDelimitedValue(value, type) {
+ var trimmed = String(value).trim();
+ if (trimmed === "") {
+ return type === "integer" || type === "float" ? null : "";
+ }
+ if (type === "integer") {
+ return parseInt(trimmed, 10);
+ }
+ if (type === "float") {
+ return Number(trimmed);
+ }
+ return value;
+}
+
+function detectCreateTableDataDelimiter(text) {
+ var firstLine =
+ text.split(/\r\n|\n|\r/).find(function (line) {
+ return line.trim() !== "";
+ }) || "";
+ var csvRows = delimiterPreviewRows(firstLine, ",");
+ var tsvRows = delimiterPreviewRows(firstLine, "\t");
+ var csvColumns = csvRows.length ? csvRows[0].length : 0;
+ var tsvColumns = tsvRows.length ? tsvRows[0].length : 0;
+
+ if (firstLine.indexOf("\t") !== -1 && firstLine.indexOf(",") === -1) {
+ return "\t";
+ }
+ if (tsvColumns > csvColumns) {
+ return "\t";
+ }
+ if (csvColumns > 1) {
+ return ",";
+ }
+ if (tsvColumns > 1) {
+ return "\t";
+ }
+ return null;
+}
+
+function parseDelimitedCreateTableRows(text) {
+ var delimiter = detectCreateTableDataDelimiter(text);
+ var rows = (
+ delimiter === null
+ ? splitSingleColumnRows(text)
+ : splitDelimitedRows(text, delimiter)
+ ).filter(function (row) {
+ return !bulkInsertDelimitedRowIsBlank(row);
+ });
+ if (!rows.length) {
+ throw new Error("No rows found to preview.");
+ }
+
+ var headers = rows[0].map(function (value) {
+ return value.trim();
+ });
+ validateCreateTableDataHeaders(headers);
+ var dataRows = rows.slice(1);
+ if (!dataRows.length) {
+ throw new Error("No data rows found to preview.");
+ }
+
+ dataRows.forEach(function (row, index) {
+ if (row.length > headers.length) {
+ throw new Error(
+ "Row " +
+ (index + 1) +
+ " has " +
+ row.length +
+ " values, but only " +
+ headers.length +
+ " columns were provided.",
+ );
+ }
+ });
+
+ var columnTypes = headers.map(function (_name, columnIndex) {
+ return inferCreateTableDelimitedColumnType(
+ dataRows.map(function (row) {
+ return row[columnIndex] || "";
+ }),
+ );
+ });
+
+ return {
+ columns: createTableDataColumnObjects(headers),
+ rows: dataRows.map(function (row) {
+ var rowObject = {};
+ headers.forEach(function (name, columnIndex) {
+ rowObject[name] = coerceCreateTableDelimitedValue(
+ row[columnIndex] || "",
+ columnTypes[columnIndex],
+ );
+ });
+ return rowObject;
+ }),
+ };
+}
+
+function parseCreateTableDataRows(text) {
+ var trimmed = text.trim();
+ if (!trimmed) {
+ throw new Error("Paste rows before previewing.");
+ }
+ if (trimmed[0] === "[" || trimmed[0] === "{") {
+ return parseJsonCreateTableRows(trimmed);
+ }
+ return parseDelimitedCreateTableRows(trimmed);
+}
+
+function tableCreateDataRecommendedPrimaryKey(columns, rows) {
+ var candidates = [];
+ columns.forEach(function (column, columnIndex) {
+ var distinctValues = {};
+ var maxLength = 0;
+ var valid = rows.length > 0;
+ rows.forEach(function (row) {
+ if (!valid) {
+ return;
+ }
+ var value = row[column.name];
+ if (value === null || typeof value === "undefined") {
+ valid = false;
+ return;
+ }
+ var text = String(value).trim();
+ if (!text || text.length >= 20 || /\s/.test(text)) {
+ valid = false;
+ return;
+ }
+ if (distinctValues[text]) {
+ valid = false;
+ return;
+ }
+ distinctValues[text] = true;
+ maxLength = Math.max(maxLength, text.length);
+ });
+ if (valid) {
+ candidates.push({
+ name: column.name,
+ maxLength: maxLength,
+ columnIndex: columnIndex,
+ });
+ }
+ });
+ candidates.sort(function (left, right) {
+ if (left.maxLength !== right.maxLength) {
+ return left.maxLength - right.maxLength;
+ }
+ return left.columnIndex - right.columnIndex;
+ });
+ return candidates.length ? candidates[0].name : TABLE_CREATE_AUTOMATIC_PK;
+}
+
+function renderTableCreateDataPreview(state, preview) {
+ state.dataPreview.textContent = "";
+
+ var summary = document.createElement("p");
+ summary.className = "table-create-data-preview-summary";
+ summary.textContent =
+ "Previewing " +
+ preview.rows.length +
+ " row" +
+ (preview.rows.length === 1 ? "." : "s.");
+ state.dataPreview.appendChild(summary);
+
+ var pkField = document.createElement("div");
+ pkField.className = "table-create-data-pk-field";
+ var pkLabel = document.createElement("label");
+ pkLabel.className = "table-create-data-label";
+ pkLabel.setAttribute("for", "table-create-data-primary-key");
+ pkLabel.textContent = "Primary key";
+ var pkSelect = document.createElement("select");
+ pkSelect.id = "table-create-data-primary-key";
+ pkSelect.className = "table-create-input table-create-data-primary-key";
+ var automaticOption = document.createElement("option");
+ automaticOption.value = TABLE_CREATE_AUTOMATIC_PK;
+ automaticOption.textContent = "Automatic ID column";
+ pkSelect.appendChild(automaticOption);
+ preview.columns.forEach(function (column) {
+ var option = document.createElement("option");
+ option.value = column.name;
+ option.textContent = column.name;
+ pkSelect.appendChild(option);
+ });
+ pkSelect.value = tableCreateDataRecommendedPrimaryKey(
+ preview.columns,
+ preview.rows,
+ );
+ pkSelect.addEventListener("change", function () {
+ clearTableCreateDialogError(state);
+ });
+ state.dataPkSelect = pkSelect;
+ pkField.appendChild(pkLabel);
+ pkField.appendChild(pkSelect);
+ state.dataPreview.appendChild(pkField);
+
+ var tableWrap = document.createElement("div");
+ tableWrap.className = "table-create-data-preview-table-wrap";
+ var table = document.createElement("table");
+ table.className = "table-create-data-preview-table";
+ var thead = document.createElement("thead");
+ var headerRow = document.createElement("tr");
+ preview.columns.forEach(function (column) {
+ var th = document.createElement("th");
+ th.scope = "col";
+ th.textContent = column.name;
+ headerRow.appendChild(th);
+ });
+ thead.appendChild(headerRow);
+ table.appendChild(thead);
+
+ var tbody = document.createElement("tbody");
+ preview.rows.forEach(function (row) {
+ var tr = document.createElement("tr");
+ preview.columns.forEach(function (column) {
+ var td = document.createElement("td");
+ var value = row[column.name];
+ td.textContent = bulkInsertPreviewValue(value);
+ if (value === null) {
+ td.className = "table-create-data-preview-null";
+ }
+ tr.appendChild(td);
+ });
+ tbody.appendChild(tr);
+ });
+ table.appendChild(tbody);
+ tableWrap.appendChild(table);
+ state.dataPreview.appendChild(tableWrap);
+ state.dataPreview.hidden = false;
+}
+
+function resetTableCreateDataPreview(state) {
+ state.dataPreviewRows = null;
+ state.dataPreviewColumns = [];
+ state.dataPreviewReady = false;
+ state.dataPkSelect = null;
+ state.dataPreview.hidden = true;
+ state.dataPreview.textContent = "";
+ syncTableCreateModeUi(state);
+}
+
+function tableCreateTableNameFromFileName(fileName) {
+ var baseName = (fileName || "").replace(/^.*[\\/]/, "");
+ var nameWithoutExtension = baseName.replace(/\.[^.]*$/, "");
+ return nameWithoutExtension
+ .trim()
+ .replace(/\s+/g, "_")
+ .toLowerCase()
+ .replace(/[^a-z0-9_]/g, "");
+}
+
+async function loadTableCreateDataTextFile(state, file) {
+ if (!file) {
+ return;
+ }
+ try {
+ var text = await readTextFile(file);
+ var tableName = tableCreateTableNameFromFileName(file.name);
+ if (tableName) {
+ state.tableName.value = tableName;
+ state.tableName.dispatchEvent(new Event("input", { bubbles: true }));
+ }
+ state.dataTextarea.value = text;
+ state.dataTextarea.dispatchEvent(new Event("input", { bubbles: true }));
+ clearTableCreateDialogError(state);
+ state.dataTextarea.focus();
+ } catch (_error) {
+ showTableCreateDialogError(state, "Could not read that text file.");
+ }
+}
+
+function previewTableCreateDataRows(state) {
+ clearTableCreateDialogError(state);
+ resetTableCreateDataPreview(state);
+ try {
+ var preview = parseCreateTableDataRows(state.dataTextarea.value);
+ state.dataPreviewRows = preview.rows;
+ state.dataPreviewColumns = preview.columns;
+ state.dataPreviewReady = true;
+ renderTableCreateDataPreview(state, preview);
+ updateTableCreateDialogButtons(state);
+ } catch (error) {
+ showTableCreateDialogError(
+ state,
+ error.message || "Could not preview rows.",
+ );
+ updateTableCreateDialogButtons(state);
+ }
+}
+
+function collectTableCreateDataPayload(state) {
+ var payload = {
+ table: state.tableName.value.trim(),
+ rows: state.dataPreviewRows || [],
+ };
+ var primaryKey = state.dataPkSelect
+ ? state.dataPkSelect.value
+ : TABLE_CREATE_AUTOMATIC_PK;
+ payload.pk =
+ primaryKey === TABLE_CREATE_AUTOMATIC_PK ? "id" : primaryKey || undefined;
+ return payload;
+}
+
+function validateTableCreateDataPayload(payload, state) {
+ var tableNameError = validateTableCreateTableName(payload.table);
+ if (tableNameError) {
+ return tableNameError;
+ }
+ if (!payload.rows.length) {
+ return "No rows found to create.";
+ }
+ if (
+ state.dataPkSelect &&
+ state.dataPkSelect.value === TABLE_CREATE_AUTOMATIC_PK &&
+ payload.rows.some(function (row) {
+ return Object.prototype.hasOwnProperty.call(row, "id");
+ })
+ ) {
+ return (
+ "Automatic ID column cannot be used because the pasted data " +
+ "already has an id column."
+ );
+ }
+ return null;
+}
+
function fallbackTableUrl(tableName) {
var data = databaseCreateTableData() || {};
if (!data.path) {
@@ -1441,6 +2008,57 @@ async function assignTableCreateColumnTypes(
}
}
+async function createTableFromDataPreview(state) {
+ var data = databaseCreateTableData();
+ if (!data || !data.path) {
+ showTableCreateDialogError(state, "Could not find the create table URL.");
+ return;
+ }
+ var payload = collectTableCreateDataPayload(state);
+ var validationError = validateTableCreateDataPayload(payload, state);
+ if (validationError) {
+ showTableCreateDialogError(state, validationError);
+ return;
+ }
+ clearTableCreateDialogError(state);
+ setTableCreateDialogSaving(state, true);
+ try {
+ var response = await fetch(data.path, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ Accept: "application/json",
+ },
+ body: JSON.stringify(payload),
+ });
+ var responseData = null;
+ try {
+ responseData = await response.json();
+ } catch (_error) {
+ responseData = null;
+ }
+ if (!response.ok || (responseData && responseData.ok === false)) {
+ throw rowMutationRequestError(response, responseData);
+ }
+ var tableUrl =
+ responseData.table_url ||
+ fallbackTableUrl(responseData.table || payload.table);
+ state.shouldRestoreFocus = false;
+ state.dialog.close();
+ if (tableUrl) {
+ location.href = tableUrl;
+ } else {
+ location.reload();
+ }
+ } catch (error) {
+ setTableCreateDialogSaving(state, false);
+ showTableCreateDialogError(
+ state,
+ error.message || "Could not create table",
+ );
+ }
+}
+
async function saveTableCreateDialog(state) {
if (state.isSaving) {
return;
@@ -1450,6 +2068,14 @@ async function saveTableCreateDialog(state) {
showTableCreateDialogError(state, "Could not find the create table URL.");
return;
}
+ if (tableCreateIsDataMode(state)) {
+ if (!state.dataPreviewReady) {
+ previewTableCreateDataRows(state);
+ } else {
+ await createTableFromDataPreview(state);
+ }
+ return;
+ }
clearTableCreateDialogError(state);
var payload = collectTableCreatePayload(state);
var columnTypeAssignments = collectTableCreateColumnTypeAssignments(state);
@@ -1560,8 +2186,18 @@ function ensureTableCreateDialog(manager) {
Add column
+
+
+
Paste TSV, CSV, or JSON . You can also open a file or drop it onto this textarea
+
+
+
+
+
@@ -1576,13 +2212,27 @@ function ensureTableCreateDialog(manager) {
error: dialog.querySelector(".table-create-error"),
fields: dialog.querySelector(".table-create-fields"),
tableName: dialog.querySelector(".table-create-table-name"),
+ columnsPanel: dialog.querySelector(".table-create-columns"),
columnList: dialog.querySelector(".table-create-column-list"),
addColumnButton: dialog.querySelector(".table-create-add-column"),
+ dataPanel: dialog.querySelector(".table-create-data"),
+ dataEditor: dialog.querySelector(".table-create-data-editor"),
+ dataTextarea: dialog.querySelector(".table-create-data-textarea"),
+ dataOpenFileButton: dialog.querySelector(".table-create-data-open-file"),
+ dataFileInput: dialog.querySelector(".table-create-data-file-input"),
+ dataPreview: dialog.querySelector(".table-create-data-preview"),
+ createFromDataLink: dialog.querySelector(".table-create-from-data"),
+ manualCreateLink: dialog.querySelector(".table-create-manual"),
cancelButton: dialog.querySelector(".table-create-cancel"),
saveButton: dialog.querySelector(".table-create-save"),
currentButton: null,
shouldRestoreFocus: true,
isSaving: false,
+ mode: "manual",
+ dataPreviewRows: null,
+ dataPreviewColumns: [],
+ dataPreviewReady: false,
+ dataPkSelect: null,
initialSignature: "",
nextColumnIndex: 0,
foreignKeyTargets: [],
@@ -1606,13 +2256,114 @@ function ensureTableCreateDialog(manager) {
});
tableCreateDialogState.cancelButton.addEventListener("click", function () {
+ if (
+ tableCreateIsDataMode(tableCreateDialogState) &&
+ tableCreateDialogState.dataPreviewReady &&
+ !tableCreateDialogState.isSaving
+ ) {
+ resetTableCreateDataPreview(tableCreateDialogState);
+ updateTableCreateDialogButtons(tableCreateDialogState);
+ tableCreateDialogState.dataTextarea.focus();
+ return;
+ }
closeTableCreateDialogIfConfirmed(tableCreateDialogState);
});
+ tableCreateDialogState.createFromDataLink.addEventListener(
+ "click",
+ function (ev) {
+ ev.preventDefault();
+ showTableCreateDataMode(tableCreateDialogState);
+ },
+ );
+
+ tableCreateDialogState.manualCreateLink.addEventListener(
+ "click",
+ function (ev) {
+ ev.preventDefault();
+ showTableCreateManualMode(tableCreateDialogState);
+ },
+ );
+
tableCreateDialogState.tableName.addEventListener("input", function () {
clearTableCreateDialogError(tableCreateDialogState);
});
+ tableCreateDialogState.dataOpenFileButton.addEventListener(
+ "click",
+ function () {
+ tableCreateDialogState.dataFileInput.click();
+ },
+ );
+
+ tableCreateDialogState.dataFileInput.addEventListener(
+ "change",
+ async function (ev) {
+ var files = ev.target.files;
+ await loadTableCreateDataTextFile(
+ tableCreateDialogState,
+ files && files.length ? files[0] : null,
+ );
+ ev.target.value = "";
+ },
+ );
+
+ tableCreateDialogState.dataTextarea.addEventListener(
+ "dragenter",
+ function (ev) {
+ ev.preventDefault();
+ tableCreateDialogState.dataTextarea.classList.add(
+ "table-create-data-drop-target",
+ );
+ },
+ );
+
+ tableCreateDialogState.dataTextarea.addEventListener(
+ "dragover",
+ function (ev) {
+ ev.preventDefault();
+ tableCreateDialogState.dataTextarea.classList.add(
+ "table-create-data-drop-target",
+ );
+ },
+ );
+
+ tableCreateDialogState.dataTextarea.addEventListener(
+ "dragleave",
+ function () {
+ tableCreateDialogState.dataTextarea.classList.remove(
+ "table-create-data-drop-target",
+ );
+ },
+ );
+
+ tableCreateDialogState.dataTextarea.addEventListener(
+ "drop",
+ async function (ev) {
+ ev.preventDefault();
+ tableCreateDialogState.dataTextarea.classList.remove(
+ "table-create-data-drop-target",
+ );
+ var files = ev.dataTransfer && ev.dataTransfer.files;
+ if (!files || !files.length) {
+ return;
+ }
+ await loadTableCreateDataTextFile(tableCreateDialogState, files[0]);
+ },
+ );
+
+ tableCreateDialogState.dataTextarea.addEventListener("dragend", function () {
+ tableCreateDialogState.dataTextarea.classList.remove(
+ "table-create-data-drop-target",
+ );
+ });
+
+ tableCreateDialogState.dataTextarea.addEventListener("input", function () {
+ resetTableCreateDataPreview(tableCreateDialogState);
+ clearTableCreateDialogError(tableCreateDialogState);
+ updateTableCreateDialogButtons(tableCreateDialogState);
+ });
+
dialog.addEventListener("click", function (ev) {
if (ev.target === dialog) {
closeTableCreateDialogIfConfirmed(tableCreateDialogState);
@@ -3582,6 +4333,14 @@ function tableInsertUrl() {
return url.toString();
}
+function tableUpsertUrl() {
+ var data = tableInsertData();
+ if (data && data.upsertPath) {
+ return new URL(data.upsertPath, location.href).toString();
+ }
+ return null;
+}
+
function rowResourceUrl(row) {
var rowId = row.getAttribute("data-row");
if (!rowId) {
@@ -3598,7 +4357,7 @@ function rowJsonUrl(row) {
return "";
}
url.pathname = url.pathname + ".json";
- url.searchParams.set("_extra", "columns,column_types");
+ url.searchParams.set("_extra", "columns,column_types,column_details");
return url.toString();
}
@@ -3878,10 +4637,155 @@ function initRowDeleteActions(manager) {
});
}
+function isBase64JsonValue(value) {
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
+ return false;
+ }
+ var keys = Object.keys(value);
+ return (
+ keys.length === 2 &&
+ Object.prototype.hasOwnProperty.call(value, "$base64") &&
+ Object.prototype.hasOwnProperty.call(value, "encoded") &&
+ value.$base64 === true &&
+ typeof value.encoded === "string"
+ );
+}
+
+function shouldUseBinaryControl(value, options) {
+ options = options || {};
+ var sqliteType = (options.sqliteType || "").toLowerCase();
+ return (
+ isBase64JsonValue(value) ||
+ sqliteType === "blob" ||
+ options.valueKind === "binary"
+ );
+}
+
+function binaryEncodedValue(value) {
+ return isBase64JsonValue(value) ? value.encoded : "";
+}
+
+function binaryByteLengthFromBase64(encoded) {
+ encoded = (encoded || "").replace(/\s/g, "");
+ if (!encoded) {
+ return 0;
+ }
+ var padding = 0;
+ if (encoded.slice(-2) === "==") {
+ padding = 2;
+ } else if (encoded.slice(-1) === "=") {
+ padding = 1;
+ }
+ return Math.max(0, Math.floor((encoded.length * 3) / 4) - padding);
+}
+
+function rowEditBinaryValue(encoded) {
+ return {
+ $base64: true,
+ encoded: encoded || "",
+ };
+}
+
+function formatRowEditBinarySize(byteLength) {
+ var number = byteLength.toLocaleString();
+ return "Binary: " + number + " byte" + (byteLength === 1 ? "" : "s");
+}
+
+function base64ToUint8Array(encoded) {
+ var binary = window.atob(encoded || "");
+ var bytes = new Uint8Array(binary.length);
+ for (var i = 0; i < binary.length; i += 1) {
+ bytes[i] = binary.charCodeAt(i);
+ }
+ return bytes;
+}
+
+function uint8ArrayToBase64(bytes) {
+ var chunks = [];
+ var chunkSize = 0x8000;
+ for (var i = 0; i < bytes.length; i += chunkSize) {
+ chunks.push(
+ String.fromCharCode.apply(null, bytes.subarray(i, i + chunkSize)),
+ );
+ }
+ return window.btoa(chunks.join(""));
+}
+
+function rowEditBinaryImageMimeType(bytes) {
+ if (!bytes || !bytes.length) {
+ return null;
+ }
+ if (
+ bytes.length >= 8 &&
+ bytes[0] === 0x89 &&
+ bytes[1] === 0x50 &&
+ bytes[2] === 0x4e &&
+ bytes[3] === 0x47 &&
+ bytes[4] === 0x0d &&
+ bytes[5] === 0x0a &&
+ bytes[6] === 0x1a &&
+ bytes[7] === 0x0a
+ ) {
+ return "image/png";
+ }
+ if (
+ bytes.length >= 3 &&
+ bytes[0] === 0xff &&
+ bytes[1] === 0xd8 &&
+ bytes[2] === 0xff
+ ) {
+ return "image/jpeg";
+ }
+ if (
+ bytes.length >= 6 &&
+ bytes[0] === 0x47 &&
+ bytes[1] === 0x49 &&
+ bytes[2] === 0x46 &&
+ bytes[3] === 0x38 &&
+ (bytes[4] === 0x37 || bytes[4] === 0x39) &&
+ bytes[5] === 0x61
+ ) {
+ return "image/gif";
+ }
+ if (
+ bytes.length >= 12 &&
+ bytes[0] === 0x52 &&
+ bytes[1] === 0x49 &&
+ bytes[2] === 0x46 &&
+ bytes[3] === 0x46 &&
+ bytes[8] === 0x57 &&
+ bytes[9] === 0x45 &&
+ bytes[10] === 0x42 &&
+ bytes[11] === 0x50
+ ) {
+ return "image/webp";
+ }
+ if (
+ bytes.length >= 12 &&
+ bytes[4] === 0x66 &&
+ bytes[5] === 0x74 &&
+ bytes[6] === 0x79 &&
+ bytes[7] === 0x70 &&
+ bytes[8] === 0x61 &&
+ bytes[9] === 0x76 &&
+ bytes[10] === 0x69 &&
+ (bytes[11] === 0x66 || bytes[11] === 0x73)
+ ) {
+ return "image/avif";
+ }
+ if (bytes.length >= 2 && bytes[0] === 0x42 && bytes[1] === 0x4d) {
+ return "image/bmp";
+ }
+ return null;
+}
+
function valueToEditText(value) {
if (value === null || typeof value === "undefined") {
return "";
}
+ if (isBase64JsonValue(value)) {
+ return value.encoded;
+ }
if (typeof value === "object") {
return JSON.stringify(value, null, 2);
}
@@ -3892,6 +4796,9 @@ function shouldUseTextarea(value, columnType) {
if (columnType && columnType.type === "textarea") {
return true;
}
+ if (isBase64JsonValue(value)) {
+ return false;
+ }
if (value && typeof value === "object") {
return true;
}
@@ -3900,6 +4807,9 @@ function shouldUseTextarea(value, columnType) {
}
function rowEditValueKind(value) {
+ if (isBase64JsonValue(value)) {
+ return "binary";
+ }
if (value === null || typeof value === "undefined") {
return "null";
}
@@ -3923,6 +4833,261 @@ function rowEditControlElement(control, autocompleteUrl) {
return autocomplete;
}
+function revokeRowEditBinaryPreview(wrapper) {
+ if (wrapper && wrapper._rowEditBinaryPreviewUrl) {
+ URL.revokeObjectURL(wrapper._rowEditBinaryPreviewUrl);
+ wrapper._rowEditBinaryPreviewUrl = null;
+ }
+}
+
+function updateRowEditBinaryPreview(wrapper, encoded, byteLength) {
+ var preview = wrapper.querySelector(".row-edit-binary-preview");
+ if (!preview) {
+ return;
+ }
+ revokeRowEditBinaryPreview(wrapper);
+ preview.hidden = true;
+ preview.textContent = "";
+ if (
+ !encoded ||
+ byteLength >= ROW_EDIT_BINARY_IMAGE_PREVIEW_MAX_BYTES ||
+ !window.atob ||
+ !window.Blob ||
+ !window.URL ||
+ !URL.createObjectURL
+ ) {
+ return;
+ }
+
+ var bytes;
+ try {
+ bytes = base64ToUint8Array(encoded);
+ } catch (_error) {
+ return;
+ }
+ var mimeType = rowEditBinaryImageMimeType(bytes);
+ if (!mimeType) {
+ return;
+ }
+
+ var image = document.createElement("img");
+ image.alt = "";
+ var objectUrl = URL.createObjectURL(new Blob([bytes], { type: mimeType }));
+ wrapper._rowEditBinaryPreviewUrl = objectUrl;
+
+ var showPreview = function () {
+ if (wrapper._rowEditBinaryPreviewUrl === objectUrl) {
+ preview.hidden = false;
+ }
+ };
+ var hidePreview = function () {
+ if (wrapper._rowEditBinaryPreviewUrl === objectUrl) {
+ revokeRowEditBinaryPreview(wrapper);
+ preview.hidden = true;
+ preview.textContent = "";
+ }
+ };
+ image.onload = showPreview;
+ image.onerror = hidePreview;
+ image.src = objectUrl;
+ preview.appendChild(image);
+}
+
+function updateRowEditBinaryDisplay(wrapper, control, fileName) {
+ var kind = rowEditControlValueKind(control);
+ var size = wrapper.querySelector(".row-edit-binary-size");
+ var name = wrapper.querySelector(".row-edit-binary-name");
+ var clear = wrapper.querySelector(".row-edit-binary-clear");
+ var byteLength =
+ kind === "binary" ? binaryByteLengthFromBase64(control.value) : null;
+
+ if (size) {
+ size.textContent =
+ kind === "binary"
+ ? formatRowEditBinarySize(byteLength)
+ : "No binary data";
+ }
+ if (name) {
+ name.textContent = fileName || "";
+ name.hidden = !fileName;
+ }
+ if (clear) {
+ clear.hidden = control.dataset.notNull === "1" || kind !== "binary";
+ }
+ if (kind === "binary") {
+ updateRowEditBinaryPreview(wrapper, control.value, byteLength);
+ } else {
+ updateRowEditBinaryPreview(wrapper, "", 0);
+ }
+}
+
+function setRowEditBinaryControlValue(control, encoded, fileName) {
+ control.value = encoded || "";
+ control.dataset.currentValueKind = "binary";
+ updateRowEditBinaryDisplay(control._rowEditBinaryWrapper, control, fileName);
+ control.dispatchEvent(new Event("input", { bubbles: true }));
+}
+
+function clearRowEditBinaryControlValue(control) {
+ control.value = "";
+ control.dataset.currentValueKind = "null";
+ updateRowEditBinaryDisplay(control._rowEditBinaryWrapper, control, "");
+ control.dispatchEvent(new Event("input", { bubbles: true }));
+}
+
+function readRowEditBinaryFile(file) {
+ return new Promise(function (resolve, reject) {
+ var reader = new FileReader();
+ reader.onload = function () {
+ try {
+ var bytes = new Uint8Array(reader.result || new ArrayBuffer(0));
+ resolve({
+ encoded: uint8ArrayToBase64(bytes),
+ name: file && file.name ? file.name : "",
+ });
+ } catch (error) {
+ reject(error);
+ }
+ };
+ reader.onerror = function () {
+ reject(reader.error || new Error("Could not read file"));
+ };
+ reader.readAsArrayBuffer(file);
+ });
+}
+
+function rowEditBinaryValueFromText(text) {
+ var encoder = new TextEncoder();
+ var bytes = encoder.encode(text || "");
+ return {
+ encoded: uint8ArrayToBase64(bytes),
+ name: "Pasted text",
+ };
+}
+
+function rowEditBinaryFirstClipboardFile(clipboardData) {
+ if (!clipboardData) {
+ return null;
+ }
+ if (clipboardData.files && clipboardData.files.length) {
+ return clipboardData.files[0];
+ }
+ var items = clipboardData.items || [];
+ for (var i = 0; i < items.length; i += 1) {
+ if (items[i].kind === "file") {
+ return items[i].getAsFile();
+ }
+ }
+ return null;
+}
+
+function handleRowEditBinaryFile(control, file) {
+ if (!file) {
+ return;
+ }
+ readRowEditBinaryFile(file)
+ .then(function (value) {
+ setRowEditBinaryControlValue(control, value.encoded, value.name);
+ })
+ .catch(function (error) {
+ console.error("Could not read binary file", error);
+ });
+}
+
+function createRowEditBinaryControlElement(control, value, options, labelId) {
+ var wrapper = document.createElement("div");
+ wrapper.className = "row-edit-binary-control";
+ wrapper.dataset.column = control.name;
+ wrapper.setAttribute("role", "group");
+ wrapper.setAttribute("tabindex", "0");
+ wrapper.setAttribute("aria-labelledby", labelId);
+
+ var preview = document.createElement("div");
+ preview.className = "row-edit-binary-preview";
+ preview.hidden = true;
+
+ var status = document.createElement("div");
+ status.className = "row-edit-binary-status";
+ var size = document.createElement("span");
+ size.className = "row-edit-binary-size";
+ var name = document.createElement("span");
+ name.className = "row-edit-binary-name";
+ name.hidden = true;
+ status.appendChild(size);
+ status.appendChild(name);
+
+ var actions = document.createElement("div");
+ actions.className = "row-edit-binary-actions";
+ var fileLabel = document.createElement("label");
+ fileLabel.className = "row-edit-binary-file-button";
+ fileLabel.textContent = "Attach file";
+ var fileInput = document.createElement("input");
+ fileInput.type = "file";
+ fileInput.setAttribute("aria-label", "Attach file for " + control.name);
+ fileInput.addEventListener("change", function () {
+ if (fileInput.files && fileInput.files.length) {
+ handleRowEditBinaryFile(control, fileInput.files[0]);
+ }
+ });
+ fileLabel.appendChild(fileInput);
+ actions.appendChild(fileLabel);
+
+ var clearButton = document.createElement("button");
+ clearButton.type = "button";
+ clearButton.className = "row-edit-binary-clear";
+ clearButton.textContent = "Set NULL";
+ clearButton.addEventListener("click", function () {
+ clearRowEditBinaryControlValue(control);
+ wrapper.focus();
+ });
+ actions.appendChild(clearButton);
+
+ var dropTarget = document.createElement("div");
+ dropTarget.className = "row-edit-binary-drop-target";
+ dropTarget.textContent = "Drop or paste file contents";
+ ["dragenter", "dragover"].forEach(function (eventName) {
+ wrapper.addEventListener(eventName, function (ev) {
+ ev.preventDefault();
+ wrapper.classList.add("row-edit-binary-dragover");
+ });
+ });
+ ["dragleave", "drop"].forEach(function (eventName) {
+ wrapper.addEventListener(eventName, function () {
+ wrapper.classList.remove("row-edit-binary-dragover");
+ });
+ });
+ wrapper.addEventListener("drop", function (ev) {
+ ev.preventDefault();
+ var file = ev.dataTransfer && ev.dataTransfer.files[0];
+ handleRowEditBinaryFile(control, file);
+ });
+ wrapper.addEventListener("paste", function (ev) {
+ var file = rowEditBinaryFirstClipboardFile(ev.clipboardData);
+ if (file) {
+ ev.preventDefault();
+ handleRowEditBinaryFile(control, file);
+ return;
+ }
+ var text = ev.clipboardData && ev.clipboardData.getData("text");
+ if (text) {
+ ev.preventDefault();
+ var pasted = rowEditBinaryValueFromText(text);
+ setRowEditBinaryControlValue(control, pasted.encoded, pasted.name);
+ }
+ });
+
+ control.type = "hidden";
+ control._rowEditBinaryWrapper = wrapper;
+ wrapper.appendChild(control);
+ wrapper.appendChild(preview);
+ wrapper.appendChild(status);
+ wrapper.appendChild(actions);
+ wrapper.appendChild(dropTarget);
+
+ updateRowEditBinaryDisplay(wrapper, control, "");
+ return wrapper;
+}
+
function columnTypeForContext(columnType) {
if (!columnType) {
return null;
@@ -4186,6 +5351,11 @@ function focusFirstRowEditControl(state, options) {
if (focusRowEditPluginControl(field)) {
return true;
}
+ var binaryControl = field.querySelector(".row-edit-binary-control");
+ if (binaryControl) {
+ binaryControl.focus();
+ return true;
+ }
control.focus();
return true;
}
@@ -4209,6 +5379,11 @@ function destroyRowEditFields(state) {
}
}
});
+ state.fields
+ .querySelectorAll(".row-edit-binary-control")
+ .forEach(function (binaryControl) {
+ revokeRowEditBinaryPreview(binaryControl);
+ });
state.fields.innerHTML = "";
}
@@ -4235,34 +5410,50 @@ function createRowEditField(column, value, isPk, columnType, index, options) {
controlWrap.className = "row-edit-control-wrap";
var context = columnFormControlContext(column, isPk, columnType, options);
- var pluginControl = makeColumnField(options.manager, context);
+ var isBinaryField = shouldUseBinaryControl(value, options);
+ var pluginControl = isBinaryField
+ ? null
+ : makeColumnField(options.manager, context);
var useTextarea =
- (pluginControl && pluginControl.useTextarea === true) ||
- shouldUseTextarea(value, columnType);
+ !isBinaryField &&
+ ((pluginControl && pluginControl.useTextarea === true) ||
+ shouldUseTextarea(value, columnType));
var control = useTextarea
? document.createElement("textarea")
: document.createElement("input");
+ var initialValue = isBinaryField
+ ? binaryEncodedValue(value)
+ : valueToEditText(value);
+ var initialValueKind = options.valueKind || rowEditValueKind(value);
+ if (isBinaryField) {
+ initialValueKind = isBase64JsonValue(value) ? "binary" : "null";
+ }
control.className = "row-edit-input";
control.id = fieldId;
control.name = column;
- control.value = valueToEditText(value);
+ control.value = initialValue;
control.setAttribute("aria-describedby", metaId);
- control.dataset.initialValue = valueToEditText(value);
- control.dataset.initialValueKind =
- options.valueKind || rowEditValueKind(value);
+ control.dataset.initialValue = initialValue;
+ control.dataset.initialValueKind = initialValueKind;
control.dataset.primaryKey = isPk ? "1" : "0";
control.dataset.currentValueKind = control.dataset.initialValueKind;
+ if (isBinaryField) {
+ control.dataset.binaryField = "1";
+ control.dataset.notNull = options.notnull ? "1" : "0";
+ }
if (hasDefaultExpression) {
control.dataset.useSqliteDefault = useSqliteDefault ? "1" : "0";
}
if (useSqliteDefault) {
control.disabled = true;
}
- if (options.omitIfBlank) {
+ if (options.omitIfBlank || (isBinaryField && options.mode === "insert")) {
control.dataset.omitIfBlank = "1";
}
- if (control.nodeName === "TEXTAREA") {
+ if (isBinaryField) {
+ control.type = "hidden";
+ } else if (control.nodeName === "TEXTAREA") {
control.rows = Math.min(8, Math.max(3, control.value.split("\n").length));
} else {
control.type = "text";
@@ -4336,9 +5527,11 @@ function createRowEditField(column, value, isPk, columnType, index, options) {
field._datasetteColumnFormField = fieldApi;
var pluginControlElement = renderColumnField(pluginControl, fieldApi);
var controlElement =
+ (isBinaryField &&
+ createRowEditBinaryControlElement(control, value, options, labelId)) ||
pluginControlElement ||
rowEditControlElement(control, options.autocompleteUrl);
- if (options.autocompleteUrl && !pluginControlElement) {
+ if (options.autocompleteUrl && !pluginControlElement && !isBinaryField) {
control.addEventListener("input", function () {
setForeignKeyMetaLink(meta, options.autocompleteUrl, null);
});
@@ -4429,18 +5622,64 @@ function clearRowEditDialogError(state) {
state.error.textContent = "";
}
-function showRowEditDialogError(state, message) {
+function showRowEditDialogError(state, message, options) {
state.error.hidden = false;
state.error.textContent = message;
- state.error.focus();
+ if (!options || options.focus !== false) {
+ state.error.focus();
+ }
+}
+
+function rowEditIsMultipleInsert(state) {
+ return state.mode === "insert" && state.insertMode === "multiple";
+}
+
+function syncRowEditInsertModeUi(state) {
+ var isInsert = state.mode === "insert";
+ var isMultiple = rowEditIsMultipleInsert(state);
+ state.fields.hidden = isMultiple;
+ state.bulkInsertPanel.hidden = !isMultiple;
+ state.bulkInsertEditor.hidden = !isMultiple || state.bulkInsertPreviewReady;
+ state.bulkInsertPreview.hidden = !isMultiple || !state.bulkInsertPreviewReady;
+ state.bulkInsertLink.hidden = !isInsert || isMultiple;
+ state.singleInsertLink.hidden = !isInsert || !isMultiple;
}
function updateRowEditDialogButtons(state) {
state.saveButton.disabled =
- state.isLoading || state.isSaving || !state.hasLoaded;
+ state.isLoading ||
+ state.isSaving ||
+ !state.hasLoaded ||
+ state.bulkInsertInserted ||
+ (rowEditIsMultipleInsert(state) &&
+ !state.bulkInsertPreviewReady &&
+ !!state.bulkInsertLiveValidationError);
state.cancelButton.disabled = state.isSaving;
- var saveLabel = state.mode === "insert" ? "Insert row" : "Save";
- state.saveButton.textContent = state.isSaving ? "Saving..." : saveLabel;
+ syncRowEditInsertModeUi(state);
+ state.cancelButton.textContent =
+ rowEditIsMultipleInsert(state) &&
+ state.bulkInsertPreviewReady &&
+ !state.bulkInsertInserted
+ ? "Back"
+ : state.bulkInsertInserted
+ ? "Close and view table"
+ : "Cancel";
+ var saveLabel = rowEditIsMultipleInsert(state)
+ ? state.bulkInsertInserted
+ ? "Inserted"
+ : state.bulkInsertPreviewReady
+ ? bulkInsertSaveLabel(state)
+ : "Preview rows"
+ : state.mode === "insert"
+ ? "Insert row"
+ : "Save";
+ state.saveButton.textContent = state.isSaving
+ ? rowEditIsMultipleInsert(state)
+ ? bulkInsertConflictMode(state) === "upsert"
+ ? "Updating..."
+ : "Inserting..."
+ : "Saving..."
+ : saveLabel;
state.form.setAttribute(
"aria-busy",
state.isLoading || state.isSaving ? "true" : "false",
@@ -4460,6 +5699,9 @@ function setRowEditDialogSaving(state, isSaving) {
function valueFromRowEditControl(control) {
var value = control.value;
+ if (rowEditControlValueKind(control) === "binary") {
+ return rowEditBinaryValue(value);
+ }
return valueFromRowEditText(
control.name,
value,
@@ -4470,6 +5712,9 @@ function valueFromRowEditControl(control) {
function valueFromRowEditText(name, value, initialValueKind) {
var trimmed = value.trim();
+ if (initialValueKind === "binary") {
+ return rowEditBinaryValue(value);
+ }
if (initialValueKind === "null" && value === "") {
return null;
}
@@ -4597,7 +5842,11 @@ function collectRowFormValues(state) {
if (control.dataset.useSqliteDefault === "1") {
return;
}
- if (control.dataset.omitIfBlank === "1" && control.value === "") {
+ if (
+ control.dataset.omitIfBlank === "1" &&
+ control.value === "" &&
+ rowEditControlValueKind(control) !== "binary"
+ ) {
return;
}
if (
@@ -4631,6 +5880,16 @@ function rowEditDialogHasChanges(state) {
if (!state || !state.hasLoaded || state.isLoading) {
return false;
}
+ if (state.bulkInsertInserted) {
+ return false;
+ }
+ if (
+ state.mode === "insert" &&
+ state.bulkInsertTextarea &&
+ state.bulkInsertTextarea.value.trim()
+ ) {
+ return true;
+ }
var fields = state.fields.querySelectorAll(".row-edit-field");
for (var i = 0; i < fields.length; i += 1) {
var fieldApi = fields[i]._datasetteColumnFormField;
@@ -4664,6 +5923,831 @@ function closeRowEditDialogIfConfirmed(state) {
return true;
}
+function setRowInsertDialogTitle(state) {
+ var insertData = tableInsertData() || {};
+ var title = rowEditIsMultipleInsert(state)
+ ? "Insert multiple rows"
+ : "Insert row";
+ setRowDialogTitle(
+ state.title,
+ insertData.tableName ? title + " into " + insertData.tableName : title,
+ );
+}
+
+function showMultipleRowInsert(state) {
+ if (!state || state.mode !== "insert" || state.isSaving) {
+ return;
+ }
+ state.insertMode = "multiple";
+ if (state.bulkInsertPreviewReady || state.bulkInsertInserted) {
+ resetBulkInsertPreview(state);
+ }
+ clearRowEditDialogError(state);
+ setRowInsertDialogTitle(state);
+ syncBulkInsertConflictUi(state);
+ syncBulkInsertTextareaValidation(state);
+ updateRowEditDialogButtons(state);
+ state.bulkInsertTextarea.focus();
+}
+
+function showSingleRowInsert(state) {
+ if (!state || state.mode !== "insert" || state.isSaving) {
+ return;
+ }
+ state.insertMode = "single";
+ clearRowEditDialogError(state);
+ setRowInsertDialogTitle(state);
+ updateRowEditDialogButtons(state);
+ if (!focusFirstRowEditControl(state, { skipReadonly: true })) {
+ state.saveButton.focus();
+ }
+}
+
+function bulkInsertConflictMode(state) {
+ if (!state || !state.bulkInsertHasPrimaryKeyColumns) {
+ return "insert";
+ }
+ return state.bulkInsertConflictMode || "ignore";
+}
+
+function syncBulkInsertConflictUi(state) {
+ if (!state || !state.bulkInsertConflictField) {
+ return;
+ }
+ var insertData = tableInsertData() || {};
+ var primaryKeys = insertData.primaryKeys || [];
+ var hasPrimaryKeys = primaryKeys.length > 0;
+ var hasPrimaryKeyColumns =
+ hasPrimaryKeys &&
+ bulkInsertTextIncludesPrimaryKeyColumns(
+ state.bulkInsertTextarea.value,
+ state.bulkInsertColumnDetails,
+ primaryKeys,
+ );
+ state.bulkInsertHasPrimaryKeyColumns = hasPrimaryKeyColumns;
+ var canUpsert = hasPrimaryKeyColumns && !!state.currentUpsertUrl;
+ var upsertOption = state.bulkInsertConflictSelect.querySelector(
+ 'option[value="upsert"]',
+ );
+ if (upsertOption) {
+ upsertOption.disabled = !canUpsert;
+ upsertOption.hidden = !canUpsert;
+ }
+ if (
+ !hasPrimaryKeyColumns ||
+ (!canUpsert && bulkInsertConflictMode(state) === "upsert")
+ ) {
+ state.bulkInsertConflictMode = hasPrimaryKeys ? "ignore" : "insert";
+ state.bulkInsertConflictSelect.value = state.bulkInsertConflictMode;
+ }
+ state.bulkInsertConflictField.hidden = !hasPrimaryKeyColumns;
+ state.bulkInsertConflictSelect.value = bulkInsertConflictMode(state);
+ var helpText = "";
+ if (bulkInsertConflictMode(state) === "upsert") {
+ helpText =
+ "Rows with existing primary keys will be updated; new primary keys will be inserted.";
+ } else if (bulkInsertConflictMode(state) === "ignore") {
+ helpText = "Rows with existing primary keys will be skipped.";
+ } else {
+ helpText = "Rows with existing primary keys will stop the import.";
+ }
+ state.bulkInsertConflictHelp.textContent = helpText;
+}
+
+function bulkInsertSaveLabel(state) {
+ if (!state.bulkInsertPreviewReady) {
+ return "Preview rows";
+ }
+ if (bulkInsertConflictMode(state) === "upsert") {
+ return "Update or insert rows";
+ }
+ return "Insert these rows";
+}
+
+function readTextFile(file) {
+ if (file.text) {
+ return file.text();
+ }
+ return new Promise(function (resolve, reject) {
+ var reader = new FileReader();
+ reader.onload = function () {
+ resolve(reader.result || "");
+ };
+ reader.onerror = function () {
+ reject(reader.error);
+ };
+ reader.readAsText(file);
+ });
+}
+
+async function loadBulkInsertTextFile(state, file) {
+ if (!file) {
+ return;
+ }
+ try {
+ state.bulkInsertTextarea.value = await readTextFile(file);
+ state.bulkInsertTextarea.dispatchEvent(
+ new Event("input", { bubbles: true }),
+ );
+ state.bulkInsertTextarea.focus();
+ } catch (_error) {
+ showRowEditDialogError(state, "Could not read that text file.");
+ }
+}
+
+function bulkInsertTemplateText(state) {
+ return (state.bulkInsertTemplateColumns || []).join("\t");
+}
+
+async function copyTextToClipboard(text) {
+ if (navigator.clipboard && navigator.clipboard.writeText) {
+ await navigator.clipboard.writeText(text);
+ return;
+ }
+ var textarea = document.createElement("textarea");
+ textarea.value = text;
+ textarea.setAttribute("readonly", "");
+ textarea.style.position = "fixed";
+ textarea.style.top = "-1000px";
+ textarea.style.left = "-1000px";
+ document.body.appendChild(textarea);
+ textarea.select();
+ var copied = document.execCommand("copy");
+ textarea.remove();
+ if (!copied) {
+ throw new Error("copy failed");
+ }
+}
+
+function setBulkInsertCopyButtonReady(state) {
+ state.copyTemplateButton.textContent = "";
+ var wideLabel = document.createElement("span");
+ wideLabel.className = "row-edit-copy-template-label-wide";
+ wideLabel.textContent = "Copy spreadsheet template";
+ state.copyTemplateButton.appendChild(wideLabel);
+ var narrowLabel = document.createElement("span");
+ narrowLabel.className = "row-edit-copy-template-label-narrow";
+ narrowLabel.textContent = "Copy template";
+ state.copyTemplateButton.appendChild(narrowLabel);
+}
+
+function setBulkInsertCopyButtonCopied(state) {
+ state.copyTemplateButton.textContent = "Copied";
+ clearTimeout(state.copyTemplateResetTimer);
+ state.copyTemplateResetTimer = setTimeout(function () {
+ setBulkInsertCopyButtonReady(state);
+ }, 1500);
+}
+
+function resetBulkInsertPreview(state) {
+ state.bulkInsertPreviewRows = null;
+ state.bulkInsertPreviewReady = false;
+ state.bulkInsertInserted = false;
+ state.bulkInsertInsertedCount = 0;
+ state.bulkInsertPreview.hidden = true;
+ state.bulkInsertPreview.textContent = "";
+ state.bulkInsertProgress.hidden = true;
+ state.bulkInsertProgressBar.value = 0;
+ state.bulkInsertProgressBar.max = 1;
+ state.bulkInsertProgressStatus.textContent = "";
+ syncBulkInsertConflictUi(state);
+ syncRowEditInsertModeUi(state);
+}
+
+function normalizeBulkInsertCell(column, value) {
+ if (typeof value === "undefined") {
+ return column.notnull ? "" : null;
+ }
+ if (value === null) {
+ return column.notnull ? "" : null;
+ }
+ if (value === "" && column.notnull) {
+ return "";
+ }
+ if (column.value_kind === "number" && typeof value === "string") {
+ return valueFromRowEditText(column.name, value, "number");
+ }
+ return value;
+}
+
+function rowObjectForBulkInsert(valuesByColumn, columns) {
+ var row = {};
+ columns.forEach(function (column) {
+ var hasValue = Object.prototype.hasOwnProperty.call(
+ valuesByColumn,
+ column.name,
+ );
+ if (!hasValue) {
+ return;
+ }
+ row[column.name] = normalizeBulkInsertCell(
+ column,
+ valuesByColumn[column.name],
+ );
+ });
+ return row;
+}
+
+function splitDelimitedRows(text, delimiter) {
+ var rows = [];
+ var row = [];
+ var cell = "";
+ var inQuotes = false;
+
+ for (var i = 0; i < text.length; i += 1) {
+ var character = text[i];
+ if (inQuotes) {
+ if (character === '"') {
+ if (text[i + 1] === '"') {
+ cell += '"';
+ i += 1;
+ } else {
+ inQuotes = false;
+ }
+ } else {
+ cell += character;
+ }
+ continue;
+ }
+
+ if (character === '"') {
+ inQuotes = true;
+ } else if (character === delimiter) {
+ row.push(cell);
+ cell = "";
+ } else if (character === "\n" || character === "\r") {
+ row.push(cell);
+ rows.push(row);
+ row = [];
+ cell = "";
+ if (character === "\r" && text[i + 1] === "\n") {
+ i += 1;
+ }
+ } else {
+ cell += character;
+ }
+ }
+
+ if (inQuotes) {
+ throw new Error("Unclosed quoted value.");
+ }
+ row.push(cell);
+ rows.push(row);
+
+ while (rows.length && bulkInsertDelimitedRowIsBlank(rows[rows.length - 1])) {
+ rows.pop();
+ }
+ return rows;
+}
+
+function bulkInsertDelimitedRowIsBlank(row) {
+ return row.every(function (value) {
+ return value.trim() === "";
+ });
+}
+
+function delimiterPreviewRows(text, delimiter) {
+ try {
+ return splitDelimitedRows(text, delimiter);
+ } catch (_error) {
+ return [];
+ }
+}
+
+function splitSingleColumnRows(text) {
+ var rows = text.split(/\r\n|\n|\r/).map(function (line) {
+ return [line];
+ });
+ while (rows.length && bulkInsertDelimitedRowIsBlank(rows[rows.length - 1])) {
+ rows.pop();
+ }
+ return rows;
+}
+
+function detectBulkInsertDelimiter(text, columns) {
+ var firstLine =
+ text.split(/\r\n|\n|\r/).find(function (line) {
+ return line.trim() !== "";
+ }) || "";
+ var csvRows = delimiterPreviewRows(firstLine, ",");
+ var tsvRows = delimiterPreviewRows(firstLine, "\t");
+ var csvColumns = csvRows.length ? csvRows[0].length : 0;
+ var tsvColumns = tsvRows.length ? tsvRows[0].length : 0;
+
+ if (firstLine.indexOf("\t") !== -1 && firstLine.indexOf(",") === -1) {
+ return "\t";
+ }
+ if (tsvColumns > csvColumns) {
+ return "\t";
+ }
+ if (csvColumns > 1) {
+ return ",";
+ }
+ if (tsvColumns > 1) {
+ return "\t";
+ }
+ if (columns.length === 1 || bulkInsertColumnMap(columns)[firstLine.trim()]) {
+ return null;
+ }
+ throw new Error("Could not detect CSV or TSV columns.");
+}
+
+function bulkInsertColumnMap(columns) {
+ var map = {};
+ columns.forEach(function (column) {
+ map[column.name] = column;
+ });
+ return map;
+}
+
+function bulkInsertTextIncludesPrimaryKeyColumns(text, columns, primaryKeys) {
+ if (!primaryKeys.length || !text.trim()) {
+ return false;
+ }
+ var trimmed = text.trim();
+ try {
+ if (trimmed[0] === "[" || trimmed[0] === "{") {
+ return jsonBulkInsertTextIncludesPrimaryKeyColumns(trimmed, primaryKeys);
+ }
+ return delimitedBulkInsertTextIncludesPrimaryKeyColumns(
+ trimmed,
+ columns,
+ primaryKeys,
+ );
+ } catch (_error) {
+ return false;
+ }
+}
+
+function jsonBulkInsertTextIncludesPrimaryKeyColumns(text, primaryKeys) {
+ var rows = parseJsonObjectRows(text);
+ var seenKeys = {};
+ rows.forEach(function (row) {
+ Object.keys(row).forEach(function (key) {
+ seenKeys[key] = true;
+ });
+ });
+ return primaryKeys.every(function (key) {
+ return !!seenKeys[key];
+ });
+}
+
+function delimitedBulkInsertTextIncludesPrimaryKeyColumns(
+ text,
+ columns,
+ primaryKeys,
+) {
+ var delimiter = detectBulkInsertDelimiter(text, columns);
+ var rows = (
+ delimiter === null
+ ? splitSingleColumnRows(text)
+ : splitDelimitedRows(text, delimiter)
+ ).filter(function (row) {
+ return !bulkInsertDelimitedRowIsBlank(row);
+ });
+ if (!rows.length) {
+ return false;
+ }
+
+ var columnMap = bulkInsertColumnMap(columns);
+ var header = rows[0].map(function (value) {
+ return value.trim();
+ });
+ var headerMatches = header.filter(function (name) {
+ return !!columnMap[name];
+ }).length;
+ if (headerMatches > 0) {
+ return primaryKeys.every(function (key) {
+ return header.indexOf(key) !== -1;
+ });
+ }
+
+ var headers = columns.map(function (column) {
+ return column.name;
+ });
+ var suppliedColumnCount = rows.reduce(function (count, row) {
+ return Math.max(count, row.length);
+ }, 0);
+ return primaryKeys.every(function (key) {
+ var index = headers.indexOf(key);
+ return index !== -1 && index < suppliedColumnCount;
+ });
+}
+
+function bulkInsertLiveValidationShouldWait(message) {
+ return (
+ message === "Paste rows before previewing." ||
+ message === "No data rows found to preview." ||
+ message.indexOf("Invalid JSON:") === 0
+ );
+}
+
+function bulkInsertLiveValidationError(state) {
+ var text = state.bulkInsertTextarea.value;
+ if (!text.trim()) {
+ return null;
+ }
+ try {
+ parseBulkInsertRows(text, state.bulkInsertColumnDetails);
+ } catch (error) {
+ var message = error.message || "Could not preview rows.";
+ return bulkInsertLiveValidationShouldWait(message) ? null : message;
+ }
+ return null;
+}
+
+function syncBulkInsertTextareaValidation(state) {
+ if (!rowEditIsMultipleInsert(state) || state.bulkInsertPreviewReady) {
+ state.bulkInsertLiveValidationError = null;
+ return;
+ }
+ state.bulkInsertLiveValidationError = bulkInsertLiveValidationError(state);
+ if (state.bulkInsertLiveValidationError) {
+ showRowEditDialogError(state, state.bulkInsertLiveValidationError, {
+ focus: false,
+ });
+ } else {
+ clearRowEditDialogError(state);
+ }
+}
+
+function parseJsonBulkInsertRows(text, columns) {
+ var parsed = parseJsonObjectRows(text);
+
+ var columnMap = bulkInsertColumnMap(columns);
+ return parsed.map(function (item, index) {
+ if (!item || typeof item !== "object" || Array.isArray(item)) {
+ throw new Error("JSON row " + (index + 1) + " must be an object.");
+ }
+ Object.keys(item).forEach(function (key) {
+ if (!columnMap[key]) {
+ throw new Error(
+ "JSON row " + (index + 1) + " has unknown column " + key + ".",
+ );
+ }
+ });
+ return rowObjectForBulkInsert(item, columns);
+ });
+}
+
+function parseDelimitedBulkInsertRows(text, columns) {
+ var delimiter = detectBulkInsertDelimiter(text, columns);
+ var rows = (
+ delimiter === null
+ ? splitSingleColumnRows(text)
+ : splitDelimitedRows(text, delimiter)
+ ).filter(function (row) {
+ return !bulkInsertDelimitedRowIsBlank(row);
+ });
+ if (!rows.length) {
+ throw new Error("No rows found to preview.");
+ }
+
+ var columnMap = bulkInsertColumnMap(columns);
+ var header = rows[0].map(function (value) {
+ return value.trim();
+ });
+ var headerMatches = header.filter(function (name) {
+ return !!columnMap[name];
+ }).length;
+ var hasHeader = headerMatches > 0;
+ var dataRows = hasHeader ? rows.slice(1) : rows;
+ var headers = hasHeader
+ ? header
+ : columns.map(function (column) {
+ return column.name;
+ });
+ var seenHeaders = {};
+
+ if (hasHeader) {
+ headers.forEach(function (name) {
+ if (!name) {
+ return;
+ }
+ if (!columnMap[name]) {
+ throw new Error("Unknown column " + name + " in header row.");
+ }
+ if (seenHeaders[name]) {
+ throw new Error("Duplicate column " + name + " in header row.");
+ }
+ seenHeaders[name] = true;
+ });
+ }
+
+ if (!dataRows.length) {
+ throw new Error("No data rows found to preview.");
+ }
+
+ return dataRows.map(function (row, rowIndex) {
+ if (row.length > headers.length) {
+ throw new Error(
+ "Row " +
+ (rowIndex + 1) +
+ " has " +
+ row.length +
+ " values, but only " +
+ headers.length +
+ " columns were provided.",
+ );
+ }
+ var valuesByColumn = {};
+ row.forEach(function (value, index) {
+ var columnName = headers[index];
+ if (columnMap[columnName]) {
+ valuesByColumn[columnName] = value;
+ }
+ });
+ return rowObjectForBulkInsert(valuesByColumn, columns);
+ });
+}
+
+function parseBulkInsertRows(text, columns) {
+ var trimmed = text.trim();
+ if (!trimmed) {
+ throw new Error("Paste rows before previewing.");
+ }
+ if (trimmed[0] === "[" || trimmed[0] === "{") {
+ return parseJsonBulkInsertRows(trimmed, columns);
+ }
+ return parseDelimitedBulkInsertRows(trimmed, columns);
+}
+
+function bulkInsertPreviewValue(value) {
+ if (value === null) {
+ return "null";
+ }
+ if (typeof value === "object") {
+ return JSON.stringify(value);
+ }
+ return String(value);
+}
+
+function bulkInsertPreviewCell(column, hasValue, value) {
+ if (!hasValue && column.is_auto_pk) {
+ return {
+ text: "auto",
+ className: "row-edit-bulk-preview-auto",
+ };
+ }
+ if (value === null) {
+ return {
+ text: bulkInsertPreviewValue(value),
+ className: "row-edit-bulk-preview-null",
+ };
+ }
+ return {
+ text: hasValue ? bulkInsertPreviewValue(value) : "",
+ className: "",
+ };
+}
+
+function renderBulkInsertPreview(state, rows) {
+ state.bulkInsertPreview.textContent = "";
+ var summary = document.createElement("p");
+ summary.className = "row-edit-bulk-preview-summary";
+ summary.textContent =
+ "Previewing " + rows.length + " row" + (rows.length === 1 ? "." : "s.");
+ state.bulkInsertPreview.appendChild(summary);
+
+ var tableWrap = document.createElement("div");
+ tableWrap.className = "row-edit-bulk-preview-table-wrap";
+ var table = document.createElement("table");
+ table.className = "row-edit-bulk-preview-table";
+ var thead = document.createElement("thead");
+ var headerRow = document.createElement("tr");
+ state.bulkInsertColumnDetails.forEach(function (column) {
+ var th = document.createElement("th");
+ th.scope = "col";
+ th.textContent = column.name;
+ headerRow.appendChild(th);
+ });
+ thead.appendChild(headerRow);
+ table.appendChild(thead);
+
+ var tbody = document.createElement("tbody");
+ rows.forEach(function (row) {
+ var tr = document.createElement("tr");
+ state.bulkInsertColumnDetails.forEach(function (column) {
+ var td = document.createElement("td");
+ var hasValue = Object.prototype.hasOwnProperty.call(row, column.name);
+ var value = hasValue ? row[column.name] : "";
+ var cell = bulkInsertPreviewCell(column, hasValue, value);
+ td.textContent = cell.text;
+ if (cell.className) {
+ td.className = cell.className;
+ }
+ tr.appendChild(td);
+ });
+ tbody.appendChild(tr);
+ });
+ table.appendChild(tbody);
+ tableWrap.appendChild(table);
+ state.bulkInsertPreview.appendChild(tableWrap);
+ state.bulkInsertPreview.hidden = false;
+}
+
+function previewBulkInsertRows(state) {
+ clearRowEditDialogError(state);
+ resetBulkInsertPreview(state);
+ syncBulkInsertConflictUi(state);
+ try {
+ var rows = parseBulkInsertRows(
+ state.bulkInsertTextarea.value,
+ state.bulkInsertColumnDetails,
+ );
+ state.bulkInsertPreviewRows = rows;
+ state.bulkInsertPreviewReady = true;
+ renderBulkInsertPreview(state, rows);
+ updateRowEditDialogButtons(state);
+ } catch (error) {
+ showRowEditDialogError(state, error.message || "Could not preview rows.");
+ updateRowEditDialogButtons(state);
+ }
+}
+
+function updateBulkInsertProgress(state, inserted, total) {
+ var words = bulkInsertProgressWords(state);
+ state.bulkInsertProgress.hidden = false;
+ state.bulkInsertProgressBar.max = total || 1;
+ state.bulkInsertProgressBar.value = inserted;
+ state.bulkInsertProgressStatus.textContent =
+ inserted >= total
+ ? total + " row" + (total === 1 ? " " : "s ") + words.complete + "."
+ : words.active + " " + inserted + " of " + total + " rows...";
+}
+
+function bulkInsertBatches(rows, batchSize) {
+ var batches = [];
+ var size = Math.max(1, batchSize || 1);
+ for (var index = 0; index < rows.length; index += size) {
+ batches.push(rows.slice(index, index + size));
+ }
+ return batches;
+}
+
+function animateBulkInsertProgress(state, from, to, total, duration) {
+ state.bulkInsertProgress.hidden = false;
+ state.bulkInsertProgressBar.max = total || 1;
+ if (duration <= 0 || !window.requestAnimationFrame) {
+ updateBulkInsertProgress(state, to, total);
+ return Promise.resolve();
+ }
+
+ return new Promise(function (resolve) {
+ var startTime = null;
+ var step = function (timestamp) {
+ if (startTime === null) {
+ startTime = timestamp;
+ }
+ var progress = Math.min((timestamp - startTime) / duration, 1);
+ var easedProgress = 1 - Math.pow(1 - progress, 3);
+ var value = from + (to - from) * easedProgress;
+ var displayValue = progress === 1 ? to : Math.floor(value);
+ var words = bulkInsertProgressWords(state);
+ state.bulkInsertProgressBar.value = value;
+ state.bulkInsertProgressStatus.textContent =
+ displayValue >= total
+ ? total + " row" + (total === 1 ? " " : "s ") + words.complete + "."
+ : words.active + " " + displayValue + " of " + total + " rows...";
+ if (progress < 1) {
+ window.requestAnimationFrame(step);
+ } else {
+ updateBulkInsertProgress(state, to, total);
+ resolve();
+ }
+ };
+ window.requestAnimationFrame(step);
+ });
+}
+
+function bulkInsertProgressWords(state) {
+ var mode = bulkInsertConflictMode(state);
+ if (mode === "upsert") {
+ return {
+ active: "Upserting",
+ complete: "upserted",
+ };
+ }
+ if (mode === "ignore") {
+ return {
+ active: "Processing",
+ complete: "processed",
+ };
+ }
+ return {
+ active: "Inserting",
+ complete: "inserted",
+ };
+}
+
+function validateBulkInsertConflictRows(state, rows) {
+ if (bulkInsertConflictMode(state) !== "upsert") {
+ return null;
+ }
+ var insertData = tableInsertData() || {};
+ var primaryKeys = insertData.primaryKeys || [];
+ for (var index = 0; index < rows.length; index += 1) {
+ var row = rows[index];
+ var missing = primaryKeys.filter(function (key) {
+ return (
+ !Object.prototype.hasOwnProperty.call(row, key) ||
+ row[key] === null ||
+ typeof row[key] === "undefined"
+ );
+ });
+ if (missing.length) {
+ return (
+ "Row " +
+ (index + 1) +
+ " is missing primary key " +
+ missing.join(", ") +
+ ". Upsert requires primary key values for every row."
+ );
+ }
+ }
+ return null;
+}
+
+async function insertBulkPreviewRows(state) {
+ if (!state.bulkInsertPreviewRows || state.bulkInsertInserted) {
+ return;
+ }
+ var conflictMode = bulkInsertConflictMode(state);
+ var url =
+ conflictMode === "upsert" ? state.currentUpsertUrl : state.currentInsertUrl;
+ if (!url) {
+ showRowEditDialogError(
+ state,
+ conflictMode === "upsert"
+ ? "Could not find the row upsert URL."
+ : "Could not find the row insert URL.",
+ );
+ return;
+ }
+
+ var rows = state.bulkInsertPreviewRows;
+ var validationError = validateBulkInsertConflictRows(state, rows);
+ if (validationError) {
+ showRowEditDialogError(state, validationError);
+ return;
+ }
+ var total = rows.length;
+ var inserted = state.bulkInsertInsertedCount || 0;
+ var batches = bulkInsertBatches(
+ rows.slice(inserted),
+ state.bulkInsertMaxRows,
+ );
+ var progressAnimationDuration = 500 / Math.max(batches.length, 1);
+
+ clearRowEditDialogError(state);
+ updateBulkInsertProgress(state, inserted, total);
+ setRowEditDialogSaving(state, true);
+ try {
+ for (var batchIndex = 0; batchIndex < batches.length; batchIndex += 1) {
+ var batch = batches[batchIndex];
+ var payload = { rows: batch };
+ if (conflictMode === "ignore") {
+ payload.ignore = true;
+ }
+ var response = await fetch(url, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ Accept: "application/json",
+ },
+ body: JSON.stringify(payload),
+ });
+ var data = null;
+ try {
+ data = await response.json();
+ } catch (_error) {
+ data = null;
+ }
+ if (!response.ok || (data && data.ok === false)) {
+ throw rowMutationRequestError(response, data);
+ }
+ var previousInserted = inserted;
+ inserted += batch.length;
+ state.bulkInsertInsertedCount = inserted;
+ await animateBulkInsertProgress(
+ state,
+ previousInserted,
+ inserted,
+ total,
+ progressAnimationDuration,
+ );
+ }
+ state.bulkInsertInserted = true;
+ state.shouldReloadOnClose = true;
+ state.redirectOnCloseUrl = tableBaseUrl().toString();
+ updateBulkInsertProgress(state, inserted, total);
+ } catch (error) {
+ showRowEditDialogError(state, error.message || "Could not insert rows.");
+ } finally {
+ setRowEditDialogSaving(state, false);
+ }
+}
+
function scheduleCloseRowEditDialogIfConfirmed(state) {
// Fix for an issue in Safari where hitting Esc would show
// the confirm() prompt asking if state should be discarded
@@ -4762,6 +6846,14 @@ async function saveRowEditDialog(state) {
if (state.isLoading || state.isSaving || !state.hasLoaded) {
return;
}
+ if (rowEditIsMultipleInsert(state)) {
+ if (!state.bulkInsertPreviewReady) {
+ previewBulkInsertRows(state);
+ } else if (!state.bulkInsertInserted) {
+ await insertBulkPreviewRows(state);
+ }
+ return;
+ }
clearRowEditDialogError(state);
setRowEditDialogSaving(state, true);
@@ -4919,9 +7011,12 @@ function renderRowEditFields(state, data) {
var columns = data.columns || (row ? Object.keys(row) : []);
var primaryKeys = data.primary_keys || [];
var columnTypes = data.column_types || {};
+ var columnDetails = data.column_details || {};
+ state.insertMode = "single";
destroyRowEditFields(state);
columns.forEach(function (column, index) {
+ var columnDetail = columnDetails[column] || {};
state.fields.appendChild(
createRowEditField(
column,
@@ -4935,7 +7030,9 @@ function renderRowEditFields(state, data) {
form: state.form,
manager: state.manager,
mode: state.mode,
+ notnull: columnDetail.notnull,
primaryKeyReadonly: true,
+ sqliteType: columnDetail.sqlite_type,
},
),
);
@@ -4950,7 +7047,23 @@ function renderRowEditFields(state, data) {
function renderRowInsertFields(state, data) {
var columns = data.columns || [];
+ var bulkColumns = data.bulkColumns || columns;
+ state.insertMode = "single";
+ state.bulkInsertColumnDetails = bulkColumns.slice();
+ state.bulkInsertMaxRows = data.maxInsertRows || 100;
+ state.bulkInsertColumns = bulkColumns.map(function (column) {
+ return column.name;
+ });
+ state.bulkInsertTemplateColumns = columns.map(function (column) {
+ return column.name;
+ });
+ state.copyTemplateButton.disabled = !state.bulkInsertTemplateColumns.length;
+ setBulkInsertCopyButtonReady(state);
+ syncBulkInsertConflictUi(state);
+ clearTimeout(state.copyTemplateResetTimer);
+ state.copyTemplateResetTimer = null;
+ resetBulkInsertPreview(state);
destroyRowEditFields(state);
columns.forEach(function (column, index) {
state.fields.appendChild(
@@ -5040,7 +7153,36 @@ function ensureRowEditDialog(manager) {
Loading row...
+
+
+
Paste TSV, CSV, or JSON . You can also open a file or drop it onto this textarea
+
+
+
+
If the row exists already
+
+
+ Stop with an error
+ Skip existing rows
+ Update existing and insert new
+
+
+
+
+
+ Copy spreadsheet template Copy template
+ You can paste the template into Google Sheets or Excel. Paste into Google Sheets or Excel
+
+
+
+
+
@@ -5056,6 +7198,27 @@ function ensureRowEditDialog(manager) {
loading: dialog.querySelector(".row-edit-loading"),
error: dialog.querySelector(".row-edit-error"),
fields: dialog.querySelector(".row-edit-fields"),
+ bulkInsertPanel: dialog.querySelector(".row-edit-bulk"),
+ bulkInsertEditor: dialog.querySelector(".row-edit-bulk-editor"),
+ bulkInsertTextarea: dialog.querySelector(".row-edit-bulk-textarea"),
+ bulkInsertPreview: dialog.querySelector(".row-edit-bulk-preview"),
+ bulkInsertProgress: dialog.querySelector(".row-edit-bulk-progress"),
+ bulkInsertProgressBar: dialog.querySelector(".row-edit-bulk-progress-bar"),
+ bulkInsertProgressStatus: dialog.querySelector(
+ ".row-edit-bulk-progress-status",
+ ),
+ bulkInsertConflictField: dialog.querySelector(".row-edit-bulk-conflict"),
+ bulkInsertConflictSelect: dialog.querySelector(
+ ".row-edit-bulk-conflict-mode",
+ ),
+ bulkInsertConflictHelp: dialog.querySelector(
+ ".row-edit-bulk-conflict-help",
+ ),
+ copyTemplateButton: dialog.querySelector(".row-edit-copy-template"),
+ bulkInsertOpenFileButton: dialog.querySelector(".row-edit-bulk-open-file"),
+ bulkInsertFileInput: dialog.querySelector(".row-edit-bulk-file-input"),
+ bulkInsertLink: dialog.querySelector(".row-edit-bulk-insert"),
+ singleInsertLink: dialog.querySelector(".row-edit-single-insert"),
cancelButton: dialog.querySelector(".row-edit-cancel"),
saveButton: dialog.querySelector(".row-edit-save"),
currentButton: null,
@@ -5063,9 +7226,25 @@ function ensureRowEditDialog(manager) {
currentRowId: null,
currentPkPath: null,
currentInsertUrl: null,
+ currentUpsertUrl: null,
currentUpdateUrl: null,
currentFragmentUrl: null,
mode: "edit",
+ insertMode: "single",
+ bulkInsertConflictMode: "ignore",
+ bulkInsertHasPrimaryKeyColumns: false,
+ bulkInsertLiveValidationError: null,
+ bulkInsertColumns: [],
+ bulkInsertTemplateColumns: [],
+ bulkInsertColumnDetails: [],
+ bulkInsertPreviewRows: null,
+ bulkInsertPreviewReady: false,
+ bulkInsertInserted: false,
+ bulkInsertInsertedCount: 0,
+ bulkInsertMaxRows: 100,
+ shouldReloadOnClose: false,
+ redirectOnCloseUrl: null,
+ copyTemplateResetTimer: null,
loadId: 0,
manager: manager,
isLoading: false,
@@ -5081,12 +7260,139 @@ function ensureRowEditDialog(manager) {
});
rowEditDialogState.cancelButton.addEventListener("click", function () {
+ if (
+ rowEditIsMultipleInsert(rowEditDialogState) &&
+ rowEditDialogState.bulkInsertPreviewReady &&
+ !rowEditDialogState.bulkInsertInserted &&
+ !rowEditDialogState.isSaving
+ ) {
+ resetBulkInsertPreview(rowEditDialogState);
+ updateRowEditDialogButtons(rowEditDialogState);
+ rowEditDialogState.bulkInsertTextarea.focus();
+ return;
+ }
if (!rowEditDialogState.isSaving) {
rowEditDialogState.shouldRestoreFocus = true;
dialog.close();
}
});
+ rowEditDialogState.bulkInsertLink.addEventListener("click", function (ev) {
+ ev.preventDefault();
+ showMultipleRowInsert(rowEditDialogState);
+ });
+
+ rowEditDialogState.singleInsertLink.addEventListener("click", function (ev) {
+ ev.preventDefault();
+ showSingleRowInsert(rowEditDialogState);
+ });
+
+ rowEditDialogState.copyTemplateButton.addEventListener(
+ "click",
+ async function () {
+ try {
+ await copyTextToClipboard(bulkInsertTemplateText(rowEditDialogState));
+ clearRowEditDialogError(rowEditDialogState);
+ setBulkInsertCopyButtonCopied(rowEditDialogState);
+ } catch (_error) {
+ showRowEditDialogError(
+ rowEditDialogState,
+ "Could not copy the spreadsheet template.",
+ );
+ }
+ },
+ );
+
+ rowEditDialogState.bulkInsertOpenFileButton.addEventListener(
+ "click",
+ function () {
+ rowEditDialogState.bulkInsertFileInput.click();
+ },
+ );
+
+ rowEditDialogState.bulkInsertFileInput.addEventListener(
+ "change",
+ async function (ev) {
+ var files = ev.target.files;
+ await loadBulkInsertTextFile(
+ rowEditDialogState,
+ files && files.length ? files[0] : null,
+ );
+ ev.target.value = "";
+ },
+ );
+
+ rowEditDialogState.bulkInsertTextarea.addEventListener(
+ "dragenter",
+ function (ev) {
+ ev.preventDefault();
+ rowEditDialogState.bulkInsertTextarea.classList.add(
+ "row-edit-bulk-drop-target",
+ );
+ },
+ );
+
+ rowEditDialogState.bulkInsertTextarea.addEventListener(
+ "dragover",
+ function (ev) {
+ ev.preventDefault();
+ rowEditDialogState.bulkInsertTextarea.classList.add(
+ "row-edit-bulk-drop-target",
+ );
+ },
+ );
+
+ rowEditDialogState.bulkInsertTextarea.addEventListener(
+ "dragleave",
+ function () {
+ rowEditDialogState.bulkInsertTextarea.classList.remove(
+ "row-edit-bulk-drop-target",
+ );
+ },
+ );
+
+ rowEditDialogState.bulkInsertTextarea.addEventListener(
+ "drop",
+ async function (ev) {
+ ev.preventDefault();
+ rowEditDialogState.bulkInsertTextarea.classList.remove(
+ "row-edit-bulk-drop-target",
+ );
+ var files = ev.dataTransfer && ev.dataTransfer.files;
+ if (!files || !files.length) {
+ return;
+ }
+ await loadBulkInsertTextFile(rowEditDialogState, files[0]);
+ },
+ );
+
+ rowEditDialogState.bulkInsertTextarea.addEventListener(
+ "dragend",
+ function () {
+ rowEditDialogState.bulkInsertTextarea.classList.remove(
+ "row-edit-bulk-drop-target",
+ );
+ },
+ );
+
+ rowEditDialogState.bulkInsertTextarea.addEventListener("input", function () {
+ resetBulkInsertPreview(rowEditDialogState);
+ syncBulkInsertTextareaValidation(rowEditDialogState);
+ updateRowEditDialogButtons(rowEditDialogState);
+ });
+
+ rowEditDialogState.bulkInsertConflictSelect.addEventListener(
+ "change",
+ function () {
+ rowEditDialogState.bulkInsertConflictMode =
+ rowEditDialogState.bulkInsertConflictSelect.value;
+ syncBulkInsertConflictUi(rowEditDialogState);
+ resetBulkInsertPreview(rowEditDialogState);
+ syncBulkInsertTextareaValidation(rowEditDialogState);
+ updateRowEditDialogButtons(rowEditDialogState);
+ },
+ );
+
dialog.addEventListener("click", function (ev) {
if (ev.target === dialog) {
closeRowEditDialogIfConfirmed(rowEditDialogState);
@@ -5108,8 +7414,17 @@ function ensureRowEditDialog(manager) {
dialog.addEventListener("close", function () {
var state = rowEditDialogState;
+ var shouldReloadOnClose = state.shouldReloadOnClose;
+ var redirectOnCloseUrl = state.redirectOnCloseUrl;
state.loadId += 1;
state.isClosePending = false;
+ state.bulkInsertLiveValidationError = null;
+ state.shouldReloadOnClose = false;
+ state.redirectOnCloseUrl = null;
+ clearTimeout(state.copyTemplateResetTimer);
+ state.copyTemplateResetTimer = null;
+ setBulkInsertCopyButtonReady(state);
+ resetBulkInsertPreview(state);
clearRowEditDialogError(state);
state.hasLoaded = false;
destroyRowEditFields(state);
@@ -5122,6 +7437,13 @@ function ensureRowEditDialog(manager) {
) {
state.currentButton.focus();
}
+ if (shouldReloadOnClose) {
+ if (redirectOnCloseUrl) {
+ location.href = redirectOnCloseUrl;
+ } else {
+ location.reload();
+ }
+ }
});
return rowEditDialogState;
@@ -5144,8 +7466,10 @@ async function openRowEditDialog(button, manager) {
state.currentRowId = row.getAttribute("data-row") || "";
state.currentPkPath = rowDisplayLabel(row);
state.currentInsertUrl = null;
+ state.currentUpsertUrl = null;
state.currentUpdateUrl = rowUpdateUrl(row);
state.currentFragmentUrl = rowFragmentUrl(row);
+ state.insertMode = "single";
if (state.currentUpdateUrl) {
state.form.action = new URL(
state.currentUpdateUrl,
@@ -5171,6 +7495,7 @@ async function openRowEditDialog(button, manager) {
);
state.summary.hidden = true;
state.summary.textContent = "";
+ syncRowEditInsertModeUi(state);
if (!state.dialog.open) {
state.dialog.showModal();
@@ -5219,8 +7544,16 @@ function openRowInsertDialog(button, manager) {
state.currentRowId = null;
state.currentPkPath = null;
state.currentInsertUrl = tableInsertUrl();
+ state.currentUpsertUrl = tableUpsertUrl();
state.currentUpdateUrl = null;
state.currentFragmentUrl = null;
+ state.insertMode = "single";
+ state.bulkInsertConflictMode = "ignore";
+ state.bulkInsertLiveValidationError = null;
+ state.bulkInsertTextarea.value = "";
+ state.shouldReloadOnClose = false;
+ state.redirectOnCloseUrl = null;
+ resetBulkInsertPreview(state);
state.shouldRestoreFocus = true;
state.hasLoaded = false;
state.loadId += 1;
@@ -5238,14 +7571,10 @@ function openRowInsertDialog(button, manager) {
setRowEditDialogLoading(state, false);
destroyRowEditFields(state);
state.dialog.removeAttribute("aria-describedby");
- setRowDialogTitle(
- state.title,
- insertData.tableName
- ? "Insert row into " + insertData.tableName
- : "Insert row",
- );
+ setRowInsertDialogTitle(state);
state.summary.hidden = true;
state.summary.textContent = "";
+ syncRowEditInsertModeUi(state);
if (!state.dialog.open) {
state.dialog.showModal();
diff --git a/datasette/utils/__init__.py b/datasette/utils/__init__.py
index 19de31ff..64c63dde 100644
--- a/datasette/utils/__init__.py
+++ b/datasette/utils/__init__.py
@@ -1,4 +1,5 @@
import asyncio
+import binascii
from contextlib import contextmanager
import aiofiles
import click
@@ -236,10 +237,8 @@ class CustomJSONEncoder(json.JSONEncoder):
- ``sqlite3.Row`` becomes a tuple
- ``sqlite3.Cursor`` becomes a list
- If a binary blob can be decoded as UTF-8, the encoder returns it as text.
-
- If it can't (for example, images), it is encoded as an object, with the actual
- data base64-encoded, like so: ::
+ Binary blobs are encoded as an object, with the actual data base64-encoded,
+ like so: ::
{
"$base64": True,
@@ -255,17 +254,42 @@ class CustomJSONEncoder(json.JSONEncoder):
if isinstance(obj, sqlite3.Cursor):
return list(obj)
if isinstance(obj, bytes):
- # Does it encode to utf8?
- try:
- return obj.decode("utf8")
- except UnicodeDecodeError:
- return {
- "$base64": True,
- "encoded": base64.b64encode(obj).decode("latin1"),
- }
+ return {
+ "$base64": True,
+ "encoded": base64.b64encode(obj).decode("latin1"),
+ }
return json.JSONEncoder.default(self, obj)
+class WriteJsonValueError(ValueError):
+ pass
+
+
+def decode_write_json_cell(value):
+ if not isinstance(value, dict):
+ return value
+ keys = set(value.keys())
+ if keys == {"$raw"}:
+ return value["$raw"]
+ if keys == {"$base64", "encoded"} and value.get("$base64") is True:
+ encoded = value["encoded"]
+ if not isinstance(encoded, str):
+ raise WriteJsonValueError("$base64 encoded value must be a string")
+ try:
+ return base64.b64decode(encoded, validate=True)
+ except binascii.Error as ex:
+ raise WriteJsonValueError("Invalid $base64 encoded value") from ex
+ return value
+
+
+def decode_write_json_row(row):
+ return {key: decode_write_json_cell(value) for key, value in row.items()}
+
+
+def decode_write_json_rows(rows):
+ return [decode_write_json_row(row) for row in rows]
+
+
@contextmanager
def sqlite_timelimit(conn, ms):
deadline = time.perf_counter() + (ms / 1000)
diff --git a/datasette/utils/asgi.py b/datasette/utils/asgi.py
index e1631b10..7777308f 100644
--- a/datasette/utils/asgi.py
+++ b/datasette/utils/asgi.py
@@ -67,13 +67,25 @@ class BadRequest(Base400):
status = 400
+class PayloadTooLarge(Base400):
+ status = 413
+
+
SAMESITE_VALUES = ("strict", "lax", "none")
+# Bodies read fully into memory (post_body/post_vars/json) are capped at this
+# size unless the max_post_body_bytes setting says otherwise. Kept deliberately
+# far below multipart's DEFAULT_MAX_REQUEST_SIZE: that parser streams to disk,
+# while these bodies are held in RAM and json.loads() can multiply their
+# footprint several times over.
+DEFAULT_MAX_POST_BODY_BYTES = 2 * 1024 * 1024 # 2MB
+
class Request:
- def __init__(self, scope, receive):
+ def __init__(self, scope, receive, max_post_body_bytes=DEFAULT_MAX_POST_BODY_BYTES):
self.scope = scope
self.receive = receive
+ self.max_post_body_bytes = max_post_body_bytes
def __repr__(self):
return ''.format(self.method, self.url)
@@ -141,15 +153,43 @@ class Request:
def actor(self):
return self.scope.get("actor", None)
- async def post_body(self):
- body = b""
+ async def post_body(self, max_bytes=None):
+ """
+ Read the request body fully into memory.
+
+ The body is capped at max_bytes - or self.max_post_body_bytes
+ (default 2MB, set from the max_post_body_bytes setting for requests
+ created by Datasette) if max_bytes is not provided. Pass max_bytes=0
+ to disable the limit. Raises PayloadTooLarge (HTTP 413) if exceeded -
+ oversized bodies are rejected as soon as the limit is passed, without
+ buffering the rest.
+ """
+ if max_bytes is None:
+ max_bytes = self.max_post_body_bytes
+ too_large = PayloadTooLarge(
+ "Request body exceeded maximum size of {} bytes".format(max_bytes)
+ )
+ if max_bytes:
+ # Reject early if the client declares an oversized body
+ try:
+ if int(self.headers.get("content-length", "")) > max_bytes:
+ raise too_large
+ except ValueError:
+ # Missing or malformed - the streaming check below still applies
+ pass
+ chunks = []
+ received = 0
more_body = True
while more_body:
message = await self.receive()
assert message["type"] == "http.request", message
- body += message.get("body", b"")
+ chunk = message.get("body", b"")
+ received += len(chunk)
+ if max_bytes and received > max_bytes:
+ raise too_large
+ chunks.append(chunk)
more_body = message.get("more_body", False)
- return body
+ return b"".join(chunks)
async def post_vars(self):
body = await self.post_body()
diff --git a/datasette/utils/sql_analysis.py b/datasette/utils/sql_analysis.py
index 3a509bd2..1be28982 100644
--- a/datasette/utils/sql_analysis.py
+++ b/datasette/utils/sql_analysis.py
@@ -488,17 +488,17 @@ def analyze_sql_tables(
and key.operation in {"create", "alter", "drop"}
for key in operations
)
- dropped_tables = {
+ dropped_tables_and_views = {
(key.database, key.table)
for key in operations
- if key.operation == "drop" and key.target_type == "table"
+ if key.operation == "drop" and key.target_type in {"table", "view"}
}
def key_is_drop_table_delete(key: OperationKey) -> bool:
return (
key.operation == "delete"
and key.target_type == "table"
- and (key.database, key.table) in dropped_tables
+ and (key.database, key.table) in dropped_tables_and_views
)
has_user_table_access_in_schema_operation = any(
diff --git a/datasette/views/database.py b/datasette/views/database.py
index b7131a05..c9dcfa89 100644
--- a/datasette/views/database.py
+++ b/datasette/views/database.py
@@ -326,7 +326,7 @@ class DatabaseContext(Context):
database_color: str = field(metadata={"help": "The color assigned to the database"})
database_page_data: dict = field(
metadata={
- "help": 'JSON data used by JavaScript on the database page. Currently ``{}`` or ``{"createTable": {...}}`` where ``createTable`` includes ``path``, ``foreignKeyTargetsPath``, ``databaseName``, ``columnTypes``, ``defaultExpressions`` and optional ``customColumnTypes``.'
+ "help": 'JSON data used by JavaScript on the database page. Currently ``{}`` or ``{"createTable": {...}}`` where ``createTable`` includes ``path``, ``foreignKeyTargetsPath``, ``databaseName``, ``columnTypes``, ``defaultExpressions``, ``canInsertRows`` and optional ``customColumnTypes``.'
}
)
database_actions: callable = field(
diff --git a/datasette/views/row.py b/datasette/views/row.py
index 296a0ac1..2a103180 100644
--- a/datasette/views/row.py
+++ b/datasette/views/row.py
@@ -8,7 +8,7 @@ from dataclasses import dataclass, field
import markupsafe
import sqlite_utils
-from datasette.utils.asgi import NotFound, Forbidden, Response
+from datasette.utils.asgi import NotFound, Forbidden, PayloadTooLarge, Response
from datasette.database import QueryInterrupted
from datasette.events import UpdateRowEvent, DeleteRowEvent
from datasette.resources import TableResource
@@ -17,7 +17,9 @@ from datasette.utils import (
add_cors_headers,
await_me_maybe,
call_with_supported_arguments,
+ CustomJSONEncoder,
CustomRow,
+ decode_write_json_row,
InvalidSql,
make_slot_function,
path_from_row_pks,
@@ -26,6 +28,7 @@ from datasette.utils import (
to_css_class,
escape_sqlite,
sqlite3,
+ WriteJsonValueError,
)
from datasette.plugins import pm
from datasette.extras import extra_names_from_request, ExtraScope
@@ -785,6 +788,8 @@ class RowUpdateView(BaseView):
data = await request.json()
except json.JSONDecodeError as e:
return _error(["Invalid JSON: {}".format(e)])
+ except PayloadTooLarge as e:
+ return _error([str(e)], 413)
if not isinstance(data, dict):
return _error(["JSON must be a dictionary"])
@@ -796,6 +801,10 @@ class RowUpdateView(BaseView):
return _error(["Invalid keys: {}".format(", ".join(invalid_keys))])
update = data["update"]
+ try:
+ update = decode_write_json_row(update)
+ except WriteJsonValueError as e:
+ return _error([str(e)], 400)
# Validate column types
from datasette.views.table import _validate_column_types
@@ -857,4 +866,4 @@ class RowUpdateView(BaseView):
self.ds.INFO,
)
- return Response.json(result, status=200)
+ return Response.json(result, status=200, default=CustomJSONEncoder().default)
diff --git a/datasette/views/table.py b/datasette/views/table.py
index 69d5a075..0a9c88d4 100644
--- a/datasette/views/table.py
+++ b/datasette/views/table.py
@@ -22,9 +22,11 @@ from datasette.utils import (
add_cors_headers,
await_me_maybe,
call_with_supported_arguments,
+ CustomJSONEncoder,
CustomRow,
append_querystring,
compound_keys_after_sql,
+ decode_write_json_rows,
format_bytes,
make_slot_function,
tilde_encode,
@@ -41,9 +43,17 @@ from datasette.utils import (
urlsafe_components,
value_as_boolean,
InvalidSql,
+ WriteJsonValueError,
sqlite3,
)
-from datasette.utils.asgi import BadRequest, Forbidden, NotFound, Request, Response
+from datasette.utils.asgi import (
+ BadRequest,
+ Forbidden,
+ NotFound,
+ PayloadTooLarge,
+ Request,
+ Response,
+)
from datasette.filters import Filters
import sqlite_utils
from dataclasses import dataclass, field
@@ -206,7 +216,7 @@ class TableContext(Context):
)
table_insert_ui: dict = field(
metadata={
- "help": "Information needed to enable the row insertion UI, or ``None`` if row insertion is not available to the current actor. When present it has ``path``, ``tableName``, ``columns`` and ``primaryKeys`` keys; each column includes ``name``, ``sqlite_type``, ``notnull``, ``default``, ``has_default``, ``is_pk``, ``value_kind`` and ``column_type`` keys."
+ "help": "Information needed to enable the row insertion UI, or ``None`` if row insertion is not available to the current actor. When present it has ``path``, ``tableName``, ``columns``, ``bulkColumns``, ``primaryKeys`` and ``maxInsertRows`` keys, plus optional ``upsertPath`` if the current actor has permission to update rows. ``columns`` lists columns for the single-row insert form, while ``bulkColumns`` lists columns for the bulk insert form. Each column includes ``name``, ``sqlite_type``, ``notnull``, ``default``, ``has_default``, ``is_pk``, ``is_auto_pk``, ``value_kind`` and ``column_type`` keys."
}
)
table_alter_ui: dict = field(
@@ -481,8 +491,15 @@ async def _table_insert_ui(
):
return None
+ can_update = await datasette.allowed(
+ action="update-row",
+ resource=TableResource(database=database_name, table=table_name),
+ actor=request.actor,
+ )
+
column_types_map = await datasette.get_column_types(database_name, table_name)
columns = []
+ bulk_columns = []
column_details = await db.table_column_details(table_name)
for column in column_details:
if column.hidden:
@@ -493,32 +510,40 @@ async def _table_insert_ui(
and len(pks) == 1
and SQLiteType.from_declared_type(column.type) == SQLiteType.INTEGER
)
+ column_type = column_types_map.get(column.name)
+ column_data = {
+ "name": column.name,
+ "sqlite_type": _column_sqlite_type_for_insert_form(column),
+ "notnull": column.notnull,
+ "default": column.default_value,
+ "has_default": column.default_value is not None,
+ "is_pk": is_pk,
+ "is_auto_pk": is_auto_pk,
+ "value_kind": _column_value_kind_for_insert_form(column),
+ "column_type": (
+ {"type": column_type.name, "config": column_type.config}
+ if column_type is not None
+ else None
+ ),
+ }
+ bulk_columns.append(column_data)
if is_auto_pk:
continue
- column_type = column_types_map.get(column.name)
- columns.append(
- {
- "name": column.name,
- "sqlite_type": _column_sqlite_type_for_insert_form(column),
- "notnull": column.notnull,
- "default": column.default_value,
- "has_default": column.default_value is not None,
- "is_pk": is_pk,
- "value_kind": _column_value_kind_for_insert_form(column),
- "column_type": (
- {"type": column_type.name, "config": column_type.config}
- if column_type is not None
- else None
- ),
- }
- )
+ columns.append(column_data)
- return {
+ data = {
"path": "{}/-/insert".format(datasette.urls.table(database_name, table_name)),
"tableName": table_name,
"columns": columns,
+ "bulkColumns": bulk_columns,
"primaryKeys": pks,
+ "maxInsertRows": datasette.setting("max_insert_rows"),
}
+ if can_update:
+ data["upsertPath"] = "{}/-/upsert".format(
+ datasette.urls.table(database_name, table_name)
+ )
+ return data
async def _table_alter_ui(
@@ -1053,11 +1078,18 @@ class TableInsertView(BaseView):
pks = await db.primary_keys(table_name)
- rows, errors, extras = await self._validate_data(
- request, db, table_name, pks, upsert
- )
+ try:
+ rows, errors, extras = await self._validate_data(
+ request, db, table_name, pks, upsert
+ )
+ except PayloadTooLarge as e:
+ return _error([str(e)], 413)
if errors:
return _error(errors, 400)
+ try:
+ rows = decode_write_json_rows(rows)
+ except WriteJsonValueError as e:
+ return _error([str(e)], 400)
# Validate column types
ct_errors = await _validate_column_types(
@@ -1192,7 +1224,11 @@ class TableInsertView(BaseView):
)
)
- return Response.json(result, status=200 if upsert else 201)
+ return Response.json(
+ result,
+ status=200 if upsert else 201,
+ default=CustomJSONEncoder().default,
+ )
class TableUpsertView(TableInsertView):
@@ -1232,6 +1268,8 @@ class TableSetColumnTypeView(BaseView):
data = await request.json()
except json.JSONDecodeError as e:
return _error(["Invalid JSON: {}".format(e)], 400)
+ except PayloadTooLarge as e:
+ return _error([str(e)], 413)
if not isinstance(data, dict):
return _error(["JSON must be a dictionary"], 400)
@@ -1350,6 +1388,8 @@ class TableDropView(BaseView):
confirm = data.get("confirm")
except json.JSONDecodeError:
pass
+ except PayloadTooLarge as e:
+ return _error([str(e)], 413)
if not confirm:
return Response.json(
diff --git a/datasette/views/table_create_alter.py b/datasette/views/table_create_alter.py
index ffeb7f14..d0384184 100644
--- a/datasette/views/table_create_alter.py
+++ b/datasette/views/table_create_alter.py
@@ -20,11 +20,13 @@ from datasette.column_types import SQLiteType
from datasette.events import AlterTableEvent, CreateTableEvent, InsertRowsEvent
from datasette.resources import DatabaseResource, TableResource
from datasette.utils import (
+ decode_write_json_rows,
escape_sqlite,
get_outbound_foreign_keys,
table_column_details,
+ WriteJsonValueError,
)
-from datasette.utils.asgi import NotFound, Response
+from datasette.utils.asgi import NotFound, PayloadTooLarge, Response
from datasette.utils.sqlite import sqlite_hidden_table_names
from .base import BaseView, _error
@@ -267,6 +269,11 @@ async def _create_table_ui_context(
"databaseName": database_name,
"columnTypes": CREATE_TABLE_COLUMN_TYPES,
"defaultExpressions": default_expression_options(),
+ "canInsertRows": await datasette.allowed(
+ action="insert-row",
+ resource=DatabaseResource(database=database_name),
+ actor=request.actor,
+ ),
}
can_set_column_type = await datasette.allowed(
action="set-column-type",
@@ -804,6 +811,8 @@ class TableCreateView(BaseView):
data = await request.json()
except json.JSONDecodeError as e:
return _error(["Invalid JSON: {}".format(e)])
+ except PayloadTooLarge as e:
+ return _error([str(e)], 413)
if not isinstance(data, dict):
return _error(["JSON must be an object"])
@@ -838,6 +847,10 @@ class TableCreateView(BaseView):
actor=request.actor,
):
return _error(["Permission denied: need insert-row"], 403)
+ try:
+ rows = decode_write_json_rows(rows)
+ except WriteJsonValueError as e:
+ return _error([str(e)], 400)
alter = False
if rows:
@@ -1161,6 +1174,8 @@ class TableAlterView(BaseView):
data = await request.json()
except json.JSONDecodeError as e:
return _error(["Invalid JSON: {}".format(e)], 400)
+ except PayloadTooLarge as e:
+ return _error([str(e)], 413)
if not isinstance(data, dict):
return _error(["JSON must be a dictionary"], 400)
diff --git a/datasette/views/table_extras.py b/datasette/views/table_extras.py
index f8ece55b..e36e8161 100644
--- a/datasette/views/table_extras.py
+++ b/datasette/views/table_extras.py
@@ -1,6 +1,7 @@
import itertools
from dataclasses import dataclass
+from datasette.column_types import SQLiteType
from datasette.database import QueryInterrupted
from datasette.extras import Extra, ExtraExample, ExtraRegistry, ExtraScope, Provider
from datasette.plugins import pm
@@ -375,6 +376,55 @@ class PrimaryKeysExtra(Extra):
return context.pks
+def column_detail_as_json(column):
+ return {
+ "type": column.type,
+ "sqlite_type": SQLiteType.from_declared_type(column.type).value,
+ "notnull": bool(column.notnull),
+ "default": column.default_value,
+ "is_pk": bool(column.is_pk),
+ "pk_position": column.is_pk,
+ "hidden": column.hidden,
+ }
+
+
+class ColumnDetailsExtra(Extra):
+ description = (
+ "SQLite schema details for columns in this table. The dictionary maps "
+ "column names to objects describing the schema for each column."
+ )
+ docs_note = (
+ "Each object has ``type`` as the declared type string returned by "
+ 'SQLite, or ``""`` if no type was declared; ``sqlite_type`` as the '
+ "normalized SQLite affinity, one of ``TEXT``, ``INTEGER``, ``REAL``, "
+ "``BLOB`` or ``NUMERIC``; ``notnull`` as a boolean; ``default`` "
+ 'as the raw SQL default expression string, such as ``"42"``, '
+ "``\"'hello'\"`` or ``\"datetime('now')\"``, or ``null`` if there is "
+ "no default; ``is_pk`` as a boolean; ``pk_position`` as the integer "
+ "primary key position reported by SQLite, or ``0`` for columns that "
+ "are not part of the primary key; and ``hidden`` as the integer value "
+ "reported by SQLite's ``PRAGMA table_xinfo``. ``hidden`` is ``0`` for "
+ "normal columns, ``1`` for hidden virtual table columns, ``2`` for "
+ "virtual generated columns and ``3`` for stored generated columns."
+ )
+ example = ExtraExample("/fixtures/binary_data.json?_size=0&_extra=column_details")
+ examples = {
+ ExtraScope.ROW: ExtraExample(
+ "/fixtures/binary_data/1.json?_extra=column_details"
+ )
+ }
+ scopes = {ExtraScope.TABLE, ExtraScope.ROW}
+
+ async def resolve(self, context):
+ column_details = await context.datasette._get_resource_column_details(
+ context.database_name, context.table_name
+ )
+ return {
+ column_name: column_detail_as_json(column)
+ for column_name, column in column_details.items()
+ }
+
+
class ActionsExtra(Extra):
description = 'Async callable returning table or view actions made available by core and plugin hooks. Each item is either a link with ``href``, ``label`` and optional ``description`` keys, or a button with ``type: "button"``, ``label``, optional ``description`` and optional ``attrs``. See :ref:`plugin_actions`, :ref:`plugin_hook_table_actions` and :ref:`plugin_hook_view_actions`.'
scopes = {ExtraScope.TABLE}
@@ -1240,6 +1290,7 @@ TABLE_EXTRA_CLASSES = [
ColumnsExtra,
AllColumnsExtra,
PrimaryKeysExtra,
+ ColumnDetailsExtra,
DisplayColumnsAndRowsProvider,
DisplayColumnsExtra,
DisplayRowsExtra,
diff --git a/datasette/write_sql.py b/datasette/write_sql.py
index cdc0c6d3..ca144a72 100644
--- a/datasette/write_sql.py
+++ b/datasette/write_sql.py
@@ -138,6 +138,33 @@ def decision_for_write_sql_operation(
),
)
)
+ if operation.operation == "create" and operation.target_type == "view":
+ if operation.database is None:
+ return UnsupportedWriteSqlOperation(unsupported_message)
+ return RequireWriteSqlPermissions(
+ (
+ PermissionRequirement(
+ action="create-view",
+ resource=DatabaseResource(database=operation.database),
+ ),
+ )
+ )
+ if (
+ operation.operation == "drop"
+ and operation.target_type == "view"
+ and operation.database is not None
+ and operation.table is not None
+ ):
+ return RequireWriteSqlPermissions(
+ (
+ PermissionRequirement(
+ action="drop-view",
+ resource=TableResource(
+ database=operation.database, table=operation.table
+ ),
+ ),
+ )
+ )
if (
operation.operation == "alter"
and operation.target_type == "table"
diff --git a/docs/authentication.rst b/docs/authentication.rst
index 0a476c1c..51fa07d5 100644
--- a/docs/authentication.rst
+++ b/docs/authentication.rst
@@ -50,7 +50,7 @@ The one exception is the "root" account, which you can sign into while using Dat
The ``--root`` flag is designed for local development and testing. When you start Datasette with ``--root``, the root user automatically receives every permission, including:
* All view permissions (``view-instance``, ``view-database``, ``view-table``, etc.)
-* All write permissions (``insert-row``, ``update-row``, ``delete-row``, ``create-table``, ``alter-table``, ``set-column-type``, ``drop-table``)
+* All write permissions (``insert-row``, ``update-row``, ``delete-row``, ``create-table``, ``create-view``, ``alter-table``, ``set-column-type``, ``drop-table``, ``drop-view``)
* Debug permissions (``permissions-debug``, ``debug-menu``)
* Any custom permissions defined by plugins
@@ -1390,6 +1390,16 @@ create-table
Actor is allowed to create a database table.
+``resource`` - ``datasette.resources.DatabaseResource(database)``
+ ``database`` is the name of the database (string)
+
+.. _actions_create_view:
+
+create-view
+-----------
+
+Actor is allowed to create a database view.
+
``resource`` - ``datasette.resources.DatabaseResource(database)``
``database`` is the name of the database (string)
@@ -1429,6 +1439,18 @@ Actor is allowed to drop a database table.
``table`` is the name of the table (string)
+.. _actions_drop_view:
+
+drop-view
+---------
+
+Actor is allowed to drop a database view.
+
+``resource`` - ``datasette.resources.TableResource(database, table)``
+ ``database`` is the name of the database (string)
+
+ ``table`` is the name of the view (string)
+
.. _actions_execute_sql:
execute-sql
diff --git a/docs/binary_data.rst b/docs/binary_data.rst
index 0c890fe5..d41e36be 100644
--- a/docs/binary_data.rst
+++ b/docs/binary_data.rst
@@ -12,7 +12,12 @@ Datasette includes special handling for these binary values. The Datasette inter
:width: 311px
:alt: Screenshot showing download links next to binary data in the table view
-Binary data is represented in ``.json`` exports using Base64 encoding.
+.. _binary_json_format:
+
+Binary values in JSON
+---------------------
+
+Binary data is represented in ``.json`` exports using Base64 encoding. Datasette uses this representation for every ``BLOB`` value, including binary values that could also be decoded as UTF-8 text.
https://latest.datasette.io/fixtures/binary_data.json?_shape=array
@@ -39,6 +44,48 @@ https://latest.datasette.io/fixtures/binary_data.json?_shape=array
}
]
+The same format can be used with the :ref:`JSON write API `.
+If a column value in a ``row``, ``rows`` or ``update`` object is a JSON object with exactly ``"$base64"`` set to ``true`` and an ``"encoded"`` string, Datasette will decode that Base64 string and store the resulting bytes:
+
+.. code-block:: json
+
+ {
+ "data": {
+ "$base64": true,
+ "encoded": "FRwCx60F/g=="
+ }
+ }
+
+This works for inserts, upserts and updates. It also works when creating a table from example ``row`` or ``rows`` data: Datasette decodes the value before inferring the schema, allowing that column to be created as a ``BLOB`` column.
+
+To store a JSON object with that exact shape literally, wrap it in a ``"$raw"`` object:
+
+.. code-block:: json
+
+ {
+ "data": {
+ "$raw": {
+ "$base64": true,
+ "encoded": "FRwCx60F/g=="
+ }
+ }
+ }
+
+``"$raw"`` unwraps exactly one layer. To store a literal ``"$raw"`` object containing a Base64 object, wrap it again:
+
+.. code-block:: json
+
+ {
+ "data": {
+ "$raw": {
+ "$raw": {
+ "$base64": true,
+ "encoded": "FRwCx60F/g=="
+ }
+ }
+ }
+ }
+
.. _binary_linking:
Linking to binary downloads
diff --git a/docs/changelog.rst b/docs/changelog.rst
index ec32b57e..4541981f 100644
--- a/docs/changelog.rst
+++ b/docs/changelog.rst
@@ -4,6 +4,19 @@
Changelog
=========
+.. _unreleased:
+
+Unreleased
+----------
+
+- Table pages now offer an "Insert multiple rows" mode in the row insertion dialog. This accepts pasted TSV, CSV or JSON, previews the parsed rows before inserting them, validates unknown columns as data is pasted and displays omitted auto integer primary keys as ``auto`` in the preview. (:pr:`2813`)
+- The bulk insert UI can skip rows with existing primary keys, or update existing rows and insert new rows using the existing ``///-/upsert`` API when the actor has both :ref:`insert-row ` and :ref:`update-row ` permissions. (:pr:`2813`)
+- The "Create table" dialog now includes a "Create table from data" mode. Paste TSV, CSV or JSON rows to preview inferred columns and types, choose the table name and primary key, then create the table and insert those rows in one step. (:pr:`2813`)
+- Datasette's JSON APIs now consistently encode every ``BLOB`` value using the documented :ref:`binary value JSON format `, even when the bytes could be decoded as UTF-8 text.
+- The insert and edit row dialogs now provide a dedicated control for ``BLOB`` values. Existing binary values are shown by byte size, image values under 10MB are previewed as thumbnails, and replacements can be attached, dropped or pasted into the control.
+- The table and row JSON APIs now support ``?_extra=column_details`` for returning SQLite schema details for columns, including declared type, SQLite affinity, primary key, ``NOT NULL``, default and hidden-column metadata.
+- POST bodies that Datasette reads fully into memory - such as JSON submitted to the write API - are now capped by the new :ref:`setting_max_post_body_bytes` setting, defaulting to 2MB. Oversized requests are rejected with an HTTP 413 error as soon as the limit is exceeded, protecting smaller servers from memory exhaustion. File uploads are unaffected - ``request.form()`` streams those to disk and has its own separate limits.
+
.. _v1_0_a35:
1.0a35 (2026-06-23)
diff --git a/docs/cli-reference.rst b/docs/cli-reference.rst
index 7ca88c4e..2302f742 100644
--- a/docs/cli-reference.rst
+++ b/docs/cli-reference.rst
@@ -244,6 +244,9 @@ These can be passed to ``datasette serve`` using ``datasette serve --setting nam
custom query (default=1000)
max_insert_rows Maximum rows that can be inserted at a time using
the bulk insert API (default=100)
+ max_post_body_bytes Maximum size in bytes for a POST body read into
+ memory, e.g. JSON API requests - set 0 to disable
+ this limit (default=2097152)
num_sql_threads Number of threads in the thread pool for
executing SQLite queries (default=3)
sql_time_limit_ms Time limit for a SQL query in milliseconds
diff --git a/docs/internals.rst b/docs/internals.rst
index 9f75640f..e5e7aca5 100644
--- a/docs/internals.rst
+++ b/docs/internals.rst
@@ -52,6 +52,9 @@ The request object is passed to various plugin hooks. It represents an incoming
``.actor`` - dictionary (str -> Any) or None
The currently authenticated actor (see :ref:`actors `), or ``None`` if the request is unauthenticated.
+``.max_post_body_bytes`` - integer
+ The maximum number of bytes ``await request.post_body()`` will read into memory, or ``0`` for no limit. Set from the :ref:`setting_max_post_body_bytes` setting (default 2MB) for requests created by Datasette. Can be passed to the ``Request`` constructor as a keyword argument.
+
The object also has the following awaitable methods:
``await request.form(files=False, ...)`` - FormData
@@ -109,9 +112,11 @@ The object also has the following awaitable methods:
``await request.json()`` - Any
Returns the parsed JSON body of a request submitted by ``POST``.
-``await request.post_body()`` - bytes
+``await request.post_body(max_bytes=None)`` - bytes
Returns the un-parsed body of a request submitted by ``POST`` - useful for things like incoming JSON data.
+ The body is read fully into memory, capped at ``request.max_post_body_bytes`` - which Datasette sets from the :ref:`setting_max_post_body_bytes` setting (default 2MB). Bodies that exceed the limit raise a ``datasette.PayloadTooLarge`` exception, which Datasette turns into an HTTP 413 error response. Pass ``max_bytes=`` to override the limit for a specific call, or ``max_bytes=0`` to disable it. ``request.post_vars()`` and ``request.json()`` read the body through this method, so the same limit applies to them.
+
And a class method that can be used to create fake request objects for use in tests:
``fake(path_with_query_string, method="GET", scheme="http", url_vars=None)``
diff --git a/docs/json_api.rst b/docs/json_api.rst
index 4b1f26d2..32d28733 100644
--- a/docs/json_api.rst
+++ b/docs/json_api.rst
@@ -497,6 +497,25 @@ The available table extras are listed below.
"pk"
]
+``column_details``
+ SQLite schema details for columns in this table. The dictionary maps column names to objects describing the schema for each column. (Each object has ``type`` as the declared type string returned by SQLite, or ``""`` if no type was declared; ``sqlite_type`` as the normalized SQLite affinity, one of ``TEXT``, ``INTEGER``, ``REAL``, ``BLOB`` or ``NUMERIC``; ``notnull`` as a boolean; ``default`` as the raw SQL default expression string, such as ``"42"``, ``"'hello'"`` or ``"datetime('now')"``, or ``null`` if there is no default; ``is_pk`` as a boolean; ``pk_position`` as the integer primary key position reported by SQLite, or ``0`` for columns that are not part of the primary key; and ``hidden`` as the integer value reported by SQLite's ``PRAGMA table_xinfo``. ``hidden`` is ``0`` for normal columns, ``1`` for hidden virtual table columns, ``2`` for virtual generated columns and ``3`` for stored generated columns.)
+
+ ``GET /fixtures/binary_data.json?_size=0&_extra=column_details``
+
+ .. code-block:: json
+
+ {
+ "data": {
+ "type": "BLOB",
+ "sqlite_type": "BLOB",
+ "notnull": false,
+ "default": null,
+ "is_pk": false,
+ "pk_position": 0,
+ "hidden": 0
+ }
+ }
+
``display_columns``
Column metadata used by the HTML table display. Each item includes ``name``, ``sortable``, ``is_pk``, ``type``, ``notnull``, ``description``, ``column_type`` and ``column_type_config`` keys.
@@ -902,6 +921,25 @@ The following extras are available for row JSON responses.
"id"
]
+``column_details``
+ SQLite schema details for columns in this table. The dictionary maps column names to objects describing the schema for each column. (Each object has ``type`` as the declared type string returned by SQLite, or ``""`` if no type was declared; ``sqlite_type`` as the normalized SQLite affinity, one of ``TEXT``, ``INTEGER``, ``REAL``, ``BLOB`` or ``NUMERIC``; ``notnull`` as a boolean; ``default`` as the raw SQL default expression string, such as ``"42"``, ``"'hello'"`` or ``"datetime('now')"``, or ``null`` if there is no default; ``is_pk`` as a boolean; ``pk_position`` as the integer primary key position reported by SQLite, or ``0`` for columns that are not part of the primary key; and ``hidden`` as the integer value reported by SQLite's ``PRAGMA table_xinfo``. ``hidden`` is ``0`` for normal columns, ``1`` for hidden virtual table columns, ``2`` for virtual generated columns and ``3`` for stored generated columns.)
+
+ ``GET /fixtures/binary_data/1.json?_extra=column_details``
+
+ .. code-block:: json
+
+ {
+ "data": {
+ "type": "BLOB",
+ "sqlite_type": "BLOB",
+ "notnull": false,
+ "default": null,
+ "is_pk": false,
+ "pk_position": 0,
+ "hidden": 0
+ }
+ }
+
``render_cell``
Rendered HTML for each cell using the render_cell plugin hook (See the :ref:`render_cell() plugin hook ` documentation.)
@@ -1626,6 +1664,8 @@ The JSON write API
Datasette provides a write API for JSON data. This is a POST-only API that requires an authenticated API token, see :ref:`CreateTokenView`. The token will need to have the specified :ref:`authentication_permissions`.
+The row-based write APIs can write :ref:`binary values in JSON ` using Datasette's Base64 representation for BLOB data.
+
.. _ExecuteWriteView:
Executing write SQL
@@ -1756,6 +1796,8 @@ A single row can be inserted using the ``"row"`` key:
}
}
+Column values can use the :ref:`binary value JSON format ` to write BLOB data.
+
If successful, this will return a ``201`` status code and the newly inserted row, for example:
.. code-block:: json
@@ -1863,6 +1905,8 @@ An upsert is an insert or update operation. If a row with a matching primary key
The upsert API is mostly the same shape as the :ref:`insert API `. It requires both the :ref:`actions_insert_row` and :ref:`actions_update_row` permissions.
+It also accepts the same :ref:`binary value JSON format `.
+
::
POST ///-/upsert
@@ -1995,6 +2039,8 @@ To update a row, make a ``POST`` to ``////-/update``.
You only need to pass the columns you want to update. Any other columns will be left unchanged.
+Updated values can use the :ref:`binary value JSON format `.
+
If successful, this will return a ``200`` status code and a ``{"ok": true}`` response body.
Add ``"return": true`` to the request body to return the updated row:
@@ -2200,6 +2246,8 @@ Datasette will create a table with a schema that matches those rows and insert t
"pk": "id"
}
+Example rows can use the :ref:`binary value JSON format `, allowing Datasette to infer ``BLOB`` columns.
+
Doing this requires both the :ref:`actions_create_table` and :ref:`actions_insert_row` permissions.
The ``201`` response here will be similar to the ``columns`` form, but will also include the number of rows that were inserted as ``row_count``:
diff --git a/docs/settings.rst b/docs/settings.rst
index 5cd49113..9c114e4a 100644
--- a/docs/settings.rst
+++ b/docs/settings.rst
@@ -125,6 +125,23 @@ You can increase or decrease this limit like so::
datasette mydatabase.db --setting max_insert_rows 1000
+.. _setting_max_post_body_bytes:
+
+max_post_body_bytes
+~~~~~~~~~~~~~~~~~~~
+
+Maximum size in bytes for a POST body that Datasette reads fully into memory, such as JSON submitted to the :ref:`write API `. Requests with larger bodies are rejected with an HTTP 413 error. Defaults to 2,097,152 (2MB).
+
+This limit exists to protect against memory exhaustion: unlike file uploads handled by ``request.form()``, which stream to disk, these bodies are held entirely in memory and parsing them as JSON can multiply their memory footprint several times over.
+
+If you increase :ref:`setting_max_insert_rows` to support larger bulk inserts you may need to increase this limit as well::
+
+ datasette mydatabase.db --setting max_post_body_bytes 10485760
+
+Set it to 0 to disable the limit entirely::
+
+ datasette mydatabase.db --setting max_post_body_bytes 0
+
.. _setting_num_sql_threads:
num_sql_threads
diff --git a/docs/template_context.rst b/docs/template_context.rst
index 5c6b1567..e9447058 100644
--- a/docs/template_context.rst
+++ b/docs/template_context.rst
@@ -98,7 +98,7 @@ The page listing the tables, views and queries in a database, e.g. /fixtures. Re
The color assigned to the database
``database_page_data`` - ``dict``
- JSON data used by JavaScript on the database page. Currently ``{}`` or ``{"createTable": {...}}`` where ``createTable`` includes ``path``, ``foreignKeyTargetsPath``, ``databaseName``, ``columnTypes``, ``defaultExpressions`` and optional ``customColumnTypes``.
+ JSON data used by JavaScript on the database page. Currently ``{}`` or ``{"createTable": {...}}`` where ``createTable`` includes ``path``, ``foreignKeyTargetsPath``, ``databaseName``, ``columnTypes``, ``defaultExpressions``, ``canInsertRows`` and optional ``customColumnTypes``.
``editable`` - ``bool``
Boolean indicating if the database is editable
@@ -389,7 +389,7 @@ Many of these keys are shared with the :ref:`JSON API ` for this page.
SQL definition for this table
``table_insert_ui`` - ``dict``
- Information needed to enable the row insertion UI, or ``None`` if row insertion is not available to the current actor. When present it has ``path``, ``tableName``, ``columns`` and ``primaryKeys`` keys; each column includes ``name``, ``sqlite_type``, ``notnull``, ``default``, ``has_default``, ``is_pk``, ``value_kind`` and ``column_type`` keys.
+ Information needed to enable the row insertion UI, or ``None`` if row insertion is not available to the current actor. When present it has ``path``, ``tableName``, ``columns``, ``bulkColumns``, ``primaryKeys`` and ``maxInsertRows`` keys, plus optional ``upsertPath`` if the current actor has permission to update rows. ``columns`` lists columns for the single-row insert form, while ``bulkColumns`` lists columns for the bulk insert form. Each column includes ``name``, ``sqlite_type``, ``notnull``, ``default``, ``has_default``, ``is_pk``, ``is_auto_pk``, ``value_kind`` and ``column_type`` keys.
``table_page_data`` - ``dict``
JSON data used by JavaScript on the table page. Includes ``database``, ``table`` and ``tableUrl``, plus optional ``foreignKeys`` mapping column names to autocomplete URLs, optional ``insertRow`` data and optional ``alterTable`` data.
diff --git a/pyproject.toml b/pyproject.toml
index 215b2cca..38776b2c 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -35,7 +35,7 @@ dependencies = [
"PyYAML>=5.3",
"mergedeep>=1.1.1",
"itsdangerous>=1.1",
- "sqlite-utils>=3.30,<4.0",
+ "sqlite-utils>=3.30",
"asyncinject>=0.7",
"setuptools",
"pip",
diff --git a/tests/test_api.py b/tests/test_api.py
index 8ce38fad..da0ebc07 100644
--- a/tests/test_api.py
+++ b/tests/test_api.py
@@ -435,7 +435,7 @@ async def test_row_foreign_key_tables(ds_client):
@pytest.mark.asyncio
async def test_row_extras(ds_client):
response = await ds_client.get(
- "/fixtures/simple_primary_key/1.json?_extra=database,table,primary_keys,query,request,debug,foreign_key_tables"
+ "/fixtures/simple_primary_key/1.json?_extra=database,table,primary_keys,query,request,debug,foreign_key_tables,column_details"
)
assert response.status_code == 200
data = response.json()
@@ -452,6 +452,45 @@ async def test_row_extras(ds_client):
"format": "json",
}
assert len(data["foreign_key_tables"]) == 5
+ id_detail = data["column_details"]["id"]
+ assert id_detail["type"].lower() == "integer"
+ assert id_detail == {
+ "type": id_detail["type"],
+ "sqlite_type": "INTEGER",
+ "notnull": False,
+ "default": None,
+ "is_pk": True,
+ "pk_position": 1,
+ "hidden": 0,
+ }
+ content_detail = data["column_details"]["content"]
+ assert content_detail["type"].lower() == "text"
+ assert content_detail == {
+ "type": content_detail["type"],
+ "sqlite_type": "TEXT",
+ "notnull": False,
+ "default": None,
+ "is_pk": False,
+ "pk_position": 0,
+ "hidden": 0,
+ }
+
+
+@pytest.mark.asyncio
+async def test_column_details_extra_row_for_null_blob(ds_client):
+ response = await ds_client.get("/fixtures/binary_data/3.json?_extra=column_details")
+ assert response.status_code == 200
+ data_detail = response.json()["column_details"]["data"]
+ assert data_detail["type"].lower() == "blob"
+ assert data_detail == {
+ "type": data_detail["type"],
+ "sqlite_type": "BLOB",
+ "notnull": False,
+ "default": None,
+ "is_pk": False,
+ "pk_position": 0,
+ "hidden": 0,
+ }
@pytest.mark.asyncio
@@ -627,6 +666,7 @@ async def test_settings_json(ds_client):
"facet_time_limit_ms": 200,
"max_returned_rows": 100,
"max_insert_rows": 100,
+ "max_post_body_bytes": 2 * 1024 * 1024,
"sql_time_limit_ms": 200,
"allow_download": True,
"allow_signed_tokens": True,
diff --git a/tests/test_api_write.py b/tests/test_api_write.py
index e3363db0..7145a859 100644
--- a/tests/test_api_write.py
+++ b/tests/test_api_write.py
@@ -3,9 +3,31 @@ from datasette.events import RenameTableEvent
from datasette.utils import error_body, escape_sqlite, sqlite3
from .utils import last_event
import pytest
+import re
import time
+def schema_variants(schema):
+ # sqlite-utils < 4 quotes identifiers [like_this] and uses FLOAT;
+ # sqlite-utils >= 4 quotes them "like_this" and uses REAL. Given a
+ # schema fragment in the old format, return both variants so tests
+ # can pass against either version.
+ converted = re.sub(r"\[([^\]]+)\]", r'"\1"', schema).replace("FLOAT", "REAL")
+ return (schema, converted)
+
+
+def assert_schema_contains(fragment, schema):
+ assert any(
+ variant in schema for variant in schema_variants(fragment)
+ ), "Expected schema to contain {!r}, got {!r}".format(fragment, schema)
+
+
+def assert_schema_not_contains(fragment, schema):
+ assert not any(
+ variant in schema for variant in schema_variants(fragment)
+ ), "Expected schema not to contain {!r}, got {!r}".format(fragment, schema)
+
+
@pytest.fixture
def ds_write(tmp_path_factory):
db_directory = tmp_path_factory.mktemp("dbs")
@@ -50,6 +72,133 @@ def _insert_and_fetch_created(conn, table, insert_sql):
).fetchone()
+BASE64_WRITE_API_VALUE = {"$base64": True, "encoded": "AAEC/f7/"}
+BASE64_WRITE_API_LITERAL = '{"$base64": true, "encoded": "AAEC/f7/"}'
+
+
+@pytest.mark.asyncio
+async def test_base64_write_api_create_table_infers_blob_and_raw_escapes(ds_write):
+ token = write_token(ds_write)
+ response = await ds_write.client.post(
+ "/data/-/create",
+ json={
+ "table": "binary_create",
+ "row": {
+ "id": 1,
+ "data": BASE64_WRITE_API_VALUE,
+ "literal": {"$raw": BASE64_WRITE_API_VALUE},
+ "double_raw": {"$raw": {"$raw": BASE64_WRITE_API_VALUE}},
+ },
+ "pk": "id",
+ },
+ headers=_headers(token),
+ )
+ assert response.status_code == 201
+ assert_schema_contains("[data] BLOB", response.json()["schema"])
+ assert_schema_contains("[literal] TEXT", response.json()["schema"])
+
+ rows = (await ds_write.get_database("data").execute("""
+ select
+ typeof(data) as data_type,
+ hex(data) as data_hex,
+ typeof(literal) as literal_type,
+ literal,
+ typeof(double_raw) as double_raw_type,
+ double_raw
+ from binary_create
+ """)).dicts()
+ assert rows == [
+ {
+ "data_type": "blob",
+ "data_hex": "000102FDFEFF",
+ "literal_type": "text",
+ "literal": BASE64_WRITE_API_LITERAL,
+ "double_raw_type": "text",
+ "double_raw": '{"$raw": {"$base64": true, "encoded": "AAEC/f7/"}}',
+ }
+ ]
+
+
+@pytest.mark.asyncio
+async def test_base64_write_api_insert_upsert_update_decode_blobs(ds_write):
+ token = write_token(ds_write)
+ db = ds_write.get_database("data")
+ await db.execute_write(
+ "create table binary_api (id integer primary key, data blob, literal text)"
+ )
+
+ insert_response = await ds_write.client.post(
+ "/data/binary_api/-/insert",
+ json={
+ "row": {
+ "id": 1,
+ "data": BASE64_WRITE_API_VALUE,
+ "literal": {"$raw": BASE64_WRITE_API_VALUE},
+ }
+ },
+ headers=_headers(token),
+ )
+ assert insert_response.status_code == 201
+ assert insert_response.json()["rows"][0]["data"] == BASE64_WRITE_API_VALUE
+
+ upsert_response = await ds_write.client.post(
+ "/data/binary_api/-/upsert",
+ json={
+ "rows": [
+ {
+ "id": 2,
+ "data": BASE64_WRITE_API_VALUE,
+ "literal": {"$raw": BASE64_WRITE_API_VALUE},
+ }
+ ]
+ },
+ headers=_headers(token),
+ )
+ assert upsert_response.status_code == 200
+ assert upsert_response.json() == {"ok": True}
+
+ update_response = await ds_write.client.post(
+ "/data/binary_api/1/-/update",
+ json={
+ "update": {
+ "data": {"$base64": True, "encoded": "/wAB"},
+ "literal": {"$raw": {"$raw": BASE64_WRITE_API_VALUE}},
+ },
+ "return": True,
+ },
+ headers=_headers(token),
+ )
+ assert update_response.status_code == 200
+ assert update_response.json()["row"]["data"] == {"$base64": True, "encoded": "/wAB"}
+
+ rows = (await db.execute("""
+ select
+ id,
+ typeof(data) as data_type,
+ hex(data) as data_hex,
+ typeof(literal) as literal_type,
+ literal
+ from binary_api
+ order by id
+ """)).dicts()
+ assert rows == [
+ {
+ "id": 1,
+ "data_type": "blob",
+ "data_hex": "FF0001",
+ "literal_type": "text",
+ "literal": '{"$raw": {"$base64": true, "encoded": "AAEC/f7/"}}',
+ },
+ {
+ "id": 2,
+ "data_type": "blob",
+ "data_hex": "000102FDFEFF",
+ "literal_type": "text",
+ "literal": BASE64_WRITE_API_LITERAL,
+ },
+ ]
+
+
@pytest.mark.asyncio
async def test_api_explorer_upsert_example_json(ds_write):
response = await ds_write.client.get("/-/api", actor={"id": "root"})
@@ -180,6 +329,35 @@ async def test_insert_rows(ds_write, return_rows):
assert response.json()["rows"] == actual_rows
+@pytest.mark.asyncio
+async def test_insert_rows_post_body_too_large(tmp_path_factory):
+ db_path = str(tmp_path_factory.mktemp("dbs") / "data.db")
+ conn = sqlite3.connect(db_path)
+ conn.execute("create table docs (id integer primary key, title text)")
+ conn.close()
+ ds = Datasette([db_path], settings={"max_post_body_bytes": 100})
+ ds.root_enabled = True
+ token = write_token(ds)
+ response = await ds.client.post(
+ "/data/docs/-/insert",
+ json={"rows": [{"title": "x" * 200}]},
+ headers=_headers(token),
+ )
+ assert response.status_code == 413
+ assert response.json() == {
+ "ok": False,
+ "errors": ["Request body exceeded maximum size of 100 bytes"],
+ }
+ # A small body should still work
+ response2 = await ds.client.post(
+ "/data/docs/-/insert",
+ json={"row": {"title": "hi"}},
+ headers=_headers(token),
+ )
+ assert response2.status_code == 201
+ ds.close()
+
+
@pytest.mark.asyncio
@pytest.mark.parametrize(
"path,input,special_case,expected_status,expected_errors",
@@ -1047,7 +1225,9 @@ async def test_alter_table_foreign_key_operations(ds_write):
assert response.status_code == 200, response.text
data = response.json()
assert data["operations_applied"] == 2
- assert "[owner_id] INTEGER REFERENCES [owners]([id])" in data["schema"]
+ assert_schema_contains(
+ "[owner_id] INTEGER REFERENCES [owners]([id])", data["schema"]
+ )
response = await ds_write.client.post(
"/data/docs/-/alter",
@@ -1058,7 +1238,7 @@ async def test_alter_table_foreign_key_operations(ds_write):
)
assert response.status_code == 200, response.text
data = response.json()
- assert "[owner_id] INTEGER REFERENCES" not in data["schema"]
+ assert_schema_not_contains("[owner_id] INTEGER REFERENCES", data["schema"])
response = await ds_write.client.post(
"/data/docs/-/alter",
@@ -1082,7 +1262,9 @@ async def test_alter_table_foreign_key_operations(ds_write):
)
assert response.status_code == 200, response.text
data = response.json()
- assert "[owner_id] INTEGER REFERENCES [categories]([id])" in data["schema"]
+ assert_schema_contains(
+ "[owner_id] INTEGER REFERENCES [categories]([id])", data["schema"]
+ )
response = await ds_write.client.post(
"/data/docs/-/alter",
@@ -1091,7 +1273,7 @@ async def test_alter_table_foreign_key_operations(ds_write):
)
assert response.status_code == 200, response.text
data = response.json()
- assert "[owner_id] INTEGER REFERENCES" not in data["schema"]
+ assert_schema_not_contains("[owner_id] INTEGER REFERENCES", data["schema"])
@pytest.mark.asyncio
@@ -2019,6 +2201,9 @@ async def test_create_table(
if expected_response.get("ok") is False:
# Error expectations list their messages; derive the canonical envelope
expected_response = error_body(expected_response["errors"], expected_status)
+ if isinstance(expected_response, dict) and "schema" in expected_response:
+ assert data.get("schema") in schema_variants(expected_response["schema"])
+ expected_response = dict(expected_response, schema=data.get("schema"))
assert data == expected_response
# Should have tracked the expected events
events = ds_write._tracked_events
@@ -2061,7 +2246,9 @@ async def test_create_table_with_foreign_key(ds_write):
)
assert response.status_code == 201
data = response.json()
- assert "[owner_id] INTEGER REFERENCES [owners]([id])" in data["schema"]
+ assert_schema_contains(
+ "[owner_id] INTEGER REFERENCES [owners]([id])", data["schema"]
+ )
@pytest.mark.asyncio
diff --git a/tests/test_internal_db.py b/tests/test_internal_db.py
index 26d63a92..340c4813 100644
--- a/tests/test_internal_db.py
+++ b/tests/test_internal_db.py
@@ -1,5 +1,6 @@
import pytest
-import sqlite_utils
+
+from datasette.utils import escape_sqlite
# ensure refresh_schemas() gets called before interacting with internal_db
@@ -76,19 +77,71 @@ async def test_internal_foreign_key_references(ds_client):
internal_db = await ensure_internal(ds_client)
def inner(conn):
- db = sqlite_utils.Database(conn)
- table_names = db.table_names()
- for table in db.tables:
- for fk in table.foreign_keys:
- other_table = fk.other_table
- other_column = fk.other_column
- message = 'Column "{}.{}" references other column "{}.{}" which does not exist'.format(
- table.name, fk.column, other_table, other_column
+ table_names = [
+ row[0]
+ for row in conn.execute(
+ "select name from sqlite_master where type = 'table'"
+ ).fetchall()
+ ]
+
+ def columns_for_table(table_name):
+ return {
+ row[1]
+ for row in conn.execute(
+ "PRAGMA table_info({})".format(escape_sqlite(table_name))
+ ).fetchall()
+ }
+
+ def primary_keys_for_table(table_name):
+ return [
+ name
+ for _, name in sorted(
+ (row[5], row[1])
+ for row in conn.execute(
+ "PRAGMA table_info({})".format(escape_sqlite(table_name))
+ ).fetchall()
+ if row[5]
+ )
+ ]
+
+ columns_by_table = {
+ table_name: columns_for_table(table_name) for table_name in table_names
+ }
+
+ for table_name in table_names:
+ foreign_key_rows = conn.execute(
+ "PRAGMA foreign_key_list({})".format(escape_sqlite(table_name))
+ ).fetchall()
+ foreign_keys_by_id = {}
+ for foreign_key in foreign_key_rows:
+ foreign_keys_by_id.setdefault(foreign_key[0], []).append(foreign_key)
+
+ for foreign_key_rows in foreign_keys_by_id.values():
+ foreign_key_rows.sort(key=lambda row: row[1])
+ other_table = foreign_key_rows[0][2]
+ other_columns = [row[4] for row in foreign_key_rows]
+ message = 'Column "{}.{}" references other table "{}" which does not exist'.format(
+ table_name, foreign_key_rows[0][3], other_table
)
assert other_table in table_names, message + " (bad table)"
- assert other_column in db[other_table].columns_dict, (
- message + " (bad column)"
+ if all(other_column is None for other_column in other_columns):
+ other_columns = primary_keys_for_table(other_table)
+ length_message = 'Foreign key from "{}" to "{}" has {} columns but references {} columns'.format(
+ table_name,
+ other_table,
+ len(foreign_key_rows),
+ len(other_columns),
)
+ assert len(other_columns) == len(foreign_key_rows), length_message
+
+ for foreign_key, other_column in zip(foreign_key_rows, other_columns):
+ column = foreign_key[3]
+ message = 'Column "{}.{}" references other column "{}.{}" which does not exist'.format(
+ table_name, column, other_table, other_column
+ )
+ assert other_column in columns_by_table[other_table], (
+ message + " (bad column)"
+ )
await internal_db.execute_fn(inner)
diff --git a/tests/test_internals_request.py b/tests/test_internals_request.py
index 9c448186..6d2dc70a 100644
--- a/tests/test_internals_request.py
+++ b/tests/test_internals_request.py
@@ -1,8 +1,38 @@
-from datasette.utils.asgi import Request
+from datasette.utils.asgi import PayloadTooLarge, Request
import json
import pytest
+def _post_scope(headers=None):
+ return {
+ "http_version": "1.1",
+ "method": "POST",
+ "path": "/",
+ "raw_path": b"/",
+ "query_string": b"",
+ "scheme": "http",
+ "type": "http",
+ "headers": headers or [[b"content-type", b"application/json"]],
+ }
+
+
+def _receive_chunks(chunks):
+ messages = [
+ {
+ "type": "http.request",
+ "body": chunk,
+ "more_body": i < len(chunks) - 1,
+ }
+ for i, chunk in enumerate(chunks)
+ ]
+ messages.reverse()
+
+ async def receive():
+ return messages.pop()
+
+ return receive
+
+
@pytest.mark.asyncio
async def test_request_post_vars():
scope = {
@@ -106,6 +136,70 @@ async def test_request_json_invalid():
await request.json()
+@pytest.mark.asyncio
+async def test_request_post_body_multiple_chunks():
+ request = Request(_post_scope(), _receive_chunks([b"hello ", b"world"]))
+ assert await request.post_body() == b"hello world"
+
+
+@pytest.mark.asyncio
+async def test_request_post_body_content_length_too_large():
+ # Should reject based on content-length without reading the body
+ async def receive():
+ raise AssertionError("receive() should not be called")
+
+ scope = _post_scope(
+ headers=[
+ [b"content-type", b"application/json"],
+ [b"content-length", b"101"],
+ ]
+ )
+ request = Request(scope, receive)
+ with pytest.raises(PayloadTooLarge):
+ await request.post_body(max_bytes=100)
+
+
+@pytest.mark.asyncio
+async def test_request_post_body_streaming_too_large():
+ # No content-length header - limit enforced as chunks arrive
+ chunks = [b"a" * 60, b"b" * 60, b"c" * 60]
+ request = Request(_post_scope(), _receive_chunks(chunks))
+ with pytest.raises(PayloadTooLarge):
+ await request.post_body(max_bytes=100)
+
+
+@pytest.mark.asyncio
+async def test_request_post_body_limit_from_constructor():
+ request = Request(
+ _post_scope(), _receive_chunks([b"too much data"]), max_post_body_bytes=5
+ )
+ with pytest.raises(PayloadTooLarge):
+ await request.post_body()
+
+
+@pytest.mark.asyncio
+async def test_request_post_body_limit_disabled():
+ body = b"a" * (3 * 1024 * 1024)
+ request = Request(_post_scope(), _receive_chunks([body]), max_post_body_bytes=0)
+ assert await request.post_body() == body
+
+
+@pytest.mark.asyncio
+async def test_request_post_body_default_limit():
+ # Bodies over 2MB are rejected by default
+ request = Request(_post_scope(), _receive_chunks([b"a" * (2 * 1024 * 1024 + 1)]))
+ with pytest.raises(PayloadTooLarge):
+ await request.post_body()
+
+
+@pytest.mark.asyncio
+async def test_request_json_too_large():
+ body = json.dumps({"rows": ["x" * 100]}).encode("utf-8")
+ request = Request(_post_scope(), _receive_chunks([body]), max_post_body_bytes=50)
+ with pytest.raises(PayloadTooLarge):
+ await request.json()
+
+
def test_request_args():
request = Request.fake("/foo?multi=1&multi=2&single=3")
assert "1" == request.args.get("multi")
diff --git a/tests/test_playwright.py b/tests/test_playwright.py
index ee396de5..eb1edb57 100644
--- a/tests/test_playwright.py
+++ b/tests/test_playwright.py
@@ -1,3 +1,4 @@
+import base64
import json
import socket
import subprocess
@@ -10,6 +11,10 @@ import pytest
from datasette.fixtures import write_fixture_database
from datasette.utils.sqlite import sqlite3
+PNG_1X1_BYTES = base64.b64decode(
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII="
+)
+
def find_free_port():
with socket.socket() as sock:
@@ -17,7 +22,7 @@ def find_free_port():
return sock.getsockname()[1]
-def wait_for_server(process, url, timeout=10):
+def wait_for_server(process, url, timeout=30):
deadline = time.monotonic() + timeout
last_error = None
while time.monotonic() < deadline:
@@ -36,7 +41,20 @@ def wait_for_server(process, url, timeout=10):
except httpx.HTTPError as ex:
last_error = repr(ex)
time.sleep(0.1)
- raise AssertionError(f"Timed out waiting for {url}: {last_error}")
+ if process.poll() is None:
+ process.terminate()
+ try:
+ stdout, stderr = process.communicate(timeout=5)
+ except subprocess.TimeoutExpired:
+ process.kill()
+ stdout, stderr = process.communicate()
+ else:
+ stdout, stderr = process.communicate()
+ raise AssertionError(
+ f"Timed out waiting for {url}: {last_error}\n"
+ f"stdout:\n{stdout}\n"
+ f"stderr:\n{stderr}"
+ )
@pytest.fixture
@@ -102,6 +120,22 @@ def write_playwright_database(db_path):
id integer primary key,
created_ms integer default (CAST((julianday('now') - 2440587.5) * 86400000 AS INTEGER))
);
+ create table binary_files (
+ id integer primary key,
+ name text not null,
+ data blob
+ );
+ create table bulk_defaults (
+ id integer primary key,
+ title text not null,
+ status text not null default 'todo',
+ score integer default 5
+ );
+ create table upsert_items (
+ id text primary key,
+ title text,
+ metadata text
+ );
insert into projects (title, metadata, logo, notes, score) values
(
'Build Datasette',
@@ -110,7 +144,22 @@ def write_playwright_database(db_path):
'Initial notes',
5
);
+ insert into upsert_items (id, title, metadata) values
+ ('existing', 'Existing title', '{"old": true}');
""")
+ conn.execute(
+ "insert into binary_files (name, data) values (?, ?)",
+ ("Raw bytes", b"\x00\x01\x02\x03"),
+ )
+ conn.execute(
+ "insert into binary_files (name, data) values (?, ?)",
+ ("PNG image", PNG_1X1_BYTES),
+ )
+ conn.execute(
+ "insert into binary_files (name, data) values (?, ?)",
+ ("Null bytes", None),
+ )
+ conn.commit()
finally:
conn.close()
@@ -123,6 +172,7 @@ def write_playwright_config(config_path):
"data": {
"permissions": {
"create-table": True,
+ "insert-row": True,
"set-column-type": True,
},
"tables": {
@@ -145,6 +195,25 @@ def write_playwright_config(config_path):
"alter-table": True,
},
},
+ "binary_files": {
+ "label_column": "name",
+ "permissions": {
+ "insert-row": True,
+ "update-row": True,
+ "delete-row": True,
+ },
+ },
+ "bulk_defaults": {
+ "permissions": {
+ "insert-row": True,
+ },
+ },
+ "upsert_items": {
+ "permissions": {
+ "insert-row": True,
+ "update-row": True,
+ },
+ },
},
},
},
@@ -278,6 +347,58 @@ def project_row(datasette_server, pk):
return rows[0]
+def binary_file_blob(datasette_server, pk):
+ response = httpx.get(
+ f"{datasette_server}data/binary_files/{pk}.blob",
+ params={"_blob_column": "data"},
+ )
+ response.raise_for_status()
+ return response.content
+
+
+def wait_for_binary_control_file(binary_control, size, name=None):
+ binary_control.locator(
+ ".row-edit-binary-size", has_text=f"Binary: {size} bytes"
+ ).wait_for()
+ if name:
+ binary_control.locator(".row-edit-binary-name", has_text=name).wait_for()
+
+
+def bulk_default_rows(datasette_server, **filters):
+ params = {
+ "_shape": "objects",
+ **{key: str(value) for key, value in filters.items()},
+ }
+ response = httpx.get(f"{datasette_server}data/bulk_defaults.json", params=params)
+ response.raise_for_status()
+ return response.json()["rows"]
+
+
+def upsert_item_rows(datasette_server, **filters):
+ params = {
+ "_shape": "objects",
+ **{key: str(value) for key, value in filters.items()},
+ }
+ response = httpx.get(f"{datasette_server}data/upsert_items.json", params=params)
+ response.raise_for_status()
+ return response.json()["rows"]
+
+
+def upsert_item_row(datasette_server, pk):
+ rows = upsert_item_rows(datasette_server, id=pk)
+ assert len(rows) == 1
+ return rows[0]
+
+
+def open_bulk_insert_dialog(page, url):
+ page.goto(url)
+ page.locator('button[data-table-action="insert-row"]').click()
+ dialog = page.locator("#row-edit-dialog")
+ dialog.wait_for()
+ dialog.locator(".row-edit-bulk-insert").click()
+ return dialog
+
+
def open_jump_menu(page):
page.keyboard.press("/")
page.locator("navigation-search .search-input").wait_for()
@@ -381,6 +502,165 @@ def test_create_table_flow(page, datasette_server):
assert "NOT NULL DEFAULT 'Untitled'" in schema
+@pytest.mark.playwright
+def test_create_table_from_data_flow(page, datasette_server):
+ page.goto(f"{datasette_server}data")
+ page.locator("details.actions-menu-links summary").click()
+ page.locator('button[data-database-action="create-table"]').click()
+
+ dialog = page.locator("#table-create-dialog")
+ dialog.wait_for()
+ from_data = dialog.locator(".table-create-from-data")
+ assert from_data.inner_text() == "Create table from data"
+ from_data.click()
+
+ assert dialog.locator(".table-create-columns").is_hidden()
+ assert dialog.locator(".table-create-data").is_visible()
+ assert dialog.locator(".table-create-save").inner_text() == "Preview rows"
+ assert (
+ dialog.locator(".table-create-data-note").inner_text()
+ == "Paste TSV, CSV, or JSON. You can also open a file or drop it onto this textarea"
+ )
+ assert dialog.locator(".table-create-data-open-file").inner_text() == "open a file"
+ assert (
+ dialog.locator(".table-create-data-editor").evaluate(
+ """node => Array.from(node.children)
+ .filter((child) => !child.hidden)
+ .map((child) => child.className)
+ .join(" ")"""
+ )
+ == ("table-create-data-note table-create-input table-create-data-textarea")
+ )
+ assert dialog.locator(".table-create-manual").inner_text() == (
+ "Create table manually"
+ )
+ assert (
+ dialog.get_by_label("Paste TSV, CSV, or JSON").get_attribute("id")
+ == "table-create-data-textarea"
+ )
+
+ textarea = dialog.locator(".table-create-data-textarea")
+ dropped_value = textarea.evaluate("""node => new Promise((resolve) => {
+ node.addEventListener("input", () => resolve(node.value), { once: true });
+ const file = new File(["short_id,name\\nx,Ada"], "Repo Export 2026!!.CSV", { type: "text/csv" });
+ const dataTransfer = new DataTransfer();
+ dataTransfer.items.add(file);
+ node.dispatchEvent(new DragEvent("dragenter", { bubbles: true, cancelable: true, dataTransfer }));
+ node.dispatchEvent(new DragEvent("dragover", { bubbles: true, cancelable: true, dataTransfer }));
+ node.dispatchEvent(new DragEvent("drop", { bubbles: true, cancelable: true, dataTransfer }));
+ })""")
+ assert dropped_value == "short_id,name\nx,Ada"
+ assert dialog.locator(".table-create-table-name").input_value() == (
+ "repo_export_2026"
+ )
+
+ dialog.locator(".table-create-table-name").fill("playwright_from_data")
+ textarea.fill(
+ json.dumps(
+ {
+ "metadata": [{"short_id": "a", "name": "Ignored", "score": 9}],
+ "people": [
+ {"short_id": "b", "name": "Ada", "score": 1},
+ {"short_id": "c", "name": "Bob", "score": 2.5},
+ ],
+ }
+ )
+ )
+ dialog.locator(".table-create-save").click()
+
+ assert dialog.locator(".table-create-data-textarea").is_hidden()
+ assert dialog.locator(".table-create-save").inner_text() == "Create table"
+ assert dialog.locator(".table-create-cancel").inner_text() == "Back"
+ assert dialog.locator(".table-create-data-preview-summary").inner_text() == (
+ "Previewing 2 rows."
+ )
+ assert dialog.locator(".table-create-data-primary-key").input_value() == "short_id"
+ preview_text = dialog.locator(".table-create-data-preview-table").inner_text()
+ assert "short_id" in preview_text
+ assert "Ada" in preview_text
+ assert "2.5" in preview_text
+ preview_cell_style = dialog.locator(
+ ".table-create-data-preview-table td"
+ ).first.evaluate(
+ """node => ({
+ overflowWrap: getComputedStyle(node).overflowWrap,
+ whiteSpace: getComputedStyle(node).whiteSpace
+ })"""
+ )
+ assert preview_cell_style == {
+ "overflowWrap": "anywhere",
+ "whiteSpace": "normal",
+ }
+
+ dialog.locator(".table-create-cancel").click()
+ assert dialog.evaluate("node => node.open")
+ assert dialog.locator(".table-create-data-textarea").is_visible()
+ assert '"people"' in dialog.locator(".table-create-data-textarea").input_value()
+ assert dialog.locator(".table-create-save").inner_text() == "Preview rows"
+
+ dialog.locator(".table-create-save").click()
+ assert dialog.locator(".table-create-save").inner_text() == "Create table"
+ dialog.locator(".table-create-save").click()
+ page.wait_for_url("**/data/playwright_from_data")
+
+ response = httpx.get(
+ f"{datasette_server}data/playwright_from_data.json?_shape=objects"
+ )
+ response.raise_for_status()
+ data = response.json()
+ assert data["rows"] == [
+ {"short_id": "b", "name": "Ada", "score": 1},
+ {"short_id": "c", "name": "Bob", "score": 2.5},
+ ]
+
+
+@pytest.mark.playwright
+def test_create_table_from_csv_keeps_numeric_type_when_values_are_blank(
+ page, datasette_server
+):
+ page.goto(f"{datasette_server}data")
+ page.locator("details.actions-menu-links summary").click()
+ page.locator('button[data-database-action="create-table"]').click()
+
+ dialog = page.locator("#table-create-dialog")
+ dialog.wait_for()
+ dialog.locator(".table-create-from-data").click()
+ dialog.locator(".table-create-table-name").fill("playwright_numeric_blanks")
+ dialog.locator(".table-create-data-textarea").fill("name,score\nA,1\nB,")
+ dialog.locator(".table-create-save").click()
+
+ assert dialog.locator(".table-create-save").inner_text() == "Create table"
+ preview_text = dialog.locator(".table-create-data-preview-table").inner_text()
+ assert "A" in preview_text
+ assert "1" in preview_text
+ assert "B" in preview_text
+ assert "null" in preview_text
+
+ dialog.locator(".table-create-save").click()
+ page.wait_for_url("**/data/playwright_numeric_blanks")
+
+ response = httpx.get(
+ f"{datasette_server}data/playwright_numeric_blanks.json?_shape=objects"
+ )
+ response.raise_for_status()
+ assert response.json()["rows"] == [
+ {"name": "A", "score": 1},
+ {"name": "B", "score": None},
+ ]
+
+ schema_response = httpx.get(
+ f"{datasette_server}data/-/query.json",
+ params={
+ "sql": (
+ "select type from pragma_table_info('playwright_numeric_blanks') "
+ "where name = 'score'"
+ )
+ },
+ )
+ schema_response.raise_for_status()
+ assert schema_response.json()["rows"] == [{"type": "INTEGER"}]
+
+
@pytest.mark.playwright
def test_create_table_foreign_key_selection_updates_column_type(page, datasette_server):
page.goto(f"{datasette_server}data")
@@ -801,11 +1081,128 @@ def test_navigation_search_renders_jump_sections_from_javascript_plugins(
@pytest.mark.playwright
def test_insert_row_flow_uses_custom_column_field(page, datasette_server):
+ page.add_init_script("""
+ (() => {
+ let clipboardText = "";
+ Object.defineProperty(navigator, "clipboard", {
+ configurable: true,
+ get: () => ({
+ writeText: async (text) => {
+ clipboardText = String(text);
+ },
+ readText: async () => clipboardText,
+ }),
+ });
+ })();
+ """)
page.goto(f"{datasette_server}data/projects")
page.locator('button[data-table-action="insert-row"]').click()
dialog = page.locator("#row-edit-dialog")
dialog.wait_for()
+ bulk_insert = dialog.locator(".row-edit-bulk-insert")
+ bulk_insert.wait_for()
+ assert bulk_insert.inner_text() == "Insert multiple rows"
+ current_url = page.url
+ bulk_insert.click()
+ assert page.url == current_url
+ assert dialog.evaluate("node => node.open")
+ assert dialog.locator(".row-edit-fields").is_hidden()
+ assert dialog.locator(".row-edit-bulk").is_visible()
+ assert dialog.locator(".row-edit-save").inner_text() == "Preview rows"
+ assert (
+ dialog.locator(".row-edit-bulk-note").inner_text()
+ == "Paste TSV, CSV, or JSON. You can also open a file or drop it onto this textarea"
+ )
+ assert dialog.locator(".row-edit-bulk-open-file").inner_text() == "open a file"
+ assert (
+ dialog.locator(".row-edit-bulk-editor").evaluate(
+ """node => Array.from(node.children)
+ .filter((child) => !child.hidden)
+ .map((child) => child.className)
+ .join(" ")"""
+ )
+ == (
+ "row-edit-bulk-note row-edit-input row-edit-bulk-textarea "
+ "row-edit-bulk-actions"
+ )
+ )
+ copy_template = dialog.locator(".row-edit-copy-template")
+ assert copy_template.inner_text() == "Copy spreadsheet template"
+ assert (
+ dialog.get_by_label("Paste TSV, CSV, or JSON").get_attribute("id")
+ == "row-edit-bulk-textarea"
+ )
+ assert dialog.locator(".row-edit-copy-template-label-narrow").text_content() == (
+ "Copy template"
+ )
+ assert dialog.locator(".row-edit-bulk-template-note").inner_text() == (
+ "You can paste the template into Google Sheets or Excel."
+ )
+ assert dialog.locator(".row-edit-bulk-template-note-narrow").text_content() == (
+ "Paste into Google Sheets or Excel"
+ )
+ copy_template.click()
+ page.wait_for_function(
+ """() => document.querySelector(".row-edit-copy-template").textContent === "Copied" """
+ )
+ assert page.evaluate("navigator.clipboard.readText()") == (
+ "title\tmetadata\tlogo\tnotes\tscore"
+ )
+ textarea = dialog.locator(".row-edit-bulk-textarea")
+ textarea.fill("title\tmetadata\nFrom TSV\t{}")
+ assert textarea.input_value() == "title\tmetadata\nFrom TSV\t{}"
+ dropped_value = textarea.evaluate("""node => new Promise((resolve) => {
+ node.addEventListener("input", () => resolve(node.value), { once: true });
+ const file = new File(["\\n\\ntitle,metadata\\nFrom CSV,{}\\n,\\n , \\n"], "rows.csv", { type: "text/csv" });
+ const dataTransfer = new DataTransfer();
+ dataTransfer.items.add(file);
+ node.dispatchEvent(new DragEvent("dragenter", { bubbles: true, cancelable: true, dataTransfer }));
+ node.dispatchEvent(new DragEvent("dragover", { bubbles: true, cancelable: true, dataTransfer }));
+ node.dispatchEvent(new DragEvent("drop", { bubbles: true, cancelable: true, dataTransfer }));
+ })""")
+ assert dropped_value == "\n\ntitle,metadata\nFrom CSV,{}\n,\n , \n"
+ dialog.locator(".row-edit-save").click()
+ assert dialog.evaluate("node => node.open")
+ assert dialog.locator(".row-edit-bulk-textarea").is_hidden()
+ assert dialog.locator(".row-edit-save").inner_text() == "Insert these rows"
+ assert dialog.locator(".row-edit-bulk-preview-summary").inner_text() == (
+ "Previewing 1 row."
+ )
+ preview_text = dialog.locator(".row-edit-bulk-preview-table").inner_text()
+ assert "id" in preview_text
+ assert "title" in preview_text
+ assert "metadata" in preview_text
+ assert "From CSV" in preview_text
+ assert dialog.locator(".row-edit-bulk-preview-auto").first.text_content() == "auto"
+ assert "null" not in preview_text
+ assert "undefined" not in preview_text
+ preview_cell_style = dialog.locator(
+ ".row-edit-bulk-preview-table td"
+ ).first.evaluate(
+ """node => ({
+ overflowWrap: getComputedStyle(node).overflowWrap,
+ whiteSpace: getComputedStyle(node).whiteSpace
+ })"""
+ )
+ assert preview_cell_style == {
+ "overflowWrap": "anywhere",
+ "whiteSpace": "normal",
+ }
+ assert dialog.locator(".row-edit-cancel").inner_text() == "Back"
+ dialog.locator(".row-edit-cancel").click()
+ assert dialog.evaluate("node => node.open")
+ assert dialog.locator(".row-edit-bulk-textarea").is_visible()
+ assert dialog.locator(".row-edit-bulk-textarea").input_value() == (
+ "\n\ntitle,metadata\nFrom CSV,{}\n,\n , \n"
+ )
+ assert dialog.locator(".row-edit-save").inner_text() == "Preview rows"
+ single_insert = dialog.locator(".row-edit-single-insert")
+ assert single_insert.inner_text() == "Insert single row"
+ single_insert.click()
+ assert dialog.locator(".row-edit-bulk").is_hidden()
+ assert dialog.locator(".row-edit-fields").is_visible()
+ assert dialog.locator(".row-edit-save").inner_text() == "Insert row"
dialog.locator('input[name="title"]').fill("Launch Datasette Cloud")
dialog.locator('textarea[name="metadata"]').fill(
'{"ok": false, "source": "playwright"}'
@@ -835,6 +1232,206 @@ def test_insert_row_flow_uses_custom_column_field(page, datasette_server):
assert data["score"] == 5
+@pytest.mark.playwright
+def test_bulk_insert_preview_inserts_rows(page, datasette_server):
+ page.goto(f"{datasette_server}data/projects")
+ page.locator('button[data-table-action="insert-row"]').click()
+
+ dialog = page.locator("#row-edit-dialog")
+ dialog.wait_for()
+ dialog.locator(".row-edit-bulk-insert").click()
+ dialog.locator(".row-edit-bulk-textarea").fill(
+ json.dumps(
+ {
+ "metadata": [{"title": "Ignored", "metadata": "{}"}],
+ "projects": [
+ {"title": "Bulk one", "metadata": "{}"},
+ {"title": "Bulk two", "metadata": "{}"},
+ ],
+ }
+ )
+ )
+ dialog.locator(".row-edit-save").click()
+ assert dialog.locator(".row-edit-save").inner_text() == "Insert these rows"
+ dialog.locator(".row-edit-save").click()
+ dialog.locator(
+ ".row-edit-bulk-progress-status", has_text="2 rows inserted."
+ ).wait_for()
+ assert dialog.locator(".row-edit-cancel").inner_text() == "Close and view table"
+ dialog.locator(".row-edit-cancel").click()
+ page.wait_for_load_state("domcontentloaded")
+ assert page.url == f"{datasette_server}data/projects"
+
+ assert project_rows(datasette_server, title="Bulk one")
+ assert project_rows(datasette_server, title="Bulk two")
+
+
+@pytest.mark.playwright
+def test_bulk_insert_upsert_option_updates_existing_and_inserts_new(
+ page, datasette_server
+):
+ dialog = open_bulk_insert_dialog(page, f"{datasette_server}data/upsert_items")
+ textarea = dialog.locator(".row-edit-bulk-textarea")
+ conflict_field = dialog.locator(".row-edit-bulk-conflict")
+ conflict_select = dialog.locator(".row-edit-bulk-conflict-mode")
+
+ assert conflict_field.is_hidden()
+ textarea.fill("title\nNo primary key")
+ assert conflict_field.is_hidden()
+
+ textarea.fill(
+ "id,title,metadata\nexisting,Updated by upsert,{}\nnew,Inserted by upsert,{}"
+ )
+ assert conflict_field.is_visible()
+ assert (
+ dialog.locator(".row-edit-bulk-conflict-label").inner_text()
+ == "If the row exists already"
+ )
+ assert conflict_select.input_value() == "ignore"
+ assert (
+ conflict_select.evaluate("""node => Array.from(node.options)
+ .filter((option) => !option.hidden)
+ .map((option) => option.textContent.trim())""")
+ == [
+ "Stop with an error",
+ "Skip existing rows",
+ "Update existing and insert new",
+ ]
+ )
+
+ conflict_select.select_option("upsert")
+ assert conflict_select.input_value() == "upsert"
+ dialog.locator(".row-edit-save").click()
+
+ assert dialog.locator(".row-edit-save").inner_text() == "Update or insert rows"
+ preview_text = dialog.locator(".row-edit-bulk-preview-table").inner_text()
+ assert "Updated by upsert" in preview_text
+ assert "Inserted by upsert" in preview_text
+
+ dialog.locator(".row-edit-save").click()
+ dialog.locator(
+ ".row-edit-bulk-progress-status", has_text="2 rows upserted."
+ ).wait_for()
+
+ assert upsert_item_row(datasette_server, "existing")["title"] == (
+ "Updated by upsert"
+ )
+ assert upsert_item_row(datasette_server, "new")["title"] == "Inserted by upsert"
+
+
+@pytest.mark.playwright
+def test_bulk_insert_conflicts_hide_upsert_without_update_permission(
+ page, datasette_server
+):
+ dialog = open_bulk_insert_dialog(page, f"{datasette_server}data/bulk_defaults")
+ textarea = dialog.locator(".row-edit-bulk-textarea")
+ conflict_field = dialog.locator(".row-edit-bulk-conflict")
+ conflict_select = dialog.locator(".row-edit-bulk-conflict-mode")
+
+ textarea.fill("title\nOnly title")
+ assert conflict_field.is_hidden()
+
+ textarea.fill("id,title\n1,Only title")
+ assert conflict_field.is_visible()
+ assert conflict_select.input_value() == "ignore"
+ assert (
+ conflict_select.evaluate("""node => Array.from(node.options)
+ .filter((option) => !option.hidden)
+ .map((option) => option.textContent.trim())""")
+ == [
+ "Stop with an error",
+ "Skip existing rows",
+ ]
+ )
+ assert conflict_select.locator('option[value="upsert"]').evaluate(
+ "node => node.hidden && node.disabled"
+ )
+
+
+@pytest.mark.playwright
+def test_bulk_insert_live_validation_reports_unknown_columns(page, datasette_server):
+ dialog = open_bulk_insert_dialog(page, f"{datasette_server}data/projects")
+ textarea = dialog.locator(".row-edit-bulk-textarea")
+ error = dialog.locator(".row-edit-error")
+ save = dialog.locator(".row-edit-save")
+
+ textarea.fill(json.dumps([{"id2": 1, "title": "Unknown column"}]))
+ error.wait_for()
+ assert error.inner_text() == "JSON row 1 has unknown column id2."
+ assert save.is_disabled()
+ assert textarea.evaluate("node => document.activeElement === node")
+
+ textarea.fill("id2,title\n1,Unknown column")
+ assert error.inner_text() == "Unknown column id2 in header row."
+ assert save.is_disabled()
+
+ textarea.fill("[")
+ assert error.is_hidden()
+ assert not save.is_disabled()
+
+ textarea.fill(json.dumps([{"id": 1, "title": "Known column"}]))
+ assert error.is_hidden()
+ assert not save.is_disabled()
+ assert dialog.locator(".row-edit-bulk-conflict").is_visible()
+ assert dialog.locator(".row-edit-bulk-conflict-mode").input_value() == "ignore"
+
+
+@pytest.mark.playwright
+def test_bulk_insert_omits_columns_absent_from_pasted_input(page, datasette_server):
+ page.goto(f"{datasette_server}data/bulk_defaults")
+ page.locator('button[data-table-action="insert-row"]').click()
+
+ dialog = page.locator("#row-edit-dialog")
+ dialog.wait_for()
+ dialog.locator(".row-edit-bulk-insert").click()
+ dialog.locator(".row-edit-bulk-textarea").fill("title\nOnly title")
+ dialog.locator(".row-edit-save").click()
+
+ assert dialog.locator(".row-edit-save").inner_text() == "Insert these rows"
+ preview_text = dialog.locator(".row-edit-bulk-preview-table").inner_text()
+ assert "Only title" in preview_text
+ assert "undefined" not in preview_text
+ assert dialog.locator(".row-edit-bulk-preview-auto").inner_text() == "auto"
+
+ dialog.locator(".row-edit-save").click()
+ dialog.locator(
+ ".row-edit-bulk-progress-status", has_text="1 row inserted."
+ ).wait_for()
+
+ rows = bulk_default_rows(datasette_server, title="Only title")
+ assert rows == [
+ {
+ "id": 1,
+ "title": "Only title",
+ "status": "todo",
+ "score": 5,
+ }
+ ]
+
+
+@pytest.mark.playwright
+def test_bulk_insert_preview_accepts_single_column_input(page, datasette_server):
+ page.goto(f"{datasette_server}data/projects")
+ page.locator('button[data-table-action="insert-row"]').click()
+
+ dialog = page.locator("#row-edit-dialog")
+ dialog.wait_for()
+ dialog.locator(".row-edit-bulk-insert").click()
+ dialog.locator(".row-edit-bulk-textarea").fill("title\none\ntwo\nthree")
+ dialog.locator(".row-edit-save").click()
+
+ assert dialog.locator(".row-edit-save").inner_text() == "Insert these rows"
+ assert dialog.locator(".row-edit-bulk-preview-summary").inner_text() == (
+ "Previewing 3 rows."
+ )
+ preview_text = dialog.locator(".row-edit-bulk-preview-table").inner_text()
+ assert "one" in preview_text
+ assert "two" in preview_text
+ assert "three" in preview_text
+ assert "null" not in preview_text
+ assert "undefined" not in preview_text
+
+
@pytest.mark.playwright
def test_edit_row_flow_validates_json_and_saves_changes(page, datasette_server):
page.goto(f"{datasette_server}data/projects")
@@ -842,6 +1439,9 @@ def test_edit_row_flow_validates_json_and_saves_changes(page, datasette_server):
dialog = page.locator("#row-edit-dialog")
dialog.wait_for()
+ assert dialog.locator(".row-edit-bulk-insert").is_hidden()
+ assert dialog.locator(".row-edit-single-insert").is_hidden()
+ assert dialog.locator(".row-edit-bulk").is_hidden()
title = dialog.locator('input[name="title"]')
title.wait_for()
title.fill("Build Datasette, edited")
@@ -877,6 +1477,120 @@ def test_edit_row_flow_validates_json_and_saves_changes(page, datasette_server):
assert data["notes"] == "Edited from Playwright"
+@pytest.mark.playwright
+def test_edit_row_binary_control_shows_size_and_image_preview(page, datasette_server):
+ page.goto(f"{datasette_server}data/binary_files")
+ page.locator('tr[data-row="1"] button[data-row-action="edit"]').click()
+
+ dialog = page.locator("#row-edit-dialog")
+ dialog.wait_for()
+ raw_control = dialog.locator('.row-edit-binary-control[data-column="data"]')
+ raw_control.wait_for()
+ assert (
+ raw_control.locator(".row-edit-binary-size").inner_text() == "Binary: 4 bytes"
+ )
+ assert raw_control.locator(".row-edit-binary-preview img").count() == 0
+ assert dialog.locator('textarea[name="data"]').count() == 0
+ assert dialog.locator('input[type="hidden"][name="data"]').count() == 1
+
+ dialog.locator(".row-edit-cancel").click()
+ page.locator('tr[data-row="2"] button[data-row-action="edit"]').click()
+
+ image_control = dialog.locator('.row-edit-binary-control[data-column="data"]')
+ image_control.wait_for()
+ assert (
+ image_control.locator(".row-edit-binary-size").inner_text()
+ == f"Binary: {len(PNG_1X1_BYTES)} bytes"
+ )
+ image = image_control.locator(".row-edit-binary-preview img")
+ image.wait_for()
+ assert image.get_attribute("src").startswith("blob:")
+
+
+@pytest.mark.playwright
+def test_edit_row_binary_control_replaces_blob_from_file(page, datasette_server):
+ replacement = b"Replacement \x00 bytes"
+
+ page.goto(f"{datasette_server}data/binary_files")
+ page.locator('tr[data-row="1"] button[data-row-action="edit"]').click()
+
+ dialog = page.locator("#row-edit-dialog")
+ binary_control = dialog.locator('.row-edit-binary-control[data-column="data"]')
+ binary_control.wait_for()
+ binary_control.locator('input[type="file"]').set_input_files(
+ {
+ "name": "replacement.bin",
+ "mimeType": "application/octet-stream",
+ "buffer": replacement,
+ }
+ )
+ wait_for_binary_control_file(binary_control, len(replacement), "replacement.bin")
+
+ dialog.locator(".row-edit-save").click()
+ page.locator(".row-mutation-status", has_text="Updated row 1").wait_for()
+ assert binary_file_blob(datasette_server, 1) == replacement
+
+
+@pytest.mark.playwright
+def test_edit_row_binary_control_handles_null_blob(page, datasette_server):
+ replacement = b"From NULL"
+
+ page.goto(f"{datasette_server}data/binary_files")
+ page.locator('tr[data-row="3"] button[data-row-action="edit"]').click()
+
+ dialog = page.locator("#row-edit-dialog")
+ binary_control = dialog.locator('.row-edit-binary-control[data-column="data"]')
+ binary_control.wait_for()
+ assert (
+ binary_control.locator(".row-edit-binary-size").inner_text() == "No binary data"
+ )
+ binary_control.locator('input[type="file"]').set_input_files(
+ {
+ "name": "from-null.bin",
+ "mimeType": "application/octet-stream",
+ "buffer": replacement,
+ }
+ )
+ wait_for_binary_control_file(binary_control, len(replacement), "from-null.bin")
+
+ dialog.locator(".row-edit-save").click()
+ page.locator(".row-mutation-status", has_text="Updated row 3").wait_for()
+ assert binary_file_blob(datasette_server, 3) == replacement
+
+
+@pytest.mark.playwright
+def test_insert_row_binary_control_accepts_pasted_file(page, datasette_server):
+ pasted = b"Pasted \x00 bytes"
+
+ page.goto(f"{datasette_server}data/binary_files")
+ page.locator('button[data-table-action="insert-row"]').click()
+
+ dialog = page.locator("#row-edit-dialog")
+ dialog.wait_for()
+ dialog.locator('input[name="name"]').fill("Pasted bytes")
+ binary_control = dialog.locator('.row-edit-binary-control[data-column="data"]')
+ binary_control.wait_for()
+ binary_control.evaluate(
+ """(node, bytes) => {
+ const transfer = new DataTransfer();
+ transfer.items.add(
+ new File([new Uint8Array(bytes)], "pasted.bin", {
+ type: "application/octet-stream"
+ })
+ );
+ const event = new Event("paste", { bubbles: true, cancelable: true });
+ Object.defineProperty(event, "clipboardData", { value: transfer });
+ node.dispatchEvent(event);
+ }""",
+ list(pasted),
+ )
+ wait_for_binary_control_file(binary_control, len(pasted), "pasted.bin")
+
+ dialog.locator(".row-edit-save").click()
+ page.locator(".row-mutation-status", has_text="Inserted row 4").wait_for()
+ assert binary_file_blob(datasette_server, 4) == pasted
+
+
@pytest.mark.playwright
def test_delete_row_flow_removes_row(page, datasette_server):
page.goto(f"{datasette_server}data/projects")
diff --git a/tests/test_queries.py b/tests/test_queries.py
index 7f29a3fb..699743c5 100644
--- a/tests/test_queries.py
+++ b/tests/test_queries.py
@@ -3221,6 +3221,74 @@ async def test_execute_write_create_table_uses_create_table_permission():
assert not await db.table_exists("should_not_exist")
+@pytest.mark.asyncio
+async def test_execute_write_create_view_uses_create_view_permission():
+ ds = Datasette(
+ memory=True,
+ default_deny=True,
+ config={
+ "permissions": {
+ "insert-row": {"id": "row-writer"},
+ "update-row": {"id": "row-writer"},
+ },
+ "databases": {
+ "data": {
+ "permissions": {
+ "view-database": {"id": ["creator", "row-writer"]},
+ "execute-write-sql": {"id": ["creator", "row-writer"]},
+ "create-view": {"id": "creator"},
+ }
+ }
+ },
+ },
+ )
+ db = ds.add_memory_database("execute_write_create_view", name="data")
+ await db.execute_write("create table dogs (id integer primary key, name text)")
+ await ds.invoke_startup()
+
+ analysis_response = await ds.client.get(
+ "/data/-/execute-write/analyze",
+ actor={"id": "creator"},
+ params={"sql": "create view dog_names as select id, name from dogs"},
+ )
+ allowed_response = await ds.client.post(
+ "/data/-/execute-write",
+ actor={"id": "creator"},
+ json={"sql": "create view dog_names as select id, name from dogs"},
+ )
+ row_permission_response = await ds.client.post(
+ "/data/-/execute-write",
+ actor={"id": "row-writer"},
+ json={"sql": "create view should_not_exist as select id from dogs"},
+ )
+
+ assert analysis_response.status_code == 200
+ analysis_data = analysis_response.json()
+ assert analysis_data["ok"] is True
+ assert analysis_data["execute_disabled"] is False
+ assert analysis_data["analysis_rows"] == [
+ {
+ "operation": "create",
+ "database": "data",
+ "table": "dog_names",
+ "required_permission": "create-view",
+ "source": None,
+ "allowed": True,
+ }
+ ]
+
+ assert allowed_response.status_code == 200
+ assert allowed_response.json()["ok"] is True
+ assert allowed_response.json()["message"] == "Query executed"
+ assert await db.view_exists("dog_names")
+
+ assert row_permission_response.status_code == 403
+ assert row_permission_response.json()["errors"] == [
+ "Permission denied: need create-view on data"
+ ]
+ assert not await db.view_exists("should_not_exist")
+
+
@pytest.mark.parametrize(
(
"database_name",
@@ -3271,8 +3339,20 @@ async def test_execute_write_create_table_uses_create_table_permission():
(),
"drop-table",
),
+ (
+ "execute_write_drop_view",
+ "dropper",
+ "drop view dogs_view",
+ "drop view cats_view",
+ "Permission denied: need drop-view on data/cats_view",
+ (
+ "create view dogs_view as select * from dogs",
+ "create view cats_view as select * from cats",
+ ),
+ "drop-view",
+ ),
),
- ids=("alter-table", "create-index", "drop-index", "drop-table"),
+ ids=("alter-table", "create-index", "drop-index", "drop-table", "drop-view"),
)
@pytest.mark.asyncio
async def test_execute_write_schema_operations_use_schema_permissions(
@@ -3307,7 +3387,12 @@ async def test_execute_write_schema_operations_use_schema_permissions(
"drop-table": {"id": "dropper"},
"view-table": {"id": "alterer"},
}
- }
+ },
+ "dogs_view": {
+ "permissions": {
+ "drop-view": {"id": "dropper"},
+ }
+ },
},
}
},
@@ -3360,6 +3445,9 @@ async def test_execute_write_schema_operations_use_schema_permissions(
elif expected_state == "drop-table":
assert not await db.table_exists("dogs")
assert await db.table_exists("cats")
+ elif expected_state == "drop-view":
+ assert not await db.view_exists("dogs_view")
+ assert await db.view_exists("cats_view")
@pytest.mark.asyncio
diff --git a/tests/test_table_api.py b/tests/test_table_api.py
index 0a593ff8..30eee5d9 100644
--- a/tests/test_table_api.py
+++ b/tests/test_table_api.py
@@ -1338,6 +1338,109 @@ async def test_binary_data_in_json(ds_client, path, expected_json, expected_text
assert response.text == expected_text
+@pytest.mark.asyncio
+async def test_column_details_extra_table(ds_client):
+ response = await ds_client.get(
+ "/fixtures/binary_data.json?_size=0&_extra=column_details"
+ )
+ assert response.status_code == 200
+ data_detail = response.json()["column_details"]["data"]
+ assert data_detail["type"].lower() == "blob"
+ assert data_detail == {
+ "type": data_detail["type"],
+ "sqlite_type": "BLOB",
+ "notnull": False,
+ "default": None,
+ "is_pk": False,
+ "pk_position": 0,
+ "hidden": 0,
+ }
+
+ response = await ds_client.get(
+ "/fixtures/simple_primary_key.json?_size=0&_extra=column_details"
+ )
+ assert response.status_code == 200
+ column_details = response.json()["column_details"]
+ id_detail = column_details["id"]
+ assert id_detail["type"].lower() == "integer"
+ assert id_detail == {
+ "type": id_detail["type"],
+ "sqlite_type": "INTEGER",
+ "notnull": False,
+ "default": None,
+ "is_pk": True,
+ "pk_position": 1,
+ "hidden": 0,
+ }
+ content_detail = column_details["content"]
+ assert content_detail["type"].lower() == "text"
+ assert content_detail == {
+ "type": content_detail["type"],
+ "sqlite_type": "TEXT",
+ "notnull": False,
+ "default": None,
+ "is_pk": False,
+ "pk_position": 0,
+ "hidden": 0,
+ }
+
+ response = await ds_client.get(
+ "/fixtures/compound_three_primary_keys.json?_size=0&_extra=column_details"
+ )
+ assert response.status_code == 200
+ column_details = response.json()["column_details"]
+ assert column_details["pk1"]["is_pk"] is True
+ assert column_details["pk1"]["pk_position"] == 1
+ assert column_details["pk2"]["is_pk"] is True
+ assert column_details["pk2"]["pk_position"] == 2
+ assert column_details["pk3"]["is_pk"] is True
+ assert column_details["pk3"]["pk_position"] == 3
+ assert column_details["content"]["is_pk"] is False
+ assert column_details["content"]["pk_position"] == 0
+
+
+def test_column_details_extra_defaults_and_notnull():
+ with make_app_client(extra_databases={"defaults.db": """
+ CREATE TABLE defaults (
+ i INTEGER NOT NULL DEFAULT 42,
+ s TEXT DEFAULT 'hello',
+ dt TEXT DEFAULT (datetime('now'))
+ );
+ """}) as client:
+ response = client.get("/defaults/defaults.json?_size=0&_extra=column_details")
+ assert response.status == 200
+ column_details = response.json["column_details"]
+ assert column_details["i"]["notnull"] is True
+ assert column_details["i"]["default"] == "42"
+ assert column_details["s"]["notnull"] is False
+ assert column_details["s"]["default"] == "'hello'"
+ assert column_details["dt"]["default"] == "datetime('now')"
+
+
+@pytest.mark.skipif(
+ sqlite_version() < (3, 31, 0),
+ reason="generated columns were added in SQLite 3.31.0",
+)
+def test_column_details_extra_generated_columns():
+ with make_app_client(extra_databases={"generated.db": """
+ CREATE TABLE generated_columns (
+ body TEXT,
+ body_length_virtual INTEGER
+ GENERATED ALWAYS AS (length(body)) VIRTUAL,
+ body_length_stored INTEGER
+ GENERATED ALWAYS AS (length(body)) STORED
+ );
+ """}) as client:
+ response = client.get(
+ "/generated/generated_columns.json?_size=0&_extra=column_details"
+ )
+ assert response.status == 200
+ column_details = response.json["column_details"]
+ assert column_details["body"]["hidden"] == 0
+ assert column_details["body_length_virtual"]["hidden"] == 2
+ assert column_details["body_length_stored"]["hidden"] == 3
+
+
@pytest.mark.asyncio
@pytest.mark.parametrize(
"qs",
diff --git a/tests/test_table_html.py b/tests/test_table_html.py
index 87c48b6b..2cfdd9f0 100644
--- a/tests/test_table_html.py
+++ b/tests/test_table_html.py
@@ -1027,6 +1027,7 @@ async def test_database_create_table_action_button_and_data():
"databaseName": "data",
"columnTypes": ["text", "integer", "float", "blob"],
"defaultExpressions": DEFAULT_EXPRESSION_OPTIONS,
+ "canInsertRows": False,
},
}
assert "customColumnTypes" not in database_data_from_soup(soup)["createTable"]
@@ -1050,6 +1051,40 @@ async def test_database_create_table_action_button_and_data():
ds.close()
+@pytest.mark.asyncio
+async def test_database_create_table_data_includes_insert_row_permission():
+ ds = Datasette(
+ [],
+ config={
+ "databases": {
+ "data": {
+ "permissions": {
+ "create-table": {"id": "root"},
+ "insert-row": {"id": "root"},
+ },
+ },
+ },
+ },
+ )
+ try:
+ db = ds.add_database(
+ Database(ds, memory_name="test_database_create_table_insert_permission"),
+ name="data",
+ )
+ await db.execute_write_script("""
+ create table items (id integer primary key, name text);
+ """)
+
+ response = await ds.client.get("/data", actor={"id": "root"})
+ assert response.status_code == 200
+ create_table_data = database_data_from_soup(Soup(response.text, "html.parser"))[
+ "createTable"
+ ]
+ assert create_table_data["canInsertRows"] is True
+ finally:
+ ds.close()
+
+
@pytest.mark.asyncio
async def test_database_create_table_data_includes_custom_column_types():
ds = Datasette(
@@ -1316,6 +1351,7 @@ async def test_table_insert_action_button_and_data():
assert insert_data["path"] == "/data/items/-/insert"
assert insert_data["tableName"] == "items"
assert insert_data["primaryKeys"] == ["id"]
+ assert insert_data["maxInsertRows"] == 100
assert [column["name"] for column in insert_data["columns"]] == [
"name",
"score",
diff --git a/tests/test_utils.py b/tests/test_utils.py
index 38ebb51e..46dcd89d 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -132,7 +132,11 @@ def test_path_from_row_pks(row, pks, expected_path):
"""
{"CategoryID": 1, "Description": "Soft drinks", "Picture": {"$base64": true, "encoded": "FRwCx60F/g=="}}
""".strip(),
- )
+ ),
+ (
+ {"message": b"hello"},
+ '{"message": {"$base64": true, "encoded": "aGVsbG8="}}',
+ ),
],
)
def test_custom_json_encoder(obj, expected):