diff options
author | Mike Bayer <mike_mp@zzzcomputing.com> | 2014-05-29 19:54:51 -0400 |
---|---|---|
committer | Mike Bayer <mike_mp@zzzcomputing.com> | 2014-05-29 19:54:51 -0400 |
commit | 752f2e0fc8dec13e313a00f7c9720d24d21429ed (patch) | |
tree | b8e8c5aa2ade21e1a80fa55f2d4a9f9756cf9088 | |
parent | a04f0c1e3719d38e71509635cc1cba43a67ce090 (diff) | |
download | sqlalchemy-752f2e0fc8dec13e313a00f7c9720d24d21429ed.tar.gz |
- Adjustment to attribute mechanics concerning when a value is
implicitly initialized to None via first access; this action,
which has always resulted in a population of the attribute,
now emits an attribute event just like any other attribute set
operation and generates the same kind of history as one. Additionally,
many mapper internal operations will no longer implicitly generate
these "None" values when various never-set attributes are checked.
These are subtle behavioral fixes to attribute mechanics which provide
a better solution to the problem of :ticket:`3060`, which also
involves recognition of attributes explicitly set to ``None``
vs. attributes that were never set.
fixes #3061
-rw-r--r-- | doc/build/changelog/changelog_09.rst | 10 | ||||
-rw-r--r-- | doc/build/changelog/changelog_10.rst | 20 | ||||
-rw-r--r-- | doc/build/changelog/migration_10.rst | 74 | ||||
-rw-r--r-- | lib/sqlalchemy/orm/attributes.py | 32 | ||||
-rw-r--r-- | lib/sqlalchemy/orm/base.py | 3 | ||||
-rw-r--r-- | lib/sqlalchemy/orm/dependency.py | 5 | ||||
-rw-r--r-- | lib/sqlalchemy/orm/loading.py | 2 | ||||
-rw-r--r-- | lib/sqlalchemy/orm/mapper.py | 16 | ||||
-rw-r--r-- | lib/sqlalchemy/orm/persistence.py | 5 | ||||
-rw-r--r-- | lib/sqlalchemy/orm/session.py | 50 | ||||
-rw-r--r-- | lib/sqlalchemy/orm/strategies.py | 2 | ||||
-rw-r--r-- | lib/sqlalchemy/orm/sync.py | 2 | ||||
-rw-r--r-- | lib/sqlalchemy/orm/unitofwork.py | 5 | ||||
-rw-r--r-- | lib/sqlalchemy/orm/util.py | 3 | ||||
-rw-r--r-- | test/orm/test_attributes.py | 78 |
15 files changed, 233 insertions, 74 deletions
diff --git a/doc/build/changelog/changelog_09.rst b/doc/build/changelog/changelog_09.rst index 512dce091..240cc48f9 100644 --- a/doc/build/changelog/changelog_09.rst +++ b/doc/build/changelog/changelog_09.rst @@ -24,10 +24,12 @@ In this situation, a many-to-one relationship set to None, or in some cases a scalar attribute set to None, may not be detected as a net change in value, and therefore the UPDATE would not reset - what was on the previous row. This is due to some as-yet - unresovled side effects of the way attribute history works in terms - of implicitly assuming None isn't really a "change" for a previously - un-set attribute. See also :ticket:`3061`. + what was on the previous row. + + The fix here takes on a different form in 1.0.0 vs. 0.9.5. + In 1.0.0, the issue is ultimately resolved by :ticket:`3061`, + which reverts the more patchwork version of the fix as it exists + in 0.9.5. .. change:: :tags: bug, orm diff --git a/doc/build/changelog/changelog_10.rst b/doc/build/changelog/changelog_10.rst index 34466b9fe..36281a13e 100644 --- a/doc/build/changelog/changelog_10.rst +++ b/doc/build/changelog/changelog_10.rst @@ -16,6 +16,26 @@ .. changelog:: :version: 1.0.0 + .. change:: + :tags: enhancement, orm + :tickets: 3061 + + Adjustment to attribute mechanics concerning when a value is + implicitly initialized to None via first access; this action, + which has always resulted in a population of the attribute, + now emits an attribute event just like any other attribute set + operation and generates the same kind of history as one. Additionally, + many mapper internal operations will no longer implicitly generate + these "None" values when various never-set attributes are checked. + These are subtle behavioral fixes to attribute mechanics which provide + a better solution to the problem of :ticket:`3060`, which also + involves recognition of attributes explicitly set to ``None`` + vs. attributes that were never set. + + .. seealso:: + + :ref:`migration_3061` + .. change:: :tags: feature, sql :tickets: 3034 diff --git a/doc/build/changelog/migration_10.rst b/doc/build/changelog/migration_10.rst index 340f9b7ad..80c22045e 100644 --- a/doc/build/changelog/migration_10.rst +++ b/doc/build/changelog/migration_10.rst @@ -27,6 +27,80 @@ potentially backwards-incompatible changes. Behavioral Changes - ORM ======================== +.. _migration_3061: + +Changes to attribute events and other operations regarding attributes that have no pre-existing value +------------------------------------------------------------------------------------------------------ + +Given an object with no state:: + + >>> obj = Foo() + +It has always been SQLAlchemy's behavior such that if we access a scalar +or many-to-one attribute that was never set, it is returned as ``None``:: + + >>> obj.someattr + None + +This value of ``None`` is in fact now part of the state of ``obj``, and is +not unlike as though we had set the attribute explicitly, e.g. +``obj.someattr = None``. However, the "set on get" here would behave +differently as far as history and events. It would not emit any attribute +event, and additionally if we view history, we see this:: + + >>> inspect(obj).attrs.someattr.history + History(added=(), unchanged=[None], deleted=()) # 0.9 and below + +That is, it's as though the attribute were always ``None`` and were +never changed. This is explicitly different from if we had set the +attribute first instead:: + + >>> obj = Foo() + >>> obj.someattr = None + >>> inspect(obj).attrs.someattr.history + History(added=[None], unchanged=(), deleted=()) # all versions + +The above means that the behavior of our "set" operation can be corrupted +by the fact that the value was accessed via "get" earlier. In 1.0, this +inconsistency has been resolved:: + + >>> obj = Foo() + >>> obj.someattr + None + >>> inspect(obj).attrs.someattr.history + History(added=[None], unchanged=(), deleted=()) # 1.0 + >>> obj.someattr = None + >>> inspect(obj).attrs.someattr.history + History(added=[None], unchanged=(), deleted=()) + +The reason the above behavior hasn't had much impact is because the +INSERT statement in relational databases considers a missing value to be +the same as NULL in most cases. Whether SQLAlchemy received a history +event for a particular attribute set to None or not would usually not matter; +as the difference between sending None/NULL or not wouldn't have an impact. +However, as :ticket:`3060` illustrates, there are some seldom edge cases +where we do in fact want to positively have ``None`` set. Also, allowing +the attribute event here means it's now possible to create "default value" +functions for ORM mapped attributes. + +As part of this change, the generation of the implicit "None" is now disabled +for other situations where this used to occur; this includes when an +attribute set operation on a many-to-one is received; previously, the "old" value +would be "None" if it had been not set otherwise; it now will send the +value :data:`.orm.attributes.NEVER_SET`, which is a value that may be sent +to an attribute listener now. This symbol may also be received when +calling on mapper utility functions such as :meth:`.Mapper.primary_key_from_state`; +if the primary key attributes have no setting at all, whereas the value +would be ``None`` before, it will now be the :data:`.orm.attributes.NEVER_SET` +symbol, and no change to the object's state occurs. + +Performance-wise, the change has the tradeoff that an attribute will need +to be considered in a unit of work flush process in more cases than before, if it has +been accessed and populated with a default value. However, performance +is improved in the case where the unit of work inspects a pending object for +an existing primary key value, as the state of the object won't change +in the common case that none was set. + .. _behavioral_changes_core_10: Behavioral Changes - Core diff --git a/lib/sqlalchemy/orm/attributes.py b/lib/sqlalchemy/orm/attributes.py index 09d6e988d..306c86e3b 100644 --- a/lib/sqlalchemy/orm/attributes.py +++ b/lib/sqlalchemy/orm/attributes.py @@ -23,7 +23,8 @@ from .base import PASSIVE_NO_RESULT, ATTR_WAS_SET, ATTR_EMPTY, NO_VALUE,\ NEVER_SET, NO_CHANGE, CALLABLES_OK, SQL_OK, RELATED_OBJECT_OK,\ INIT_OK, NON_PERSISTENT_OK, LOAD_AGAINST_COMMITTED, PASSIVE_OFF,\ PASSIVE_RETURN_NEVER_SET, PASSIVE_NO_INITIALIZE, PASSIVE_NO_FETCH,\ - PASSIVE_NO_FETCH_RELATED, PASSIVE_ONLY_PERSISTENT, NO_AUTOFLUSH + PASSIVE_NO_FETCH_RELATED, PASSIVE_ONLY_PERSISTENT, NO_AUTOFLUSH,\ + _none_tuple from .base import state_str, instance_str @inspection._self_inspects @@ -355,7 +356,10 @@ class Event(object): self.op = op self.parent_token = self.impl.parent_token - + def __eq__(self, other): + return isinstance(other, Event) and \ + other.impl is self.impl and \ + other.op == self.op @property def key(self): return self.impl.key @@ -553,8 +557,15 @@ class AttributeImpl(object): def initialize(self, state, dict_): """Initialize the given state's attribute with an empty value.""" - dict_[self.key] = None - return None + old = NEVER_SET + value = None + if self.dispatch.set: + value = self.fire_replace_event(state, dict_, + None, old, None) + state._modified_event(dict_, self, old) + + dict_[self.key] = value + return value def get(self, state, dict_, passive=PASSIVE_OFF): """Retrieve a value from the given object. @@ -763,14 +774,7 @@ class ScalarObjectAttributeImpl(ScalarAttributeImpl): if self.dispatch._active_history: old = self.get(state, dict_, passive=PASSIVE_ONLY_PERSISTENT | NO_AUTOFLUSH) else: - # would like to call with PASSIVE_NO_FETCH ^ INIT_OK. However, - # we have a long-standing behavior that a "get()" on never set - # should implicitly set the value to None. Leaving INIT_OK - # set here means we are consistent whether or not we did a get - # first. - # see test_use_object_set_None vs. test_use_object_get_first_set_None - # in test_attributes.py - old = self.get(state, dict_, passive=PASSIVE_NO_FETCH) + old = self.get(state, dict_, passive=PASSIVE_NO_FETCH ^ INIT_OK) if check_old is not None and \ old is not PASSIVE_NO_RESULT and \ @@ -1087,7 +1091,7 @@ def backref_listeners(attribute, key, uselist): def emit_backref_from_scalar_set_event(state, child, oldchild, initiator): if oldchild is child: return child - if oldchild is not None and oldchild not in (PASSIVE_NO_RESULT, NEVER_SET): + if oldchild not in _none_tuple: # With lazy=None, there's no guarantee that the full collection is # present when updating via a backref. old_state, old_dict = instance_state(oldchild),\ @@ -1175,7 +1179,7 @@ _NO_STATE_SYMBOLS = frozenset([ History = util.namedtuple("History", [ "added", "unchanged", "deleted" - ]) +]) class History(History): diff --git a/lib/sqlalchemy/orm/base.py b/lib/sqlalchemy/orm/base.py index e81375787..896041980 100644 --- a/lib/sqlalchemy/orm/base.py +++ b/lib/sqlalchemy/orm/base.py @@ -151,7 +151,8 @@ NOT_EXTENSION = util.symbol('NOT_EXTENSION', """) -_none_set = frozenset([None]) +_none_set = frozenset([None, NEVER_SET, PASSIVE_NO_RESULT]) +_none_tuple = tuple(_none_set) # for "in" checks that won't trip __hash__ def _generative(*assertions): diff --git a/lib/sqlalchemy/orm/dependency.py b/lib/sqlalchemy/orm/dependency.py index 0d5a4f909..68ae0a0e4 100644 --- a/lib/sqlalchemy/orm/dependency.py +++ b/lib/sqlalchemy/orm/dependency.py @@ -745,11 +745,6 @@ class ManyToOneDP(DependencyProcessor): for child in history.added: self._synchronize(state, child, None, False, uowcommit, "add") - elif history.unchanged == [None]: - # this is to appease the case where our row - # here is in fact going to be a so-called "row switch", - # where an INSERT becomes an UPDATE. See #3060. - self._synchronize(state, None, None, True, uowcommit) if self.post_update: self._post_update(state, uowcommit, history.sum()) diff --git a/lib/sqlalchemy/orm/loading.py b/lib/sqlalchemy/orm/loading.py index 1fe924d96..dad1f1a0a 100644 --- a/lib/sqlalchemy/orm/loading.py +++ b/lib/sqlalchemy/orm/loading.py @@ -329,7 +329,7 @@ def instance_processor(mapper, context, path, adapter, if mapper.allow_partial_pks: is_not_primary_key = _none_set.issuperset else: - is_not_primary_key = _none_set.issubset + is_not_primary_key = _none_set.intersection def _instance(row, result): if not new_populators and invoke_all_eagers: diff --git a/lib/sqlalchemy/orm/mapper.py b/lib/sqlalchemy/orm/mapper.py index 3e93840a1..373e18271 100644 --- a/lib/sqlalchemy/orm/mapper.py +++ b/lib/sqlalchemy/orm/mapper.py @@ -24,6 +24,7 @@ from .. import sql, util, log, exc as sa_exc, event, schema, inspection from ..sql import expression, visitors, operators, util as sql_util from . import instrumentation, attributes, exc as orm_exc, loading from . import properties +from . import util as orm_util from .interfaces import MapperProperty, _InspectionAttr, _MappedAttribute from .base import _class_to_mapper, _state_mapper, class_mapper, \ @@ -2270,7 +2271,7 @@ class Mapper(_InspectionAttr): manager = state.manager return self._identity_class, tuple([ manager[self._columntoproperty[col].key].\ - impl.get(state, dict_, attributes.PASSIVE_OFF) + impl.get(state, dict_, attributes.PASSIVE_RETURN_NEVER_SET) for col in self.primary_key ]) @@ -2292,12 +2293,13 @@ class Mapper(_InspectionAttr): manager = state.manager return [ manager[self._columntoproperty[col].key].\ - impl.get(state, dict_, attributes.PASSIVE_OFF) + impl.get(state, dict_, + attributes.PASSIVE_RETURN_NEVER_SET) for col in self.primary_key ] def _get_state_attr_by_column(self, state, dict_, column, - passive=attributes.PASSIVE_OFF): + passive=attributes.PASSIVE_RETURN_NEVER_SET): prop = self._columntoproperty[column] return state.manager[prop.key].impl.get(state, dict_, passive=passive) @@ -2311,7 +2313,8 @@ class Mapper(_InspectionAttr): return self._get_committed_state_attr_by_column(state, dict_, column) def _get_committed_state_attr_by_column(self, state, dict_, - column, passive=attributes.PASSIVE_OFF): + column, + passive=attributes.PASSIVE_RETURN_NEVER_SET): prop = self._columntoproperty[column] return state.manager[prop.key].impl.\ @@ -2352,7 +2355,7 @@ class Mapper(_InspectionAttr): state, state.dict, leftcol, passive=attributes.PASSIVE_NO_INITIALIZE) - if leftval is attributes.PASSIVE_NO_RESULT or leftval is None: + if leftval in orm_util._none_set: raise ColumnsNotAvailable() binary.left = sql.bindparam(None, leftval, type_=binary.right.type) @@ -2361,8 +2364,7 @@ class Mapper(_InspectionAttr): state, state.dict, rightcol, passive=attributes.PASSIVE_NO_INITIALIZE) - if rightval is attributes.PASSIVE_NO_RESULT or \ - rightval is None: + if rightval in orm_util._none_set: raise ColumnsNotAvailable() binary.right = sql.bindparam(None, rightval, type_=binary.right.type) diff --git a/lib/sqlalchemy/orm/persistence.py b/lib/sqlalchemy/orm/persistence.py index 49d9d11b9..b4c31a027 100644 --- a/lib/sqlalchemy/orm/persistence.py +++ b/lib/sqlalchemy/orm/persistence.py @@ -386,11 +386,6 @@ def _collect_update_commands(base_mapper, uowtransaction, hasnull = True params[col._label] = value - # see #3060. Need to consider an "unchanged" None - # as potentially history for now. - elif row_switch and history.unchanged == [None]: - params[col.key] = None - hasdata = True if hasdata: if hasnull: raise orm_exc.FlushError( diff --git a/lib/sqlalchemy/orm/session.py b/lib/sqlalchemy/orm/session.py index 89d9946ee..3cc03a2d4 100644 --- a/lib/sqlalchemy/orm/session.py +++ b/lib/sqlalchemy/orm/session.py @@ -518,28 +518,28 @@ class Session(_SessionClassMethods): :ref:`session_autocommit` :param autoflush: When ``True``, all query operations will issue a - :meth:`~.Session.flush` call to this ``Session`` before proceeding. - This is a convenience feature so that :meth:`~.Session.flush` need - not be called repeatedly in order for database queries to retrieve + :meth:`~.Session.flush` call to this ``Session`` before proceeding. + This is a convenience feature so that :meth:`~.Session.flush` need + not be called repeatedly in order for database queries to retrieve results. It's typical that ``autoflush`` is used in conjunction with ``autocommit=False``. In this scenario, explicit calls to - :meth:`~.Session.flush` are rarely needed; you usually only need to + :meth:`~.Session.flush` are rarely needed; you usually only need to call :meth:`~.Session.commit` (which flushes) to finalize changes. - :param bind: An optional :class:`.Engine` or :class:`.Connection` to - which this ``Session`` should be bound. When specified, all SQL - operations performed by this session will execute via this + :param bind: An optional :class:`.Engine` or :class:`.Connection` to + which this ``Session`` should be bound. When specified, all SQL + operations performed by this session will execute via this connectable. :param binds: An optional dictionary which contains more granular "bind" information than the ``bind`` parameter provides. This - dictionary can map individual :class`.Table` - instances as well as :class:`~.Mapper` instances to individual - :class:`.Engine` or :class:`.Connection` objects. Operations which - proceed relative to a particular :class:`.Mapper` will consult this - dictionary for the direct :class:`.Mapper` instance as - well as the mapper's ``mapped_table`` attribute in order to locate a - connectable to use. The full resolution is described in the + dictionary can map individual :class`.Table` + instances as well as :class:`~.Mapper` instances to individual + :class:`.Engine` or :class:`.Connection` objects. Operations which + proceed relative to a particular :class:`.Mapper` will consult this + dictionary for the direct :class:`.Mapper` instance as + well as the mapper's ``mapped_table`` attribute in order to locate a + connectable to use. The full resolution is described in the :meth:`.Session.get_bind`. Usage looks like:: @@ -566,8 +566,8 @@ class Session(_SessionClassMethods): :meth:`~.Session.begin`, all of which are interdependent. :param expire_on_commit: Defaults to ``True``. When ``True``, all - instances will be fully expired after each :meth:`~.commit`, - so that all attribute/object access subsequent to a completed + instances will be fully expired after each :meth:`~.commit`, + so that all attribute/object access subsequent to a completed transaction will load from the most recent database state. :param extension: An optional @@ -585,16 +585,16 @@ class Session(_SessionClassMethods): .. versionadded:: 0.9.0 :param query_cls: Class which should be used to create new Query - objects, as returned by the :meth:`~.Session.query` method. Defaults + objects, as returned by the :meth:`~.Session.query` method. Defaults to :class:`.Query`. :param twophase: When ``True``, all transactions will be started as a "two phase" transaction, i.e. using the "two phase" semantics - of the database in use along with an XID. During a - :meth:`~.commit`, after :meth:`~.flush` has been issued for all + of the database in use along with an XID. During a + :meth:`~.commit`, after :meth:`~.flush` has been issued for all attached databases, the :meth:`~.TwoPhaseTransaction.prepare` method - on each database's :class:`.TwoPhaseTransaction` will be called. - This allows each database to roll back the entire transaction, + on each database's :class:`.TwoPhaseTransaction` will be called. + This allows each database to roll back the entire transaction, before each transaction is committed. :param weak_identity_map: Defaults to ``True`` - when set to @@ -1048,7 +1048,7 @@ class Session(_SessionClassMethods): bind Any Connectable: a :class:`.Engine` or :class:`.Connection`. - All subsequent operations involving this :class:`.Table` will use the + All subsequent operations involving this :class:`.Table` will use the given `bind`. """ @@ -1149,7 +1149,7 @@ class Session(_SessionClassMethods): ', '.join(context))) def query(self, *entities, **kwargs): - """Return a new :class:`.Query` object corresponding to this + """Return a new :class:`.Query` object corresponding to this :class:`.Session`.""" return self._query_cls(entities, self, **kwargs) @@ -1403,7 +1403,7 @@ class Session(_SessionClassMethods): instance_key = mapper._identity_key_from_state(state) - if _none_set.issubset(instance_key[1]) and \ + if _none_set.intersection(instance_key[1]) and \ not mapper.allow_partial_pks or \ _none_set.issuperset(instance_key[1]): raise exc.FlushError( @@ -1635,7 +1635,7 @@ class Session(_SessionClassMethods): self._update_impl(merged_state) new_instance = True - elif not _none_set.issubset(key[1]) or \ + elif not _none_set.intersection(key[1]) or \ (mapper.allow_partial_pks and not _none_set.issuperset(key[1])): merged = self.query(mapper.class_).get(key[1]) diff --git a/lib/sqlalchemy/orm/strategies.py b/lib/sqlalchemy/orm/strategies.py index c8946a0c0..2674b9c6f 100644 --- a/lib/sqlalchemy/orm/strategies.py +++ b/lib/sqlalchemy/orm/strategies.py @@ -564,7 +564,7 @@ class LazyLoader(AbstractRelationshipLoader): if pending: bind_values = sql_util.bind_values(lazy_clause) - if None in bind_values: + if orm_util._none_set.intersection(bind_values): return None q = q.filter(lazy_clause) diff --git a/lib/sqlalchemy/orm/sync.py b/lib/sqlalchemy/orm/sync.py index aed98bdf0..7556e9f71 100644 --- a/lib/sqlalchemy/orm/sync.py +++ b/lib/sqlalchemy/orm/sync.py @@ -48,7 +48,7 @@ def clear(dest, dest_mapper, synchronize_pairs): for l, r in synchronize_pairs: if r.primary_key and \ dest_mapper._get_state_attr_by_column( - dest, dest.dict, r) is not None: + dest, dest.dict, r) not in orm_util._none_set: raise AssertionError( "Dependency rule tried to blank-out primary key " diff --git a/lib/sqlalchemy/orm/unitofwork.py b/lib/sqlalchemy/orm/unitofwork.py index 2964705a2..3ef2b2edf 100644 --- a/lib/sqlalchemy/orm/unitofwork.py +++ b/lib/sqlalchemy/orm/unitofwork.py @@ -86,9 +86,8 @@ def track_cascade_events(descriptor, prop): not sess._contains_state(newvalue_state): sess._save_or_update_state(newvalue_state) - if oldvalue is not None and \ - oldvalue is not attributes.PASSIVE_NO_RESULT and \ - prop._cascade.delete_orphan: + if oldvalue not in orm_util._none_tuple and \ + prop._cascade.delete_orphan: # possible to reach here with attributes.NEVER_SET ? oldvalue_state = attributes.instance_state(oldvalue) diff --git a/lib/sqlalchemy/orm/util.py b/lib/sqlalchemy/orm/util.py index 8694705a4..fd902adaf 100644 --- a/lib/sqlalchemy/orm/util.py +++ b/lib/sqlalchemy/orm/util.py @@ -12,7 +12,8 @@ from . import attributes import re from .base import instance_str, state_str, state_class_str, attribute_str, \ - state_attribute_str, object_mapper, object_state, _none_set + state_attribute_str, object_mapper, object_state, _none_set, \ + _none_tuple from .base import class_mapper, _class_to_mapper from .base import _InspectionAttr from .path_registry import PathRegistry diff --git a/test/orm/test_attributes.py b/test/orm/test_attributes.py index b44f883c9..ab9b9f5d5 100644 --- a/test/orm/test_attributes.py +++ b/test/orm/test_attributes.py @@ -906,7 +906,7 @@ class GetNoValueTest(fixtures.ORMTest): "attr", useobject=True, uselist=False) - f1 = Foo() + f1 = self.f1 = Foo() return Foo.attr.impl,\ attributes.instance_state(f1), \ attributes.instance_dict(f1) @@ -1566,6 +1566,31 @@ class HistoryTest(fixtures.TestBase): f.someattr = 'hi' eq_(self._someattr_history(f), (['hi'], (), ())) + def test_scalar_set_None(self): + # note - compare: + # test_scalar_set_None, + # test_scalar_get_first_set_None, + # test_use_object_set_None, + # test_use_object_get_first_set_None + Foo = self._fixture(uselist=False, useobject=False, + active_history=False) + f = Foo() + f.someattr = None + eq_(self._someattr_history(f), ([None], (), ())) + + def test_scalar_get_first_set_None(self): + # note - compare: + # test_scalar_set_None, + # test_scalar_get_first_set_None, + # test_use_object_set_None, + # test_use_object_get_first_set_None + Foo = self._fixture(uselist=False, useobject=False, + active_history=False) + f = Foo() + assert f.someattr is None + f.someattr = None + eq_(self._someattr_history(f), ([None], (), ())) + def test_scalar_set_commit(self): Foo = self._fixture(uselist=False, useobject=False, active_history=False) @@ -1954,20 +1979,27 @@ class HistoryTest(fixtures.TestBase): eq_(self._someattr_history(f), ((), [there], ())) def test_use_object_set_None(self): + # note - compare: + # test_scalar_set_None, + # test_scalar_get_first_set_None, + # test_use_object_set_None, + # test_use_object_get_first_set_None Foo, Bar = self._two_obj_fixture(uselist=False) f = Foo() f.someattr = None - # we'd expect ([None], (), ()), however because - # we set to None w/o setting history if we were to "get" first, - # it is more consistent that this doesn't set history. - eq_(self._someattr_history(f), ((), [None], ())) + eq_(self._someattr_history(f), ([None], (), ())) def test_use_object_get_first_set_None(self): + # note - compare: + # test_scalar_set_None, + # test_scalar_get_first_set_None, + # test_use_object_set_None, + # test_use_object_get_first_set_None Foo, Bar = self._two_obj_fixture(uselist=False) f = Foo() assert f.someattr is None f.someattr = None - eq_(self._someattr_history(f), ((), [None], ())) + eq_(self._someattr_history(f), ([None], (), ())) def test_use_object_set_dict_set_None(self): Foo, Bar = self._two_obj_fixture(uselist=False) @@ -2526,6 +2558,40 @@ class ListenerTest(fixtures.ORMTest): f1.barlist.remove(None) eq_(canary, [(f1, b1), (f1, None), (f1, b2), (f1, None)]) + def test_none_init_scalar(self): + canary = Mock() + class Foo(object): + pass + instrumentation.register_class(Foo) + attributes.register_attribute(Foo, 'bar') + + event.listen(Foo.bar, "set", canary) + + f1 = Foo() + eq_(f1.bar, None) + eq_(canary.mock_calls, [ + call( + f1, None, attributes.NEVER_SET, + attributes.Event(Foo.bar.impl, attributes.OP_REPLACE)) + ]) + + def test_none_init_object(self): + canary = Mock() + class Foo(object): + pass + instrumentation.register_class(Foo) + attributes.register_attribute(Foo, 'bar', useobject=True) + + event.listen(Foo.bar, "set", canary) + + f1 = Foo() + eq_(f1.bar, None) + eq_(canary.mock_calls, [ + call( + f1, None, attributes.NEVER_SET, + attributes.Event(Foo.bar.impl, attributes.OP_REPLACE)) + ]) + def test_propagate(self): classes = [None, None, None] canary = [] |