diff --git a/src/wuttjamaican/app.py b/src/wuttjamaican/app.py index 8e4d891..6a5f288 100644 --- a/src/wuttjamaican/app.py +++ b/src/wuttjamaican/app.py @@ -30,7 +30,7 @@ import sys import warnings from wuttjamaican.util import (load_entry_points, load_object, - make_title, make_uuid, + make_title, make_uuid, make_true_uuid, progress_loop, resource_path) @@ -491,14 +491,44 @@ class AppHandler: """ return make_title(text) + def make_true_uuid(self): + """ + Generate a new v7 UUID value. + + By default this simply calls + :func:`wuttjamaican.util.make_true_uuid()`. + + :returns: :class:`python:uuid.UUID` instance + + .. warning:: + + For now, callers should use this method when they want a + proper UUID instance, whereas :meth:`make_uuid()` will + always return a string. + + However once all dependent logic has been refactored to + support proper UUID data type, then ``make_uuid()`` will + return those and this method will eventually be removed. + """ + return make_true_uuid() + def make_uuid(self): """ - Generate a new UUID value. + Generate a new v7 UUID value. By default this simply calls :func:`wuttjamaican.util.make_uuid()`. :returns: UUID value as 32-character string. + + .. warning:: + + For now, this method always returns a string. + + However once all dependent logic has been refactored to + support proper UUID data type, then this method will return + those and the :meth:`make_true_uuid()` method will + eventually be removed. """ return make_uuid() diff --git a/src/wuttjamaican/cli/make_uuid.py b/src/wuttjamaican/cli/make_uuid.py index 21b9cf7..84f4182 100644 --- a/src/wuttjamaican/cli/make_uuid.py +++ b/src/wuttjamaican/cli/make_uuid.py @@ -40,5 +40,5 @@ def make_uuid( """ config = ctx.parent.wutta_config app = config.get_app() - uuid = app.make_uuid() + uuid = app.make_true_uuid() sys.stdout.write(f"{uuid}\n") diff --git a/src/wuttjamaican/db/alembic/script.py.mako b/src/wuttjamaican/db/alembic/script.py.mako index fbc4b07..b49109f 100644 --- a/src/wuttjamaican/db/alembic/script.py.mako +++ b/src/wuttjamaican/db/alembic/script.py.mako @@ -9,6 +9,7 @@ from typing import Sequence, Union from alembic import op import sqlalchemy as sa +import wuttjamaican.db.util ${imports if imports else ""} # revision identifiers, used by Alembic. diff --git a/src/wuttjamaican/db/alembic/versions/6be0ed225f4d_convert_uuid_types.py b/src/wuttjamaican/db/alembic/versions/6be0ed225f4d_convert_uuid_types.py new file mode 100644 index 0000000..26db2ce --- /dev/null +++ b/src/wuttjamaican/db/alembic/versions/6be0ed225f4d_convert_uuid_types.py @@ -0,0 +1,66 @@ +"""convert uuid types + +Revision ID: 6be0ed225f4d +Revises: 6bf900765500 +Create Date: 2024-11-30 17:03:08.930050 + +""" +import uuid as _uuid +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import wuttjamaican.db.util + + +# revision identifiers, used by Alembic. +revision: str = '6be0ed225f4d' +down_revision: Union[str, None] = '6bf900765500' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + + # upgrade (convert uuid) + op.add_column('upgrade', sa.Column('uuid_true', wuttjamaican.db.util.UUID(), nullable=True)) + upgrade = sa.sql.table('upgrade', + sa.sql.column('uuid'), + sa.sql.column('uuid_true')) + + engine = op.get_bind() + cursor = engine.execute(upgrade.select()) + for row in cursor.fetchall(): + if row['uuid']: + uuid_true = _uuid.UUID(row['uuid']) + engine.execute(upgrade.update()\ + .where(upgrade.c.uuid == row['uuid'])\ + .values({'uuid_true': uuid_true})) + + op.drop_constraint('pk_upgrade', 'upgrade', type_='primary') + op.drop_column('upgrade', 'uuid') + op.alter_column('upgrade', 'uuid_true', new_column_name='uuid') + op.create_primary_key('pk_upgrade', 'upgrade', ['uuid']) + + +def downgrade() -> None: + + # upgrade (convert uuid) + op.add_column('upgrade', sa.Column('uuid_str', sa.VARCHAR(length=32), nullable=True)) + upgrade = sa.sql.table('upgrade', + sa.sql.column('uuid'), + sa.sql.column('uuid_str')) + + engine = op.get_bind() + cursor = engine.execute(upgrade.select()) + for row in cursor.fetchall(): + if row['uuid']: + uuid_str = row['uuid'].hex + engine.execute(upgrade.update()\ + .where(upgrade.c.uuid == row['uuid'])\ + .values({'uuid_str': uuid_str})) + + op.drop_constraint('pk_upgrade', 'upgrade', type_='primary') + op.drop_column('upgrade', 'uuid') + op.alter_column('upgrade', 'uuid_str', new_column_name='uuid') + op.create_primary_key('pk_upgrade', 'upgrade', ['uuid']) diff --git a/src/wuttjamaican/db/model/upgrades.py b/src/wuttjamaican/db/model/upgrades.py index c8f3666..6893ba6 100644 --- a/src/wuttjamaican/db/model/upgrades.py +++ b/src/wuttjamaican/db/model/upgrades.py @@ -31,6 +31,8 @@ from sqlalchemy import orm from .base import Base, uuid_column, uuid_fk_column from wuttjamaican.enum import UpgradeStatus +from wuttjamaican.db.util import UUID +from wuttjamaican.util import make_true_uuid class Upgrade(Base): @@ -39,7 +41,7 @@ class Upgrade(Base): """ __tablename__ = 'upgrade' - uuid = uuid_column() + uuid = uuid_column(UUID(), default=make_true_uuid) created = sa.Column(sa.DateTime(timezone=True), nullable=False, default=datetime.datetime.now, doc=""" diff --git a/src/wuttjamaican/db/util.py b/src/wuttjamaican/db/util.py index 30f9f82..0df0e68 100644 --- a/src/wuttjamaican/db/util.py +++ b/src/wuttjamaican/db/util.py @@ -24,7 +24,10 @@ Database Utilities """ +import uuid as _uuid + import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import UUID as PGUUID from wuttjamaican.util import make_uuid @@ -57,6 +60,51 @@ class ModelBase: return getattr(self, key) +class UUID(sa.types.TypeDecorator): + """ + Platform-independent UUID type. + + Uses PostgreSQL's UUID type, otherwise uses CHAR(32), storing as + stringified hex values. + + This type definition is based on example from the `SQLAlchemy + documentation + `_. + """ + impl = sa.CHAR + cache_ok = True + """ """ # nb. suppress sphinx autodoc for cache_ok + + def load_dialect_impl(self, dialect): + """ """ + if dialect.name == "postgresql": + return dialect.type_descriptor(PGUUID()) + else: + return dialect.type_descriptor(sa.CHAR(32)) + + def process_bind_param(self, value, dialect): + """ """ + if value is None: + return value + elif dialect.name == "postgresql": + return str(value) + else: + if not isinstance(value, _uuid.UUID): + return "%.32x" % _uuid.UUID(value).int + else: + # hexstring + return "%.32x" % value.int + + def process_result_value(self, value, dialect): + """ """ + if value is None: + return value + else: + if not isinstance(value, _uuid.UUID): + value = _uuid.UUID(value) + return value + + def uuid_column(*args, **kwargs): """ Returns a UUID column for use as a table's primary key. diff --git a/src/wuttjamaican/util.py b/src/wuttjamaican/util.py index 48c321d..5574020 100644 --- a/src/wuttjamaican/util.py +++ b/src/wuttjamaican/util.py @@ -171,13 +171,43 @@ def make_title(text): return ' '.join([x.capitalize() for x in words]) +def make_true_uuid(): + """ + Generate a new v7 UUID value. + + :returns: :class:`python:uuid.UUID` instance + + .. warning:: + + For now, callers should use this function when they want a + proper UUID instance, whereas :func:`make_uuid()` will always + return a string. + + However once all dependent logic has been refactored to support + proper UUID data type, then ``make_uuid()`` will return those + and this function will eventually be removed. + """ + return uuid7() + + +# TODO: deprecate this logic, and reclaim this name +# but using the above logic def make_uuid(): """ - Generate a universally-unique identifier. + Generate a new v7 UUID value. :returns: A 32-character hex string. + + .. warning:: + + For now, this function always returns a string. + + However once all dependent logic has been refactored to support + proper UUID data type, then this function will return those and + the :func:`make_true_uuid()` function will eventually be + removed. """ - return uuid7().hex + return make_true_uuid().hex def parse_bool(value): diff --git a/tests/db/test_util.py b/tests/db/test_util.py index 566b68a..2f0c9ed 100644 --- a/tests/db/test_util.py +++ b/tests/db/test_util.py @@ -1,12 +1,16 @@ # -*- coding: utf-8; -*- +import uuid as _uuid from unittest import TestCase +from unittest.mock import MagicMock try: import sqlalchemy as sa + from sqlalchemy.dialects.postgresql import UUID as PGUUID from wuttjamaican.db import util as mod from wuttjamaican.db.model.base import Setting + from wuttjamaican.util import make_true_uuid except ImportError: pass else: @@ -22,6 +26,88 @@ else: self.assertEqual(setting['name'], 'foo') + class TestUUID(TestCase): + + def test_load_dialect_impl(self): + typ = mod.UUID() + dialect = MagicMock() + + # TODO: this doesn't really test anything, but gives us + # coverage at least.. + + # postgres + dialect.name = 'postgresql' + dialect.type_descriptor.return_value = 42 + result = typ.load_dialect_impl(dialect) + self.assertTrue(dialect.type_descriptor.called) + self.assertEqual(result, 42) + + # other + dialect.name = 'mysql' + dialect.type_descriptor.return_value = 43 + dialect.type_descriptor.reset_mock() + result = typ.load_dialect_impl(dialect) + self.assertTrue(dialect.type_descriptor.called) + self.assertEqual(result, 43) + + def test_process_bind_param_postgres(self): + typ = mod.UUID() + dialect = MagicMock() + dialect.name = 'postgresql' + + # null + result = typ.process_bind_param(None, dialect) + self.assertIsNone(result) + + # string + uuid_str = make_true_uuid().hex + result = typ.process_bind_param(uuid_str, dialect) + self.assertEqual(result, uuid_str) + + # uuid + uuid_true = make_true_uuid() + result = typ.process_bind_param(uuid_true, dialect) + self.assertEqual(result, str(uuid_true)) + + def test_process_bind_param_other(self): + typ = mod.UUID() + dialect = MagicMock() + dialect.name = 'mysql' + + # null + result = typ.process_bind_param(None, dialect) + self.assertIsNone(result) + + # string + uuid_str = make_true_uuid().hex + result = typ.process_bind_param(uuid_str, dialect) + self.assertEqual(result, uuid_str) + + # uuid + uuid_true = make_true_uuid() + result = typ.process_bind_param(uuid_true, dialect) + self.assertEqual(result, uuid_true.hex) + + def test_process_result_value(self): + typ = mod.UUID() + dialect = MagicMock() + + # null + result = typ.process_result_value(None, dialect) + self.assertIsNone(result) + + # string + uuid_str = make_true_uuid().hex + result = typ.process_result_value(uuid_str, dialect) + self.assertIsInstance(result, _uuid.UUID) + self.assertEqual(result.hex, uuid_str) + + # uuid + uuid_true = make_true_uuid() + result = typ.process_result_value(uuid_true, dialect) + self.assertIs(result, uuid_true) + + class TestUUIDColumn(TestCase): def test_basic(self): diff --git a/tests/test_app.py b/tests/test_app.py index 31d266d..4504ba9 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -5,6 +5,7 @@ import shutil import sys import tempfile import warnings +import uuid as _uuid from unittest import TestCase from unittest.mock import patch, MagicMock @@ -385,6 +386,10 @@ app_title = WuttaTest uuid = self.app.make_uuid() self.assertEqual(len(uuid), 32) + def test_make_true_uuid(self): + uuid = self.app.make_true_uuid() + self.assertIsInstance(uuid, _uuid.UUID) + def test_progress_loop(self): def act(obj, i):