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

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


The following commit(s) were added to refs/heads/past-M2 by this push:
     new 5c22b2d47 CAY-2954 Do not wrap selecting queries in transactions
5c22b2d47 is described below

commit 5c22b2d478cac99b5a014083b7827ca75588416a
Author: Andrus Adamchik <[email protected]>
AuthorDate: Sat Jun 13 13:53:49 2026 -0400

    CAY-2954 Do not wrap selecting queries in transactions
---
 RELEASE-NOTES.txt                                  |   1 +
 .../cayenne/access/DataDomainQueryAction.java      |  16 ++-
 .../apache/cayenne/access/DataNodeQueryAction.java |  14 +--
 .../apache/cayenne/access/jdbc/SelectAction.java   |   7 +-
 .../java/org/apache/cayenne/dba/AutoAdapter.java   |   1 +
 .../cayenne/dba/postgres/PostgresSelectAction.java |  56 +++++++++
 .../org/apache/cayenne/query/ObjectIdQuery.java    |   5 +
 .../apache/cayenne/query/ObjectSelectMetadata.java |   8 ++
 .../org/apache/cayenne/query/QueryMetadata.java    |  11 ++
 .../apache/cayenne/query/QueryMetadataProxy.java   |   5 +
 .../apache/cayenne/query/RelationshipQuery.java    |   5 +
 .../org/apache/cayenne/tx/BaseTransaction.java     |   6 +-
 .../cayenne/tx/DefaultTransactionFactory.java      |   5 -
 .../cayenne/tx/DefaultTransactionManager.java      |  20 +++-
 .../org/apache/cayenne/tx/ReadOnlyTransaction.java | 130 +++++++++++++++++++++
 .../org/apache/cayenne/tx/TransactionFactory.java  |   5 +-
 .../org/apache/cayenne/tx/TransactionManager.java  |  15 +++
 .../apache/cayenne/tx/TransactionPropagation.java  |   6 +-
 .../apache/cayenne/access/DataContextBlobIT.java   |  25 +++-
 .../apache/cayenne/query/ObjectIdQueryTest.java    |   5 +
 .../org/apache/cayenne/query/ObjectSelectTest.java |   7 ++
 .../org/apache/cayenne/query/SQLSelectTest.java    |   6 +
 .../org/apache/cayenne/query/SQLTemplateTest.java  |   8 ++
 .../apache/cayenne/tx/ReadOnlyTransactionTest.java |  86 ++++++++++++++
 24 files changed, 417 insertions(+), 36 deletions(-)

diff --git a/RELEASE-NOTES.txt b/RELEASE-NOTES.txt
index 62c80db7e..cc06801ef 100644
--- a/RELEASE-NOTES.txt
+++ b/RELEASE-NOTES.txt
@@ -13,6 +13,7 @@ Date:
 ----------------------------------
 Changes/New Features:
 
+CAY-2954 Don't wrap selecting queries in transactions
 CAY-2956 Get rid of a dedicated adapter for Oracle 8
 CAY-2957 Get rid of adapter for legacy HSQLDB <= 1.8
 CAY-2963 Replace TypesHandler / types.xml with hardcoded map
diff --git 
a/cayenne/src/main/java/org/apache/cayenne/access/DataDomainQueryAction.java 
b/cayenne/src/main/java/org/apache/cayenne/access/DataDomainQueryAction.java
index 0d0719f5b..fd1dd1475 100644
--- a/cayenne/src/main/java/org/apache/cayenne/access/DataDomainQueryAction.java
+++ b/cayenne/src/main/java/org/apache/cayenne/access/DataDomainQueryAction.java
@@ -54,6 +54,7 @@ import org.apache.cayenne.query.RelationshipQuery;
 import org.apache.cayenne.reflect.ClassDescriptor;
 import org.apache.cayenne.reflect.LifecycleCallbackRegistry;
 import org.apache.cayenne.tx.BaseTransaction;
+import org.apache.cayenne.tx.ReadOnlyTransaction;
 import org.apache.cayenne.tx.Transaction;
 import org.apache.cayenne.util.GenericResponse;
 import org.apache.cayenne.util.ListResponse;
