Skip to content

Commit 2055238

Browse files
committed
❀[MIGRATIONS] Add support for Alembic through mitsuki init, documentation on migrations, and accessibility via entity metadata
1 parent 3fe1190 commit 2055238

File tree

15 files changed

+1402
-103
lines changed

15 files changed

+1402
-103
lines changed

docs/.vitepress/config.mts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ export default defineConfig({
3333
{ text: 'Metrics', link: '/15_metrics' },
3434
{ text: 'OpenAPI', link: '/16_openapi' },
3535
{ text: 'Database', link: '/17_database' },
36+
{ text: 'Database Migrations', link: '/19_database_migrations' },
3637
{ text: 'Dockerizing Mitsuki', link: '/18_dockerizing_mitsuki' },
3738
]
3839
}

docs/19_database_migrations.md

Lines changed: 256 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,256 @@
1+
# Database Migrations with Alembic
2+
3+
Mitsuki provides optional, pre-configured support for database migrations using [Alembic](https://alembic.sqlalchemy.org/), the standard migration tool for SQLAlchemy.
4+
5+
## Table of Contents
6+
7+
- [Overview](#overview)
8+
- [Getting Started](#getting-started)
9+
- [Migration Workflow](#migration-workflow)
10+
- [How It Works](#how-it-works)
11+
- [Manual Setup](#manual-setup)
12+
13+
## Overview
14+
15+
When you enable Alembic support, Mitsuki's CLI tool automatically generates the necessary configuration files, allowing you to use standard Alembic commands to manage your database schema.
16+
17+
**What you get:**
18+
- **Automatic Setup**: `mitsuki init` can create and configure Alembic for you.
19+
- **Standard Workflow**: Use familiar commands like `alembic revision`, `alembic upgrade`, and `alembic downgrade`.
20+
- **Pre-configured Environment**: The generated `env.py` is already set up to work with Mitsuki's configuration system and entity discovery.
21+
22+
## Getting Started
23+
24+
The easiest way to start using Alembic is to enable it when creating a new project with the `mitsuki init` command.
25+
26+
1. **Run `mitsuki init`**
27+
28+
```bash
29+
mitsuki init
30+
```
31+
32+
2. **Enable Alembic**
33+
34+
When prompted, answer "yes" to setting up Alembic:
35+
36+
```
37+
Setup Alembic for database migrations? [Y/n]: y
38+
```
39+
40+
3. **Generated Files**
41+
42+
The CLI will generate the following files in your project's root directory:
43+
44+
```
45+
my_app/
46+
├── alembic.ini # Alembic configuration
47+
├── alembic/ # Migration scripts
48+
│ ├── env.py # Alembic runtime environment
49+
│ ├── script.py.mako # Migration template
50+
│ └── versions/ # Directory for migration files
51+
└── src/
52+
└── my_app/
53+
└── ...
54+
```
55+
56+
4. **Success Message**
57+
58+
The CLI will confirm that Alembic has been configured:
59+
60+
```
61+
✓ Alembic configured
62+
63+
To create your first migration:
64+
cd my_app
65+
alembic revision --autogenerate -m "initial schema"
66+
alembic upgrade head
67+
```
68+
69+
## Migration Workflow
70+
71+
Once your project is set up, you can use the standard Alembic workflow to manage your database schema.
72+
73+
### 1. Import Your Entities
74+
75+
Before generating a migration, you need to make sure Alembic can see your `@Entity` classes. The generated `alembic/env.py` includes a wildcard import:
76+
77+
```python
78+
from my_app.src.domain import *
79+
```
80+
81+
This automatically imports all entities in your `domain` package. If you place your entities in different packages, you'll need to update this import or add additional imports:
82+
83+
```python
84+
# alembic/env.py
85+
86+
# ... (existing code)
87+
88+
# Import your entities so Mitsuki can discover them
89+
from my_app.src.domain import *
90+
from my_app.src.models import * # If you have entities in other packages
91+
92+
# ... (rest of the file)
93+
```
94+
95+
### 2. Generate a Migration
96+
97+
Whenever you create a new entity or modify an existing one, you can generate a new migration script automatically:
98+
99+
```bash
100+
alembic revision --autogenerate -m "Add Post entity"
101+
```
102+
103+
This will create a new file in the `alembic/versions/` directory containing the `upgrade` and `downgrade` functions for applying and reverting the schema changes.
104+
105+
### 3. Apply the Migration
106+
107+
To apply the migration to your database, run:
108+
109+
```bash
110+
alembic upgrade head
111+
```
112+
113+
This will execute the `upgrade` function in the latest migration script, bringing your database schema up to date.
114+
115+
### 4. Downgrade a Migration
116+
117+
To revert the last migration, you can use:
118+
119+
```bash
120+
alembic downgrade -1
121+
```
122+
123+
## How It Works
124+
125+
The integration between Mitsuki and Alembic is designed to be seamless and requires minimal configuration on your part.
126+
127+
- **`alembic.ini`**: This is the main configuration file for Alembic. The generated file is pre-configured with the location of the migration scripts.
128+
129+
- **`alembic/env.py`**: This is the key file for the integration. It's responsible for:
130+
- Reading your `application.yml` or `application-{profile}.yml` to get the correct database URL based on the `MITSUKI_PROFILE` environment variable.
131+
- Importing your entity classes so that Alembic's autogenerate feature can detect changes (via `from {{app_name}}.src.domain import *`).
132+
- Getting the SQLAlchemy metadata from Mitsuki using `get_sqlalchemy_metadata()`, which contains the schema information for all your entities.
133+
134+
- **`get_sqlalchemy_metadata()`**: This function from `mitsuki.data` automatically discovers all registered `@Entity` classes and builds SQLAlchemy metadata without requiring database initialization or async operations.
135+
136+
## Manual Setup
137+
138+
If you have an existing Mitsuki project and want to add Alembic support, you can follow these steps:
139+
140+
1. **Install Alembic**:
141+
```bash
142+
pip install alembic
143+
```
144+
145+
2. **Initialize Alembic**:
146+
```bash
147+
alembic init alembic
148+
```
149+
150+
3. **Configure `alembic/env.py`**:
151+
Replace the contents of `alembic/env.py` with the following, making sure to update the import paths to match your project structure:
152+
```python
153+
import asyncio
154+
import os
155+
from logging.config import fileConfig
156+
157+
from sqlalchemy import pool
158+
from sqlalchemy.engine import Connection
159+
from sqlalchemy.ext.asyncio import async_engine_from_config
160+
161+
from alembic import context
162+
163+
config = context.config
164+
165+
if config.config_file_name is not None:
166+
fileConfig(config.config_file_name)
167+
168+
from my_app.src.domain import *
169+
from mitsuki.data import convert_to_async_url, get_sqlalchemy_metadata
170+
171+
target_metadata = get_sqlalchemy_metadata()
172+
173+
174+
def get_url():
175+
"""Get database URL from application.yml based on MITSUKI_PROFILE."""
176+
import yaml
177+
profile = os.getenv("MITSUKI_PROFILE", "")
178+
179+
if profile:
180+
config_file = f"application-{profile}.yml"
181+
if not os.path.exists(config_file):
182+
raise FileNotFoundError(
183+
f"Configuration file '{config_file}' not found for MITSUKI_PROFILE='{profile}'. "
184+
f"Available profiles: dev, stg, prod (or unset MITSUKI_PROFILE to use application.yml)"
185+
)
186+
else:
187+
config_file = "application.yml"
188+
189+
with open(config_file) as f:
190+
app_config = yaml.safe_load(f)
191+
192+
url = app_config["database"]["url"]
193+
return convert_to_async_url(url)
194+
195+
196+
def render_item(type_, obj, autogen_context):
197+
"""Render custom types for migrations."""
198+
if type_ == "type":
199+
if obj.__class__.__name__ == "GUID":
200+
autogen_context.imports.add("from mitsuki.data.adapters.sqlalchemy import GUID")
201+
return "GUID()"
202+
return False
203+
204+
205+
def run_migrations_offline() -> None:
206+
"""Run migrations in 'offline' mode."""
207+
url = get_url()
208+
context.configure(
209+
url=url,
210+
target_metadata=target_metadata,
211+
literal_binds=True,
212+
dialect_opts={"paramstyle": "named"},
213+
render_item=render_item,
214+
)
215+
216+
with context.begin_transaction():
217+
context.run_migrations()
218+
219+
220+
def do_run_migrations(connection: Connection) -> None:
221+
context.configure(
222+
connection=connection,
223+
target_metadata=target_metadata,
224+
render_item=render_item,
225+
)
226+
with context.begin_transaction():
227+
context.run_migrations()
228+
229+
230+
async def run_async_migrations() -> None:
231+
"""Run migrations in 'online' mode with async engine."""
232+
configuration = config.get_section(config.config_ini_section, {})
233+
configuration["sqlalchemy.url"] = get_url()
234+
235+
connectable = async_engine_from_config(
236+
configuration,
237+
prefix="sqlalchemy.",
238+
poolclass=pool.NullPool,
239+
)
240+
241+
async with connectable.connect() as connection:
242+
await connection.run_sync(do_run_migrations)
243+
244+
await connectable.dispose()
245+
246+
247+
def run_migrations_online() -> None:
248+
"""Run migrations in 'online' mode."""
249+
asyncio.run(run_async_migrations())
250+
251+
252+
if context.is_offline_mode():
253+
run_migrations_offline()
254+
else:
255+
run_migrations_online()
256+
```

mitsuki/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@
8080
)
8181
from mitsuki.web.upload import UploadFile
8282

83-
__version__ = "0.1.2"
83+
__version__ = "0.1.3"
8484
__all__ = [
8585
# Core
8686
"Application",

mitsuki/cli/bootstrap.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,11 @@ def init():
102102
domain_name = click.prompt("Domain name")
103103
domains.append(domain_name)
104104

105+
# Alembic setup
106+
setup_alembic = click.confirm(
107+
"Setup Alembic for database migrations?", default=True
108+
)
109+
105110
# Create project structure
106111
project_root = Path.cwd() / app_name
107112
package_dir = project_root / app_name
@@ -152,6 +157,19 @@ def init():
152157
for domain_name in domains:
153158
create_domain_files(app_dir, app_name, domain_name)
154159

160+
# Update domain/__init__.py to export all entities for Alembic
161+
if domains:
162+
domain_exports = "\n".join(
163+
[
164+
f"from .{domain_name.lower()} import {domain_name}"
165+
for domain_name in domains
166+
]
167+
)
168+
domain_init = (
169+
f'"""{description or f"{app_name} domain entities"}"""\n{domain_exports}\n'
170+
)
171+
write_file(app_dir / "domain" / "__init__.py", domain_init)
172+
155173
# Create configuration files
156174
db_url_map = {
157175
"sqlite": f"sqlite:///{app_name}.db",
@@ -205,13 +223,45 @@ def init():
205223
gitignore = read_template("gitignore.tpl")
206224
write_file(project_root / ".gitignore", gitignore)
207225

226+
# Setup Alembic if requested
227+
if setup_alembic:
228+
alembic_dir = project_root / "alembic"
229+
versions_dir = alembic_dir / "versions"
230+
create_directory(alembic_dir)
231+
create_directory(versions_dir)
232+
233+
# Create alembic.ini
234+
alembic_ini = read_template("alembic.ini.tpl")
235+
write_file(project_root / "alembic.ini", alembic_ini)
236+
237+
# Create alembic/env.py
238+
alembic_env = read_template("alembic_env.py.tpl").replace(
239+
"{{app_name}}", app_name
240+
)
241+
write_file(alembic_dir / "env.py", alembic_env)
242+
243+
# Create alembic/script.py.mako
244+
alembic_script = read_template("alembic_script.py.mako.tpl")
245+
write_file(alembic_dir / "script.py.mako", alembic_script)
246+
247+
# Create empty __init__.py in versions
248+
write_file(versions_dir / "__init__.py", "")
249+
250+
click.echo("\n✓ Alembic configured for database migrations")
251+
208252
click.echo(f"\nSuccessfully created Mitsuki application: {app_name}")
209253
click.echo("\nTo get started:")
210254
click.echo(f" cd {app_name}")
211255
click.echo(f" python3 -m {app_name}.src.app")
212256
click.echo("\nOr with a specific profile:")
213257
click.echo(f" MITSUKI_PROFILE=development python3 -m {app_name}.src.app")
214258

259+
if setup_alembic:
260+
click.echo("\nTo create your first migration:")
261+
click.echo(f" cd {app_name}")
262+
click.echo(" alembic revision --autogenerate -m 'initial schema'")
263+
click.echo(" alembic upgrade head")
264+
215265

216266
def main():
217267
cli()
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
[alembic]
2+
script_location = alembic
3+
file_template = %%(year)d%%(month).2d%%(day).2d_%%(hour).2d%%(minute).2d_%%(rev)s_%%(slug)s
4+
prepend_sys_path = .
5+
version_path_separator = os
6+
7+
[alembic:exclude]
8+
tables = spatial_ref_sys
9+
10+
[loggers]
11+
keys = root,sqlalchemy,alembic
12+
13+
[handlers]
14+
keys = console
15+
16+
[formatters]
17+
keys = generic
18+
19+
[logger_root]
20+
level = WARN
21+
handlers = console
22+
qualname =
23+
24+
[logger_sqlalchemy]
25+
level = WARN
26+
handlers =
27+
qualname = sqlalchemy.engine
28+
29+
[logger_alembic]
30+
level = INFO
31+
handlers =
32+
qualname = alembic
33+
34+
[handler_console]
35+
class = StreamHandler
36+
args = (sys.stderr,)
37+
level = NOTSET
38+
formatter = generic
39+
40+
[formatter_generic]
41+
format = %(levelname)-5.5s [%(name)s] %(message)s
42+
datefmt = %H:%M:%S

0 commit comments

Comments
 (0)