fix: add util module, w/ model_transaction_query()
				
					
				
			just a basic implementation, will have to improve later
This commit is contained in:
		
							parent
							
								
									0e25cca0ba
								
							
						
					
					
						commit
						4db3fa5962
					
				
					 9 changed files with 281 additions and 13 deletions
				
			
		
							
								
								
									
										6
									
								
								docs/api/wutta_continuum.testing.rst
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								docs/api/wutta_continuum.testing.rst
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,6 @@ | ||||||
|  | 
 | ||||||
|  | ``wutta_continuum.testing`` | ||||||
|  | =========================== | ||||||
|  | 
 | ||||||
|  | .. automodule:: wutta_continuum.testing | ||||||
|  |    :members: | ||||||
							
								
								
									
										6
									
								
								docs/api/wutta_continuum.util.rst
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								docs/api/wutta_continuum.util.rst
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,6 @@ | ||||||
|  | 
 | ||||||
|  | ``wutta_continuum.util`` | ||||||
|  | ======================== | ||||||
|  | 
 | ||||||
|  | .. automodule:: wutta_continuum.util | ||||||
|  |    :members: | ||||||
|  | @ -27,6 +27,7 @@ templates_path = ["_templates"] | ||||||
| exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] | exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] | ||||||
| 
 | 
 | ||||||
| intersphinx_mapping = { | intersphinx_mapping = { | ||||||
|  |     "sqlalchemy": ("http://docs.sqlalchemy.org/en/latest/", None), | ||||||
|     "sqlalchemy-continuum": ( |     "sqlalchemy-continuum": ( | ||||||
|         "https://sqlalchemy-continuum.readthedocs.io/en/latest/", |         "https://sqlalchemy-continuum.readthedocs.io/en/latest/", | ||||||
|         None, |         None, | ||||||
|  |  | ||||||
|  | @ -29,3 +29,5 @@ This package adds data versioning/history for `WuttJamaican`_, using | ||||||
|    api/wutta_continuum |    api/wutta_continuum | ||||||
|    api/wutta_continuum.app |    api/wutta_continuum.app | ||||||
|    api/wutta_continuum.conf |    api/wutta_continuum.conf | ||||||
|  |    api/wutta_continuum.testing | ||||||
|  |    api/wutta_continuum.util | ||||||
|  |  | ||||||
|  | @ -45,7 +45,28 @@ class WuttaContinuumConfigExtension(WuttaConfigExtension): | ||||||
|     key = "wutta_continuum" |     key = "wutta_continuum" | ||||||
| 
 | 
 | ||||||
|     def startup(self, config):  # pylint: disable=empty-docstring |     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 |         # only do this if config enables it | ||||||
|         if not config.get_bool( |         if not config.get_bool( | ||||||
|             "wutta_continuum.enable_versioning", usedb=False, default=False |             "wutta_continuum.enable_versioning", usedb=False, default=False | ||||||
|  | @ -60,14 +81,17 @@ class WuttaContinuumConfigExtension(WuttaConfigExtension): | ||||||
|         ) |         ) | ||||||
|         plugin = load_object(spec) |         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()]) |         make_versioned(plugins=[plugin()]) | ||||||
| 
 | 
 | ||||||
|         # nb. must load the model before configuring mappers |         # must load model *between* prev and next calls | ||||||
|         app = config.get_app() |         app.get_model() | ||||||
|         model = app.model  # pylint: disable=unused-variable |  | ||||||
| 
 | 
 | ||||||
|         # tell sqlalchemy to do its thing |         # let sqlalchemy do its thing | ||||||
|         configure_mappers() |         configure_mappers() | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
							
								
								
									
										92
									
								
								src/wutta_continuum/testing.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										92
									
								
								src/wutta_continuum/testing.py
									
										
									
									
									
										Normal 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 | ||||||
							
								
								
									
										85
									
								
								src/wutta_continuum/util.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										85
									
								
								src/wutta_continuum/util.py
									
										
									
									
									
										Normal 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 | ||||||
|  | @ -4,33 +4,45 @@ import socket | ||||||
| 
 | 
 | ||||||
| from unittest.mock import patch | from unittest.mock import patch | ||||||
| 
 | 
 | ||||||
| from wuttjamaican.testing import DataTestCase | from wuttjamaican.testing import ConfigTestCase, DataTestCase | ||||||
| 
 | 
 | ||||||
| from wutta_continuum import conf as mod | from wutta_continuum import conf as mod | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| class TestWuttaContinuumConfigExtension(DataTestCase): | class TestWuttaContinuumConfigExtension(ConfigTestCase): | ||||||
| 
 | 
 | ||||||
|     def make_extension(self): |     def make_extension(self): | ||||||
|         return mod.WuttaContinuumConfigExtension() |         return mod.WuttaContinuumConfigExtension() | ||||||
| 
 | 
 | ||||||
|     def test_startup(self): |     def test_startup_without_versioning(self): | ||||||
|         ext = self.make_extension() |         ext = self.make_extension() | ||||||
| 
 |  | ||||||
|         with patch.object(mod, "make_versioned") as make_versioned: |         with patch.object(mod, "make_versioned") as make_versioned: | ||||||
|             with patch.object(mod, "configure_mappers") as configure_mappers: |             with patch.object(mod, "configure_mappers") as configure_mappers: | ||||||
| 
 |  | ||||||
|                 # nothing happens by default |  | ||||||
|                 ext.startup(self.config) |                 ext.startup(self.config) | ||||||
|                 make_versioned.assert_not_called() |                 make_versioned.assert_not_called() | ||||||
|                 configure_mappers.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") |                 self.config.setdefault("wutta_continuum.enable_versioning", "true") | ||||||
|                 ext.startup(self.config) |                 ext.startup(self.config) | ||||||
|                 make_versioned.assert_called_once() |                 make_versioned.assert_called_once() | ||||||
|                 configure_mappers.assert_called_once_with() |                 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): | class TestWuttaContinuumPlugin(DataTestCase): | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
							
								
								
									
										40
									
								
								tests/test_util.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										40
									
								
								tests/test_util.py
									
										
									
									
									
										Normal 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) | ||||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue