Skip to content

Commit 7badcce

Browse files
committed
fix: enhance SQLAlchemy inheritance support with auto-generated table names
1 parent f2d6153 commit 7badcce

File tree

3 files changed

+214
-112
lines changed

3 files changed

+214
-112
lines changed

advanced_alchemy/base.py

Lines changed: 148 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -197,43 +197,128 @@ class CommonTableAttributes(BasicAttributes):
197197
Inherits from :class:`BasicAttributes` and provides a mechanism to infer table names from class names
198198
while respecting SQLAlchemy's inheritance patterns.
199199
200+
This mixin supports all three SQLAlchemy inheritance patterns:
201+
- **Single Table Inheritance (STI)**: Child classes automatically use parent's table
202+
- **Joined Table Inheritance (JTI)**: Child classes have their own tables with foreign keys
203+
- **Concrete Table Inheritance (CTI)**: Child classes have independent tables
204+
200205
Attributes:
201206
__tablename__ (str | None): The inferred table name, or None for Single Table Inheritance children.
202207
"""
203208

209+
def __init_subclass__(cls, **kwargs: Any) -> None:
210+
"""Hook called when a subclass is created.
211+
212+
This method intercepts class creation to correctly handle ``__tablename__`` for
213+
Single Table Inheritance (STI) hierarchies. When a parent class explicitly
214+
defines ``__tablename__``, subclasses would normally inherit that string value.
215+
For STI, child classes must have ``__tablename__`` resolve to ``None`` to indicate
216+
they share the parent's table. This hook enforces that rule.
217+
218+
The detection logic identifies STI children by checking:
219+
1. Class has ``polymorphic_identity`` in ``__mapper_args__`` (explicit STI child marker)
220+
2. AND doesn't have ``concrete=True`` (which would make it CTI)
221+
3. AND doesn't have ``polymorphic_on`` itself (which would make it a base)
222+
4. AND doesn't explicitly define ``__tablename__`` in its own ``__dict__``
223+
224+
For children without ``polymorphic_identity`` but with a parent that has
225+
``polymorphic_on``, SQLAlchemy treats them as abstract intermediate classes
226+
and will issue a warning. We don't modify ``__tablename__`` for these cases.
227+
228+
This allows both usage patterns:
229+
1. Auto-generated names (don't set ``__tablename__`` on parent)
230+
2. Explicit names (set ``__tablename__`` on parent, STI still works)
231+
"""
232+
# IMPORTANT: Modify the class BEFORE calling super().__init_subclass__()
233+
# because super() triggers SQLAlchemy's declarative processing
234+
mapper_args = getattr(cls, "__mapper_args__", {})
235+
236+
# Skip if this class explicitly defines its own __tablename__
237+
if "__tablename__" in cls.__dict__:
238+
super().__init_subclass__(**kwargs)
239+
return
240+
241+
# Skip if this is CTI (concrete table inheritance)
242+
if mapper_args.get("concrete", False):
243+
super().__init_subclass__(**kwargs)
244+
return
245+
246+
# Check if this class might be an STI child
247+
# An STI child either has polymorphic_identity in its own __mapper_args__,
248+
# or inherits from a parent with polymorphic_on
249+
is_potential_sti_child = False
250+
251+
# Check if THIS class (not inherited) defines polymorphic_on
252+
# If it does, it's a base class, not a child
253+
if "__mapper_args__" in cls.__dict__:
254+
own_mapper_args = cls.__dict__["__mapper_args__"]
255+
if "polymorphic_on" in own_mapper_args:
256+
# This is a base class, not a child - skip
257+
super().__init_subclass__(**kwargs)
258+
return
259+
260+
# Check if any parent has polymorphic_on (indicates we're in an STI hierarchy)
261+
for parent in cls.__mro__[1:]:
262+
if not hasattr(parent, "__mapper_args__"):
263+
continue
264+
parent_mapper_args = getattr(parent, "__mapper_args__", {})
265+
if "polymorphic_on" in parent_mapper_args:
266+
# We're inheriting from a polymorphic base, so we're an STI child
267+
is_potential_sti_child = True
268+
break
269+
270+
if is_potential_sti_child and "__tablename__" not in cls.__dict__:
271+
# For STI children that inherited an explicit __tablename__ from a parent,
272+
# we need to explicitly set it to None so SQLAlchemy knows to use the parent's table.
273+
# This overrides the inherited string value.
274+
cls.__tablename__ = None # type: ignore[misc]
275+
276+
# Now call super() which triggers SQLAlchemy's declarative system
277+
super().__init_subclass__(**kwargs)
278+
204279
if TYPE_CHECKING:
205280
__tablename__: Optional[str]
206281
else:
207282

208283
@declared_attr.directive
209284
@classmethod
210285
def __tablename__(cls) -> Optional[str]:
211-
"""Infer table name from class name, respecting inheritance patterns.
212-
213-
IMPORTANT: This function is called for EVERY class in the hierarchy due to
214-
@declared_attr.directive, even if a parent class explicitly sets __tablename__.
286+
"""Generate table name automatically for base models.
215287
216-
This method automatically detects SQLAlchemy inheritance patterns and returns
217-
the appropriate table name or None:
288+
This is called for models that do not have an explicit ``__tablename__``.
289+
For STI child models, ``__init_subclass__`` will have already set
290+
``__tablename__ = None``, so this function returns ``None`` to indicate
291+
the child should use the parent's table.
218292
219-
- **Single Table Inheritance (STI)**: Returns None for child classes to use parent's table
220-
- **Joined Table Inheritance (JTI)**: Returns generated name (child has own table with ForeignKey)
221-
- **Concrete Table Inheritance (CTI)**: Returns generated name (independent table with concrete=True)
222-
223-
The detection logic:
224-
1. If class explicitly defines ``__tablename__`` in its ``__dict__``, use that value
225-
2. Walk the MRO to find parent classes with tables
226-
3. If parent has ``polymorphic_on`` and child doesn't have ``concrete=True``, return None (STI)
227-
4. Otherwise, generate table name from class name
293+
The generation logic:
294+
1. If class explicitly defines ``__tablename__`` in its ``__dict__``, use that
295+
2. Otherwise, generate from class name using snake_case conversion
228296
229297
Returns:
230-
str | None: Table name string, or None for STI children to inherit parent's table.
298+
str | None: Table name generated from class name in snake_case, or None for STI children.
231299
232300
Example:
233-
Single Table Inheritance::
301+
Single Table Inheritance (both patterns work)::
234302
303+
# Pattern 1: Auto-generated table name (recommended)
235304
class Employee(UUIDBase):
236-
__tablename__ = "employee"
305+
# __tablename__ auto-generated as "employee"
306+
type: Mapped[str]
307+
__mapper_args__ = {
308+
"polymorphic_on": "type",
309+
"polymorphic_identity": "employee",
310+
}
311+
312+
313+
class Manager(Employee):
314+
# __tablename__ = None (set by __init_subclass__)
315+
department: Mapped[str | None]
316+
__mapper_args__ = {"polymorphic_identity": "manager"}
317+
318+
319+
# Pattern 2: Explicit table name on parent
320+
class Employee(UUIDBase):
321+
__tablename__ = "custom_employee" # Explicit!
237322
type: Mapped[str]
238323
__mapper_args__ = {
239324
"polymorphic_on": "type",
@@ -242,7 +327,8 @@ class Employee(UUIDBase):
242327
243328
244329
class Manager(Employee):
245-
# No __tablename__ needed - automatically uses "employee" table
330+
# __tablename__ = None (set by __init_subclass__)
331+
# Still uses parent's "custom_employee" table
246332
department: Mapped[str | None]
247333
__mapper_args__ = {"polymorphic_identity": "manager"}
248334
@@ -261,42 +347,51 @@ class Manager(Employee):
261347
)
262348
department: Mapped[str]
263349
__mapper_args__ = {"polymorphic_identity": "manager"}
350+
351+
Concrete Table Inheritance::
352+
353+
class Employee(UUIDBase):
354+
__tablename__ = "employee"
355+
id: Mapped[int] = mapped_column(primary_key=True)
356+
357+
358+
class Manager(Employee):
359+
__tablename__ = "manager" # Independent table
360+
__mapper_args__ = {"concrete": True}
264361
"""
265-
# 1. Check if class explicitly defines __tablename__ in its own __dict__
266-
# This handles JTI and CTI patterns where tablename is explicitly set
362+
# Check if class explicitly defines __tablename__ in its own __dict__
267363
if "__tablename__" in cls.__dict__:
268-
return cls.__dict__["__tablename__"]
269-
270-
# 2. Walk the Method Resolution Order to detect inheritance patterns
271-
for base in cls.__mro__[1:]:
272-
# Skip framework base classes that don't represent actual tables
273-
if base in (
274-
CommonTableAttributes,
275-
BasicAttributes,
276-
AdvancedDeclarativeBase,
277-
DeclarativeBase,
278-
):
279-
continue
280-
281-
# Check if parent class has a table
282-
if hasattr(base, "__tablename__"):
283-
# Get mapper arguments for both parent and current class
284-
parent_mapper_args = getattr(base, "__mapper_args__", {})
285-
cls_mapper_args = getattr(cls, "__mapper_args__", {})
286-
287-
# STI Pattern Detection:
288-
# Parent has polymorphic_on (discriminator column) and
289-
# child doesn't explicitly set concrete=True
290-
if parent_mapper_args.get("polymorphic_on") is not None and not cls_mapper_args.get(
291-
"concrete", False
292-
):
293-
# This is STI - child uses parent's table
294-
return None
295-
296-
# JTI Pattern: Child explicitly sets __tablename__ (handled in step 1)
297-
# CTI Pattern: Child sets concrete=True and __tablename__ (handled above)
298-
299-
# 3. Default: No inheritance detected or first in hierarchy
364+
value = cls.__dict__["__tablename__"]
365+
# If explicitly set to None (e.g., by __init_subclass__ for STI), return None
366+
if value is None:
367+
return None
368+
return value
369+
370+
# Check if this is an STI child class that needs auto-detection
371+
# This handles cases where the parent didn't explicitly set __tablename__
372+
mapper_args = getattr(cls, "__mapper_args__", {})
373+
374+
# Skip STI detection if this class defines polymorphic_on (it's a base, not a child)
375+
if "polymorphic_on" not in mapper_args:
376+
is_sti_child = False
377+
378+
# Check explicit STI marker
379+
if "polymorphic_identity" in mapper_args:
380+
is_sti_child = True
381+
else:
382+
# Check if any parent has polymorphic_on (indicates STI hierarchy)
383+
for parent in cls.__mro__[1:]:
384+
if not hasattr(parent, "__mapper_args__"):
385+
continue
386+
parent_mapper_args = getattr(parent, "__mapper_args__", {})
387+
if "polymorphic_on" in parent_mapper_args:
388+
is_sti_child = True
389+
break
390+
391+
if is_sti_child:
392+
# This is an STI child - return None to use parent's table
393+
return None
394+
300395
# Generate table name from class name using snake_case conversion
301396
return table_name_regexp.sub(r"_\1", cls.__name__).lower()
302397

0 commit comments

Comments
 (0)