.. changelog::
:version: 1.1.0b1
+ .. change::
+ :tags: bug, orm
+ :tickets: 3601
+
+ The :meth:`.Session.merge` method now tracks pending objects by
+ primary key before emitting an INSERT, and merges distinct objects with
+ duplicate primary keys together as they are encountered, which is
+ essentially semi-deterministic at best. This behavior
+ matches what happens already with persistent objects.
+
+ .. seealso::
+
+ :ref:`change_3601`
+
.. change::
:tags: bug, postgresql
:tickets: 3587
some issues may be moved to later milestones in order to allow
for a timely release.
- Document last updated: November 11, 2015
+ Document last updated: December 4, 2015
Introduction
============
:ticket:`3582`
+.. _change_3601:
+
+Session.merge resolves pending conflicts the same as persistent
+---------------------------------------------------------------
+
+The :meth:`.Session.merge` method will now track the identities of objects given
+within a graph to maintain primary key uniqueness before emitting an INSERT.
+When duplicate objects of the same identity are encountered, non-primary-key
+attributes are **overwritten** as the objects are encountered, which is
+essentially non-deterministic. This behavior matches that of how persistent
+objects, that is objects that are already located in the database via
+primary key, are already treated, so this behavior is more internally
+consistent.
+
+Given::
+
+ u1 = User(id=7, name='x')
+ u1.orders = [
+ Order(description='o1', address=Address(id=1, email_address='a')),
+ Order(description='o2', address=Address(id=1, email_address='b')),
+ Order(description='o3', address=Address(id=1, email_address='c'))
+ ]
+
+ sess = Session()
+ sess.merge(u1)
+
+Above, we merge a ``User`` object with three new ``Order`` objects, each referring to
+a distinct ``Address`` object, however each is given the same primary key.
+The current behavior of :meth:`.Session.merge` is to look in the identity
+map for this ``Address`` object, and use that as the target. If the object
+is present, meaning that the database already has a row for ``Address`` with
+primary key "1", we can see that the ``email_address`` field of the ``Address``
+will be overwritten three times, in this case with the values a, b and finally
+c.
+
+However, if the ``Address`` row for primary key "1" were not present, :meth:`.Session.merge`
+would instead create three separate ``Address`` instances, and we'd then get
+a primary key conflict upon INSERT. The new behavior is that the proposed
+primary key for these ``Address`` objects are tracked in a separate dictionary
+so that we merge the state of the three proposed ``Address`` objects onto
+one ``Address`` object to be inserted.
+
+It may have been preferable if the original case emitted some kind of warning
+that conflicting data were present in a single merge-tree, however the
+non-deterministic merging of values has been the behavior for many
+years for the persistent case; it now matches for the pending case. A
+feature that warns for conflicting values could still be feasible for both
+cases but would add considerable performance overhead as each column value
+would have to be compared during the merge.
+
+
+:ticket:`3601`
+
New Features and Improvements - Core
====================================
"""
def merge(self, session, source_state, source_dict, dest_state,
- dest_dict, load, _recursive):
+ dest_dict, load, _recursive, _resolve_conflict_map):
"""Merge the attribute represented by this ``MapperProperty``
from source to destination object.
get_committed_value(state, dict_, passive=passive)
def merge(self, session, source_state, source_dict, dest_state,
- dest_dict, load, _recursive):
+ dest_dict, load, _recursive, _resolve_conflict_map):
if not self.instrument:
return
elif self.key in source_dict:
source_dict,
dest_state,
dest_dict,
- load, _recursive):
+ load, _recursive, _resolve_conflict_map):
if load:
for r in self._reverse_property:
current_state = attributes.instance_state(current)
current_dict = attributes.instance_dict(current)
_recursive[(current_state, self)] = True
- obj = session._merge(current_state, current_dict,
- load=load, _recursive=_recursive)
+ obj = session._merge(
+ current_state, current_dict,
+ load=load, _recursive=_recursive,
+ _resolve_conflict_map=_resolve_conflict_map)
if obj is not None:
dest_list.append(obj)
current_state = attributes.instance_state(current)
current_dict = attributes.instance_dict(current)
_recursive[(current_state, self)] = True
- obj = session._merge(current_state, current_dict,
- load=load, _recursive=_recursive)
+ obj = session._merge(
+ current_state, current_dict,
+ load=load, _recursive=_recursive,
+ _resolve_conflict_map=_resolve_conflict_map)
else:
obj = None
See :ref:`unitofwork_merging` for a detailed discussion of merging.
+ .. versionchanged:: 1.1 - :meth:`.Session.merge` will now reconcile
+ pending objects with overlapping primary keys in the same way
+ as persistent. See :ref:`change_3601` for discussion.
+
:param instance: Instance to be merged.
:param load: Boolean, when False, :meth:`.merge` switches into
a "high performance" mode which causes it to forego emitting history
should be "clean" as well, else this suggests a mis-use of the
method.
+
"""
if self._warn_on_events:
self._flush_warning("Session.merge()")
_recursive = {}
+ _resolve_conflict_map = {}
if load:
# flush current contents if we expect to load data
return self._merge(
attributes.instance_state(instance),
attributes.instance_dict(instance),
- load=load, _recursive=_recursive)
+ load=load, _recursive=_recursive,
+ _resolve_conflict_map=_resolve_conflict_map)
finally:
self.autoflush = autoflush
- def _merge(self, state, state_dict, load=True, _recursive=None):
+ def _merge(self, state, state_dict, load=True, _recursive=None,
+ _resolve_conflict_map=None):
mapper = _state_mapper(state)
if state in _recursive:
return _recursive[state]
"all changes on mapped instances before merging with "
"load=False.")
key = mapper._identity_key_from_state(state)
+ key_is_persistent = attributes.NEVER_SET not in key[1]
+ else:
+ key_is_persistent = True
if key in self.identity_map:
merged = self.identity_map[key]
+ elif key_is_persistent and key in _resolve_conflict_map:
+ merged = _resolve_conflict_map[key]
elif not load:
if state.modified:
merged_dict = attributes.instance_dict(merged)
_recursive[state] = merged
+ _resolve_conflict_map[key] = merged
# check that we didn't just pull the exact same
# state out.
for prop in mapper.iterate_properties:
prop.merge(self, state, state_dict,
merged_state, merged_dict,
- load, _recursive)
+ load, _recursive, _resolve_conflict_map)
if not load:
# remove any history
eq_(ustate.load_path.path, (umapper, ))
eq_(ustate.load_options, set([opt2]))
+ def test_resolve_conflicts_pending_doesnt_interfere_no_ident(self):
+ User, Address, Order = (
+ self.classes.User, self.classes.Address, self.classes.Order)
+ users, addresses, orders = (
+ self.tables.users, self.tables.addresses, self.tables.orders)
+
+ mapper(User, users, properties={
+ 'orders': relationship(Order)
+ })
+ mapper(Order, orders, properties={
+ 'address': relationship(Address)
+ })
+ mapper(Address, addresses)
+
+ u1 = User(id=7, name='x')
+ u1.orders = [
+ Order(description='o1', address=Address(email_address='a')),
+ Order(description='o2', address=Address(email_address='b')),
+ Order(description='o3', address=Address(email_address='c'))
+ ]
+
+ sess = Session()
+ sess.merge(u1)
+ sess.flush()
+
+ eq_(
+ sess.query(Address.email_address).order_by(
+ Address.email_address).all(),
+ [('a', ), ('b', ), ('c', )]
+ )
+
+ def test_resolve_conflicts_pending(self):
+ User, Address, Order = (
+ self.classes.User, self.classes.Address, self.classes.Order)
+ users, addresses, orders = (
+ self.tables.users, self.tables.addresses, self.tables.orders)
+
+ mapper(User, users, properties={
+ 'orders': relationship(Order)
+ })
+ mapper(Order, orders, properties={
+ 'address': relationship(Address)
+ })
+ mapper(Address, addresses)
+
+ u1 = User(id=7, name='x')
+ u1.orders = [
+ Order(description='o1', address=Address(id=1, email_address='a')),
+ Order(description='o2', address=Address(id=1, email_address='b')),
+ Order(description='o3', address=Address(id=1, email_address='c'))
+ ]
+
+ sess = Session()
+ sess.merge(u1)
+ sess.flush()
+
+ eq_(
+ sess.query(Address).one(),
+ Address(id=1, email_address='c')
+ )
+
+ def test_resolve_conflicts_persistent(self):
+ User, Address, Order = (
+ self.classes.User, self.classes.Address, self.classes.Order)
+ users, addresses, orders = (
+ self.tables.users, self.tables.addresses, self.tables.orders)
+
+ mapper(User, users, properties={
+ 'orders': relationship(Order)
+ })
+ mapper(Order, orders, properties={
+ 'address': relationship(Address)
+ })
+ mapper(Address, addresses)
+
+ sess = Session()
+ sess.add(Address(id=1, email_address='z'))
+ sess.commit()
+
+ u1 = User(id=7, name='x')
+ u1.orders = [
+ Order(description='o1', address=Address(id=1, email_address='a')),
+ Order(description='o2', address=Address(id=1, email_address='b')),
+ Order(description='o3', address=Address(id=1, email_address='c'))
+ ]
+
+ sess = Session()
+ sess.merge(u1)
+ sess.flush()
+
+ eq_(
+ sess.query(Address).one(),
+ Address(id=1, email_address='c')
+ )
+
class M2ONoUseGetLoadingTest(fixtures.MappedTest):
"""Merge a one-to-many. The many-to-one on the other side is set up