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)); + } }
