From: Mike Bayer Date: Wed, 29 Dec 2010 03:23:13 +0000 (-0500) Subject: - restore mapper.get_property() to use the _props dict. at the moment X-Git-Tag: rel_0_7b1~107 X-Git-Url: http://git.ipfire.org/cgi-bin/gitweb.cgi?a=commitdiff_plain;h=3b41b66981d8665282c645178643d273361eb6ad;p=thirdparty%2Fsqlalchemy%2Fsqlalchemy.git - 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. --- diff --git a/examples/mutable_events/composite.py b/examples/mutable_events/composite.py new file mode 100644 index 0000000000..f46f28e6dc --- /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 b4d6b350d7..1c135a9576 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 347f9bce9a..5f974e2607 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 47f63a7d6b..6c512100f4 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 346d7d4bf6..cfd1750086 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 558a80b15f..768d8636e6 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 8f30325554..ba7a82c62a 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 cd0deee2ec..15ffaff2f0 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'),