Commits

Mike Bayer committed e25f578 Draft

The :class:`.MutableComposite` type did not allow for the
:meth:`.MutableBase.coerce` method to be used, even though
the code seemed to indicate this intent, so this now works
and a brief example is added. As a side-effect,
the mechanics of this event handler have been changed so that
new :class:`.MutableComposite` types no longer add per-type
global event handlers. Also in 0.7.10

[ticket:2624]

Comments (0)

Files changed (6)

doc/build/changelog/changelog_07.rst

 
     .. change::
         :tags: orm, bug
+        :tickets: 2624
+
+      The :class:`.MutableComposite` type did not allow for the
+      :meth:`.MutableBase.coerce` method to be used, even though
+      the code seemed to indicate this intent, so this now works
+      and a brief example is added.  As a side-effect,
+      the mechanics of this event handler have been changed so that
+      new :class:`.MutableComposite` types no longer add per-type
+      global event handlers.  Also in 0.8.0b2.
+
+    .. change::
+        :tags: orm, bug
         :tickets: 2583
 
       Fixed Session accounting bug whereby replacing

doc/build/changelog/changelog_08.rst

     :version: 0.8.0b2
 
     .. change::
+        :tags: orm, bug
+        :tickets: 2624
+
+      The :class:`.MutableComposite` type did not allow for the
+      :meth:`.MutableBase.coerce` method to be used, even though
+      the code seemed to indicate this intent, so this now works
+      and a brief example is added.  As a side-effect,
+      the mechanics of this event handler have been changed so that
+      new :class:`.MutableComposite` types no longer add per-type
+      global event handlers.  Also in 0.7.10.
+
+    .. change::
         :tags: sql, bug
         :tickets: 2621
 

doc/build/orm/extensions/mutable.rst

 -------------
 
 .. autoclass:: MutableBase
-    :members: _parents
+    :members: _parents, coerce
 
 .. autoclass:: Mutable
     :show-inheritance:

lib/sqlalchemy/event.py

         self._clslevel = util.defaultdict(list)
         self._empty_listeners = {}
 
+    def _contains(self, cls, evt):
+        return evt in self._clslevel[cls]
+
     def insert(self, obj, target, propagate):
         assert isinstance(target, type), \
                 "Class-level Event targets must be classes."

lib/sqlalchemy/ext/mutable.py

     >>> assert v1 in sess.dirty
     True
 
+Coercing Mutable Composites
+---------------------------
+
+The :meth:`.MutableBase.coerce` method is also supported on composite types.
+In the case of :class:`.MutableComposite`, the :meth:`.MutableBase.coerce`
+method is only called for attribute set operations, not load operations.
+Overriding the :meth:`.MutableBase.coerce` method is essentially equivalent
+to using a :func:`.validates` validation routine for all attributes which
+make use of the custom composite type::
+
+    class Point(MutableComposite):
+        # other Point methods
+        # ...
+
+        def coerce(cls, key, value):
+            if isinstance(value, tuple):
+                value = Point(*value)
+            elif not isinstance(value, Point):
+                raise ValueError("tuple or Point expected")
+            return value
+
+.. versionadded:: 0.7.10,0.8.0b2
+    Support for the :meth:`.MutableBase.coerce` method in conjunction with
+    objects of type :class:`.MutableComposite`.
+
 Supporting Pickling
 --------------------
 
 """
 from ..orm.attributes import flag_modified
 from .. import event, types
-from ..orm import mapper, object_mapper
+from ..orm import mapper, object_mapper, Mapper
 from ..util import memoized_property
 import weakref
 
 
     @classmethod
     def coerce(cls, key, value):
-        """Given a value, coerce it into this type.
+        """Given a value, coerce it into the target type.
 
-        By default raises ValueError.
+        Can be overridden by custom subclasses to coerce incoming
+        data into a particular type.
+
+        By default, raises ``ValueError``.
+
+        This method is called in different scenarios depending on if
+        the parent class is of type :class:`.Mutable` or of type
+        :class:`.MutableComposite`.  In the case of the former, it is called
+        for both attribute-set operations as well as during ORM loading
+        operations.  For the latter, it is only called during attribute-set
+        operations; the mechanics of the :func:`.composite` construct
+        handle coercion during load operations.
+
+
+        :param key: string name of the ORM-mapped attribute being set.
+        :param value: the incoming value.
+        :return: the method should return the coerced value, or raise
+         ``ValueError`` if the coercion cannot be completed.
+
         """
         if value is None:
             return None
         return sqltype
 
 
