Skip to content

Commit

Permalink
Merge pull request #141 from hasgeek/issue-138-immutable-columns
Browse files Browse the repository at this point in the history
Implement column annotations and immutable columns. Fixes #138.
  • Loading branch information
jace authored Sep 7, 2017
2 parents e38bd0b + 0f353c4 commit ddbfe7d
Show file tree
Hide file tree
Showing 7 changed files with 528 additions and 10 deletions.
181 changes: 181 additions & 0 deletions coaster/annotations.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
# -*- coding: utf-8 -*-

"""
SQLAlchemy attribute annotations
================================
Annotations are strings attached to attributes that serve as a programmer
reference on how those attributes are meant to be used. They can be used to
indicate that a column's value should be :attr:`immutable` and should never
change, or that it's a cached copy of :attr:`cached` copy of a value from
another source that can be safely discarded in case of a conflict.
This module's exports may be imported via :mod:`coaster.sqlalchemy`.
Sample usage::
from coaster.db import db
from coaster.sqlalchemy import annotation_wrapper, immutable
natural_key = annotation_wrapper('natural_key', "Natural key for this model")
class MyModel(db.Model):
__tablename__ = 'my_model'
id = immutable(db.Column(db.Integer, primary_key=True))
name = natural_key(db.Column(db.Unicode(250), unique=True))
@classmethod
def get(cls, **kwargs):
for key in kwargs:
if key in cls.__annotations__[natural_key.name]:
return cls.query.filter_by(**{key: kwargs[key]}).one_or_none()
Annotations are saved to the model's class as an ``__annotations__``
dictionary, mapping annotation names to a list of attribute names, and to a
reverse lookup ``__annotations_by_attr__`` of attribute names to annotations.
"""

from __future__ import absolute_import
import collections
from blinker import Namespace
from sqlalchemy import event, inspect
from sqlalchemy.orm import mapper
from sqlalchemy.orm.attributes import InstrumentedAttribute
from sqlalchemy.orm.attributes import NEVER_SET, NO_VALUE

__all__ = [
'annotations_configured',
'annotation_wrapper', 'immutable', 'cached',
'ImmutableColumnError'
]

# Global dictionary for temporary storage of annotations until the mapper_configured events
__cache__ = {}

# --- Signals -----------------------------------------------------------------

annotation_signals = Namespace()
annotations_configured = annotation_signals.signal('annotations-configured',
doc="Signal raised after all annotations on a class are configured")


# --- SQLAlchemy signals for base class ---------------------------------------

@event.listens_for(mapper, 'mapper_configured')
def __configure_annotations(mapper, cls):
"""
Run through attributes of the class looking for annotations from
:func:`annotation_wrapper` and add them to :attr:`cls.__annotations__`
and :attr:`cls.__annotations_by_attr__`
"""
annotations = {}
annotations_by_attr = {}

# An attribute may be defined more than once in base classes. Only handle the first
processed = set()

# Loop through all attributes in the class and its base classes, looking for annotations
for base in cls.__mro__:
for name, attr in base.__dict__.items():
if name in processed or name.startswith('__'):
continue

# 'data' is a list of string annotations
if isinstance(attr, collections.Hashable) and attr in __cache__:
data = __cache__[attr]
del __cache__[attr]
elif isinstance(attr, InstrumentedAttribute) and attr.property in __cache__:
data = __cache__[attr.property]
del __cache__[attr.property]
elif hasattr(attr, '_coaster_annotations'):
data = attr._coaster_annotations
else:
data = None
if data is not None:
annotations_by_attr.setdefault(name, []).extend(data)
for a in data:
annotations.setdefault(a, []).append(name)
processed.add(name)

# Classes specifying ``__annotations__`` directly isn't supported,
# so we don't bother preserving existing content, if any.
if annotations:
cls.__annotations__ = annotations
if annotations_by_attr:
cls.__annotations_by_attr__ = annotations_by_attr
annotations_configured.send(cls)


@event.listens_for(mapper, 'after_configured')
def __clear_cache():
for key in tuple(__cache__):
del __cache__[key]


# --- Helpers -----------------------------------------------------------------

def annotation_wrapper(annotation, doc=None):
"""
Defines an annotation, which can be applied to attributes in a database model.
"""
def decorator(attr):
__cache__.setdefault(attr, []).append(annotation)
# Also mark the annotation on the object itself. This will
# fail if the object has a restrictive __slots__, but it's
# required for some objects like Column because SQLAlchemy copies
# them in subclasses, changing their hash and making them
# undiscoverable via the cache.
try:
if not hasattr(attr, '_coaster_annotations'):
setattr(attr, '_coaster_annotations', [])
attr._coaster_annotations.append(annotation)
except AttributeError:
pass
return attr

decorator.__name__ = decorator.name = annotation
decorator.__doc__ = doc
return decorator


# --- Annotations -------------------------------------------------------------

immutable = annotation_wrapper('immutable', "Marks a column as immutable once set. "
"Only blocks direct changes; columns may still be updated via relationships or SQL")
cached = annotation_wrapper('cached', "Marks the column's contents as a cached value from another source")


class ImmutableColumnError(AttributeError):
def __init__(self, class_name, column_name, old_value, new_value, message=None):
self.class_name = class_name
self.column_name = column_name
self.old_value = old_value
self.new_value = new_value

if message is None:
self.message = (
u"Cannot update column {class_name}.{column_name} from {old_value} to {new_value}: "
u"column is immutable.".format(
column_name=column_name, class_name=class_name, old_value=old_value, new_value=new_value))


