-
Notifications
You must be signed in to change notification settings - Fork 152
Alembic setup #579
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: development
Are you sure you want to change the base?
Alembic setup #579
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,37 @@ | ||
| [alembic] | ||
| script_location = alembic | ||
| prepend_sys_path = . | ||
| path_separator = os | ||
|
|
||
| [loggers] | ||
| keys = root,sqlalchemy,alembic | ||
|
|
||
| [handlers] | ||
| keys = console | ||
|
|
||
| [formatters] | ||
| keys = generic | ||
|
|
||
| [logger_root] | ||
| level = WARN | ||
| handlers = console | ||
|
|
||
| [logger_sqlalchemy] | ||
| level = WARN | ||
| handlers = | ||
| qualname = sqlalchemy.engine | ||
|
|
||
| [logger_alembic] | ||
| level = INFO | ||
| handlers = | ||
| qualname = alembic | ||
|
|
||
| [handler_console] | ||
| class = StreamHandler | ||
| args = (sys.stderr,) | ||
| level = NOTSET | ||
| formatter = generic | ||
|
|
||
| [formatter_generic] | ||
| format = %(levelname)-5.5s [%(name)s] %(message)s | ||
| datefmt = %H:%M:%S |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,50 @@ | ||
| from logging.config import fileConfig | ||
|
|
||
| from alembic import context | ||
| from sqlalchemy import engine_from_config, pool | ||
|
|
||
| from app.core.config import DATABASE_URL | ||
| from app.models.models import SQLModel | ||
| from app.models import Template, FormSubmission | ||
|
|
||
| config = context.config | ||
|
|
||
| if not config.get_main_option("sqlalchemy.url"): | ||
| config.set_main_option("sqlalchemy.url", DATABASE_URL) | ||
|
|
||
| if config.config_file_name is not None: | ||
| fileConfig(config.config_file_name) | ||
|
|
||
| target_metadata = SQLModel.metadata | ||
|
|
||
|
|
||
| def run_migrations_offline(): | ||
| url = config.get_main_option("sqlalchemy.url") | ||
| context.configure(url=url, target_metadata=target_metadata, literal_binds=True) | ||
| with context.begin_transaction(): | ||
| context.run_migrations() | ||
|
|
||
|
|
||
| def run_migrations_online(): | ||
| connectable = config.attributes.get("connection", None) | ||
|
|
||
| if connectable is None: | ||
| connectable = engine_from_config( | ||
| config.get_section(config.config_ini_section, {}), | ||
| prefix="sqlalchemy.", | ||
| poolclass=pool.NullPool, | ||
| ) | ||
| with connectable.connect() as connection: | ||
| context.configure(connection=connection, target_metadata=target_metadata) | ||
| with context.begin_transaction(): | ||
| context.run_migrations() | ||
| else: | ||
| context.configure(connection=connectable, target_metadata=target_metadata) | ||
| with context.begin_transaction(): | ||
| context.run_migrations() | ||
|
|
||
|
|
||
| if context.is_offline_mode(): | ||
| run_migrations_offline() | ||
| else: | ||
| run_migrations_online() |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,27 @@ | ||
| """${message} | ||
|
|
||
| Revision ID: ${up_revision} | ||
| Revises: ${down_revision | comma,n} | ||
| Create Date: ${create_date} | ||
|
|
||
| """ | ||
| from typing import Sequence, Union | ||
|
|
||
| from alembic import op | ||
| import sqlalchemy as sa | ||
| import sqlmodel | ||
| ${imports if imports else ""} | ||
|
|
||
| # revision identifiers, used by Alembic. | ||
| revision: str = ${repr(up_revision)} | ||
| down_revision: Union[str, None] = ${repr(down_revision)} | ||
| branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} | ||
| depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} | ||
|
|
||
|
|
||
| def upgrade() -> None: | ||
| ${upgrades if upgrades else "pass"} | ||
|
|
||
|
|
||
| def downgrade() -> None: | ||
| ${downgrades if downgrades else "pass"} |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,52 @@ | ||
| """Initial schema — Template and FormSubmission tables. | ||
|
|
||
| Revision ID: 001 | ||
| Revises: | ||
| Create Date: 2026-06-22 | ||
|
|
||
| """ | ||
| from typing import Sequence, Union | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Same for sequence. If you are using typing.Sequence for type hinting, the modern alternative is to use collections.abc.Sequence or the built-in list generic. Python has moved away from the bulky typing module. Modern Python allows you to use builtin collections directly with subscripts (like list[int]) and union types. |
||
|
|
||
| from alembic import op | ||
| import sqlalchemy as sa | ||
| import sqlmodel | ||
|
|
||
| revision: str = "001" | ||
| down_revision: Union[str, None] = None | ||
| branch_labels: Union[str, Sequence[str], None] = None | ||
| depends_on: Union[str, Sequence[str], None] = None | ||
|
|
||
|
|
||
| def upgrade() -> None: | ||
| op.create_table( | ||
| "template", | ||
| sa.Column("id", sa.Integer, primary_key=True, autoincrement=True), | ||
| sa.Column("name", sqlmodel.sql.sqltypes.AutoString, nullable=False), | ||
| sa.Column("fields", sa.JSON, nullable=False), | ||
| sa.Column("pdf_path", sqlmodel.sql.sqltypes.AutoString, nullable=False), | ||
| sa.Column( | ||
| "created_at", | ||
| sa.DateTime, | ||
| nullable=False, | ||
| server_default=sa.text("now()"), | ||
| ), | ||
| ) | ||
|
|
||
| op.create_table( | ||
| "formsubmission", | ||
| sa.Column("id", sa.Integer, primary_key=True, autoincrement=True), | ||
| sa.Column("template_id", sa.Integer, sa.ForeignKey("template.id"), nullable=False), | ||
| sa.Column("input_text", sqlmodel.sql.sqltypes.AutoString, nullable=False), | ||
| sa.Column("output_pdf_path", sqlmodel.sql.sqltypes.AutoString, nullable=False), | ||
| sa.Column( | ||
| "created_at", | ||
| sa.DateTime, | ||
| nullable=False, | ||
| server_default=sa.text("now()"), | ||
| ), | ||
| ) | ||
|
|
||
|
|
||
| def downgrade() -> None: | ||
| op.drop_table("formsubmission") | ||
| op.drop_table("template") | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -16,4 +16,5 @@ openmeteo-requests | |
| requests-cache | ||
| retry-requests | ||
| pandas | ||
| geopy | ||
| geopy | ||
| alembic | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,85 @@ | ||
| """Tests for Alembic migration infrastructure. | ||
|
|
||
| Runs migrations against an in-memory SQLite database to verify: | ||
| - upgrade to head works | ||
| - downgrade to base works | ||
| - round-trip (up → down → up) works | ||
| - schema matches expected columns and foreign keys | ||
| """ | ||
|
|
||
| import pytest | ||
| from alembic import command | ||
| from alembic.config import Config | ||
| from sqlalchemy import create_engine, inspect | ||
|
|
||
| ALEMBIC_INI = "alembic.ini" | ||
|
|
||
|
|
||
| @pytest.fixture() | ||
| def alembic_engine(): | ||
| return create_engine("sqlite://") | ||
|
|
||
|
|
||
| @pytest.fixture() | ||
| def alembic_cfg(alembic_engine): | ||
| cfg = Config(ALEMBIC_INI) | ||
| cfg.set_main_option("sqlalchemy.url", "sqlite://") | ||
| cfg.attributes["connection"] = alembic_engine.connect() | ||
| return cfg | ||
|
|
||
|
|
||
| def test_upgrade_head(alembic_cfg, alembic_engine): | ||
| command.upgrade(alembic_cfg, "head") | ||
|
|
||
| inspector = inspect(alembic_engine) | ||
| tables = inspector.get_table_names() | ||
| assert "template" in tables | ||
| assert "formsubmission" in tables | ||
| assert "alembic_version" in tables | ||
|
|
||
|
|
||
| def test_downgrade_base(alembic_cfg, alembic_engine): | ||
| command.upgrade(alembic_cfg, "head") | ||
| command.downgrade(alembic_cfg, "base") | ||
|
|
||
| inspector = inspect(alembic_engine) | ||
| tables = inspector.get_table_names() | ||
| assert "template" not in tables | ||
| assert "formsubmission" not in tables | ||
|
|
||
|
|
||
| def test_round_trip(alembic_cfg, alembic_engine): | ||
| command.upgrade(alembic_cfg, "head") | ||
| command.downgrade(alembic_cfg, "-1") | ||
| command.upgrade(alembic_cfg, "head") | ||
|
|
||
| inspector = inspect(alembic_engine) | ||
| tables = inspector.get_table_names() | ||
| assert "template" in tables | ||
| assert "formsubmission" in tables | ||
|
|
||
|
|
||
| def test_template_columns(alembic_cfg, alembic_engine): | ||
| command.upgrade(alembic_cfg, "head") | ||
|
|
||
| inspector = inspect(alembic_engine) | ||
| columns = {c["name"] for c in inspector.get_columns("template")} | ||
| assert columns == {"id", "name", "fields", "pdf_path", "created_at"} | ||
|
|
||
|
|
||
| def test_formsubmission_columns(alembic_cfg, alembic_engine): | ||
| command.upgrade(alembic_cfg, "head") | ||
|
|
||
| inspector = inspect(alembic_engine) | ||
| columns = {c["name"] for c in inspector.get_columns("formsubmission")} | ||
| assert columns == {"id", "template_id", "input_text", "output_pdf_path", "created_at"} | ||
|
|
||
|
|
||
| def test_formsubmission_fk(alembic_cfg, alembic_engine): | ||
| command.upgrade(alembic_cfg, "head") | ||
|
|
||
| inspector = inspect(alembic_engine) | ||
| fks = inspector.get_foreign_keys("formsubmission") | ||
| assert len(fks) == 1 | ||
| assert fks[0]["referred_table"] == "template" | ||
| assert fks[0]["referred_columns"] == ["id"] |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The modern alternative to Python's typing.Union is the | (pipe) operator, introduced via PEP 604.