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:58 UTC

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

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;
             }
         });
     }