Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 26 additions & 7 deletions jaydebeapiarrow/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,18 +115,35 @@ def _handle_sql_exception_jpype():
import jpype
SQLException = jpype.java.sql.SQLException
exc_info = sys.exc_info()
if old_jpype:
clazz = exc_info[1].__javaclass__
db_err = issubclass(clazz, SQLException)
else:
db_err = isinstance(exc_info[1], SQLException)
exc_val = exc_info[1]

# Check the exception and its cause chain for SQLException.
# The Arrow JDBC library wraps driver SQLExceptions in
# JdbcConsumerException, so we must walk the chain to find
# the original SQL error (e.g., divide-by-zero during fetch).
db_err = False
current = exc_val
while current is not None:
if old_jpype:
clazz = current.__javaclass__
if issubclass(clazz, SQLException):
db_err = True
break
else:
if isinstance(current, SQLException):
db_err = True
break
try:
current = current.getCause()
except AttributeError:
break

if db_err:
exc_type = DatabaseError
else:
exc_type = InterfaceError
reraise(exc_type, exc_info[1], exc_info[2])

reraise(exc_type, exc_val, exc_info[2])

def _jdbc_connect_jpype(jclassname, url, driver_args, jars, libs):
import jpype
Expand Down Expand Up @@ -225,6 +242,8 @@ def _prepare_jpype():
_jdbc_connect = _jdbc_connect_jpype
global _handle_sql_exception
_handle_sql_exception = _handle_sql_exception_jpype
from jaydebeapiarrow.lib import arrow_utils
arrow_utils._handle_sql_exception = _handle_sql_exception_jpype

_prepare_jpype()

Expand Down
8 changes: 8 additions & 0 deletions jaydebeapiarrow/lib/arrow_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@
import pyarrow as pa
from pyarrow.cffi import ffi as arrow_c

# Set by __init__.py after JPype initialization.
# Converts Java SQLException to Python DatabaseError.
_handle_sql_exception = None


def convert_jdbc_rs_to_arrow_iterator(rs, batch_size=1024):
import jpype.imports
Expand All @@ -29,6 +33,8 @@ def fetch_next_batch(it):
decimal_message = _find_decimal_conversion_message(e)
if decimal_message:
raise RuntimeError(decimal_message) from e
if _handle_sql_exception is not None:
_handle_sql_exception()
raise
try:
batch = pa.jvm.record_batch(root).to_pylist()
Expand Down Expand Up @@ -82,6 +88,8 @@ def read_rows_from_arrow_iterator(it, nrows=-1):
root.clear()

except Exception as e:
if _handle_sql_exception is not None:
_handle_sql_exception()
traceback.print_exc()
print(f"Error converting iterator to rows: {e}")
raise e
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -446,6 +446,22 @@ public final void mockTimestampResult(LocalDateTime localDateTime) throws SQLExc
Mockito.when(this.prepareStatement(Mockito.any())).thenReturn(mockPreparedStatement);
}

public final void mockExceptionOnFetch(String className, String exceptionMessage) throws SQLException {
PreparedStatement mockPreparedStatement = Mockito.mock(PreparedStatement.class);
Mockito.when(mockPreparedStatement.execute()).thenReturn(true);
mockResultSet = Mockito.mock(ResultSet.class, "ResultSet(for exception on fetch)");
Mockito.when(mockPreparedStatement.getResultSet()).thenReturn(mockResultSet);
Mockito.when(mockResultSet.next()).thenReturn(true);
ResultSetMetaData mockMetaData = Mockito.mock(ResultSetMetaData.class);
mockGeneralResultSetMetaData(mockMetaData, Types.DOUBLE);
Mockito.when(mockResultSet.getMetaData()).thenReturn(mockMetaData);

Throwable exception = createException(className, exceptionMessage);
Mockito.when(mockResultSet.getObject(1)).thenThrow(exception);
Mockito.when(mockResultSet.getDouble(1)).thenThrow(exception);
Mockito.when(this.prepareStatement(Mockito.any())).thenReturn(mockPreparedStatement);
}

public final ResultSet verifyResultSet() {
return Mockito.verify(mockResultSet);
}
Expand Down
Binary file not shown.
25 changes: 25 additions & 0 deletions test/test_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -999,6 +999,31 @@ def test_rollback_with_autocommit_disabled(self):
self.conn.jconn.setAutoCommit(False)
self.conn.rollback()

def test_java_exception_during_fetch_raises_database_error(self):
"""JDBC exceptions during fetch should raise DatabaseError, not raw Java exception.

Regression test for legacy issue baztian/jaydebeapi#58. Tests that
SQL exceptions raised during data retrieval (e.g., division by zero
in calculated columns) are properly converted to Python DatabaseError.
Also verifies that valid arithmetic expressions still work correctly."""
_, conn = self.connect()
try:
cursor = conn.cursor()
cursor.execute("CREATE TABLE test_divzero (a INTEGER, b INTEGER)")
cursor.execute("INSERT INTO test_divzero VALUES (10, 2)")
cursor.execute("INSERT INTO test_divzero VALUES (5, 0)")
# Normal division works fine
cursor.execute("SELECT a / b FROM test_divzero WHERE b <> 0")
result = cursor.fetchone()
self.assertEqual(result[0], 5)
# Integer division by zero raises DatabaseError
with self.assertRaises(jaydebeapiarrow.DatabaseError):
cursor.execute("SELECT a / b FROM test_divzero WHERE b = 0")
cursor.fetchone()
cursor.execute("DROP TABLE test_divzero IF EXISTS")
finally:
conn.close()


class PostgresTest(IntegrationTestBase, unittest.TestCase):

Expand Down
33 changes: 32 additions & 1 deletion test/test_mock.py
Original file line number Diff line number Diff line change
Expand Up @@ -1248,7 +1248,6 @@ def test_rollback_called_when_autocommit_disabled(self):
self.conn.jconn.mockAutoCommit(False)
self.conn.rollback()


def test_lastrowid_exists_and_is_none(self):
"""PEP-249: lastrowid attribute must exist on cursor (fixes #84)."""
with self.conn.cursor() as cursor:
Expand All @@ -1273,6 +1272,38 @@ def test_lastrowid_none_after_executemany(self):
"""lastrowid should be None after executemany (mock driver limitation: skip)."""
self.skipTest("Mock driver executeBatch returns None; covered by integration test")

# --- JDBC exception during fetch tests (legacy #58) ---

def test_sql_exception_on_fetch_raises_database_error(self):
"""SQLException during fetch should raise DatabaseError, not raw Java exception.

Regression test for legacy issue baztian/jaydebeapi#58 where JDBC driver
exceptions (e.g., divide-by-zero in calculated columns) propagated as
raw Java exceptions through fetchone() instead of proper Python DatabaseError.
The Arrow JDBC library wraps SQLExceptions in JdbcConsumerException, so
the handler must walk the cause chain to find the original SQL error."""
self.conn.jconn.mockExceptionOnFetch("java.sql.SQLException", "Division by zero")
with self.conn.cursor() as cursor:
cursor.execute("dummy stmt")
with self.assertRaises(jaydebeapiarrow.DatabaseError):
cursor.fetchone()

def test_runtime_exception_on_fetch_raises_interface_error(self):
"""Non-SQL Java exceptions during fetch should raise InterfaceError."""
self.conn.jconn.mockExceptionOnFetch("java.lang.RuntimeException", "driver error")
with self.conn.cursor() as cursor:
cursor.execute("dummy stmt")
with self.assertRaises(jaydebeapiarrow.InterfaceError):
cursor.fetchone()

def test_sql_exception_on_fetchall_raises_database_error(self):
"""SQLException during fetchall should raise DatabaseError."""
self.conn.jconn.mockExceptionOnFetch("java.sql.SQLException", "Data conversion error")
with self.conn.cursor() as cursor:
cursor.execute("dummy stmt")
with self.assertRaises(jaydebeapiarrow.DatabaseError):
cursor.fetchall()


class JarPathSpacesTest(unittest.TestCase):
"""Tests for JAR file paths containing spaces (issue #86).
Expand Down
Loading
Loading