diff --git a/datasette/views/table_create_alter.py b/datasette/views/table_create_alter.py index 61ceabd2..28ddf8dd 100644 --- a/datasette/views/table_create_alter.py +++ b/datasette/views/table_create_alter.py @@ -905,16 +905,21 @@ class TableCreateView(BaseView): # leaves rows that cannot be viewed, edited or deleted (see # #2807). A single integer primary key aliases the rowid and is # never NULL, so it needs no explicit constraint. - pk_names = set(pks) if pks else ({pk} if pk else set()) + # Matched case-insensitively because SQLite resolves identifiers + # that way: pk "code" against a column named "Code" still creates + # that column as the primary key. + pk_names = { + name.lower() for name in (pks if pks else ([pk] if pk else [])) + } if len(pk_names) == 1 and any( - column.name in pk_names and column.type == "integer" + column.name.lower() in pk_names and column.type == "integer" for column in columns ): pk_names = set() not_null = [ column.name for column in columns - if column.not_null or column.name in pk_names + if column.not_null or column.name.lower() in pk_names ] defaults = {} for column in columns: diff --git a/tests/test_api_write.py b/tests/test_api_write.py index f531e41a..479cad39 100644 --- a/tests/test_api_write.py +++ b/tests/test_api_write.py @@ -2352,6 +2352,56 @@ async def test_create_table_primary_keys_are_not_null(ds_write): assert columns["b"]["notnull"] == 1 assert columns["value"]["notnull"] == 0 + # Primary key named with different casing from the column. SQLite resolves + # identifiers case-insensitively, so this still creates "Code" as the primary + # key and it must pick up NOT NULL too. + response = await ds_write.client.post( + "/data/-/create", + json={ + "table": "pk_mixed_case", + "columns": [ + {"name": "Code", "type": "text"}, + {"name": "name", "type": "text"}, + ], + "pk": "code", + }, + headers=_headers(token), + ) + assert response.status_code == 201, response.text + columns = { + column["name"]: column + for column in ( + await db.execute("select * from pragma_table_info('pk_mixed_case')") + ).dicts() + } + assert columns["Code"]["pk"] == 1 + assert columns["Code"]["notnull"] == 1 + assert columns["name"]["notnull"] == 0 + + # The integer-rowid-alias exemption is matched case-insensitively too, so an + # integer pk stays a plain rowid alias rather than gaining a constraint. + response = await ds_write.client.post( + "/data/-/create", + json={ + "table": "pk_mixed_case_integer", + "columns": [ + {"name": "Id", "type": "integer"}, + {"name": "name", "type": "text"}, + ], + "pk": "id", + }, + headers=_headers(token), + ) + assert response.status_code == 201, response.text + columns = { + column["name"]: column + for column in ( + await db.execute("select * from pragma_table_info('pk_mixed_case_integer')") + ).dicts() + } + assert columns["Id"]["pk"] == 1 + assert columns["Id"]["notnull"] == 0 + @pytest.mark.asyncio @pytest.mark.parametrize(