2
0
Fork 0

fix: use true UUID type for Upgrades table primary key

hopefully can use this everywhere soon but let's start slow and test
This commit is contained in:
Lance Edgar 2024-11-30 19:59:59 -06:00
parent 8b6e32145c
commit 028c64fc12
9 changed files with 274 additions and 6 deletions

View file

@ -30,7 +30,7 @@ import sys
import warnings import warnings
from wuttjamaican.util import (load_entry_points, load_object, from wuttjamaican.util import (load_entry_points, load_object,
make_title, make_uuid, make_title, make_uuid, make_true_uuid,
progress_loop, resource_path) progress_loop, resource_path)
@ -491,14 +491,44 @@ class AppHandler:
""" """
return make_title(text) 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): def make_uuid(self):
""" """
Generate a new UUID value. Generate a new v7 UUID value.
By default this simply calls By default this simply calls
:func:`wuttjamaican.util.make_uuid()`. :func:`wuttjamaican.util.make_uuid()`.
:returns: UUID value as 32-character string. :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() return make_uuid()

View file

@ -40,5 +40,5 @@ def make_uuid(
""" """
config = ctx.parent.wutta_config config = ctx.parent.wutta_config
app = config.get_app() app = config.get_app()
uuid = app.make_uuid() uuid = app.make_true_uuid()
sys.stdout.write(f"{uuid}\n") sys.stdout.write(f"{uuid}\n")

View file

@ -9,6 +9,7 @@ from typing import Sequence, Union
from alembic import op from alembic import op
import sqlalchemy as sa import sqlalchemy as sa
import wuttjamaican.db.util
${imports if imports else ""} ${imports if imports else ""}
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.

View file

@ -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'])

View file

@ -31,6 +31,8 @@ from sqlalchemy import orm
from .base import Base, uuid_column, uuid_fk_column from .base import Base, uuid_column, uuid_fk_column
from wuttjamaican.enum import UpgradeStatus from wuttjamaican.enum import UpgradeStatus
from wuttjamaican.db.util import UUID
from wuttjamaican.util import make_true_uuid
class Upgrade(Base): class Upgrade(Base):
@ -39,7 +41,7 @@ class Upgrade(Base):
""" """
__tablename__ = 'upgrade' __tablename__ = 'upgrade'
uuid = uuid_column() uuid = uuid_column(UUID(), default=make_true_uuid)
created = sa.Column(sa.DateTime(timezone=True), nullable=False, created = sa.Column(sa.DateTime(timezone=True), nullable=False,
default=datetime.datetime.now, doc=""" default=datetime.datetime.now, doc="""

View file

@ -24,7 +24,10 @@
Database Utilities Database Utilities
""" """
import uuid as _uuid
import sqlalchemy as sa import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from wuttjamaican.util import make_uuid from wuttjamaican.util import make_uuid
@ -57,6 +60,51 @@ class ModelBase:
return getattr(self, key) 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
<https://docs.sqlalchemy.org/en/14/core/custom_types.html#backend-agnostic-guid-type>`_.
"""
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): def uuid_column(*args, **kwargs):
""" """
Returns a UUID column for use as a table's primary key. Returns a UUID column for use as a table's primary key.

View file

@ -171,13 +171,43 @@ def make_title(text):
return ' '.join([x.capitalize() for x in words]) 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(): def make_uuid():
""" """
Generate a universally-unique identifier. Generate a new v7 UUID value.
:returns: A 32-character hex string. :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): def parse_bool(value):

View file

@ -1,12 +1,16 @@
# -*- coding: utf-8; -*- # -*- coding: utf-8; -*-
import uuid as _uuid
from unittest import TestCase from unittest import TestCase
from unittest.mock import MagicMock
try: try:
import sqlalchemy as sa import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from wuttjamaican.db import util as mod from wuttjamaican.db import util as mod
from wuttjamaican.db.model.base import Setting from wuttjamaican.db.model.base import Setting
from wuttjamaican.util import make_true_uuid
except ImportError: except ImportError:
pass pass
else: else:
@ -22,6 +26,88 @@ else:
self.assertEqual(setting['name'], 'foo') 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): class TestUUIDColumn(TestCase):
def test_basic(self): def test_basic(self):

View file

@ -5,6 +5,7 @@ import shutil
import sys import sys
import tempfile import tempfile
import warnings import warnings
import uuid as _uuid
from unittest import TestCase from unittest import TestCase
from unittest.mock import patch, MagicMock from unittest.mock import patch, MagicMock
@ -385,6 +386,10 @@ app_title = WuttaTest
uuid = self.app.make_uuid() uuid = self.app.make_uuid()
self.assertEqual(len(uuid), 32) 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 test_progress_loop(self):
def act(obj, i): def act(obj, i):