summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMike Bayer <mike_mp@zzzcomputing.com>2010-12-28 22:23:13 -0500
committerMike Bayer <mike_mp@zzzcomputing.com>2010-12-28 22:23:13 -0500
commit3b41b66981d8665282c645178643d273361eb6ad (patch)
tree51955294b658bbf3bc0e840901d3a888b557223e
parent4f8f6b3989327398c048fa55bc2ed8f26fb022bd (diff)
downloadsqlalchemy-3b41b66981d8665282c645178643d273361eb6ad.tar.gz
- restore mapper.get_property() to use the _props dict. at the moment
synonyms for relationships might just be taken out altogether, since they aren't documented and are of little use. a plain proxying descriptor, combined with attribute-based usage with Query (as opposted to naming it by string) can do the same thing more simply. - add event support to composites, change the model around so that the composite is generated at the point of load. - add a recipe for tracking mutations on composites. will probably make both of these mutations examples into extensions since they're intricate, should have a lot of test coverage, and what they need to do is fairly straightforward. Will use metaclasses so that no extra userland step is needed beyond usage of the type.
-rw-r--r--examples/mutable_events/composite.py139
-rw-r--r--examples/mutable_events/scalars.py10
-rw-r--r--lib/sqlalchemy/orm/descriptor_props.py92
-rw-r--r--lib/sqlalchemy/orm/interfaces.py2
-rw-r--r--lib/sqlalchemy/orm/mapper.py8
-rw-r--r--test/orm/test_composites.py20
-rw-r--r--test/orm/test_mapper.py7
-rw-r--r--test/orm/test_query.py4
8 files changed, 237 insertions, 45 deletions
diff --git a/examples/mutable_events/composite.py b/examples/mutable_events/composite.py
new file mode 100644
index 000000000..f46f28e6d
--- /dev/null
+++ b/examples/mutable_events/composite.py
@@ -0,0 +1,139 @@
+# this example is probably moving to be an extension.
+
+from sqlalchemy import event
+from sqlalchemy.orm import mapper, composite, object_mapper
+
+from sqlalchemy.util import memoized_property
+import weakref
+
+class _CompositeMutationsMixinMeta(type):
+ def __init__(cls, classname, bases, dict_):
+ cls._setup_listeners()
+ return type.__init__(cls, classname, bases, dict_)
+
+class CompositeMutationsMixin(object):
+ """Mixin that defines transparent propagation of change
+ events to a parent object.
+
+ This class might be moved to be a SQLA extension
+ due to its complexity and potential for widespread use.
+
+ """
+ __metaclass__ = _CompositeMutationsMixinMeta
+
+ @memoized_property
+ def _parents(self):
+ """Dictionary of parent object->attribute name on the parent."""
+
+ return weakref.WeakKeyDictionary()
+
+ def __setattr__(self, key, value):
+ object.__setattr__(self, key, value)
+ self.on_change()
+
+ def on_change(self):
+ """Subclasses should call this method whenever change events occur."""
+
+ for parent, key in self._parents.items():
+
+ prop = object_mapper(parent).get_property(key)
+ for value, attr_name in zip(self.__composite_values__(), prop._attribute_keys):
+ setattr(parent, attr_name, value)
+
+ @classmethod
+ def _listen_on_attribute(cls, attribute):
+ """Establish this type as a mutation listener for the given
+ mapped descriptor.
+
+ """
+ key = attribute.key
+ parent_cls = attribute.class_
+
+ def on_load(state):
+ """Listen for objects loaded or refreshed.
+
+ Wrap the target data member's value with
+ ``TrackMutationsMixin``.
+
+ """
+
+ val = state.dict.get(key, None)
+ if val is not None:
+ val._parents[state.obj()] = key
+
+ def on_set(target, value, oldvalue, initiator):
+ """Listen for set/replace events on the target
+ data member.
+
+ Establish a weak reference to the parent object
+ on the incoming value, remove it for the one
+ outgoing.
+
+ """
+
+ value._parents[target.obj()] = key
+ if isinstance(oldvalue, cls):
+ oldvalue._parents.pop(state.obj(), None)
+ return value
+
+ event.listen(parent_cls, 'on_load', on_load, raw=True)
+ event.listen(parent_cls, 'on_refresh', on_load, raw=True)
+ event.listen(attribute, 'on_set', on_set, raw=True, retval=True)
+
+ @classmethod
+ def _setup_listeners(cls):
+ """Associate this wrapper with all future mapped compoistes
+ 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))
+
+ event.listen(mapper, 'on_mapper_configured', listen_for_type)
+
+
+if __name__ == '__main__':
+ from sqlalchemy import Column, Integer, create_engine
+ from sqlalchemy.orm import Session
+ from sqlalchemy.ext.declarative import declarative_base
+
+ class Point(CompositeMutationsMixin):
+ def __init__(self, x, y):
+ self.x = x
+ self.y = y
+
+ def __composite_values__(self):
+ return self.x, self.y
+
+ def __eq__(self, other):
+ return isinstance(other, Point) and \
+ other.x == self.x and \
+ other.y == self.y
+
+ Base = declarative_base()
+ class Foo(Base):
+ __tablename__ = 'foo'
+ id = Column(Integer, primary_key=True)
+ data = composite(Point, Column('x', Integer), Column('y', Integer))
+
+ e = create_engine('sqlite://', echo=True)
+
+ Base.metadata.create_all(e)
+
+ sess = Session(e)
+ d = Point(3, 4)
+ f1 = Foo(data=d)
+ sess.add(f1)
+ sess.commit()
+
+ f1.data.y = 5
+ sess.commit()
+
+ assert f1.data == Point(3, 5)
+
+ \ No newline at end of file
diff --git a/examples/mutable_events/scalars.py b/examples/mutable_events/scalars.py
index b4d6b350d..1c135a957 100644
--- a/examples/mutable_events/scalars.py
+++ b/examples/mutable_events/scalars.py
@@ -1,3 +1,5 @@
+# this example is probably moving to be an extension.
+
from sqlalchemy.orm.attributes import flag_modified
from sqlalchemy import event
from sqlalchemy.orm import mapper
@@ -8,6 +10,9 @@ class TrackMutationsMixin(object):
"""Mixin that defines transparent propagation of change
events to a parent object.
+ This class might be moved to be a SQLA extension
+ due to its complexity and potential for widespread use.
+
"""
@memoized_property
def _parents(self):
@@ -76,7 +81,7 @@ class TrackMutationsMixin(object):
def listen_for_type(mapper, class_):
for prop in mapper.iterate_properties:
if hasattr(prop, 'columns') and isinstance(prop.columns[0].type, type_):
- cls.listen(getattr(class_, prop.key))
+ cls.associate_with_attribute(getattr(class_, prop.key))
event.listen(mapper, 'on_mapper_configured', listen_for_type)
@@ -121,7 +126,8 @@ if __name__ == '__main__':
def __delitem__(self, key):
dict.__delitem__(self, key)
self.on_change()
-
+
+ # TODO: do the metaclass approach the same as composite
MutationDict.associate_with_type(JSONEncodedDict)
Base = declarative_base()
diff --git a/lib/sqlalchemy/orm/descriptor_props.py b/lib/sqlalchemy/orm/descriptor_props.py
index 347f9bce9..5f974e260 100644
--- a/lib/sqlalchemy/orm/descriptor_props.py
+++ b/lib/sqlalchemy/orm/descriptor_props.py
@@ -66,7 +66,7 @@ class DescriptorProperty(MapperProperty):
lambda: self._comparator_factory(mapper),
doc=self.doc
)
-
+ proxy_attr.property = self
proxy_attr.impl = _ProxyImpl(self.key)
mapper.class_manager.instrument_attribute(self.key, proxy_attr)
@@ -81,6 +81,10 @@ class CompositeProperty(DescriptorProperty):
self.group = kwargs.get('group', None)
util.set_creation_order(self)
self._create_descriptor()
+
+ def instrument_class(self, mapper):
+ super(CompositeProperty, self).instrument_class(mapper)
+ self._setup_event_handlers()
def do_init(self):
"""Initialization which occurs after the :class:`.CompositeProperty`
@@ -88,43 +92,55 @@ class CompositeProperty(DescriptorProperty):
"""
self._setup_arguments_on_columns()
- self._setup_event_handlers()
def _create_descriptor(self):
- """Create the actual Python descriptor that will serve as
- the access point on the mapped class.
+ """Create the Python descriptor that will serve as
+ the access point on instances of the mapped class.
"""
def fget(instance):
dict_ = attributes.instance_dict(instance)
- if self.key in dict_:
- return dict_[self.key]
- else:
- dict_[self.key] = composite = self.composite_class(
- *[getattr(instance, key) for key in self._attribute_keys]
- )
- return composite
+
+ # key not present, assume the columns aren't
+ # loaded. The load events will establish
+ # the item.
+ if self.key not in dict_:
+ for key in self._attribute_keys:
+ getattr(instance, key)
+
+ return dict_.get(self.key, None)
def fset(instance, value):
+ dict_ = attributes.instance_dict(instance)
+ state = attributes.instance_state(instance)
+ attr = state.manager[self.key]
+ previous = dict_.get(self.key, attributes.NO_VALUE)
+ for fn in attr.dispatch.on_set:
+ value = fn(state, value, previous, attr.impl)
+ dict_[self.key] = value
if value is None:
- fdel(instance)
+ for key in self._attribute_keys:
+ setattr(instance, key, None)
else:
- dict_ = attributes.instance_dict(instance)
- dict_[self.key] = value
for key, value in zip(
self._attribute_keys,
value.__composite_values__()):
setattr(instance, key, value)
def fdel(instance):
+ state = attributes.instance_state(instance)
+ dict_ = attributes.instance_dict(instance)
+ previous = dict_.pop(self.key, attributes.NO_VALUE)
+ attr = state.manager[self.key]
+ attr.dispatch.on_remove(state, previous, attr.impl)
for key in self._attribute_keys:
setattr(instance, key, None)
self.descriptor = property(fget, fset, fdel)
def _setup_arguments_on_columns(self):
- """Propigate configuration arguments made on this composite
+ """Propagate configuration arguments made on this composite
to the target columns, for those that apply.
"""
@@ -137,19 +153,35 @@ class CompositeProperty(DescriptorProperty):
prop.group = self.group
def _setup_event_handlers(self):
- """Establish events that will clear out the composite value
- whenever changes in state occur on the target columns.
+ """Establish events that populate/expire the composite attribute."""
- """
def load_handler(state):
- state.dict.pop(self.key, None)
+ dict_ = state.dict
+
+ if self.key in dict_:
+ return
+
+ # if column elements aren't loaded, skip.
+ # __get__() will initiate a load for those
+ # columns
+ for k in self._attribute_keys:
+ if k not in dict_:
+ return
+
+ dict_[self.key] = self.composite_class(
+ *[state.dict[key] for key in
+ self._attribute_keys]
+ )
def expire_handler(state, keys):
if keys is None or set(self._attribute_keys).intersection(keys):
state.dict.pop(self.key, None)
def insert_update_handler(mapper, connection, state):
- state.dict.pop(self.key, None)
+ state.dict[self.key] = self.composite_class(
+ *[state.dict.get(key, None) for key in
+ self._attribute_keys]
+ )
event.listen(self.parent, 'on_after_insert',
insert_update_handler, raw=True)
@@ -159,14 +191,6 @@ class CompositeProperty(DescriptorProperty):
event.listen(self.parent, 'on_refresh', load_handler, raw=True)
event.listen(self.parent, "on_expire", expire_handler, raw=True)
- # TODO: add listeners to the column attributes, which
- # refresh the composite based on userland settings.
-
- # TODO: add a callable to the composite of the form
- # _on_change(self, attrname) which will send up a corresponding
- # refresh to the column attribute on all parents. Basically
- # a specialization of the scalars.py example.
-
@util.memoized_property
def _attribute_keys(self):
@@ -293,10 +317,18 @@ class SynonymProperty(DescriptorProperty):
self.descriptor = descriptor
self.comparator_factory = comparator_factory
self.doc = doc or (descriptor and descriptor.__doc__) or None
+
util.set_creation_order(self)
-
+
+ # TODO: when initialized, check _proxied_property,
+ # emit a warning if its not a column-based property
+
+ @util.memoized_property
+ def _proxied_property(self):
+ return getattr(self.parent.class_, self.name).property
+
def _comparator_factory(self, mapper):
- prop = getattr(mapper.class_, self.name).property
+ prop = self._proxied_property
if self.comparator_factory:
comp = self.comparator_factory(prop, mapper)
diff --git a/lib/sqlalchemy/orm/interfaces.py b/lib/sqlalchemy/orm/interfaces.py
index 47f63a7d6..6c512100f 100644
--- a/lib/sqlalchemy/orm/interfaces.py
+++ b/lib/sqlalchemy/orm/interfaces.py
@@ -168,7 +168,7 @@ class MapperProperty(object):
pass
- def compare(self, operator, value):
+ def compare(self, operator, value, **kw):
"""Return a compare operation for the columns represented by
this ``MapperProperty`` to the given value, which may be a
column value or an instance. 'operator' is an operator from
diff --git a/lib/sqlalchemy/orm/mapper.py b/lib/sqlalchemy/orm/mapper.py
index 346d7d4bf..cfd175008 100644
--- a/lib/sqlalchemy/orm/mapper.py
+++ b/lib/sqlalchemy/orm/mapper.py
@@ -894,18 +894,14 @@ class Mapper(object):
def get_property(self, key, _compile_mappers=True):
"""return a MapperProperty associated with the given key.
-
- Calls getattr() against the mapped class itself, so that class-level
- proxies will be resolved to the underlying property, if any.
-
"""
if _compile_mappers and _new_mappers:
configure_mappers()
try:
- return getattr(self.class_, key).property
- except AttributeError:
+ return self._props[key]
+ except KeyError:
raise sa_exc.InvalidRequestError(
"Mapper '%s' has no property '%s'" % (self, key))
diff --git a/test/orm/test_composites.py b/test/orm/test_composites.py
index 558a80b15..768d8636e 100644
--- a/test/orm/test_composites.py
+++ b/test/orm/test_composites.py
@@ -50,9 +50,9 @@ class PointTest(_base.MappedTest):
class Graph(_base.BasicEntity):
pass
class Edge(_base.BasicEntity):
- def __init__(self, start, end):
- self.start = start
- self.end = end
+ def __init__(self, *args):
+ if args:
+ self.start, self.end = args
mapper(Graph, graphs, properties={
'edges':relationship(Edge)
@@ -183,6 +183,20 @@ class PointTest(_base.MappedTest):
assert g2.edges[-1].start.x is None
assert g2.edges[-1].start.y is None
+ @testing.resolve_artifact_names
+ def test_expire(self):
+ sess = self._fixture()
+ g = sess.query(Graph).first()
+ e = g.edges[0]
+ sess.expire(e)
+ assert 'start' not in e.__dict__
+ assert e.start == Point(3, 4)
+
+ @testing.resolve_artifact_names
+ def test_default_value(self):
+ e = Edge()
+ eq_(e.start, None)
+
class PrimaryKeyTest(_base.MappedTest):
@classmethod
def define_tables(cls, metadata):
diff --git a/test/orm/test_mapper.py b/test/orm/test_mapper.py
index 8f3032555..ba7a82c62 100644
--- a/test/orm/test_mapper.py
+++ b/test/orm/test_mapper.py
@@ -1212,7 +1212,9 @@ class DocumentTest(testing.TestBase):
class OptionsTest(_fixtures.FixtureTest):
-
+
+ @testing.fails_if(lambda: True, "0.7 regression, may not support "
+ "synonyms for relationship")
@testing.fails_on('maxdb', 'FIXME: unknown')
@testing.resolve_artifact_names
def test_synonym_options(self):
@@ -1220,8 +1222,7 @@ class OptionsTest(_fixtures.FixtureTest):
addresses = relationship(mapper(Address, addresses), lazy='select',
order_by=addresses.c.id),
adlist = synonym('addresses')))
-
-
+
def go():
sess = create_session()
u = (sess.query(User).
diff --git a/test/orm/test_query.py b/test/orm/test_query.py
index cd0deee2e..15ffaff2f 100644
--- a/test/orm/test_query.py
+++ b/test/orm/test_query.py
@@ -1401,6 +1401,8 @@ class SynonymTest(QueryTest):
})
mapper(Keyword, keywords)
+ @testing.fails_if(lambda: True, "0.7 regression, may not support "
+ "synonyms for relationship")
def test_joins(self):
for j in (
['orders', 'items'],
@@ -1411,6 +1413,8 @@ class SynonymTest(QueryTest):
result = create_session().query(User).join(*j).filter_by(id=3).all()
assert [User(id=7, name='jack'), User(id=9, name='fred')] == result
+ @testing.fails_if(lambda: True, "0.7 regression, may not support "
+ "synonyms for relationship")
def test_with_parent(self):
for nameprop, orderprop in (
('name', 'orders'),