Skip to content

Commit 258439c

Browse files
authored
feat: add database migration support and connection utilities (#17)
1 parent 2800dec commit 258439c

8 files changed

Lines changed: 453 additions & 3 deletions

File tree

alembic.ini

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
# A generic, single database configuration.
2+
3+
[alembic]
4+
# path to migration scripts
5+
script_location = alembic
6+
7+
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
8+
# Uncomment the line below if you want the files to be prepended with date and time
9+
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
10+
11+
# sys.path path, will be prepended to sys.path if present.
12+
# defaults to the current working directory.
13+
prepend_sys_path = .
14+
15+
# timezone to use when rendering the date within the migration file
16+
# as well as the filename.
17+
# If specified, requires the python>=3.9 or backports.zoneinfo library.
18+
# Any required deps can be installed by adding `alembic[tz]` to the pip requirements
19+
# string value is passed to ZoneInfo()
20+
# leave blank for localtime
21+
# timezone =
22+
23+
# max length of characters to apply to the "slug" field
24+
# truncate_slug_length = 40
25+
26+
# set to 'true' to run the environment during
27+
# the 'revision' command, regardless of autogenerate
28+
# revision_environment = false
29+
30+
# set to 'true' to allow .pyc and .pyo files without
31+
# a source .py file to be detected as revisions in the
32+
# versions/ directory
33+
# sourceless = false
34+
35+
# version location specification; This defaults
36+
# to alembic/versions. When using multiple version
37+
# directories, initial revisions must be specified with --version-path.
38+
# The path separator used here should be the separator specified by "version_path_separator" below.
39+
# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions
40+
41+
# version path separator; As mentioned above, this is the character used to split
42+
# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
43+
# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
44+
# Valid values for version_path_separator are:
45+
#
46+
# version_path_separator = :
47+
# version_path_separator = ;
48+
# version_path_separator = space
49+
version_path_separator = os # Use os.pathsep. Default configuration used for new projects.
50+
51+
# set to 'true' to search source files recursively
52+
# in each "version_locations" directory
53+
# new in Alembic version 1.10
54+
# recursive_version_locations = false
55+
56+
# the output encoding used when revision files
57+
# are written from script.py.mako
58+
# output_encoding = utf-8
59+
60+
# Database URL Configuration
61+
#
62+
# Note: This project reads the database URL from environment variables in alembic/env.py.
63+
# The sqlalchemy.url setting is intentionally left unset here.
64+
#
65+
# Supported environment variables (in priority order):
66+
# 1) DATABASE_URL - Full connection URL (e.g., postgresql+psycopg://user:pass@host:5432/db)
67+
# 2) Individual variables: DATABASE_HOST, DATABASE_PORT, DATABASE_USER, DATABASE_PASSWORD, DATABASE_NAME
68+
#
69+
# The env.py automatically normalizes URL schemes (postgres://, postgresql://, postgresql+asyncpg://)
70+
# to postgresql+psycopg:// for Alembic migrations.
71+
#
72+
# sqlalchemy.url =
73+
74+
[post_write_hooks]
75+
# post_write_hooks defines scripts or Python functions that are run
76+
# on newly generated revision scripts. See the documentation for further
77+
# detail and examples
78+
79+
# format using "black" - use the console_scripts runner, against the "black" entrypoint
80+
# hooks = black
81+
# black.type = console_scripts
82+
# black.entrypoint = black
83+
# black.options = -l 79 REVISION_SCRIPT_FILENAME
84+
85+
# lint with attempts to fix using "ruff" - use the exec runner, execute a binary
86+
# hooks = ruff
87+
# ruff.type = exec
88+
# ruff.executable = %(here)s/.venv/bin/ruff
89+
# ruff.options = --fix REVISION_SCRIPT_FILENAME
90+
91+
# Logging configuration
92+
[loggers]
93+
keys = root,sqlalchemy,alembic
94+
95+
[handlers]
96+
keys = console
97+
98+
[formatters]
99+
keys = generic
100+
101+
[logger_root]
102+
level = WARN
103+
handlers = console
104+
qualname =
105+
106+
[logger_sqlalchemy]
107+
level = WARN
108+
handlers =
109+
qualname = sqlalchemy.engine
110+
111+
[logger_alembic]
112+
level = INFO
113+
handlers =
114+
qualname = alembic
115+
116+
[handler_console]
117+
class = StreamHandler
118+
args = (sys.stderr,)
119+
level = NOTSET
120+
formatter = generic
121+
122+
[formatter_generic]
123+
format = %(levelname)-5.5s [%(name)s] %(message)s
124+
datefmt = %H:%M:%S

alembic/env.py

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
"""Alembic migration environment configuration."""
2+
3+
import os
4+
5+
# pylint: disable=no-member
6+
from logging.config import fileConfig
7+
from urllib.parse import quote
8+
9+
from sqlalchemy import pool
10+
11+
from alembic import context
12+
13+
# Import Base metadata for autogenerate support
14+
# Note: We import from app.models.base which is side-effect free
15+
# (doesn't create database connections or read environment variables)
16+
from app.models.base import Base
17+
18+
19+
def get_sync_database_url() -> str:
20+
"""
21+
Get synchronous database URL for Alembic migrations.
22+
23+
Alembic uses synchronous database connections, so we need to convert
24+
the async URL (postgresql+asyncpg://) to sync format (postgresql+psycopg://).
25+
26+
Returns:
27+
str: Synchronous database connection URL
28+
"""
29+
# First try DATABASE_URL from environment
30+
database_url = os.getenv("DATABASE_URL")
31+
if database_url:
32+
# Normalize common PostgreSQL DSNs to use the psycopg (sync) driver.
33+
# Handle bare postgres:// and postgresql:// URLs that don't specify a driver.
34+
if database_url.startswith("postgres://"):
35+
# postgres://user:pass@host/db -> postgresql+psycopg://user:pass@host/db
36+
database_url = "postgresql+psycopg://" + database_url[len("postgres://") :]
37+
elif database_url.startswith("postgresql://") and not database_url.startswith("postgresql+"):
38+
# postgresql://user:pass@host/db -> postgresql+psycopg://user:pass@host/db
39+
database_url = "postgresql+psycopg://" + database_url[len("postgresql://") :]
40+
41+
# Convert async driver to sync driver if needed
42+
# postgresql+asyncpg:// -> postgresql+psycopg://
43+
if database_url.startswith("postgresql+asyncpg://"):
44+
database_url = "postgresql+psycopg://" + database_url[len("postgresql+asyncpg://") :]
45+
46+
return database_url
47+
48+
# Construct from individual variables
49+
db_host = os.getenv("DATABASE_HOST")
50+
db_port = os.getenv("DATABASE_PORT", "5432") # Default PostgreSQL port
51+
db_user = os.getenv("DATABASE_USER")
52+
db_pass = os.getenv("DATABASE_PASSWORD")
53+
db_name = os.getenv("DATABASE_NAME")
54+
55+
# Validate required environment variables (consistent with app/database.py)
56+
missing_vars = [
57+
name
58+
for name, value in [
59+
("DATABASE_HOST", db_host),
60+
("DATABASE_USER", db_user),
61+
("DATABASE_PASSWORD", db_pass),
62+
("DATABASE_NAME", db_name),
63+
]
64+
if not value
65+
]
66+
67+
if missing_vars:
68+
raise RuntimeError(
69+
f"Database configuration is incomplete. Missing environment variables: {', '.join(missing_vars)}"
70+
)
71+
72+
# At this point, we know these are not None
73+
assert db_host is not None
74+
assert db_user is not None
75+
assert db_pass is not None
76+
assert db_name is not None
77+
78+
# URL-encode username and password to handle special characters
79+
# Use quote(..., safe="") instead of quote_plus() for URL userinfo section
80+
db_user_encoded = quote(db_user, safe="")
81+
db_pass_encoded = quote(db_pass, safe="")
82+
83+
# Use psycopg (sync) for Alembic migrations
84+
return f"postgresql+psycopg://{db_user_encoded}:{db_pass_encoded}@{db_host}:{db_port}/{db_name}"
85+
86+
87+
# this is the Alembic Config object, which provides
88+
# access to the values within the .ini file in use.
89+
config = context.config
90+
91+
# Interpret the config file for Python logging.
92+
# This line sets up loggers basically.
93+
if config.config_file_name is not None:
94+
fileConfig(config.config_file_name)
95+
96+
target_metadata = Base.metadata
97+
98+
# other values from the config, defined by the needs of env.py,
99+
# can be acquired:
100+
# my_important_option = config.get_main_option("my_important_option")
101+
# ... etc.
102+
103+
104+
def run_migrations_offline() -> None:
105+
"""Run migrations in 'offline' mode.
106+
107+
This configures the context with just a URL
108+
and not an Engine, though an Engine is acceptable
109+
here as well. By skipping the Engine creation
110+
we don't even need a DBAPI to be available.
111+
112+
Calls to context.execute() here emit the given string to the
113+
script output.
114+
115+
"""
116+
# Get URL from environment variables
117+
url = get_sync_database_url()
118+
context.configure(
119+
url=url,
120+
target_metadata=target_metadata,
121+
literal_binds=True,
122+
dialect_opts={"paramstyle": "named"},
123+
)
124+
125+
with context.begin_transaction():
126+
context.run_migrations()
127+
128+
129+
def run_migrations_online() -> None:
130+
"""Run migrations in 'online' mode.
131+
132+
In this scenario we need to create an Engine
133+
and associate a connection with the context.
134+
135+
"""
136+
from sqlalchemy import create_engine
137+
138+
# Get URL from environment variables and create engine directly
139+
url = get_sync_database_url()
140+
connectable = create_engine(url, poolclass=pool.NullPool)
141+
142+
with connectable.connect() as connection:
143+
context.configure(connection=connection, target_metadata=target_metadata)
144+
145+
with context.begin_transaction():
146+
context.run_migrations()
147+
148+
149+
if context.is_offline_mode():
150+
run_migrations_offline()
151+
else:
152+
run_migrations_online()

alembic/script.py.mako

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
"""${message}
2+
3+
Revision ID: ${up_revision}
4+
Revises: ${down_revision | comma,n}
5+
Create Date: ${create_date}
6+
7+
"""
8+
from alembic import op
9+
import sqlalchemy as sa
10+
${imports if imports else ""}
11+
12+
# revision identifiers, used by Alembic.
13+
revision = ${repr(up_revision)}
14+
down_revision = ${repr(down_revision)}
15+
branch_labels = ${repr(branch_labels)}
16+
depends_on = ${repr(depends_on)}
17+
18+
19+
def upgrade() -> None:
20+
${upgrades if upgrades else "pass"}
21+
22+
23+
def downgrade() -> None:
24+
${downgrades if downgrades else "pass"}

alembic/versions/.gitkeep

Whitespace-only changes.

0 commit comments

Comments
 (0)