]> git.ipfire.org Git - thirdparty/sqlalchemy/sqlalchemy.git/commitdiff
- The ``viewonly`` flag on :func:`.relationship` will now prevent
authorMike Bayer <mike_mp@zzzcomputing.com>
Wed, 20 Nov 2013 00:16:26 +0000 (19:16 -0500)
committerMike Bayer <mike_mp@zzzcomputing.com>
Wed, 20 Nov 2013 00:16:26 +0000 (19:16 -0500)
attribute history from being written on behalf of the target attribute.
This has the effect of the object not being written to the
Session.dirty list if it is mutated.  Previously, the object would
be present in Session.dirty, but no change would take place on behalf
of the modified attribute during flush.   The attribute still emits
events such as backref events and user-defined events and will still
receive mutations from backrefs. [ticket:2833]

doc/build/changelog/changelog_09.rst
doc/build/changelog/migration_09.rst
lib/sqlalchemy/orm/attributes.py
lib/sqlalchemy/orm/state.py
lib/sqlalchemy/orm/strategies.py
test/orm/test_relationships.py

index 0427741ccbc79eb928294a87d9b967b68a629664..6b7eea2ebac962dfcafa19062112b590cd180a41 100644 (file)
 .. changelog::
     :version: 0.9.0b2
 
+    .. change::
+        :tags: bug, orm
+        :tickets: 2833
+
+        The ``viewonly`` flag on :func:`.relationship` will now prevent
+        attribute history from being written on behalf of the target attribute.
+        This has the effect of the object not being written to the
+        Session.dirty list if it is mutated.  Previously, the object would
+        be present in Session.dirty, but no change would take place on behalf
+        of the modified attribute during flush.   The attribute still emits
+        events such as backref events and user-defined events and will still
+        receive mutations from backrefs.
+
+        .. seealso::
+
+            :ref:`migration_2833`
+
     .. change::
         :tags: bug, orm
 
index 936097328c51a80b80fb01b4a9205691039914b3..72968a8f54fc387015f29d5ad6ddb077e58cc60e 100644 (file)
@@ -229,6 +229,63 @@ new ``initiator`` value for some operations.
 
 :ticket:`2789`
 
+.. _migration_2833:
+
+``viewonly=True`` on ``relationship()`` prevents history from taking effect
+---------------------------------------------------------------------------
+
+The ``viewonly`` flag on :func:`.relationship` is applied to prevent changes
+to the target attribute from having any effect within the flush process.
+This is achieved by eliminating the attribute from being considered during
+the flush.  However, up until now, changes to the attribute would still
+register the parent object as "dirty" and trigger a potential flush.  The change
+is that the ``viewonly`` flag now prevents history from being set for the
+target attribute as well.  Attribute events like backrefs and user-defined events
+still continue to function normally.
+
+The change is illustrated as follows::
+
+    from sqlalchemy import Column, Integer, ForeignKey, create_engine
+    from sqlalchemy.orm import backref, relationship, Session
+    from sqlalchemy.ext.declarative import declarative_base
+    from sqlalchemy import inspect
+
+    Base = declarative_base()
+
+    class A(Base):
+        __tablename__ = 'a'
+        id = Column(Integer, primary_key=True)
+
+    class B(Base):
+        __tablename__ = 'b'
+
+        id = Column(Integer, primary_key=True)
+        a_id = Column(Integer, ForeignKey('a.id'))
+        a = relationship("A", backref=backref("bs", viewonly=True))
+
+    e = create_engine("sqlite://")
+    Base.metadata.create_all(e)
+
+    a = A()
+    b = B()
+
+    sess = Session(e)
+    sess.add_all([a, b])
+    sess.commit()
+
+    b.a = a
+
+    assert b in sess.dirty
+
+    # before 0.9.0b2
+    # assert a in sess.dirty
+    # assert inspect(a).attrs.bs.history.has_changes()
+
+    # after 0.9.0b2
+    assert a not in sess.dirty
+    assert not inspect(a).attrs.bs.history.has_changes()
+
+:ticket:`2833`
 
 .. _migration_2751:
 
index e7897345910370c6a156ed1355bf6b42868b1d91..6071b565dc4c6582b50fed1f8d5306e133cac375 100644 (file)
@@ -370,6 +370,7 @@ class AttributeImpl(object):
                     callable_, dispatch, trackparent=False, extension=None,
                     compare_function=None, active_history=False,
                     parent_token=None, expire_missing=True,
+                    send_modified_events=True,
                     **kwargs):
         """Construct an AttributeImpl.
 
@@ -413,6 +414,10 @@ class AttributeImpl(object):
           during state.expire_attributes(None), if no value is present
           for this key.
 
+        send_modified_events
+          if False, the InstanceState._modified_event method will have no effect;
+          this means the attribute will never show up as changed in a
+          history entry.
         """
         self.class_ = class_
         self.key = key
@@ -420,6 +425,7 @@ class AttributeImpl(object):
         self.dispatch = dispatch
         self.trackparent = trackparent
         self.parent_token = parent_token or self
+        self.send_modified_events = send_modified_events
         if compare_function is None:
             self.is_equal = operator.eq
         else:
index 957e29700e38e5df09fcea94b5df17972967b184..ddd991acc91e22d597d4cc2c9ce715470966b47a 100644 (file)
@@ -415,6 +415,8 @@ class InstanceState(interfaces._InspectionAttr):
         return None
 
     def _modified_event(self, dict_, attr, previous, collection=False):
