From 8856914be805f4dee2e241895b848031bae7c526 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Fri, 3 Jul 2026 14:45:38 -0700 Subject: [PATCH] Add column_details JSON extra --- datasette/views/table_extras.py | 37 +++++++++++++++++++++++++++++ docs/changelog.rst | 7 ++++++ docs/json_api.rst | 36 +++++++++++++++++++++++++++++ tests/test_api.py | 36 ++++++++++++++++++++++++++++- tests/test_table_api.py | 41 +++++++++++++++++++++++++++++++++ 5 files changed, 156 insertions(+), 1 deletion(-) diff --git a/datasette/views/table_extras.py b/datasette/views/table_extras.py index db659c80..5ba4187a 100644 --- a/datasette/views/table_extras.py +++ b/datasette/views/table_extras.py @@ -1,6 +1,7 @@ import itertools from dataclasses import dataclass +from datasette.column_types import SQLiteType from datasette.database import QueryInterrupted from datasette.extras import Extra, ExtraExample, ExtraRegistry, ExtraScope, Provider from datasette.plugins import pm @@ -342,6 +343,41 @@ class PrimaryKeysExtra(Extra): return context.pks +def column_detail_as_json(column): + return { + "type": column.type, + "sqlite_type": SQLiteType.from_declared_type(column.type).value, + "notnull": column.notnull, + "default": column.default_value, + "is_pk": bool(column.is_pk), + "hidden": bool(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." + ) + example = ExtraExample("/fixtures/binary_data.json?_size=0&_extra=column_details") + examples = { + ExtraScope.ROW: ExtraExample( + "/fixtures/binary_data/1.json?_extra=column_details" + ) + } + scopes = {ExtraScope.TABLE, ExtraScope.ROW} + + async def resolve(self, context): + column_details = await context.datasette._get_resource_column_details( + context.database_name, context.table_name + ) + return { + column_name: column_detail_as_json(column) + for column_name, column in column_details.items() + } + + class ActionsExtra(Extra): description = 'Async callable returning table or view actions made available by core and plugin hooks. Each item is either a link with ``href``, ``label`` and optional ``description`` keys, or a button with ``type: "button"``, ``label``, optional ``description`` and optional ``attrs``. See :ref:`plugin_actions`, :ref:`plugin_hook_table_actions` and :ref:`plugin_hook_view_actions`.' scopes = {ExtraScope.TABLE} @@ -1206,6 +1242,7 @@ TABLE_EXTRA_CLASSES = [ ColumnsExtra, AllColumnsExtra, PrimaryKeysExtra, + ColumnDetailsExtra, DisplayColumnsAndRowsProvider, DisplayColumnsExtra, DisplayRowsExtra, diff --git a/docs/changelog.rst b/docs/changelog.rst index ec32b57e..7f3fd2c4 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -4,6 +4,13 @@ Changelog ========= +.. _v1_0_a36: + +1.0a36 (in development) +----------------------- + +- The table and row JSON APIs now support ``?_extra=column_details`` for returning SQLite schema details for columns, including declared type, SQLite affinity, primary key, ``NOT NULL``, default and hidden-column metadata. + .. _v1_0_a35: 1.0a35 (2026-06-23) diff --git a/docs/json_api.rst b/docs/json_api.rst index 01b95f08..05c2565f 100644 --- a/docs/json_api.rst +++ b/docs/json_api.rst @@ -402,6 +402,24 @@ The available table extras are listed below. "pk" ] +``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. + + ``GET /fixtures/binary_data.json?_size=0&_extra=column_details`` + + .. code-block:: json + + { + "data": { + "type": "BLOB", + "sqlite_type": "BLOB", + "notnull": 0, + "default": null, + "is_pk": false, + "hidden": false + } + } + ``display_columns`` Column metadata used by the HTML table display. Each item includes ``name``, ``sortable``, ``is_pk``, ``type``, ``notnull``, ``description``, ``column_type`` and ``column_type_config`` keys. @@ -807,6 +825,24 @@ The following extras are available for row JSON responses. "id" ] +``column_details`` + SQLite schema details for columns in this table. The dictionary maps column names to ``type``, ``sqlite_type``, ``notnull``, ``default``, ``is_pk`` and ``hidden`` values. + + ``GET /fixtures/binary_data/1.json?_extra=column_details`` + + .. code-block:: json + + { + "data": { + "type": "BLOB", + "sqlite_type": "BLOB", + "notnull": 0, + "default": null, + "is_pk": false, + "hidden": false + } + } + ``render_cell`` Rendered HTML for each cell using the render_cell plugin hook (See the :ref:`render_cell() plugin hook ` documentation.) diff --git a/tests/test_api.py b/tests/test_api.py index f57d0206..da9e5d00 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -429,7 +429,7 @@ async def test_row_foreign_key_tables(ds_client): @pytest.mark.asyncio async def test_row_extras(ds_client): response = await ds_client.get( - "/fixtures/simple_primary_key/1.json?_extra=database,table,primary_keys,query,request,debug,foreign_key_tables" + "/fixtures/simple_primary_key/1.json?_extra=database,table,primary_keys,query,request,debug,foreign_key_tables,column_details" ) assert response.status_code == 200 data = response.json() @@ -446,6 +446,40 @@ async def test_row_extras(ds_client): "format": "json", } assert len(data["foreign_key_tables"]) == 5 + assert data["column_details"]["id"] == { + "type": "INTEGER", + "sqlite_type": "INTEGER", + "notnull": 0, + "default": None, + "is_pk": True, + "hidden": False, + } + assert data["column_details"]["content"] == { + "type": "TEXT", + "sqlite_type": "TEXT", + "notnull": 0, + "default": None, + "is_pk": False, + "hidden": False, + } + + +@pytest.mark.asyncio +async def test_column_details_extra_row_for_null_blob(ds_client): + response = await ds_client.get( + "/fixtures/binary_data/3.json?_extra=column_details" + ) + assert response.status_code == 200 + assert response.json()["column_details"] == { + "data": { + "type": "BLOB", + "sqlite_type": "BLOB", + "notnull": 0, + "default": None, + "is_pk": False, + "hidden": False, + } + } @pytest.mark.asyncio diff --git a/tests/test_table_api.py b/tests/test_table_api.py index 272e39e3..0362c701 100644 --- a/tests/test_table_api.py +++ b/tests/test_table_api.py @@ -1336,6 +1336,47 @@ async def test_binary_data_in_json(ds_client, path, expected_json, expected_text assert response.text == expected_text +@pytest.mark.asyncio +async def test_column_details_extra_table(ds_client): + response = await ds_client.get( + "/fixtures/binary_data.json?_size=0&_extra=column_details" + ) + assert response.status_code == 200 + assert response.json()["column_details"] == { + "data": { + "type": "BLOB", + "sqlite_type": "BLOB", + "notnull": 0, + "default": None, + "is_pk": False, + "hidden": False, + } + } + + response = await ds_client.get( + "/fixtures/simple_primary_key.json?_size=0&_extra=column_details" + ) + assert response.status_code == 200 + assert response.json()["column_details"] == { + "id": { + "type": "INTEGER", + "sqlite_type": "INTEGER", + "notnull": 0, + "default": None, + "is_pk": True, + "hidden": False, + }, + "content": { + "type": "TEXT", + "sqlite_type": "TEXT", + "notnull": 0, + "default": None, + "is_pk": False, + "hidden": False, + }, + } + + @pytest.mark.asyncio @pytest.mark.parametrize( "qs",