@annotations_configured.connect
def __make_immutable(cls):
if hasattr(cls, '__annotations__') and immutable.name in cls.__annotations__:
for attr in cls.__annotations__[immutable.name]:
col = getattr(cls, attr)

@event.listens_for(col, 'set')
def immutable_column_set_listener(target, value, old_value, initiator):
# Note:
# NEVER_SET is for columns getting a default value during a commit.
# NO_VALUE is for columns that have no value (either never set, or not loaded).
# Because of this ambiguity, we pair NO_VALUE with a has_identity test.
if old_value == value:
pass
elif old_value is NEVER_SET:
pass
elif old_value is NO_VALUE and inspect(target).has_identity is False:
pass
else:
raise ImmutableColumnError(cls.__name__, col.name, old_value, value)
14 changes: 8 additions & 6 deletions coaster/roles.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ def roles_for(self, user=None, token=None):
from copy import deepcopy
from sqlalchemy import event
from sqlalchemy.orm import mapper
from sqlalchemy.orm.attributes import QueryableAttribute
from sqlalchemy.orm.attributes import InstrumentedAttribute

__all__ = ['RoleAccessProxy', 'RoleMixin', 'with_roles', 'declared_attr_roles']

Expand Down Expand Up @@ -238,8 +238,8 @@ def inner(attr):
try:
attr._coaster_roles = {'read': read, 'write': write}
# If the attr has a restrictive __slots__, we'll get an attribute error.
# Use of _coaster_roles is now legacy, for declared_attr_roles, so we
# can safely ignore the error.
# Unfortunately, because of the way SQLAlchemy works, by copying objects
# into subclasses, the cache alone is not a reliable mechanism. We need both.
except AttributeError:
pass
return attr
Expand Down Expand Up @@ -370,8 +370,10 @@ def __configure_roles(mapper, cls):
Run through attributes of the class looking for role decorations from
:func:`with_roles` and add them to :attr:`cls.__roles__`
"""
# Don't mutate __roles__ in the base class.
# Don't mutate ``__roles__`` in the base class.
# The subclass must have its own.
# Since classes may specify ``__roles__`` directly without
# using :func:`with_roles`, we must preserve existing content.
if '__roles__' not in cls.__dict__:
# If the following line is confusing, it's because reading an
# attribute on an object invokes the Method Resolution Order (MRO)
Expand All @@ -391,10 +393,10 @@ def __configure_roles(mapper, cls):
if isinstance(attr, collections.Hashable) and attr in __cache__:
data = __cache__[attr]
del __cache__[attr]
elif isinstance(attr, QueryableAttribute) and attr.property in __cache__:
elif isinstance(attr, InstrumentedAttribute) and attr.property in __cache__:
data = __cache__[attr.property]
del __cache__[attr.property]
elif hasattr(attr, '_coaster_roles'): # XXX: Deprecated
elif hasattr(attr, '_coaster_roles'):
data = attr._coaster_roles
else:
data = None
Expand Down
11 changes: 7 additions & 4 deletions coaster/sqlalchemy.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ class MyModel(BaseMixin, db.Model):
from flask_sqlalchemy import BaseQuery
from .utils import make_name, uuid2buid, uuid2suuid, buid2uuid, suuid2uuid
from .roles import RoleMixin, with_roles, set_roles, declared_attr_roles # NOQA
from .annotations import annotation_wrapper, immutable, cached, annotations_configured, ImmutableColumnError # NOQA
from .gfm import markdown
import six

Expand Down Expand Up @@ -215,9 +216,9 @@ 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, nullable=False)
return immutable(Column(UUIDType(binary=False), default=uuid_.uuid4, primary_key=True, nullable=False))
else:
return Column(Integer, primary_key=True, nullable=False)
return immutable(Column(Integer, primary_key=True, nullable=False))

@declared_attr
def url_id(cls):
Expand All @@ -230,6 +231,7 @@ def url_id_func(self):
def url_id_is(cls):
return SqlHexUuidComparator(cls.id)

url_id_func.__name__ = 'url_id'
url_id_property = hybrid_property(url_id_func)
url_id_property = url_id_property.comparator(url_id_is)
return url_id_property
Expand All @@ -242,6 +244,7 @@ def url_id_expression(cls):
"""The URL id, integer primary key"""
return cls.id

url_id_func.__name__ = 'url_id'
url_id_property = hybrid_property(url_id_func)
url_id_property = url_id_property.expression(url_id_expression)
return url_id_property
Expand Down Expand Up @@ -287,7 +290,7 @@ 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, nullable=False)
return immutable(Column(UUIDType(binary=False), default=uuid_.uuid4, unique=True, nullable=False))

@hybrid_property
def url_id(self):
Expand Down Expand Up @@ -368,7 +371,7 @@ class TimestampMixin(object):
"""
query_class = Query
#: Timestamp for when this instance was created, in UTC
created_at = Column(DateTime, default=func.utcnow(), nullable=False)
created_at = immutable(Column(DateTime, default=func.utcnow(), nullable=False))
#: Timestamp for when this instance was last updated (via the app), in UTC
updated_at = Column(DateTime, default=func.utcnow(), onupdate=func.utcnow(), nullable=False)

Expand Down
2 changes: 2 additions & 0 deletions docs/annotations.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
.. automodule:: coaster.annotations
:members:
1 change: 1 addition & 0 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ Coaster is available under the BSD license, the same license as Flask.
docflow
sqlalchemy
roles
annotations
db
utils
shortuuid
Expand Down
1 change: 1 addition & 0 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
'simplejson',
'werkzeug',
'markupsafe',
'blinker',
'Flask',
'six',
]
Expand Down
Loading

0 comments on commit ddbfe7d

Please sign in to comment.