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/06/29 20:46:42 UTC

(cayenne) branch master updated (7081c9195 -> 10396c6d8)

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

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


    from 7081c9195 Compound PK pagination test
     new 190eb3618 aligning DefaultRowReaderFactory id handling with the actuial RSColumn[] layout
     new 10396c6d8 CAY-2967 SQLTemplate/SQLSelect broken pagination

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


Summary of changes:
 RELEASE-NOTES.txt                                  |  1 +
 .../org/apache/cayenne/access/jdbc/RSColumn.java   | 45 ++++++++++++++--
 .../cayenne/access/jdbc/SQLTemplateAction.java     | 25 +++++----
 .../jdbc/reader/DefaultRowReaderFactory.java       | 63 +++++++++++++++-------
 .../{FullRowReader.java => IndexRowReader.java}    | 59 +++++---------------
 .../access/jdbc/reader/OffsetRowReader.java        |  5 --
 .../java/org/apache/cayenne/CayenneCompoundIT.java | 46 ++++++++++++++++
 .../org/apache/cayenne/access/DataContextIT.java   |  2 +-
 .../cayenne/access/DataContextSQLTemplateIT.java   | 45 ++++++++++++++++
 9 files changed, 205 insertions(+), 86 deletions(-)
 copy cayenne/src/main/java/org/apache/cayenne/access/jdbc/reader/{FullRowReader.java => IndexRowReader.java} (50%)


(cayenne) 01/02: aligning DefaultRowReaderFactory id handling with the actuial RSColumn[] layout

Posted by aa...@apache.org.
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

commit 190eb36187c42b2dcb0c07865f207d2139571f3a
Author: Andrus Adamchik <an...@objectstyle.com>
AuthorDate: Mon Jun 29 11:39:17 2026 -0400

    aligning DefaultRowReaderFactory id handling with the actuial RSColumn[] layout
---
 .../jdbc/reader/DefaultRowReaderFactory.java       | 55 +++++-----------------
 .../access/jdbc/reader/OffsetRowReader.java        |  5 --
 2 files changed, 11 insertions(+), 49 deletions(-)

diff --git a/cayenne/src/main/java/org/apache/cayenne/access/jdbc/reader/DefaultRowReaderFactory.java b/cayenne/src/main/java/org/apache/cayenne/access/jdbc/reader/DefaultRowReaderFactory.java
index 63c38a369..47bd4f07e 100644
--- a/cayenne/src/main/java/org/apache/cayenne/access/jdbc/reader/DefaultRowReaderFactory.java
+++ b/cayenne/src/main/java/org/apache/cayenne/access/jdbc/reader/DefaultRowReaderFactory.java
@@ -18,17 +18,13 @@
  ****************************************************************/
 package org.apache.cayenne.access.jdbc.reader;
 
-import org.apache.cayenne.CayenneRuntimeException;
 import org.apache.cayenne.access.jdbc.RSColumn;
 import org.apache.cayenne.dba.DbAdapter;
-import org.apache.cayenne.map.DbEntity;
-import org.apache.cayenne.map.ObjEntity;
 import org.apache.cayenne.query.EmbeddableResultSegment;
 import org.apache.cayenne.query.EntityResultSegment;
 import org.apache.cayenne.query.QueryMetadata;
 import org.apache.cayenne.query.ScalarResultSegment;
 
