From 6f5ca485acf37afefec3da748ee933cfcdcad5fe Mon Sep 17 00:00:00 2001 From: HenryNebula <22852427+HenryNebula@users.noreply.github.com> Date: Sat, 25 Apr 2026 17:08:23 -0400 Subject: [PATCH 1/4] Fix: preserve microsecond precision in datetime parameter binding (legacy #70) The fallback path's _to_java() was dropping microseconds when converting Python datetime to java.sql.Timestamp (%S instead of %S.%f), and time.isoformat() could produce fractional seconds that java.sql.Time rejects. Also adds regression tests for datetime/date/time parameter binding in both mock and integration test suites. Closes #88 Co-Authored-By: Claude Opus 4.6 --- jaydebeapiarrow/__init__.py | 5 +++-- test/test_integration.py | 18 ++++++++++++++++ test/test_mock.py | 42 +++++++++++++++++++++++++++++++++++++ 3 files changed, 63 insertions(+), 2 deletions(-) diff --git a/jaydebeapiarrow/__init__.py b/jaydebeapiarrow/__init__.py index 9b1f8053..686cb442 100644 --- a/jaydebeapiarrow/__init__.py +++ b/jaydebeapiarrow/__init__.py @@ -584,11 +584,12 @@ def _to_java(p): return jpype.JArray(jpype.JByte)(p) if isinstance(p, datetime.datetime): return jpype.JClass("java.sql.Timestamp").valueOf( - p.strftime("%Y-%m-%d %H:%M:%S")) + p.strftime("%Y-%m-%d %H:%M:%S.%f")) if isinstance(p, datetime.date): return jpype.JClass("java.sql.Date").valueOf(p.isoformat()) if isinstance(p, datetime.time): - return jpype.JClass("java.sql.Time").valueOf(p.isoformat()) + return jpype.JClass("java.sql.Time").valueOf( + p.strftime("%H:%M:%S")) if isinstance(p, Decimal): return jpype.JClass("java.math.BigDecimal")(str(p)) if isinstance(p, list): diff --git a/test/test_integration.py b/test/test_integration.py index c9242f0d..c5661012 100644 --- a/test/test_integration.py +++ b/test/test_integration.py @@ -612,6 +612,24 @@ def test_execute_param_none(self): result = cursor.fetchone() self.assertIsNone(result[0]) + def test_execute_param_datetime(self): + """Verify Python datetime objects round-trip correctly via parameter binding.""" + stmt = ("insert into ACCOUNT " + "(ACCOUNT_ID, ACCOUNT_NO, BALANCE, OPENED_AT, OPENED_AT_TIME) " + "values (?, ?, ?, ?, ?)") + ts = datetime(2024, 6, 15, 10, 30, 45, 123456) + d = datetime(2024, 6, 15).date() + t = datetime(2024, 6, 15, 10, 30, 45).time() + with self.conn.cursor() as cursor: + cursor.execute(stmt, (ts, 40, Decimal('7.0'), d, t)) + cursor.execute( + "select ACCOUNT_ID, OPENED_AT, OPENED_AT_TIME " + "from ACCOUNT where ACCOUNT_NO = 40") + result = cursor.fetchone() + self.assertEqual(result[0], datetime(2024, 6, 15, 10, 30, 45, 123456)) + self.assertEqual(result[1], datetime(2024, 6, 15).date()) + self.assertEqual(result[2], datetime(2024, 6, 15, 10, 30, 45).time()) + class SqliteTestBase(IntegrationTestBase): def setUpSql(self): diff --git a/test/test_mock.py b/test/test_mock.py index c45843d4..69a477ba 100644 --- a/test/test_mock.py +++ b/test/test_mock.py @@ -646,6 +646,36 @@ def test_to_java_datetime(self): self.assertEqual(len(captured), 1) self.assertIsInstance(captured[0][1], Timestamp) + def test_to_java_datetime_preserves_microseconds(self): + """datetime with microseconds should preserve fractional seconds in Timestamp.""" + import jpype + Timestamp = jpype.JClass("java.sql.Timestamp") + dt = datetime(2024, 6, 15, 10, 30, 45, 123456) + self.conn.jconn.mockSetObjectCapture() + with self.conn.cursor() as cursor: + cursor.execute("dummy stmt", (dt,)) + captured = self.conn.jconn.getCapturedSetObjectArgs() + self.assertEqual(len(captured), 1) + self.assertIsInstance(captured[0][1], Timestamp) + ts = captured[0][1] + self.assertEqual(ts.getNanos(), 123456000) + + def test_to_java_datetime_mixed_params(self): + """datetime alongside other types should all convert correctly.""" + import jpype + Timestamp = jpype.JClass("java.sql.Timestamp") + dt = datetime(2024, 1, 2, 3, 4, 5, 500000) + self.conn.jconn.mockSetObjectCapture() + with self.conn.cursor() as cursor: + cursor.execute("dummy stmt", (42, "hello", dt, None)) + captured = self.conn.jconn.getCapturedSetObjectArgs() + self.assertEqual(len(captured), 4) + self.assertEqual(captured[0][1], 42) + self.assertEqual(captured[1][1], "hello") + self.assertIsInstance(captured[2][1], Timestamp) + self.assertEqual(captured[2][1].getNanos(), 500000000) + self.assertIsNone(captured[3][1]) + def test_to_java_date(self): """date should convert to java.sql.Date.""" import jpype @@ -670,6 +700,18 @@ def test_to_java_time(self): self.assertEqual(len(captured), 1) self.assertIsInstance(captured[0][1], Time) + def test_to_java_time_with_microseconds(self): + """time with microseconds should convert to java.sql.Time without error.""" + import jpype + Time = jpype.JClass("java.sql.Time") + t = datetime(2024, 6, 15, 10, 30, 45, 999999).time() + self.conn.jconn.mockSetObjectCapture() + with self.conn.cursor() as cursor: + cursor.execute("dummy stmt", (t,)) + captured = self.conn.jconn.getCapturedSetObjectArgs() + self.assertEqual(len(captured), 1) + self.assertIsInstance(captured[0][1], Time) + def test_to_java_decimal(self): """Decimal should convert to java.math.BigDecimal.""" import jpype From 72cdbb51f0a26081b853b19ef93fa8372a7b4571 Mon Sep 17 00:00:00 2001 From: HenryNebula <22852427+HenryNebula@users.noreply.github.com> Date: Sat, 25 Apr 2026 17:14:00 -0400 Subject: [PATCH 2/4] Fix: make datetime param test tolerant of DB-specific precision Trino truncates to millisecond precision, Oracle returns datetime for DATE columns. Relax assertions to compare at second-level precision and accept both date/datetime forms. Co-Authored-By: Claude Opus 4.6 --- test/test_integration.py | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/test/test_integration.py b/test/test_integration.py index c5661012..2d0cadd4 100644 --- a/test/test_integration.py +++ b/test/test_integration.py @@ -626,9 +626,23 @@ def test_execute_param_datetime(self): "select ACCOUNT_ID, OPENED_AT, OPENED_AT_TIME " "from ACCOUNT where ACCOUNT_NO = 40") result = cursor.fetchone() - self.assertEqual(result[0], datetime(2024, 6, 15, 10, 30, 45, 123456)) - self.assertEqual(result[1], datetime(2024, 6, 15).date()) - self.assertEqual(result[2], datetime(2024, 6, 15, 10, 30, 45).time()) + # Timestamp: must match at least to second precision. + # Some drivers (Trino) truncate to milliseconds; Oracle may drop + # fractional seconds. Compare the floor to whole seconds. + self.assertEqual(result[0].replace(microsecond=0), + datetime(2024, 6, 15, 10, 30, 45)) + # Date: some drivers (Oracle) return datetime(2024,6,15,0,0) for + # DATE columns; accept both forms. + actual_date = result[1] + if isinstance(actual_date, datetime): + actual_date = actual_date.replace(hour=0, minute=0, second=0, + microsecond=0) + self.assertEqual(actual_date, datetime(2024, 6, 15)) + else: + self.assertEqual(actual_date, datetime(2024, 6, 15).date()) + # Time: compare to second precision + self.assertEqual(result[2].replace(microsecond=0), + datetime(2024, 6, 15, 10, 30, 45).time()) class SqliteTestBase(IntegrationTestBase): From 4a6e9f1797f45940848e21156b137ac964baaecf Mon Sep 17 00:00:00 2001 From: HenryNebula <22852427+HenryNebula@users.noreply.github.com> Date: Sat, 25 Apr 2026 17:18:48 -0400 Subject: [PATCH 3/4] Fix: handle Oracle TIME column returning datetime(1970,1,1) in test Oracle's JDBC driver returns TIME columns as datetime(1970,1,1,HH,MM,SS) instead of a pure time object. Relax the time assertion to check hour/minute/second components regardless of the wrapping type. Co-Authored-By: Claude Opus 4.6 --- test/test_integration.py | 13 ++++++++++--- test/test_mock.py | 4 ++-- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/test/test_integration.py b/test/test_integration.py index 2d0cadd4..9f2df186 100644 --- a/test/test_integration.py +++ b/test/test_integration.py @@ -640,9 +640,16 @@ def test_execute_param_datetime(self): self.assertEqual(actual_date, datetime(2024, 6, 15)) else: self.assertEqual(actual_date, datetime(2024, 6, 15).date()) - # Time: compare to second precision - self.assertEqual(result[2].replace(microsecond=0), - datetime(2024, 6, 15, 10, 30, 45).time()) + # Time: some drivers (Oracle) return datetime(1970,1,1,HH,MM,SS) + # instead of a pure time object; accept both forms. + actual_time = result[2] + if isinstance(actual_time, datetime): + self.assertEqual(actual_time.hour, 10) + self.assertEqual(actual_time.minute, 30) + self.assertEqual(actual_time.second, 45) + else: + self.assertEqual(actual_time.replace(microsecond=0), + datetime(2024, 6, 15, 10, 30, 45).time()) class SqliteTestBase(IntegrationTestBase): diff --git a/test/test_mock.py b/test/test_mock.py index 69a477ba..b142de5f 100644 --- a/test/test_mock.py +++ b/test/test_mock.py @@ -669,12 +669,12 @@ def test_to_java_datetime_mixed_params(self): with self.conn.cursor() as cursor: cursor.execute("dummy stmt", (42, "hello", dt, None)) captured = self.conn.jconn.getCapturedSetObjectArgs() - self.assertEqual(len(captured), 4) + # None uses setNull() (not setObject), so only 3 captures + self.assertEqual(len(captured), 3) self.assertEqual(captured[0][1], 42) self.assertEqual(captured[1][1], "hello") self.assertIsInstance(captured[2][1], Timestamp) self.assertEqual(captured[2][1].getNanos(), 500000000) - self.assertIsNone(captured[3][1]) def test_to_java_date(self): """date should convert to java.sql.Date.""" From 9ed78b19042af20fa6147820060dc8dddc40eced Mon Sep 17 00:00:00 2001 From: HenryNebula <22852427+HenryNebula@users.noreply.github.com> Date: Sun, 26 Apr 2026 09:03:35 -0400 Subject: [PATCH 4/4] Skip test_execute_param_datetime on Drill (no parameterized INSERT) Co-Authored-By: Claude Opus 4.6 --- test/test_integration.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/test/test_integration.py b/test/test_integration.py index 9f2df186..ee7d2b64 100644 --- a/test/test_integration.py +++ b/test/test_integration.py @@ -1819,6 +1819,10 @@ def test_execute_param_none(self): """Drill has no INSERT INTO ... VALUES — skip param none test.""" self.skipTest("Drill does not support INSERT INTO ... VALUES") + def test_execute_param_datetime(self): + """Drill has no parameterized INSERT — skip datetime param test.""" + self.skipTest("Drill does not support parameterized INSERT queries") + def test_execute_different_rowcounts(self): """Drill has no INSERT INTO ... VALUES — skip rowcount test.""" self.skipTest("Drill does not support INSERT INTO ... VALUES")