Skip to content

bug(sqla): get_one() raises InvalidRequestError (HTTP 500) when id contains a comma on a single-PK model #2915

Description

@hasansezertasan

bug(sqla): get_one() raises InvalidRequestError (HTTP 500) when id contains a comma on a single-PK model

Summary

ModelView.get_one() in flask_admin.contrib.sqla.view calls
session.get(self.model, tools.iterdecode(id)) unconditionally. For a model
with a single primary key, passing an id query string that contains a comma
(e.g. ?id=1,2) makes iterdecode return a 2-tuple, which SQLAlchemy 2.x
rejects in Session.get(). The exception is not caught by details_view /
edit_view, so it surfaces as an unhandled HTTP 500.

This was previously raised in #2788, but the discussion stalled without a
minimal reproducer. Opening a fresh issue with a PEP 723 MRE so the failure
mode is unambiguous, and so the right fix can be discussed separately from
that PR's approach.

Expected behavior

A malformed id parameter on a single-PK model should produce a
user-facing error (e.g. 400 Bad Request or the existing "Record does not
exist." flash + redirect to the list view) — not an unhandled 500.

Actual behavior

sqlalchemy.exc.InvalidRequestError: Incorrect number of values in identifier
to formulate primary key for session.get(); primary key columns are 'widget.id'

…rendered as a 500 Internal Server Error response.

Minimal reproducer (PEP 723)

Save as repro.py and run with uv run repro.py. No venv setup needed.

# /// script
# requires-python = ">=3.10"
# dependencies = [
#     "flask-admin",
#     "flask-sqlalchemy",
# ]
# ///
"""MRE for flask-admin get_one() crash on `?id=1,2` with a single-PK model."""
from flask import Flask
from flask_admin import Admin
from flask_admin.contrib.sqla import ModelView
from flask_sqlalchemy import SQLAlchemy

app = Flask(__name__)
app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///:memory:"
app.config["SECRET_KEY"] = "x"
app.config["WTF_CSRF_ENABLED"] = False
db = SQLAlchemy(app)


class Widget(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    name = db.Column(db.String(50))


class WidgetView(ModelView):
    can_view_details = True


admin = Admin(app)
admin.add_view(WidgetView(Widget, db.session, endpoint="widget"))

with app.app_context():
    db.create_all()
    db.session.add_all([Widget(name="alpha"), Widget(name="beta")])
    db.session.commit()

client = app.test_client()

print("=== id=1 (valid) ===")
rv = client.get("/admin/widget/details/?id=1")
print("status:", rv.status_code)

print("\n=== id=1,2 (malformed for single-PK model) ===")
rv = client.get("/admin/widget/details/?id=1,2")
print("status:", rv.status_code)
print("body head:", rv.data[:120])

Output

=== id=1 (valid) ===
status: 200

=== id=1,2 (malformed for single-PK model) ===
status: 500
body head: b'<!doctype html>\n<html lang=en>\n<title>500 Internal Server Error</title>\n<h1>Internal Server Error</h1>...'

The same crash is reachable on /admin/widget/edit/?id=1,2 and any other view
that calls get_one().

Root cause

flask_admin/contrib/sqla/view.py:

def get_one(self, id):
    session = _get_deprecated_session(self.session)
    return session.get(self.model, tools.iterdecode(id))

tools.iterdecode("1,2") returns ("1", "2"). SQLAlchemy 2.x's
Session.get() validates the identifier length against the model's primary
key columns and raises InvalidRequestError when they don't match. Compare
with get_pk_value() (same file), which already branches on
isinstance(self._primary_key, tuple) for the write side — the read side
should mirror that.

Notes on PR #2788

The PR proposes a fix but:

  1. Uses tools.escape(id) for the single-PK branch, which encodes the value
    in the iterencode escape format (e.g. "1,2""1\,2") — a value
    that can never match an integer primary key. The crash is avoided only
    because session.get returns None and the existing
    "record not found → redirect" path takes over.
  2. Contains a dead _id = tools.iterdecode(id) line immediately overwritten
    in both branches of the if/else.
  3. The added test uses follow_redirects=True and asserts on strings
    (test1_val_1, test2_val_1) that appear on the list view after the
    redirect — not on the details page. It would pass even if get_one
    returned None, so it does not actually verify the multi-id case.

Environment

  • flask-admin from master (commit at time of writing).
  • SQLAlchemy 2.x.
  • Python 3.10+.

Possible fix sketch (for discussion, not part of this issue)

def get_one(self, id):
    session = _get_deprecated_session(self.session)
    if isinstance(self._primary_key, tuple):
        return session.get(self.model, tools.iterdecode(id))
    return session.get(self.model, (id,))

…and a test that asserts the status code / flash message on the malformed
input rather than scraping the redirected list page.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions