mirror of
https://github.com/simonw/datasette.git
synced 2026-06-23 09:14:34 +02:00
/<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:
parent
2900efb32d
commit
a6ef65f90d
4 changed files with 173 additions and 1 deletions
|
|
@ -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))?$",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue