diff --git a/CHANGES b/CHANGES index aba313a7ce..721cc4d732 100644 --- a/CHANGES +++ b/CHANGES @@ -50,6 +50,20 @@ CHANGES those configured on the mapper. False will make it as though order_by() was never called, while None is an active setting. + + - An instance which is moved to "transient", has + an incomplete or missing set of primary key + attributes, and contains expired attributes, will + raise an InvalidRequestError if an expired attribute + is accessed, instead of getting a recursion overflow. + + - The make_transient() function is now in the generated + documentation. + + - make_transient() removes all "loader" callables from + the state being made transient, removing any + "expired" state - all unloaded attributes reset back + to undefined, None/empty on access. - sql - The warning emitted by the Unicode and String types diff --git a/lib/sqlalchemy/orm/mapper.py b/lib/sqlalchemy/orm/mapper.py index 935cbc35d7..b122b49510 100644 --- a/lib/sqlalchemy/orm/mapper.py +++ b/lib/sqlalchemy/orm/mapper.py @@ -2407,6 +2407,14 @@ def _load_scalar_attributes(state, attribute_names): if has_key: identity_key = state.key else: + # this codepath is rare - only valid when inside a flush, and the + # object is becoming persistent but hasn't yet been assigned an identity_key. + # check here to ensure we have the attrs we need. + pk_attrs = [mapper._get_col_to_prop(col).key for col in mapper.primary_key] + if state.expired_attributes.intersection(pk_attrs): + raise sa_exc.InvalidRequestError("Instance %s cannot be refreshed - it's not " + " persistent and does not " + "contain a full primary key." % state_str(state)) identity_key = mapper._identity_key_from_state(state) if (_none_set.issubset(identity_key) and \ diff --git a/lib/sqlalchemy/orm/session.py b/lib/sqlalchemy/orm/session.py index af646aa5f5..ead860ebd3 100644 --- a/lib/sqlalchemy/orm/session.py +++ b/lib/sqlalchemy/orm/session.py @@ -1592,11 +1592,19 @@ def make_transient(instance): such that it's as though the object were newly constructed, except retaining its values. + Attributes which were "expired" or deferred at the + instance level are reverted to undefined, and + will not trigger any loads. + """ state = attributes.instance_state(instance) s = _state_session(state) if s: s._expunge_state(state) + + # remove expired state and + # deferred callables + state.callables.clear() del state.key diff --git a/test/orm/test_expire.py b/test/orm/test_expire.py index 926ae06299..c0c96f8730 100644 --- a/test/orm/test_expire.py +++ b/test/orm/test_expire.py @@ -221,7 +221,23 @@ class ExpireTest(_fixtures.FixtureTest): assert 'name' not in u.__dict__ sess.add(u) assert u.name == 'jack' + + @testing.resolve_artifact_names + def test_no_instance_key_no_pk(self): + # same as test_no_instance_key, but the PK columns + # are absent. ensure an error is raised. + mapper(User, users) + sess = create_session() + u = sess.query(User).get(7) + sess.expire(u, attribute_names=['name', 'id']) + sess.expunge(u) + attributes.instance_state(u).key = None + assert 'name' not in u.__dict__ + sess.add(u) + assert_raises(sa_exc.InvalidRequestError, getattr, u, 'name') + + @testing.resolve_artifact_names def test_expire_preserves_changes(self): """test that the expire load operation doesn't revert post-expire changes""" diff --git a/test/orm/test_session.py b/test/orm/test_session.py index fca3bf757e..36709dbcb3 100644 --- a/test/orm/test_session.py +++ b/test/orm/test_session.py @@ -250,6 +250,15 @@ class SessionTest(_fixtures.FixtureTest): sess.add(u1) assert u1 in sess.new + # test expired attributes + # get unexpired + u1 = sess.query(User).first() + sess.expire(u1) + make_transient(u1) + assert u1.id is None + assert u1.name is None + + @testing.resolve_artifact_names def test_autoflush_expressions(self): """test that an expression which is dependent on object state is