From 489d5010fda0c530f5b5cbb84ec127f4cab9b0cc Mon Sep 17 00:00:00 2001 From: Mike Bayer Date: Sat, 30 Jan 2010 18:28:37 +0000 Subject: [PATCH] - the "save-update" cascade will now cascade the pending *removed* values from a scalar or collection attribute into the new session during an add() operation. This so that the flush() operation will also delete or modify rows of those disconnected items. --- CHANGES | 5 ++++ doc/build/session.rst | 2 +- lib/sqlalchemy/orm/attributes.py | 10 +++++--- lib/sqlalchemy/orm/properties.py | 17 +++++++++---- lib/sqlalchemy/orm/session.py | 3 ++- test/orm/test_cascade.py | 43 ++++++++++++++++++++++++++++++-- 6 files changed, 68 insertions(+), 12 deletions(-) diff --git a/CHANGES b/CHANGES index 1ee77d6f76..fa9d2366d9 100644 --- a/CHANGES +++ b/CHANGES @@ -91,6 +91,11 @@ CHANGES example of how to integrate Beaker with SQLAlchemy. See the notes in the "examples" note below. + - the "save-update" cascade will now cascade the pending *removed* + values from a scalar or collection attribute into the new session + during an add() operation. This so that the flush() operation + will also delete or modify rows of those disconnected items. + - Using a "dynamic" loader with a "secondary" table now produces a query where the "secondary" table is *not* aliased. This allows the secondary Table object to be used in the "order_by" diff --git a/doc/build/session.rst b/doc/build/session.rst index cbd33357b2..9fa03a7833 100644 --- a/doc/build/session.rst +++ b/doc/build/session.rst @@ -395,7 +395,7 @@ Cascading is configured by setting the ``cascade`` keyword argument on a ``relat The above mapper specifies two relations, ``items`` and ``customer``. The ``items`` relationship specifies "all, delete-orphan" as its ``cascade`` value, indicating that all ``add``, ``merge``, ``expunge``, ``refresh`` ``delete`` and ``expire`` operations performed on a parent ``Order`` instance should also be performed on the child ``Item`` instances attached to it. The ``delete-orphan`` cascade value additionally indicates that if an ``Item`` instance is no longer associated with an ``Order``, it should also be deleted. The "all, delete-orphan" cascade argument allows a so-called *lifecycle* relationship between an ``Order`` and an ``Item`` object. -The ``customer`` relationship specifies only the "save-update" cascade value, indicating most operations will not be cascaded from a parent ``Order`` instance to a child ``User`` instance except for the ``add()`` operation. "save-update" cascade indicates that an ``add()`` on the parent will cascade to all child items, and also that items added to a parent which is already present in the session will also be added. +The ``customer`` relationship specifies only the "save-update" cascade value, indicating most operations will not be cascaded from a parent ``Order`` instance to a child ``User`` instance except for the ``add()`` operation. "save-update" cascade indicates that an ``add()`` on the parent will cascade to all child items, and also that items added to a parent which is already present in the session will also be added. "save-update" cascade also cascades the *pending history* of a relation()-based attribute, meaning that objects which were removed from a scalar or collection attribute whose changes have not yet been flushed are also placed into the new session - this so that foreign key clear operations and deletions will take place (new in 0.6). Note that the ``delete-orphan`` cascade only functions for relationships where the target object can have a single parent at a time, meaning it is only appropriate for one-to-one or one-to-many relationships. For a :func:`~sqlalchemy.orm.relation` which establishes one-to-one via a local foreign key, i.e. a many-to-one that stores only a single parent, or one-to-one/one-to-many via a "secondary" (association) table, a warning will be issued if ``delete-orphan`` is configured. To disable this warning, also specify the ``single_parent=True`` flag on the relationship, which constrains objects to allow attachment to only one parent at a time. diff --git a/lib/sqlalchemy/orm/attributes.py b/lib/sqlalchemy/orm/attributes.py index 3021f99042..574df7d91a 100644 --- a/lib/sqlalchemy/orm/attributes.py +++ b/lib/sqlalchemy/orm/attributes.py @@ -1227,13 +1227,17 @@ class History(tuple): return self != HISTORY_BLANK def sum(self): - return self.added + self.unchanged + self.deleted + return (self.added or []) +\ + (self.unchanged or []) +\ + (self.deleted or []) def non_deleted(self): - return self.added + self.unchanged + return (self.added or []) +\ + (self.unchanged or []) def non_added(self): - return self.unchanged + self.deleted + return (self.unchanged or []) +\ + (self.deleted or []) def has_changes(self): return bool(self.added or self.deleted) diff --git a/lib/sqlalchemy/orm/properties.py b/lib/sqlalchemy/orm/properties.py index 28f2f4dfd0..486aa2e4b7 100644 --- a/lib/sqlalchemy/orm/properties.py +++ b/lib/sqlalchemy/orm/properties.py @@ -694,17 +694,24 @@ class RelationProperty(StrategizedProperty): else: passive = attributes.PASSIVE_OFF - mapper = self.mapper.primary_mapper() - instances = state.value_as_iterable(self.key, passive=passive) + if type_ == 'save-update': + instances = attributes.get_state_history(state, self.key, passive=passive).sum() + else: + instances = state.value_as_iterable(self.key, passive=passive) + if instances: for c in instances: - if c is not None and c not in visited_instances and (halt_on is None or not halt_on(c)): + if c is not None and c not in visited_instances and \ + (halt_on is None or not halt_on(c)): if not isinstance(c, self.mapper.class_): raise AssertionError("Attribute '%s' on class '%s' doesn't handle objects " - "of type '%s'" % (self.key, str(self.parent.class_), str(c.__class__))) + "of type '%s'" % (self.key, + str(self.parent.class_), + str(c.__class__))) visited_instances.add(c) - # cascade using the mapper local to this object, so that its individual properties are located + # cascade using the mapper local to this + # object, so that its individual properties are located instance_mapper = object_mapper(c) yield (c, instance_mapper, attributes.instance_state(c)) diff --git a/lib/sqlalchemy/orm/session.py b/lib/sqlalchemy/orm/session.py index 77e15be5a9..d15fbbc1a7 100644 --- a/lib/sqlalchemy/orm/session.py +++ b/lib/sqlalchemy/orm/session.py @@ -1049,7 +1049,8 @@ class Session(object): self._cascade_save_or_update(state) def _cascade_save_or_update(self, state): - for state, mapper in _cascade_unknown_state_iterator('save-update', state, halt_on=lambda c:c in self): + for state, mapper in _cascade_unknown_state_iterator( + 'save-update', state, halt_on=lambda c:c in self): self._save_or_update_impl(state) def delete(self, instance): diff --git a/test/orm/test_cascade.py b/test/orm/test_cascade.py index 3b9481dae9..7264ed8243 100644 --- a/test/orm/test_cascade.py +++ b/test/orm/test_cascade.py @@ -2,7 +2,7 @@ from sqlalchemy.test.testing import assert_raises, assert_raises_message from sqlalchemy import Integer, String, ForeignKey, Sequence, exc as sa_exc from sqlalchemy.test.schema import Table, Column -from sqlalchemy.orm import mapper, relation, create_session, class_mapper, backref +from sqlalchemy.orm import mapper, relation, create_session, sessionmaker, class_mapper, backref from sqlalchemy.orm import attributes, exc as orm_exc from sqlalchemy.test import testing from sqlalchemy.test.testing import eq_ @@ -56,7 +56,26 @@ class O2MCascadeTest(_fixtures.FixtureTest): sess.add(o5) assert_raises_message(orm_exc.FlushError, "is an orphan", sess.flush) - + @testing.resolve_artifact_names + def test_save_update_sends_pending(self): + """test that newly added and deleted collection items are cascaded on save-update""" + + sess = sessionmaker(expire_on_commit=False)() + o1, o2, o3 = Order(description='o1'), Order(description='o2'), Order(description='o3') + u = User(name='jack', orders=[o1, o2]) + sess.add(u) + sess.commit() + sess.close() + + u.orders.append(o3) + u.orders.remove(o1) + + sess.add(u) + assert o1 in sess + assert o2 in sess + assert o3 in sess + sess.commit() + @testing.resolve_artifact_names def test_delete(self): sess = create_session() @@ -401,6 +420,26 @@ class M2OCascadeTest(_base.MappedTest): assert prefs.count().scalar() == 2 assert extra.count().scalar() == 2 + @testing.resolve_artifact_names + def test_save_update_sends_pending(self): + """test that newly added and deleted scalar items are cascaded on save-update""" + + sess = sessionmaker(expire_on_commit=False)() + p1, p2 = Pref(data='p1'), Pref(data='p2') + + + u = User(name='jack', pref=p1) + sess.add(u) + sess.commit() + sess.close() + + u.pref = p2 + + sess.add(u) + assert p1 in sess + assert p2 in sess + sess.commit() + @testing.fails_on('maxdb', 'FIXME: unknown') @testing.resolve_artifact_names def test_orphan_on_update(self): -- 2.47.3