2022-10-27 12:08:26 -07:00
from datasette . app import Datasette
2026-06-22 12:02:51 -07:00
from datasette . events import RenameTableEvent
2022-10-27 12:08:26 -07:00
from datasette . utils import sqlite3
2024-01-31 15:21:40 -08:00
from . utils import last_event
2022-10-27 12:08:26 -07:00
import pytest
import time
@pytest.fixture
def ds_write ( tmp_path_factory ) :
db_directory = tmp_path_factory . mktemp ( " dbs " )
db_path = str ( db_directory / " data.db " )
2022-11-13 21:40:10 -08:00
db_path_immutable = str ( db_directory / " immutable.db " )
db1 = sqlite3 . connect ( str ( db_path ) )
db2 = sqlite3 . connect ( str ( db_path_immutable ) )
for db in ( db1 , db2 ) :
db . execute ( " vacuum " )
db . execute (
2022-11-29 10:06:19 -08:00
" create table docs (id integer primary key, title text, score float, age integer) "
2022-11-13 21:40:10 -08:00
)
2025-12-12 22:38:04 -08:00
db1 . close ( )
db2 . close ( )
2022-11-13 21:40:10 -08:00
ds = Datasette ( [ db_path ] , immutables = [ db_path_immutable ] )
2025-10-23 14:43:51 -07:00
ds . root_enabled = True
2022-10-27 12:08:26 -07:00
yield ds
2026-04-16 20:25:58 -07:00
ds . close ( )
2022-10-27 12:08:26 -07:00
2022-12-07 17:12:15 -08:00
def write_token ( ds , actor_id = " root " , permissions = None ) :
to_sign = { " a " : actor_id , " token " : " dstok " , " t " : int ( time . time ( ) ) }
if permissions :
to_sign [ " _r " ] = { " a " : permissions }
return " dstok_ {} " . format ( ds . sign ( to_sign , namespace = " token " ) )
2022-10-30 15:17:21 -07:00
2022-12-07 17:39:07 -08:00
def _headers ( token ) :
return {
" Authorization " : " Bearer {} " . format ( token ) ,
" Content-Type " : " application/json " ,
}
2026-04-15 15:29:59 -07:00
@pytest.mark.asyncio
async def test_api_explorer_upsert_example_json ( ds_write ) :
response = await ds_write . client . get ( " /-/api " , actor = { " id " : " root " } )
assert response . status_code == 200
import urllib . parse
text = urllib . parse . unquote_plus ( response . text )
upsert_idx = text . index ( " /data/docs/-/upsert " )
upsert_chunk = text [ upsert_idx : upsert_idx + 500 ]
assert ' " id " : " <id (primary key)> " ' in upsert_chunk
assert ' " title " : " <title> " ' in upsert_chunk
assert ' " score " : " <score> " ' in upsert_chunk
assert ' " age " : " <age> " ' in upsert_chunk
2026-04-15 15:47:48 -07:00
@pytest.mark.asyncio
async def test_api_explorer_upsert_example_json_rowid_table ( tmp_path_factory ) :
db_path = str ( tmp_path_factory . mktemp ( " dbs " ) / " data.db " )
conn = sqlite3 . connect ( db_path )
conn . execute ( " create table things (title text, score float) " )
conn . close ( )
ds = Datasette ( [ db_path ] )
ds . root_enabled = True
response = await ds . client . get ( " /-/api " , actor = { " id " : " root " } )
assert response . status_code == 200
import urllib . parse
text = urllib . parse . unquote_plus ( response . text )
upsert_idx = text . index ( " /data/things/-/upsert " )
upsert_chunk = text [ upsert_idx : upsert_idx + 500 ]
assert ' " rowid " : " <rowid (primary key)> " ' in upsert_chunk
assert ' " title " : " <title> " ' in upsert_chunk
assert ' " score " : " <score> " ' in upsert_chunk
2022-10-27 12:08:26 -07:00
@pytest.mark.asyncio
2024-08-14 21:37:40 -07:00
@pytest.mark.parametrize (
" content_type " ,
(
" application/json " ,
" application/json; charset=utf-8 " ,
) ,
)
async def test_insert_row ( ds_write , content_type ) :
2022-10-29 23:03:45 -07:00
token = write_token ( ds_write )
2022-10-27 12:08:26 -07:00
response = await ds_write . client . post (
2022-10-27 13:17:18 -07:00
" /data/docs/-/insert " ,
2022-11-29 10:06:19 -08:00
json = { " row " : { " title " : " Test " , " score " : 1.2 , " age " : 5 } } ,
2024-08-14 21:37:40 -07:00
headers = {
" Authorization " : " Bearer {} " . format ( token ) ,
" Content-Type " : content_type ,
} ,
2022-10-27 12:08:26 -07:00
)
2022-11-29 10:06:19 -08:00
expected_row = { " id " : 1 , " title " : " Test " , " score " : 1.2 , " age " : 5 }
2022-10-27 12:08:26 -07:00
assert response . status_code == 201
2022-12-07 17:12:15 -08:00
assert response . json ( ) [ " ok " ] is True
2022-11-01 11:07:59 -07:00
assert response . json ( ) [ " rows " ] == [ expected_row ]
2024-09-01 17:20:41 -07:00
rows = ( await ds_write . get_database ( " data " ) . execute ( " select * from docs " ) ) . dicts ( )
assert rows [ 0 ] == expected_row
2024-01-31 15:21:40 -08:00
# Analytics event
event = last_event ( ds_write )
assert event . name == " insert-rows "
assert event . num_rows == 1
assert event . database == " data "
assert event . table == " docs "
assert not event . ignore
assert not event . replace
2022-10-29 23:03:45 -07:00
2024-02-08 13:14:12 -08:00
@pytest.mark.asyncio
async def test_insert_row_alter ( ds_write ) :
token = write_token ( ds_write )
response = await ds_write . client . post (
" /data/docs/-/insert " ,
json = {
" row " : { " title " : " Test " , " score " : 1.2 , " age " : 5 , " extra " : " extra " } ,
" alter " : True ,
} ,
headers = _headers ( token ) ,
)
assert response . status_code == 201
assert response . json ( ) [ " ok " ] is True
assert response . json ( ) [ " rows " ] [ 0 ] [ " extra " ] == " extra "
# Analytics event
event = last_event ( ds_write )
assert event . name == " alter-table "
assert " extra " not in event . before_schema
assert " extra " in event . after_schema
2022-10-29 23:03:45 -07:00
@pytest.mark.asyncio
@pytest.mark.parametrize ( " return_rows " , ( True , False ) )
2022-12-07 17:12:15 -08:00
async def test_insert_rows ( ds_write , return_rows ) :
2022-10-29 23:03:45 -07:00
token = write_token ( ds_write )
2022-11-29 10:06:19 -08:00
data = {
" rows " : [
{ " title " : " Test {} " . format ( i ) , " score " : 1.0 , " age " : 5 } for i in range ( 20 )
]
}
2022-10-29 23:03:45 -07:00
if return_rows :
2022-11-13 21:49:23 -08:00
data [ " return " ] = True
2022-10-29 23:03:45 -07:00
response = await ds_write . client . post (
" /data/docs/-/insert " ,
json = data ,
2022-12-07 17:39:07 -08:00
headers = _headers ( token ) ,
2022-10-29 23:03:45 -07:00
)
assert response . status_code == 201
2024-01-31 15:21:40 -08:00
# Analytics event
event = last_event ( ds_write )
assert event . name == " insert-rows "
assert event . num_rows == 20
assert event . database == " data "
assert event . table == " docs "
assert not event . ignore
assert not event . replace
2024-09-01 17:20:41 -07:00
actual_rows = (
await ds_write . get_database ( " data " ) . execute ( " select * from docs " )
) . dicts ( )
2022-10-29 23:03:45 -07:00
assert len ( actual_rows ) == 20
assert actual_rows == [
2022-11-29 10:06:19 -08:00
{ " id " : i + 1 , " title " : " Test {} " . format ( i ) , " score " : 1.0 , " age " : 5 }
for i in range ( 20 )
2022-10-29 23:03:45 -07:00
]
assert response . json ( ) [ " ok " ] is True
if return_rows :
2022-11-01 11:07:59 -07:00
assert response . json ( ) [ " rows " ] == actual_rows
2022-10-29 23:03:45 -07:00
@pytest.mark.asyncio
@pytest.mark.parametrize (
" path,input,special_case,expected_status,expected_errors " ,
(
(
" /data2/docs/-/insert " ,
{ } ,
None ,
404 ,
2024-06-21 16:02:15 -07:00
[ " Database not found " ] ,
2022-10-29 23:03:45 -07:00
) ,
(
" /data/docs2/-/insert " ,
{ } ,
None ,
404 ,
2024-06-21 16:09:20 -07:00
[ " Table not found " ] ,
2022-10-29 23:03:45 -07:00
) ,
(
" /data/docs/-/insert " ,
{ " rows " : [ { " title " : " Test " } for i in range ( 10 ) ] } ,
" bad_token " ,
403 ,
[ " Permission denied " ] ,
) ,
(
" /data/docs/-/insert " ,
{ } ,
" invalid_json " ,
400 ,
[
" Invalid JSON: Expecting property name enclosed in double quotes: line 1 column 2 (char 1) "
] ,
) ,
(
" /data/docs/-/insert " ,
{ } ,
" invalid_content_type " ,
400 ,
[ " Invalid content-type, must be application/json " ] ,
) ,
(
" /data/docs/-/insert " ,
[ ] ,
None ,
400 ,
[ " JSON must be a dictionary " ] ,
) ,
(
" /data/docs/-/insert " ,
{ " row " : " blah " } ,
None ,
400 ,
[ ' " row " must be a dictionary ' ] ,
) ,
(
" /data/docs/-/insert " ,
{ " blah " : " blah " } ,
None ,
400 ,
[ ' JSON must have one or other of " row " or " rows " ' ] ,
) ,
(
" /data/docs/-/insert " ,
{ " rows " : " blah " } ,
None ,
400 ,
[ ' " rows " must be a list ' ] ,
) ,
(
" /data/docs/-/insert " ,
{ " rows " : [ " blah " ] } ,
None ,
400 ,
[ ' " rows " must be a list of dictionaries ' ] ,
) ,
(
" /data/docs/-/insert " ,
{ " rows " : [ { " title " : " Test " } for i in range ( 101 ) ] } ,
None ,
400 ,
[ " Too many rows, maximum allowed is 100 " ] ,
) ,
2022-11-01 12:59:17 -07:00
(
" /data/docs/-/insert " ,
2022-11-30 18:05:29 -08:00
{ " rows " : [ { " id " : 1 , " title " : " Test " } , { " id " : 2 , " title " : " Test " } ] } ,
2022-11-01 12:59:17 -07:00
" duplicate_id " ,
400 ,
[ " UNIQUE constraint failed: docs.id " ] ,
) ,
2022-11-01 11:07:59 -07:00
(
" /data/docs/-/insert " ,
{ " rows " : [ { " title " : " Test " } ] , " ignore " : True , " replace " : True } ,
None ,
400 ,
[ ' Cannot use " ignore " and " replace " at the same time ' ] ,
) ,
2024-02-19 12:55:51 -08:00
(
# Replace is not allowed if you don't have update-row
" /data/docs/-/insert " ,
{ " rows " : [ { " title " : " Test " } ] , " replace " : True } ,
" insert-but-not-update " ,
403 ,
[ ' Permission denied: need update-row to use " replace " ' ] ,
) ,
2022-11-01 11:07:59 -07:00
(
" /data/docs/-/insert " ,
{ " rows " : [ { " title " : " Test " } ] , " invalid_param " : True } ,
None ,
400 ,
[ ' Invalid parameter: " invalid_param " ' ] ,
) ,
(
" /data/docs/-/insert " ,
{ " rows " : [ { " title " : " Test " } ] , " one " : True , " two " : True } ,
None ,
400 ,
[ ' Invalid parameter: " one " , " two " ' ] ,
) ,
2022-12-07 17:12:15 -08:00
(
" /immutable/docs/-/insert " ,
{ " rows " : [ { " title " : " Test " } ] } ,
None ,
403 ,
[ " Database is immutable " ] ,
) ,
2022-10-29 23:03:45 -07:00
# Validate columns of each row
(
" /data/docs/-/insert " ,
{ " rows " : [ { " title " : " Test " , " bad " : 1 , " worse " : 2 } for i in range ( 2 ) ] } ,
None ,
400 ,
[
" Row 0 has invalid columns: bad, worse " ,
" Row 1 has invalid columns: bad, worse " ,
] ,
) ,
2022-12-07 17:12:15 -08:00
## UPSERT ERRORS:
(
" /immutable/docs/-/upsert " ,
{ " rows " : [ { " title " : " Test " } ] } ,
None ,
403 ,
[ " Database is immutable " ] ,
) ,
(
" /data/badtable/-/upsert " ,
{ " rows " : [ { " title " : " Test " } ] } ,
None ,
404 ,
2024-06-21 16:09:20 -07:00
[ " Table not found " ] ,
2022-12-07 17:12:15 -08:00
) ,
# missing primary key
(
" /data/docs/-/upsert " ,
{ " rows " : [ { " title " : " Missing PK " } ] } ,
None ,
400 ,
[ ' Row 0 is missing primary key column(s): " id " ' ] ,
) ,
2026-04-15 15:11:18 -07:00
# null primary key
(
" /data/docs/-/upsert " ,
{ " rows " : [ { " id " : None , " title " : " Null PK " } ] } ,
None ,
400 ,
[ ' Row 0 has null primary key column(s): " id " ' ] ,
) ,
2022-12-07 17:12:15 -08:00
# Upsert does not support ignore or replace
(
" /data/docs/-/upsert " ,
{ " rows " : [ { " id " : 1 , " title " : " Bad " } ] , " ignore " : True } ,
None ,
400 ,
[ " Upsert does not support ignore or replace " ] ,
) ,
# Upsert permissions
(
" /data/docs/-/upsert " ,
{ " rows " : [ { " id " : 1 , " title " : " Disallowed " } ] } ,
" insert-but-not-update " ,
403 ,
[ " Permission denied: need both insert-row and update-row " ] ,
) ,
(
" /data/docs/-/upsert " ,
{ " rows " : [ { " id " : 1 , " title " : " Disallowed " } ] } ,
" update-but-not-insert " ,
403 ,
[ " Permission denied: need both insert-row and update-row " ] ,
) ,
2024-02-08 13:14:12 -08:00
# Alter table forbidden without alter permission
(
" /data/docs/-/upsert " ,
{ " rows " : [ { " id " : 1 , " title " : " One " , " extra " : " extra " } ] , " alter " : True } ,
" update-and-insert-but-no-alter " ,
403 ,
[ " Permission denied for alter-table " ] ,
) ,
2022-10-29 23:03:45 -07:00
) ,
)
2022-12-07 17:12:15 -08:00
async def test_insert_or_upsert_row_errors (
2022-10-29 23:03:45 -07:00
ds_write , path , input , special_case , expected_status , expected_errors
) :
2024-02-08 13:14:12 -08:00
token_permissions = [ ]
2022-12-07 17:12:15 -08:00
if special_case == " insert-but-not-update " :
2024-02-08 13:14:12 -08:00
token_permissions = [ " ir " , " vi " ]
2022-12-07 17:12:15 -08:00
if special_case == " update-but-not-insert " :
2024-02-08 13:14:12 -08:00
token_permissions = [ " ur " , " vi " ]
if special_case == " update-and-insert-but-no-alter " :
token_permissions = [ " ur " , " ir " ]
token = write_token ( ds_write , permissions = token_permissions )
2022-11-01 12:59:17 -07:00
if special_case == " duplicate_id " :
await ds_write . get_database ( " data " ) . execute_write (
" insert into docs (id) values (1) "
)
2022-10-29 23:03:45 -07:00
if special_case == " bad_token " :
token + = " bad "
kwargs = dict (
json = input ,
headers = {
" Authorization " : " Bearer {} " . format ( token ) ,
2024-01-30 19:55:26 -08:00
" Content-Type " : (
" text/plain "
if special_case == " invalid_content_type "
else " application/json "
) ,
2022-10-29 23:03:45 -07:00
} ,
)
2022-12-07 17:12:15 -08:00
actor_response = (
await ds_write . client . get ( " /-/actor.json " , headers = kwargs [ " headers " ] )
) . json ( )
2024-02-08 13:14:12 -08:00
assert set ( ( actor_response [ " actor " ] or { } ) . get ( " _r " , { } ) . get ( " a " ) or [ ] ) == set (
token_permissions
)
2022-12-07 17:12:15 -08:00
2022-10-29 23:03:45 -07:00
if special_case == " invalid_json " :
del kwargs [ " json " ]
kwargs [ " content " ] = " { bad json "
2022-11-30 18:05:29 -08:00
before_count = (
await ds_write . get_database ( " data " ) . execute ( " select count(*) from docs " )
) . rows [ 0 ] [ 0 ] == 0
2022-10-29 23:03:45 -07:00
response = await ds_write . client . post (
path ,
* * kwargs ,
)
assert response . status_code == expected_status
assert response . json ( ) [ " ok " ] is False
assert response . json ( ) [ " errors " ] == expected_errors
2022-11-30 18:05:29 -08:00
# Check that no rows were inserted
after_count = (
await ds_write . get_database ( " data " ) . execute ( " select count(*) from docs " )
) . rows [ 0 ] [ 0 ] == 0
assert before_count == after_count
2022-10-29 23:03:45 -07:00
2024-02-16 13:39:57 -08:00
@pytest.mark.asyncio
@pytest.mark.parametrize ( " allowed " , ( True , False ) )
async def test_upsert_permissions_per_table ( ds_write , allowed ) :
# https://github.com/simonw/datasette/issues/2262
token = " dstok_ {} " . format (
ds_write . sign (
{
" a " : " root " ,
" token " : " dstok " ,
" t " : int ( time . time ( ) ) ,
" _r " : {
" r " : {
" data " : {
" docs " if allowed else " other " : [ " ir " , " ur " ] ,
}
}
} ,
} ,
namespace = " token " ,
)
)
response = await ds_write . client . post (
" /data/docs/-/upsert " ,
json = { " rows " : [ { " id " : 1 , " title " : " One " } ] } ,
headers = {
" Authorization " : " Bearer {} " . format ( token ) ,
} ,
)
if allowed :
assert response . status_code == 200
assert response . json ( ) [ " ok " ] is True
else :
assert response . status_code == 403
2022-11-01 11:07:59 -07:00
@pytest.mark.asyncio
@pytest.mark.parametrize (
" ignore,replace,expected_rows " ,
(
(
True ,
False ,
[
2022-11-29 10:06:19 -08:00
{ " id " : 1 , " title " : " Exists " , " score " : None , " age " : None } ,
2022-11-01 11:07:59 -07:00
] ,
) ,
(
False ,
True ,
[
2022-11-29 10:06:19 -08:00
{ " id " : 1 , " title " : " One " , " score " : None , " age " : None } ,
2022-11-01 11:07:59 -07:00
] ,
) ,
) ,
)
@pytest.mark.parametrize ( " should_return " , ( True , False ) )
async def test_insert_ignore_replace (
ds_write , ignore , replace , expected_rows , should_return
) :
await ds_write . get_database ( " data " ) . execute_write (
" insert into docs (id, title) values (1, ' Exists ' ) "
)
token = write_token ( ds_write )
data = { " rows " : [ { " id " : 1 , " title " : " One " } ] }
if ignore :
data [ " ignore " ] = True
if replace :
data [ " replace " ] = True
if should_return :
2022-11-13 21:49:23 -08:00
data [ " return " ] = True
2022-11-01 11:07:59 -07:00
response = await ds_write . client . post (
" /data/docs/-/insert " ,
json = data ,
2022-12-07 17:39:07 -08:00
headers = _headers ( token ) ,
2022-11-01 11:07:59 -07:00
)
assert response . status_code == 201
2024-01-31 15:21:40 -08:00
# Analytics event
event = last_event ( ds_write )
assert event . name == " insert-rows "
assert event . num_rows == 1
assert event . database == " data "
assert event . table == " docs "
assert event . ignore == ignore
assert event . replace == replace
2024-09-01 17:20:41 -07:00
actual_rows = (
await ds_write . get_database ( " data " ) . execute ( " select * from docs " )
) . dicts ( )
2022-11-01 11:07:59 -07:00
assert actual_rows == expected_rows
assert response . json ( ) [ " ok " ] is True
if should_return :
assert response . json ( ) [ " rows " ] == expected_rows
2022-12-07 17:12:15 -08:00
@pytest.mark.asyncio
@pytest.mark.parametrize (
" initial,input,expected_rows " ,
(
(
# Simple primary key update
{ " rows " : [ { " id " : 1 , " title " : " One " } ] , " pk " : " id " } ,
{ " rows " : [ { " id " : 1 , " title " : " Two " } ] } ,
[
{ " id " : 1 , " title " : " Two " } ,
] ,
) ,
(
# Multiple rows update one of them
{
" rows " : [ { " id " : 1 , " title " : " One " } , { " id " : 2 , " title " : " Two " } ] ,
" pk " : " id " ,
} ,
{ " rows " : [ { " id " : 1 , " title " : " Three " } ] } ,
[
{ " id " : 1 , " title " : " Three " } ,
{ " id " : 2 , " title " : " Two " } ,
] ,
) ,
(
# rowid update
{ " rows " : [ { " title " : " One " } ] } ,
{ " rows " : [ { " rowid " : 1 , " title " : " Two " } ] } ,
[
{ " rowid " : 1 , " title " : " Two " } ,
] ,
) ,
(
# Compound primary key update
{ " rows " : [ { " id " : 1 , " title " : " One " , " score " : 1 } ] , " pks " : [ " id " , " score " ] } ,
{ " rows " : [ { " id " : 1 , " title " : " Two " , " score " : 1 } ] } ,
[
{ " id " : 1 , " title " : " Two " , " score " : 1 } ,
] ,
) ,
2024-02-08 13:14:12 -08:00
(
# Upsert with an alter
{ " rows " : [ { " id " : 1 , " title " : " One " } ] , " pk " : " id " } ,
{ " rows " : [ { " id " : 1 , " title " : " Two " , " extra " : " extra " } ] , " alter " : True } ,
[ { " id " : 1 , " title " : " Two " , " extra " : " extra " } ] ,
) ,
2022-12-07 17:12:15 -08:00
) ,
)
@pytest.mark.parametrize ( " should_return " , ( False , True ) )
async def test_upsert ( ds_write , initial , input , expected_rows , should_return ) :
token = write_token ( ds_write )
# Insert initial data
initial [ " table " ] = " upsert_test "
create_response = await ds_write . client . post (
" /data/-/create " ,
json = initial ,
2022-12-07 17:39:07 -08:00
headers = _headers ( token ) ,
2022-12-07 17:12:15 -08:00
)
assert create_response . status_code == 201
if should_return :
input [ " return " ] = True
response = await ds_write . client . post (
" /data/upsert_test/-/upsert " ,
json = input ,
2022-12-07 17:39:07 -08:00
headers = _headers ( token ) ,
2022-12-07 17:12:15 -08:00
)
2024-06-13 10:19:26 -07:00
assert response . status_code == 200 , response . text
2022-12-07 17:12:15 -08:00
assert response . json ( ) [ " ok " ] is True
2024-01-31 15:21:40 -08:00
# Analytics event
event = last_event ( ds_write )
assert event . database == " data "
assert event . table == " upsert_test "
2024-02-08 13:32:36 -08:00
if input . get ( " alter " ) :
assert event . name == " alter-table "
assert " extra " in event . after_schema
else :
assert event . name == " upsert-rows "
assert event . num_rows == 1
2024-01-31 15:21:40 -08:00
2022-12-07 17:12:15 -08:00
if should_return :
# We only expect it to return rows corresponding to those we sent
expected_returned_rows = expected_rows [ : len ( input [ " rows " ] ) ]
assert response . json ( ) [ " rows " ] == expected_returned_rows
# Check the database too
actual_rows = (
await ds_write . client . get ( " /data/upsert_test.json?_shape=array " )
) . json ( )
assert actual_rows == expected_rows
# Drop the upsert_test table
await ds_write . get_database ( " data " ) . execute_write ( " drop table upsert_test " )
2022-11-29 10:06:19 -08:00
async def _insert_row ( ds ) :
insert_response = await ds . client . post (
" /data/docs/-/insert " ,
json = { " row " : { " title " : " Row one " , " score " : 1.2 , " age " : 5 } , " return " : True } ,
2022-12-07 17:39:07 -08:00
headers = _headers ( write_token ( ds ) ) ,
2022-11-29 10:06:19 -08:00
)
assert insert_response . status_code == 201
return insert_response . json ( ) [ " rows " ] [ 0 ] [ " id " ]
2022-10-30 16:16:00 -07:00
@pytest.mark.asyncio
2022-11-29 10:53:55 -08:00
@pytest.mark.parametrize ( " scenario " , ( " no_token " , " no_perm " , " bad_table " ) )
async def test_delete_row_errors ( ds_write , scenario ) :
2022-10-30 16:16:00 -07:00
if scenario == " no_token " :
token = " bad_token "
elif scenario == " no_perm " :
token = write_token ( ds_write , actor_id = " not-root " )
else :
token = write_token ( ds_write )
2022-11-29 10:06:19 -08:00
pk = await _insert_row ( ds_write )
2022-10-30 16:16:00 -07:00
path = " /data/ {} / {} /-/delete " . format (
" docs " if scenario != " bad_table " else " bad_table " , pk
)
response = await ds_write . client . post (
path ,
2022-12-07 17:39:07 -08:00
headers = _headers ( token ) ,
2022-10-30 16:16:00 -07:00
)
2022-11-29 10:53:55 -08:00
assert response . status_code == 403 if scenario in ( " no_token " , " bad_token " ) else 404
assert response . json ( ) [ " ok " ] is False
assert (
response . json ( ) [ " errors " ] == [ " Permission denied " ]
if scenario == " no_token "
2024-06-21 16:09:20 -07:00
else [ " Table not found " ]
2022-11-29 10:53:55 -08:00
)
assert len ( ( await ds_write . client . get ( " /data/docs.json?_shape=array " ) ) . json ( ) ) == 1
@pytest.mark.asyncio
@pytest.mark.parametrize (
" table,row_for_create,pks,delete_path " ,
(
( " rowid_table " , { " name " : " rowid row " } , None , None ) ,
( " pk_table " , { " id " : 1 , " name " : " ID table " } , " id " , " 1 " ) ,
(
" compound_pk_table " ,
{ " type " : " article " , " key " : " k " } ,
[ " type " , " key " ] ,
" article,k " ,
) ,
) ,
)
async def test_delete_row ( ds_write , table , row_for_create , pks , delete_path ) :
# First create the table with that example row
create_data = {
" table " : table ,
" row " : row_for_create ,
}
if pks :
if isinstance ( pks , str ) :
create_data [ " pk " ] = pks
else :
create_data [ " pks " ] = pks
create_response = await ds_write . client . post (
" /data/-/create " ,
json = create_data ,
2022-12-07 17:39:07 -08:00
headers = _headers ( write_token ( ds_write ) ) ,
2022-11-29 10:53:55 -08:00
)
assert create_response . status_code == 201 , create_response . json ( )
# Should be a single row
assert (
await ds_write . client . get (
2024-07-15 10:33:51 -07:00
" /data/-/query.json?_shape=arrayfirst&sql=select+count(*)+from+ {} " . format (
table
)
2022-10-30 16:16:00 -07:00
)
2022-11-29 10:53:55 -08:00
) . json ( ) == [ 1 ]
# Now delete the row
if delete_path is None :
# Special case for that rowid table
delete_path = (
await ds_write . client . get (
2024-07-15 10:33:51 -07:00
" /data/-/query.json?_shape=arrayfirst&sql=select+rowid+from+ {} " . format (
table
)
2022-11-29 10:53:55 -08:00
)
) . json ( ) [ 0 ]
delete_response = await ds_write . client . post (
" /data/ {} / {} /-/delete " . format ( table , delete_path ) ,
2022-12-07 17:39:07 -08:00
headers = _headers ( write_token ( ds_write ) ) ,
2022-11-29 10:53:55 -08:00
)
assert delete_response . status_code == 200
2024-01-31 15:21:40 -08:00
# Analytics event
event = last_event ( ds_write )
assert event . name == " delete-row "
assert event . database == " data "
assert event . table == table
assert event . pks == str ( delete_path ) . split ( " , " )
2022-11-29 10:53:55 -08:00
assert (
await ds_write . client . get (
2024-07-15 10:33:51 -07:00
" /data/-/query.json?_shape=arrayfirst&sql=select+count(*)+from+ {} " . format (
table
)
2022-10-30 16:16:00 -07:00
)
2022-11-29 10:53:55 -08:00
) . json ( ) == [ 0 ]
2022-10-30 16:16:00 -07:00
2022-11-29 10:06:19 -08:00
@pytest.mark.asyncio
2024-02-08 13:30:48 -08:00
@pytest.mark.parametrize (
" scenario " , ( " no_token " , " no_perm " , " bad_table " , " cannot_alter " )
)
2022-11-29 10:06:19 -08:00
async def test_update_row_check_permission ( ds_write , scenario ) :
if scenario == " no_token " :
token = " bad_token "
elif scenario == " no_perm " :
token = write_token ( ds_write , actor_id = " not-root " )
2024-02-08 13:30:48 -08:00
elif scenario == " cannot_alter " :
# update-row but no alter-table:
token = write_token ( ds_write , permissions = [ " ur " ] )
2022-11-29 10:06:19 -08:00
else :
token = write_token ( ds_write )
pk = await _insert_row ( ds_write )
2024-02-08 13:19:47 -08:00
path = " /data/ {} / {} /-/update " . format (
2022-11-29 10:06:19 -08:00
" docs " if scenario != " bad_table " else " bad_table " , pk
)
2024-02-08 13:30:48 -08:00
json_body = { " update " : { " title " : " New title " } }
if scenario == " cannot_alter " :
json_body [ " alter " ] = True
2022-11-29 10:06:19 -08:00
response = await ds_write . client . post (
path ,
2024-02-08 13:30:48 -08:00
json = json_body ,
2022-12-07 17:39:07 -08:00
headers = _headers ( token ) ,
2022-11-29 10:06:19 -08:00
)
assert response . status_code == 403 if scenario in ( " no_token " , " bad_token " ) else 404
assert response . json ( ) [ " ok " ] is False
assert (
response . json ( ) [ " errors " ] == [ " Permission denied " ]
if scenario == " no_token "
2024-06-21 16:09:20 -07:00
else [ " Table not found " ]
2022-11-29 10:06:19 -08:00
)
2024-02-08 13:30:48 -08:00
@pytest.mark.asyncio
async def test_update_row_invalid_key ( ds_write ) :
token = write_token ( ds_write )
pk = await _insert_row ( ds_write )
path = " /data/docs/ {} /-/update " . format ( pk )
response = await ds_write . client . post (
path ,
json = { " update " : { " title " : " New title " } , " bad_key " : 1 } ,
headers = _headers ( token ) ,
)
assert response . status_code == 400
assert response . json ( ) == { " ok " : False , " errors " : [ " Invalid keys: bad_key " ] }
@pytest.mark.asyncio
async def test_update_row_alter ( ds_write ) :
token = write_token ( ds_write , permissions = [ " ur " , " at " ] )
pk = await _insert_row ( ds_write )
path = " /data/docs/ {} /-/update " . format ( pk )
response = await ds_write . client . post (
path ,
json = { " update " : { " title " : " New title " , " extra " : " extra " } , " alter " : True } ,
headers = _headers ( token ) ,
)
assert response . status_code == 200
assert response . json ( ) == { " ok " : True }
2026-06-17 09:14:19 -07:00
@pytest.mark.asyncio
async def test_alter_table_operations ( ds_write ) :
token = write_token ( ds_write , permissions = [ " at " ] )
db = ds_write . get_database ( " data " )
before_schema = await db . execute_fn (
lambda conn : conn . execute (
" select sql from sqlite_master where type = ' table ' and name = ' docs ' "
) . fetchone ( ) [ 0 ]
)
response = await ds_write . client . post (
" /data/docs/-/alter " ,
json = {
" operations " : [
{
" op " : " add_column " ,
" args " : {
" name " : " slug " ,
" type " : " text " ,
" not_null " : True ,
" default " : " " ,
} ,
} ,
{
" op " : " add_column " ,
" args " : {
" name " : " created " ,
" type " : " text " ,
" default_expr " : " current_timestamp " ,
} ,
} ,
{
" op " : " add_column " ,
" args " : {
" name " : " literal_default " ,
" type " : " text " ,
" default " : " hello) " ,
} ,
} ,
{ " op " : " rename_column " , " args " : { " name " : " title " , " to " : " headline " } } ,
{
" op " : " alter_column " ,
" args " : { " name " : " age " , " type " : " text " , " default " : " 0 " } ,
} ,
{ " op " : " drop_column " , " args " : { " name " : " score " } } ,
{
" op " : " reorder_columns " ,
" args " : {
" columns " : [
" id " ,
" headline " ,
" slug " ,
" created " ,
" literal_default " ,
" age " ,
]
} ,
} ,
{ " op " : " set_primary_key " , " args " : { " columns " : [ " id " ] } } ,
]
} ,
headers = _headers ( token ) ,
)
assert response . status_code == 200 , response . text
data = response . json ( )
assert data [ " ok " ] is True
assert data [ " database " ] == " data "
assert data [ " table " ] == " docs "
assert data [ " altered " ] is True
assert data [ " operations_applied " ] == 8
assert data [ " before_schema " ] == before_schema
assert " headline " in data [ " schema " ]
assert " score " not in data [ " schema " ]
assert " DEFAULT CURRENT_TIMESTAMP " in data [ " schema " ]
assert " DEFAULT ' hello) ' " in data [ " schema " ]
columns = (
await db . execute ( " select * from pragma_table_info( ' docs ' ) order by cid " )
) . dicts ( )
assert [ column [ " name " ] for column in columns ] == [
" id " ,
" headline " ,
" slug " ,
" created " ,
" literal_default " ,
" age " ,
]
assert columns [ 0 ] [ " pk " ] == 1
assert columns [ 2 ] [ " notnull " ] == 1
assert columns [ 2 ] [ " dflt_value " ] == " ' ' "
assert columns [ 3 ] [ " dflt_value " ] == " CURRENT_TIMESTAMP "
assert columns [ 4 ] [ " dflt_value " ] == " ' hello) ' "
assert columns [ 5 ] [ " type " ] == " TEXT "
assert columns [ 5 ] [ " dflt_value " ] == " ' 0 ' "
event = last_event ( ds_write )
assert event . name == " alter-table "
assert event . database == " data "
assert event . table == " docs "
assert event . before_schema == before_schema
assert event . after_schema == data [ " schema " ]
2026-06-22 13:42:35 -07:00
@pytest.mark.asyncio
@pytest.mark.parametrize (
" default_expr,minimum_value,expected_schema " ,
(
(
" current_unixtime " ,
1_600_000_000 ,
" strftime( ' %s ' , ' now ' ) " ,
) ,
(
" current_unixtime_ms " ,
1_600_000_000_000 ,
" julianday( ' now ' ) " ,
) ,
) ,
)
async def test_alter_table_integer_default_expr (
ds_write , default_expr , minimum_value , expected_schema
) :
token = write_token ( ds_write , permissions = [ " at " ] )
db = ds_write . get_database ( " data " )
response = await ds_write . client . post (
" /data/docs/-/alter " ,
json = {
" operations " : [
{
" op " : " add_column " ,
" args " : {
" name " : " created " ,
" type " : " integer " ,
" default_expr " : default_expr ,
} ,
}
]
} ,
headers = _headers ( token ) ,
)
assert response . status_code == 200 , response . text
data = response . json ( )
assert expected_schema in data [ " schema " ]
columns = await db . execute ( " select * from pragma_table_info( ' docs ' ) " )
created_column = [
column for column in columns . dicts ( ) if column [ " name " ] == " created "
] [ 0 ]
assert created_column [ " type " ] == " INTEGER "
assert expected_schema in created_column [ " dflt_value " ]
row = await db . execute_write_fn (
lambda conn : conn . execute (
" insert into docs (title) values ( ' with default ' ) "
" returning created, typeof(created) "
) . fetchone ( )
)
assert row [ 0 ] > minimum_value
assert row [ 1 ] == " integer "
2026-06-22 12:02:51 -07:00
@pytest.mark.asyncio
async def test_alter_table_rename_table ( ds_write ) :
token = write_token ( ds_write , permissions = [ " at " ] )
db = ds_write . get_database ( " data " )
before_schema = await db . execute_fn (
lambda conn : conn . execute (
" select sql from sqlite_master where type = ' table ' and name = ' docs ' "
) . fetchone ( ) [ 0 ]
)
response = await ds_write . client . post (
" /data/docs/-/alter " ,
json = {
" operations " : [
{ " op " : " rename_table " , " args " : { " to " : " documents " } } ,
]
} ,
headers = _headers ( token ) ,
)
assert response . status_code == 200 , response . text
data = response . json ( )
assert data [ " ok " ] is True
assert data [ " database " ] == " data "
assert data [ " table " ] == " documents "
assert data [ " table_url " ] . endswith ( " /data/documents " )
assert data [ " table_api_url " ] . endswith ( " /data/documents.json " )
assert data [ " altered " ] is True
assert data [ " operations_applied " ] == 1
assert data [ " before_schema " ] == before_schema
assert ' CREATE TABLE " documents " ' in data [ " schema " ]
tables = (
await db . execute (
" select name from sqlite_master where type = ' table ' order by name "
)
) . dicts ( )
table_names = [ table [ " name " ] for table in tables ]
assert " docs " not in table_names
assert " documents " in table_names
rename_events = [
event
for event in ds_write . _tracked_events
if isinstance ( event , RenameTableEvent )
]
assert len ( rename_events ) == 1
assert rename_events [ 0 ] . database == " data "
assert rename_events [ 0 ] . old_table == " docs "
assert rename_events [ 0 ] . new_table == " documents "
2026-06-17 12:51:19 -07:00
@pytest.mark.asyncio
async def test_alter_table_foreign_key_operations ( ds_write ) :
token = write_token ( ds_write , permissions = [ " at " ] )
db = ds_write . get_database ( " data " )
await db . execute_write ( " create table owners (id integer primary key) " )
await db . execute_write ( " create table categories (id integer primary key) " )
response = await ds_write . client . post (
" /data/docs/-/alter " ,
json = {
" operations " : [
{ " op " : " add_column " , " args " : { " name " : " owner_id " , " type " : " integer " } } ,
{
" op " : " add_foreign_key " ,
" args " : { " column " : " owner_id " , " fk_table " : " owners " } ,
} ,
]
} ,
headers = _headers ( token ) ,
)
assert response . status_code == 200 , response . text
data = response . json ( )
assert data [ " operations_applied " ] == 2
assert " [owner_id] INTEGER REFERENCES [owners]([id]) " in data [ " schema " ]
response = await ds_write . client . post (
" /data/docs/-/alter " ,
json = {
" operations " : [ { " op " : " drop_foreign_key " , " args " : { " column " : " owner_id " } } ]
} ,
headers = _headers ( token ) ,
)
assert response . status_code == 200 , response . text
data = response . json ( )
assert " [owner_id] INTEGER REFERENCES " not in data [ " schema " ]
response = await ds_write . client . post (
" /data/docs/-/alter " ,
json = {
" operations " : [
{
" op " : " set_foreign_keys " ,
" args " : {
" foreign_keys " : [
{
" column " : " owner_id " ,
" fk_table " : " categories " ,
" fk_column " : " id " ,
}
]
} ,
}
]
} ,
headers = _headers ( token ) ,
)
assert response . status_code == 200 , response . text
data = response . json ( )
assert " [owner_id] INTEGER REFERENCES [categories]([id]) " in data [ " schema " ]
response = await ds_write . client . post (
" /data/docs/-/alter " ,
json = { " operations " : [ { " op " : " set_foreign_keys " , " args " : { " foreign_keys " : [ ] } } ] } ,
headers = _headers ( token ) ,
)
assert response . status_code == 200 , response . text
data = response . json ( )
assert " [owner_id] INTEGER REFERENCES " not in data [ " schema " ]
@pytest.mark.asyncio
async def test_alter_table_foreign_key_requires_fk_table_for_fk_column ( ds_write ) :
response = await ds_write . client . post (
" /data/docs/-/alter " ,
json = {
" operations " : [
{
" op " : " add_foreign_key " ,
" args " : { " column " : " age " , " fk_column " : " id " } ,
}
]
} ,
headers = _headers ( write_token ( ds_write , permissions = [ " at " ] ) ) ,
)
assert response . status_code == 400
assert response . json ( ) == {
" ok " : False ,
" errors " : [ " operations.0.add_foreign_key.args: fk_column requires fk_table " ] ,
}
@pytest.mark.asyncio
async def test_alter_table_foreign_key_without_fk_column_requires_single_pk ( ds_write ) :
token = write_token ( ds_write , permissions = [ " at " ] )
db = ds_write . get_database ( " data " )
await db . execute_write (
" create table accounts (tenant_id integer, id integer, primary key (tenant_id, id)) "
)
response = await ds_write . client . post (
" /data/docs/-/alter " ,
json = {
" operations " : [
{
" op " : " add_foreign_key " ,
" args " : { " column " : " age " , " fk_table " : " accounts " } ,
}
]
} ,
headers = _headers ( token ) ,
)
assert response . status_code == 400
assert response . json ( ) == {
" ok " : False ,
" errors " : [ " Could not detect single primary key for table ' accounts ' " ] ,
}
2026-06-17 14:47:25 -07:00
@pytest.mark.asyncio
async def test_foreign_key_suggestions ( ds_write ) :
token = write_token ( ds_write , permissions = [ " at " ] )
db = ds_write . get_database ( " data " )
await db . execute_write ( " create table owners (id integer primary key) " )
await db . execute_write ( " insert into owners (id) values (1), (2), (3) " )
await db . execute_write ( " create table categories (slug text primary key) " )
await db . execute_write ( " insert into categories (slug) values ( ' one ' ), ( ' two ' ) " )
await db . execute_write ( " create table numbers (id integer primary key) " )
await db . execute_write ( " insert into numbers (id) values (10), (20) " )
await db . execute_write ( " create table weights (id real primary key) " )
await db . execute_write ( " insert into weights (id) values (1.5), (2.5) " )
await db . execute_write (
" insert into docs (id, title, score, age) values "
" (1, ' one ' , 1.5, 1), (2, ' two ' , 999.5, 2), (3, null, null, null) "
)
response = await ds_write . client . get (
" /data/docs/-/foreign-key-suggestions " ,
headers = _headers ( token ) ,
)
assert response . status_code == 200 , response . text
data = response . json ( )
assert data [ " ok " ] is True
assert data [ " database " ] == " data "
assert data [ " table " ] == " docs "
assert data [ " row_check " ] [ " attempted " ] is True
assert data [ " row_check " ] [ " status " ] == " completed "
assert data [ " row_check " ] [ " row_limit " ] == 500
assert data [ " row_check " ] [ " sampled_rows " ] == 3
columns = { column [ " column " ] : column for column in data [ " columns " ] }
assert columns [ " age " ] [ " options " ] == [
{ " fk_table " : " numbers " , " fk_column " : " id " , " type " : " INTEGER " } ,
{ " fk_table " : " owners " , " fk_column " : " id " , " type " : " INTEGER " } ,
]
assert columns [ " age " ] [ " suggestions " ] == [
{
" fk_table " : " owners " ,
" fk_column " : " id " ,
" confidence " : " sampled " ,
" sampled_values " : 2 ,
" reasons " : [ " type_match " , " sample_values_exist " ] ,
}
]
assert columns [ " title " ] [ " options " ] == [
{ " fk_table " : " categories " , " fk_column " : " slug " , " type " : " TEXT " }
]
assert columns [ " title " ] [ " suggestions " ] [ 0 ] [ " fk_table " ] == " categories "
assert columns [ " score " ] [ " options " ] == [
{ " fk_table " : " weights " , " fk_column " : " id " , " type " : " REAL " }
]
assert columns [ " score " ] [ " suggestions " ] == [ ]
@pytest.mark.asyncio
async def test_foreign_key_suggestions_permission_denied ( ds_write ) :
token = write_token ( ds_write , permissions = [ " ir " ] )
response = await ds_write . client . get (
" /data/docs/-/foreign-key-suggestions " ,
headers = _headers ( token ) ,
)
assert response . status_code == 403
assert response . json ( ) == {
" ok " : False ,
" errors " : [ " Permission denied: need alter-table " ] ,
}
@pytest.mark.asyncio
async def test_foreign_key_suggestions_fail_open ( ds_write , monkeypatch ) :
token = write_token ( ds_write , permissions = [ " at " ] )
db = ds_write . get_database ( " data " )
await db . execute_write ( " create table owners (id integer primary key) " )
async def raise_timeout ( * args , * * kwargs ) :
raise table_create_alter . ForeignKeySuggestionTimedOut
from datasette . views import table_create_alter
monkeypatch . setattr (
table_create_alter ,
" _foreign_key_suggestion_samples " ,
raise_timeout ,
)
response = await ds_write . client . get (
" /data/docs/-/foreign-key-suggestions " ,
headers = _headers ( token ) ,
)
assert response . status_code == 200 , response . text
data = response . json ( )
assert data [ " row_check " ] [ " status " ] == " timed_out "
columns = { column [ " column " ] : column for column in data [ " columns " ] }
assert columns [ " age " ] [ " options " ] == [
{ " fk_table " : " owners " , " fk_column " : " id " , " type " : " INTEGER " }
]
assert columns [ " age " ] [ " suggestions " ] == [ ]
2026-06-17 16:29:59 -07:00
@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) " )
2026-06-18 08:13:28 -07:00
try :
await db . execute_write ( " create virtual table search_docs using fts5(body) " )
except Exception :
pass
2026-06-17 16:29:59 -07:00
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 " ,
} ,
] ,
}
2026-06-18 08:13:28 -07:00
assert not any (
target [ " fk_table " ] . startswith ( " search_docs_ " )
for target in response . json ( ) [ " targets " ]
)
2026-06-17 16:29:59 -07:00
@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 " ] ,
}
2026-06-22 12:47:02 -07:00
@pytest.mark.asyncio
async def test_foreign_key_targets_allowed_for_alter_table ( ds_write ) :
token = write_token ( ds_write , permissions = [ " at " ] )
response = await ds_write . client . get (
" /data/-/foreign-key-targets?table=docs " ,
headers = _headers ( token ) ,
)
assert response . status_code == 200 , response . text
assert response . json ( ) [ " ok " ] is True
2026-06-17 09:14:19 -07:00
@pytest.mark.asyncio
async def test_alter_table_permission_denied ( ds_write ) :
token = write_token ( ds_write , permissions = [ " ir " ] )
response = await ds_write . client . post (
" /data/docs/-/alter " ,
json = { " operations " : [ { " op " : " add_column " , " args " : { " name " : " slug " } } ] } ,
headers = _headers ( token ) ,
)
assert response . status_code == 403
assert response . json ( ) == {
" ok " : False ,
" errors " : [ " Permission denied: need alter-table " ] ,
}
@pytest.mark.asyncio
@pytest.mark.parametrize (
" body,expected_error " ,
(
2026-06-22 10:18:01 -07:00
(
{
" dry_run " : True ,
" operations " : [
{ " op " : " add_column " , " args " : { " name " : " slug " , " type " : " text " } }
] ,
} ,
" dry_run: Extra inputs are not permitted " ,
) ,
2026-06-17 09:14:19 -07:00
(
{ " operations " : [ { " op " : " add_column " , " args " : { " type " : " text " } } ] } ,
" operations.0.add_column.args.name: Field required " ,
) ,
(
{
" operations " : [
{ " op " : " add_column " , " args " : { " name " : " x " , " type " : " bad " } }
]
} ,
" operations.0.add_column.args.type: Input should be ' text ' , ' integer ' , ' float ' or ' blob ' " ,
) ,
(
{
" operations " : [
{
" op " : " add_column " ,
" args " : {
" name " : " x " ,
" default_expr " : " datetime( ' now ' ) " ,
} ,
}
]
} ,
2026-06-22 13:42:35 -07:00
" operations.0.add_column.args.default_expr: Input should be ' current_timestamp ' , ' current_date ' , ' current_time ' , ' current_unixtime ' or ' current_unixtime_ms ' " ,
2026-06-17 09:14:19 -07:00
) ,
(
{
" operations " : [
{
" op " : " add_column " ,
" args " : {
" name " : " x " ,
" default " : " x " ,
" default_expr " : " current_timestamp " ,
} ,
}
]
} ,
" operations.0.add_column.args: Value error, default and default_expr cannot both be provided " ,
) ,
) ,
)
async def test_alter_table_validation_errors ( ds_write , body , expected_error ) :
response = await ds_write . client . post (
" /data/docs/-/alter " ,
json = body ,
headers = _headers ( write_token ( ds_write , permissions = [ " at " ] ) ) ,
)
assert response . status_code == 400
assert response . json ( ) [ " ok " ] is False
assert response . json ( ) [ " errors " ] == [ expected_error ]
2026-06-10 20:15:03 -07:00
@pytest.mark.asyncio
async def test_execute_write_form_parameter_called_sql ( ) :
ds = Datasette ( memory = True , default_deny = True )
ds . root_enabled = True
db = ds . add_memory_database ( " execute_write_parameter_sql " , name = " data " )
await db . execute_write ( " create table docs (id integer primary key, title text) " )
await db . execute_write ( " insert into docs (id, title) values (1, ' Initial ' ) " )
await ds . invoke_startup ( )
form_response = await ds . client . get (
" /data/-/execute-write " ,
actor = { " id " : " root " } ,
params = { " sql " : " update docs set title = :sql where id = :id " } ,
)
assert form_response . status_code == 200
assert ' data-parameter-name-prefix= " _sql_param_ " ' in form_response . text
assert ' <label for= " qp1 " >sql</label> ' in form_response . text
assert ' name= " _sql_param_sql " ' in form_response . text
assert ' data-parameter-name= " sql " ' in form_response . text
assert ' name= " _sql_param_id " ' in form_response . text
response = await ds . client . post (
" /data/-/execute-write " ,
actor = { " id " : " root " } ,
data = {
" sql " : " update docs set title = :sql where id = :id " ,
" _sql_param_sql " : " Updated " ,
" _sql_param_id " : " 1 " ,
} ,
)
assert response . status_code == 200
assert " Query executed, 1 row affected " in response . text
assert ( await db . execute ( " select title from docs where id = 1 " ) ) . first ( ) [
0
] == " Updated "
2022-11-29 10:06:19 -08:00
@pytest.mark.asyncio
@pytest.mark.parametrize (
" input,expected_errors " ,
(
( { " title " : " New title " } , None ) ,
( { " title " : None } , None ) ,
( { " score " : 1.6 } , None ) ,
( { " age " : 10 } , None ) ,
( { " title " : " New title " , " score " : 1.6 } , None ) ,
( { " title2 " : " New title " } , [ " no such column: title2 " ] ) ,
) ,
)
@pytest.mark.parametrize ( " use_return " , ( True , False ) )
async def test_update_row ( ds_write , input , expected_errors , use_return ) :
token = write_token ( ds_write )
pk = await _insert_row ( ds_write )
path = " /data/docs/ {} /-/update " . format ( pk )
data = { " update " : input }
if use_return :
data [ " return " ] = True
response = await ds_write . client . post (
path ,
json = data ,
2022-12-07 17:39:07 -08:00
headers = _headers ( token ) ,
2022-11-29 10:06:19 -08:00
)
if expected_errors :
assert response . status_code == 400
assert response . json ( ) [ " ok " ] is False
assert response . json ( ) [ " errors " ] == expected_errors
return
assert response . json ( ) [ " ok " ] is True
if not use_return :
assert " row " not in response . json ( )
else :
returned_row = response . json ( ) [ " row " ]
assert returned_row [ " id " ] == pk
for k , v in input . items ( ) :
assert returned_row [ k ] == v
2024-01-31 15:21:40 -08:00
# Analytics event
event = last_event ( ds_write )
assert event . actor == { " id " : " root " , " token " : " dstok " }
assert event . database == " data "
assert event . table == " docs "
assert event . pks == [ str ( pk ) ]
2022-11-29 10:06:19 -08:00
# And fetch the row to check it's updated
response = await ds_write . client . get (
" /data/docs/ {} .json?_shape=array " . format ( pk ) ,
)
assert response . status_code == 200
row = response . json ( ) [ 0 ]
assert row [ " id " ] == pk
for k , v in input . items ( ) :
assert row [ k ] == v
2022-10-30 15:17:21 -07:00
@pytest.mark.asyncio
2022-11-13 21:40:10 -08:00
@pytest.mark.parametrize (
" scenario " , ( " no_token " , " no_perm " , " bad_table " , " has_perm " , " immutable " )
)
2022-10-30 15:17:21 -07:00
async def test_drop_table ( ds_write , scenario ) :
if scenario == " no_token " :
token = " bad_token "
elif scenario == " no_perm " :
token = write_token ( ds_write , actor_id = " not-root " )
else :
token = write_token ( ds_write )
should_work = scenario == " has_perm "
2022-11-13 21:17:18 -08:00
await ds_write . get_database ( " data " ) . execute_write (
" insert into docs (id, title) values (1, ' Row 1 ' ) "
)
2022-11-13 21:40:10 -08:00
path = " / {database} / {table} /-/drop " . format (
database = " immutable " if scenario == " immutable " else " data " ,
table = " docs " if scenario != " bad_table " else " bad_table " ,
)
2022-10-30 15:17:21 -07:00
response = await ds_write . client . post (
path ,
2022-12-07 17:39:07 -08:00
headers = _headers ( token ) ,
2022-10-29 23:03:45 -07:00
)
2022-11-13 21:17:18 -08:00
if not should_work :
2022-10-30 15:17:21 -07:00
assert (
response . status_code == 403
if scenario in ( " no_token " , " bad_token " )
else 404
)
assert response . json ( ) [ " ok " ] is False
2022-11-13 21:40:10 -08:00
expected_error = " Permission denied "
if scenario == " bad_table " :
2024-06-21 16:09:20 -07:00
expected_error = " Table not found "
2022-11-13 21:40:10 -08:00
elif scenario == " immutable " :
expected_error = " Database is immutable "
assert response . json ( ) [ " errors " ] == [ expected_error ]
2022-10-30 15:17:21 -07:00
assert ( await ds_write . client . get ( " /data/docs " ) ) . status_code == 200
2022-11-13 21:17:18 -08:00
else :
# It should show a confirmation page
assert response . status_code == 200
assert response . json ( ) == {
" ok " : True ,
2022-11-13 21:40:10 -08:00
" database " : " data " ,
" table " : " docs " ,
2022-11-13 21:17:18 -08:00
" row_count " : 1 ,
" message " : ' Pass " confirm " : true to confirm ' ,
}
assert ( await ds_write . client . get ( " /data/docs " ) ) . status_code == 200
# Now send confirm: true
response2 = await ds_write . client . post (
path ,
json = { " confirm " : True } ,
2022-12-07 17:39:07 -08:00
headers = _headers ( token ) ,
2022-11-13 21:17:18 -08:00
)
assert response2 . json ( ) == { " ok " : True }
2024-01-31 15:21:40 -08:00
# Check event
event = last_event ( ds_write )
assert event . name == " drop-table "
assert event . actor == { " id " : " root " , " token " : " dstok " }
assert event . table == " docs "
assert event . database == " data "
# Table should 404
2022-11-13 21:17:18 -08:00
assert ( await ds_write . client . get ( " /data/docs " ) ) . status_code == 404
2022-11-14 21:57:28 -08:00
@pytest.mark.asyncio
@pytest.mark.parametrize (
2024-02-16 13:58:33 -08:00
" input,expected_status,expected_response,expected_events " ,
2022-11-14 21:57:28 -08:00
(
# Permission error with a bad token
(
{ " table " : " bad " , " row " : { " id " : 1 } } ,
403 ,
{ " ok " : False , " errors " : [ " Permission denied " ] } ,
2024-02-16 13:58:33 -08:00
[ ] ,
2022-11-14 21:57:28 -08:00
) ,
# Successful creation with columns:
(
{
" table " : " one " ,
" columns " : [
{
" name " : " id " ,
" type " : " integer " ,
} ,
{
" name " : " title " ,
" type " : " text " ,
} ,
{
" name " : " score " ,
" type " : " integer " ,
} ,
{
" name " : " weight " ,
" type " : " float " ,
} ,
{
" name " : " thumbnail " ,
" type " : " blob " ,
} ,
] ,
" pk " : " id " ,
} ,
201 ,
{
" ok " : True ,
" database " : " data " ,
" table " : " one " ,
" table_url " : " http://localhost/data/one " ,
" table_api_url " : " http://localhost/data/one.json " ,
" schema " : (
" CREATE TABLE [one] ( \n "
" [id] INTEGER PRIMARY KEY, \n "
" [title] TEXT, \n "
" [score] INTEGER, \n "
" [weight] FLOAT, \n "
" [thumbnail] BLOB \n "
" ) "
) ,
} ,
2024-02-16 13:58:33 -08:00
[ " create-table " ] ,
2022-11-14 21:57:28 -08:00
) ,
# Successful creation with rows:
(
{
" table " : " two " ,
" rows " : [
{
" id " : 1 ,
" title " : " Row 1 " ,
" score " : 1.5 ,
} ,
{
" id " : 2 ,
" title " : " Row 2 " ,
" score " : 1.5 ,
} ,
] ,
" pk " : " id " ,
} ,
201 ,
{
" ok " : True ,
" database " : " data " ,
" table " : " two " ,
" table_url " : " http://localhost/data/two " ,
" table_api_url " : " http://localhost/data/two.json " ,
" schema " : (
" CREATE TABLE [two] ( \n "
" [id] INTEGER PRIMARY KEY, \n "
" [title] TEXT, \n "
" [score] FLOAT \n "
" ) "
) ,
" row_count " : 2 ,
} ,
2024-02-16 13:58:33 -08:00
[ " create-table " , " insert-rows " ] ,
2022-11-14 21:57:28 -08:00
) ,
# Successful creation with row:
(
{
" table " : " three " ,
" row " : {
" id " : 1 ,
" title " : " Row 1 " ,
" score " : 1.5 ,
} ,
" pk " : " id " ,
} ,
201 ,
{
" ok " : True ,
" database " : " data " ,
" table " : " three " ,
" table_url " : " http://localhost/data/three " ,
" table_api_url " : " http://localhost/data/three.json " ,
" schema " : (
" CREATE TABLE [three] ( \n "
" [id] INTEGER PRIMARY KEY, \n "
" [title] TEXT, \n "
" [score] FLOAT \n "
" ) "
) ,
" row_count " : 1 ,
} ,
2024-02-16 13:58:33 -08:00
[ " create-table " , " insert-rows " ] ,
2022-11-14 21:57:28 -08:00
) ,
# Create with row and no primary key
(
{
" table " : " four " ,
" row " : {
" name " : " Row 1 " ,
} ,
} ,
201 ,
{
" ok " : True ,
" database " : " data " ,
" table " : " four " ,
" table_url " : " http://localhost/data/four " ,
" table_api_url " : " http://localhost/data/four.json " ,
" schema " : ( " CREATE TABLE [four] ( \n " " [name] TEXT \n " " ) " ) ,
" row_count " : 1 ,
} ,
2024-02-16 13:58:33 -08:00
[ " create-table " , " insert-rows " ] ,
2022-11-14 21:57:28 -08:00
) ,
2022-11-29 10:47:46 -08:00
# Create table with compound primary key
(
{
" table " : " five " ,
" row " : { " type " : " article " , " key " : 123 , " title " : " Article 1 " } ,
" pks " : [ " type " , " key " ] ,
} ,
201 ,
{
" ok " : True ,
" database " : " data " ,
" table " : " five " ,
" table_url " : " http://localhost/data/five " ,
" table_api_url " : " http://localhost/data/five.json " ,
" schema " : (
" CREATE TABLE [five] ( \n [type] TEXT, \n [key] INTEGER, \n "
" [title] TEXT, \n PRIMARY KEY ([type], [key]) \n ) "
) ,
" row_count " : 1 ,
} ,
2024-02-16 13:58:33 -08:00
[ " create-table " , " insert-rows " ] ,
2022-11-29 10:47:46 -08:00
) ,
2022-11-14 21:57:28 -08:00
# Error: Table is required
(
{
" row " : { " id " : 1 } ,
} ,
400 ,
{
" ok " : False ,
" errors " : [ " Table is required " ] ,
} ,
2024-02-16 13:58:33 -08:00
[ ] ,
2022-11-14 21:57:28 -08:00
) ,
# Error: Invalid table name
(
{
" table " : " sqlite_bad_name " ,
" row " : { " id " : 1 } ,
} ,
400 ,
{
" ok " : False ,
" errors " : [ " Invalid table name " ] ,
} ,
2024-02-16 13:58:33 -08:00
[ ] ,
2022-11-14 21:57:28 -08:00
) ,
# Error: JSON must be an object
(
[ ] ,
400 ,
{
" ok " : False ,
" errors " : [ " JSON must be an object " ] ,
} ,
2024-02-16 13:58:33 -08:00
[ ] ,
2022-11-14 21:57:28 -08:00
) ,
# Error: Cannot specify columns with rows or row
(
{
" table " : " bad " ,
" columns " : [ { " name " : " id " , " type " : " integer " } ] ,
" rows " : [ { " id " : 1 } ] ,
} ,
400 ,
{
" ok " : False ,
" errors " : [ " Cannot specify columns with rows or row " ] ,
} ,
2024-02-16 13:58:33 -08:00
[ ] ,
2022-11-14 21:57:28 -08:00
) ,
# Error: columns, rows or row is required
(
{
" table " : " bad " ,
} ,
400 ,
{
" ok " : False ,
" errors " : [ " columns, rows or row is required " ] ,
} ,
2024-02-16 13:58:33 -08:00
[ ] ,
2022-11-14 21:57:28 -08:00
) ,
# Error: columns must be a list
(
{
" table " : " bad " ,
" columns " : { " name " : " id " , " type " : " integer " } ,
} ,
400 ,
{
" ok " : False ,
" errors " : [ " columns must be a list " ] ,
} ,
2024-02-16 13:58:33 -08:00
[ ] ,
2022-11-14 21:57:28 -08:00
) ,
# Error: columns must be a list of objects
(
{
" table " : " bad " ,
" columns " : [ " id " ] ,
} ,
400 ,
{
" ok " : False ,
" errors " : [ " columns must be a list of objects " ] ,
} ,
2024-02-16 13:58:33 -08:00
[ ] ,
2022-11-14 21:57:28 -08:00
) ,
# Error: Column name is required
(
{
" table " : " bad " ,
" columns " : [ { " type " : " integer " } ] ,
} ,
400 ,
{
" ok " : False ,
" errors " : [ " Column name is required " ] ,
} ,
2024-02-16 13:58:33 -08:00
[ ] ,
2022-11-14 21:57:28 -08:00
) ,
# Error: Unsupported column type
(
{
" table " : " bad " ,
" columns " : [ { " name " : " id " , " type " : " bad " } ] ,
} ,
400 ,
{
" ok " : False ,
" errors " : [ " Unsupported column type: bad " ] ,
} ,
2024-02-16 13:58:33 -08:00
[ ] ,
2022-11-14 21:57:28 -08:00
) ,
# Error: Duplicate column name
(
{
" table " : " bad " ,
" columns " : [
{ " name " : " id " , " type " : " integer " } ,
{ " name " : " id " , " type " : " integer " } ,
] ,
} ,
400 ,
{
" ok " : False ,
" errors " : [ " Duplicate column name: id " ] ,
} ,
2024-02-16 13:58:33 -08:00
[ ] ,
2022-11-14 21:57:28 -08:00
) ,
# Error: rows must be a list
(
{
" table " : " bad " ,
" rows " : { " id " : 1 } ,
} ,
400 ,
{
" ok " : False ,
" errors " : [ " rows must be a list " ] ,
} ,
2024-02-16 13:58:33 -08:00
[ ] ,
2022-11-14 21:57:28 -08:00
) ,
# Error: rows must be a list of objects
(
{
" table " : " bad " ,
" rows " : [ " id " ] ,
} ,
400 ,
{
" ok " : False ,
" errors " : [ " rows must be a list of objects " ] ,
} ,
2024-02-16 13:58:33 -08:00
[ ] ,
2022-11-14 21:57:28 -08:00
) ,
# Error: pk must be a string
(
{
" table " : " bad " ,
" row " : { " id " : 1 } ,
" pk " : 1 ,
} ,
400 ,
{
" ok " : False ,
" errors " : [ " pk must be a string " ] ,
} ,
2024-02-16 13:58:33 -08:00
[ ] ,
2022-11-14 21:57:28 -08:00
) ,
2022-11-29 10:47:46 -08:00
# Error: Cannot specify both pk and pks
(
{
" table " : " bad " ,
" row " : { " id " : 1 , " name " : " Row 1 " } ,
" pk " : " id " ,
" pks " : [ " id " , " name " ] ,
} ,
400 ,
{
" ok " : False ,
" errors " : [ " Cannot specify both pk and pks " ] ,
} ,
2024-02-16 13:58:33 -08:00
[ ] ,
2022-11-29 10:47:46 -08:00
) ,
# Error: pks must be a list
(
{
" table " : " bad " ,
" row " : { " id " : 1 , " name " : " Row 1 " } ,
" pks " : " id " ,
} ,
400 ,
{
" ok " : False ,
" errors " : [ " pks must be a list " ] ,
} ,
2024-02-16 13:58:33 -08:00
[ ] ,
2022-11-29 10:47:46 -08:00
) ,
# Error: pks must be a list of strings
(
{ " table " : " bad " , " row " : { " id " : 1 , " name " : " Row 1 " } , " pks " : [ 1 , 2 ] } ,
400 ,
{ " ok " : False , " errors " : [ " pks must be a list of strings " ] } ,
2024-02-16 13:58:33 -08:00
[ ] ,
2022-11-29 10:47:46 -08:00
) ,
2022-12-02 22:57:57 -08:00
# Error: ignore and replace are mutually exclusive
(
{
" table " : " bad " ,
" row " : { " id " : 1 , " name " : " Row 1 " } ,
" pk " : " id " ,
" ignore " : True ,
" replace " : True ,
} ,
400 ,
{
" ok " : False ,
" errors " : [ " ignore and replace are mutually exclusive " ] ,
} ,
2024-02-16 13:58:33 -08:00
[ ] ,
2022-12-02 22:57:57 -08:00
) ,
# ignore and replace require row or rows
(
{
" table " : " bad " ,
" columns " : [ { " name " : " id " , " type " : " integer " } ] ,
" ignore " : True ,
} ,
400 ,
{
" ok " : False ,
" errors " : [ " ignore and replace require row or rows " ] ,
} ,
2024-02-16 13:58:33 -08:00
[ ] ,
2022-12-02 22:57:57 -08:00
) ,
2022-12-07 17:18:40 -08:00
# ignore and replace require pk or pks
(
{
" table " : " bad " ,
" row " : { " id " : 1 } ,
" ignore " : True ,
} ,
400 ,
{
" ok " : False ,
" errors " : [ " ignore and replace require pk or pks " ] ,
} ,
2024-02-16 13:58:33 -08:00
[ ] ,
2022-12-07 17:18:40 -08:00
) ,
(
{
" table " : " bad " ,
" row " : { " id " : 1 } ,
" replace " : True ,
} ,
400 ,
{
" ok " : False ,
" errors " : [ " ignore and replace require pk or pks " ] ,
} ,
2024-02-16 13:58:33 -08:00
[ ] ,
2022-12-07 17:18:40 -08:00
) ,
2022-11-14 21:57:28 -08:00
) ,
)
2024-02-16 13:58:33 -08:00
async def test_create_table (
ds_write , input , expected_status , expected_response , expected_events
) :
ds_write . _tracked_events = [ ]
2022-11-14 21:57:28 -08:00
# Special case for expected status of 403
if expected_status == 403 :
token = " bad_token "
else :
token = write_token ( ds_write )
response = await ds_write . client . post (
" /data/-/create " ,
json = input ,
2022-12-07 17:39:07 -08:00
headers = _headers ( token ) ,
2022-11-14 21:57:28 -08:00
)
assert response . status_code == expected_status
data = response . json ( )
assert data == expected_response
2024-02-16 13:58:33 -08:00
# Should have tracked the expected events
events = ds_write . _tracked_events
assert [ e . name for e in events ] == expected_events
2022-11-29 21:15:13 -08:00
2026-06-17 12:38:51 -07:00
@pytest.mark.asyncio
async def test_create_table_with_foreign_key ( ds_write ) :
token = write_token ( ds_write )
response = await ds_write . client . post (
" /data/-/create " ,
json = {
" table " : " owners " ,
" columns " : [
{ " name " : " id " , " type " : " integer " } ,
{ " name " : " name " , " type " : " text " } ,
] ,
" pk " : " id " ,
} ,
headers = _headers ( token ) ,
)
assert response . status_code == 201
response = await ds_write . client . post (
" /data/-/create " ,
json = {
" table " : " projects " ,
" columns " : [
{ " name " : " id " , " type " : " integer " } ,
{
" name " : " owner_id " ,
" type " : " integer " ,
" fk_table " : " owners " ,
} ,
{ " name " : " title " , " type " : " text " } ,
] ,
" pk " : " id " ,
} ,
headers = _headers ( token ) ,
)
assert response . status_code == 201
data = response . json ( )
assert " [owner_id] INTEGER REFERENCES [owners]([id]) " in data [ " schema " ]
2026-06-22 11:04:19 -07:00
@pytest.mark.asyncio
async def test_create_table_with_column_constraints ( ds_write ) :
token = write_token ( ds_write )
response = await ds_write . client . post (
" /data/-/create " ,
json = {
" table " : " constrained " ,
" columns " : [
{ " name " : " id " , " type " : " integer " } ,
{
" name " : " title " ,
" type " : " text " ,
" not_null " : True ,
" default " : " Untitled " ,
} ,
{
" name " : " created " ,
" type " : " text " ,
" default_expr " : " current_timestamp " ,
} ,
{ " name " : " score " , " type " : " integer " , " default " : 0 } ,
{ " name " : " literal_default " , " type " : " text " , " default " : " hello) " } ,
] ,
" pk " : " id " ,
} ,
headers = _headers ( token ) ,
)
assert response . status_code == 201 , response . text
data = response . json ( )
assert data [ " ok " ] is True
assert " NOT NULL DEFAULT ' Untitled ' " in data [ " schema " ]
assert " DEFAULT CURRENT_TIMESTAMP " in data [ " schema " ]
assert " DEFAULT 0 " in data [ " schema " ]
assert " DEFAULT ' hello) ' " in data [ " schema " ]
db = ds_write . get_database ( " data " )
columns = (
await db . execute ( " select * from pragma_table_info( ' constrained ' ) order by cid " )
) . dicts ( )
assert [ column [ " name " ] for column in columns ] == [
" id " ,
" title " ,
" created " ,
" score " ,
" literal_default " ,
]
assert columns [ 0 ] [ " pk " ] == 1
assert columns [ 1 ] [ " notnull " ] == 1
assert columns [ 1 ] [ " dflt_value " ] == " ' Untitled ' "
assert columns [ 2 ] [ " dflt_value " ] == " CURRENT_TIMESTAMP "
assert columns [ 3 ] [ " dflt_value " ] == " 0 "
assert columns [ 4 ] [ " dflt_value " ] == " ' hello) ' "
2026-06-22 13:42:35 -07:00
@pytest.mark.asyncio
@pytest.mark.parametrize (
" default_expr,minimum_value,expected_schema " ,
(
(
" current_unixtime " ,
1_600_000_000 ,
" strftime( ' %s ' , ' now ' ) " ,
) ,
(
" current_unixtime_ms " ,
1_600_000_000_000 ,
" julianday( ' now ' ) " ,
) ,
) ,
)
async def test_create_table_integer_default_expr (
ds_write , default_expr , minimum_value , expected_schema
) :
token = write_token ( ds_write )
table = " default_ {} " . format ( default_expr )
response = await ds_write . client . post (
" /data/-/create " ,
json = {
" table " : table ,
" columns " : [
{ " name " : " id " , " type " : " integer " } ,
{
" name " : " created " ,
" type " : " integer " ,
" default_expr " : default_expr ,
} ,
] ,
" pk " : " id " ,
} ,
headers = _headers ( token ) ,
)
assert response . status_code == 201 , response . text
data = response . json ( )
assert expected_schema in data [ " schema " ]
db = ds_write . get_database ( " data " )
columns = ( await db . execute ( " select * from pragma_table_info(?) " , [ table ] ) ) . dicts ( )
assert columns [ 1 ] [ " type " ] == " INTEGER "
assert expected_schema in columns [ 1 ] [ " dflt_value " ]
row = await db . execute_write_fn (
lambda conn : conn . execute (
" insert into [ {} ] default values returning created, typeof(created) " . format (
table
)
) . fetchone ( )
)
assert row [ 0 ] > minimum_value
assert row [ 1 ] == " integer "
2026-06-17 12:38:51 -07:00
@pytest.mark.asyncio
@pytest.mark.parametrize (
" column,expected_error " ,
(
(
{ " name " : " owner_id " , " type " : " integer " , " fk_table " : " owners " } ,
None ,
) ,
(
{ " name " : " owner_id " , " type " : " integer " , " fk_column " : " id " } ,
" columns.0: fk_column requires fk_table " ,
) ,
2026-06-22 11:04:19 -07:00
(
{
" name " : " created " ,
" type " : " text " ,
" default_expr " : " datetime( ' now ' ) " ,
} ,
2026-06-22 13:42:35 -07:00
" columns.0.default_expr: Input should be ' current_timestamp ' , ' current_date ' , ' current_time ' , ' current_unixtime ' or ' current_unixtime_ms ' " ,
2026-06-22 11:04:19 -07:00
) ,
(
{
" name " : " created " ,
" type " : " text " ,
" default " : " x " ,
" default_expr " : " current_timestamp " ,
} ,
" columns.0: Value error, default and default_expr cannot both be provided " ,
) ,
2026-06-17 12:38:51 -07:00
) ,
)
2026-06-22 11:04:19 -07:00
async def test_create_table_column_validation ( ds_write , column , expected_error ) :
2026-06-17 12:38:51 -07:00
token = write_token ( ds_write )
response = await ds_write . client . post (
" /data/-/create " ,
json = {
" table " : " projects " ,
" columns " : [ column ] ,
} ,
headers = _headers ( token ) ,
)
if expected_error :
assert response . status_code == 400
assert response . json ( ) == { " ok " : False , " errors " : [ expected_error ] }
else :
assert response . status_code == 400
assert response . json ( ) == {
" ok " : False ,
" errors " : [ " Could not detect single primary key for table ' owners ' " ] ,
}
@pytest.mark.asyncio
async def test_create_table_foreign_key_without_fk_column_requires_single_pk ( ds_write ) :
token = write_token ( ds_write )
response = await ds_write . client . post (
" /data/-/create " ,
json = {
" table " : " accounts " ,
" columns " : [
{ " name " : " tenant_id " , " type " : " integer " } ,
{ " name " : " id " , " type " : " integer " } ,
{ " name " : " name " , " type " : " text " } ,
] ,
" pks " : [ " tenant_id " , " id " ] ,
} ,
headers = _headers ( token ) ,
)
assert response . status_code == 201
response = await ds_write . client . post (
" /data/-/create " ,
json = {
" table " : " projects " ,
" columns " : [
{ " name " : " id " , " type " : " integer " } ,
{
" name " : " account_id " ,
" type " : " integer " ,
" fk_table " : " accounts " ,
} ,
] ,
" pk " : " id " ,
} ,
headers = _headers ( token ) ,
)
assert response . status_code == 400
assert response . json ( ) == {
" ok " : False ,
" errors " : [ " Could not detect single primary key for table ' accounts ' " ] ,
}
2022-12-14 12:21:18 -08:00
@pytest.mark.asyncio
@pytest.mark.parametrize (
" permissions,body,expected_status,expected_errors " ,
(
( [ " create-table " ] , { " table " : " t " , " columns " : [ { " name " : " c " } ] } , 201 , None ) ,
# Need insert-row too if you use "rows":
(
[ " create-table " ] ,
{ " table " : " t " , " rows " : [ { " name " : " c " } ] } ,
403 ,
2024-02-19 12:51:14 -08:00
[ " Permission denied: need insert-row " ] ,
2022-12-14 12:21:18 -08:00
) ,
# This should work:
(
[ " create-table " , " insert-row " ] ,
{ " table " : " t " , " rows " : [ { " name " : " c " } ] } ,
201 ,
None ,
) ,
# If you use replace: true you need update-row too:
(
[ " create-table " , " insert-row " ] ,
{ " table " : " t " , " rows " : [ { " id " : 1 } ] , " pk " : " id " , " replace " : True } ,
403 ,
2024-02-19 12:51:14 -08:00
[ " Permission denied: need update-row " ] ,
2022-12-14 12:21:18 -08:00
) ,
) ,
)
async def test_create_table_permissions (
ds_write , permissions , body , expected_status , expected_errors
) :
register_token_handler() plugin hook for custom API token backends (#2650)
Closes #2649
* Add register_token_handler plugin hook for pluggable token backends
Adds a new register_token_handler hook that allows plugins to provide
custom token creation and verification backends. This enables plugins
like datasette-oauth to issue tokens without depending on specific
backend plugins like datasette-auth-tokens.
Key changes:
- New datasette/tokens.py with TokenHandler base class and SignedTokenHandler
(the default signed-token implementation moved here)
- New register_token_handler hookspec in hookspecs.py
- Datasette.create_token() is now async and delegates to token handlers
- New Datasette.verify_token() method tries all handlers in sequence
- handler= parameter on create_token() to select a specific backend
- TokenHandler exported from datasette package for plugin use
- Fixed actor_from_request loop to await all coroutines (avoids warnings)
* Add documentation and hook test for register_token_handler
Fixes CI failures: the new hook needs a section in docs/plugin_hooks.rst
(checked by test_plugin_hooks_are_documented) and a test_hook_* function
in test_plugins.py (checked by test_plugin_hooks_have_tests).
* Register tokens module as separate default plugin
Instead of re-exporting hookimpls from default_permissions/__init__.py,
register datasette.default_permissions.tokens as its own DEFAULT_PLUGINS
entry. Cleaner and avoids confusing import-for-side-effect patterns.
* Replace restrict_x params with TokenRestrictions dataclass
Consolidates the three separate restrict_all, restrict_database, and
restrict_resource parameters into a single TokenRestrictions dataclass.
Cleaner API surface for both Datasette.create_token() and
TokenHandler.create_token().
Also clarifies docs re: default handler selection via pluggy ordering.
* Add builder methods to TokenRestrictions
Adds allow_all(), allow_database(), and allow_resource() methods that
return self for chaining. Callers no longer need to manipulate nested
dicts directly:
restrictions = (TokenRestrictions()
.allow_all("view-instance")
.allow_database("mydb", "create-table")
.allow_resource("mydb", "mytable", "insert-row"))
* docs: add 1.0a25 upgrade guide section for create_token() signature change
Ref: https://github.com/simonw/datasette/issues/2649#issuecomment-3962639393
* docs: note that create_token() is now async in upgrade guide
* docs: update internals, plugin_hooks, authentication for new token API
- internals.rst: new async create_token() signature with restrictions
and handler params, add TokenRestrictions reference docs
- plugin_hooks.rst: show full create_token signature in TokenHandler
example, note list returns and error cases
- authentication.rst: cross-reference TokenRestrictions from the
restrictions section
* style: apply black formatting to token handler files
* docs: fix RST heading underline length in internals.rst
* tests: add restrictions round-trip and expiration tests for token handler
Covers allow_database/allow_resource builders, _r payload encoding,
and token_expires in verified actors. Coverage 76% -> 90%.
* tests: add test for signed tokens disabled
* fix: add TokenRestrictions TYPE_CHECKING import to fix ruff F821
* docs: regenerate plugins.rst with cog
* docs: reformat code blocks in plugin_hooks.rst with blacken-docs
* docs: add await .verify_token() to internals.rst
* tests: rewrite register_token_handler test to use real plugin handler
Adds a HardcodedTokenHandler to the test plugins dir that creates
tokens like dstok_hardcoded_token_1. The test now exercises creating
tokens via the default handler (which is the plugin's hardcoded one),
by explicitly naming the hardcoded handler, and by explicitly naming
the signed handler -- then verifies each token round-trips correctly.
* tests: clarify test_token_handler_via_http tests the default signed handler
* fix: use handler="signed" explicitly where signed tokens are expected
The HardcodedTokenHandler in my_plugin.py gets globally registered,
so create_token() without a handler name picks it up as the default.
Fix the create-token view, CLI, and tests to explicitly request the
signed handler where they depend on signed token behavior.
* fix: use handler="signed" in test_create_table_permissions
https://claude.ai/code/session_013cQFiDQjYRrRBH2biFfKuS
2026-02-25 16:32:45 -08:00
from datasette . tokens import TokenRestrictions
restrictions = TokenRestrictions ( )
for action in [ " view-instance " ] + permissions :
restrictions . allow_all ( action )
token = await ds_write . create_token (
" root " , handler = " signed " , restrictions = restrictions
)
2022-12-14 12:21:18 -08:00
response = await ds_write . client . post (
" /data/-/create " ,
json = body ,
headers = _headers ( token ) ,
)
assert response . status_code == expected_status
if expected_errors :
data = response . json ( )
assert data [ " ok " ] is False
assert data [ " errors " ] == expected_errors
2022-12-02 22:57:57 -08:00
@pytest.mark.asyncio
2024-06-13 10:15:38 -07:00
@pytest.mark.xfail ( reason = " Flaky, see https://github.com/simonw/datasette/issues/2356 " )
2022-12-02 22:57:57 -08:00
@pytest.mark.parametrize (
" input,expected_rows_after " ,
(
(
{
" table " : " test_insert_replace " ,
" rows " : [
{ " id " : 1 , " name " : " Row 1 new " } ,
{ " id " : 3 , " name " : " Row 3 new " } ,
] ,
2022-12-07 17:18:40 -08:00
" pk " : " id " ,
2022-12-02 22:57:57 -08:00
" ignore " : True ,
} ,
[
{ " id " : 1 , " name " : " Row 1 " } ,
{ " id " : 2 , " name " : " Row 2 " } ,
{ " id " : 3 , " name " : " Row 3 new " } ,
] ,
) ,
(
{
" table " : " test_insert_replace " ,
" rows " : [
{ " id " : 1 , " name " : " Row 1 new " } ,
{ " id " : 3 , " name " : " Row 3 new " } ,
] ,
2022-12-07 17:18:40 -08:00
" pk " : " id " ,
2022-12-02 22:57:57 -08:00
" replace " : True ,
} ,
[
{ " id " : 1 , " name " : " Row 1 new " } ,
{ " id " : 2 , " name " : " Row 2 " } ,
{ " id " : 3 , " name " : " Row 3 new " } ,
] ,
) ,
) ,
)
async def test_create_table_ignore_replace ( ds_write , input , expected_rows_after ) :
# Create table with two rows
token = write_token ( ds_write )
first_response = await ds_write . client . post (
" /data/-/create " ,
json = {
" rows " : [ { " id " : 1 , " name " : " Row 1 " } , { " id " : 2 , " name " : " Row 2 " } ] ,
" table " : " test_insert_replace " ,
" pk " : " id " ,
} ,
2022-12-07 17:39:07 -08:00
headers = _headers ( token ) ,
2022-12-02 22:57:57 -08:00
)
assert first_response . status_code == 201
2024-02-16 13:58:33 -08:00
ds_write . _tracked_events = [ ]
2022-12-02 22:57:57 -08:00
# Try a second time
second_response = await ds_write . client . post (
" /data/-/create " ,
json = input ,
2022-12-07 17:39:07 -08:00
headers = _headers ( token ) ,
2022-12-02 22:57:57 -08:00
)
assert second_response . status_code == 201
# Check that the rows are as expected
rows = await ds_write . client . get ( " /data/test_insert_replace.json?_shape=array " )
assert rows . json ( ) == expected_rows_after
2024-02-16 13:58:33 -08:00
# Check it fired the right events
event_names = [ e . name for e in ds_write . _tracked_events ]
assert event_names == [ " insert-rows " ]
2022-12-02 22:57:57 -08:00
2022-12-07 17:27:01 -08:00
@pytest.mark.asyncio
async def test_create_table_error_if_pk_changed ( ds_write ) :
token = write_token ( ds_write )
first_response = await ds_write . client . post (
" /data/-/create " ,
json = {
" rows " : [ { " id " : 1 , " name " : " Row 1 " } , { " id " : 2 , " name " : " Row 2 " } ] ,
" table " : " test_insert_replace " ,
" pk " : " id " ,
} ,
2022-12-07 17:39:07 -08:00
headers = _headers ( token ) ,
2022-12-07 17:27:01 -08:00
)
assert first_response . status_code == 201
# Try a second time with a different pk
second_response = await ds_write . client . post (
" /data/-/create " ,
json = {
" rows " : [ { " id " : 1 , " name " : " Row 1 " } , { " id " : 2 , " name " : " Row 2 " } ] ,
" table " : " test_insert_replace " ,
" pk " : " name " ,
" replace " : True ,
} ,
2022-12-07 17:39:07 -08:00
headers = _headers ( token ) ,
2022-12-07 17:27:01 -08:00
)
assert second_response . status_code == 400
assert second_response . json ( ) == {
" ok " : False ,
" errors " : [ " pk cannot be changed for existing table " ] ,
}
2022-12-07 17:29:24 -08:00
@pytest.mark.asyncio
async def test_create_table_error_rows_twice_with_duplicates ( ds_write ) :
# Error if you don't send ignore: True or replace: True
token = write_token ( ds_write )
input = {
" rows " : [ { " id " : 1 , " name " : " Row 1 " } , { " id " : 2 , " name " : " Row 2 " } ] ,
" table " : " test_create_twice " ,
" pk " : " id " ,
}
first_response = await ds_write . client . post (
" /data/-/create " ,
json = input ,
2022-12-07 17:39:07 -08:00
headers = _headers ( token ) ,
2022-12-07 17:29:24 -08:00
)
assert first_response . status_code == 201
second_response = await ds_write . client . post (
" /data/-/create " ,
json = input ,
2022-12-07 17:39:07 -08:00
headers = _headers ( token ) ,
2022-12-07 17:29:24 -08:00
)
assert second_response . status_code == 400
assert second_response . json ( ) == {
" ok " : False ,
" errors " : [ " UNIQUE constraint failed: test_create_twice.id " ] ,
}
2022-11-29 21:15:13 -08:00
@pytest.mark.asyncio
@pytest.mark.parametrize (
" path " ,
(
" /data/-/create " ,
" /data/docs/-/drop " ,
" /data/docs/-/insert " ,
) ,
)
async def test_method_not_allowed ( ds_write , path ) :
response = await ds_write . client . get (
path ,
headers = {
" Content-Type " : " application/json " ,
} ,
)
assert response . status_code == 405
assert response . json ( ) == {
" ok " : False ,
" error " : " Method not allowed " ,
}
2024-02-08 12:21:13 -08:00
@pytest.mark.asyncio
async def test_create_uses_alter_by_default_for_new_table ( ds_write ) :
2024-02-16 13:58:33 -08:00
ds_write . _tracked_events = [ ]
2024-02-08 12:21:13 -08:00
token = write_token ( ds_write )
response = await ds_write . client . post (
" /data/-/create " ,
json = {
" table " : " new_table " ,
" rows " : [
{
" name " : " Row 1 " ,
}
]
* 100
+ [
{ " name " : " Row 2 " , " extra " : " Extra " } ,
] ,
" pk " : " id " ,
} ,
headers = _headers ( token ) ,
)
assert response . status_code == 201
2024-02-16 13:58:33 -08:00
event_names = [ e . name for e in ds_write . _tracked_events ]
assert event_names == [ " create-table " , " insert-rows " ]
2024-02-08 12:21:13 -08:00
@pytest.mark.asyncio
2024-02-08 12:35:12 -08:00
@pytest.mark.parametrize ( " has_alter_permission " , ( True , False ) )
2024-02-08 12:21:13 -08:00
async def test_create_using_alter_against_existing_table (
ds_write , has_alter_permission
) :
token = write_token (
ds_write , permissions = [ " ir " , " ct " ] + ( [ " at " ] if has_alter_permission else [ ] )
)
# First create the table
response = await ds_write . client . post (
" /data/-/create " ,
json = {
" table " : " new_table " ,
" rows " : [
{
" name " : " Row 1 " ,
}
] ,
" pk " : " id " ,
} ,
headers = _headers ( token ) ,
)
assert response . status_code == 201
2024-02-16 13:58:33 -08:00
ds_write . _tracked_events = [ ]
2024-02-08 12:21:13 -08:00
# Now try to insert more rows using /-/create with alter=True
response2 = await ds_write . client . post (
" /data/-/create " ,
json = {
" table " : " new_table " ,
" rows " : [ { " name " : " Row 2 " , " extra " : " extra " } ] ,
" pk " : " id " ,
" alter " : True ,
} ,
headers = _headers ( token ) ,
)
if not has_alter_permission :
assert response2 . status_code == 403
assert response2 . json ( ) == {
" ok " : False ,
2024-02-19 12:51:14 -08:00
" errors " : [ " Permission denied: need alter-table " ] ,
2024-02-08 12:21:13 -08:00
}
else :
assert response2 . status_code == 201
2024-02-16 13:58:33 -08:00
event_names = [ e . name for e in ds_write . _tracked_events ]
assert event_names == [ " alter-table " , " insert-rows " ]
2024-02-08 12:21:13 -08:00
# It should have altered the table
2024-02-16 13:58:33 -08:00
alter_event = ds_write . _tracked_events [ 0 ]
assert alter_event . name == " alter-table "
assert " extra " not in alter_event . before_schema
assert " extra " in alter_event . after_schema
insert_rows_event = ds_write . _tracked_events [ 1 ]
assert insert_rows_event . name == " insert-rows "
assert insert_rows_event . num_rows == 1