From 415c8fc72718d7c3fad21fc12dd167563a3a2d02 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=20Declare=20`=5F=5Ftablename=5F=5F?= =?UTF-8?q?`=20on=20`SQLModelMetaclass`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `SQLModel` base declared `__tablename__` both as `ClassVar[str | Callable[..., str]]` and as a `@declared_attr` method. Type checkers (pyright) see the descriptor type from `@declared_attr`, so `__tablename__ = "my_table"` in a subclass is rejected as a type mismatch even though it works at runtime; and the `ClassVar` on the base both leaks into the constructor signature and conflicts with a descriptor override such as `@declared_attr.directive`. Move the declaration to `SQLModelMetaclass` as `__tablename__: str`, setting the default in `SQLModelMetaclass.__new__` (in the class dict, before class creation) unless the user supplied one. Because the attribute now lives on the metaclass: - explicit names (`__tablename__ = "my_table"`) narrow to `str` rather than the `str | Callable` union, so reads are usable without casts; - it is not collected as a model field, so it never appears in the constructor; - a dynamically computed name via `@declared_attr.directive` type-checks in pyright basic mode, leaving only a single reportIncompatibleVariableOverride in standard/strict mode, inherent to overriding a class attribute with a descriptor. Tests added for default name, explicit override, inheritance, non-table models, and the `@declared_attr.directive` form. Fixes #98. Co-Authored-By: Claude Opus 4.8 (1M context) --- sqlmodel/main.py | 11 ++--- tests/test_tablename.py | 106 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 111 insertions(+), 6 deletions(-) create mode 100644 tests/test_tablename.py diff --git a/sqlmodel/main.py b/sqlmodel/main.py index c551afea36..6f2171aa5d 100644 --- a/sqlmodel/main.py +++ b/sqlmodel/main.py @@ -40,7 +40,6 @@ from sqlalchemy.orm import ( Mapped, RelationshipProperty, - declared_attr, registry, relationship, ) @@ -519,6 +518,7 @@ def Relationship( @__dataclass_transform__(kw_only_default=True, field_descriptors=(Field, FieldInfo)) class SQLModelMetaclass(ModelMetaclass, DeclarativeMeta): + __tablename__: str __sqlmodel_relationships__: dict[str, RelationshipInfo] model_config: SQLModelConfig model_fields: ClassVar[dict[str, FieldInfo]] @@ -565,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, unless the user supplied one. + 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 @@ -804,7 +808,6 @@ def get_column_from_field(field: Any) -> Column: class SQLModel(BaseModel, metaclass=SQLModelMetaclass, registry=default_registry): # SQLAlchemy needs to set weakref(s), Pydantic will set the other slots values __slots__ = ("__weakref__",) - __tablename__: ClassVar[str | Callable[..., str]] __sqlmodel_relationships__: ClassVar[builtins.dict[str, RelationshipInfo]] __name__: ClassVar[str] metadata: ClassVar[MetaData] @@ -864,10 +867,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( # ty: ignore[invalid-method-override] cls: type[_TSQLModel], diff --git a/tests/test_tablename.py b/tests/test_tablename.py new file mode 100644 index 0000000000..4a7fce8aaf --- /dev/null +++ b/tests/test_tablename.py @@ -0,0 +1,106 @@ +from pydantic.alias_generators import to_snake +from sqlalchemy import inspect +from sqlalchemy.orm import declared_attr +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" + + +def test_tablename_declared_attr_directive() -> None: + """A dynamically computed __tablename__ via @declared_attr.directive works.""" + + class FirstWidget(SQLModel, table=True): + id: int | None = Field(default=None, primary_key=True) + name: str + + @declared_attr.directive + @classmethod + def __tablename__(cls) -> str: + return to_snake(cls.__name__) + + assert FirstWidget.__tablename__ == "first_widget" + + engine = _engine() + SQLModel.metadata.create_all(engine) + assert inspect(engine).has_table("first_widget")