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 f0eec8562621faa1623f0b8beefc8c67f561296c
Author: Andrus Adamchik <[email protected]>
AuthorDate: Sat Jul 4 16:23:28 2026 -0400

    CAY-2974 CayenneSqlException with a reference to translated query
---
 RELEASE-NOTES.txt                                  |   1 +
 .../org/apache/cayenne/CayenneSqlException.java    |  69 ++++++++++++
 .../org/apache/cayenne/access/DataContext.java     |   5 +-
 .../cayenne/access/DataDomainQueryAction.java      |  19 +++-
 .../apache/cayenne/access/flush/FlushObserver.java |  22 +++-
 .../main/java/org/apache/cayenne/util/Util.java    |  47 +++++---
 .../apache/cayenne/CayenneSqlExceptionTest.java    |  79 ++++++++++++++
 .../cayenne/access/CayenneSqlExceptionIT.java      | 119 +++++++++++++++++++++
 .../java/org/apache/cayenne/util/UtilTest.java     |  38 +++++++
 9 files changed, 382 insertions(+), 17 deletions(-)

diff --git a/RELEASE-NOTES.txt b/RELEASE-NOTES.txt
index acd6dcc9d..df2a21fff 100644
--- a/RELEASE-NOTES.txt
+++ b/RELEASE-NOTES.txt
@@ -23,6 +23,7 @@ CAY-2969 Extender API for "soft" delete
 CAY-2970 Tighten deferred value resolution contract on commit
 CAY-2971 Remove extra spaces within SQL parenthesis
 CAY-2972 Fewer parentheses in generated SQL
+CAY-2974 CayenneSqlException with a reference to translated query
 
 Bug Fixes:
 
diff --git a/cayenne/src/main/java/org/apache/cayenne/CayenneSqlException.java 
b/cayenne/src/main/java/org/apache/cayenne/CayenneSqlException.java
new file mode 100644
index 000000000..1f0b69e7a
--- /dev/null
+++ b/cayenne/src/main/java/org/apache/cayenne/CayenneSqlException.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;
+
+import org.apache.cayenne.access.translator.TranslatedStatement;
+import org.apache.cayenne.query.Query;
+
+/**
+ * An exception thrown when a failure occurs while executing a query statement 
or reading its results. It carries the
+ * {@link Query} being executed and the {@link TranslatedStatement} that was 
being executed at the time of the failure,
+ * and includes the statement's SQL in the exception message, so that a 
failure can be correlated with a specific query.
+ *
+ * @since 5.0
+ */
+public class CayenneSqlException extends CayenneRuntimeException {
+
+    private final transient Query query;
+    private final transient TranslatedStatement statement;
+
+    /**
+     * Creates an exception for a failure that happened while executing the 
provided query and statement. The statement
+     * may be null if the failure occurred before a statement was translated.
+     */
+    public CayenneSqlException(String message, Query query, 
TranslatedStatement statement, Throwable cause) {
+        // pass the pre-built message as a "%s" argument so that any "%" 
characters in the SQL (e.g. LIKE patterns)
+        // are not interpreted as format specifiers by the superclass
+        super("%s", cause, buildMessage(message, statement));
+        this.query = query;
+        this.statement = statement;
+    }
+
+    /**
+     * Returns the query that was being executed when the failure occurred, or 
null if it is not known.
+     */
+    public Query getQuery() {
+        return query;
+    }
+
+    /**
+     * Returns the statement that was being executed when the failure 
occurred, or null if it is not known.
+     */
+    public TranslatedStatement getStatement() {
+        return statement;
+    }
+
+    private static String buildMessage(String message, TranslatedStatement 
statement) {
+        String base = message != null ? message : "SQL failure";
+        return statement != null
+                ? base + " SQL: [" + statement.sql() + "]"
+                : base;
+    }
+}
diff --git a/cayenne/src/main/java/org/apache/cayenne/access/DataContext.java 
b/cayenne/src/main/java/org/apache/cayenne/access/DataContext.java
index bc479d2fa..27a26c2a4 100644
--- a/cayenne/src/main/java/org/apache/cayenne/access/DataContext.java
+++ b/cayenne/src/main/java/org/apache/cayenne/access/DataContext.java
@@ -991,9 +991,10 @@ public class DataContext implements ObjectContext {
                     // this event is caught by peer nested DataContexts to 
synchronize the state
                     fireDataChannelCommitted(this, changes);
                 }
-                // "catch" is needed to unwrap OptimisticLockExceptions
+                // "catch" is needed to unwrap meaningful 
CayenneRuntimeExceptions (e.g. OptimisticLockException, or a
+                // CayenneSqlException carrying the failing SQL) without 
unwinding past them into their lower-level cause
                 catch (CayenneRuntimeException ex) {
-                    Throwable unwound = Util.unwindException(ex);
+                    Throwable unwound = Util.unwindException(ex, 
CayenneRuntimeException.class);
 
                     if (unwound instanceof CayenneRuntimeException 
cayenneRuntimeException) {
                         throw cayenneRuntimeException;
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 87e3709a1..e6e80d8f2 100644
--- a/cayenne/src/main/java/org/apache/cayenne/access/DataDomainQueryAction.java
+++ b/cayenne/src/main/java/org/apache/cayenne/access/DataDomainQueryAction.java
@@ -20,6 +20,7 @@
 package org.apache.cayenne.access;
 
 import org.apache.cayenne.CayenneRuntimeException;
+import org.apache.cayenne.CayenneSqlException;
 import org.apache.cayenne.DataRow;
 import org.apache.cayenne.EmbeddableObject;
 import org.apache.cayenne.ObjectContext;
@@ -27,6 +28,7 @@ import org.apache.cayenne.ObjectId;
 import org.apache.cayenne.Persistent;
 import org.apache.cayenne.QueryResponse;
 import org.apache.cayenne.ResultIterator;
+import org.apache.cayenne.access.translator.TranslatedStatement;
 import org.apache.cayenne.cache.QueryCache;
 import org.apache.cayenne.cache.QueryCacheEntryFactory;
 import org.apache.cayenne.di.AdhocObjectFactory;
@@ -100,6 +102,9 @@ class DataDomainQueryAction implements QueryRouter, 
OperationObserver {
     private Map<DataNode, Collection<Query>> queriesByNode;
     private boolean noObjectConversion;
 
+    // the latest statement reported via nextStatement, used to correlate a 
failure with a specific SQL statement
+    private TranslatedStatement statement;
+
     // True when using a caching strategy (shared or local cache), indicating 
lists are immutable and need copying
     private boolean cachedResult;
     // True when results were found in cache (cache hit), false when fetched 
from database (cache miss or explicit refresh)
@@ -685,9 +690,21 @@ class DataDomainQueryAction implements QueryRouter, 
OperationObserver {
         }
     }
 
+    @Override
+    public void nextStatement(Query query, TranslatedStatement statement) {
+        this.statement = statement;
+    }
+
     @Override
     public void nextQueryException(Query query, Exception ex) {
-        throw new CayenneRuntimeException("Query exception.", 
Util.unwindException(ex));
+        // preserve meaningful Cayenne exceptions, even when they wrap a 
lower-level cause; only wrap raw lower-level
+        // failures in a CayenneSqlException so they can be correlated with 
the failing statement
+        Throwable unwound = Util.unwindException(ex, 
CayenneRuntimeException.class);
+
+        if (unwound instanceof CayenneRuntimeException cayenneException) {
+            throw cayenneException;
+        }
+        throw new CayenneSqlException("Query exception.", query, statement, 
unwound);
     }
 
     @Override
diff --git 
a/cayenne/src/main/java/org/apache/cayenne/access/flush/FlushObserver.java 
b/cayenne/src/main/java/org/apache/cayenne/access/flush/FlushObserver.java
index 09808bdee..9834dc30b 100644
--- a/cayenne/src/main/java/org/apache/cayenne/access/flush/FlushObserver.java
+++ b/cayenne/src/main/java/org/apache/cayenne/access/flush/FlushObserver.java
@@ -20,10 +20,12 @@
 package org.apache.cayenne.access.flush;
 
 import org.apache.cayenne.CayenneRuntimeException;
+import org.apache.cayenne.CayenneSqlException;
 import org.apache.cayenne.DataRow;
 import org.apache.cayenne.ObjectId;
 import org.apache.cayenne.ResultIterator;
 import org.apache.cayenne.access.OperationObserver;
+import org.apache.cayenne.access.translator.TranslatedStatement;
 import org.apache.cayenne.map.DbAttribute;
 import org.apache.cayenne.query.InsertBatchQuery;
 import org.apache.cayenne.query.Query;
@@ -36,14 +38,30 @@ import java.util.List;
  */
 class FlushObserver implements OperationObserver {
 
+    // the latest statement reported via nextStatement, used to correlate a 
failure with a specific SQL statement
+    private TranslatedStatement statement;
+
+    @Override
+    public void nextStatement(Query query, TranslatedStatement statement) {
+        this.statement = statement;
+    }
+
     @Override
     public void nextQueryException(Query query, Exception ex) {
-        throw new CayenneRuntimeException("Raising from query exception.", 
Util.unwindException(ex));
+        // preserve meaningful Cayenne exceptions (e.g. 
OptimisticLockException), even when they wrap a lower-level
+        // cause; only wrap raw lower-level failures in a CayenneSqlException 
so they can be correlated with the
+        // failing statement
+        Throwable unwound = Util.unwindException(ex, 
CayenneRuntimeException.class);
+
+        if (unwound instanceof CayenneRuntimeException cayenneException) {
+            throw cayenneException;
+        }
+        throw new CayenneSqlException("Flush exception.", query, statement, 
unwound);
     }
 
     @Override
     public void nextGlobalException(Exception ex) {
-        throw new CayenneRuntimeException("Raising from underlyingQueryEngine 
exception.", Util.unwindException(ex));
+        throw new CayenneRuntimeException("Flush exception.", 
Util.unwindException(ex));
     }
 
     /**
diff --git a/cayenne/src/main/java/org/apache/cayenne/util/Util.java 
b/cayenne/src/main/java/org/apache/cayenne/util/Util.java
index ccce61697..01e9ff22e 100644
--- a/cayenne/src/main/java/org/apache/cayenne/util/Util.java
+++ b/cayenne/src/main/java/org/apache/cayenne/util/Util.java
@@ -173,20 +173,43 @@ public class Util {
      * recursively "unwraps" it, and returns the result to the user.
      */
     public static Throwable unwindException(Throwable th) {
-        if (th instanceof SAXException sax) {
-            if (sax.getException() != null) {
-                return unwindException(sax.getException());
-            }
-        } else if (th instanceof SQLException) {
-            SQLException sql = (SQLException) th;
-            if (sql.getNextException() != null) {
-                return unwindException(sql.getNextException());
-            }
-        } else if (th.getCause() != null) {
-            return unwindException(th.getCause());
+        Throwable next = nextInChain(th);
+        return next != null ? unwindException(next) : th;
+    }
+
+    /**
+     * Unwinds an exception chain like {@link #unwindException(Throwable)}, 
but never unwinds past the innermost
+     * exception that is an instance of {@code upTo}. If the chain contains 
such an exception, the innermost matching one
+     * is returned; otherwise the fully unwound root cause is returned. This 
lets callers strip generic wrappers while
+     * preserving a meaningful exception type that itself wraps a lower-level 
cause.
+     *
+     * @since 5.0
+     */
+    public static Throwable unwindException(Throwable th, Class<? extends 
Throwable> upTo) {
+        Throwable next = nextInChain(th);
+        if (next == null) {
+            return th;
         }
 
-        return th;
+        Throwable deeper = unwindException(next, upTo);
+        if (upTo.isInstance(deeper)) {
+            return deeper;
+        }
+        return upTo.isInstance(th) ? th : deeper;
+    }
+
+    /**
+     * Returns the next exception to unwind to (a nested SAX exception, a 
chained SQL exception, or the cause), or null
+     * if this is the end of the chain.
+     */
+    private static Throwable nextInChain(Throwable th) {
+        if (th instanceof SAXException sax) {
+            return sax.getException();
+        } else if (th instanceof SQLException sql) {
+            return sql.getNextException();
+        } else {
+            return th.getCause();
+        }
     }
 
     /**
diff --git 
a/cayenne/src/test/java/org/apache/cayenne/CayenneSqlExceptionTest.java 
b/cayenne/src/test/java/org/apache/cayenne/CayenneSqlExceptionTest.java
new file mode 100644
index 000000000..29edd36a6
--- /dev/null
+++ b/cayenne/src/test/java/org/apache/cayenne/CayenneSqlExceptionTest.java
@@ -0,0 +1,79 @@
+/*****************************************************************
+ *   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.access.translator.TranslatedSQL;
+import org.apache.cayenne.access.translator.TranslatedStatement;
+import org.apache.cayenne.query.Query;
+import org.apache.cayenne.query.SQLTemplate;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+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 CayenneSqlExceptionTest {
+
+    @Test
+    public void messageIncludesSql() {
+        Query query = new SQLTemplate();
+        TranslatedStatement statement = new TranslatedSQL("SELECT * FROM 
ARTIST", null, null);
+        Throwable cause = new RuntimeException("boom");
+
+        CayenneSqlException e = new CayenneSqlException("Query exception.", 
query, statement, cause);
+
+        assertSame(query, e.getQuery());
+        assertSame(statement, e.getStatement());
+        assertSame(cause, e.getCause());
+        assertTrue(e.getMessage().contains("Query exception."), 
e.getMessage());
+        assertTrue(e.getMessage().contains("SELECT * FROM ARTIST"), 
e.getMessage());
+    }
+
+    @Test
+    public void queryNotIncludedInMessage() {
+        Query query = new SQLTemplate();
+        TranslatedStatement statement = new TranslatedSQL("SELECT * FROM 
ARTIST", null, null);
+
+        CayenneSqlException e = new CayenneSqlException("Query exception.", 
query, statement, new RuntimeException());
+
+        assertSame(query, e.getQuery());
+        assertFalse(e.getMessage().contains(query.toString()), e.getMessage());
+    }
+
+    @Test
+    public void sqlWithPercentDoesNotBreakFormatting() {
+        // SQL may contain '%' (e.g. LIKE patterns); it must not be 
interpreted as a format specifier
+        TranslatedStatement statement = new TranslatedSQL("SELECT * FROM 
ARTIST WHERE NAME LIKE '%foo%'", null, null);
+
+        CayenneSqlException e = new CayenneSqlException("Query exception.", 
null, statement, new RuntimeException());
+
+        assertTrue(e.getMessage().contains("LIKE '%foo%'"), e.getMessage());
+    }
+
+    @Test
+    public void nullQueryAndStatementAreAllowed() {
+        CayenneSqlException e = new CayenneSqlException("Query exception.", 
null, null, new RuntimeException());
+
+        assertNull(e.getQuery());
+        assertNull(e.getStatement());
+        assertTrue(e.getMessage().contains("Query exception."), 
e.getMessage());
+    }
+}
diff --git 
a/cayenne/src/test/java/org/apache/cayenne/access/CayenneSqlExceptionIT.java 
b/cayenne/src/test/java/org/apache/cayenne/access/CayenneSqlExceptionIT.java
new file mode 100644
index 000000000..34c464846
--- /dev/null
+++ b/cayenne/src/test/java/org/apache/cayenne/access/CayenneSqlExceptionIT.java
@@ -0,0 +1,119 @@
+/*****************************************************************
+ *   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.CayenneSqlException;
+import org.apache.cayenne.exp.ExpressionFactory;
+import org.apache.cayenne.map.DbAttribute;
+import org.apache.cayenne.map.DbEntity;
+import org.apache.cayenne.map.ObjAttribute;
+import org.apache.cayenne.map.ObjEntity;
+import org.apache.cayenne.query.ObjectSelect;
+import org.apache.cayenne.query.SQLSelect;
+import org.apache.cayenne.testdo.testmap.Artist;
+import org.apache.cayenne.unit.CayenneProjects;
+import org.apache.cayenne.unit.CayenneTestsEnv;
+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.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Verifies that failures happening while a statement is executed against the 
database surface as a
+ * {@link CayenneSqlException} carrying the failing query and its translated 
SQL.
+ */
+public class CayenneSqlExceptionIT {
+
+    @RegisterExtension
+    static final CayenneTestsEnv env = 
CayenneTestsEnv.forProject(CayenneProjects.TESTMAP_PROJECT);
+
+    private DataContext context;
+
+    @BeforeEach
+    public void setUp() {
+        context = env.context();
+    }
+
+    @Test
+    public void selectWithInvalidSql() {
+        // deliberately malformed SQL
+        String badSql = "SELECT FROM NOT ARTIST WHERE";
+        SQLSelect<Artist> query = SQLSelect.query(Artist.class, badSql);
+
+        CayenneSqlException e = assertThrows(CayenneSqlException.class, () -> 
query.select(context));
+//        e.printStackTrace();
+
+        assertNotNull(e.getStatement(), "statement should have been captured");
+        // SQLSelect is executed as an internally-routed SQLTemplate, so 
getQuery() reports that executed query
+        assertNotNull(e.getQuery());
+        assertTrue(e.getMessage().contains(badSql),
+                () -> "Expected message to include the failing SQL, but was: " 
+ e.getMessage());
+    }
+
+    @Test
+    public void objectSelectWithBadExpressionParameter() {
+        // comparing the numeric PK column to a non-numeric value produces 
valid SQL that the DB rejects when the
+        // bogus parameter is bound and the statement is executed
+        ObjectSelect<Artist> query = ObjectSelect.query(Artist.class)
+                
.where(ExpressionFactory.matchDbExp(Artist.ARTIST_ID_PK_COLUMN, 
"not-a-number"));
+
+        CayenneSqlException e = assertThrows(CayenneSqlException.class, () -> 
query.select(context));
+//        e.printStackTrace();
+
+        assertNotNull(e.getStatement(), "statement should have been captured");
+        assertSame(query, e.getQuery());
+        assertTrue(e.getStatement().sql().contains("ARTIST"),
+                () -> "Expected SELECT SQL against ARTIST, but was: " + 
e.getStatement().sql());
+    }
+
+    @Test
+    public void commitWithNonExistingColumn() {
+        // point the "artistName" attribute at a column that does not exist in 
the ARTIST table, so the generated
+        // INSERT references an unknown column and the DB rejects it on commit
+        DbEntity artistDb = context.getEntityResolver().getDbEntity("ARTIST");
+        DbAttribute nameColumn = artistDb.getAttribute("ARTIST_NAME");
+        artistDb.removeAttribute("ARTIST_NAME");
+        nameColumn.setName("BOGUS_COLUMN");
+        artistDb.addAttribute(nameColumn);
+
+        ObjEntity artistObj = 
context.getEntityResolver().getObjEntity("Artist");
+        ObjAttribute nameAttribute = artistObj.getAttribute("artistName");
+        nameAttribute.setDbAttributePath("BOGUS_COLUMN");
+
+        context.getEntityResolver().refreshMappingCache();
+
+        Artist artist = context.newObject(Artist.class);
+        artist.setArtistName("aaa");
+
+        CayenneSqlException e = assertThrows(CayenneSqlException.class, () -> 
context.commitChanges());
+//        e.printStackTrace();
+
+        assertNotNull(e.getStatement(), "statement should have been captured");
+        assertNotNull(e.getQuery());
+        assertTrue(e.getStatement().sql().contains("BOGUS_COLUMN"),
+                () -> "Expected INSERT SQL to reference BOGUS_COLUMN, but was: 
" + e.getStatement().sql());
+        assertTrue(e.getMessage().contains("BOGUS_COLUMN"),
+                () -> "Expected message to include the failing SQL, but was: " 
+ e.getMessage());
+    }
+}
diff --git a/cayenne/src/test/java/org/apache/cayenne/util/UtilTest.java 
b/cayenne/src/test/java/org/apache/cayenne/util/UtilTest.java
index 7a114681e..99010cb4e 100644
--- a/cayenne/src/test/java/org/apache/cayenne/util/UtilTest.java
+++ b/cayenne/src/test/java/org/apache/cayenne/util/UtilTest.java
@@ -26,6 +26,7 @@ import org.junit.jupiter.api.Test;
 
 import java.io.File;
 import java.io.FileWriter;
+import java.sql.SQLException;
 import java.util.Map;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
@@ -327,4 +328,41 @@ public class UtilTest {
        public void underscoredToJavaSpecialChars() throws Exception {
                assertEquals("ABCpoundXyz", Util.underscoredToJava("ABC#_XYZ", 
true));
        }
+
+       @Test
+       public void unwindException() {
+               SQLException sql = new SQLException("bad sql");
+               CayenneRuntimeException wrapper = new 
CayenneRuntimeException(sql);
+
+               // the plain form unwinds all the way to the non-Cayenne root
+               assertSame(sql, Util.unwindException(wrapper));
+       }
+
+       @Test
+       public void unwindExceptionUpTo_stopsAtWrapper() {
+               SQLException sql = new SQLException("bad sql");
+               CayenneRuntimeException wrapper = new 
CayenneRuntimeException(sql);
+
+               // does not unwind past the CayenneRuntimeException into its 
lower-level SQLException cause
+               assertSame(wrapper, Util.unwindException(wrapper, 
CayenneRuntimeException.class));
+       }
+
+       @Test
+       public void unwindExceptionUpTo_returnsInnermostMatch() {
+               SQLException sql = new SQLException("bad sql");
+               CayenneRuntimeException inner = new 
CayenneRuntimeException("inner", sql);
+               CayenneRuntimeException outer = new 
CayenneRuntimeException("outer", inner);
+
+               // generic outer wrapper is stripped, but unwinding stops at 
the innermost matching exception
+               assertSame(inner, Util.unwindException(outer, 
CayenneRuntimeException.class));
+       }
+
+       @Test
+       public void unwindExceptionUpTo_noMatchUnwindsToRoot() {
+               IllegalArgumentException root = new 
IllegalArgumentException("root");
+               RuntimeException wrapper = new RuntimeException(root);
+
+               // no exception of the requested type in the chain, so behaves 
like the plain unwind
+               assertSame(root, Util.unwindException(wrapper, 
SQLException.class));
+       }
 }

Reply via email to