Overriding models¶
You change a module's data model in two places: the table (columns your app stores) and the schemas (what its endpoints accept and return). This guide shows both for the account and workspace modules, and states where the current code does not let you override something.
Which tables the modules ship¶
| Module | Exported from models |
Concrete table | Table name |
|---|---|---|---|
| account | AccountBase, ProfileBase and their schemas |
msflib.account.models.account.Account and Profile, not exported by msflib.account.models |
account, profile |
| workspaces | WorkspaceBase, UserBase and their schemas |
msflib.workspaces.models.workspace.Workspace, msflib.workspaces.models.user.User |
workspace, workspace_user |
| tenancy | msflib.tenancy.models.tenant.Tenant |
tenant |
The account module is built so that your app owns the Account table: the router and action factories take account_type= and profile_type= and work with whatever class you give them. That is the pattern to follow for every table you want to extend: define your own table class from the module's *Base class, and pass it to the factories.
Do not import the module's concrete table when you define your own
Both classes map to the same table name in the shared SQLModel.metadata. Importing the module's Account and defining yours raises InvalidRequestError: Table 'account' is already defined. For workspaces, redefining the table with extend_existing appears to work, but create_all then fails with index ix_workspace_name already exists. Import only the *Base classes.
Subclassing the concrete class (class MyWorkspace(Workspace, table=True)) raises ValueError and is not supported (tracked with the workspaces limitations in #271; see the notice under Add columns to the workspace).
Add columns to the account¶
Define Account and Profile from the bases and add fields. The relationship between them must be declared on both sides, as below.
from typing import Optional
from msflib.account.models import AccountBase, ProfileBase
from sqlmodel import Field, Relationship
class Account(AccountBase, table=True):
nickname: str | None = Field(default=None, max_length=64)
profile: Optional["Profile"] = Relationship(
sa_relationship_kwargs={"uselist": False}, back_populates="account"
)
class Profile(ProfileBase, table=True):
certifications: str | None = Field(default=None)
account: Account | None = Relationship(back_populates="profile")
This is the shape of the reference host app's testsite/app/models/account.py. Remember that the new columns need a migration on an existing database.
Request and response schemas¶
A new column is stored once it is on the table, but the module's endpoints only know the schemas you hand them. Extend the module schemas and pass them to the factory:
from msflib.account.actions import AccountAction
from msflib.account.models import AccountCreate, AccountRead, AccountUpdate
from msflib.account.router import account_router
class AccountCreateWithNickname(AccountCreate):
nickname: str | None = None
class AccountReadWithNickname(AccountRead):
nickname: str | None = None
aa = AccountAction[Account, AccountCreateWithNickname, AccountUpdate](
settings=settings, profile_action=pa
)
router = account_router(
get_session=get_session,
get_current_account=auth_deps.get_current_account,
settings=settings,
account_type=Account,
profile_type=Profile,
account_read_type=AccountReadWithNickname,
account_create_type=AccountCreateWithNickname,
account_action=aa,
profile_action=pa,
prefix="/accounts",
)
With this, POST /accounts/open accepts nickname and stores it, and every response that returns an account includes it. ModelAction.create copies from the schema only the fields that exist on the table, so the extra input reaches the new column without any further code.
account_read_type is also accepted by the auth router, so the login response shows the same shape.
What the account router does not let you override¶
The account update endpoints (PUT /me and the admin PUT /{account_id}) validate against the module's AccountUpdate and the router has no account_update_type argument. A nickname sent to PUT /me is accepted with 200 and silently ignored. Add your own endpoint for the new field.
Known issue (#272)
There is no account_update_type argument, so extra fields sent to PUT /me and to the admin PUT /{account_id} are dropped. Use your own endpoint, as below, until it is added.
A route registered on its own router and included next to the module's router does not collide with it:
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from sqlmodel import Session
extra = APIRouter()
class NicknameUpdate(BaseModel):
nickname: str | None = None
@extra.put("/me/nickname", response_model=AccountReadWithNickname)
def set_nickname(
data: NicknameUpdate,
session: Session = Depends(get_session),
account=Depends(auth_deps.get_current_active_account),
):
return aa.update(session, model=account, update=data.model_dump(exclude_unset=True))
app.include_router(extra, prefix="/api/v1/accounts")
The profile router is more flexible: profile_router takes profile_create_type, profile_update_type and profile_read_type.
Add columns to the workspace¶
The workspaces module follows the same rule, with two extra tables to consider. WorkspaceBase has foreign keys to account.id and tenant.id, and UserBase (the membership row) has foreign keys to account.id and workspace.id. So your metadata must contain an account table, a tenant table and a workspace table, and the membership table should be named workspace_user, because other tables refer to it by that name (the workspace_notifications receiver column and the optional user-info table both have a foreign key to workspace_user.id).
from msflib.tenancy.models.tenant import Tenant # registers the tenant table
from msflib.workspaces.models import UserBase, WorkspaceBase
from sqlmodel import Field
class Workspace(WorkspaceBase, table=True):
region: str = Field(default="global", index=True)
class WorkspaceMember(UserBase, table=True):
__tablename__ = "workspace_user"
Create the actions with your classes and pass them to the router and the dependencies. Your composed Settings class needs WorkspaceSettings and TenancySettings (from msflib.workspaces.config and msflib.tenancy.config) among its bases.
from msflib.workspaces.actions import UserAction, WorkspaceAction
from msflib.workspaces.models import UserCreate, UserUpdate, WorkspaceCreate, WorkspaceUpdate
from msflib.workspaces.router import router as create_workspace_router
wa = WorkspaceAction[Workspace, WorkspaceCreate, WorkspaceUpdate](settings=settings)
ua = UserAction[WorkspaceMember, UserCreate, UserUpdate]()
workspaces_router = create_workspace_router(
get_session=get_session,
get_current_account=auth_deps.get_current_account,
get_current_account_or_none=auth_deps.get_current_account_or_none,
role_check=auth_deps.RoleCheck,
settings=settings,
account_type=Account,
workspace_type=Workspace,
user_type=WorkspaceMember,
workspace_action=wa,
user_action=ua,
prefix="/workspaces",
)
Seed the default tenant at startup, or pass get_current_tenant= to the router. Without either, POST /workspaces/ answers 500 with Default tenant has not been seeded. For example, at startup:
from msflib.tenancy.resolver import resolve_default_tenant_id
with Session(engine) as session:
resolve_default_tenant_id(session, settings=settings.scope("TENANCY"))
session.commit()
Setting the new column when you create a workspace in code goes through the update argument, which overlays values on the payload:
from msflib.tenancy.resolver import resolve_default_tenant_id
tenant = session.get(Tenant, resolve_default_tenant_id(session, settings=settings.scope("TENANCY")))
workspace = wa.create_with_owner(
session,
data=WorkspaceCreate(name="Acme", description="Acme Inc", owner_id=owner.id, status="open"),
owner=owner,
tenant=tenant,
update={"region": "eu"},
)
Known issue (#271)
The workspace router returns the module's WorkspaceRead. The workspace router's responses are fixed to WorkspaceRead, and it has no workspace_read_type argument. A new column such as region is stored and available on the model (for example to the workspace dependencies in Authentication), but it is not in the module router's JSON. Both WorkspaceBase and WorkspaceRead carry a free-form data JSON field (and settings), which you can use for extra attributes that must round-trip through the module's endpoints without a new column. To return a typed column, add your own endpoint with your own response model.
workspace_create_type and workspace_update_type are accepted by the router if you need extra input fields on those endpoints.
Attach relationships later¶
Relationships to your own tables do not have to be declared inside the class. ModelBase.add_relationship attaches one after both classes exist, which is useful when the target is defined in a module that imports the owner.
Account.add_relationship("notes", Note, lazy="selectin")
Note must have a foreign key to account.id. The helper wraps SQLAlchemy's Mapper.add_property, requires both classes to be table=True models, and raises TypeError otherwise. It adds relationships only, not columns.
Checklist when you override a table¶
- Define the class from the
*Baseand do not import the module's concrete class. - Keep the table name that foreign keys expect:
account,profile,workspace,workspace_user,tenant. - Pass your class to every factory that takes
account_type,workspace_type,user_typeorprofile_type, and as the first generic argument of the action. - Pass extended schemas where the factory accepts them, and check the factory signature for the ones it does not.
- Write a migration for the new columns.
create_alldoes not alter existing tables. - If your app uses
msflib.workspace_notifications, see the known interaction on How to extend MSFLib.