ColumnPoperty.merge() w/ load=False calls expire_attributes on a deferred, forcing it to load on unexpire

Issue #3488 resolved
Mike Bayer repo owner created an issue
from sqlalchemy import *
from sqlalchemy.orm import *
from sqlalchemy.ext.declarative import declarative_base

Base = declarative_base()


class A(Base):
    __tablename__ = 'a'
    id = Column(Integer, primary_key=True)
    d1 = deferred(Column(String))
    d2 = Column(String)

e = create_engine("sqlite://", echo=True)
Base.metadata.create_all(e)

a1 = A(d1='d1', d2='d2')

s = Session(e)
s.add(a1)
s.commit()
s.close()

a1 = s.query(A).first()

s.expire(a1, ['d2'])
s.close()

a1 = s.merge(a1, load=False)
print "---------------"
# emits load for d1 also
print a1.d2

this is not the fix because it does not take the strategy itself or load_options into account, but this is the idea:

diff --git a/lib/sqlalchemy/orm/properties.py b/lib/sqlalchemy/orm/properties.py
index 55e0298..2bc9626 100644
--- a/lib/sqlalchemy/orm/properties.py
+++ b/lib/sqlalchemy/orm/properties.py
@@ -217,7 +217,7 @@ class ColumnProperty(StrategizedProperty):
             else:
                 impl = dest_state.get_impl(self.key)
                 impl.set(dest_state, dest_dict, value, None)
-        elif dest_state.has_identity and self.key not in dest_dict:
+        elif dest_state.has_identity and self.key not in dest_dict and not self.deferred:
             dest_state._expire_attributes(dest_dict, [self.key])

     class Comparator(util.MemoizedSlots, PropComparator):

Comments (5)

  1. Mike Bayer reporter

    here's the final patch w/o tests, which also introduces that we copy the callables_ over from the loaded state:

    diff --git a/lib/sqlalchemy/orm/properties.py b/lib/sqlalchemy/orm/properties.py
    index 55e0298..33a3dec 100644
    --- a/lib/sqlalchemy/orm/properties.py
    +++ b/lib/sqlalchemy/orm/properties.py
    @@ -218,7 +218,8 @@ class ColumnProperty(StrategizedProperty):
                     impl = dest_state.get_impl(self.key)
                     impl.set(dest_state, dest_dict, value, None)
             elif dest_state.has_identity and self.key not in dest_dict:
    -            dest_state._expire_attributes(dest_dict, [self.key])
    +            dest_state._expire_attributes(
    +                dest_dict, [self.key], no_loader=True)
    
         class Comparator(util.MemoizedSlots, PropComparator):
             """Produce boolean, comparison, and other operators for
    diff --git a/lib/sqlalchemy/orm/session.py b/lib/sqlalchemy/orm/session.py
    index 4619027..948678b 100644
    --- a/lib/sqlalchemy/orm/session.py
    +++ b/lib/sqlalchemy/orm/session.py
    @@ -1784,6 +1784,13 @@ class Session(_SessionClassMethods):
                 merged_state.load_path = state.load_path
                 merged_state.load_options = state.load_options
    
    +            # since we are copying load_options, we need to copy
    +            # the callables_ that would have been generated by those
    +            # load_options.
    +            # assumes that the callables we put in state.callables_
    +            # are not instance-specific (which they should not be)
    +            merged_state._copy_callables(state)
    +
                 for prop in mapper.iterate_properties:
                     prop.merge(self, state, state_dict,
                                merged_state, merged_dict,
    diff --git a/lib/sqlalchemy/orm/state.py b/lib/sqlalchemy/orm/state.py
    index 6034e74..dc55b99 100644
    --- a/lib/sqlalchemy/orm/state.py
    +++ b/lib/sqlalchemy/orm/state.py
    @@ -391,6 +391,10 @@ class InstanceState(interfaces.InspectionAttr):
             if self.callables:
                 self.callables.pop(key, None)
    
    +    def _copy_callables(self, from_):
    +        if 'callables' in from_.__dict__:
    +            self.callables = dict(from_.callables)
    +
         @classmethod
         def _instance_level_callable_processor(cls, manager, fn, key):
             impl = manager[key].impl
    @@ -443,7 +447,7 @@ class InstanceState(interfaces.InspectionAttr):
    
             self.manager.dispatch.expire(self, None)
    
    -    def _expire_attributes(self, dict_, attribute_names):
    +    def _expire_attributes(self, dict_, attribute_names, no_loader=False):
             pending = self.__dict__.get('_pending_mutations', None)
    
             callables = self.callables
    @@ -451,6 +455,12 @@ class InstanceState(interfaces.InspectionAttr):
             for key in attribute_names:
                 impl = self.manager[key].impl
                 if impl.accepts_scalar_loader:
    +                if no_loader and (
    +                    impl.callable_ or
    +                    key in callables
    +                ):
    +                    continue
    +
                     self.expired_attributes.add(key)
                     if callables and key in callables:
                         del callables[key]
    

    tests are:

    class A(Base):
        __tablename__ = 'a'
        id = Column(Integer, primary_key=True)
        d1 = deferred(Column(String))
        d2 = Column(String)
    
    # ...
    s.expire(a1, ['d2'])
    s.close()
    
    a1 = s.merge(a1, load=False)
    print "---------------"
    # should not emit load for d1 also
    print a1.d2
    
    
    print "---------------"
    # loads correctly
    print a1.d1
    

    and then for the local callable:

    class A(Base):
        __tablename__ = 'a'
        id = Column(Integer, primary_key=True)
        d1 = Column(String)
        d2 = Column(String)
    
    # ...
    
    # defer at query level instead of mapping level
    a1 = s.query(A).options(defer(A.d1)).first()
    
    s.expire(a1, ['d2'])
    #s.close()
    
    s2 = Session(e)
    print "^^^"
    a1 = s2.merge(a1, load=False)
    print "---------------"
    # should not emit load for d1 also
    print a1.d2
    
    
    print "---------------"
    # loads correctly
    print a1.d1
    
  2. Log in to comment