Skip to content

Commit 415c8fc

Browse files
estarfooclaude
andcommitted
🐛 Declare __tablename__ on SQLModelMetaclass
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) <noreply@anthropic.com>
1 parent 60c122d commit 415c8fc

2 files changed

Lines changed: 111 additions & 6 deletions

File tree

sqlmodel/main.py

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,6 @@
4040
from sqlalchemy.orm import (
4141
Mapped,
4242
RelationshipProperty,
43-
declared_attr,
4443
registry,
4544
relationship,
4645
)
@@ -519,6 +518,7 @@ def Relationship(
519518

520519
@__dataclass_transform__(kw_only_default=True, field_descriptors=(Field, FieldInfo))
521520
class SQLModelMetaclass(ModelMetaclass, DeclarativeMeta):
521+
__tablename__: str
522522
__sqlmodel_relationships__: dict[str, RelationshipInfo]
523523
model_config: SQLModelConfig
524524
model_fields: ClassVar[dict[str, FieldInfo]]
@@ -565,6 +565,10 @@ def __new__(
565565
"__sqlmodel_relationships__": relationships,
566566
"__annotations__": pydantic_annotations,
567567
}
568+
# Set default __tablename__ before class creation so it's part of the
569+
# class dict, unless the user supplied one.
570+
if "__tablename__" not in class_dict:
571+
dict_used["__tablename__"] = name.lower()
568572
# Duplicate logic from Pydantic to filter config kwargs because if they are
569573
# passed directly including the registry Pydantic will pass them over to the
570574
# superclass causing an error
@@ -804,7 +808,6 @@ def get_column_from_field(field: Any) -> Column:
804808
class SQLModel(BaseModel, metaclass=SQLModelMetaclass, registry=default_registry):
805809
# SQLAlchemy needs to set weakref(s), Pydantic will set the other slots values
806810
__slots__ = ("__weakref__",)
807-
__tablename__: ClassVar[str | Callable[..., str]]
808811
__sqlmodel_relationships__: ClassVar[builtins.dict[str, RelationshipInfo]]
809812
__name__: ClassVar[str]
810813
metadata: ClassVar[MetaData]
@@ -864,10 +867,6 @@ def __repr_args__(self) -> Sequence[tuple[str | None, Any]]:
864867
if not (isinstance(k, str) and k.startswith("_sa_"))
865868
]
866869

867-
@declared_attr # type: ignore
868-
def __tablename__(cls) -> str:
869-
return cls.__name__.lower()
870-
871870
@classmethod
872871
def model_validate( # ty: ignore[invalid-method-override]
873872
cls: type[_TSQLModel],

tests/test_tablename.py

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
from pydantic.alias_generators import to_snake
2+
from sqlalchemy import inspect
3+
from sqlalchemy.orm import declared_attr
4+
from sqlmodel import Field, Session, SQLModel, create_engine, select
5+
from sqlmodel.pool import StaticPool
6+
7+
8+
def _engine():
9+
return create_engine(
10+
"sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool
11+
)
12+
13+
14+
def test_default_tablename() -> None:
15+
"""table=True models get __tablename__ = classname.lower() by default."""
16+
17+
class Gadget(SQLModel, table=True):
18+
id: int | None = Field(default=None, primary_key=True)
19+
20+
assert Gadget.__tablename__ == "gadget"
21+
22+
engine = _engine()
23+
SQLModel.metadata.create_all(engine)
24+
assert inspect(engine).has_table("gadget")
25+
26+
27+
def test_explicit_tablename() -> None:
28+
"""An explicit __tablename__ overrides the default."""
29+
30+
class Widget(SQLModel, table=True):
31+
__tablename__ = "custom_widgets"
32+
id: int | None = Field(default=None, primary_key=True)
33+
name: str
34+
35+
assert Widget.__tablename__ == "custom_widgets"
36+
37+
engine = _engine()
38+
SQLModel.metadata.create_all(engine)
39+
assert inspect(engine).has_table("custom_widgets")
40+
assert not inspect(engine).has_table("widget")
41+
42+
with Session(engine) as session:
43+
session.add(Widget(name="sprocket"))
44+
session.commit()
45+
46+
with Session(engine) as session:
47+
row = session.exec(select(Widget)).first()
48+
assert row is not None
49+
assert row.name == "sprocket"
50+
51+
52+
def test_tablename_inheritance_default() -> None:
53+
"""A subclass that is also a table gets its own default __tablename__."""
54+
55+
class BaseThing(SQLModel, table=True):
56+
id: int | None = Field(default=None, primary_key=True)
57+
kind: str = "base"
58+
59+
class SubThing(BaseThing, table=True):
60+
extra: str | None = None
61+
62+
assert BaseThing.__tablename__ == "basething"
63+
assert SubThing.__tablename__ == "subthing"
64+
65+
66+
def test_tablename_inheritance_explicit_child() -> None:
67+
"""A subclass can set its own __tablename__, visible on the class."""
68+
69+
class Vehicle(SQLModel, table=True):
70+
id: int | None = Field(default=None, primary_key=True)
71+
kind: str = ""
72+
73+
class Truck(Vehicle, table=True):
74+
__tablename__ = "trucks"
75+
payload: int | None = None
76+
77+
assert Vehicle.__tablename__ == "vehicle"
78+
assert Truck.__tablename__ == "trucks"
79+
80+
81+
def test_tablename_default_on_plain_model() -> None:
82+
"""Non-table models also get a default __tablename__."""
83+
84+
class Schema(SQLModel):
85+
name: str
86+
87+
assert Schema.__tablename__ == "schema"
88+
89+
90+
def test_tablename_declared_attr_directive() -> None:
91+
"""A dynamically computed __tablename__ via @declared_attr.directive works."""
92+
93+
class FirstWidget(SQLModel, table=True):
94+
id: int | None = Field(default=None, primary_key=True)
95+
name: str
96+
97+
@declared_attr.directive
98+
@classmethod
99+
def __tablename__(cls) -> str:
100+
return to_snake(cls.__name__)
101+
102+
assert FirstWidget.__tablename__ == "first_widget"
103+
104+
engine = _engine()
105+
SQLModel.metadata.create_all(engine)
106+
assert inspect(engine).has_table("first_widget")

0 commit comments

Comments
 (0)