+        if not attr.send_modified_events:
+            return
         if attr.key not in self.committed_state:
             if collection:
                 if previous is NEVER_SET:
index 009bf74a4fbcafa28e6b07afe0849fbf2c3b4acb..b04338d9c66d80dc16705f5e185843d794604b1f 100644 (file)
@@ -80,6 +80,7 @@ def _register_attribute(strategy, mapper, useobject,
                 callable_=callable_,
                 active_history=active_history,
                 impl_class=impl_class,
+                send_modified_events=not useobject or not prop.viewonly,
                 doc=prop.doc,
                 **kw
                 )
index 4cd278e162b72de276fc3661ae7efbf9750d8cbc..717f136c0c5cdbafb8537006d61dc9d82aaabc46 100644 (file)
@@ -14,6 +14,7 @@ from sqlalchemy.testing import eq_, startswith_, AssertsCompiledSQL, is_
 from sqlalchemy.testing import fixtures
 from test.orm import _fixtures
 from sqlalchemy import exc
+from sqlalchemy import inspect
 
 class _RelationshipErrors(object):
     def _assert_raises_no_relevant_fks(self, fn, expr, relname,
@@ -1516,6 +1517,117 @@ class TypedAssociationTable(fixtures.MappedTest):
 
         assert t3.count().scalar() == 1
 
+class ViewOnlyHistoryTest(fixtures.MappedTest):
+    @classmethod
+    def define_tables(cls, metadata):
+        Table("t1", metadata,
+            Column('id', Integer, primary_key=True,
+                                    test_needs_autoincrement=True),
+            Column('data', String(40)))
+        Table("t2", metadata,
+            Column('id', Integer, primary_key=True,
+                                    test_needs_autoincrement=True),
+            Column('data', String(40)),
+            Column('t1id', Integer, ForeignKey('t1.id')))
+
+    def _assert_fk(self, a1, b1, is_set):
+        s = Session(testing.db)
+        s.add_all([a1, b1])
+        s.flush()
+
+        if is_set:
+            eq_(b1.t1id, a1.id)
+        else:
+            eq_(b1.t1id, None)
+
+        return s
+
+    def test_o2m_viewonly_oneside(self):
+        class A(fixtures.ComparableEntity):
+            pass
+        class B(fixtures.ComparableEntity):
+            pass
+
+        mapper(A, self.tables.t1, properties={
+            "bs": relationship(B, viewonly=True,
+                            backref=backref("a", viewonly=False))
+        })
+        mapper(B, self.tables.t2)
+
+        a1 = A()
+        b1 = B()
+        a1.bs.append(b1)
+        assert b1.a is a1
+        assert not inspect(a1).attrs.bs.history.has_changes()
+        assert inspect(b1).attrs.a.history.has_changes()
+
+        sess = self._assert_fk(a1, b1, True)
+
+        a1.bs.remove(b1)
+        assert a1 not in sess.dirty
+        assert b1 in sess.dirty
+
+    def test_m2o_viewonly_oneside(self):
+        class A(fixtures.ComparableEntity):
+            pass
+        class B(fixtures.ComparableEntity):
+            pass
+
+        mapper(A, self.tables.t1, properties={
+            "bs": relationship(B, viewonly=False,
+                            backref=backref("a", viewonly=True))
+        })
+        mapper(B, self.tables.t2)
+
+        a1 = A()
+        b1 = B()
+        b1.a = a1
+        assert b1 in a1.bs
+        assert inspect(a1).attrs.bs.history.has_changes()
+        assert not inspect(b1).attrs.a.history.has_changes()
+
+        sess = self._assert_fk(a1, b1, True)
+
+        a1.bs.remove(b1)
+        assert a1 in sess.dirty
+        assert b1 not in sess.dirty
+
+    def test_o2m_viewonly_only(self):
+        class A(fixtures.ComparableEntity):
+            pass
+        class B(fixtures.ComparableEntity):
+            pass
+
+        mapper(A, self.tables.t1, properties={
+            "bs": relationship(B, viewonly=True)
+        })
+        mapper(B, self.tables.t2)
+
+        a1 = A()
+        b1 = B()
+        a1.bs.append(b1)
+        assert not inspect(a1).attrs.bs.history.has_changes()
+
+        self._assert_fk(a1, b1, False)
+
+    def test_m2o_viewonly_only(self):
+        class A(fixtures.ComparableEntity):
+            pass
+        class B(fixtures.ComparableEntity):
+            pass
+
+        mapper(A, self.tables.t1)
+        mapper(B, self.tables.t2, properties={
+            'a': relationship(A, viewonly=True)
+            })
+
+        a1 = A()
+        b1 = B()
+        b1.a = a1
+        assert not inspect(b1).attrs.a.history.has_changes()
+
+        self._assert_fk(a1, b1, False)
+
 class ViewOnlyM2MBackrefTest(fixtures.MappedTest):
     @classmethod
     def define_tables(cls, metadata):
@@ -1551,6 +1663,8 @@ class ViewOnlyM2MBackrefTest(fixtures.MappedTest):
         a1 = A()
         b1 = B(as_=[a1])
 
+        assert not inspect(b1).attrs.as_.history.has_changes()
+
         sess.add(a1)
         sess.flush()
         eq_(