Skip to content

Commit

Permalink
Reorganise utils and sqlalchemy into sub-packages (#143). Fixes #142
Browse files Browse the repository at this point in the history
  • Loading branch information
jace authored Sep 7, 2017
1 parent ddbfe7d commit fd580e3
Show file tree
Hide file tree
Showing 33 changed files with 1,195 additions and 1,049 deletions.
2 changes: 2 additions & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@

* Renamed ``coaster.roles.set_roles`` to ``with_roles`` and added support for
wrapping ``declared_attr`` and column properties
* Added SQLAlchemy column annotations
* Reorganised ``coaster.utils`` and ``coaster.sqlalchemy`` into sub-packages


0.6.0
Expand Down
22 changes: 22 additions & 0 deletions coaster/sqlalchemy/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# -*- coding: utf-8 -*-

"""
SQLAlchemy patterns
===================
Coaster provides a number of SQLAlchemy helper functions and mixin classes
that add standard columns or special functionality.
All functions and mixins are importable from the :mod:`coaster.sqlalchemy`
namespace.
"""

from __future__ import absolute_import

from .comparators import * # NOQA
from .functions import * # NOQA
from .roles import * # NOQA
from .annotations import * # NOQA
from .immutable_annotation import * # NOQA
from .mixins import * # NOQA
from .columns import * # NOQA
51 changes: 3 additions & 48 deletions coaster/annotations.py → coaster/sqlalchemy/annotations.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

"""
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
Expand Down Expand Up @@ -38,15 +38,13 @@ def get(cls, **kwargs):
from __future__ import absolute_import
import collections
from blinker import Namespace
from sqlalchemy import event, inspect
from sqlalchemy import event
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'
'annotation_wrapper'
]

# Global dictionary for temporary storage of annotations until the mapper_configured events
Expand Down Expand Up @@ -136,46 +134,3 @@ def decorator(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)
184 changes: 184 additions & 0 deletions coaster/sqlalchemy/columns.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
# -*- coding: utf-8 -*-

"""
SQLAlchemy column types
-----------------------
"""

from __future__ import absolute_import
import simplejson
from sqlalchemy import Column, UnicodeText
from sqlalchemy.types import UserDefinedType, TypeDecorator, TEXT
from sqlalchemy.orm import composite
from sqlalchemy.ext.mutable import Mutable, MutableComposite
from sqlalchemy_utils.types import UUIDType # NOQA
from flask import Markup
import six
from ..gfm import markdown

__all__ = ['JsonDict', 'MarkdownComposite', 'MarkdownColumn', 'UUIDType']


class JsonType(UserDefinedType):
"""The PostgreSQL JSON type."""

def get_col_spec(self):
return 'JSON'


class JsonbType(UserDefinedType):
"""The PostgreSQL JSONB type."""

def get_col_spec(self):
return 'JSONB'


# Adapted from http://docs.sqlalchemy.org/en/rel_0_8/orm/extensions/mutable.html#establishing-mutability-on-scalar-column-values

class JsonDict(TypeDecorator):
"""
Represents a JSON data structure. Usage::
column = Column(JsonDict)
The column will be represented to the database as a ``JSONB`` column if
the server is PostgreSQL 9.4 or later, ``JSON`` if PostgreSQL 9.2 or 9.3,
and ``TEXT`` for everything else. The column behaves like a JSON store
regardless of the backing data type.
"""

impl = TEXT

def load_dialect_impl(self, dialect):
if dialect.name == 'postgresql':
version = tuple(dialect.server_version_info[:2])
if version in [(9, 2), (9, 3)]:
return dialect.type_descriptor(JsonType)
elif version >= (9, 4):
return dialect.type_descriptor(JsonbType)
return dialect.type_descriptor(self.impl)

def process_bind_param(self, value, dialect):
if value is not None:
value = simplejson.dumps(value, default=lambda o: six.text_type(o))
return value

def process_result_value(self, value, dialect):
if value is not None and isinstance(value, six.string_types):
# Psycopg2 >= 2.5 will auto-decode JSON columns, so
# we only attempt decoding if the value is a string.
# Since this column stores dicts only, processed values
# can never be strings.
value = simplejson.loads(value, use_decimal=True)
return value


class MutableDict(Mutable, dict):
@classmethod
def coerce(cls, key, value):
"""Convert plain dictionaries to MutableDict."""

if not isinstance(value, MutableDict):
if isinstance(value, dict):
return MutableDict(value)
elif isinstance(value, six.string_types):
# Assume JSON string
if value:
return MutableDict(simplejson.loads(value, use_decimal=True))
else:
return MutableDict() # Empty value is an empty dict

# this call will raise ValueError
return Mutable.coerce(key, value)
else:
return value

def __setitem__(self, key, value):
"""Detect dictionary set events and emit change events."""

dict.__setitem__(self, key, value)
self.changed()

def __delitem__(self, key):
"""Detect dictionary del events and emit change events."""

dict.__delitem__(self, key)
self.changed()

MutableDict.associate_with(JsonDict)


@six.python_2_unicode_compatible
class MarkdownComposite(MutableComposite):
"""
Represents GitHub-flavoured Markdown text and rendered HTML as a composite column.
"""
def __init__(self, text, html=None):
if html is None:
self.text = text # This will regenerate HTML
else:
object.__setattr__(self, 'text', text)
object.__setattr__(self, '_html', html)

# If the text value is set, regenerate HTML, then notify parents of the change
def __setattr__(self, key, value):
if key == 'text':
object.__setattr__(self, '_html', markdown(value))
object.__setattr__(self, key, value)
self.changed()

# Return column values for SQLAlchemy to insert into the database
def __composite_values__(self):
return (self.text, self._html)

# Return a string representation of the text (see class decorator)
def __str__(self):
return six.text_type(self.text)

# Return a HTML representation of the text
def __html__(self):
return self._html or u''

# Return a Markup string of the HTML
@property
def html(self):
return Markup(self._html or u'')

# Compare text value
def __eq__(self, other):
return (self.text == other.text) if isinstance(other, MarkdownComposite) else (self.text == other)

def __ne__(self, other):
return not self.__eq__(other)

# Return state for pickling
def __getstate__(self):
return (self.text, self._html)

# Set state from pickle
def __setstate__(self, state):
object.__setattr__(self, 'text', state[0])
object.__setattr__(self, '_html', state[1])
self.changed()

def __bool__(self):
return bool(self.text)

__nonzero__ = __bool__

# Allow a composite column to be assigned a string value
@classmethod
def coerce(cls, key, value):
return cls(value)


def MarkdownColumn(name, deferred=False, group=None, **kwargs):
"""
Create a composite column that autogenerates HTML from Markdown text,
storing data in db columns named with ``_html`` and ``_text`` prefixes.
"""
return composite(MarkdownComposite,
Column(name + '_text', UnicodeText, **kwargs),
Column(name + '_html', UnicodeText, **kwargs),
deferred=deferred, group=group or name
)
Loading

0 comments on commit fd580e3

Please sign in to comment.