You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@cayenne.apache.org by nt...@apache.org on 2019/04/24 14:53:57 UTC

[cayenne] branch master updated (74ebe54 -> cbb96b1)

This is an automated email from the ASF dual-hosted git repository.

ntimofeev pushed a change to branch master
in repository https://gitbox.apache.org/repos/asf/cayenne.git.


    from 74ebe54  Merge PR #379
     new f55efff  CAY-2571 DataDomainFlushAction redesign initial version and db operations API
     new 99175f9  CAY-2571 DataDomainFlushAction redesign tests
     new d0b4244  CAY-2571 DataDomainFlushAction redesign switch data domain to new Flush action implementation
     new 1e2d9bf  CAY-2571 DataDomainFlushAction redesign tests
     new c43a3bd  Merge PR #377
     new cbb96b1  CAY-2571 DataDomainFlushAction redesign cleanup

The 6 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.


Summary of changes:
 .../dbsync/reverse/configuration/ToolsModule.java  |   8 +-
 .../event/JavaGroupsBridgeProviderTest.java        |   6 +
 .../cayenne/event/JMSBridgeProviderTest.java       |   6 +
 .../java/org/apache/cayenne/access/DataDomain.java |  12 +-
 .../cayenne/access/DataDomainDBDiffBuilder.java    |   2 +
 .../cayenne/access/DataDomainDeleteBucket.java     |   4 +-
 .../cayenne/access/DataDomainFlattenedBucket.java  |   6 +-
 .../cayenne/access/DataDomainFlushObserver.java    |   2 +
 .../access/DataDomainIndirectDiffBuilder.java      |   7 +-
 .../cayenne/access/DataDomainInsertBucket.java     |   7 +-
 .../cayenne/access/DataDomainSyncBucket.java       |   6 +-
 .../cayenne/access/DataDomainUpdateBucket.java     |   4 +-
 .../access/DataNodeSyncQualifierDescriptor.java    |   2 +
 .../java/org/apache/cayenne/access/DbArcId.java    |   2 +
 .../cayenne/access/DbEntityClassDescriptor.java    |   2 +
 .../org/apache/cayenne/access/FlattenedArcKey.java |   2 +
 ...ction.java => LegacyDataDomainFlushAction.java} |  11 +-
 .../LegacyDataDomainFlushActionFactory.java}       |  46 ++--
 .../java/org/apache/cayenne/access/ObjectDiff.java |  22 +-
 .../org/apache/cayenne/access/ObjectResolver.java  |   4 +-
 .../org/apache/cayenne/access/ObjectStore.java     |  38 ++-
 .../cayenne/access/ObjectStoreGraphDiff.java       |   4 +-
 .../apache/cayenne/access/OperationObserver.java   |   4 +-
 .../org/apache/cayenne/access/flush/ArcTarget.java |  87 +++++++
 .../access/flush/ArcValuesCreationHandler.java     | 275 +++++++++++++++++++++
 .../flush/DataDomainFlushAction.java}              |  38 +--
 .../flush/DataDomainFlushActionFactory.java}       |  33 +--
 .../flush/DataDomainIndirectDiffBuilder.java       | 108 ++++++++
 .../cayenne/access/flush/DbRowOpFactory.java       | 138 +++++++++++
 .../access/flush/DefaultDataDomainFlushAction.java | 210 ++++++++++++++++
 .../DefaultDataDomainFlushActionFactory.java}      |  43 ++--
 .../apache/cayenne/access/flush/EffectiveOpId.java |  64 +++++
 .../FlushObserver.java}                            |  35 +--
 .../access/flush/ObjectIdValueSupplier.java        |  82 ++++++
 .../flush/OptimisticLockQualifierBuilder.java      |  85 +++++++
 .../access/flush/PermanentObjectIdVisitor.java     | 139 +++++++++++
 .../cayenne/access/flush/PostprocessVisitor.java   | 143 +++++++++++
 .../cayenne/access/flush/QueryCreatorVisitor.java  | 121 +++++++++
 .../cayenne/access/flush/ReplacementIdVisitor.java | 106 ++++++++
 .../cayenne/access/flush/RootRowOpProcessor.java   |  82 ++++++
 .../access/flush/ValuesCreationHandler.java        |  77 ++++++
 .../access/flush/operation/BaseDbRowOp.java        |  77 ++++++
 .../flush/operation/DbRowOp.java}                  |  40 ++-
 .../access/flush/operation/DbRowOpMerger.java      |  82 ++++++
 .../flush/operation/DbRowOpSorter.java}            |  34 +--
 .../flush/operation/DbRowOpType.java}              |  49 ++--
 .../flush/operation/DbRowOpVisitor.java}           |  39 +--
 .../flush/operation/DbRowOpWithQualifier.java}     |  32 +--
 .../flush/operation/DbRowOpWithValues.java}        |  32 +--
 .../flush/operation/DefaultDbRowOpSorter.java      | 150 +++++++++++
 .../access/flush/operation/DeleteDbRowOp.java      |  72 ++++++
 .../flush/operation/DeleteInsertDbRowOp.java}      |  48 ++--
 .../access/flush/operation/InsertDbRowOp.java      |  71 ++++++
 .../cayenne/access/flush/operation/Qualifier.java  | 147 +++++++++++
 .../access/flush/operation/UpdateDbRowOp.java      |  74 ++++++
 .../cayenne/access/flush/operation/Values.java     | 151 +++++++++++
 .../cayenne/ashwood/AshwoodEntitySorter.java       | 140 ++++++-----
 .../cayenne/configuration/server/ServerModule.java |  13 +-
 .../main/java/org/apache/cayenne/map/DbEntity.java |   7 +-
 .../java/org/apache/cayenne/map/EntitySorter.java  |  21 ++
 .../org/apache/cayenne/query/DeleteBatchQuery.java |   7 +-
 .../DataContextEntityWithMeaningfulPKIT.java       |  15 ++
 .../access/DataContextFlattenedAttributesIT.java   |  50 ++++
 .../access/DefaultDataRowStoreFactoryIT.java       |   6 +
 .../cayenne/access/VerticalInheritanceIT.java      |  19 +-
 .../access/flush/ArcValuesCreationHandlerTest.java | 210 ++++++++++++++++
 .../flush/DefaultDataDomainFlushActionTest.java    | 158 ++++++++++++
 .../access/flush/DefaultDbRowOpSorterTest.java     | 215 ++++++++++++++++
 .../access/flush/operation/BaseDbRowOpTest.java    | 125 ++++++++++
 .../access/flush/operation/DbRowOpMergerTest.java  | 177 +++++++++++++
 .../access/flush/operation/QualifierTest.java      | 171 +++++++++++++
 .../cayenne/access/flush/operation/ValuesTest.java | 109 ++++++++
 .../server/DataContextFactoryTest.java             |  12 +
 .../test/resources/inheritance-vertical.map.xml    |   4 +-
 cayenne-server/src/test/resources/testmap.map.xml  |   4 +-
 .../cayenne/event/XMPPBridgeProviderTest.java      |   6 +
 76 files changed, 4204 insertions(+), 436 deletions(-)
 rename cayenne-server/src/main/java/org/apache/cayenne/access/{DataDomainFlushAction.java => LegacyDataDomainFlushAction.java} (95%)
 copy cayenne-server/src/main/java/org/apache/cayenne/{map/EntitySorter.java => access/LegacyDataDomainFlushActionFactory.java} (53%)
 create mode 100644 cayenne-server/src/main/java/org/apache/cayenne/access/flush/ArcTarget.java
 create mode 100644 cayenne-server/src/main/java/org/apache/cayenne/access/flush/ArcValuesCreationHandler.java
 copy cayenne-server/src/main/java/org/apache/cayenne/{map/EntitySorter.java => access/flush/DataDomainFlushAction.java} (53%)
 copy cayenne-server/src/main/java/org/apache/cayenne/{map/EntitySorter.java => access/flush/DataDomainFlushActionFactory.java} (53%)
 create mode 100644 cayenne-server/src/main/java/org/apache/cayenne/access/flush/DataDomainIndirectDiffBuilder.java
 create mode 100644 cayenne-server/src/main/java/org/apache/cayenne/access/flush/DbRowOpFactory.java
 create mode 100644 cayenne-server/src/main/java/org/apache/cayenne/access/flush/DefaultDataDomainFlushAction.java
 copy cayenne-server/src/main/java/org/apache/cayenne/{map/EntitySorter.java => access/flush/DefaultDataDomainFlushActionFactory.java} (53%)
 create mode 100644 cayenne-server/src/main/java/org/apache/cayenne/access/flush/EffectiveOpId.java
 copy cayenne-server/src/main/java/org/apache/cayenne/access/{DataDomainFlushObserver.java => flush/FlushObserver.java} (82%)
 create mode 100644 cayenne-server/src/main/java/org/apache/cayenne/access/flush/ObjectIdValueSupplier.java
 create mode 100644 cayenne-server/src/main/java/org/apache/cayenne/access/flush/OptimisticLockQualifierBuilder.java
 create mode 100644 cayenne-server/src/main/java/org/apache/cayenne/access/flush/PermanentObjectIdVisitor.java
 create mode 100644 cayenne-server/src/main/java/org/apache/cayenne/access/flush/PostprocessVisitor.java
 create mode 100644 cayenne-server/src/main/java/org/apache/cayenne/access/flush/QueryCreatorVisitor.java
 create mode 100644 cayenne-server/src/main/java/org/apache/cayenne/access/flush/ReplacementIdVisitor.java
 create mode 100644 cayenne-server/src/main/java/org/apache/cayenne/access/flush/RootRowOpProcessor.java
 create mode 100644 cayenne-server/src/main/java/org/apache/cayenne/access/flush/ValuesCreationHandler.java
 create mode 100644 cayenne-server/src/main/java/org/apache/cayenne/access/flush/operation/BaseDbRowOp.java
 copy cayenne-server/src/main/java/org/apache/cayenne/{map/EntitySorter.java => access/flush/operation/DbRowOp.java} (54%)
 create mode 100644 cayenne-server/src/main/java/org/apache/cayenne/access/flush/operation/DbRowOpMerger.java
 copy cayenne-server/src/main/java/org/apache/cayenne/{map/EntitySorter.java => access/flush/operation/DbRowOpSorter.java} (55%)
 copy cayenne-server/src/main/java/org/apache/cayenne/{map/EntitySorter.java => access/flush/operation/DbRowOpType.java} (53%)
 copy cayenne-server/src/main/java/org/apache/cayenne/{map/EntitySorter.java => access/flush/operation/DbRowOpVisitor.java} (53%)
 copy cayenne-server/src/main/java/org/apache/cayenne/{map/EntitySorter.java => access/flush/operation/DbRowOpWithQualifier.java} (53%)
 copy cayenne-server/src/main/java/org/apache/cayenne/{map/EntitySorter.java => access/flush/operation/DbRowOpWithValues.java} (53%)
 create mode 100644 cayenne-server/src/main/java/org/apache/cayenne/access/flush/operation/DefaultDbRowOpSorter.java
 create mode 100644 cayenne-server/src/main/java/org/apache/cayenne/access/flush/operation/DeleteDbRowOp.java
 copy cayenne-server/src/main/java/org/apache/cayenne/{map/EntitySorter.java => access/flush/operation/DeleteInsertDbRowOp.java} (53%)
 create mode 100644 cayenne-server/src/main/java/org/apache/cayenne/access/flush/operation/InsertDbRowOp.java
 create mode 100644 cayenne-server/src/main/java/org/apache/cayenne/access/flush/operation/Qualifier.java
 create mode 100644 cayenne-server/src/main/java/org/apache/cayenne/access/flush/operation/UpdateDbRowOp.java
 create mode 100644 cayenne-server/src/main/java/org/apache/cayenne/access/flush/operation/Values.java
 create mode 100644 cayenne-server/src/test/java/org/apache/cayenne/access/flush/ArcValuesCreationHandlerTest.java
 create mode 100644 cayenne-server/src/test/java/org/apache/cayenne/access/flush/DefaultDataDomainFlushActionTest.java
 create mode 100644 cayenne-server/src/test/java/org/apache/cayenne/access/flush/DefaultDbRowOpSorterTest.java
 create mode 100644 cayenne-server/src/test/java/org/apache/cayenne/access/flush/operation/BaseDbRowOpTest.java
 create mode 100644 cayenne-server/src/test/java/org/apache/cayenne/access/flush/operation/DbRowOpMergerTest.java
 create mode 100644 cayenne-server/src/test/java/org/apache/cayenne/access/flush/operation/QualifierTest.java
 create mode 100644 cayenne-server/src/test/java/org/apache/cayenne/access/flush/operation/ValuesTest.java


[cayenne] 06/06: CAY-2571 DataDomainFlushAction redesign cleanup

Posted by nt...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

ntimofeev pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/cayenne.git

commit cbb96b1e3c0b2bbdd16fcefcda0d1f6469b0f0a0
Author: Nikita Timofeev <st...@gmail.com>
AuthorDate: Wed Apr 24 17:53:27 2019 +0300

    CAY-2571 DataDomainFlushAction redesign
    cleanup
---
 .../access/flush/DataDomainIndirectDiffBuilder.java    |  2 ++
 .../org/apache/cayenne/access/flush/EffectiveOpId.java |  1 +
 .../cayenne/configuration/server/ServerModule.java     |  1 -
 .../access/DataContextFlattenedAttributesIT.java       | 18 +-----------------
 4 files changed, 4 insertions(+), 18 deletions(-)

diff --git a/cayenne-server/src/main/java/org/apache/cayenne/access/flush/DataDomainIndirectDiffBuilder.java b/cayenne-server/src/main/java/org/apache/cayenne/access/flush/DataDomainIndirectDiffBuilder.java
index 65e4190..078dfd5 100644
--- a/cayenne-server/src/main/java/org/apache/cayenne/access/flush/DataDomainIndirectDiffBuilder.java
+++ b/cayenne-server/src/main/java/org/apache/cayenne/access/flush/DataDomainIndirectDiffBuilder.java
@@ -34,6 +34,8 @@ import org.apache.cayenne.map.ObjRelationship;
 /**
  * A processor of ObjectStore indirect changes, such as flattened relationships
  * and to-many relationships.
+ *
+ * @since 4.2
  */
 final class DataDomainIndirectDiffBuilder implements GraphChangeHandler {
 
diff --git a/cayenne-server/src/main/java/org/apache/cayenne/access/flush/EffectiveOpId.java b/cayenne-server/src/main/java/org/apache/cayenne/access/flush/EffectiveOpId.java
index c906c08..b3d2ec7 100644
--- a/cayenne-server/src/main/java/org/apache/cayenne/access/flush/EffectiveOpId.java
+++ b/cayenne-server/src/main/java/org/apache/cayenne/access/flush/EffectiveOpId.java
@@ -27,6 +27,7 @@ import org.apache.cayenne.ObjectId;
  * Helper value-object class that used to compare operations by "effective" id (i.e. by id snapshot,
  * that will include replacement id if any).
  * This class is not used directly by Cayenne, it's designed to ease custom implementations.
+ * @since 4.2
  */
 @SuppressWarnings("unused")
 public class EffectiveOpId {
diff --git a/cayenne-server/src/main/java/org/apache/cayenne/configuration/server/ServerModule.java b/cayenne-server/src/main/java/org/apache/cayenne/configuration/server/ServerModule.java
index bdeb97f..1854d89 100644
--- a/cayenne-server/src/main/java/org/apache/cayenne/configuration/server/ServerModule.java
+++ b/cayenne-server/src/main/java/org/apache/cayenne/configuration/server/ServerModule.java
@@ -29,7 +29,6 @@ import org.apache.cayenne.access.DataDomain;
 import org.apache.cayenne.access.DataRowStoreFactory;
 import org.apache.cayenne.access.DefaultDataRowStoreFactory;
 import org.apache.cayenne.access.DefaultObjectMapRetainStrategy;
-import org.apache.cayenne.access.LegacyDataDomainFlushActionFactory;
 import org.apache.cayenne.access.ObjectMapRetainStrategy;
 import org.apache.cayenne.access.dbsync.DefaultSchemaUpdateStrategyFactory;
 import org.apache.cayenne.access.dbsync.SchemaUpdateStrategyFactory;
diff --git a/cayenne-server/src/test/java/org/apache/cayenne/access/DataContextFlattenedAttributesIT.java b/cayenne-server/src/test/java/org/apache/cayenne/access/DataContextFlattenedAttributesIT.java
index c3f2bed..24b7922 100644
--- a/cayenne-server/src/test/java/org/apache/cayenne/access/DataContextFlattenedAttributesIT.java
+++ b/cayenne-server/src/test/java/org/apache/cayenne/access/DataContextFlattenedAttributesIT.java
@@ -26,7 +26,6 @@ import org.apache.cayenne.configuration.server.ServerRuntime;
 import org.apache.cayenne.di.Inject;
 import org.apache.cayenne.exp.ExpressionFactory;
 import org.apache.cayenne.query.EJBQLQuery;
-import org.apache.cayenne.query.ObjectSelect;
 import org.apache.cayenne.query.SelectById;
 import org.apache.cayenne.query.SelectQuery;
 import org.apache.cayenne.reflect.PersistentDescriptor;
@@ -116,21 +115,6 @@ public class DataContextFlattenedAttributesIT extends ServerCase {
     }
 
     @Test
-    public void testSelect() throws Exception {
-        createTestDataSet();
-
-        CompoundPainting result = ObjectSelect
-                .query(CompoundPainting.class)
-                .orderBy(CompoundPainting.PAINTING_ID_PK_PROPERTY.asc())
-                .selectFirst(context);
-
-        assertNotNull(result);
-
-        result.setArtistName("new A");
-        context.commitChanges();
-    }
-
-    @Test
     public void testSelectCompound1() throws Exception {
         createTestDataSet();
         SelectQuery<CompoundPainting> query = SelectQuery.query(CompoundPainting.class);
@@ -414,7 +398,7 @@ public class DataContextFlattenedAttributesIT extends ServerCase {
             o1.setTextReview("T1");
 
             context1.commitChanges();
-            id = o1.getObjectId().getIdSnapshot().get(CompoundPainting.PAINTING_ID_PK_COLUMN);
+            id = Cayenne.pkForObject(o1);
         }
 
         {


[cayenne] 03/06: CAY-2571 DataDomainFlushAction redesign switch data domain to new Flush action implementation

Posted by nt...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

ntimofeev pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/cayenne.git

commit d0b4244748b8e5fc579587b8711085c1d58de011
Author: Nikita Timofeev <st...@gmail.com>
AuthorDate: Mon Apr 22 18:03:04 2019 +0300

    CAY-2571 DataDomainFlushAction redesign
    switch data domain to new Flush action implementation
---
 .../dbsync/reverse/configuration/ToolsModule.java  |  8 +++-
 .../event/JavaGroupsBridgeProviderTest.java        |  6 +++
 .../cayenne/event/JMSBridgeProviderTest.java       |  6 +++
 .../java/org/apache/cayenne/access/DataDomain.java | 12 ++++--
 .../cayenne/access/DataDomainDBDiffBuilder.java    |  2 +
 .../cayenne/access/DataDomainDeleteBucket.java     |  4 +-
 .../cayenne/access/DataDomainFlattenedBucket.java  |  6 ++-
 .../cayenne/access/DataDomainFlushObserver.java    |  2 +
 .../access/DataDomainIndirectDiffBuilder.java      |  7 ++--
 .../cayenne/access/DataDomainInsertBucket.java     |  7 +++-
 .../cayenne/access/DataDomainSyncBucket.java       |  6 ++-
 .../cayenne/access/DataDomainUpdateBucket.java     |  4 +-
 .../access/DataNodeSyncQualifierDescriptor.java    |  2 +
 .../java/org/apache/cayenne/access/DbArcId.java    |  2 +
 .../cayenne/access/DbEntityClassDescriptor.java    |  2 +
 .../org/apache/cayenne/access/FlattenedArcKey.java |  2 +
 ...ction.java => LegacyDataDomainFlushAction.java} | 11 ++++--
 .../access/LegacyDataDomainFlushActionFactory.java | 45 ++++++++++++++++++++++
 .../apache/cayenne/access/OperationObserver.java   |  4 +-
 .../cayenne/configuration/server/ServerModule.java | 14 +++++--
 .../cayenne/event/XMPPBridgeProviderTest.java      |  6 +++
 21 files changed, 132 insertions(+), 26 deletions(-)

diff --git a/cayenne-dbsync/src/main/java/org/apache/cayenne/dbsync/reverse/configuration/ToolsModule.java b/cayenne-dbsync/src/main/java/org/apache/cayenne/dbsync/reverse/configuration/ToolsModule.java
index 0f41ffd..ce6d643 100644
--- a/cayenne-dbsync/src/main/java/org/apache/cayenne/dbsync/reverse/configuration/ToolsModule.java
+++ b/cayenne-dbsync/src/main/java/org/apache/cayenne/dbsync/reverse/configuration/ToolsModule.java
@@ -19,6 +19,10 @@
 
 package org.apache.cayenne.dbsync.reverse.configuration;
 
+import org.apache.cayenne.access.flush.DataDomainFlushActionFactory;
+import org.apache.cayenne.access.flush.DefaultDataDomainFlushActionFactory;
+import org.apache.cayenne.access.flush.operation.DefaultDbRowOpSorter;
+import org.apache.cayenne.access.flush.operation.DbRowOpSorter;
 import org.apache.cayenne.access.translator.batch.BatchTranslatorFactory;
 import org.apache.cayenne.access.translator.batch.DefaultBatchTranslatorFactory;
 import org.apache.cayenne.access.types.DefaultValueObjectTypeRegistry;
@@ -53,9 +57,7 @@ import org.apache.cayenne.dba.openbase.OpenBaseSniffer;
 import org.apache.cayenne.dba.oracle.OracleSniffer;
 import org.apache.cayenne.dba.postgres.PostgresSniffer;
 import org.apache.cayenne.dba.sqlite.SQLiteSniffer;
-import org.apache.cayenne.dba.sqlserver.SQLServerAdapter;
 import org.apache.cayenne.dba.sqlserver.SQLServerSniffer;
-import org.apache.cayenne.dba.sybase.SybasePkGenerator;
 import org.apache.cayenne.dba.sybase.SybaseSniffer;
 import org.apache.cayenne.di.AdhocObjectFactory;
 import org.apache.cayenne.di.Binder;
@@ -129,6 +131,8 @@ public class ToolsModule implements Module {
         binder.bind(HandlerFactory.class).to(ExtensionAwareHandlerFactory.class);
         binder.bind(DataChannelMetaData.class).to(DefaultDataChannelMetaData.class);
         binder.bind(XMLReader.class).toProviderInstance(new XMLReaderProvider(true)).withoutScope();
+        binder.bind(DataDomainFlushActionFactory.class).to(DefaultDataDomainFlushActionFactory.class);
+        binder.bind(DbRowOpSorter.class).to(DefaultDbRowOpSorter.class);
 
         ProjectModule.contributeExtensions(binder);
     }
diff --git a/cayenne-jgroups/src/test/java/org/apache/cayenne/event/JavaGroupsBridgeProviderTest.java b/cayenne-jgroups/src/test/java/org/apache/cayenne/event/JavaGroupsBridgeProviderTest.java
index 8df03d8..eee4833 100644
--- a/cayenne-jgroups/src/test/java/org/apache/cayenne/event/JavaGroupsBridgeProviderTest.java
+++ b/cayenne-jgroups/src/test/java/org/apache/cayenne/event/JavaGroupsBridgeProviderTest.java
@@ -20,6 +20,10 @@
 package org.apache.cayenne.event;
 
 import org.apache.cayenne.access.DataDomain;
+import org.apache.cayenne.access.flush.DataDomainFlushActionFactory;
+import org.apache.cayenne.access.flush.DefaultDataDomainFlushActionFactory;
+import org.apache.cayenne.access.flush.operation.DefaultDbRowOpSorter;
+import org.apache.cayenne.access.flush.operation.DbRowOpSorter;
 import org.apache.cayenne.configuration.Constants;
 import org.apache.cayenne.configuration.DefaultRuntimeProperties;
 import org.apache.cayenne.configuration.RuntimeProperties;
@@ -93,6 +97,8 @@ public class JavaGroupsBridgeProviderTest {
             binder.bind(TransactionFactory.class).to(DefaultTransactionFactory.class);
             binder.bind(JdbcEventLogger.class).to(Slf4jJdbcEventLogger.class);
             binder.bind(RuntimeProperties.class).to(DefaultRuntimeProperties.class);
+            binder.bind(DataDomainFlushActionFactory.class).to(DefaultDataDomainFlushActionFactory.class);
+            binder.bind(DbRowOpSorter.class).to(DefaultDbRowOpSorter.class);
         }
     }
 }
diff --git a/cayenne-jms/src/test/java/org/apache/cayenne/event/JMSBridgeProviderTest.java b/cayenne-jms/src/test/java/org/apache/cayenne/event/JMSBridgeProviderTest.java
index 9c77f05..117859f 100644
--- a/cayenne-jms/src/test/java/org/apache/cayenne/event/JMSBridgeProviderTest.java
+++ b/cayenne-jms/src/test/java/org/apache/cayenne/event/JMSBridgeProviderTest.java
@@ -20,6 +20,10 @@
 package org.apache.cayenne.event;
 
 import org.apache.cayenne.access.DataDomain;
+import org.apache.cayenne.access.flush.DataDomainFlushActionFactory;
+import org.apache.cayenne.access.flush.DefaultDataDomainFlushActionFactory;
+import org.apache.cayenne.access.flush.operation.DefaultDbRowOpSorter;
+import org.apache.cayenne.access.flush.operation.DbRowOpSorter;
 import org.apache.cayenne.configuration.Constants;
 import org.apache.cayenne.configuration.DefaultRuntimeProperties;
 import org.apache.cayenne.configuration.RuntimeProperties;
@@ -82,6 +86,8 @@ public class JMSBridgeProviderTest {
             binder.bind(TransactionFactory.class).to(DefaultTransactionFactory.class);
             binder.bind(JdbcEventLogger.class).to(Slf4jJdbcEventLogger.class);
             binder.bind(RuntimeProperties.class).to(DefaultRuntimeProperties.class);
+            binder.bind(DataDomainFlushActionFactory.class).to(DefaultDataDomainFlushActionFactory.class);
+            binder.bind(DbRowOpSorter.class).to(DefaultDbRowOpSorter.class);
         }
     }
 }
diff --git a/cayenne-server/src/main/java/org/apache/cayenne/access/DataDomain.java b/cayenne-server/src/main/java/org/apache/cayenne/access/DataDomain.java
index a81c940..6c41466 100644
--- a/cayenne-server/src/main/java/org/apache/cayenne/access/DataDomain.java
+++ b/cayenne-server/src/main/java/org/apache/cayenne/access/DataDomain.java
@@ -28,6 +28,8 @@ import org.apache.cayenne.DataChannelSyncFilter;
 import org.apache.cayenne.DataChannelSyncFilterChain;
 import org.apache.cayenne.ObjectContext;
 import org.apache.cayenne.QueryResponse;
+import org.apache.cayenne.access.flush.DataDomainFlushAction;
+import org.apache.cayenne.access.flush.DataDomainFlushActionFactory;
 import org.apache.cayenne.cache.QueryCache;
 import org.apache.cayenne.configuration.Constants;
 import org.apache.cayenne.di.BeforeScopeEnd;
@@ -107,6 +109,12 @@ public class DataDomain implements QueryEngine, DataChannel {
 	 */
 	protected List<DataChannelSyncFilter> syncFilters;
 
+	/**
+	 * @since 4.2
+	 */
+	@Inject
+	protected DataDomainFlushActionFactory flushActionFactory;
+
 	protected Map<String, DataNode> nodes;
 	protected Map<String, DataNode> nodesByDataMapName;
 	protected DataNode defaultNode;
@@ -618,9 +626,7 @@ public class DataDomain implements QueryEngine, DataChannel {
 							+ "Unsupported context: %s", originatingContext);
 		}
 
-		DataDomainFlushAction action = new DataDomainFlushAction(this);
-		action.setJdbcEventLogger(jdbcEventLogger);
-
+		DataDomainFlushAction action = flushActionFactory.createFlushAction(this);
 		return action.flush((DataContext) originatingContext, childChanges);
 	}
 
diff --git a/cayenne-server/src/main/java/org/apache/cayenne/access/DataDomainDBDiffBuilder.java b/cayenne-server/src/main/java/org/apache/cayenne/access/DataDomainDBDiffBuilder.java
index ea6964d..a9ccd39 100644
--- a/cayenne-server/src/main/java/org/apache/cayenne/access/DataDomainDBDiffBuilder.java
+++ b/cayenne-server/src/main/java/org/apache/cayenne/access/DataDomainDBDiffBuilder.java
@@ -42,7 +42,9 @@ import java.util.Map.Entry;
  * INSERT.
  * 
  * @since 1.2
+ * @deprecated since 4.2 as part of deprecated {@link LegacyDataDomainFlushAction}
  */
+@Deprecated
 class DataDomainDBDiffBuilder implements GraphChangeHandler {
 
     private ObjEntity objEntity;
diff --git a/cayenne-server/src/main/java/org/apache/cayenne/access/DataDomainDeleteBucket.java b/cayenne-server/src/main/java/org/apache/cayenne/access/DataDomainDeleteBucket.java
index 7d1e772..46193bd 100644
--- a/cayenne-server/src/main/java/org/apache/cayenne/access/DataDomainDeleteBucket.java
+++ b/cayenne-server/src/main/java/org/apache/cayenne/access/DataDomainDeleteBucket.java
@@ -36,10 +36,12 @@ import org.apache.cayenne.query.Query;
 
 /**
  * @since 1.2
+ * @deprecated since 4.2 as part of deprecated {@link LegacyDataDomainFlushAction}
  */
+@Deprecated
 class DataDomainDeleteBucket extends DataDomainSyncBucket {
 
-    DataDomainDeleteBucket(DataDomainFlushAction parent) {
+    DataDomainDeleteBucket(LegacyDataDomainFlushAction parent) {
         super(parent);
     }
 
diff --git a/cayenne-server/src/main/java/org/apache/cayenne/access/DataDomainFlattenedBucket.java b/cayenne-server/src/main/java/org/apache/cayenne/access/DataDomainFlattenedBucket.java
index ba2a844..321daee 100644
--- a/cayenne-server/src/main/java/org/apache/cayenne/access/DataDomainFlattenedBucket.java
+++ b/cayenne-server/src/main/java/org/apache/cayenne/access/DataDomainFlattenedBucket.java
@@ -38,14 +38,16 @@ import java.util.Map;
  * A sync bucket that holds flattened queries.
  * 
  * @since 1.2
+ * @deprecated since 4.2 as part of deprecated {@link LegacyDataDomainFlushAction}
  */
+@Deprecated
 class DataDomainFlattenedBucket {
 
-    final DataDomainFlushAction parent;
+    final LegacyDataDomainFlushAction parent;
     final Map<DbEntity, List<FlattenedArcKey>> insertArcKeys;
     final Map<DbEntity, DeleteBatchQuery> flattenedDeleteQueries;
 
-    DataDomainFlattenedBucket(DataDomainFlushAction parent) {
+    DataDomainFlattenedBucket(LegacyDataDomainFlushAction parent) {
         this.parent = parent;
         this.insertArcKeys = new HashMap<>();
         this.flattenedDeleteQueries = new HashMap<>();
diff --git a/cayenne-server/src/main/java/org/apache/cayenne/access/DataDomainFlushObserver.java b/cayenne-server/src/main/java/org/apache/cayenne/access/DataDomainFlushObserver.java
index 44923dc..58d1d4a 100644
--- a/cayenne-server/src/main/java/org/apache/cayenne/access/DataDomainFlushObserver.java
+++ b/cayenne-server/src/main/java/org/apache/cayenne/access/DataDomainFlushObserver.java
@@ -36,7 +36,9 @@ import org.apache.cayenne.util.Util;
  * Used as an observer for DataContext commit operations.
  * 
  * @since 1.2
+ * @deprecated since 4.2 as part of deprecated {@link LegacyDataDomainFlushAction}
  */
+@Deprecated
 class DataDomainFlushObserver implements OperationObserver {
 
     /**
diff --git a/cayenne-server/src/main/java/org/apache/cayenne/access/DataDomainIndirectDiffBuilder.java b/cayenne-server/src/main/java/org/apache/cayenne/access/DataDomainIndirectDiffBuilder.java
index b7aeaf2..57686ac 100644
--- a/cayenne-server/src/main/java/org/apache/cayenne/access/DataDomainIndirectDiffBuilder.java
+++ b/cayenne-server/src/main/java/org/apache/cayenne/access/DataDomainIndirectDiffBuilder.java
@@ -28,7 +28,6 @@ import org.apache.cayenne.graph.ArcId;
 import org.apache.cayenne.graph.GraphChangeHandler;
 import org.apache.cayenne.graph.GraphDiff;
 import org.apache.cayenne.map.DbEntity;
-import org.apache.cayenne.map.DbRelationship;
 import org.apache.cayenne.map.EntityResolver;
 import org.apache.cayenne.map.ObjEntity;
 import org.apache.cayenne.map.ObjRelationship;
@@ -38,16 +37,18 @@ import org.apache.cayenne.map.ObjRelationship;
  * and to-many relationships.
  * 
  * @since 1.2
+ * @deprecated since 4.2 as part of deprecated {@link LegacyDataDomainFlushAction}
  */
+@Deprecated
 final class DataDomainIndirectDiffBuilder implements GraphChangeHandler {
 
-    private final DataDomainFlushAction parent;
+    private final LegacyDataDomainFlushAction parent;
     private final EntityResolver resolver;
     private final Collection<ObjectId> indirectModifications;
     private final Collection<FlattenedArcKey> flattenedInserts;
     private final Collection<FlattenedArcKey> flattenedDeletes;
 
-    DataDomainIndirectDiffBuilder(DataDomainFlushAction parent) {
+    DataDomainIndirectDiffBuilder(LegacyDataDomainFlushAction parent) {
         this.parent = parent;
         this.indirectModifications = parent.getResultIndirectlyModifiedIds();
         this.resolver = parent.getDomain().getEntityResolver();
diff --git a/cayenne-server/src/main/java/org/apache/cayenne/access/DataDomainInsertBucket.java b/cayenne-server/src/main/java/org/apache/cayenne/access/DataDomainInsertBucket.java
index eee50ab..8fbdd18 100644
--- a/cayenne-server/src/main/java/org/apache/cayenne/access/DataDomainInsertBucket.java
+++ b/cayenne-server/src/main/java/org/apache/cayenne/access/DataDomainInsertBucket.java
@@ -41,12 +41,14 @@ import org.apache.cayenne.query.Query;
 
 /**
  * @since 1.2
+ * @deprecated since 4.2 as part of deprecated {@link LegacyDataDomainFlushAction}
  */
+@Deprecated
 class DataDomainInsertBucket extends DataDomainSyncBucket {
 
     List<FlattenedInsert> flattenedInserts;
 
-    DataDomainInsertBucket(DataDomainFlushAction parent) {
+    DataDomainInsertBucket(LegacyDataDomainFlushAction parent) {
         super(parent);
     }
 
@@ -222,13 +224,14 @@ class DataDomainInsertBucket extends DataDomainSyncBucket {
     private static class FlattenedInsert {
         private final String path;
         private final Persistent object;
+
         private FlattenedInsert(String path, Persistent object) {
             this.path = path;
             this.object = object;
         }
 
         private void register(ObjectStore objectStore) {
-            objectStore.markFlattenedPath(object.getObjectId(), path);
+            objectStore.markFlattenedPath(object.getObjectId(), path, ObjectId.of("tmp"));
         }
     }
 }
diff --git a/cayenne-server/src/main/java/org/apache/cayenne/access/DataDomainSyncBucket.java b/cayenne-server/src/main/java/org/apache/cayenne/access/DataDomainSyncBucket.java
index 4e9dd0d..afb6a82 100644
--- a/cayenne-server/src/main/java/org/apache/cayenne/access/DataDomainSyncBucket.java
+++ b/cayenne-server/src/main/java/org/apache/cayenne/access/DataDomainSyncBucket.java
@@ -48,16 +48,18 @@ import java.util.function.Supplier;
  * A superclass of batch query wrappers.
  * 
  * @since 1.2
+ * @deprecated since 4.2 as part of deprecated {@link LegacyDataDomainFlushAction}
  */
+@Deprecated
 abstract class DataDomainSyncBucket {
 
     final Map<ClassDescriptor, List<Persistent>> objectsByDescriptor;
-    final DataDomainFlushAction parent;
+    final LegacyDataDomainFlushAction parent;
 
     List<DbEntity> dbEntities;
     Map<DbEntity, Collection<DbEntityClassDescriptor>> descriptorsByDbEntity;
 
-    DataDomainSyncBucket(DataDomainFlushAction parent) {
+    DataDomainSyncBucket(LegacyDataDomainFlushAction parent) {
         this.objectsByDescriptor = new HashMap<>();
         this.parent = parent;
     }
diff --git a/cayenne-server/src/main/java/org/apache/cayenne/access/DataDomainUpdateBucket.java b/cayenne-server/src/main/java/org/apache/cayenne/access/DataDomainUpdateBucket.java
index 57d871a..9fcf23a 100644
--- a/cayenne-server/src/main/java/org/apache/cayenne/access/DataDomainUpdateBucket.java
+++ b/cayenne-server/src/main/java/org/apache/cayenne/access/DataDomainUpdateBucket.java
@@ -40,10 +40,12 @@ import org.apache.cayenne.reflect.ClassDescriptor;
 
 /**
  * @since 1.2
+ * @deprecated since 4.2 as part of deprecated {@link LegacyDataDomainFlushAction}
  */
+@Deprecated
 class DataDomainUpdateBucket extends DataDomainSyncBucket {
 
-    DataDomainUpdateBucket(DataDomainFlushAction parent) {
+    DataDomainUpdateBucket(LegacyDataDomainFlushAction parent) {
         super(parent);
     }
 
diff --git a/cayenne-server/src/main/java/org/apache/cayenne/access/DataNodeSyncQualifierDescriptor.java b/cayenne-server/src/main/java/org/apache/cayenne/access/DataNodeSyncQualifierDescriptor.java
index 176227c..cc75bae 100644
--- a/cayenne-server/src/main/java/org/apache/cayenne/access/DataNodeSyncQualifierDescriptor.java
+++ b/cayenne-server/src/main/java/org/apache/cayenne/access/DataNodeSyncQualifierDescriptor.java
@@ -38,7 +38,9 @@ import org.apache.cayenne.map.ObjRelationship;
  * Builds update qualifier snapshots, including optimistic locking.
  * 
  * @since 1.2
+ * @deprecated since 4.2 as part of deprecated {@link LegacyDataDomainFlushAction}
  */
+@Deprecated
 class DataNodeSyncQualifierDescriptor {
 
 	private List<DbAttribute> attributes;
diff --git a/cayenne-server/src/main/java/org/apache/cayenne/access/DbArcId.java b/cayenne-server/src/main/java/org/apache/cayenne/access/DbArcId.java
index 4d66101..3ac905d 100644
--- a/cayenne-server/src/main/java/org/apache/cayenne/access/DbArcId.java
+++ b/cayenne-server/src/main/java/org/apache/cayenne/access/DbArcId.java
@@ -30,7 +30,9 @@ import org.apache.cayenne.util.HashCodeBuilder;
  * 'hashCode' implementations adequate for use as a map key.
  * 
  * @since 4.0
+ * @deprecated since 4.2 as part of deprecated {@link LegacyDataDomainFlushAction}
  */
+@Deprecated
 final class DbArcId {
 
     private int hashCode;
diff --git a/cayenne-server/src/main/java/org/apache/cayenne/access/DbEntityClassDescriptor.java b/cayenne-server/src/main/java/org/apache/cayenne/access/DbEntityClassDescriptor.java
index 273ce33..b8e8344 100644
--- a/cayenne-server/src/main/java/org/apache/cayenne/access/DbEntityClassDescriptor.java
+++ b/cayenne-server/src/main/java/org/apache/cayenne/access/DbEntityClassDescriptor.java
@@ -36,7 +36,9 @@ import java.util.List;
  * commit.
  * 
  * @since 3.0
+ * @deprecated since 4.2 as part of deprecated {@link LegacyDataDomainFlushAction}
  */
+@Deprecated
 class DbEntityClassDescriptor {
 
     private ClassDescriptor classDescriptor;
diff --git a/cayenne-server/src/main/java/org/apache/cayenne/access/FlattenedArcKey.java b/cayenne-server/src/main/java/org/apache/cayenne/access/FlattenedArcKey.java
index 2b2217d..3209add 100644
--- a/cayenne-server/src/main/java/org/apache/cayenne/access/FlattenedArcKey.java
+++ b/cayenne-server/src/main/java/org/apache/cayenne/access/FlattenedArcKey.java
@@ -48,7 +48,9 @@ import java.util.Map;
  * A holder of flattened relationship modification data.
  * 
  * @since 1.2
+ * @deprecated since 4.2 as part of deprecated {@link LegacyDataDomainFlushAction}
  */
+@Deprecated
 final class FlattenedArcKey {
 
 	ObjRelationship relationship;
diff --git a/cayenne-server/src/main/java/org/apache/cayenne/access/DataDomainFlushAction.java b/cayenne-server/src/main/java/org/apache/cayenne/access/LegacyDataDomainFlushAction.java
similarity index 95%
rename from cayenne-server/src/main/java/org/apache/cayenne/access/DataDomainFlushAction.java
rename to cayenne-server/src/main/java/org/apache/cayenne/access/LegacyDataDomainFlushAction.java
index 2acb561..b4d21d5 100644
--- a/cayenne-server/src/main/java/org/apache/cayenne/access/DataDomainFlushAction.java
+++ b/cayenne-server/src/main/java/org/apache/cayenne/access/LegacyDataDomainFlushAction.java
@@ -24,6 +24,7 @@ import org.apache.cayenne.DataRow;
 import org.apache.cayenne.ObjectId;
 import org.apache.cayenne.PersistenceState;
 import org.apache.cayenne.Persistent;
+import org.apache.cayenne.access.flush.DefaultDataDomainFlushAction;
 import org.apache.cayenne.graph.CompoundDiff;
 import org.apache.cayenne.graph.GraphDiff;
 import org.apache.cayenne.log.JdbcEventLogger;
@@ -46,10 +47,12 @@ import java.util.Map;
  * DataContextCommitAction resolves primary key dependencies, referential integrity
  * dependencies (including multi-reflexive entities), generates primary keys, creates
  * batches for massive data modifications, assigns operations to data nodes.
- * 
+ *
  * @since 1.2
+ * @deprecated this implementation is deprecated since 4.2, {@link DefaultDataDomainFlushAction} is used
  */
-class DataDomainFlushAction {
+@Deprecated
+public class LegacyDataDomainFlushAction implements org.apache.cayenne.access.flush.DataDomainFlushAction {
 
     private final DataDomain domain;
     private DataContext context;
@@ -69,7 +72,7 @@ class DataDomainFlushAction {
 
     private JdbcEventLogger logger;
 
-    DataDomainFlushAction(DataDomain domain) {
+    public LegacyDataDomainFlushAction(DataDomain domain) {
         this.domain = domain;
     }
 
@@ -117,7 +120,7 @@ class DataDomainFlushAction {
         flattenedBucket.addFlattenedDelete(flattenedEntity, flattenedDeleteInfo);
     }
 
-    GraphDiff flush(DataContext context, GraphDiff changes) {
+    public GraphDiff flush(DataContext context, GraphDiff changes) {
 
         if (changes == null) {
             return new CompoundDiff();
diff --git a/cayenne-server/src/main/java/org/apache/cayenne/access/LegacyDataDomainFlushActionFactory.java b/cayenne-server/src/main/java/org/apache/cayenne/access/LegacyDataDomainFlushActionFactory.java
new file mode 100644
index 0000000..b822b03
--- /dev/null
+++ b/cayenne-server/src/main/java/org/apache/cayenne/access/LegacyDataDomainFlushActionFactory.java
@@ -0,0 +1,45 @@
+/*****************************************************************
+ *   Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ ****************************************************************/
+
+package org.apache.cayenne.access;
+
+import org.apache.cayenne.access.flush.DataDomainFlushAction;
+import org.apache.cayenne.access.flush.DataDomainFlushActionFactory;
+import org.apache.cayenne.di.Inject;
+import org.apache.cayenne.log.JdbcEventLogger;
+
+/**
+ * Factory for {@link LegacyDataDomainFlushAction}.
+ * A fallback factory to use deprecated implementation if absolutely needed.
+ *
+ * @since 4.2
+ */
+@Deprecated
+public class LegacyDataDomainFlushActionFactory implements DataDomainFlushActionFactory {
+
+    @Inject
+    private JdbcEventLogger jdbcEventLogger;
+
+    @Override
+    public DataDomainFlushAction createFlushAction(DataDomain dataDomain) {
+        LegacyDataDomainFlushAction action = new LegacyDataDomainFlushAction(dataDomain);
+        action.setJdbcEventLogger(jdbcEventLogger);
+        return action;
+    }
+}
diff --git a/cayenne-server/src/main/java/org/apache/cayenne/access/OperationObserver.java b/cayenne-server/src/main/java/org/apache/cayenne/access/OperationObserver.java
index 2c7734a..80f2906 100644
--- a/cayenne-server/src/main/java/org/apache/cayenne/access/OperationObserver.java
+++ b/cayenne-server/src/main/java/org/apache/cayenne/access/OperationObserver.java
@@ -69,11 +69,11 @@ public interface OperationObserver extends OperationHints {
      * Callback method invoked on exceptions that happen during an execution of a specific
      * query.
      */
-    public void nextQueryException(Query query, Exception ex);
+    void nextQueryException(Query query, Exception ex);
 
     /**
      * Callback method invoked on exceptions that are not tied to a specific query
      * execution, such as JDBC connection exceptions, etc.
      */
-    public void nextGlobalException(Exception ex);
+    void nextGlobalException(Exception ex);
 }
diff --git a/cayenne-server/src/main/java/org/apache/cayenne/configuration/server/ServerModule.java b/cayenne-server/src/main/java/org/apache/cayenne/configuration/server/ServerModule.java
index 197ad72..bdeb97f 100644
--- a/cayenne-server/src/main/java/org/apache/cayenne/configuration/server/ServerModule.java
+++ b/cayenne-server/src/main/java/org/apache/cayenne/configuration/server/ServerModule.java
@@ -29,9 +29,14 @@ import org.apache.cayenne.access.DataDomain;
 import org.apache.cayenne.access.DataRowStoreFactory;
 import org.apache.cayenne.access.DefaultDataRowStoreFactory;
 import org.apache.cayenne.access.DefaultObjectMapRetainStrategy;
+import org.apache.cayenne.access.LegacyDataDomainFlushActionFactory;
 import org.apache.cayenne.access.ObjectMapRetainStrategy;
 import org.apache.cayenne.access.dbsync.DefaultSchemaUpdateStrategyFactory;
 import org.apache.cayenne.access.dbsync.SchemaUpdateStrategyFactory;
+import org.apache.cayenne.access.flush.DataDomainFlushActionFactory;
+import org.apache.cayenne.access.flush.DefaultDataDomainFlushActionFactory;
+import org.apache.cayenne.access.flush.operation.DbRowOpSorter;
+import org.apache.cayenne.access.flush.operation.DefaultDbRowOpSorter;
 import org.apache.cayenne.access.jdbc.SQLTemplateProcessor;
 import org.apache.cayenne.access.jdbc.reader.DefaultRowReaderFactory;
 import org.apache.cayenne.access.jdbc.reader.RowReaderFactory;
@@ -473,12 +478,10 @@ public class ServerModule implements Module {
 
         binder.bind(SchemaUpdateStrategyFactory.class).to(DefaultSchemaUpdateStrategyFactory.class);
 
-        // a default DBAdapterFactory used to load custom and automatic
-        // DbAdapters
+        // a default DBAdapterFactory used to load custom and automatic DbAdapters
         binder.bind(DbAdapterFactory.class).to(DefaultDbAdapterFactory.class);
 
-        // binding AshwoodEntitySorter without scope, as this is a stateful
-        // object and is
+        // binding AshwoodEntitySorter without scope, as this is a stateful object and is
         // configured by the owning domain
         binder.bind(EntitySorter.class).to(AshwoodEntitySorter.class).withoutScope();
 
@@ -501,5 +504,8 @@ public class ServerModule implements Module {
         binder.bind(HandlerFactory.class).to(DefaultHandlerFactory.class);
         binder.bind(DataChannelMetaData.class).to(NoopDataChannelMetaData.class);
         binder.bind(XMLReader.class).toProviderInstance(new XMLReaderProvider(false)).withoutScope();
+
+        binder.bind(DataDomainFlushActionFactory.class).to(DefaultDataDomainFlushActionFactory.class);
+        binder.bind(DbRowOpSorter.class).to(DefaultDbRowOpSorter.class);
     }
 }
diff --git a/cayenne-xmpp/src/test/java/org/apache/cayenne/event/XMPPBridgeProviderTest.java b/cayenne-xmpp/src/test/java/org/apache/cayenne/event/XMPPBridgeProviderTest.java
index e2de1bb..1f84d45 100644
--- a/cayenne-xmpp/src/test/java/org/apache/cayenne/event/XMPPBridgeProviderTest.java
+++ b/cayenne-xmpp/src/test/java/org/apache/cayenne/event/XMPPBridgeProviderTest.java
@@ -20,6 +20,10 @@
 package org.apache.cayenne.event;
 
 import org.apache.cayenne.access.DataDomain;
+import org.apache.cayenne.access.flush.DataDomainFlushActionFactory;
+import org.apache.cayenne.access.flush.DefaultDataDomainFlushActionFactory;
+import org.apache.cayenne.access.flush.operation.DefaultDbRowOpSorter;
+import org.apache.cayenne.access.flush.operation.DbRowOpSorter;
 import org.apache.cayenne.configuration.Constants;
 import org.apache.cayenne.configuration.DefaultRuntimeProperties;
 import org.apache.cayenne.configuration.RuntimeProperties;
@@ -101,6 +105,8 @@ public class XMPPBridgeProviderTest {
             binder.bind(TransactionFactory.class).to(DefaultTransactionFactory.class);
             binder.bind(JdbcEventLogger.class).to(Slf4jJdbcEventLogger.class);
             binder.bind(RuntimeProperties.class).to(DefaultRuntimeProperties.class);
+            binder.bind(DataDomainFlushActionFactory.class).to(DefaultDataDomainFlushActionFactory.class);
+            binder.bind(DbRowOpSorter.class).to(DefaultDbRowOpSorter.class);
         }
     }
 }


[cayenne] 04/06: CAY-2571 DataDomainFlushAction redesign tests

Posted by nt...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

ntimofeev pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/cayenne.git

commit 1e2d9bfef2cd35c450562b918538eaea7723ae58
Author: Nikita Timofeev <st...@gmail.com>
AuthorDate: Mon Apr 22 18:05:24 2019 +0300

    CAY-2571 DataDomainFlushAction redesign
    tests
---
 .../DataContextEntityWithMeaningfulPKIT.java       | 15 +++++
 .../access/DataContextFlattenedAttributesIT.java   | 66 ++++++++++++++++++++++
 .../access/DefaultDataRowStoreFactoryIT.java       |  6 ++
 .../cayenne/access/VerticalInheritanceIT.java      | 19 ++-----
 .../server/DataContextFactoryTest.java             | 12 ++++
 .../test/resources/inheritance-vertical.map.xml    |  4 +-
 cayenne-server/src/test/resources/testmap.map.xml  |  4 +-
 7 files changed, 107 insertions(+), 19 deletions(-)

diff --git a/cayenne-server/src/test/java/org/apache/cayenne/access/DataContextEntityWithMeaningfulPKIT.java b/cayenne-server/src/test/java/org/apache/cayenne/access/DataContextEntityWithMeaningfulPKIT.java
index 31136a0..2ac114d 100644
--- a/cayenne-server/src/test/java/org/apache/cayenne/access/DataContextEntityWithMeaningfulPKIT.java
+++ b/cayenne-server/src/test/java/org/apache/cayenne/access/DataContextEntityWithMeaningfulPKIT.java
@@ -29,10 +29,12 @@ import org.apache.cayenne.query.ObjectIdQuery;
 import org.apache.cayenne.query.ObjectSelect;
 import org.apache.cayenne.testdo.meaningful_pk.MeaningfulPKDep;
 import org.apache.cayenne.testdo.meaningful_pk.MeaningfulPKTest1;
+import org.apache.cayenne.testdo.meaningful_pk.MeaningfulPk;
 import org.apache.cayenne.testdo.meaningful_pk.MeaningfulPkTest2;
 import org.apache.cayenne.unit.di.server.CayenneProjects;
 import org.apache.cayenne.unit.di.server.ServerCase;
 import org.apache.cayenne.unit.di.server.UseServerRuntime;
+import org.junit.Ignore;
 import org.junit.Test;
 
 import java.util.List;
@@ -189,4 +191,17 @@ public class DataContextEntityWithMeaningfulPKIT extends ServerCase {
         assertEquals(1, obj1.getPkAttribute());
         assertEquals(2, obj2.getPkAttribute());
     }
+
+    @Test
+    @Ignore("Insert will fail")
+    public void testInsertDelete() {
+        MeaningfulPk pkObj = context.newObject(MeaningfulPk.class);
+        pkObj.setPk("123");
+        context.commitChanges();
+
+        context.deleteObject(pkObj);
+        MeaningfulPk pkObj2 = context.newObject(MeaningfulPk.class);
+        pkObj2.setPk("123");
+        context.commitChanges();
+    }
 }
diff --git a/cayenne-server/src/test/java/org/apache/cayenne/access/DataContextFlattenedAttributesIT.java b/cayenne-server/src/test/java/org/apache/cayenne/access/DataContextFlattenedAttributesIT.java
index fb8f7a5..c3f2bed 100644
--- a/cayenne-server/src/test/java/org/apache/cayenne/access/DataContextFlattenedAttributesIT.java
+++ b/cayenne-server/src/test/java/org/apache/cayenne/access/DataContextFlattenedAttributesIT.java
@@ -20,10 +20,14 @@
 package org.apache.cayenne.access;
 
 import org.apache.cayenne.Cayenne;
+import org.apache.cayenne.ObjectContext;
 import org.apache.cayenne.PersistenceState;
+import org.apache.cayenne.configuration.server.ServerRuntime;
 import org.apache.cayenne.di.Inject;
 import org.apache.cayenne.exp.ExpressionFactory;
 import org.apache.cayenne.query.EJBQLQuery;
+import org.apache.cayenne.query.ObjectSelect;
+import org.apache.cayenne.query.SelectById;
 import org.apache.cayenne.query.SelectQuery;
 import org.apache.cayenne.reflect.PersistentDescriptor;
 import org.apache.cayenne.test.jdbc.DBHelper;
@@ -51,6 +55,9 @@ import static org.junit.Assert.assertTrue;
 public class DataContextFlattenedAttributesIT extends ServerCase {
 
     @Inject
+    private ServerRuntime runtime;
+
+    @Inject
     private DataContext context;
 
     @Inject
@@ -109,6 +116,21 @@ public class DataContextFlattenedAttributesIT extends ServerCase {
     }
 
     @Test
+    public void testSelect() throws Exception {
+        createTestDataSet();
+
+        CompoundPainting result = ObjectSelect
+                .query(CompoundPainting.class)
+                .orderBy(CompoundPainting.PAINTING_ID_PK_PROPERTY.asc())
+                .selectFirst(context);
+
+        assertNotNull(result);
+
+        result.setArtistName("new A");
+        context.commitChanges();
+    }
+
+    @Test
     public void testSelectCompound1() throws Exception {
         createTestDataSet();
         SelectQuery<CompoundPainting> query = SelectQuery.query(CompoundPainting.class);
@@ -377,4 +399,48 @@ public class DataContextFlattenedAttributesIT extends ServerCase {
 
         context.commitChanges();
     }
+
+    @Test
+    public void testUpdateDifferentContext() {
+        Object id;
+        {
+            // insert
+            ObjectContext context1 = runtime.newContext();
+            CompoundPainting o1 = context1.newObject(CompoundPainting.class);
+            o1.setArtistName("A1");
+            o1.setEstimatedPrice(new BigDecimal(1d));
+            o1.setGalleryName("G1");
+            o1.setPaintingTitle("P1");
+            o1.setTextReview("T1");
+
+            context1.commitChanges();
+            id = o1.getObjectId().getIdSnapshot().get(CompoundPainting.PAINTING_ID_PK_COLUMN);
+        }
+
+        {
+            // read and update
+            ObjectContext context2 = runtime.newContext();
+            CompoundPainting o2 = SelectById.query(CompoundPainting.class, id).selectFirst(context2);
+
+            o2.setArtistName("AX1");
+            o2.setEstimatedPrice(new BigDecimal(2d));
+            o2.setGalleryName("XG1");
+            o2.setPaintingTitle("PX1");
+            o2.setTextReview("TX1");
+
+            context2.commitChanges();
+        }
+
+        {
+            // read and check
+            ObjectContext context3 = runtime.newContext();
+            CompoundPainting o3 = SelectById.query(CompoundPainting.class, id).selectFirst(context3);
+
+            assertEquals("AX1", o3.getArtistName());
+            assertEquals("2.00", o3.getEstimatedPrice().toPlainString());
+            assertEquals("XG1", o3.getGalleryName());
+            assertEquals("PX1", o3.getPaintingTitle());
+            assertEquals("TX1", o3.getTextReview());
+        }
+    }
 }
diff --git a/cayenne-server/src/test/java/org/apache/cayenne/access/DefaultDataRowStoreFactoryIT.java b/cayenne-server/src/test/java/org/apache/cayenne/access/DefaultDataRowStoreFactoryIT.java
index b95aeac..cd849d9 100644
--- a/cayenne-server/src/test/java/org/apache/cayenne/access/DefaultDataRowStoreFactoryIT.java
+++ b/cayenne-server/src/test/java/org/apache/cayenne/access/DefaultDataRowStoreFactoryIT.java
@@ -19,6 +19,10 @@
 
 package org.apache.cayenne.access;
 
+import org.apache.cayenne.access.flush.DataDomainFlushActionFactory;
+import org.apache.cayenne.access.flush.operation.DbRowOpSorter;
+import org.apache.cayenne.access.flush.DefaultDataDomainFlushActionFactory;
+import org.apache.cayenne.access.flush.operation.DefaultDbRowOpSorter;
 import org.apache.cayenne.configuration.DefaultRuntimeProperties;
 import org.apache.cayenne.configuration.RuntimeProperties;
 import org.apache.cayenne.configuration.server.ServerModule;
@@ -100,6 +104,8 @@ public class DefaultDataRowStoreFactoryIT extends ServerCase {
             binder.bind(RuntimeProperties.class).to(DefaultRuntimeProperties.class);
             binder.bind(EventBridge.class).toProvider(MockEventBridgeProvider.class);
             binder.bind(DataRowStoreFactory.class).to(DefaultDataRowStoreFactory.class);
+            binder.bind(DataDomainFlushActionFactory.class).to(DefaultDataDomainFlushActionFactory.class);
+            binder.bind(DbRowOpSorter.class).to(DefaultDbRowOpSorter.class);
             ServerModule.contributeProperties(binder);
         };
 
diff --git a/cayenne-server/src/test/java/org/apache/cayenne/access/VerticalInheritanceIT.java b/cayenne-server/src/test/java/org/apache/cayenne/access/VerticalInheritanceIT.java
index c8d2077..b79989b 100644
--- a/cayenne-server/src/test/java/org/apache/cayenne/access/VerticalInheritanceIT.java
+++ b/cayenne-server/src/test/java/org/apache/cayenne/access/VerticalInheritanceIT.java
@@ -94,7 +94,7 @@ public class VerticalInheritanceIT extends ServerCase {
 		sub1.getObjectContext().commitChanges();
 
 		assertEquals(1, ivRootTable.getRowCount());
-		assertEquals(1, ivSub1Table.getRowCount());
+		assertEquals(0, ivSub1Table.getRowCount());
 
 		Object[] data = ivRootTable.select();
 		assertEquals(3, data.length);
@@ -103,11 +103,6 @@ public class VerticalInheritanceIT extends ServerCase {
 		assertEquals("XyZX", data[1]);
 		assertEquals("IvSub1", data[2]);
 
-		Object[] subdata = ivSub1Table.select();
-		assertEquals(2, subdata.length);
-		assertEquals(data[0], subdata[0]);
-		assertNull(subdata[1]);
-
 		ivSub1Table.deleteAll();
 		ivRootTable.deleteAll();
 
@@ -123,7 +118,7 @@ public class VerticalInheritanceIT extends ServerCase {
 		assertEquals("XyZXY", data[1]);
 		assertEquals("IvSub1", data[2]);
 
-		subdata = ivSub1Table.select();
+		Object[] subdata = ivSub1Table.select();
 		assertEquals(2, subdata.length);
 		assertEquals(data[0], subdata[0]);
 		assertEquals("BdE2", subdata[1]);
@@ -143,7 +138,7 @@ public class VerticalInheritanceIT extends ServerCase {
 		sub2.getObjectContext().commitChanges();
 
 		assertEquals(1, ivRootTable.getRowCount());
-		assertEquals(1, ivSub2Table.getRowCount());
+		assertEquals(0, ivSub2Table.getRowCount());
 
 		Object[] data = ivRootTable.select();
 		assertEquals(3, data.length);
@@ -152,12 +147,6 @@ public class VerticalInheritanceIT extends ServerCase {
 		assertEquals("XyZX", data[1]);
 		assertEquals("IvSub2", data[2]);
 
-		Object[] subdata = ivSub2Table.select();
-		assertEquals(3, subdata.length);
-		assertEquals(data[0], subdata[0]);
-		assertNull(subdata[1]);
-		assertNull(subdata[2]);
-
 		ivSub2Table.deleteAll();
 		ivRootTable.deleteAll();
 
@@ -174,7 +163,7 @@ public class VerticalInheritanceIT extends ServerCase {
 		assertEquals("XyZXY", data[1]);
 		assertEquals("IvSub2", data[2]);
 
-		subdata = ivSub2Table.select();
+		Object[] subdata = ivSub2Table.select();
 		assertEquals(3, subdata.length);
 		assertEquals(data[0], subdata[0]);
 		assertEquals("BdE2", subdata[1]);
diff --git a/cayenne-server/src/test/java/org/apache/cayenne/configuration/server/DataContextFactoryTest.java b/cayenne-server/src/test/java/org/apache/cayenne/configuration/server/DataContextFactoryTest.java
index d92ca62..62ac624 100644
--- a/cayenne-server/src/test/java/org/apache/cayenne/configuration/server/DataContextFactoryTest.java
+++ b/cayenne-server/src/test/java/org/apache/cayenne/configuration/server/DataContextFactoryTest.java
@@ -24,6 +24,11 @@ import org.apache.cayenne.access.DataRowStoreFactory;
 import org.apache.cayenne.access.DefaultDataRowStoreFactory;
 import org.apache.cayenne.access.DefaultObjectMapRetainStrategy;
 import org.apache.cayenne.access.ObjectMapRetainStrategy;
+import org.apache.cayenne.access.flush.DataDomainFlushActionFactory;
+import org.apache.cayenne.access.flush.operation.DbRowOpSorter;
+import org.apache.cayenne.access.flush.DefaultDataDomainFlushActionFactory;
+import org.apache.cayenne.access.flush.operation.DefaultDbRowOpSorter;
+import org.apache.cayenne.ashwood.AshwoodEntitySorter;
 import org.apache.cayenne.cache.MapQueryCache;
 import org.apache.cayenne.cache.QueryCache;
 import org.apache.cayenne.configuration.DefaultObjectStoreFactory;
@@ -39,6 +44,7 @@ import org.apache.cayenne.event.MockEventManager;
 import org.apache.cayenne.event.NoopEventBridgeProvider;
 import org.apache.cayenne.log.JdbcEventLogger;
 import org.apache.cayenne.log.Slf4jJdbcEventLogger;
+import org.apache.cayenne.map.EntitySorter;
 import org.apache.cayenne.tx.DefaultTransactionFactory;
 import org.apache.cayenne.tx.DefaultTransactionManager;
 import org.apache.cayenne.tx.TransactionFactory;
@@ -74,6 +80,9 @@ public class DataContextFactoryTest {
             binder.bind(DataRowStoreFactory.class).to(DefaultDataRowStoreFactory.class);
             binder.bind(EventBridge.class).toProvider(NoopEventBridgeProvider.class);
             binder.bind(DataRowStoreFactory.class).to(DefaultDataRowStoreFactory.class);
+            binder.bind(DataDomainFlushActionFactory.class).to(DefaultDataDomainFlushActionFactory.class);
+            binder.bind(DbRowOpSorter.class).to(DefaultDbRowOpSorter.class);
+            binder.bind(EntitySorter.class).to(AshwoodEntitySorter.class);
         };
 
         Injector injector = DIBootstrap.createInjector(testModule);
@@ -110,6 +119,9 @@ public class DataContextFactoryTest {
             binder.bind(TransactionManager.class).to(DefaultTransactionManager.class);
             binder.bind(EventBridge.class).toProvider(NoopEventBridgeProvider.class);
             binder.bind(DataRowStoreFactory.class).to(DefaultDataRowStoreFactory.class);
+            binder.bind(DataDomainFlushActionFactory.class).to(DefaultDataDomainFlushActionFactory.class);
+            binder.bind(DbRowOpSorter.class).to(DefaultDbRowOpSorter.class);
+            binder.bind(EntitySorter.class).to(AshwoodEntitySorter.class);
         };
 
         Injector injector = DIBootstrap.createInjector(testModule);
diff --git a/cayenne-server/src/test/resources/inheritance-vertical.map.xml b/cayenne-server/src/test/resources/inheritance-vertical.map.xml
index 4af9df1..25b772a 100644
--- a/cayenne-server/src/test/resources/inheritance-vertical.map.xml
+++ b/cayenne-server/src/test/resources/inheritance-vertical.map.xml
@@ -1,7 +1,7 @@
 <?xml version="1.0" encoding="utf-8"?>
 <data-map xmlns="http://cayenne.apache.org/schema/10/modelMap"
 	 xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
-	 xsi:schemaLocation="http://cayenne.apache.org/schema/10/modelMap http://cayenne.apache.org/schema/10/modelMap.xsd"
+	 xsi:schemaLocation="http://cayenne.apache.org/schema/10/modelMap https://cayenne.apache.org/schema/10/modelMap.xsd"
 	 project-version="10">
 	<property name="defaultPackage" value="org.apache.cayenne.testdo.inheritance_vertical"/>
 	<db-entity name="IV1_ROOT">
@@ -117,7 +117,7 @@
 		<obj-attribute name="attr1" type="java.lang.String" db-attribute-path="impl.ATTR1"/>
 		<obj-attribute name="attr2" type="java.lang.String" db-attribute-path="impl.ATTR2"/>
 	</obj-entity>
-	<obj-entity name="IvImplWithLock" superEntityName="IvBaseWithLock" className="org.apache.cayenne.testdo.inheritance_vertical.IvImplWithLock">
+	<obj-entity name="IvImplWithLock" superEntityName="IvBaseWithLock" className="org.apache.cayenne.testdo.inheritance_vertical.IvImplWithLock" lock-type="optimistic">
 		<qualifier><![CDATA[type = "I"]]></qualifier>
 		<obj-attribute name="attr1" type="java.lang.String" db-attribute-path="impl.ATTR1"/>
 	</obj-entity>
diff --git a/cayenne-server/src/test/resources/testmap.map.xml b/cayenne-server/src/test/resources/testmap.map.xml
index 68539b6..442ce65 100644
--- a/cayenne-server/src/test/resources/testmap.map.xml
+++ b/cayenne-server/src/test/resources/testmap.map.xml
@@ -158,7 +158,7 @@
 	<obj-entity name="SubPainting" className="org.apache.cayenne.testdo.testmap.SubPainting" dbEntityName="PAINTING">
 		<obj-attribute name="paintingTitle" type="java.lang.String" db-attribute-path="PAINTING_TITLE"/>
 	</obj-entity>
-	<db-relationship name="artistGroupArray" source="ARTGROUP" target="ARTIST_GROUP" toMany="true">
+	<db-relationship name="artistGroupArray" source="ARTGROUP" target="ARTIST_GROUP" toDependentPK="true" toMany="true">
 		<db-attribute-pair source="GROUP_ID" target="GROUP_ID"/>
 	</db-relationship>
 	<db-relationship name="toChildGroups" source="ARTGROUP" target="ARTGROUP" toMany="true">
@@ -170,7 +170,7 @@
 	<db-relationship name="artistExhibitArray" source="ARTIST" target="ARTIST_EXHIBIT" toDependentPK="true" toMany="true">
 		<db-attribute-pair source="ARTIST_ID" target="ARTIST_ID"/>
 	</db-relationship>
-	<db-relationship name="artistGroupArray" source="ARTIST" target="ARTIST_GROUP" toMany="true">
+	<db-relationship name="artistGroupArray" source="ARTIST" target="ARTIST_GROUP" toDependentPK="true" toMany="true">
 		<db-attribute-pair source="ARTIST_ID" target="ARTIST_ID"/>
 	</db-relationship>
 	<db-relationship name="paintingArray" source="ARTIST" target="PAINTING" toMany="true">


[cayenne] 01/06: CAY-2571 DataDomainFlushAction redesign initial version and db operations API

Posted by nt...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

ntimofeev pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/cayenne.git

commit f55efff11227671dc89ab2d5561858e9e2e75100
Author: Nikita Timofeev <st...@gmail.com>
AuthorDate: Mon Apr 22 18:01:56 2019 +0300

    CAY-2571 DataDomainFlushAction redesign
    initial version and db operations API
---
 .../java/org/apache/cayenne/access/ObjectDiff.java |  22 +-
 .../org/apache/cayenne/access/ObjectResolver.java  |   4 +-
 .../org/apache/cayenne/access/ObjectStore.java     |  38 ++-
 .../cayenne/access/ObjectStoreGraphDiff.java       |   4 +-
 .../org/apache/cayenne/access/flush/ArcTarget.java |  87 +++++++
 .../access/flush/ArcValuesCreationHandler.java     | 275 +++++++++++++++++++++
 .../flush/DataDomainFlushAction.java}              |  38 +--
 .../flush/DataDomainFlushActionFactory.java}       |  33 +--
 .../flush/DataDomainIndirectDiffBuilder.java       | 106 ++++++++
 .../cayenne/access/flush/DbRowOpFactory.java       | 138 +++++++++++
 .../access/flush/DefaultDataDomainFlushAction.java | 210 ++++++++++++++++
 .../DefaultDataDomainFlushActionFactory.java}      |  43 ++--
 .../apache/cayenne/access/flush/EffectiveOpId.java |  63 +++++
 .../apache/cayenne/access/flush/FlushObserver.java | 152 ++++++++++++
 .../access/flush/ObjectIdValueSupplier.java        |  82 ++++++
 .../flush/OptimisticLockQualifierBuilder.java      |  85 +++++++
 .../access/flush/PermanentObjectIdVisitor.java     | 139 +++++++++++
 .../cayenne/access/flush/PostprocessVisitor.java   | 143 +++++++++++
 .../cayenne/access/flush/QueryCreatorVisitor.java  | 121 +++++++++
 .../cayenne/access/flush/ReplacementIdVisitor.java | 106 ++++++++
 .../cayenne/access/flush/RootRowOpProcessor.java   |  82 ++++++
 .../access/flush/ValuesCreationHandler.java        |  77 ++++++
 .../access/flush/operation/BaseDbRowOp.java        |  77 ++++++
 .../flush/operation/DbRowOp.java}                  |  40 ++-
 .../access/flush/operation/DbRowOpMerger.java      |  82 ++++++
 .../flush/operation/DbRowOpSorter.java}            |  34 +--
 .../flush/operation/DbRowOpType.java}              |  49 ++--
 .../flush/operation/DbRowOpVisitor.java}           |  39 +--
 .../flush/operation/DbRowOpWithQualifier.java}     |  32 +--
 .../flush/operation/DbRowOpWithValues.java}        |  32 +--
 .../flush/operation/DefaultDbRowOpSorter.java      | 150 +++++++++++
 .../access/flush/operation/DeleteDbRowOp.java      |  72 ++++++
 .../flush/operation/DeleteInsertDbRowOp.java}      |  48 ++--
 .../access/flush/operation/InsertDbRowOp.java      |  71 ++++++
 .../cayenne/access/flush/operation/Qualifier.java  | 147 +++++++++++
 .../access/flush/operation/UpdateDbRowOp.java      |  74 ++++++
 .../cayenne/access/flush/operation/Values.java     | 151 +++++++++++
 .../cayenne/ashwood/AshwoodEntitySorter.java       | 140 ++++++-----
 .../main/java/org/apache/cayenne/map/DbEntity.java |   7 +-
 .../java/org/apache/cayenne/map/EntitySorter.java  |  21 ++
 .../org/apache/cayenne/query/DeleteBatchQuery.java |   7 +-
 41 files changed, 2980 insertions(+), 341 deletions(-)

diff --git a/cayenne-server/src/main/java/org/apache/cayenne/access/ObjectDiff.java b/cayenne-server/src/main/java/org/apache/cayenne/access/ObjectDiff.java
index 97080eb..cc025f6 100644
--- a/cayenne-server/src/main/java/org/apache/cayenne/access/ObjectDiff.java
+++ b/cayenne-server/src/main/java/org/apache/cayenne/access/ObjectDiff.java
@@ -51,7 +51,7 @@ import java.util.Map;
  * A dynamic GraphDiff that represents a delta between object simple properties
  * at diff creation time and its current state.
  */
-class ObjectDiff extends NodeDiff {
+public class ObjectDiff extends NodeDiff {
 
     private final String entityName;
 
@@ -144,11 +144,11 @@ class ObjectDiff extends NodeDiff {
         return classDescriptor;
     }
 
-    Object getSnapshotValue(String propertyName) {
+    public Object getSnapshotValue(String propertyName) {
         return snapshot != null ? snapshot.get(propertyName) : null;
     }
 
-    ObjectId getArcSnapshotValue(String propertyName) {
+    public ObjectId getArcSnapshotValue(String propertyName) {
         Object value = arcSnapshot != null ? arcSnapshot.get(propertyName) : null;
 
         if (value instanceof Fault) {
@@ -161,6 +161,20 @@ class ObjectDiff extends NodeDiff {
         return (ObjectId) value;
     }
 
+    /**
+     * @since 4.2
+     */
+    public ObjectId getCurrentArcSnapshotValue(String propertyName) {
+        Object value = currentArcSnapshot != null ? currentArcSnapshot.get(propertyName) : null;
+        if (value instanceof Fault) {
+            Persistent target = (Persistent) ((Fault) value).resolveFault(object, propertyName);
+
+            value = target != null ? target.getObjectId() : null;
+            currentArcSnapshot.put(propertyName, value);
+        }
+        return (ObjectId) value;
+    }
+
     boolean containsArcSnapshot(String propertyName) {
         return arcSnapshot != null && arcSnapshot.containsKey(propertyName);
     }
@@ -462,7 +476,7 @@ class ObjectDiff extends NodeDiff {
         @Override
         public int hashCode() {
             // assuming String and ObjectId provide a good hashCode
-            return arcId.hashCode() + targetNodeId.hashCode() + 5;
+            return 31 * arcId.hashCode() + targetNodeId.hashCode();
         }
 
         @Override
diff --git a/cayenne-server/src/main/java/org/apache/cayenne/access/ObjectResolver.java b/cayenne-server/src/main/java/org/apache/cayenne/access/ObjectResolver.java
index 088d341..14f8873 100644
--- a/cayenne-server/src/main/java/org/apache/cayenne/access/ObjectResolver.java
+++ b/cayenne-server/src/main/java/org/apache/cayenne/access/ObjectResolver.java
@@ -192,9 +192,9 @@ class ObjectResolver {
             String path = entry.getKey();
             int lastDot = path.lastIndexOf('.');
             String prefix = lastDot == -1 ? path : path.substring(lastDot + 1);
-            ObjectId objectId = createObjectId(row, dbEntity.getName(), dbEntity.getPrimaryKeys(), prefix + '.', false);
+            ObjectId objectId = createObjectId(row, "db:" + dbEntity.getName(), dbEntity.getPrimaryKeys(), prefix + '.', false);
             if(objectId != null) {
-				context.getObjectStore().markFlattenedPath(object.getObjectId(), path);
+				context.getObjectStore().markFlattenedPath(object.getObjectId(), path, objectId);
             }
         }
     }
diff --git a/cayenne-server/src/main/java/org/apache/cayenne/access/ObjectStore.java b/cayenne-server/src/main/java/org/apache/cayenne/access/ObjectStore.java
index 8a821c4..4415aab 100644
--- a/cayenne-server/src/main/java/org/apache/cayenne/access/ObjectStore.java
+++ b/cayenne-server/src/main/java/org/apache/cayenne/access/ObjectStore.java
@@ -74,7 +74,7 @@ public class ObjectStore implements Serializable, SnapshotEventListener, GraphMa
      * Presence of path in this map is used to separate insert from update case of flattened records.
      * @since 4.1
      */
-    protected Map<Object, Set<String>> trackedFlattenedPaths;
+    protected Map<Object, Map<String, ObjectId>> trackedFlattenedPaths;
 
     // a sequential id used to tag GraphDiffs so that they can later be sorted in the
     // original creation order
@@ -407,7 +407,7 @@ public class ObjectStore implements Serializable, SnapshotEventListener, GraphMa
      * 
      * @since 1.2
      */
-    void postprocessAfterCommit(GraphDiff parentChanges) {
+    public void postprocessAfterCommit(GraphDiff parentChanges) {
 
         // scan through changed objects, set persistence state to committed
         for (Object id : changes.keySet()) {
@@ -604,7 +604,7 @@ public class ObjectStore implements Serializable, SnapshotEventListener, GraphMa
         }
 
         if(trackedFlattenedPaths != null) {
-            Set<String> paths = trackedFlattenedPaths.remove(nodeId);
+            Map<String, ObjectId> paths = trackedFlattenedPaths.remove(nodeId);
             if(paths != null) {
                 trackedFlattenedPaths.put(newId, paths);
             }
@@ -997,20 +997,44 @@ public class ObjectStore implements Serializable, SnapshotEventListener, GraphMa
             return false;
         }
         return trackedFlattenedPaths
-                .getOrDefault(objectId, Collections.emptySet()).contains(path);
+                .getOrDefault(objectId, Collections.emptyMap()).containsKey(path);
+    }
+
+    /**
+     * @since 4.2
+     */
+    public ObjectId getFlattenedId(ObjectId objectId, String path) {
+        if(trackedFlattenedPaths == null) {
+            return null;
+        }
+
+        return trackedFlattenedPaths
+                .getOrDefault(objectId, Collections.emptyMap()).get(path);
+    }
+
+    /**
+     * @since 4.2
+     */
+    public Collection<ObjectId> getFlattenedIds(ObjectId objectId) {
+        if(trackedFlattenedPaths == null) {
+            return Collections.emptyList();
+        }
+
+        return trackedFlattenedPaths
+                .getOrDefault(objectId, Collections.emptyMap()).values();
     }
 
     /**
      * Mark that flattened path for object has data row in DB.
      * @since 4.1
      */
-    void markFlattenedPath(ObjectId objectId, String path) {
+    public void markFlattenedPath(ObjectId objectId, String path, ObjectId id) {
         if(trackedFlattenedPaths == null) {
             trackedFlattenedPaths = new ConcurrentHashMap<>();
         }
         trackedFlattenedPaths
-                .computeIfAbsent(objectId, o -> Collections.newSetFromMap(new ConcurrentHashMap<>()))
-                .add(path);
+                .computeIfAbsent(objectId, o -> new ConcurrentHashMap<>())
+                .put(path, id);
     }
 
     // an ObjectIdQuery optimized for retrieval of multiple snapshots - it can be reset
diff --git a/cayenne-server/src/main/java/org/apache/cayenne/access/ObjectStoreGraphDiff.java b/cayenne-server/src/main/java/org/apache/cayenne/access/ObjectStoreGraphDiff.java
index 534a9cc..04e570f 100644
--- a/cayenne-server/src/main/java/org/apache/cayenne/access/ObjectStoreGraphDiff.java
+++ b/cayenne-server/src/main/java/org/apache/cayenne/access/ObjectStoreGraphDiff.java
@@ -44,7 +44,7 @@ import java.util.Map.Entry;
  * 
  * @since 1.2
  */
-class ObjectStoreGraphDiff implements GraphDiff {
+public class ObjectStoreGraphDiff implements GraphDiff {
 
     private ObjectStore objectStore;
     private GraphDiff resolvedDiff;
@@ -55,7 +55,7 @@ class ObjectStoreGraphDiff implements GraphDiff {
         preprocess(objectStore);
     }
 
-    Map<Object, ObjectDiff> getChangesByObjectId() {
+    public Map<Object, ObjectDiff> getChangesByObjectId() {
         return objectStore.getChangesByObjectId();
     }
 
diff --git a/cayenne-server/src/main/java/org/apache/cayenne/access/flush/ArcTarget.java b/cayenne-server/src/main/java/org/apache/cayenne/access/flush/ArcTarget.java
new file mode 100644
index 0000000..8b6474a
--- /dev/null
+++ b/cayenne-server/src/main/java/org/apache/cayenne/access/flush/ArcTarget.java
@@ -0,0 +1,87 @@
+/*****************************************************************
+ *   Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ ****************************************************************/
+
+package org.apache.cayenne.access.flush;
+
+import java.util.Objects;
+
+import org.apache.cayenne.ObjectId;
+import org.apache.cayenne.graph.ArcId;
+
+/**
+ * Value object describing exact arc between two objects.
+ * Implements {@link #equals(Object)} and {@link #hashCode()} methods.
+ *
+ * @since 4.2
+ */
+class ArcTarget {
+
+    private final ObjectId sourceId;
+    private final ObjectId targetId;
+    private final ArcId arcId;
+
+    ArcTarget(ObjectId sourceId, ObjectId targetId, ArcId arcId) {
+        this.sourceId = Objects.requireNonNull(sourceId);
+        this.targetId = Objects.requireNonNull(targetId);
+        this.arcId = Objects.requireNonNull(arcId);
+    }
+
+    ArcTarget getReversed() {
+        return new ArcTarget(targetId, sourceId, arcId.getReverseId());
+    }
+
+    ArcId getArcId() {
+        return arcId;
+    }
+
+    ObjectId getSourceId() {
+        return sourceId;
+    }
+
+    ObjectId getTargetId() {
+        return targetId;
+    }
+
+    @Override
+    public boolean equals(Object o) {
+        if (this == o) {
+            return true;
+        }
+        if (o == null || getClass() != o.getClass()) {
+            return false;
+        }
+
+        ArcTarget arcTarget = (ArcTarget) o;
+        if (!sourceId.equals(arcTarget.sourceId)) {
+            return false;
+        }
+        if (!targetId.equals(arcTarget.targetId)) {
+            return false;
+        }
+        return arcId.equals(arcTarget.arcId);
+    }
+
+    @Override
+    public int hashCode() {
+        int result = sourceId.hashCode();
+        result = 31 * result + targetId.hashCode();
+        result = 31 * result + arcId.hashCode();
+        return result;
+    }
+}
diff --git a/cayenne-server/src/main/java/org/apache/cayenne/access/flush/ArcValuesCreationHandler.java b/cayenne-server/src/main/java/org/apache/cayenne/access/flush/ArcValuesCreationHandler.java
new file mode 100644
index 0000000..181b0de
--- /dev/null
+++ b/cayenne-server/src/main/java/org/apache/cayenne/access/flush/ArcValuesCreationHandler.java
@@ -0,0 +1,275 @@
+/*****************************************************************
+ *   Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ ****************************************************************/
+
+package org.apache.cayenne.access.flush;
+
+import java.util.Iterator;
+
+import org.apache.cayenne.ObjectId;
+import org.apache.cayenne.access.flush.operation.DbRowOp;
+import org.apache.cayenne.access.flush.operation.DbRowOpType;
+import org.apache.cayenne.access.flush.operation.DbRowOpVisitor;
+import org.apache.cayenne.access.flush.operation.DbRowOpWithValues;
+import org.apache.cayenne.access.flush.operation.DeleteDbRowOp;
+import org.apache.cayenne.access.flush.operation.InsertDbRowOp;
+import org.apache.cayenne.access.flush.operation.UpdateDbRowOp;
+import org.apache.cayenne.exp.parser.ASTDbPath;
+import org.apache.cayenne.graph.ArcId;
+import org.apache.cayenne.graph.GraphChangeHandler;
+import org.apache.cayenne.map.DbAttribute;
+import org.apache.cayenne.map.DbEntity;
+import org.apache.cayenne.map.DbJoin;
+import org.apache.cayenne.map.DbRelationship;
+import org.apache.cayenne.map.ObjEntity;
+import org.apache.cayenne.map.ObjRelationship;
+import org.apache.cayenne.util.CayenneMapEntry;
+
+/**
+ * Graph handler that collects information about arc changes into
+ * {@link org.apache.cayenne.access.flush.operation.Values} and/or {@link org.apache.cayenne.access.flush.operation.Qualifier}.
+ *
+ * @since 4.2
+ */
+class ArcValuesCreationHandler implements GraphChangeHandler {
+
+    final DbRowOpFactory factory;
+    final DbRowOpType defaultType;
+
+    ArcValuesCreationHandler(DbRowOpFactory factory, DbRowOpType defaultType) {
+        this.factory = factory;
+        this.defaultType = defaultType;
+    }
+
+    public void arcCreated(Object nodeId, Object targetNodeId, ArcId arcId) {
+        processArcChange(nodeId, targetNodeId, arcId, true);
+    }
+
+    public void arcDeleted(Object nodeId, Object targetNodeId, ArcId arcId) {
+        processArcChange(nodeId, targetNodeId, arcId, false);
+    }
+
+    private void processArcChange(Object nodeId, Object targetNodeId, ArcId arcId, boolean created) {
+        ObjectId actualTargetId = (ObjectId)targetNodeId;
+        ObjectId snapshotId = factory.getDiff().getCurrentArcSnapshotValue(arcId.getForwardArc());
+        if(snapshotId != null) {
+            actualTargetId = snapshotId;
+        }
+        ArcTarget arcTarget = new ArcTarget((ObjectId) nodeId, actualTargetId, arcId);
+        if(factory.getProcessedArcs().contains(arcTarget.getReversed())) {
+            return;
+        }
+
+        ObjEntity entity = factory.getDescriptor().getEntity();
+        ObjRelationship objRelationship = entity.getRelationship(arcTarget.getArcId().getForwardArc());
+        if(objRelationship == null) {
+            String arc = arcId.getForwardArc();
+            if(arc.startsWith(ASTDbPath.DB_PREFIX)) {
+                String relName = arc.substring(ASTDbPath.DB_PREFIX.length());
+                DbRelationship dbRelationship = entity.getDbEntity().getRelationship(relName);
+                processRelationship(dbRelationship, arcTarget.getSourceId(), arcTarget.getTargetId(), created);
+            }
+            return;
+        }
+
+        if(objRelationship.isFlattened()) {
+            processFlattenedPath(arcTarget.getSourceId(), arcTarget.getTargetId(), entity.getDbEntity(),
+                    objRelationship.getDbRelationshipPath(), created);
+        } else {
+            DbRelationship dbRelationship = objRelationship.getDbRelationships().get(0);
+            processRelationship(dbRelationship, arcTarget.getSourceId(), arcTarget.getTargetId(), created);
+        }
+
+        factory.getProcessedArcs().add(arcTarget);
+    }
+
+    ObjectId processFlattenedPath(ObjectId id, ObjectId finalTargetId, DbEntity entity, String dbPath, boolean add) {
+        Iterator<CayenneMapEntry> dbPathIterator = entity.resolvePathComponents(dbPath);
+        StringBuilder path = new StringBuilder();
+
+        ObjectId srcId = id;
+        ObjectId targetId = null;
+
+        while(dbPathIterator.hasNext()) {
+            CayenneMapEntry entry = dbPathIterator.next();
+            if(path.length() > 0) {
+                path.append('.');
+            }
+
+            path.append(entry.getName());
+            if(entry instanceof DbRelationship) {
+                DbRelationship relationship = (DbRelationship)entry;
+                // intermediate db entity to be inserted
+                DbEntity target = relationship.getTargetEntity();
+                // if ID is present, just use it, otherwise create new
+                String flattenedPath = path.toString();
+
+                // if this is last segment and it's a relationship, use known target id from arc creation
+                if(!dbPathIterator.hasNext()) {
+                    targetId = finalTargetId;
+                } else {
+                    if(!relationship.isToMany()) {
+                        targetId = factory.getStore().getFlattenedId(id, flattenedPath);
+                    } else {
+                        targetId = null;
+                    }
+                }
+
+                if(targetId == null) {
+                    // should insert, regardless of original operation (insert/update)
+                    targetId = ObjectId.of(ASTDbPath.DB_PREFIX + target.getName());
+                    if(!relationship.isToMany()) {
+                        factory.getStore().markFlattenedPath(id, flattenedPath, targetId);
+                    }
+
+                    DbRowOpType type;
+                    if(relationship.isToMany()) {
+                        type = add ? DbRowOpType.INSERT : DbRowOpType.DELETE;
+                        factory.getOrCreate(target, targetId, type);
+                    } else {
+                        type = add ? DbRowOpType.INSERT : DbRowOpType.UPDATE;
+                        factory.<DbRowOpWithValues>getOrCreate(target, targetId, type)
+                            .getValues()
+                            .addFlattenedId(flattenedPath, targetId);
+                    }
+                } else if(dbPathIterator.hasNext()) {
+                    // should update existing DB row
+                    factory.getOrCreate(target, targetId, add ? DbRowOpType.UPDATE : defaultType);
+                }
+                processRelationship(relationship, srcId, targetId, add);
+                srcId = targetId; // use target as next source..
+            }
+        }
+
+        return targetId;
+    }
+
+    protected void processRelationship(DbRelationship dbRelationship, ObjectId srcId, ObjectId targetId, boolean add) {
+        for(DbJoin join : dbRelationship.getJoins()) {
+            boolean srcPK = join.getSource().isPrimaryKey();
+            boolean targetPK = join.getTarget().isPrimaryKey();
+
+            Object valueToUse;
+            DbRowOp rowOp;
+            DbAttribute attribute;
+            ObjectId id;
+            boolean processDelete;
+
+            // We manage 3 cases here:
+            // 1. PK -> FK: just propagate value from PK and to FK
+            // 2. PK -> PK: check isToDep flag and set dependent one
+            // 3. NON-PK -> FK (not supported fully for now, see CAY-2488): also check isToDep flag,
+            //    but get value from DbRow, not ObjID
+            if(srcPK != targetPK) {
+                // case 1
+                processDelete = true;
+                id = null;
+                if(srcPK) {
+                    valueToUse = ObjectIdValueSupplier.getFor(srcId, join.getSourceName());
+                    rowOp = factory.getOrCreate(dbRelationship.getTargetEntity(), targetId, DbRowOpType.UPDATE);
+                    attribute = join.getTarget();
+                } else {
+                    valueToUse = ObjectIdValueSupplier.getFor(targetId, join.getTargetName());
+                    rowOp = factory.getOrCreate(dbRelationship.getSourceEntity(), srcId, defaultType);
+                    attribute = join.getSource();
+                }
+            } else {
+                // case 2 and 3
+                processDelete = false;
+                if(dbRelationship.isToDependentPK()) {
+                    valueToUse = ObjectIdValueSupplier.getFor(srcId, join.getSourceName());
+                    rowOp = factory.getOrCreate(dbRelationship.getTargetEntity(), targetId, DbRowOpType.UPDATE);
+                    attribute = join.getTarget();
+                    id = targetId;
+                    if(dbRelationship.isToMany()) {
+                        // strange mapping toDepPK and toMany, but just skip it
+                        rowOp = null;
+                    }
+                } else {
+                    valueToUse = ObjectIdValueSupplier.getFor(targetId, join.getTargetName());
+                    rowOp = factory.getOrCreate(dbRelationship.getSourceEntity(), srcId, defaultType);
+                    attribute = join.getSource();
+                    id = srcId;
+                    if(dbRelationship.getReverseRelationship().isToMany()) {
+                        // strange mapping toDepPK and toMany, but just skip it
+                        rowOp = null;
+                    }
+                }
+            }
+
+            // propagated master -> child PK
+            if(id != null && attribute.isPrimaryKey()) {
+                id.getReplacementIdMap().put(attribute.getName(), valueToUse);
+            }
+            if(rowOp != null) {
+                rowOp.accept(new ValuePropagationVisitor(attribute, add, valueToUse, processDelete));
+            }
+        }
+    }
+
+    // not interested in following events in this handler
+    @Override
+    public void nodeIdChanged(Object nodeId, Object newId) {
+    }
+
+    @Override
+    public void nodeCreated(Object nodeId) {
+    }
+
+    @Override
+    public void nodeRemoved(Object nodeId) {
+    }
+
+    @Override
+    public void nodePropertyChanged(Object nodeId, String property, Object oldValue, Object newValue) {
+    }
+
+    private static class ValuePropagationVisitor implements DbRowOpVisitor<Void> {
+        private final DbAttribute attribute;
+        private final boolean add;
+        private final Object valueToUse;
+        private final boolean processDelete;
+
+        private ValuePropagationVisitor(DbAttribute attribute, boolean add, Object valueToUse, boolean processDelete) {
+            this.attribute = attribute;
+            this.add = add;
+            this.valueToUse = valueToUse;
+            this.processDelete = processDelete;
+        }
+
+        @Override
+        public Void visitInsert(InsertDbRowOp dbRow) {
+            dbRow.getValues().addValue(attribute, add ? valueToUse : null);
+            return null;
+        }
+
+        @Override
+        public Void visitUpdate(UpdateDbRowOp dbRow) {
+            dbRow.getValues().addValue(attribute, add ? valueToUse : null);
+            return null;
+        }
+
+        @Override
+        public Void visitDelete(DeleteDbRowOp dbRow) {
+            if(processDelete) {
+                dbRow.getQualifier().addAdditionalQualifier(attribute, valueToUse);
+            }
+            return null;
+        }
+    }
+}
diff --git a/cayenne-server/src/main/java/org/apache/cayenne/map/EntitySorter.java b/cayenne-server/src/main/java/org/apache/cayenne/access/flush/DataDomainFlushAction.java
similarity index 53%
copy from cayenne-server/src/main/java/org/apache/cayenne/map/EntitySorter.java
copy to cayenne-server/src/main/java/org/apache/cayenne/access/flush/DataDomainFlushAction.java
index 5c7981e..c547d8f 100644
--- a/cayenne-server/src/main/java/org/apache/cayenne/map/EntitySorter.java
+++ b/cayenne-server/src/main/java/org/apache/cayenne/access/flush/DataDomainFlushAction.java
@@ -17,37 +17,21 @@
  *  under the License.
  ****************************************************************/
 
-package org.apache.cayenne.map;
+package org.apache.cayenne.access.flush;
 
-import java.util.List;
+import org.apache.cayenne.access.DataContext;
+import org.apache.cayenne.graph.GraphDiff;
 
 /**
- * Defines API for sorting of Cayenne entities based on their mutual dependencies.
- * 
- * @since 1.1
+ * A stateful commit handler used by DataContext to perform commit operation.
+ * DataDomainFlushAction resolves primary key dependencies, referential integrity
+ * dependencies (including multi-reflexive entities), generates primary keys, creates
+ * batches for massive data modifications, assigns operations to data nodes.
+ *
+ * @since 4.2
  */
-public interface EntitySorter {
-
-    /**
-     * Sets EntityResolver for this sorter. All entities present in the resolver will be
-     * used to determine sort ordering.
-     * 
-     * @since 3.1
-     */
-    void setEntityResolver(EntityResolver resolver);
-
-    /**
-     * Sorts a list of DbEntities.
-     */
-    void sortDbEntities(List<DbEntity> dbEntities, boolean deleteOrder);
+public interface DataDomainFlushAction {
 
-    /**
-     * Sorts a list of ObjEntities.
-     */
-    void sortObjEntities(List<ObjEntity> objEntities, boolean deleteOrder);
+    GraphDiff flush(DataContext context, GraphDiff changes);
 
-    /**
-     * Sorts a list of objects belonging to the ObjEntity.
-     */
-    void sortObjectsForEntity(ObjEntity entity, List<?> objects, boolean deleteOrder);
 }
diff --git a/cayenne-server/src/main/java/org/apache/cayenne/map/EntitySorter.java b/cayenne-server/src/main/java/org/apache/cayenne/access/flush/DataDomainFlushActionFactory.java
similarity index 53%
copy from cayenne-server/src/main/java/org/apache/cayenne/map/EntitySorter.java
copy to cayenne-server/src/main/java/org/apache/cayenne/access/flush/DataDomainFlushActionFactory.java
index 5c7981e..437dab2 100644
--- a/cayenne-server/src/main/java/org/apache/cayenne/map/EntitySorter.java
+++ b/cayenne-server/src/main/java/org/apache/cayenne/access/flush/DataDomainFlushActionFactory.java
@@ -17,37 +17,16 @@
  *  under the License.
  ****************************************************************/
 
-package org.apache.cayenne.map;
+package org.apache.cayenne.access.flush;
 
-import java.util.List;
+import org.apache.cayenne.access.DataDomain;
 
 /**
- * Defines API for sorting of Cayenne entities based on their mutual dependencies.
- * 
- * @since 1.1
+ * Factory that produces {@link DataDomainFlushAction}
+ * @since 4.2
  */
-public interface EntitySorter {
+public interface DataDomainFlushActionFactory {
 
-    /**
-     * Sets EntityResolver for this sorter. All entities present in the resolver will be
-     * used to determine sort ordering.
-     * 
-     * @since 3.1
-     */
-    void setEntityResolver(EntityResolver resolver);
+    DataDomainFlushAction createFlushAction(DataDomain dataDomain);
 
-    /**
-     * Sorts a list of DbEntities.
-     */
-    void sortDbEntities(List<DbEntity> dbEntities, boolean deleteOrder);
-
-    /**
-     * Sorts a list of ObjEntities.
-     */
-    void sortObjEntities(List<ObjEntity> objEntities, boolean deleteOrder);
-
-    /**
-     * Sorts a list of objects belonging to the ObjEntity.
-     */
-    void sortObjectsForEntity(ObjEntity entity, List<?> objects, boolean deleteOrder);
 }
diff --git a/cayenne-server/src/main/java/org/apache/cayenne/access/flush/DataDomainIndirectDiffBuilder.java b/cayenne-server/src/main/java/org/apache/cayenne/access/flush/DataDomainIndirectDiffBuilder.java
new file mode 100644
index 0000000..65e4190
--- /dev/null
+++ b/cayenne-server/src/main/java/org/apache/cayenne/access/flush/DataDomainIndirectDiffBuilder.java
@@ -0,0 +1,106 @@
+/*****************************************************************
+ *   Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ ****************************************************************/
+
+package org.apache.cayenne.access.flush;
+
+import java.util.Collection;
+import java.util.HashSet;
+
+import org.apache.cayenne.CayenneRuntimeException;
+import org.apache.cayenne.ObjectId;
+import org.apache.cayenne.access.ObjectStoreGraphDiff;
+import org.apache.cayenne.graph.ArcId;
+import org.apache.cayenne.graph.GraphChangeHandler;
+import org.apache.cayenne.map.EntityResolver;
+import org.apache.cayenne.map.ObjEntity;
+import org.apache.cayenne.map.ObjRelationship;
+
+/**
+ * A processor of ObjectStore indirect changes, such as flattened relationships
+ * and to-many relationships.
+ */
+final class DataDomainIndirectDiffBuilder implements GraphChangeHandler {
+
+    private final EntityResolver resolver;
+    private Collection<ObjectId> indirectModifications;
+
+    DataDomainIndirectDiffBuilder(EntityResolver resolver) {
+        this.resolver = resolver;
+    }
+
+    void processChanges(ObjectStoreGraphDiff allChanges) {
+        // extract flattened and indirect changes and remove duplicate changes...
+        allChanges.getChangesByObjectId()
+                .forEach((obj, diff) -> diff.apply(this));
+    }
+
+    Collection<ObjectId> getIndirectModifications() {
+        return indirectModifications;
+    }
+
+    @Override
+    public void arcCreated(Object nodeId, Object targetNodeId, ArcId arcId) {
+        processArcChange((ObjectId) nodeId, arcId);
+    }
+
+    @Override
+    public void arcDeleted(Object nodeId, Object targetNodeId, ArcId arcId) {
+        processArcChange((ObjectId) nodeId, arcId);
+    }
+
+    private void processArcChange(ObjectId nodeId, ArcId arcId) {
+        ObjEntity entity = resolver.getObjEntity(nodeId.getEntityName());
+        ObjRelationship relationship = entity.getRelationship(arcId.getForwardArc());
+
+        if (relationship != null && relationship.isSourceIndependentFromTargetChange()) {
+            // do not record temporary id mods...
+            if (!nodeId.isTemporary()) {
+                if(indirectModifications == null) {
+                    indirectModifications = new HashSet<>();
+                }
+                indirectModifications.add(nodeId);
+            }
+
+            if (relationship.isFlattened() && relationship.isReadOnly()) {
+                throw new CayenneRuntimeException("Cannot change the read-only flattened relationship %s in ObjEntity '%s'."
+                        , relationship.getName(), relationship.getSourceEntity().getName());
+            }
+        }
+    }
+
+    @Override
+    public void nodeIdChanged(Object nodeId, Object newId) {
+        // noop
+    }
+
+    @Override
+    public void nodeCreated(Object nodeId) {
+        // noop
+    }
+
+    @Override
+    public void nodeRemoved(Object nodeId) {
+        // noop
+    }
+
+    @Override
+    public void nodePropertyChanged(Object nodeId, String property, Object oldValue, Object newValue) {
+        // noop
+    }
+}
diff --git a/cayenne-server/src/main/java/org/apache/cayenne/access/flush/DbRowOpFactory.java b/cayenne-server/src/main/java/org/apache/cayenne/access/flush/DbRowOpFactory.java
new file mode 100644
index 0000000..47f4de1
--- /dev/null
+++ b/cayenne-server/src/main/java/org/apache/cayenne/access/flush/DbRowOpFactory.java
@@ -0,0 +1,138 @@
+/*****************************************************************
+ *   Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ ****************************************************************/
+
+package org.apache.cayenne.access.flush;
+
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+
+import org.apache.cayenne.CayenneRuntimeException;
+import org.apache.cayenne.ObjectId;
+import org.apache.cayenne.Persistent;
+import org.apache.cayenne.access.ObjectDiff;
+import org.apache.cayenne.access.ObjectStore;
+import org.apache.cayenne.access.flush.operation.DbRowOp;
+import org.apache.cayenne.access.flush.operation.DbRowOpType;
+import org.apache.cayenne.access.flush.operation.DeleteDbRowOp;
+import org.apache.cayenne.access.flush.operation.InsertDbRowOp;
+import org.apache.cayenne.access.flush.operation.UpdateDbRowOp;
+import org.apache.cayenne.exp.parser.ASTDbPath;
+import org.apache.cayenne.map.DbEntity;
+import org.apache.cayenne.map.EntityResolver;
+import org.apache.cayenne.map.ObjEntity;
+import org.apache.cayenne.reflect.ClassDescriptor;
+
+/**
+ * Factory that produces a collection of {@link DbRowOp} from given {@link ObjectDiff}.
+ *
+ * @since 4.2
+ */
+class DbRowOpFactory {
+
+    private final EntityResolver resolver;
+    private final ObjectStore store;
+    private final Set<ArcTarget> processedArcs;
+    private final Map<ObjectId, DbRowOp> dbRows;
+    private final RootRowOpProcessor rootRowOpProcessor;
+
+    private ClassDescriptor descriptor;
+    private Persistent object;
+    private ObjectDiff diff;
+
+    DbRowOpFactory(EntityResolver resolver, ObjectStore store, Set<ArcTarget> processedArcs) {
+        this.resolver = resolver;
+        this.store = store;
+        this.dbRows = new HashMap<>(4);
+        this.processedArcs = processedArcs;
+        this.rootRowOpProcessor = new RootRowOpProcessor(this);
+    }
+
+    private void udpateDiff(ObjectDiff diff) {
+        ObjectId id = (ObjectId)diff.getNodeId();
+        this.diff = diff;
+        this.descriptor = resolver.getClassDescriptor(id.getEntityName());
+        this.object = (Persistent) store.getNode(id);
+        this.dbRows.clear();
+    }
+
+    Collection<? extends DbRowOp> createRows(ObjectDiff diff) {
+        udpateDiff(diff);
+        DbEntity rootEntity = descriptor.getEntity().getDbEntity();
+        DbRowOp row = getOrCreate(rootEntity, object.getObjectId(), DbRowOpType.forObject(object));
+        rootRowOpProcessor.setDiff(diff);
+        row.accept(rootRowOpProcessor);
+        return dbRows.values();
+    }
+
+    @SuppressWarnings("unchecked")
+    <E extends DbRowOp> E get(ObjectId id) {
+        return Objects.requireNonNull((E) dbRows.get(id));
+    }
+
+    @SuppressWarnings("unchecked")
+    <E extends DbRowOp> E getOrCreate(DbEntity entity, ObjectId id, DbRowOpType type) {
+        return (E) dbRows.computeIfAbsent(id, nextId -> createRow(entity, id, type));
+    }
+
+    private DbRowOp createRow(DbEntity entity, ObjectId id, DbRowOpType type) {
+        switch (type) {
+            case INSERT:
+                return new InsertDbRowOp(object, entity, id);
+            case UPDATE:
+                return new UpdateDbRowOp(object, entity, id);
+            case DELETE:
+                return new DeleteDbRowOp(object, entity, id);
+        }
+        throw new CayenneRuntimeException("Unknown DbRowType '%s'", type);
+    }
+
+    ClassDescriptor getDescriptor() {
+        return descriptor;
+    }
+
+    Persistent getObject() {
+        return object;
+    }
+
+    ObjectStore getStore() {
+        return store;
+    }
+
+    ObjectDiff getDiff() {
+        return diff;
+    }
+
+    DbEntity getDbEntity(ObjectId id) {
+        String entityName = id.getEntityName();
+        if(entityName.startsWith(ASTDbPath.DB_PREFIX)) {
+            entityName = entityName.substring(ASTDbPath.DB_PREFIX.length());
+            return resolver.getDbEntity(entityName);
+        } else {
+            ObjEntity objEntity = resolver.getObjEntity(entityName);
+            return objEntity.getDbEntity();
+        }
+    }
+
+    Set<ArcTarget> getProcessedArcs() {
+        return processedArcs;
+    }
+}
diff --git a/cayenne-server/src/main/java/org/apache/cayenne/access/flush/DefaultDataDomainFlushAction.java b/cayenne-server/src/main/java/org/apache/cayenne/access/flush/DefaultDataDomainFlushAction.java
new file mode 100644
index 0000000..25511db
--- /dev/null
+++ b/cayenne-server/src/main/java/org/apache/cayenne/access/flush/DefaultDataDomainFlushAction.java
@@ -0,0 +1,210 @@
+/*****************************************************************
+ *   Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ ****************************************************************/
+
+package org.apache.cayenne.access.flush;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import org.apache.cayenne.CayenneRuntimeException;
+import org.apache.cayenne.ObjectId;
+import org.apache.cayenne.access.DataContext;
+import org.apache.cayenne.access.DataDomain;
+import org.apache.cayenne.access.ObjectDiff;
+import org.apache.cayenne.access.ObjectStore;
+import org.apache.cayenne.access.ObjectStoreGraphDiff;
+import org.apache.cayenne.access.OperationObserver;
+import org.apache.cayenne.access.flush.operation.DbRowOpMerger;
+import org.apache.cayenne.access.flush.operation.DbRowOpSorter;
+import org.apache.cayenne.access.flush.operation.DbRowOp;
+import org.apache.cayenne.access.flush.operation.DbRowOpVisitor;
+import org.apache.cayenne.graph.CompoundDiff;
+import org.apache.cayenne.graph.GraphDiff;
+import org.apache.cayenne.log.JdbcEventLogger;
+import org.apache.cayenne.map.EntityResolver;
+import org.apache.cayenne.query.Query;
+
+/**
+ * Default implementation of {@link DataDomainFlushAction}.
+ *
+ * @since 4.2
+ */
+public class DefaultDataDomainFlushAction implements DataDomainFlushAction {
+
+    protected final DataDomain dataDomain;
+    protected final DbRowOpSorter dbRowOpSorter;
+    protected final JdbcEventLogger jdbcEventLogger;
+    protected final OperationObserver observer;
+
+    protected DefaultDataDomainFlushAction(DataDomain dataDomain, DbRowOpSorter dbRowOpSorter, JdbcEventLogger jdbcEventLogger) {
+        this.dataDomain = dataDomain;
+        this.dbRowOpSorter = dbRowOpSorter;
+        this.jdbcEventLogger = jdbcEventLogger;
+        this.observer = new FlushObserver(jdbcEventLogger);
+    }
+
+    @Override
+    public GraphDiff flush(DataContext context, GraphDiff changes) {
+        CompoundDiff afterCommitDiff = new CompoundDiff();
+        if (changes == null) {
+            return afterCommitDiff;
+        }
+        if(!(changes instanceof ObjectStoreGraphDiff)) {
+            throw new CayenneRuntimeException("Instance of ObjectStoreGraphDiff expected, got %s", changes.getClass());
+        }
+
+        ObjectStore objectStore = context.getObjectStore();
+        ObjectStoreGraphDiff objectStoreGraphDiff = (ObjectStoreGraphDiff) changes;
+
+        List<DbRowOp> dbRowOps = createDbRowOps(objectStore, objectStoreGraphDiff);
+        updateObjectIds(dbRowOps);
+        List<DbRowOp> deduplicatedOps = mergeSameObjectIds(dbRowOps);
+        List<DbRowOp> sortedOps = sort(deduplicatedOps);
+        List<? extends Query> queries = createQueries(sortedOps);
+        executeQueries(queries);
+        createReplacementIds(objectStore, afterCommitDiff, sortedOps);
+        postprocess(context, objectStoreGraphDiff, afterCommitDiff, sortedOps);
+
+        return afterCommitDiff;
+    }
+
+    /**
+     * Create ops based on incoming graph changes
+     * @param objectStore originating object store
+     * @param changes object graph diff
+     * @return collection of {@link DbRowOp}
+     */
+    protected List<DbRowOp> createDbRowOps(ObjectStore objectStore, ObjectStoreGraphDiff changes) {
+        EntityResolver resolver = dataDomain.getEntityResolver();
+
+        Map<Object, ObjectDiff> changesByObjectId = changes.getChangesByObjectId();
+        List<DbRowOp> ops = new ArrayList<>(changesByObjectId.size());
+        Set<ArcTarget> processedArcs = new HashSet<>();
+
+        DbRowOpFactory factory = new DbRowOpFactory(resolver, objectStore, processedArcs);
+        changesByObjectId.forEach((obj, diff) -> ops.addAll(factory.createRows(diff)));
+
+        return ops;
+    }
+
+    /**
+     * Fill in replacement IDs' data for given operations
+     * @param dbRowOps collection of {@link DbRowOp}
+     */
+    protected void updateObjectIds(Collection<DbRowOp> dbRowOps) {
+        DbRowOpVisitor<Void> permIdVisitor = new PermanentObjectIdVisitor(dataDomain);
+        dbRowOps.forEach(row -> row.accept(permIdVisitor));
+    }
+
+    /**
+     * @param dbRowOps collection of {@link DbRowOp}
+     * @return collection of ops with merged duplicates
+     */
+    protected List<DbRowOp> mergeSameObjectIds(List<DbRowOp> dbRowOps) {
+        Map<ObjectId, DbRowOp> index = new HashMap<>(dbRowOps.size());
+        // new EffectiveOpId()
+        dbRowOps.forEach(row -> index.merge(row.getChangeId(), row, DbRowOpMerger.INSTANCE));
+        // reuse list
+        dbRowOps.clear();
+        dbRowOps.addAll(index.values());
+        return dbRowOps;
+    }
+
+    /**
+     * Sort all operations
+     * @param dbRowOps collection of {@link DbRowOp}
+     * @return sorted collection of operations
+     * @see DbRowOpSorter interface and it's default implementation
+     */
+    protected List<DbRowOp> sort(List<DbRowOp> dbRowOps) {
+        return dbRowOpSorter.sort(dbRowOps);
+    }
+
+    /**
+     *
+     * @param dbRowOps collection of {@link DbRowOp}
+     * @return collection of {@link Query} to perform
+     */
+    protected List<? extends Query> createQueries(List<DbRowOp> dbRowOps) {
+        QueryCreatorVisitor queryCreator = new QueryCreatorVisitor(dbRowOps.size());
+        dbRowOps.forEach(row -> row.accept(queryCreator));
+        return queryCreator.getQueryList();
+    }
+
+    /**
+     * Execute queries, grouping them by nodes
+     * @param queries to execute
+     */
+    protected void executeQueries(List<? extends Query> queries) {
+        EntityResolver entityResolver = dataDomain.getEntityResolver();
+        queries.stream()
+                .collect(Collectors.groupingBy(query
+                        -> dataDomain.lookupDataNode(query.getMetaData(entityResolver).getDataMap())))
+                .forEach((node, nodeQueries)
+                        -> node.performQueries(nodeQueries, observer));
+    }
+
+    /**
+     * Set final {@link ObjectId} for persistent objects
+     *
+     * @param store object store
+     * @param afterCommitDiff result graph diff
+     * @param dbRowOps collection of {@link DbRowOp}
+     */
+    protected void createReplacementIds(ObjectStore store, CompoundDiff afterCommitDiff, List<DbRowOp> dbRowOps) {
+        ReplacementIdVisitor visitor = new ReplacementIdVisitor(store, dataDomain.getEntityResolver(), afterCommitDiff);
+        dbRowOps.forEach(row -> row.accept(visitor));
+    }
+
+    /**
+     * Notify {@link ObjectStore} and it's data row cache about actual changes we performed.
+     *
+     * @param context originating context
+     * @param changes incoming diff
+     * @param afterCommitDiff resulting diff
+     * @param dbRowOps collection of {@link DbRowOp}
+     */
+    protected void postprocess(DataContext context, ObjectStoreGraphDiff changes, CompoundDiff afterCommitDiff, List<DbRowOp> dbRowOps) {
+        ObjectStore objectStore = context.getObjectStore();
+
+        PostprocessVisitor postprocessor = new PostprocessVisitor(context);
+        dbRowOps.forEach(row -> row.accept(postprocessor));
+
+        DataDomainIndirectDiffBuilder indirectDiffBuilder = new DataDomainIndirectDiffBuilder(context.getEntityResolver());
+        indirectDiffBuilder.processChanges(changes);
+
+        objectStore.getDataRowCache()
+                .processSnapshotChanges(
+                        objectStore,
+                        postprocessor.getUpdatedSnapshots(),
+                        postprocessor.getDeletedIds(),
+                        Collections.emptyList(),
+                        indirectDiffBuilder.getIndirectModifications()
+                );
+        objectStore.postprocessAfterCommit(afterCommitDiff);
+    }
+
+}
diff --git a/cayenne-server/src/main/java/org/apache/cayenne/map/EntitySorter.java b/cayenne-server/src/main/java/org/apache/cayenne/access/flush/DefaultDataDomainFlushActionFactory.java
similarity index 53%
copy from cayenne-server/src/main/java/org/apache/cayenne/map/EntitySorter.java
copy to cayenne-server/src/main/java/org/apache/cayenne/access/flush/DefaultDataDomainFlushActionFactory.java
index 5c7981e..47d08a3 100644
--- a/cayenne-server/src/main/java/org/apache/cayenne/map/EntitySorter.java
+++ b/cayenne-server/src/main/java/org/apache/cayenne/access/flush/DefaultDataDomainFlushActionFactory.java
@@ -17,37 +17,28 @@
  *  under the License.
  ****************************************************************/
 
-package org.apache.cayenne.map;
+package org.apache.cayenne.access.flush;
 
-import java.util.List;
+import org.apache.cayenne.access.DataDomain;
+import org.apache.cayenne.access.flush.operation.DbRowOpSorter;
+import org.apache.cayenne.di.Inject;
+import org.apache.cayenne.log.JdbcEventLogger;
 
 /**
- * Defines API for sorting of Cayenne entities based on their mutual dependencies.
- * 
- * @since 1.1
+ * Factory that produces {@link DefaultDataDomainFlushAction}.
+ *
+ * @since 4.2
  */
-public interface EntitySorter {
-
-    /**
-     * Sets EntityResolver for this sorter. All entities present in the resolver will be
-     * used to determine sort ordering.
-     * 
-     * @since 3.1
-     */
-    void setEntityResolver(EntityResolver resolver);
+public class DefaultDataDomainFlushActionFactory implements DataDomainFlushActionFactory {
 
-    /**
-     * Sorts a list of DbEntities.
-     */
-    void sortDbEntities(List<DbEntity> dbEntities, boolean deleteOrder);
+    @Inject
+    private DbRowOpSorter operationSorter;
 
-    /**
-     * Sorts a list of ObjEntities.
-     */
-    void sortObjEntities(List<ObjEntity> objEntities, boolean deleteOrder);
+    @Inject
+    private JdbcEventLogger jdbcEventLogger;
 
-    /**
-     * Sorts a list of objects belonging to the ObjEntity.
-     */
-    void sortObjectsForEntity(ObjEntity entity, List<?> objects, boolean deleteOrder);
+    @Override
+    public DataDomainFlushAction createFlushAction(DataDomain dataDomain) {
+        return new DefaultDataDomainFlushAction(dataDomain, operationSorter, jdbcEventLogger);
+    }
 }
diff --git a/cayenne-server/src/main/java/org/apache/cayenne/access/flush/EffectiveOpId.java b/cayenne-server/src/main/java/org/apache/cayenne/access/flush/EffectiveOpId.java
new file mode 100644
index 0000000..c906c08
--- /dev/null
+++ b/cayenne-server/src/main/java/org/apache/cayenne/access/flush/EffectiveOpId.java
@@ -0,0 +1,63 @@
+/*****************************************************************
+ *   Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ ****************************************************************/
+
+package org.apache.cayenne.access.flush;
+
+import java.util.Map;
+
+import org.apache.cayenne.ObjectId;
+
+/**
+ * Helper value-object class that used to compare operations by "effective" id (i.e. by id snapshot,
+ * that will include replacement id if any).
+ * This class is not used directly by Cayenne, it's designed to ease custom implementations.
+ */
+@SuppressWarnings("unused")
+public class EffectiveOpId {
+    private final String entityName;
+    private final Map<String, Object> snapshot;
+
+    public EffectiveOpId(ObjectId id) {
+        this.entityName = id.getEntityName();
+        this.snapshot = id.getIdSnapshot();
+    }
+
+    @Override
+    public boolean equals(Object o) {
+        if (this == o) return true;
+        if (o == null || getClass() != o.getClass()) return false;
+
+        if(snapshot.isEmpty()) {
+            return false;
+        }
+
+        EffectiveOpId that = (EffectiveOpId) o;
+
+        if (!entityName.equals(that.entityName)) return false;
+        return snapshot.equals(that.snapshot);
+
+    }
+
+    @Override
+    public int hashCode() {
+        int result = entityName.hashCode();
+        result = 31 * result + snapshot.hashCode();
+        return result;
+    }
+}
diff --git a/cayenne-server/src/main/java/org/apache/cayenne/access/flush/FlushObserver.java b/cayenne-server/src/main/java/org/apache/cayenne/access/flush/FlushObserver.java
new file mode 100644
index 0000000..ad79935
--- /dev/null
+++ b/cayenne-server/src/main/java/org/apache/cayenne/access/flush/FlushObserver.java
@@ -0,0 +1,152 @@
+/*****************************************************************
+ *   Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ ****************************************************************/
+
+package org.apache.cayenne.access.flush;
+
+import java.util.List;
+
+import org.apache.cayenne.CayenneRuntimeException;
+import org.apache.cayenne.DataRow;
+import org.apache.cayenne.ObjectId;
+import org.apache.cayenne.ResultIterator;
+import org.apache.cayenne.access.OperationObserver;
+import org.apache.cayenne.log.JdbcEventLogger;
+import org.apache.cayenne.map.DbAttribute;
+import org.apache.cayenne.query.BatchQuery;
+import org.apache.cayenne.query.InsertBatchQuery;
+import org.apache.cayenne.query.Query;
+import org.apache.cayenne.util.Util;
+
+/**
+ * @since 4.2
+ */
+class FlushObserver implements OperationObserver {
+
+    private JdbcEventLogger logger;
+
+    FlushObserver(JdbcEventLogger logger) {
+        this.logger = logger;
+    }
+
+    @Override
+    public void nextQueryException(Query query, Exception ex) {
+        throw new CayenneRuntimeException("Raising from query exception.", Util.unwindException(ex));
+    }
+
+    @Override
+    public void nextGlobalException(Exception ex) {
+        throw new CayenneRuntimeException("Raising from underlyingQueryEngine exception.", Util.unwindException(ex));
+    }
+
+    /**
+     * Processes generated keys.
+     */
+    @Override
+    @SuppressWarnings("unchecked")
+    public void nextGeneratedRows(Query query, ResultIterator<?> keysIterator, ObjectId idToUpdate) {
+
+        // read and close the iterator before doing anything else
+        List<DataRow> keys;
+        try {
+            keys = (List<DataRow>) keysIterator.allRows();
+        } finally {
+            keysIterator.close();
+        }
+
+        if (!(query instanceof InsertBatchQuery)) {
+            throw new CayenneRuntimeException("Generated keys only supported for InsertBatchQuery, instead got %s", query);
+        }
+
+        if (idToUpdate == null || !idToUpdate.isTemporary()) {
+            // why would this happen?
+            return;
+        }
+
+        if (keys.size() != 1) {
+            throw new CayenneRuntimeException("One and only one PK row is expected, instead got %d",  keys.size());
+        }
+
+        DataRow key = keys.get(0);
+
+        // empty key?
+        if (key.size() == 0) {
+            throw new CayenneRuntimeException("Empty key generated.");
+        }
+
+        // determine DbAttribute name...
+
+        // As of now (01/2005) all tested drivers don't provide decent
+        // descriptors of identity result sets, so a data row will contain garbage labels.
+        // Also most DBs only support one autogenerated key per table...
+        // So here we will have to infer the key name and currently will only support a single column...
+        if (key.size() > 1) {
+            throw new CayenneRuntimeException("Only a single column autogenerated PK is supported. "
+                    + "Generated key: %s", key);
+        }
+
+        BatchQuery batch = (BatchQuery) query;
+        for (DbAttribute attribute : batch.getDbEntity().getGeneratedAttributes()) {
+
+            // batch can have generated attributes that are not PKs, e.g.
+            // columns with DB DEFAULT values. Ignore those.
+            if (attribute.isPrimaryKey()) {
+                Object value = key.values().iterator().next();
+
+                // Log the generated PK
+                logger.logGeneratedKey(attribute, value);
+
+                // I guess we should override any existing value,
+                // as generated key is the latest thing that exists in the DB.
+                idToUpdate.getReplacementIdMap().put(attribute.getName(), value);
+                break;
+            }
+        }
+    }
+
+    public void setJdbcEventLogger(JdbcEventLogger logger) {
+        this.logger = logger;
+    }
+
+    public JdbcEventLogger getJdbcEventLogger() {
+        return this.logger;
+    }
+
+    @Override
+    public void nextBatchCount(Query query, int[] resultCount) {
+    }
+
+    @Override
+    public void nextCount(Query query, int resultCount) {
+    }
+
+    @Override
+    public void nextRows(Query query, List<?> dataRows) {
+    }
+
+    @Override
+    @SuppressWarnings("rawtypes")
+    public void nextRows(Query q, ResultIterator it) {
+        throw new UnsupportedOperationException("'nextDataRows(Query,ResultIterator)' is unsupported (and unexpected) on commit.");
+    }
+
+    @Override
+    public boolean isIteratedResult() {
+        return false;
+    }
+}
diff --git a/cayenne-server/src/main/java/org/apache/cayenne/access/flush/ObjectIdValueSupplier.java b/cayenne-server/src/main/java/org/apache/cayenne/access/flush/ObjectIdValueSupplier.java
new file mode 100644
index 0000000..6d4fb11
--- /dev/null
+++ b/cayenne-server/src/main/java/org/apache/cayenne/access/flush/ObjectIdValueSupplier.java
@@ -0,0 +1,82 @@
+/*****************************************************************
+ *   Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ ****************************************************************/
+
+package org.apache.cayenne.access.flush;
+
+import java.util.Objects;
+import java.util.function.Supplier;
+
+import org.apache.cayenne.ObjectId;
+
+/**
+ * Deferred value extracted from ObjectId
+ *
+ * @since 4.2
+ */
+class ObjectIdValueSupplier implements Supplier<Object> {
+
+    private final ObjectId id;
+    private final String attribute;
+
+    static Object getFor(ObjectId id, String attribute) {
+        // resolve eagerly, if value is already present
+        // TODO: what if this is a meaningful part of an ID and it will change?
+        Object value = id.getIdSnapshot().get(attribute);
+        if(value != null) {
+            return value;
+        }
+        return new ObjectIdValueSupplier(id, attribute);
+    }
+
+    private ObjectIdValueSupplier(ObjectId id, String attribute) {
+        this.id = Objects.requireNonNull(id);
+        this.attribute = Objects.requireNonNull(attribute);
+    }
+
+    @Override
+    public Object get() {
+        return id.getIdSnapshot().get(attribute);
+    }
+
+    @Override
+    public boolean equals(Object o) {
+        if (this == o) {
+            return true;
+        }
+        if (o == null || getClass() != o.getClass()) {
+            return false;
+        }
+
+        ObjectIdValueSupplier that = (ObjectIdValueSupplier) o;
+        if (!id.equals(that.id)) {
+            return false;
+        }
+        return attribute.equals(that.attribute);
+    }
+
+    @Override
+    public int hashCode() {
+        return 31 * id.hashCode() + attribute.hashCode();
+    }
+
+    @Override
+    public String toString() {
+        return "{id=" + id + ", attr=" + attribute + '}';
+    }
+}
diff --git a/cayenne-server/src/main/java/org/apache/cayenne/access/flush/OptimisticLockQualifierBuilder.java b/cayenne-server/src/main/java/org/apache/cayenne/access/flush/OptimisticLockQualifierBuilder.java
new file mode 100644
index 0000000..1aa7b19
--- /dev/null
+++ b/cayenne-server/src/main/java/org/apache/cayenne/access/flush/OptimisticLockQualifierBuilder.java
@@ -0,0 +1,85 @@
+/*****************************************************************
+ *   Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ ****************************************************************/
+
+package org.apache.cayenne.access.flush;
+
+import org.apache.cayenne.ObjectId;
+import org.apache.cayenne.access.ObjectDiff;
+import org.apache.cayenne.access.flush.operation.DbRowOpWithQualifier;
+import org.apache.cayenne.map.DbAttribute;
+import org.apache.cayenne.map.DbJoin;
+import org.apache.cayenne.map.DbRelationship;
+import org.apache.cayenne.map.ObjAttribute;
+import org.apache.cayenne.map.ObjRelationship;
+import org.apache.cayenne.reflect.AttributeProperty;
+import org.apache.cayenne.reflect.PropertyVisitor;
+import org.apache.cayenne.reflect.ToManyProperty;
+import org.apache.cayenne.reflect.ToOneProperty;
+
+/**
+ * {@link PropertyVisitor} that builds optimistic lock qualifier for given db change.
+ *
+ * @since 4.2
+ */
+class OptimisticLockQualifierBuilder implements PropertyVisitor {
+    private final DbRowOpWithQualifier dbRow;
+    private final ObjectDiff diff;
+
+    OptimisticLockQualifierBuilder(DbRowOpWithQualifier dbRow, ObjectDiff diff) {
+        this.dbRow = dbRow;
+        this.diff = diff;
+    }
+
+    @Override
+    public boolean visitAttribute(AttributeProperty property) {
+        ObjAttribute attribute = property.getAttribute();
+        DbAttribute dbAttribute = attribute.getDbAttribute();
+        if (attribute.isUsedForLocking() && dbAttribute.getEntity() == dbRow.getEntity()) {
+            dbRow.getQualifier()
+                    .addAdditionalQualifier(dbAttribute, diff.getSnapshotValue(property.getName()), true);
+
+        } else {
+            // unimplemented case, see CAY-2560 for details.
+            // we can't grab sub entity row here as no good accessor for this implemented.
+        }
+        return true;
+    }
+
+    @Override
+    public boolean visitToOne(ToOneProperty property) {
+        ObjRelationship relationship = property.getRelationship();
+        if(relationship.isUsedForLocking()) {
+            ObjectId value = diff.getArcSnapshotValue(property.getName());
+            DbRelationship dbRelationship = relationship.getDbRelationships().get(0);
+            for(DbJoin join : dbRelationship.getJoins()) {
+                DbAttribute source = join.getSource();
+                if(!source.isPrimaryKey()) {
+                    dbRow.getQualifier()
+                            .addAdditionalQualifier(source, ObjectIdValueSupplier.getFor(value, join.getTargetName()), true);
+                }
+            }
+        }
+        return true;
+    }
+
+    @Override
+    public boolean visitToMany(ToManyProperty property) {
+        return true;
+    }
+}
diff --git a/cayenne-server/src/main/java/org/apache/cayenne/access/flush/PermanentObjectIdVisitor.java b/cayenne-server/src/main/java/org/apache/cayenne/access/flush/PermanentObjectIdVisitor.java
new file mode 100644
index 0000000..a359bee
--- /dev/null
+++ b/cayenne-server/src/main/java/org/apache/cayenne/access/flush/PermanentObjectIdVisitor.java
@@ -0,0 +1,139 @@
+/*****************************************************************
+ *   Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ ****************************************************************/
+
+package org.apache.cayenne.access.flush;
+
+import java.util.Map;
+
+import org.apache.cayenne.CayenneRuntimeException;
+import org.apache.cayenne.ObjectId;
+import org.apache.cayenne.access.DataDomain;
+import org.apache.cayenne.access.DataNode;
+import org.apache.cayenne.access.flush.operation.DbRowOpVisitor;
+import org.apache.cayenne.access.flush.operation.InsertDbRowOp;
+import org.apache.cayenne.dba.PkGenerator;
+import org.apache.cayenne.exp.parser.ASTDbPath;
+import org.apache.cayenne.map.DbAttribute;
+import org.apache.cayenne.map.DbEntity;
+import org.apache.cayenne.map.EntityResolver;
+import org.apache.cayenne.map.ObjAttribute;
+import org.apache.cayenne.map.ObjEntity;
+import org.apache.cayenne.reflect.ClassDescriptor;
+
+/**
+ * Visitor that fills replacement map of {@link ObjectId}s of inserted objects.
+ *
+ * @since 4.2
+ */
+class PermanentObjectIdVisitor implements DbRowOpVisitor<Void> {
+
+    private final DataDomain dataDomain;
+    private final EntityResolver resolver;
+
+    private ClassDescriptor lastDescriptor;
+    private ObjEntity lastObjEntity;
+    private DbEntity lastDbEntity;
+    private DataNode lastNode;
+    private String lastEntityName;
+
+    PermanentObjectIdVisitor(DataDomain dataDomain) {
+        this.dataDomain = dataDomain;
+        this.resolver = dataDomain.getEntityResolver();
+    }
+
+    @Override
+    public Void visitInsert(InsertDbRowOp dbRow) {
+        ObjectId id = dbRow.getChangeId();
+        if (id == null || !id.isTemporary()) {
+            return null;
+        }
+
+        if((lastObjEntity == null && lastDbEntity == null) || !id.getEntityName().equals(lastEntityName)) {
+            lastEntityName = id.getEntityName();
+            if(lastEntityName.startsWith(ASTDbPath.DB_PREFIX)) {
+                lastDbEntity = resolver.getDbEntity(lastEntityName.substring(ASTDbPath.DB_PREFIX.length()));
+                lastObjEntity = null;
+                lastDescriptor = null;
+                lastNode = dataDomain.lookupDataNode(lastDbEntity.getDataMap());
+            } else {
+                lastObjEntity = resolver.getObjEntity(id.getEntityName());
+                lastDbEntity = lastObjEntity.getDbEntity();
+                lastDescriptor = resolver.getClassDescriptor(lastObjEntity.getName());
+                lastNode = dataDomain.lookupDataNode(lastObjEntity.getDataMap());
+            }
+        }
+
+        createPermanentId(dbRow);
+        return null;
+    }
+
+    private void createPermanentId(InsertDbRowOp dbRow) {
+        ObjectId id = dbRow.getChangeId();
+        boolean supportsGeneratedKeys = lastNode.getAdapter().supportsGeneratedKeys();
+        PkGenerator pkGenerator = lastNode.getAdapter().getPkGenerator();
+
+        // modify replacement id directly...
+        Map<String, Object> idMap = id.getReplacementIdMap();
+
+        boolean autoPkDone = false;
+
+        for (DbAttribute dbAttr : lastDbEntity.getPrimaryKeys()) {
+            String dbAttrName = dbAttr.getName();
+
+            if (idMap.containsKey(dbAttrName)) {
+                continue;
+            }
+
+            // handle meaningful PK
+            if(lastObjEntity != null) {
+                ObjAttribute objAttr = lastObjEntity.getAttributeForDbAttribute(dbAttr);
+                if (objAttr != null) {
+                    Object value = lastDescriptor.getProperty(objAttr.getName()).readPropertyDirectly(dbRow.getObject());
+                    if (value != null) {
+                        // primitive 0 has to be treated as NULL, or otherwise we can't generate PK for POJO's
+                        Class<?> javaClass = objAttr.getJavaClass();
+                        if (!javaClass.isPrimitive() || !(value instanceof Number) || ((Number) value).intValue() != 0) {
+                            idMap.put(dbAttrName, value);
+                            continue;
+                        }
+                    }
+                }
+            }
+
+            // skip db-generated
+            if (supportsGeneratedKeys && dbAttr.isGenerated()) {
+                continue;
+            }
+
+            // only a single key can be generated from DB... if this is done already in this loop, we must bail out.
+            if (autoPkDone) {
+                throw new CayenneRuntimeException("Primary Key autogeneration only works for a single attribute.");
+            }
+
+            // finally, use database generation mechanism
+            try {
+                Object pkValue = pkGenerator.generatePk(lastNode, dbAttr);
+                idMap.put(dbAttrName, pkValue);
+                autoPkDone = true;
+            } catch (Exception ex) {
+                throw new CayenneRuntimeException("Error generating PK: %s", ex,  ex.getMessage());
+            }
+        }
+    }
+}
diff --git a/cayenne-server/src/main/java/org/apache/cayenne/access/flush/PostprocessVisitor.java b/cayenne-server/src/main/java/org/apache/cayenne/access/flush/PostprocessVisitor.java
new file mode 100644
index 0000000..35a55a5
--- /dev/null
+++ b/cayenne-server/src/main/java/org/apache/cayenne/access/flush/PostprocessVisitor.java
@@ -0,0 +1,143 @@
+/*****************************************************************
+ *   Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ ****************************************************************/
+
+package org.apache.cayenne.access.flush;
+
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.Map;
+
+import org.apache.cayenne.DataObject;
+import org.apache.cayenne.DataRow;
+import org.apache.cayenne.ObjectId;
+import org.apache.cayenne.access.DataContext;
+import org.apache.cayenne.access.flush.operation.DbRowOp;
+import org.apache.cayenne.access.flush.operation.DbRowOpVisitor;
+import org.apache.cayenne.access.flush.operation.DeleteDbRowOp;
+import org.apache.cayenne.access.flush.operation.InsertDbRowOp;
+import org.apache.cayenne.access.flush.operation.UpdateDbRowOp;
+import org.apache.cayenne.exp.parser.ASTDbPath;
+import org.apache.cayenne.reflect.ArcProperty;
+import org.apache.cayenne.reflect.ClassDescriptor;
+import org.apache.cayenne.reflect.ToManyMapProperty;
+
+/**
+ * @since 4.2
+ */
+class PostprocessVisitor implements DbRowOpVisitor<Void> {
+
+    private final DataContext context;
+    private Map<ObjectId, DataRow> updatedSnapshots;
+    private Collection<ObjectId> deletedIds;
+
+    PostprocessVisitor(DataContext context) {
+        this.context = context;
+    }
+
+    @Override
+    public Void visitInsert(InsertDbRowOp dbRow) {
+        processObjectChange(dbRow);
+        return null;
+    }
+
+    @Override
+    public Void visitUpdate(UpdateDbRowOp dbRow) {
+        processObjectChange(dbRow);
+        return null;
+    }
+
+    private void processObjectChange(DbRowOp dbRow) {
+        if (dbRow.getChangeId().getEntityName().startsWith(ASTDbPath.DB_PREFIX)) {
+            return;
+        }
+
+        DataRow dataRow = context.currentSnapshot(dbRow.getObject());
+
+        if (dbRow.getObject() instanceof DataObject) {
+            DataObject dataObject = (DataObject) dbRow.getObject();
+            dataRow.setReplacesVersion(dataObject.getSnapshotVersion());
+            dataObject.setSnapshotVersion(dataRow.getVersion());
+        }
+
+        if (updatedSnapshots == null) {
+            updatedSnapshots = new HashMap<>();
+        }
+        updatedSnapshots.put(dbRow.getObject().getObjectId(), dataRow);
+
+        // update Map reverse relationships
+        ClassDescriptor descriptor = context.getEntityResolver().getClassDescriptor(dbRow.getChangeId().getEntityName());
+        for (ArcProperty arc : descriptor.getMapArcProperties()) {
+            ToManyMapProperty reverseArc = (ToManyMapProperty) arc.getComplimentaryReverseArc();
+
+            // must resolve faults... hopefully for to-one this will not cause extra fetches...
+            Object source = arc.readProperty(dbRow.getObject());
+            if (source != null && !reverseArc.isFault(source)) {
+                remapTarget(reverseArc, source, dbRow.getObject());
+            }
+        }
+    }
+
+    @Override
+    public Void visitDelete(DeleteDbRowOp dbRow) {
+        if (dbRow.getChangeId().getEntityName().startsWith(ASTDbPath.DB_PREFIX)) {
+            return null;
+        }
+        if (deletedIds == null) {
+            deletedIds = new HashSet<>();
+        }
+        deletedIds.add(dbRow.getChangeId());
+        return null;
+    }
+
+    Collection<ObjectId> getDeletedIds() {
+        return deletedIds == null ? Collections.emptyList() : deletedIds;
+    }
+
+    Map<ObjectId, DataRow> getUpdatedSnapshots() {
+        return updatedSnapshots == null ? Collections.emptyMap() : updatedSnapshots;
+    }
+
+    private void remapTarget(ToManyMapProperty property, Object source, Object target) {
+        @SuppressWarnings("unchecked")
+        Map<Object, Object> map = (Map<Object, Object>) property.readProperty(source);
+        Object newKey = property.getMapKey(target);
+        Object currentValue = map.get(newKey);
+
+        if (currentValue == target) {
+            // nothing to do
+            return;
+        }
+        // else - do not check for conflicts here (i.e. another object mapped for the same key), as we have no control
+        // of the order in which this method is called, so another object may be remapped later by the caller
+        // must do a slow map scan to ensure the object is not mapped under a different key...
+        Iterator<Map.Entry<Object, Object>> it = map.entrySet().iterator();
+        while (it.hasNext()) {
+            Map.Entry<Object, Object> e = it.next();
+            if (e.getValue() == target) {
+                it.remove();
+                break;
+            }
+        }
+
+        map.put(newKey, target);
+    }
+}
diff --git a/cayenne-server/src/main/java/org/apache/cayenne/access/flush/QueryCreatorVisitor.java b/cayenne-server/src/main/java/org/apache/cayenne/access/flush/QueryCreatorVisitor.java
new file mode 100644
index 0000000..d1e2f15
--- /dev/null
+++ b/cayenne-server/src/main/java/org/apache/cayenne/access/flush/QueryCreatorVisitor.java
@@ -0,0 +1,121 @@
+/*****************************************************************
+ *   Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ ****************************************************************/
+
+package org.apache.cayenne.access.flush;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import org.apache.cayenne.access.flush.operation.DbRowOp;
+import org.apache.cayenne.access.flush.operation.DbRowOpVisitor;
+import org.apache.cayenne.access.flush.operation.DeleteDbRowOp;
+import org.apache.cayenne.access.flush.operation.InsertDbRowOp;
+import org.apache.cayenne.access.flush.operation.UpdateDbRowOp;
+import org.apache.cayenne.query.BatchQuery;
+import org.apache.cayenne.query.DeleteBatchQuery;
+import org.apache.cayenne.query.InsertBatchQuery;
+import org.apache.cayenne.query.UpdateBatchQuery;
+
+/**
+ * Visitor that creates batch queries.
+ * It relies on correct sorting of {@link DbRowOp} to just linearly scan of rows and put them in batches.
+ *
+ * @since 4.2
+ */
+// TODO: pass DbRowOp as argument directly to batch...
+class QueryCreatorVisitor implements DbRowOpVisitor<Void> {
+
+    private final List<BatchQuery> queryList;
+    private final int batchSize;
+    private DbRowOp lastRow = null;
+    private BatchQuery lastBatch = null;
+
+    QueryCreatorVisitor(int size) {
+        // these sizes are pretty much random ...
+        this.queryList = new ArrayList<>(Math.min(4, size / 2));
+        this.batchSize = Math.min(2, size / 3);
+    }
+
+    List<BatchQuery> getQueryList() {
+        return queryList;
+    }
+
+    @Override
+    public Void visitInsert(InsertDbRowOp dbRow) {
+        InsertBatchQuery query;
+        if(lastRow == null || !lastRow.isSameBatch(dbRow)) {
+            query = new InsertBatchQuery(dbRow.getEntity(), batchSize);
+            queryList.add(query);
+            lastBatch = query;
+        } else {
+            query = (InsertBatchQuery)lastBatch;
+        }
+        query.add(dbRow.getValues().getSnapshot(), dbRow.getChangeId());
+        lastRow = dbRow;
+        return null;
+    }
+
+    @Override
+    public Void visitUpdate(UpdateDbRowOp dbRow) {
+        // skip empty update..
+        if(dbRow.getValues().isEmpty()) {
+            return null;
+        }
+
+        UpdateBatchQuery query;
+        if(lastRow == null || !lastRow.isSameBatch(dbRow)) {
+            query = new UpdateBatchQuery(
+                    dbRow.getEntity(),
+                    dbRow.getQualifier().getQualifierAttributes(),
+                    dbRow.getValues().getUpdatedAttributes(),
+                    dbRow.getQualifier().getNullQualifierNames(),
+                    batchSize
+            );
+            query.setUsingOptimisticLocking(dbRow.getQualifier().isUsingOptimisticLocking());
+            queryList.add(query);
+            lastBatch = query;
+        } else {
+            query = (UpdateBatchQuery)lastBatch;
+        }
+        query.add(dbRow.getQualifier().getSnapshot(), dbRow.getValues().getSnapshot(), dbRow.getChangeId());
+        lastRow = dbRow;
+        return null;
+    }
+
+    @Override
+    public Void visitDelete(DeleteDbRowOp dbRow) {
+        DeleteBatchQuery query;
+        if(lastRow == null || !lastRow.isSameBatch(dbRow)) {
+            query = new DeleteBatchQuery(
+                    dbRow.getEntity(),
+                    dbRow.getQualifier().getQualifierAttributes(),
+                    dbRow.getQualifier().getNullQualifierNames(),
+                    batchSize
+            );
+            query.setUsingOptimisticLocking(dbRow.getQualifier().isUsingOptimisticLocking());
+            queryList.add(query);
+            lastBatch = query;
+        } else {
+            query = (DeleteBatchQuery)lastBatch;
+        }
+        query.add(dbRow.getQualifier().getSnapshot());
+        lastRow = dbRow;
+        return null;
+    }
+}
diff --git a/cayenne-server/src/main/java/org/apache/cayenne/access/flush/ReplacementIdVisitor.java b/cayenne-server/src/main/java/org/apache/cayenne/access/flush/ReplacementIdVisitor.java
new file mode 100644
index 0000000..405eec6
--- /dev/null
+++ b/cayenne-server/src/main/java/org/apache/cayenne/access/flush/ReplacementIdVisitor.java
@@ -0,0 +1,106 @@
+/*****************************************************************
+ *   Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ ****************************************************************/
+
+package org.apache.cayenne.access.flush;
+
+import java.util.Map;
+import java.util.function.Supplier;
+
+import org.apache.cayenne.CayenneRuntimeException;
+import org.apache.cayenne.ObjectId;
+import org.apache.cayenne.Persistent;
+import org.apache.cayenne.access.ObjectStore;
+import org.apache.cayenne.access.flush.operation.DbRowOp;
+import org.apache.cayenne.access.flush.operation.DbRowOpVisitor;
+import org.apache.cayenne.access.flush.operation.InsertDbRowOp;
+import org.apache.cayenne.access.flush.operation.UpdateDbRowOp;
+import org.apache.cayenne.exp.parser.ASTDbPath;
+import org.apache.cayenne.graph.CompoundDiff;
+import org.apache.cayenne.graph.NodeIdChangeOperation;
+import org.apache.cayenne.map.EntityResolver;
+import org.apache.cayenne.reflect.AttributeProperty;
+
+/**
+ * @since 4.2
+ */
+class ReplacementIdVisitor implements DbRowOpVisitor<Void> {
+
+    private final ObjectStore store;
+    private final EntityResolver resolver;
+    private final CompoundDiff result;
+
+    ReplacementIdVisitor(ObjectStore store, EntityResolver resolver, CompoundDiff result) {
+        this.store = store;
+        this.resolver = resolver;
+        this.result = result;
+    }
+
+    @Override
+    public Void visitInsert(InsertDbRowOp dbRow) {
+        updateId(dbRow);
+        dbRow.getValues().getFlattenedIds().forEach((path, id) -> {
+            if(id.isTemporary() && id.isReplacementIdAttached()) {
+                // resolve lazy suppliers
+                for (Map.Entry<String, Object> next : id.getReplacementIdMap().entrySet()) {
+                    if (next.getValue() instanceof Supplier) {
+                        next.setValue(((Supplier) next.getValue()).get());
+                    }
+                }
+                store.markFlattenedPath(dbRow.getChangeId(), path, id.createReplacementId());
+            } else {
+                throw new CayenneRuntimeException("PK for flattened path '%s' of object %s is not set during insert."
+                        , path, dbRow.getObject());
+            }
+        });
+        return null;
+    }
+
+    @Override
+    public Void visitUpdate(UpdateDbRowOp dbRow) {
+        updateId(dbRow);
+        return null;
+    }
+
+    private void updateId(DbRowOp dbRow) {
+        ObjectId id = dbRow.getChangeId();
+        if (!id.isReplacementIdAttached()) {
+            if (id.isTemporary()) {
+                throw new CayenneRuntimeException("PK for the object %s is not set during insert.", dbRow.getObject());
+            }
+            return;
+        }
+
+        Persistent object = dbRow.getObject();
+        Map<String, Object> replacement = id.getReplacementIdMap();
+        ObjectId replacementId = id.createReplacementId();
+        if (object.getObjectId() == id && !replacementId.getEntityName().startsWith(ASTDbPath.DB_PREFIX)) {
+            object.setObjectId(replacementId);
+            // update meaningful PKs
+            for (AttributeProperty property: resolver.getClassDescriptor(replacementId.getEntityName()).getIdProperties()) {
+                if(property.getAttribute() != null) {
+                    Object value = replacement.get(property.getAttribute().getDbAttributeName());
+                    if (value != null) {
+                        property.writePropertyDirectly(object, null, value);
+                    }
+                }
+            }
+            result.add(new NodeIdChangeOperation(id, replacementId));
+        }
+    }
+}
diff --git a/cayenne-server/src/main/java/org/apache/cayenne/access/flush/RootRowOpProcessor.java b/cayenne-server/src/main/java/org/apache/cayenne/access/flush/RootRowOpProcessor.java
new file mode 100644
index 0000000..edc919c
--- /dev/null
+++ b/cayenne-server/src/main/java/org/apache/cayenne/access/flush/RootRowOpProcessor.java
@@ -0,0 +1,82 @@
+/*****************************************************************
+ *   Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ ****************************************************************/
+
+package org.apache.cayenne.access.flush;
+
+import java.util.Collection;
+
+import org.apache.cayenne.CayenneRuntimeException;
+import org.apache.cayenne.ObjectId;
+import org.apache.cayenne.access.ObjectDiff;
+import org.apache.cayenne.access.flush.operation.DbRowOpType;
+import org.apache.cayenne.access.flush.operation.DbRowOpVisitor;
+import org.apache.cayenne.access.flush.operation.DeleteDbRowOp;
+import org.apache.cayenne.access.flush.operation.InsertDbRowOp;
+import org.apache.cayenne.access.flush.operation.UpdateDbRowOp;
+import org.apache.cayenne.map.ObjEntity;
+
+/**
+ * Visitor that runs all required actions based on operation type.
+ * <p>
+ * E.g. it creates values for insert and update, it fills optimistic lock qualifier for update and delete, etc.
+ *
+ * @since 4.2
+ */
+class RootRowOpProcessor implements DbRowOpVisitor<Void> {
+    private final DbRowOpFactory dbRowOpFactory;
+    private ObjectDiff diff;
+
+    RootRowOpProcessor(DbRowOpFactory dbRowOpFactory) {
+        this.dbRowOpFactory = dbRowOpFactory;
+    }
+
+    void setDiff(ObjectDiff diff) {
+        this.diff = diff;
+    }
+
+    @Override
+    public Void visitInsert(InsertDbRowOp dbRow) {
+        diff.apply(new ValuesCreationHandler(dbRowOpFactory, DbRowOpType.INSERT));
+        return null;
+    }
+
+    @Override
+    public Void visitUpdate(UpdateDbRowOp dbRow) {
+        diff.apply(new ValuesCreationHandler(dbRowOpFactory, DbRowOpType.UPDATE));
+        if (dbRowOpFactory.getDescriptor().getEntity().getDeclaredLockType() == ObjEntity.LOCK_TYPE_OPTIMISTIC) {
+            dbRowOpFactory.getDescriptor().visitAllProperties(new OptimisticLockQualifierBuilder(dbRow, diff));
+        }
+        return null;
+    }
+
+    @Override
+    public Void visitDelete(DeleteDbRowOp dbRow) {
+        if (dbRowOpFactory.getDescriptor().getEntity().isReadOnly()) {
+            throw new CayenneRuntimeException("Attempt to modify object(s) mapped to a read-only entity: '%s'. " +
+                    "Can't commit changes.", dbRowOpFactory.getDescriptor().getEntity().getName());
+        }
+        diff.apply(new ArcValuesCreationHandler(dbRowOpFactory, DbRowOpType.DELETE));
+        Collection<ObjectId> flattenedIds = dbRowOpFactory.getStore().getFlattenedIds(dbRow.getChangeId());
+        flattenedIds.forEach(id -> dbRowOpFactory.getOrCreate(dbRowOpFactory.getDbEntity(id), id, DbRowOpType.DELETE));
+        if (dbRowOpFactory.getDescriptor().getEntity().getDeclaredLockType() == ObjEntity.LOCK_TYPE_OPTIMISTIC) {
+            dbRowOpFactory.getDescriptor().visitAllProperties(new OptimisticLockQualifierBuilder(dbRow, diff));
+        }
+        return null;
+    }
+}
diff --git a/cayenne-server/src/main/java/org/apache/cayenne/access/flush/ValuesCreationHandler.java b/cayenne-server/src/main/java/org/apache/cayenne/access/flush/ValuesCreationHandler.java
new file mode 100644
index 0000000..a9adacc
--- /dev/null
+++ b/cayenne-server/src/main/java/org/apache/cayenne/access/flush/ValuesCreationHandler.java
@@ -0,0 +1,77 @@
+/*****************************************************************
+ *   Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ ****************************************************************/
+
+package org.apache.cayenne.access.flush;
+
+import org.apache.cayenne.CayenneRuntimeException;
+import org.apache.cayenne.ObjectId;
+import org.apache.cayenne.access.flush.operation.DbRowOpType;
+import org.apache.cayenne.access.flush.operation.DbRowOpWithValues;
+import org.apache.cayenne.map.DbAttribute;
+import org.apache.cayenne.map.DbEntity;
+import org.apache.cayenne.map.ObjAttribute;
+import org.apache.cayenne.map.ObjEntity;
+
+/**
+ * Extension of {@link ArcValuesCreationHandler} that also tracks property changes.
+ *
+ * @since 4.2
+ */
+class ValuesCreationHandler extends ArcValuesCreationHandler {
+
+    ValuesCreationHandler(DbRowOpFactory factory, DbRowOpType defaultType) {
+        super(factory, defaultType);
+    }
+
+    @Override
+    public void nodePropertyChanged(Object nodeId, String property, Object oldValue, Object newValue) {
+        ObjectId id = (ObjectId)nodeId;
+        ObjEntity entity = factory.getDescriptor().getEntity();
+        if(entity.isReadOnly()) {
+            throw new CayenneRuntimeException("Attempt to modify object(s) mapped to a read-only entity: '%s'. " +
+                    "Can't commit changes.", entity.getName());
+        }
+        ObjAttribute attribute = entity.getAttribute(property);
+        DbEntity dbEntity = entity.getDbEntity();
+
+        if(attribute.isFlattened()) {
+            // get target row ID
+            id = processFlattenedPath(id, null, dbEntity, attribute.getDbAttributePath(), newValue != null);
+        }
+
+        if(id == null) {
+            // some extra safety, shouldn't happen
+            throw new CayenneRuntimeException("Unable to resolve DB row PK for object's %s update of property '%s'"
+                    , nodeId, property);
+        }
+
+        DbAttribute dbAttribute = attribute.getDbAttribute();
+        if(dbAttribute.isPrimaryKey()) {
+            if(!(newValue instanceof Number) || ((Number) newValue).longValue() != 0) {
+                id.getReplacementIdMap().put(dbAttribute.getName(), newValue);
+            }
+        }
+
+        DbRowOpWithValues dbRow = factory.get(id);
+        if(dbRow != null) {
+            dbRow.getValues().addValue(dbAttribute, newValue);
+        }
+    }
+
+}
diff --git a/cayenne-server/src/main/java/org/apache/cayenne/access/flush/operation/BaseDbRowOp.java b/cayenne-server/src/main/java/org/apache/cayenne/access/flush/operation/BaseDbRowOp.java
new file mode 100644
index 0000000..648affb
--- /dev/null
+++ b/cayenne-server/src/main/java/org/apache/cayenne/access/flush/operation/BaseDbRowOp.java
@@ -0,0 +1,77 @@
+/*****************************************************************
+ *   Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ ****************************************************************/
+
+package org.apache.cayenne.access.flush.operation;
+
+import java.util.Objects;
+
+import org.apache.cayenne.ObjectId;
+import org.apache.cayenne.Persistent;
+import org.apache.cayenne.map.DbEntity;
+
+/**
+ * @since 4.2
+ */
+public abstract class BaseDbRowOp implements DbRowOp {
+
+    protected final Persistent object;
+    protected final DbEntity entity;
+    // Can be ObjEntity id or a DB row id for flattened rows
+    protected final ObjectId changeId;
+
+    protected BaseDbRowOp(Persistent object, DbEntity entity, ObjectId id) {
+        this.object = Objects.requireNonNull(object);
+        this.entity = Objects.requireNonNull(entity);
+        this.changeId = Objects.requireNonNull(id);
+    }
+
+    @Override
+    public DbEntity getEntity() {
+        return entity;
+    }
+
+    @Override
+    public ObjectId getChangeId() {
+        return changeId;
+    }
+
+    @Override
+    public Persistent getObject() {
+        return object;
+    }
+
+    @Override
+    public boolean equals(Object o) {
+        if (this == o) return true;
+        if (!(o instanceof DbRowOp)) return false;
+
+        DbRowOp other = (DbRowOp) o;
+        return changeId.equals(other.getChangeId());
+    }
+
+    @Override
+    public int hashCode() {
+        return changeId.hashCode();
+    }
+
+    @Override
+    public String toString() {
+        return entity.getName() + " " + changeId;
+    }
+}
diff --git a/cayenne-server/src/main/java/org/apache/cayenne/map/EntitySorter.java b/cayenne-server/src/main/java/org/apache/cayenne/access/flush/operation/DbRowOp.java
similarity index 54%
copy from cayenne-server/src/main/java/org/apache/cayenne/map/EntitySorter.java
copy to cayenne-server/src/main/java/org/apache/cayenne/access/flush/operation/DbRowOp.java
index 5c7981e..94b0f91 100644
--- a/cayenne-server/src/main/java/org/apache/cayenne/map/EntitySorter.java
+++ b/cayenne-server/src/main/java/org/apache/cayenne/access/flush/operation/DbRowOp.java
@@ -17,37 +17,31 @@
  *  under the License.
  ****************************************************************/
 
-package org.apache.cayenne.map;
+package org.apache.cayenne.access.flush.operation;
 
-import java.util.List;
+import org.apache.cayenne.ObjectId;
+import org.apache.cayenne.Persistent;
+import org.apache.cayenne.map.DbEntity;
 
 /**
- * Defines API for sorting of Cayenne entities based on their mutual dependencies.
- * 
- * @since 1.1
+ * Object that represents some change on DB level.
+ * Common cases are insert/update/delete of single DB row.
+ *
+ * @since 4.2
  */
-public interface EntitySorter {
+public interface DbRowOp {
 
-    /**
-     * Sets EntityResolver for this sorter. All entities present in the resolver will be
-     * used to determine sort ordering.
-     * 
-     * @since 3.1
-     */
-    void setEntityResolver(EntityResolver resolver);
+    <T> T accept(DbRowOpVisitor<T> visitor);
 
-    /**
-     * Sorts a list of DbEntities.
-     */
-    void sortDbEntities(List<DbEntity> dbEntities, boolean deleteOrder);
+    DbEntity getEntity();
 
-    /**
-     * Sorts a list of ObjEntities.
-     */
-    void sortObjEntities(List<ObjEntity> objEntities, boolean deleteOrder);
+    ObjectId getChangeId();
+
+    Persistent getObject();
 
     /**
-     * Sorts a list of objects belonging to the ObjEntity.
+     * @param rowOp to check
+     * @return is this and rowOp operations belong to same sql batch
      */
-    void sortObjectsForEntity(ObjEntity entity, List<?> objects, boolean deleteOrder);
+    boolean isSameBatch(DbRowOp rowOp);
 }
diff --git a/cayenne-server/src/main/java/org/apache/cayenne/access/flush/operation/DbRowOpMerger.java b/cayenne-server/src/main/java/org/apache/cayenne/access/flush/operation/DbRowOpMerger.java
new file mode 100644
index 0000000..34dda5b
--- /dev/null
+++ b/cayenne-server/src/main/java/org/apache/cayenne/access/flush/operation/DbRowOpMerger.java
@@ -0,0 +1,82 @@
+/*****************************************************************
+ *   Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ ****************************************************************/
+
+package org.apache.cayenne.access.flush.operation;
+
+import java.util.function.BiFunction;
+
+/**
+ * BiFunction that merges two {@link DbRowOp} changing same object.
+ *
+ * @since 4.2
+ */
+public class DbRowOpMerger implements DbRowOpVisitor<DbRowOp>, BiFunction<DbRowOp, DbRowOp, DbRowOp> {
+
+    public static final DbRowOpMerger INSTANCE = new DbRowOpMerger();
+
+    private DbRowOp dbRow;
+
+    public DbRowOpMerger() {
+    }
+
+    @Override
+    public DbRowOp apply(DbRowOp oldValue, DbRowOp newValue) {
+        this.dbRow = oldValue;
+        return newValue.accept(this);
+    }
+
+    @Override
+    public DbRowOp visitInsert(InsertDbRowOp other) {
+        if(dbRow instanceof DeleteDbRowOp) {
+            return new DeleteInsertDbRowOp((DeleteDbRowOp)dbRow, other);
+        }
+        return mergeValues((DbRowOpWithValues) dbRow, other);
+    }
+
+    @Override
+    public DbRowOp visitUpdate(UpdateDbRowOp other) {
+        // delete beats update ...
+        if(dbRow instanceof DeleteDbRowOp) {
+            return dbRow;
+        }
+        return mergeValues((DbRowOpWithValues) dbRow, other);
+    }
+
+    @Override
+    public DbRowOp visitDelete(DeleteDbRowOp other) {
+        if(dbRow.getChangeId() == other.getChangeId()) {
+            return other;
+        }
+        // clash of Insert/Delete with equal ObjectId
+        if(dbRow instanceof InsertDbRowOp) {
+            return new DeleteInsertDbRowOp(other, (InsertDbRowOp)dbRow);
+        }
+        return other;
+    }
+
+    private DbRowOp mergeValues(DbRowOpWithValues left, DbRowOpWithValues right) {
+        if(right.getChangeId() == right.getObject().getObjectId()) {
+            right.getValues().merge(left.getValues());
+            return right;
+        } else {
+            left.getValues().merge(right.getValues());
+            return left;
+        }
+    }
+}
diff --git a/cayenne-server/src/main/java/org/apache/cayenne/map/EntitySorter.java b/cayenne-server/src/main/java/org/apache/cayenne/access/flush/operation/DbRowOpSorter.java
similarity index 55%
copy from cayenne-server/src/main/java/org/apache/cayenne/map/EntitySorter.java
copy to cayenne-server/src/main/java/org/apache/cayenne/access/flush/operation/DbRowOpSorter.java
index 5c7981e..24238d0 100644
--- a/cayenne-server/src/main/java/org/apache/cayenne/map/EntitySorter.java
+++ b/cayenne-server/src/main/java/org/apache/cayenne/access/flush/operation/DbRowOpSorter.java
@@ -17,37 +17,17 @@
  *  under the License.
  ****************************************************************/
 
-package org.apache.cayenne.map;
+package org.apache.cayenne.access.flush.operation;
 
 import java.util.List;
 
 /**
- * Defines API for sorting of Cayenne entities based on their mutual dependencies.
- * 
- * @since 1.1
+ * Sorter of {@link DbRowOp} operations.
+ * @see DefaultDbRowOpSorter default implementation.
+ *
+ * @since 4.2
  */
-public interface EntitySorter {
-
-    /**
-     * Sets EntityResolver for this sorter. All entities present in the resolver will be
-     * used to determine sort ordering.
-     * 
-     * @since 3.1
-     */
-    void setEntityResolver(EntityResolver resolver);
-
-    /**
-     * Sorts a list of DbEntities.
-     */
-    void sortDbEntities(List<DbEntity> dbEntities, boolean deleteOrder);
-
-    /**
-     * Sorts a list of ObjEntities.
-     */
-    void sortObjEntities(List<ObjEntity> objEntities, boolean deleteOrder);
+public interface DbRowOpSorter {
 
-    /**
-     * Sorts a list of objects belonging to the ObjEntity.
-     */
-    void sortObjectsForEntity(ObjEntity entity, List<?> objects, boolean deleteOrder);
+    List<DbRowOp> sort(List<DbRowOp> dbRows);
 }
diff --git a/cayenne-server/src/main/java/org/apache/cayenne/map/EntitySorter.java b/cayenne-server/src/main/java/org/apache/cayenne/access/flush/operation/DbRowOpType.java
similarity index 53%
copy from cayenne-server/src/main/java/org/apache/cayenne/map/EntitySorter.java
copy to cayenne-server/src/main/java/org/apache/cayenne/access/flush/operation/DbRowOpType.java
index 5c7981e..445c500 100644
--- a/cayenne-server/src/main/java/org/apache/cayenne/map/EntitySorter.java
+++ b/cayenne-server/src/main/java/org/apache/cayenne/access/flush/operation/DbRowOpType.java
@@ -17,37 +17,30 @@
  *  under the License.
  ****************************************************************/
 
-package org.apache.cayenne.map;
+package org.apache.cayenne.access.flush.operation;
 
-import java.util.List;
+import org.apache.cayenne.CayenneRuntimeException;
+import org.apache.cayenne.PersistenceState;
+import org.apache.cayenne.Persistent;
 
 /**
- * Defines API for sorting of Cayenne entities based on their mutual dependencies.
- * 
- * @since 1.1
+ * @since 4.2
  */
-public interface EntitySorter {
+public enum DbRowOpType implements Comparable<DbRowOpType> {
+    INSERT,
+    UPDATE,
+    DELETE;
 
-    /**
-     * Sets EntityResolver for this sorter. All entities present in the resolver will be
-     * used to determine sort ordering.
-     * 
-     * @since 3.1
-     */
-    void setEntityResolver(EntityResolver resolver);
-
-    /**
-     * Sorts a list of DbEntities.
-     */
-    void sortDbEntities(List<DbEntity> dbEntities, boolean deleteOrder);
-
-    /**
-     * Sorts a list of ObjEntities.
-     */
-    void sortObjEntities(List<ObjEntity> objEntities, boolean deleteOrder);
-
-    /**
-     * Sorts a list of objects belonging to the ObjEntity.
-     */
-    void sortObjectsForEntity(ObjEntity entity, List<?> objects, boolean deleteOrder);
+    public static DbRowOpType forObject(Persistent object) {
+        switch (object.getPersistenceState()) {
+            case PersistenceState.NEW:
+                return INSERT;
+            case PersistenceState.MODIFIED:
+                return UPDATE;
+            case PersistenceState.DELETED:
+                return DELETE;
+        }
+        throw new CayenneRuntimeException("Trying to flush object %s in wrong persistence state %s",
+                object, PersistenceState.persistenceStateName(object.getPersistenceState()));
+    }
 }
diff --git a/cayenne-server/src/main/java/org/apache/cayenne/map/EntitySorter.java b/cayenne-server/src/main/java/org/apache/cayenne/access/flush/operation/DbRowOpVisitor.java
similarity index 53%
copy from cayenne-server/src/main/java/org/apache/cayenne/map/EntitySorter.java
copy to cayenne-server/src/main/java/org/apache/cayenne/access/flush/operation/DbRowOpVisitor.java
index 5c7981e..d179321 100644
--- a/cayenne-server/src/main/java/org/apache/cayenne/map/EntitySorter.java
+++ b/cayenne-server/src/main/java/org/apache/cayenne/access/flush/operation/DbRowOpVisitor.java
@@ -17,37 +17,22 @@
  *  under the License.
  ****************************************************************/
 
-package org.apache.cayenne.map;
-
-import java.util.List;
+package org.apache.cayenne.access.flush.operation;
 
 /**
- * Defines API for sorting of Cayenne entities based on their mutual dependencies.
- * 
- * @since 1.1
+ * @since 4.2
  */
-public interface EntitySorter {
-
-    /**
-     * Sets EntityResolver for this sorter. All entities present in the resolver will be
-     * used to determine sort ordering.
-     * 
-     * @since 3.1
-     */
-    void setEntityResolver(EntityResolver resolver);
+public interface DbRowOpVisitor<T> {
 
-    /**
-     * Sorts a list of DbEntities.
-     */
-    void sortDbEntities(List<DbEntity> dbEntities, boolean deleteOrder);
+    default T visitInsert(InsertDbRowOp dbRow) {
+        return null;
+    }
 
-    /**
-     * Sorts a list of ObjEntities.
-     */
-    void sortObjEntities(List<ObjEntity> objEntities, boolean deleteOrder);
+    default T visitUpdate(UpdateDbRowOp dbRow) {
+        return null;
+    }
 
-    /**
-     * Sorts a list of objects belonging to the ObjEntity.
-     */
-    void sortObjectsForEntity(ObjEntity entity, List<?> objects, boolean deleteOrder);
+    default T visitDelete(DeleteDbRowOp dbRow) {
+        return null;
+    }
 }
diff --git a/cayenne-server/src/main/java/org/apache/cayenne/map/EntitySorter.java b/cayenne-server/src/main/java/org/apache/cayenne/access/flush/operation/DbRowOpWithQualifier.java
similarity index 53%
copy from cayenne-server/src/main/java/org/apache/cayenne/map/EntitySorter.java
copy to cayenne-server/src/main/java/org/apache/cayenne/access/flush/operation/DbRowOpWithQualifier.java
index 5c7981e..ed64e2f 100644
--- a/cayenne-server/src/main/java/org/apache/cayenne/map/EntitySorter.java
+++ b/cayenne-server/src/main/java/org/apache/cayenne/access/flush/operation/DbRowOpWithQualifier.java
@@ -17,37 +17,13 @@
  *  under the License.
  ****************************************************************/
 
-package org.apache.cayenne.map;
-
-import java.util.List;
+package org.apache.cayenne.access.flush.operation;
 
 /**
- * Defines API for sorting of Cayenne entities based on their mutual dependencies.
- * 
- * @since 1.1
+ * @since 4.2
  */
-public interface EntitySorter {
-
-    /**
-     * Sets EntityResolver for this sorter. All entities present in the resolver will be
-     * used to determine sort ordering.
-     * 
-     * @since 3.1
-     */
-    void setEntityResolver(EntityResolver resolver);
-
-    /**
-     * Sorts a list of DbEntities.
-     */
-    void sortDbEntities(List<DbEntity> dbEntities, boolean deleteOrder);
+public interface DbRowOpWithQualifier extends DbRowOp {
 
-    /**
-     * Sorts a list of ObjEntities.
-     */
-    void sortObjEntities(List<ObjEntity> objEntities, boolean deleteOrder);
+    Qualifier getQualifier();
 
-    /**
-     * Sorts a list of objects belonging to the ObjEntity.
-     */
-    void sortObjectsForEntity(ObjEntity entity, List<?> objects, boolean deleteOrder);
 }
diff --git a/cayenne-server/src/main/java/org/apache/cayenne/map/EntitySorter.java b/cayenne-server/src/main/java/org/apache/cayenne/access/flush/operation/DbRowOpWithValues.java
similarity index 53%
copy from cayenne-server/src/main/java/org/apache/cayenne/map/EntitySorter.java
copy to cayenne-server/src/main/java/org/apache/cayenne/access/flush/operation/DbRowOpWithValues.java
index 5c7981e..2b8b67e 100644
--- a/cayenne-server/src/main/java/org/apache/cayenne/map/EntitySorter.java
+++ b/cayenne-server/src/main/java/org/apache/cayenne/access/flush/operation/DbRowOpWithValues.java
@@ -17,37 +17,13 @@
  *  under the License.
  ****************************************************************/
 
-package org.apache.cayenne.map;
-
-import java.util.List;
+package org.apache.cayenne.access.flush.operation;
 
 /**
- * Defines API for sorting of Cayenne entities based on their mutual dependencies.
- * 
- * @since 1.1
+ * @since 4.2
  */
-public interface EntitySorter {
-
-    /**
-     * Sets EntityResolver for this sorter. All entities present in the resolver will be
-     * used to determine sort ordering.
-     * 
-     * @since 3.1
-     */
-    void setEntityResolver(EntityResolver resolver);
-
-    /**
-     * Sorts a list of DbEntities.
-     */
-    void sortDbEntities(List<DbEntity> dbEntities, boolean deleteOrder);
+public interface DbRowOpWithValues extends DbRowOp {
 
-    /**
-     * Sorts a list of ObjEntities.
-     */
-    void sortObjEntities(List<ObjEntity> objEntities, boolean deleteOrder);
+    Values getValues();
 
-    /**
-     * Sorts a list of objects belonging to the ObjEntity.
-     */
-    void sortObjectsForEntity(ObjEntity entity, List<?> objects, boolean deleteOrder);
 }
diff --git a/cayenne-server/src/main/java/org/apache/cayenne/access/flush/operation/DefaultDbRowOpSorter.java b/cayenne-server/src/main/java/org/apache/cayenne/access/flush/operation/DefaultDbRowOpSorter.java
new file mode 100644
index 0000000..8af3cf5
--- /dev/null
+++ b/cayenne-server/src/main/java/org/apache/cayenne/access/flush/operation/DefaultDbRowOpSorter.java
@@ -0,0 +1,150 @@
+/*****************************************************************
+ *   Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ ****************************************************************/
+
+package org.apache.cayenne.access.flush.operation;
+
+import java.util.Comparator;
+import java.util.List;
+
+import org.apache.cayenne.access.DataDomain;
+import org.apache.cayenne.di.Inject;
+import org.apache.cayenne.di.Provider;
+import org.apache.cayenne.map.DbEntity;
+import org.apache.cayenne.map.EntityResolver;
+import org.apache.cayenne.map.EntitySorter;
+import org.apache.cayenne.map.ObjEntity;
+
+/**
+ * @since 4.2
+ */
+public class DefaultDbRowOpSorter implements DbRowOpSorter {
+
+    protected final Provider<DataDomain> dataDomainProvider;
+    protected volatile Comparator<DbRowOp> comparator;
+
+    public DefaultDbRowOpSorter(@Inject Provider<DataDomain> dataDomainProvider) {
+        this.dataDomainProvider = dataDomainProvider;
+    }
+
+    @Override
+    public List<DbRowOp> sort(List<DbRowOp> dbRows) {
+        // sort by id, operation type and entity relations
+        dbRows.sort(getComparator());
+        // sort reflexively dependent objects
+        sortReflexive(dbRows);
+
+        return dbRows;
+    }
+
+    protected void sortReflexive(List<DbRowOp> sortedDbRows) {
+        DataDomain dataDomain = dataDomainProvider.get();
+        EntitySorter sorter = dataDomain.getEntitySorter();
+        EntityResolver resolver = dataDomain.getEntityResolver();
+
+        DbEntity lastEntity = null;
+        int start = 0;
+        int idx = 0;
+        DbRowOp lastRow = null;
+        for(DbRowOp row : sortedDbRows) {
+            if (row.getEntity() != lastEntity) {
+                start = idx;
+                if(lastEntity != null && sorter.isReflexive(lastEntity)) {
+                    ObjEntity objEntity = resolver.getObjEntity(lastRow.getObject().getObjectId().getEntityName());
+                    List<DbRowOp> reflexiveSublist = sortedDbRows.subList(start, idx);
+                    sorter.sortObjectsForEntity(objEntity, reflexiveSublist, lastRow instanceof DeleteDbRowOp);
+                }
+                lastEntity = row.getEntity();
+            }
+            lastRow = row;
+            idx++;
+        }
+        // sort last chunk
+        if(lastEntity != null && sorter.isReflexive(lastEntity)) {
+            ObjEntity objEntity = resolver.getObjEntity(lastRow.getObject().getObjectId().getEntityName());
+            List<DbRowOp> reflexiveSublist = sortedDbRows.subList(start, idx);
+            sorter.sortObjectsForEntity(objEntity, reflexiveSublist, lastRow instanceof DeleteDbRowOp);
+        }
+    }
+
+    protected Comparator<DbRowOp> getComparator() {
+        Comparator<DbRowOp> local = comparator;
+        if(local == null) {
+            synchronized (this) {
+                local = comparator;
+                if(local == null) {
+                    local = new DbRowComparator(dataDomainProvider.get().getEntitySorter());
+                    comparator = local;
+                }
+            }
+        }
+        return local;
+    }
+
+    protected static class DbRowComparator implements Comparator<DbRowOp> {
+
+        private final EntitySorter entitySorter;
+
+        protected DbRowComparator(EntitySorter entitySorter) {
+            this.entitySorter = entitySorter;
+        }
+
+        @Override
+        public int compare(DbRowOp left, DbRowOp right) {
+            DbRowOpType leftType = left.accept(DbRowTypeVisitor.INSTANCE);
+            DbRowOpType rightType = right.accept(DbRowTypeVisitor.INSTANCE);
+            int result = leftType.compareTo(rightType);
+
+            // 1. sort by op type
+            if(result != 0) {
+                return result;
+            }
+
+            // 2. sort by entity relations
+            result = entitySorter.getDbEntityComparator().compare(left.getEntity(), right.getEntity());
+            if(result != 0) {
+                // invert result for delete
+                return leftType == DbRowOpType.DELETE ? -result : result;
+            }
+
+            // TODO: 3. sort updates by changed and null attributes to batch it better,
+            //  need to check cost vs benefit though
+            return result;
+        }
+    }
+
+    protected static class DbRowTypeVisitor implements DbRowOpVisitor<DbRowOpType> {
+
+        private static final DbRowTypeVisitor INSTANCE = new DbRowTypeVisitor();
+
+        @Override
+        public DbRowOpType visitInsert(InsertDbRowOp diffSnapshot) {
+            return DbRowOpType.INSERT;
+        }
+
+        @Override
+        public DbRowOpType visitUpdate(UpdateDbRowOp diffSnapshot) {
+            return DbRowOpType.UPDATE;
+        }
+
+        @Override
+        public DbRowOpType visitDelete(DeleteDbRowOp diffSnapshot) {
+            return DbRowOpType.DELETE;
+        }
+    }
+}
diff --git a/cayenne-server/src/main/java/org/apache/cayenne/access/flush/operation/DeleteDbRowOp.java b/cayenne-server/src/main/java/org/apache/cayenne/access/flush/operation/DeleteDbRowOp.java
new file mode 100644
index 0000000..8ed6e90
--- /dev/null
+++ b/cayenne-server/src/main/java/org/apache/cayenne/access/flush/operation/DeleteDbRowOp.java
@@ -0,0 +1,72 @@
+/*****************************************************************
+ *   Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ ****************************************************************/
+
+package org.apache.cayenne.access.flush.operation;
+
+import org.apache.cayenne.ObjectId;
+import org.apache.cayenne.Persistent;
+import org.apache.cayenne.map.DbEntity;
+
+/**
+ * @since 4.2
+ */
+public class DeleteDbRowOp extends BaseDbRowOp implements DbRowOpWithQualifier {
+
+    protected final Qualifier qualifier;
+
+    public DeleteDbRowOp(Persistent object, DbEntity entity, ObjectId id) {
+        super(object, entity, id);
+        qualifier = new Qualifier(this);
+    }
+
+    @Override
+    public <T> T accept(DbRowOpVisitor<T> visitor) {
+        return visitor.visitDelete(this);
+    }
+
+    @Override
+    public boolean equals(Object o) {
+        if(!(o instanceof DbRowOpWithQualifier)) {
+            return false;
+        }
+        return super.equals(o);
+    }
+
+    @Override
+    public boolean isSameBatch(DbRowOp rowOp) {
+        if(!(rowOp instanceof DeleteDbRowOp)) {
+            return false;
+        }
+        if(!rowOp.getEntity().getName().equals(getEntity().getName())) {
+            return false;
+        }
+        DeleteDbRowOp other = (DeleteDbRowOp) rowOp;
+        return qualifier.isSameBatch(other.qualifier);
+    }
+
+    @Override
+    public Qualifier getQualifier() {
+        return qualifier;
+    }
+
+    @Override
+    public String toString() {
+        return "delete " + super.toString();
+    }
+}
diff --git a/cayenne-server/src/main/java/org/apache/cayenne/map/EntitySorter.java b/cayenne-server/src/main/java/org/apache/cayenne/access/flush/operation/DeleteInsertDbRowOp.java
similarity index 53%
copy from cayenne-server/src/main/java/org/apache/cayenne/map/EntitySorter.java
copy to cayenne-server/src/main/java/org/apache/cayenne/access/flush/operation/DeleteInsertDbRowOp.java
index 5c7981e..40191f5 100644
--- a/cayenne-server/src/main/java/org/apache/cayenne/map/EntitySorter.java
+++ b/cayenne-server/src/main/java/org/apache/cayenne/access/flush/operation/DeleteInsertDbRowOp.java
@@ -17,37 +17,33 @@
  *  under the License.
  ****************************************************************/
 
-package org.apache.cayenne.map;
-
-import java.util.List;
+package org.apache.cayenne.access.flush.operation;
 
 /**
- * Defines API for sorting of Cayenne entities based on their mutual dependencies.
- * 
- * @since 1.1
+ * Special case op, that describes delete/insert sequence of different objects
+ * that have same ObjectId (known example: meaningful PK set to same value as used before).
+ *
+ * @since 4.2
  */
-public interface EntitySorter {
+public class DeleteInsertDbRowOp extends BaseDbRowOp {
 
-    /**
-     * Sets EntityResolver for this sorter. All entities present in the resolver will be
-     * used to determine sort ordering.
-     * 
-     * @since 3.1
-     */
-    void setEntityResolver(EntityResolver resolver);
+    private final DeleteDbRowOp delete;
+    private final InsertDbRowOp insert;
 
-    /**
-     * Sorts a list of DbEntities.
-     */
-    void sortDbEntities(List<DbEntity> dbEntities, boolean deleteOrder);
+    public DeleteInsertDbRowOp(DeleteDbRowOp delete, InsertDbRowOp insert) {
+        super(delete.getObject(), delete.getEntity(), delete.getChangeId());
+        this.delete = delete;
+        this.insert = insert;
+    }
 
-    /**
-     * Sorts a list of ObjEntities.
-     */
-    void sortObjEntities(List<ObjEntity> objEntities, boolean deleteOrder);
+    @Override
+    public <T> T accept(DbRowOpVisitor<T> visitor) {
+        visitor.visitDelete(delete);
+        return visitor.visitInsert(insert);
+    }
 
-    /**
-     * Sorts a list of objects belonging to the ObjEntity.
-     */
-    void sortObjectsForEntity(ObjEntity entity, List<?> objects, boolean deleteOrder);
+    @Override
+    public boolean isSameBatch(DbRowOp rowOp) {
+        return false;
+    }
 }
diff --git a/cayenne-server/src/main/java/org/apache/cayenne/access/flush/operation/InsertDbRowOp.java b/cayenne-server/src/main/java/org/apache/cayenne/access/flush/operation/InsertDbRowOp.java
new file mode 100644
index 0000000..9eafbd6
--- /dev/null
+++ b/cayenne-server/src/main/java/org/apache/cayenne/access/flush/operation/InsertDbRowOp.java
@@ -0,0 +1,71 @@
+/*****************************************************************
+ *   Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ ****************************************************************/
+
+package org.apache.cayenne.access.flush.operation;
+
+import org.apache.cayenne.ObjectId;
+import org.apache.cayenne.Persistent;
+import org.apache.cayenne.map.DbEntity;
+
+/**
+ * @since 4.2
+ */
+public class InsertDbRowOp extends BaseDbRowOp implements DbRowOpWithValues {
+
+    protected final Values values;
+
+    public InsertDbRowOp(Persistent object, DbEntity entity, ObjectId id) {
+        super(object, entity, id);
+        values = new Values(this, true);
+    }
+
+    @Override
+    public <T> T accept(DbRowOpVisitor<T> visitor) {
+        return visitor.visitInsert(this);
+    }
+
+    @Override
+    public Values getValues() {
+        return values;
+    }
+
+    @Override
+    public boolean equals(Object o) {
+        // TODO: here go troubles with transitivity
+        //   insert = update, update = delete, delete != insert
+        //   though we need this only to store in a hash map, so it should be ok...
+        if(!(o instanceof DbRowOpWithValues)) {
+            return false;
+        }
+        return super.equals(o);
+    }
+
+    @Override
+    public boolean isSameBatch(DbRowOp rowOp) {
+        if(!(rowOp instanceof InsertDbRowOp)) {
+            return false;
+        }
+        return rowOp.getEntity().getName().equals(getEntity().getName());
+    }
+
+    @Override
+    public String toString() {
+        return "insert " + super.toString();
+    }
+}
diff --git a/cayenne-server/src/main/java/org/apache/cayenne/access/flush/operation/Qualifier.java b/cayenne-server/src/main/java/org/apache/cayenne/access/flush/operation/Qualifier.java
new file mode 100644
index 0000000..f680f3c
--- /dev/null
+++ b/cayenne-server/src/main/java/org/apache/cayenne/access/flush/operation/Qualifier.java
@@ -0,0 +1,147 @@
+/*****************************************************************
+ *   Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ ****************************************************************/
+
+package org.apache.cayenne.access.flush.operation;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import org.apache.cayenne.map.DbAttribute;
+
+/**
+ * Qualifier of DB row. It uses PK and optimistic lock qualifier if any.
+ *
+ * @since 4.2
+ */
+public class Qualifier {
+
+    protected final DbRowOp row;
+    // additional qualifier for optimistic lock
+    protected Map<DbAttribute, Object> additionalQualifier;
+    protected List<String> nullNames;
+    protected boolean optimisticLock;
+
+    protected Qualifier(DbRowOp row) {
+        this.row = row;
+    }
+
+    public Map<String, Object> getSnapshot() {
+        Map<String, Object> idSnapshot = row.getChangeId().getIdSnapshot();
+        if(additionalQualifier == null || additionalQualifier.isEmpty()) {
+            return idSnapshot;
+        }
+
+        Map<String, Object> qualifier = new HashMap<>(additionalQualifier.size() + idSnapshot.size());
+        AtomicBoolean hasPK = new AtomicBoolean(!idSnapshot.isEmpty());
+        idSnapshot.forEach((attr, value) -> {
+            if(value != null) {
+                qualifier.put(attr, value);
+            } else {
+                hasPK.set(false);
+            }
+        });
+
+        if(!hasPK.get() || optimisticLock) {
+            additionalQualifier.forEach((attr, value) ->
+                    qualifier.put(attr.getName(), value)
+            );
+        }
+
+        return qualifier;
+    }
+
+    public List<DbAttribute> getQualifierAttributes() {
+        List<DbAttribute> primaryKeys = row.getEntity().getPrimaryKeys();
+        if(additionalQualifier == null || additionalQualifier.isEmpty()) {
+            return primaryKeys;
+        }
+
+        List<DbAttribute> attributes = new ArrayList<>();
+        Map<String, Object> idSnapshot = row.getChangeId().getIdSnapshot();
+        AtomicBoolean hasPK = new AtomicBoolean(!idSnapshot.isEmpty());
+        primaryKeys.forEach(pk -> {
+            if(idSnapshot.get(pk.getName()) != null) {
+                attributes.add(pk);
+            } else {
+                hasPK.set(false);
+            }
+        });
+
+        if(!hasPK.get() || optimisticLock) {
+            attributes.addAll(additionalQualifier.keySet());
+        }
+        return attributes;
+    }
+
+    public Collection<String> getNullQualifierNames() {
+        if(nullNames == null || nullNames.isEmpty()) {
+            return Collections.emptyList();
+        }
+        return nullNames;
+    }
+
+    public void addAdditionalQualifier(DbAttribute dbAttribute, Object value) {
+        addAdditionalQualifier(dbAttribute, value, false);
+    }
+
+    public void addAdditionalQualifier(DbAttribute dbAttribute, Object value, boolean optimisticLock) {
+        if(additionalQualifier == null) {
+            additionalQualifier = new HashMap<>();
+        }
+
+        additionalQualifier.put(dbAttribute, value);
+        if(value == null) {
+            if(nullNames == null) {
+                nullNames = new ArrayList<>();
+            }
+            nullNames.add(dbAttribute.getName());
+        }
+
+        if(optimisticLock) {
+            this.optimisticLock = true;
+        }
+    }
+
+    public boolean isUsingOptimisticLocking() {
+        return optimisticLock;
+    }
+
+    public boolean isSameBatch(Qualifier other) {
+        if(additionalQualifier == null) {
+            return other.additionalQualifier == null;
+        }
+        if(optimisticLock != other.optimisticLock) {
+            return false;
+        }
+        if(other.additionalQualifier == null) {
+            return false;
+        }
+        if(!additionalQualifier.keySet().equals(other.additionalQualifier.keySet())) {
+            return false;
+        }
+        return Objects.equals(nullNames, other.nullNames);
+    }
+
+}
diff --git a/cayenne-server/src/main/java/org/apache/cayenne/access/flush/operation/UpdateDbRowOp.java b/cayenne-server/src/main/java/org/apache/cayenne/access/flush/operation/UpdateDbRowOp.java
new file mode 100644
index 0000000..fccaa80
--- /dev/null
+++ b/cayenne-server/src/main/java/org/apache/cayenne/access/flush/operation/UpdateDbRowOp.java
@@ -0,0 +1,74 @@
+/*****************************************************************
+ *   Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ ****************************************************************/
+
+package org.apache.cayenne.access.flush.operation;
+
+import org.apache.cayenne.ObjectId;
+import org.apache.cayenne.Persistent;
+import org.apache.cayenne.map.DbEntity;
+
+/**
+ * @since 4.2
+ */
+public class UpdateDbRowOp extends BaseDbRowOp implements DbRowOpWithValues, DbRowOpWithQualifier {
+
+    protected final Values values;
+    protected final Qualifier qualifier;
+
+    public UpdateDbRowOp(Persistent object, DbEntity entity, ObjectId id) {
+        super(object, entity, id);
+        values = new Values(this, false);
+        qualifier = new Qualifier(this);
+    }
+
+    @Override
+    public <T> T accept(DbRowOpVisitor<T> visitor) {
+        return visitor.visitUpdate(this);
+    }
+
+    @Override
+    public Qualifier getQualifier() {
+        return qualifier;
+    }
+
+    @Override
+    public Values getValues() {
+        return values;
+    }
+
+    @Override
+    public boolean isSameBatch(DbRowOp rowOp) {
+        if(!(rowOp instanceof UpdateDbRowOp)) {
+            return false;
+        }
+        if(!rowOp.getEntity().getName().equals(getEntity().getName())) {
+            return false;
+        }
+        UpdateDbRowOp other = (UpdateDbRowOp) rowOp;
+        if(!values.isSameBatch(other.values)) {
+            return false;
+        }
+        return qualifier.isSameBatch(other.qualifier);
+    }
+
+    @Override
+    public String toString() {
+        return "update " + super.toString();
+    }
+}
diff --git a/cayenne-server/src/main/java/org/apache/cayenne/access/flush/operation/Values.java b/cayenne-server/src/main/java/org/apache/cayenne/access/flush/operation/Values.java
new file mode 100644
index 0000000..248d891
--- /dev/null
+++ b/cayenne-server/src/main/java/org/apache/cayenne/access/flush/operation/Values.java
@@ -0,0 +1,151 @@
+/*****************************************************************
+ *   Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ ****************************************************************/
+
+package org.apache.cayenne.access.flush.operation;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import org.apache.cayenne.ObjectId;
+import org.apache.cayenne.map.DbAttribute;
+
+/**
+ * Collection of values that should be inserted or updated in DB.
+ *
+ * @since 4.2
+ */
+public class Values {
+
+    protected final DbRowOp row;
+    protected final boolean includeId;
+    // new values to store to DB
+    protected Map<String, Object> snapshot;
+    protected List<DbAttribute> updatedAttributes;
+    // generated flattened Ids for this insert
+    protected Map<String, ObjectId> flattenedIds;
+
+    public Values(DbRowOp row, boolean includeId) {
+        this.row = row;
+        this.includeId = includeId;
+    }
+
+    public void addValue(DbAttribute attribute, Object value) {
+        if(snapshot == null) {
+            snapshot = new HashMap<>();
+            updatedAttributes = new ArrayList<>();
+        }
+        computeSnapshotValue(attribute.getName(), value);
+        if(!updatedAttributes.contains(attribute)) {
+            updatedAttributes.add(attribute);
+        }
+    }
+
+    private void computeSnapshotValue(String attribute, Object value) {
+        snapshot.putIfAbsent(attribute, value);
+    }
+
+    public void merge(Values other) {
+        if(this.snapshot == null) {
+            this.snapshot = other.snapshot;
+            this.updatedAttributes = other.updatedAttributes;
+        } else if(other.snapshot != null) {
+            other.snapshot.forEach(this::computeSnapshotValue);
+            other.updatedAttributes.forEach(attr -> {
+                if(!updatedAttributes.contains(attr)) {
+                    updatedAttributes.add(attr);
+                }
+            });
+        }
+
+        if(other.flattenedIds != null) {
+            if(flattenedIds == null) {
+                flattenedIds = other.getFlattenedIds();
+            } else {
+                other.flattenedIds.forEach((path, id) -> flattenedIds.compute(path, (p, existing) -> {
+                     if(id.getEntityName().equals(row.getChangeId().getEntityName())
+                        || (existing != null && existing.getEntityName().equals(row.getChangeId().getEntityName()))) {
+                         return row.getChangeId();
+                     }
+                     if(existing != null) {
+                         return existing;
+                     }
+                     return id;
+                }));
+            }
+        }
+    }
+
+    public void addFlattenedId(String path, ObjectId id) {
+        if(flattenedIds == null) {
+            flattenedIds = new HashMap<>();
+        }
+        flattenedIds.put(path, id);
+    }
+
+    public Map<String, Object> getSnapshot() {
+        if(!includeId) {
+            if(snapshot == null) {
+                return Collections.emptyMap();
+            }
+            return snapshot;
+        } else {
+            if (snapshot == null) {
+                snapshot = new HashMap<>();
+                snapshot.putAll(row.getChangeId().getIdSnapshot());
+                return snapshot;
+            }
+            snapshot.putAll(row.getChangeId().getIdSnapshot());
+            return snapshot;
+        }
+    }
+
+    public List<DbAttribute> getUpdatedAttributes() {
+        if(updatedAttributes == null) {
+            return Collections.emptyList();
+        }
+        return updatedAttributes;
+    }
+
+    public Map<String, ObjectId> getFlattenedIds() {
+        if(flattenedIds == null) {
+            return Collections.emptyMap();
+        }
+        return flattenedIds;
+    }
+
+    public boolean isEmpty() {
+        if(includeId) {
+            return false;
+        }
+        return snapshot == null || snapshot.isEmpty();
+    }
+
+    public boolean isSameBatch(Values other) {
+        if(snapshot == null) {
+            return other.snapshot == null;
+        }
+        if(other.snapshot == null) {
+            return false;
+        }
+        return snapshot.keySet().equals(other.snapshot.keySet());
+    }
+}
diff --git a/cayenne-server/src/main/java/org/apache/cayenne/ashwood/AshwoodEntitySorter.java b/cayenne-server/src/main/java/org/apache/cayenne/ashwood/AshwoodEntitySorter.java
index 4c1a947..cc8de25 100644
--- a/cayenne-server/src/main/java/org/apache/cayenne/ashwood/AshwoodEntitySorter.java
+++ b/cayenne-server/src/main/java/org/apache/cayenne/ashwood/AshwoodEntitySorter.java
@@ -25,6 +25,7 @@ import org.apache.cayenne.ObjectContext;
 import org.apache.cayenne.ObjectId;
 import org.apache.cayenne.Persistent;
 import org.apache.cayenne.QueryResponse;
+import org.apache.cayenne.access.flush.operation.DbRowOp;
 import org.apache.cayenne.ashwood.graph.Digraph;
 import org.apache.cayenne.ashwood.graph.IndegreeTopologicalSort;
 import org.apache.cayenne.ashwood.graph.MapDigraph;
@@ -47,6 +48,7 @@ import java.util.Comparator;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.function.Function;
 
 /**
  * Implements dependency sorting algorithms for ObjEntities, DbEntities and
@@ -120,11 +122,8 @@ public class AshwoodEntitySorter implements EntitySorter {
 						if (targetAttribute.isPrimaryKey()) {
 
 							if (newReflexive) {
-								List<DbRelationship> reflexiveRels = reflexiveDbEntities.get(destination);
-								if (reflexiveRels == null) {
-									reflexiveRels = new ArrayList<>(1);
-									reflexiveDbEntities.put(destination, reflexiveRels);
-								}
+								List<DbRelationship> reflexiveRels = reflexiveDbEntities
+										.computeIfAbsent(destination, k -> new ArrayList<>(1));
 								reflexiveRels.add(candidate);
 								newReflexive = false;
 							}
@@ -140,7 +139,6 @@ public class AshwoodEntitySorter implements EntitySorter {
 					}
 				}
 			}
-
 		}
 
 		StrongConnection<DbEntity, List<DbAttribute>> contractor = new StrongConnection<>(referentialDigraph);
@@ -178,51 +176,58 @@ public class AshwoodEntitySorter implements EntitySorter {
 	@Override
 	public void sortDbEntities(List<DbEntity> dbEntities, boolean deleteOrder) {
 		indexSorter();
-		Collections.sort(dbEntities, getDbEntityComparator(deleteOrder));
+		dbEntities.sort(getDbEntityComparator(deleteOrder));
 	}
 
 	@Override
 	public void sortObjEntities(List<ObjEntity> objEntities, boolean deleteOrder) {
 		indexSorter();
-		Collections.sort(objEntities, getObjEntityComparator(deleteOrder));
+		objEntities.sort(getObjEntityComparator(deleteOrder));
 	}
 
+	@SuppressWarnings("unchecked")
 	@Override
 	public void sortObjectsForEntity(ObjEntity objEntity, List<?> objects, boolean deleteOrder) {
+		if(objects == null || objects.size() == 0) {
+			return;
+		}
 
 		indexSorter();
-
-		List<Persistent> persistent = (List<Persistent>) objects;
-
 		DbEntity dbEntity = objEntity.getDbEntity();
-
 		// if no sorting is required
 		if (!isReflexive(dbEntity)) {
 			return;
 		}
 
-		int size = persistent.size();
-		if (size == 0) {
-			return;
+		Object probe = objects.get(0);
+		if (probe instanceof DbRowOp) {
+			sortObjectsForEntity(objEntity, (List<DbRowOp>) objects, deleteOrder, DbRowOp::getObject);
+		} else if(probe instanceof Persistent) {
+			sortObjectsForEntity(objEntity, (List<Persistent>) objects, deleteOrder, Function.identity());
+		} else {
+			throw new IllegalArgumentException("Can sort only Persistent or DbRow objects, got " + probe.getClass().getSimpleName());
 		}
+	}
 
-		EntityResolver resolver = persistent.get(0).getObjectContext().getEntityResolver();
-		ClassDescriptor descriptor = resolver.getClassDescriptor(objEntity.getName());
+	protected <E> void sortObjectsForEntity(ObjEntity objEntity, List<E> objects, boolean deleteOrder, Function<E, Persistent> converter) {
+		Digraph<E, Boolean> objectDependencyGraph = buildDigraph(objEntity, objects, converter);
 
-		List<DbRelationship> reflexiveRels = reflexiveDbEntities.get(dbEntity);
-		String[] reflexiveRelNames = new String[reflexiveRels.size()];
-		for (int i = 0; i < reflexiveRelNames.length; i++) {
-			DbRelationship dbRel = reflexiveRels.get(i);
-			ObjRelationship objRel = (dbRel != null ? objEntity.getRelationshipForDbRelationship(dbRel) : null);
-			reflexiveRelNames[i] = (objRel != null ? objRel.getName() : null);
+		if(!topologicalSort(objects, objectDependencyGraph, deleteOrder)) {
+			throw new CayenneRuntimeException("Sorting objects for %s failed. Cycles found."
+					, objEntity.getClassName());
 		}
+	}
 
-		List<Persistent> sorted = new ArrayList<>(size);
+	protected <E> Digraph<E, Boolean> buildDigraph(ObjEntity objEntity, List<E> objects, Function<E, Persistent> converter) {
+		EntityResolver resolver = converter.apply(objects.get(0)).getObjectContext().getEntityResolver();
+		ClassDescriptor descriptor = resolver.getClassDescriptor(objEntity.getName());
+		String[] reflexiveRelNames = getReflexiveRelationshipsNames(objEntity);
 
-		Digraph<Persistent, Boolean> objectDependencyGraph = new MapDigraph<>();
-		Object[] masters = new Object[reflexiveRelNames.length];
+		int size = objects.size();
+		Digraph<E, Boolean> objectDependencyGraph = new MapDigraph<>();
+		Persistent[] masters = new Persistent[reflexiveRelNames.length];
 		for (int i = 0; i < size; i++) {
-			Persistent current = (Persistent) objects.get(i);
+			E current = objects.get(i);
 			objectDependencyGraph.addVertex(current);
 			int actualMasterCount = 0;
 			for (int k = 0; k < reflexiveRelNames.length; k++) {
@@ -232,11 +237,12 @@ public class AshwoodEntitySorter implements EntitySorter {
 					continue;
 				}
 
-				masters[k] = descriptor.getProperty(reflexiveRelName).readProperty(current);
+				Persistent persistent = converter.apply(current);
+				masters[k] = (Persistent)descriptor.getProperty(reflexiveRelName).readProperty(persistent);
 
 				if (masters[k] == null) {
-					masters[k] = findReflexiveMaster(current, objEntity.getRelationship(reflexiveRelName), current
-							.getObjectId().getEntityName());
+					masters[k] = findReflexiveMaster(persistent, objEntity.getRelationship(reflexiveRelName)
+							, persistent.getObjectId().getEntityName());
 				}
 
 				if (masters[k] != null) {
@@ -251,23 +257,26 @@ public class AshwoodEntitySorter implements EntitySorter {
 					continue;
 				}
 
-				Persistent masterCandidate = persistent.get(j);
-				for (Object master : masters) {
-					if (masterCandidate == master) {
+				E masterCandidate = objects.get(j);
+				for (Persistent master : masters) {
+					if (converter.apply(masterCandidate) == master) {
 						objectDependencyGraph.putArc(masterCandidate, current, Boolean.TRUE);
 						mastersFound++;
 					}
 				}
 			}
 		}
+		return objectDependencyGraph;
+	}
 
-		IndegreeTopologicalSort<Persistent> sorter = new IndegreeTopologicalSort<>(objectDependencyGraph);
+	protected <E> boolean topologicalSort(List<E> data, Digraph<E, Boolean> graph, boolean reverse) {
+		IndegreeTopologicalSort<E> sorter = new IndegreeTopologicalSort<>(graph);
+		List<E> sorted = new ArrayList<>(data.size());
 
 		while (sorter.hasNext()) {
-			Persistent o = sorter.next();
+			E o = sorter.next();
 			if (o == null) {
-				throw new CayenneRuntimeException("Sorting objects for %s failed. Cycles found."
-						, objEntity.getClassName());
+				return false;
 			}
 			sorted.add(o);
 		}
@@ -275,25 +284,35 @@ public class AshwoodEntitySorter implements EntitySorter {
 		// since API requires sorting within the same array,
 		// simply replace all objects with objects in the right order...
 		// may come up with something cleaner later
-		persistent.clear();
-		persistent.addAll(sorted);
+		data.clear();
+		data.addAll(sorted);
 
-		if (deleteOrder) {
-			Collections.reverse(persistent);
+		if (reverse) {
+			Collections.reverse(data);
 		}
+		return true;
 	}
 
-	protected Object findReflexiveMaster(Persistent object, ObjRelationship toOneRel, String targetEntityName) {
+	protected String[] getReflexiveRelationshipsNames(ObjEntity objEntity) {
+		List<DbRelationship> reflexiveRels = reflexiveDbEntities.get(objEntity.getDbEntity());
+		String[] reflexiveRelNames = new String[reflexiveRels.size()];
+		for (int i = 0; i < reflexiveRelNames.length; i++) {
+			DbRelationship dbRel = reflexiveRels.get(i);
+			ObjRelationship objRel = (dbRel != null ? objEntity.getRelationshipForDbRelationship(dbRel) : null);
+			reflexiveRelNames[i] = (objRel != null ? objRel.getName() : null);
+		}
+		return reflexiveRelNames;
+	}
+
+	protected Persistent findReflexiveMaster(Persistent object, ObjRelationship toOneRel, String targetEntityName) {
 
 		DbRelationship finalRel = toOneRel.getDbRelationships().get(0);
 		ObjectContext context = object.getObjectContext();
 
-		// find committed snapshot - so we can't fetch from the context as it
-		// will return
-		// dirty snapshot; must go down the stack instead
+		// find committed snapshot - so we can't fetch from the context as it will return dirty snapshot;
+		// must go down the stack instead
 
-		// how do we handle this for NEW objects correctly? For now bail from
-		// the method
+		// how do we handle this for NEW objects correctly? For now bail from the method
 		if (object.getObjectId().isTemporary()) {
 			return null;
 		}
@@ -309,12 +328,21 @@ public class AshwoodEntitySorter implements EntitySorter {
 
 		ObjectId id = snapshot.createTargetObjectId(targetEntityName, finalRel);
 
-		// not using 'localObject', looking up in context instead, as within the
-		// sorter
-		// we only care about objects participating in transaction, so no need
-		// to create
-		// hollow objects
-		return (id != null) ? context.getGraphManager().getNode(id) : null;
+		// not using 'localObject', looking up in context instead, as within the sorter
+		// we only care about objects participating in transaction, so no need to create hollow objects
+		return (id != null) ? (Persistent) context.getGraphManager().getNode(id) : null;
+	}
+
+	@Override
+	public Comparator<DbEntity> getDbEntityComparator() {
+		indexSorter();
+		return dbEntityComparator;
+	}
+
+	@Override
+	public Comparator<ObjEntity> getObjEntityComparator() {
+		indexSorter();
+		return objEntityComparator;
 	}
 
 	protected Comparator<DbEntity> getDbEntityComparator(boolean dependantFirst) {
@@ -333,7 +361,9 @@ public class AshwoodEntitySorter implements EntitySorter {
 		return c;
 	}
 
-	protected boolean isReflexive(DbEntity metadata) {
+	@Override
+	public boolean isReflexive(DbEntity metadata) {
+		indexSorter();
 		return reflexiveDbEntities.containsKey(metadata);
 	}
 
@@ -378,7 +408,7 @@ public class AshwoodEntitySorter implements EntitySorter {
 				int index1 = rec1.index;
 				int index2 = rec2.index;
 
-				int result = index1 > index2 ? 1 : (index1 < index2 ? -1 : 0);
+				int result = Integer.compare(index1, index2);
 
 				// TODO: is this check really needed?
 				if (result != 0 && rec1.component == rec2.component) {
diff --git a/cayenne-server/src/main/java/org/apache/cayenne/map/DbEntity.java b/cayenne-server/src/main/java/org/apache/cayenne/map/DbEntity.java
index d92a7d4..beb4764 100644
--- a/cayenne-server/src/main/java/org/apache/cayenne/map/DbEntity.java
+++ b/cayenne-server/src/main/java/org/apache/cayenne/map/DbEntity.java
@@ -25,6 +25,7 @@ import java.util.Collections;
 import java.util.HashSet;
 import java.util.Iterator;
 import java.util.LinkedList;
+import java.util.List;
 import java.util.Map;
 import java.util.TreeMap;
 import java.util.function.Function;
@@ -56,7 +57,7 @@ public class DbEntity extends Entity implements ConfigurationNode, DbEntityListe
 
     protected String catalog;
     protected String schema;
-    protected Collection<DbAttribute> primaryKey;
+    protected List<DbAttribute> primaryKey;
 
     /**
      * @since 1.2
@@ -184,8 +185,8 @@ public class DbEntity extends Entity implements ConfigurationNode, DbEntityListe
      *
      * @since 3.0
      */
-    public Collection<DbAttribute> getPrimaryKeys() {
-        return Collections.unmodifiableCollection(primaryKey);
+    public List<DbAttribute> getPrimaryKeys() {
+        return Collections.unmodifiableList(primaryKey);
     }
 
     /**
diff --git a/cayenne-server/src/main/java/org/apache/cayenne/map/EntitySorter.java b/cayenne-server/src/main/java/org/apache/cayenne/map/EntitySorter.java
index 5c7981e..331f3bc 100644
--- a/cayenne-server/src/main/java/org/apache/cayenne/map/EntitySorter.java
+++ b/cayenne-server/src/main/java/org/apache/cayenne/map/EntitySorter.java
@@ -19,6 +19,7 @@
 
 package org.apache.cayenne.map;
 
+import java.util.Comparator;
 import java.util.List;
 
 /**
@@ -50,4 +51,24 @@ public interface EntitySorter {
      * Sorts a list of objects belonging to the ObjEntity.
      */
     void sortObjectsForEntity(ObjEntity entity, List<?> objects, boolean deleteOrder);
+
+    /**
+     * @return comparator for {@link DbEntity}
+     * @since 4.2
+     */
+    Comparator<DbEntity> getDbEntityComparator();
+
+    /**
+     * @return comparator for {@link ObjEntity}
+     * @since 4.2
+     */
+    Comparator<ObjEntity> getObjEntityComparator();
+
+    /**
+     * @param entity to check
+     * @return is entity has reflexive relationships
+     *
+     * @since 4.2
+     */
+    boolean isReflexive(DbEntity entity);
 }
diff --git a/cayenne-server/src/main/java/org/apache/cayenne/query/DeleteBatchQuery.java b/cayenne-server/src/main/java/org/apache/cayenne/query/DeleteBatchQuery.java
index b58d11c..0ec1721 100644
--- a/cayenne-server/src/main/java/org/apache/cayenne/query/DeleteBatchQuery.java
+++ b/cayenne-server/src/main/java/org/apache/cayenne/query/DeleteBatchQuery.java
@@ -22,6 +22,7 @@ package org.apache.cayenne.query;
 import java.util.Collection;
 import java.util.List;
 import java.util.Map;
+import java.util.function.Supplier;
 
 import org.apache.cayenne.map.DbAttribute;
 import org.apache.cayenne.map.DbEntity;
@@ -91,7 +92,11 @@ public class DeleteBatchQuery extends BatchQuery {
         rows.add(new BatchQueryRow(null, dataObjectId) {
             @Override
             public Object getValue(int i) {
-                return qualifier.get(dbAttributes.get(i).getName());
+                Object value = qualifier.get(dbAttributes.get(i).getName());
+                if(value instanceof Supplier) {
+                    return ((Supplier) value).get();
+                }
+                return value;
             }
         });
     }


[cayenne] 02/06: CAY-2571 DataDomainFlushAction redesign tests

Posted by nt...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

ntimofeev pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/cayenne.git

commit 99175f9042524a9314c64eab4664865a12750415
Author: Nikita Timofeev <st...@gmail.com>
AuthorDate: Mon Apr 22 18:02:20 2019 +0300

    CAY-2571 DataDomainFlushAction redesign
    tests
---
 .../access/flush/ArcValuesCreationHandlerTest.java | 210 ++++++++++++++++++++
 .../flush/DefaultDataDomainFlushActionTest.java    | 158 +++++++++++++++
 .../access/flush/DefaultDbRowOpSorterTest.java     | 215 +++++++++++++++++++++
 .../access/flush/operation/BaseDbRowOpTest.java    | 125 ++++++++++++
 .../access/flush/operation/DbRowOpMergerTest.java  | 177 +++++++++++++++++
 .../access/flush/operation/QualifierTest.java      | 171 ++++++++++++++++
 .../cayenne/access/flush/operation/ValuesTest.java | 109 +++++++++++
 7 files changed, 1165 insertions(+)

diff --git a/cayenne-server/src/test/java/org/apache/cayenne/access/flush/ArcValuesCreationHandlerTest.java b/cayenne-server/src/test/java/org/apache/cayenne/access/flush/ArcValuesCreationHandlerTest.java
new file mode 100644
index 0000000..73a7afb
--- /dev/null
+++ b/cayenne-server/src/test/java/org/apache/cayenne/access/flush/ArcValuesCreationHandlerTest.java
@@ -0,0 +1,210 @@
+/*****************************************************************
+ *   Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ ****************************************************************/
+
+package org.apache.cayenne.access.flush;
+
+import java.util.Collections;
+import java.util.Map;
+
+import org.apache.cayenne.ObjectId;
+import org.apache.cayenne.Persistent;
+import org.apache.cayenne.access.ObjectDiff;
+import org.apache.cayenne.access.ObjectStore;
+import org.apache.cayenne.access.flush.operation.DbRowOpType;
+import org.apache.cayenne.access.flush.operation.DbRowOpVisitor;
+import org.apache.cayenne.access.flush.operation.InsertDbRowOp;
+import org.apache.cayenne.access.flush.operation.Values;
+import org.apache.cayenne.map.DbAttribute;
+import org.apache.cayenne.map.DbJoin;
+import org.apache.cayenne.map.DbRelationship;
+import org.apache.cayenne.map.ObjEntity;
+import org.apache.cayenne.map.ObjRelationship;
+import org.apache.cayenne.reflect.ClassDescriptor;
+import org.junit.Before;
+import org.junit.Test;
+
+import static org.junit.Assert.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+/**
+ * @since 4.2
+ */
+public class ArcValuesCreationHandlerTest {
+
+    private ArcValuesCreationHandler handler;
+    private DbRowOpFactory factory;
+    private InsertDbRowOp dbRowOp;
+    private Values values;
+
+    @SuppressWarnings("unchecked")
+    @Before
+    public void setup() {
+        factory = mock(DbRowOpFactory.class);
+        handler = new ArcValuesCreationHandler(factory, DbRowOpType.INSERT);
+        dbRowOp = mock(InsertDbRowOp.class);
+        values = new Values(dbRowOp, false);
+
+        ObjectDiff diff = mock(ObjectDiff.class);
+        ClassDescriptor descriptor = mock(ClassDescriptor.class);
+        ObjEntity entity = mock(ObjEntity.class);
+        ObjRelationship relationship = mock(ObjRelationship.class);
+        DbRelationship dbRelationship = mock(DbRelationship.class);
+        ObjectStore store = mock(ObjectStore.class);
+        Persistent object = mock(Persistent.class);
+
+        when(relationship.getDbRelationships()).thenReturn(Collections.singletonList(dbRelationship));
+        when(entity.getRelationship(anyString())).thenReturn(relationship);
+        when(descriptor.getEntity()).thenReturn(entity);
+        when(dbRowOp.accept(any(DbRowOpVisitor.class))).thenCallRealMethod();
+        when(dbRowOp.getValues()).thenReturn(values);
+        when(factory.getDiff()).thenReturn(diff);
+        when(factory.getDescriptor()).thenReturn(descriptor);
+        when(factory.getStore()).thenReturn(store);
+        when(factory.getObject()).thenReturn(object);
+        when(factory.getOrCreate(isNull(), any(ObjectId.class), any(DbRowOpType.class))).thenReturn(dbRowOp);
+    }
+
+    @Test
+    public void processRelationshipPkPkMaster() {
+        ObjectId srcId = ObjectId.of("test1", "id1", 1);
+        ObjectId targetId = ObjectId.of("test2", "id2", 2);
+
+        DbRelationship relationship = DbRelBuilder.of("id1", "id2")
+                .withToDepPk().withDstPk().withSrcPk().build();
+
+        handler.processRelationship(relationship, srcId, targetId, true);
+
+        assertNotNull(handler);
+        verify(factory).getOrCreate(isNull(), eq(targetId), eq(DbRowOpType.UPDATE));
+        assertTrue(targetId.isReplacementIdAttached());
+        assertEquals(1, targetId.getReplacementIdMap().size());
+        assertEquals(1, targetId.getReplacementIdMap().get("id2"));
+        assertFalse(srcId.isReplacementIdAttached());
+    }
+
+    @Test
+    public void processRelationshipPkPkDependent() {
+        ObjectId srcId = ObjectId.of("test1", "id1", 1);
+        ObjectId targetId = ObjectId.of("test2", "id2", 2);
+
+        DbRelationship relationship = DbRelBuilder.of("id1", "id2")
+                .withDstPk().withSrcPk().build();
+
+        handler.processRelationship(relationship, srcId, targetId, true);
+
+        assertNotNull(handler);
+        verify(factory).getOrCreate(isNull(), eq(srcId), eq(DbRowOpType.INSERT));
+        assertTrue(srcId.isReplacementIdAttached());
+        assertEquals(1, srcId.getReplacementIdMap().size());
+        assertEquals(2, srcId.getReplacementIdMap().get("id1"));
+        assertFalse(targetId.isReplacementIdAttached());
+    }
+
+    @Test
+    public void processRelationshipPkFkMaster() {
+        ObjectId srcId = ObjectId.of("test1", "pk", 1);
+        ObjectId targetId = ObjectId.of("test2", "id2", 2);
+
+        DbRelationship relationship = DbRelBuilder.of("pk", "fk")
+                .withSrcPk().build();
+
+        handler.processRelationship(relationship, srcId, targetId, true);
+
+        assertNotNull(handler);
+        verify(factory).getOrCreate(isNull(), eq(targetId), eq(DbRowOpType.UPDATE));
+        assertFalse(srcId.isReplacementIdAttached());
+        assertFalse(targetId.isReplacementIdAttached());
+
+        verify(dbRowOp).getValues();
+        Map<String, Object> snapshot = values.getSnapshot();
+        assertEquals(1, snapshot.size());
+        assertEquals(1, snapshot.get("fk"));
+    }
+
+    @Test
+    public void processRelationshipFkPkDependent() {
+        ObjectId srcId = ObjectId.of("test1", "id1", 1);
+        ObjectId targetId = ObjectId.of("test2", "pk", 2);
+
+        DbRelationship relationship = DbRelBuilder.of("fk", "pk")
+                .withDstPk().build();
+
+        handler.processRelationship(relationship, srcId, targetId, true);
+
+        assertNotNull(handler);
+        verify(factory).getOrCreate(isNull(), eq(srcId), eq(DbRowOpType.INSERT));
+        assertFalse(srcId.isReplacementIdAttached());
+        assertFalse(targetId.isReplacementIdAttached());
+
+        verify(dbRowOp).getValues();
+        Map<String, Object> snapshot = values.getSnapshot();
+        assertEquals(1, snapshot.size());
+        assertEquals(2, snapshot.get("fk"));
+    }
+
+    final static class DbRelBuilder {
+        private String srcName;
+        private String dstName;
+        private boolean srcPk;
+        private boolean dstPk;
+        private boolean toDepPk;
+
+        static DbRelBuilder of(String srcName, String dstName) {
+            DbRelBuilder builder = new DbRelBuilder();
+            builder.srcName = srcName;
+            builder.dstName = dstName;
+            return builder;
+        }
+
+        DbRelBuilder withSrcPk() {
+            srcPk = true;
+            return this;
+        }
+
+        DbRelBuilder withDstPk() {
+            dstPk = true;
+            return this;
+        }
+
+        DbRelBuilder withToDepPk() {
+            toDepPk = true;
+            return this;
+        }
+
+        DbRelationship build() {
+            DbRelationship relationship = mock(DbRelationship.class);
+            when(relationship.isToDependentPK()).thenReturn(toDepPk);
+            DbJoin join = mock(DbJoin.class);
+            DbAttribute src = new DbAttribute(srcName);
+            src.setPrimaryKey(srcPk);
+            DbAttribute target = new DbAttribute(dstName);
+            target.setPrimaryKey(dstPk);
+            when(join.getSource()).thenReturn(src);
+            when(join.getSourceName()).thenReturn(src.getName());
+            when(join.getTarget()).thenReturn(target);
+            when(join.getTargetName()).thenReturn(target.getName());
+            when(relationship.getJoins()).thenReturn(Collections.singletonList(join));
+
+            DbRelationship mockRel = mock(DbRelationship.class);
+            when(relationship.getReverseRelationship()).thenReturn(mockRel);
+            return relationship;
+        }
+    }
+}
diff --git a/cayenne-server/src/test/java/org/apache/cayenne/access/flush/DefaultDataDomainFlushActionTest.java b/cayenne-server/src/test/java/org/apache/cayenne/access/flush/DefaultDataDomainFlushActionTest.java
new file mode 100644
index 0000000..69a927f
--- /dev/null
+++ b/cayenne-server/src/test/java/org/apache/cayenne/access/flush/DefaultDataDomainFlushActionTest.java
@@ -0,0 +1,158 @@
+/*****************************************************************
+ *   Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ ****************************************************************/
+
+package org.apache.cayenne.access.flush;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.List;
+
+import org.apache.cayenne.ObjectId;
+import org.apache.cayenne.PersistenceState;
+import org.apache.cayenne.Persistent;
+import org.apache.cayenne.access.flush.DefaultDataDomainFlushAction;
+import org.apache.cayenne.access.flush.operation.BaseDbRowOp;
+import org.apache.cayenne.access.flush.operation.DbRowOp;
+import org.apache.cayenne.access.flush.operation.DeleteDbRowOp;
+import org.apache.cayenne.access.flush.operation.InsertDbRowOp;
+import org.apache.cayenne.access.flush.operation.UpdateDbRowOp;
+import org.apache.cayenne.map.DbAttribute;
+import org.apache.cayenne.map.DbEntity;
+import org.apache.cayenne.query.DeleteBatchQuery;
+import org.apache.cayenne.query.InsertBatchQuery;
+import org.apache.cayenne.query.Query;
+import org.junit.Test;
+
+import static org.hamcrest.CoreMatchers.*;
+import static org.junit.Assert.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+
+/**
+ * @since 4.2
+ */
+public class DefaultDataDomainFlushActionTest {
+
+    @Test
+    public void mergeSameObjectIds() {
+        ObjectId id1  = ObjectId.of("test2", "id", 1);
+        ObjectId id2  = ObjectId.of("test",  "id", 2);
+        ObjectId id3  = ObjectId.of("test",  "id", 2);
+        ObjectId id4  = ObjectId.of("test",  "id", 3);
+        ObjectId id5  = ObjectId.of("test2", "id", 4);
+        ObjectId id6  = ObjectId.of("test",  "id", 5);
+        ObjectId id7  = ObjectId.of("test",  "id", 6);
+        ObjectId id8  = ObjectId.of("test2", "id", 3);
+        ObjectId id9  = ObjectId.of("test2", "id", 4);
+        ObjectId id10 = ObjectId.of("test",  "id", 6);
+
+        DbEntity test = mockEntity("test");
+        DbEntity test2 = mockEntity("test2");
+        BaseDbRowOp[] op = new BaseDbRowOp[10];
+        op[0] = new InsertDbRowOp(mockObject(id1),  test2, id1); // +
+        op[1] = new InsertDbRowOp(mockObject(id2),  test,  id2); // -
+        op[2] = new DeleteDbRowOp(mockObject(id3),  test,  id3); // -
+        op[3] = new UpdateDbRowOp(mockObject(id4),  test,  id4); // +
+        op[4] = new InsertDbRowOp(mockObject(id5),  test2, id5); // -
+        op[5] = new DeleteDbRowOp(mockObject(id6),  test,  id6); // +
+        op[6] = new InsertDbRowOp(mockObject(id7),  test,  id7); // -
+        op[7] = new UpdateDbRowOp(mockObject(id8),  test2, id8); // +
+        op[8] = new DeleteDbRowOp(mockObject(id9),  test2, id9); // -
+        op[9] = new DeleteDbRowOp(mockObject(id10), test,  id10);// -
+
+        DefaultDataDomainFlushAction action = mock(DefaultDataDomainFlushAction.class);
+        when(action.mergeSameObjectIds((List<DbRowOp>) any(List.class))).thenCallRealMethod();
+
+        Collection<DbRowOp> merged = action.mergeSameObjectIds(new ArrayList<>(Arrays.asList(op)));
+        assertEquals(7, merged.size());
+        assertThat(merged, hasItems(op[0], op[3], op[5], op[7]));
+        assertThat(merged, not(hasItem(sameInstance(op[1]))));
+        assertThat(merged, not(hasItem(sameInstance(op[2]))));
+        assertThat(merged, not(hasItem(sameInstance(op[4]))));
+        assertThat(merged, not(hasItem(sameInstance(op[6]))));
+        assertThat(merged, not(hasItem(sameInstance(op[8]))));
+        assertThat(merged, not(hasItem(sameInstance(op[9]))));
+    }
+
+    @Test
+    public void createQueries() {
+        ObjectId id1  = ObjectId.of("test",  "id", 1);
+        ObjectId id2  = ObjectId.of("test",  "id", 2);
+        ObjectId id3  = ObjectId.of("test2", "id", 3);
+        ObjectId id4  = ObjectId.of("test2", "id", 4);
+        ObjectId id5  = ObjectId.of("test",  "id", 5);
+        ObjectId id6  = ObjectId.of("test2", "id", 6);
+        ObjectId id7  = ObjectId.of("test",  "id", 7);
+
+        DbEntity test = mockEntity("test");
+        DbEntity test2 = mockEntity("test2");
+
+        List<DbRowOp> ops = new ArrayList<>();
+        ops.add(new InsertDbRowOp(mockObject(id1),  test,  id1));
+        ops.add(new InsertDbRowOp(mockObject(id2),  test,  id2));
+        ops.add(new InsertDbRowOp(mockObject(id3),  test2, id5));
+        ops.add(new InsertDbRowOp(mockObject(id4),  test2, id7));
+        ops.add(new UpdateDbRowOp(mockObject(id5),  test,  id3));
+        ops.add(new DeleteDbRowOp(mockObject(id6),  test2, id6));
+        ops.add(new DeleteDbRowOp(mockObject(id7),  test,  id4));
+
+        DefaultDataDomainFlushAction action = mock(DefaultDataDomainFlushAction.class);
+        when(action.createQueries((List<DbRowOp>) any(List.class))).thenCallRealMethod();
+
+        List<? extends Query> queries = action.createQueries(ops);
+        assertEquals(4, queries.size());
+        assertThat(queries.get(0), instanceOf(InsertBatchQuery.class));
+        InsertBatchQuery insert1 = (InsertBatchQuery)queries.get(0);
+        assertSame(test, insert1.getDbEntity());
+        assertEquals(2, insert1.getRows().size());
+
+        assertThat(queries.get(1), instanceOf(InsertBatchQuery.class));
+        InsertBatchQuery insert2 = (InsertBatchQuery)queries.get(1);
+        assertSame(test2, insert2.getDbEntity());
+        assertEquals(2, insert2.getRows().size());
+
+        assertThat(queries.get(2), instanceOf(DeleteBatchQuery.class));
+        DeleteBatchQuery delete1 = (DeleteBatchQuery)queries.get(2);
+        assertSame(test2, delete1.getDbEntity());
+        assertEquals(1, delete1.getRows().size());
+
+        assertThat(queries.get(3), instanceOf(DeleteBatchQuery.class));
+        DeleteBatchQuery delete2 = (DeleteBatchQuery)queries.get(3);
+        assertSame(test, delete2.getDbEntity());
+        assertEquals(1, delete2.getRows().size());
+    }
+
+    private Persistent mockObject(ObjectId id) {
+        Persistent persistent = mock(Persistent.class);
+        when(persistent.getObjectId()).thenReturn(id);
+        when(persistent.getPersistenceState()).thenReturn(PersistenceState.MODIFIED);
+        return persistent;
+    }
+
+    private DbEntity mockEntity(String name) {
+        DbAttribute attribute1 = new DbAttribute("id");
+        attribute1.setPrimaryKey(true);
+        DbAttribute attribute2 = new DbAttribute("attr");
+        DbEntity testEntity = new DbEntity(name);
+        testEntity.addAttribute(attribute1);
+        testEntity.addAttribute(attribute2);
+        return testEntity;
+    }
+}
\ No newline at end of file
diff --git a/cayenne-server/src/test/java/org/apache/cayenne/access/flush/DefaultDbRowOpSorterTest.java b/cayenne-server/src/test/java/org/apache/cayenne/access/flush/DefaultDbRowOpSorterTest.java
new file mode 100644
index 0000000..b332d03
--- /dev/null
+++ b/cayenne-server/src/test/java/org/apache/cayenne/access/flush/DefaultDbRowOpSorterTest.java
@@ -0,0 +1,215 @@
+/*****************************************************************
+ *   Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ ****************************************************************/
+
+package org.apache.cayenne.access.flush;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Comparator;
+import java.util.List;
+
+import org.apache.cayenne.ObjectId;
+import org.apache.cayenne.PersistenceState;
+import org.apache.cayenne.Persistent;
+import org.apache.cayenne.access.DataDomain;
+import org.apache.cayenne.access.flush.operation.DefaultDbRowOpSorter;
+import org.apache.cayenne.access.flush.operation.BaseDbRowOp;
+import org.apache.cayenne.access.flush.operation.DbRowOp;
+import org.apache.cayenne.access.flush.operation.DbRowOpSorter;
+import org.apache.cayenne.access.flush.operation.DeleteDbRowOp;
+import org.apache.cayenne.access.flush.operation.InsertDbRowOp;
+import org.apache.cayenne.access.flush.operation.UpdateDbRowOp;
+import org.apache.cayenne.map.DbAttribute;
+import org.apache.cayenne.map.DbEntity;
+import org.apache.cayenne.map.EntityResolver;
+import org.apache.cayenne.map.EntitySorter;
+import org.junit.Before;
+import org.junit.Test;
+
+import static org.junit.Assert.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+/**
+ * @since 4.2
+ */
+public class DefaultDbRowOpSorterTest {
+
+    private EntitySorter entitySorter;
+    private DbRowOpSorter sorter;
+
+    @Before
+    public void createSorter() {
+        entitySorter = mock(EntitySorter.class);
+        EntityResolver entityResolver = mock(EntityResolver.class);
+
+        when(entitySorter.getDbEntityComparator())
+                .thenReturn(Comparator.comparing(DbEntity::getName));
+        when(entitySorter.isReflexive(argThat(ent -> ent.getName().equals("reflexive"))))
+                .thenReturn(true);
+
+        DataDomain dataDomain = mock(DataDomain.class);
+        when(dataDomain.getEntitySorter()).thenReturn(entitySorter);
+        when(dataDomain.getEntityResolver()).thenReturn(entityResolver);
+
+        sorter = new DefaultDbRowOpSorter(() -> dataDomain);
+    }
+
+    @Test
+    public void sortEmptyList() {
+        List<DbRowOp> rows = new ArrayList<>();
+        List<DbRowOp> sorted = sorter.sort(rows);
+        assertTrue(sorted.isEmpty());
+    }
+
+    @Test
+    public void sortByOpType() {
+        ObjectId id1 = ObjectId.of("test", "id", 1);
+        ObjectId id2 = ObjectId.of("test", "id", 2);
+        ObjectId id3 = ObjectId.of("test", "id", 3);
+        ObjectId id4 = ObjectId.of("test", "id", 4);
+        ObjectId id5 = ObjectId.of("test", "id", 5);
+        ObjectId id6 = ObjectId.of("test", "id", 6);
+
+        DbEntity test = mockEntity("test");
+        DbRowOp op1 = new InsertDbRowOp(mockObject(id1), test, id1);
+        DbRowOp op2 = new UpdateDbRowOp(mockObject(id2), test, id2);
+        DbRowOp op3 = new DeleteDbRowOp(mockObject(id3), test, id3);
+        DbRowOp op4 = new InsertDbRowOp(mockObject(id4), test, id4);
+        DbRowOp op5 = new UpdateDbRowOp(mockObject(id5), test, id5);
+        DbRowOp op6 = new DeleteDbRowOp(mockObject(id6), test, id6);
+
+        List<DbRowOp> rows = Arrays.asList(op1, op2, op3, op4, op5, op6);
+        List<DbRowOp> expected = Arrays.asList(op1, op4, op2, op5, op3, op6);
+
+        List<DbRowOp> sorted = sorter.sort(rows);
+        assertEquals(expected, sorted);
+    }
+
+    @Test
+    public void sortByOpEntity() {
+        ObjectId id1 = ObjectId.of("test4", "id", 1);
+        ObjectId id2 = ObjectId.of("test2", "id", 2);
+        ObjectId id3 = ObjectId.of("test3", "id", 3);
+        ObjectId id4 = ObjectId.of("test1", "id", 4);
+
+        DbRowOp op1 = new InsertDbRowOp(mockObject(id1), mockEntity("test4"), id1);
+        DbRowOp op2 = new InsertDbRowOp(mockObject(id2), mockEntity("test2"), id2);
+        DbRowOp op3 = new InsertDbRowOp(mockObject(id3), mockEntity("test3"), id3);
+        DbRowOp op4 = new InsertDbRowOp(mockObject(id4), mockEntity("test1"), id4);
+
+        List<DbRowOp> rows = Arrays.asList(op1, op2, op3, op4);
+        List<DbRowOp> expected = Arrays.asList(op4, op2, op3, op1);
+
+        List<DbRowOp> sorted = sorter.sort(rows);
+        assertEquals(expected, sorted);
+    }
+
+    @Test
+    public void sortById() {
+        ObjectId id1 = ObjectId.of("test", "id", 1);
+        ObjectId id2 = ObjectId.of("test", "id", 2);
+        ObjectId id3 = ObjectId.of("test", "id", 2);
+        ObjectId id4 = ObjectId.of("test", "id", 3);
+
+        DbEntity test = mockEntity("test");
+        InsertDbRowOp op1 = new InsertDbRowOp(mockObject(id1), test, id1);
+        InsertDbRowOp op2 = new InsertDbRowOp(mockObject(id2), test, id2);
+        DeleteDbRowOp op3 = new DeleteDbRowOp(mockObject(id3), test, id3);
+        DeleteDbRowOp op4 = new DeleteDbRowOp(mockObject(id4), test, id4);
+
+        List<DbRowOp> rows = Arrays.asList(op1, op2, op3, op4);
+        List<DbRowOp> expected = Arrays.asList(op1, op2, op3, op4);
+
+        List<DbRowOp> sorted = sorter.sort(rows);
+        assertEquals(expected, sorted);
+    }
+
+    @Test
+    public void sortByIdDifferentEntities() {
+        ObjectId id1  = ObjectId.of("test2", "id", 1);
+        ObjectId id2  = ObjectId.of("test",  "id", 2);
+        ObjectId id3  = ObjectId.of("test",  "id", 2);
+        ObjectId id4  = ObjectId.of("test",  "id", 3);
+        ObjectId id5  = ObjectId.of("test2", "id", 4);
+        ObjectId id6  = ObjectId.of("test",  "id", 5);
+        ObjectId id7  = ObjectId.of("test",  "id", 8);
+        ObjectId id8  = ObjectId.of("test2", "id", 7);
+        ObjectId id9  = ObjectId.of("test2", "id", 4);
+        ObjectId id10 = ObjectId.of("test",  "id", 8);
+
+        DbEntity test = mockEntity("test");
+        DbEntity test2 = mockEntity("test2");
+        BaseDbRowOp[] op = new BaseDbRowOp[10];
+        op[0] = new InsertDbRowOp(mockObject(id1),  test2, id1);
+        op[1] = new InsertDbRowOp(mockObject(id2),  test,  id2);
+        op[2] = new DeleteDbRowOp(mockObject(id3),  test,  id3);
+        op[3] = new UpdateDbRowOp(mockObject(id4),  test,  id4);
+        op[4] = new InsertDbRowOp(mockObject(id5),  test2, id5);
+        op[5] = new DeleteDbRowOp(mockObject(id6),  test,  id6);
+        op[6] = new InsertDbRowOp(mockObject(id7),  test,  id7);
+        op[7] = new UpdateDbRowOp(mockObject(id8),  test2, id8);
+        op[8] = new DeleteDbRowOp(mockObject(id9),  test2, id9);
+        op[9] = new DeleteDbRowOp(mockObject(id10), test,  id10);
+
+        List<DbRowOp> expected = Arrays.asList(op[1], op[6], op[0], op[4], op[3], op[7], op[8], op[2], op[5], op[9]);
+        List<DbRowOp> sorted = sorter.sort(Arrays.asList(op));
+
+        assertEquals(expected, sorted);
+    }
+
+    @Test
+    public void sortReflexive() {
+        ObjectId id1 = ObjectId.of("reflexive", "id", 1);
+        ObjectId id2 = ObjectId.of("reflexive", "id", 2);
+        ObjectId id3 = ObjectId.of("reflexive", "id", 3);
+        ObjectId id4 = ObjectId.of("reflexive", "id", 4);
+
+        DbEntity reflexive = mockEntity("reflexive");
+        DbRowOp op1 = new InsertDbRowOp(mockObject(id1), reflexive, id1);
+        DbRowOp op2 = new InsertDbRowOp(mockObject(id2), reflexive, id2);
+        DbRowOp op3 = new InsertDbRowOp(mockObject(id3), reflexive, id3);
+        DbRowOp op4 = new InsertDbRowOp(mockObject(id4), reflexive, id4);
+
+        List<DbRowOp> rows = Arrays.asList(op1, op2, op3, op4);
+        List<DbRowOp> expected = Arrays.asList(op1, op2, op3, op4);
+
+        List<DbRowOp> sorted = sorter.sort(rows);
+        assertEquals(expected, sorted); // no actual sorting is done
+        verify(entitySorter) // should call entity sorter
+                .sortObjectsForEntity(isNull(), any(List.class), eq(false));
+    }
+
+    private Persistent mockObject(ObjectId id) {
+        Persistent persistent = mock(Persistent.class);
+        when(persistent.getObjectId()).thenReturn(id);
+        when(persistent.getPersistenceState()).thenReturn(PersistenceState.MODIFIED);
+        return persistent;
+    }
+
+    private DbEntity mockEntity(String name) {
+        DbAttribute attribute1 = new DbAttribute("id");
+        attribute1.setPrimaryKey(true);
+        DbAttribute attribute2 = new DbAttribute("attr");
+        DbEntity testEntity = new DbEntity(name);
+        testEntity.addAttribute(attribute1);
+        testEntity.addAttribute(attribute2);
+        return testEntity;
+    }
+}
\ No newline at end of file
diff --git a/cayenne-server/src/test/java/org/apache/cayenne/access/flush/operation/BaseDbRowOpTest.java b/cayenne-server/src/test/java/org/apache/cayenne/access/flush/operation/BaseDbRowOpTest.java
new file mode 100644
index 0000000..e0353ad
--- /dev/null
+++ b/cayenne-server/src/test/java/org/apache/cayenne/access/flush/operation/BaseDbRowOpTest.java
@@ -0,0 +1,125 @@
+/*****************************************************************
+ *   Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ ****************************************************************/
+
+package org.apache.cayenne.access.flush.operation;
+
+import org.apache.cayenne.ObjectId;
+import org.apache.cayenne.PersistenceState;
+import org.apache.cayenne.Persistent;
+import org.apache.cayenne.map.DbAttribute;
+import org.apache.cayenne.map.DbEntity;
+import org.junit.Test;
+
+import static org.junit.Assert.*;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * @since 4.2
+ */
+public class BaseDbRowOpTest {
+
+    @Test
+    public void testEquals_SameId() {
+        ObjectId id = ObjectId.of("test");
+
+        DbRowOp row1 = new InsertDbRowOp(mockObject(id), mockEntity(), id);
+        DbRowOp row2 = new InsertDbRowOp(mockObject(id), mockEntity(), id);
+
+        assertEquals(row1, row2);
+        assertEquals(row2, row1);
+    }
+
+    @Test
+    public void testEquals_EqualId() {
+        ObjectId id1 = ObjectId.of("test", "id", 1);
+        ObjectId id2 = ObjectId.of("test", "id", 1);
+
+        DbRowOp row1 = new InsertDbRowOp(mockObject(id1), mockEntity(), id1);
+        DbRowOp row2 = new InsertDbRowOp(mockObject(id2), mockEntity(), id2);
+
+        assertEquals(row1, row2);
+        assertEquals(row2, row1);
+    }
+
+    @Test
+    public void testNotEquals_EqualId() {
+        ObjectId id1 = ObjectId.of("test", "id", 1);
+        ObjectId id2 = ObjectId.of("test", "id", 1);
+
+        DbRowOp row1 = new InsertDbRowOp(mockObject(id1), mockEntity(), id1);
+        DbRowOp row2 = new DeleteDbRowOp(mockObject(id2), mockEntity(), id2);
+
+        assertNotEquals(row1, row2);
+        assertNotEquals(row2, row1);
+    }
+
+    @Test
+    public void testEqualsInsertUpdate_EqualId() {
+        ObjectId id1 = ObjectId.of("test", "id", 1);
+        ObjectId id2 = ObjectId.of("test", "id", 1);
+
+        DbRowOp row1 = new InsertDbRowOp(mockObject(id1), mockEntity(), id1);
+        DbRowOp row2 = new UpdateDbRowOp(mockObject(id2), mockEntity(), id2);
+
+        assertEquals(row1, row2);
+        assertEquals(row2, row1);
+    }
+
+    @Test
+    public void testEqualsUpdateDelete_EqualId() {
+        ObjectId id1 = ObjectId.of("test", "id", 1);
+        ObjectId id2 = ObjectId.of("test", "id", 1);
+
+        DbRowOp row1 = new DeleteDbRowOp(mockObject(id1), mockEntity(), id1);
+        DbRowOp row2 = new UpdateDbRowOp(mockObject(id2), mockEntity(), id2);
+
+        assertEquals(row1, row2);
+        assertEquals(row2, row1);
+    }
+
+    @Test
+    public void testNotEquals_NotEqualId() {
+        ObjectId id1 = ObjectId.of("test", "id", 1);
+        ObjectId id2 = ObjectId.of("test", "id", 2);
+
+        DbRowOp row1 = new InsertDbRowOp(mockObject(id1), mockEntity(), id1);
+        DbRowOp row2 = new InsertDbRowOp(mockObject(id2), mockEntity(), id2);
+
+        assertNotEquals(row1, row2);
+        assertNotEquals(row2, row1);
+    }
+
+    private Persistent mockObject(ObjectId id) {
+        Persistent persistent = mock(Persistent.class);
+        when(persistent.getObjectId()).thenReturn(id);
+        when(persistent.getPersistenceState()).thenReturn(PersistenceState.MODIFIED);
+        return persistent;
+    }
+
+    private DbEntity mockEntity() {
+        DbAttribute attribute1 = new DbAttribute("id");
+        attribute1.setPrimaryKey(true);
+        DbAttribute attribute2 = new DbAttribute("attr");
+        DbEntity testEntity = new DbEntity("TEST");
+        testEntity.addAttribute(attribute1);
+        testEntity.addAttribute(attribute2);
+        return testEntity;
+    }
+}
\ No newline at end of file
diff --git a/cayenne-server/src/test/java/org/apache/cayenne/access/flush/operation/DbRowOpMergerTest.java b/cayenne-server/src/test/java/org/apache/cayenne/access/flush/operation/DbRowOpMergerTest.java
new file mode 100644
index 0000000..0499704
--- /dev/null
+++ b/cayenne-server/src/test/java/org/apache/cayenne/access/flush/operation/DbRowOpMergerTest.java
@@ -0,0 +1,177 @@
+/*****************************************************************
+ *   Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ ****************************************************************/
+
+package org.apache.cayenne.access.flush.operation;
+
+import java.util.Map;
+
+import org.apache.cayenne.ObjectId;
+import org.apache.cayenne.PersistenceState;
+import org.apache.cayenne.Persistent;
+import org.apache.cayenne.map.DbAttribute;
+import org.apache.cayenne.map.DbEntity;
+import org.junit.Test;
+
+import static org.junit.Assert.*;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * @since 4.2
+ */
+public class DbRowOpMergerTest {
+
+    @Test
+    public void testMergeUpdateDelete() {
+        ObjectId id = ObjectId.of("test");
+
+        UpdateDbRowOp row1 = new UpdateDbRowOp(mockObject(id), mockEntity(), id);
+        DeleteDbRowOp row2 = new DeleteDbRowOp(mockObject(id), mockEntity(), id);
+
+        {
+            DbRowOpMerger merger = new DbRowOpMerger();
+            DbRowOp row = merger.apply(row1, row2);
+            assertSame(row2, row);
+        }
+
+        {
+            DbRowOpMerger merger = new DbRowOpMerger();
+            DbRowOp row = merger.apply(row2, row1);
+            assertSame(row2, row);
+        }
+    }
+
+    @Test
+    public void testMergeInsertDelete() {
+        ObjectId id = ObjectId.of("test");
+
+        InsertDbRowOp row1 = new InsertDbRowOp(mockObject(id), mockEntity(), id);
+        DeleteDbRowOp row2 = new DeleteDbRowOp(mockObject(id), mockEntity(), id);
+
+        {
+            DbRowOpMerger merger = new DbRowOpMerger();
+            DbRowOp row = merger.apply(row1, row2);
+            assertSame(row2, row);
+        }
+    }
+
+    @Test
+    public void testMergeUpdateInsert() {
+        ObjectId id = ObjectId.of("test");
+
+        UpdateDbRowOp row1 = new UpdateDbRowOp(mockObject(id), mockEntity(), id);
+        InsertDbRowOp row2 = new InsertDbRowOp(mockObject(id), mockEntity(), id);
+
+        {
+            DbRowOpMerger merger = new DbRowOpMerger();
+            DbRowOp row = merger.apply(row1, row2);
+            assertSame(row2, row);
+        }
+
+        {
+            DbRowOpMerger merger = new DbRowOpMerger();
+            DbRowOp row = merger.apply(row2, row1);
+            assertSame(row1, row);
+        }
+    }
+
+    @Test
+    public void testMergeInsertInsert() {
+        ObjectId id = ObjectId.of("test");
+
+        DbAttribute attr1 = new DbAttribute("attr1");
+        DbAttribute attr2 = new DbAttribute("attr2");
+
+        InsertDbRowOp row1 = new InsertDbRowOp(mockObject(id), mockEntity(), id);
+        row1.getValues().addValue(attr1, 1);
+        InsertDbRowOp row2 = new InsertDbRowOp(mockObject(id), mockEntity(), id);
+        row2.getValues().addValue(attr2, 2);
+
+        {
+            DbRowOpMerger merger = new DbRowOpMerger();
+            DbRowOp row = merger.apply(row1, row2);
+            assertSame(row2, row);
+            Map<String, Object> snapshot = ((InsertDbRowOp) row).getValues().getSnapshot();
+            assertEquals(2, snapshot.size());
+            assertEquals(1, snapshot.get("attr1"));
+            assertEquals(2, snapshot.get("attr2"));
+        }
+
+        {
+            DbRowOpMerger merger = new DbRowOpMerger();
+            DbRowOp row = merger.apply(row2, row1);
+            assertSame(row1, row);
+            Map<String, Object> snapshot = ((InsertDbRowOp) row).getValues().getSnapshot();
+            assertEquals(2, snapshot.size());
+            assertEquals(1, snapshot.get("attr1"));
+            assertEquals(2, snapshot.get("attr2"));
+        }
+    }
+
+    @Test
+    public void testMergeUpdateUpdate() {
+        ObjectId id = ObjectId.of("test");
+
+        DbAttribute attr1 = new DbAttribute("attr1");
+        DbAttribute attr2 = new DbAttribute("attr2");
+
+        UpdateDbRowOp row1 = new UpdateDbRowOp(mockObject(id), mockEntity(), id);
+        row1.getValues().addValue(attr1, 1);
+        UpdateDbRowOp row2 = new UpdateDbRowOp(mockObject(id), mockEntity(), id);
+        row2.getValues().addValue(attr2, 2);
+
+        {
+            DbRowOpMerger merger = new DbRowOpMerger();
+            DbRowOp row = merger.apply(row1, row2);
+            assertSame(row2, row);
+            Map<String, Object> snapshot = ((UpdateDbRowOp) row).getValues().getSnapshot();
+            assertEquals(2, snapshot.size());
+            assertEquals(1, snapshot.get("attr1"));
+            assertEquals(2, snapshot.get("attr2"));
+        }
+
+        {
+            DbRowOpMerger merger = new DbRowOpMerger();
+            DbRowOp row = merger.apply(row2, row1);
+            assertSame(row1, row);
+            Map<String, Object> snapshot = ((UpdateDbRowOp) row).getValues().getSnapshot();
+            assertEquals(2, snapshot.size());
+            assertEquals(1, snapshot.get("attr1"));
+            assertEquals(2, snapshot.get("attr2"));
+        }
+    }
+
+    private Persistent mockObject(ObjectId id) {
+        Persistent persistent = mock(Persistent.class);
+        when(persistent.getObjectId()).thenReturn(id);
+        when(persistent.getPersistenceState()).thenReturn(PersistenceState.MODIFIED);
+        return persistent;
+    }
+
+    private DbEntity mockEntity() {
+        DbAttribute attribute1 = new DbAttribute("id");
+        attribute1.setPrimaryKey(true);
+        DbAttribute attribute2 = new DbAttribute("attr");
+        DbEntity testEntity = new DbEntity("TEST");
+        testEntity.addAttribute(attribute1);
+        testEntity.addAttribute(attribute2);
+        return testEntity;
+    }
+
+}
\ No newline at end of file
diff --git a/cayenne-server/src/test/java/org/apache/cayenne/access/flush/operation/QualifierTest.java b/cayenne-server/src/test/java/org/apache/cayenne/access/flush/operation/QualifierTest.java
new file mode 100644
index 0000000..4eca80f
--- /dev/null
+++ b/cayenne-server/src/test/java/org/apache/cayenne/access/flush/operation/QualifierTest.java
@@ -0,0 +1,171 @@
+/*****************************************************************
+ *   Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ ****************************************************************/
+
+package org.apache.cayenne.access.flush.operation;
+
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+
+import org.apache.cayenne.ObjectId;
+import org.apache.cayenne.PersistenceState;
+import org.apache.cayenne.Persistent;
+import org.apache.cayenne.map.DbAttribute;
+import org.apache.cayenne.map.DbEntity;
+import org.junit.Test;
+
+import static org.junit.Assert.*;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * @since 4.2
+ */
+public class QualifierTest {
+
+    @Test
+    public void testScalarObjectIdQualifier() {
+        ObjectId id = ObjectId.of("test", "id", 123);
+        Persistent persistent = mockObject(id);
+        DbRowOp row = mockRow(persistent);
+
+        Qualifier qualifier = new Qualifier(row);
+        Map<String, Object> qualifierSnapshot = qualifier.getSnapshot();
+
+        assertEquals(Collections.singletonMap("id", 123), qualifierSnapshot);
+        assertFalse(qualifier.isUsingOptimisticLocking());
+
+        qualifierSnapshot = qualifier.getSnapshot();
+        assertEquals(Collections.singletonMap("id", 123), qualifierSnapshot);
+    }
+
+    @Test
+    public void testMapObjectIdQualifier() {
+        Map<String, Object> idMap = new HashMap<>();
+        idMap.put("id1", 123);
+        idMap.put("id2", 321);
+        ObjectId id = ObjectId.of("test", idMap);
+
+        Persistent persistent = mockObject(id);
+        DbRowOp row = mockRow(persistent);
+
+        Qualifier qualifier = new Qualifier(row);
+        Map<String, Object> qualifierSnapshot = qualifier.getSnapshot();
+        assertEquals(idMap, qualifierSnapshot);
+
+        qualifierSnapshot = qualifier.getSnapshot();
+        assertEquals(idMap, qualifierSnapshot);
+    }
+
+    @Test
+    public void testAdditionalQualifier() {
+        ObjectId id = ObjectId.of("test", "id", 123);
+        Persistent persistent = mockObject(id);
+        DbRowOp row = mockRow(persistent);
+
+        Qualifier qualifier = new Qualifier(row);
+        qualifier.addAdditionalQualifier(new DbAttribute("attr"), 42, true);
+
+        Map<String, Object> qualifierSnapshot = qualifier.getSnapshot();
+
+        Map<String, Object> expectedSnapshot = new HashMap<>();
+        expectedSnapshot.put("id", 123);
+        expectedSnapshot.put("attr", 42);
+
+        assertEquals(expectedSnapshot, qualifierSnapshot);
+        assertTrue(qualifier.isUsingOptimisticLocking());
+
+        qualifierSnapshot = qualifier.getSnapshot();
+        assertEquals(expectedSnapshot, qualifierSnapshot);
+    }
+
+    @Test
+    public void testOptimisticQualifier() {
+        ObjectId id = ObjectId.of("test", "id", 123);
+        Persistent persistent = mockObject(id);
+        DbRowOp row = mockRow(persistent);
+
+        Qualifier qualifier = new Qualifier(row);
+        qualifier.addAdditionalQualifier(new DbAttribute("attr"), 42, true);
+
+        Map<String, Object> qualifierSnapshot = qualifier.getSnapshot();
+
+        Map<String, Object> expectedSnapshot = new HashMap<>();
+        expectedSnapshot.put("id", 123);
+        expectedSnapshot.put("attr", 42);
+
+        assertEquals(expectedSnapshot, qualifierSnapshot);
+        assertTrue(qualifier.isUsingOptimisticLocking());
+
+        qualifierSnapshot = qualifier.getSnapshot();
+        assertEquals(expectedSnapshot, qualifierSnapshot);
+    }
+
+    @Test
+    public void testSameBatch() {
+        ObjectId id1 = ObjectId.of("test", "id", 123);
+        Persistent persistent1 = mockObject(id1);
+        DbRowOp row1 = mockRow(persistent1);
+
+        Qualifier qualifier1 = new Qualifier(row1);
+
+        ObjectId id2 = ObjectId.of("test", "id", 321);
+        Persistent persistent2 = mockObject(id2);
+        DbRowOp row2 = mockRow(persistent2);
+
+        Qualifier qualifier2 = new Qualifier(row2);
+
+        assertTrue(qualifier1.isSameBatch(qualifier2));
+
+        ObjectId id3 = ObjectId.of("test", "id", 321);
+        Persistent persistent3 = mockObject(id3);
+        DbRowOp row3 = mockRow(persistent3);
+
+        Qualifier qualifier3 = new Qualifier(row3);
+        qualifier3.addAdditionalQualifier(new DbAttribute("attr"), 42);
+
+        assertFalse(qualifier1.isSameBatch(qualifier3));
+    }
+
+    private DbRowOp mockRow(Persistent persistent) {
+        DbRowOp row = mock(DbRowOp.class);
+        ObjectId objectId = persistent.getObjectId();
+        when(row.getChangeId()).thenReturn(objectId);
+        when(row.getObject()).thenReturn(persistent);
+        when(row.getEntity()).thenReturn(mockEntity());
+        return row;
+    }
+
+    private Persistent mockObject(ObjectId id) {
+        Persistent persistent = mock(Persistent.class);
+        when(persistent.getObjectId()).thenReturn(id);
+        when(persistent.getPersistenceState()).thenReturn(PersistenceState.MODIFIED);
+        return persistent;
+    }
+
+    private DbEntity mockEntity() {
+        DbAttribute attribute1 = new DbAttribute("id");
+        attribute1.setPrimaryKey(true);
+        DbAttribute attribute2 = new DbAttribute("attr");
+        DbEntity testEntity = new DbEntity("TEST");
+        testEntity.addAttribute(attribute1);
+        testEntity.addAttribute(attribute2);
+        return testEntity;
+    }
+}
\ No newline at end of file
diff --git a/cayenne-server/src/test/java/org/apache/cayenne/access/flush/operation/ValuesTest.java b/cayenne-server/src/test/java/org/apache/cayenne/access/flush/operation/ValuesTest.java
new file mode 100644
index 0000000..fe22066
--- /dev/null
+++ b/cayenne-server/src/test/java/org/apache/cayenne/access/flush/operation/ValuesTest.java
@@ -0,0 +1,109 @@
+/*****************************************************************
+ *   Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ ****************************************************************/
+
+package org.apache.cayenne.access.flush.operation;
+
+import java.util.Collections;
+
+import org.apache.cayenne.ObjectId;
+import org.apache.cayenne.PersistenceState;
+import org.apache.cayenne.Persistent;
+import org.apache.cayenne.map.DbAttribute;
+import org.apache.cayenne.map.DbEntity;
+import org.junit.Test;
+
+import static org.junit.Assert.*;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * @since 4.2
+ */
+public class ValuesTest {
+
+    @Test
+    public void testEmptyValues() {
+        ObjectId id = ObjectId.of("test", "id", 123);
+        Persistent persistent = mockObject(id);
+        DbRowOp row = mockRow(persistent);
+
+        Values values = new Values(row, false);
+
+        assertTrue(values.getUpdatedAttributes().isEmpty());
+        assertTrue(values.getSnapshot().isEmpty());
+        assertTrue(values.getFlattenedIds().isEmpty());
+        assertTrue(values.isEmpty());
+    }
+
+    @Test
+    public void testValuesWithId() {
+        ObjectId id = ObjectId.of("test", "id", 123);
+        Persistent persistent = mockObject(id);
+        DbRowOp row = mockRow(persistent);
+
+        Values values = new Values(row, true);
+
+        assertTrue(values.getUpdatedAttributes().isEmpty());
+        assertTrue(values.getFlattenedIds().isEmpty());
+        assertEquals(Collections.singletonMap("id", 123), values.getSnapshot());
+        assertFalse(values.isEmpty());
+    }
+
+    @Test
+    public void testValuesWithUpdatedAttributes() {
+        ObjectId id = ObjectId.of("test", "id", 123);
+        Persistent persistent = mockObject(id);
+        DbRowOp row = mockRow(persistent);
+
+        Values values = new Values(row, false);
+        DbAttribute attr1 = new DbAttribute("attr1");
+        values.addValue(attr1, 32);
+
+        assertEquals(Collections.singletonList(attr1), values.getUpdatedAttributes());
+        assertEquals(Collections.singletonMap("attr1", 32), values.getSnapshot());
+        assertTrue(values.getFlattenedIds().isEmpty());
+        assertFalse(values.isEmpty());
+    }
+
+    private DbRowOp mockRow(Persistent persistent) {
+        DbRowOp row = mock(DbRowOp.class);
+        ObjectId objectId = persistent.getObjectId();
+        when(row.getChangeId()).thenReturn(objectId);
+        when(row.getObject()).thenReturn(persistent);
+        when(row.getEntity()).thenReturn(mockEntity());
+        return row;
+    }
+
+    private Persistent mockObject(ObjectId id) {
+        Persistent persistent = mock(Persistent.class);
+        when(persistent.getObjectId()).thenReturn(id);
+        when(persistent.getPersistenceState()).thenReturn(PersistenceState.MODIFIED);
+        return persistent;
+    }
+
+    private DbEntity mockEntity() {
+        DbAttribute attribute1 = new DbAttribute("id");
+        attribute1.setPrimaryKey(true);
+        DbAttribute attribute2 = new DbAttribute("attr");
+        DbEntity testEntity = new DbEntity("TEST");
+        testEntity.addAttribute(attribute1);
+        testEntity.addAttribute(attribute2);
+        return testEntity;
+    }
+}
\ No newline at end of file


[cayenne] 05/06: Merge PR #377

Posted by nt...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

ntimofeev pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/cayenne.git

commit c43a3bd7817f1082a349373b4bcdc8f2850ad76e
Merge: 74ebe54 1e2d9bf
Author: Nikita Timofeev <st...@gmail.com>
AuthorDate: Wed Apr 24 17:33:58 2019 +0300

    Merge PR #377

 .../dbsync/reverse/configuration/ToolsModule.java  |   8 +-
 .../event/JavaGroupsBridgeProviderTest.java        |   6 +
 .../cayenne/event/JMSBridgeProviderTest.java       |   6 +
 .../java/org/apache/cayenne/access/DataDomain.java |  12 +-
 .../cayenne/access/DataDomainDBDiffBuilder.java    |   2 +
 .../cayenne/access/DataDomainDeleteBucket.java     |   4 +-
 .../cayenne/access/DataDomainFlattenedBucket.java  |   6 +-
 .../cayenne/access/DataDomainFlushObserver.java    |   2 +
 .../access/DataDomainIndirectDiffBuilder.java      |   7 +-
 .../cayenne/access/DataDomainInsertBucket.java     |   7 +-
 .../cayenne/access/DataDomainSyncBucket.java       |   6 +-
 .../cayenne/access/DataDomainUpdateBucket.java     |   4 +-
 .../access/DataNodeSyncQualifierDescriptor.java    |   2 +
 .../java/org/apache/cayenne/access/DbArcId.java    |   2 +
 .../cayenne/access/DbEntityClassDescriptor.java    |   2 +
 .../org/apache/cayenne/access/FlattenedArcKey.java |   2 +
 ...ction.java => LegacyDataDomainFlushAction.java} |  11 +-
 .../LegacyDataDomainFlushActionFactory.java}       |  46 ++--
 .../java/org/apache/cayenne/access/ObjectDiff.java |  22 +-
 .../org/apache/cayenne/access/ObjectResolver.java  |   4 +-
 .../org/apache/cayenne/access/ObjectStore.java     |  38 ++-
 .../cayenne/access/ObjectStoreGraphDiff.java       |   4 +-
 .../apache/cayenne/access/OperationObserver.java   |   4 +-
 .../org/apache/cayenne/access/flush/ArcTarget.java |  87 +++++++
 .../access/flush/ArcValuesCreationHandler.java     | 275 +++++++++++++++++++++
 .../flush/DataDomainFlushAction.java}              |  38 +--
 .../flush/DataDomainFlushActionFactory.java}       |  33 +--
 .../flush/DataDomainIndirectDiffBuilder.java       | 106 ++++++++
 .../cayenne/access/flush/DbRowOpFactory.java       | 138 +++++++++++
 .../access/flush/DefaultDataDomainFlushAction.java | 210 ++++++++++++++++
 .../DefaultDataDomainFlushActionFactory.java}      |  43 ++--
 .../apache/cayenne/access/flush/EffectiveOpId.java |  63 +++++
 .../FlushObserver.java}                            |  35 +--
 .../access/flush/ObjectIdValueSupplier.java        |  82 ++++++
 .../flush/OptimisticLockQualifierBuilder.java      |  85 +++++++
 .../access/flush/PermanentObjectIdVisitor.java     | 139 +++++++++++
 .../cayenne/access/flush/PostprocessVisitor.java   | 143 +++++++++++
 .../cayenne/access/flush/QueryCreatorVisitor.java  | 121 +++++++++
 .../cayenne/access/flush/ReplacementIdVisitor.java | 106 ++++++++
 .../cayenne/access/flush/RootRowOpProcessor.java   |  82 ++++++
 .../access/flush/ValuesCreationHandler.java        |  77 ++++++
 .../access/flush/operation/BaseDbRowOp.java        |  77 ++++++
 .../flush/operation/DbRowOp.java}                  |  40 ++-
 .../access/flush/operation/DbRowOpMerger.java      |  82 ++++++
 .../flush/operation/DbRowOpSorter.java}            |  34 +--
 .../flush/operation/DbRowOpType.java}              |  49 ++--
 .../flush/operation/DbRowOpVisitor.java}           |  39 +--
 .../flush/operation/DbRowOpWithQualifier.java}     |  32 +--
 .../flush/operation/DbRowOpWithValues.java}        |  32 +--
 .../flush/operation/DefaultDbRowOpSorter.java      | 150 +++++++++++
 .../access/flush/operation/DeleteDbRowOp.java      |  72 ++++++
 .../flush/operation/DeleteInsertDbRowOp.java}      |  48 ++--
 .../access/flush/operation/InsertDbRowOp.java      |  71 ++++++
 .../cayenne/access/flush/operation/Qualifier.java  | 147 +++++++++++
 .../access/flush/operation/UpdateDbRowOp.java      |  74 ++++++
 .../cayenne/access/flush/operation/Values.java     | 151 +++++++++++
 .../cayenne/ashwood/AshwoodEntitySorter.java       | 140 ++++++-----
 .../cayenne/configuration/server/ServerModule.java |  14 +-
 .../main/java/org/apache/cayenne/map/DbEntity.java |   7 +-
 .../java/org/apache/cayenne/map/EntitySorter.java  |  21 ++
 .../org/apache/cayenne/query/DeleteBatchQuery.java |   7 +-
 .../DataContextEntityWithMeaningfulPKIT.java       |  15 ++
 .../access/DataContextFlattenedAttributesIT.java   |  66 +++++
 .../access/DefaultDataRowStoreFactoryIT.java       |   6 +
 .../cayenne/access/VerticalInheritanceIT.java      |  19 +-
 .../access/flush/ArcValuesCreationHandlerTest.java | 210 ++++++++++++++++
 .../flush/DefaultDataDomainFlushActionTest.java    | 158 ++++++++++++
 .../access/flush/DefaultDbRowOpSorterTest.java     | 215 ++++++++++++++++
 .../access/flush/operation/BaseDbRowOpTest.java    | 125 ++++++++++
 .../access/flush/operation/DbRowOpMergerTest.java  | 177 +++++++++++++
 .../access/flush/operation/QualifierTest.java      | 171 +++++++++++++
 .../cayenne/access/flush/operation/ValuesTest.java | 109 ++++++++
 .../server/DataContextFactoryTest.java             |  12 +
 .../test/resources/inheritance-vertical.map.xml    |   4 +-
 cayenne-server/src/test/resources/testmap.map.xml  |   4 +-
 .../cayenne/event/XMPPBridgeProviderTest.java      |   6 +
 76 files changed, 4218 insertions(+), 436 deletions(-)