|
| 1 | +--- |
| 2 | +name: fastsqla-models |
| 3 | +description: > |
| 4 | + How to define ORM models with FastSQLA. Covers fastsqla.Base |
| 5 | + (DeclarativeBase + DeferredReflection), fully declared models, reflected |
| 6 | + models, Pydantic response models, SQLModel integration, and |
| 7 | + extend_existing. Includes a comparison table of Base vs SQLModel tradeoffs. |
| 8 | +--- |
| 9 | + |
| 10 | +# FastSQLA Models |
| 11 | + |
| 12 | +FastSQLA provides `Base`, a pre-configured SQLAlchemy declarative base that |
| 13 | +combines `DeclarativeBase` with `DeferredReflection`. Alternatively, you can |
| 14 | +use SQLModel for models that are both ORM and Pydantic. |
| 15 | + |
| 16 | +## `fastsqla.Base` |
| 17 | + |
| 18 | +```python |
| 19 | +from sqlalchemy.ext.asyncio import AsyncSession |
| 20 | +from sqlalchemy.ext.declarative import DeferredReflection |
| 21 | +from sqlalchemy.orm import DeclarativeBase |
| 22 | + |
| 23 | + |
| 24 | +class Base(DeclarativeBase, DeferredReflection): |
| 25 | + __abstract__ = True |
| 26 | +``` |
| 27 | + |
| 28 | +Because `Base` inherits from `DeferredReflection`, table metadata is **not** |
| 29 | +loaded at import time. Instead, `Base.prepare()` is called inside FastSQLA's |
| 30 | +lifespan during app startup, which reflects all tables from the database in a |
| 31 | +single pass. |
| 32 | + |
| 33 | +This means model attributes (columns, relationships) are **not available** |
| 34 | +until the app lifespan has started. |
| 35 | + |
| 36 | +## Fully Declared Models |
| 37 | + |
| 38 | +Standard SQLAlchemy 2.0 declarative mapping. You define every column |
| 39 | +explicitly using `Mapped` and `mapped_column`: |
| 40 | + |
| 41 | +```python |
| 42 | +from fastsqla import Base |
| 43 | +from sqlalchemy.orm import Mapped, mapped_column |
| 44 | + |
| 45 | + |
| 46 | +class Hero(Base): |
| 47 | + __tablename__ = "hero" |
| 48 | + |
| 49 | + id: Mapped[int] = mapped_column(primary_key=True) |
| 50 | + name: Mapped[str] = mapped_column(unique=True) |
| 51 | + secret_identity: Mapped[str] |
| 52 | + age: Mapped[int] |
| 53 | +``` |
| 54 | + |
| 55 | +Columns are fully typed and available for IDE autocompletion even before |
| 56 | +`Base.prepare()` runs. The `DeferredReflection` base still applies — the |
| 57 | +actual table binding happens at startup. |
| 58 | + |
| 59 | +### Relationships |
| 60 | + |
| 61 | +Declare relationships the standard SQLAlchemy way: |
| 62 | + |
| 63 | +```python |
| 64 | +from sqlalchemy import ForeignKey, String |
| 65 | +from sqlalchemy.orm import Mapped, mapped_column, relationship |
| 66 | + |
| 67 | +from fastsqla import Base |
| 68 | + |
| 69 | + |
| 70 | +class Team(Base): |
| 71 | + __tablename__ = "team" |
| 72 | + |
| 73 | + id: Mapped[int] = mapped_column(primary_key=True) |
| 74 | + name: Mapped[str] = mapped_column(String, unique=True) |
| 75 | + heroes: Mapped[list["Hero"]] = relationship(back_populates="team") |
| 76 | + |
| 77 | + |
| 78 | +class Hero(Base): |
| 79 | + __tablename__ = "hero" |
| 80 | + |
| 81 | + id: Mapped[int] = mapped_column(primary_key=True) |
| 82 | + name: Mapped[str] = mapped_column(unique=True) |
| 83 | + team_id: Mapped[int] = mapped_column(ForeignKey("team.id")) |
| 84 | + team: Mapped["Team"] = relationship(back_populates="heroes") |
| 85 | +``` |
| 86 | + |
| 87 | +## Reflected Models (DeferredReflection) |
| 88 | + |
| 89 | +For tables that already exist in the database, declare only the table name. |
| 90 | +All columns are auto-reflected from the database schema when `Base.prepare()` |
| 91 | +runs at startup: |
| 92 | + |
| 93 | +```python |
| 94 | +from fastsqla import Base |
| 95 | + |
| 96 | + |
| 97 | +class User(Base): |
| 98 | + __tablename__ = "user" |
| 99 | +``` |
| 100 | + |
| 101 | +After the lifespan starts, `User` has all columns from the `user` table as |
| 102 | +attributes (e.g. `User.id`, `User.email`). |
| 103 | + |
| 104 | +**Important:** Reflected model attributes are **not available** until |
| 105 | +`Base.prepare()` has been called. Do not access column attributes at module |
| 106 | +level or during import. |
| 107 | + |
| 108 | +You can mix reflected and fully declared models freely — both inherit from the |
| 109 | +same `Base`. |
| 110 | + |
| 111 | +## Pydantic Response Models |
| 112 | + |
| 113 | +`fastsqla.Base` models are **not** Pydantic models. To use them as FastAPI |
| 114 | +response types, create a separate Pydantic `BaseModel` with |
| 115 | +`ConfigDict(from_attributes=True)`: |
| 116 | + |
| 117 | +```python |
| 118 | +from fastsqla import Base |
| 119 | +from pydantic import BaseModel, ConfigDict |
| 120 | +from sqlalchemy.orm import Mapped, mapped_column |
| 121 | + |
| 122 | + |
| 123 | +class Hero(Base): |
| 124 | + __tablename__ = "hero" |
| 125 | + |
| 126 | + id: Mapped[int] = mapped_column(primary_key=True) |
| 127 | + name: Mapped[str] = mapped_column(unique=True) |
| 128 | + secret_identity: Mapped[str] |
| 129 | + age: Mapped[int] |
| 130 | + |
| 131 | + |
| 132 | +class HeroResponse(BaseModel): |
| 133 | + model_config = ConfigDict(from_attributes=True) |
| 134 | + |
| 135 | + id: int |
| 136 | + name: str |
| 137 | + secret_identity: str |
| 138 | + age: int |
| 139 | +``` |
| 140 | + |
| 141 | +Then use `HeroResponse` in your endpoint: |
| 142 | + |
| 143 | +```python |
| 144 | +from fastapi import FastAPI |
| 145 | +from fastsqla import Item, Session |
| 146 | +from sqlalchemy import select |
| 147 | + |
| 148 | +app = FastAPI() |
| 149 | + |
| 150 | + |
| 151 | +@app.get("/heroes/{hero_id}") |
| 152 | +async def get_hero(hero_id: int, session: Session) -> Item[HeroResponse]: |
| 153 | + hero = await session.get(Hero, hero_id) |
| 154 | + return {"data": hero} |
| 155 | +``` |
| 156 | + |
| 157 | +FastAPI automatically converts the ORM instance to `HeroResponse` via |
| 158 | +`from_attributes`. |
| 159 | + |
| 160 | +## SQLModel Alternative |
| 161 | + |
| 162 | +Install with the `sqlmodel` extra: |
| 163 | + |
| 164 | +```bash |
| 165 | +pip install FastSQLA[sqlmodel] |
| 166 | +``` |
| 167 | + |
| 168 | +SQLModel models are both SQLAlchemy ORM models **and** Pydantic models. No |
| 169 | +separate response model is needed: |
| 170 | + |
| 171 | +```python |
| 172 | +from sqlmodel import Field, SQLModel |
| 173 | + |
| 174 | + |
| 175 | +class Hero(SQLModel, table=True): |
| 176 | + id: int | None = Field(default=None, primary_key=True) |
| 177 | + name: str = Field(unique=True) |
| 178 | + secret_identity: str |
| 179 | + age: int |
| 180 | +``` |
| 181 | + |
| 182 | +Use directly in endpoints: |
| 183 | + |
| 184 | +```python |
| 185 | +from fastapi import FastAPI |
| 186 | +from fastsqla import Item, Session |
| 187 | +from sqlmodel import select |
| 188 | + |
| 189 | +app = FastAPI() |
| 190 | + |
| 191 | + |
| 192 | +@app.get("/heroes/{hero_id}") |
| 193 | +async def get_hero(hero_id: int, session: Session) -> Item[Hero]: |
| 194 | + hero = (await session.execute(select(Hero).where(Hero.id == hero_id))).scalar_one() |
| 195 | + return {"data": hero} |
| 196 | +``` |
| 197 | + |
| 198 | +### Session Swap |
| 199 | + |
| 200 | +When SQLModel is installed, FastSQLA silently uses |
| 201 | +`sqlmodel.ext.asyncio.session.AsyncSession` instead of SQLAlchemy's |
| 202 | +`AsyncSession`. This happens automatically — no configuration needed: |
| 203 | + |
| 204 | +```python |
| 205 | +# src/fastsqla.py lines 23-27 |
| 206 | +try: |
| 207 | + from sqlmodel.ext.asyncio.session import AsyncSession |
| 208 | +except ImportError: |
| 209 | + pass |
| 210 | +``` |
| 211 | + |
| 212 | +The `Session` dependency and all internal session handling use whichever |
| 213 | +`AsyncSession` was imported. |
| 214 | + |
| 215 | +### `extend_existing` |
| 216 | + |
| 217 | +When using SQLModel, if another part of your code (or a library) has already |
| 218 | +registered a table with the same name in SQLAlchemy's metadata, add |
| 219 | +`extend_existing`: |
| 220 | + |
| 221 | +```python |
| 222 | +from sqlmodel import Field, SQLModel |
| 223 | + |
| 224 | + |
| 225 | +class Hero(SQLModel, table=True): |
| 226 | + __table_args__ = {"extend_existing": True} |
| 227 | + |
| 228 | + id: int | None = Field(default=None, primary_key=True) |
| 229 | + name: str = Field(unique=True) |
| 230 | + secret_identity: str |
| 231 | + age: int |
| 232 | +``` |
| 233 | + |
| 234 | +This tells SQLAlchemy to merge the model definition with the existing table |
| 235 | +metadata instead of raising an error. |
| 236 | + |
| 237 | +### SQLModel Does Not Use `fastsqla.Base` |
| 238 | + |
| 239 | +SQLModel models inherit from `SQLModel`, not from `fastsqla.Base`. They do |
| 240 | +**not** participate in `DeferredReflection` and do **not** support column |
| 241 | +reflection. All columns must be explicitly declared. |
| 242 | + |
| 243 | +## Base vs SQLModel Comparison |
| 244 | + |
| 245 | +| Feature | `fastsqla.Base` | SQLModel | |
| 246 | +|---|---|---| |
| 247 | +| Column reflection | Yes — declare only `__tablename__`, columns auto-reflected | No — all columns must be declared | |
| 248 | +| Separate Pydantic model needed | Yes — `BaseModel` with `from_attributes=True` | No — model is both ORM and Pydantic | |
| 249 | +| Validation on assignment | No | Yes — Pydantic validation on fields | |
| 250 | +| Dependencies | SQLAlchemy only | SQLAlchemy + SQLModel + Pydantic | |
| 251 | +| Relationships | Standard SQLAlchemy `relationship()` | SQLModel `Relationship()` (wraps SQLAlchemy) | |
| 252 | +| DeferredReflection | Yes — `Base.prepare()` at startup | No — tables registered at import time | |
| 253 | +| Install | `pip install FastSQLA` | `pip install FastSQLA[sqlmodel]` | |
| 254 | + |
| 255 | +## Quick Reference |
| 256 | + |
| 257 | +| What you need | What to use | |
| 258 | +|---|---| |
| 259 | +| Fully declared model with explicit columns | `class MyModel(Base)` with `Mapped` + `mapped_column` | |
| 260 | +| Reflected model from existing table | `class MyModel(Base)` with only `__tablename__` | |
| 261 | +| Response model for Base | Separate `BaseModel` with `ConfigDict(from_attributes=True)` | |
| 262 | +| Combined ORM + Pydantic model | `class MyModel(SQLModel, table=True)` | |
| 263 | +| Table already in metadata (SQLModel) | Add `__table_args__ = {"extend_existing": True}` | |
0 commit comments