1

Archive Project

# Conflicts:
#	.gitignore
This commit is contained in:
Kim Wittenburg
2020-07-22 11:06:03 +02:00
parent 1d58ad24b4
commit 2f4c6d4908
7 changed files with 299 additions and 0 deletions

85
alembic_data/__init__.py Normal file
View File

@@ -0,0 +1,85 @@
"""Alembic-Data is an extension for alembic that creates automatic data migrations.
Data migrations (in contrast to schema migrations) are performed on the contents of database tables. They allow you to
create, update, and delete rows inside the database. Alembic-Data provides a framework to generate these migrations
automatically.
Using Alembic-Data is very simple. You can use the `register_object` function to register an object in the database.
Alembic data will make sure that the next migration creates the registered object. This is normally only useful for
tables that do not contain any user data (as the migrations would have to be generated depending on the actual data).
Instead the typical usecase is to combine the `register_object` function with the `@managed_model` decorator to tell
Alembic-Data that a model will be managed exclusively programmatically.
The `@managed_model` decorator is used on your declarative model classes. For any `@managed_model` Alembic-Data will
created your `register_object` and will additionally delete any rows from that database that do not belong to any
registered object.
"""
from typing import List, Any, Dict
from alembic.autogenerate import comparators
from alembic.autogenerate.api import AutogenContext
from alembic.operations.ops import UpgradeOps
from sqlalchemy.ext.declarative import DeclarativeMeta
from .ops import *
__all__ = ["managed_model", "register_object"]
def managed_model(cls: DeclarativeMeta) -> DeclarativeMeta:
"""Marks this model as a managed model.
Class decorator to be used on declarative SQLAlchemy models. Using this decorator causes alembic-data to assume that
objects for this model will only be created through code and the `register_object` function.
:param cls: The class to be decorated. Must be a SQLAlchemy declarative class.
:return: The decorated class, unchanged.
"""
cls.metadata.info.setdefault("managed models", set()).add(cls) # type: ignore
return cls
def register_object(cls: DeclarativeMeta, **kwargs) -> None:
"""
Registers an object to be created during a database migration.
:param cls: The class of the declarative model class. This is used to determine which table to operate on.
:param kwargs: Arguments used to create the object. This must uniquely identify the object as it is also used to
determine whether the object already exists.
"""
cls.metadata.info.setdefault("objects", dict()).setdefault(cls, list()).append(kwargs) # type: ignore
@comparators.dispatch_for("schema")
def compare_objects(autogen_context: AutogenContext, upgrade_ops: UpgradeOps, schemas: List[str]) -> None:
"""
Generates appropriate data migrations for the `autogen_context` and adds them to `upgrade_ops`.
"""
managed_models = autogen_context.metadata.info.get('managed models', set())
objects: Dict[DeclarativeMeta, Any] = autogen_context.metadata.info.get('objects', dict())
remaining_objects = {cls: set(cls.query.all()) # type: ignore
for cls in objects.keys()
if cls in managed_models
and autogen_context.dialect.has_table(autogen_context.connection.engine,
cls.__table__.name)} # type: ignore
# Add new objects
for cls, datas in objects.items():
for data in datas:
existing_object = (cls.query.filter_by(**data).first() # type: ignore
if autogen_context.dialect.has_table(autogen_context.connection.engine,
cls.__table__.name) # type: ignore
else None)
if existing_object:
remaining_objects.get(cls, set()).discard(existing_object)
else:
print("Adding " + cls.__name__ + ": " + str(data))
upgrade_ops.ops.append(InsertRowOp(cls.__table__.name, **data)) # type: ignore
# Remove old objects
for cls, object_set in remaining_objects.items():
for o in object_set:
values = {key: getattr(o, key) for key in o.__mapper__.column_attrs.keys()}
print("Deleting " + cls.__name__ + ": " + str(values))
upgrade_ops.ops.append(DeleteRowOp(cls.__table__.name, **values)) # type: ignore

View File

@@ -0,0 +1,5 @@
"""Operations used by Alembic-Data to create, update and delete objects."""
from .delete_row import *
from .insert_row import *
from .update_row import *

View File

@@ -0,0 +1,54 @@
from typing import Any, Mapping, List
import sqlalchemy
from alembic.autogenerate import renderers
from alembic.autogenerate.api import AutogenContext
from alembic.operations import Operations, MigrateOperation
from sqlalchemy import and_
__all__ = ["DeleteRowOp"]
from sqlalchemy.sql.elements import ColumnClause
@Operations.register_operation("delete_row")
class DeleteRowOp(MigrateOperation):
"""This `MigrationOperation` deletes a rows from a database table."""
def __init__(self, table_name: str, **kwargs: Mapping[str, Any]) -> None:
"""Creates a new `DeleteRowOp`.
:param table_name: The name of the table to delete the row from.
:param kwargs: The values used to identify the exact row. Keys must correspond to column names in the table and
values to their values.
"""
self.table_name = table_name
self.values = kwargs
@classmethod
def delete_row(cls, operations: Operations, table_name: str, **kwargs: Mapping[str, Any]):
"""Factory method for `DeleteRowOp`."""
op = DeleteRowOp(table_name, **kwargs)
return operations.invoke(op)
def reverse(self) -> MigrateOperation:
"""Reverses the insert row operation by inserting the respective row."""
from .insert_row import InsertRowOp
return InsertRowOp(self.table_name, **self.values)
@Operations.implementation_for(DeleteRowOp)
def delete_row(operations, operation: DeleteRowOp) -> None:
columns: List[ColumnClause] = [sqlalchemy.column(key) for key in operation.values.keys()]
table = sqlalchemy.table(operation.table_name, *columns)
where_clauses = [table.columns[key] == value for (key, value) in operation.values.items()]
sql = table.delete().where(and_(*where_clauses))
operations.execute(sql)
@renderers.dispatch_for(DeleteRowOp)
def render_delete_row(autogen_context: AutogenContext, op: DeleteRowOp) -> str:
return "op.delete_row(%r, **%r)" % (
op.table_name,
op.values
)

