/<database>/-/foreign-key-targets API endpoint

Returns a list of tables with a single primary key, and for each one
the name of that primary key column and its SQLite type affinity.

This will be used by the create table UI to suggest foreign keys.
This commit is contained in:
Simon Willison 2026-06-17 16:29:59 -07:00
commit a6ef65f90d
4 changed files with 173 additions and 1 deletions

View file

@ -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<database>[^\/\.]+)(\.(?P<format>\w+))?$",
)
add_route(TableCreateView.as_view(self), r"/(?P<database>[^\/\.]+)/-/create$")
add_route(
DatabaseForeignKeyTargetsView.as_view(self),
r"/(?P<database>[^\/\.]+)/-/foreign-key-targets$",
)
add_route(
QueryListView.as_view(self),
r"/(?P<database>[^\/\.]+)/-/queries(\.(?P<format>json))?$",

View file

@ -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"

View file

@ -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 ``/<database>/-/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 /<database>/-/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

View file

@ -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"])