Skip to content

Commit

Permalink
Implement UuidMixin. Fixes #123.
Browse files Browse the repository at this point in the history
  • Loading branch information
jace committed Jun 20, 2017
1 parent 4ae4604 commit 353931e
Show file tree
Hide file tree
Showing 3 changed files with 224 additions and 20 deletions.
1 change: 1 addition & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
``suuid2uuid`` and ``uuid2suuid`` functions
* ``buid`` reverts to using UUID4 instead of UUID1mc
* The deprecated ``newid`` alias for ``buid`` has now been removed
* New: ``UuidMixin`` that adds a UUID secondary key and complements ``IdMixin``


0.5.2
Expand Down
107 changes: 94 additions & 13 deletions coaster/sqlalchemy.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
# -*- coding: utf-8 -*-

from __future__ import absolute_import
import uuid
import uuid as uuid_
import simplejson
from sqlalchemy import Column, Integer, DateTime, Unicode, UnicodeText, CheckConstraint, Numeric
from sqlalchemy import event, inspect
from sqlalchemy.sql import select, func, functions
from sqlalchemy.types import UserDefinedType, TypeDecorator, TEXT
from sqlalchemy.orm import composite
from sqlalchemy.orm import composite, synonym
from sqlalchemy.ext.declarative import declared_attr
from sqlalchemy.ext.mutable import Mutable, MutableComposite
from sqlalchemy.ext.hybrid import Comparator, hybrid_property
Expand All @@ -18,7 +18,7 @@
from werkzeug.exceptions import NotFound
from flask import Markup, url_for
from flask_sqlalchemy import BaseQuery
from .utils import make_name
from .utils import make_name, uuid2buid, uuid2suuid, buid2uuid, suuid2uuid
from .gfm import markdown


Expand Down Expand Up @@ -64,7 +64,8 @@ def __utcnow_mssql(element, compiler, **kw):

__all_mixins = ['IdMixin', 'TimestampMixin', 'PermissionMixin', 'UrlForMixin',
'BaseMixin', 'BaseNameMixin', 'BaseScopedNameMixin', 'BaseIdNameMixin',
'BaseScopedIdMixin', 'BaseScopedIdNameMixin', 'CoordinatesMixin']
'BaseScopedIdMixin', 'BaseScopedIdNameMixin', 'CoordinatesMixin',
'UuidMixin']


class Query(BaseQuery):
Expand Down Expand Up @@ -94,9 +95,36 @@ class SqlHexUuidComparator(Comparator):
Allows comparing UUID fields with hex representations of the UUID
"""
def operate(self, op, other):
if not isinstance(other, uuid.UUID):
if not isinstance(other, uuid_.UUID):
try:
other = uuid.UUID(other)
other = uuid_.UUID(other)
except ValueError:
raise InvalidUuid(other)
return op(self.__clause_element__(), other)


class SqlBuidComparator(Comparator):
"""
Allows comparing UUID fields with URL-safe Base64 (BUID) representations
of the UUID
"""
def operate(self, op, other):
if not isinstance(other, uuid_.UUID):
try:
other = buid2uuid(other)
except ValueError:
raise InvalidUuid(other)
return op(self.__clause_element__(), other)


class SqlSuuidComparator(Comparator):
"""
Allows comparing UUID fields with ShortUUID representations of the UUID
"""
def operate(self, op, other):
if not isinstance(other, uuid_.UUID):
try:
other = suuid2uuid(other)
except ValueError:
raise InvalidUuid(other)
return op(self.__clause_element__(), other)
Expand All @@ -117,7 +145,7 @@ def id(cls):
Database identity for this model, used for foreign key references from other models
"""
if cls.__uuid_primary_key__:
return Column(UUIDType(binary=False), default=uuid.uuid4, primary_key=True)
return Column(UUIDType(binary=False), default=uuid_.uuid4, primary_key=True)
else:
return Column(Integer, primary_key=True)

Expand Down Expand Up @@ -152,22 +180,75 @@ def __repr__(self):
return '<%s %s>' % (self.__class__.__name__, self.id)


