Bug: using `form_ajax_refs` with a self-referential column breaks edit page

#1129 · open · 0 comments

View on GitHub ↗

ebencollins

### Checklist - [x] The bug is reproducible against the latest release or `master`. - [x] There are no similar issues or pull requests to fix it yet. ### Describe the bug When using form_ajax_refs fails join with a self-referential column, the edit page fails to load due to a `sqlalchemy.exc.InvalidRequestError` error. ### Steps to reproduce the bug ```py import sqlalchemy as sa from sqladmin import Admin, ModelView from sqlalchemy import create_engine from sqlalchemy.orm import declarative_base, Mapped, relationship from sqlalchemy.testing.schema import mapped_column from starlette.applications import Starlette Base = declarative_base() engine = create_engine( "sqlite:///example.db", connect_args={"check_same_thread": False}, ) class User(Base): __tablename__ = "users" id: Mapped[int] = mapped_column(primary_key=True) name: Mapped[str] = mapped_column(sa.String(50)) parent_id: Mapped[int | None] = mapped_column(sa.ForeignKey("users.id"), nullable=True) parent: Mapped[list["User"]] = relationship() def __repr__(self) -> str: return f"User(id={self.id}, name={self.name})" Base.metadata.create_all(engine) app = Starlette() admin = Admin(app, engine) class UserAdmin(ModelView, model=User): column_list = [User.id, User.name] form_ajax_refs = { "parent": { "fields": [User.id, User.name], "order_by": [User.name], } } admin.add_view(UserAdmin) ``` ### Expected behavior Ajax field works with self referential field ### Actual behavior Page fails to load with an internal server error ### Debugging material <details> <summary>traceback</summary> ```txt INFO: 127.0.0.1:33224 - "GET /admin/user/edit/1 HTTP/1.1" 500 Internal Server Error dialect=dialect, ^^^^^^^^^^^^^^^^ ...<4 lines>... linting=self.dialect.compiler_linting | compiler.WARN_LINTING, ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ) ^ File "<...>/.env/lib/python3.14/site-packages/sqlalchemy/sql/elements.py", line 715, in _compile_w_cache compiled_sql = self._compiler( dialect, ...<4 lines>... **kw, ) File "<...>/.env/lib/python3.14/site-packages/sqlalchemy/sql/elements.py", line 323, in _compiler return dialect.statement_compiler(dialect, self, **kw) ~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^ File "<...>/.env/lib/python3.14/site-packages/sqlalchemy/sql/compiler.py", line 1462, in __init__ Compiled.__init__(self, dialect, statement, **kwargs) ~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "<...>/.env/lib/python3.14/site-packages/sqlalchemy/sql/compiler.py", line 902, in __init__ self.string = self.process(self.statement, **compile_kwargs) ~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "<...>/.env/lib/python3.14/site-packages/sqlalchemy/sql/compiler.py", line 948, in process return obj._compiler_dispatch(self, **kwargs) ~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^ File "<...>/.env/lib/python3.14/site-packages/sqlalchemy/sql/visitors.py", line 138, in _compiler_dispatch return meth(self, **kw) # type: ignore # noqa: E501 File "<...>/.env/lib/python3.14/site-packages/sqlalchemy/sql/compiler.py", line 4818, in visit_select compile_state = select_stmt._compile_state_factory( select_stmt, self, **kwargs ) File "<...>/.env/lib/python3.14/site-packages/sqlalchemy/sql/base.py", line 774, in create_for_statement return klass.create_for_statement(statement, compiler, **kw) ~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "<...>/.env/lib/python3.14/site-packages/sqlalchemy/orm/context.py", line 447, in create_for_statement return cls._create_orm_context( ~~~~~~~~~~~~~~~~~~~~~~~^ cast("Union[Select, FromStatement]", statement), ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ...<2 lines>... **kw, ^^^^^ ) ^ File "<...>/.env/lib/python3.14/site-packages/sqlalchemy/orm/context.py", line 1246, in _create_orm_context self._setup_for_generate() ~~~~~~~~~~~~~~~~~~~~~~~~^^ File "<...>/.env/lib/python3.14/site-packages/sqlalchemy/orm/context.py", line 1281, in _setup_for_generate self._join(query._setup_joins, self._entities) ~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "<...>/.env/lib/python3.14/site-packages/sqlalchemy/orm/context.py", line 1922, in _join self._join_left_to_right( ~~~~~~~~~~~~~~~~~~~~~~~~^ entities_collection, ^^^^^^^^^^^^^^^^^^^^ ...<5 lines>... full, ^^^^^ ) ^ File "<...>/.env/lib/python3.14/site-packages/sqlalchemy/orm/context.py", line 1971, in _join_left_to_right raise sa_exc.InvalidRequestError( ...<2 lines>... ) sqlalchemy.exc.InvalidRequestError: Can't construct a join from Mapper[User(users)] to Mapper[User(users)], they are the same entity ``` </details> ### Environment python 3.14 sqladmin 0.31.0 sqlalchemy 2.0.52 ### Additional context I was able to fix the issue for my usecase by overriding `get_form_data_for_edit` with the following POC, but obviously it is not polished ```py async def get_form_data_for_edit(self, obj: Any) -> dict[str, Any]: result = {} pk_value = get_object_identifier(obj) for name in self._form_prop_names: if name in self._ajax_relation_names: relation = self._mapper.relationships[name] target = relation.mapper.class_ direction = get_direction(relation) if direction in ("ONETOMANY", "MANYTOMANY"): ### START CHANGED CODE ### # whereclause = parent_stmt.whereclause # if whereclause is None: # continue # stmt = ( # select(target) # .join(getattr(self.model, name)) # .where(whereclause) # .distinct() # ) # result[name] = await self._run_query(stmt) parent = aliased(self.model, name="parent", flat=True) target_alias = aliased(target, name="target", flat=True) parent_pk_name = get_primary_keys(self.model)[0].key parent_pk = getattr(parent, parent_pk_name) stmt = ( select(target_alias) .select_from(parent) .join(getattr(parent, name).of_type(target_alias)) .where(parent_pk == pk_value) .distinct() ) result[name] = await self._run_query(stmt) ### END CHANGED CODE else: fk_value = getattr( obj, relation.local_remote_pairs[0][0].name, None ) if fk_value is not None: target_pk = get_primary_keys(target)[0] stmt = select(target).where(target_pk == fk_value) rows = await self._run_query(stmt) result[name] = rows[0] if rows else None else: result[name] = None else: value = getattr(obj, name, None) wtf_key = WTFORMS_ATTRS.get(name, name) result[wtf_key] = value return result ``` Note: The above "fix" was designed to address a slightly more complex model situation where I have both a self-referential column and polymorphism. A second reproduction example & error below: <details> <summary>code</summary> ```py import sqlalchemy as sa from sqladmin import Admin, ModelView from sqlalchemy import create_engine from sqlalchemy.orm import declarative_base, Mapped, relationship from sqlalchemy.testing.schema import mapped_column from starlette.applications import Starlette Base = declarative_base() class Employee(Base): id: Mapped[int] = mapped_column(primary_key=True) name: Mapped[str] type: Mapped[str] __tablename__ = "employee" __mapper_args__ = { "polymorphic_identity": "employee", "polymorphic_on": "type", } def __repr__(self): return f"{self.__class__.__name__}({self.name!r})" class Manager(Employee): id: Mapped[int] = mapped_column( sa.ForeignKey(Employee.id, ondelete="CASCADE", onupdate="CASCADE"), primary_key=True, ) # <other properties> reports: Mapped[list[Employee]] = relationship(Employee, secondary="manager_report") __tablename__ = "manager" __mapper_args__ = { "polymorphic_identity": "manager", } class ManagerReport(Base): manager_id: Mapped[int] = mapped_column( sa.ForeignKey(Manager.id, ondelete="CASCADE", onupdate="CASCADE"), primary_key=True, ) report_id: Mapped[int] = mapped_column( sa.ForeignKey(Employee.id, onupdate="CASCADE"), primary_key=True, ) __tablename__ = "manager_report" class ManagerAdmin(ModelView, model=Manager): column_list = [Manager.id, Manager.name] form_excluded_columns = (Manager.id, Manager.type) form_ajax_refs = { "reports": { "fields": [Employee.id, Employee.name], "order_by": [Employee.name], } } engine = create_engine( "sqlite:///example2.db", connect_args={"check_same_thread": False}, ) Base.metadata.create_all(engine) app = Starlette() admin = Admin(app, engine) admin.add_view(ManagerAdmin) ``` </details> <details> <summary>traceback</summary> ``` ERROR: Exception in ASGI application Traceback (most recent call last): File "<...>/.env/lib/python3.14/site-packages/sqlalchemy/engine/base.py", line 1969, in _exec_single_context self.dialect.do_execute( ~~~~~~~~~~~~~~~~~~~~~~~^ cursor, str_statement, effective_parameters, context ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ) ^ File "<...>/.env/lib/python3.14/site-packages/sqlalchemy/engine/default.py", line 952, in do_execute cursor.execute(statement, parameters) ~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^ sqlite3.OperationalError: ambiguous column name: employee.id The above exception was the direct cause of the following exception: Traceback (most recent call last): File "<...>/.env/lib/python3.14/site-packages/uvicorn/protocols/http/h11_impl.py", line 416, in run_asgi result = await app( # type: ignore[func-returns-value] ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ self.scope, self.receive, self.send ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ) ^ File "<...>/.env/lib/python3.14/site-packages/uvicorn/middleware/proxy_headers.py", line 63, in __call__ return await self.app(scope, receive, send) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "<...>/.env/lib/python3.14/site-packages/starlette/applications.py", line 96, in __call__ await self.middleware_stack(scope, receive, send) File "<...>/.env/lib/python3.14/site-packages/starlette/middleware/errors.py", line 186, in __call__ raise exc File "<...>/.env/lib/python3.14/site-packages/starlette/middleware/errors.py", line 164, in __call__ await self.app(scope, receive, _send) File "<...>/.env/lib/python3.14/site-packages/starlette/middleware/exceptions.py", line 63, in __call__ await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send) File "<...>/.env/lib/python3.14/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app raise exc File "<...>/.env/lib/python3.14/site-packages/starlette/_exception_handler.py", line 42, in wrapped_app await app(scope, receive, sender) File "<...>/.env/lib/python3.14/site-packages/starlette/routing.py", line 670, in __call__ await self.middleware_stack(scope, receive, send) File "<...>/.env/lib/python3.14/site-packages/starlette/routing.py", line 690, in app await route.handle(scope, receive, send) File "<...>/.env/lib/python3.14/site-packages/starlette/routing.py", line 455, in handle await self.app(scope, receive, send) File "<...>/.env/lib/python3.14/site-packages/starlette/applications.py", line 96, in __call__ await self.middleware_stack(scope, receive, send) File "<...>/.env/lib/python3.14/site-packages/starlette/middleware/errors.py", line 186, in __call__ raise exc File "<...>/.env/lib/python3.14/site-packages/starlette/middleware/errors.py", line 164, in __call__ await self.app(scope, receive, _send) File "<...>/.env/lib/python3.14/site-packages/starlette/middleware/exceptions.py", line 63, in __call__ await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send) File "<...>/.env/lib/python3.14/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app raise exc File "<...>/.env/lib/python3.14/site-packages/starlette/_exception_handler.py", line 42, in wrapped_app await app(scope, receive, sender) File "<...>/.env/lib/python3.14/site-packages/starlette/routing.py", line 670, in __call__ await self.middleware_stack(scope, receive, send) File "<...>/.env/lib/python3.14/site-packages/starlette/routing.py", line 690, in app await route.handle(scope, receive, send) File "<...>/.env/lib/python3.14/site-packages/starlette/routing.py", line 280, in handle await self.app(scope, receive, send) File "<...>/.env/lib/python3.14/site-packages/starlette/routing.py", line 65, in app await wrap_app_handling_exceptions(app, request)(scope, receive, send) File "<...>/.env/lib/python3.14/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app raise exc File "<...>/.env/lib/python3.14/site-packages/starlette/_exception_handler.py", line 42, in wrapped_app await app(scope, receive, sender) File "<...>/.env/lib/python3.14/site-packages/starlette/routing.py", line 62, in app response = await f(request) ^^^^^^^^^^^^^^^^ File "<...>/.env/lib/python3.14/site-packages/sqladmin/authentication.py", line 76, in wrapper_decorator return await func(*args, **kwargs) ^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "<...>/.env/lib/python3.14/site-packages/sqladmin/application.py", line 828, in edit initial_data = await model_view.get_form_data_for_edit(model) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "<...>/.env/lib/python3.14/site-packages/sqladmin/models.py", line 1138, in get_form_data_for_edit result[name] = await self._run_query(stmt) ^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "<...>/.env/lib/python3.14/site-packages/sqladmin/models.py", line 896, in _run_query return await anyio.to_thread.run_sync(self._run_query_sync, stmt) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "<...>/.env/lib/python3.14/site-packages/anyio/to_thread.py", line 65, in run_sync return await get_async_backend().run_sync_in_worker_thread( ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ func, args, abandon_on_cancel=abandon_on_cancel, limiter=limiter ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ) ^ File "<...>/.env/lib/python3.14/site-packages/anyio/_backends/_asyncio.py", line 2641, in run_sync_in_worker_thread return await future ^^^^^^^^^^^^ File "<...>/.env/lib/python3.14/site-packages/anyio/_backends/_asyncio.py", line 1033, in run result = context.run(func, *args) File "<...>/.env/lib/python3.14/site-packages/sqladmin/models.py", line 887, in _run_query_sync result = session.execute(stmt) File "<...>/.env/lib/python3.14/site-packages/sqlalchemy/orm/session.py", line 2373, in execute return self._execute_internal( ~~~~~~~~~~~~~~~~~~~~~~^ statement, ^^^^^^^^^^ ...<4 lines>... _add_event=_add_event, ^^^^^^^^^^^^^^^^^^^^^^ ) ^ File "<...>/.env/lib/python3.14/site-packages/sqlalchemy/orm/session.py", line 2271, in _execute_internal result: Result[Any] = compile_state_cls.orm_execute_statement( ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^ self, ^^^^^ ...<4 lines>... conn, ^^^^^ ) ^ File "<...>/.env/lib/python3.14/site-packages/sqlalchemy/orm/context.py", line 306, in orm_execute_statement result = conn.execute( statement, params or {}, execution_options=execution_options ) File "<...>/.env/lib/python3.14/site-packages/sqlalchemy/engine/base.py", line 1421, in execute return meth( self, distilled_parameters, execution_options or NO_OPTIONS, ) File "<...>/.env/lib/python3.14/site-packages/sqlalchemy/sql/elements.py", line 526, in _execute_on_connection return connection._execute_clauseelement( ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^ self, distilled_params, execution_options ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ) ^ File "<...>/.env/lib/python3.14/site-packages/sqlalchemy/engine/base.py", line 1643, in _execute_clauseelement ret = self._execute_context( dialect, ...<8 lines>... cache_hit=cache_hit, ) File "<...>/.env/lib/python3.14/site-packages/sqlalchemy/engine/base.py", line 1848, in _execute_context return self._exec_single_context( ~~~~~~~~~~~~~~~~~~~~~~~~~^ dialect, context, statement, parameters ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ) ^ File "<...>/.env/lib/python3.14/site-packages/sqlalchemy/engine/base.py", line 1988, in _exec_single_context self._handle_dbapi_exception( ~~~~~~~~~~~~~~~~~~~~~~~~~~~~^ e, str_statement, effective_parameters, cursor, context ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ) ^ File "<...>/.env/lib/python3.14/site-packages/sqlalchemy/engine/base.py", line 2365, in _handle_dbapi_exception raise sqlalchemy_exception.with_traceback(exc_info[2]) from e File "<...>/.env/lib/python3.14/site-packages/sqlalchemy/engine/base.py", line 1969, in _exec_single_context self.dialect.do_execute( ~~~~~~~~~~~~~~~~~~~~~~~^ cursor, str_statement, effective_parameters, context ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ) ^ File "<...>/.env/lib/python3.14/site-packages/sqlalchemy/engine/default.py", line 952, in do_execute cursor.execute(statement, parameters) ~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^ sqlalchemy.exc.OperationalError: (sqlite3.OperationalError) ambiguous column name: employee.id [SQL: SELECT DISTINCT employee.id, employee.name, employee.type FROM employee JOIN manager_report AS manager_report_1 ON employee.id = manager_report_1.manager_id JOIN employee ON employee.id = manager_report_1.report_id WHERE employee.id = ?] [parameters: (2,)] (Background on this error at: https://sqlalche.me/e/20/e3q8) ``` </details>

Comments