-import java.util.Arrays;
 import java.util.List;
 
 /**
@@ -99,12 +95,14 @@ public class DefaultRowReaderFactory implements RowReaderFactory {
 
     protected RowReader<?> entitySegmentReader(RSColumn[] columns, QueryMetadata metadata, EntityResultSegment segment) {
 
-        if (metadata.getPageSize() > 0) {
-            ObjEntity objEntity = metadata.getObjEntity();
-            return idReader(columns,
-                    segment.getColumnOffset(),
-                    objEntity != null ? objEntity.getName() : null,
-                    segment.getClassDescriptor().getEntity().getDbEntity());
+        // For a paginated query the result columns are trimmed to the root PK (see IdColumnExtractor). A single-column
+        // PK is read as a scalar (consumed by SimpleIdIncrementalFaultList); a compound PK falls through to the regular
+        // DataRow reader below, which over PK-only columns yields exactly the id map IncrementalFaultList expects.
+        if (metadata.getPageSize() > 0
+                && segment.getClassDescriptor().getEntity().getDbEntity().getPrimaryKeys().size() == 1) {
+            int pk = segment.getColumnOffset();
+            // jdbc column indexes start from 1
+            return new ScalarRowReader<>(columns[pk].reader(), pk + 1, columns[pk].rsType());
         }
 
         int startIndex = segment.getColumnOffset();
@@ -135,39 +133,8 @@ public class DefaultRowReaderFactory implements RowReaderFactory {
     }
 
     protected RowReader<?> noSegmentReader(RSColumn[] columns, QueryMetadata metadata) {
-        if (metadata.getPageSize() > 0) {
-            ObjEntity objEntity = metadata.getObjEntity();
-            return idReader(
-                    columns,
-                    0,
-                    objEntity != null ? objEntity.getName() : null,
-                    metadata.getDbEntity());
-        }
-        return FullRowReader.of(columns, metadata);
+        return metadata.getPageSize() > 0 && metadata.getDbEntity().getPrimaryKeys().size() == 1
+                ? new ScalarRowReader<>(columns[0].reader(), 1, columns[0].rsType())
+                : FullRowReader.of(columns, metadata);
     }
-
-    private RowReader<?> idReader(RSColumn[] columns, int offset, String entityName, DbEntity dbEntity) {
-        if (dbEntity == null) {
-            throw new CayenneRuntimeException("Null root DbEntity, can't index PK");
-        }
-
-        int pkLen = dbEntity.getPrimaryKeys().size();
-        if (pkLen == 0) {
-            throw new CayenneRuntimeException("Root DBEntity has no PK defined: %s", dbEntity);
-        }
-
-        // TODO: An implicit assumption below is that "RSColumn[] columns" starts with a PK block (with an optional
-        //  segment offset), and PK columns are contiguous. If the upstream algorithm ever changes, this will no longer
-        //  work.
-
-        if (pkLen == 1) {
-            RSColumn column = columns[offset];
-            // jdbc column indexes start from 1
-            return new ScalarRowReader<>(column.reader(), offset + 1, column.rsType());
-        }
-
-        RSColumn[] pkColumns = Arrays.copyOfRange(columns, offset, offset + pkLen);
-        return OffsetRowReader.of(pkColumns, offset, entityName);
-    }
-
 }
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 3ba96adcd..1538df403 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
@@ -45,11 +45,6 @@ class OffsetRowReader implements RowReader<DataRow> {
                 : new OffsetRowReader(columns, offset, entityName);
     }
 
-    // fixed entity name, no inheritance
-    static OffsetRowReader of(RSColumn[] columns, int offset, String entityName) {
-        return new OffsetRowReader(columns, offset, entityName);
-    }
-
     // no entity name, no inheritance
     static OffsetRowReader of(RSColumn[] columns, int offset) {
         return new OffsetRowReader(columns, offset, null);


(cayenne) 02/02: CAY-2967 SQLTemplate/SQLSelect broken pagination

Posted by aa...@apache.org.
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

commit 10396c6d888ccdf569d1430c639736375d4a3b33
Author: Andrus Adamchik <an...@objectstyle.com>
AuthorDate: Mon Jun 29 12:40:41 2026 -0400

    CAY-2967 SQLTemplate/SQLSelect broken pagination
---
 RELEASE-NOTES.txt                                  |  1 +
 .../org/apache/cayenne/access/jdbc/RSColumn.java   | 45 +++++++++++--
 .../cayenne/access/jdbc/SQLTemplateAction.java     | 25 ++++---
 .../jdbc/reader/DefaultRowReaderFactory.java       | 78 +++++++++++++++++++---
 .../cayenne/access/jdbc/reader/IndexRowReader.java | 69 +++++++++++++++++++
 .../java/org/apache/cayenne/CayenneCompoundIT.java | 46 +++++++++++++
 .../org/apache/cayenne/access/DataContextIT.java   |  2 +-
 .../cayenne/access/DataContextSQLTemplateIT.java   | 45 +++++++++++++
 8 files changed, 285 insertions(+), 26 deletions(-)

diff --git a/RELEASE-NOTES.txt b/RELEASE-NOTES.txt
index bc11eb7cb..40cb8de23 100644
--- a/RELEASE-NOTES.txt
+++ b/RELEASE-NOTES.txt
@@ -28,6 +28,7 @@ CAY-2961 PostgreSQL "text" column is reverse-engineered as CLOB
 CAY-2964 ClassCastException for non-generated meaningful PKs
 CAY-2965 MCP Cgen should not fail on an absent "<cgen>" tag
 CAY-2966 "comment" field is lost when upgrading from v10 to v12
+CAY-2967 SQLTemplate/SQLSelect broken pagination
 
 ----------------------------------
 Release: 5.0-M2
diff --git a/cayenne/src/main/java/org/apache/cayenne/access/jdbc/RSColumn.java b/cayenne/src/main/java/org/apache/cayenne/access/jdbc/RSColumn.java
index 6b2acf134..376f879b9 100644
--- a/cayenne/src/main/java/org/apache/cayenne/access/jdbc/RSColumn.java
+++ b/cayenne/src/main/java/org/apache/cayenne/access/jdbc/RSColumn.java
@@ -24,6 +24,7 @@ import org.apache.cayenne.access.types.ExtendedType;
 import org.apache.cayenne.access.types.ExtendedTypeMap;
 import org.apache.cayenne.dba.TypesMapping;
 import org.apache.cayenne.map.DbAttribute;
+import org.apache.cayenne.map.DbEntity;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -104,6 +105,7 @@ public record RSColumn(
         private Map<String, String> typeOverrides;
         private boolean mergeColumnsWithRsMetadata;
         private boolean validateDuplicateColumnNames;
+        private DbEntity dbEntity;
 
         /**
          * Returns the array of {@link RSColumn}s describing the result row, with an {@link ExtendedType}
@@ -111,21 +113,24 @@ public record RSColumn(
          */
         public RSColumn[] build(ExtendedTypeMap typeMap) throws SQLException, IllegalStateException {
 
-            RSColumn[] columnsForRD;
+            RSColumn[] columns1;
 
             if (this.resultSetMetadata != null) {
                 // do merge between explicitly-set columns and ResultSetMetadata
                 // explicitly-set columns take precedence
-                columnsForRD = mergeResultSetAndPresetColumns(typeMap);
+                columns1 = mergeResultSetAndPresetColumns(typeMap);
             } else if (this.columns != null) {
                 // use explicitly-set columns
-                columnsForRD = this.columns;
+                columns1 = this.columns;
             } else {
                 throw new IllegalStateException(
                         "Can't build row descriptor, both 'columns' and 'resultSetMetadata' are null");
             }
 
-            return performTransformAndTypeOverride(columnsForRD, typeMap);
+            RSColumn[] columns2 = performTransformAndTypeOverride(columns1, typeMap);
+
+            // match with DbAttributes after the final names are resolved
+            return resolveDbAttributes(columns2);
         }
 
         /**
@@ -267,6 +272,29 @@ public record RSColumn(
             return result;
         }
 
+        private RSColumn[] resolveDbAttributes(RSColumn[] columnArray) {
+            if (dbEntity == null) {
+                return columnArray;
+            }
+
+            RSColumn[] result = null;
+            for (int i = 0; i < columnArray.length; i++) {
+                RSColumn column = columnArray[i];
+                if (column.attribute() != null) {
+                    continue;
+                }
+                DbAttribute attribute = dbEntity.getAttribute(column.rsName());
+                if (attribute != null) {
+                    if (result == null) {
+                        result = columnArray.clone();
+                    }
+                    result[i] = new RSColumn(
+                            column.rsName(), column.rsType(), column.dataRowName(), column.reader(), attribute);
+                }
+            }
+            return result != null ? result : columnArray;
+        }
+
         /**
          * Sets an explicit set of columns. The builder may replace these with new instances to enforce the column
          * capitalization policy and column Java type overrides.
@@ -315,5 +343,14 @@ public record RSColumn(
             this.mergeColumnsWithRsMetadata = true;
             return this;
         }
+
+        /**
+         * Sets the root DbEntity used to resolve each column's {@link DbAttribute} by name. Optional - when not set,
+         * column attributes are left as provided.
+         */
+        public RowBuilder dbEntity(DbEntity dbEntity) {
+            this.dbEntity = dbEntity;
+            return this;
+        }
     }
 }
