fix: add util module, w/ model_transaction_query()

just a basic implementation, will have to improve later
This commit is contained in:
Lance Edgar 2025-10-21 13:32:35 -05:00
parent 0e25cca0ba
commit 4db3fa5962
9 changed files with 281 additions and 13 deletions

View file

@ -0,0 +1,6 @@
``wutta_continuum.testing``
===========================
.. automodule:: wutta_continuum.testing
:members:

View file

@ -0,0 +1,6 @@
``wutta_continuum.util``
========================
.. automodule:: wutta_continuum.util
:members:

View file

@ -27,6 +27,7 @@ templates_path = ["_templates"]
exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"]
intersphinx_mapping = {
"sqlalchemy": ("http://docs.sqlalchemy.org/en/latest/", None),
"sqlalchemy-continuum": (
"https://sqlalchemy-continuum.readthedocs.io/en/latest/",
None,

View file

@ -29,3 +29,5 @@ This package adds data versioning/history for `WuttJamaican`_, using
api/wutta_continuum
api/wutta_continuum.app
api/wutta_continuum.conf
api/wutta_continuum.testing
api/wutta_continuum.util

View file

@ -45,7 +45,28 @@ class WuttaContinuumConfigExtension(WuttaConfigExtension):
key = "wutta_continuum"
def startup(self, config): # pylint: disable=empty-docstring
""" """
"""
Perform final configuration setup for app startup.
This will do nothing at all, unless config enables the
versioning feature. This must be done in config file and not
in DB settings table:
.. code-block:: ini
[wutta_continuum]
enable_versioning = true
Once enabled, this method will configure the integration, via
these steps:
1. call :func:`sqlalchemy-continuum:sqlalchemy_continuum.make_versioned()`
2. call :meth:`wuttjamaican:wuttjamaican.app.AppHandler.get_model()`
3. call :func:`sqlalchemy:sqlalchemy.orm.configure_mappers()`
For more about SQLAlchemy-Continuum see
:doc:`sqlalchemy-continuum:intro`.
"""
# only do this if config enables it
if not config.get_bool(
"wutta_continuum.enable_versioning", usedb=False, default=False
@ -60,14 +81,17 @@ class WuttaContinuumConfigExtension(WuttaConfigExtension):
)
plugin = load_object(spec)
# tell sqlalchemy-continuum to do its thing
app = config.get_app()
if "model" in app.__dict__:
raise RuntimeError("something not right, app already has model")
# let sqlalchemy-continuum do its thing
make_versioned(plugins=[plugin()])
# nb. must load the model before configuring mappers
app = config.get_app()
model = app.model # pylint: disable=unused-variable
# must load model *between* prev and next calls
app.get_model()
# tell sqlalchemy to do its thing
# let sqlalchemy do its thing
configure_mappers()

View file

@ -0,0 +1,92 @@
# -*- coding: utf-8; -*-
################################################################################
#
# Wutta-Continuum -- SQLAlchemy Versioning for Wutta Framework
# Copyright © 2024-2025 Lance Edgar
#
# This file is part of Wutta Framework.
#
# Wutta Framework is free software: you can redistribute it and/or modify it
# under the terms of the GNU General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option) any
# later version.
#
# Wutta Framework is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
# more details.
#
# You should have received a copy of the GNU General Public License along with
# Wutta Framework. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Testing utilities
"""
import sys
import sqlalchemy_continuum as continuum
from wuttjamaican.testing import DataTestCase
from wutta_continuum.conf import WuttaContinuumConfigExtension
class VersionTestCase(DataTestCase):
"""
Base class for test suites requiring the SQLAlchemy-Continuum
versioning feature.
This inherits from
:class:`~wuttjamaican:wuttjamaican.testing.DataTestCase`.
"""
def setUp(self):
self.setup_versioning()
def setup_versioning(self):
"""
Do setup tasks relating to this class, as well as its parent(s):
* call :meth:`wuttjamaican:wuttjamaican.testing.DataTestCase.setup_db()`
* this will in turn call :meth:`make_config()`
"""
self.setup_db()
def tearDown(self):
self.teardown_versioning()
def teardown_versioning(self):
"""
Do teardown tasks relating to this class, as well as its parent(s):
* call :func:`sqlalchemy-continuum:sqlalchemy_continuum.remove_versioning()`
* call :meth:`wuttjamaican:wuttjamaican.testing.DataTestCase.teardown_db()`
"""
continuum.remove_versioning()
continuum.versioning_manager.transaction_cls = continuum.TransactionFactory()
self.teardown_db()
def make_config(self, **kwargs):
"""
Make and customize the config object.
We override this to explicitly enable the versioning feature.
"""
config = super().make_config(**kwargs)
config.setdefault("wutta_continuum.enable_versioning", "true")
# nb. must purge model classes from sys.modules, so they will
# be reloaded and sqlalchemy-continuum can reconfigure
if "wuttjamaican.db.model" in sys.modules:
del sys.modules["wuttjamaican.db.model.batch"]
del sys.modules["wuttjamaican.db.model.upgrades"]
del sys.modules["wuttjamaican.db.model.auth"]
del sys.modules["wuttjamaican.db.model.base"]
del sys.modules["wuttjamaican.db.model"]
ext = WuttaContinuumConfigExtension()
ext.startup(config)
return config

View file

@ -0,0 +1,85 @@
# -*- coding: utf-8; -*-
################################################################################
#
# Wutta-Continuum -- SQLAlchemy Versioning for Wutta Framework
# Copyright © 2024-2025 Lance Edgar
#
# This file is part of Wutta Framework.
#
# Wutta Framework is free software: you can redistribute it and/or modify it
# under the terms of the GNU General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option) any
# later version.
#
# Wutta Framework is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
# more details.
#
# You should have received a copy of the GNU General Public License along with
# Wutta Framework. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
SQLAlchemy-Continuum utilities
"""
import sqlalchemy as sa
from sqlalchemy import orm
import sqlalchemy_continuum as continuum
OPERATION_TYPES = {
continuum.Operation.INSERT: "INSERT",
continuum.Operation.UPDATE: "UPDATE",
continuum.Operation.DELETE: "DELETE",
}
def render_operation_type(operation_type):
"""
Render a SQLAlchemy-Continuum ``operation_type`` from a version
record, for display to user.
:param operation_type: Value of same name from a version record.
Must be one of:
* :attr:`sqlalchemy_continuum:sqlalchemy_continuum.operation.Operation.INSERT`
* :attr:`sqlalchemy_continuum:sqlalchemy_continuum.operation.Operation.UPDATE`
* :attr:`sqlalchemy_continuum:sqlalchemy_continuum.operation.Operation.DELETE`
:returns: Display name for the operation type, as string.
"""
return OPERATION_TYPES[operation_type]
def model_transaction_query(instance, session=None, model_class=None):
"""
Make a query capable of finding all SQLAlchemy-Continuum
``transaction`` records associated with the given model instance.
:param instance: Instance of a versioned :term:`data model`.
:param session: Optional :term:`db session` to use for the query.
If not specified, will be obtained from the ``instance``.
:param model_class: Optional :term:`data model` class to query.
If not specified, will be obtained from the ``instance``.
:returns: SQLAlchemy query object. Note that it will *not* have an
``ORDER BY`` clause yet.
"""
if not session:
session = orm.object_session(instance)
if not model_class:
model_class = type(instance)
txncls = continuum.transaction_class(model_class)
vercls = continuum.version_class(model_class)
query = session.query(txncls).join(
vercls,
sa.and_(vercls.uuid == instance.uuid, vercls.transaction_id == txncls.id),
)
return query

View file

@ -4,33 +4,45 @@ import socket
from unittest.mock import patch
from wuttjamaican.testing import DataTestCase
from wuttjamaican.testing import ConfigTestCase, DataTestCase
from wutta_continuum import conf as mod
class TestWuttaContinuumConfigExtension(DataTestCase):
class TestWuttaContinuumConfigExtension(ConfigTestCase):
def make_extension(self):
return mod.WuttaContinuumConfigExtension()
def test_startup(self):
def test_startup_without_versioning(self):
ext = self.make_extension()
with patch.object(mod, "make_versioned") as make_versioned:
with patch.object(mod, "configure_mappers") as configure_mappers:
# nothing happens by default
ext.startup(self.config)
make_versioned.assert_not_called()
configure_mappers.assert_not_called()
# but will if we enable it in config
def test_startup_with_versioning(self):
ext = self.make_extension()
with patch.object(mod, "make_versioned") as make_versioned:
with patch.object(mod, "configure_mappers") as configure_mappers:
self.config.setdefault("wutta_continuum.enable_versioning", "true")
ext.startup(self.config)
make_versioned.assert_called_once()
configure_mappers.assert_called_once_with()
def test_startup_with_error(self):
ext = self.make_extension()
with patch.object(mod, "make_versioned") as make_versioned:
with patch.object(mod, "configure_mappers") as configure_mappers:
self.config.setdefault("wutta_continuum.enable_versioning", "true")
# nb. it is an error for the model to be loaded prior to
# calling make_versioned() for sqlalchemy-continuum
self.app.get_model()
self.assertRaises(RuntimeError, ext.startup, self.config)
make_versioned.assert_not_called()
configure_mappers.assert_not_called()
class TestWuttaContinuumPlugin(DataTestCase):

40
tests/test_util.py Normal file
View file

@ -0,0 +1,40 @@
# -*- coding: utf-8; -*-
from unittest import TestCase
import sqlalchemy_continuum as continuum
from wutta_continuum import util as mod
from wutta_continuum.testing import VersionTestCase
class TestRenderOperationType(TestCase):
def test_basic(self):
self.assertEqual(
mod.render_operation_type(continuum.Operation.INSERT), "INSERT"
)
self.assertEqual(
mod.render_operation_type(continuum.Operation.UPDATE), "UPDATE"
)
self.assertEqual(
mod.render_operation_type(continuum.Operation.DELETE), "DELETE"
)
class TestModelTransactionQuery(VersionTestCase):
def test_basic(self):
model = self.app.model
user = model.User(username="fred")
self.session.add(user)
self.session.commit()
query = mod.model_transaction_query(user)
self.assertEqual(query.count(), 1)
txn = query.one()
UserVersion = continuum.version_class(model.User)
version = self.session.query(UserVersion).one()
self.assertIs(version.transaction, txn)