Skip to content

Commit 24d66f7

Browse files
estarfooclaude
andcommitted
🐛 Move __tablename__ default from @declared_attr to metaclass
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) <noreply@anthropic.com>
1 parent 530c268 commit 24d66f7

File tree

2 files changed

+89
-5
lines changed

2 files changed

+89
-5
lines changed

sqlmodel/main.py

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,6 @@
4141
from sqlalchemy.orm import (
4242
Mapped,
4343
RelationshipProperty,
44-
declared_attr,
4544
registry,
4645
relationship,
4746
)
@@ -566,6 +565,10 @@ def __new__(
566565
"__sqlmodel_relationships__": relationships,
567566
"__annotations__": pydantic_annotations,
568567
}
568+
# Set default __tablename__ before class creation so it's part of the
569+
# class dict and consistent with the ClassVar[str | Callable] declaration.
570+
if "__tablename__" not in class_dict:
571+
dict_used["__tablename__"] = name.lower()
569572
# Duplicate logic from Pydantic to filter config kwargs because if they are
570573
# passed directly including the registry Pydantic will pass them over to the
571574
# superclass causing an error
@@ -865,10 +868,6 @@ def __repr_args__(self) -> Sequence[tuple[str | None, Any]]:
865868
if not (isinstance(k, str) and k.startswith("_sa_"))
866869
]
867870

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

tests/test_tablename.py

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

0 commit comments

Comments
 (0)