summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMike Bayer <mike_mp@zzzcomputing.com>2013-11-19 19:16:26 -0500
committerMike Bayer <mike_mp@zzzcomputing.com>2013-11-19 19:16:26 -0500
commit63508b82cd5710c660383bcac5fcfd3bb6af83c1 (patch)
treed1d37de872d048d2beff170dac5324394ec58e99
parent452ce0c2e7fae13a569341cbbe6f3a988c0c6360 (diff)
downloadsqlalchemy-63508b82cd5710c660383bcac5fcfd3bb6af83c1.tar.gz
- 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. [ticket:2833]
-rw-r--r--doc/build/changelog/changelog_09.rst17
-rw-r--r--doc/build/changelog/migration_09.rst57
-rw-r--r--lib/sqlalchemy/orm/attributes.py6
-rw-r--r--lib/sqlalchemy/orm/state.py2
-rw-r--r--lib/sqlalchemy/orm/strategies.py1
-rw-r--r--test/orm/test_relationships.py114
6 files changed, 197 insertions, 0 deletions
diff --git a/doc/build/changelog/changelog_09.rst b/doc/build/changelog/changelog_09.rst
index 0427741cc..6b7eea2eb 100644
--- a/doc/build/changelog/changelog_09.rst
+++ b/doc/build/changelog/changelog_09.rst
@@ -16,6 +16,23 @@
.. 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
Added support for new :attr:`.Session.info` attribute to
:class:`.scoped_session`.
diff --git a/doc/build/changelog/migration_09.rst b/doc/build/changelog/migration_09.rst
index 936097328..72968a8f5 100644
--- a/doc/build/changelog/migration_09.rst
+++ b/doc/build/changelog/migration_09.rst
@@ -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:
diff --git a/lib/sqlalchemy/orm/attributes.py b/lib/sqlalchemy/orm/attributes.py
index e78973459..6071b565d 100644
--- a/lib/sqlalchemy/orm/attributes.py
+++ b/lib/sqlalchemy/orm/attributes.py
@@ -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:
diff --git a/lib/sqlalchemy/orm/state.py b/lib/sqlalchemy/orm/state.py
index 957e29700..ddd991acc 100644
--- a/lib/sqlalchemy/orm/state.py
+++ b/lib/sqlalchemy/orm/state.py
@@ -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:
diff --git a/lib/sqlalchemy/orm/strategies.py b/lib/sqlalchemy/orm/strategies.py
index 009bf74a4..b04338d9c 100644
--- a/lib/sqlalchemy/orm/strategies.py
+++ b/lib/sqlalchemy/orm/strategies.py
@@ -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
)
diff --git a/test/orm/test_relationships.py b/test/orm/test_relationships.py
index 4cd278e16..717f136c0 100644
--- a/test/orm/test_relationships.py
+++ b/test/orm/test_relationships.py
@@ -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_(