From 24d66f76232242dd9b56f9d535d91e1b8856eda4 Mon Sep 17 00:00:00 2001 From: Ewgenij Starostin Date: Wed, 18 Mar 2026 13:10:33 +0100 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20Move=20`=5F=5Ftablename=5F=5F`?= =?UTF-8?q?=20default=20from=20`@declared=5Fattr`=20to=20metaclass?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `SQLModel` base class declared `__tablename__` both as a `ClassVar` and as a `@declared_attr` method. Some type checkers (pyright) see the descriptor type from `@declared_attr`, so setting `__tablename__ = "my_table"` in a subclass is rejected as a type mismatch, even though it works at runtime. Replace the `@declared_attr` method with a default set in `SQLModelMetaclass.__new__` via `dict_used`, before class creation. Fixes #98. Co-Authored-By: Claude Opus 4.6 (1M context) --- sqlmodel/main.py | 9 ++--- tests/test_tablename.py | 85 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+), 5 deletions(-) create mode 100644 tests/test_tablename.py diff --git a/sqlmodel/main.py b/sqlmodel/main.py index 300031de8b..fe8e7584e4 100644 --- a/sqlmodel/main.py +++ b/sqlmodel/main.py @@ -41,7 +41,6 @@ from sqlalchemy.orm import ( Mapped, RelationshipProperty, - declared_attr, registry, relationship, ) @@ -566,6 +565,10 @@ def __new__( "__sqlmodel_relationships__": relationships, "__annotations__": pydantic_annotations, } + # Set default __tablename__ before class creation so it's part of the + # class dict and consistent with the ClassVar[str | Callable] declaration. + if "__tablename__" not in class_dict: + dict_used["__tablename__"] = name.lower() # Duplicate logic from Pydantic to filter config kwargs because if they are # passed directly including the registry Pydantic will pass them over to the # superclass causing an error @@ -865,10 +868,6 @@ def __repr_args__(self) -> Sequence[tuple[str | None, Any]]: if not (isinstance(k, str) and k.startswith("_sa_")) ] - @declared_attr # type: ignore - def __tablename__(cls) -> str: - return cls.__name__.lower() - @classmethod def model_validate( # type: ignore[override] cls: type[_TSQLModel], diff --git a/tests/test_tablename.py b/tests/test_tablename.py new file mode 100644 index 0000000000..4aadcccd14 --- /dev/null +++ b/tests/test_tablename.py @@ -0,0 +1,85 @@ +from sqlalchemy import inspect +from sqlmodel import Field, Session, SQLModel, create_engine, select +from sqlmodel.pool import StaticPool + + +def _engine(): + return create_engine( + "sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool + ) + + +def test_default_tablename() -> None: + """table=True models get __tablename__ = classname.lower() by default.""" + + class Gadget(SQLModel, table=True): + id: int | None = Field(default=None, primary_key=True) + + assert Gadget.__tablename__ == "gadget" + + engine = _engine() + SQLModel.metadata.create_all(engine) + assert inspect(engine).has_table("gadget") + + +def test_explicit_tablename() -> None: + """An explicit __tablename__ overrides the default.""" + + class Widget(SQLModel, table=True): + __tablename__ = "custom_widgets" + id: int | None = Field(default=None, primary_key=True) + name: str + + assert Widget.__tablename__ == "custom_widgets" + + engine = _engine() + SQLModel.metadata.create_all(engine) + assert inspect(engine).has_table("custom_widgets") + assert not inspect(engine).has_table("widget") + + with Session(engine) as session: + session.add(Widget(name="sprocket")) + session.commit() + + with Session(engine) as session: + row = session.exec(select(Widget)).first() + assert row is not None + assert row.name == "sprocket" + + +def test_tablename_inheritance_default() -> None: + """A subclass that is also a table gets its own default __tablename__.""" + + class BaseThing(SQLModel, table=True): + id: int | None = Field(default=None, primary_key=True) + kind: str = "base" + + class SubThing(BaseThing, table=True): + extra: str | None = None + + assert BaseThing.__tablename__ == "basething" + assert SubThing.__tablename__ == "subthing" + + +def test_tablename_inheritance_explicit_child() -> None: + """A subclass can set its own __tablename__, visible on the class.""" + + class Vehicle(SQLModel, table=True): + id: int | None = Field(default=None, primary_key=True) + kind: str = "" + + class Truck(Vehicle, table=True): + __tablename__ = "trucks" + payload: int | None = None + + assert Vehicle.__tablename__ == "vehicle" + assert Truck.__tablename__ == "trucks" + + +def test_tablename_default_on_plain_model() -> None: + """Non-table models also get a default __tablename__.""" + + class Schema(SQLModel): + name: str + + assert Schema.__tablename__ == "schema"