diff --git a/datasette/app.py b/datasette/app.py index 6b9f47ba..79dffb66 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -50,6 +50,7 @@ from .views.database import ( QueryView, ) from .views.table_create_alter import ( + DatabaseForeignKeyTargetsView, TableAlterView, TableCreateView, TableForeignKeySuggestionsView, @@ -2566,6 +2567,10 @@ class Datasette: r"/(?P[^\/\.]+)(\.(?P\w+))?$", ) add_route(TableCreateView.as_view(self), r"/(?P[^\/\.]+)/-/create$") + add_route( + DatabaseForeignKeyTargetsView.as_view(self), + r"/(?P[^\/\.]+)/-/foreign-key-targets$", + ) add_route( QueryListView.as_view(self), r"/(?P[^\/\.]+)/-/queries(\.(?Pjson))?$", diff --git a/datasette/views/table_create_alter.py b/datasette/views/table_create_alter.py index 2cb59ac1..ce43199c 100644 --- a/datasette/views/table_create_alter.py +++ b/datasette/views/table_create_alter.py @@ -52,6 +52,34 @@ ALTER_TABLE_TYPE_FOR_SQLITE_TYPE = { FOREIGN_KEY_SUGGESTION_ROW_LIMIT = 500 FOREIGN_KEY_SUGGESTION_TIME_LIMIT_MS = 50 FOREIGN_KEY_SUGGESTION_TOTAL_TIME_LIMIT_MS = 200 +FOREIGN_KEY_TARGETS_SQL = """ +select + m.name as fk_table, + p.name as fk_column, + case + when upper(coalesce(p.type, '')) like '%INT%' then 'integer' + when upper(coalesce(p.type, '')) like '%CHAR%' + or upper(coalesce(p.type, '')) like '%CLOB%' + or upper(coalesce(p.type, '')) like '%TEXT%' then 'text' + when upper(coalesce(p.type, '')) like '%BLOB%' + or coalesce(p.type, '') = '' then 'blob' + when upper(coalesce(p.type, '')) like '%REAL%' + or upper(coalesce(p.type, '')) like '%FLOA%' + or upper(coalesce(p.type, '')) like '%' || 'DOU' || 'B' || '%' then 'real' + else 'numeric' + end as type +from sqlite_master as m +cross join pragma_table_info(m.name) as p +where m.type = 'table' + and m.name not like 'sqlite_%' + and p.pk > 0 + and ( + select count(*) + from pragma_table_info(m.name) as p2 + where p2.pk > 0 + ) = 1 +order by m.name +""" class ForeignKeySuggestionTimedOut(Exception): @@ -66,7 +94,10 @@ def _sqlite_type_affinity(type_name): return "text" if "BLOB" in type_name or not type_name: return "blob" - if any(token in type_name for token in ("REAL", "FLOA", "DOUB")): + if any( + token in type_name + for token in ("REAL", "FLOA", "DOUB") # codespell:ignore doub + ): return "real" return "numeric" @@ -788,6 +819,33 @@ class TableCreateView(BaseView): return Response.json(details, status=201) +class DatabaseForeignKeyTargetsView(BaseView): + name = "database-foreign-key-targets" + + def __init__(self, datasette): + self.ds = datasette + + async def get(self, request): + db = await self.ds.resolve_database(request) + database_name = db.name + + if not await self.ds.allowed( + action="create-table", + resource=DatabaseResource(database=database_name), + actor=request.actor, + ): + return _error(["Permission denied: need create-table"], 403) + + targets = (await db.execute(FOREIGN_KEY_TARGETS_SQL)).dicts() + return Response.json( + { + "ok": True, + "database": database_name, + "targets": targets, + } + ) + + class TableForeignKeySuggestionsView(BaseView): name = "table-foreign-key-suggestions" diff --git a/docs/json_api.rst b/docs/json_api.rst index 5b05e920..dee98ef2 100644 --- a/docs/json_api.rst +++ b/docs/json_api.rst @@ -2097,6 +2097,40 @@ To use the ``"replace": true`` option you will also need the :ref:`actions_updat Pass ``"alter": true`` to automatically add any missing columns to the existing table that are present in the rows you are submitting. This requires the :ref:`actions_alter_table` permission. +.. _DatabaseForeignKeyTargetsView: + +Database foreign key targets +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The ``//-/foreign-key-targets`` endpoint returns the list of tables in a database that can be referenced by a single-column foreign key. This requires the :ref:`actions_create_table` permission. + +:: + + GET //-/foreign-key-targets + +The response includes only tables with exactly one primary key column. Tables with compound primary keys and tables with no explicit primary key are omitted. + +Each target includes the normalized SQLite type affinity for the primary key column in ``type``. The type is calculated using SQLite's documented affinity rules: ``INT`` maps to ``integer``; ``CHAR``, ``CLOB`` or ``TEXT`` maps to ``text``; ``BLOB`` or no type maps to ``blob``; ``REAL`` and floating-point declared types map to ``real``; everything else maps to ``numeric``. + +.. code-block:: json + + { + "ok": true, + "database": "data", + "targets": [ + { + "fk_table": "owners", + "fk_column": "id", + "type": "integer" + }, + { + "fk_table": "categories", + "fk_column": "slug", + "type": "text" + } + ] + } + .. _TableForeignKeySuggestionsView: Table foreign key suggestions diff --git a/tests/test_api_write.py b/tests/test_api_write.py index 36fe40e9..18ffe43d 100644 --- a/tests/test_api_write.py +++ b/tests/test_api_write.py @@ -1144,6 +1144,81 @@ async def test_foreign_key_suggestions_fail_open(ds_write, monkeypatch): assert columns["age"]["suggestions"] == [] +@pytest.mark.asyncio +async def test_foreign_key_targets(ds_write): + token = write_token(ds_write, permissions=["ct"]) + db = ds_write.get_database("data") + await db.execute_write("create table owners (id integer primary key)") + await db.execute_write("create table categories (slug varchar(30) primary key)") + await db.execute_write("create table blob_things (hash blob primary key)") + await db.execute_write( + "create table numeric_codes (code decimal(10,5) primary key)" + ) + await db.execute_write( + 'create table floating_point (value "FLOATING POINT" primary key)' + ) + await db.execute_write( + "create table compound (a integer, b integer, primary key (a, b))" + ) + await db.execute_write("create table no_pk (name text)") + + response = await ds_write.client.get( + "/data/-/foreign-key-targets", + headers=_headers(token), + ) + assert response.status_code == 200, response.text + assert response.json() == { + "ok": True, + "database": "data", + "targets": [ + { + "fk_table": "blob_things", + "fk_column": "hash", + "type": "blob", + }, + { + "fk_table": "categories", + "fk_column": "slug", + "type": "text", + }, + { + "fk_table": "docs", + "fk_column": "id", + "type": "integer", + }, + { + "fk_table": "floating_point", + "fk_column": "value", + "type": "integer", + }, + { + "fk_table": "numeric_codes", + "fk_column": "code", + "type": "numeric", + }, + { + "fk_table": "owners", + "fk_column": "id", + "type": "integer", + }, + ], + } + + +@pytest.mark.asyncio +async def test_foreign_key_targets_permission_denied(ds_write): + token = write_token(ds_write, permissions=["ir"]) + response = await ds_write.client.get( + "/data/-/foreign-key-targets", + headers=_headers(token), + ) + assert response.status_code == 403 + assert response.json() == { + "ok": False, + "errors": ["Permission denied: need create-table"], + } + + @pytest.mark.asyncio async def test_alter_table_permission_denied(ds_write): token = write_token(ds_write, permissions=["ir"])