class UuidMixin(object):
"""
Provides a ``uuid`` attribute that is either a SQL UUID column or an alias
to the existing ``id`` column if the class uses UUID primary keys. Also
provides hybrid properties ``url_id``, ``buid`` and ``suuid`` that provide
hex, BUID and ShortUUID representations of the ``uuid`` column.
"""
@declared_attr
def uuid(cls):
if hasattr(cls, '__uuid_primary_key__') and cls.__uuid_primary_key__:
return synonym('id')
else:
return Column(UUIDType(binary=False), default=uuid_.uuid4, unique=True)

@hybrid_property
def url_id(self):
return self.uuid.hex

@url_id.comparator
def url_id(cls):
# For some reason the test fails if we use `cls.uuid` here
# but works fine in the `buid` and `suuid` comparators below
if hasattr(cls, '__uuid_primary_key__') and cls.__uuid_primary_key__:
return SqlHexUuidComparator(cls.id)
else:
return SqlHexUuidComparator(cls.uuid)

@hybrid_property
def buid(self):
return uuid2buid(self.uuid)

@buid.comparator
def buid(cls):
return SqlBuidComparator(cls.uuid)

@hybrid_property
def suuid(self):
return uuid2suuid(self.uuid)

@suuid.comparator
def suuid(cls):
return SqlSuuidComparator(cls.uuid)


# Supply a default value for UUID-based id columns
def __uuid_default_listener(idcolumn):
@event.listens_for(idcolumn, 'init_scalar', retval=True, propagate=True)
def __uuid_default_listener(uuidcolumn):
@event.listens_for(uuidcolumn, 'init_scalar', retval=True, propagate=True)
def init_scalar(target, value, dict_):
value = idcolumn.columns[0].default.arg(None)
dict_[idcolumn.key] = value
value = uuidcolumn.columns[0].default.arg(None)
dict_[uuidcolumn.key] = value
return value


# Setup listeners for UUID-based subclasses
def __configure_listener(mapper, class_):
def __configure_id_listener(mapper, class_):
if hasattr(class_, '__uuid_primary_key__') and class_.__uuid_primary_key__:
__uuid_default_listener(mapper.attrs.id)


event.listen(IdMixin, 'mapper_configured', __configure_listener, propagate=True)
def __configure_uuid_listener(mapper, class_):
if hasattr(class_, '__uuid_primary_key__') and class_.__uuid_primary_key__:
return
# Only configure this listener if the class doesn't use UUID primary keys,
# as the `uuid` column will only be an alias for `id` in that case
__uuid_default_listener(mapper.attrs.uuid)


event.listen(IdMixin, 'mapper_configured', __configure_id_listener, propagate=True)
event.listen(UuidMixin, 'mapper_configured', __configure_uuid_listener, propagate=True)


def make_timestamp_columns():
Expand Down
136 changes: 129 additions & 7 deletions tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm.exc import MultipleResultsFound
from coaster.sqlalchemy import (BaseMixin, BaseNameMixin, BaseScopedNameMixin,
BaseIdNameMixin, BaseScopedIdMixin, BaseScopedIdNameMixin, JsonDict, failsafe_add, InvalidUuid)
BaseIdNameMixin, BaseScopedIdMixin, BaseScopedIdNameMixin, JsonDict, failsafe_add, InvalidUuid,
UuidMixin)
from coaster.utils import uuid2buid, uuid2suuid
from coaster.db import db


Expand Down Expand Up @@ -140,6 +142,16 @@ class UuidIdName(BaseIdNameMixin, db.Model):
__uuid_primary_key__ = True


class NonUuidMixinKey(UuidMixin, BaseMixin, db.Model):
__tablename__ = 'non_uuid_mixin_key'
__uuid_primary_key__ = False


class UuidMixinKey(UuidMixin, BaseMixin, db.Model):
__tablename__ = 'uuid_mixin_key'
__uuid_primary_key__ = True


# -- Tests --------------------------------------------------------------------

