@@ -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