Knock out a migrations system for AnoSQL
This commit is contained in:
parent
1d028bbfbf
commit
9991eb4466
3 changed files with 286 additions and 0 deletions
6
projects/anosql-migrations/BUILD
Normal file
6
projects/anosql-migrations/BUILD
Normal file
|
@ -0,0 +1,6 @@
|
|||
py_project(
|
||||
name = "anosql-migrations",
|
||||
lib_deps = [
|
||||
py_requirement("anosql"),
|
||||
],
|
||||
)
|
183
projects/anosql-migrations/src/python/anosql_migrations.py
Normal file
183
projects/anosql-migrations/src/python/anosql_migrations.py
Normal file
|
@ -0,0 +1,183 @@
|
|||
"""Quick and dirty migrations for AnoSQL."""
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from hashlib import sha256
|
||||
import re
|
||||
import typing as t
|
||||
|
||||
import anosql
|
||||
from anosql.core import Queries, from_str
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MigrationDescriptor(t.NamedTuple):
|
||||
name: str
|
||||
sha256sum: str
|
||||
committed_at: t.Optional[datetime] = None
|
||||
|
||||
def __repr__(self):
|
||||
return f"MigrationDescriptor(name={self.name!r}, sha256sum='{self.sha256sum[:7]}...')"
|
||||
|
||||
def __hash__(self):
|
||||
return hash((self.name, self.sha256sum))
|
||||
|
||||
def __eq__(self, other):
|
||||
return (self.name, self.sha256sum) == (other.name, other.sha256sum)
|
||||
|
||||
|
||||
_SQL = """
|
||||
-- name: anosql_migrations_create_table#
|
||||
-- Create the migrations table for the anosql_migrations plugin.
|
||||
CREATE TABLE IF NOT EXISTS `anosql_migration` (
|
||||
`name` TEXT PRIMARY KEY NOT NULL
|
||||
, `committed_at` INT
|
||||
, `sha256sum` TEXT NOT NULL
|
||||
, CONSTRAINT `am_sha256sum_unique` UNIQUE (`sha256sum`)
|
||||
);
|
||||
|
||||
-- name: anosql_migrations_list
|
||||
-- List committed migrations
|
||||
SELECT
|
||||
`name`
|
||||
, `committed_at`
|
||||
, `sha256sum`
|
||||
FROM `anosql_migration`
|
||||
WHERE
|
||||
`committed_at` > 0
|
||||
ORDER BY
|
||||
`name` ASC
|
||||
;
|
||||
|
||||
-- name: anosql_migrations_get
|
||||
-- Get a given migration by name
|
||||
SELECT
|
||||
`name`
|
||||
, `committed_at`,
|
||||
, `sha256sum`
|
||||
FROM `anosql_migration`
|
||||
WHERE
|
||||
`name` = :name
|
||||
ORDER BY
|
||||
`rowid` ASC
|
||||
;
|
||||
|
||||
-- name: anosql_migrations_create<!
|
||||
-- Insert a migration, marking it as committed
|
||||
INSERT OR REPLACE INTO `anosql_migration` (
|
||||
`name`
|
||||
, `committed_at`
|
||||
, `sha256sum`
|
||||
) VALUES (
|
||||
:name
|
||||
, :date
|
||||
, :sha256sum
|
||||
);
|
||||
"""
|
||||
|
||||
|
||||
def with_migrations(driver_adapter, queries: Queries, conn) -> Queries:
|
||||
"""Initialize the migrations plugin."""
|
||||
|
||||
# Compile SQL as needed from the _SQL constant
|
||||
_q = from_str(_SQL, driver_adapter)
|
||||
|
||||
# Merge. Sigh.
|
||||
for _qname in _q.available_queries:
|
||||
queries.add_query(_qname, getattr(_q, _qname))
|
||||
|
||||
# Create the migrations table
|
||||
create_tables(queries, conn)
|
||||
|
||||
return queries
|
||||
|
||||
|
||||
def create_tables(queries: Queries, conn) -> None:
|
||||
"""Create the migrations table (if it doesn't exist)."""
|
||||
|
||||
if queries.anosql_migrations_create_table(conn):
|
||||
log.info("Created migrations table")
|
||||
|
||||
# Insert the bootstrap 'fixup' record
|
||||
execute_migration(queries, conn,
|
||||
MigrationDescriptor(
|
||||
name='anosql_migrations_create_table',
|
||||
sha256sum=sha256(queries.anosql_migrations_create_table.sql.encode("utf-8")).hexdigest()))
|
||||
|
||||
|
||||
def committed_migrations(queries: Queries, conn) -> t.Iterable[MigrationDescriptor]:
|
||||
"""Enumerate migrations committed to the database."""
|
||||
|
||||
for name, committed_at, sha256sum in queries.anosql_migrations_list(conn):
|
||||
yield MigrationDescriptor(
|
||||
name=name,
|
||||
committed_at=datetime.fromtimestamp(committed_at),
|
||||
sha256sum=sha256sum,
|
||||
)
|
||||
|
||||
|
||||
def available_migrations(queries: Queries, conn) -> t.Iterable[MigrationDescriptor]:
|
||||
"""Enumerate all available migrations, executed or no."""
|
||||
|
||||
for query_name in sorted(queries.available_queries):
|
||||
if not re.match("^migration", query_name):
|
||||
continue
|
||||
|
||||
if query_name.endswith("_cursor"):
|
||||
continue
|
||||
|
||||
# type: query_name: str
|
||||
query_fn = getattr(queries, query_name)
|
||||
# type: query_fn: t.Callable + {.__name__, .__doc__, .sql}
|
||||
yield MigrationDescriptor(
|
||||
name = query_name,
|
||||
committed_at = None,
|
||||
sha256sum = sha256(query_fn.sql.encode("utf-8")).hexdigest())
|
||||
|
||||
|
||||
def execute_migration(queries: Queries, conn, migration: MigrationDescriptor):
|
||||
"""Execute a given migration singularly."""
|
||||
|
||||
with conn:
|
||||
# Mark the migration as in flight
|
||||
queries.anosql_migrations_create(
|
||||
conn,
|
||||
# Args
|
||||
name=migration.name,
|
||||
date=-1,
|
||||
sha256sum=migration.sha256sum,
|
||||
)
|
||||
|
||||
# Run the migration function
|
||||
getattr(queries, migration.name)(conn)
|
||||
|
||||
# Mark the migration as committed
|
||||
queries.anosql_migrations_create(
|
||||
conn,
|
||||
# Args
|
||||
name=migration.name,
|
||||
date=int(datetime.utcnow().timestamp()),
|
||||
sha256sum=migration.sha256sum,
|
||||
)
|
||||
|
||||
|
||||
def run_migrations(queries, conn):
|
||||
"""Run all remaining migrations."""
|
||||
|
||||
avail = set(available_migrations(queries, conn))
|
||||
committed = set(committed_migrations(queries, conn))
|
||||
|
||||
for migration in sorted(avail, key=lambda m: m.name):
|
||||
if migration in committed:
|
||||
log.info(f"Skipping committed migration {migration.name}")
|
||||
|
||||
else:
|
||||
log.info(f"Beginning migration {migration.name}")
|
||||
|
||||
try:
|
||||
execute_migration(queries, conn, migration)
|
||||
except Exception as e:
|
||||
log.exception(f"Migration {migration.name} failed!", e)
|
||||
raise e
|
97
projects/anosql-migrations/test/python/test_migrations.py
Normal file
97
projects/anosql-migrations/test/python/test_migrations.py
Normal file
|
@ -0,0 +1,97 @@
|
|||
"""Tests covering the migrations framework."""
|
||||
|
||||
import sqlite3
|
||||
|
||||
import anosql
|
||||
from anosql.core import Queries
|
||||
import anosql_migrations
|
||||
import pytest
|
||||
|
||||
_SQL = """\
|
||||
-- name: migration_0000_create_kv
|
||||
CREATE TABLE kv (`id` INT, `key` TEXT, `value` TEXT);
|
||||
"""
|
||||
|
||||
def table_exists(conn, table_name):
|
||||
return list(conn.execute(f"""\
|
||||
SELECT (
|
||||
`name`
|
||||
)
|
||||
FROM `sqlite_master`
|
||||
WHERE
|
||||
`type` = 'table'
|
||||
AND `name` = '{table_name}'
|
||||
;"""))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def conn() -> sqlite3.Connection:
|
||||
"""Return an (empty) SQLite instance."""
|
||||
|
||||
return sqlite3.connect(":memory:")
|
||||
|
||||
|
||||
def test_connect(conn: sqlite3.Connection):
|
||||
"""Assert that the connection works and we can execute against it."""
|
||||
|
||||
assert list(conn.execute("SELECT 1;")) == [(1,),]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def queries(conn) -> Queries:
|
||||
"""A fixture for building a (migrations capable) anosql queries object."""
|
||||
|
||||
q = anosql.from_str(_SQL, "sqlite3")
|
||||
return anosql_migrations.with_migrations("sqlite3", q, conn)
|
||||
|
||||
|
||||
def test_queries(queries):
|
||||
"""Assert that we can construct a queries instance with migrations features."""
|
||||
|
||||
assert isinstance(queries, Queries)
|
||||
assert hasattr(queries, 'anosql_migrations_create_table')
|
||||
assert hasattr(queries, 'anosql_migrations_list')
|
||||
assert hasattr(queries, 'anosql_migrations_create')
|
||||
|
||||
|
||||
def test_migrations_create_table(conn, queries):
|
||||
"""Assert that the migrations system will (automagically) create the table."""
|
||||
|
||||
assert table_exists(conn, "anosql_migration"), "Migrations table did not create"
|
||||
|
||||
|
||||
def test_migrations_list(conn, queries):
|
||||
"""Test that we can list out available migrations."""
|
||||
|
||||
ms = list(anosql_migrations.available_migrations(queries, conn))
|
||||
assert any(m.name == "migration_0000_create_kv" for m in ms), f"Didn't find in {ms!r}"
|
||||
|
||||
|
||||
def test_committed_migrations(conn, queries):
|
||||
"""Assert that only the bootstrap migration is committed to the empty connection."""
|
||||
|
||||
ms = list(anosql_migrations.committed_migrations(queries, conn))
|
||||
assert len(ms) == 1
|
||||
assert ms[0].name == "anosql_migrations_create_table"
|
||||
|
||||
|
||||
def test_apply_migrations(conn, queries):
|
||||
"""Assert that if we apply migrations, the requisite table is created."""
|
||||
|
||||
anosql_migrations.run_migrations(queries, conn)
|
||||
assert table_exists(conn, "kv")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def migrated_conn(conn, queries):
|
||||
"""Generate a connection whithin which the `kv` migration has already been run."""
|
||||
|
||||
anosql_migrations.run_migrations(queries, conn)
|
||||
return conn
|
||||
|
||||
|
||||
def test_post_committed_migrations(migrated_conn, queries):
|
||||
"""Assert that the create_kv migration has been committed."""
|
||||
|
||||
ms = list(anosql_migrations.committed_migrations(queries, migrated_conn))
|
||||
assert any(m.name == "migration_0000_create_kv" for m in ms), "\n".join(migrated_conn.iterdump())
|
Loading…
Reference in a new issue