class TestCoasterModels(unittest.TestCase):
Expand Down Expand Up @@ -550,19 +562,37 @@ def test_uuid_url_id(self):
"""
u1 = NonUuidKey()
u2 = UuidKey()
db.session.add_all([u1, u2])
u3 = NonUuidMixinKey()
u4 = UuidMixinKey()
db.session.add_all([u1, u2, u3, u4])
db.session.commit()

# Regular IdMixin ids
i1 = u1.id
i2 = u2.id
# UUID keys from UuidMixin
i3 = u3.uuid
i4 = u4.uuid

self.assertEqual(u1.url_id, unicode(i1))
self.assertIsInstance(u2.id, uuid.UUID)

self.assertIsInstance(i2, uuid.UUID)
self.assertEqual(u2.url_id, i2.hex)
self.assertEqual(len(u2.url_id), 32) # This is a 32-byte hex representation
self.assertFalse('-' in u2.url_id) # Without dashes

# Querying against `url_id` redirects the query to `id`.
self.assertIsInstance(i3, uuid.UUID)
self.assertEqual(u3.url_id, i3.hex)
self.assertEqual(len(u3.url_id), 32) # This is a 32-byte hex representation
self.assertFalse('-' in u3.url_id) # Without dashes

self.assertIsInstance(i4, uuid.UUID)
self.assertEqual(u4.url_id, i4.hex)
self.assertEqual(len(u4.url_id), 32) # This is a 32-byte hex representation
self.assertFalse('-' in u4.url_id) # Without dashes

# Querying against `url_id` redirects the query to
# `id` (IdMixin) or `uuid` (UuidMixin).

# With integer primary keys, `url_id` is simply a proxy for `id`
self.assertEqual(
Expand All @@ -576,7 +606,7 @@ def test_uuid_url_id(self):
u"non_uuid_key.id = '1'")

# With UUID primary keys, `url_id` casts the value into a UUID
# and then queries against `id`
# and then queries against `id` or ``uuid``

# Note that `literal_binds` here doesn't know how to render UUIDs if
# no engine is specified, and so casts them into a string. We test this
Expand All @@ -598,15 +628,97 @@ def test_uuid_url_id(self):
).compile(compile_kwargs={'literal_binds': True})),
u"uuid_key.id = '74d588574a7611e78c27c38403d0935c'")

# Query raises InvalidUuid if given an invalid value
with self.assertRaises(InvalidUuid):
UuidKey.url_id == 'garbage!'
with self.assertRaises(InvalidUuid):
NonUuidMixinKey.url_id == 'garbage!'
with self.assertRaises(InvalidUuid):
UuidKey.url_id == 'garbage'
UuidMixinKey.url_id == 'garbage!'

# Repeat against UuidMixin classes (with only hex keys for brevity)
self.assertEqual(
unicode((NonUuidMixinKey.url_id == '74d588574a7611e78c27c38403d0935c'
).compile(compile_kwargs={'literal_binds': True})),
u"non_uuid_mixin_key.uuid = '74d588574a7611e78c27c38403d0935c'")
self.assertEqual(
unicode((UuidMixinKey.url_id == '74d588574a7611e78c27c38403d0935c'
).compile(compile_kwargs={'literal_binds': True})),
u"uuid_mixin_key.id = '74d588574a7611e78c27c38403d0935c'")

# Running a database query with url_id works as expected.
# This test should pass on both SQLite and PostgreSQL
qu1 = NonUuidKey.query.filter_by(url_id=u1.url_id).first()
self.assertEqual(u1, qu1)
qu2 = UuidKey.query.filter_by(url_id=u2.url_id).first()
self.assertEqual(u2, qu2)
qu3 = NonUuidMixinKey.query.filter_by(url_id=u3.url_id).first()
self.assertEqual(u3, qu3)
qu4 = UuidMixinKey.query.filter_by(url_id=u4.url_id).first()
self.assertEqual(u4, qu4)

def test_uuid_buid_suuid(self):
"""
UuidMixin provides buid and suuid
"""
u1 = NonUuidMixinKey()
u2 = UuidMixinKey()
db.session.add_all([u1, u2])
db.session.commit()

# The `uuid` column contains a UUID
self.assertIsInstance(u1.uuid, uuid.UUID)
self.assertIsInstance(u2.uuid, uuid.UUID)

# Test readbility of `buid` attribute
self.assertEqual(u1.buid, uuid2buid(u1.uuid))
self.assertEqual(len(u1.buid), 22) # This is a 22-byte BUID representation
self.assertEqual(u2.buid, uuid2buid(u2.uuid))
self.assertEqual(len(u2.buid), 22) # This is a 22-byte BUID representation

# Test readability of `suuid` attribute
self.assertEqual(u1.suuid, uuid2suuid(u1.uuid))
self.assertEqual(len(u1.suuid), 22) # This is a 22-byte ShortUUID representation
self.assertEqual(u2.suuid, uuid2suuid(u2.uuid))
self.assertEqual(len(u2.suuid), 22) # This is a 22-byte ShortUUID representation

# SQL queries against `buid` and `suuid` cast the value into a UUID
# and then query against `id` or ``uuid``

# Note that `literal_binds` here doesn't know how to render UUIDs if
# no engine is specified, and so casts them into a string

# UuidMixin with integer primary key queries against the `uuid` column
self.assertEqual(
unicode((NonUuidMixinKey.buid == 'dNWIV0p2EeeMJ8OEA9CTXA'
).compile(compile_kwargs={'literal_binds': True})),
u"non_uuid_mixin_key.uuid = '74d588574a7611e78c27c38403d0935c'")

# UuidMixin with UUID primary key queries against the `id` column
self.assertEqual(
unicode((UuidMixinKey.buid == 'dNWIV0p2EeeMJ8OEA9CTXA'
).compile(compile_kwargs={'literal_binds': True})),
u"uuid_mixin_key.id = '74d588574a7611e78c27c38403d0935c'")

# Repeat for `suuid`
self.assertEqual(
unicode((NonUuidMixinKey.suuid == 'vVoaZTeXGiD4qrMtYNosnN'
).compile(compile_kwargs={'literal_binds': True})),
u"non_uuid_mixin_key.uuid = '74d588574a7611e78c27c38403d0935c'")
self.assertEqual(
unicode((UuidMixinKey.suuid == 'vVoaZTeXGiD4qrMtYNosnN'
).compile(compile_kwargs={'literal_binds': True})),
u"uuid_mixin_key.id = '74d588574a7611e78c27c38403d0935c'")

# Query raises InvalidUuid if given an invalid value
with self.assertRaises(InvalidUuid):
NonUuidMixinKey.buid == 'garbage!'
with self.assertRaises(InvalidUuid):
NonUuidMixinKey.suuid == 'garbage!'
with self.assertRaises(InvalidUuid):
UuidMixinKey.buid == 'garbage!'
with self.assertRaises(InvalidUuid):
UuidMixinKey.suuid == 'garbage!'

def test_uuid_url_name(self):
"""
Expand All @@ -619,10 +731,13 @@ def test_uuid_url_name(self):

def test_uuid_default(self):
"""
Models with a UUID primary key have a default value before adding to session
Models with a UUID primary or secondary key have a default value before
adding to session
"""
uuid_no = NonUuidKey()
uuid_yes = UuidKey()
uuidm_no = NonUuidMixinKey()
uuidm_yes = UuidMixinKey()
# Non-UUID primary keys are not automatically generated
u1 = uuid_no.id
self.assertIsNone(u1)
Expand All @@ -633,6 +748,13 @@ def test_uuid_default(self):
u3 = uuid_yes.id
self.assertEqual(u2, u3)

# UuidMixin works likewise
um1 = uuidm_no.uuid
self.assertIsInstance(um1, uuid.UUID)
um2 = uuidm_yes.uuid # This should generate uuidm_yes.id
self.assertIsInstance(um2, uuid.UUID)
self.assertEqual(uuidm_yes.id, uuidm_yes.uuid)


class TestCoasterModels2(TestCoasterModels):
app = app2

0 comments on commit 353931e

Please sign in to comment.