Match primary key names case-insensitively when forcing NOT NULL

SQLite resolves identifiers case-insensitively, so a request with pk "code"
against a column named "Code" still creates that column as the primary key. The
membership check was case-sensitive, so that column was left out of not_null and
the API could still create a nullable text primary key, which is the exact
unviewable/undeletable NULL-PK row this change is meant to prevent.

The integer-rowid-alias exemption is matched the same way, so an integer pk
whose casing differs still stays a plain rowid alias rather than picking up a
constraint it does not need.

Two cases added to test_create_table_primary_keys_are_not_null; the text one
fails without this change (notnull 0, expected 1).
This commit is contained in:
Pranav Mishra 2026-08-03 08:10:42 -07:00
commit c9a365e1ef
2 changed files with 58 additions and 3 deletions

View file

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

View file

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