You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@cayenne.apache.org by aa...@apache.org on 2026/10/14 19:18:16 UTC

(cayenne) branch master updated: CAY-3050 Array-based DataRows

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

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


The following commit(s) were added to refs/heads/master by this push:
     new 513c8791a CAY-3050 Array-based DataRows
513c8791a is described below

commit 513c8791a6c2c74d6f1260a461422779ca290230
Author: Andrus Adamchik <an...@objectstyle.com>
AuthorDate: Wed Oct 14 13:35:22 2026 -0400

    CAY-3050 Array-based DataRows
    
    1. Pure array row, with layout cached in ClassDescriptor
    2. Side effect - null FKs are now explicitly recorded in object snapshots
       This is arguably the correct way vs how we previously ignored it
---
 UPGRADE.md                                         |   8 +-
 .../crypto/transformer/DefaultMapTransformer.java  |  66 +----
 .../main/java/org/apache/cayenne/ArrayDataRow.java | 280 -------------------
 .../src/main/java/org/apache/cayenne/DataRow.java  | 309 +++++++++++++++++----
 .../java/org/apache/cayenne/DataRowLayout.java     | 171 ++++++++++++
 .../main/java/org/apache/cayenne/MapDataRow.java   | 132 ---------
 .../main/java/org/apache/cayenne/RowLayout.java    |  89 ------
 .../apache/cayenne/access/DataContextDelegate.java |  10 +-
 .../cayenne/access/DataContextObjectStore.java     |  29 +-
 .../org/apache/cayenne/access/DataRowStore.java    | 122 ++++----
 .../org/apache/cayenne/access/DataRowUtils.java    | 203 +++-----------
 .../cayenne/access/HierarchicalObjectResolver.java |   5 +-
 .../access/HierarchicalObjectResolverNode.java     |   2 +-
 .../java/org/apache/cayenne/access/MergePlan.java  |  87 +++---
 .../org/apache/cayenne/access/NoopDelegate.java    |   5 +-
 .../org/apache/cayenne/access/ObjectResolver.java  |  78 +++---
 .../org/apache/cayenne/access/ObjectSnapshots.java |  94 ++++++-
 .../cayenne/access/PrefetchProcessorJointNode.java |  56 +---
 .../access/ResultScanParentAttachmentStrategy.java |  17 +-
 .../java/org/apache/cayenne/access/RowColumns.java |  61 ----
 .../apache/cayenne/access/event/SnapshotEvent.java |  33 ++-
 .../cayenne/access/jdbc/reader/FullRowReader.java  |  16 +-
 .../cayenne/access/jdbc/reader/IndexRowReader.java |   4 +-
 .../access/jdbc/reader/OffsetRowReader.java        |   4 +-
 .../apache/cayenne/reflect/ClassDescriptor.java    |  11 +
 .../reflect/LazyClassDescriptorDecorator.java      |   7 +
 .../cayenne/reflect/PersistentDescriptor.java      |  55 ++++
 .../java/org/apache/cayenne/DataRowLayoutTest.java | 146 ++++++++++
 .../test/java/org/apache/cayenne/DataRowTest.java  | 158 +++++------
 .../java/org/apache/cayenne/RowLayoutTest.java     |  67 -----
 .../access/DataContextConcurrentResolveIT.java     |   5 +-
 .../access/DataContextDelegateSharedCacheIT.java   |   6 +-
 .../apache/cayenne/access/DataContextExtrasIT.java |  15 +-
 .../org/apache/cayenne/access/DataContextIT.java   |  13 +-
 .../cayenne/access/DataContextObjectStoreIT.java   |   8 +-
 .../access/DataContextObjectTrackingIT.java        |  14 +-
 .../access/DataContextProcedureQueryIT.java        |  15 +-
 .../cayenne/access/DataContextQueryCachingIT.java  |   7 +-
 .../org/apache/cayenne/access/DataRowStoreIT.java  |  23 +-
 .../org/apache/cayenne/access/DataRowUtilsIT.java  |   9 +-
 .../apache/cayenne/access/LoggingObserverTest.java |   7 +-
 .../cayenne/access/MockDataContextDelegate.java    |   5 +-
 .../apache/cayenne/access/MockDataRowStore.java    |  13 +-
 .../cayenne/access/NestedDataContextReadIT.java    |   8 +-
 .../access/PrefetchProcessorTreeBuilderIT.java     |   5 +-
 .../SimpleIdIncrementalFaultListDataRowsIT.java    |  17 +-
 .../apache/cayenne/access/SnapshotVersionTest.java |   4 +-
 .../cayenne/access/event/SnapshotEventTest.java    |   6 +-
 .../org/apache/cayenne/event/EventBridgeTest.java  |   3 +
 .../org/apache/cayenne/query/ProcedureCallIT.java  |  13 +-
 .../PersistentDescriptor_SnapshotLayoutIT.java     | 117 ++++++++
 51 files changed, 1317 insertions(+), 1321 deletions(-)

diff --git a/UPGRADE.md b/UPGRADE.md
index 26538aae4..d413e2351 100644
--- a/UPGRADE.md
+++ b/UPGRADE.md
@@ -445,12 +445,8 @@ List<Artist> withExpensivePaintings = ObjectSelect
    (`AttributeProperty`, `ToOneProperty`, `ToManyProperty`) instead. A custom `ClassDescriptor` must implement the
    three accessors; the deprecated visit methods are now default methods on top of them.
 
