Migrate
Alright, now that we are familiar with SQLAlchemy, the logical next step is keeping your database up to date. And that's the point where we should talk about migrating your database. Of course, we do already provide an extension for that as well.
Working with Migrate
Migrate is a CLI-only extension for WebFluid that provides single- and multi-db
Alembic templates, enrolls
the matching one based on your app configuration and manages your migrations for
each of your projects' apps. In general, it is a wrapper for alembic commands with
improved compatibility for WebFluid.
Anyway, since your main.py file is not executed when working with Migrate, it is
time to outsource our config class into the apps' config.py file.
Migrate uses the frameworks' magic we talked about in
Config classes to parse the config
and evaluate which template to use when it gets initialized:
from webfluid.core.config import register_config
import os
@register_config(10)
class MyConfig:
SESSION_COOKIE_SECURE = True
MYEXT_BAR = "crazy"
# To test both templates we will create a second app later on
# and uncomment the SQLALCHEMY_BINDS only for the second one:
# SQLALCHEMY_BINDS = {
# "test": os.getenv("TEST_DB_URI", "sqlite:///test.db")
# }
# You do not need to import this file manually, the framework
# initializes it automatically if it exists.
Preparing for migration
Okay, let's update your models so we've got something to migrate:
from webfluid.core.ext import db
from sqlalchemy import func
from sqlalchemy.orm import Mapped, mapped_column
from datetime import datetime
class MyModel(db.Model):
id: Mapped[int] = mapped_column(primary_key=True)
value: Mapped[str]
created_at: Mapped[datetime] = mapped_column(
server_default=func.now()
)
def __init__(self, value: str):
self.value = value
class SecondModel(db.Model):
__bind_key__ = "test"
id: Mapped[int] = mapped_column(primary_key=True)
value: Mapped[str]
created_at: Mapped[datetime] = mapped_column(
server_default=func.now()
)
def __init__(self, value: str):
self.value = value
And integrate the update into your app. It is important to switch to the app factory setup,
because Migrate builds your fluid itself instead of running it. It looks for a function called
prepare_fluid in your main.py (and falls back to a module level
fluid if there is none), so that is the name we give our factory:
from webfluid import Fluid
from webfluid.core.ext import scheduler, db
from fastapi import Request
from fastapi.responses import HTMLResponse
from fastapi.exceptions import HTTPException
from apscheduler.triggers.interval import IntervalTrigger
from sqlalchemy import select
from extension.main import MyExtension
from fluid.jobs import heart
from fluid.models import MyModel
my_ext = MyExtension()
scheduler.add_job(heart, IntervalTrigger(seconds=5))
async def home():
return await fluid.render(
"index.html",
title="Hello World!",
name="my friend"
)
async def health():
return {"status": "ok"}
async def get_model(request: Request):
model_id = request.query_params.get("id")
if not model_id:
raise HTTPException(status_code=400, detail="Bad Request")
async with db.async_executor(model=MyModel) as e:
results = await e.exec(
select(MyModel).where(MyModel.id == model_id)
)
result = results.first()
if not result:
raise HTTPException(status_code=404, detail="Model not found")
timestamp = result.created_at
return {
"model_id": model_id,
"value": result.value,
"created_at": timestamp.isoformat() if timestamp else None
}
async def add_model(request: Request):
data = await request.json()
value = data.get("value")
if not value:
raise HTTPException(status_code=400, detail="Bad Request")
async with db.async_executor(model=MyModel) as e:
model = await e.insert(
MyModel(value),
flush=True
)
return {
"model_id": model.id,
"value": model.value,
"created_at": model.created_at.isoformat()
}
def prepare_fluid() -> Fluid:
app = Fluid(__name__)
app.get("/", response_class=HTMLResponse)(home)
app.get("/health")(health)
app.get("/my-model")(get_model)
app.post("/my-model")(add_model)
my_ext.expand_fluid(app)
return app
if __name__ == "__main__":
fluid = prepare_fluid()
fluid.mix()
Single-DB
Now everything is set in place, and we can start migrating your databases using the Migrate CLI. This is done in three commands:
# Initialize Migrate to pick the correct alembic template
wf migrate init app
# The generated env.py scans the additives directory,
# so make sure it exists (wf create project makes it for you)
mkdir additives
# Perform revision with autogenerate
wf migrate revision app -a
# Upgrade your database
wf migrate upgrade app
# And test it (do not forget to set
# EXT_SQLALCHEMY = 1 in your app.ini before):
wf run app
Multi-DB
Alright, now let's test the same thing with multiple binds:
# Copy your app config:
cp app_configs/app.ini app_configs/app2.ini
# Now set your DATABASE_URI to sqlite:///app2.db
# and optionally add a TEST_DB_URI if you want.
# Then uncomment the SQLALCHEMY_BINDS in your config.
# The procedure is exactly the same:
wf migrate init app2
wf migrate revision app2 -a
wf migrate upgrade app2
wf run app2
wf migrate init looks at your config to decide which template to enroll: it uses
the multi-db template as soon as your config defines SQLALCHEMY_BINDS, and the
single-db template otherwise.
What the migration environment sees
Migrate reads the same app_configs/[app].ini that wf run does, through
the same parser — so file backed *_FILE keys are resolved here too, and your
migration connects with the real credentials behind them.
On top of that it trims the app down to what a migration actually needs: EXT_SQLALCHEMY
is forced on, and events, cache, mail, JWT and the whole surface layer (themes, Tailwind,
processing) are switched off for the duration. Your models get built, your frontend does not.
The generated env.py then imports fluid.models plus the models of every
Additive that is enabled for this app, and skips the rest. So an autogenerated
revision contains your tables and the tables of the Additives you actually switched on —
not everything that happens to sit in your additives directory.
The alembic.ini Migrate writes takes its log prefix from the framework's
environment prefix, so migration output lines up with the rest of your logs — and stays
lined up in a fork that renamed the framework.
Migrate decides between the single- and multi-db template from the SQLALCHEMY_BINDS
in your config. Binds you attach at runtime through Model.set_bind are not visible
at init time, so add them to your config before running wf migrate init.
SQLite gets a rebuild instead of an ALTER
SQLite has no real ALTER TABLE: you cannot rename a column, change its type or
add a constraint. Which is awkward, because SQLite is the database you develop against —
the default DATABASE_URI is a file. So the generated env.py turns on
Alembic's batch mode whenever the dialect is SQLite, offline and online. Alembic then creates
a new table with the shape you asked for, copies the rows across and swaps the names, and the
revision you wrote runs unchanged.
env.py is generated once, by wf migrate init, and never touched
again — it is your file from that moment on. A migration environment created before
beta 3 therefore does not have this. If a rename fails on SQLite in an older project, add
render_as_batch=True to the two context.configure(...) calls in
your env.py, or generate a fresh environment somewhere and copy them over.
Continue reading
From here you can continue straight with Mail.