From b476218edb78447529d3085b3c1684a39aa80d62 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Fri, 3 Jul 2026 16:08:34 -0700 Subject: [PATCH] Refine column_details metadata shape --- datasette/views/table_extras.py | 22 ++++++++-- docs/json_api.rst | 14 ++++--- tests/test_api.py | 19 ++++----- tests/test_table_api.py | 71 ++++++++++++++++++++++++++++++--- 4 files changed, 101 insertions(+), 25 deletions(-) diff --git a/datasette/views/table_extras.py b/datasette/views/table_extras.py index 5ba4187a..274c99ff 100644 --- a/datasette/views/table_extras.py +++ b/datasette/views/table_extras.py @@ -347,18 +347,32 @@ def column_detail_as_json(column): return { "type": column.type, "sqlite_type": SQLiteType.from_declared_type(column.type).value, - "notnull": column.notnull, + "notnull": bool(column.notnull), "default": column.default_value, "is_pk": bool(column.is_pk), - "hidden": bool(column.hidden), + "pk_position": column.is_pk, + "hidden": column.hidden, } class ColumnDetailsExtra(Extra): description = ( "SQLite schema details for columns in this table. The dictionary maps " - "column names to ``type``, ``sqlite_type``, ``notnull``, ``default``, " - "``is_pk`` and ``hidden`` values." + "column names to objects describing the schema for each column." + ) + docs_note = ( + "Each object has ``type`` as the declared type string returned by " + 'SQLite, or ``""`` if no type was declared; ``sqlite_type`` as the ' + "normalized SQLite affinity, one of ``TEXT``, ``INTEGER``, ``REAL``, " + "``BLOB`` or ``NUMERIC``; ``notnull`` as a boolean; ``default`` " + 'as the raw SQL default expression string, such as ``"42"``, ' + "``\"'hello'\"`` or ``\"datetime('now')\"``, or ``null`` if there is " + "no default; ``is_pk`` as a boolean; ``pk_position`` as the integer " + "primary key position reported by SQLite, or ``0`` for columns that " + "are not part of the primary key; and ``hidden`` as the integer value " + "reported by SQLite's ``PRAGMA table_xinfo``. ``hidden`` is ``0`` for " + "normal columns, ``1`` for hidden virtual table columns, ``2`` for " + "virtual generated columns and ``3`` for stored generated columns." ) example = ExtraExample("/fixtures/binary_data.json?_size=0&_extra=column_details") examples = { diff --git a/docs/json_api.rst b/docs/json_api.rst index 05c2565f..f14a5c07 100644 --- a/docs/json_api.rst +++ b/docs/json_api.rst @@ -403,7 +403,7 @@ The available table extras are listed below. ] ``column_details`` - SQLite schema details for columns in this table. The dictionary maps column names to ``type``, ``sqlite_type``, ``notnull``, ``default``, ``is_pk`` and ``hidden`` values. + SQLite schema details for columns in this table. The dictionary maps column names to objects describing the schema for each column. (Each object has ``type`` as the declared type string returned by SQLite, or ``""`` if no type was declared; ``sqlite_type`` as the normalized SQLite affinity, one of ``TEXT``, ``INTEGER``, ``REAL``, ``BLOB`` or ``NUMERIC``; ``notnull`` as a boolean; ``default`` as the raw SQL default expression string, such as ``"42"``, ``"'hello'"`` or ``"datetime('now')"``, or ``null`` if there is no default; ``is_pk`` as a boolean; ``pk_position`` as the integer primary key position reported by SQLite, or ``0`` for columns that are not part of the primary key; and ``hidden`` as the integer value reported by SQLite's ``PRAGMA table_xinfo``. ``hidden`` is ``0`` for normal columns, ``1`` for hidden virtual table columns, ``2`` for virtual generated columns and ``3`` for stored generated columns.) ``GET /fixtures/binary_data.json?_size=0&_extra=column_details`` @@ -413,10 +413,11 @@ The available table extras are listed below. "data": { "type": "BLOB", "sqlite_type": "BLOB", - "notnull": 0, + "notnull": false, "default": null, "is_pk": false, - "hidden": false + "pk_position": 0, + "hidden": 0 } } @@ -826,7 +827,7 @@ The following extras are available for row JSON responses. ] ``column_details`` - SQLite schema details for columns in this table. The dictionary maps column names to ``type``, ``sqlite_type``, ``notnull``, ``default``, ``is_pk`` and ``hidden`` values. + SQLite schema details for columns in this table. The dictionary maps column names to objects describing the schema for each column. (Each object has ``type`` as the declared type string returned by SQLite, or ``""`` if no type was declared; ``sqlite_type`` as the normalized SQLite affinity, one of ``TEXT``, ``INTEGER``, ``REAL``, ``BLOB`` or ``NUMERIC``; ``notnull`` as a boolean; ``default`` as the raw SQL default expression string, such as ``"42"``, ``"'hello'"`` or ``"datetime('now')"``, or ``null`` if there is no default; ``is_pk`` as a boolean; ``pk_position`` as the integer primary key position reported by SQLite, or ``0`` for columns that are not part of the primary key; and ``hidden`` as the integer value reported by SQLite's ``PRAGMA table_xinfo``. ``hidden`` is ``0`` for normal columns, ``1`` for hidden virtual table columns, ``2`` for virtual generated columns and ``3`` for stored generated columns.) ``GET /fixtures/binary_data/1.json?_extra=column_details`` @@ -836,10 +837,11 @@ The following extras are available for row JSON responses. "data": { "type": "BLOB", "sqlite_type": "BLOB", - "notnull": 0, + "notnull": false, "default": null, "is_pk": false, - "hidden": false + "pk_position": 0, + "hidden": 0 } } diff --git a/tests/test_api.py b/tests/test_api.py index da9e5d00..ee3329e1 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -449,35 +449,36 @@ async def test_row_extras(ds_client): assert data["column_details"]["id"] == { "type": "INTEGER", "sqlite_type": "INTEGER", - "notnull": 0, + "notnull": False, "default": None, "is_pk": True, - "hidden": False, + "pk_position": 1, + "hidden": 0, } assert data["column_details"]["content"] == { "type": "TEXT", "sqlite_type": "TEXT", - "notnull": 0, + "notnull": False, "default": None, "is_pk": False, - "hidden": False, + "pk_position": 0, + "hidden": 0, } @pytest.mark.asyncio async def test_column_details_extra_row_for_null_blob(ds_client): - response = await ds_client.get( - "/fixtures/binary_data/3.json?_extra=column_details" - ) + response = await ds_client.get("/fixtures/binary_data/3.json?_extra=column_details") assert response.status_code == 200 assert response.json()["column_details"] == { "data": { "type": "BLOB", "sqlite_type": "BLOB", - "notnull": 0, + "notnull": False, "default": None, "is_pk": False, - "hidden": False, + "pk_position": 0, + "hidden": 0, } } diff --git a/tests/test_table_api.py b/tests/test_table_api.py index 0362c701..067a8bab 100644 --- a/tests/test_table_api.py +++ b/tests/test_table_api.py @@ -1346,10 +1346,11 @@ async def test_column_details_extra_table(ds_client): "data": { "type": "BLOB", "sqlite_type": "BLOB", - "notnull": 0, + "notnull": False, "default": None, "is_pk": False, - "hidden": False, + "pk_position": 0, + "hidden": 0, } } @@ -1361,21 +1362,79 @@ async def test_column_details_extra_table(ds_client): "id": { "type": "INTEGER", "sqlite_type": "INTEGER", - "notnull": 0, + "notnull": False, "default": None, "is_pk": True, - "hidden": False, + "pk_position": 1, + "hidden": 0, }, "content": { "type": "TEXT", "sqlite_type": "TEXT", - "notnull": 0, + "notnull": False, "default": None, "is_pk": False, - "hidden": False, + "pk_position": 0, + "hidden": 0, }, } + response = await ds_client.get( + "/fixtures/compound_three_primary_keys.json?_size=0&_extra=column_details" + ) + assert response.status_code == 200 + column_details = response.json()["column_details"] + assert column_details["pk1"]["is_pk"] is True + assert column_details["pk1"]["pk_position"] == 1 + assert column_details["pk2"]["is_pk"] is True + assert column_details["pk2"]["pk_position"] == 2 + assert column_details["pk3"]["is_pk"] is True + assert column_details["pk3"]["pk_position"] == 3 + assert column_details["content"]["is_pk"] is False + assert column_details["content"]["pk_position"] == 0 + + +def test_column_details_extra_defaults_and_notnull(): + with make_app_client(extra_databases={"defaults.db": """ + CREATE TABLE defaults ( + i INTEGER NOT NULL DEFAULT 42, + s TEXT DEFAULT 'hello', + dt TEXT DEFAULT (datetime('now')) + ); + """}) as client: + response = client.get("/defaults/defaults.json?_size=0&_extra=column_details") + assert response.status == 200 + column_details = response.json["column_details"] + assert column_details["i"]["notnull"] is True + assert column_details["i"]["default"] == "42" + assert column_details["s"]["notnull"] is False + assert column_details["s"]["default"] == "'hello'" + assert column_details["dt"]["default"] == "datetime('now')" + + +@pytest.mark.skipif( + sqlite_version() < (3, 31, 0), + reason="generated columns were added in SQLite 3.31.0", +) +def test_column_details_extra_generated_columns(): + with make_app_client(extra_databases={"generated.db": """ + CREATE TABLE generated_columns ( + body TEXT, + body_length_virtual INTEGER + GENERATED ALWAYS AS (length(body)) VIRTUAL, + body_length_stored INTEGER + GENERATED ALWAYS AS (length(body)) STORED + ); + """}) as client: + response = client.get( + "/generated/generated_columns.json?_size=0&_extra=column_details" + ) + assert response.status == 200 + column_details = response.json["column_details"] + assert column_details["body"]["hidden"] == 0 + assert column_details["body_length_virtual"]["hidden"] == 2 + assert column_details["body_length_stored"]["hidden"] == 3 + @pytest.mark.asyncio @pytest.mark.parametrize(