Add Alembic to project for database migrations

This commit is contained in:
Lexi / Zoe 2022-04-15 16:10:38 +02:00
parent 7755bfb46d
commit 7ce43a2bfe
Signed by: binaryDiv
GPG Key ID: F8D4956E224DA232
15 changed files with 256 additions and 49 deletions

View File

@ -2,9 +2,17 @@
DOCKER_COMPOSE = docker-compose DOCKER_COMPOSE = docker-compose
DOCKER_RUN = $(DOCKER_COMPOSE) run --rm backend DOCKER_RUN = $(DOCKER_COMPOSE) run --rm backend
# General
# -------
# Default target # Default target
.PHONY: all .PHONY: up
all: docker-up up: docker-up
# Shortcut for first start (initializing database, etc.)
.PHONY: first-start
first-start: docker-build db-upgrade docker-up
# Container management # Container management
@ -16,7 +24,7 @@ docker-up:
.PHONY: docker-down .PHONY: docker-down
docker-down: docker-down:
$(DOCKER_COMPOSE) down $(DOCKER_COMPOSE) down --remove-orphans
.PHONY: docker-build .PHONY: docker-build
docker-build: docker-build:
@ -28,7 +36,7 @@ docker-rebuild:
.PHONY: docker-purge .PHONY: docker-purge
docker-purge: docker-purge:
$(DOCKER_COMPOSE) down --volumes $(DOCKER_COMPOSE) down --remove-orphans --volumes
.PHONY: docker-restart .PHONY: docker-restart
docker-restart: docker-restart:
@ -45,3 +53,23 @@ docker-run:
.PHONY: docker-shell .PHONY: docker-shell
docker-shell: docker-shell:
$(DOCKER_RUN) bash $(DOCKER_RUN) bash
# Database management
# -------------------
# Run migrations to upgrade the database to the head revision (set REVISION parameter to override)
.PHONY: db-upgrade
db-upgrade:
$(DOCKER_RUN) alembic upgrade $(or $(REVISION),head)
# Run migrations to downgrade the database to the previous revision (set REVISION parameter to override)
.PHONY: db-downgrade
db-downgrade:
$(DOCKER_RUN) alembic downgrade $(or $(REVISION),-1)
# Autogenerate a revision for database migration (requires MESSAGE parameter to set the revision message)
.PHONY: db-generate-migration
db-generate-migration:
@test -n "$(MESSAGE)" || (echo "Please set the revision message: make db-generate-migration MESSAGE=\"...\""; exit 1)
$(DOCKER_RUN) alembic revision --autogenerate -m "$(MESSAGE)"

View File

@ -10,6 +10,8 @@ flask = "~=2.0"
pyyaml = "*" pyyaml = "*"
sqlalchemy = "~=1.4" sqlalchemy = "~=1.4"
pymysql = "*" pymysql = "*"
alembic = "~=1.7"
python-dateutil = "*"
[dev-packages] [dev-packages]

34
Pipfile.lock generated
View File

