2026-07-25 15:47:08 -07:00
|
|
|
|
import json
|
|
|
|
|
|
import urllib
|
|
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
|
|
|
|
|
|
|
from datasette.fixtures import generate_compound_rows, generate_sortable_rows
|
Remove the hand-rolled tracer now that OpenTelemetry covers the same ground
Datasette had two tracing systems since the OpenTelemetry spans landed. The
hand-rolled one measures the wrong thing - issue 1730, open since 2022, is
about exactly that - and it cannot be rebuilt on top of the new spans without
core owning a TracerProvider, which is the one thing the OTel design refuses
to do. Rather than carry duplicate instrumentation on the db.execute() hot
path indefinitely, the old system goes.
Deleted: datasette/tracer.py, the trace_debug setting, the AsgiTracer
response-rewriting middleware and the ?_trace=1 query-string argument.
- datasette/database.py: the four `with trace(...)` wrappers PR 1 deliberately
nested the OTel spans inside are removed and the bodies dedented. That also
retires the `# noqa: SIM117` comments those wrappers required - a leftover
unnecessary noqa trips ruff's RUF100 - and `kwargs["count"] = count` in
execute_write_many, which fed the old tracer only. `git diff -w` on this file
shows nothing but the deleted lines.
- datasette/views/base.py: stream_csv() still read ?_trace=1 to wrap CSV output
in an HTML <textarea> debug page. That whole branch, including the
EscapeHtmlWriter selection and the conditional content-type, is gone. The
EscapeHtmlWriter class itself stays in datasette.utils - it is an importable
public name and removing it would widen the API break.
- .github/workflows/deploy-latest.yml no longer passes --setting trace_debug 1.
Worth stating precisely, because the ticket claimed otherwise: this would not
have broken the deploy. Setting.convert() in cli.py only rewrites a bare name
to settings.<name> for *known* settings, so `--setting trace_debug 1` would
have been silently accepted as a meaningless top-level config key. The flag is
removed because it is dead, not because it errors.
Tests. tests/test_tracer.py is deleted outright (6 items). Four other tests used
?_trace=1 as an assertion instrument rather than testing tracing:
- test_csv_trace tested the trace mechanism itself - deleted.
- test_table_csv_stream_does_not_calculate_facets,
test_table_csv_stream_does_not_calculate_counts and
test_nocount_nofacet_if_shape_is_object test real behaviour, and are rebuilt
against captured spans. All three had silently stopped being able to fail: the
facets test looked for "select content, count(*) as n", which facet suggestion
has not emitted since it moved to a `with limited as (...)` CTE, and none of
the three requested the count or facet work whose suppression they claim to
check. The rebuilt versions ask for it explicitly, match strings the current
SQL contains, and carry a guard assertion so an empty span list cannot
masquerade as a pass. Each was confirmed to fail with the covered code broken.
- test_trace_correctly_escaped is kept, renamed test_query_page_escapes_sql,
with ?_trace=1 dropped. It ran against ds_client, which has no trace_debug, so
it never exercised the tracer - what it actually covered is the query page
echoing user SQL into HTML, the surface of the two reflected-XSS advisories in
issue 1360, and nothing else in the suite covers it. Deleting it would have
quietly dropped that.
tests/test_utils.py's pairs_to_nested_config case used settings.trace_debug to
check that a later key overrides an earlier one; it now uses template_debug
rather than losing the case.
Docs: the datasette.tracer section of internals.rst, the trace_debug section of
settings.rst, the ?_trace=1 entries in json_api.rst and introspection.rst, and
the regenerated cli-reference.rst. changelog.rst gets a breaking-change entry
and keeps all its historical ?_trace=1 entries - two of them had to lose a
:ref: role pointing at a label this commit deletes, or Sphinx warns on every
build.
2368 passed, 39 skipped, 6 xfailed, 15 xpassed, 140 subtests, against 2375 /
141 before. Net -7 tests, fully accounted for: -6 test_tracer.py, -1
test_csv_trace, -1 test_trace_correctly_escaped, +1 test_query_page_escapes_sql.
The lost subtest is the per-setting case trace_debug generated in
test_settings_are_documented.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 20:14:08 -07:00
|
|
|
|
from datasette.telemetry_registry import DB_QUERY, DB_QUERY_TEXT
|
2026-08-06 10:22:35 -07:00
|
|
|
|
from datasette.utils import detect_json1, tilde_encode
|
2021-12-11 19:07:19 -08:00
|
|
|
|
from datasette.utils.sqlite import sqlite_version
|
2026-07-25 15:47:08 -07:00
|
|
|
|
|
2026-05-21 23:05:37 -07:00
|
|
|
|
from .fixtures import make_app_client
|
2021-12-11 19:07:19 -08:00
|
|
|
|
|
|
|
|
|
|
|
2022-12-15 22:09:33 -08:00
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
|
async def test_table_json(ds_client):
|
2023-03-22 15:49:39 -07:00
|
|
|
|
response = await ds_client.get("/fixtures/simple_primary_key.json?_extra=query")
|
2022-12-15 22:09:33 -08:00
|
|
|
|
assert response.status_code == 200
|
|
|
|
|
|
data = response.json()
|
2021-12-11 19:07:19 -08:00
|
|
|
|
assert (
|
|
|
|
|
|
data["query"]["sql"]
|
|
|
|
|
|
== "select id, content from simple_primary_key order by id limit 51"
|
|
|
|
|
|
)
|
|
|
|
|
|
assert data["query"]["params"] == {}
|
|
|
|
|
|
assert data["rows"] == [
|
2025-02-01 21:42:49 -08:00
|
|
|
|
{"id": 1, "content": "hello"},
|
|
|
|
|
|
{"id": 2, "content": "world"},
|
|
|
|
|
|
{"id": 3, "content": ""},
|
|
|
|
|
|
{"id": 4, "content": "RENDER_CELL_DEMO"},
|
|
|
|
|
|
{"id": 5, "content": "RENDER_CELL_ASYNC"},
|
2021-12-11 19:07:19 -08:00
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
|
2022-12-15 22:09:33 -08:00
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
|
async def test_table_not_exists_json(ds_client):
|
|
|
|
|
|
assert (await ds_client.get("/fixtures/blah.json")).json() == {
|
2021-12-11 19:07:19 -08:00
|
|
|
|
"ok": False,
|
2024-06-21 16:09:20 -07:00
|
|
|
|
"error": "Table not found",
|
Unify JSON error responses into one canonical shape
All JSON error responses now use a single format built by the new
datasette.utils.error_body() helper:
{"ok": false, "error": "...", "errors": ["..."], "status": 400}
- error is all messages joined with '; ', errors is the full list,
status always matches the HTTP status code
- The exception handler no longer emits the legacy title key in JSON
(it is still available to the HTML error template)
- The permission debug endpoints (/-/allowed, /-/rules, /-/check,
POST /-/permissions) no longer return bare {"error": ...} objects
- JSON renderer SQL errors keep their rows/truncated context keys but
now include the canonical keys as well
- _shape=object misuse (queries or tables without primary keys) now
returns HTTP 400 instead of 200 with an error body
- Method-not-allowed 405 responses use the canonical shape
Adds tests/test_error_shape.py covering all four previous shape
producers, updates affected tests, and documents the format in a new
'Error responses' section of docs/json_api.rst.
Implements section 1 of stable-api-recommendations.md.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GrHZSypDfMnym1tM5XJAFZ
2026-07-04 03:12:15 +00:00
|
|
|
|
"errors": ["Table not found"],
|
2021-12-11 19:07:19 -08:00
|
|
|
|
"status": 404,
|
2022-12-15 22:09:33 -08:00
|
|
|
|
}
|
2021-12-11 19:07:19 -08:00
|
|
|
|
|
|
|
|
|
|
|
2022-12-15 22:09:33 -08:00
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
|
async def test_table_shape_arrays(ds_client):
|
|
|
|
|
|
response = await ds_client.get("/fixtures/simple_primary_key.json?_shape=arrays")
|
|
|
|
|
|
assert response.json()["rows"] == [
|
2025-02-01 21:42:49 -08:00
|
|
|
|
[1, "hello"],
|
|
|
|
|
|
[2, "world"],
|
|
|
|
|
|
[3, ""],
|
|
|
|
|
|
[4, "RENDER_CELL_DEMO"],
|
|
|
|
|
|
[5, "RENDER_CELL_ASYNC"],
|
2022-12-15 22:09:33 -08:00
|
|
|
|
]
|
2021-12-11 19:07:19 -08:00
|
|
|
|
|
|
|
|
|
|
|
2022-12-15 22:09:33 -08:00
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
|
async def test_table_shape_arrayfirst(ds_client):
|
|
|
|
|
|
response = await ds_client.get(
|
2024-07-15 10:33:51 -07:00
|
|
|
|
"/fixtures/-/query.json?"
|
2021-12-11 19:07:19 -08:00
|
|
|
|
+ urllib.parse.urlencode(
|
|
|
|
|
|
{
|
|
|
|
|
|
"sql": "select content from simple_primary_key order by id",
|
|
|
|
|
|
"_shape": "arrayfirst",
|
|
|
|
|
|
}
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
2022-12-15 22:09:33 -08:00
|
|
|
|
assert response.json() == [
|
2021-12-11 19:07:19 -08:00
|
|
|
|
"hello",
|
|
|
|
|
|
"world",
|
|
|
|
|
|
"",
|
|
|
|
|
|
"RENDER_CELL_DEMO",
|
|
|
|
|
|
"RENDER_CELL_ASYNC",
|
2022-12-15 22:09:33 -08:00
|
|
|
|
]
|
2021-12-11 19:07:19 -08:00
|
|
|
|
|
|
|
|
|
|
|
2026-06-09 02:56:27 -07:00
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
|
async def test_query_extras_for_arbitrary_sql(ds_client):
|
|
|
|
|
|
response = await ds_client.get(
|
|
|
|
|
|
"/fixtures/-/query.json?"
|
|
|
|
|
|
+ urllib.parse.urlencode(
|
|
|
|
|
|
{
|
|
|
|
|
|
"sql": "select 1 as one",
|
|
|
|
|
|
"_extra": "columns,database,query,request,debug",
|
|
|
|
|
|
}
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
assert response.status_code == 200
|
|
|
|
|
|
data = response.json()
|
|
|
|
|
|
assert data["rows"] == [{"one": 1}]
|
|
|
|
|
|
assert data["columns"] == ["one"]
|
|
|
|
|
|
assert data["database"] == "fixtures"
|
|
|
|
|
|
assert data["query"]["sql"] == "select 1 as one"
|
|
|
|
|
|
assert data["request"]["path"] == "/fixtures/-/query.json"
|
|
|
|
|
|
assert data["debug"]["url_vars"] == {
|
|
|
|
|
|
"database": "fixtures",
|
|
|
|
|
|
"format": "json",
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
|
async def test_query_extras_for_stored_query(ds_client):
|
|
|
|
|
|
response = await ds_client.get(
|
|
|
|
|
|
"/fixtures/neighborhood_search.json?"
|
|
|
|
|
|
+ urllib.parse.urlencode(
|
|
|
|
|
|
{
|
|
|
|
|
|
"text": "town",
|
|
|
|
|
|
"_extra": "columns,database,query,request,debug",
|
|
|
|
|
|
}
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
assert response.status_code == 200
|
|
|
|
|
|
data = response.json()
|
|
|
|
|
|
assert data["columns"] == ["_neighborhood", "name", "state"]
|
|
|
|
|
|
assert data["database"] == "fixtures"
|
|
|
|
|
|
assert data["query"]["sql"].strip().startswith("select _neighborhood")
|
|
|
|
|
|
assert data["query"]["params"]["text"] == "town"
|
|
|
|
|
|
assert data["request"]["path"] == "/fixtures/neighborhood_search.json"
|
|
|
|
|
|
assert data["debug"]["url_vars"] == {
|
|
|
|
|
|
"database": "fixtures",
|
|
|
|
|
|
"table": "neighborhood_search",
|
|
|
|
|
|
"format": "json",
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-10 22:50:44 -07:00
|
|
|
|
@pytest.mark.parametrize("extra", ["filters", "actions", "display_rows"])
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
|
async def test_html_only_extras_are_not_available_via_json(ds_client, extra):
|
|
|
|
|
|
# These extras exist for the HTML view; their values are not JSON
|
|
|
|
|
|
# serializable so they are internal, not part of the JSON API
|
|
|
|
|
|
response = await ds_client.get(f"/fixtures/facetable.json?_extra={extra}")
|
2026-07-04 16:16:02 +00:00
|
|
|
|
assert response.status_code == 400
|
|
|
|
|
|
assert response.json()["errors"] == [f"Unknown _extra: {extra}"]
|
2026-06-10 22:50:44 -07:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
|
async def test_html_only_extras_are_not_advertised(ds_client):
|
|
|
|
|
|
response = await ds_client.get("/fixtures/facetable.json?_extra=extras")
|
|
|
|
|
|
assert response.status_code == 200
|
|
|
|
|
|
names = {e["name"] for e in response.json()["extras"]}
|
|
|
|
|
|
assert {"filters", "actions", "display_rows"}.isdisjoint(names)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-10 22:45:13 -07:00
|
|
|
|
def test_query_extra_private_for_arbitrary_sql():
|
|
|
|
|
|
with make_app_client(config={"allow_sql": {"id": "root"}}) as client:
|
|
|
|
|
|
cookies = {"ds_actor": client.actor_cookie({"id": "root"})}
|
|
|
|
|
|
response = client.get(
|
|
|
|
|
|
"/fixtures/-/query.json?sql=select+1+as+one&_extra=private",
|
|
|
|
|
|
cookies=cookies,
|
|
|
|
|
|
)
|
|
|
|
|
|
assert response.status == 200
|
|
|
|
|
|
assert response.json["private"] is True
|
|
|
|
|
|
# Anonymous users cannot execute SQL at all here
|
|
|
|
|
|
anon = client.get("/fixtures/-/query.json?sql=select+1+as+one")
|
|
|
|
|
|
assert anon.status == 403
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-10 22:47:26 -07:00
|
|
|
|
def test_query_extra_query_reports_bound_params():
|
|
|
|
|
|
config = {
|
|
|
|
|
|
"databases": {
|
|
|
|
|
|
"fixtures": {
|
|
|
|
|
|
"queries": {
|
|
|
|
|
|
"declared_params": {
|
|
|
|
|
|
"sql": "select 1 as one",
|
|
|
|
|
|
"params": ["foo"],
|
|
|
|
|
|
},
|
|
|
|
|
|
"magic_host": {
|
|
|
|
|
|
"sql": "select :_header_host as h",
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
with make_app_client(config=config) as client:
|
|
|
|
|
|
# Declared parameters are reported even when the regex cannot find them
|
|
|
|
|
|
response = client.get("/fixtures/declared_params.json?foo=bar&_extra=query")
|
|
|
|
|
|
assert response.status == 200
|
|
|
|
|
|
assert response.json["query"]["params"] == {"foo": "bar"}
|
|
|
|
|
|
# Magic parameters are bound internally and should not be reported,
|
|
|
|
|
|
# especially not as a value taken from the querystring
|
|
|
|
|
|
response = client.get(
|
|
|
|
|
|
"/fixtures/magic_host.json?_extra=query&_header_host=spoofed"
|
|
|
|
|
|
)
|
|
|
|
|
|
assert response.status == 200
|
|
|
|
|
|
assert response.json["rows"] == [{"h": "localhost"}]
|
|
|
|
|
|
assert response.json["query"]["params"] == {}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-04 15:39:11 +00:00
|
|
|
|
def test_query_extra_query_does_not_echo_querystring():
|
2026-06-10 22:47:26 -07:00
|
|
|
|
with make_app_client() as client:
|
2026-07-04 15:39:11 +00:00
|
|
|
|
response = client.get(
|
|
|
|
|
|
"/fixtures/-/query.json?sql=select+1&_extra=query&foo=bar"
|
|
|
|
|
|
)
|
2026-06-10 22:47:26 -07:00
|
|
|
|
assert response.status == 200
|
|
|
|
|
|
assert response.json["query"]["params"] == {}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-10 22:45:13 -07:00
|
|
|
|
def test_query_extra_private_false_when_sql_is_public():
|
|
|
|
|
|
with make_app_client() as client:
|
|
|
|
|
|
response = client.get(
|
|
|
|
|
|
"/fixtures/-/query.json?sql=select+1+as+one&_extra=private"
|
|
|
|
|
|
)
|
|
|
|
|
|
assert response.status == 200
|
|
|
|
|
|
assert response.json["private"] is False
|
|
|
|
|
|
|
|
|
|
|
|
|
2022-12-15 22:09:33 -08:00
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
|
async def test_table_shape_objects(ds_client):
|
|
|
|
|
|
response = await ds_client.get("/fixtures/simple_primary_key.json?_shape=objects")
|
|
|
|
|
|
assert response.json()["rows"] == [
|
2025-02-01 21:42:49 -08:00
|
|
|
|
{"id": 1, "content": "hello"},
|
|
|
|
|
|
{"id": 2, "content": "world"},
|
|
|
|
|
|
{"id": 3, "content": ""},
|
|
|
|
|
|
{"id": 4, "content": "RENDER_CELL_DEMO"},
|
|
|
|
|
|
{"id": 5, "content": "RENDER_CELL_ASYNC"},
|
2022-12-15 22:09:33 -08:00
|
|
|
|
]
|
2021-12-11 19:07:19 -08:00
|
|
|
|
|
|
|
|
|
|
|
2022-12-15 22:09:33 -08:00
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
|
async def test_table_shape_array(ds_client):
|
|
|
|
|
|
response = await ds_client.get("/fixtures/simple_primary_key.json?_shape=array")
|
|
|
|
|
|
assert response.json() == [
|
2025-02-01 21:42:49 -08:00
|
|
|
|
{"id": 1, "content": "hello"},
|
|
|
|
|
|
{"id": 2, "content": "world"},
|
|
|
|
|
|
{"id": 3, "content": ""},
|
|
|
|
|
|
{"id": 4, "content": "RENDER_CELL_DEMO"},
|
|
|
|
|
|
{"id": 5, "content": "RENDER_CELL_ASYNC"},
|
2022-12-15 22:09:33 -08:00
|
|
|
|
]
|
2021-12-11 19:07:19 -08:00
|
|
|
|
|
|
|
|
|
|
|
2022-12-15 22:09:33 -08:00
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
|
async def test_table_shape_array_nl(ds_client):
|
|
|
|
|
|
response = await ds_client.get(
|
|
|
|
|
|
"/fixtures/simple_primary_key.json?_shape=array&_nl=on"
|
|
|
|
|
|
)
|
2021-12-11 19:07:19 -08:00
|
|
|
|
lines = response.text.split("\n")
|
|
|
|
|
|
results = [json.loads(line) for line in lines]
|
|
|
|
|
|
assert [
|
2025-02-01 21:42:49 -08:00
|
|
|
|
{"id": 1, "content": "hello"},
|
|
|
|
|
|
{"id": 2, "content": "world"},
|
|
|
|
|
|
{"id": 3, "content": ""},
|
|
|
|
|
|
{"id": 4, "content": "RENDER_CELL_DEMO"},
|
|
|
|
|
|
{"id": 5, "content": "RENDER_CELL_ASYNC"},
|
2021-12-11 19:07:19 -08:00
|
|
|
|
] == results
|
|
|
|
|
|
|
|
|
|
|
|
|
2022-12-15 22:09:33 -08:00
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
|
async def test_table_shape_invalid(ds_client):
|
|
|
|
|
|
response = await ds_client.get("/fixtures/simple_primary_key.json?_shape=invalid")
|
|
|
|
|
|
assert response.json() == {
|
2021-12-11 19:07:19 -08:00
|
|
|
|
"ok": False,
|
|
|
|
|
|
"error": "Invalid _shape: invalid",
|
Unify JSON error responses into one canonical shape
All JSON error responses now use a single format built by the new
datasette.utils.error_body() helper:
{"ok": false, "error": "...", "errors": ["..."], "status": 400}
- error is all messages joined with '; ', errors is the full list,
status always matches the HTTP status code
- The exception handler no longer emits the legacy title key in JSON
(it is still available to the HTML error template)
- The permission debug endpoints (/-/allowed, /-/rules, /-/check,
POST /-/permissions) no longer return bare {"error": ...} objects
- JSON renderer SQL errors keep their rows/truncated context keys but
now include the canonical keys as well
- _shape=object misuse (queries or tables without primary keys) now
returns HTTP 400 instead of 200 with an error body
- Method-not-allowed 405 responses use the canonical shape
Adds tests/test_error_shape.py covering all four previous shape
producers, updates affected tests, and documents the format in a new
'Error responses' section of docs/json_api.rst.
Implements section 1 of stable-api-recommendations.md.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GrHZSypDfMnym1tM5XJAFZ
2026-07-04 03:12:15 +00:00
|
|
|
|
"errors": ["Invalid _shape: invalid"],
|
2021-12-11 19:07:19 -08:00
|
|
|
|
"status": 400,
|
2022-12-15 22:09:33 -08:00
|
|
|
|
}
|
2021-12-11 19:07:19 -08:00
|
|
|
|
|
|
|
|
|
|
|
2022-12-15 22:09:33 -08:00
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
|
async def test_table_shape_object(ds_client):
|
|
|
|
|
|
response = await ds_client.get("/fixtures/simple_primary_key.json?_shape=object")
|
|
|
|
|
|
assert response.json() == {
|
2025-02-01 21:42:49 -08:00
|
|
|
|
"1": {"id": 1, "content": "hello"},
|
|
|
|
|
|
"2": {"id": 2, "content": "world"},
|
|
|
|
|
|
"3": {"id": 3, "content": ""},
|
|
|
|
|
|
"4": {"id": 4, "content": "RENDER_CELL_DEMO"},
|
|
|
|
|
|
"5": {"id": 5, "content": "RENDER_CELL_ASYNC"},
|
2022-12-15 22:09:33 -08:00
|
|
|
|
}
|
2021-12-11 19:07:19 -08:00
|
|
|
|
|
|
|
|
|
|
|
2022-12-15 22:09:33 -08:00
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
|
async def test_table_shape_object_compound_primary_key(ds_client):
|
|
|
|
|
|
response = await ds_client.get("/fixtures/compound_primary_key.json?_shape=object")
|
|
|
|
|
|
assert response.json() == {
|
2022-03-07 07:38:29 -08:00
|
|
|
|
"a,b": {"pk1": "a", "pk2": "b", "content": "c"},
|
2022-03-15 11:01:57 -07:00
|
|
|
|
"a~2Fb,~2Ec-d": {"pk1": "a/b", "pk2": ".c-d", "content": "c"},
|
2026-02-17 20:09:04 +00:00
|
|
|
|
"d,e": {"pk1": "d", "pk2": "e", "content": "RENDER_CELL_DEMO"},
|
2022-03-07 07:38:29 -08:00
|
|
|
|
}
|
2021-12-11 19:07:19 -08:00
|
|
|
|
|
|
|
|
|
|
|
2022-12-15 22:09:33 -08:00
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
|
async def test_table_with_slashes_in_name(ds_client):
|
|
|
|
|
|
response = await ds_client.get(
|
2022-03-15 11:01:57 -07:00
|
|
|
|
"/fixtures/table~2Fwith~2Fslashes~2Ecsv.json?_shape=objects"
|
2021-12-11 19:07:19 -08:00
|
|
|
|
)
|
2022-12-15 22:09:33 -08:00
|
|
|
|
assert response.status_code == 200
|
|
|
|
|
|
data = response.json()
|
2021-12-11 19:07:19 -08:00
|
|
|
|
assert data["rows"] == [{"pk": "3", "content": "hey"}]
|
|
|
|
|
|
|
|
|
|
|
|
|
2022-12-15 22:09:33 -08:00
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
|
async def test_table_with_reserved_word_name(ds_client):
|
|
|
|
|
|
response = await ds_client.get("/fixtures/select.json?_shape=objects")
|
|
|
|
|
|
assert response.status_code == 200
|
|
|
|
|
|
data = response.json()
|
2021-12-11 19:07:19 -08:00
|
|
|
|
assert data["rows"] == [
|
|
|
|
|
|
{
|
|
|
|
|
|
"rowid": 1,
|
|
|
|
|
|
"group": "group",
|
|
|
|
|
|
"having": "having",
|
|
|
|
|
|
"and": "and",
|
|
|
|
|
|
"json": '{"href": "http://example.com/", "label":"Example"}',
|
|
|
|
|
|
}
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
|
2022-12-15 22:09:33 -08:00
|
|
|
|
@pytest.mark.asyncio
|
2021-12-11 19:07:19 -08:00
|
|
|
|
@pytest.mark.parametrize(
|
|
|
|
|
|
"path,expected_rows,expected_pages",
|
|
|
|
|
|
[
|
2026-02-17 20:09:04 +00:00
|
|
|
|
("/fixtures/no_primary_key.json", 202, 5),
|
|
|
|
|
|
("/fixtures/paginated_view.json", 202, 9),
|
|
|
|
|
|
("/fixtures/no_primary_key.json?_size=25", 202, 9),
|
|
|
|
|
|
("/fixtures/paginated_view.json?_size=50", 202, 5),
|
|
|
|
|
|
("/fixtures/paginated_view.json?_size=max", 202, 3),
|
2021-12-11 19:07:19 -08:00
|
|
|
|
("/fixtures/123_starts_with_digits.json", 0, 1),
|
|
|
|
|
|
# Ensure faceting doesn't break pagination:
|
|
|
|
|
|
("/fixtures/compound_three_primary_keys.json?_facet=pk1", 1001, 21),
|
|
|
|
|
|
# Paginating while sorted by an expanded foreign key should work
|
|
|
|
|
|
(
|
|
|
|
|
|
"/fixtures/roadside_attraction_characteristics.json?_size=2&_sort=attraction_id&_labels=on",
|
|
|
|
|
|
5,
|
|
|
|
|
|
3,
|
|
|
|
|
|
),
|
|
|
|
|
|
],
|
|
|
|
|
|
)
|
2022-12-15 22:09:33 -08:00
|
|
|
|
async def test_paginate_tables_and_views(
|
|
|
|
|
|
ds_client, path, expected_rows, expected_pages
|
|
|
|
|
|
):
|
2021-12-11 19:07:19 -08:00
|
|
|
|
fetched = []
|
|
|
|
|
|
count = 0
|
|
|
|
|
|
while path:
|
2022-12-15 22:09:33 -08:00
|
|
|
|
response = await ds_client.get(path)
|
|
|
|
|
|
assert response.status_code == 200
|
2021-12-11 19:07:19 -08:00
|
|
|
|
count += 1
|
2022-12-15 22:09:33 -08:00
|
|
|
|
fetched.extend(response.json()["rows"])
|
|
|
|
|
|
path = response.json()["next_url"]
|
2021-12-11 19:07:19 -08:00
|
|
|
|
if path:
|
2022-12-15 22:09:33 -08:00
|
|
|
|
assert urllib.parse.urlencode({"_next": response.json()["next"]}) in path
|
2021-12-11 19:07:19 -08:00
|
|
|
|
path = path.replace("http://localhost", "")
|
|
|
|
|
|
assert count < 30, "Possible infinite loop detected"
|
|
|
|
|
|
|
|
|
|
|
|
assert expected_rows == len(fetched)
|
|
|
|
|
|
assert expected_pages == count
|
|
|
|
|
|
|
|
|
|
|
|
|
2022-12-15 22:09:33 -08:00
|
|
|
|
@pytest.mark.asyncio
|
2021-12-11 19:07:19 -08:00
|
|
|
|
@pytest.mark.parametrize(
|
|
|
|
|
|
"path,expected_error",
|
|
|
|
|
|
[
|
|
|
|
|
|
("/fixtures/no_primary_key.json?_size=-4", "_size must be a positive integer"),
|
|
|
|
|
|
("/fixtures/no_primary_key.json?_size=dog", "_size must be a positive integer"),
|
|
|
|
|
|
("/fixtures/no_primary_key.json?_size=1001", "_size must be <= 100"),
|
|
|
|
|
|
],
|
|
|
|
|
|
)
|
2022-12-15 22:09:33 -08:00
|
|
|
|
async def test_validate_page_size(ds_client, path, expected_error):
|
|
|
|
|
|
response = await ds_client.get(path)
|
|
|
|
|
|
assert expected_error == response.json()["error"]
|
|
|
|
|
|
assert response.status_code == 400
|
2021-12-11 19:07:19 -08:00
|
|
|
|
|
|
|
|
|
|
|
2022-12-15 22:09:33 -08:00
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
|
async def test_page_size_zero(ds_client):
|
2021-12-11 19:07:19 -08:00
|
|
|
|
"""For _size=0 we return the counts, empty rows and no continuation token"""
|
2026-07-06 23:59:44 +00:00
|
|
|
|
response = await ds_client.get("/fixtures/no_primary_key.json?_size=0&_extra=count")
|
2022-12-15 22:09:33 -08:00
|
|
|
|
assert response.status_code == 200
|
|
|
|
|
|
assert [] == response.json()["rows"]
|
2026-02-17 20:09:04 +00:00
|
|
|
|
assert 202 == response.json()["count"]
|
2022-12-15 22:09:33 -08:00
|
|
|
|
assert None is response.json()["next"]
|
|
|
|
|
|
assert None is response.json()["next_url"]
|
2021-12-11 19:07:19 -08:00
|
|
|
|
|
|
|
|
|
|
|
2022-12-15 22:09:33 -08:00
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
|
async def test_paginate_compound_keys(ds_client):
|
2021-12-11 19:07:19 -08:00
|
|
|
|
fetched = []
|
2026-07-06 23:59:44 +00:00
|
|
|
|
path = "/fixtures/compound_three_primary_keys.json?_shape=objects"
|
2021-12-11 19:07:19 -08:00
|
|
|
|
page = 0
|
|
|
|
|
|
while path:
|
|
|
|
|
|
page += 1
|
2022-12-15 22:09:33 -08:00
|
|
|
|
response = await ds_client.get(path)
|
|
|
|
|
|
fetched.extend(response.json()["rows"])
|
|
|
|
|
|
path = response.json()["next_url"]
|
2021-12-11 19:07:19 -08:00
|
|
|
|
if path:
|
|
|
|
|
|
path = path.replace("http://localhost", "")
|
|
|
|
|
|
assert page < 100
|
|
|
|
|
|
assert 1001 == len(fetched)
|
|
|
|
|
|
assert 21 == page
|
|
|
|
|
|
# Should be correctly ordered
|
|
|
|
|
|
contents = [f["content"] for f in fetched]
|
|
|
|
|
|
expected = [r[3] for r in generate_compound_rows(1001)]
|
|
|
|
|
|
assert expected == contents
|
|
|
|
|
|
|
|
|
|
|
|
|
2022-12-15 22:09:33 -08:00
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
|
async def test_paginate_compound_keys_with_extra_filters(ds_client):
|
2021-12-11 19:07:19 -08:00
|
|
|
|
fetched = []
|
2026-07-06 23:59:44 +00:00
|
|
|
|
path = (
|
|
|
|
|
|
"/fixtures/compound_three_primary_keys.json?content__contains=d&_shape=objects"
|
|
|
|
|
|
)
|
2021-12-11 19:07:19 -08:00
|
|
|
|
page = 0
|
|
|
|
|
|
while path:
|
|
|
|
|
|
page += 1
|
|
|
|
|
|
assert page < 100
|
2022-12-15 22:09:33 -08:00
|
|
|
|
response = await ds_client.get(path)
|
|
|
|
|
|
fetched.extend(response.json()["rows"])
|
|
|
|
|
|
path = response.json()["next_url"]
|
2021-12-11 19:07:19 -08:00
|
|
|
|
if path:
|
|
|
|
|
|
path = path.replace("http://localhost", "")
|
|
|
|
|
|
assert 2 == page
|
|
|
|
|
|
expected = [r[3] for r in generate_compound_rows(1001) if "d" in r[3]]
|
|
|
|
|
|
assert expected == [f["content"] for f in fetched]
|
|
|
|
|
|
|
|
|
|
|
|
|
2022-12-15 22:09:33 -08:00
|
|
|
|
@pytest.mark.asyncio
|
2021-12-11 19:07:19 -08:00
|
|
|
|
@pytest.mark.parametrize(
|
|
|
|
|
|
"query_string,sort_key,human_description_en",
|
|
|
|
|
|
[
|
|
|
|
|
|
("_sort=sortable", lambda row: row["sortable"], "sorted by sortable"),
|
|
|
|
|
|
(
|
|
|
|
|
|
"_sort_desc=sortable",
|
|
|
|
|
|
lambda row: -row["sortable"],
|
|
|
|
|
|
"sorted by sortable descending",
|
|
|
|
|
|
),
|
|
|
|
|
|
(
|
|
|
|
|
|
"_sort=sortable_with_nulls",
|
|
|
|
|
|
lambda row: (
|
|
|
|
|
|
1 if row["sortable_with_nulls"] is not None else 0,
|
|
|
|
|
|
row["sortable_with_nulls"],
|
|
|
|
|
|
),
|
|
|
|
|
|
"sorted by sortable_with_nulls",
|
|
|
|
|
|
),
|
|
|
|
|
|
(
|
|
|
|
|
|
"_sort_desc=sortable_with_nulls",
|
|
|
|
|
|
lambda row: (
|
|
|
|
|
|
1 if row["sortable_with_nulls"] is None else 0,
|
2024-01-30 19:55:26 -08:00
|
|
|
|
(
|
|
|
|
|
|
-row["sortable_with_nulls"]
|
|
|
|
|
|
if row["sortable_with_nulls"] is not None
|
|
|
|
|
|
else 0
|
|
|
|
|
|
),
|
2021-12-11 19:07:19 -08:00
|
|
|
|
row["content"],
|
|
|
|
|
|
),
|
|
|
|
|
|
"sorted by sortable_with_nulls descending",
|
|
|
|
|
|
),
|
|
|
|
|
|
# text column contains '$null' - ensure it doesn't confuse pagination:
|
|
|
|
|
|
("_sort=text", lambda row: row["text"], "sorted by text"),
|
2022-08-14 08:44:02 -07:00
|
|
|
|
# Still works if sort column removed using _col=
|
|
|
|
|
|
("_sort=text&_col=content", lambda row: row["text"], "sorted by text"),
|
2021-12-11 19:07:19 -08:00
|
|
|
|
],
|
|
|
|
|
|
)
|
2022-12-15 22:09:33 -08:00
|
|
|
|
async def test_sortable(ds_client, query_string, sort_key, human_description_en):
|
2026-07-06 23:59:44 +00:00
|
|
|
|
path = f"/fixtures/sortable.json?_shape=objects&_extra=human_description_en&{query_string}"
|
2021-12-11 19:07:19 -08:00
|
|
|
|
fetched = []
|
|
|
|
|
|
page = 0
|
|
|
|
|
|
while path:
|
|
|
|
|
|
page += 1
|
|
|
|
|
|
assert page < 100
|
2022-12-15 22:09:33 -08:00
|
|
|
|
response = await ds_client.get(path)
|
|
|
|
|
|
assert human_description_en == response.json()["human_description_en"]
|
|
|
|
|
|
fetched.extend(response.json()["rows"])
|
|
|
|
|
|
path = response.json()["next_url"]
|
2021-12-11 19:07:19 -08:00
|
|
|
|
if path:
|
|
|
|
|
|
path = path.replace("http://localhost", "")
|
2022-03-07 07:38:29 -08:00
|
|
|
|
assert page == 5
|
2021-12-11 19:07:19 -08:00
|
|
|
|
expected = list(generate_sortable_rows(201))
|
|
|
|
|
|
expected.sort(key=sort_key)
|
|
|
|
|
|
assert [r["content"] for r in expected] == [r["content"] for r in fetched]
|
|
|
|
|
|
|
|
|
|
|
|
|
2022-12-15 22:09:33 -08:00
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
|
async def test_sortable_and_filtered(ds_client):
|
2021-12-11 19:07:19 -08:00
|
|
|
|
path = (
|
|
|
|
|
|
"/fixtures/sortable.json"
|
|
|
|
|
|
"?content__contains=d&_sort_desc=sortable&_shape=objects"
|
2023-03-22 15:49:39 -07:00
|
|
|
|
"&_extra=human_description_en,count"
|
2021-12-11 19:07:19 -08:00
|
|
|
|
)
|
2022-12-15 22:09:33 -08:00
|
|
|
|
response = await ds_client.get(path)
|
|
|
|
|
|
fetched = response.json()["rows"]
|
2021-12-11 19:07:19 -08:00
|
|
|
|
assert (
|
|
|
|
|
|
'where content contains "d" sorted by sortable descending'
|
2022-12-15 22:09:33 -08:00
|
|
|
|
== response.json()["human_description_en"]
|
2021-12-11 19:07:19 -08:00
|
|
|
|
)
|
|
|
|
|
|
expected = [row for row in generate_sortable_rows(201) if "d" in row["content"]]
|
2022-12-31 12:52:57 -08:00
|
|
|
|
assert len(expected) == response.json()["count"]
|
2021-12-11 19:07:19 -08:00
|
|
|
|
expected.sort(key=lambda row: -row["sortable"])
|
|
|
|
|
|
assert [r["content"] for r in expected] == [r["content"] for r in fetched]
|
|
|
|
|
|
|
|
|
|
|
|
|
2022-12-15 22:09:33 -08:00
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
|
async def test_sortable_argument_errors(ds_client):
|
|
|
|
|
|
response = await ds_client.get("/fixtures/sortable.json?_sort=badcolumn")
|
|
|
|
|
|
assert "Cannot sort table by badcolumn" == response.json()["error"]
|
|
|
|
|
|
response = await ds_client.get("/fixtures/sortable.json?_sort_desc=badcolumn2")
|
|
|
|
|
|
assert "Cannot sort table by badcolumn2" == response.json()["error"]
|
|
|
|
|
|
response = await ds_client.get(
|
2021-12-11 19:07:19 -08:00
|
|
|
|
"/fixtures/sortable.json?_sort=sortable_with_nulls&_sort_desc=sortable"
|
|
|
|
|
|
)
|
2022-12-15 22:09:33 -08:00
|
|
|
|
assert (
|
|
|
|
|
|
"Cannot use _sort and _sort_desc at the same time" == response.json()["error"]
|
|
|
|
|
|
)
|
2021-12-11 19:07:19 -08:00
|
|
|
|
|
|
|
|
|
|
|
2022-12-15 22:09:33 -08:00
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
|
async def test_sortable_columns_metadata(ds_client):
|
|
|
|
|
|
response = await ds_client.get("/fixtures/sortable.json?_sort=content")
|
|
|
|
|
|
assert "Cannot sort table by content" == response.json()["error"]
|
2021-12-11 19:07:19 -08:00
|
|
|
|
# no_primary_key has ALL sort options disabled
|
|
|
|
|
|
for column in ("content", "a", "b", "c"):
|
2022-12-15 22:09:33 -08:00
|
|
|
|
response = await ds_client.get(f"/fixtures/sortable.json?_sort={column}")
|
|
|
|
|
|
assert f"Cannot sort table by {column}" == response.json()["error"]
|
2021-12-11 19:07:19 -08:00
|
|
|
|
|
|
|
|
|
|
|
2022-12-15 22:09:33 -08:00
|
|
|
|
@pytest.mark.asyncio
|
2025-10-30 15:48:46 -07:00
|
|
|
|
@pytest.mark.xfail
|
2021-12-11 19:07:19 -08:00
|
|
|
|
@pytest.mark.parametrize(
|
|
|
|
|
|
"path,expected_rows",
|
|
|
|
|
|
[
|
|
|
|
|
|
(
|
2022-12-30 06:52:47 -08:00
|
|
|
|
"/fixtures/searchable.json?_shape=arrays&_search=dog",
|
2021-12-11 19:07:19 -08:00
|
|
|
|
[
|
|
|
|
|
|
[1, "barry cat", "terry dog", "panther"],
|
|
|
|
|
|
[2, "terry dog", "sara weasel", "puma"],
|
|
|
|
|
|
],
|
|
|
|
|
|
),
|
|
|
|
|
|
(
|
|
|
|
|
|
# Special keyword shouldn't break FTS query
|
2022-12-30 06:52:47 -08:00
|
|
|
|
"/fixtures/searchable.json?_shape=arrays&_search=AND",
|
2021-12-11 19:07:19 -08:00
|
|
|
|
[],
|
|
|
|
|
|
),
|
|
|
|
|
|
(
|
|
|
|
|
|
# Without _searchmode=raw this should return no results
|
2022-12-30 06:52:47 -08:00
|
|
|
|
"/fixtures/searchable.json?_shape=arrays&_search=te*+AND+do*",
|
2021-12-11 19:07:19 -08:00
|
|
|
|
[],
|
|
|
|
|
|
),
|
|
|
|
|
|
(
|
|
|
|
|
|
# _searchmode=raw
|
2022-12-30 06:52:47 -08:00
|
|
|
|
"/fixtures/searchable.json?_shape=arrays&_search=te*+AND+do*&_searchmode=raw",
|
2021-12-11 19:07:19 -08:00
|
|
|
|
[
|
|
|
|
|
|
[1, "barry cat", "terry dog", "panther"],
|
|
|
|
|
|
[2, "terry dog", "sara weasel", "puma"],
|
|
|
|
|
|
],
|
|
|
|
|
|
),
|
|
|
|
|
|
(
|
|
|
|
|
|
# _searchmode=raw combined with _search_COLUMN
|
2022-12-30 06:52:47 -08:00
|
|
|
|
"/fixtures/searchable.json?_shape=arrays&_search_text2=te*&_searchmode=raw",
|
2021-12-11 19:07:19 -08:00
|
|
|
|
[
|
|
|
|
|
|
[1, "barry cat", "terry dog", "panther"],
|
|
|
|
|
|
],
|
|
|
|
|
|
),
|
|
|
|
|
|
(
|
2022-12-30 06:52:47 -08:00
|
|
|
|
"/fixtures/searchable.json?_shape=arrays&_search=weasel",
|
2021-12-11 19:07:19 -08:00
|
|
|
|
[[2, "terry dog", "sara weasel", "puma"]],
|
|
|
|
|
|
),
|
|
|
|
|
|
(
|
2022-12-30 06:52:47 -08:00
|
|
|
|
"/fixtures/searchable.json?_shape=arrays&_search_text2=dog",
|
2021-12-11 19:07:19 -08:00
|
|
|
|
[[1, "barry cat", "terry dog", "panther"]],
|
|
|
|
|
|
),
|
|
|
|
|
|
(
|
2022-12-30 06:52:47 -08:00
|
|
|
|
"/fixtures/searchable.json?_shape=arrays&_search_name%20with%20.%20and%20spaces=panther",
|
2021-12-11 19:07:19 -08:00
|
|
|
|
[[1, "barry cat", "terry dog", "panther"]],
|
|
|
|
|
|
),
|
|
|
|
|
|
],
|
|
|
|
|
|
)
|
2022-12-15 22:09:33 -08:00
|
|
|
|
async def test_searchable(ds_client, path, expected_rows):
|
|
|
|
|
|
response = await ds_client.get(path)
|
|
|
|
|
|
assert expected_rows == response.json()["rows"]
|
2021-12-11 19:07:19 -08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
_SEARCHMODE_RAW_RESULTS = [
|
|
|
|
|
|
[1, "barry cat", "terry dog", "panther"],
|
|
|
|
|
|
[2, "terry dog", "sara weasel", "puma"],
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
|
|
|
|
"table_metadata,querystring,expected_rows",
|
|
|
|
|
|
[
|
|
|
|
|
|
(
|
|
|
|
|
|
{},
|
|
|
|
|
|
"_search=te*+AND+do*",
|
|
|
|
|
|
[],
|
|
|
|
|
|
),
|
|
|
|
|
|
(
|
|
|
|
|
|
{"searchmode": "raw"},
|
|
|
|
|
|
"_search=te*+AND+do*",
|
|
|
|
|
|
_SEARCHMODE_RAW_RESULTS,
|
|
|
|
|
|
),
|
|
|
|
|
|
(
|
|
|
|
|
|
{},
|
|
|
|
|
|
"_search=te*+AND+do*&_searchmode=raw",
|
|
|
|
|
|
_SEARCHMODE_RAW_RESULTS,
|
|
|
|
|
|
),
|
|
|
|
|
|
# Can be over-ridden with _searchmode=escaped
|
|
|
|
|
|
(
|
|
|
|
|
|
{"searchmode": "raw"},
|
|
|
|
|
|
"_search=te*+AND+do*&_searchmode=escaped",
|
|
|
|
|
|
[],
|
|
|
|
|
|
),
|
|
|
|
|
|
],
|
|
|
|
|
|
)
|
|
|
|
|
|
def test_searchmode(table_metadata, querystring, expected_rows):
|
|
|
|
|
|
with make_app_client(
|
|
|
|
|
|
metadata={"databases": {"fixtures": {"tables": {"searchable": table_metadata}}}}
|
|
|
|
|
|
) as client:
|
2022-12-30 06:52:47 -08:00
|
|
|
|
response = client.get("/fixtures/searchable.json?_shape=arrays&" + querystring)
|
2021-12-11 19:07:19 -08:00
|
|
|
|
assert expected_rows == response.json["rows"]
|
|
|
|
|
|
|
|
|
|
|
|
|
2022-12-15 22:09:33 -08:00
|
|
|
|
@pytest.mark.asyncio
|
2021-12-11 19:07:19 -08:00
|
|
|
|
@pytest.mark.parametrize(
|
|
|
|
|
|
"path,expected_rows",
|
|
|
|
|
|
[
|
|
|
|
|
|
(
|
2022-12-30 06:52:47 -08:00
|
|
|
|
"/fixtures/searchable_view_configured_by_metadata.json?_shape=arrays&_search=weasel",
|
2021-12-11 19:07:19 -08:00
|
|
|
|
[[2, "terry dog", "sara weasel", "puma"]],
|
|
|
|
|
|
),
|
|
|
|
|
|
# This should return all results because search is not configured:
|
|
|
|
|
|
(
|
2022-12-30 06:52:47 -08:00
|
|
|
|
"/fixtures/searchable_view.json?_shape=arrays&_search=weasel",
|
2021-12-11 19:07:19 -08:00
|
|
|
|
[
|
|
|
|
|
|
[1, "barry cat", "terry dog", "panther"],
|
|
|
|
|
|
[2, "terry dog", "sara weasel", "puma"],
|
|
|
|
|
|
],
|
|
|
|
|
|
),
|
|
|
|
|
|
(
|
2022-12-30 06:52:47 -08:00
|
|
|
|
"/fixtures/searchable_view.json?_shape=arrays&_search=weasel&_fts_table=searchable_fts&_fts_pk=pk",
|
2021-12-11 19:07:19 -08:00
|
|
|
|
[[2, "terry dog", "sara weasel", "puma"]],
|
|
|
|
|
|
),
|
|
|
|
|
|
],
|
|
|
|
|
|
)
|
2022-12-15 22:09:33 -08:00
|
|
|
|
async def test_searchable_views(ds_client, path, expected_rows):
|
|
|
|
|
|
response = await ds_client.get(path)
|
2022-12-30 06:52:47 -08:00
|
|
|
|
assert response.json()["rows"] == expected_rows
|
2021-12-11 19:07:19 -08:00
|
|
|
|
|
|
|
|
|
|
|
2022-12-15 22:09:33 -08:00
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
|
async def test_searchable_invalid_column(ds_client):
|
|
|
|
|
|
response = await ds_client.get("/fixtures/searchable.json?_search_invalid=x")
|
|
|
|
|
|
assert response.status_code == 400
|
|
|
|
|
|
assert response.json() == {
|
2021-12-11 19:07:19 -08:00
|
|
|
|
"ok": False,
|
|
|
|
|
|
"error": "Cannot search by that column",
|
Unify JSON error responses into one canonical shape
All JSON error responses now use a single format built by the new
datasette.utils.error_body() helper:
{"ok": false, "error": "...", "errors": ["..."], "status": 400}
- error is all messages joined with '; ', errors is the full list,
status always matches the HTTP status code
- The exception handler no longer emits the legacy title key in JSON
(it is still available to the HTML error template)
- The permission debug endpoints (/-/allowed, /-/rules, /-/check,
POST /-/permissions) no longer return bare {"error": ...} objects
- JSON renderer SQL errors keep their rows/truncated context keys but
now include the canonical keys as well
- _shape=object misuse (queries or tables without primary keys) now
returns HTTP 400 instead of 200 with an error body
- Method-not-allowed 405 responses use the canonical shape
Adds tests/test_error_shape.py covering all four previous shape
producers, updates affected tests, and documents the format in a new
'Error responses' section of docs/json_api.rst.
Implements section 1 of stable-api-recommendations.md.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GrHZSypDfMnym1tM5XJAFZ
2026-07-04 03:12:15 +00:00
|
|
|
|
"errors": ["Cannot search by that column"],
|
2021-12-11 19:07:19 -08:00
|
|
|
|
"status": 400,
|
2022-12-15 22:09:33 -08:00
|
|
|
|
}
|
2021-12-11 19:07:19 -08:00
|
|
|
|
|
|
|
|
|
|
|
2022-12-15 22:09:33 -08:00
|
|
|
|
@pytest.mark.asyncio
|
2021-12-11 19:07:19 -08:00
|
|
|
|
@pytest.mark.parametrize(
|
|
|
|
|
|
"path,expected_rows",
|
|
|
|
|
|
[
|
|
|
|
|
|
(
|
2022-12-30 06:52:47 -08:00
|
|
|
|
"/fixtures/simple_primary_key.json?_shape=arrays&content=hello",
|
2025-02-01 21:42:49 -08:00
|
|
|
|
[[1, "hello"]],
|
2022-12-30 06:52:47 -08:00
|
|
|
|
),
|
|
|
|
|
|
(
|
|
|
|
|
|
"/fixtures/simple_primary_key.json?_shape=arrays&content__contains=o",
|
2021-12-11 19:07:19 -08:00
|
|
|
|
[
|
2025-02-01 21:42:49 -08:00
|
|
|
|
[1, "hello"],
|
|
|
|
|
|
[2, "world"],
|
|
|
|
|
|
[4, "RENDER_CELL_DEMO"],
|
2021-12-11 19:07:19 -08:00
|
|
|
|
],
|
|
|
|
|
|
),
|
|
|
|
|
|
(
|
2022-12-30 06:52:47 -08:00
|
|
|
|
"/fixtures/simple_primary_key.json?_shape=arrays&content__exact=",
|
2025-02-01 21:42:49 -08:00
|
|
|
|
[[3, ""]],
|
2022-12-30 06:52:47 -08:00
|
|
|
|
),
|
|
|
|
|
|
(
|
|
|
|
|
|
"/fixtures/simple_primary_key.json?_shape=arrays&content__not=world",
|
2021-12-11 19:07:19 -08:00
|
|
|
|
[
|
2025-02-01 21:42:49 -08:00
|
|
|
|
[1, "hello"],
|
|
|
|
|
|
[3, ""],
|
|
|
|
|
|
[4, "RENDER_CELL_DEMO"],
|
|
|
|
|
|
[5, "RENDER_CELL_ASYNC"],
|
2021-12-11 19:07:19 -08:00
|
|
|
|
],
|
|
|
|
|
|
),
|
|
|
|
|
|
],
|
|
|
|
|
|
)
|
2022-12-15 22:09:33 -08:00
|
|
|
|
async def test_table_filter_queries(ds_client, path, expected_rows):
|
|
|
|
|
|
response = await ds_client.get(path)
|
2022-12-30 06:52:47 -08:00
|
|
|
|
assert response.json()["rows"] == expected_rows
|
2021-12-11 19:07:19 -08:00
|
|
|
|
|
|
|
|
|
|
|
2022-12-15 22:09:33 -08:00
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
|
async def test_table_filter_queries_multiple_of_same_type(ds_client):
|
|
|
|
|
|
response = await ds_client.get(
|
2022-12-30 06:52:47 -08:00
|
|
|
|
"/fixtures/simple_primary_key.json?_shape=arrays&content__not=world&content__not=hello"
|
2021-12-11 19:07:19 -08:00
|
|
|
|
)
|
|
|
|
|
|
assert [
|
2025-02-01 21:42:49 -08:00
|
|
|
|
[3, ""],
|
|
|
|
|
|
[4, "RENDER_CELL_DEMO"],
|
|
|
|
|
|
[5, "RENDER_CELL_ASYNC"],
|
2022-12-15 22:09:33 -08:00
|
|
|
|
] == response.json()["rows"]
|
2021-12-11 19:07:19 -08:00
|
|
|
|
|
|
|
|
|
|
|
2026-08-06 10:22:35 -07:00
|
|
|
|
@pytest.mark.skipif(not detect_json1(), reason="Requires the SQLite json1 module")
|
|
|
|
|
|
def test_table_filters_quote_identifiers():
|
|
|
|
|
|
with make_app_client(
|
|
|
|
|
|
extra_databases={"demo.db": """
|
|
|
|
|
|
create table "items]bracket" (
|
|
|
|
|
|
id integer primary key,
|
|
|
|
|
|
"name""quote" text,
|
|
|
|
|
|
"tags]bracket" text
|
|
|
|
|
|
);
|
|
|
|
|
|
insert into "items]bracket" values (1, 'Alice', '["red"]');
|
|
|
|
|
|
"""},
|
|
|
|
|
|
) as client:
|
|
|
|
|
|
table_path = tilde_encode("items]bracket")
|
|
|
|
|
|
exact_query = urllib.parse.urlencode(
|
|
|
|
|
|
{'name"quote__exact': "Alice", "_shape": "arrays"}
|
|
|
|
|
|
)
|
|
|
|
|
|
exact_response = client.get(f"/demo/{table_path}.json?{exact_query}")
|
|
|
|
|
|
assert exact_response.status == 200
|
|
|
|
|
|
assert exact_response.json["rows"] == [[1, "Alice", '["red"]']]
|
|
|
|
|
|
|
|
|
|
|
|
array_query = urllib.parse.urlencode(
|
|
|
|
|
|
{"tags]bracket__arraycontains": "red", "_shape": "arrays"}
|
|
|
|
|
|
)
|
|
|
|
|
|
array_response = client.get(f"/demo/{table_path}.json?{array_query}")
|
|
|
|
|
|
assert array_response.status == 200
|
|
|
|
|
|
assert array_response.json["rows"] == [[1, "Alice", '["red"]']]
|
|
|
|
|
|
|
|
|
|
|
|
|
2021-12-11 19:07:19 -08:00
|
|
|
|
@pytest.mark.skipif(not detect_json1(), reason="Requires the SQLite json1 module")
|
2022-12-15 22:09:33 -08:00
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
|
async def test_table_filter_json_arraycontains(ds_client):
|
2022-12-30 06:52:47 -08:00
|
|
|
|
response = await ds_client.get(
|
|
|
|
|
|
"/fixtures/facetable.json?_shape=arrays&tags__arraycontains=tag1"
|
|
|
|
|
|
)
|
2022-12-15 22:09:33 -08:00
|
|
|
|
assert response.json()["rows"] == [
|
2021-12-11 19:07:19 -08:00
|
|
|
|
[
|
|
|
|
|
|
1,
|
|
|
|
|
|
"2019-01-14 08:00:00",
|
|
|
|
|
|
1,
|
|
|
|
|
|
1,
|
|
|
|
|
|
"CA",
|
|
|
|
|
|
1,
|
|
|
|
|
|
"Mission",
|
|
|
|
|
|
'["tag1", "tag2"]',
|
|
|
|
|
|
'[{"foo": "bar"}]',
|
|
|
|
|
|
"one",
|
2022-03-18 18:37:54 -07:00
|
|
|
|
"n1",
|
2021-12-11 19:07:19 -08:00
|
|
|
|
],
|
|
|
|
|
|
[
|
|
|
|
|
|
2,
|
|
|
|
|
|
"2019-01-14 08:00:00",
|
|
|
|
|
|
1,
|
|
|
|
|
|
1,
|
|
|
|
|
|
"CA",
|
|
|
|
|
|
1,
|
|
|
|
|
|
"Dogpatch",
|
|
|
|
|
|
'["tag1", "tag3"]',
|
|
|
|
|
|
"[]",
|
|
|
|
|
|
"two",
|
2022-03-18 18:37:54 -07:00
|
|
|
|
"n2",
|
2021-12-11 19:07:19 -08:00
|
|
|
|
],
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.skipif(not detect_json1(), reason="Requires the SQLite json1 module")
|
2022-12-15 22:09:33 -08:00
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
|
async def test_table_filter_json_arraynotcontains(ds_client):
|
|
|
|
|
|
response = await ds_client.get(
|
2022-12-30 06:52:47 -08:00
|
|
|
|
"/fixtures/facetable.json?_shape=arrays&tags__arraynotcontains=tag3&tags__not=[]"
|
2021-12-11 19:07:19 -08:00
|
|
|
|
)
|
2022-12-15 22:09:33 -08:00
|
|
|
|
assert response.json()["rows"] == [
|
2021-12-11 19:07:19 -08:00
|
|
|
|
[
|
|
|
|
|
|
1,
|
|
|
|
|
|
"2019-01-14 08:00:00",
|
|
|
|
|
|
1,
|
|
|
|
|
|
1,
|
|
|
|
|
|
"CA",
|
|
|
|
|
|
1,
|
|
|
|
|
|
"Mission",
|
|
|
|
|
|
'["tag1", "tag2"]',
|
|
|
|
|
|
'[{"foo": "bar"}]',
|
|
|
|
|
|
"one",
|
2022-03-18 18:37:54 -07:00
|
|
|
|
"n1",
|
2021-12-11 19:07:19 -08:00
|
|
|
|
]
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
|
2022-12-15 22:09:33 -08:00
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
|
async def test_table_filter_extra_where(ds_client):
|
|
|
|
|
|
response = await ds_client.get(
|
2022-12-30 06:52:47 -08:00
|
|
|
|
"/fixtures/facetable.json?_shape=arrays&_where=_neighborhood='Dogpatch'"
|
2021-12-11 19:07:19 -08:00
|
|
|
|
)
|
|
|
|
|
|
assert [
|
|
|
|
|
|
[
|
|
|
|
|
|
2,
|
|
|
|
|
|
"2019-01-14 08:00:00",
|
|
|
|
|
|
1,
|
|
|
|
|
|
1,
|
|
|
|
|
|
"CA",
|
|
|
|
|
|
1,
|
|
|
|
|
|
"Dogpatch",
|
|
|
|
|
|
'["tag1", "tag3"]',
|
|
|
|
|
|
"[]",
|
|
|
|
|
|
"two",
|
2022-03-18 18:37:54 -07:00
|
|
|
|
"n2",
|
2021-12-11 19:07:19 -08:00
|
|
|
|
]
|
2022-12-15 22:09:33 -08:00
|
|
|
|
] == response.json()["rows"]
|
2021-12-11 19:07:19 -08:00
|
|
|
|
|
|
|
|
|
|
|
2022-12-15 22:09:33 -08:00
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
|
async def test_table_filter_extra_where_invalid(ds_client):
|
|
|
|
|
|
response = await ds_client.get(
|
|
|
|
|
|
"/fixtures/facetable.json?_where=_neighborhood=Dogpatch'"
|
|
|
|
|
|
)
|
|
|
|
|
|
assert response.status_code == 400
|
Unify JSON error responses into one canonical shape
All JSON error responses now use a single format built by the new
datasette.utils.error_body() helper:
{"ok": false, "error": "...", "errors": ["..."], "status": 400}
- error is all messages joined with '; ', errors is the full list,
status always matches the HTTP status code
- The exception handler no longer emits the legacy title key in JSON
(it is still available to the HTML error template)
- The permission debug endpoints (/-/allowed, /-/rules, /-/check,
POST /-/permissions) no longer return bare {"error": ...} objects
- JSON renderer SQL errors keep their rows/truncated context keys but
now include the canonical keys as well
- _shape=object misuse (queries or tables without primary keys) now
returns HTTP 400 instead of 200 with an error body
- Method-not-allowed 405 responses use the canonical shape
Adds tests/test_error_shape.py covering all four previous shape
producers, updates affected tests, and documents the format in a new
'Error responses' section of docs/json_api.rst.
Implements section 1 of stable-api-recommendations.md.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GrHZSypDfMnym1tM5XJAFZ
2026-07-04 03:12:15 +00:00
|
|
|
|
assert "unrecognized token" in response.json()["error"]
|
2021-12-11 19:07:19 -08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_table_filter_extra_where_disabled_if_no_sql_allowed():
|
2023-10-12 09:16:37 -07:00
|
|
|
|
with make_app_client(config={"allow_sql": {}}) as client:
|
2021-12-11 19:07:19 -08:00
|
|
|
|
response = client.get(
|
|
|
|
|
|
"/fixtures/facetable.json?_where=_neighborhood='Dogpatch'"
|
|
|
|
|
|
)
|
2022-12-15 22:09:33 -08:00
|
|
|
|
assert response.status_code == 403
|
2021-12-11 19:07:19 -08:00
|
|
|
|
assert "_where= is not allowed" == response.json["error"]
|
|
|
|
|
|
|
|
|
|
|
|
|
2022-12-15 22:09:33 -08:00
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
|
async def test_table_through(ds_client):
|
2021-12-11 19:07:19 -08:00
|
|
|
|
# Just the museums:
|
2022-12-15 22:09:33 -08:00
|
|
|
|
response = await ds_client.get(
|
2023-03-22 15:49:39 -07:00
|
|
|
|
"/fixtures/roadside_attractions.json?_shape=arrays"
|
|
|
|
|
|
'&_through={"table":"roadside_attraction_characteristics","column":"characteristic_id","value":"1"}'
|
|
|
|
|
|
"&_extra=human_description_en"
|
2021-12-11 19:07:19 -08:00
|
|
|
|
)
|
2022-12-15 22:09:33 -08:00
|
|
|
|
assert response.json()["rows"] == [
|
2021-12-11 19:07:19 -08:00
|
|
|
|
[
|
|
|
|
|
|
3,
|
|
|
|
|
|
"Burlingame Museum of PEZ Memorabilia",
|
|
|
|
|
|
"214 California Drive, Burlingame, CA 94010",
|
2022-09-06 16:50:43 -07:00
|
|
|
|
None,
|
2021-12-11 19:07:19 -08:00
|
|
|
|
37.5793,
|
|
|
|
|
|
-122.3442,
|
|
|
|
|
|
],
|
|
|
|
|
|
[
|
|
|
|
|
|
4,
|
|
|
|
|
|
"Bigfoot Discovery Museum",
|
|
|
|
|
|
"5497 Highway 9, Felton, CA 95018",
|
2022-09-06 16:50:43 -07:00
|
|
|
|
"https://www.bigfootdiscoveryproject.com/",
|
2021-12-11 19:07:19 -08:00
|
|
|
|
37.0414,
|
|
|
|
|
|
-122.0725,
|
|
|
|
|
|
],
|
2022-09-06 16:50:43 -07:00
|
|
|
|
]
|
|
|
|
|
|
|
2021-12-11 19:07:19 -08:00
|
|
|
|
assert (
|
2022-12-15 22:09:33 -08:00
|
|
|
|
response.json()["human_description_en"]
|
2022-09-06 16:50:43 -07:00
|
|
|
|
== 'where roadside_attraction_characteristics.characteristic_id = "1"'
|
2021-12-11 19:07:19 -08:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2022-12-15 22:09:33 -08:00
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
|
async def test_max_returned_rows(ds_client):
|
|
|
|
|
|
response = await ds_client.get(
|
2024-07-15 10:33:51 -07:00
|
|
|
|
"/fixtures/-/query.json?sql=select+content+from+no_primary_key"
|
2022-12-15 22:09:33 -08:00
|
|
|
|
)
|
|
|
|
|
|
data = response.json()
|
2021-12-11 19:07:19 -08:00
|
|
|
|
assert data["truncated"]
|
|
|
|
|
|
assert 100 == len(data["rows"])
|
|
|
|
|
|
|
|
|
|
|
|
|
2022-12-15 22:09:33 -08:00
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
|
async def test_view(ds_client):
|
|
|
|
|
|
response = await ds_client.get("/fixtures/simple_view.json?_shape=objects")
|
|
|
|
|
|
assert response.status_code == 200
|
|
|
|
|
|
data = response.json()
|
2021-12-11 19:07:19 -08:00
|
|
|
|
assert data["rows"] == [
|
|
|
|
|
|
{"upper_content": "HELLO", "content": "hello"},
|
|
|
|
|
|
{"upper_content": "WORLD", "content": "world"},
|
|
|
|
|
|
{"upper_content": "", "content": ""},
|
|
|
|
|
|
{"upper_content": "RENDER_CELL_DEMO", "content": "RENDER_CELL_DEMO"},
|
|
|
|
|
|
{"upper_content": "RENDER_CELL_ASYNC", "content": "RENDER_CELL_ASYNC"},
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_page_size_matching_max_returned_rows(
|
|
|
|
|
|
app_client_returned_rows_matches_page_size,
|
|
|
|
|
|
):
|
|
|
|
|
|
fetched = []
|
2026-07-06 23:59:44 +00:00
|
|
|
|
path = "/fixtures/no_primary_key.json"
|
2021-12-11 19:07:19 -08:00
|
|
|
|
while path:
|
|
|
|
|
|
response = app_client_returned_rows_matches_page_size.get(path)
|
|
|
|
|
|
fetched.extend(response.json["rows"])
|
2026-02-17 20:09:04 +00:00
|
|
|
|
assert len(response.json["rows"]) in (2, 50)
|
2021-12-11 19:07:19 -08:00
|
|
|
|
path = response.json["next_url"]
|
|
|
|
|
|
if path:
|
|
|
|
|
|
path = path.replace("http://localhost", "")
|
2026-02-17 20:09:04 +00:00
|
|
|
|
assert len(fetched) == 202
|
2021-12-11 19:07:19 -08:00
|
|
|
|
|
|
|
|
|
|
|
2022-12-15 22:09:33 -08:00
|
|
|
|
@pytest.mark.asyncio
|
2021-12-11 19:07:19 -08:00
|
|
|
|
@pytest.mark.parametrize(
|
|
|
|
|
|
"path,expected_facet_results",
|
|
|
|
|
|
[
|
|
|
|
|
|
(
|
|
|
|
|
|
"/fixtures/facetable.json?_facet=state&_facet=_city_id",
|
|
|
|
|
|
{
|
|
|
|
|
|
"state": {
|
|
|
|
|
|
"name": "state",
|
|
|
|
|
|
"hideable": True,
|
|
|
|
|
|
"type": "column",
|
|
|
|
|
|
"toggle_url": "/fixtures/facetable.json?_facet=_city_id",
|
|
|
|
|
|
"results": [
|
|
|
|
|
|
{
|
|
|
|
|
|
"value": "CA",
|
|
|
|
|
|
"label": "CA",
|
|
|
|
|
|
"count": 10,
|
|
|
|
|
|
"toggle_url": "_facet=state&_facet=_city_id&state=CA",
|
|
|
|
|
|
"selected": False,
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
"value": "MI",
|
|
|
|
|
|
"label": "MI",
|
|
|
|
|
|
"count": 4,
|
|
|
|
|
|
"toggle_url": "_facet=state&_facet=_city_id&state=MI",
|
|
|
|
|
|
"selected": False,
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
"value": "MC",
|
|
|
|
|
|
"label": "MC",
|
|
|
|
|
|
"count": 1,
|
|
|
|
|
|
"toggle_url": "_facet=state&_facet=_city_id&state=MC",
|
|
|
|
|
|
"selected": False,
|
|
|
|
|
|
},
|
|
|
|
|
|
],
|
|
|
|
|
|
"truncated": False,
|
|
|
|
|
|
},
|
|
|
|
|
|
"_city_id": {
|
|
|
|
|
|
"name": "_city_id",
|
|
|
|
|
|
"hideable": True,
|
|
|
|
|
|
"type": "column",
|
|
|
|
|
|
"toggle_url": "/fixtures/facetable.json?_facet=state",
|
|
|
|
|
|
"results": [
|
|
|
|
|
|
{
|
|
|
|
|
|
"value": 1,
|
|
|
|
|
|
"label": "San Francisco",
|
|
|
|
|
|
"count": 6,
|
|
|
|
|
|
"toggle_url": "_facet=state&_facet=_city_id&_city_id__exact=1",
|
|
|
|
|
|
"selected": False,
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
"value": 2,
|
|
|
|
|
|
"label": "Los Angeles",
|
|
|
|
|
|
"count": 4,
|
|
|
|
|
|
"toggle_url": "_facet=state&_facet=_city_id&_city_id__exact=2",
|
|
|
|
|
|
"selected": False,
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
"value": 3,
|
|
|
|
|
|
"label": "Detroit",
|
|
|
|
|
|
"count": 4,
|
|
|
|
|
|
"toggle_url": "_facet=state&_facet=_city_id&_city_id__exact=3",
|
|
|
|
|
|
"selected": False,
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
"value": 4,
|
|
|
|
|
|
"label": "Memnonia",
|
|
|
|
|
|
"count": 1,
|
|
|
|
|
|
"toggle_url": "_facet=state&_facet=_city_id&_city_id__exact=4",
|
|
|
|
|
|
"selected": False,
|
|
|
|
|
|
},
|
|
|
|
|
|
],
|
|
|
|
|
|
"truncated": False,
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
),
|
|
|
|
|
|
(
|
|
|
|
|
|
"/fixtures/facetable.json?_facet=state&_facet=_city_id&state=MI",
|
|
|
|
|
|
{
|
|
|
|
|
|
"state": {
|
|
|
|
|
|
"name": "state",
|
|
|
|
|
|
"hideable": True,
|
|
|
|
|
|
"type": "column",
|
|
|
|
|
|
"toggle_url": "/fixtures/facetable.json?_facet=_city_id&state=MI",
|
|
|
|
|
|
"results": [
|
|
|
|
|
|
{
|
|
|
|
|
|
"value": "MI",
|
|
|
|
|
|
"label": "MI",
|
|
|
|
|
|
"count": 4,
|
|
|
|
|
|
"selected": True,
|
|
|
|
|
|
"toggle_url": "_facet=state&_facet=_city_id",
|
|
|
|
|
|
}
|
|
|
|
|
|
],
|
|
|
|
|
|
"truncated": False,
|
|
|
|
|
|
},
|
|
|
|
|
|
"_city_id": {
|
|
|
|
|
|
"name": "_city_id",
|
|
|
|
|
|
"hideable": True,
|
|
|
|
|
|
"type": "column",
|
|
|
|
|
|
"toggle_url": "/fixtures/facetable.json?_facet=state&state=MI",
|
|
|
|
|
|
"results": [
|
|
|
|
|
|
{
|
|
|
|
|
|
"value": 3,
|
|
|
|
|
|
"label": "Detroit",
|
|
|
|
|
|
"count": 4,
|
|
|
|
|
|
"selected": False,
|
|
|
|
|
|
"toggle_url": "_facet=state&_facet=_city_id&state=MI&_city_id__exact=3",
|
|
|
|
|
|
}
|
|
|
|
|
|
],
|
|
|
|
|
|
"truncated": False,
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
),
|
|
|
|
|
|
(
|
|
|
|
|
|
"/fixtures/facetable.json?_facet=planet_int",
|
|
|
|
|
|
{
|
|
|
|
|
|
"planet_int": {
|
|
|
|
|
|
"name": "planet_int",
|
|
|
|
|
|
"hideable": True,
|
|
|
|
|
|
"type": "column",
|
|
|
|
|
|
"toggle_url": "/fixtures/facetable.json",
|
|
|
|
|
|
"results": [
|
|
|
|
|
|
{
|
|
|
|
|
|
"value": 1,
|
|
|
|
|
|
"label": 1,
|
|
|
|
|
|
"count": 14,
|
|
|
|
|
|
"selected": False,
|
|
|
|
|
|
"toggle_url": "_facet=planet_int&planet_int=1",
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
"value": 2,
|
|
|
|
|
|
"label": 2,
|
|
|
|
|
|
"count": 1,
|
|
|
|
|
|
"selected": False,
|
|
|
|
|
|
"toggle_url": "_facet=planet_int&planet_int=2",
|
|
|
|
|
|
},
|
|
|
|
|
|
],
|
|
|
|
|
|
"truncated": False,
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
),
|
|
|
|
|
|
(
|
|
|
|
|
|
# planet_int is an integer field:
|
|
|
|
|
|
"/fixtures/facetable.json?_facet=planet_int&planet_int=1",
|
|
|
|
|
|
{
|
|
|
|
|
|
"planet_int": {
|
|
|
|
|
|
"name": "planet_int",
|
|
|
|
|
|
"hideable": True,
|
|
|
|
|
|
"type": "column",
|
|
|
|
|
|
"toggle_url": "/fixtures/facetable.json?planet_int=1",
|
|
|
|
|
|
"results": [
|
|
|
|
|
|
{
|
|
|
|
|
|
"value": 1,
|
|
|
|
|
|
"label": 1,
|
|
|
|
|
|
"count": 14,
|
|
|
|
|
|
"selected": True,
|
|
|
|
|
|
"toggle_url": "_facet=planet_int",
|
|
|
|
|
|
}
|
|
|
|
|
|
],
|
|
|
|
|
|
"truncated": False,
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
),
|
|
|
|
|
|
],
|
|
|
|
|
|
)
|
2022-12-15 22:09:33 -08:00
|
|
|
|
async def test_facets(ds_client, path, expected_facet_results):
|
|
|
|
|
|
response = await ds_client.get(path)
|
|
|
|
|
|
facet_results = response.json()["facet_results"]
|
2021-12-11 19:07:19 -08:00
|
|
|
|
# We only compare the querystring portion of the taggle_url
|
2023-03-22 15:49:39 -07:00
|
|
|
|
for facet_name, facet_info in facet_results["results"].items():
|
2021-12-11 19:07:19 -08:00
|
|
|
|
assert facet_name == facet_info["name"]
|
|
|
|
|
|
assert False is facet_info["truncated"]
|
|
|
|
|
|
for facet_value in facet_info["results"]:
|
|
|
|
|
|
facet_value["toggle_url"] = facet_value["toggle_url"].split("?")[1]
|
2023-03-22 15:49:39 -07:00
|
|
|
|
assert expected_facet_results == facet_results["results"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
|
@pytest.mark.skipif(not detect_json1(), reason="requires JSON1 extension")
|
|
|
|
|
|
async def test_facets_array(ds_client):
|
|
|
|
|
|
response = await ds_client.get("/fixtures/facetable.json?_facet_array=tags")
|
|
|
|
|
|
facet_results = response.json()["facet_results"]
|
|
|
|
|
|
assert facet_results["results"]["tags"]["results"] == [
|
|
|
|
|
|
{
|
|
|
|
|
|
"value": "tag1",
|
|
|
|
|
|
"label": "tag1",
|
|
|
|
|
|
"count": 2,
|
|
|
|
|
|
"toggle_url": "http://localhost/fixtures/facetable.json?_facet_array=tags&tags__arraycontains=tag1",
|
|
|
|
|
|
"selected": False,
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
"value": "tag2",
|
|
|
|
|
|
"label": "tag2",
|
|
|
|
|
|
"count": 1,
|
|
|
|
|
|
"toggle_url": "http://localhost/fixtures/facetable.json?_facet_array=tags&tags__arraycontains=tag2",
|
|
|
|
|
|
"selected": False,
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
"value": "tag3",
|
|
|
|
|
|
"label": "tag3",
|
|
|
|
|
|
"count": 1,
|
|
|
|
|
|
"toggle_url": "http://localhost/fixtures/facetable.json?_facet_array=tags&tags__arraycontains=tag3",
|
|
|
|
|
|
"selected": False,
|
|
|
|
|
|
},
|
|
|
|
|
|
]
|
2021-12-11 19:07:19 -08:00
|
|
|
|
|
|
|
|
|
|
|
2022-12-15 22:09:33 -08:00
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
|
async def test_suggested_facets(ds_client):
|
2021-12-11 19:07:19 -08:00
|
|
|
|
suggestions = [
|
|
|
|
|
|
{
|
|
|
|
|
|
"name": suggestion["name"],
|
|
|
|
|
|
"querystring": suggestion["toggle_url"].split("?")[-1],
|
|
|
|
|
|
}
|
2023-03-22 15:49:39 -07:00
|
|
|
|
for suggestion in (
|
|
|
|
|
|
await ds_client.get("/fixtures/facetable.json?_extra=suggested_facets")
|
|
|
|
|
|
).json()["suggested_facets"]
|
2021-12-11 19:07:19 -08:00
|
|
|
|
]
|
|
|
|
|
|
expected = [
|
2023-03-22 15:49:39 -07:00
|
|
|
|
{"name": "created", "querystring": "_extra=suggested_facets&_facet=created"},
|
|
|
|
|
|
{
|
|
|
|
|
|
"name": "planet_int",
|
|
|
|
|
|
"querystring": "_extra=suggested_facets&_facet=planet_int",
|
|
|
|
|
|
},
|
|
|
|
|
|
{"name": "on_earth", "querystring": "_extra=suggested_facets&_facet=on_earth"},
|
|
|
|
|
|
{"name": "state", "querystring": "_extra=suggested_facets&_facet=state"},
|
|
|
|
|
|
{"name": "_city_id", "querystring": "_extra=suggested_facets&_facet=_city_id"},
|
|
|
|
|
|
{
|
|
|
|
|
|
"name": "_neighborhood",
|
|
|
|
|
|
"querystring": "_extra=suggested_facets&_facet=_neighborhood",
|
|
|
|
|
|
},
|
|
|
|
|
|
{"name": "tags", "querystring": "_extra=suggested_facets&_facet=tags"},
|
|
|
|
|
|
{
|
|
|
|
|
|
"name": "complex_array",
|
|
|
|
|
|
"querystring": "_extra=suggested_facets&_facet=complex_array",
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
"name": "created",
|
|
|
|
|
|
"querystring": "_extra=suggested_facets&_facet_date=created",
|
|
|
|
|
|
},
|
2021-12-11 19:07:19 -08:00
|
|
|
|
]
|
|
|
|
|
|
if detect_json1():
|
2023-03-22 15:49:39 -07:00
|
|
|
|
expected.append(
|
|
|
|
|
|
{"name": "tags", "querystring": "_extra=suggested_facets&_facet_array=tags"}
|
|
|
|
|
|
)
|
2021-12-11 19:07:19 -08:00
|
|
|
|
assert expected == suggestions
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_allow_facet_off():
|
|
|
|
|
|
with make_app_client(settings={"allow_facet": False}) as client:
|
2023-03-22 15:49:39 -07:00
|
|
|
|
assert (
|
|
|
|
|
|
client.get(
|
|
|
|
|
|
"/fixtures/facetable.json?_facet=planet_int&_extra=suggested_facets"
|
|
|
|
|
|
).status
|
|
|
|
|
|
== 400
|
|
|
|
|
|
)
|
|
|
|
|
|
data = client.get("/fixtures/facetable.json?_extra=suggested_facets").json
|
2021-12-11 19:07:19 -08:00
|
|
|
|
# Should not suggest any facets either:
|
2023-03-22 15:49:39 -07:00
|
|
|
|
assert [] == data["suggested_facets"]
|
2021-12-11 19:07:19 -08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_suggest_facets_off():
|
|
|
|
|
|
with make_app_client(settings={"suggest_facets": False}) as client:
|
|
|
|
|
|
# Now suggested_facets should be []
|
2023-03-22 15:49:39 -07:00
|
|
|
|
assert (
|
|
|
|
|
|
[]
|
|
|
|
|
|
== client.get("/fixtures/facetable.json?_extra=suggested_facets").json[
|
|
|
|
|
|
"suggested_facets"
|
|
|
|
|
|
]
|
|
|
|
|
|
)
|
2021-12-11 19:07:19 -08:00
|
|
|
|
|
|
|
|
|
|
|
2022-12-15 22:09:33 -08:00
|
|
|
|
@pytest.mark.asyncio
|
2021-12-11 19:07:19 -08:00
|
|
|
|
@pytest.mark.parametrize("nofacet", (True, False))
|
2022-12-15 22:09:33 -08:00
|
|
|
|
async def test_nofacet(ds_client, nofacet):
|
2023-03-22 15:49:39 -07:00
|
|
|
|
path = "/fixtures/facetable.json?_facet=state&_extra=suggested_facets"
|
2021-12-11 19:07:19 -08:00
|
|
|
|
if nofacet:
|
|
|
|
|
|
path += "&_nofacet=1"
|
2022-12-15 22:09:33 -08:00
|
|
|
|
response = await ds_client.get(path)
|
2021-12-11 19:07:19 -08:00
|
|
|
|
if nofacet:
|
2022-12-15 22:09:33 -08:00
|
|
|
|
assert response.json()["suggested_facets"] == []
|
2023-03-22 15:49:39 -07:00
|
|
|
|
assert response.json()["facet_results"]["results"] == {}
|
2021-12-11 19:07:19 -08:00
|
|
|
|
else:
|
2022-12-15 22:09:33 -08:00
|
|
|
|
assert response.json()["suggested_facets"] != []
|
2023-03-22 15:49:39 -07:00
|
|
|
|
assert response.json()["facet_results"]["results"] != {}
|
2021-12-11 19:07:19 -08:00
|
|
|
|
|
|
|
|
|
|
|
2022-12-15 22:09:33 -08:00
|
|
|
|
@pytest.mark.asyncio
|
2021-12-16 11:24:54 -08:00
|
|
|
|
@pytest.mark.parametrize("nosuggest", (True, False))
|
2022-12-15 22:09:33 -08:00
|
|
|
|
async def test_nosuggest(ds_client, nosuggest):
|
2023-03-22 15:49:39 -07:00
|
|
|
|
path = "/fixtures/facetable.json?_facet=state&_extra=suggested_facets"
|
2021-12-16 11:24:54 -08:00
|
|
|
|
if nosuggest:
|
|
|
|
|
|
path += "&_nosuggest=1"
|
2022-12-15 22:09:33 -08:00
|
|
|
|
response = await ds_client.get(path)
|
2021-12-16 11:24:54 -08:00
|
|
|
|
if nosuggest:
|
2022-12-15 22:09:33 -08:00
|
|
|
|
assert response.json()["suggested_facets"] == []
|
2021-12-16 11:24:54 -08:00
|
|
|
|
# But facets should still be returned:
|
2022-12-15 22:09:33 -08:00
|
|
|
|
assert response.json()["facet_results"] != {}
|
2021-12-16 11:24:54 -08:00
|
|
|
|
else:
|
2022-12-15 22:09:33 -08:00
|
|
|
|
assert response.json()["suggested_facets"] != []
|
|
|
|
|
|
assert response.json()["facet_results"] != {}
|
2021-12-16 11:24:54 -08:00
|
|
|
|
|
|
|
|
|
|
|
2022-12-15 22:09:33 -08:00
|
|
|
|
@pytest.mark.asyncio
|
2021-12-11 19:07:19 -08:00
|
|
|
|
@pytest.mark.parametrize("nocount,expected_count", ((True, None), (False, 15)))
|
2022-12-15 22:09:33 -08:00
|
|
|
|
async def test_nocount(ds_client, nocount, expected_count):
|
2023-03-22 15:49:39 -07:00
|
|
|
|
path = "/fixtures/facetable.json?_extra=count"
|
2021-12-11 19:07:19 -08:00
|
|
|
|
if nocount:
|
2023-03-22 15:49:39 -07:00
|
|
|
|
path += "&_nocount=1"
|
2022-12-15 22:09:33 -08:00
|
|
|
|
response = await ds_client.get(path)
|
2022-12-31 12:52:57 -08:00
|
|
|
|
assert response.json()["count"] == expected_count
|
2021-12-11 19:07:19 -08:00
|
|
|
|
|
|
|
|
|
|
|
Remove the hand-rolled tracer now that OpenTelemetry covers the same ground
Datasette had two tracing systems since the OpenTelemetry spans landed. The
hand-rolled one measures the wrong thing - issue 1730, open since 2022, is
about exactly that - and it cannot be rebuilt on top of the new spans without
core owning a TracerProvider, which is the one thing the OTel design refuses
to do. Rather than carry duplicate instrumentation on the db.execute() hot
path indefinitely, the old system goes.
Deleted: datasette/tracer.py, the trace_debug setting, the AsgiTracer
response-rewriting middleware and the ?_trace=1 query-string argument.
- datasette/database.py: the four `with trace(...)` wrappers PR 1 deliberately
nested the OTel spans inside are removed and the bodies dedented. That also
retires the `# noqa: SIM117` comments those wrappers required - a leftover
unnecessary noqa trips ruff's RUF100 - and `kwargs["count"] = count` in
execute_write_many, which fed the old tracer only. `git diff -w` on this file
shows nothing but the deleted lines.
- datasette/views/base.py: stream_csv() still read ?_trace=1 to wrap CSV output
in an HTML <textarea> debug page. That whole branch, including the
EscapeHtmlWriter selection and the conditional content-type, is gone. The
EscapeHtmlWriter class itself stays in datasette.utils - it is an importable
public name and removing it would widen the API break.
- .github/workflows/deploy-latest.yml no longer passes --setting trace_debug 1.
Worth stating precisely, because the ticket claimed otherwise: this would not
have broken the deploy. Setting.convert() in cli.py only rewrites a bare name
to settings.<name> for *known* settings, so `--setting trace_debug 1` would
have been silently accepted as a meaningless top-level config key. The flag is
removed because it is dead, not because it errors.
Tests. tests/test_tracer.py is deleted outright (6 items). Four other tests used
?_trace=1 as an assertion instrument rather than testing tracing:
- test_csv_trace tested the trace mechanism itself - deleted.
- test_table_csv_stream_does_not_calculate_facets,
test_table_csv_stream_does_not_calculate_counts and
test_nocount_nofacet_if_shape_is_object test real behaviour, and are rebuilt
against captured spans. All three had silently stopped being able to fail: the
facets test looked for "select content, count(*) as n", which facet suggestion
has not emitted since it moved to a `with limited as (...)` CTE, and none of
the three requested the count or facet work whose suppression they claim to
check. The rebuilt versions ask for it explicitly, match strings the current
SQL contains, and carry a guard assertion so an empty span list cannot
masquerade as a pass. Each was confirmed to fail with the covered code broken.
- test_trace_correctly_escaped is kept, renamed test_query_page_escapes_sql,
with ?_trace=1 dropped. It ran against ds_client, which has no trace_debug, so
it never exercised the tracer - what it actually covered is the query page
echoing user SQL into HTML, the surface of the two reflected-XSS advisories in
issue 1360, and nothing else in the suite covers it. Deleting it would have
quietly dropped that.
tests/test_utils.py's pairs_to_nested_config case used settings.trace_debug to
check that a later key overrides an earlier one; it now uses template_debug
rather than losing the case.
Docs: the datasette.tracer section of internals.rst, the trace_debug section of
settings.rst, the ?_trace=1 entries in json_api.rst and introspection.rst, and
the regenerated cli-reference.rst. changelog.rst gets a breaking-change entry
and keeps all its historical ?_trace=1 entries - two of them had to lose a
:ref: role pointing at a label this commit deletes, or Sphinx warns on every
build.
2368 passed, 39 skipped, 6 xfailed, 15 xpassed, 140 subtests, against 2375 /
141 before. Net -7 tests, fully accounted for: -6 test_tracer.py, -1
test_csv_trace, -1 test_trace_correctly_escaped, +1 test_query_page_escapes_sql.
The lost subtest is the per-setting case trace_debug generated in
test_settings_are_documented.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 20:14:08 -07:00
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
|
async def test_nocount_nofacet_if_shape_is_object(ds_client, otel_spans):
|
|
|
|
|
|
# ?_extra=count and ?_facet=state each cause a count(*) query on their
|
|
|
|
|
|
# own. _shape=object is supposed to suppress both, so asking for them
|
|
|
|
|
|
# explicitly is what makes this test able to fail - a plain
|
|
|
|
|
|
# ?_shape=object request would never have run either query anyway.
|
|
|
|
|
|
response = await ds_client.get(
|
|
|
|
|
|
"/fixtures/facetable.json?_shape=object&_extra=count&_facet=state"
|
2021-12-11 19:07:19 -08:00
|
|
|
|
)
|
Remove the hand-rolled tracer now that OpenTelemetry covers the same ground
Datasette had two tracing systems since the OpenTelemetry spans landed. The
hand-rolled one measures the wrong thing - issue 1730, open since 2022, is
about exactly that - and it cannot be rebuilt on top of the new spans without
core owning a TracerProvider, which is the one thing the OTel design refuses
to do. Rather than carry duplicate instrumentation on the db.execute() hot
path indefinitely, the old system goes.
Deleted: datasette/tracer.py, the trace_debug setting, the AsgiTracer
response-rewriting middleware and the ?_trace=1 query-string argument.
- datasette/database.py: the four `with trace(...)` wrappers PR 1 deliberately
nested the OTel spans inside are removed and the bodies dedented. That also
retires the `# noqa: SIM117` comments those wrappers required - a leftover
unnecessary noqa trips ruff's RUF100 - and `kwargs["count"] = count` in
execute_write_many, which fed the old tracer only. `git diff -w` on this file
shows nothing but the deleted lines.
- datasette/views/base.py: stream_csv() still read ?_trace=1 to wrap CSV output
in an HTML <textarea> debug page. That whole branch, including the
EscapeHtmlWriter selection and the conditional content-type, is gone. The
EscapeHtmlWriter class itself stays in datasette.utils - it is an importable
public name and removing it would widen the API break.
- .github/workflows/deploy-latest.yml no longer passes --setting trace_debug 1.
Worth stating precisely, because the ticket claimed otherwise: this would not
have broken the deploy. Setting.convert() in cli.py only rewrites a bare name
to settings.<name> for *known* settings, so `--setting trace_debug 1` would
have been silently accepted as a meaningless top-level config key. The flag is
removed because it is dead, not because it errors.
Tests. tests/test_tracer.py is deleted outright (6 items). Four other tests used
?_trace=1 as an assertion instrument rather than testing tracing:
- test_csv_trace tested the trace mechanism itself - deleted.
- test_table_csv_stream_does_not_calculate_facets,
test_table_csv_stream_does_not_calculate_counts and
test_nocount_nofacet_if_shape_is_object test real behaviour, and are rebuilt
against captured spans. All three had silently stopped being able to fail: the
facets test looked for "select content, count(*) as n", which facet suggestion
has not emitted since it moved to a `with limited as (...)` CTE, and none of
the three requested the count or facet work whose suppression they claim to
check. The rebuilt versions ask for it explicitly, match strings the current
SQL contains, and carry a guard assertion so an empty span list cannot
masquerade as a pass. Each was confirmed to fail with the covered code broken.
- test_trace_correctly_escaped is kept, renamed test_query_page_escapes_sql,
with ?_trace=1 dropped. It ran against ds_client, which has no trace_debug, so
it never exercised the tracer - what it actually covered is the query page
echoing user SQL into HTML, the surface of the two reflected-XSS advisories in
issue 1360, and nothing else in the suite covers it. Deleting it would have
quietly dropped that.
tests/test_utils.py's pairs_to_nested_config case used settings.trace_debug to
check that a later key overrides an earlier one; it now uses template_debug
rather than losing the case.
Docs: the datasette.tracer section of internals.rst, the trace_debug section of
settings.rst, the ?_trace=1 entries in json_api.rst and introspection.rst, and
the regenerated cli-reference.rst. changelog.rst gets a breaking-change entry
and keeps all its historical ?_trace=1 entries - two of them had to lose a
:ref: role pointing at a label this commit deletes, or Sphinx warns on every
build.
2368 passed, 39 skipped, 6 xfailed, 15 xpassed, 140 subtests, against 2375 /
141 before. Net -7 tests, fully accounted for: -6 test_tracer.py, -1
test_csv_trace, -1 test_trace_correctly_escaped, +1 test_query_page_escapes_sql.
The lost subtest is the per-setting case trace_debug generated in
test_settings_are_documented.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 20:14:08 -07:00
|
|
|
|
assert response.status_code == 200
|
|
|
|
|
|
queries = [
|
|
|
|
|
|
span.attributes.get(DB_QUERY_TEXT, "")
|
|
|
|
|
|
for span in otel_spans.get_finished_spans()
|
|
|
|
|
|
if span.name == DB_QUERY
|
|
|
|
|
|
]
|
|
|
|
|
|
# Guard: prove the request really did query the table, so the assertion
|
|
|
|
|
|
# below is measuring suppression rather than an empty span list.
|
|
|
|
|
|
assert any("from facetable" in q for q in queries), queries
|
|
|
|
|
|
assert not any("count(*)" in q for q in queries), queries
|
2021-12-11 19:07:19 -08:00
|
|
|
|
|
|
|
|
|
|
|
2022-12-15 22:09:33 -08:00
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
|
async def test_expand_labels(ds_client):
|
|
|
|
|
|
response = await ds_client.get(
|
2021-12-11 19:07:19 -08:00
|
|
|
|
"/fixtures/facetable.json?_shape=object&_labels=1&_size=2"
|
|
|
|
|
|
"&_neighborhood__contains=c"
|
|
|
|
|
|
)
|
2022-12-15 22:09:33 -08:00
|
|
|
|
assert response.json() == {
|
2021-12-11 19:07:19 -08:00
|
|
|
|
"2": {
|
|
|
|
|
|
"pk": 2,
|
|
|
|
|
|
"created": "2019-01-14 08:00:00",
|
|
|
|
|
|
"planet_int": 1,
|
|
|
|
|
|
"on_earth": 1,
|
|
|
|
|
|
"state": "CA",
|
|
|
|
|
|
"_city_id": {"value": 1, "label": "San Francisco"},
|
|
|
|
|
|
"_neighborhood": "Dogpatch",
|
|
|
|
|
|
"tags": '["tag1", "tag3"]',
|
|
|
|
|
|
"complex_array": "[]",
|
|
|
|
|
|
"distinct_some_null": "two",
|
2022-03-18 18:37:54 -07:00
|
|
|
|
"n": "n2",
|
2021-12-11 19:07:19 -08:00
|
|
|
|
},
|
|
|
|
|
|
"13": {
|
|
|
|
|
|
"pk": 13,
|
|
|
|
|
|
"created": "2019-01-17 08:00:00",
|
|
|
|
|
|
"planet_int": 1,
|
|
|
|
|
|
"on_earth": 1,
|
|
|
|
|
|
"state": "MI",
|
|
|
|
|
|
"_city_id": {"value": 3, "label": "Detroit"},
|
|
|
|
|
|
"_neighborhood": "Corktown",
|
|
|
|
|
|
"tags": "[]",
|
|
|
|
|
|
"complex_array": "[]",
|
|
|
|
|
|
"distinct_some_null": None,
|
2022-03-18 18:37:54 -07:00
|
|
|
|
"n": None,
|
2021-12-11 19:07:19 -08:00
|
|
|
|
},
|
2022-12-15 22:09:33 -08:00
|
|
|
|
}
|
2021-12-11 19:07:19 -08:00
|
|
|
|
|
|
|
|
|
|
|
2022-12-15 22:09:33 -08:00
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
|
async def test_expand_label(ds_client):
|
|
|
|
|
|
response = await ds_client.get(
|
2021-12-11 19:07:19 -08:00
|
|
|
|
"/fixtures/foreign_key_references.json?_shape=object"
|
|
|
|
|
|
"&_label=foreign_key_with_label&_size=1"
|
|
|
|
|
|
)
|
2022-12-15 22:09:33 -08:00
|
|
|
|
assert response.json() == {
|
2021-12-11 19:07:19 -08:00
|
|
|
|
"1": {
|
|
|
|
|
|
"pk": "1",
|
2025-02-01 21:42:49 -08:00
|
|
|
|
"foreign_key_with_label": {"value": 1, "label": "hello"},
|
|
|
|
|
|
"foreign_key_with_blank_label": 3,
|
2021-12-11 19:07:19 -08:00
|
|
|
|
"foreign_key_with_no_label": "1",
|
|
|
|
|
|
"foreign_key_compound_pk1": "a",
|
|
|
|
|
|
"foreign_key_compound_pk2": "b",
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
2022-12-15 22:09:33 -08:00
|
|
|
|
@pytest.mark.asyncio
|
2021-12-11 19:07:19 -08:00
|
|
|
|
@pytest.mark.parametrize(
|
|
|
|
|
|
"path,expected_cache_control",
|
|
|
|
|
|
[
|
|
|
|
|
|
("/fixtures/facetable.json", "max-age=5"),
|
|
|
|
|
|
("/fixtures/facetable.json?_ttl=invalid", "max-age=5"),
|
|
|
|
|
|
("/fixtures/facetable.json?_ttl=10", "max-age=10"),
|
|
|
|
|
|
("/fixtures/facetable.json?_ttl=0", "no-cache"),
|
|
|
|
|
|
],
|
|
|
|
|
|
)
|
2022-12-15 22:09:33 -08:00
|
|
|
|
async def test_ttl_parameter(ds_client, path, expected_cache_control):
|
|
|
|
|
|
response = await ds_client.get(path)
|
|
|
|
|
|
assert response.headers["Cache-Control"] == expected_cache_control
|
2021-12-11 19:07:19 -08:00
|
|
|
|
|
|
|
|
|
|
|
2022-12-15 22:09:33 -08:00
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
|
async def test_infinity_returned_as_null(ds_client):
|
|
|
|
|
|
response = await ds_client.get("/fixtures/infinity.json?_shape=array")
|
|
|
|
|
|
assert response.json() == [
|
2021-12-11 19:07:19 -08:00
|
|
|
|
{"rowid": 1, "value": None},
|
|
|
|
|
|
{"rowid": 2, "value": None},
|
|
|
|
|
|
{"rowid": 3, "value": 1.5},
|
2022-12-15 22:09:33 -08:00
|
|
|
|
]
|
2021-12-11 19:07:19 -08:00
|
|
|
|
|
|
|
|
|
|
|
2022-12-15 22:09:33 -08:00
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
|
async def test_infinity_returned_as_invalid_json_if_requested(ds_client):
|
|
|
|
|
|
response = await ds_client.get(
|
|
|
|
|
|
"/fixtures/infinity.json?_shape=array&_json_infinity=1"
|
|
|
|
|
|
)
|
|
|
|
|
|
assert response.json() == [
|
2021-12-11 19:07:19 -08:00
|
|
|
|
{"rowid": 1, "value": float("inf")},
|
|
|
|
|
|
{"rowid": 2, "value": float("-inf")},
|
|
|
|
|
|
{"rowid": 3, "value": 1.5},
|
2022-12-15 22:09:33 -08:00
|
|
|
|
]
|
2021-12-11 19:07:19 -08:00
|
|
|
|
|
|
|
|
|
|
|
2022-12-15 22:09:33 -08:00
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
|
async def test_custom_query_with_unicode_characters(ds_client):
|
2022-03-15 11:01:57 -07:00
|
|
|
|
# /fixtures/𝐜𝐢𝐭𝐢𝐞𝐬.json
|
2022-12-15 22:09:33 -08:00
|
|
|
|
response = await ds_client.get(
|
2022-03-15 11:01:57 -07:00
|
|
|
|
"/fixtures/~F0~9D~90~9C~F0~9D~90~A2~F0~9D~90~AD~F0~9D~90~A2~F0~9D~90~9E~F0~9D~90~AC.json?_shape=array"
|
|
|
|
|
|
)
|
2022-12-15 22:09:33 -08:00
|
|
|
|
assert response.json() == [{"id": 1, "name": "San Francisco"}]
|
2021-12-11 19:07:19 -08:00
|
|
|
|
|
|
|
|
|
|
|
2022-12-15 22:09:33 -08:00
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
|
async def test_null_and_compound_foreign_keys_are_not_expanded(ds_client):
|
|
|
|
|
|
response = await ds_client.get(
|
2021-12-11 19:07:19 -08:00
|
|
|
|
"/fixtures/foreign_key_references.json?_shape=array&_labels=on"
|
|
|
|
|
|
)
|
2022-12-15 22:09:33 -08:00
|
|
|
|
assert response.json() == [
|
2021-12-11 19:07:19 -08:00
|
|
|
|
{
|
|
|
|
|
|
"pk": "1",
|
2025-02-01 21:42:49 -08:00
|
|
|
|
"foreign_key_with_label": {"value": 1, "label": "hello"},
|
|
|
|
|
|
"foreign_key_with_blank_label": {"value": 3, "label": ""},
|
2021-12-11 19:07:19 -08:00
|
|
|
|
"foreign_key_with_no_label": {"value": "1", "label": "1"},
|
|
|
|
|
|
"foreign_key_compound_pk1": "a",
|
|
|
|
|
|
"foreign_key_compound_pk2": "b",
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
"pk": "2",
|
|
|
|
|
|
"foreign_key_with_label": None,
|
|
|
|
|
|
"foreign_key_with_blank_label": None,
|
|
|
|
|
|
"foreign_key_with_no_label": None,
|
|
|
|
|
|
"foreign_key_compound_pk1": None,
|
|
|
|
|
|
"foreign_key_compound_pk2": None,
|
|
|
|
|
|
},
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
|
2022-12-15 22:09:33 -08:00
|
|
|
|
@pytest.mark.asyncio
|
2021-12-11 19:07:19 -08:00
|
|
|
|
@pytest.mark.parametrize(
|
|
|
|
|
|
"path,expected_json,expected_text",
|
|
|
|
|
|
[
|
|
|
|
|
|
(
|
|
|
|
|
|
"/fixtures/binary_data.json?_shape=array",
|
|
|
|
|
|
[
|
|
|
|
|
|
{"rowid": 1, "data": {"$base64": True, "encoded": "FRwCx60F/g=="}},
|
|
|
|
|
|
{"rowid": 2, "data": {"$base64": True, "encoded": "FRwDx60F/g=="}},
|
|
|
|
|
|
{"rowid": 3, "data": None},
|
|
|
|
|
|
],
|
|
|
|
|
|
None,
|
|
|
|
|
|
),
|
|
|
|
|
|
(
|
|
|
|
|
|
"/fixtures/binary_data.json?_shape=array&_nl=on",
|
|
|
|
|
|
None,
|
|
|
|
|
|
(
|
|
|
|
|
|
'{"rowid": 1, "data": {"$base64": true, "encoded": "FRwCx60F/g=="}}\n'
|
|
|
|
|
|
'{"rowid": 2, "data": {"$base64": true, "encoded": "FRwDx60F/g=="}}\n'
|
|
|
|
|
|
'{"rowid": 3, "data": null}'
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
],
|
|
|
|
|
|
)
|
2022-12-15 22:09:33 -08:00
|
|
|
|
async def test_binary_data_in_json(ds_client, path, expected_json, expected_text):
|
|
|
|
|
|
response = await ds_client.get(path)
|
2021-12-11 19:07:19 -08:00
|
|
|
|
if expected_json:
|
2022-12-15 22:09:33 -08:00
|
|
|
|
assert response.json() == expected_json
|
2021-12-11 19:07:19 -08:00
|
|
|
|
else:
|
|
|
|
|
|
assert response.text == expected_text
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-03 14:45:38 -07:00
|
|
|
|
@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
|
2026-07-03 17:12:26 -07:00
|
|
|
|
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,
|
2026-07-03 14:45:38 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
response = await ds_client.get(
|
|
|
|
|
|
"/fixtures/simple_primary_key.json?_size=0&_extra=column_details"
|
|
|
|
|
|
)
|
|
|
|
|
|
assert response.status_code == 200
|
2026-07-03 17:12:26 -07:00
|
|
|
|
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,
|
2026-07-03 14:45:38 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-03 16:08:34 -07:00
|
|
|
|
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
|
|
|
|
|
|
|
2026-07-03 14:45:38 -07:00
|
|
|
|
|
2022-12-15 22:09:33 -08:00
|
|
|
|
@pytest.mark.asyncio
|
2021-12-11 19:07:19 -08:00
|
|
|
|
@pytest.mark.parametrize(
|
|
|
|
|
|
"qs",
|
|
|
|
|
|
[
|
|
|
|
|
|
"",
|
|
|
|
|
|
"?_shape=arrays",
|
|
|
|
|
|
"?_shape=arrayfirst",
|
|
|
|
|
|
"?_shape=object",
|
|
|
|
|
|
"?_shape=objects",
|
|
|
|
|
|
"?_shape=array",
|
|
|
|
|
|
"?_shape=array&_nl=on",
|
|
|
|
|
|
],
|
|
|
|
|
|
)
|
2022-12-15 22:09:33 -08:00
|
|
|
|
async def test_paginate_using_link_header(ds_client, qs):
|
2021-12-11 19:07:19 -08:00
|
|
|
|
path = f"/fixtures/compound_three_primary_keys.json{qs}"
|
|
|
|
|
|
num_pages = 0
|
|
|
|
|
|
while path:
|
2022-12-15 22:09:33 -08:00
|
|
|
|
response = await ds_client.get(path)
|
|
|
|
|
|
assert response.status_code == 200
|
2021-12-11 19:07:19 -08:00
|
|
|
|
num_pages += 1
|
|
|
|
|
|
link = response.headers.get("link")
|
|
|
|
|
|
if link:
|
|
|
|
|
|
assert link.startswith("<")
|
|
|
|
|
|
assert link.endswith('>; rel="next"')
|
|
|
|
|
|
path = link[1:].split(">")[0]
|
|
|
|
|
|
path = path.replace("http://localhost", "")
|
|
|
|
|
|
else:
|
|
|
|
|
|
path = None
|
|
|
|
|
|
assert num_pages == 21
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.skipif(
|
|
|
|
|
|
sqlite_version() < (3, 31, 0),
|
|
|
|
|
|
reason="generated columns were added in SQLite 3.31.0",
|
|
|
|
|
|
)
|
|
|
|
|
|
def test_generated_columns_are_visible_in_datasette():
|
2026-02-17 13:30:24 -08:00
|
|
|
|
with make_app_client(extra_databases={"generated.db": """
|
2021-12-11 19:07:19 -08:00
|
|
|
|
CREATE TABLE generated_columns (
|
|
|
|
|
|
body TEXT,
|
|
|
|
|
|
id INT GENERATED ALWAYS AS (json_extract(body, '$.number')) STORED,
|
|
|
|
|
|
consideration INT GENERATED ALWAYS AS (json_extract(body, '$.string')) STORED
|
|
|
|
|
|
);
|
|
|
|
|
|
INSERT INTO generated_columns (body) VALUES (
|
|
|
|
|
|
'{"number": 1, "string": "This is a string"}'
|
2026-02-17 13:30:24 -08:00
|
|
|
|
);"""}) as client:
|
2021-12-11 19:07:19 -08:00
|
|
|
|
response = client.get("/generated/generated_columns.json?_shape=array")
|
|
|
|
|
|
assert response.json == [
|
|
|
|
|
|
{
|
|
|
|
|
|
"rowid": 1,
|
|
|
|
|
|
"body": '{"number": 1, "string": "This is a string"}',
|
|
|
|
|
|
"id": 1,
|
|
|
|
|
|
"consideration": "This is a string",
|
|
|
|
|
|
}
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
|
2022-12-15 22:09:33 -08:00
|
|
|
|
@pytest.mark.asyncio
|
2021-12-11 19:07:19 -08:00
|
|
|
|
@pytest.mark.parametrize(
|
|
|
|
|
|
"path,expected_columns",
|
|
|
|
|
|
(
|
|
|
|
|
|
("/fixtures/facetable.json?_col=created", ["pk", "created"]),
|
|
|
|
|
|
(
|
|
|
|
|
|
"/fixtures/facetable.json?_nocol=created",
|
|
|
|
|
|
[
|
|
|
|
|
|
"pk",
|
|
|
|
|
|
"planet_int",
|
|
|
|
|
|
"on_earth",
|
|
|
|
|
|
"state",
|
|
|
|
|
|
"_city_id",
|
|
|
|
|
|
"_neighborhood",
|
|
|
|
|
|
"tags",
|
|
|
|
|
|
"complex_array",
|
|
|
|
|
|
"distinct_some_null",
|
2022-03-18 18:37:54 -07:00
|
|
|
|
"n",
|
2021-12-11 19:07:19 -08:00
|
|
|
|
],
|
|
|
|
|
|
),
|
|
|
|
|
|
(
|
|
|
|
|
|
"/fixtures/facetable.json?_col=state&_col=created",
|
|
|
|
|
|
["pk", "state", "created"],
|
|
|
|
|
|
),
|
|
|
|
|
|
(
|
|
|
|
|
|
"/fixtures/facetable.json?_col=state&_col=state",
|
|
|
|
|
|
["pk", "state"],
|
|
|
|
|
|
),
|
2026-06-24 02:48:49 +05:30
|
|
|
|
(
|
|
|
|
|
|
# https://github.com/simonw/datasette/issues/1975
|
|
|
|
|
|
"/fixtures/facetable.json?_col=pk&_col=state",
|
|
|
|
|
|
["pk", "state"],
|
|
|
|
|
|
),
|
|
|
|
|
|
(
|
|
|
|
|
|
# https://github.com/simonw/datasette/issues/1975
|
|
|
|
|
|
"/fixtures/facetable.json?_col=pk",
|
|
|
|
|
|
["pk"],
|
|
|
|
|
|
),
|
2021-12-11 19:07:19 -08:00
|
|
|
|
(
|
|
|
|
|
|
"/fixtures/facetable.json?_col=state&_col=created&_nocol=created",
|
|
|
|
|
|
["pk", "state"],
|
|
|
|
|
|
),
|
|
|
|
|
|
(
|
|
|
|
|
|
# Ensure faceting doesn't break, https://github.com/simonw/datasette/issues/1345
|
|
|
|
|
|
"/fixtures/facetable.json?_nocol=state&_facet=state",
|
|
|
|
|
|
[
|
|
|
|
|
|
"pk",
|
|
|
|
|
|
"created",
|
|
|
|
|
|
"planet_int",
|
|
|
|
|
|
"on_earth",
|
|
|
|
|
|
"_city_id",
|
|
|
|
|
|
"_neighborhood",
|
|
|
|
|
|
"tags",
|
|
|
|
|
|
"complex_array",
|
|
|
|
|
|
"distinct_some_null",
|
2022-03-18 18:37:54 -07:00
|
|
|
|
"n",
|
2021-12-11 19:07:19 -08:00
|
|
|
|
],
|
|
|
|
|
|
),
|
|
|
|
|
|
(
|
|
|
|
|
|
"/fixtures/simple_view.json?_nocol=content",
|
|
|
|
|
|
["upper_content"],
|
|
|
|
|
|
),
|
|
|
|
|
|
("/fixtures/simple_view.json?_col=content", ["content"]),
|
|
|
|
|
|
),
|
|
|
|
|
|
)
|
2022-12-15 22:09:33 -08:00
|
|
|
|
async def test_col_nocol(ds_client, path, expected_columns):
|
2023-03-22 15:49:39 -07:00
|
|
|
|
response = await ds_client.get(path + "&_extra=columns")
|
2022-12-15 22:09:33 -08:00
|
|
|
|
assert response.status_code == 200
|
|
|
|
|
|
columns = response.json()["columns"]
|
2021-12-11 19:07:19 -08:00
|
|
|
|
assert columns == expected_columns
|
|
|
|
|
|
|
|
|
|
|
|
|
2022-12-15 22:09:33 -08:00
|
|
|
|
@pytest.mark.asyncio
|
2021-12-11 19:07:19 -08:00
|
|
|
|
@pytest.mark.parametrize(
|
|
|
|
|
|
"path,expected_error",
|
|
|
|
|
|
(
|
|
|
|
|
|
("/fixtures/facetable.json?_col=bad", "_col=bad - invalid columns"),
|
|
|
|
|
|
("/fixtures/facetable.json?_nocol=bad", "_nocol=bad - invalid columns"),
|
|
|
|
|
|
("/fixtures/facetable.json?_nocol=pk", "_nocol=pk - invalid columns"),
|
|
|
|
|
|
("/fixtures/simple_view.json?_col=bad", "_col=bad - invalid columns"),
|
|
|
|
|
|
),
|
|
|
|
|
|
)
|
2022-12-15 22:09:33 -08:00
|
|
|
|
async def test_col_nocol_errors(ds_client, path, expected_error):
|
|
|
|
|
|
response = await ds_client.get(path)
|
|
|
|
|
|
assert response.status_code == 400
|
|
|
|
|
|
assert response.json()["error"] == expected_error
|
2024-01-08 13:12:57 -08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
|
|
|
|
"extra,expected_json",
|
|
|
|
|
|
(
|
|
|
|
|
|
(
|
|
|
|
|
|
"columns",
|
|
|
|
|
|
{
|
|
|
|
|
|
"ok": True,
|
|
|
|
|
|
"next": None,
|
2026-07-04 16:19:03 +00:00
|
|
|
|
"next_url": None,
|
2024-01-08 13:12:57 -08:00
|
|
|
|
"columns": ["id", "content", "content2"],
|
|
|
|
|
|
"rows": [{"id": "1", "content": "hey", "content2": "world"}],
|
|
|
|
|
|
"truncated": False,
|
|
|
|
|
|
},
|
|
|
|
|
|
),
|
2024-01-08 13:13:53 -08:00
|
|
|
|
(
|
|
|
|
|
|
"count",
|
|
|
|
|
|
{
|
|
|
|
|
|
"ok": True,
|
|
|
|
|
|
"next": None,
|
2026-07-04 16:19:03 +00:00
|
|
|
|
"next_url": None,
|
2024-01-08 13:13:53 -08:00
|
|
|
|
"rows": [{"id": "1", "content": "hey", "content2": "world"}],
|
|
|
|
|
|
"truncated": False,
|
|
|
|
|
|
"count": 1,
|
2026-07-04 16:09:40 +00:00
|
|
|
|
"count_truncated": False,
|
2024-01-08 13:13:53 -08:00
|
|
|
|
},
|
|
|
|
|
|
),
|
2024-01-08 13:12:57 -08:00
|
|
|
|
),
|
|
|
|
|
|
)
|
|
|
|
|
|
async def test_table_extras(ds_client, extra, expected_json):
|
|
|
|
|
|
response = await ds_client.get(
|
|
|
|
|
|
"/fixtures/primary_key_multiple_columns.json?_extra=" + extra
|
|
|
|
|
|
)
|
|
|
|
|
|
assert response.status_code == 200
|
|
|
|
|
|
assert response.json() == expected_json
|
2025-12-21 19:52:49 -08:00
|
|
|
|
|
|
|
|
|
|
|
2026-06-08 20:45:01 -07:00
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
|
async def test_table_extra_columns_can_be_comma_separated(ds_client):
|
|
|
|
|
|
response = await ds_client.get(
|
|
|
|
|
|
"/fixtures/primary_key_multiple_columns.json?_extra=columns,count"
|
|
|
|
|
|
)
|
|
|
|
|
|
assert response.status_code == 200
|
|
|
|
|
|
data = response.json()
|
|
|
|
|
|
assert data["columns"] == ["id", "content", "content2"]
|
|
|
|
|
|
assert data["count"] == 1
|
|
|
|
|
|
|
|
|
|
|
|
|
2025-12-21 19:52:49 -08:00
|
|
|
|
@pytest.mark.asyncio
|
2025-12-21 20:03:10 -08:00
|
|
|
|
async def test_extra_render_cell():
|
|
|
|
|
|
"""Test that _extra=render_cell returns rendered HTML from render_cell plugin hook"""
|
2025-12-21 19:52:49 -08:00
|
|
|
|
from datasette import hookimpl
|
|
|
|
|
|
from datasette.app import Datasette
|
|
|
|
|
|
|
|
|
|
|
|
class TestRenderCellPlugin:
|
|
|
|
|
|
__name__ = "TestRenderCellPlugin"
|
|
|
|
|
|
|
|
|
|
|
|
@hookimpl
|
|
|
|
|
|
def render_cell(self, value, column, table, database):
|
|
|
|
|
|
# Only modify cells in our test table
|
|
|
|
|
|
if table == "test_render" and column == "name":
|
|
|
|
|
|
return f"<strong>{value}</strong>"
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
ds = Datasette(memory=True)
|
|
|
|
|
|
await ds.invoke_startup()
|
2025-12-21 20:03:10 -08:00
|
|
|
|
db = ds.add_memory_database("test_table_render")
|
2025-12-21 19:52:49 -08:00
|
|
|
|
await db.execute_write(
|
|
|
|
|
|
"create table test_render (id integer primary key, name text)"
|
|
|
|
|
|
)
|
|
|
|
|
|
await db.execute_write("insert into test_render values (1, 'Alice')")
|
|
|
|
|
|
await db.execute_write("insert into test_render values (2, 'Bob')")
|
|
|
|
|
|
|
|
|
|
|
|
# Register our test plugin
|
|
|
|
|
|
ds.pm.register(TestRenderCellPlugin(), name="TestRenderCellPlugin")
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
2025-12-21 20:03:10 -08:00
|
|
|
|
# Request with _extra=render_cell
|
|
|
|
|
|
response = await ds.client.get(
|
|
|
|
|
|
"/test_table_render/test_render.json?_extra=render_cell"
|
|
|
|
|
|
)
|
2025-12-21 19:52:49 -08:00
|
|
|
|
assert response.status_code == 200
|
|
|
|
|
|
data = response.json()
|
|
|
|
|
|
|
|
|
|
|
|
# Verify the response structure
|
2025-12-21 20:03:10 -08:00
|
|
|
|
assert "render_cell" in data
|
2025-12-21 19:52:49 -08:00
|
|
|
|
assert "rows" in data
|
|
|
|
|
|
|
2025-12-21 20:03:10 -08:00
|
|
|
|
# render_cell should be a list of rows, each row being a dict of column -> rendered HTML
|
2025-12-21 20:18:26 -08:00
|
|
|
|
# Only columns modified by plugins are included (sparse output)
|
2025-12-21 20:03:10 -08:00
|
|
|
|
render_cell = data["render_cell"]
|
|
|
|
|
|
assert len(render_cell) == 2
|
2025-12-21 19:52:49 -08:00
|
|
|
|
|
|
|
|
|
|
# First row: id=1, name='Alice'
|
|
|
|
|
|
# The 'name' column should be rendered by our plugin as <strong>Alice</strong>
|
2025-12-21 20:03:10 -08:00
|
|
|
|
assert render_cell[0]["name"] == "<strong>Alice</strong>"
|
2025-12-21 20:18:26 -08:00
|
|
|
|
# The 'id' column is not included since no plugin modified it
|
|
|
|
|
|
assert "id" not in render_cell[0]
|
2025-12-21 19:52:49 -08:00
|
|
|
|
|
|
|
|
|
|
# Second row: id=2, name='Bob'
|
2025-12-21 20:03:10 -08:00
|
|
|
|
assert render_cell[1]["name"] == "<strong>Bob</strong>"
|
2025-12-21 20:18:26 -08:00
|
|
|
|
assert "id" not in render_cell[1]
|
2025-12-21 19:52:49 -08:00
|
|
|
|
|
|
|
|
|
|
# The regular rows should still contain raw values
|
|
|
|
|
|
assert data["rows"] == [
|
|
|
|
|
|
{"id": 1, "name": "Alice"},
|
|
|
|
|
|
{"id": 2, "name": "Bob"},
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
finally:
|
|
|
|
|
|
ds.pm.unregister(name="TestRenderCellPlugin")
|
2026-07-04 16:09:40 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
|
async def test_count_truncated_included_with_count_extra(tmp_path_factory):
|
|
|
|
|
|
from datasette.app import Datasette
|
|
|
|
|
|
from datasette.utils import sqlite3
|
|
|
|
|
|
|
|
|
|
|
|
db_directory = tmp_path_factory.mktemp("dbs")
|
|
|
|
|
|
db_path = str(db_directory / "counts.db")
|
|
|
|
|
|
conn = sqlite3.connect(db_path)
|
|
|
|
|
|
conn.execute("vacuum")
|
|
|
|
|
|
conn.execute("create table big (id integer primary key)")
|
|
|
|
|
|
conn.execute("create table small (id integer primary key)")
|
|
|
|
|
|
conn.executemany("insert into big (id) values (?)", [(i,) for i in range(10)])
|
|
|
|
|
|
conn.executemany("insert into small (id) values (?)", [(i,) for i in range(3)])
|
|
|
|
|
|
conn.commit()
|
|
|
|
|
|
conn.close()
|
|
|
|
|
|
ds = Datasette([db_path])
|
|
|
|
|
|
ds.get_database("counts").count_limit = 5
|
|
|
|
|
|
try:
|
|
|
|
|
|
response = await ds.client.get("/counts/big.json?_extra=count")
|
|
|
|
|
|
data = response.json()
|
|
|
|
|
|
# Count is capped at count_limit + 1 and flagged as truncated
|
|
|
|
|
|
assert data["count"] == 6
|
|
|
|
|
|
assert data["count_truncated"] is True
|
|
|
|
|
|
|
|
|
|
|
|
response = await ds.client.get("/counts/small.json?_extra=count")
|
|
|
|
|
|
data = response.json()
|
|
|
|
|
|
assert data["count"] == 3
|
|
|
|
|
|
assert data["count_truncated"] is False
|
|
|
|
|
|
|
|
|
|
|
|
# count_truncated can also be requested on its own
|
|
|
|
|
|
response = await ds.client.get("/counts/big.json?_extra=count_truncated")
|
|
|
|
|
|
assert response.json()["count_truncated"] is True
|
|
|
|
|
|
finally:
|
|
|
|
|
|
ds.close()
|
2026-07-04 16:19:03 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
|
async def test_next_url_included_by_default(ds_client):
|
|
|
|
|
|
response = await ds_client.get("/fixtures/compound_three_primary_keys.json")
|
|
|
|
|
|
data = response.json()
|
|
|
|
|
|
assert data["next"] is not None
|
|
|
|
|
|
assert data["next_url"].endswith(
|
|
|
|
|
|
"/fixtures/compound_three_primary_keys.json?_next="
|
|
|
|
|
|
+ urllib.parse.quote(data["next"], safe="")
|
|
|
|
|
|
)
|
|
|
|
|
|
# Follow to the last page - next and next_url are both null there
|
|
|
|
|
|
while data["next"]:
|
|
|
|
|
|
response = await ds_client.get(
|
|
|
|
|
|
"/fixtures/compound_three_primary_keys.json?_next=" + data["next"]
|
|
|
|
|
|
)
|
|
|
|
|
|
data = response.json()
|
|
|
|
|
|
assert data["next"] is None
|
|
|
|
|
|
assert data["next_url"] is None
|