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

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

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

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

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

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

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