@@ -483,10 +484,17 @@ class DataDomainQueryAction implements QueryRouter, 
OperationObserver {
      * Gets response from the underlying DataNodes.
      */
     void runQueryInTransaction() {
-        domain.getTransactionManager().performInTransaction(() -> {
-            runQuery();
-            return null;
-        });
+        if (metadata.isReadOnly() && BaseTransaction.getThreadTransaction() == 
null) {
+            domain.getTransactionManager().performInTransaction(() -> {
+                runQuery();
+                return null;
+            }, descriptor -> new ReadOnlyTransaction());
+        } else {
+            domain.getTransactionManager().performInTransaction(() -> {
+                runQuery();
+                return null;
+            });
+        }
     }
 
     private void runQuery() {
diff --git 
a/cayenne/src/main/java/org/apache/cayenne/access/DataNodeQueryAction.java 
b/cayenne/src/main/java/org/apache/cayenne/access/DataNodeQueryAction.java
index a7787b795..a071a92c6 100644
--- a/cayenne/src/main/java/org/apache/cayenne/access/DataNodeQueryAction.java
+++ b/cayenne/src/main/java/org/apache/cayenne/access/DataNodeQueryAction.java
@@ -19,20 +19,19 @@
 
 package org.apache.cayenne.access;
 
-import java.sql.Connection;
-import java.sql.SQLException;
-import java.util.List;
-
 import org.apache.cayenne.ObjectId;
 import org.apache.cayenne.ResultIterator;
 import org.apache.cayenne.query.Query;
 import org.apache.cayenne.query.SQLAction;
 
+import java.sql.Connection;
+import java.util.List;
+
 /**
  * A helper that executes a sequence of queries, providing correct mapping of 
the results
  * to the original query. Note that this class is not thread-safe as it stores 
current
  * query execution state.
- * 
+ *
  * @since 1.2
  */
 class DataNodeQueryAction {
@@ -45,8 +44,7 @@ class DataNodeQueryAction {
         this.node = node;
     }
 
-    public void runQuery(Connection connection, final Query originalQuery)
-            throws SQLException, Exception {
+    public void runQuery(Connection connection, Query originalQuery) throws 
Exception {
 
         // wrap to ensure that the result is mapped back to the original 
query, even if
         // the underlying SQLAction uses query substitute...
@@ -71,7 +69,7 @@ class DataNodeQueryAction {
             public void nextRows(Query q, ResultIterator it) {
                 observer.nextRows(originalQuery, it);
             }
-            
+
             @Override
             public void nextGeneratedRows(Query query, ResultIterator<?> keys, 
List<ObjectId> idsToUpdate) {
                 observer.nextGeneratedRows(originalQuery, keys, idsToUpdate);
diff --git 
a/cayenne/src/main/java/org/apache/cayenne/access/jdbc/SelectAction.java 
b/cayenne/src/main/java/org/apache/cayenne/access/jdbc/SelectAction.java
index e3e2b5aeb..acaaf155b 100644
--- a/cayenne/src/main/java/org/apache/cayenne/access/jdbc/SelectAction.java
+++ b/cayenne/src/main/java/org/apache/cayenne/access/jdbc/SelectAction.java
@@ -75,12 +75,15 @@ public class SelectAction extends BaseSQLAction {
 
     @Override
     public void performAction(Connection connection, OperationObserver 
observer) throws Exception {
+        TranslatedSelect translated = 
dataNode.getSelectTranslator().translate(query, dataNode.getAdapter(), 
dataNode.getEntityResolver());
+        performAction(connection, observer, translated);
+    }
+
+    protected void performAction(Connection connection, OperationObserver 
observer, TranslatedSelect translated) throws Exception {
 
         long t1 = System.currentTimeMillis();
 
         JdbcEventLogger logger = dataNode.getJdbcEventLogger();
-        TranslatedSelect translated = dataNode.getSelectTranslator()
-                .translate(query, dataNode.getAdapter(), 
dataNode.getEntityResolver());
 
         logger.logQuery(translated.sql(), translated.bindings());
 
diff --git a/cayenne/src/main/java/org/apache/cayenne/dba/AutoAdapter.java 
b/cayenne/src/main/java/org/apache/cayenne/dba/AutoAdapter.java
index 9f270bd9c..b67f80022 100644
--- a/cayenne/src/main/java/org/apache/cayenne/dba/AutoAdapter.java
+++ b/cayenne/src/main/java/org/apache/cayenne/dba/AutoAdapter.java
@@ -152,6 +152,7 @@ public class AutoAdapter implements DbAdapter {
                return getAdapter().supportsGeneratedKeysForBatchInserts();
        }
 
+
        @Override
        public boolean supportsBatchUpdates() {
                return getAdapter().supportsBatchUpdates();
diff --git 
a/cayenne/src/main/java/org/apache/cayenne/dba/postgres/PostgresSelectAction.java
 
b/cayenne/src/main/java/org/apache/cayenne/dba/postgres/PostgresSelectAction.java
index 8e93faf9e..0edc106ad 100644
--- 
a/cayenne/src/main/java/org/apache/cayenne/dba/postgres/PostgresSelectAction.java
+++ 
b/cayenne/src/main/java/org/apache/cayenne/dba/postgres/PostgresSelectAction.java
@@ -19,9 +19,17 @@
 package org.apache.cayenne.dba.postgres;
 
 import org.apache.cayenne.access.DataNode;
+import org.apache.cayenne.access.OperationObserver;
+import org.apache.cayenne.access.jdbc.ColumnDescriptor;
 import org.apache.cayenne.access.jdbc.SelectAction;
+import org.apache.cayenne.access.translator.ParameterBinding;
+import org.apache.cayenne.access.translator.select.TranslatedSelect;
 import org.apache.cayenne.query.Select;
 
+import java.sql.Connection;
+import java.sql.SQLException;
+import java.sql.Types;
+
 /**
  * @since 3.0
  */
@@ -35,4 +43,52 @@ class PostgresSelectAction extends SelectAction {
        protected int getInMemoryOffset(int queryOffset) {
                return 0;
        }
+
+       @Override
+       protected void performAction(Connection connection, OperationObserver 
observer, TranslatedSelect translated) throws Exception {
+
+               if (!connection.getAutoCommit() || 
!readsLargeObjects(translated)) {
+                       super.performAction(connection, observer, translated);
+                       return;
+               }
+
+               // manual tx management for reading LOBs
+               connection.setAutoCommit(false);
+               try {
+                       super.performAction(connection, observer, translated);
+                       connection.commit();
+               } catch (Exception e) {
+                       try {
+                               connection.rollback();
+                       } catch (SQLException ignored) {
+                               // connection is being returned/closed anyway
+                       }
+                       throw e;
+               } finally {
+                       try {
+                               connection.setAutoCommit(true);
+                       } catch (SQLException ignored) {
+                               // connection is being returned/closed anyway
+                       }
+               }
+       }
+
+       private static boolean readsLargeObjects(TranslatedSelect translated) {
+               for (ColumnDescriptor column : translated.resultColumns()) {
+                       if (isLargeObject(column.getJdbcType())) {
+                               return true;
+                       }
+               }
+               // a large object bound as a parameter (e.g. in a qualifier) 
also needs a transaction
+               for (ParameterBinding binding : translated.bindings()) {
+                       if (isLargeObject(binding.getJdbcType())) {
+                               return true;
+                       }
+               }
+               return false;
+       }
+
+       private static boolean isLargeObject(int jdbcType) {
+               return jdbcType == Types.BLOB || jdbcType == Types.CLOB || 
jdbcType == Types.NCLOB;
+       }
 }
diff --git a/cayenne/src/main/java/org/apache/cayenne/query/ObjectIdQuery.java 
b/cayenne/src/main/java/org/apache/cayenne/query/ObjectIdQuery.java
index 9ba51d25f..ef7bf1134 100644
--- a/cayenne/src/main/java/org/apache/cayenne/query/ObjectIdQuery.java
+++ b/cayenne/src/main/java/org/apache/cayenne/query/ObjectIdQuery.java
@@ -105,6 +105,11 @@ public class ObjectIdQuery extends IndirectQuery {
                 public boolean isFetchingDataRows() {
                     return fetchingDataRows;
                 }
+
+                @Override
+                public boolean isReadOnly() {
+                    return true;
+                }
             };
 
             this.metadataResolver = resolver;
diff --git 
a/cayenne/src/main/java/org/apache/cayenne/query/ObjectSelectMetadata.java 
b/cayenne/src/main/java/org/apache/cayenne/query/ObjectSelectMetadata.java
index 451480ea7..4d6c6dd87 100644
--- a/cayenne/src/main/java/org/apache/cayenne/query/ObjectSelectMetadata.java
+++ b/cayenne/src/main/java/org/apache/cayenne/query/ObjectSelectMetadata.java
@@ -39,6 +39,14 @@ class ObjectSelectMetadata extends BaseQueryMetadata {
 
        protected Map<String, String> pathSplitAliases;
 
+       /**
+        * @since 5.0
+        */
+       @Override
+       public boolean isReadOnly() {
+               return true;
+       }
+
        @Override
        void copyFromInfo(QueryMetadata info) {
                super.copyFromInfo(info);
diff --git a/cayenne/src/main/java/org/apache/cayenne/query/QueryMetadata.java 
b/cayenne/src/main/java/org/apache/cayenne/query/QueryMetadata.java
index 412a104ac..262c24b85 100644
--- a/cayenne/src/main/java/org/apache/cayenne/query/QueryMetadata.java
+++ b/cayenne/src/main/java/org/apache/cayenne/query/QueryMetadata.java
@@ -264,4 +264,15 @@ public interface QueryMetadata {
     default Function<?, ?> getResultMapper() {
         return null;
     }
+
+    /**
+     * Returns true if this is a read-only (selecting) query that does not 
need to run in its own
+     * transaction. Defaults to false, so any query not explicitly marked 
read-only is still wrapped
+     * in a transaction.
+     *
+     * @since 5.0
+     */
+    default boolean isReadOnly() {
+        return false;
+    }
 }
diff --git 
a/cayenne/src/main/java/org/apache/cayenne/query/QueryMetadataProxy.java 
b/cayenne/src/main/java/org/apache/cayenne/query/QueryMetadataProxy.java
index b39b1776f..403ba1b6f 100644
--- a/cayenne/src/main/java/org/apache/cayenne/query/QueryMetadataProxy.java
+++ b/cayenne/src/main/java/org/apache/cayenne/query/QueryMetadataProxy.java
@@ -141,4 +141,9 @@ public class QueryMetadataProxy implements QueryMetadata {
     public boolean isSuppressingDistinct() {
         return mdDelegate.isSuppressingDistinct();
     }
+
+    @Override
+    public boolean isReadOnly() {
+        return mdDelegate.isReadOnly();
+    }
 }
diff --git 
a/cayenne/src/main/java/org/apache/cayenne/query/RelationshipQuery.java 
b/cayenne/src/main/java/org/apache/cayenne/query/RelationshipQuery.java
index bcb6bd6de..786086baf 100644
--- a/cayenne/src/main/java/org/apache/cayenne/query/RelationshipQuery.java
+++ b/cayenne/src/main/java/org/apache/cayenne/query/RelationshipQuery.java
@@ -173,6 +173,11 @@ public class RelationshipQuery extends IndirectQuery {
                 public int getStatementFetchSize() {
                     return statementFetchSize;
                 }
+
+                @Override
+                public boolean isReadOnly() {
+                    return true;
+                }
             };
 
             this.metadataResolver = resolver;
diff --git a/cayenne/src/main/java/org/apache/cayenne/tx/BaseTransaction.java 
b/cayenne/src/main/java/org/apache/cayenne/tx/BaseTransaction.java
index 989dc88d0..d4b8b2f30 100644
--- a/cayenne/src/main/java/org/apache/cayenne/tx/BaseTransaction.java
+++ b/cayenne/src/main/java/org/apache/cayenne/tx/BaseTransaction.java
@@ -19,6 +19,8 @@
 
 package org.apache.cayenne.tx;
 
+import org.apache.cayenne.CayenneRuntimeException;
+
 import javax.sql.DataSource;
 import java.sql.Connection;
 import java.sql.SQLException;
@@ -28,11 +30,7 @@ import java.util.HashMap;
 import java.util.LinkedHashSet;
 import java.util.Map;
 
-import org.apache.cayenne.CayenneRuntimeException;
-
 /**
- * A Cayenne transaction. Currently supports managing JDBC connections.
- *
  * @since 4.0
  */
 public abstract class BaseTransaction implements Transaction {
diff --git 
a/cayenne/src/main/java/org/apache/cayenne/tx/DefaultTransactionFactory.java 
b/cayenne/src/main/java/org/apache/cayenne/tx/DefaultTransactionFactory.java
index 88fbd3acd..0b2646dc1 100644
--- a/cayenne/src/main/java/org/apache/cayenne/tx/DefaultTransactionFactory.java
+++ b/cayenne/src/main/java/org/apache/cayenne/tx/DefaultTransactionFactory.java
@@ -36,11 +36,6 @@ public class DefaultTransactionFactory implements 
TransactionFactory {
         this.jdbcEventLogger = jdbcEventLogger;
     }
 
-    @Override
-    public Transaction createTransaction() {
-        return createTransaction(TransactionDescriptor.defaultDescriptor());
-    }
-
     /**
      * @since 4.1
      */
diff --git 
a/cayenne/src/main/java/org/apache/cayenne/tx/DefaultTransactionManager.java 
b/cayenne/src/main/java/org/apache/cayenne/tx/DefaultTransactionManager.java
index 3545ab815..6a8fc0005 100644
--- a/cayenne/src/main/java/org/apache/cayenne/tx/DefaultTransactionManager.java
+++ b/cayenne/src/main/java/org/apache/cayenne/tx/DefaultTransactionManager.java
@@ -58,8 +58,18 @@ public class DefaultTransactionManager implements 
TransactionManager {
      */
     @Override
     public <T> T performInTransaction(TransactionalOperation<T> op, 
TransactionListener callback, TransactionDescriptor descriptor) {
-        BaseTransactionHandler handler = getHandler(descriptor);
-        return handler.handle(op, callback, descriptor);
+        return getHandler(descriptor).handle(op, callback, descriptor);
+    }
+
+    /**
+     * @since 5.0
+     */
+    @Override
+    public <T> T performInTransaction(TransactionalOperation<T> op, 
TransactionFactory transactionFactory) {
+        // TODO: feels kinda dirty to bypass the normal handler flow. Probably 
an indicator that handlers need to be
+        //  refactored
+        return new NestedTransactionHandler(transactionFactory, 
jdbcEventLogger)
+                .handle(op, DoNothingTransactionListener.getInstance(), 
TransactionDescriptor.defaultDescriptor());
     }
 
     protected BaseTransactionHandler getHandler(TransactionDescriptor 
descriptor) {
@@ -84,7 +94,7 @@ public class DefaultTransactionManager implements 
TransactionManager {
         @Override
         protected <T> T handle(TransactionalOperation<T> op, 
TransactionListener callback, TransactionDescriptor descriptor) {
             Transaction currentTx = BaseTransaction.getThreadTransaction();
-            if(currentTx != null) {
+            if (currentTx != null) {
                 return performInTransaction(currentTx, op, callback);
             } else {
                 return performInNewTransaction(op, callback, descriptor);
@@ -101,7 +111,7 @@ public class DefaultTransactionManager implements 
TransactionManager {
         @Override
         protected <T> T handle(TransactionalOperation<T> op, 
TransactionListener callback, TransactionDescriptor descriptor) {
             Transaction currentTx = BaseTransaction.getThreadTransaction();
-            if(currentTx == null) {
+            if (currentTx == null) {
                 throw new CayenneRuntimeException("Transaction operation 
should join to existing transaction but none found.");
             }
             return performInTransaction(currentTx, op, callback);
@@ -120,7 +130,7 @@ public class DefaultTransactionManager implements 
TransactionManager {
             try {
                 return performInNewTransaction(op, callback, descriptor);
             } finally {
-                if(currentTx != null) {
+                if (currentTx != null) {
                     // restore old transaction, if where set
                     BaseTransaction.bindThreadTransaction(currentTx);
                 }
diff --git 
a/cayenne/src/main/java/org/apache/cayenne/tx/ReadOnlyTransaction.java 
b/cayenne/src/main/java/org/apache/cayenne/tx/ReadOnlyTransaction.java
new file mode 100644
index 000000000..950e63c5a
--- /dev/null
+++ b/cayenne/src/main/java/org/apache/cayenne/tx/ReadOnlyTransaction.java
@@ -0,0 +1,130 @@
+/*****************************************************************
+ *   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.tx;
+
+import javax.sql.DataSource;
+import java.sql.Connection;
+import java.sql.SQLException;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * A minimal {@link Transaction} for read-only (selecting) queries that run 
outside a managed
+ * transaction. It is not a real transaction - it exists only so that a 
DataNode has a thread-bound
+ * transaction to acquire and release its connection through, and it keeps 
that connection in
+ * autocommit mode (each select commits on its own, which is optimal for 
reads). It never commits or
+ * rolls back, and fires no {@link TransactionListener} events.
+ * <p>
+ * Reads that require a real transaction (e.g. materializing PostgreSQL {@code 
oid}/BLOB large
+ * objects) are not routed here - they use a {@link CayenneTransaction}.
+ *
+ * @since 5.0
+ */
+public class ReadOnlyTransaction implements Transaction {
+
+    private Map<String, Connection> connections;
+    private boolean rollbackOnly;
+
+    @Override
+    public Connection getOrCreateConnection(String connectionName, DataSource 
dataSource) throws SQLException {
+        if (connections == null) {
+            connections = new HashMap<>();
+        }
+
+        Connection c = connections.get(connectionName);
+        if (c == null || c.isClosed()) {
+            c = createConnection(dataSource);
+            connections.put(connectionName, c);
+        }
+
+        // prevent callers from closing the connection - this transaction 
closes it on commit/rollback
+        return new TransactionConnectionDecorator(c);
+    }
+
+    private Connection createConnection(DataSource dataSource) throws 
SQLException {
+        Connection c = dataSource.getConnection();
+
+        // Auto commit saves time on explicit commit to DB, reducing the 
overall load and increasing throughput.
+        // This is the main reason we have ReadOnlyTransaction.
+        if (!c.getAutoCommit()) {
+            c.setAutoCommit(true);
+        }
+
+        return c;
+    }
+
+    @Override
+    public void commit() {
+        closeConnections();
+    }
+
+    @Override
+    public void rollback() {
+        closeConnections();
+    }
+
+    private void closeConnections() {
+        if (connections == null) {
+            return;
+        }
+
+        for (Connection c : connections.values()) {
+            try {
+                c.close();
+            } catch (SQLException ignored) {
+                // returning the connection to the pool; nothing useful to do 
on failure
+            }
+        }
+        connections = null;
+    }
+
+    @Override
+    public Map<String, Connection> getConnections() {
+        return connections != null ? Collections.unmodifiableMap(connections) 
: Collections.emptyMap();
+    }
+
+    @Override
+    public void begin() {
+        // no real transaction is started
+    }
+
+    @Override
+    public void setRollbackOnly() {
+        // tracked so the TransactionManager closes the connection via 
rollback() on the error path;
+        // there is no actual DB transaction to roll back
+        this.rollbackOnly = true;
+    }
+
+    @Override
+    public boolean isRollbackOnly() {
+        return rollbackOnly;
+    }
+
+    @Override
+    public void addListener(TransactionListener listener) {
+        // read-only queries intentionally fire no transaction events
+    }
+
+    @Override
+    public boolean isExternal() {
+        return false;
+    }
+}
diff --git 
a/cayenne/src/main/java/org/apache/cayenne/tx/TransactionFactory.java 
b/cayenne/src/main/java/org/apache/cayenne/tx/TransactionFactory.java
index 8e13af701..c064985bc 100644
--- a/cayenne/src/main/java/org/apache/cayenne/tx/TransactionFactory.java
+++ b/cayenne/src/main/java/org/apache/cayenne/tx/TransactionFactory.java
@@ -22,9 +22,12 @@ package org.apache.cayenne.tx;
 /**
  * @since 4.0
  */
+@FunctionalInterface
 public interface TransactionFactory {
 
-    Transaction createTransaction();
+    default Transaction createTransaction() {
+        return createTransaction(TransactionDescriptor.defaultDescriptor());
+    }
 
     /**
      *
diff --git 
a/cayenne/src/main/java/org/apache/cayenne/tx/TransactionManager.java 
b/cayenne/src/main/java/org/apache/cayenne/tx/TransactionManager.java
index 355743b40..ff1ca8262 100644
--- a/cayenne/src/main/java/org/apache/cayenne/tx/TransactionManager.java
+++ b/cayenne/src/main/java/org/apache/cayenne/tx/TransactionManager.java
@@ -78,4 +78,19 @@ public interface TransactionManager {
      * @since 4.1
      */
     <T> T performInTransaction(TransactionalOperation<T> op, 
TransactionListener callback, TransactionDescriptor descriptor);
+
+    /**
+     * Performs an operation in a transaction created by the supplied factory, 
instead of the
+     * configured one. This lets callers run an operation in a special 
transaction, such as a
+     * {@link ReadOnlyTransaction}. Behaves like {@link 
TransactionPropagation#NESTED} - joining an
+     * existing transaction if one is bound, otherwise creating a new one from 
the given factory.
+     *
+     * @param op                 an operation to perform within the 
transaction.
+     * @param transactionFactory a factory for the transaction to run the 
operation in.
+     * @param <T> returned value type
+     * @return a value returned by the "op" operation.
+     *
+     * @since 5.0
+     */
+    <T> T performInTransaction(TransactionalOperation<T> op, 
TransactionFactory transactionFactory);
 }
diff --git 
a/cayenne/src/main/java/org/apache/cayenne/tx/TransactionPropagation.java 
b/cayenne/src/main/java/org/apache/cayenne/tx/TransactionPropagation.java
index 18aabdf1f..c49ef6f3d 100644
--- a/cayenne/src/main/java/org/apache/cayenne/tx/TransactionPropagation.java
+++ b/cayenne/src/main/java/org/apache/cayenne/tx/TransactionPropagation.java
@@ -20,17 +20,17 @@
 package org.apache.cayenne.tx;
 
 /**
- * Propagation behaviour of transaction
+ * Propagation behavior of transaction
  */
 public enum TransactionPropagation {
+
     /**
      * Support a current transaction, throw an exception if none exists.
      */
     MANDATORY,
 
     /**
-     * Execute within a nested transaction if a current transaction exists,
-     * create a new one if none exists.
+     * Execute within a nested transaction if a current transaction exists, 
create a new one if none exists.
      */
     NESTED,
 
diff --git 
a/cayenne/src/test/java/org/apache/cayenne/access/DataContextBlobIT.java 
b/cayenne/src/test/java/org/apache/cayenne/access/DataContextBlobIT.java
index baa11815c..9efe6359f 100644
--- a/cayenne/src/test/java/org/apache/cayenne/access/DataContextBlobIT.java
+++ b/cayenne/src/test/java/org/apache/cayenne/access/DataContextBlobIT.java
@@ -19,8 +19,6 @@
 
 package org.apache.cayenne.access;
 
-import java.util.List;
-
 import org.apache.cayenne.access.types.ByteArrayTypeTest;
 import org.apache.cayenne.query.ObjectSelect;
 import org.apache.cayenne.testdo.lob.BlobTestEntity;
@@ -30,8 +28,9 @@ import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.extension.RegisterExtension;
 
-import static org.junit.jupiter.api.Assertions.assertEquals;
-import static org.junit.jupiter.api.Assertions.assertNull;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.*;
 
 public class DataContextBlobIT {
 
@@ -115,6 +114,24 @@ public class DataContextBlobIT {
         runWithBlobSize(1024 * 1024);
     }
 
+    @Test
+    public void selectWithBlobInQualifier() {
+        if (skipTests()) {
+            return;
+        }
+
+        byte[] bytes = {'a', 'b', 'c', 'd'};
+        BlobTestEntity blob = context.newObject(BlobTestEntity.class);
+        blob.setBlobCol(bytes);
+        context.commitChanges();
+
+        // TODO: change this to a real query one PostgreSQLAdapter start 
supporting LOB-based criteria (that will require
+        //  a special SQL syntax).
+        assertDoesNotThrow(() -> ObjectSelect.query(BlobTestEntity.class)
+                .where(BlobTestEntity.BLOB_COL.eq(bytes))
+                .selectCount(context2));
+    }
+
     @Test
     public void nullBlob() throws Exception {
         if (skipTests()) {
diff --git 
a/cayenne/src/test/java/org/apache/cayenne/query/ObjectIdQueryTest.java 
b/cayenne/src/test/java/org/apache/cayenne/query/ObjectIdQueryTest.java
index 8f594adf4..2443e9a34 100644
--- a/cayenne/src/test/java/org/apache/cayenne/query/ObjectIdQueryTest.java
+++ b/cayenne/src/test/java/org/apache/cayenne/query/ObjectIdQueryTest.java
@@ -94,4 +94,9 @@ public class ObjectIdQueryTest {
         QueryMetadata md3 = q3.getMetaData(null);
         assertFalse(md3.isFetchingDataRows());
     }
+
+    @Test
+    public void readOnlyMetadata() {
+        assertTrue(new ObjectIdQuery(ObjectId.of("abc", "a", 
1)).getMetaData(null).isReadOnly());
+    }
 }
diff --git 
a/cayenne/src/test/java/org/apache/cayenne/query/ObjectSelectTest.java 
b/cayenne/src/test/java/org/apache/cayenne/query/ObjectSelectTest.java
index bac7ccc3f..2808b9d27 100644
--- a/cayenne/src/test/java/org/apache/cayenne/query/ObjectSelectTest.java
+++ b/cayenne/src/test/java/org/apache/cayenne/query/ObjectSelectTest.java
@@ -25,10 +25,12 @@ import java.util.Collections;
 import org.apache.cayenne.DataRow;
 import org.apache.cayenne.exp.Expression;
 import org.apache.cayenne.exp.ExpressionFactory;
+import org.apache.cayenne.map.EntityResolver;
 import org.apache.cayenne.testdo.testmap.Artist;
 import org.junit.jupiter.api.Test;
 
 import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.mock;
 
 public class ObjectSelectTest {
 
@@ -435,4 +437,9 @@ public class ObjectSelectTest {
                assertEquals(2, query.getQueryTimeout());
        }
 
+       @Test
+       public void readOnlyMetadata() {
+               
assertTrue(ObjectSelect.query(Artist.class).getMetaData(mock(EntityResolver.class)).isReadOnly());
+       }
+
 }
diff --git a/cayenne/src/test/java/org/apache/cayenne/query/SQLSelectTest.java 
b/cayenne/src/test/java/org/apache/cayenne/query/SQLSelectTest.java
index 646f2e982..027e44d2c 100644
--- a/cayenne/src/test/java/org/apache/cayenne/query/SQLSelectTest.java
+++ b/cayenne/src/test/java/org/apache/cayenne/query/SQLSelectTest.java
@@ -119,4 +119,10 @@ public class SQLSelectTest {
                assertNotEquals(md1.getCacheKey(), md2.getCacheKey());
                assertNotEquals(md3.getCacheKey(), md2.getCacheKey());
        }
+
+       @Test
+       public void notReadOnlyMetadata() {
+               // raw SQL is opaque, so SQLSelect keeps the regular transaction
+               assertFalse(SQLSelect.dataRowQuery("SELECT * FROM 
X").getMetaData(mock(EntityResolver.class)).isReadOnly());
+       }
 }
diff --git 
a/cayenne/src/test/java/org/apache/cayenne/query/SQLTemplateTest.java 
b/cayenne/src/test/java/org/apache/cayenne/query/SQLTemplateTest.java
index 2b5969819..95d1212e7 100644
--- a/cayenne/src/test/java/org/apache/cayenne/query/SQLTemplateTest.java
+++ b/cayenne/src/test/java/org/apache/cayenne/query/SQLTemplateTest.java
@@ -19,16 +19,19 @@
 package org.apache.cayenne.query;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertNotSame;
 import static org.junit.jupiter.api.Assertions.assertNull;
 import static org.junit.jupiter.api.Assertions.assertSame;
 import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
 
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.HashMap;
 import java.util.Map;
 
+import org.apache.cayenne.map.EntityResolver;
 import org.apache.cayenne.util.Util;
 import org.junit.jupiter.api.Test;
 
@@ -134,4 +137,9 @@ public class SQLTemplateTest {
                assertEquals(o.getRoot(), c1.getRoot());
                assertEquals(o.getDefaultTemplate(), c1.getDefaultTemplate());
        }
+
+       @Test
+       public void notReadOnlyMetadata() {
+               assertFalse(new SQLTemplate("E1", 
"SELECT").getMetaData(mock(EntityResolver.class)).isReadOnly());
+       }
 }
diff --git 
a/cayenne/src/test/java/org/apache/cayenne/tx/ReadOnlyTransactionTest.java 
b/cayenne/src/test/java/org/apache/cayenne/tx/ReadOnlyTransactionTest.java
new file mode 100644
index 000000000..7d4fbcddd
--- /dev/null
+++ b/cayenne/src/test/java/org/apache/cayenne/tx/ReadOnlyTransactionTest.java
@@ -0,0 +1,86 @@
+/*****************************************************************
+ *   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.tx;
+
+import java.sql.Connection;
+
+import javax.sql.DataSource;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotSame;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+public class ReadOnlyTransactionTest {
+
+    @Test
+    public void notExternal() {
+        assertFalse(new ReadOnlyTransaction().isExternal());
+    }
+
+    @Test
+    public void addListenerIsNoOp() {
+        // read-only queries must not fire transaction events
+        new ReadOnlyTransaction().addListener(mock(TransactionListener.class));
+    }
+
+    @Test
+    public void ensuresAutoCommitAndDoesNotCloseEarly() throws Exception {
+        Connection connection = mock(Connection.class);
+        when(connection.getAutoCommit()).thenReturn(false);
+        DataSource dataSource = mock(DataSource.class);
+        when(dataSource.getConnection()).thenReturn(connection);
+
+        ReadOnlyTransaction tx = new ReadOnlyTransaction();
+        Connection handed = tx.getOrCreateConnection("node", dataSource);
+
+        // reads run in autocommit mode
+        verify(connection, times(1)).setAutoCommit(true);
+
+        // the handed-out connection is a decorator that does not close the 
real connection
+        assertNotSame(connection, handed);
+        handed.close();
+        verify(connection, never()).close();
+    }
+
+    @Test
+    public void commitClosesWithoutCommittingOrRollingBack() throws Exception {
+        Connection connection = mock(Connection.class);
+        when(connection.getAutoCommit()).thenReturn(true);
+        DataSource dataSource = mock(DataSource.class);
+        when(dataSource.getConnection()).thenReturn(connection);
+
+        ReadOnlyTransaction tx = new ReadOnlyTransaction();
+        tx.getOrCreateConnection("node", dataSource);
+        tx.commit();
+
+        // a read-only transaction never commits, rolls back, or disables 
autocommit; it just
+        // returns the connection to the pool by closing it
+        verify(connection, never()).commit();
+        verify(connection, never()).rollback();
+        verify(connection, never()).setAutoCommit(false);
+        verify(connection, times(1)).close();
+    }
+}


Reply via email to