diff --git a/docs/api/wuttjamaican.db.handler.rst b/docs/api/wuttjamaican.db.handler.rst new file mode 100644 index 0000000..6ae56d3 --- /dev/null +++ b/docs/api/wuttjamaican.db.handler.rst @@ -0,0 +1,6 @@ + +``wuttjamaican.db.handler`` +=========================== + +.. automodule:: wuttjamaican.db.handler + :members: diff --git a/docs/glossary.rst b/docs/glossary.rst index 777ba0b..b6cb19f 100644 --- a/docs/glossary.rst +++ b/docs/glossary.rst @@ -128,6 +128,14 @@ Glossary Most :term:`apps` will have at least one :term:`app database`. See also :doc:`narr/db/index`. + db handler + The :term:`handler` responsible for various operations involving + the :term:`app database` (and possibly other :term:`databases + `). + + See also the :class:`~wuttjamaican.db.handler.DatabaseHandler` + base class. + db session The "session" is a SQLAlchemy abstraction for an open database connection, essentially. diff --git a/docs/index.rst b/docs/index.rst index cd2064f..c62b34a 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -71,6 +71,7 @@ Contents api/wuttjamaican.conf api/wuttjamaican.db api/wuttjamaican.db.conf + api/wuttjamaican.db.handler api/wuttjamaican.db.model api/wuttjamaican.db.model.auth api/wuttjamaican.db.model.base diff --git a/src/wuttjamaican/app.py b/src/wuttjamaican/app.py index 7a7000c..19766eb 100644 --- a/src/wuttjamaican/app.py +++ b/src/wuttjamaican/app.py @@ -81,6 +81,7 @@ class AppHandler: default_model_spec = 'wuttjamaican.db.model' default_enum_spec = 'wuttjamaican.enum' default_auth_handler_spec = 'wuttjamaican.auth:AuthHandler' + default_db_handler_spec = 'wuttjamaican.db.handler:DatabaseHandler' default_email_handler_spec = 'wuttjamaican.email:EmailHandler' default_install_handler_spec = 'wuttjamaican.install:InstallHandler' default_people_handler_spec = 'wuttjamaican.people:PeopleHandler' @@ -493,7 +494,7 @@ class AppHandler: def make_true_uuid(self): """ - Generate a new v7 UUID value. + Generate a new UUID value. By default this simply calls :func:`wuttjamaican.util.make_true_uuid()`. @@ -514,7 +515,7 @@ class AppHandler: def make_uuid(self): """ - Generate a new v7 UUID value. + Generate a new UUID value. By default this simply calls :func:`wuttjamaican.util.make_uuid()`. @@ -730,6 +731,19 @@ class AppHandler: self.handlers['auth'] = factory(self.config, **kwargs) return self.handlers['auth'] + def get_db_handler(self, **kwargs): + """ + Get the configured :term:`db handler`. + + :rtype: :class:`~wuttjamaican.db.handler.DatabaseHandler` + """ + if 'db' not in self.handlers: + spec = self.config.get(f'{self.appname}.db.handler', + default=self.default_db_handler_spec) + factory = self.load_object(spec) + self.handlers['db'] = factory(self.config, **kwargs) + return self.handlers['db'] + def get_email_handler(self, **kwargs): """ Get the configured :term:`email handler`. diff --git a/src/wuttjamaican/db/handler.py b/src/wuttjamaican/db/handler.py new file mode 100644 index 0000000..7c745d8 --- /dev/null +++ b/src/wuttjamaican/db/handler.py @@ -0,0 +1,73 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# WuttJamaican -- Base package for Wutta Framework +# Copyright © 2024 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 . +# +################################################################################ +""" +Database Handler +""" + +import sqlalchemy as sa + +from wuttjamaican.app import GenericHandler + + +class DatabaseHandler(GenericHandler): + """ + Base class and default implementation for the :term:`db handler`. + """ + + def next_counter_value(self, session, key): + """ + Return the next counter value for the given key. + + If the DB backend is PostgreSQL, then a proper "sequence" is + used for the counter. + + All other backends use a "fake" sequence by creating a + dedicated table with auto-increment primary key, to provide + the counter. + + :param session: Current :term:`db session`. + + :param key: Unique key indicating the counter for which the + next value should be fetched. + + :returns: Next value as integer. + """ + dialect = session.bind.url.get_dialect().name + + # postgres uses "true" native sequence + if dialect == 'postgresql': + sql = f"create sequence if not exists {key}_seq" + session.execute(sa.text(sql)) + sql = f"select nextval('{key}_seq')" + value = session.execute(sa.text(sql)).scalar() + return value + + # otherwise use "magic" workaround + engine = session.bind + metadata = sa.MetaData() + table = sa.Table(f'_counter_{key}', metadata, + sa.Column('value', sa.Integer(), primary_key=True)) + table.create(engine, checkfirst=True) + with engine.begin() as cxn: + result = cxn.execute(table.insert()) + return result.lastrowid diff --git a/tests/db/test_handler.py b/tests/db/test_handler.py new file mode 100644 index 0000000..e28e813 --- /dev/null +++ b/tests/db/test_handler.py @@ -0,0 +1,64 @@ +# -*- coding: utf-8; -*- + +from unittest.mock import patch, MagicMock + +from wuttjamaican.testing import DataTestCase + +try: + import sqlalchemy as sa + from wuttjamaican.db import handler as mod +except ImportError: + pass +else: + + class TestDatabaseHandler(DataTestCase): + + def make_handler(self, **kwargs): + return mod.DatabaseHandler(self.config, **kwargs) + + def test_next_counter_value_sqlite(self): + handler = self.make_handler() + + # counter table should not exist yet + metadata = sa.MetaData() + metadata.reflect(self.session.bind) + self.assertNotIn('_counter_testing', metadata.tables) + + # using sqlite as backend, should make table for counter + value = handler.next_counter_value(self.session, 'testing') + self.assertEqual(value, 1) + + # counter table should exist now + metadata.reflect(self.session.bind) + self.assertIn('_counter_testing', metadata.tables) + + # counter increments okay + value = handler.next_counter_value(self.session, 'testing') + self.assertEqual(value, 2) + value = handler.next_counter_value(self.session, 'testing') + self.assertEqual(value, 3) + + def test_next_counter_value_postgres(self): + handler = self.make_handler() + + # counter table should not exist + metadata = sa.MetaData() + metadata.reflect(self.session.bind) + self.assertNotIn('_counter_testing', metadata.tables) + + # nb. we have to pretty much mock this out, can't really + # test true sequence behavior for postgres since tests are + # using sqlite backend. + + # using postgres as backend, should use "sequence" + with patch.object(self.session.bind.url, 'get_dialect') as get_dialect: + get_dialect.return_value.name = 'postgresql' + with patch.object(self.session, 'execute') as execute: + execute.return_value.scalar.return_value = 1 + value = handler.next_counter_value(self.session, 'testing') + self.assertEqual(value, 1) + execute.return_value.scalar.assert_called_once_with() + + # counter table should still not exist + metadata.reflect(self.session.bind) + self.assertNotIn('_counter_testing', metadata.tables) diff --git a/tests/test_app.py b/tests/test_app.py index be67509..ca8c952 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -448,6 +448,15 @@ app_title = WuttaTest auth = self.app.get_auth_handler() self.assertIsInstance(auth, AuthHandler) + def test_get_db_handler(self): + try: + from wuttjamaican.db.handler import DatabaseHandler + except ImportError: + pytest.skip("test not relevant without sqlalchemy") + + db = self.app.get_db_handler() + self.assertIsInstance(db, DatabaseHandler) + def test_get_email_handler(self): from wuttjamaican.email import EmailHandler