View File

@@ -0,0 +1,51 @@
from typing import Any, Mapping, List
import sqlalchemy
from alembic.autogenerate import renderers
from alembic.autogenerate.api import AutogenContext
from alembic.operations import Operations, MigrateOperation
__all__ = ["InsertRowOp"]
from sqlalchemy.sql.elements import ColumnClause
@Operations.register_operation("insert_row")
class InsertRowOp(MigrateOperation):
"""This `MigrationOperation` adds rows to a database table."""
def __init__(self, table_name: str, **kwargs: Mapping[str, Any]) -> None:
"""Creates a new `InsertRowOp`.
:param table_name: The name of the table to insert the row.
:param kwargs: The values to insert into the table. Keys must correspond to column names in the table.
"""
self.table_name = table_name
self.values = kwargs
@classmethod
def insert_row(cls, operations: Operations, table_name: str, **kwargs: Mapping[str, Any]):
"""Factory method for `InsertRowOp`."""
op = InsertRowOp(table_name, **kwargs)
return operations.invoke(op)
def reverse(self) -> MigrateOperation:
"""Reverses the insert row operation by deleting the respective row."""
from .delete_row import DeleteRowOp
return DeleteRowOp(self.table_name, **self.values)
@Operations.implementation_for(InsertRowOp)
def insert_row(operations: Operations, operation: InsertRowOp) -> None:
columns: List[ColumnClause] = [sqlalchemy.column(key) for key in operation.values.keys()]
table = sqlalchemy.table(operation.table_name, *columns)
sql = table.insert().values(**operation.values)
operations.execute(sql)
@renderers.dispatch_for(InsertRowOp)
def render_insert_row(autogen_context: AutogenContext, op: InsertRowOp) -> str:
return "op.insert_row(%r, **%r)" % (
op.table_name,
op.values
)

View File

@@ -0,0 +1,57 @@
from typing import Any, Mapping, List
import sqlalchemy
from alembic.autogenerate import renderers
from alembic.autogenerate.api import AutogenContext
from alembic.operations import Operations, MigrateOperation
__all__ = ["UpdateRowOp"]
from sqlalchemy.sql.elements import ColumnClause
@Operations.register_operation("update_row")
class UpdateRowOp(MigrateOperation):
"""This `MigrationOperation` updates rows in a database table."""
def __init__(self, table_name: str, new_values: Mapping[str, Any], old_values: Mapping[str, Any]) -> None:
"""Creates a new `InsertRowOp`.
:param table_name: The name of the table to update values in.
:param new_values: The new values to update in the table. Keys must correspond to column names in the table and
values to their values.
:param old_values: The old values used to identify the row that should be updated.
"""
self.table_name = table_name
self.new_values = new_values
self.old_values = old_values
@classmethod
def update_row(cls, operations: Operations, table_name: str, new_values: Mapping[str, Any],
old_values: Mapping[str, Any]):
"""Factory method for `UpdateRowOp`."""
op = UpdateRowOp(table_name, new_values, old_values)
return operations.invoke(op)
def reverse(self) -> MigrateOperation:
"""Reverses the update operation by reverting back to the old values."""
return UpdateRowOp(self.table_name, self.old_values, self.new_values)
@Operations.implementation_for(UpdateRowOp)
def update_row(operations: Operations, operation: UpdateRowOp) -> None:
columns: List[ColumnClause] = [sqlalchemy.column(key) for key in operation.values.keys()]
table = sqlalchemy.table(operation.table_name, *columns)
where_clauses = [table.columns[key] == value for (key, value) in operation.old_values.items()]
operations.execute(
table.update().where(*where_clauses).values(**operation.new_values)
)
@renderers.dispatch_for(UpdateRowOp)
def render_update_row(autogen_context: AutogenContext, op: UpdateRowOp) -> str:
return "op.update_row(%r, **%r, **%r)" % (
op.table_name,
op.new_values,
op.old_values
)

0
alembic_data/py.typed Normal file
View File

47
setup.py Normal file
View File

@@ -0,0 +1,47 @@
"""
Alembic-Data
------------
Alembic-Data is an extension for alembic that allows the automatic creation of database content (in addition to schema).
It adds
"""
from setuptools import setup
setup(
name='Alembic-Data',
version='0.9',
url='https://gitlab.com/Codello/alembic-data/',
license='MIT',
author='Kim Wittenburg',
author_email='codello@wittenburg.kim',
description='Data Migrations for Alembic.',
long_description=__doc__,
packages=['alembic_data'],
package_data={"alembic_data": ["py.typed"]},
zip_safe=False,
include_package_data=True,
platforms='any',
install_requires=[
'alembic'
],
extras_require={
'types': [
'sqlalchemy-stubs'
]
},
test_requirements=[
'pytest',
'pytest-pep8',
'pytest-cov',
],
classifiers=[
'Development Status :: 3 - Alpha',
'Environment :: Plugins',
'Intended Audience :: Developers',
'License :: OSI Approved :: MIT License',
'Operating System :: OS Independent',
'Programming Language :: Python',
'Topic :: Database',
'Topic :: Software Development :: Libraries :: Python Modules'
]
)