diff --git a/cayenne/src/main/java/org/apache/cayenne/access/jdbc/SQLTemplateAction.java b/cayenne/src/main/java/org/apache/cayenne/access/jdbc/SQLTemplateAction.java
index b9d5de054..e9d50d11d 100644
--- a/cayenne/src/main/java/org/apache/cayenne/access/jdbc/SQLTemplateAction.java
+++ b/cayenne/src/main/java/org/apache/cayenne/access/jdbc/SQLTemplateAction.java
@@ -234,15 +234,18 @@ public class SQLTemplateAction implements SQLAction {
     }
 
     @SuppressWarnings({"unchecked", "rawtypes"})
-    protected void processSelectResult(TranslatedSQL compiled, Connection connection, Statement statement,
-                                       ResultSet resultSet, OperationObserver callback, final long startTime) throws Exception {
+    protected void processSelectResult(
+            TranslatedSQL compiled,
+            Connection connection, Statement statement,
+            ResultSet resultSet,
+            OperationObserver callback,
+            long startTime) throws Exception {
 
-        boolean iteratedResult = callback.isIteratedResult();
-        ExtendedTypeMap types = dataNode.getAdapter().getExtendedTypes();
-        RSColumn.RowBuilder rowBuilder = rowBuilder(compiled, resultSet);
         recreateQueryMetadata(resultSet);
-        RowReader<?> rowReader = dataNode.getRowReaderFactory()
-                .rowReader(rowBuilder.build(types), queryMetadata, dataNode.getAdapter());
+        boolean iteratedResult = callback.isIteratedResult();
+        RSColumn[] columns = rowBuilder(compiled, resultSet).build(dataNode.getAdapter().getExtendedTypes());
+
+        RowReader<?> rowReader = dataNode.getRowReaderFactory().rowReader(columns, queryMetadata, dataNode.getAdapter());
         ResultIterator<?> it = new RSIterator<>(statement, resultSet, rowReader);
 
         if (iteratedResult) {
@@ -317,12 +320,14 @@ public class SQLTemplateAction implements SQLAction {
     /**
      * @since 3.0
      */
-    protected RSColumn.RowBuilder rowBuilder(TranslatedSQL compiled, ResultSet resultSet)
-            throws SQLException {
+    protected RSColumn.RowBuilder rowBuilder(TranslatedSQL compiled, ResultSet resultSet) throws SQLException {
         RSColumn.RowBuilder builder = RSColumn.rowBuilder()
                 .resultSet(resultSet)
                 .columns(createColumnDescriptors(compiled))
-                .validateDuplicateColumnNames();
+                .validateDuplicateColumnNames()
+                // resolve column DbAttributes so the row reader factory can tell e.g. PK columns apart, the same way
+                // it can for ObjectSelect; lets pagination read the PK regardless of column order
+                .dbEntity(dbEntity);
 
         if (query.getResultColumnsTypes() != null) {
             builder.mergeColumnsWithRsMetadata();
diff --git a/cayenne/src/main/java/org/apache/cayenne/access/jdbc/reader/DefaultRowReaderFactory.java b/cayenne/src/main/java/org/apache/cayenne/access/jdbc/reader/DefaultRowReaderFactory.java
index 47bd4f07e..2db68028f 100644
--- a/cayenne/src/main/java/org/apache/cayenne/access/jdbc/reader/DefaultRowReaderFactory.java
+++ b/cayenne/src/main/java/org/apache/cayenne/access/jdbc/reader/DefaultRowReaderFactory.java
@@ -18,14 +18,20 @@
  ****************************************************************/
 package org.apache.cayenne.access.jdbc.reader;
 
+import org.apache.cayenne.CayenneRuntimeException;
 import org.apache.cayenne.access.jdbc.RSColumn;
 import org.apache.cayenne.dba.DbAdapter;
+import org.apache.cayenne.map.DbAttribute;
+import org.apache.cayenne.map.DbEntity;
+import org.apache.cayenne.map.ObjEntity;
 import org.apache.cayenne.query.EmbeddableResultSegment;
 import org.apache.cayenne.query.EntityResultSegment;
 import org.apache.cayenne.query.QueryMetadata;
 import org.apache.cayenne.query.ScalarResultSegment;
 
+import java.util.HashSet;
 import java.util.List;
+import java.util.Set;
 
 /**
  * @since 4.0
@@ -95,14 +101,15 @@ public class DefaultRowReaderFactory implements RowReaderFactory {
 
     protected RowReader<?> entitySegmentReader(RSColumn[] columns, QueryMetadata metadata, EntityResultSegment segment) {
 
-        // For a paginated query the result columns are trimmed to the root PK (see IdColumnExtractor). A single-column
-        // PK is read as a scalar (consumed by SimpleIdIncrementalFaultList); a compound PK falls through to the regular
-        // DataRow reader below, which over PK-only columns yields exactly the id map IncrementalFaultList expects.
-        if (metadata.getPageSize() > 0
-                && segment.getClassDescriptor().getEntity().getDbEntity().getPrimaryKeys().size() == 1) {
-            int pk = segment.getColumnOffset();
-            // jdbc column indexes start from 1
-            return new ScalarRowReader<>(columns[pk].reader(), pk + 1, columns[pk].rsType());
+        // For a paginated query only the root PK is read into the IncrementalFaultList - locate the PK columns within
+        // this segment by their DbAttribute (see idReader).
+        if (metadata.getPageSize() > 0) {
+            ObjEntity objEntity = segment.getClassDescriptor().getEntity();
+            return idReader(columns,
+                    segment.getColumnOffset(),
+                    segment.getColumnOffset() + columns.length,
+                    objEntity.getDbEntity(),
+                    objEntity.getName());
         }
 
         int startIndex = segment.getColumnOffset();
@@ -133,8 +140,57 @@ public class DefaultRowReaderFactory implements RowReaderFactory {
     }
 
     protected RowReader<?> noSegmentReader(RSColumn[] columns, QueryMetadata metadata) {
-        return metadata.getPageSize() > 0 && metadata.getDbEntity().getPrimaryKeys().size() == 1
-                ? new ScalarRowReader<>(columns[0].reader(), 1, columns[0].rsType())
-                : FullRowReader.of(columns, metadata);
+        if (metadata.getPageSize() > 0) {
+            ObjEntity objEntity = metadata.getObjEntity();
+            return idReader(columns, 0, columns.length, metadata.getDbEntity(),
+                    objEntity != null ? objEntity.getName() : null);
+        }
+        return FullRowReader.of(columns, metadata);
     }
+
+    private RowReader<?> idReader(RSColumn[] columns, int from, int to, DbEntity dbEntity, String entityName) {
+        if (dbEntity == null) {
+            throw new CayenneRuntimeException("Null root DbEntity, can't index PK");
+        }
+
+        int pkLen = dbEntity.getPrimaryKeys().size();
+        if (pkLen == 0) {
+            throw new CayenneRuntimeException("Root DbEntity has no PK defined: %s", dbEntity.getName());
+        }
+
+        RSColumn[] pkColumns = new RSColumn[pkLen];
+        int[] pkIndexes = new int[pkLen];
+        int found = 0;
+
+        Set<DbAttribute> seen = new HashSet<>();
+        for (int i = from; i < to && found < pkLen; i++) {
+            DbAttribute attribute = columns[i].attribute();
+            if (attribute != null && attribute.isPrimaryKey() && seen.add(attribute)) {
+                pkColumns[found] = columns[i];
+                pkIndexes[found] = i + 1;
+                found++;
+            }
+        }
+
+        if (found != pkLen) {
+            // TODO: HACK: the result columns don't carry resolvable PK DbAttributes (these are most likely aliased
+            //  EJBQL columns). Fall back to the legacy assumption that the PK is the first pkLen columns starting at
+            //  'from'.
+            if (from + pkLen > columns.length) {
+                throw new CayenneRuntimeException(
+                        "Result set for paginated query is missing PK column(s) of entity '%s'; expected %s",
+                        entityName, dbEntity.getPrimaryKeys().stream().map(DbAttribute::getName).toList());
+            }
+            for (int i = 0; i < pkLen; i++) {
+                pkColumns[i] = columns[from + i];
+                // jdbc column indexes start from 1
+                pkIndexes[i] = from + i + 1;
+            }
+        }
+
+        return pkLen == 1
+                ? new ScalarRowReader<>(pkColumns[0].reader(), pkIndexes[0], pkColumns[0].rsType())
+                : IndexRowReader.of(pkColumns, pkIndexes, entityName);
+    }
+
 }
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
new file mode 100644
index 000000000..add339f66
--- /dev/null
+++ b/cayenne/src/main/java/org/apache/cayenne/access/jdbc/reader/IndexRowReader.java
@@ -0,0 +1,69 @@
+/*****************************************************************
+ *   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.jdbc.reader;
+
+import org.apache.cayenne.CayenneRuntimeException;
+import org.apache.cayenne.DataRow;
+import org.apache.cayenne.access.jdbc.RSColumn;
+
+import java.sql.ResultSet;
+
+/**
+ * A {@link RowReader} that materializes a DataRow from a set of columns read at explicit, possibly non-contiguous,
+ * result-set positions. Unlike {@link OffsetRowReader}, each column carries its own position, so the columns need not
+ * form a contiguous run.
+ */
+class IndexRowReader implements RowReader<DataRow> {
+
+    private final RSColumn[] columns;
+    private final int[] jdbcIndexes;
+    private final String entityName;
+    private final int mapCapacity;
+
+    static RowReader<DataRow> of(RSColumn[] columns, int[] jdbcIndexes, String entityName) {
+        return new IndexRowReader(columns, jdbcIndexes, entityName);
+    }
+
+    private IndexRowReader(RSColumn[] columns, int[] jdbcIndexes, String entityName) {
+        this.columns = columns;
+        this.jdbcIndexes = jdbcIndexes;
+        this.entityName = entityName;
+        this.mapCapacity = (int) Math.ceil(columns.length / 0.75);
+    }
+
+    @Override
+    public DataRow readRow(ResultSet resultSet) {
+        DataRow dataRow = new DataRow(mapCapacity);
+
+        try {
+            for (int i = 0; i < columns.length; i++) {
+                RSColumn column = columns[i];
+                Object val = column.reader().materializeObject(resultSet, jdbcIndexes[i], column.rsType());
+                dataRow.put(column.dataRowName(), val);
+            }
+
+            dataRow.setEntityName(entityName);
+            return dataRow;
+        } catch (CayenneRuntimeException ex) {
+            throw ex;
+        } catch (Exception ex) {
+            throw new CayenneRuntimeException("Exception materializing column.", ex);
+        }
+    }
+}
diff --git a/cayenne/src/test/java/org/apache/cayenne/CayenneCompoundIT.java b/cayenne/src/test/java/org/apache/cayenne/CayenneCompoundIT.java
index 421ab68f7..145d14a50 100644
--- a/cayenne/src/test/java/org/apache/cayenne/CayenneCompoundIT.java
+++ b/cayenne/src/test/java/org/apache/cayenne/CayenneCompoundIT.java
@@ -20,8 +20,11 @@
 package org.apache.cayenne;
 
 import org.apache.cayenne.exp.property.PropertyFactory;
+import org.apache.cayenne.query.CapsStrategy;
 import org.apache.cayenne.query.EJBQLQuery;
 import org.apache.cayenne.query.ObjectSelect;
+import org.apache.cayenne.query.SQLSelect;
+import org.apache.cayenne.query.SQLTemplate;
 import org.apache.cayenne.test.jdbc.TableHelper;
 import org.apache.cayenne.testdo.compound.CharPkTestEntity;
 import org.apache.cayenne.testdo.compound.CompoundPkTestEntity;
@@ -179,6 +182,49 @@ public class CayenneCompoundIT {
         assertEquals(4, queriesCount);
     }
 
+    @Test
+    public void pageSizeCompoundPkSQLTemplate() throws Exception {
+        createCompoundPKs(20);
+
+        int pageSize = 7;
+
+        // the compound PK columns (KEY1, KEY2) are NOT the first selected columns and are read as a
+        // DataRow id map by the paginated id reader
+        SQLTemplate query = new SQLTemplate(CompoundPkTestEntity.class,
+                "SELECT NAME, KEY1, KEY2 FROM COMPOUND_PK_TEST ORDER BY NAME");
+        query.setColumnNamesCapitalization(CapsStrategy.UPPER);
+        query.setPageSize(pageSize);
+
+        List<?> result = env.context().performQuery(query);
+        assertEquals(20, result.size());
+
+        // resolve every page, including subsequent pages faulted in by compound id
+        for (int i = 0; i < result.size(); i++) {
+            CompoundPkTestEntity e = (CompoundPkTestEntity) result.get(i);
+            assertEquals(String.format("BBB%02d", i), e.getName());
+        }
+    }
+
+    @Test
+    public void pageSizeCompoundPkSQLSelect() throws Exception {
+        createCompoundPKs(20);
+
+        int pageSize = 7;
+
+        // SQLSelect builds a replacement SQLTemplate, so it exercises the same compound-PK paginated id reader
+        SQLSelect<CompoundPkTestEntity> query = SQLSelect
+                .query(CompoundPkTestEntity.class, "SELECT NAME, KEY1, KEY2 FROM COMPOUND_PK_TEST ORDER BY NAME")
+                .columnNameCaps(CapsStrategy.UPPER)
+                .pageSize(pageSize);
+
+        List<CompoundPkTestEntity> result = query.select(env.context());
+        assertEquals(20, result.size());
+
+        for (int i = 0; i < result.size(); i++) {
+            assertEquals(String.format("BBB%02d", i), result.get(i).getName());
+        }
+    }
+
     @Test
     public void ejbqlCountSelect() throws Exception {
         tCompoundIntPKTest.insert(1, 2, "test");
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 28eaccd7e..516118393 100644
--- a/cayenne/src/test/java/org/apache/cayenne/access/DataContextIT.java
+++ b/cayenne/src/test/java/org/apache/cayenne/access/DataContextIT.java
@@ -443,7 +443,7 @@ public class DataContextIT {
 	}
 
 	@Test
-	public void performPaginatedQuery1() throws Exception {
+	public void performPaginatedQuery_EJBQL() throws Exception {
 		createArtistsDataSet();
 
 		EJBQLQuery query = new EJBQLQuery("select a FROM Artist a");
diff --git a/cayenne/src/test/java/org/apache/cayenne/access/DataContextSQLTemplateIT.java b/cayenne/src/test/java/org/apache/cayenne/access/DataContextSQLTemplateIT.java
index 903b4eb07..942cb5a3d 100644
--- a/cayenne/src/test/java/org/apache/cayenne/access/DataContextSQLTemplateIT.java
+++ b/cayenne/src/test/java/org/apache/cayenne/access/DataContextSQLTemplateIT.java
@@ -27,6 +27,7 @@ import org.apache.cayenne.map.DataMap;
 import org.apache.cayenne.map.EntityResult;
 import org.apache.cayenne.map.SQLResult;
 import org.apache.cayenne.query.CapsStrategy;
+import org.apache.cayenne.query.SQLSelect;
 import org.apache.cayenne.query.SQLTemplate;
 import org.apache.cayenne.test.jdbc.TableHelper;
 import org.apache.cayenne.testdo.testmap.Artist;
@@ -496,6 +497,50 @@ public class DataContextSQLTemplateIT {
 		assertEquals("artist" + (pageSize + 2), artist.getArtistName());
 	}
 
+	@Test
+	public void pageSizePkNotFirstColumn() throws Exception {
+		createFourArtists();
+
+		int pageSize = 3;
+
+		// the PK is the SECOND result column - exercises the paginated id reader's column indexing
+		String template = "SELECT ARTIST_NAME, ARTIST_ID FROM ARTIST ORDER BY ARTIST_ID";
+		SQLTemplate query = sqlTemplateCustomizer.createSQLTemplate(Artist.class, template);
+
+		query.setPageSize(pageSize);
+
+		List<?> objects = context.performQuery(query);
+
+		assertEquals(4, objects.size());
+
+		// resolving the first page must materialize the right rows by their actual PK
+		assertTrue(objects.get(0) instanceof Artist);
+		assertEquals("artist2", ((Artist) objects.get(0)).getArtistName());
+
+		// resolving a subsequent page exercises id-to-object resolution from the stored ids
+		Artist artist = (Artist) objects.get(pageSize);
+		assertEquals("artist" + (pageSize + 2), artist.getArtistName());
+	}
+
+	@Test
+	public void pageSizeSQLSelectPkNotFirstColumn() throws Exception {
+		createFourArtists();
+
+		int pageSize = 3;
+
+		// SQLSelect builds a replacement SQLTemplate, so it goes through the same paginated id reader
+		SQLSelect<Artist> query = SQLSelect
+				.query(Artist.class, "SELECT ARTIST_NAME, ARTIST_ID FROM ARTIST ORDER BY ARTIST_ID")
+				.columnNameCaps(CapsStrategy.UPPER)
+				.pageSize(pageSize);
+
+		List<Artist> objects = query.select(context);
+
+		assertEquals(4, objects.size());
+		assertEquals("artist2", objects.get(0).getArtistName());
+		assertEquals("artist" + (pageSize + 2), objects.get(pageSize).getArtistName());
+	}
+
 	@Test
 	public void iteratedQuery() throws Exception {
 		createFourArtists();