-*  Per [CAY-3050](https://issues.apache.org/jira/browse/CAY-3050) `DataRow` is now immutable: `put`, `putAll`,
-  `remove` and `clear` throw `UnsupportedOperationException`, including on rows returned by `dataRows()` queries. To
-  build a row, fill a `Map` first and pass it to `DataRow.of(entityName, map, version)`, which replaces the public
-  constructor. A row with changed values is derived with `row.merged(changes, version)`. Rows read from the database
-  are stored as arrays over a shared `RowLayout`, and a result set column with a repeated name is no longer dropped:
-  the last column keeps the name, and the earlier ones get an underscore appended (`ID`, `ID_`, ...).
+*  Per [CAY-3050](https://issues.apache.org/jira/browse/CAY-3050) `DataRow` is now immutable: `put`, `putAll`, `remove` and `clear` throw 
+   `UnsupportedOperationException`, including on rows returned by `dataRows()` queries. 
 
 ## Upgrading to 5.0-M3
 
diff --git a/cayenne-crypto/src/main/java/org/apache/cayenne/crypto/transformer/DefaultMapTransformer.java b/cayenne-crypto/src/main/java/org/apache/cayenne/crypto/transformer/DefaultMapTransformer.java
index 1c7f416a8..bf93a4ba3 100644
--- a/cayenne-crypto/src/main/java/org/apache/cayenne/crypto/transformer/DefaultMapTransformer.java
+++ b/cayenne-crypto/src/main/java/org/apache/cayenne/crypto/transformer/DefaultMapTransformer.java
@@ -18,18 +18,17 @@
  ****************************************************************/
 package org.apache.cayenne.crypto.transformer;
 
-import java.util.HashMap;
-import java.util.Map;
-
+import org.apache.cayenne.DataRowLayout;
 import org.apache.cayenne.DataRow;
-import org.apache.cayenne.RowLayout;
 import org.apache.cayenne.crypto.transformer.bytes.BytesDecryptor;
 import org.apache.cayenne.crypto.transformer.value.ValueDecryptor;
 
+import java.util.HashMap;
+import java.util.Map;
+
 /**
- * Decrypts the encrypted columns of a row. A row with a {@link RowLayout} is decrypted by position, with the column
- * positions resolved once per layout, i.e. once per result set, as the transformer is created per row reader. Not
- * thread-safe.
+ * Decrypts the encrypted columns of a row. The columns are read through a {@link DataRowLayout.Reader}, which
+ * resolves their positions once per result set.
  *
  * @since 4.0
  */
@@ -38,66 +37,21 @@ public class DefaultMapTransformer implements MapTransformer {
     private final String[] mapKeys;
     private final ValueDecryptor[] transformers;
     private final BytesDecryptor bytesDecryptor;
-
-    // the positions of mapKeys in the layout of the last row seen, -1 for a key the layout does not have
-    private RowLayout layout;
-    private int[] indexes;
+    private final DataRowLayout.Reader reader;
 
     public DefaultMapTransformer(String[] mapKeys, ValueDecryptor[] transformers, BytesDecryptor bytesDecryptor) {
         this.mapKeys = mapKeys;
         this.transformers = transformers;
         this.bytesDecryptor = bytesDecryptor;
+        this.reader = DataRowLayout.of(mapKeys).reader();
     }
 
     @Override
     public DataRow transform(DataRow row) {
-        RowLayout rowLayout = row.getLayout();
-        return rowLayout != null ? transformByPosition(row, rowLayout) : transformByName(row);
-    }
-
-    private DataRow transformByPosition(DataRow row, RowLayout rowLayout) {
-        int[] indexes = indexes(rowLayout);
-
-        // the values are copied on the first encrypted non-null value, so a row with nothing to decrypt is returned
-        // as is
-        Object[] values = null;
-        for (int i = 0; i < indexes.length; i++) {
-            int index = indexes[i];
-            if (index < 0) {
-                continue;
-            }
-
-            Object value = row.valueAt(index);
-            if (value != null) {
-                if (values == null) {
-                    values = new Object[rowLayout.size()];
-                    for (int j = 0; j < values.length; j++) {
-                        values[j] = row.valueAt(j);
-                    }
-                }
-                values[index] = transformers[i].decrypt(bytesDecryptor, value);
-            }
-        }
-
-        return values != null ? DataRow.of(row.getEntityName(), rowLayout, values, row.getVersion()) : row;
-    }
-
-    private int[] indexes(RowLayout rowLayout) {
-        if (rowLayout != layout) {
-            int[] resolved = new int[mapKeys.length];
-            for (int i = 0; i < mapKeys.length; i++) {
-                resolved[i] = rowLayout.indexOf(mapKeys[i]);
-            }
-            indexes = resolved;
-            layout = rowLayout;
-        }
-        return indexes;
-    }
-
-    private DataRow transformByName(DataRow row) {
+        // a row with nothing to decrypt is returned as is
         Map<String, Object> decrypted = null;
         for (int i = 0; i < mapKeys.length; i++) {
-            Object value = row.get(mapKeys[i]);
+            Object value = reader.get(row, i);
             if (value != null) {
                 if (decrypted == null) {
                     decrypted = new HashMap<>();
diff --git a/cayenne/src/main/java/org/apache/cayenne/ArrayDataRow.java b/cayenne/src/main/java/org/apache/cayenne/ArrayDataRow.java
deleted file mode 100644
index 09324f79c..000000000
--- a/cayenne/src/main/java/org/apache/cayenne/ArrayDataRow.java
+++ /dev/null
@@ -1,280 +0,0 @@
-/*****************************************************************
- *   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
- *
- *    https://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;
-
-import java.io.Serial;
-import java.util.AbstractCollection;
-import java.util.AbstractMap;
-import java.util.AbstractSet;
-import java.util.Arrays;
-import java.util.Collection;
-import java.util.HashMap;
-import java.util.Iterator;
-import java.util.Map;
-import java.util.NoSuchElementException;
-import java.util.Objects;
-import java.util.Set;
-import java.util.function.BiConsumer;
-
-/**
- * A {@link DataRow} stored as an array of values over a {@link RowLayout}.
- *
- * @since 5.0
- */
-final class ArrayDataRow extends DataRow {
-
-    private final RowLayout layout;
-    private final Object[] values;
-
-    ArrayDataRow(String entityName, RowLayout layout, Object[] values, long version) {
-        super(entityName, version);
-        if (values.length != layout.size()) {
-            throw new IllegalArgumentException(
-                    "Values length " + values.length + " does not match the layout size " + layout.size());
-        }
-        this.layout = layout;
-        this.values = values;
-    }
-
-    @Override
-    public RowLayout getLayout() {
-        return layout;
-    }
-
-    @Override
-    public Object valueAt(int index) {
-        return values[index];
-    }
-
-    @Override
-    public DataRow withEntityName(String entityName) {
-        return new ArrayDataRow(entityName, layout, values, getVersion());
-    }
-
-    @Override
-    public DataRow merged(Map<String, ?> changes, long version) {
-        Object[] merged = values.clone();
-        for (Map.Entry<String, ?> entry : changes.entrySet()) {
-            int index = layout.indexOf(entry.getKey());
-            if (index < 0) {
-                // a key outside the layout: the result has to be a map
-                HashMap<String, Object> map = new HashMap<>(this);
-                map.putAll(changes);
-                return new MapDataRow(getEntityName(), map, version);
-            }
-            merged[index] = entry.getValue();
-        }
-        return new ArrayDataRow(getEntityName(), layout, merged, version);
-    }
-
-    @Override
-    public int size() {
-        return layout.size();
-    }
-
-    @Override
-    public boolean isEmpty() {
-        return layout.size() == 0;
-    }
-
-    @Override
-    public boolean containsKey(Object key) {
-        return key instanceof String name && layout.indexOf(name) >= 0;
-    }
-
-    @Override
-    public boolean containsValue(Object value) {
-        for (Object v : values) {
-            if (Objects.equals(v, value)) {
-                return true;
-            }
-        }
-        return false;
-    }
-
-    @Override
-    public Object get(Object key) {
-        if (!(key instanceof String name)) {
-            return null;
-        }
-        int index = layout.indexOf(name);
-        return index < 0 ? null : values[index];
-    }
-
-    @Override
-    public Object getOrDefault(Object key, Object defaultValue) {
-        if (!(key instanceof String name)) {
-            return defaultValue;
-        }
-        int index = layout.indexOf(name);
-        return index < 0 ? defaultValue : values[index];
-    }
-
-    @Override
-    public Set<String> keySet() {
-        return new AbstractSet<>() {
-            @Override
-            public Iterator<String> iterator() {
-                return new PositionIterator<>() {
-                    @Override
-                    String at(int index) {
-                        return layout.nameAt(index);
-                    }
-                };
-            }
-
-            @Override
-            public int size() {
-                return layout.size();
-            }
-
-            @Override
-            public boolean contains(Object o) {
-                return containsKey(o);
-            }
-        };
-    }
-
-    @Override
-    public Collection<Object> values() {
-        return new AbstractCollection<>() {
-            @Override
-            public Iterator<Object> iterator() {
-                return new PositionIterator<>() {
-                    @Override
-                    Object at(int index) {
-                        return values[index];
-                    }
-                };
-            }
-
-            @Override
-            public int size() {
-                return layout.size();
-            }
-        };
-    }
-
-    @Override
-    public Set<Entry<String, Object>> entrySet() {
-        return new AbstractSet<>() {
-            @Override
-            public Iterator<Entry<String, Object>> iterator() {
-                return new PositionIterator<>() {
-                    @Override
-                    Entry<String, Object> at(int index) {
-                        return new AbstractMap.SimpleImmutableEntry<>(layout.nameAt(index), values[index]);
-                    }
-                };
-            }
-
-            @Override
-            public int size() {
-                return layout.size();
-            }
-        };
-    }
-
-    @Override
-    public void forEach(BiConsumer<? super String, ? super Object> action) {
-        for (int i = 0; i < values.length; i++) {
-            action.accept(layout.nameAt(i), values[i]);
-        }
-    }
-
-    @Override
-    public boolean equals(Object o) {
-        if (o == this) {
-            return true;
-        }
-
-        if (o instanceof ArrayDataRow other && other.layout == layout) {
-            return Arrays.equals(values, other.values);
-        }
-
-        if (!(o instanceof Map<?, ?> map) || map.size() != layout.size()) {
-            return false;
-        }
-
-        for (int i = 0; i < values.length; i++) {
-            String name = layout.nameAt(i);
-            Object value = values[i];
-            if (value == null) {
-                if (map.get(name) != null || !map.containsKey(name)) {
-                    return false;
-                }
-            } else if (!value.equals(map.get(name))) {
-                return false;
-            }
-        }
-        return true;
-    }
-
-    @Override
-    public int hashCode() {
-        // the Map contract: the sum of the entry hashes, each being the key hash xor the value hash
-        int h = 0;
-        for (int i = 0; i < values.length; i++) {
-            h += layout.nameAt(i).hashCode() ^ Objects.hashCode(values[i]);
-        }
-        return h;
-    }
-
-    @Override
-    public String toString() {
-        StringBuilder buffer = new StringBuilder(getClass().getSimpleName()).append("@")
-                .append(Integer.toHexString(System.identityHashCode(this))).append("[values={");
-        for (int i = 0; i < values.length; i++) {
-            if (i > 0) {
-                buffer.append(", ");
-            }
-            buffer.append(layout.nameAt(i)).append('=').append(values[i]);
-        }
-        return buffer.append("}, version=").append(getVersion()).append(']').toString();
-    }
-
-    /**
-     * Serializes as a map row: the layout is not shared across the wire.
-     */
-    @Serial
-    private Object writeReplace() {
-        return new MapDataRow(getEntityName(), new HashMap<>(this), getVersion());
-    }
-
-    private abstract class PositionIterator<T> implements Iterator<T> {
-
-        private int next;
-
-        abstract T at(int index);
-
-        @Override
-        public boolean hasNext() {
-            return next < values.length;
-        }
-
-        @Override
-        public T next() {
-            if (next >= values.length) {
-                throw new NoSuchElementException();
-            }
-            return at(next++);
-        }
-    }
-}
diff --git a/cayenne/src/main/java/org/apache/cayenne/DataRow.java b/cayenne/src/main/java/org/apache/cayenne/DataRow.java
index e5c13e545..3b97a7fcd 100644
--- a/cayenne/src/main/java/org/apache/cayenne/DataRow.java
+++ b/cayenne/src/main/java/org/apache/cayenne/DataRow.java
@@ -20,47 +20,41 @@
 package org.apache.cayenne;
 
 import java.io.Serializable;
+import java.util.AbstractCollection;
+import java.util.AbstractMap;
+import java.util.AbstractSet;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Iterator;
+import java.util.List;
 import java.util.Map;
+import java.util.NoSuchElementException;
+import java.util.Objects;
+import java.util.Set;
+import java.util.function.BiConsumer;
 
 /**
- * DataRow is a read-only map that holds values retrieved from the database for a given query row. DataRows are used to
- * cache raw database data and as a reference point for tracking PersistentObject changes.
- * <p>
- * Every DataRow carries a {@link org.apache.cayenne.access.SnapshotVersion snapshot version}, the version of the
- * operation that produced the row. All rows read by one SQL statement share a version, as do all snapshots of one
- * commit, and an operation that runs later has a greater version. Two rows of the same ObjectId with the same version
- * therefore hold the same data. A cached row keeps its version though while later statements read the same data.
- * <p>
- * A row is immutable: the mutating Map methods throw {@link UnsupportedOperationException}, and the key, value and
- * entry views of a row stored as a map are the views of that map, which the caller is not to change either. A row
- * with changed values is derived with {@link #merged(Map, long)}. Rows come in two forms. A row read from a result set is stored as an
- * array of values over a {@link RowLayout} shared by all the rows of the result set, and exposes its
- * {@link #getLayout() layout} so that the values can be read by position. Any other row is stored as a map and has no
- * layout.
+ * DataRow is a precursor of a persistent object that holds its database values, but models no relationships. A row is
+ * immutable and the mutating Map methods throw {@link UnsupportedOperationException}. A row is serializable when its
+ * values are. Rows serialized in one stream keep sharing their layout.
  *
  * @since 1.1
  */
-public abstract sealed class DataRow implements Map<String, Object>, Serializable permits MapDataRow, ArrayDataRow {
+public final class DataRow implements Map<String, Object>, Serializable {
 
-    private final long version;
     private final String entityName;
+    private final long version;
+    final DataRowLayout layout;
+    final Object[] values;
 
-    DataRow(String entityName, long version) {
+    private DataRow(String entityName, DataRowLayout layout, Object[] values, long version) {
         this.entityName = entityName;
+        this.layout = layout;
+        this.values = values;
         this.version = version;
     }
 
-    /**
-     * Creates a row over the given values, with the given entity name and snapshot version. The map is not copied:
-     * the row takes ownership of it, and the caller must not change the map after this call. To create a row from a
-     * map that stays in use, pass a copy. The map should be serializable for the row to be.
-     *
-     * @since 5.0
-     */
-    public static DataRow of(String entityName, Map<String, Object> values, long version) {
-        return new MapDataRow(entityName, values, version);
-    }
-
     /**
      * Creates a row over the given values laid out by the given layout, with the given entity name and snapshot
      * version. The array is not copied: the row takes ownership of it, and the caller must not use the array after
@@ -68,8 +62,12 @@ public abstract sealed class DataRow implements Map<String, Object>, Serializabl
      *
      * @since 5.0
      */
-    public static DataRow of(String entityName, RowLayout layout, Object[] values, long version) {
-        return new ArrayDataRow(entityName, layout, values, version);
+    public static DataRow of(String entityName, DataRowLayout layout, Object[] values, long version) {
+        if (values.length != layout.size()) {
+            throw new IllegalArgumentException(
+                    "Values length " + values.length + " does not match the layout size " + layout.size());
+        }
+        return new DataRow(entityName, layout, values, version);
     }
 
     /**
@@ -89,64 +87,263 @@ public abstract sealed class DataRow implements Map<String, Object>, Serializabl
         return version;
     }
 
-    /**
-     * Returns the layout of a row stored as an array, or null if the row is stored as a map. The values of a row with
-     * a layout can be read by position with {@link #valueAt(int)}.
-     *
-     * @since 5.0
-     */
-    public abstract RowLayout getLayout();
-
-    /**
-     * Returns the value at the given position of the {@link #getLayout() layout} of this row.
-     *
-     * @throws UnsupportedOperationException if the row has no layout
-     * @since 5.0
-     */
-    public abstract Object valueAt(int index);
-
     /**
      * Returns a row with the given entity name and this row's values and version. The values are shared: rows are
      * immutable, so the copy is free.
      *
      * @since 5.0
      */
-    public abstract DataRow withEntityName(String entityName);
+    public DataRow withEntityName(String entityName) {
+        return new DataRow(entityName, layout, values, version);
+    }
 
     /**
      * Returns a row with this row's entity name and the given version, holding this row's values overlaid with the
-     * given ones. A row with a layout keeps it when every changed key is a column of the layout.
+     * given ones. The row keeps its layout when every changed key is a column of it, and gets its own wider layout
+     * otherwise.
      *
      * @since 5.0
      */
-    public abstract DataRow merged(Map<String, ?> changes, long version);
+    public DataRow merged(Map<String, ?> changes, long version) {
+        Object[] merged = values.clone();
+        List<Map.Entry<String, ?>> added = null;
+
+        for (Map.Entry<String, ?> entry : changes.entrySet()) {
+            int index = layout.indexOf(entry.getKey());
+            if (index >= 0) {
+                merged[index] = entry.getValue();
+            } else {
+                if (added == null) {
+                    added = new ArrayList<>();
+                }
+                added.add(entry);
+            }
+        }
+
+        if (added == null) {
+            return new DataRow(entityName, layout, merged, version);
+        }
+
+        int size = values.length;
+        String[] names = new String[size + added.size()];
+        Object[] widened = Arrays.copyOf(merged, names.length);
+        for (int i = 0; i < size; i++) {
+            names[i] = layout.nameAt(i);
+        }
+        for (int i = 0; i < added.size(); i++) {
+            names[size + i] = added.get(i).getKey();
+            widened[size + i] = added.get(i).getValue();
+        }
+        return new DataRow(entityName, DataRowLayout.of(names), widened, version);
+    }
+
+    @Override
+    public int size() {
+        return values.length;
+    }
+
+    @Override
+    public boolean isEmpty() {
+        return values.length == 0;
+    }
+
+    @Override
+    public boolean containsKey(Object key) {
+        return key instanceof String name && layout.indexOf(name) >= 0;
+    }
+
+    @Override
+    public boolean containsValue(Object value) {
+        for (Object v : values) {
+            if (Objects.equals(v, value)) {
+                return true;
+            }
+        }
+        return false;
+    }
 
     @Override
-    public final Object put(String key, Object value) {
+    public Object get(Object key) {
+        if (!(key instanceof String name)) {
+            return null;
+        }
+        int index = layout.indexOf(name);
+        return index < 0 ? null : values[index];
+    }
+
+    @Override
+    public Object getOrDefault(Object key, Object defaultValue) {
+        if (!(key instanceof String name)) {
+            return defaultValue;
+        }
+        int index = layout.indexOf(name);
+        return index < 0 ? defaultValue : values[index];
+    }
+
+    @Override
+    public Object put(String key, Object value) {
         throw new UnsupportedOperationException("DataRow is immutable");
     }
 
     @Override
-    public final Object remove(Object key) {
+    public Object remove(Object key) {
         throw new UnsupportedOperationException("DataRow is immutable");
     }
 
     @Override
-    public final void putAll(Map<? extends String, ?> m) {
+    public void putAll(Map<? extends String, ?> m) {
         throw new UnsupportedOperationException("DataRow is immutable");
     }
 
     @Override
-    public final void clear() {
+    public void clear() {
         throw new UnsupportedOperationException("DataRow is immutable");
     }
 
+    @Override
+    public Set<String> keySet() {
+        return new AbstractSet<>() {
+            @Override
+            public Iterator<String> iterator() {
+                return new PositionIterator<>() {
+                    @Override
+                    String at(int index) {
+                        return layout.nameAt(index);
+                    }
+                };
+            }
+
+            @Override
+            public int size() {
+                return values.length;
+            }
+
+            @Override
+            public boolean contains(Object o) {
+                return containsKey(o);
+            }
+        };
+    }
+
+    @Override
+    public Collection<Object> values() {
+        return new AbstractCollection<>() {
+            @Override
+            public Iterator<Object> iterator() {
+                return new PositionIterator<>() {
+                    @Override
+                    Object at(int index) {
+                        return values[index];
+                    }
+                };
+            }
+
+            @Override
+            public int size() {
+                return values.length;
+            }
+        };
+    }
+
+    @Override
+    public Set<Entry<String, Object>> entrySet() {
+        return new AbstractSet<>() {
+            @Override
+            public Iterator<Entry<String, Object>> iterator() {
+                return new PositionIterator<>() {
+                    @Override
+                    Entry<String, Object> at(int index) {
+                        return new AbstractMap.SimpleImmutableEntry<>(layout.nameAt(index), values[index]);
+                    }
+                };
+            }
+
+            @Override
+            public int size() {
+                return values.length;
+            }
+        };
+    }
+
     /**
-     * Rows are equal to any Map with the same entries, regardless of the version and the entity name.
+     * Visits the entries in column order. Unlike the entry set, allocates nothing per entry.
      */
     @Override
-    public abstract boolean equals(Object o);
+    public void forEach(BiConsumer<? super String, ? super Object> action) {
+        for (int i = 0; i < values.length; i++) {
+            action.accept(layout.nameAt(i), values[i]);
+        }
+    }
 
+    /**
+     * Compares the rows as maps, i.e. by their keys and values and regardless of their entity names, versions or the
+     * order of their columns.
+     */
     @Override
-    public abstract int hashCode();
+    public boolean equals(Object o) {
+        if (o == this) {
+            return true;
+        }
+        if (o instanceof DataRow other && other.layout == layout) {
+            return Arrays.equals(values, other.values);
+        }
+        if (!(o instanceof Map<?, ?> map) || map.size() != values.length) {
+            return false;
+        }
+        for (int i = 0; i < values.length; i++) {
+            String name = layout.nameAt(i);
+            Object value = values[i];
+            if (value == null) {
+                if (map.get(name) != null || !map.containsKey(name)) {
+                    return false;
+                }
+            } else if (!value.equals(map.get(name))) {
+                return false;
+            }
+        }
+        return true;
+    }
+
+    @Override
+    public int hashCode() {
+        // the Map contract: the sum of the entry hashes, each being the key hash xor the value hash
+        int h = 0;
+        for (int i = 0; i < values.length; i++) {
+            h += layout.nameAt(i).hashCode() ^ Objects.hashCode(values[i]);
+        }
+        return h;
+    }
+
+    @Override
+    public String toString() {
+        StringBuilder buffer = new StringBuilder("DataRow@")
+                .append(Integer.toHexString(System.identityHashCode(this))).append("[values={");
+        for (int i = 0; i < values.length; i++) {
+            if (i > 0) {
+                buffer.append(", ");
+            }
+            buffer.append(layout.nameAt(i)).append('=').append(values[i]);
+        }
+        return buffer.append("}, version=").append(version).append(']').toString();
+    }
+
+    private abstract class PositionIterator<T> implements Iterator<T> {
+
+        private int next;
+
+        abstract T at(int index);
+
+        @Override
+        public boolean hasNext() {
+            return next < values.length;
+        }
+
+        @Override
+        public T next() {
+            if (next >= values.length) {
+                throw new NoSuchElementException();
+            }
+            return at(next++);
+        }
+    }
 }
diff --git a/cayenne/src/main/java/org/apache/cayenne/DataRowLayout.java b/cayenne/src/main/java/org/apache/cayenne/DataRowLayout.java
new file mode 100644
index 000000000..505c6448e
--- /dev/null
+++ b/cayenne/src/main/java/org/apache/cayenne/DataRowLayout.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
+ *
+ *    https://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;
+
+import java.io.Serial;
+import java.io.Serializable;
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * The layout of a {@link DataRow}: the column names in column order, with a lookup of a name to its position. A row
+ * holds its values by position in its layout, and all the rows of one result set share a layout. Layouts are
+ * immutable and compared by identity, so two rows share a layout only when they came from the same reader. A layout
+ * is serializable, and the rows serialized in one stream keep sharing theirs.
+ * <p>
+ * A consumer that needs a fixed set of columns from rows of any layouts builds a layout of those names and reads
+ * them through a {@link Reader} of it.
+ *
+ * @since 5.0
+ */
+public final class DataRowLayout implements Serializable {
+
+    private final String[] names;
+    private final transient Map<String, Integer> indexes;
+
+    private DataRowLayout(String[] names) {
+        this.indexes = new HashMap<>((int) Math.ceil(names.length / 0.75));
+
+        // the array is owned, so the duplicates are renamed in place
+        for (int i = names.length - 1; i >= 0; i--) {
+            String name = names[i];
+            if (indexes.containsKey(name)) {
+                do {
+                    name = name + "_";
+                } while (indexes.containsKey(name));
+                names[i] = name;
+            }
+            indexes.put(name, i);
+        }
+        this.names = names;
+    }
+
+    /**
+     * Returns a layout of the given column names, in the given order. A repeated name is deduplicated: the last
+     * occurrence keeps the name, the way the last put wins in a map, and each earlier one is renamed with an
+     * underscore appended until it is unique.
+     */
+    public static DataRowLayout of(String... names) {
+        return new DataRowLayout(names.clone());
+    }
+
+    /**
+     * Returns the number of columns.
+     */
+    public int size() {
+        return names.length;
+    }
+
+    /**
+     * Returns the position of the named column, or -1 if there is no such column.
+     */
+    public int indexOf(String name) {
+        Integer index = indexes.get(name);
+        return index != null ? index : -1;
+    }
+
+    /**
+     * Returns the name of the column at the given position.
+     */
+    public String nameAt(int index) {
+        return names[index];
+    }
+
+    /**
+     * Returns a reader of the columns of this layout from rows of any layouts.
+     */
+    public Reader reader() {
+        return new Reader(this);
+    }
+
+    @Override
+    public String toString() {
+        return "DataRowLayout[" + String.join(",", names) + "]";
+    }
+
+    /**
+     * Rebuilds the index of the names, which is not serialized.
+     */
+    @Serial
+    private Object readResolve() {
+        return new DataRowLayout(names);
+    }
+
+    /**
+     * Reads the columns of a layout from rows of any layouts. A row of the reader's own layout is read by position.
+     * A row of another layout has the reader's column names resolved to positions in it, which the reader keeps
+     * until it reads a row of yet another layout, so a loop over a result set resolves the positions once.
+     * <p>
+     * A reader is thus a per-execution object: one is created per query execution, or per other loop over the rows
+     * of one result set, and not shared across them. It may still be shared between threads: the resolved positions
+     * are kept as one immutable pair, and the race of two threads resolving them costs a repeated resolution only.
+     */
+    public static final class Reader {
+
+        private final DataRowLayout layout;
+        private Resolved resolved;
+
+        private record Resolved(DataRowLayout rowLayout, int[] indexes) {
+        }
+
+        private Reader(DataRowLayout layout) {
+            this.layout = layout;
+        }
+
+        /**
+         * Returns the value of the i-th column of the reader's layout in the row, or null if the row has no such
+         * column. A null is told from a missing column with {@link #has(DataRow, int)}.
+         */
+        public Object get(DataRow row, int i) {
+            DataRowLayout rowLayout = row.layout;
+            if (rowLayout == layout) {
+                return row.values[i];
+            }
+            int index = indexesIn(rowLayout)[i];
+            return index < 0 ? null : row.values[index];
+        }
+
+        /**
+         * Returns whether the row has the i-th column of the reader's layout.
+         */
+        public boolean has(DataRow row, int i) {
+            DataRowLayout rowLayout = row.layout;
+            return rowLayout == layout || indexesIn(rowLayout)[i] >= 0;
+        }
+
+        private int[] indexesIn(DataRowLayout rowLayout) {
+            Resolved r = resolved;
+            if (r == null || r.rowLayout != rowLayout) {
+                int[] indexes = new int[layout.names.length];
+                for (int j = 0; j < indexes.length; j++) {
+                    indexes[j] = rowLayout.indexOf(layout.names[j]);
+                }
+                r = new Resolved(rowLayout, indexes);
+                resolved = r;
+            }
+            return r.indexes;
+        }
+
+        @Override
+        public String toString() {
+            return "Reader" + layout;
+        }
+    }
+}
diff --git a/cayenne/src/main/java/org/apache/cayenne/MapDataRow.java b/cayenne/src/main/java/org/apache/cayenne/MapDataRow.java
deleted file mode 100644
index 25c7ff40c..000000000
--- a/cayenne/src/main/java/org/apache/cayenne/MapDataRow.java
+++ /dev/null
@@ -1,132 +0,0 @@
-/*****************************************************************
- *   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
- *
- *    https://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;
-
-import org.apache.cayenne.util.ToStringBuilder;
-
-import java.util.Collection;
-import java.util.HashMap;
-import java.util.Map;
-import java.util.Set;
-import java.util.function.BiConsumer;
-
-/**
- * A {@link DataRow} stored as a map.
- *
- * @since 5.0
- */
-final class MapDataRow extends DataRow {
-
-    private final Map<String, Object> values;
-
-    MapDataRow(String entityName, Map<String, Object> values, long version) {
-        super(entityName, version);
-        this.values = values;
-    }
-
-    @Override
-    public RowLayout getLayout() {
-        return null;
-    }
-
-    @Override
-    public Object valueAt(int index) {
-        throw new UnsupportedOperationException("The row has no layout");
-    }
-
-    @Override
-    public DataRow withEntityName(String entityName) {
-        return new MapDataRow(entityName, values, getVersion());
-    }
-
-    @Override
-    public DataRow merged(Map<String, ?> changes, long version) {
-        HashMap<String, Object> merged = new HashMap<>(values);
-        merged.putAll(changes);
-        return new MapDataRow(getEntityName(), merged, version);
-    }
-
-    @Override
-    public int size() {
-        return values.size();
-    }
-
-    @Override
-    public boolean isEmpty() {
-        return values.isEmpty();
-    }
-
-    @Override
-    public boolean containsKey(Object key) {
-        return values.containsKey(key);
-    }
-
-    @Override
-    public boolean containsValue(Object value) {
-        return values.containsValue(value);
-    }
-
-    @Override
-    public Object get(Object key) {
-        return values.get(key);
-    }
-
-    @Override
-    public Object getOrDefault(Object key, Object defaultValue) {
-        return values.getOrDefault(key, defaultValue);
-    }
-
-    @Override
-    public Set<String> keySet() {
-        return values.keySet();
-    }
-
-    @Override
-    public Collection<Object> values() {
-        return values.values();
-    }
-
-    @Override
-    public Set<Entry<String, Object>> entrySet() {
-        return values.entrySet();
-    }
-
-    @Override
-    public void forEach(BiConsumer<? super String, ? super Object> action) {
-        values.forEach(action);
-    }
-
-    @Override
-    public boolean equals(Object o) {
-        return values.equals(o);
-    }
-
-    @Override
-    public int hashCode() {
-        return values.hashCode();
-    }
-
-    @Override
-    public String toString() {
-        return new ToStringBuilder(this)
-                .append("values", values)
-                .append(" version", getVersion()).toString();
-    }
-}
diff --git a/cayenne/src/main/java/org/apache/cayenne/RowLayout.java b/cayenne/src/main/java/org/apache/cayenne/RowLayout.java
deleted file mode 100644
index 1a7430e7a..000000000
--- a/cayenne/src/main/java/org/apache/cayenne/RowLayout.java
+++ /dev/null
@@ -1,89 +0,0 @@
-/*****************************************************************
- *   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
- *
- *    https://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;
-
-import java.util.HashMap;
-import java.util.Map;
-
-/**
- * The shape of a {@link DataRow} stored as an array: the column names in column order, with a lookup of a name to its
- * position. One layout is shared by all the rows of one result set. Layouts are compared by identity, so two rows
- * share a layout only when they came from the same reader.
- *
- * @since 5.0
- */
-public final class RowLayout {
-
-    private final String[] names;
-    private final Map<String, Integer> indexes;
-
-    /**
-     * Creates a layout over the given column names. The array is not copied unless a name is repeated in it: the
-     * layout takes ownership of it, and the caller must not change it after this call. A repeated name is
-     * deduplicated: the last occurrence keeps the name, the way the last put wins in a map, and each earlier one is
-     * renamed with an underscore appended until it is unique.
-     */
-    public RowLayout(String[] names) {
-        this.indexes = new HashMap<>((int) Math.ceil(names.length / 0.75));
-
-        String[] deduplicated = names;
-        for (int i = names.length - 1; i >= 0; i--) {
-            String name = names[i];
-            if (indexes.containsKey(name)) {
-                if (deduplicated == names) {
-                    deduplicated = names.clone();
-                }
-                do {
-                    name = name + "_";
-                } while (indexes.containsKey(name));
-                deduplicated[i] = name;
-            }
-            indexes.put(name, i);
-        }
-        this.names = deduplicated;
-    }
-
-    /**
-     * Returns the number of columns.
-     */
-    public int size() {
-        return names.length;
-    }
-
-    /**
-     * Returns the position of the named column, or -1 if there is no such column.
-     */
-    public int indexOf(String name) {
-        Integer index = indexes.get(name);
-        return index != null ? index : -1;
-    }
-
-    /**
-     * Returns the name of the column at the given position.
-     */
-    public String nameAt(int index) {
-        return names[index];
-    }
-
-    @Override
-    public String toString() {
-        return "RowLayout[" + String.join(",", names) + "]";
-    }
-}
diff --git a/cayenne/src/main/java/org/apache/cayenne/access/DataContextDelegate.java b/cayenne/src/main/java/org/apache/cayenne/access/DataContextDelegate.java
index 4f94662d9..7db0846f5 100644
--- a/cayenne/src/main/java/org/apache/cayenne/access/DataContextDelegate.java
+++ b/cayenne/src/main/java/org/apache/cayenne/access/DataContextDelegate.java
@@ -19,10 +19,11 @@
 
 package org.apache.cayenne.access;
 
-import org.apache.cayenne.DataRow;
 import org.apache.cayenne.Persistent;
 import org.apache.cayenne.query.Query;
 
+import java.util.Map;
+
 /**
  * Defines API for a DataContext "delegate" - an object that is temporarily passed control
  * by DataContext at some critical points in the normal flow of execution. A delegate thus
@@ -59,13 +60,16 @@ public interface DataContextDelegate {
      * not resulting in a snapshot change. In the later case snapshot argument may be
      * null. If a delegate returns <code>true</code>, ObjectStore will attempt to merge
      * the changes into an object.
+     *
+     * @param snapshotInStore the snapshot of the object in the cache, or the changed keys of it with their new
+     *                        values, or null
      */
-    boolean shouldMergeChanges(Persistent object, DataRow snapshotInStore);
+    boolean shouldMergeChanges(Persistent object, Map<String, Object> snapshotInStore);
 
     /**
      * Called after a successful merging of external changes to an object. If previosly a
      * delegate returned <code>false</code> from
-     * {@link #shouldMergeChanges(Persistent, DataRow)}, this method is not invoked,
+     * {@link #shouldMergeChanges(Persistent, Map)}, this method is not invoked,
      * since changes were not merged.
      */
     void finishedMergeChanges(Persistent object);
diff --git a/cayenne/src/main/java/org/apache/cayenne/access/DataContextObjectStore.java b/cayenne/src/main/java/org/apache/cayenne/access/DataContextObjectStore.java
index 974ed80c2..a3a24ad18 100644
--- a/cayenne/src/main/java/org/apache/cayenne/access/DataContextObjectStore.java
+++ b/cayenne/src/main/java/org/apache/cayenne/access/DataContextObjectStore.java
@@ -48,6 +48,7 @@ import org.apache.cayenne.reflect.ToOneProperty;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
+import java.util.HashMap;
 import java.util.Iterator;
 import java.util.List;
 import java.util.Map;
@@ -590,10 +591,12 @@ public class DataContextObjectStore implements ObjectStore {
 
     void processSnapshotEvent(SnapshotEvent event) {
 
-        Map<ObjectId, DataRow> modifiedDiffs = event.getModifiedDiffs();
+        Map<ObjectId, Map<String, Object>> modifiedDiffs = event.getModifiedDiffs();
         if (modifiedDiffs != null && !modifiedDiffs.isEmpty()) {
-            for (Map.Entry<ObjectId, DataRow> entry : modifiedDiffs.entrySet()) {
-                processUpdatedSnapshot(entry.getKey(), entry.getValue());
+            // the merge plans of the refreshed objects, built once per descriptor per event
+            Map<ClassDescriptor, MergePlan> plans = new HashMap<>();
+            for (Map.Entry<ObjectId, Map<String, Object>> entry : modifiedDiffs.entrySet()) {
+                processUpdatedSnapshot(entry.getKey(), entry.getValue(), event.getVersion(), plans);
             }
         }
 
@@ -713,7 +716,7 @@ public class DataContextObjectStore implements ObjectStore {
                             ClassDescriptor descriptor = context
                                     .getEntityResolver()
                                     .getClassDescriptor(oid.getEntityName());
-                            mergeDirty(object, descriptor, snapshot);
+                            mergeDirty(object, descriptor, snapshot, snapshot.getVersion());
                             delegate.finishedMergeChanges(object);
                         }
                     }
@@ -766,9 +769,14 @@ public class DataContextObjectStore implements ObjectStore {
     }
 
     /**
+     * @param diff    the changed keys of the snapshot of the id, with their new values
+     * @param version the version of the snapshot the diff is of
+     * @param plans   the merge plans built so far for this event, by descriptor. A COMMITTED object is refreshed with
+     *                the plan of its descriptor, which is built on the first object of that descriptor
      * @since 1.1
      */
-    void processUpdatedSnapshot(ObjectId id, DataRow diff) {
+    void processUpdatedSnapshot(ObjectId id, Map<String, Object> diff, long version,
+                                Map<ClassDescriptor, MergePlan> plans) {
 
         Persistent object = objectMap.get(id);
 
@@ -796,12 +804,13 @@ public class DataContextObjectStore implements ObjectStore {
                     // the snapshot may need a fetch, so it is taken before the object lock
                     DataRow snapshot = getSnapshot(id);
                     if (snapshot != null) {
+                        MergePlan plan = plans.computeIfAbsent(descriptor, MergePlan::of);
                         withObjectLock(id, () -> {
                             // the object may have been modified since the state check above. A MODIFIED object merges
                             // the cached snapshot when its first change is recorded, so there's nothing to do for it
                             if (object.getPersistenceState() == PersistenceState.COMMITTED
                                     && snapshot.getVersion() >= object.getSnapshotVersion()) {
-                                DataRowUtils.refreshObjectWithSnapshot(descriptor, object, snapshot, true);
+                                DataRowUtils.refreshObjectWithSnapshot(plan, object, snapshot, true);
                                 object.setSnapshotVersion(snapshot.getVersion());
                             }
                             return null;
@@ -818,7 +827,7 @@ public class DataContextObjectStore implements ObjectStore {
                     ClassDescriptor descriptor = context
                             .getEntityResolver()
                             .getClassDescriptor(id.getEntityName());
-                    mergeDirty(object, descriptor, diff);
+                    mergeDirty(object, descriptor, diff, version);
                     delegate.finishedMergeChanges(object);
                 }
             }
@@ -830,13 +839,13 @@ public class DataContextObjectStore implements ObjectStore {
      * record changes to the object, so it takes the changes lock before the object lock. The snapshot is only merged
      * if it is not older than the one the object already reflects.
      */
-    private void mergeDirty(Persistent object, ClassDescriptor descriptor, DataRow snapshot) {
+    private void mergeDirty(Persistent object, ClassDescriptor descriptor, Map<String, Object> snapshot, long version) {
         withChangesLock(() -> withObjectLock(object.getObjectId(), () -> {
             int state = object.getPersistenceState();
             if ((state == PersistenceState.MODIFIED || state == PersistenceState.DELETED)
-                    && snapshot.getVersion() >= object.getSnapshotVersion()) {
+                    && version >= object.getSnapshotVersion()) {
                 DataRowUtils.forceMergeWithSnapshot(context, descriptor, object, snapshot);
-                object.setSnapshotVersion(snapshot.getVersion());
+                object.setSnapshotVersion(version);
             }
             return null;
         }));
diff --git a/cayenne/src/main/java/org/apache/cayenne/access/DataRowStore.java b/cayenne/src/main/java/org/apache/cayenne/access/DataRowStore.java
index 2d5170d6b..b6744bb95 100644
--- a/cayenne/src/main/java/org/apache/cayenne/access/DataRowStore.java
+++ b/cayenne/src/main/java/org/apache/cayenne/access/DataRowStore.java
@@ -24,7 +24,6 @@ import org.apache.cayenne.DataRow;
 import org.apache.cayenne.ObjectId;
 import org.apache.cayenne.PersistenceState;
 import org.apache.cayenne.Persistent;
-import org.apache.cayenne.RowLayout;
 import org.apache.cayenne.access.event.SnapshotEvent;
 import org.apache.cayenne.event.EventBridge;
 import org.apache.cayenne.event.EventManager;
@@ -39,6 +38,7 @@ import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Objects;
+import java.util.function.BiConsumer;
 import java.util.concurrent.ConcurrentMap;
 
 /**
@@ -286,7 +286,7 @@ public class DataRowStore {
 
         Collection<ObjectId> deletedSnapshotIds = event.getDeletedIds();
         Collection<ObjectId> invalidatedSnapshotIds = event.getInvalidatedIds();
-        Map<ObjectId, DataRow> diffs = event.getModifiedDiffs();
+        Map<ObjectId, Map<String, Object>> diffs = event.getModifiedDiffs();
         Collection<ObjectId> indirectlyModifiedIds = event.getIndirectlyModifiedIds();
 
         if (deletedSnapshotIds.isEmpty()
@@ -299,9 +299,10 @@ public class DataRowStore {
 
         processDeletedIDs(deletedSnapshotIds);
         processInvalidatedIDs(invalidatedSnapshotIds);
-        processUpdateDiffs(diffs);
+        long version = processUpdateDiffs(diffs);
         sendUpdateNotification(
                 event.getPostedBy(),
+                version,
                 diffs,
                 deletedSnapshotIds,
                 invalidatedSnapshotIds,
@@ -331,9 +332,10 @@ public class DataRowStore {
 
         processDeletedIDs(deletedSnapshotIds);
         processInvalidatedIDs(invalidatedSnapshotIds);
-        Map<ObjectId, DataRow> diffs = processUpdatedSnapshots(updatedSnapshots);
+        Map<ObjectId, Map<String, Object>> diffs = processUpdatedSnapshots(updatedSnapshots);
         sendUpdateNotification(
                 postedBy,
+                version(updatedSnapshots),
                 diffs,
                 deletedSnapshotIds,
                 invalidatedSnapshotIds,
@@ -358,8 +360,20 @@ public class DataRowStore {
         }
     }
 
-    private Map<ObjectId, DataRow> processUpdatedSnapshots(Map<ObjectId, DataRow> updatedSnapshots) {
-        Map<ObjectId, DataRow> diffs = null;
+    /**
+     * Returns the version of the snapshots, which are of one operation, or {@link SnapshotVersion#NONE} if there are
+     * none.
+     */
+    private static long version(Map<ObjectId, DataRow> snapshots) {
+        long version = SnapshotVersion.NONE;
+        for (DataRow snapshot : snapshots.values()) {
+            version = Math.max(version, snapshot.getVersion());
+        }
+        return version;
+    }
+
+    private Map<ObjectId, Map<String, Object>> processUpdatedSnapshots(Map<ObjectId, DataRow> updatedSnapshots) {
+        Map<ObjectId, Map<String, Object>> diffs = null;
 
         // MODIFIED: replace/add snapshots, generate diffs for event
         if (!updatedSnapshots.isEmpty()) {
@@ -388,7 +402,7 @@ public class DataRowStore {
 
                 // generate diff for the updated event, if this not a new snapshot
                 if (replaced[0] != null) {
-                    DataRow diff = diff(replaced[0], newSnapshot);
+                    Map<String, Object> diff = diff(replaced[0], newSnapshot);
 
                     if (diff != null) {
                         if (diffs == null) {
@@ -405,68 +419,67 @@ public class DataRowStore {
     }
 
     /**
-     * Returns a row of the keys of {@code oldSnapshot} whose values differ in {@code newSnapshot}, with the values of
+     * Returns the keys of {@code oldSnapshot} whose values differ in {@code newSnapshot}, with the values of
      * {@code newSnapshot}, or null if there are no differences. Key sets are assumed compatible, i.e. both rows are
-     * snapshots of the same entity. The diff represents the newer row, so it carries that row's version.
+     * snapshots of the same entity.
      */
-    private static DataRow diff(DataRow oldSnapshot, DataRow newSnapshot) {
-        HashMap<String, Object> diff = null;
-
-        RowLayout layout = oldSnapshot.getLayout();
-        if (layout != null) {
-            // the old row is an array row: walk its columns by position instead of through entrySet(), which would
-            // allocate an entry per column. The new row is normally read by a later statement, so it has a layout of
-            // its own, and its values are looked up by name. The layouts are the same object only when both rows came
-            // from the same reader, and then the new row can be read by position too
-            boolean sameLayout = layout == newSnapshot.getLayout();
-            int size = layout.size();
-            for (int i = 0; i < size; i++) {
-                String name = layout.nameAt(i);
-                Object newValue = sameLayout ? newSnapshot.valueAt(i) : newSnapshot.get(name);
-                if (!Objects.deepEquals(oldSnapshot.valueAt(i), newValue)) {
-                    if (diff == null) {
-                        diff = new HashMap<>(size);
-                    }
-                    diff.put(name, newValue);
-                }
-            }
-        } else {
-            for (Map.Entry<String, Object> entry : oldSnapshot.entrySet()) {
-                Object newValue = newSnapshot.get(entry.getKey());
-                if (!Objects.deepEquals(entry.getValue(), newValue)) {
-                    if (diff == null) {
-                        diff = new HashMap<>(oldSnapshot.size());
-                    }
-                    diff.put(entry.getKey(), newValue);
+    private static Map<String, Object> diff(DataRow oldSnapshot, DataRow newSnapshot) {
+        // forEach, unlike the entry set, allocates no entries
+        Diff diff = new Diff(newSnapshot);
+        oldSnapshot.forEach(diff);
+        return diff.changes;
+    }
+
+    private static final class Diff implements BiConsumer<String, Object> {
+
+        private final DataRow newSnapshot;
+        private HashMap<String, Object> changes;
+
+        Diff(DataRow newSnapshot) {
+            this.newSnapshot = newSnapshot;
+        }
+
+        @Override
+        public void accept(String name, Object oldValue) {
+            Object newValue = newSnapshot.get(name);
+            if (!Objects.deepEquals(oldValue, newValue)) {
+                if (changes == null) {
+                    changes = new HashMap<>(newSnapshot.size());
                 }
+                changes.put(name, newValue);
             }
         }
-
-        return diff != null ? DataRow.of(newSnapshot.getEntityName(), diff, newSnapshot.getVersion()) : null;
     }
 
-    private void processUpdateDiffs(Map<ObjectId, DataRow> diffs) {
-        // apply snapshot diffs
-        if (!diffs.isEmpty()) {
-            // the diffs come from another JVM, so their versions are not comparable with the local ones: the merged
-            // snapshots take the version of this event
-            long version = SnapshotVersion.next();
-            for (Map.Entry<ObjectId, DataRow> entry : diffs.entrySet()) {
-                ObjectId key = entry.getKey();
-                DataRow oldSnapshot = snapshots.remove(key);
+    /**
+     * Applies the diffs of a remote event to the cached snapshots and returns the version the merged snapshots got,
+     * or {@link SnapshotVersion#NONE} if there were no diffs.
+     */
+    private long processUpdateDiffs(Map<ObjectId, Map<String, Object>> diffs) {
+        if (diffs.isEmpty()) {
+            return SnapshotVersion.NONE;
+        }
 
-                if (oldSnapshot == null) {
-                    continue;
-                }
+        // the diffs come from another JVM, so their version is not comparable with the local ones: the merged
+        // snapshots take a version of this JVM
+        long version = SnapshotVersion.next();
+        for (Map.Entry<ObjectId, Map<String, Object>> entry : diffs.entrySet()) {
+            ObjectId key = entry.getKey();
+            DataRow oldSnapshot = snapshots.remove(key);
 
-                snapshots.put(key, oldSnapshot.merged(entry.getValue(), version));
+            if (oldSnapshot == null) {
+                continue;
             }
+
+            snapshots.put(key, oldSnapshot.merged(entry.getValue(), version));
         }
+        return version;
     }
 
     private void sendUpdateNotification(
             Object postedBy,
-            Map<ObjectId, DataRow> diffs,
+            long version,
+            Map<ObjectId, Map<String, Object>> diffs,
             Collection<ObjectId> deletedSnapshotIDs,
             Collection<ObjectId> invalidatedSnapshotIDs,
             Collection<ObjectId> indirectlyModifiedIds) {
@@ -480,6 +493,7 @@ public class DataRowStore {
             SnapshotEvent event = new SnapshotEvent(
                     this,
                     postedBy,
+                    version,
                     diffs,
                     deletedSnapshotIDs,
                     invalidatedSnapshotIDs,
diff --git a/cayenne/src/main/java/org/apache/cayenne/access/DataRowUtils.java b/cayenne/src/main/java/org/apache/cayenne/access/DataRowUtils.java
index dd6b5d34a..d58f8f59b 100644
--- a/cayenne/src/main/java/org/apache/cayenne/access/DataRowUtils.java
+++ b/cayenne/src/main/java/org/apache/cayenne/access/DataRowUtils.java
@@ -16,16 +16,15 @@
  *  specific language governing permissions and limitations
  *  under the License.
  ****************************************************************/
-
 package org.apache.cayenne.access;
 
+import org.apache.cayenne.DataRowLayout;
 import org.apache.cayenne.DataRow;
 import org.apache.cayenne.ObjectId;
 import org.apache.cayenne.PersistenceState;
 import org.apache.cayenne.Persistent;
 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.ArcProperty;
 import org.apache.cayenne.reflect.AttributeProperty;
@@ -35,7 +34,6 @@ import org.apache.cayenne.reflect.ToManyProperty;
 import org.apache.cayenne.reflect.ToOneProperty;
 import org.apache.cayenne.util.Util;
 
-import java.util.List;
 import java.util.Map;
 
 /**
@@ -46,72 +44,58 @@ import java.util.Map;
  */
 class DataRowUtils {
 
-    /**
-     * Merges changes reflected in snapshot map to the object. Changes made to attributes
-     * and to-one relationships will be merged. In case an object is already modified,
-     * modified properties will not be overwritten.
-     */
-    static void mergeObjectWithSnapshot(
-            DataContext context,
-            ClassDescriptor descriptor,
-            Persistent object,
-            DataRow snapshot) {
-        mergeObjectWithSnapshot(context, descriptor, object, snapshot, null);
-    }
-
     /**
      * Merges changes reflected in snapshot map to the object. Changes made to attributes
      * and to-one relationships will be merged. In case an object is already modified,
      * modified properties will not be overwritten.
      *
-     * @param plan the positions of the descriptor's columns in the snapshot, or null to look them up by name. A plan
-     *             of another layout than the snapshot's is ignored
+     * @param plan the plan of the object's descriptor
      */
     static void mergeObjectWithSnapshot(
             DataContext context,
-            ClassDescriptor descriptor,
             Persistent object,
             DataRow snapshot,
             MergePlan plan) {
 
         int state = object.getPersistenceState();
 
-        if (plan != null && plan.layout != snapshot.getLayout()) {
-            plan = null;
-        }
-
         if (state == PersistenceState.HOLLOW) {
-            refreshObjectWithSnapshot(descriptor, object, snapshot, true, plan);
-        } else if (state != PersistenceState.COMMITTED && !descriptor.getEntity().isReadOnly()) {
-            forceMergeWithSnapshot(context, descriptor, object, snapshot);
+            refreshObjectWithSnapshot(plan, object, snapshot, true);
+        } else if (state != PersistenceState.COMMITTED && !plan.descriptor.getEntity().isReadOnly()) {
+            forceMergeWithSnapshot(context, plan.descriptor, object, snapshot);
         } else {
             // do not invalidate relationships: to-many lists might have just been prefetched, and a to-one target
             // whose FK did not change is still valid
-            refreshObjectWithSnapshot(descriptor, object, snapshot, false, plan);
+            refreshObjectWithSnapshot(plan, object, snapshot, false);
         }
     }
 
-    private static void refreshObjectWithSnapshot(
-            ClassDescriptor descriptor,
+    /**
+     * Replaces all object attribute values with snapshot values. Sets object state to
+     * COMMITTED, unless the snapshot is partial in which case the state is set to HOLLOW.
+     * With 'invalidateRelationships' all relationships are turned into faults. Otherwise to-many relationships
+     * are left alone, and a resolved to-one target is kept when the snapshot FK still points to it.
+     *
+     * @param plan the plan of the object's descriptor
+     */
+    static void refreshObjectWithSnapshot(
+            MergePlan plan,
             Persistent object,
             DataRow snapshot,
-            boolean invalidateRelationships,
-            MergePlan plan) {
-
-        if (plan == null) {
-            refreshObjectWithSnapshot(descriptor, object, snapshot, invalidateRelationships);
-            return;
-        }
+            boolean invalidateRelationships) {
 
         boolean isPartialSnapshot = false;
 
+        DataRowLayout.Reader reader = plan.reader;
         AttributeProperty[] attributes = plan.attributes;
-        int[] attributeIndexes = plan.attributeIndexes;
         for (int i = 0; i < attributes.length; i++) {
             AttributeProperty property = attributes[i];
-            int index = attributeIndexes[i];
-            if (index >= 0) {
-                property.writePropertyDirectly(object, null, snapshot.valueAt(index));
+            int column = plan.attributeColumns[i];
+            Object value = reader.get(snapshot, column);
+
+            // a null value is valid, so the column is only checked for when the value is null
+            if (value != null || reader.has(snapshot, column)) {
+                property.writePropertyDirectly(object, null, value);
             } else if (property.getAttribute().isLazy()) {
                 property.writePropertyDirectly(object, null, new AttributeFault(property));
             } else {
@@ -131,64 +115,7 @@ class DataRowUtils {
         } else {
             ToOneProperty[] toOnes = plan.toOnes;
             for (int i = 0; i < toOnes.length; i++) {
-                refreshToOne(toOnes[i], object, snapshot, plan.toOneFkIndexes[i], plan.toOneTargetNames[i]);
-            }
-        }
-
-        object.setPersistenceState(isPartialSnapshot
-                ? PersistenceState.HOLLOW
-                : PersistenceState.COMMITTED);
-    }
-
-    /**
-     * Replaces all object attribute values with snapshot values. Sets object state to
-     * COMMITTED, unless the snapshot is partial in which case the state is set to HOLLOW.
-     * With 'invalidateRelationships' all relationships are turned into faults. Otherwise to-many relationships
-     * are left alone, and a resolved to-one target is kept when the snapshot FK still points to it.
-     */
-    static void refreshObjectWithSnapshot(
-            ClassDescriptor descriptor,
-            Persistent object,
-            DataRow snapshot,
-            boolean invalidateRelationships) {
-
-        boolean isPartialSnapshot = false;
-
-        for (PropertyDescriptor property : descriptor.getProperties()) {
-            switch (property) {
-                case AttributeProperty attribute -> {
-                    ObjAttribute attr = attribute.getAttribute();
-                    String dbAttrPath = attr.getDbAttributePath().value();
-
-                    Object value = snapshot.get(dbAttrPath);
-                    attribute.writePropertyDirectly(object, null, value);
-
-                    // note that a check "snaphsot.get(..) == null" would be incorrect in this
-                    // case, as NULL value is entirely valid; still save a map lookup by
-                    // checking for the null value first
-                    if (value == null && !snapshot.containsKey(dbAttrPath)) {
-                        if (attr.isLazy()) {
-                            attribute.writePropertyDirectly(object, null, new AttributeFault(attribute));
-                        } else {
-                            isPartialSnapshot = true;
-                        }
-                    }
-                }
-                case ToOneProperty toOne -> {
-                    if (invalidateRelationships) {
-                        toOne.invalidate(object);
-                    } else {
-                        refreshToOne(toOne, object, snapshot);
-                    }
-                }
-                case ToManyProperty toMany -> {
-                    // "to many" relationships have no information to collect from snapshot
-                    if (invalidateRelationships) {
-                        toMany.invalidate(object);
-                    }
-                }
-                default -> {
-                }
+                refreshToOne(toOnes[i], object, snapshot, reader, plan.toOneFkColumns[i], plan.toOneTargetNames[i]);
             }
         }
 
@@ -202,91 +129,43 @@ class DataRowUtils {
      * Replacing an unchanged target with a fault would be pointless: the fault resolves from the cached snapshot to
      * the very same object. It is also harmful on a shared DataContext, where other threads would observe the
      * transient fault and have to resolve it, each contending for the ObjectStore lock.
+     *
+     * @param fkColumns   the FK columns, join by join, or null when the FK does not define the arc. A flattened or a
+     *                    to-dependent-PK relationship is defined by the target rows, so it is invalidated as before
+     * @param targetNames the target PK column of each join
      */
-    private static void refreshToOne(ToOneProperty property, Persistent object, DataRow snapshot) {
+    private static void refreshToOne(ToOneProperty property, Persistent object, DataRow snapshot,
+                                     DataRowLayout.Reader reader, int[] fkColumns, String[] targetNames) {
         Object current = property.readPropertyDirectly(object);
         if (property.isFaultValue(current)) {
             return;
         }
 
-        ObjRelationship relationship = property.getRelationship();
-
-        // only an FK to the target PK lets the source snapshot tell whether the arc changed. A flattened or a
-        // to-dependent-PK relationship is defined by the target rows, so it is invalidated as before
-        if (relationship.isSourceIndependentFromTargetChange()) {
+        if (fkColumns == null) {
             property.invalidate(object);
             return;
         }
 
         // the FK is compared with the current target id join by join, without building an intermediate id
         Map<String, Object> currentPk = current != null ? ((Persistent) current).getObjectId().getIdSnapshot() : null;
-        List<DbJoin> joins = relationship.getDbRelationships().getFirst().getJoins();
         int nullFks = 0;
 
-        for (DbJoin join : joins) {
-            String fkName = join.getSourceName();
-            Object fk = snapshot.get(fkName);
+        for (int i = 0; i < fkColumns.length; i++) {
+            Object fk = reader.get(snapshot, fkColumns[i]);
             if (fk == null) {
-                if (!snapshot.containsKey(fkName)) {
+                if (!reader.has(snapshot, fkColumns[i])) {
                     // partial snapshot
                     property.invalidate(object);
                     return;
                 }
                 nullFks++;
-            } else if (currentPk == null || !Util.idValueEquals(fk, currentPk.get(join.getTargetName()))) {
-                property.invalidate(object);
-                return;
-            }
-        }
-
-        refreshToOne(property, object, current, nullFks, joins.size());
-    }
-
-    /**
-     * The same as {@link #refreshToOne(ToOneProperty, Persistent, DataRow)}, with the FK columns read by position.
-     *
-     * @param fkIndexes   the positions of the FK columns, join by join, or null when the FK does not define the arc
-     * @param targetNames the target PK column of each join
-     */
-    private static void refreshToOne(ToOneProperty property, Persistent object, DataRow snapshot, int[] fkIndexes,
-                                     String[] targetNames) {
-        Object current = property.readPropertyDirectly(object);
-        if (property.isFaultValue(current)) {
-            return;
-        }
-
-        if (fkIndexes == null) {
-            property.invalidate(object);
-            return;
-        }
-
-        Map<String, Object> currentPk = current != null ? ((Persistent) current).getObjectId().getIdSnapshot() : null;
-        int nullFks = 0;
-
-        for (int i = 0; i < fkIndexes.length; i++) {
-            if (fkIndexes[i] < 0) {
-                // partial snapshot
-                property.invalidate(object);
-                return;
-            }
-            Object fk = snapshot.valueAt(fkIndexes[i]);
-            if (fk == null) {
-                nullFks++;
             } else if (currentPk == null || !Util.idValueEquals(fk, currentPk.get(targetNames[i]))) {
                 property.invalidate(object);
                 return;
             }
         }
 
-        refreshToOne(property, object, current, nullFks, fkIndexes.length);
-    }
-
-    /**
-     * Settles a to-one whose non-null FK columns all point to the current target.
-     */
-    private static void refreshToOne(ToOneProperty property, Persistent object, Object current, int nullFks,
-                                     int joins) {
-        if (nullFks == joins) {
+        if (nullFks == fkColumns.length) {
             if (current != null) {
                 property.writePropertyDirectly(object, current, null);
             }
@@ -296,11 +175,15 @@ class DataRowUtils {
         }
     }
 
+    /**
+     * Merges a snapshot, or a diff of one, into a MODIFIED or DELETED object: the properties the object has not
+     * changed take the snapshot values.
+     */
     static void forceMergeWithSnapshot(
             DataContext context,
             ClassDescriptor descriptor,
             Persistent object,
-            DataRow snapshot) {
+            Map<String, Object> snapshot) {
 
         ObjectDiff diff = context
                 .getObjectStore()
@@ -321,7 +204,7 @@ class DataRowUtils {
     private static void mergeAttribute(
             AttributeProperty property,
             Persistent object,
-            DataRow snapshot,
+            Map<String, Object> snapshot,
             ObjectDiff diff) {
 
         String dbAttrPath = property.getAttribute().getDbAttributePath().value();
@@ -346,7 +229,7 @@ class DataRowUtils {
             DataContext context,
             ToOneProperty property,
             Persistent object,
-            DataRow snapshot,
+            Map<String, Object> snapshot,
             ObjectDiff diff) {
 
         ObjRelationship relationship = property.getRelationship();
diff --git a/cayenne/src/main/java/org/apache/cayenne/access/HierarchicalObjectResolver.java b/cayenne/src/main/java/org/apache/cayenne/access/HierarchicalObjectResolver.java
index ea5cf38b7..a5a6c1305 100644
--- a/cayenne/src/main/java/org/apache/cayenne/access/HierarchicalObjectResolver.java
+++ b/cayenne/src/main/java/org/apache/cayenne/access/HierarchicalObjectResolver.java
@@ -20,6 +20,7 @@
 package org.apache.cayenne.access;
 
 import org.apache.cayenne.CayenneRuntimeException;
+import org.apache.cayenne.DataRowLayout;
 import org.apache.cayenne.DataRow;
 import org.apache.cayenne.Persistent;
 import org.apache.cayenne.exp.Expression;
@@ -156,7 +157,7 @@ class HierarchicalObjectResolver {
 
             int maxIdQualifierSize = context.getChannel().getDataDomain().getMaxIdQualifierSize();
             List<DbJoin> joins = getDbJoins(relationship);
-            RowColumns keyColumns = new RowColumns(dataRowKeys(joins, pathPrefix));
+            DataRowLayout.Reader keyReader = DataRowLayout.of(dataRowKeys(joins, pathPrefix)).reader();
 
             List<PrefetchSelectQuery<DataRow>> queries = new ArrayList<>();
             PrefetchSelectQuery<DataRow> currentQuery = null;
@@ -179,7 +180,7 @@ class HierarchicalObjectResolver {
 
                 List<Object> joinValues = new ArrayList<>(joins.size());
                 for (int i = 0; i < joins.size(); i++) {
-                    joinValues.add(keyColumns.get(dataRow, i));
+                    joinValues.add(keyReader.get(dataRow, i));
                 }
 
                 if(values.add(joinValues)) {
diff --git a/cayenne/src/main/java/org/apache/cayenne/access/HierarchicalObjectResolverNode.java b/cayenne/src/main/java/org/apache/cayenne/access/HierarchicalObjectResolverNode.java
index 3d8e9dbb9..fca548e99 100644
--- a/cayenne/src/main/java/org/apache/cayenne/access/HierarchicalObjectResolverNode.java
+++ b/cayenne/src/main/java/org/apache/cayenne/access/HierarchicalObjectResolverNode.java
@@ -52,7 +52,7 @@ class HierarchicalObjectResolverNode extends PrefetchObjectResolver {
             // determine entity to use
             ClassDescriptor classDescriptor = descriptorResolutionStrategy.descriptorForRow(row);
 
-            MergePlan plan = planFor(row, classDescriptor);
+            MergePlan plan = planFor(classDescriptor);
             ObjectId anId = createObjectId(row, classDescriptor, plan);
 
             Persistent object = objectFromDataRow(row, anId, classDescriptor, plan);
diff --git a/cayenne/src/main/java/org/apache/cayenne/access/MergePlan.java b/cayenne/src/main/java/org/apache/cayenne/access/MergePlan.java
index 47cb75d1f..baea724b0 100644
--- a/cayenne/src/main/java/org/apache/cayenne/access/MergePlan.java
+++ b/cayenne/src/main/java/org/apache/cayenne/access/MergePlan.java
@@ -16,10 +16,11 @@
  *  specific language governing permissions and limitations
  *  under the License.
  ****************************************************************/
-
 package org.apache.cayenne.access;
 
-import org.apache.cayenne.RowLayout;
+import org.apache.cayenne.CayenneRuntimeException;
+import org.apache.cayenne.DataRowLayout;
+import org.apache.cayenne.DataRow;
 import org.apache.cayenne.map.DbAttribute;
 import org.apache.cayenne.map.DbJoin;
 import org.apache.cayenne.map.ObjRelationship;
@@ -34,56 +35,62 @@ import java.util.Collection;
 import java.util.List;
 
 /**
- * The positions of the columns that turn rows of one {@link RowLayout} into objects of one {@link ClassDescriptor}:
- * the primary key, the attributes and the to-one foreign keys. A resolver builds a plan for the rows it converts,
- * so the column names are looked up once per batch of rows instead of once per row. A position of -1 marks a column
- * missing from the layout, i.e. a partial snapshot.
+ * The columns that turn rows into objects of one {@link ClassDescriptor}: the primary key, the attributes and the
+ * to-one foreign keys, by their positions in the descriptor's {@link ClassDescriptor#getSnapshotLayout() snapshot
+ * layout}, and a {@link DataRowLayout.Reader} of that layout, which reads a snapshot built from an object by position
+ * and looks the positions up once per result set otherwise. A resolver builds a plan per descriptor for the rows it
+ * converts.
  */
 final class MergePlan {
 
-    final RowLayout layout;
     final ClassDescriptor descriptor;
 
-    // the primary key of the descriptor's DbEntity
+    final DataRowLayout layout;
+    final DataRowLayout.Reader reader;
+
+    // the primary key of the descriptor's DbEntity, PK i being column pkColumns[i]
     final String[] pkNames;
-    final int[] pkIndexes;
+    final int[] pkColumns;
 
+    // attribute i being column attributeColumns[i]
     final AttributeProperty[] attributes;
-    final int[] attributeIndexes;
+    final int[] attributeColumns;
 
     final ToOneProperty[] toOnes;
-    // per to-one, the positions of the FK columns join by join, or null when the FK does not define the arc
-    final int[][] toOneFkIndexes;
-    // per to-one, the target PK column of each join
+    // per to-one, the columns of the FK join by join, or null when the FK does not define the arc
+    final int[][] toOneFkColumns;
+    // per to-one, the target PK column name of each join
     final String[][] toOneTargetNames;
 
     final ToManyProperty[] toManys;
 
-    private MergePlan(RowLayout layout, ClassDescriptor descriptor, String[] pkNames, int[] pkIndexes,
-                      AttributeProperty[] attributes, int[] attributeIndexes, ToOneProperty[] toOnes,
-                      int[][] toOneFkIndexes, String[][] toOneTargetNames, ToManyProperty[] toManys) {
-        this.layout = layout;
+    private MergePlan(ClassDescriptor descriptor, DataRowLayout layout, String[] pkNames, int[] pkColumns,
+                      AttributeProperty[] attributes, int[] attributeColumns, ToOneProperty[] toOnes,
+                      int[][] toOneFkColumns, String[][] toOneTargetNames, ToManyProperty[] toManys) {
         this.descriptor = descriptor;
+        this.layout = layout;
+        this.reader = layout.reader();
         this.pkNames = pkNames;
-        this.pkIndexes = pkIndexes;
+        this.pkColumns = pkColumns;
         this.attributes = attributes;
-        this.attributeIndexes = attributeIndexes;
+        this.attributeColumns = attributeColumns;
         this.toOnes = toOnes;
-        this.toOneFkIndexes = toOneFkIndexes;
+        this.toOneFkColumns = toOneFkColumns;
         this.toOneTargetNames = toOneTargetNames;
         this.toManys = toManys;
     }
 
-    static MergePlan of(RowLayout layout, ClassDescriptor descriptor) {
+    static MergePlan of(ClassDescriptor descriptor) {
+
+        DataRowLayout layout = descriptor.getSnapshotLayout();
 
         Collection<DbAttribute> pk = descriptor.getEntity().getDbEntity().getPrimaryKeys();
         String[] pkNames = new String[pk.size()];
-        int[] pkIndexes = new int[pk.size()];
+        int[] pkColumns = new int[pk.size()];
         int i = 0;
         for (DbAttribute attribute : pk) {
             pkNames[i] = attribute.getName();
-            pkIndexes[i] = layout.indexOf(attribute.getName());
-            i++;
+            pkColumns[i++] = column(layout, attribute.getName());
         }
 
         List<AttributeProperty> attributes = new ArrayList<>();
@@ -99,33 +106,41 @@ final class MergePlan {
             }
         }
 
-        int[] attributeIndexes = new int[attributes.size()];
-        for (i = 0; i < attributeIndexes.length; i++) {
-            attributeIndexes[i] = layout.indexOf(attributes.get(i).getAttribute().getDbAttributePath().value());
+        int[] attributeColumns = new int[attributes.size()];
+        for (i = 0; i < attributeColumns.length; i++) {
+            attributeColumns[i] = column(layout, attributes.get(i).getAttribute().getDbAttributePath().value());
         }
 
-        int[][] toOneFkIndexes = new int[toOnes.size()][];
+        int[][] toOneFkColumns = new int[toOnes.size()][];
         String[][] toOneTargetNames = new String[toOnes.size()][];
-        for (i = 0; i < toOneFkIndexes.length; i++) {
+        for (i = 0; i < toOneFkColumns.length; i++) {
             ObjRelationship relationship = toOnes.get(i).getRelationship();
             // only an FK to the target PK lets the source snapshot tell whether the arc changed
             if (relationship.isSourceIndependentFromTargetChange()) {
                 continue;
             }
             List<DbJoin> joins = relationship.getDbRelationships().getFirst().getJoins();
-            int[] fkIndexes = new int[joins.size()];
+            int[] fkColumns = new int[joins.size()];
             String[] targetNames = new String[joins.size()];
-            for (int j = 0; j < fkIndexes.length; j++) {
-                fkIndexes[j] = layout.indexOf(joins.get(j).getSourceName());
+            for (int j = 0; j < fkColumns.length; j++) {
+                fkColumns[j] = column(layout, joins.get(j).getSourceName());
                 targetNames[j] = joins.get(j).getTargetName();
             }
-            toOneFkIndexes[i] = fkIndexes;
+            toOneFkColumns[i] = fkColumns;
             toOneTargetNames[i] = targetNames;
         }
 
-        return new MergePlan(layout, descriptor, pkNames, pkIndexes,
-                attributes.toArray(new AttributeProperty[0]), attributeIndexes,
-                toOnes.toArray(new ToOneProperty[0]), toOneFkIndexes, toOneTargetNames,
+        return new MergePlan(descriptor, layout, pkNames, pkColumns,
+                attributes.toArray(new AttributeProperty[0]), attributeColumns,
+                toOnes.toArray(new ToOneProperty[0]), toOneFkColumns, toOneTargetNames,
                 toManys.toArray(new ToManyProperty[0]));
     }
+
+    private static int column(DataRowLayout layout, String name) {
+        int column = layout.indexOf(name);
+        if (column < 0) {
+            throw new CayenneRuntimeException("Column '%s' is not in the snapshot layout %s", name, layout);
+        }
+        return column;
+    }
 }
diff --git a/cayenne/src/main/java/org/apache/cayenne/access/NoopDelegate.java b/cayenne/src/main/java/org/apache/cayenne/access/NoopDelegate.java
index 606cb214a..4bfffde48 100644
--- a/cayenne/src/main/java/org/apache/cayenne/access/NoopDelegate.java
+++ b/cayenne/src/main/java/org/apache/cayenne/access/NoopDelegate.java
@@ -19,10 +19,11 @@
 
 package org.apache.cayenne.access;
 
-import org.apache.cayenne.DataRow;
 import org.apache.cayenne.Persistent;
 import org.apache.cayenne.query.Query;
 
+import java.util.Map;
+
 /**
  * A noop DataContextDelegate.
  * 
@@ -43,7 +44,7 @@ final class NoopDelegate implements DataContextDelegate {
     }
 
     @Override
-    public boolean shouldMergeChanges(Persistent object, DataRow snapshotInStore) {
+    public boolean shouldMergeChanges(Persistent object, Map<String, Object> snapshotInStore) {
         return true;
     }
 
diff --git a/cayenne/src/main/java/org/apache/cayenne/access/ObjectResolver.java b/cayenne/src/main/java/org/apache/cayenne/access/ObjectResolver.java
index d70bda101..320ca1e77 100644
--- a/cayenne/src/main/java/org/apache/cayenne/access/ObjectResolver.java
+++ b/cayenne/src/main/java/org/apache/cayenne/access/ObjectResolver.java
@@ -20,12 +20,12 @@
 package org.apache.cayenne.access;
 
 import org.apache.cayenne.CayenneRuntimeException;
+import org.apache.cayenne.DataRowLayout;
 import org.apache.cayenne.DataRow;
 import org.apache.cayenne.ObjectContext;
 import org.apache.cayenne.ObjectId;
 import org.apache.cayenne.PersistenceState;
 import org.apache.cayenne.Persistent;
-import org.apache.cayenne.RowLayout;
 import org.apache.cayenne.exp.path.CayennePath;
 import org.apache.cayenne.map.DbAttribute;
 import org.apache.cayenne.map.DbEntity;
@@ -60,7 +60,7 @@ class ObjectResolver {
 	DescriptorResolutionStrategy descriptorResolutionStrategy;
 
 	// the plan of the rows being converted, for the root descriptor, and per subclass descriptor when there is
-	// inheritance. Rebuilt when rows of another layout come along
+	// inheritance. A plan follows the columns of the rows it is applied to, so it is built once per resolver
 	private MergePlan plan;
 	private Map<ClassDescriptor, MergePlan> subclassPlans;
 
@@ -121,24 +121,18 @@ class ObjectResolver {
 	Persistent objectFromDataRow(DataRow row) {
 		// determine entity to use
 		ClassDescriptor classDescriptor = descriptorResolutionStrategy.descriptorForRow(row);
-		MergePlan plan = planFor(row, classDescriptor);
+		MergePlan plan = planFor(classDescriptor);
 		ObjectId anId = createObjectId(row, classDescriptor, plan);
 		return objectFromDataRow(row, anId, classDescriptor, plan);
 	}
 
 	/**
-	 * Returns the plan of the columns of the given descriptor in rows of the given row's layout, or null if the row
-	 * has no layout, in which case the columns are looked up by name.
+	 * Returns the plan of the columns of the given descriptor, built on the first call for the descriptor.
 	 */
-	MergePlan planFor(DataRow row, ClassDescriptor classDescriptor) {
-		RowLayout layout = row.getLayout();
-		if (layout == null) {
-			return null;
-		}
-
+	MergePlan planFor(ClassDescriptor classDescriptor) {
 		if (classDescriptor == descriptor) {
-			if (plan == null || plan.layout != layout) {
-				plan = MergePlan.of(layout, classDescriptor);
+			if (plan == null) {
+				plan = MergePlan.of(classDescriptor);
 			}
 			return plan;
 		}
@@ -146,56 +140,50 @@ class ObjectResolver {
 		if (subclassPlans == null) {
 			subclassPlans = new HashMap<>();
 		}
-		MergePlan subclassPlan = subclassPlans.get(classDescriptor);
-		if (subclassPlan == null || subclassPlan.layout != layout) {
-			subclassPlan = MergePlan.of(layout, classDescriptor);
-			subclassPlans.put(classDescriptor, subclassPlan);
-		}
-		return subclassPlan;
+		return subclassPlans.computeIfAbsent(classDescriptor, MergePlan::of);
 	}
 
 	/**
 	 * Returns the id of the row's object, or null if the row has a null PK, which is possible when processing left
 	 * outer joint prefetches.
 	 *
-	 * @param plan the plan of the descriptor's columns in the row, as returned by {@link #planFor(DataRow,
-	 *             ClassDescriptor)}, or null to look the PK columns up by name
+	 * @param plan the plan of the descriptor, as returned by {@link #planFor(ClassDescriptor)}
 	 */
 	ObjectId createObjectId(DataRow row, ClassDescriptor classDescriptor, MergePlan plan) {
-		if (plan == null) {
-			// not using DataRow.createObjectId for performance reasons -
-			// ObjectResolver has all needed metadata already cached.
-			return createObjectId(row, classDescriptor.getEntity(), CayennePath.EMPTY_PATH);
-		}
-
 		String name = classDescriptor.getEntity().getName();
-		int[] pkIndexes = plan.pkIndexes;
+		String[] pkNames = plan.pkNames;
+		int[] pkColumns = plan.pkColumns;
+		DataRowLayout.Reader reader = plan.reader;
 
-		if (pkIndexes.length == 1) {
-			if (pkIndexes[0] < 0) {
-				throw new CayenneRuntimeException("No PK column '%s' found in data row.", plan.pkNames[0]);
+		// PK i is column i of the plan
+
+		if (pkNames.length == 1) {
+			Object val = reader.get(row, pkColumns[0]);
+			if (val == null) {
+				if (!reader.has(row, pkColumns[0])) {
+					throw new CayenneRuntimeException("No PK column '%s' found in data row.", pkNames[0]);
+				}
+				return null;
 			}
-			Object val = row.valueAt(pkIndexes[0]);
-			return val != null ? ObjectId.of(name, plan.pkNames[0], val) : null;
+			return ObjectId.of(name, pkNames[0], val);
 		}
 
-		Map<String, Object> idMap = new HashMap<>(pkIndexes.length * 2);
-		for (int i = 0; i < pkIndexes.length; i++) {
-			if (pkIndexes[i] < 0) {
-				throw new CayenneRuntimeException("No PK column '%s' found in data row.", plan.pkNames[i]);
-			}
-			Object val = row.valueAt(pkIndexes[i]);
+		Map<String, Object> idMap = new HashMap<>(pkNames.length * 2);
+		for (int i = 0; i < pkNames.length; i++) {
+			Object val = reader.get(row, pkColumns[i]);
 			if (val == null) {
+				if (!reader.has(row, pkColumns[i])) {
+					throw new CayenneRuntimeException("No PK column '%s' found in data row.", pkNames[i]);
+				}
 				return null;
 			}
-			idMap.put(plan.pkNames[i], val);
+			idMap.put(pkNames[i], val);
 		}
 		return ObjectId.of(name, idMap);
 	}
 
 	/**
-	 * @param plan the plan of the descriptor's columns in the row, as returned by {@link #planFor(DataRow,
-	 *             ClassDescriptor)}, or null to look the columns up by name
+	 * @param plan the plan of the descriptor, as returned by {@link #planFor(ClassDescriptor)}
 	 */
 	Persistent objectFromDataRow(DataRow row, ObjectId anId, ClassDescriptor classDescriptor, MergePlan plan) {
 
@@ -251,7 +239,7 @@ class ObjectResolver {
 		case PersistenceState.DELETED:
 			// process the above only if refresh is requested...
 			if (refreshObjects && row.getVersion() >= object.getSnapshotVersion()) {
-				DataRowUtils.mergeObjectWithSnapshot(context, classDescriptor, object, row, plan);
+				DataRowUtils.mergeObjectWithSnapshot(context, object, row, plan);
 				object.setSnapshotVersion(row.getVersion());
 			}
 			break;
@@ -260,11 +248,11 @@ class ObjectResolver {
 			if (!refreshObjects) {
 				DataRow cachedRow = cache.getCachedSnapshot(object.getObjectId());
 				if (cachedRow != null) {
-					// the cached row may be of another layout, which the merge detects and looks the columns up by name
+					// the cached row may be of other columns, which the plan's columns resolve again
 					row = cachedRow;
 				}
 			}
-			DataRowUtils.mergeObjectWithSnapshot(context, classDescriptor, object, row, plan);
+			DataRowUtils.mergeObjectWithSnapshot(context, object, row, plan);
 			object.setSnapshotVersion(row.getVersion());
 			break;
 		default:
diff --git a/cayenne/src/main/java/org/apache/cayenne/access/ObjectSnapshots.java b/cayenne/src/main/java/org/apache/cayenne/access/ObjectSnapshots.java
index bec44da55..38df06852 100644
--- a/cayenne/src/main/java/org/apache/cayenne/access/ObjectSnapshots.java
+++ b/cayenne/src/main/java/org/apache/cayenne/access/ObjectSnapshots.java
@@ -21,6 +21,7 @@ package org.apache.cayenne.access;
 
 import org.apache.cayenne.CayenneRuntimeException;
 import org.apache.cayenne.DataRow;
+import org.apache.cayenne.DataRowLayout;
 import org.apache.cayenne.Fault;
 import org.apache.cayenne.ObjectId;
 import org.apache.cayenne.PersistenceState;
@@ -37,7 +38,8 @@ import org.apache.cayenne.reflect.ClassDescriptor;
 import org.apache.cayenne.reflect.PropertyDescriptor;
 import org.apache.cayenne.reflect.ToOneProperty;
 
-import java.util.HashMap;
+import java.util.ArrayList;
+import java.util.List;
 import java.util.Map;
 
 /**
@@ -71,7 +73,14 @@ public final class ObjectSnapshots {
         private final DataContextObjectStore objectStore;
         private final Persistent object;
         private final long version;
-        private final HashMap<String, Object> values = new HashMap<>(10);
+
+        private DataRowLayout layout;
+        private Object[] values;
+        private boolean[] present;
+        private int presentCount;
+
+        // the FK columns of the to-ones that are null, filled last
+        private List<String> nullFks;
 
         Builder(EntityResolver resolver, DataContextObjectStore objectStore, Persistent object, long version) {
             this.resolver = resolver;
@@ -89,6 +98,10 @@ public final class ObjectSnapshots {
             ObjEntity entity = resolver.getObjEntity(object);
             ClassDescriptor descriptor = resolver.getClassDescriptor(entity.getName());
 
+            this.layout = descriptor.getSnapshotLayout();
+            this.values = new Object[layout.size()];
+            this.present = new boolean[layout.size()];
+
             // to-many relationships contribute nothing to the snapshot
             for (PropertyDescriptor property : descriptor.getProperties()) {
                 switch (property) {
@@ -105,13 +118,8 @@ public final class ObjectSnapshots {
             // set above when db relationships where processed.
             Map<String, Object> thisIdParts = object.getObjectId().getIdSnapshot();
             if (thisIdParts != null) {
-
-                // put only those that do not exist in the map
                 for (Map.Entry<String, Object> entry : thisIdParts.entrySet()) {
-                    String nextKey = entry.getKey();
-                    if (!values.containsKey(nextKey)) {
-                        values.put(nextKey, entry.getValue());
-                    }
+                    setIfAbsent(entry.getKey(), entry.getValue());
                 }
             }
 
@@ -120,18 +128,70 @@ public final class ObjectSnapshots {
                 ObjectId flattenedId = objectStore.getFlattenedId(object.getObjectId(), path);
                 if (flattenedId != null) {
                     for (Map.Entry<String, Object> idEntry : flattenedId.getIdSnapshot().entrySet()) {
-                        values.putIfAbsent(path.dot(idEntry.getKey()).value(), idEntry.getValue());
+                        setIfAbsent(path.dot(idEntry.getKey()).value(), idEntry.getValue());
                     }
                 }
             }
 
-            return DataRow.of(entity.getName(), values, version);
+            // a null to-one is a null FK, unless the column is also a part of the id, which was set above
+            if (nullFks != null) {
+                for (String name : nullFks) {
+                    setIfAbsent(name, null);
+                }
+            }
+
+            return presentCount == values.length
+                    ? DataRow.of(entity.getName(), layout, values, version)
+                    : partialRow(entity.getName());
+        }
+
+        /**
+         * Returns a row of the columns that got a value, over a layout of its own: the snapshot is partial, see the
+         * warning in {@link ObjectSnapshots#snapshot(DataContext, Persistent, long)}.
+         */
+        private DataRow partialRow(String entityName) {
+            String[] names = new String[presentCount];
+            Object[] partial = new Object[presentCount];
+            int j = 0;
+            for (int i = 0; i < values.length; i++) {
+                if (present[i]) {
+                    names[j] = layout.nameAt(i);
+                    partial[j++] = values[i];
+                }
+            }
+            return DataRow.of(entityName, DataRowLayout.of(names), partial, version);
+        }
+
+        private void set(String name, Object value) {
+            int i = index(name);
+            values[i] = value;
+            if (!present[i]) {
+                present[i] = true;
+                presentCount++;
+            }
+        }
+
+        private void setIfAbsent(String name, Object value) {
+            int i = index(name);
+            if (!present[i]) {
+                values[i] = value;
+                present[i] = true;
+                presentCount++;
+            }
+        }
+
+        private int index(String name) {
+            int i = layout.indexOf(name);
+            if (i < 0) {
+                throw new CayenneRuntimeException("Column '%s' is not in the snapshot layout %s", name, layout);
+            }
+            return i;
         }
 
         private void addAttribute(AttributeProperty property) {
             ObjAttribute objAttr = property.getAttribute();
             // processing compound attributes correctly
-            values.put(objAttr.getDbAttributePath().value(), property.readPropertyDirectly(object));
+            set(objAttr.getDbAttributePath().value(), property.readPropertyDirectly(object));
         }
 
         private void addToOne(ToOneProperty property) {
@@ -144,6 +204,12 @@ public final class ObjectSnapshots {
 
             Object targetObject = property.readPropertyDirectly(object);
             if (targetObject == null) {
+                if (nullFks == null) {
+                    nullFks = new ArrayList<>();
+                }
+                for (DbJoin join : rel.getDbRelationships().getFirst().getJoins()) {
+                    nullFks.add(join.getSourceName());
+                }
                 return;
             }
 
@@ -158,7 +224,7 @@ public final class ObjectSnapshots {
                 DbRelationship dbRel = rel.getDbRelationships().getFirst();
                 for (DbJoin join : dbRel.getJoins()) {
                     String key = join.getSourceName();
-                    values.put(key, storedSnapshot.get(key));
+                    set(key, storedSnapshot.get(key));
                 }
 
                 return;
@@ -175,7 +241,9 @@ public final class ObjectSnapshots {
 
             DbRelationship dbRel = rel.getDbRelationships().getFirst();
             Map<String, Object> fk = dbRel.srcFkSnapshotWithTargetSnapshot(idParts);
-            values.putAll(fk);
+            for (Map.Entry<String, Object> entry : fk.entrySet()) {
+                set(entry.getKey(), entry.getValue());
+            }
         }
     }
 }
diff --git a/cayenne/src/main/java/org/apache/cayenne/access/PrefetchProcessorJointNode.java b/cayenne/src/main/java/org/apache/cayenne/access/PrefetchProcessorJointNode.java
index e360da4d7..d1e2ac9bb 100644
--- a/cayenne/src/main/java/org/apache/cayenne/access/PrefetchProcessorJointNode.java
+++ b/cayenne/src/main/java/org/apache/cayenne/access/PrefetchProcessorJointNode.java
@@ -20,9 +20,9 @@
 package org.apache.cayenne.access;
 
 import org.apache.cayenne.CayenneRuntimeException;
+import org.apache.cayenne.DataRowLayout;
 import org.apache.cayenne.DataRow;
 import org.apache.cayenne.Persistent;
-import org.apache.cayenne.RowLayout;
 import org.apache.cayenne.access.jdbc.RSColumn;
 import org.apache.cayenne.exp.Expression;
 import org.apache.cayenne.exp.ExpressionFactory;
@@ -61,12 +61,11 @@ class PrefetchProcessorJointNode extends PrefetchProcessorNode {
     Map<Object, Persistent> resolved;
     List<DataRow> resolvedRows;
 
-    // the layout of this node's rows, keyed by the columns' result set names
-    private RowLayout rowLayout;
+    // the layout of this node's rows, by the columns' result set names
+    private DataRowLayout rowLayout;
 
-    // the positions of this node's columns in the flat rows, resolved for the layout of the last flat row seen
-    private RowLayout flatLayout;
-    private int[] flatIndexes;
+    // reads this node's columns from the flat rows
+    private DataRowLayout.Reader flatReader;
 
     PrefetchProcessorJointNode(PrefetchProcessorNode parent, String segmentPath) {
         super(parent, segmentPath);
@@ -112,44 +111,19 @@ class PrefetchProcessorJointNode extends PrefetchProcessorNode {
         // likely be an indicator of an outer join ... and considering SQLTemplate,
         // this is reasonable to expect...
 
-        int[] indexes = flatRow.getLayout() != null ? flatIndexes(flatRow) : null;
         if (idIndices.length == 1) {
-            return idValue(flatRow, indexes, idIndices[0]);
+            return flatReader.get(flatRow, idIndices[0]);
         }
 
         Object[] id = new Object[idIndices.length];
         for (int i = 0; i < id.length; i++) {
-            id[i] = idValue(flatRow, indexes, idIndices[i]);
+            id[i] = flatReader.get(flatRow, idIndices[i]);
         }
 
         // Arrays.asList: a value-based key, unlike List.of it allows nulls, which an outer join produces
         return Arrays.asList(id);
     }
 
-    private Object idValue(DataRow flatRow, int[] indexes, int idIndex) {
-        return indexes != null ? valueAt(flatRow, indexes[idIndex]) : flatRow.get(columns[idIndex].dataRowName());
-    }
-
-    /**
-     * Returns the positions of this node's columns in the given flat row, which has a layout.
-     */
-    private int[] flatIndexes(DataRow flatRow) {
-        RowLayout layout = flatRow.getLayout();
-        if (layout != flatLayout) {
-            int[] indexes = new int[columns.length];
-            for (int i = 0; i < columns.length; i++) {
-                indexes[i] = layout.indexOf(columns[i].dataRowName());
-            }
-            flatIndexes = indexes;
-            flatLayout = layout;
-        }
-        return flatIndexes;
-    }
-
-    private static Object valueAt(DataRow row, int index) {
-        return index < 0 ? null : row.valueAt(index);
-    }
-
     /**
      * Looks up a previously resolved object by the key returned by {@link #idFromFlatRow(DataRow)}. Returns null if
      * no matching object exists.
@@ -173,15 +147,8 @@ class PrefetchProcessorJointNode extends PrefetchProcessorNode {
     DataRow rowFromFlatRow(DataRow flatRow) {
         // extract subset of flat row columns, recasting to the target keys
         Object[] values = new Object[columns.length];
-        if (flatRow.getLayout() != null) {
-            int[] indexes = flatIndexes(flatRow);
-            for (int i = 0; i < columns.length; i++) {
-                values[i] = valueAt(flatRow, indexes[i]);
-            }
-        } else {
-            for (int i = 0; i < columns.length; i++) {
-                values[i] = flatRow.get(columns[i].dataRowName());
-            }
+        for (int i = 0; i < columns.length; i++) {
+            values[i] = flatReader.get(flatRow, i);
         }
 
         DataRow row = DataRow.of(resolver.getEntity().getName(), rowLayout, values, flatRow.getVersion());
@@ -284,10 +251,13 @@ class PrefetchProcessorJointNode extends PrefetchProcessorNode {
         targetSource.values().toArray(columns);
 
         String[] names = new String[size];
+        String[] flatNames = new String[size];
         for (int i = 0; i < size; i++) {
             names[i] = columns[i].rsName();
+            flatNames[i] = columns[i].dataRowName();
         }
-        this.rowLayout = new RowLayout(names);
+        this.rowLayout = DataRowLayout.of(names);
+        this.flatReader = DataRowLayout.of(flatNames).reader();
     }
 
     private RSColumn appendColumn(
diff --git a/cayenne/src/main/java/org/apache/cayenne/access/ResultScanParentAttachmentStrategy.java b/cayenne/src/main/java/org/apache/cayenne/access/ResultScanParentAttachmentStrategy.java
index 0dab73ce9..2affe238f 100644
--- a/cayenne/src/main/java/org/apache/cayenne/access/ResultScanParentAttachmentStrategy.java
+++ b/cayenne/src/main/java/org/apache/cayenne/access/ResultScanParentAttachmentStrategy.java
@@ -18,6 +18,7 @@
  ****************************************************************/
 package org.apache.cayenne.access;
 
+import org.apache.cayenne.DataRowLayout;
 import org.apache.cayenne.DataRow;
 import org.apache.cayenne.Persistent;
 import org.apache.cayenne.map.DbJoin;
@@ -39,8 +40,8 @@ class ResultScanParentAttachmentStrategy implements ParentAttachmentStrategy {
     private final PrefetchProcessorNode node;
 
     // the join columns in the child rows and in the parent rows
-    private final RowColumns targetColumns;
-    private final RowColumns sourceColumns;
+    private final DataRowLayout.Reader targetReader;
+    private final DataRowLayout.Reader sourceReader;
 
     // TODO: the ivar below makes this strategy STATEFUL and non-reusable. If we need a
     // stateless version down the line, will need to move this to the
@@ -73,8 +74,8 @@ class ResultScanParentAttachmentStrategy implements ParentAttachmentStrategy {
             targetNames[i] = joins[i].getTargetName();
             sourceNames[i] = joins[i].getSourceName();
         }
-        targetColumns = new RowColumns(targetNames);
-        sourceColumns = new RowColumns(sourceNames);
+        targetReader = DataRowLayout.of(targetNames).reader();
+        sourceReader = DataRowLayout.of(sourceNames).reader();
     }
 
     public void linkToParent(DataRow row, Persistent object) {
@@ -87,11 +88,11 @@ class ResultScanParentAttachmentStrategy implements ParentAttachmentStrategy {
         if (joins.length > 1) {
             List<Object> values = new ArrayList<>(joins.length);
             for (int i = 0; i < joins.length; i++) {
-                values.add(targetColumns.get(row, i));
+                values.add(targetReader.get(row, i));
             }
             key = values;
         } else {
-            key = targetColumns.get(row, 0);
+            key = targetReader.get(row, 0);
         }
 
         List<Persistent> parents = partitionByChild.get(key);
@@ -133,11 +134,11 @@ class ResultScanParentAttachmentStrategy implements ParentAttachmentStrategy {
             if (joins.length > 1) {
                 List<Object> values = new ArrayList<>(joins.length);
                 for (int j = 0; j < joins.length; j++) {
-                    values.add(sourceColumns.get(row, j));
+                    values.add(sourceReader.get(row, j));
                 }
                 key = values;
             } else {
-                key = sourceColumns.get(row, 0);
+                key = sourceReader.get(row, 0);
             }
 
             List<Persistent> parents = partitionByChild.computeIfAbsent(key, k -> new ArrayList<>());
diff --git a/cayenne/src/main/java/org/apache/cayenne/access/RowColumns.java b/cayenne/src/main/java/org/apache/cayenne/access/RowColumns.java
deleted file mode 100644
index bc183682e..000000000
--- a/cayenne/src/main/java/org/apache/cayenne/access/RowColumns.java
+++ /dev/null
@@ -1,61 +0,0 @@
-/*****************************************************************
- *   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
- *
- *    https://www.apache.org/licenses/LICENSE-2.0
- *
- *  Unless required by applicable law or agreed to in writing,
- *  software distributed under the License is distributed on an
- *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
- *  KIND, either express or implied.  See the License for the
- *  specific language governing permissions and limitations
- *  under the License.
- ****************************************************************/
-
-package org.apache.cayenne.access;
-
-import org.apache.cayenne.DataRow;
-import org.apache.cayenne.RowLayout;
-
-/**
- * Reads a fixed set of named columns from rows, by position when a row has a {@link RowLayout}. The positions are
- * resolved when a row of a new layout comes along, so a loop over the rows of one result set looks the names up
- * once. Not thread-safe.
- */
-final class RowColumns {
-
-    private final String[] names;
-    private RowLayout layout;
-    private int[] indexes;
-
-    RowColumns(String[] names) {
-        this.names = names;
-    }
-
-    /**
-     * Returns the value of the i-th column of the row, or null if the row has no such column.
-     */
-    Object get(DataRow row, int i) {
-        RowLayout rowLayout = row.getLayout();
-        if (rowLayout == null) {
-            return row.get(names[i]);
-        }
-
-        if (rowLayout != layout) {
-            int[] resolved = new int[names.length];
-            for (int j = 0; j < names.length; j++) {
-                resolved[j] = rowLayout.indexOf(names[j]);
-            }
-            indexes = resolved;
-            layout = rowLayout;
-        }
-
-        int index = indexes[i];
-        return index < 0 ? null : row.valueAt(index);
-    }
-}
diff --git a/cayenne/src/main/java/org/apache/cayenne/access/event/SnapshotEvent.java b/cayenne/src/main/java/org/apache/cayenne/access/event/SnapshotEvent.java
index 8aafbf928..785e818df 100644
--- a/cayenne/src/main/java/org/apache/cayenne/access/event/SnapshotEvent.java
+++ b/cayenne/src/main/java/org/apache/cayenne/access/event/SnapshotEvent.java
@@ -23,8 +23,8 @@ import java.util.Collection;
 import java.util.Collections;
 import java.util.Map;
 
-import org.apache.cayenne.DataRow;
 import org.apache.cayenne.ObjectId;
+import org.apache.cayenne.access.SnapshotVersion;
 import org.apache.cayenne.event.CayenneEvent;
 
 /**
@@ -35,18 +35,26 @@ import org.apache.cayenne.event.CayenneEvent;
 public class SnapshotEvent extends CayenneEvent {
 
     protected long timestamp;
+    protected long version;
     protected Collection<ObjectId> deletedIds;
     protected Collection<ObjectId> invalidatedIds;
-    protected Map<ObjectId, DataRow> modifiedDiffs;
+    protected Map<ObjectId, Map<String, Object>> modifiedDiffs;
     protected Collection<ObjectId> indirectlyModifiedIds;
 
-    public SnapshotEvent(Object source, Object postedBy, Map<ObjectId, DataRow> modifiedDiffs,
+    /**
+     * @param version       the {@link SnapshotVersion snapshot version} of the modified snapshots, which are of one
+     *                      operation, or {@link SnapshotVersion#NONE} if none were modified
+     * @param modifiedDiffs the changed keys of each modified snapshot, with their new values
+     * @since 5.0
+     */
+    public SnapshotEvent(Object source, Object postedBy, long version, Map<ObjectId, Map<String, Object>> modifiedDiffs,
             Collection<ObjectId> deletedIds, Collection<ObjectId> invalidatedIds,
             Collection<ObjectId> indirectlyModifiedIds) {
 
         super(source, postedBy);
 
         this.timestamp = System.currentTimeMillis();
+        this.version = version;
         this.modifiedDiffs = modifiedDiffs;
         this.deletedIds = deletedIds;
         this.invalidatedIds = invalidatedIds;
@@ -57,8 +65,21 @@ public class SnapshotEvent extends CayenneEvent {
         return timestamp;
     }
 
-    public Map<ObjectId, DataRow> getModifiedDiffs() {
-        return (modifiedDiffs != null) ? modifiedDiffs : Collections.<ObjectId, DataRow>emptyMap();
+    /**
+     * Returns the {@link SnapshotVersion snapshot version} of the modified snapshots, or {@link SnapshotVersion#NONE}
+     * if none were modified.
+     *
+     * @since 5.0
+     */
+    public long getVersion() {
+        return version;
+    }
+
+    /**
+     * Returns the changed keys of each modified snapshot, with their new values.
+     */
+    public Map<ObjectId, Map<String, Object>> getModifiedDiffs() {
+        return (modifiedDiffs != null) ? modifiedDiffs : Collections.emptyMap();
     }
 
     public Collection<ObjectId> getDeletedIds() {
@@ -80,7 +101,7 @@ public class SnapshotEvent extends CayenneEvent {
         StringBuilder buffer = new StringBuilder();
         buffer.append("[SnapshotEvent] source: ").append(getSource());
 
-        Map<ObjectId, DataRow> modified = getModifiedDiffs();
+        Map<ObjectId, Map<String, Object>> modified = getModifiedDiffs();
         if (!modified.isEmpty()) {
             buffer.append(", modified ").append(modified.size()).append(" id(s)");
         }
diff --git a/cayenne/src/main/java/org/apache/cayenne/access/jdbc/reader/FullRowReader.java b/cayenne/src/main/java/org/apache/cayenne/access/jdbc/reader/FullRowReader.java
index e57addf7e..86ecc51a0 100644
--- a/cayenne/src/main/java/org/apache/cayenne/access/jdbc/reader/FullRowReader.java
+++ b/cayenne/src/main/java/org/apache/cayenne/access/jdbc/reader/FullRowReader.java
@@ -21,7 +21,7 @@ package org.apache.cayenne.access.jdbc.reader;
 
 import org.apache.cayenne.CayenneRuntimeException;
 import org.apache.cayenne.DataRow;
-import org.apache.cayenne.RowLayout;
+import org.apache.cayenne.DataRowLayout;
 import org.apache.cayenne.access.jdbc.RSColumn;
 import org.apache.cayenne.map.EntityInheritanceTree;
 import org.apache.cayenne.map.ObjEntity;
@@ -37,7 +37,7 @@ import java.util.Objects;
 class FullRowReader implements RowReader<DataRow> {
 
     private final RSColumn[] columns;
-    private final RowLayout layout;
+    private final DataRowLayout layout;
     private final long version;
     protected final String entityName;
 
@@ -56,14 +56,14 @@ class FullRowReader implements RowReader<DataRow> {
     }
 
     /**
-     * Returns a layout of the columns' DataRow names, in column order.
+     * Returns the layout of the columns' DataRow names, in column order.
      */
-    static RowLayout layout(RSColumn[] columns) {
-        String[] names = new String[columns.length];
-        for (int i = 0; i < columns.length; i++) {
-            names[i] = columns[i].dataRowName();
+    static DataRowLayout layout(RSColumn[] rsColumns) {
+        String[] names = new String[rsColumns.length];
+        for (int i = 0; i < rsColumns.length; i++) {
+            names[i] = rsColumns[i].dataRowName();
         }
-        return new RowLayout(names);
+        return DataRowLayout.of(names);
     }
 
     private FullRowReader(RSColumn[] columns, String entityName, long version) {
diff --git a/cayenne/src/main/java/org/apache/cayenne/access/jdbc/reader/IndexRowReader.java b/cayenne/src/main/java/org/apache/cayenne/access/jdbc/reader/IndexRowReader.java
index 4f2ffa0b2..01300bf89 100644
--- a/cayenne/src/main/java/org/apache/cayenne/access/jdbc/reader/IndexRowReader.java
+++ b/cayenne/src/main/java/org/apache/cayenne/access/jdbc/reader/IndexRowReader.java
@@ -21,7 +21,7 @@ package org.apache.cayenne.access.jdbc.reader;
 
 import org.apache.cayenne.CayenneRuntimeException;
 import org.apache.cayenne.DataRow;
-import org.apache.cayenne.RowLayout;
+import org.apache.cayenne.DataRowLayout;
 import org.apache.cayenne.access.jdbc.RSColumn;
 
 import java.sql.ResultSet;
@@ -34,7 +34,7 @@ import java.sql.ResultSet;
 class IndexRowReader implements RowReader<DataRow> {
 
     private final RSColumn[] columns;
-    private final RowLayout layout;
+    private final DataRowLayout layout;
     private final int[] jdbcIndexes;
     private final String entityName;
     private final long version;
diff --git a/cayenne/src/main/java/org/apache/cayenne/access/jdbc/reader/OffsetRowReader.java b/cayenne/src/main/java/org/apache/cayenne/access/jdbc/reader/OffsetRowReader.java
index 8bf79c569..dab2d2266 100644
--- a/cayenne/src/main/java/org/apache/cayenne/access/jdbc/reader/OffsetRowReader.java
+++ b/cayenne/src/main/java/org/apache/cayenne/access/jdbc/reader/OffsetRowReader.java
@@ -21,7 +21,7 @@ package org.apache.cayenne.access.jdbc.reader;
 
 import org.apache.cayenne.CayenneRuntimeException;
 import org.apache.cayenne.DataRow;
-import org.apache.cayenne.RowLayout;
+import org.apache.cayenne.DataRowLayout;
 import org.apache.cayenne.access.jdbc.RSColumn;
 import org.apache.cayenne.map.EntityInheritanceTree;
 import org.apache.cayenne.reflect.ClassDescriptor;
@@ -36,7 +36,7 @@ class OffsetRowReader implements RowReader<DataRow> {
     protected final String entityName;
     private final long version;
     private final RSColumn[] columns;
-    private final RowLayout layout;
+    private final DataRowLayout layout;
     private final int offset;
 
     // possible inheritance
diff --git a/cayenne/src/main/java/org/apache/cayenne/reflect/ClassDescriptor.java b/cayenne/src/main/java/org/apache/cayenne/reflect/ClassDescriptor.java
index 9a7fcac1d..317f0dbe8 100644
--- a/cayenne/src/main/java/org/apache/cayenne/reflect/ClassDescriptor.java
+++ b/cayenne/src/main/java/org/apache/cayenne/reflect/ClassDescriptor.java
@@ -22,6 +22,7 @@ package org.apache.cayenne.reflect;
 import java.util.Collection;
 import java.util.Map;
 
+import org.apache.cayenne.DataRowLayout;
 import org.apache.cayenne.exp.Expression;
 import org.apache.cayenne.exp.path.CayennePath;
 import org.apache.cayenne.map.DbEntity;
@@ -215,6 +216,16 @@ public interface ClassDescriptor {
      */
     Collection<PropertyDescriptor> getAllProperties();
 
+    /**
+     * Returns the layout of a snapshot of an object of the described class: the columns of the attributes, the
+     * foreign keys of the to-one relationships whose targets propagate their keys to this entity, the primary key,
+     * and the primary keys of the additional DB entities, each column listed once. Snapshots of objects are built
+     * over this layout, and rows of any layout are merged into objects through it.
+     *
+     * @since 5.0
+     */
+    DataRowLayout getSnapshotLayout();
+
     /**
      * Passes the visitor to the properties "visit" method for all properties declared in
      * this descriptor and all its super-decsriptors. Properties that are overridden in
diff --git a/cayenne/src/main/java/org/apache/cayenne/reflect/LazyClassDescriptorDecorator.java b/cayenne/src/main/java/org/apache/cayenne/reflect/LazyClassDescriptorDecorator.java
index 1121875e8..95f6e5c16 100644
--- a/cayenne/src/main/java/org/apache/cayenne/reflect/LazyClassDescriptorDecorator.java
+++ b/cayenne/src/main/java/org/apache/cayenne/reflect/LazyClassDescriptorDecorator.java
@@ -21,6 +21,7 @@ package org.apache.cayenne.reflect;
 import java.util.Collection;
 import java.util.Map;
 
+import org.apache.cayenne.DataRowLayout;
 import org.apache.cayenne.exp.Expression;
 import org.apache.cayenne.exp.path.CayennePath;
 import org.apache.cayenne.map.DbEntity;
@@ -195,4 +196,10 @@ public class LazyClassDescriptorDecorator implements ClassDescriptor {
         checkDescriptorInitialized();
         return descriptor.getAllProperties();
     }
+
+    @Override
+    public DataRowLayout getSnapshotLayout() {
+        checkDescriptorInitialized();
+        return descriptor.getSnapshotLayout();
+    }
 }
diff --git a/cayenne/src/main/java/org/apache/cayenne/reflect/PersistentDescriptor.java b/cayenne/src/main/java/org/apache/cayenne/reflect/PersistentDescriptor.java
index db2195c2b..f1f003b3b 100644
--- a/cayenne/src/main/java/org/apache/cayenne/reflect/PersistentDescriptor.java
+++ b/cayenne/src/main/java/org/apache/cayenne/reflect/PersistentDescriptor.java
@@ -18,11 +18,14 @@
  ****************************************************************/
 package org.apache.cayenne.reflect;
 
+import org.apache.cayenne.DataRowLayout;
 import org.apache.cayenne.PersistenceState;
 import org.apache.cayenne.Persistent;
 import org.apache.cayenne.exp.Expression;
 import org.apache.cayenne.exp.path.CayennePath;
+import org.apache.cayenne.map.DbAttribute;
 import org.apache.cayenne.map.DbEntity;
+import org.apache.cayenne.map.DbJoin;
 import org.apache.cayenne.map.EntityInheritanceTree;
 import org.apache.cayenne.map.ObjAttribute;
 import org.apache.cayenne.map.ObjEntity;
@@ -34,9 +37,11 @@ import java.util.Collections;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Map.Entry;
+import java.util.Set;
 
 /**
  * A default ClassDescriptor implementation for persistent objects.
@@ -74,6 +79,9 @@ public class PersistentDescriptor implements ClassDescriptor {
 	// the values that this entity's own qualifier pins its attributes to, compiled at creation
 	protected Map<AttributeProperty, Object> discriminatorValues;
 
+	// built on first use. The layout is immutable and the build deterministic, so a racing rebuild is harmless
+	private DataRowLayout snapshotLayout;
+
 	/**
 	 * Creates a PersistentDescriptor.
 	 */
@@ -406,6 +414,53 @@ public class PersistentDescriptor implements ClassDescriptor {
 		return all;
 	}
 
+	@Override
+	public DataRowLayout getSnapshotLayout() {
+		DataRowLayout layout = snapshotLayout;
+		if (layout == null) {
+			layout = buildSnapshotLayout();
+			snapshotLayout = layout;
+		}
+		return layout;
+	}
+
+	private DataRowLayout buildSnapshotLayout() {
+		// a column is listed once however many roles it has: a PK column is often an attribute or an FK as well
+		Set<String> names = new LinkedHashSet<>();
+
+		for (PropertyDescriptor property : getProperties()) {
+			switch (property) {
+				case AttributeProperty attribute -> names.add(attribute.getAttribute().getDbAttributePath().value());
+				case ToOneProperty toOne -> {
+					// a to-one whose target does not propagate its key to this entity has no FK in the snapshot
+					ObjRelationship relationship = toOne.getRelationship();
+					if (!relationship.isSourceIndependentFromTargetChange()) {
+						for (DbJoin join : relationship.getDbRelationships().getFirst().getJoins()) {
+							names.add(join.getSourceName());
+						}
+					}
+				}
+				default -> {
+				}
+			}
+		}
+
+		DbEntity dbEntity = entity.getDbEntity();
+		if (dbEntity != null) {
+			for (DbAttribute pk : dbEntity.getPrimaryKeys()) {
+				names.add(pk.getName());
+			}
+		}
+
+		for (AdditionalDbEntityDescriptor additional : getAdditionalDbEntities().values()) {
+			for (DbAttribute pk : additional.getDbEntity().getPrimaryKeys()) {
+				names.add(additional.getPath().dot(pk.getName()).value());
+			}
+		}
+
+		return DataRowLayout.of(names.toArray(new String[0]));
+	}
+
 	public void setEntity(ObjEntity entity) {
 		this.entity = entity;
 	}
diff --git a/cayenne/src/test/java/org/apache/cayenne/DataRowLayoutTest.java b/cayenne/src/test/java/org/apache/cayenne/DataRowLayoutTest.java
new file mode 100644
index 000000000..0f9034e8f
--- /dev/null
+++ b/cayenne/src/test/java/org/apache/cayenne/DataRowLayoutTest.java
@@ -0,0 +1,146 @@
+/*****************************************************************
+ *   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
+ *
+ *    https://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;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+public class DataRowLayoutTest {
+
+    private static final DataRowLayout ABC = DataRowLayout.of("a", "b", "c");
+    private static final DataRowLayout CBA = DataRowLayout.of("c", "b", "a");
+
+    @Test
+    public void indexOf() {
+        DataRowLayout layout = DataRowLayout.of("a", "b", "c");
+        assertEquals(3, layout.size());
+        assertEquals(0, layout.indexOf("a"));
+        assertEquals(2, layout.indexOf("c"));
+        assertEquals(-1, layout.indexOf("d"));
+        assertEquals("b", layout.nameAt(1));
+    }
+
+    @Test
+    public void duplicates_LastWins() {
+        String[] names = {"a", "b", "a", "a"};
+        DataRowLayout layout = DataRowLayout.of(names);
+
+        assertEquals(4, layout.size());
+        assertEquals(3, layout.indexOf("a"));
+        assertEquals(2, layout.indexOf("a_"));
+        assertEquals(0, layout.indexOf("a__"));
+        assertEquals(1, layout.indexOf("b"));
+
+        assertArrayEquals(new String[]{"a", "b", "a", "a"}, names, "the argument must not be changed");
+    }
+
+    @Test
+    public void duplicates_ExistingSuffixedName() {
+        DataRowLayout layout = DataRowLayout.of("a", "a_", "a");
+        assertEquals(2, layout.indexOf("a"));
+        assertEquals(1, layout.indexOf("a_"));
+        assertEquals(0, layout.indexOf("a__"));
+    }
+
+    @Test
+    public void reader_OwnLayout() {
+        DataRowLayout.Reader reader = ABC.reader();
+        DataRow row = DataRow.of("E", ABC, new Object[]{1, null, "z"}, 1);
+
+        assertEquals(1, reader.get(row, 0));
+        assertNull(reader.get(row, 1));
+        assertEquals("z", reader.get(row, 2));
+        assertTrue(reader.has(row, 1), "a null value is not a missing column");
+    }
+
+    @Test
+    public void reader_OtherLayout() {
+        DataRowLayout.Reader reader = DataRowLayout.of("c", "a", "x").reader();
+        DataRow row = DataRow.of("E", ABC, new Object[]{1, null, "z"}, 1);
+
+        assertEquals("z", reader.get(row, 0));
+        assertEquals(1, reader.get(row, 1));
+        assertNull(reader.get(row, 2), "a column the row does not have");
+        assertTrue(reader.has(row, 0));
+        assertFalse(reader.has(row, 2));
+    }
+
+    @Test
+    public void reader_NullValue() {
+        DataRowLayout.Reader reader = DataRowLayout.of("b").reader();
+        DataRow row = DataRow.of("E", ABC, new Object[]{1, null, "z"}, 1);
+
+        assertNull(reader.get(row, 0));
+        assertTrue(reader.has(row, 0), "a null value is not a missing column");
+    }
+
+    @Test
+    public void reader_NullAndMissingColumns() {
+        DataRowLayout.Reader reader = DataRowLayout.of("c", "a", "x", "n").reader();
+        DataRow row = DataRow.of("E", DataRowLayout.of("a", "c", "n"), new Object[]{1, "z", null}, 1);
+
+        assertEquals("z", reader.get(row, 0));
+        assertEquals(1, reader.get(row, 1));
+        assertNull(reader.get(row, 2));
+        assertFalse(reader.has(row, 2));
+        assertNull(reader.get(row, 3));
+        assertTrue(reader.has(row, 3));
+    }
+
+    @Test
+    public void reader_RowsOfSeveralLayouts() {
+        DataRowLayout.Reader reader = DataRowLayout.of("a", "c").reader();
+        DataRow abc = DataRow.of("E", ABC, new Object[]{1, 2, 3}, 1);
+        DataRow cba = DataRow.of("E", CBA, new Object[]{1, 2, 3}, 1);
+        DataRow ac = DataRow.of("E", DataRowLayout.of("a", "c"), new Object[]{10, 30}, 1);
+
+        // the positions are resolved per layout, whatever the order the rows come in
+        for (int i = 0; i < 3; i++) {
+            assertEquals(1, reader.get(abc, 0));
+            assertEquals(3, reader.get(abc, 1));
+            assertEquals(3, reader.get(cba, 0));
+            assertEquals(1, reader.get(cba, 1));
+            assertEquals(10, reader.get(ac, 0));
+            assertEquals(30, reader.get(ac, 1));
+        }
+    }
+
+    @Test
+    public void reader_MergedRow() {
+        DataRowLayout.Reader reader = DataRowLayout.of("b", "d").reader();
+        DataRow row = DataRow.of("E", ABC, new Object[]{1, 2, 3}, 1);
+
+        DataRow sameLayout = row.merged(Map.of("b", 20), 2);
+        assertEquals(20, reader.get(sameLayout, 0));
+        assertFalse(reader.has(sameLayout, 1));
+
+        DataRow widened = row.merged(Map.of("d", 4), 3);
+        assertEquals(2, reader.get(widened, 0));
+        assertEquals(4, reader.get(widened, 1));
+        assertTrue(reader.has(widened, 1));
+    }
+}
diff --git a/cayenne/src/test/java/org/apache/cayenne/DataRowTest.java b/cayenne/src/test/java/org/apache/cayenne/DataRowTest.java
index f6a55ac96..6b8afc58b 100644
--- a/cayenne/src/test/java/org/apache/cayenne/DataRowTest.java
+++ b/cayenne/src/test/java/org/apache/cayenne/DataRowTest.java
@@ -41,17 +41,21 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
 
 public class DataRowTest {
 
-    private static final RowLayout LAYOUT = new RowLayout(new String[]{"a", "b", "c"});
+    private static final DataRowLayout ABC = DataRowLayout.of("a", "b", "c");
 
-    private static DataRow arrayRow(Object... values) {
-        return DataRow.of("E", LAYOUT, values, SnapshotVersion.next());
+    private static DataRow rowOf(Object... values) {
+        return DataRow.of("E", ABC, values, SnapshotVersion.next());
+    }
+
+    private static DataRow rowOf(DataRowLayout layout, Object... values) {
+        return DataRow.of("E", layout, values, 1);
     }
 
     @Test
-    public void version() throws Exception {
-        DataRow s1 = DataRow.of("E", new HashMap<>(10), SnapshotVersion.next());
-        DataRow s2 = DataRow.of("E", new HashMap<>(10), SnapshotVersion.next());
-        DataRow s3 = DataRow.of("E", new HashMap<>(10), SnapshotVersion.next());
+    public void version() {
+        DataRow s1 = rowOf(1, 2, 3);
+        DataRow s2 = rowOf(1, 2, 3);
+        DataRow s3 = rowOf(1, 2, 3);
 
         assertFalse(s1.getVersion() == s2.getVersion());
         assertFalse(s2.getVersion() == s3.getVersion());
@@ -61,8 +65,8 @@ public class DataRowTest {
     @Test
     public void explicitVersion() {
         long version = SnapshotVersion.next();
-        DataRow s1 = DataRow.of("E", Map.of("a", 1), version);
-        DataRow s2 = DataRow.of("E2", new HashMap<>(s1), version);
+        DataRow s1 = DataRow.of("E", ABC, new Object[]{1, 2, 3}, version);
+        DataRow s2 = DataRow.of("E2", ABC, new Object[]{1, 2, 3}, version);
 
         assertEquals(version, s1.getVersion());
         assertEquals(version, s2.getVersion());
@@ -73,26 +77,22 @@ public class DataRowTest {
 
     @Test
     public void immutable() {
-        DataRow map = DataRow.of("E", new HashMap<>(Map.of("a", 1)), 1);
-        DataRow array = arrayRow(1, 2, 3);
-
-        for (DataRow row : List.of(map, array)) {
-            assertThrows(UnsupportedOperationException.class, () -> row.put("a", 2));
-            assertThrows(UnsupportedOperationException.class, () -> row.putAll(Map.of("a", 2)));
-            assertThrows(UnsupportedOperationException.class, () -> row.remove("a"));
-            assertThrows(UnsupportedOperationException.class, row::clear);
-        }
+        DataRow row = rowOf(1, 2, 3);
 
-        // the views of an array row are read-only. The views of a map row are those of the map, not wrapped
-        assertThrows(UnsupportedOperationException.class, () -> array.keySet().remove("a"));
-        assertThrows(UnsupportedOperationException.class, () -> array.entrySet().iterator().next().setValue(5));
+        assertThrows(UnsupportedOperationException.class, () -> row.put("a", 2));
+        assertThrows(UnsupportedOperationException.class, () -> row.putAll(Map.of("a", 2)));
+        assertThrows(UnsupportedOperationException.class, () -> row.remove("a"));
+        assertThrows(UnsupportedOperationException.class, row::clear);
+
+        // the views are read-only too
+        assertThrows(UnsupportedOperationException.class, () -> row.keySet().remove("a"));
+        assertThrows(UnsupportedOperationException.class, () -> row.entrySet().iterator().next().setValue(5));
     }
 
     @Test
-    public void arrayRow_MapView() {
-        DataRow row = arrayRow(1, null, "x");
+    public void mapView() {
+        DataRow row = rowOf(1, null, "x");
 
-        assertSame(LAYOUT, row.getLayout());
         assertEquals(3, row.size());
         assertFalse(row.isEmpty());
         assertEquals(1, row.get("a"));
@@ -104,7 +104,6 @@ public class DataRowTest {
         assertTrue(row.containsValue(null));
         assertTrue(row.containsValue("x"));
         assertFalse(row.containsValue("y"));
-        assertEquals("x", row.valueAt(2));
         assertEquals(7, row.getOrDefault("d", 7));
         assertNull(row.getOrDefault("b", 7));
 
@@ -118,100 +117,91 @@ public class DataRowTest {
         assertTrue(copy.containsKey("b"));
     }
 
-    @Test
-    public void mapRow_NoLayout() {
-        DataRow row = DataRow.of("E", Map.of("a", 1), 1);
-        assertNull(row.getLayout());
-        assertThrows(UnsupportedOperationException.class, () -> row.valueAt(0));
-    }
-
     @Test
     public void equalsAndHashCode() {
-        DataRow array = arrayRow(1, null, "x");
-        DataRow sameLayout = arrayRow(1, null, "x");
-        DataRow otherLayout = DataRow.of("E", new RowLayout(new String[]{"c", "a", "b"}), new Object[]{"x", 1, null}, 1);
-        DataRow map = DataRow.of("E", new HashMap<>(Map.of("a", 1, "c", "x")), 1);
+        DataRow row = rowOf(1, null, "x");
+        DataRow sameLayout = rowOf(1, null, "x");
+        DataRow otherLayout = rowOf(DataRowLayout.of("c", "a", "b"), "x", 1, null);
+        DataRow narrower = rowOf(DataRowLayout.of("a", "c"), 1, "x");
         HashMap<String, Object> plainMap = new HashMap<>(Map.of("a", 1, "c", "x"));
         plainMap.put("b", null);
 
-        assertEquals(array, sameLayout);
-        assertEquals(array, otherLayout);
-        assertEquals(otherLayout, array);
-        assertEquals(array, plainMap);
-        assertEquals(plainMap, array);
-        assertEquals(array.hashCode(), plainMap.hashCode());
-        assertEquals(array.hashCode(), otherLayout.hashCode());
-
-        // "b" is absent in the map row, null in the array row
-        assertNotEquals(array, map);
-        assertNotEquals(map, array);
-
-        assertNotEquals(array, arrayRow(1, null, "y"));
-        assertNotEquals(array, arrayRow(1, 2, "x"));
-        assertNotEquals(array, DataRow.of("E", Map.of("a", 1), 1));
+        assertEquals(row, sameLayout);
+        assertEquals(row, otherLayout);
+        assertEquals(otherLayout, row);
+        assertEquals(row, plainMap);
+        assertEquals(plainMap, row);
+        assertEquals(row.hashCode(), plainMap.hashCode());
+        assertEquals(row.hashCode(), otherLayout.hashCode());
+
+        // "b" is absent in the narrower row, null in the other
+        assertNotEquals(row, narrower);
+        assertNotEquals(narrower, row);
+
+        assertNotEquals(row, rowOf(1, null, "y"));
+        assertNotEquals(row, rowOf(1, 2, "x"));
+        assertNotEquals(row, rowOf(DataRowLayout.of("a"), 1));
     }
 
     @Test
     public void merged() {
-        DataRow array = arrayRow(1, 2, 3);
-        DataRow merged = array.merged(Map.of("b", 20), 7);
+        DataRow row = rowOf(1, 2, 3);
+        DataRow merged = row.merged(Map.of("b", 20), 7);
 
-        assertSame(LAYOUT, merged.getLayout());
         assertEquals(7, merged.getVersion());
         assertEquals("E", merged.getEntityName());
-        assertEquals(20, merged.valueAt(1));
-        assertEquals(1, merged.valueAt(0));
-        assertEquals(2, array.valueAt(1), "the original must not change");
+        assertEquals(Map.of("a", 1, "b", 20, "c", 3), merged);
+        assertSame(row.layout, merged.layout);
+        assertEquals(2, row.get("b"), "the original must not change");
 
-        DataRow widened = array.merged(Map.of("d", 4), 8);
-        assertNull(widened.getLayout());
+        DataRow widened = row.merged(Map.of("d", 4), 8);
         assertEquals(4, widened.size());
         assertEquals(4, widened.get("d"));
         assertEquals(3, widened.get("c"));
-
-        DataRow map = DataRow.of("E", new HashMap<>(Map.of("a", 1)), 1).merged(Map.of("b", 2), 9);
-        assertNull(map.getLayout());
-        assertEquals(9, map.getVersion());
-        assertEquals(Map.of("a", 1, "b", 2), map);
+        assertEquals(List.of("a", "b", "c", "d"), List.copyOf(widened.keySet()));
     }
 
     @Test
     public void withEntityName() {
-        DataRow array = arrayRow(1, 2, 3);
-        DataRow renamed = array.withEntityName("E2");
+        DataRow row = rowOf(1, 2, 3);
+        DataRow renamed = row.withEntityName("E2");
         assertEquals("E2", renamed.getEntityName());
-        assertEquals(array.getVersion(), renamed.getVersion());
-        assertSame(LAYOUT, renamed.getLayout());
-        assertEquals(array, renamed);
-
-        DataRow map = DataRow.of("E", Map.of("a", 1), 1).withEntityName("E2");
-        assertEquals("E2", map.getEntityName());
-        assertEquals(1, map.get("a"));
+        assertEquals(row.getVersion(), renamed.getVersion());
+        assertEquals(row, renamed);
+        assertSame(row.layout, renamed.layout);
     }
 
     @Test
     public void serialization() throws Exception {
-        DataRow array = arrayRow(1, null, "x");
+        DataRow row1 = rowOf(1, null, "x");
+        DataRow row2 = rowOf(2, null, "y");
 
         ByteArrayOutputStream bytes = new ByteArrayOutputStream();
         try (ObjectOutputStream out = new ObjectOutputStream(bytes)) {
-            out.writeObject(array);
+            out.writeObject(List.of(row1, row2));
         }
 
-        DataRow read;
+        List<?> read;
         try (ObjectInputStream in = new ObjectInputStream(new ByteArrayInputStream(bytes.toByteArray()))) {
-            read = (DataRow) in.readObject();
+            read = (List<?>) in.readObject();
         }
 
-        assertNotNull(read);
-        assertNull(read.getLayout(), "the layout is not serialized");
-        assertEquals(array, read);
-        assertEquals(array.getVersion(), read.getVersion());
-        assertEquals("E", read.getEntityName());
+        DataRow read1 = (DataRow) read.get(0);
+        DataRow read2 = (DataRow) read.get(1);
+        assertEquals(row1, read1);
+        assertEquals(row2, read2);
+        assertEquals(row1.getVersion(), read1.getVersion());
+        assertEquals("E", read1.getEntityName());
+
+        // the layout is read back once, with its index rebuilt
+        assertSame(read1.layout, read2.layout);
+        assertEquals(List.of("a", "b", "c"), List.copyOf(read1.keySet()));
+        assertEquals("x", read1.get("c"));
+        assertEquals("y", read1.layout.reader().get(read2, 2));
     }
 
     @Test
-    public void arrayRow_LengthMismatch() {
-        assertThrows(IllegalArgumentException.class, () -> DataRow.of("E", LAYOUT, new Object[]{1}, 1));
+    public void lengthMismatch() {
+        assertThrows(IllegalArgumentException.class, () -> DataRow.of("E", ABC, new Object[]{1}, 1));
     }
 }
diff --git a/cayenne/src/test/java/org/apache/cayenne/RowLayoutTest.java b/cayenne/src/test/java/org/apache/cayenne/RowLayoutTest.java
deleted file mode 100644
index e80de4b60..000000000
--- a/cayenne/src/test/java/org/apache/cayenne/RowLayoutTest.java
+++ /dev/null
@@ -1,67 +0,0 @@
-/*****************************************************************
- *   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
- *
- *    https://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;
-
-import org.junit.jupiter.api.Test;
-
-import static org.junit.jupiter.api.Assertions.assertArrayEquals;
-import static org.junit.jupiter.api.Assertions.assertEquals;
-import static org.junit.jupiter.api.Assertions.assertSame;
-
-public class RowLayoutTest {
-
-    @Test
-    public void indexOf() {
-        RowLayout layout = new RowLayout(new String[]{"a", "b", "c"});
-        assertEquals(3, layout.size());
-        assertEquals(0, layout.indexOf("a"));
-        assertEquals(2, layout.indexOf("c"));
-        assertEquals(-1, layout.indexOf("d"));
-        assertEquals("b", layout.nameAt(1));
-    }
-
-    @Test
-    public void duplicates_LastWins() {
-        String[] names = {"a", "b", "a", "a"};
-        RowLayout layout = new RowLayout(names);
-
-        assertEquals(4, layout.size());
-        assertEquals(3, layout.indexOf("a"));
-        assertEquals(2, layout.indexOf("a_"));
-        assertEquals(0, layout.indexOf("a__"));
-        assertEquals(1, layout.indexOf("b"));
-
-        assertArrayEquals(new String[]{"a", "b", "a", "a"}, names, "the argument must not be changed");
-    }
-
-    @Test
-    public void duplicates_ExistingSuffixedName() {
-        RowLayout layout = new RowLayout(new String[]{"a", "a_", "a"});
-        assertEquals(2, layout.indexOf("a"));
-        assertEquals(1, layout.indexOf("a_"));
-        assertEquals(0, layout.indexOf("a__"));
-    }
-
-    @Test
-    public void noDuplicates_NoCopy() {
-        RowLayout layout = new RowLayout(new String[]{"a", "b"});
-        assertSame("a", layout.nameAt(0));
-    }
-}
diff --git a/cayenne/src/test/java/org/apache/cayenne/access/DataContextConcurrentResolveIT.java b/cayenne/src/test/java/org/apache/cayenne/access/DataContextConcurrentResolveIT.java
index 2d5d1a2e5..66ea1b736 100644
--- a/cayenne/src/test/java/org/apache/cayenne/access/DataContextConcurrentResolveIT.java
+++ b/cayenne/src/test/java/org/apache/cayenne/access/DataContextConcurrentResolveIT.java
@@ -39,7 +39,6 @@ import org.junit.jupiter.api.extension.RegisterExtension;
 import java.math.BigDecimal;
 import java.sql.Types;
 import java.util.ArrayList;
-import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Queue;
@@ -241,9 +240,7 @@ public class DataContextConcurrentResolveIT {
     }
 
     private static DataRow copy(DataRow row, String title, long version) {
-        HashMap<String, Object> values = new HashMap<>(row);
-        values.put("PAINTING_TITLE", title);
-        return DataRow.of(row.getEntityName(), values, version);
+        return row.merged(Map.of("PAINTING_TITLE", title), version);
     }
 
     @Test
diff --git a/cayenne/src/test/java/org/apache/cayenne/access/DataContextDelegateSharedCacheIT.java b/cayenne/src/test/java/org/apache/cayenne/access/DataContextDelegateSharedCacheIT.java
index 89293fdd1..25993d09a 100644
--- a/cayenne/src/test/java/org/apache/cayenne/access/DataContextDelegateSharedCacheIT.java
+++ b/cayenne/src/test/java/org/apache/cayenne/access/DataContextDelegateSharedCacheIT.java
@@ -20,8 +20,8 @@
 package org.apache.cayenne.access;
 
 import java.util.Date;
+import java.util.Map;
 
-import org.apache.cayenne.DataRow;
 import org.apache.cayenne.PersistenceState;
 import org.apache.cayenne.Persistent;
 import org.apache.cayenne.CoreModule;
@@ -72,7 +72,7 @@ public class DataContextDelegateSharedCacheIT {
         DataContextDelegate delegate = new MockDataContextDelegate() {
 
             @Override
-            public boolean shouldMergeChanges(Persistent object, DataRow snapshotInStore) {
+            public boolean shouldMergeChanges(Persistent object, Map<String, Object> snapshotInStore) {
                 methodInvoked[0] = true;
                 return true;
             }
@@ -116,7 +116,7 @@ public class DataContextDelegateSharedCacheIT {
         DataContextDelegate delegate = new MockDataContextDelegate() {
 
             @Override
-            public boolean shouldMergeChanges(Persistent object, DataRow snapshotInStore) {
+            public boolean shouldMergeChanges(Persistent object, Map<String, Object> snapshotInStore) {
                 return false;
             }
         };
diff --git a/cayenne/src/test/java/org/apache/cayenne/access/DataContextExtrasIT.java b/cayenne/src/test/java/org/apache/cayenne/access/DataContextExtrasIT.java
index f7623645e..14a57eb1b 100644
--- a/cayenne/src/test/java/org/apache/cayenne/access/DataContextExtrasIT.java
+++ b/cayenne/src/test/java/org/apache/cayenne/access/DataContextExtrasIT.java
@@ -22,6 +22,7 @@ package org.apache.cayenne.access;
 import org.apache.cayenne.Cayenne;
 import org.apache.cayenne.CayenneRuntimeException;
 import org.apache.cayenne.DataRow;
+import org.apache.cayenne.DataRowLayout;
 import org.apache.cayenne.ObjectId;
 import org.apache.cayenne.PersistenceState;
 import org.apache.cayenne.Persistent;
@@ -204,7 +205,8 @@ public class DataContextExtrasIT {
     @Test
     public void idObjectFromDataRow() {
 
-        DataRow row = DataRow.of("Artist", Map.of("ARTIST_ID", 100000), SnapshotVersion.next());
+        DataRow row = DataRow.of("Artist", DataRowLayout.of("ARTIST_ID"),
+                new Object[]{100000}, SnapshotVersion.next());
         Persistent obj = context.objectFromDataRow(Artist.class, row);
         assertNotNull(obj);
         assertTrue(context.getObjectStore().registeredObjects().contains(obj));
@@ -216,9 +218,8 @@ public class DataContextExtrasIT {
     @Test
     public void partialObjectFromDataRow() {
 
-        DataRow row = DataRow.of("Artist", Map.of(
-                "ARTIST_ID", 100001,
-                "ARTIST_NAME", "ArtistXYZ"), SnapshotVersion.next());
+        DataRow row = DataRow.of("Artist", DataRowLayout.of("ARTIST_ID", "ARTIST_NAME"),
+                new Object[]{100001, "ArtistXYZ"}, SnapshotVersion.next());
         Persistent obj = context.objectFromDataRow(Artist.class, row);
         assertNotNull(obj);
         assertTrue(context.getObjectStore().registeredObjects().contains(obj));
@@ -229,10 +230,8 @@ public class DataContextExtrasIT {
     @Test
     public void fullObjectFromDataRow() {
 
-        DataRow row = DataRow.of("Artist", Map.of(
-                "ARTIST_ID", 123456,
-                "ARTIST_NAME", "ArtistXYZ",
-                "DATE_OF_BIRTH", new Date()), SnapshotVersion.next());
+        DataRow row = DataRow.of("Artist", DataRowLayout.of("ARTIST_ID", "ARTIST_NAME", "DATE_OF_BIRTH"),
+                new Object[]{123456, "ArtistXYZ", new Date()}, SnapshotVersion.next());
         Artist obj = context.objectFromDataRow(Artist.class, row);
 
         assertTrue(context.getObjectStore().registeredObjects().contains(obj));
diff --git a/cayenne/src/test/java/org/apache/cayenne/access/DataContextIT.java b/cayenne/src/test/java/org/apache/cayenne/access/DataContextIT.java
index f2ccc8db4..1bc42f528 100644
--- a/cayenne/src/test/java/org/apache/cayenne/access/DataContextIT.java
+++ b/cayenne/src/test/java/org/apache/cayenne/access/DataContextIT.java
@@ -21,6 +21,7 @@ package org.apache.cayenne.access;
 
 import org.apache.cayenne.Cayenne;
 import org.apache.cayenne.DataRow;
+import org.apache.cayenne.DataRowLayout;
 import org.apache.cayenne.Fault;
 import org.apache.cayenne.ObjectId;
 import org.apache.cayenne.PersistenceState;
@@ -551,10 +552,8 @@ public class DataContextIT {
 	@Test
 	public void invalidateObjects_Vararg() throws Exception {
 
-		DataRow row = DataRow.of("Artist", Map.of(
-		        "ARTIST_ID", 1,
-		        "ARTIST_NAME", "ArtistXYZ",
-		        "DATE_OF_BIRTH", new Date()), SnapshotVersion.next());
+		DataRow row = DataRow.of("Artist", DataRowLayout.of("ARTIST_ID", "ARTIST_NAME", "DATE_OF_BIRTH"),
+		        new Object[]{1, "ArtistXYZ", new Date()}, SnapshotVersion.next());
 		Persistent object = context.objectFromDataRow(Artist.class, row);
 		ObjectId oid = object.getObjectId();
 
@@ -574,10 +573,8 @@ public class DataContextIT {
 	@Test
 	public void invalidateObjects() throws Exception {
 
-		DataRow row = DataRow.of("Artist", Map.of(
-		        "ARTIST_ID", 1,
-		        "ARTIST_NAME", "ArtistXYZ",
-		        "DATE_OF_BIRTH", new Date()), SnapshotVersion.next());
+		DataRow row = DataRow.of("Artist", DataRowLayout.of("ARTIST_ID", "ARTIST_NAME", "DATE_OF_BIRTH"),
+		        new Object[]{1, "ArtistXYZ", new Date()}, SnapshotVersion.next());
 		Persistent object = context.objectFromDataRow(Artist.class, row);
 		ObjectId oid = object.getObjectId();
 
diff --git a/cayenne/src/test/java/org/apache/cayenne/access/DataContextObjectStoreIT.java b/cayenne/src/test/java/org/apache/cayenne/access/DataContextObjectStoreIT.java
index e9dba8ef2..b8ee165f2 100644
--- a/cayenne/src/test/java/org/apache/cayenne/access/DataContextObjectStoreIT.java
+++ b/cayenne/src/test/java/org/apache/cayenne/access/DataContextObjectStoreIT.java
@@ -21,6 +21,7 @@ package org.apache.cayenne.access;
 
 import org.apache.cayenne.Cayenne;
 import org.apache.cayenne.DataRow;
+import org.apache.cayenne.DataRowLayout;
 import org.apache.cayenne.MockPersistentObject;
 import org.apache.cayenne.ObjectId;
 import org.apache.cayenne.Persistent;
@@ -32,7 +33,6 @@ import org.apache.cayenne.unit.CayenneProjects;
 import org.apache.cayenne.unit.CayenneTestsEnv;
 import org.junit.jupiter.api.extension.RegisterExtension;
 import org.junit.jupiter.api.Test;
-import java.util.Map;
 import java.util.Collections;
 import java.util.Date;
 
@@ -72,10 +72,8 @@ public class DataContextObjectStoreIT {
     @Test
     public void objectsUnregistered() throws Exception {
 
-        DataRow row = DataRow.of("Artist", Map.of(
-                "ARTIST_ID", 1,
-                "ARTIST_NAME", "ArtistXYZ",
-                "DATE_OF_BIRTH", new Date()), SnapshotVersion.next());
+        DataRow row = DataRow.of("Artist", DataRowLayout.of("ARTIST_ID", "ARTIST_NAME", "DATE_OF_BIRTH"),
+                new Object[]{1, "ArtistXYZ", new Date()}, SnapshotVersion.next());
         Persistent object = env.context().objectFromDataRow(Artist.class, row);
         ObjectId oid = object.getObjectId();
 
diff --git a/cayenne/src/test/java/org/apache/cayenne/access/DataContextObjectTrackingIT.java b/cayenne/src/test/java/org/apache/cayenne/access/DataContextObjectTrackingIT.java
index e184d9b14..b6a3562ea 100644
--- a/cayenne/src/test/java/org/apache/cayenne/access/DataContextObjectTrackingIT.java
+++ b/cayenne/src/test/java/org/apache/cayenne/access/DataContextObjectTrackingIT.java
@@ -20,6 +20,7 @@
 package org.apache.cayenne.access;
 
 import org.apache.cayenne.DataRow;
+import org.apache.cayenne.DataRowLayout;
 import org.apache.cayenne.ObjectId;
 import org.apache.cayenne.PersistenceState;
 import org.apache.cayenne.Persistent;
@@ -33,7 +34,6 @@ import org.junit.jupiter.api.extension.RegisterExtension;
 import org.junit.jupiter.api.Test;
 
 import java.sql.Types;
-import java.util.Map;
 import java.util.Collections;
 import java.util.Date;
 
@@ -89,10 +89,8 @@ public class DataContextObjectTrackingIT {
     @Test
     public void unregisterObject() {
 
-        DataRow row = DataRow.of("Artist", Map.of(
-                "ARTIST_ID", 1,
-                "ARTIST_NAME", "ArtistXYZ",
-                "DATE_OF_BIRTH", new Date()), SnapshotVersion.next());
+        DataRow row = DataRow.of("Artist", DataRowLayout.of("ARTIST_ID", "ARTIST_NAME", "DATE_OF_BIRTH"),
+                new Object[]{1, "ArtistXYZ", new Date()}, SnapshotVersion.next());
         Persistent obj = context.objectFromDataRow(Artist.class, row);
         ObjectId oid = obj.getObjectId();
 
@@ -112,10 +110,8 @@ public class DataContextObjectTrackingIT {
     @Test
     public void invalidateObjects_Vararg() {
 
-        DataRow row = DataRow.of("Artist", Map.of(
-                "ARTIST_ID", 1,
-                "ARTIST_NAME", "ArtistXYZ",
-                "DATE_OF_BIRTH", new Date()), SnapshotVersion.next());
+        DataRow row = DataRow.of("Artist", DataRowLayout.of("ARTIST_ID", "ARTIST_NAME", "DATE_OF_BIRTH"),
+                new Object[]{1, "ArtistXYZ", new Date()}, SnapshotVersion.next());
         Persistent obj = context.objectFromDataRow(Artist.class, row);
         ObjectId oid = obj.getObjectId();
 
diff --git a/cayenne/src/test/java/org/apache/cayenne/access/DataContextProcedureQueryIT.java b/cayenne/src/test/java/org/apache/cayenne/access/DataContextProcedureQueryIT.java
index ba0af6980..9f0554528 100644
--- a/cayenne/src/test/java/org/apache/cayenne/access/DataContextProcedureQueryIT.java
+++ b/cayenne/src/test/java/org/apache/cayenne/access/DataContextProcedureQueryIT.java
@@ -20,6 +20,7 @@
 package org.apache.cayenne.access;
 
 import org.apache.cayenne.DataRow;
+import org.apache.cayenne.DataRowLayout;
 import org.apache.cayenne.OutParametersResult;
 import org.apache.cayenne.QueryResult;
 import org.apache.cayenne.dba.TypesMapping;
@@ -41,7 +42,6 @@ import org.junit.jupiter.api.extension.RegisterExtension;
 
 import java.math.BigDecimal;
 import java.sql.Types;
-import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Map.Entry;
@@ -427,12 +427,13 @@ public class DataContextProcedureQueryIT  {
      * An ugly hack - converting row keys to uppercase ... Tracked via CAY-148.
      */
     protected DataRow uppercaseConverter(DataRow row) {
-        DataRow converted = DataRow.of(row.getEntityName(), new HashMap<>(row.size()), SnapshotVersion.next());
-
-        for (Entry<String, Object> entry : row.entrySet()) {
-            converted.put(entry.getKey().toString().toUpperCase(), entry.getValue());
+        String[] names = new String[row.size()];
+        Object[] values = new Object[names.length];
+        int i = 0;
+        for (Map.Entry<String, Object> entry : row.entrySet()) {
+            names[i] = entry.getKey().toUpperCase();
+            values[i++] = entry.getValue();
         }
-
-        return converted;
+        return DataRow.of(row.getEntityName(), DataRowLayout.of(names), values, SnapshotVersion.next());
     }
 }
diff --git a/cayenne/src/test/java/org/apache/cayenne/access/DataContextQueryCachingIT.java b/cayenne/src/test/java/org/apache/cayenne/access/DataContextQueryCachingIT.java
index ddb2c642f..bf3512269 100644
--- a/cayenne/src/test/java/org/apache/cayenne/access/DataContextQueryCachingIT.java
+++ b/cayenne/src/test/java/org/apache/cayenne/access/DataContextQueryCachingIT.java
@@ -20,6 +20,7 @@
 package org.apache.cayenne.access;
 
 import org.apache.cayenne.DataRow;
+import org.apache.cayenne.DataRowLayout;
 import org.apache.cayenne.Persistent;
 import org.apache.cayenne.query.ObjectSelect;
 import org.apache.cayenne.query.QueryCacheStrategy;
@@ -32,7 +33,6 @@ import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.extension.RegisterExtension;
 
-import java.util.Map;
 import java.util.ArrayList;
 import java.util.List;
 
@@ -221,9 +221,8 @@ public class DataContextQueryCachingIT {
         List<Object> rows = new ArrayList<>(len);
 
         for (int i = 0; i < len; i++) {
-            DataRow a = DataRow.of("Artist", Map.of(
-                    "ARTIST_ID", i + 1,
-                    "ARTIST_NAME", "A-" + (i + 1)), SnapshotVersion.next());
+            DataRow a = DataRow.of("Artist", DataRowLayout.of("ARTIST_ID", "ARTIST_NAME"),
+                    new Object[]{i + 1, "A-" + (i + 1)}, SnapshotVersion.next());
             rows.add(a);
         }
 
diff --git a/cayenne/src/test/java/org/apache/cayenne/access/DataRowStoreIT.java b/cayenne/src/test/java/org/apache/cayenne/access/DataRowStoreIT.java
index 5c3878ac0..a0d21e622 100644
--- a/cayenne/src/test/java/org/apache/cayenne/access/DataRowStoreIT.java
+++ b/cayenne/src/test/java/org/apache/cayenne/access/DataRowStoreIT.java
@@ -20,6 +20,7 @@
 package org.apache.cayenne.access;
 
 import org.apache.cayenne.DataRow;
+import org.apache.cayenne.DataRowLayout;
 import org.apache.cayenne.ObjectId;
 import org.apache.cayenne.access.event.SnapshotEvent;
 import org.apache.cayenne.event.DefaultEventManager;
@@ -80,15 +81,15 @@ public class DataRowStoreIT {
 
         ObjectId key1 = ObjectId.of("Artist", Artist.ARTIST_ID_PK_COLUMN, 1);
         Map<ObjectId, DataRow> diff1 = new HashMap<>();
-        diff1.put(key1, DataRow.of("Artist", new HashMap<>(1), SnapshotVersion.next()));
+        diff1.put(key1, DataRow.of("Artist", DataRowLayout.of(), new Object[0], SnapshotVersion.next()));
 
         ObjectId key2 = ObjectId.of("Artist", Artist.ARTIST_ID_PK_COLUMN, 2);
         Map<ObjectId, DataRow> diff2 = new HashMap<>();
-        diff2.put(key2, DataRow.of("Artist", new HashMap<>(1), SnapshotVersion.next()));
+        diff2.put(key2, DataRow.of("Artist", DataRowLayout.of(), new Object[0], SnapshotVersion.next()));
 
         ObjectId key3 = ObjectId.of("Artist", Artist.ARTIST_ID_PK_COLUMN, 3);
         Map<ObjectId, DataRow> diff3 = new HashMap<>();
-        diff3.put(key3, DataRow.of("Artist", new HashMap<>(1), SnapshotVersion.next()));
+        diff3.put(key3, DataRow.of("Artist", DataRowLayout.of(), new Object[0], SnapshotVersion.next()));
 
         cache.processSnapshotChanges(
                 this,
@@ -137,28 +138,24 @@ public class DataRowStoreIT {
             eventManager.addListener(events, SnapshotEvent.class, List::add, cache.getSnapshotEventSubject());
 
             ObjectId key = ObjectId.of("Artist", Artist.ARTIST_ID_PK_COLUMN, 1);
-            DataRow old = DataRow.of("Artist",
-                    new HashMap<>(Map.of("ARTIST_ID", 1, "ARTIST_NAME", "a", "DATE_OF_BIRTH", "d")),
-                    SnapshotVersion.next());
+            DataRowLayout layout = DataRowLayout.of("ARTIST_ID", "ARTIST_NAME", "DATE_OF_BIRTH");
+            DataRow old = DataRow.of("Artist", layout, new Object[]{1, "a", "d"}, SnapshotVersion.next());
             cache.processSnapshotChanges(this, Map.of(key, old), Collections.emptyList(), Collections.emptyList(),
                     Collections.emptyList());
             assertTrue(events.isEmpty(), "a new snapshot is not an update");
 
-            DataRow same = DataRow.of("Artist", new HashMap<>(old), SnapshotVersion.next());
+            DataRow same = DataRow.of("Artist", layout, new Object[]{1, "a", "d"}, SnapshotVersion.next());
             cache.processSnapshotChanges(this, Map.of(key, same), Collections.emptyList(), Collections.emptyList(),
                     Collections.emptyList());
             assertTrue(events.isEmpty(), "an unchanged snapshot is not broadcast");
 
-            DataRow changed = DataRow.of("Artist",
-                    new HashMap<>(Map.of("ARTIST_ID", 1, "ARTIST_NAME", "b", "DATE_OF_BIRTH", "d")),
-                    SnapshotVersion.next());
+            DataRow changed = DataRow.of("Artist", layout, new Object[]{1, "b", "d"}, SnapshotVersion.next());
             cache.processSnapshotChanges(this, Map.of(key, changed), Collections.emptyList(),
                     Collections.emptyList(), Collections.emptyList());
             assertEquals(1, events.size());
 
-            DataRow diff = events.getFirst().getModifiedDiffs().get(key);
-            assertEquals(Map.of("ARTIST_NAME", "b"), diff);
-            assertEquals(changed.getVersion(), diff.getVersion());
+            assertEquals(Map.of("ARTIST_NAME", "b"), events.getFirst().getModifiedDiffs().get(key));
+            assertEquals(changed.getVersion(), events.getFirst().getVersion());
         } finally {
             eventManager.shutdown();
         }
diff --git a/cayenne/src/test/java/org/apache/cayenne/access/DataRowUtilsIT.java b/cayenne/src/test/java/org/apache/cayenne/access/DataRowUtilsIT.java
index 1cc09bac7..3f4786b4a 100644
--- a/cayenne/src/test/java/org/apache/cayenne/access/DataRowUtilsIT.java
+++ b/cayenne/src/test/java/org/apache/cayenne/access/DataRowUtilsIT.java
@@ -19,10 +19,10 @@
 
 package org.apache.cayenne.access;
 
-import java.util.Map;
 import java.util.List;
 
 import org.apache.cayenne.DataRow;
+import org.apache.cayenne.DataRowLayout;
 import org.apache.cayenne.query.ObjectSelect;
 import org.apache.cayenne.reflect.ArcProperty;
 import org.apache.cayenne.reflect.ClassDescriptor;
@@ -84,12 +84,11 @@ public class DataRowUtilsIT {
                 .get(0);
         a1.setArtistName(n1);
 
-        DataRow s2 = DataRow.of("Artist", Map.of(
-                "ARTIST_NAME", n2,
-                "DATE_OF_BIRTH", new java.util.Date()), SnapshotVersion.next());
+        DataRow s2 = DataRow.of("Artist", DataRowLayout.of("ARTIST_NAME", "DATE_OF_BIRTH"),
+                new Object[]{n2, new java.util.Date()}, SnapshotVersion.next());
 
         ClassDescriptor d = context.getEntityResolver().getClassDescriptor("Artist");
-        DataRowUtils.mergeObjectWithSnapshot(context, d, a1, s2);
+        DataRowUtils.mergeObjectWithSnapshot(context, a1, s2, MergePlan.of(d));
 
         // name was modified, so it should not change during merge
         assertEquals(n1, a1.getArtistName());
diff --git a/cayenne/src/test/java/org/apache/cayenne/access/LoggingObserverTest.java b/cayenne/src/test/java/org/apache/cayenne/access/LoggingObserverTest.java
index 91f4cce55..bb2eb5242 100644
--- a/cayenne/src/test/java/org/apache/cayenne/access/LoggingObserverTest.java
+++ b/cayenne/src/test/java/org/apache/cayenne/access/LoggingObserverTest.java
@@ -20,6 +20,7 @@
 package org.apache.cayenne.access;
 
 import org.apache.cayenne.DataRow;
+import org.apache.cayenne.DataRowLayout;
 import org.apache.cayenne.access.jdbc.CSParameter;
 import org.apache.cayenne.access.jdbc.PSBatchParameter;
 import org.apache.cayenne.access.jdbc.PSParameter;
@@ -194,8 +195,10 @@ public class LoggingObserverTest {
         CapturingLogger logger = new CapturingLogger();
         LoggingObserver observer = observer(logger);
 
-        DataRow row1 = DataRow.of("Artist", Map.of("ARTIST_ID", 1L), SnapshotVersion.next());
-        DataRow row2 = DataRow.of("Artist", Map.of("ARTIST_ID", 2L), SnapshotVersion.next());
+        DataRow row1 = DataRow.of("Artist", DataRowLayout.of("ARTIST_ID"),
+                new Object[]{1L}, SnapshotVersion.next());
+        DataRow row2 = DataRow.of("Artist", DataRowLayout.of("ARTIST_ID"),
+                new Object[]{2L}, SnapshotVersion.next());
 
         observer.nextStatement(null, batch());
         observer.nextGeneratedRows(null, asList(row1, row2), List.of());
diff --git a/cayenne/src/test/java/org/apache/cayenne/access/MockDataContextDelegate.java b/cayenne/src/test/java/org/apache/cayenne/access/MockDataContextDelegate.java
index 74b49c327..b199f62d8 100644
--- a/cayenne/src/test/java/org/apache/cayenne/access/MockDataContextDelegate.java
+++ b/cayenne/src/test/java/org/apache/cayenne/access/MockDataContextDelegate.java
@@ -19,10 +19,11 @@
 
 package org.apache.cayenne.access;
 
-import org.apache.cayenne.DataRow;
 import org.apache.cayenne.Persistent;
 import org.apache.cayenne.query.Query;
 
+import java.util.Map;
+
 /**
  * Default implementation of DataContextDelegate that serves as a superclass for mockup
  * test delegates.
@@ -41,7 +42,7 @@ public class MockDataContextDelegate implements DataContextDelegate {
     }
 
     @Override
-    public boolean shouldMergeChanges(Persistent object, DataRow snapshotInStore) {
+    public boolean shouldMergeChanges(Persistent object, Map<String, Object> snapshotInStore) {
         return true;
     }
 
diff --git a/cayenne/src/test/java/org/apache/cayenne/access/MockDataRowStore.java b/cayenne/src/test/java/org/apache/cayenne/access/MockDataRowStore.java
index 261874f84..7a904cb68 100644
--- a/cayenne/src/test/java/org/apache/cayenne/access/MockDataRowStore.java
+++ b/cayenne/src/test/java/org/apache/cayenne/access/MockDataRowStore.java
@@ -20,10 +20,10 @@
 package org.apache.cayenne.access;
 
 import org.apache.cayenne.DataRow;
+import org.apache.cayenne.DataRowLayout;
 import org.apache.cayenne.ObjectId;
 import org.apache.cayenne.event.MockEventManager;
 
-import java.util.HashMap;
 import java.util.Map;
 
 /**
@@ -46,11 +46,18 @@ public class MockDataRowStore extends DataRowStore {
     }
 
     public void putSnapshot(ObjectId id, Map<String, ?> snapshot) {
-        snapshots.put(id, DataRow.of(id.getEntityName(), new HashMap<>(snapshot), SnapshotVersion.next()));
+        String[] names = new String[snapshot.size()];
+        Object[] values = new Object[names.length];
+        int i = 0;
+        for (Map.Entry<String, ?> entry : snapshot.entrySet()) {
+            names[i] = entry.getKey();
+            values[i++] = entry.getValue();
+        }
+        snapshots.put(id, DataRow.of(id.getEntityName(), DataRowLayout.of(names), values, SnapshotVersion.next()));
     }
 
     public void putEmptySnapshot(ObjectId id) {
-        snapshots.put(id, DataRow.of(id.getEntityName(), new HashMap<>(2), SnapshotVersion.next()));
+        snapshots.put(id, DataRow.of(id.getEntityName(), DataRowLayout.of(), new Object[0], SnapshotVersion.next()));
     }
 
 }
diff --git a/cayenne/src/test/java/org/apache/cayenne/access/NestedDataContextReadIT.java b/cayenne/src/test/java/org/apache/cayenne/access/NestedDataContextReadIT.java
index 629ddcbd6..b411b4e7e 100644
--- a/cayenne/src/test/java/org/apache/cayenne/access/NestedDataContextReadIT.java
+++ b/cayenne/src/test/java/org/apache/cayenne/access/NestedDataContextReadIT.java
@@ -21,6 +21,7 @@ package org.apache.cayenne.access;
 
 import org.apache.cayenne.Cayenne;
 import org.apache.cayenne.DataRow;
+import org.apache.cayenne.DataRowLayout;
 import org.apache.cayenne.ObjectContext;
 import org.apache.cayenne.ObjectId;
 import org.apache.cayenne.PersistenceState;
@@ -37,7 +38,6 @@ import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 
 import java.sql.Types;
-import java.util.Map;
 import java.util.Date;
 import java.util.List;
 
@@ -339,10 +339,8 @@ public class NestedDataContextReadIT {
 
         DataContext childContext = (DataContext) runtime.newContext(context);
 
-        DataRow row = DataRow.of("Artist", Map.of(
-                "ARTIST_ID", 5L,
-                "ARTIST_NAME", "A",
-                "DATE_OF_BIRTH", new Date()), SnapshotVersion.next());
+        DataRow row = DataRow.of("Artist", DataRowLayout.of("ARTIST_ID", "ARTIST_NAME", "DATE_OF_BIRTH"),
+                new Object[]{5L, "A", new Date()}, SnapshotVersion.next());
 
         Artist artist = childContext.objectFromDataRow(Artist.class, row);
         assertNotNull(artist);
diff --git a/cayenne/src/test/java/org/apache/cayenne/access/PrefetchProcessorTreeBuilderIT.java b/cayenne/src/test/java/org/apache/cayenne/access/PrefetchProcessorTreeBuilderIT.java
index 6b6d5d296..842652271 100644
--- a/cayenne/src/test/java/org/apache/cayenne/access/PrefetchProcessorTreeBuilderIT.java
+++ b/cayenne/src/test/java/org/apache/cayenne/access/PrefetchProcessorTreeBuilderIT.java
@@ -20,6 +20,7 @@
 package org.apache.cayenne.access;
 
 import org.apache.cayenne.DataRow;
+import org.apache.cayenne.DataRowLayout;
 import org.apache.cayenne.exp.path.CayennePath;
 import org.apache.cayenne.map.DataMap;
 import org.apache.cayenne.map.DbEntity;
@@ -69,8 +70,8 @@ public class PrefetchProcessorTreeBuilderIT {
 
         final ClassDescriptor descriptor = resolver.getClassDescriptor("Artist");
         List<DataRow> dataRows = new ArrayList<>();
-        dataRows.add(DataRow.of("Artist", new HashMap<>(4), SnapshotVersion.next()));
-        dataRows.add(DataRow.of("Artist", new HashMap<>(4), SnapshotVersion.next()));
+        dataRows.add(DataRow.of("Artist", DataRowLayout.of(), new Object[0], SnapshotVersion.next()));
+        dataRows.add(DataRow.of("Artist", DataRowLayout.of(), new Object[0], SnapshotVersion.next()));
 
         QueryMetadata metadata = new MockQueryMetadata() {
 
diff --git a/cayenne/src/test/java/org/apache/cayenne/access/SimpleIdIncrementalFaultListDataRowsIT.java b/cayenne/src/test/java/org/apache/cayenne/access/SimpleIdIncrementalFaultListDataRowsIT.java
index 117f9b0f5..77e7c6819 100644
--- a/cayenne/src/test/java/org/apache/cayenne/access/SimpleIdIncrementalFaultListDataRowsIT.java
+++ b/cayenne/src/test/java/org/apache/cayenne/access/SimpleIdIncrementalFaultListDataRowsIT.java
@@ -20,6 +20,7 @@
 package org.apache.cayenne.access;
 
 import org.apache.cayenne.DataRow;
+import org.apache.cayenne.DataRowLayout;
 import org.apache.cayenne.query.ObjectSelect;
 import org.apache.cayenne.test.jdbc.TableHelper;
 import org.apache.cayenne.testdo.testmap.Artist;
@@ -29,9 +30,10 @@ import org.junit.jupiter.api.extension.RegisterExtension;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 
-import java.util.HashMap;
+import java.util.ArrayList;
 import java.util.Iterator;
 import java.util.List;
+import java.util.Map;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertNotNull;
@@ -117,7 +119,7 @@ public class SimpleIdIncrementalFaultListDataRowsIT {
         DataRow row = artists.getFirst();
         assertEquals(19, list.indexOf(row));
 
-        DataRow clone = DataRow.of(row.getEntityName(), new HashMap<>(row), SnapshotVersion.next());
+        DataRow clone = row.merged(Map.of(), SnapshotVersion.next());
         assertEquals(19, list.indexOf(clone));
 
         assertEquals(-1, list.indexOf(without(row, "ARTIST_ID")));
@@ -199,8 +201,13 @@ public class SimpleIdIncrementalFaultListDataRowsIT {
         }
     }
     private static DataRow without(DataRow row, String key) {
-        HashMap<String, Object> values = new HashMap<>(row);
-        values.remove(key);
-        return DataRow.of(row.getEntityName(), values, row.getVersion());
+        List<String> names = new ArrayList<>(row.keySet());
+        names.remove(key);
+        Object[] values = new Object[names.size()];
+        for (int i = 0; i < values.length; i++) {
+            values[i] = row.get(names.get(i));
+        }
+        DataRowLayout layout = DataRowLayout.of(names.toArray(new String[0]));
+        return DataRow.of(row.getEntityName(), layout, values, row.getVersion());
     }
 }
diff --git a/cayenne/src/test/java/org/apache/cayenne/access/SnapshotVersionTest.java b/cayenne/src/test/java/org/apache/cayenne/access/SnapshotVersionTest.java
index 2fbc1136d..9d6da29d3 100644
--- a/cayenne/src/test/java/org/apache/cayenne/access/SnapshotVersionTest.java
+++ b/cayenne/src/test/java/org/apache/cayenne/access/SnapshotVersionTest.java
@@ -20,6 +20,7 @@
 package org.apache.cayenne.access;
 
 import org.apache.cayenne.DataRow;
+import org.apache.cayenne.DataRowLayout;
 import org.apache.cayenne.Persistent;
 import org.junit.jupiter.api.Test;
 
@@ -27,7 +28,6 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertNotEquals;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 
-import java.util.HashMap;
 
 public class SnapshotVersionTest {
 
@@ -37,7 +37,7 @@ public class SnapshotVersionTest {
         long v2 = SnapshotVersion.next();
         assertNotEquals(SnapshotVersion.NONE, v1);
         assertTrue(v2 > v1);
-        assertTrue(DataRow.of("E", new HashMap<>(10), SnapshotVersion.next()).getVersion() > v2);
+        assertTrue(DataRow.of("E", DataRowLayout.of(), new Object[0], SnapshotVersion.next()).getVersion() > v2);
     }
 
     @Test
diff --git a/cayenne/src/test/java/org/apache/cayenne/access/event/SnapshotEventTest.java b/cayenne/src/test/java/org/apache/cayenne/access/event/SnapshotEventTest.java
index e3bf0aec6..d429161dc 100644
--- a/cayenne/src/test/java/org/apache/cayenne/access/event/SnapshotEventTest.java
+++ b/cayenne/src/test/java/org/apache/cayenne/access/event/SnapshotEventTest.java
@@ -19,7 +19,6 @@
 
 package org.apache.cayenne.access.event;
 
-import org.apache.cayenne.DataRow;
 import org.apache.cayenne.ObjectId;
 import org.junit.jupiter.api.Test;
 
@@ -28,6 +27,7 @@ import java.util.Collection;
 import java.util.HashMap;
 import java.util.Map;
 
+import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertSame;
 
 public class SnapshotEventTest {
@@ -37,12 +37,13 @@ public class SnapshotEventTest {
         Object source = new Object();
         Collection<ObjectId> deleted = new ArrayList<>();
         Collection<ObjectId> invalidated = new ArrayList<>();
-        Map<ObjectId, DataRow> modified = new HashMap<>();
+        Map<ObjectId, Map<String, Object>> modified = new HashMap<>();
         Collection<ObjectId> related = new ArrayList<>();
 
         SnapshotEvent event = new SnapshotEvent(
                 source,
                 source,
+                7,
                 modified,
                 deleted,
                 invalidated,
@@ -52,6 +53,7 @@ public class SnapshotEventTest {
         assertSame(deleted, event.getDeletedIds());
         assertSame(invalidated, event.getInvalidatedIds());
         assertSame(modified, event.getModifiedDiffs());
+        assertEquals(7, event.getVersion());
         assertSame(related, event.getIndirectlyModifiedIds());
     }
 }
diff --git a/cayenne/src/test/java/org/apache/cayenne/event/EventBridgeTest.java b/cayenne/src/test/java/org/apache/cayenne/event/EventBridgeTest.java
index a641acd56..246bd369d 100644
--- a/cayenne/src/test/java/org/apache/cayenne/event/EventBridgeTest.java
+++ b/cayenne/src/test/java/org/apache/cayenne/event/EventBridgeTest.java
@@ -19,6 +19,7 @@
 
 package org.apache.cayenne.event;
 
+import org.apache.cayenne.access.SnapshotVersion;
 import org.apache.cayenne.access.event.SnapshotEvent;
 import org.apache.cayenne.test.parallel.ParallelTestContainer;
 import org.junit.jupiter.api.AfterEach;
@@ -116,6 +117,7 @@ public class EventBridgeTest {
         final SnapshotEvent eventWithNoSubject = new SnapshotEvent(
                 this,
                 this,
+                SnapshotVersion.NONE,
                 null,
                 null,
                 null,
@@ -141,6 +143,7 @@ public class EventBridgeTest {
         final SnapshotEvent eventWithSubject = new SnapshotEvent(
                 this,
                 this,
+                SnapshotVersion.NONE,
                 null,
                 null,
                 null,
diff --git a/cayenne/src/test/java/org/apache/cayenne/query/ProcedureCallIT.java b/cayenne/src/test/java/org/apache/cayenne/query/ProcedureCallIT.java
index aedab8a23..24c8e46d8 100644
--- a/cayenne/src/test/java/org/apache/cayenne/query/ProcedureCallIT.java
+++ b/cayenne/src/test/java/org/apache/cayenne/query/ProcedureCallIT.java
@@ -19,6 +19,7 @@
 package org.apache.cayenne.query;
 
 import org.apache.cayenne.DataRow;
+import org.apache.cayenne.DataRowLayout;
 import org.apache.cayenne.OutParametersResult;
 import org.apache.cayenne.QueryResult;
 import org.apache.cayenne.access.SnapshotVersion;
@@ -35,7 +36,6 @@ import org.junit.jupiter.api.extension.RegisterExtension;
 
 import java.math.BigDecimal;
 import java.sql.Types;
-import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.function.Supplier;
@@ -345,12 +345,13 @@ public class ProcedureCallIT {
      * An ugly hack - converting row keys to uppercase ... Tracked via CAY-148.
      */
     private DataRow uppercaseConverter(DataRow row) {
-        DataRow converted = DataRow.of(row.getEntityName(), new HashMap<>(row.size()), SnapshotVersion.next());
-
+        String[] names = new String[row.size()];
+        Object[] values = new Object[names.length];
+        int i = 0;
         for (Map.Entry<String, Object> entry : row.entrySet()) {
-            converted.put(entry.getKey().toUpperCase(), entry.getValue());
+            names[i] = entry.getKey().toUpperCase();
+            values[i++] = entry.getValue();
         }
-
-        return converted;
+        return DataRow.of(row.getEntityName(), DataRowLayout.of(names), values, SnapshotVersion.next());
     }
 }
diff --git a/cayenne/src/test/java/org/apache/cayenne/reflect/PersistentDescriptor_SnapshotLayoutIT.java b/cayenne/src/test/java/org/apache/cayenne/reflect/PersistentDescriptor_SnapshotLayoutIT.java
new file mode 100644
index 000000000..24fa10071
--- /dev/null
+++ b/cayenne/src/test/java/org/apache/cayenne/reflect/PersistentDescriptor_SnapshotLayoutIT.java
@@ -0,0 +1,117 @@
+/*****************************************************************
+ *   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
+ *
+ *    https://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.reflect;
+
+import org.apache.cayenne.DataRow;
+import org.apache.cayenne.DataRowLayout;
+import org.apache.cayenne.ObjectContext;
+import org.apache.cayenne.access.DataContext;
+import org.apache.cayenne.testdo.testmap.Artist;
+import org.apache.cayenne.testdo.testmap.Painting;
+import org.apache.cayenne.unit.CayenneProjects;
+import org.apache.cayenne.unit.CayenneTestsEnv;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.RegisterExtension;
+
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+public class PersistentDescriptor_SnapshotLayoutIT {
+
+    @RegisterExtension
+    static final CayenneTestsEnv env = CayenneTestsEnv.forProject(CayenneProjects.TESTMAP_PROJECT);
+
+    private static Set<String> names(DataRowLayout layout) {
+        Set<String> names = new HashSet<>();
+        for (int i = 0; i < layout.size(); i++) {
+            names.add(layout.nameAt(i));
+        }
+        return names;
+    }
+
+    private DataRowLayout layout(String entityName) {
+        return env.context().getEntityResolver().getClassDescriptor(entityName).getSnapshotLayout();
+    }
+
+    @Test
+    public void attributesForeignKeysAndPk() {
+        // the FKs of "toArtist" and "toGallery" are columns, the to-dependent-PK "toPaintingInfo" contributes nothing
+        DataRowLayout painting = layout("Painting");
+        assertEquals(Set.of("PAINTING_ID", "PAINTING_TITLE", "PAINTING_DESCRIPTION", "ESTIMATED_PRICE", "ARTIST_ID",
+                "GALLERY_ID"), names(painting));
+        assertEquals(6, painting.size(), "each column is listed once");
+
+        DataRowLayout artist = layout("Artist");
+        assertEquals(Set.of("ARTIST_ID", "ARTIST_NAME", "DATE_OF_BIRTH"), names(artist));
+    }
+
+    @Test
+    public void flattenedAttributesAndAdditionalPks() {
+        Set<String> names = names(layout("CompoundPainting"));
+        assertTrue(names.containsAll(List.of("PAINTING_ID", "PAINTING_TITLE", "ESTIMATED_PRICE",
+                "toArtist.ARTIST_NAME", "toGallery.GALLERY_NAME", "toPaintingInfo.TEXT_REVIEW",
+                "toArtist.ARTIST_ID", "toGallery.GALLERY_ID", "toPaintingInfo.PAINTING_ID")), names.toString());
+    }
+
+    @Test
+    public void sameInstance() {
+        assertSame(layout("Artist"), layout("Artist"));
+    }
+
+    @Test
+    public void snapshotsAreBuiltOverTheLayout() {
+        ObjectContext context = env.context();
+        Artist artist = context.newObject(Artist.class);
+        artist.setArtistName("a");
+        Painting painting = context.newObject(Painting.class);
+        painting.setPaintingTitle("p");
+        painting.setToArtist(artist);
+        context.commitChanges();
+
+        // the keys of a snapshot come in the layout order
+        DataRowLayout layout = layout("Painting");
+        List<String> expected = new ArrayList<>();
+        for (int i = 0; i < layout.size(); i++) {
+            expected.add(layout.nameAt(i));
+        }
+
+        DataRow snapshot = ((DataContext) context).currentSnapshot(painting);
+        assertEquals(expected, new ArrayList<>(snapshot.keySet()));
+        assertEquals(artist.getObjectId().getIdSnapshot().get("ARTIST_ID"), snapshot.get("ARTIST_ID"));
+        assertEquals("p", snapshot.get("PAINTING_TITLE"));
+
+        // a partial snapshot, of an object pointing at an uncommitted one, has only the columns it could fill: a null
+        // to-one is a null FK, an uncommitted one and the object's own temporary id are missing columns
+        Painting uncommitted = context.newObject(Painting.class);
+        uncommitted.setPaintingTitle("u");
+        uncommitted.setToArtist(context.newObject(Artist.class));
+        DataRow partial = ((DataContext) context).currentSnapshot(uncommitted);
+        assertEquals(Set.of("PAINTING_TITLE", "PAINTING_DESCRIPTION", "ESTIMATED_PRICE", "GALLERY_ID"),
+                partial.keySet());
+        assertNull(partial.get("GALLERY_ID"));
+    }
+}