Define JTI (joined table inheritance) models
Goal: model a "notification system" as one abstract base class Notification plus several concrete subclasses (EmailNotification, PushNotification), each with its own fields and database table linked by a foreign key to the parent.
When to choose JTI:
- Subclasses have many distinct fields (5+ exclusive fields each)
- You want each sub-table to be compact, without lots of NULL columns
- You can accept the JOIN overhead
If subclasses only add 1–2 fields, STI is a better fit.
1. Define the abstract base class
from abc import ABC, abstractmethod
from sqlmodel_ext import (
SQLModelBase, UUIDTableBaseMixin,
PolymorphicBaseMixin, AutoPolymorphicIdentityMixin,
create_subclass_id_mixin,
Str64,
)
# 1. Base class: pure fields, no table
class NotificationBase(SQLModelBase):
user_id: UUID = Field(foreign_key='user.id')
message: Str64
# 2. Abstract parent: table + abstract method
class Notification(
NotificationBase,
UUIDTableBaseMixin,
PolymorphicBaseMixin,
ABC,
):
@abstractmethod
async def deliver(self) -> None: ...PolymorphicBaseMixin automatically adds the _polymorphic_name discriminator column. ABC + abstract methods automatically enable polymorphic_abstract=True — abstract classes cannot be instantiated.
2. Create the subclass FK mixin
NotificationSubclassIdMixin = create_subclass_id_mixin('notification') This dynamically generated mixin provides id: UUID = Field(primary_key=True, foreign_key='notification.id') — i.e. the subclass's primary key is also the parent's foreign key. That's the heart of JTI.
3. Define concrete subclasses
class EmailNotification(
NotificationSubclassIdMixin, # ← must be first
Notification,
AutoPolymorphicIdentityMixin,
table=True,
):
email_to: Str64
subject: Str64
async def deliver(self) -> None:
await send_email(self.email_to, self.subject, self.message)
class PushNotification(
NotificationSubclassIdMixin, # ← must be first
Notification,
AutoPolymorphicIdentityMixin,
table=True,
):
device_token: Str64
async def deliver(self) -> None:
await send_push(self.device_token, self.message)MRO order
NotificationSubclassIdMixin must be first in the inheritance list. Reason: its id field (with FK to the parent table) needs to override UUIDTableBaseMixin's id (plain primary key). Wrong MRO order → no foreign key in the sub-table → JTI broken.
AutoPolymorphicIdentityMixin automatically uses the lowercased class name as the identity, so EmailNotification.__mapper_args__['polymorphic_identity'] == 'emailnotification'.
4. Querying: subclass instances returned automatically
notifications = await Notification.get(session, fetch_mode='all')
# notifications[0] might be an EmailNotification instance
# notifications[1] might be a PushNotification instance
for n in notifications:
await n.deliver() # Polymorphic dispatch, no isinstance checks neededget() automatically uses with_polymorphic(cls, '*') to JOIN every sub-table, avoiding N+1 queries.
5. Querying by subclass
emails = await EmailNotification.get(session, fetch_mode='all')
# Only returns rows from the emailnotification tableVerifying the schema
After migration the database has 3 tables:
notification -- Parent (id, user_id, message, _polymorphic_name, created_at, updated_at)
emailnotification -- Child (id PK FK→notification.id, email_to, subject)
pushnotification -- Child (id PK FK→notification.id, device_token)Each EmailNotification occupies one row in both notification and emailnotification.