@ -1,7 +1,7 @@
{ {
"_meta": { "_meta": {
"hash": { "hash": {
"sha256": "b0c979ea7ddef64da26c3f7537dbadaa9de352c7a8f6cde551f7ce85519de503" "sha256": "f63a254c353bc5c8e6cbe2a920f8402ba0da5bec775bfff91f64fa485e9bf95c"
}, },
"pipfile-spec": 6, "pipfile-spec": 6,
"requires": { "requires": {
@ -16,6 +16,14 @@
] ]
}, },
"default": { "default": {
"alembic": {
"hashes": [
"sha256:29be0856ec7591c39f4e1cb10f198045d890e6e2274cf8da80cb5e721a09642b",
"sha256:4961248173ead7ce8a21efb3de378f13b8398e6630fab0eb258dc74a8af24c58"
],
"index": "pypi",
"version": "==1.7.7"
},
"click": { "click": {
"hashes": [ "hashes": [
"sha256:24e1a4a9ec5bf6299411369b208c1df2188d9eb8d916302fe6bf03faed227f1e", "sha256:24e1a4a9ec5bf6299411369b208c1df2188d9eb8d916302fe6bf03faed227f1e",
@ -117,6 +125,14 @@
"markers": "python_version >= '3.7'", "markers": "python_version >= '3.7'",
"version": "==3.1.1" "version": "==3.1.1"
}, },
"mako": {
"hashes": [
"sha256:23aab11fdbbb0f1051b93793a58323ff937e98e34aece1c4219675122e57e4ba",
"sha256:9a7c7e922b87db3686210cf49d5d767033a41d4010b284e747682c92bddd8b39"
],
"markers": "python_version >= '3.7'",
"version": "==1.2.0"
},
"markupsafe": { "markupsafe": {
"hashes": [ "hashes": [
"sha256:0212a68688482dc52b2d45013df70d169f542b7394fc744c02a57374a4207003", "sha256:0212a68688482dc52b2d45013df70d169f542b7394fc744c02a57374a4207003",
@ -171,6 +187,14 @@
"index": "pypi", "index": "pypi",
"version": "==1.0.2" "version": "==1.0.2"
}, },
"python-dateutil": {
"hashes": [
"sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86",
"sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9"
],
"index": "pypi",
"version": "==2.8.2"
},
"pyyaml": { "pyyaml": {
"hashes": [ "hashes": [
"sha256:0283c35a6a9fbf047493e3a0ce8d79ef5030852c51e9d911a27badfde0605293", "sha256:0283c35a6a9fbf047493e3a0ce8d79ef5030852c51e9d911a27badfde0605293",
@ -218,6 +242,14 @@
"markers": "python_version >= '3.7'", "markers": "python_version >= '3.7'",
"version": "==62.1.0" "version": "==62.1.0"
}, },
"six": {
"hashes": [
"sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926",
"sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==1.16.0"
},
"sqlalchemy": { "sqlalchemy": {
"hashes": [ "hashes": [
"sha256:093b3109c2747d5dc0fa4314b1caf4c7ca336d5c8c831e3cfbec06a7e861e1e6", "sha256:093b3109c2747d5dc0fa4314b1caf4c7ca336d5c8c831e3cfbec06a7e861e1e6",

50
alembic.ini Normal file
View File

@ -0,0 +1,50 @@
[alembic]
# Path to migration scripts
script_location = migrations
# Add project root directory to the import path
prepend_sys_path = .
# String template for migration filenames
file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
# Timezone for the date within the migration files and filenames
timezone = Europe/Berlin
# Max length for the "slug" field in filenames
truncate_slug_length = 24
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[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

View File

@ -34,7 +34,7 @@ services:
timeout: 1s timeout: 1s
retries: 20 retries: 20
adminer: phpmyadmin:
image: phpmyadmin image: phpmyadmin
ports: ports:
- '8099:80' - '8099:80'

63
migrations/env.py Normal file
View File

@ -0,0 +1,63 @@
from logging.config import fileConfig
from alembic import context
from tofu_api.app import create_app
from tofu_api.models import BaseModel
# This is the Alembic Config object, which provides access to the values within the .ini file in use.
alembic_config = context.config
# Interpret the config file for Python logging. This line sets up loggers basically.
if alembic_config.config_file_name is not None:
fileConfig(alembic_config.config_file_name)
# Create Flask app, which loads the app config and initializes the database engine.
app = create_app()
db = app.dependencies.get_sqlalchemy()
def process_revision_directives(_context, _revision, directives):
"""
Callback used to prevent generating empty migrations with autogenerate.
Source: https://alembic.sqlalchemy.org/en/latest/cookbook.html#don-t-generate-empty-migrations-with-autogenerate
"""
if alembic_config.cmd_opts.autogenerate and directives[0].upgrade_ops.is_empty():
directives[:] = []
def run_migrations_offline():
"""
Run migrations in 'offline' mode, which does not require an actual database engine and can be used to generate SQL scripts.
"""
context.configure(
url=app.config.sqlalchemy_database_uri,
target_metadata=BaseModel.metadata,
process_revision_directives=process_revision_directives,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online():
"""
Run migrations in 'online' mode, which requires a database engine.
"""
with db.engine.connect() as connection:
context.configure(
connection=connection,
target_metadata=BaseModel.metadata,
process_revision_directives=process_revision_directives,
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

25
migrations/script.py.mako Normal file
View File

@ -0,0 +1,25 @@
"""
${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# Revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade():
${upgrades if upgrades else "pass"}
def downgrade():
${downgrades if downgrades else "pass"}

View File

@ -0,0 +1,34 @@
"""
Initial revision
Revision ID: 716726b4a1fe
Revises:
Create Date: 2022-04-15 16:05:29.358429+02:00
"""
from alembic import op
import sqlalchemy as sa
# Revision identifiers, used by Alembic.
revision = '716726b4a1fe'
down_revision = None
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
'tasks',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('title', sa.String(length=255), nullable=False),
sa.Column('description', sa.Text(), nullable=False),
sa.PrimaryKeyConstraint('id', name=op.f('pk_tasks'))
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('tasks')
# ### end Alembic commands ###

View File

@ -60,12 +60,8 @@ class App(Flask):
# Import models to fill the metadata object # Import models to fill the metadata object
import tofu_api.models # noqa (unused import) import tofu_api.models # noqa (unused import)
# Create all tables
# TODO: Use migrations instead
db.create_all_tables()
def create_app() -> App:
def create_app() -> Flask:
""" """
App factory, returns a Flask app object. App factory, returns a Flask app object.
""" """

View File

@ -1,3 +1 @@
from .metadata import metadata_obj
from .model import Model
from .sqlalchemy import SQLAlchemy from .sqlalchemy import SQLAlchemy

View File

@ -1,10 +0,0 @@
from sqlalchemy.orm import declarative_base
from .metadata import metadata_obj
__all__ = [
'Model',
]
# Generate declarative base class for database models
Model = declarative_base(name='Model', metadata=metadata_obj)

View File

@ -1,12 +1,11 @@
from typing import Optional, cast from typing import Optional, cast
from flask import Flask from flask import Flask
from sqlalchemy import MetaData, create_engine from sqlalchemy import create_engine
from sqlalchemy.engine import Engine from sqlalchemy.engine import Engine
from sqlalchemy.orm import Session, scoped_session, sessionmaker from sqlalchemy.orm import Session, scoped_session, sessionmaker
from tofu_api.common.config import Config from tofu_api.common.config import Config
from .metadata import metadata_obj
__all__ = [ __all__ = [
'SQLAlchemy', 'SQLAlchemy',
@ -70,22 +69,3 @@ class SQLAlchemy:
# For all further purposes, the scoped session should be treated like a regular Session object. # For all further purposes, the scoped session should be treated like a regular Session object.
# Use cast() so we can use Session as the type annotation. # Use cast() so we can use Session as the type annotation.
return cast(Session, self._scoped_session) return cast(Session, self._scoped_session)
@property
def metadata(self) -> MetaData:
"""
Database metadata object.
"""
return metadata_obj
def create_all_tables(self) -> None:
"""
Create tables in the database for all models defined in the metadata.
"""
self.metadata.create_all(self.engine)
def drop_all_tables(self) -> None:
"""
Delete tables in the database for all models defined in the metadata.
"""
self.metadata.drop_all(self.engine)

View File

@ -1 +1,5 @@
# Base model first
from .base import metadata, BaseModel
# Data models
from .task import Task from .task import Task

View File

@ -1,7 +1,9 @@
from sqlalchemy import MetaData from sqlalchemy import MetaData
from sqlalchemy.orm import declarative_base
__all__ = [ __all__ = [
'metadata_obj', 'metadata',
'BaseModel',
] ]
# Define naming convention for constraints # Define naming convention for constraints
@ -13,5 +15,8 @@ _naming_convention = {
"pk": "pk_%(table_name)s" "pk": "pk_%(table_name)s"
} }
# Create global metadata object for database schemas # Create metadata object for database schemas
metadata_obj = MetaData(naming_convention=_naming_convention) metadata = MetaData(naming_convention=_naming_convention)
# Generate declarative base class for database models
BaseModel = declarative_base(name='BaseModel', metadata=metadata)

View File

@ -1,9 +1,9 @@
from sqlalchemy import Column, Integer, String, Text from sqlalchemy import Column, Integer, String, Text
from tofu_api.common.database import Model from .base import BaseModel
class Task(Model): class Task(BaseModel):
""" """
Database model for tasks. Database model for tasks.
""" """