Since 1.3.0, the update(Model).values(field=...) check from #4047 reports every plain column of a SQLModel table model as "Unexpected SQLAlchemy update field" if the model has at least one relationship annotated with sqlalchemy.orm.Mapped[...].
The check takes the model's columns to be the attributes annotated Mapped[...], and skips the model if there are none. That works for DeclarativeBase classes, where every column is Mapped[...]. In SQLModel, columns are Pydantic fields (name: str = Field(...)) and Mapped[...] only appears on Relationship() attributes. So the check sees only the relationships as columns and reports every real column as unknown.
SQLModel accepts Mapped[...] on relationships, though its docs do not use it. SQLModelMetaclass.__init__ (sqlmodel/main.py, since fastapi/sqlmodel#700) unwraps a Mapped[...] annotation if present, otherwise it rewrites the annotation to Mapped[ann] itself; either way it builds the relationship() from the inner type, so both spellings produce the same mapper. selectinload(Model.rel) only type-checks with the explicit form: Mapped.__get__ returns InstrumentedAttribute[_T] for class-level access, while with a plain annotation Model.rel has the instance type and selectinload rejects it.
Reproduction
pyrefly==1.3.0, sqlmodel==0.0.42, sqlalchemy==2.0.52, Python 3.13.
from typing import Optional
from uuid import UUID
from sqlalchemy import update
from sqlalchemy.orm import Mapped
from sqlmodel import Field, Relationship, SQLModel
class Node(SQLModel, table=True):
id: UUID = Field(primary_key=True)
name: str = Field(description="x")
parent_id: UUID | None = Field(default=None, foreign_key="node.id")
parent: Mapped[Optional["Node"]] = Relationship()
update(Node).values(name="a") # real column, false positive
update(Node).values(parent_id=None) # real column, false positive
update(Node).values(nope="a") # wrong, the check catches it
ERROR Unexpected SQLAlchemy update field `name` [unexpected-keyword]
--> repro.py:16:21
ERROR Unexpected SQLAlchemy update field `parent_id` [unexpected-keyword]
--> repro.py:17:21
ERROR Unexpected SQLAlchemy update field `nope` [unexpected-keyword]
--> repro.py:18:21
Removing the parent relationship, or its Mapped[...] wrapper, makes all three lines pass, including the wrong one, because the model then has no Mapped[...] attributes and the check skips it.
1.2.0 reports nothing for this file.
Expected behavior
Either
- skip classes whose MRO contains
sqlmodel.SQLModel, since the check cannot read their columns from Mapped[...] annotations, or
- use the Pydantic fields as the columns for SQLModel classes, which keeps the
nope error and removes the false positives.
There is no config switch for this check and it uses the generic unexpected-keyword kind, so staying on 1.3.0 means a suppression at every update().values() call on such a model (31 sites in our codebase).
Sandbox Link
Link here
Since 1.3.0, the
update(Model).values(field=...)check from #4047 reports every plain column of a SQLModel table model as "Unexpected SQLAlchemy update field" if the model has at least one relationship annotated withsqlalchemy.orm.Mapped[...].The check takes the model's columns to be the attributes annotated
Mapped[...], and skips the model if there are none. That works forDeclarativeBaseclasses, where every column isMapped[...]. In SQLModel, columns are Pydantic fields (name: str = Field(...)) andMapped[...]only appears onRelationship()attributes. So the check sees only the relationships as columns and reports every real column as unknown.SQLModel accepts
Mapped[...]on relationships, though its docs do not use it.SQLModelMetaclass.__init__(sqlmodel/main.py, since fastapi/sqlmodel#700) unwraps aMapped[...]annotation if present, otherwise it rewrites the annotation toMapped[ann]itself; either way it builds therelationship()from the inner type, so both spellings produce the same mapper.selectinload(Model.rel)only type-checks with the explicit form:Mapped.__get__returnsInstrumentedAttribute[_T]for class-level access, while with a plain annotationModel.relhas the instance type andselectinloadrejects it.Reproduction
pyrefly==1.3.0,sqlmodel==0.0.42,sqlalchemy==2.0.52, Python 3.13.Removing the
parentrelationship, or itsMapped[...]wrapper, makes all three lines pass, including the wrong one, because the model then has noMapped[...]attributes and the check skips it.1.2.0 reports nothing for this file.
Expected behavior
Either
sqlmodel.SQLModel, since the check cannot read their columns fromMapped[...]annotations, ornopeerror and removes the false positives.There is no config switch for this check and it uses the generic
unexpected-keywordkind, so staying on 1.3.0 means a suppression at everyupdate().values()call on such a model (31 sites in our codebase).Sandbox Link
Link here