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..ee7d2b64 100644 --- a/test/test_integration.py +++ b/test/test_integration.py @@ -612,6 +612,45 @@ 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() + # 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: 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): def setUpSql(self): @@ -1780,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") diff --git a/test/test_mock.py b/test/test_mock.py index c45843d4..b142de5f 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() + # 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) + 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