-class _MutableCompositeMeta(type):
-    def __init__(cls, classname, bases, dict_):
-        cls._setup_listeners()
-        return type.__init__(cls, classname, bases, dict_)
-
 
 class MutableComposite(MutableBase):
     """Mixin that defines transparent propagation of change
 
     See the example in :ref:`mutable_composites` for usage information.
 
-    .. warning::
-
-       The listeners established by the :class:`.MutableComposite`
-       class are *global* to all mappers, and are *not* garbage
-       collected.   Only use :class:`.MutableComposite` for types that are
-       permanent to an application, not with ad-hoc types else this will
-       cause unbounded growth in memory usage.
-
     """
-    __metaclass__ = _MutableCompositeMeta
 
     def changed(self):
         """Subclasses should call this method whenever change events occur."""
                                     prop._attribute_keys):
                 setattr(parent, attr_name, value)
 
-    @classmethod
-    def _setup_listeners(cls):
-        """Associate this wrapper with all future mapped composites
-        of the given type.
-
-        This is a convenience method that calls ``associate_with_attribute``
-        automatically.
-
-        """
-
-        def listen_for_type(mapper, class_):
-            for prop in mapper.iterate_properties:
-                if (hasattr(prop, 'composite_class') and
-                    issubclass(prop.composite_class, cls)):
-                    cls._listen_on_attribute(
-                        getattr(class_, prop.key), False, class_)
-
-        event.listen(mapper, 'mapper_configured', listen_for_type)
+def _setup_composite_listener():
+    def _listen_for_type(mapper, class_):
+        for prop in mapper.iterate_properties:
+            if (hasattr(prop, 'composite_class') and
+                issubclass(prop.composite_class, MutableComposite)):
+                prop.composite_class._listen_on_attribute(
+                    getattr(class_, prop.key), False, class_)
+    if not Mapper.dispatch.mapper_configured._contains(Mapper, _listen_for_type):
+        event.listen(Mapper, 'mapper_configured', _listen_for_type)
+_setup_composite_listener()
 
 
 class MutableDict(Mutable, dict):

test/ext/test_mutable.py

             Column('unrelated_data', String(50))
         )
 
+    def setup(self):
+        from sqlalchemy.ext import mutable
+        mutable._setup_composite_listener()
+        super(_CompositeTestBase, self).setup()
+
+
     def teardown(self):
         # clear out mapper events
         Mapper.dispatch._clear()
 
         eq_(f1.data.x, 5)
 
+class MutableCompositeCustomCoerceTest(_CompositeTestBase, fixtures.MappedTest):
+    @classmethod
+    def _type_fixture(cls):
+
+        from sqlalchemy.ext.mutable import MutableComposite
+
+        global Point
+
+        class Point(MutableComposite):
+            def __init__(self, x, y):
+                self.x = x
+                self.y = y
+
+            @classmethod
+            def coerce(cls, key, value):
+                if isinstance(value, tuple):
+                    value = Point(*value)
+                return value
+
+            def __setattr__(self, key, value):
+                object.__setattr__(self, key, value)
+                self.changed()
+
+            def __composite_values__(self):
+                return self.x, self.y
+
+            def __getstate__(self):
+                return self.x, self.y
+
+            def __setstate__(self, state):
+                self.x, self.y = state
+
+            def __eq__(self, other):
+                return isinstance(other, Point) and \
+                    other.x == self.x and \
+                    other.y == self.y
+        return Point
+
+
+    @classmethod
+    def setup_mappers(cls):
+        foo = cls.tables.foo
+
+        Point = cls._type_fixture()
+
+        mapper(Foo, foo, properties={
+            'data': composite(Point, foo.c.x, foo.c.y)
+        })
+
+    def test_custom_coerce(self):
+        f = Foo()
+        f.data = (3, 4)
+        eq_(f.data, Point(3, 4))
+
+    def test_round_trip_ok(self):
+        sess = Session()
+        f = Foo()
+        f.data = (3, 4)
+
+        sess.add(f)
+        sess.commit()
+
+        eq_(f.data, Point(3, 4))
+
+
 class MutableInheritedCompositesTest(_CompositeTestBase, fixtures.MappedTest):
     @classmethod
     def define_tables(cls, metadata):