Skip to content

Return 400 instead of 500 for wrong-arity composite-PK row URLs (#2811)#2815

Open
HarperZ9 wants to merge 1 commit into
simonw:mainfrom
HarperZ9:fix-row-pk-arity-2811
Open

Return 400 instead of 500 for wrong-arity composite-PK row URLs (#2811)#2815
HarperZ9 wants to merge 1 commit into
simonw:mainfrom
HarperZ9:fix-row-pk-arity-2811

Conversation

@HarperZ9

Copy link
Copy Markdown

Fixes #2811

The bug

A GET for a row page on a table with a composite (multi-column) primary key returns HTTP 500 (sqlite3.ProgrammingError, unbound bind parameter) when the URL has the wrong number of comma-separated primary-key components. The most common trigger in the wild is a proxy/crawler re-encoding the separating comma as %2C, which collapses the arity.

Reproduce (confirmed on 1.0a35 / main 34ab85e)

sqlite3 demo.db "create table t (a text, b text, primary key (a, b)); insert into t values ('x', 'y');"
datasette demo.db
curl -s -o /dev/null -w '%{http_code}\n' 'http://127.0.0.1:8001/demo/t/x,y'     # 200
curl -s -o /dev/null -w '%{http_code}\n' 'http://127.0.0.1:8001/demo/t/x%2Cy'   # 500  <-- bug
curl -s -o /dev/null -w '%{http_code}\n' 'http://127.0.0.1:8001/demo/t/x'       # 500  <-- bug

Root cause

row_sql_params_pks (datasette/utils/__init__.py) builds one :pN bind placeholder per primary-key column but binds params only for the supplied URL components:

wheres = [f'"{pk}"=:p{i}' for i, pk in enumerate(pks)]   # one per PK column
...
for i, pk_value in enumerate(pk_values):                  # one per supplied value
    params[f"p{i}"] = pk_value

When too few components are supplied, the trailing placeholder (e.g. :p1) is left unbound and Datasette.resolve_row (datasette/app.py) executes the SQL with no arity guard, so SQLite raises You did not supply a value for binding parameter :p1 — a 500, raised before the existing handle_404 recovery can run. (When too many components are supplied, the extra params are silently ignored and a misleading result is returned.)

The identical pk-count mismatch is already guarded with BadRequest in _fragment_request_for_row (datasette/views/table.py), but the row-page resolution path had no such guard.

The fix

Add a primary-key arity guard in resolve_row, mirroring the existing convention in datasette/views/table.py:

sql, params, pks = await row_sql_params_pks(db, table_name, pk_values)
if len(pk_values) != len(pks):
    raise BadRequest(
        "URL row identifier does not match the primary key for this table"
    )

A malformed-arity row URL is a client error, so 400 is the appropriate status, consistent with _fragment_request_for_row and the ?_sort errors -> 400 change (#1950).

BadRequest was not previously imported into app.py, so it is added to the from .utils.asgi import (...) block (it is a Base400 subclass that yields HTTP 400).

After the fix:

/demo/t/x,y    -> 200   (canonical, unchanged)
/demo/t/x%2Cy  -> 400   (was 500)
/demo/t/x      -> 400   (was 500)
/demo/t/x,y,z  -> 400   (was a silent wrong-result 200)

A single-column PK value that legitimately contains a comma is unaffected: it tilde-encodes (a,b -> a~2Cb), so it stays one component and resolves normally.

Test

Added test_row_pk_arity_mismatch_returns_400 (parametrized over too-few / too-many components and HTML / .json) plus test_row_compound_pk_correct_arity in tests/test_api.py, using the existing compound_primary_key fixture. The new tests fail on main (500 / silent-200) and pass with the fix. The 400 assertion style follows the existing test_sort_errors precedent in tests/test_table_html.py.


Authored with AI assistance (Claude); reviewed and verified before submitting (bug reproduced before the change, fix and tests verified after).

A row URL with the wrong number of comma-separated primary key
components for a compound (multi-column) primary key table raised an
uncaught sqlite3.ProgrammingError, surfacing as an HTTP 500.

row_sql_params_pks builds one SQL bind placeholder per primary-key
column but only binds params for the supplied URL components. When too
few components are supplied, a trailing placeholder is left unbound and
Datasette.resolve_row executes the SQL with no arity guard, so SQLite
raises "You did not supply a value for binding parameter :pN" before the
404 recovery can run. When too many components are supplied the extra
params are silently ignored and a misleading result is returned.

Add a primary-key arity guard in resolve_row that raises BadRequest
(HTTP 400), mirroring the existing guard in datasette/views/table.py
(len(pk_values) != len(row_pks) -> BadRequest). BadRequest was not
previously imported into app.py, so add it to the .utils.asgi import.

Fixes simonw#2811

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Composite-primary-key row pages return HTTP 500 (unbound bind parameter) on a malformed PK URL, should be 400

1 participant