diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 3a4fab12..d4fa830e 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -108,7 +108,7 @@ jobs: - name: Install from TestPyPI run: pip install --index-url https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple/ JayDeBeApiArrow - name: Run mock tests - run: CLASSPATH="test/jars/*" python test/testsuite.py test_mock + run: CLASSPATH="test/jars/*:test/mock-jars/*" python -m pytest test/test_mock.py test/test_infrastructure.py -v publish-to-pypi: name: Publish to PyPI diff --git a/CLAUDE.md b/CLAUDE.md index b8aac997..6ec2edfb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,9 +1,11 @@ ## Ways of Working Use `uv run` to run Python scripts and tests — it automatically manages the virtual environment. - `uv sync` to install/sync dependencies -- `CLASSPATH="test/jars/*" uv run python -m unittest test.test_integration.HsqldbTest` to run integration tests -- `CLASSPATH="test/jars/*:test/mock-jars/*" uv run python -m unittest test.test_mock` to run mock tests - `uv run bash test/build.sh` to build JARs +- `uv run pytest test/ -v` to run all tests via pytest +- `uv run pytest test/test_postgres.py -v` to run a specific driver's tests +- `uv run pytest test/ -k "test_execute_and_fetch" -v` to run specific tests by name +- `CLASSPATH` is set automatically by tox; for local runs set it to `test/jars/*:test/mock-jars/*` ## Speical Requirements in YOLO Mode diff --git a/README.md b/README.md index deba7419..ee43806c 100644 --- a/README.md +++ b/README.md @@ -120,44 +120,52 @@ In theory *every database with a suitable JDBC driver should work*. It is confir ## Testing -Integration tests are located in `test/`. The test suite covers SQLite (in-memory), PostgreSQL, MySQL, and HSQLDB. +Integration tests are located in `test/`. Tests run via [pytest](https://docs.pytest.org/) and cover all supported databases: SQLite (in-memory), HSQLDB, PostgreSQL, MySQL, MSSQL, Oracle, DB2, Trino, and Apache Drill. ### Build JARs and download drivers ```bash uv run bash test/build.sh # Build arrow-jdbc-extension and MockDriver JARs -uv run bash test/download_jdbc_drivers.sh # Download PostgreSQL, MySQL, SQLite, HSQLDB JDBC drivers +uv run bash test/download_jdbc_drivers.sh # Download JDBC drivers ``` ### Run tests ```bash -CLASSPATH="test/jars/*" uv run python -m unittest test.test_integration.HsqldbTest # HSQLDB -CLASSPATH="test/jars/*" uv run python -m unittest test.test_integration.SqliteXerialTest # SQLite -CLASSPATH="test/jars/*" uv run python -m unittest test.test_mock # Mock driver +CLASSPATH="test/jars/*:test/mock-jars/*" uv run pytest test/test_mock.py test/test_infrastructure.py -v # Mock + infrastructure +CLASSPATH="test/jars/*" uv run pytest test/test_hsqldb.py -v # HSQLDB +CLASSPATH="test/jars/*" uv run pytest test/test_sqlite.py::SqliteXerialTest -v # SQLite JDBC +CLASSPATH="test/jars/*" uv run pytest test/ -v --tb=short # All tests ``` +Pytest is configured in `pyproject.toml` to run tests in parallel across files using `pytest-xdist` with `--dist loadfile`. + ### External database tests -PostgreSQL and MySQL tests require running database instances. Docker Compose configs and helper scripts are provided in `test/`: +Container-based databases are managed via Docker Compose: ```bash -# Start both databases -bash test/start.sh +# Start all databases +cd test && docker compose up -d # Check status -bash test/status.sh +cd test && docker compose ps -# Stop databases -bash test/stop.sh +# Stop all databases +cd test && docker compose down ``` Database connection defaults (overridable via environment variables): | Database | Host | Port | DB | User | Password | Env prefix | |---|---|---|---|---|---|---| -| PostgreSQL | localhost | 5432 | test_db | user | password | `JY_PG_*` | -| MySQL | localhost | 3306 | test_db | user | password | `JY_MYSQL_*` | +| PostgreSQL | localhost | 15432 | test_db | user | password | `JY_PG_*` | +| MySQL | localhost | 13306 | test_db | user | password | `JY_MYSQL_*` | +| MSSQL | localhost | 11433 | — | sa | Password123! | `JY_MSSQL_*` | +| Oracle | localhost | 11521 | XEPDB1 | system | Password123! | `JY_ORACLE_*` | +| DB2 | localhost | 15000 | test_db | db2inst1 | Password123! | `JY_DB2_*` | +| Trino | localhost | 18080 | — | test | — | `JY_TRINO_*` | +| Drill | localhost | 31010 | — | — | — | `JY_DRILL_*` | ## Benchmarks diff --git a/conftest.py b/conftest.py new file mode 100644 index 00000000..db006c4e --- /dev/null +++ b/conftest.py @@ -0,0 +1,7 @@ +import pytest + + +def pytest_collection_modifyitems(items): + for item in items: + if "test_drill" in item.module.__name__: + item.add_marker(pytest.mark.xdist_group(name="drill")) diff --git a/jaydebeapiarrow/__init__.py b/jaydebeapiarrow/__init__.py index 636c339f..6d2dfc94 100644 --- a/jaydebeapiarrow/__init__.py +++ b/jaydebeapiarrow/__init__.py @@ -240,6 +240,10 @@ def _jdbc_connect_jpype(jclassname, url, driver_args, jars, libs, experimental=N # Add-opens for Apache Arrow on Java 9+ args.append('--add-opens=java.base/java.nio=ALL-UNNAMED') + # Drill's javassist needs reflective access to ClassLoader.defineClass + args.append('--add-opens=java.base/java.lang=ALL-UNNAMED') + # User-supplied extra JVM arguments (e.g. logging suppression) + args.extend(_experimental.get('jvm_args', [])) # jvm_path = ('/usr/lib/jvm/java-6-openjdk' # '/jre/lib/i386/client/libjvm.so') @@ -490,6 +494,9 @@ def connect(jclassname, url, driver_args=None, jars=None, libs=None, experimenta from JARs after the JVM has already been started, using a DriverShim proxy. This also bypasses the fork-after-JVM-start guard, making it suitable for gunicorn --preload workers. + jvm_args (list[str]): Extra JVM arguments passed to startJVM(). + Only takes effect on the first connect() call (when the JVM + is started). Ignored on subsequent calls. """ if isinstance(driver_args, str): driver_args = [ driver_args ] diff --git a/jaydebeapiarrow/logging.properties b/jaydebeapiarrow/logging.properties new file mode 100644 index 00000000..d27712fd --- /dev/null +++ b/jaydebeapiarrow/logging.properties @@ -0,0 +1,7 @@ +# Java util logging configuration for jaydebeapiarrow. +# Silences noisy third-party JDBC drivers (Drill, Trino, Arrow) that log +# to stdout/stderr during class loading. +handlers=java.util.logging.ConsoleHandler +.level=OFF +java.util.logging.ConsoleHandler.level=OFF +java.util.logging.ConsoleHandler.formatter=java.util.logging.SimpleFormatter diff --git a/pyproject.toml b/pyproject.toml index 058aeb62..caef22f0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,6 +45,8 @@ Homepage = "https://github.com/HenryNebula/jaydebeapiarrow" dev = [ "coverage>=4.5", "jaydebeapi>=1.2.3", + "pytest>=8.4.2", + "pytest-xdist>=3.8.0", "unittest-xml-reporting", ] @@ -68,3 +70,6 @@ values = ["dev", "rc", "final"] [tool.setuptools] packages = ["jaydebeapiarrow", "jaydebeapiarrow.lib"] include-package-data = true + +[tool.pytest.ini_options] +addopts = "-n auto --dist loadfile" diff --git a/test/__init__.py b/test/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/test/_base.py b/test/_base.py new file mode 100644 index 00000000..c4661660 --- /dev/null +++ b/test/_base.py @@ -0,0 +1,799 @@ +#-*- coding: utf-8 -*- + +# Copyright 2010 Bastian Bowe +# +# This file is part of JayDeBeApi. +# JayDeBeApi is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# JayDeBeApi is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with JayDeBeApi. If not, see +# . + +import jaydebeapiarrow +import os +import unittest + +from decimal import Decimal +from datetime import datetime + +_THIS_DIR = os.path.dirname(os.path.abspath(__file__)) + +_SUPPRESS_LOGGING_ARGS = [ + '-Dorg.slf4j.simpleLogger.defaultLogLevel=off', + '-Djava.util.logging.config.file=%s' % os.path.join( + os.path.dirname(jaydebeapiarrow.__file__), 'logging.properties'), +] + + +class IntegrationTestBase(object): + + JDBC_SUPPORT_TEMPORAL_TYPE = True + + def _cast_datetime(self, datetime_str, fmt=r'%Y-%m-%d %H:%M:%S'): + if self.JDBC_SUPPORT_TEMPORAL_TYPE and type(datetime_str) == str: + return datetime.strptime(datetime_str, fmt) + else: + return datetime_str + + def _cast_time(self, time_str, fmt=r'%H:%M:%S'): + if self.JDBC_SUPPORT_TEMPORAL_TYPE and type(time_str) == str: + return datetime.strptime(time_str, fmt).time() + else: + return time_str + + def _cast_date(self, date_str, fmt=r'%Y-%m-%d'): + if self.JDBC_SUPPORT_TEMPORAL_TYPE and type(date_str) == str: + return datetime.strptime(date_str, fmt).date() + else: + return date_str + + def sql_file(self, filename): + f = open(filename, 'r') + try: + lines = f.readlines() + finally: + f.close() + stmt = [] + stmts = [] + for i in lines: + stmt.append(i) + if ";" in i: + stmts.append(" ".join(stmt)) + stmt = [] + with self.conn.cursor() as cursor: + for i in stmts: + cursor.execute(i.rstrip().rstrip(";")) + + def setUp(self): + (self.dbapi, self.conn) = self.connect() + self._suppress_java_noise() + self._cleanup_tables() + self.setUpSql() + + def _cleanup_tables(self): + """Drop any leftover tables from a previous failed test run.""" + with self.conn.cursor() as cursor: + for table in ('ACCOUNT', 'NUMERIC_TEST', 'NUMERIC_COMBO', + 'DOUBLE_TEST', 'BIGINT_TEST'): + try: + cursor.execute(f"DROP TABLE {table}") + except Exception: + pass + + @staticmethod + def _quiet_connect(*args, **kwargs): + """Wrapper around jaydebeapiarrow.connect() that silences Java + loggers (slf4j-simple and java.util.logging) on the first call.""" + kwargs.setdefault('experimental', {}) + kwargs['experimental'].setdefault('jvm_args', _SUPPRESS_LOGGING_ARGS) + return jaydebeapiarrow.connect(*args, **kwargs) + + @staticmethod + def _suppress_java_noise(): + """Suppress noisy Java loggers from Drill, Trino, etc.""" + try: + import jpype + from jaydebeapiarrow import _is_jvm_started + if not _is_jvm_started(): + return + Level = jpype.JClass("java.util.logging.Level") + root = jpype.JClass("java.util.logging.Logger").getLogger("") + for name in ( + "oadd.org.apache.drill", + "org.apache.drill", + "io.trino", + "org.apache.arrow.memory", + "org.apache.arrow.vector", + "org.jaydebeapiarrow.extension", + ): + root.getLogger(name).setLevel(Level.WARNING) + except Exception: + pass + + def setUpSql(self): + raise NotImplementedError + + def connect(self): + raise NotImplementedError + + def tearDown(self): + with self.conn.cursor() as cursor: + cursor.execute("drop table ACCOUNT") + self._numeric_teardown() + try: + self.conn.jconn.setAutoCommit(True) + except Exception: + pass + self.conn.close() + + def test_execute_and_fetch_no_data(self): + with self.conn.cursor() as cursor: + stmt = "select * from ACCOUNT where ACCOUNT_ID is null" + cursor.execute(stmt) + result = cursor.fetchall() + self.assertEqual(result, []) + + def test_execute_and_fetch(self): + with self.conn.cursor() as cursor: + cursor.execute("select ACCOUNT_ID, ACCOUNT_NO, BALANCE, BLOCKING " \ + "from ACCOUNT ORDER BY ACCOUNT_NO") + result = cursor.fetchall() + self.assertEqual(result, [ + ( + self._cast_datetime('2009-09-10 14:15:22.123456', r'%Y-%m-%d %H:%M:%S.%f'), + 18, Decimal('12.4'), None), + ( + self._cast_datetime('2009-09-11 14:15:22.123456', r'%Y-%m-%d %H:%M:%S.%f'), + 19, Decimal('12.9'), Decimal('1')) + ]) + + def test_execute_and_fetch_parameter(self): + with self.conn.cursor() as cursor: + cursor.execute("select ACCOUNT_ID, ACCOUNT_NO, BALANCE, BLOCKING " \ + "from ACCOUNT where ACCOUNT_NO = ?", (18,)) + result = cursor.fetchall() + self.assertEqual(result, [ + ( + self._cast_datetime('2009-09-10 14:15:22.123456', r'%Y-%m-%d %H:%M:%S.%f'), + 18, Decimal('12.4'), None) + ]) + + def test_execute_and_fetchone(self): + with self.conn.cursor() as cursor: + cursor.execute("select ACCOUNT_ID, ACCOUNT_NO, BALANCE, BLOCKING " \ + "from ACCOUNT order by ACCOUNT_NO") + result = cursor.fetchone() + self.assertEqual(result, ( + self._cast_datetime('2009-09-10 14:15:22.123456', r'%Y-%m-%d %H:%M:%S.%f'), + 18, Decimal('12.4'), None)) + cursor.close() + + def test_execute_reset_description_without_execute_result(self): + """Expect the descriptions property being reset when no query + has been made via execute method. + """ + with self.conn.cursor() as cursor: + cursor.execute("select * from ACCOUNT") + self.assertIsNotNone(cursor.description) + cursor.fetchone() + cursor.execute("delete from ACCOUNT") + self.assertIsNone(cursor.description) + + def test_execute_and_fetchone_after_end(self): + with self.conn.cursor() as cursor: + cursor.execute("select * from ACCOUNT where ACCOUNT_NO = ?", (18,)) + cursor.fetchone() + result = cursor.fetchone() + self.assertIsNone(result) + + def test_execute_and_fetchone_consecutive(self): + with self.conn.cursor() as cursor: + cursor.execute("select ACCOUNT_ID, ACCOUNT_NO, BALANCE, BLOCKING " \ + "from ACCOUNT order by ACCOUNT_NO") + result1 = cursor.fetchone() + result2 = cursor.fetchone() + + self.assertEqual(result1, ( + self._cast_datetime('2009-09-10 14:15:22.123456', r'%Y-%m-%d %H:%M:%S.%f'), + 18, Decimal('12.4'), None)) + + self.assertEqual(result2, ( + self._cast_datetime('2009-09-11 14:15:22.123456', r'%Y-%m-%d %H:%M:%S.%f'), + 19, Decimal('12.9'), Decimal('1'))) + + def test_execute_and_fetchmany(self): + with self.conn.cursor() as cursor: + cursor.execute("select ACCOUNT_ID, ACCOUNT_NO, BALANCE, BLOCKING " \ + "from ACCOUNT order by ACCOUNT_NO") + result = cursor.fetchmany() + self.assertEqual(result, [ + ( + self._cast_datetime('2009-09-10 14:15:22.123456', r'%Y-%m-%d %H:%M:%S.%f'), + 18, Decimal('12.4'), None) + ]) + + def test_executemany(self): + stmt = "insert into ACCOUNT (ACCOUNT_ID, ACCOUNT_NO, BALANCE) " \ + "values (?, ?, ?)" + parms = ( + ( self.dbapi.Timestamp(2009, 9, 11, 14, 15, 22, 123450), 20, 13.1 ), + ( self.dbapi.Timestamp(2009, 9, 11, 14, 15, 22, 123451), 21, 13.2 ), + ( self.dbapi.Timestamp(2009, 9, 11, 14, 15, 22, 123452), 22, 13.3 ), + ) + with self.conn.cursor() as cursor: + cursor.executemany(stmt, parms) + self.assertEqual(cursor.rowcount, 3) + + def test_execute_types(self): + stmt = "insert into ACCOUNT (ACCOUNT_ID, ACCOUNT_NO, BALANCE, " \ + "BLOCKING, DBL_COL, OPENED_AT, VALID, PRODUCT_NAME) " \ + "values (?, ?, ?, ?, ?, ?, ?, ?)" + account_id = self.dbapi.Timestamp(2010, 1, 26, 14, 31, 59) + account_no = 20 + balance = Decimal('1.2') + blocking = 10.0 + dbl_col = 3.5 + opened_at = self.dbapi.Date(1908, 2, 27) + valid = True + product_name = u'Savings account' + parms = (account_id, account_no, balance, blocking, dbl_col, + opened_at, valid, product_name) + with self.conn.cursor() as cursor: + cursor.execute(stmt, parms) + stmt = "select ACCOUNT_ID, ACCOUNT_NO, BALANCE, BLOCKING, " \ + "DBL_COL, OPENED_AT, VALID, PRODUCT_NAME " \ + "from ACCOUNT where ACCOUNT_NO = ?" + parms = (20, ) + cursor.execute(stmt, parms) + result = cursor.fetchone() + exp = ( + self._cast_datetime('2010-01-26 14:31:59', r'%Y-%m-%d %H:%M:%S'), + account_no, balance, blocking, dbl_col, + self._cast_date('1908-02-27', r'%Y-%m-%d'), + valid, product_name + ) + self.assertEqual(result, exp) + + def test_execute_type_time(self): + stmt = "insert into ACCOUNT (ACCOUNT_ID, ACCOUNT_NO, BALANCE, " \ + "OPENED_AT_TIME) " \ + "values (?, ?, ?, ?)" + account_id = self.dbapi.Timestamp(2010, 1, 26, 14, 31, 59) + account_no = 20 + balance = 1.2 + opened_at_time = self.dbapi.Time(13, 59, 59) + parms = (account_id, account_no, balance, opened_at_time) + with self.conn.cursor() as cursor: + cursor.execute(stmt, parms) + stmt = "select ACCOUNT_ID, ACCOUNT_NO, BALANCE, OPENED_AT_TIME " \ + "from ACCOUNT where ACCOUNT_NO = ?" + parms = (20, ) + cursor.execute(stmt, parms) + result = cursor.fetchone() + + exp = ( + self._cast_datetime('2010-01-26 14:31:59', r'%Y-%m-%d %H:%M:%S'), + account_no, Decimal(str(balance)), + self._cast_time('13:59:59', r'%H:%M:%S') + ) + self.assertEqual(result, exp) + + def test_execute_different_rowcounts(self): + stmt = "insert into ACCOUNT (ACCOUNT_ID, ACCOUNT_NO, BALANCE) " \ + "values (?, ?, ?)" + parms = ( + ( self.dbapi.Timestamp(2009, 9, 11, 14, 15, 22, 123450), 20, 13.1 ), + ( self.dbapi.Timestamp(2009, 9, 11, 14, 15, 22, 123452), 22, 13.3 ), + ) + with self.conn.cursor() as cursor: + cursor.executemany(stmt, parms) + self.assertEqual(cursor.rowcount, 2) + parms = ( self.dbapi.Timestamp(2009, 9, 11, 14, 15, 22, 123451), 21, 13.2 ) + cursor.execute(stmt, parms) + self.assertEqual(cursor.rowcount, 1) + cursor.execute("select * from ACCOUNT") + self.assertEqual(cursor.rowcount, -1) + + def test_lastrowid_exists_and_is_none(self): + """PEP-249: lastrowid attribute must exist and be None (fixes #84).""" + with self.conn.cursor() as cursor: + self.assertIsNone(cursor.lastrowid) + + def test_lastrowid_none_after_select(self): + """lastrowid should be None after a SELECT query.""" + with self.conn.cursor() as cursor: + cursor.execute("select * from ACCOUNT") + self.assertIsNone(cursor.lastrowid) + + def test_lastrowid_none_after_insert(self): + """lastrowid should be None after INSERT (JDBC doesn't expose rowid).""" + stmt = "insert into ACCOUNT (ACCOUNT_ID, ACCOUNT_NO, BALANCE) " \ + "values (?, ?, ?)" + with self.conn.cursor() as cursor: + cursor.execute(stmt, (self.dbapi.Timestamp(2009, 9, 11, 14, 15, 22, 123450), 99, 1.0)) + self.assertIsNone(cursor.lastrowid) + + def test_lastrowid_none_after_executemany(self): + """lastrowid should be None after executemany.""" + stmt = "insert into ACCOUNT (ACCOUNT_ID, ACCOUNT_NO, BALANCE) " \ + "values (?, ?, ?)" + parms = ( + (self.dbapi.Timestamp(2009, 9, 11, 14, 15, 22, 123450), 98, 1.0), + (self.dbapi.Timestamp(2009, 9, 11, 14, 15, 22, 123452), 97, 2.0), + ) + with self.conn.cursor() as cursor: + cursor.executemany(stmt, parms) + self.assertIsNone(cursor.lastrowid) + + def test_execute_type_blob(self): + stmt = "insert into ACCOUNT (ACCOUNT_ID, ACCOUNT_NO, BALANCE, " \ + "STUFF) values (?, ?, ?, ?)" + binary_stuff = 'abcdef'.encode('UTF-8') + account_id = self.dbapi.Timestamp(2009, 9, 11, 14, 15, 22, 123450) + stuff = self.dbapi.Binary(binary_stuff) + parms = (account_id, 20, 13.1, stuff) + with self.conn.cursor() as cursor: + cursor.execute(stmt, parms) + stmt = "select STUFF from ACCOUNT where ACCOUNT_NO = ?" + parms = (20, ) + cursor.execute(stmt, parms) + result = cursor.fetchone() + value = result[0] + self.assertEqual(value, memoryview(binary_stuff)) + + def test_timestamp_subsecond_leading_zeros(self): + """Verify that TIMESTAMP columns preserve sub-second leading zeros. + Regression test for legacy baztian/jaydebeapi#44 where + 2017-06-19 15:30:00.096965169 was displayed as + 2017-06-19 15:30:00.960000 due to string-based parsing + stripping the leading zero. The Arrow path uses integer + nanosecond arithmetic, so this should be correct.""" + test_cases = [ + # (year, month, day, hour, minute, second, microsecond) + (2017, 6, 19, 15, 30, 0, 96965), # .096965 — exact case from legacy #44 + (2020, 1, 1, 0, 0, 0, 1), # .000001 — minimal non-zero + (2021, 3, 15, 12, 0, 0, 1000), # .001000 — leading zeros then trailing + (2019, 7, 4, 10, 30, 0, 99999), # .099999 — leading zero + 9s + (2022, 1, 1, 0, 0, 0, 0), # .000000 — zero sub-second + ] + stmt = ("insert into ACCOUNT (ACCOUNT_ID, ACCOUNT_NO, BALANCE) " + "values (?, ?, ?)") + with self.conn.cursor() as cursor: + for idx, (y, mo, d, h, mi, s, us) in enumerate(test_cases): + ts = self.dbapi.Timestamp(y, mo, d, h, mi, s, us) + cursor.execute(stmt, (ts, 60 + idx, Decimal('1.0'))) + cursor.execute( + "select ACCOUNT_ID from ACCOUNT " + "where ACCOUNT_NO >= 60 order by ACCOUNT_NO") + results = cursor.fetchall() + for idx, (y, mo, d, h, mi, s, us) in enumerate(test_cases): + expected = self._cast_datetime( + f'{y}-{mo:02d}-{d:02d} {h:02d}:{mi:02d}:{s:02d}.{us:06d}', + r'%Y-%m-%d %H:%M:%S.%f') + self.assertEqual(results[idx][0], expected, + f"Failed for microseconds={us}") + + def test_timestamp_microsecond_precision(self): + """Verify that TIMESTAMP columns preserve microsecond precision. + Regression test for legacy issue baztian/jaydebeapi#229 where certain + microsecond values (e.g. 90000) were corrupted during the Arrow + conversion.""" + test_cases = [ + (2009, 9, 11, 10, 0, 0, 200000), + (2009, 9, 11, 10, 0, 1, 90000), + (2009, 9, 11, 10, 0, 2, 123456), + (2009, 9, 11, 10, 0, 3, 0), + (2009, 9, 11, 10, 0, 4, 999999), + ] + stmt = ("insert into ACCOUNT (ACCOUNT_ID, ACCOUNT_NO, BALANCE) " + "values (?, ?, ?)") + with self.conn.cursor() as cursor: + for idx, (y, mo, d, h, mi, s, us) in enumerate(test_cases): + ts = self.dbapi.Timestamp(y, mo, d, h, mi, s, us) + cursor.execute(stmt, (ts, 50 + idx, Decimal('1.0'))) + cursor.execute( + "select ACCOUNT_ID from ACCOUNT " + "where ACCOUNT_NO >= 50 order by ACCOUNT_NO") + results = cursor.fetchall() + for idx, (y, mo, d, h, mi, s, us) in enumerate(test_cases): + expected = self._cast_datetime( + f'{y}-{mo:02d}-{d:02d} {h:02d}:{mi:02d}:{s:02d}.{us:06d}', + r'%Y-%m-%d %H:%M:%S.%f') + self.assertEqual(results[idx][0], expected, + f"Failed for microseconds={us}") + + def test_binary_non_utf8_roundtrip(self): + """Verify that binary data containing non-UTF-8 bytes round-trips + correctly through the Arrow path. Regression test for legacy issue + baztian/jaydebeapi#147 where binary data was incorrectly decoded as + UTF-8 strings, corrupting byte values >= 0x80.""" + test_data = bytes([0x00, 0x01, 0x02, 0x80, 0xff, 0xfe]) + stmt = ("insert into ACCOUNT (ACCOUNT_ID, ACCOUNT_NO, BALANCE, " + "STUFF) values (?, ?, ?, ?)") + account_id = self.dbapi.Timestamp(2009, 9, 11, 14, 15, 22, 123450) + stuff = self.dbapi.Binary(test_data) + parms = (account_id, 20, 13.1, stuff) + with self.conn.cursor() as cursor: + cursor.execute(stmt, parms) + cursor.execute("select STUFF from ACCOUNT where ACCOUNT_NO = ?", + (20,)) + result = cursor.fetchone() + value = result[0] + self.assertEqual(bytes(value), test_data) + + def test_blob_non_utf8_roundtrip(self): + """Verify BLOB columns preserve non-UTF-8 bytes through Arrow path. + Regression test for legacy issue baztian/jaydebeapi#76 where BLOB + data returned as raw Java objects instead of Python bytes.""" + test_data = bytes([0x00, 0x01, 0x02, 0x80, 0xff, 0xfe]) + stmt = ("insert into ACCOUNT (ACCOUNT_ID, ACCOUNT_NO, BALANCE, " + "STUFF) values (?, ?, ?, ?)") + account_id = self.dbapi.Timestamp(2009, 9, 11, 14, 15, 22, 123450) + stuff = self.dbapi.Binary(test_data) + parms = (account_id, 20, 13.1, stuff) + with self.conn.cursor() as cursor: + cursor.execute(stmt, parms) + cursor.execute("select STUFF from ACCOUNT where ACCOUNT_NO = ?", + (20,)) + result = cursor.fetchone() + self.assertIsInstance(result[0], (bytes, memoryview)) + self.assertEqual(bytes(result[0]), test_data) + + def test_blob_all_byte_values_roundtrip(self): + """All 256 byte values should round-trip correctly through BLOB columns.""" + test_data = bytes(range(256)) + stmt = ("insert into ACCOUNT (ACCOUNT_ID, ACCOUNT_NO, BALANCE, " + "STUFF) values (?, ?, ?, ?)") + account_id = self.dbapi.Timestamp(2009, 9, 11, 14, 15, 22, 123450) + stuff = self.dbapi.Binary(test_data) + parms = (account_id, 21, 13.2, stuff) + with self.conn.cursor() as cursor: + cursor.execute(stmt, parms) + cursor.execute("select STUFF from ACCOUNT where ACCOUNT_NO = ?", + (21,)) + result = cursor.fetchone() + self.assertEqual(bytes(result[0]), test_data) + + def test_blob_null_value(self): + """NULL BLOB values should return None, not crash or return garbage.""" + stmt = ("insert into ACCOUNT (ACCOUNT_ID, ACCOUNT_NO, BALANCE, " + "STUFF) values (?, ?, ?, ?)") + account_id = self.dbapi.Timestamp(2009, 9, 11, 14, 15, 22, 123450) + parms = (account_id, 22, 13.3, None) + with self.conn.cursor() as cursor: + cursor.execute(stmt, parms) + cursor.execute("select STUFF from ACCOUNT where ACCOUNT_NO = ?", + (22,)) + result = cursor.fetchone() + self.assertIsNone(result[0]) + + def test_numeric_types(self): + """Test that NUMERIC columns round-trip correctly, including NULL values + and edge-case precision/scale values.""" + create_table = self._numeric_create_table_sql() + with self.conn.cursor() as cursor: + cursor.execute(create_table) + # Insert NULL numeric value + cursor.execute( + "INSERT INTO NUMERIC_TEST (ID, NUM_COL) VALUES (1, NULL)") + # Insert a regular numeric value + cursor.execute( + "INSERT INTO NUMERIC_TEST (ID, NUM_COL) VALUES (2, 99.99)") + # Insert an integer-like numeric value + cursor.execute( + "INSERT INTO NUMERIC_TEST (ID, NUM_COL) VALUES (3, 100.00)") + # Read back only the numeric column to avoid ID type differences + cursor.execute("SELECT NUM_COL FROM NUMERIC_TEST ORDER BY ID") + result = cursor.fetchall() + self.assertEqual(len(result), 3) + self.assertIsNone(result[0][0]) # NULL + self.assertEqual(result[1][0], Decimal('99.99')) + self.assertEqual(result[2][0], Decimal('100.00')) + + def test_bigint_column_returns_int(self): + """Verify JDBC BIGINT columns return Python int, not raw java.lang.Long. + Regression test for legacy baztian/jaydebeapi#63.""" + if type(self).__name__.startswith(('OracleTest', 'DrillTest')): + self.skipTest('BIGINT type not supported by this database') + with self.conn.cursor() as cursor: + cursor.execute("CREATE TABLE BIGINT_TEST (val BIGINT)") + try: + cursor.execute("INSERT INTO BIGINT_TEST VALUES (0)") + cursor.execute("INSERT INTO BIGINT_TEST VALUES (377518399)") + cursor.execute("INSERT INTO BIGINT_TEST VALUES (-9223372036854775808)") + cursor.execute("INSERT INTO BIGINT_TEST VALUES (9223372036854775807)") + cursor.execute("SELECT val FROM BIGINT_TEST ORDER BY val") + result = cursor.fetchall() + finally: + cursor.execute("DROP TABLE BIGINT_TEST") + self.assertEqual(len(result), 4) + for row in result: + self.assertIsInstance(row[0], int) + self.assertEqual(result[0][0], -9223372036854775808) + self.assertEqual(result[1][0], 0) + self.assertEqual(result[2][0], 377518399) + self.assertEqual(result[3][0], 9223372036854775807) + + def test_double_column_returns_float(self): + """Verify JDBC DOUBLE columns return Python float, not raw java.lang.Double. + Regression test for legacy baztian/jaydebeapi#243.""" + with self.conn.cursor() as cursor: + cursor.execute(self._double_create_sql()) + try: + self._double_populate(cursor) + cursor.execute("SELECT val FROM DOUBLE_TEST ORDER BY val") + result = cursor.fetchall() + finally: + cursor.execute("DROP TABLE DOUBLE_TEST") + self.assertEqual(len(result), 3) + for row in result: + self.assertIsInstance(row[0], float) + self.assertAlmostEqual(result[0][0], -1.5) + self.assertAlmostEqual(result[1][0], 0.0) + self.assertAlmostEqual(result[2][0], 3.14) + + def _double_populate(self, cursor): + cursor.execute("INSERT INTO DOUBLE_TEST VALUES (3.14)") + cursor.execute("INSERT INTO DOUBLE_TEST VALUES (-1.5)") + cursor.execute("INSERT INTO DOUBLE_TEST VALUES (0.0)") + + def test_numeric_precision_scale_combos(self): + """Test various DECIMAL/NUMERIC precision/scale combinations.""" + with self.conn.cursor() as cursor: + cursor.execute(self._numeric_combo_create_sql()) + cursor.execute(self._numeric_combo_insert_sql()) + cursor.execute("SELECT DEC_S2, DEC_S4, DEC_S0, DEC_PES, " + "NUM_S2, NUM_S0, NUM_S4, NUM_PES, NUM_NEG " + "FROM NUMERIC_COMBO ORDER BY ID") + result = cursor.fetchone() + self.assertEqual(result[0], Decimal('12345.67')) # DECIMAL(10, 2) + self.assertEqual(result[1], Decimal('12345.6789')) # DECIMAL(15, 4) + self.assertEqual(result[2], Decimal('987654321012345678')) # DECIMAL(18, 0) + self.assertEqual(result[3], Decimal('0.12345')) # DECIMAL(5, 5) + self.assertEqual(result[4], Decimal('99.99')) # NUMERIC(10, 2) + self.assertEqual(result[5], Decimal('42')) # NUMERIC(10, 0) + self.assertEqual(result[6], Decimal('12345.6789')) # NUMERIC(15, 4) + self.assertEqual(result[7], Decimal('0.1234')) # NUMERIC(4, 4) + self.assertEqual(result[8], Decimal('-99.99')) # NUMERIC(10, 2) + + def _numeric_combo_create_sql(self): + return ( + "CREATE TABLE NUMERIC_COMBO (" + "ID INTEGER NOT NULL, " + "DEC_S2 DECIMAL(10, 2), " + "DEC_S4 DECIMAL(15, 4), " + "DEC_S0 DECIMAL(18, 0), " + "DEC_PES DECIMAL(5, 5), " + "NUM_S2 NUMERIC(10, 2), " + "NUM_S0 NUMERIC(10, 0), " + "NUM_S4 NUMERIC(15, 4), " + "NUM_PES NUMERIC(4, 4), " + "NUM_NEG NUMERIC(10, 2), " + "PRIMARY KEY (ID))" + ) + + def _numeric_combo_insert_sql(self): + return ( + "INSERT INTO NUMERIC_COMBO " + "(ID, DEC_S2, DEC_S4, DEC_S0, DEC_PES, " + "NUM_S2, NUM_S0, NUM_S4, NUM_PES, NUM_NEG) " + "VALUES (1, 12345.67, 12345.6789, 987654321012345678, 0.12345, " + "99.99, 42, 12345.6789, 0.1234, -99.99)" + ) + + def _numeric_create_table_sql(self): + return ( + "CREATE TABLE NUMERIC_TEST (" + "ID INTEGER NOT NULL, " + "NUM_COL NUMERIC(10, 2), " + "PRIMARY KEY (ID))" + ) + + def _numeric_teardown(self): + with self.conn.cursor() as cursor: + try: + cursor.execute("DROP TABLE NUMERIC_TEST") + except Exception: + pass + try: + cursor.execute("DROP TABLE NUMERIC_COMBO") + except Exception: + pass + + def _double_create_sql(self): + return "CREATE TABLE DOUBLE_TEST (val DOUBLE)" + + def test_description_returns_column_alias(self): + """cursor.description should return the AS alias, not the table column name.""" + with self.conn.cursor() as cursor: + cursor.execute('SELECT ACCOUNT_NO AS "ACCT_NUM" FROM ACCOUNT') + self.assertEqual(cursor.description[0][0], "ACCT_NUM") + + def test_execute_param_none(self): + """Verify that Python None round-trips as SQL NULL via parameter binding.""" + stmt = "insert into ACCOUNT (ACCOUNT_ID, ACCOUNT_NO, BALANCE, BLOCKING) " \ + "values (?, ?, ?, ?)" + account_id = self.dbapi.Timestamp(2010, 1, 26, 14, 31, 59) + with self.conn.cursor() as cursor: + cursor.execute(stmt, (account_id, 30, Decimal('5.0'), None)) + cursor.execute("select BLOCKING from ACCOUNT where ACCOUNT_NO = 30") + result = cursor.fetchone() + self.assertIsNone(result[0]) + + def test_varchar_non_ascii_roundtrip(self): + """Verify that VARCHAR columns containing non-ASCII characters + round-trip correctly through the Arrow path. Regression test for + legacy issue baztian/jaydebeapi#176 where reading VARCHAR columns + with umlauts caused CharConversionException.""" + test_cases = [ + "Grüße aus München", + "café — résumé", + "こんにちは", + "Hello 🌍", + ] + stmt = ("insert into ACCOUNT (ACCOUNT_ID, ACCOUNT_NO, BALANCE, " + "PRODUCT_NAME) values (?, ?, ?, ?)") + with self.conn.cursor() as cursor: + for idx, text in enumerate(test_cases): + ts = self.dbapi.Timestamp(2024, 1, 15, 10, 0, 0, idx * 100000) + cursor.execute(stmt, (ts, 50 + idx, Decimal('1.0'), text)) + cursor.execute( + "select PRODUCT_NAME from ACCOUNT " + "where ACCOUNT_NO >= 50 order by ACCOUNT_NO") + results = cursor.fetchall() + for idx, text in enumerate(test_cases): + self.assertEqual(results[idx][0], text, + f"Failed for text: {text!r}") + + def test_long_query_string_18k_characters(self): + """SQL queries with 18k+ characters must execute correctly. + Regression test for baztian/jaydebeapi#91 where long queries + caused failures in the legacy codebase.""" + long_query = ("SELECT ACCOUNT_NO FROM ACCOUNT WHERE ACCOUNT_NO IN (" + + ",".join(str(i) for i in range(5000)) + ")") + self.assertGreater(len(long_query), 18000, + "Test query must exceed 18k characters") + with self.conn.cursor() as cursor: + cursor.execute(long_query) + result = cursor.fetchall() + self.assertIsInstance(result, list) + self.assertEqual(len(result), 2, + "Both ACCOUNT rows (18, 19) should match the IN clause") + returned_ids = sorted(row[0] for row in result) + self.assertEqual(returned_ids, [18, 19]) + + def test_iterator_closed_after_fetchall(self): + """After fetchall exhausts the result set, the Arrow iterator should + be closed and nulled out (memory leak regression, legacy #227).""" + with self.conn.cursor() as cursor: + cursor.execute("SELECT * FROM ACCOUNT") + cursor.fetchall() + self.assertIsNone(cursor._iter) + + def test_iterator_closed_after_fetchone_exhaustion(self): + """After fetchone exhausts the result set, iterator should be closed.""" + with self.conn.cursor() as cursor: + cursor.execute("SELECT COUNT(*) FROM ACCOUNT") + cursor.fetchone() + result = cursor.fetchone() + self.assertIsNone(result) + self.assertIsNone(cursor._iter) + + def test_iterator_closed_after_fetchmany_exhaustion(self): + """After fetchmany exhausts the result set, iterator should be closed.""" + with self.conn.cursor() as cursor: + cursor.execute("SELECT * FROM ACCOUNT") + cursor.fetchmany(size=1000) + self.assertIsNone(cursor._iter) + + def test_repeated_query_cycles_release_resources(self): + """Repeated execute/fetchall cycles should not accumulate iterators + or buffers (memory leak regression, legacy #227).""" + with self.conn.cursor() as cursor: + for _ in range(5): + cursor.execute("SELECT * FROM ACCOUNT") + result = cursor.fetchall() + self.assertTrue(len(result) > 0) + self.assertIsNone(cursor._iter) + self.assertEqual(cursor._buffer, []) + + def test_timestamp_utc_roundtrip_no_timezone_shift(self): + """Verify TIMESTAMP values round-trip without timezone shifting. + + Regression test for baztian/jaydebeapi#73. Legacy jaydebeapi returned + timestamps in the JVM's local timezone instead of UTC. This test + inserts specific timestamp values via parameter binding and verifies + they are returned as naive datetime objects with exact values — no + timezone offset applied. + """ + test_cases = [ + (self.dbapi.Timestamp(2024, 1, 15, 0, 0, 0), + "UTC midnight — legacy bug would shift to previous day in EST"), + (self.dbapi.Timestamp(2024, 6, 15, 14, 30, 0, 123456), + "midday with microseconds"), + (self.dbapi.Timestamp(2024, 12, 31, 23, 59, 59, 999999), + "end-of-day edge case — legacy bug could roll over to next day"), + ] + stmt = ("insert into ACCOUNT (ACCOUNT_ID, ACCOUNT_NO, BALANCE) " + "values (?, ?, ?)") + with self.conn.cursor() as cursor: + for idx, (ts, _desc) in enumerate(test_cases): + cursor.execute(stmt, (ts, 100 + idx, Decimal('1.0'))) + cursor.execute( + "select ACCOUNT_ID from ACCOUNT " + "where ACCOUNT_NO >= 100 order by ACCOUNT_NO") + results = cursor.fetchall() + for idx, (ts, desc) in enumerate(test_cases): + with self.subTest(desc=desc): + self.assertEqual(results[idx][0], ts) + self.assertIsNone(results[idx][0].tzinfo, + "TIMESTAMP must return naive datetime") + + def test_varchar_columns_return_data(self): + """Verify VARCHAR columns return actual data, not empty strings. + + Regression test for legacy issue #119 where Oracle 9i VARCHAR2 columns + returned empty strings while numeric fields worked fine. The original + jaydebeapi used getObject() which could return driver-specific types + (e.g., oracle.sql.CHAR) that JPype couldn't convert. jaydebeapiarrow's + Arrow JDBC adapter uses getString() for VARCHAR columns, which always + returns a proper java.lang.String. + """ + with self.conn.cursor() as cursor: + cursor.execute( + "INSERT INTO ACCOUNT " + "(ACCOUNT_ID, ACCOUNT_NO, BALANCE, PRODUCT_NAME) " + "VALUES ('2010-01-01 00:00:00.000000', 100, 99.99, 'Savings Account')" + ) + cursor.execute( + "INSERT INTO ACCOUNT " + "(ACCOUNT_ID, ACCOUNT_NO, BALANCE, PRODUCT_NAME) " + "VALUES ('2010-01-02 00:00:00.000000', 101, 0.00, 'Checking Account')" + ) + cursor.execute( + "SELECT ACCOUNT_NO, BALANCE, PRODUCT_NAME " + "FROM ACCOUNT WHERE ACCOUNT_NO >= 100 ORDER BY ACCOUNT_NO" + ) + result = cursor.fetchall() + self.assertEqual(len(result), 2) + self.assertEqual(result[0][0], 100) + self.assertEqual(result[0][1], Decimal('99.99')) + self.assertIsInstance(result[0][2], str) + self.assertEqual(result[0][2], 'Savings Account') + self.assertNotEqual(result[0][2], '') + self.assertEqual(result[1][2], 'Checking Account') + + def test_commit_with_autocommit_enabled(self): + """commit() should not raise when autocommit is enabled.""" + self.conn.jconn.setAutoCommit(True) + self.conn.commit() + + def test_commit_with_autocommit_disabled(self): + """commit() should succeed normally when autocommit is disabled.""" + self.conn.jconn.setAutoCommit(False) + self.conn.commit() + + def test_rollback_with_autocommit_enabled(self): + """rollback() should not raise when autocommit is enabled.""" + self.conn.jconn.setAutoCommit(True) + self.conn.rollback() + + def test_rollback_with_autocommit_disabled(self): + """rollback() should succeed normally when autocommit is disabled.""" + self.conn.jconn.setAutoCommit(False) + self.conn.rollback() + + +class SqliteTestBase(IntegrationTestBase): + + def setUpSql(self): + self.sql_file(os.path.join(_THIS_DIR, 'data', 'create.sql')) + self.sql_file(os.path.join(_THIS_DIR, 'data', 'insert.sql')) diff --git a/test/data/create_mssql.sql b/test/data/create_mssql.sql index 431d4ae9..d2e3d837 100644 --- a/test/data/create_mssql.sql +++ b/test/data/create_mssql.sql @@ -7,7 +7,7 @@ DBL_COL FLOAT, OPENED_AT DATE, OPENED_AT_TIME TIME, VALID BIT, -PRODUCT_NAME VARCHAR(50), +PRODUCT_NAME NVARCHAR(50), STUFF VARBINARY(MAX), primary key (ACCOUNT_ID) ); diff --git a/test/data/drill/bootstrap-storage.sh b/test/data/drill/bootstrap-storage.sh new file mode 100755 index 00000000..79c64c65 --- /dev/null +++ b/test/data/drill/bootstrap-storage.sh @@ -0,0 +1,24 @@ +#!/bin/bash +# Configure Drill dfs.tmp workspace with parquet as default format. +# Without this, CTAS fails on empty directories with +# "No default format is set on the queried workspace". +set -e + +DRILL_URL="${DRILL_URL:-http://localhost:18047}" + +# Wait for Drill REST API to be ready +for i in $(seq 1 30); do + if curl -sf "$DRILL_URL/storage.json" >/dev/null 2>&1; then + break + fi + sleep 2 +done + +# Get current dfs config, patch defaultInputFormat, and post back +curl -sf "$DRILL_URL/storage/dfs.json" | \ + python3 -c " +import json, sys +config = json.load(sys.stdin) +config['config']['workspaces']['tmp']['defaultInputFormat'] = 'parquet' +json.dump(config, sys.stdout) +" | curl -sf -X POST -H 'Content-Type: application/json' -d @- "$DRILL_URL/storage/dfs.json" >/dev/null diff --git a/test/test_db2.py b/test/test_db2.py new file mode 100644 index 00000000..c5b5790b --- /dev/null +++ b/test/test_db2.py @@ -0,0 +1,77 @@ +#-*- coding: utf-8 -*- + +import jaydebeapiarrow +import os +import unittest + +from decimal import Decimal +try: + from test._base import IntegrationTestBase, _THIS_DIR, _SUPPRESS_LOGGING_ARGS +except ImportError: + from _base import IntegrationTestBase, _THIS_DIR, _SUPPRESS_LOGGING_ARGS + + +class DB2Test(IntegrationTestBase, unittest.TestCase): + + def connect(self): + + import jpype + + host = os.environ.get("JY_DB2_HOST", "localhost") + port = os.environ.get("JY_DB2_PORT", "15000") + user = os.environ.get("JY_DB2_USER", "db2inst1") + password = os.environ.get("JY_DB2_PASSWORD", "Password123!") + + driver, url, driver_args = ( + 'com.ibm.db2.jcc.DB2Driver', + f'jdbc:db2://{host}:{port}/test_db', + {'user': user, 'password': password} + ) + + try: + db, conn = jaydebeapiarrow, jaydebeapiarrow.connect( + driver, url, driver_args, + experimental={'jvm_args': _SUPPRESS_LOGGING_ARGS}) + except jpype.JException: + self.fail("Can not connect with DB2. Please check if the instance is up and running.") + else: + return db, conn + + def setUpSql(self): + self.sql_file(os.path.join(_THIS_DIR, 'data', 'create_db2.sql')) + self.sql_file(os.path.join(_THIS_DIR, 'data', 'insert.sql')) + + def test_execute_types(self): + """DB2 uses SMALLINT instead of BOOLEAN — VALID returns int not bool.""" + stmt = "insert into ACCOUNT (ACCOUNT_ID, ACCOUNT_NO, BALANCE, " \ + "BLOCKING, DBL_COL, OPENED_AT, VALID, PRODUCT_NAME) " \ + "values (?, ?, ?, ?, ?, ?, ?, ?)" + account_id = self.dbapi.Timestamp(2010, 1, 26, 14, 31, 59) + account_no = 20 + balance = Decimal('1.2') + blocking = 10.0 + dbl_col = 3.5 + opened_at = self.dbapi.Date(1908, 2, 27) + valid = 1 + product_name = u'Savings account' + parms = (account_id, account_no, balance, blocking, dbl_col, + opened_at, valid, product_name) + with self.conn.cursor() as cursor: + cursor.execute(stmt, parms) + stmt = "select ACCOUNT_ID, ACCOUNT_NO, BALANCE, BLOCKING, " \ + "DBL_COL, OPENED_AT, VALID, PRODUCT_NAME " \ + "from ACCOUNT where ACCOUNT_NO = ?" + parms = (20, ) + cursor.execute(stmt, parms) + result = cursor.fetchone() + exp = ( + self._cast_datetime('2010-01-26 14:31:59', r'%Y-%m-%d %H:%M:%S'), + account_no, balance, blocking, dbl_col, + self._cast_date('1908-02-27', r'%Y-%m-%d'), + valid, product_name + ) + self.assertEqual(result, exp) + + def test_blob_null_value(self): + """DB2 rejects NULL for VARBINARY parameter binding.""" + self.skipTest("DB2 does not support NULL for VARBINARY parameter binding") diff --git a/test/test_drill.py b/test/test_drill.py new file mode 100644 index 00000000..a3319005 --- /dev/null +++ b/test/test_drill.py @@ -0,0 +1,379 @@ +#-*- coding: utf-8 -*- + +import jaydebeapiarrow +import calendar +import os +import unittest + +from decimal import Decimal +from datetime import datetime, timedelta +try: + from test._base import IntegrationTestBase, _THIS_DIR, _SUPPRESS_LOGGING_ARGS +except ImportError: + from _base import IntegrationTestBase, _THIS_DIR, _SUPPRESS_LOGGING_ARGS + + +class DrillTest(IntegrationTestBase, unittest.TestCase): + + def connect(self): + + import jpype + + host = os.environ.get("JY_DRILL_HOST", "localhost") + port = os.environ.get("JY_DRILL_PORT", "31010") + + driver, url, driver_args = ( + 'org.apache.drill.jdbc.Driver', + f'jdbc:drill:drillbit={host}:{port}', + None + ) + + try: + db, conn = jaydebeapiarrow, jaydebeapiarrow.connect( + driver, url, driver_args, + experimental={'jvm_args': _SUPPRESS_LOGGING_ARGS}) + except jpype.JException: + self.fail("Can not connect with Drill. Please check if the instance is up and running.") + else: + return db, conn + + def _cast_datetime(self, datetime_str, fmt=r'%Y-%m-%d %H:%M:%S'): + """Drill stores TIMESTAMP as UTC and shifts by JVM timezone on read.""" + dt = super()._cast_datetime(datetime_str, fmt) + import jpype + tz = jpype.JClass('java.util.TimeZone').getDefault() + epoch_ms = int(calendar.timegm(dt.timetuple())) * 1000 + offset_ms = tz.getOffset(epoch_ms) + return dt + timedelta(milliseconds=-offset_ms) + + def setUpSql(self): + jstmt = self.conn.jconn.createStatement() + try: + jstmt.execute("DROP TABLE IF EXISTS dfs.tmp.account") + except Exception: + pass + sql = open(os.path.join(_THIS_DIR, 'data', 'create_drill.sql')).read().strip().rstrip(';') + jstmt.execute(sql) + + def tearDown(self): + jstmt = self.conn.jconn.createStatement() + try: + jstmt.execute("DROP TABLE IF EXISTS dfs.tmp.account") + except Exception: + pass + try: + jstmt.execute("DROP TABLE IF EXISTS dfs.tmp.numeric_test") + except Exception: + pass + try: + jstmt.execute("DROP TABLE IF EXISTS dfs.tmp.blob_test") + except Exception: + pass + try: + jstmt.execute("DROP TABLE IF EXISTS dfs.tmp.numeric_combo") + except Exception: + pass + self.conn.close() + + def _query_table(self, cursor): + cursor.execute("select ACCOUNT_ID, ACCOUNT_NO, BALANCE, BLOCKING " + "from dfs.tmp.account") + + def test_double_column_returns_float(self): + """Drill: use direct JDBC for DDL, cursor for SELECT.""" + jstmt = self.conn.jconn.createStatement() + try: + jstmt.execute( + "CREATE TABLE dfs.tmp.DOUBLE_TEST AS " + "SELECT CAST(c1 AS DOUBLE) AS val FROM " + "(VALUES(3.14), (-1.5), (0.0)) AS t(c1)" + ) + except Exception: + jstmt.execute("DROP TABLE IF EXISTS dfs.tmp.DOUBLE_TEST") + raise + try: + with self.conn.cursor() as cursor: + cursor.execute("SELECT val FROM dfs.tmp.DOUBLE_TEST ORDER BY val") + result = cursor.fetchall() + finally: + jstmt.execute("DROP TABLE IF EXISTS dfs.tmp.DOUBLE_TEST") + self.assertEqual(len(result), 3) + for row in result: + self.assertIsInstance(row[0], float) + self.assertAlmostEqual(result[0][0], -1.5) + self.assertAlmostEqual(result[1][0], 0.0) + self.assertAlmostEqual(result[2][0], 3.14) + + def test_executemany(self): + """Drill has no INSERT INTO ... VALUES — skip executemany test.""" + self.skipTest("Drill does not support INSERT INTO ... VALUES") + + def test_execute_types(self): + """Drill preserves DECIMAL scale; data seeded via CTAS, no INSERT.""" + with self.conn.cursor() as cursor: + cursor.execute( + "SELECT ACCOUNT_ID, ACCOUNT_NO, BALANCE, BLOCKING, " + "DBL_COL, OPENED_AT, VALID, PRODUCT_NAME " + "FROM dfs.tmp.account WHERE ACCOUNT_NO = 20") + result = cursor.fetchone() + exp = ( + self._cast_datetime('2010-01-26 14:31:59', r'%Y-%m-%d %H:%M:%S'), + 20, Decimal('1.20'), Decimal('10.00'), 3.5, + self._cast_date('2024-01-15', r'%Y-%m-%d'), + True, 'Savings account' + ) + self.assertEqual(result, exp) + + def test_execute_type_time(self): + """Drill: TIME data seeded via CTAS, no INSERT needed.""" + with self.conn.cursor() as cursor: + cursor.execute( + "SELECT ACCOUNT_ID, ACCOUNT_NO, BALANCE, OPENED_AT_TIME " + "FROM dfs.tmp.account WHERE ACCOUNT_NO = 20") + result = cursor.fetchone() + exp = ( + self._cast_datetime('2010-01-26 14:31:59', r'%Y-%m-%d %H:%M:%S'), + 20, Decimal('1.20'), + self._cast_time('13:59:59', r'%H:%M:%S') + ) + self.assertEqual(result, exp) + + def test_execute_type_blob(self): + """Drill: seed VARBINARY via separate CTAS, verify read path.""" + jstmt = self.conn.jconn.createStatement() + jstmt.execute('DROP TABLE IF EXISTS dfs.tmp.blob_test') + jstmt.execute( + "CREATE TABLE dfs.tmp.blob_test AS " + "SELECT CAST('abcdef' AS VARBINARY) AS STUFF FROM (VALUES(1))") + with self.conn.cursor() as cursor: + cursor.execute("SELECT STUFF FROM dfs.tmp.blob_test") + result = cursor.fetchone() + binary_stuff = b'abcdef' + self.assertEqual(result[0], memoryview(binary_stuff)) + + def test_binary_non_utf8_roundtrip(self): + """Drill does not support CTAS with VARBINARY hex literals or + parameterized INSERT for binary data with non-UTF-8 bytes.""" + self.skipTest("Drill cannot create VARBINARY with non-UTF-8 bytes via CTAS") + + def test_numeric_types(self): + """Drill: seed NUMERIC_TEST via CTAS, then verify round-trip.""" + jstmt = self.conn.jconn.createStatement() + jstmt.execute('DROP TABLE IF EXISTS dfs.tmp.numeric_test') + jstmt.execute( + "CREATE TABLE dfs.tmp.numeric_test AS " + "SELECT 1 AS ID, CAST(NULL AS DECIMAL(10, 2)) AS NUM_COL " + "UNION ALL " + "SELECT 2, CAST(99.99 AS DECIMAL(10, 2)) " + "UNION ALL " + "SELECT 3, CAST(100.00 AS DECIMAL(10, 2))") + with self.conn.cursor() as cursor: + cursor.execute( + "SELECT NUM_COL FROM dfs.tmp.numeric_test ORDER BY ID") + result = cursor.fetchall() + self.assertEqual(len(result), 3) + self.assertIsNone(result[0][0]) + self.assertEqual(result[1][0], Decimal('99.99')) + self.assertEqual(result[2][0], Decimal('100.00')) + + def test_numeric_precision_scale_combos(self): + """Drill: seed NUMERIC_COMBO via CTAS, then verify round-trip.""" + jstmt = self.conn.jconn.createStatement() + jstmt.execute('DROP TABLE IF EXISTS dfs.tmp.numeric_combo') + jstmt.execute( + "CREATE TABLE dfs.tmp.numeric_combo AS " + "SELECT 1 AS ID, " + "CAST(12345.67 AS DECIMAL(10, 2)) AS DEC_S2, " + "CAST(12345.6789 AS DECIMAL(15, 4)) AS DEC_S4, " + "CAST(987654321012345678 AS DECIMAL(18, 0)) AS DEC_S0, " + "CAST(0.12345 AS DECIMAL(5, 5)) AS DEC_PES, " + "CAST(99.99 AS DECIMAL(10, 2)) AS NUM_S2, " + "CAST(42 AS DECIMAL(10, 0)) AS NUM_S0, " + "CAST(12345.6789 AS DECIMAL(15, 4)) AS NUM_S4, " + "CAST(0.1234 AS DECIMAL(4, 4)) AS NUM_PES, " + "CAST(-99.99 AS DECIMAL(10, 2)) AS NUM_NEG") + with self.conn.cursor() as cursor: + cursor.execute("SELECT DEC_S2, DEC_S4, DEC_S0, DEC_PES, " + "NUM_S2, NUM_S0, NUM_S4, NUM_PES, NUM_NEG " + "FROM dfs.tmp.numeric_combo ORDER BY ID") + result = cursor.fetchone() + self.assertEqual(result[0], Decimal('12345.67')) + self.assertEqual(result[1], Decimal('12345.6789')) + self.assertEqual(result[2], Decimal('987654321012345678')) + self.assertEqual(result[3], Decimal('0.12345')) + self.assertEqual(result[4], Decimal('99.99')) + self.assertEqual(result[5], Decimal('42')) + self.assertEqual(result[6], Decimal('12345.6789')) + self.assertEqual(result[7], Decimal('0.1234')) + self.assertEqual(result[8], Decimal('-99.99')) + + 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_different_rowcounts(self): + """Drill has no INSERT INTO ... VALUES — skip rowcount test.""" + self.skipTest("Drill does not support INSERT INTO ... VALUES") + + def test_lastrowid_none_after_select(self): + """Drill uses different table schema — skip.""" + self.skipTest("Drill test schema differs from standard ACCOUNT table") + + def test_lastrowid_none_after_insert(self): + """Drill has no INSERT INTO ... VALUES — skip.""" + self.skipTest("Drill does not support INSERT INTO ... VALUES") + + def test_lastrowid_none_after_executemany(self): + """Drill has no INSERT INTO ... VALUES — skip.""" + self.skipTest("Drill does not support INSERT INTO ... VALUES") + + def test_execute_reset_description_without_execute_result(self): + """Drill has no DELETE — verify description reset with SELECT only.""" + with self.conn.cursor() as cursor: + cursor.execute("select * from dfs.tmp.account") + self.assertIsNotNone(cursor.description) + cursor.fetchone() + + def test_execute_and_fetch(self): + with self.conn.cursor() as cursor: + cursor.execute("select ACCOUNT_ID, ACCOUNT_NO, BALANCE, BLOCKING " + "from dfs.tmp.account WHERE ACCOUNT_NO <= 19") + result = cursor.fetchall() + self.assertEqual(result, [ + ( + self._cast_datetime('2009-09-10 14:15:22.123', r'%Y-%m-%d %H:%M:%S.%f'), + 18, Decimal('12.40'), None), + ( + self._cast_datetime('2009-09-11 14:15:22.123', r'%Y-%m-%d %H:%M:%S.%f'), + 19, Decimal('12.90'), Decimal('1.00')) + ]) + + def test_execute_and_fetchone(self): + with self.conn.cursor() as cursor: + cursor.execute("select ACCOUNT_ID, ACCOUNT_NO, BALANCE, BLOCKING " + "from dfs.tmp.account WHERE ACCOUNT_NO <= 19 order by ACCOUNT_NO") + result = cursor.fetchone() + self.assertEqual(result, ( + self._cast_datetime('2009-09-10 14:15:22.123', r'%Y-%m-%d %H:%M:%S.%f'), + 18, Decimal('12.40'), None)) + cursor.close() + + def test_execute_and_fetchone_consecutive(self): + with self.conn.cursor() as cursor: + cursor.execute("select ACCOUNT_ID, ACCOUNT_NO, BALANCE, BLOCKING " + "from dfs.tmp.account WHERE ACCOUNT_NO <= 19 order by ACCOUNT_NO") + result1 = cursor.fetchone() + result2 = cursor.fetchone() + + self.assertEqual(result1, ( + self._cast_datetime('2009-09-10 14:15:22.123', r'%Y-%m-%d %H:%M:%S.%f'), + 18, Decimal('12.40'), None)) + + self.assertEqual(result2, ( + self._cast_datetime('2009-09-11 14:15:22.123', r'%Y-%m-%d %H:%M:%S.%f'), + 19, Decimal('12.90'), Decimal('1.00'))) + + def test_execute_and_fetch_no_data(self): + with self.conn.cursor() as cursor: + stmt = "select * from dfs.tmp.account where ACCOUNT_ID is null" + cursor.execute(stmt) + result = cursor.fetchall() + self.assertEqual(result, []) + + def test_execute_and_fetch_parameter(self): + """Drill does not support JDBC parameterized queries.""" + self.skipTest("Drill does not support prepared statement parameters") + + def test_execute_and_fetchone_after_end(self): + with self.conn.cursor() as cursor: + cursor.execute("select * from dfs.tmp.account where ACCOUNT_NO = 18") + cursor.fetchone() + result = cursor.fetchone() + self.assertIsNone(result) + + def test_execute_and_fetchmany(self): + with self.conn.cursor() as cursor: + cursor.execute("select ACCOUNT_ID, ACCOUNT_NO, BALANCE, BLOCKING " + "from dfs.tmp.account WHERE ACCOUNT_NO <= 19 order by ACCOUNT_NO") + result = cursor.fetchmany() + self.assertEqual(result, [ + ( + self._cast_datetime('2009-09-10 14:15:22.123', r'%Y-%m-%d %H:%M:%S.%f'), + 18, Decimal('12.40'), None) + ]) + + def test_timestamp_subsecond_leading_zeros(self): + """Drill does not support parameterized TIMESTAMP INSERT.""" + self.skipTest("Drill does not support parameterized TIMESTAMP INSERT") + + def test_timestamp_microsecond_precision(self): + """Drill does not support TIMESTAMP with microsecond INSERT via parameterized queries.""" + self.skipTest("Drill does not support parameterized TIMESTAMP INSERT") + + def test_blob_non_utf8_roundtrip(self): + """Drill does not support parameterized INSERT.""" + self.skipTest("Drill does not support parameterized INSERT queries") + + def test_blob_all_byte_values_roundtrip(self): + """Drill does not support parameterized INSERT.""" + self.skipTest("Drill does not support parameterized INSERT queries") + + def test_blob_null_value(self): + """Drill does not support parameterized INSERT.""" + self.skipTest("Drill does not support parameterized INSERT queries") + + def test_varchar_non_ascii_roundtrip(self): + """Drill does not support parameterized INSERT.""" + self.skipTest("Drill does not support parameterized INSERT queries") + + def test_timestamp_utc_roundtrip_no_timezone_shift(self): + """Drill does not support parameterized INSERT.""" + self.skipTest("Drill does not support parameterized INSERT queries") + + def test_varchar_columns_return_data(self): + """Drill does not support INSERT INTO ... VALUES.""" + self.skipTest("Drill does not support INSERT INTO ... VALUES") + + def test_iterator_closed_after_fetchall(self): + with self.conn.cursor() as cursor: + cursor.execute("SELECT * FROM dfs.tmp.account") + cursor.fetchall() + self.assertIsNone(cursor._iter) + + def test_iterator_closed_after_fetchone_exhaustion(self): + with self.conn.cursor() as cursor: + cursor.execute("SELECT COUNT(*) FROM dfs.tmp.account") + cursor.fetchone() + result = cursor.fetchone() + self.assertIsNone(result) + self.assertIsNone(cursor._iter) + + def test_iterator_closed_after_fetchmany_exhaustion(self): + with self.conn.cursor() as cursor: + cursor.execute("SELECT * FROM dfs.tmp.account") + cursor.fetchmany(size=1000) + self.assertIsNone(cursor._iter) + + def test_repeated_query_cycles_release_resources(self): + with self.conn.cursor() as cursor: + for _ in range(5): + cursor.execute("SELECT * FROM dfs.tmp.account") + result = cursor.fetchall() + self.assertTrue(len(result) > 0) + self.assertIsNone(cursor._iter) + self.assertEqual(cursor._buffer, []) + + def test_long_query_string_18k_characters(self): + long_query = ("SELECT ACCOUNT_NO FROM dfs.tmp.account WHERE ACCOUNT_NO IN (" + + ",".join(str(i) for i in range(5000)) + ")") + self.assertGreater(len(long_query), 18000) + with self.conn.cursor() as cursor: + cursor.execute(long_query) + result = cursor.fetchall() + self.assertIsInstance(result, list) + self.assertEqual(len(result), 3) + returned_ids = sorted(row[0] for row in result) + self.assertEqual(returned_ids, [18, 19, 20]) + + def test_description_returns_column_alias(self): + self.skipTest("Drill does not support quoted identifiers") diff --git a/test/test_hsqldb.py b/test/test_hsqldb.py new file mode 100644 index 00000000..7adac638 --- /dev/null +++ b/test/test_hsqldb.py @@ -0,0 +1,27 @@ +#-*- coding: utf-8 -*- + +import jaydebeapiarrow +import os +import unittest + +try: + from test._base import IntegrationTestBase, _THIS_DIR, _SUPPRESS_LOGGING_ARGS +except ImportError: + from _base import IntegrationTestBase, _THIS_DIR, _SUPPRESS_LOGGING_ARGS + + +class HsqldbTest(IntegrationTestBase, unittest.TestCase): + + def connect(self): + # http://hsqldb.org/ + # hsqldb.jar + driver, url, driver_args = ( 'org.hsqldb.jdbcDriver', + 'jdbc:hsqldb:mem:.', + ['SA', ''] ) + return jaydebeapiarrow, jaydebeapiarrow.connect( + driver, url, driver_args, + experimental={'jvm_args': _SUPPRESS_LOGGING_ARGS}) + + def setUpSql(self): + self.sql_file(os.path.join(_THIS_DIR, 'data', 'create_hsqldb.sql')) + self.sql_file(os.path.join(_THIS_DIR, 'data', 'insert.sql')) diff --git a/test/test_infrastructure.py b/test/test_infrastructure.py new file mode 100644 index 00000000..0869ffb4 --- /dev/null +++ b/test/test_infrastructure.py @@ -0,0 +1,619 @@ +#-*- coding: utf-8 -*- + +# Consolidated infrastructure tests that previously lived in both +# test_integration.py and test_mock.py with near-identical logic. +# +# Each test category has a base class parameterized by driver class, +# with concrete HSQLDB and MockDriver subclasses. + +import jaydebeapiarrow +import os +import shutil +import subprocess +import sys +import tempfile +import unittest + +try: + from test._base import _THIS_DIR, _SUPPRESS_LOGGING_ARGS +except ImportError: + from _base import _THIS_DIR, _SUPPRESS_LOGGING_ARGS + + +def _connect(*args, **kwargs): + """Wrapper that injects logging suppression JVM args on first connect.""" + kwargs.setdefault('experimental', {}) + kwargs['experimental'].setdefault('jvm_args', _SUPPRESS_LOGGING_ARGS) + return jaydebeapiarrow.connect(*args, **kwargs) + + +# --------------------------------------------------------------------------- +# Fork safety tests (legacy issue #232) +# --------------------------------------------------------------------------- + +class _ForkSafetyTestBase(object): + """Base class for fork-after-JVM-start guard tests.""" + + DRIVER_CLASS = None # override in subclass + JDBC_URL = None # override in subclass + DRIVER_ARGS = None # override in subclass + + def test_fork_after_connect_raises_interface_error(self): + """Simulating a fork by overwriting the PID tracker must raise + InterfaceError when attempting a new connection.""" + original_pid = jaydebeapiarrow._jvm_started_pid + try: + jaydebeapiarrow._jvm_started_pid = os.getpid() + 99999 + with self.assertRaises(jaydebeapiarrow.InterfaceError) as ctx: + _connect(self.DRIVER_CLASS, + self.JDBC_URL, self.DRIVER_ARGS) + self.assertIn("forked process", str(ctx.exception)) + finally: + jaydebeapiarrow._jvm_started_pid = original_pid + + def test_pid_recorded_after_connect(self): + """After connect(), _jvm_started_pid must equal the current PID.""" + c = _connect(self.DRIVER_CLASS, + self.JDBC_URL, self.DRIVER_ARGS) + try: + self.assertEqual(jaydebeapiarrow._jvm_started_pid, os.getpid()) + finally: + c.close() + + +class ForkSafetyHsqldbTest(_ForkSafetyTestBase, unittest.TestCase): + DRIVER_CLASS = 'org.hsqldb.jdbcDriver' + JDBC_URL = 'jdbc:hsqldb:mem:.' + DRIVER_ARGS = ['SA', ''] + + +class ForkSafetyMockTest(_ForkSafetyTestBase, unittest.TestCase): + DRIVER_CLASS = 'org.jaydebeapi.mockdriver.MockDriver' + JDBC_URL = 'jdbc:jaydebeapi://dummyurl' + DRIVER_ARGS = None + + +# --------------------------------------------------------------------------- +# JAR path with spaces tests (issue #86) +# --------------------------------------------------------------------------- + +class _JarPathSpacesTestBase(object): + """Base class for JAR file paths containing spaces.""" + + def _find_jar(self): + raise NotImplementedError + + def _driver_class(self): + raise NotImplementedError + + def _jdbc_url(self): + raise NotImplementedError + + def _driver_args(self): + return None + + def _run_connect_in_subprocess(self, jar_path): + """Run a connect call in a fresh subprocess and return success/failure.""" + driver = self._driver_class() + url = self._jdbc_url() + args = self._driver_args() + code = f''' +import jaydebeapiarrow +try: + conn = jaydebeapiarrow.connect( + {repr(driver)}, + {repr(url)}, + driver_args={repr(args)}, + jars={repr(jar_path)} + ) + print('OK') + conn.close() +except Exception as e: + print(f'FAIL: {{type(e).__name__}}: {{e}}') +''' + result = subprocess.run( + [sys.executable, '-c', code], + capture_output=True, text=True, timeout=30, + cwd=os.path.dirname(os.path.dirname(__file__)) + ) + return result.stdout.strip(), result.stderr.strip() + + def test_jar_path_with_spaces(self): + """JAR paths containing spaces should work (issue #86).""" + jar = self._find_jar() + with tempfile.TemporaryDirectory(prefix='path with spaces ') as tmpdir: + dest = os.path.join(tmpdir, os.path.basename(jar)) + shutil.copy2(jar, dest) + stdout, stderr = self._run_connect_in_subprocess(dest) + self.assertEqual(stdout, 'OK', f'Connection failed: {stderr}') + + def test_jar_path_with_special_chars(self): + """JAR paths containing parentheses and special chars should work.""" + jar = self._find_jar() + with tempfile.TemporaryDirectory(prefix='path (x86) & test ') as tmpdir: + dest = os.path.join(tmpdir, os.path.basename(jar)) + shutil.copy2(jar, dest) + stdout, stderr = self._run_connect_in_subprocess(dest) + self.assertEqual(stdout, 'OK', f'Connection failed: {stderr}') + + +class JarPathSpacesHsqldbTest(_JarPathSpacesTestBase, unittest.TestCase): + + def _find_jar(self): + jar_dir = os.path.join(_THIS_DIR, 'jars') + if not os.path.isdir(jar_dir): + self.skipTest('test/jars/ directory not found (run download_jdbc_drivers.sh)') + for f in os.listdir(jar_dir): + if 'hsqldb' in f.lower() and f.endswith('.jar'): + return os.path.join(jar_dir, f) + self.skipTest('HSQLDB JAR not found in test/jars/') + + def _driver_class(self): + return 'org.hsqldb.jdbcDriver' + + def _jdbc_url(self): + return 'jdbc:hsqldb:mem:.' + + def _driver_args(self): + return ['SA', ''] + + +class JarPathSpacesMockTest(_JarPathSpacesTestBase, unittest.TestCase): + + def _find_jar(self): + for root, dirs, files in os.walk(_THIS_DIR): + for f in files: + if f.startswith('mockdriver') and f.endswith('.jar'): + return os.path.join(root, f) + self.fail('mockdriver JAR not found') + + def _driver_class(self): + return 'org.jaydebeapi.mockdriver.MockDriver' + + def _jdbc_url(self): + return 'jdbc:jaydebeapi://dummyurl' + + +# --------------------------------------------------------------------------- +# Dynamic classpath tests +# --------------------------------------------------------------------------- + +class _DynamicClasspathTestBase(object): + """Base class for experimental dynamic_classpath feature.""" + + def _find_primary_jar(self): + raise NotImplementedError + + def _primary_driver_class(self): + raise NotImplementedError + + def _primary_jdbc_url(self): + raise NotImplementedError + + def _primary_driver_args(self): + return None + + def _run_in_subprocess(self, code): + """Run code in a fresh subprocess and return stdout, stderr.""" + result = subprocess.run( + [sys.executable, '-c', code], + capture_output=True, text=True, timeout=30, + cwd=os.path.dirname(os.path.dirname(__file__)) + ) + return result.stdout.strip(), result.stderr.strip() + + def test_dynamic_load_after_jvm_start(self): + """Connect with a driver JAR after JVM is already running (dynamic_classpath).""" + jar = self._find_primary_jar() + driver = self._primary_driver_class() + url = self._primary_jdbc_url() + args = self._primary_driver_args() + code = f''' +import jaydebeapiarrow + +# First connection starts the JVM normally +conn1 = jaydebeapiarrow.connect( + {repr(driver)}, + {repr(url)}, + driver_args={repr(args)} +) +conn1.close() + +# Second connection uses dynamic classpath to load the driver from JAR +conn2 = jaydebeapiarrow.connect( + {repr(driver)}, + {repr(url)}, + driver_args={repr(args)}, + jars={repr(jar)}, + experimental={{'dynamic_classpath': True}} +) +conn2.close() +print('OK') +''' + stdout, stderr = self._run_in_subprocess(code) + self.assertEqual(stdout, 'OK', f'Dynamic load failed: {stderr}') + + def test_dynamic_load_without_flag_raises_error(self): + """Without dynamic_classpath flag, connecting with new JARs after JVM + start should raise InterfaceError (fork guard).""" + jar = self._find_primary_jar() + driver = self._primary_driver_class() + url = self._primary_jdbc_url() + args = self._primary_driver_args() + code = f''' +import jaydebeapiarrow + +# Start JVM with first connection +conn1 = jaydebeapiarrow.connect( + {repr(driver)}, + {repr(url)}, + driver_args={repr(args)} +) +conn1.close() + +# Try connecting with explicit jars after JVM start — no experimental flag +try: + conn2 = jaydebeapiarrow.connect( + {repr(driver)}, + {repr(url)}, + driver_args={repr(args)}, + jars={repr(jar)} + ) + conn2.close() + print('NO_ERROR') +except jaydebeapiarrow.InterfaceError as e: + if 'forked process' in str(e): + print('FORK_ERROR') + else: + print(f'OTHER_INTERFACE_ERROR: {{e}}') +except Exception as e: + print(f'OTHER_ERROR: {{type(e).__name__}}: {{e}}') +''' + stdout, stderr = self._run_in_subprocess(code) + self.assertIn(stdout, ['OK', 'NO_ERROR', 'FORK_ERROR', 'OTHER_INTERFACE_ERROR'], + f'Unexpected output: {stdout}\nstderr: {stderr}') + + def test_dynamic_load_bypasses_fork_guard(self): + """dynamic_classpath flag bypasses the fork-after-JVM-start guard.""" + jar = self._find_primary_jar() + driver = self._primary_driver_class() + url = self._primary_jdbc_url() + args = self._primary_driver_args() + code = f''' +import jaydebeapiarrow, os + +# Start JVM +conn1 = jaydebeapiarrow.connect( + {repr(driver)}, + {repr(url)}, + driver_args={repr(args)} +) +conn1.close() + +# Simulate fork: change _jvm_started_pid to a different PID +jaydebeapiarrow._jvm_started_pid = os.getpid() + 99999 + +# Without flag — should raise +try: + conn2 = jaydebeapiarrow.connect( + {repr(driver)}, + {repr(url)}, + driver_args={repr(args)}, + jars={repr(jar)} + ) + print('NO_ERROR') +except jaydebeapiarrow.InterfaceError as e: + print('FORK_ERROR') + +# With flag — should succeed +try: + conn3 = jaydebeapiarrow.connect( + {repr(driver)}, + {repr(url)}, + driver_args={repr(args)}, + jars={repr(jar)}, + experimental={{'dynamic_classpath': True}} + ) + conn3.close() + print('DYNAMIC_OK') +except Exception as e: + print(f'DYNAMIC_FAIL: {{type(e).__name__}}: {{e}}') +''' + stdout, stderr = self._run_in_subprocess(code) + lines = stdout.split('\n') + self.assertEqual(lines[0], 'FORK_ERROR', + f'Expected fork error without flag, got: {stdout}\nstderr: {stderr}') + self.assertEqual(lines[1], 'DYNAMIC_OK', + f'Dynamic load should bypass fork guard, got: {stdout}\nstderr: {stderr}') + + +class DynamicClasspathHsqldbTest(_DynamicClasspathTestBase, unittest.TestCase): + """Integration test with real HSQLDB driver.""" + + def _find_primary_jar(self): + jar_dir = os.path.join(_THIS_DIR, 'jars') + if not os.path.isdir(jar_dir): + self.skipTest('test/jars/ directory not found (run download_jdbc_drivers.sh)') + for f in os.listdir(jar_dir): + if 'hsqldb' in f.lower() and f.endswith('.jar'): + return os.path.join(jar_dir, f) + self.skipTest('HSQLDB JAR not found in test/jars/') + + def _primary_driver_class(self): + return 'org.hsqldb.jdbcDriver' + + def _primary_jdbc_url(self): + return 'jdbc:hsqldb:mem:.' + + def _primary_driver_args(self): + return ['SA', ''] + + def test_hsqldb_fails_without_dynamic_classpath(self): + """Connecting to HSQLDB after JVM starts with only mock driver on classpath + should fail — the HSQLDB driver is not available.""" + hsqldb_jar = self._find_primary_jar() + mock_dir = os.path.join(_THIS_DIR, 'mock-jars') + mock_jar = None + for f in os.listdir(mock_dir): + if f.startswith('mockdriver') and f.endswith('.jar'): + mock_jar = os.path.join(mock_dir, f) + break + if not mock_jar: + self.skipTest('mockdriver JAR not found') + + env = {**os.environ, 'CLASSPATH': mock_jar} + code = f''' +import jaydebeapiarrow + +# Start JVM with only the mock driver available +conn1 = jaydebeapiarrow.connect( + 'org.jaydebeapi.mockdriver.MockDriver', + 'jdbc:jaydebeapi://dummyurl' +) +conn1.close() + +# Try to connect to HSQLDB without dynamic classpath — should fail +try: + conn2 = jaydebeapiarrow.connect( + 'org.hsqldb.jdbcDriver', + 'jdbc:hsqldb:mem:.', + ['SA', ''] + ) + conn2.close() + print('UNEXPECTED_SUCCESS') +except Exception as e: + print(f'EXPECTED_FAIL: {{type(e).__name__}}') +''' + result = subprocess.run( + [sys.executable, '-c', code], + capture_output=True, text=True, timeout=30, + cwd=os.path.dirname(os.path.dirname(__file__)), + env=env + ) + self.assertTrue(result.stdout.strip().startswith('EXPECTED_FAIL'), + f'HSQLDB should fail without dynamic classpath.\n' + f'stdout: {result.stdout}\nstderr: {result.stderr}') + + def test_dynamic_load_hsqldb_after_jvm_start(self): + """Dynamically load HSQLDB driver after JVM is already running.""" + hsqldb_jar = self._find_primary_jar() + mock_dir = os.path.join(_THIS_DIR, 'mock-jars') + mock_jar = None + for f in os.listdir(mock_dir): + if f.startswith('mockdriver') and f.endswith('.jar'): + mock_jar = os.path.join(mock_dir, f) + break + if not mock_jar: + self.skipTest('mockdriver JAR not found') + + env = {**os.environ, 'CLASSPATH': mock_jar} + code = f''' +import jaydebeapiarrow + +# Start JVM with only the mock driver on the classpath +conn1 = jaydebeapiarrow.connect( + 'org.jaydebeapi.mockdriver.MockDriver', + 'jdbc:jaydebeapi://dummyurl' +) +conn1.close() + +# Verify HSQLDB is NOT available yet +try: + conn_bad = jaydebeapiarrow.connect( + 'org.hsqldb.jdbcDriver', + 'jdbc:hsqldb:mem:.', + ['SA', ''] + ) + conn_bad.close() + print('HSQQLDB_AVAILABLE_WITHOUT_DYNAMIC') +except Exception: + print('HSQQLDB_NOT_AVAILABLE') + +# Now dynamically load HSQLDB driver from JAR +conn2 = jaydebeapiarrow.connect( + 'org.hsqldb.jdbcDriver', + 'jdbc:hsqldb:mem:.', + ['SA', ''], + jars={repr(hsqldb_jar)}, + experimental={{'dynamic_classpath': True}} +) +cursor = conn2.cursor() + +# Verify it actually works — run real SQL +cursor.execute('CREATE TABLE test_dynamic (id INTEGER, name VARCHAR(50))') +cursor.execute("INSERT INTO test_dynamic VALUES (1, 'hello'), (2, 'world')") +cursor.execute('SELECT id, name FROM test_dynamic ORDER BY id') +rows = cursor.fetchall() +cursor.execute('DROP TABLE test_dynamic') +cursor.close() +conn2.close() + +print(f'DYNAMIC_OK: {{rows}}') +''' + result = subprocess.run( + [sys.executable, '-c', code], + capture_output=True, text=True, timeout=30, + cwd=os.path.dirname(os.path.dirname(__file__)), + env=env + ) + lines = result.stdout.strip().split('\n') + self.assertEqual(lines[0], 'HSQQLDB_NOT_AVAILABLE', + f'HSQLDB should not be available before dynamic load.\n' + f'stdout: {result.stdout}\nstderr: {result.stderr}') + self.assertEqual(lines[1], 'DYNAMIC_OK: [(1, \'hello\'), (2, \'world\')]', + f'Dynamic HSQLDB load failed or returned wrong data.\n' + f'stdout: {result.stdout}\nstderr: {result.stderr}') + + +class DynamicClasspathMockTest(_DynamicClasspathTestBase, unittest.TestCase): + """Tests using mock driver.""" + + def _find_primary_jar(self): + for root, dirs, files in os.walk(_THIS_DIR): + for f in files: + if f.startswith('mockdriver') and f.endswith('.jar'): + return os.path.join(root, f) + self.skipTest('mockdriver JAR not found') + + def _primary_driver_class(self): + return 'org.jaydebeapi.mockdriver.MockDriver' + + def _primary_jdbc_url(self): + return 'jdbc:jaydebeapi://dummyurl' + + +# --------------------------------------------------------------------------- +# JPype reflection / type mapping tests (legacy #111) +# --------------------------------------------------------------------------- + +class _ReflectionTestBase(object): + """Base class for java.sql.Types reflection and DBAPITypeObject tests.""" + + DRIVER_CLASS = None + JDBC_URL = None + DRIVER_ARGS = None + + def setUp(self): + self.conn = _connect( + self.DRIVER_CLASS, + self.JDBC_URL, + self.DRIVER_ARGS, + ) + + def tearDown(self): + self.conn.close() + + def test_type_constants_accessible_via_reflection(self): + """java.sql.Types constants should be accessible through + standard Java Reflection, not getStaticAttribute().""" + import jpype + Types = jpype.java.sql.Types + self.assertEqual(Types.INTEGER, 4) + self.assertEqual(Types.VARCHAR, 12) + self.assertEqual(Types.TIMESTAMP, 93) + self.assertEqual(Types.DECIMAL, 3) + + def test_dbapi_type_comparison_with_real_connection(self): + """DBAPITypeObject comparison should work after a real JDBC + connection initializes the type mapping via Reflection.""" + import jpype + Types = jpype.java.sql.Types + self.assertIsNotNone(jaydebeapiarrow._jdbc_const_to_name) + self.assertEqual(jaydebeapiarrow.NUMBER, Types.INTEGER) + self.assertEqual(jaydebeapiarrow.STRING, Types.VARCHAR) + self.assertEqual(jaydebeapiarrow.DATETIME, Types.TIMESTAMP) + + def test_cursor_description_maps_types_correctly(self): + """cursor.description should use correct type names from + Reflection-based type mapping.""" + with self.conn.cursor() as cursor: + cursor.execute("CREATE TABLE test_reflect (id INTEGER, name VARCHAR(50), val DECIMAL(10,2))") + cursor.execute("INSERT INTO test_reflect VALUES (1, 'test', 3.14)") + cursor.execute("SELECT * FROM test_reflect") + desc = cursor.description + self.assertEqual(len(desc), 3) + self.assertEqual(desc[0][0], 'ID') + self.assertEqual(desc[1][0], 'NAME') + self.assertEqual(desc[2][0], 'VAL') + + def test_java_sql_types_reflection_uses_standard_api(self): + """Verify java.sql.Types constants are accessed via standard Java + Reflection API (field.get/getModifiers/getName), not the deprecated + JPype-specific getStaticAttribute() which was removed in newer JPype.""" + import jpype + Types = jpype.java.sql.Types + fields = Types.class_.getFields() + static_public_fields = {} + for field in fields: + modifiers = field.getModifiers() + if jpype.java.lang.reflect.Modifier.isStatic(modifiers) and \ + jpype.java.lang.reflect.Modifier.isPublic(modifiers): + value = int(field.get(None)) + static_public_fields[field.getName()] = value + self.assertEqual(static_public_fields['INTEGER'], 4) + self.assertEqual(static_public_fields['VARCHAR'], 12) + self.assertEqual(static_public_fields['TIMESTAMP'], 93) + self.assertEqual(static_public_fields['DECIMAL'], 3) + self.assertEqual(static_public_fields['NUMERIC'], 2) + + def test_jdbc_type_mapping_populates_correctly(self): + """Verify _map_jdbc_type_to_dbapi builds the mapping using + standard Reflection (not getStaticAttribute).""" + import jpype + Types = jpype.java.sql.Types + result = jaydebeapiarrow.DBAPITypeObject._map_jdbc_type_to_dbapi(Types.INTEGER) + self.assertIs(result, jaydebeapiarrow.NUMBER) + self.assertIsNotNone(jaydebeapiarrow._jdbc_const_to_name) + self.assertGreater(len(jaydebeapiarrow._jdbc_const_to_name), 20) + + def test_dbapi_type_eq_with_jdbc_constants(self): + """Verify DBAPITypeObject.__eq__ works with JDBC type constants + accessed through standard Java Reflection.""" + import jpype + Types = jpype.java.sql.Types + jaydebeapiarrow.DBAPITypeObject._map_jdbc_type_to_dbapi(Types.INTEGER) + self.assertTrue(jaydebeapiarrow.NUMBER == int(Types.INTEGER)) + self.assertTrue(jaydebeapiarrow.NUMBER == int(Types.BIGINT)) + self.assertTrue(jaydebeapiarrow.NUMBER == int(Types.SMALLINT)) + self.assertTrue(jaydebeapiarrow.NUMBER == int(Types.TINYINT)) + self.assertTrue(jaydebeapiarrow.STRING == int(Types.VARCHAR)) + self.assertTrue(jaydebeapiarrow.STRING == int(Types.CHAR)) + self.assertTrue(jaydebeapiarrow.DATETIME == int(Types.TIMESTAMP)) + self.assertTrue(jaydebeapiarrow.DATE == int(Types.DATE)) + + +class ReflectionHsqldbTest(_ReflectionTestBase, unittest.TestCase): + DRIVER_CLASS = 'org.hsqldb.jdbc.JDBCDriver' + JDBC_URL = 'jdbc:hsqldb:mem:testreflection.' + DRIVER_ARGS = ['SA', ''] + + +class ReflectionMockTest(_ReflectionTestBase, unittest.TestCase): + DRIVER_CLASS = 'org.jaydebeapi.mockdriver.MockDriver' + JDBC_URL = 'jdbc:jaydebeapi://dummyurl' + DRIVER_ARGS = None + + def test_cursor_description_maps_types_correctly(self): + """Mock driver does not support DDL — skip cursor description test.""" + self.skipTest("Mock driver does not support CREATE TABLE / SELECT") + + +# --------------------------------------------------------------------------- +# Properties driver args passing tests +# --------------------------------------------------------------------------- + +class PropertiesDriverArgsPassingTest(unittest.TestCase): + + def test_connect_with_sequence(self): + driver, url, driver_args = ( 'org.hsqldb.jdbcDriver', + 'jdbc:hsqldb:mem:.', + ['SA', ''] ) + c = _connect(driver, url, driver_args) + c.close() + + def test_connect_with_properties(self): + driver, url, driver_args = ( 'org.hsqldb.jdbcDriver', + 'jdbc:hsqldb:mem:.', + {'user': 'SA', 'password': '' } ) + c = _connect(driver, url, driver_args) + c.close() diff --git a/test/test_integration.py b/test/test_integration.py deleted file mode 100644 index bb4858d9..00000000 --- a/test/test_integration.py +++ /dev/null @@ -1,2170 +0,0 @@ -#-*- coding: utf-8 -*- - -# Copyright 2010 Bastian Bowe -# -# This file is part of JayDeBeApi. -# JayDeBeApi is free software: you can redistribute it and/or modify -# it under the terms of the GNU Lesser General Public License as -# published by the Free Software Foundation, either version 3 of the -# License, or (at your option) any later version. -# -# JayDeBeApi is distributed in the hope that it will be useful, but -# WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU -# Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public -# License along with JayDeBeApi. If not, see -# . -# -# Modified by HenryNebula: -# 1. Remove py2 & Jython support -# 2. Modify test to enforce typing for Decimal and temporal types - - -import jaydebeapiarrow - -import calendar -import glob -import os -import shutil -import subprocess -import sys -import tempfile -import threading - -import unittest - -from decimal import Decimal -from datetime import datetime, timedelta, timezone -from collections import namedtuple - -_THIS_DIR = os.path.dirname(os.path.abspath(__file__)) - - -class IntegrationTestBase(object): - - JDBC_SUPPORT_TEMPORAL_TYPE = True - - def _cast_datetime(self, datetime_str, fmt=r'%Y-%m-%d %H:%M:%S'): - if self.JDBC_SUPPORT_TEMPORAL_TYPE and type(datetime_str) == str: - return datetime.strptime(datetime_str, fmt) - else: - return datetime_str - - def _cast_time(self, time_str, fmt=r'%H:%M:%S'): - if self.JDBC_SUPPORT_TEMPORAL_TYPE and type(time_str) == str: - return datetime.strptime(time_str, fmt).time() - else: - return time_str - - def _cast_date(self, date_str, fmt=r'%Y-%m-%d'): - if self.JDBC_SUPPORT_TEMPORAL_TYPE and type(date_str) == str: - return datetime.strptime(date_str, fmt).date() - else: - return date_str - - def sql_file(self, filename): - f = open(filename, 'r') - try: - lines = f.readlines() - finally: - f.close() - stmt = [] - stmts = [] - for i in lines: - stmt.append(i) - if ";" in i: - stmts.append(" ".join(stmt)) - stmt = [] - with self.conn.cursor() as cursor: - for i in stmts: - cursor.execute(i.rstrip().rstrip(";")) - - def setUp(self): - (self.dbapi, self.conn) = self.connect() - self._suppress_java_noise() - self.setUpSql() - - @staticmethod - def _suppress_java_noise(): - """Suppress noisy Java loggers from Drill, Trino, etc.""" - try: - import jpype - from jaydebeapiarrow import _is_jvm_started - if not _is_jvm_started(): - return - Level = jpype.JClass("java.util.logging.Level") - root = jpype.JClass("java.util.logging.Logger").getLogger("") - for name in ( - "oadd.org.apache.drill", - "org.apache.drill", - "io.trino", - "org.apache.arrow.memory", - "org.apache.arrow.vector", - "org.jaydebeapiarrow.extension", - ): - root.getLogger(name).setLevel(Level.WARNING) - except Exception: - pass - - def setUpSql(self): - raise NotImplementedError - - def connect(self): - raise NotImplementedError - - def tearDown(self): - with self.conn.cursor() as cursor: - cursor.execute("drop table ACCOUNT") - self._numeric_teardown() - self.conn.close() - - def test_execute_and_fetch_no_data(self): - with self.conn.cursor() as cursor: - stmt = "select * from ACCOUNT where ACCOUNT_ID is null" - cursor.execute(stmt) - result = cursor.fetchall() - self.assertEqual(result, []) - - def test_execute_and_fetch(self): - with self.conn.cursor() as cursor: - cursor.execute("select ACCOUNT_ID, ACCOUNT_NO, BALANCE, BLOCKING " \ - "from ACCOUNT ORDER BY ACCOUNT_NO") - result = cursor.fetchall() - self.assertEqual(result, [ - ( - self._cast_datetime('2009-09-10 14:15:22.123456', r'%Y-%m-%d %H:%M:%S.%f'), - 18, Decimal('12.4'), None), - ( - self._cast_datetime('2009-09-11 14:15:22.123456', r'%Y-%m-%d %H:%M:%S.%f'), - 19, Decimal('12.9'), Decimal('1')) - ]) - - def test_execute_and_fetch_parameter(self): - with self.conn.cursor() as cursor: - cursor.execute("select ACCOUNT_ID, ACCOUNT_NO, BALANCE, BLOCKING " \ - "from ACCOUNT where ACCOUNT_NO = ?", (18,)) - result = cursor.fetchall() - self.assertEqual(result, [ - ( - self._cast_datetime('2009-09-10 14:15:22.123456', r'%Y-%m-%d %H:%M:%S.%f'), - 18, Decimal('12.4'), None) - ]) - - def test_execute_and_fetchone(self): - with self.conn.cursor() as cursor: - cursor.execute("select ACCOUNT_ID, ACCOUNT_NO, BALANCE, BLOCKING " \ - "from ACCOUNT order by ACCOUNT_NO") - result = cursor.fetchone() - self.assertEqual(result, ( - self._cast_datetime('2009-09-10 14:15:22.123456', r'%Y-%m-%d %H:%M:%S.%f'), - 18, Decimal('12.4'), None)) - cursor.close() - - def test_execute_reset_description_without_execute_result(self): - """Expect the descriptions property being reset when no query - has been made via execute method. - """ - with self.conn.cursor() as cursor: - cursor.execute("select * from ACCOUNT") - self.assertIsNotNone(cursor.description) - cursor.fetchone() - cursor.execute("delete from ACCOUNT") - self.assertIsNone(cursor.description) - - def test_execute_and_fetchone_after_end(self): - with self.conn.cursor() as cursor: - cursor.execute("select * from ACCOUNT where ACCOUNT_NO = ?", (18,)) - cursor.fetchone() - result = cursor.fetchone() - self.assertIsNone(result) - - def test_execute_and_fetchone_consecutive(self): - with self.conn.cursor() as cursor: - cursor.execute("select ACCOUNT_ID, ACCOUNT_NO, BALANCE, BLOCKING " \ - "from ACCOUNT order by ACCOUNT_NO") - result1 = cursor.fetchone() - result2 = cursor.fetchone() - - self.assertEqual(result1, ( - self._cast_datetime('2009-09-10 14:15:22.123456', r'%Y-%m-%d %H:%M:%S.%f'), - 18, Decimal('12.4'), None)) - - self.assertEqual(result2, ( - self._cast_datetime('2009-09-11 14:15:22.123456', r'%Y-%m-%d %H:%M:%S.%f'), - 19, Decimal('12.9'), Decimal('1'))) - - def test_execute_and_fetchmany(self): - with self.conn.cursor() as cursor: - cursor.execute("select ACCOUNT_ID, ACCOUNT_NO, BALANCE, BLOCKING " \ - "from ACCOUNT order by ACCOUNT_NO") - result = cursor.fetchmany() - self.assertEqual(result, [ - ( - self._cast_datetime('2009-09-10 14:15:22.123456', r'%Y-%m-%d %H:%M:%S.%f'), - 18, Decimal('12.4'), None) - ]) - # TODO: find out why this cursor has to be closed in order to - # let this test work with sqlite if __del__ is not overridden - # in cursor - # cursor.close() - - def test_executemany(self): - stmt = "insert into ACCOUNT (ACCOUNT_ID, ACCOUNT_NO, BALANCE) " \ - "values (?, ?, ?)" - parms = ( - ( self.dbapi.Timestamp(2009, 9, 11, 14, 15, 22, 123450), 20, 13.1 ), - ( self.dbapi.Timestamp(2009, 9, 11, 14, 15, 22, 123451), 21, 13.2 ), - ( self.dbapi.Timestamp(2009, 9, 11, 14, 15, 22, 123452), 22, 13.3 ), - ) - with self.conn.cursor() as cursor: - cursor.executemany(stmt, parms) - self.assertEqual(cursor.rowcount, 3) - - def test_execute_types(self): - stmt = "insert into ACCOUNT (ACCOUNT_ID, ACCOUNT_NO, BALANCE, " \ - "BLOCKING, DBL_COL, OPENED_AT, VALID, PRODUCT_NAME) " \ - "values (?, ?, ?, ?, ?, ?, ?, ?)" - account_id = self.dbapi.Timestamp(2010, 1, 26, 14, 31, 59) - account_no = 20 - balance = Decimal('1.2') - blocking = 10.0 - dbl_col = 3.5 - opened_at = self.dbapi.Date(1908, 2, 27) - valid = True - product_name = u'Savings account' - parms = (account_id, account_no, balance, blocking, dbl_col, - opened_at, valid, product_name) - with self.conn.cursor() as cursor: - cursor.execute(stmt, parms) - stmt = "select ACCOUNT_ID, ACCOUNT_NO, BALANCE, BLOCKING, " \ - "DBL_COL, OPENED_AT, VALID, PRODUCT_NAME " \ - "from ACCOUNT where ACCOUNT_NO = ?" - parms = (20, ) - cursor.execute(stmt, parms) - result = cursor.fetchone() - exp = ( - self._cast_datetime('2010-01-26 14:31:59', r'%Y-%m-%d %H:%M:%S'), - account_no, balance, blocking, dbl_col, - self._cast_date('1908-02-27', r'%Y-%m-%d'), - valid, product_name - ) - self.assertEqual(result, exp) - - def test_execute_type_time(self): - stmt = "insert into ACCOUNT (ACCOUNT_ID, ACCOUNT_NO, BALANCE, " \ - "OPENED_AT_TIME) " \ - "values (?, ?, ?, ?)" - account_id = self.dbapi.Timestamp(2010, 1, 26, 14, 31, 59) - account_no = 20 - balance = 1.2 - opened_at_time = self.dbapi.Time(13, 59, 59) - parms = (account_id, account_no, balance, opened_at_time) - with self.conn.cursor() as cursor: - cursor.execute(stmt, parms) - stmt = "select ACCOUNT_ID, ACCOUNT_NO, BALANCE, OPENED_AT_TIME " \ - "from ACCOUNT where ACCOUNT_NO = ?" - parms = (20, ) - cursor.execute(stmt, parms) - result = cursor.fetchone() - - exp = ( - self._cast_datetime('2010-01-26 14:31:59', r'%Y-%m-%d %H:%M:%S'), - account_no, Decimal(str(balance)), - self._cast_time('13:59:59', r'%H:%M:%S') - ) - self.assertEqual(result, exp) - - def test_execute_different_rowcounts(self): - stmt = "insert into ACCOUNT (ACCOUNT_ID, ACCOUNT_NO, BALANCE) " \ - "values (?, ?, ?)" - parms = ( - ( self.dbapi.Timestamp(2009, 9, 11, 14, 15, 22, 123450), 20, 13.1 ), - ( self.dbapi.Timestamp(2009, 9, 11, 14, 15, 22, 123452), 22, 13.3 ), - ) - with self.conn.cursor() as cursor: - cursor.executemany(stmt, parms) - self.assertEqual(cursor.rowcount, 2) - parms = ( self.dbapi.Timestamp(2009, 9, 11, 14, 15, 22, 123451), 21, 13.2 ) - cursor.execute(stmt, parms) - self.assertEqual(cursor.rowcount, 1) - cursor.execute("select * from ACCOUNT") - self.assertEqual(cursor.rowcount, -1) - - def test_lastrowid_exists_and_is_none(self): - """PEP-249: lastrowid attribute must exist and be None (fixes #84).""" - with self.conn.cursor() as cursor: - self.assertIsNone(cursor.lastrowid) - - def test_lastrowid_none_after_select(self): - """lastrowid should be None after a SELECT query.""" - with self.conn.cursor() as cursor: - cursor.execute("select * from ACCOUNT") - self.assertIsNone(cursor.lastrowid) - - def test_lastrowid_none_after_insert(self): - """lastrowid should be None after INSERT (JDBC doesn't expose rowid).""" - stmt = "insert into ACCOUNT (ACCOUNT_ID, ACCOUNT_NO, BALANCE) " \ - "values (?, ?, ?)" - with self.conn.cursor() as cursor: - cursor.execute(stmt, (self.dbapi.Timestamp(2009, 9, 11, 14, 15, 22, 123450), 99, 1.0)) - self.assertIsNone(cursor.lastrowid) - - def test_lastrowid_none_after_executemany(self): - """lastrowid should be None after executemany.""" - stmt = "insert into ACCOUNT (ACCOUNT_ID, ACCOUNT_NO, BALANCE) " \ - "values (?, ?, ?)" - parms = ( - (self.dbapi.Timestamp(2009, 9, 11, 14, 15, 22, 123450), 98, 1.0), - (self.dbapi.Timestamp(2009, 9, 11, 14, 15, 22, 123452), 97, 2.0), - ) - with self.conn.cursor() as cursor: - cursor.executemany(stmt, parms) - self.assertIsNone(cursor.lastrowid) - - def test_execute_type_blob(self): - stmt = "insert into ACCOUNT (ACCOUNT_ID, ACCOUNT_NO, BALANCE, " \ - "STUFF) values (?, ?, ?, ?)" - binary_stuff = 'abcdef'.encode('UTF-8') - account_id = self.dbapi.Timestamp(2009, 9, 11, 14, 15, 22, 123450) - stuff = self.dbapi.Binary(binary_stuff) - parms = (account_id, 20, 13.1, stuff) - with self.conn.cursor() as cursor: - cursor.execute(stmt, parms) - stmt = "select STUFF from ACCOUNT where ACCOUNT_NO = ?" - parms = (20, ) - cursor.execute(stmt, parms) - result = cursor.fetchone() - value = result[0] - self.assertEqual(value, memoryview(binary_stuff)) - - def test_timestamp_subsecond_leading_zeros(self): - """Verify that TIMESTAMP columns preserve sub-second leading zeros. - Regression test for legacy baztian/jaydebeapi#44 where - 2017-06-19 15:30:00.096965169 was displayed as - 2017-06-19 15:30:00.960000 due to string-based parsing - stripping the leading zero. The Arrow path uses integer - nanosecond arithmetic, so this should be correct.""" - test_cases = [ - # (year, month, day, hour, minute, second, microsecond) - (2017, 6, 19, 15, 30, 0, 96965), # .096965 — exact case from legacy #44 - (2020, 1, 1, 0, 0, 0, 1), # .000001 — minimal non-zero - (2021, 3, 15, 12, 0, 0, 1000), # .001000 — leading zeros then trailing - (2019, 7, 4, 10, 30, 0, 99999), # .099999 — leading zero + 9s - (2022, 1, 1, 0, 0, 0, 0), # .000000 — zero sub-second - ] - stmt = ("insert into ACCOUNT (ACCOUNT_ID, ACCOUNT_NO, BALANCE) " - "values (?, ?, ?)") - with self.conn.cursor() as cursor: - for idx, (y, mo, d, h, mi, s, us) in enumerate(test_cases): - ts = self.dbapi.Timestamp(y, mo, d, h, mi, s, us) - cursor.execute(stmt, (ts, 60 + idx, Decimal('1.0'))) - cursor.execute( - "select ACCOUNT_ID from ACCOUNT " - "where ACCOUNT_NO >= 60 order by ACCOUNT_NO") - results = cursor.fetchall() - for idx, (y, mo, d, h, mi, s, us) in enumerate(test_cases): - expected = self._cast_datetime( - f'{y}-{mo:02d}-{d:02d} {h:02d}:{mi:02d}:{s:02d}.{us:06d}', - r'%Y-%m-%d %H:%M:%S.%f') - self.assertEqual(results[idx][0], expected, - f"Failed for microseconds={us}") - - def test_timestamp_microsecond_precision(self): - """Verify that TIMESTAMP columns preserve microsecond precision. - Regression test for legacy issue baztian/jaydebeapi#229 where certain - microsecond values (e.g. 90000) were corrupted during the Arrow - conversion.""" - test_cases = [ - (2009, 9, 11, 10, 0, 0, 200000), - (2009, 9, 11, 10, 0, 1, 90000), - (2009, 9, 11, 10, 0, 2, 123456), - (2009, 9, 11, 10, 0, 3, 0), - (2009, 9, 11, 10, 0, 4, 999999), - ] - stmt = ("insert into ACCOUNT (ACCOUNT_ID, ACCOUNT_NO, BALANCE) " - "values (?, ?, ?)") - with self.conn.cursor() as cursor: - for idx, (y, mo, d, h, mi, s, us) in enumerate(test_cases): - ts = self.dbapi.Timestamp(y, mo, d, h, mi, s, us) - cursor.execute(stmt, (ts, 50 + idx, Decimal('1.0'))) - cursor.execute( - "select ACCOUNT_ID from ACCOUNT " - "where ACCOUNT_NO >= 50 order by ACCOUNT_NO") - results = cursor.fetchall() - for idx, (y, mo, d, h, mi, s, us) in enumerate(test_cases): - expected = self._cast_datetime( - f'{y}-{mo:02d}-{d:02d} {h:02d}:{mi:02d}:{s:02d}.{us:06d}', - r'%Y-%m-%d %H:%M:%S.%f') - self.assertEqual(results[idx][0], expected, - f"Failed for microseconds={us}") - - def test_binary_non_utf8_roundtrip(self): - """Verify that binary data containing non-UTF-8 bytes round-trips - correctly through the Arrow path. Regression test for legacy issue - baztian/jaydebeapi#147 where binary data was incorrectly decoded as - UTF-8 strings, corrupting byte values >= 0x80.""" - test_data = bytes([0x00, 0x01, 0x02, 0x80, 0xff, 0xfe]) - stmt = ("insert into ACCOUNT (ACCOUNT_ID, ACCOUNT_NO, BALANCE, " - "STUFF) values (?, ?, ?, ?)") - account_id = self.dbapi.Timestamp(2009, 9, 11, 14, 15, 22, 123450) - stuff = self.dbapi.Binary(test_data) - parms = (account_id, 20, 13.1, stuff) - with self.conn.cursor() as cursor: - cursor.execute(stmt, parms) - cursor.execute("select STUFF from ACCOUNT where ACCOUNT_NO = ?", - (20,)) - result = cursor.fetchone() - value = result[0] - self.assertEqual(bytes(value), test_data) - - def test_blob_non_utf8_roundtrip(self): - """Verify BLOB columns preserve non-UTF-8 bytes through Arrow path. - Regression test for legacy issue baztian/jaydebeapi#76 where BLOB - data returned as raw Java objects instead of Python bytes.""" - test_data = bytes([0x00, 0x01, 0x02, 0x80, 0xff, 0xfe]) - stmt = ("insert into ACCOUNT (ACCOUNT_ID, ACCOUNT_NO, BALANCE, " - "STUFF) values (?, ?, ?, ?)") - account_id = self.dbapi.Timestamp(2009, 9, 11, 14, 15, 22, 123450) - stuff = self.dbapi.Binary(test_data) - parms = (account_id, 20, 13.1, stuff) - with self.conn.cursor() as cursor: - cursor.execute(stmt, parms) - cursor.execute("select STUFF from ACCOUNT where ACCOUNT_NO = ?", - (20,)) - result = cursor.fetchone() - self.assertIsInstance(result[0], (bytes, memoryview)) - self.assertEqual(bytes(result[0]), test_data) - - def test_blob_all_byte_values_roundtrip(self): - """All 256 byte values should round-trip correctly through BLOB columns.""" - test_data = bytes(range(256)) - stmt = ("insert into ACCOUNT (ACCOUNT_ID, ACCOUNT_NO, BALANCE, " - "STUFF) values (?, ?, ?, ?)") - account_id = self.dbapi.Timestamp(2009, 9, 11, 14, 15, 22, 123450) - stuff = self.dbapi.Binary(test_data) - parms = (account_id, 21, 13.2, stuff) - with self.conn.cursor() as cursor: - cursor.execute(stmt, parms) - cursor.execute("select STUFF from ACCOUNT where ACCOUNT_NO = ?", - (21,)) - result = cursor.fetchone() - self.assertEqual(bytes(result[0]), test_data) - - def test_blob_null_value(self): - """NULL BLOB values should return None, not crash or return garbage.""" - stmt = ("insert into ACCOUNT (ACCOUNT_ID, ACCOUNT_NO, BALANCE, " - "STUFF) values (?, ?, ?, ?)") - account_id = self.dbapi.Timestamp(2009, 9, 11, 14, 15, 22, 123450) - parms = (account_id, 22, 13.3, None) - with self.conn.cursor() as cursor: - cursor.execute(stmt, parms) - cursor.execute("select STUFF from ACCOUNT where ACCOUNT_NO = ?", - (22,)) - result = cursor.fetchone() - self.assertIsNone(result[0]) - - def test_numeric_types(self): - """Test that NUMERIC columns round-trip correctly, including NULL values - and edge-case precision/scale values.""" - create_table = self._numeric_create_table_sql() - with self.conn.cursor() as cursor: - cursor.execute(create_table) - # Insert NULL numeric value - cursor.execute( - "INSERT INTO NUMERIC_TEST (ID, NUM_COL) VALUES (1, NULL)") - # Insert a regular numeric value - cursor.execute( - "INSERT INTO NUMERIC_TEST (ID, NUM_COL) VALUES (2, 99.99)") - # Insert an integer-like numeric value - cursor.execute( - "INSERT INTO NUMERIC_TEST (ID, NUM_COL) VALUES (3, 100.00)") - # Read back only the numeric column to avoid ID type differences - cursor.execute("SELECT NUM_COL FROM NUMERIC_TEST ORDER BY ID") - result = cursor.fetchall() - self.assertEqual(len(result), 3) - self.assertIsNone(result[0][0]) # NULL - self.assertEqual(result[1][0], Decimal('99.99')) - self.assertEqual(result[2][0], Decimal('100.00')) - - def test_bigint_column_returns_int(self): - """Verify JDBC BIGINT columns return Python int, not raw java.lang.Long. - Regression test for legacy baztian/jaydebeapi#63.""" - if type(self).__name__.startswith(('OracleTest', 'DrillTest')): - self.skipTest('BIGINT type not supported by this database') - with self.conn.cursor() as cursor: - cursor.execute("CREATE TABLE BIGINT_TEST (val BIGINT)") - try: - cursor.execute("INSERT INTO BIGINT_TEST VALUES (0)") - cursor.execute("INSERT INTO BIGINT_TEST VALUES (377518399)") - cursor.execute("INSERT INTO BIGINT_TEST VALUES (-9223372036854775808)") - cursor.execute("INSERT INTO BIGINT_TEST VALUES (9223372036854775807)") - cursor.execute("SELECT val FROM BIGINT_TEST ORDER BY val") - result = cursor.fetchall() - finally: - cursor.execute("DROP TABLE BIGINT_TEST") - self.assertEqual(len(result), 4) - for row in result: - self.assertIsInstance(row[0], int) - self.assertEqual(result[0][0], -9223372036854775808) - self.assertEqual(result[1][0], 0) - self.assertEqual(result[2][0], 377518399) - self.assertEqual(result[3][0], 9223372036854775807) - - def test_double_column_returns_float(self): - """Verify JDBC DOUBLE columns return Python float, not raw java.lang.Double. - Regression test for legacy baztian/jaydebeapi#243.""" - with self.conn.cursor() as cursor: - cursor.execute(self._double_create_sql()) - try: - self._double_populate(cursor) - cursor.execute("SELECT val FROM DOUBLE_TEST ORDER BY val") - result = cursor.fetchall() - finally: - cursor.execute("DROP TABLE DOUBLE_TEST") - self.assertEqual(len(result), 3) - for row in result: - self.assertIsInstance(row[0], float) - self.assertAlmostEqual(result[0][0], -1.5) - self.assertAlmostEqual(result[1][0], 0.0) - self.assertAlmostEqual(result[2][0], 3.14) - - def _double_populate(self, cursor): - cursor.execute("INSERT INTO DOUBLE_TEST VALUES (3.14)") - cursor.execute("INSERT INTO DOUBLE_TEST VALUES (-1.5)") - cursor.execute("INSERT INTO DOUBLE_TEST VALUES (0.0)") - - def test_numeric_precision_scale_combos(self): - """Test various DECIMAL/NUMERIC precision/scale combinations.""" - with self.conn.cursor() as cursor: - cursor.execute(self._numeric_combo_create_sql()) - cursor.execute(self._numeric_combo_insert_sql()) - cursor.execute("SELECT DEC_S2, DEC_S4, DEC_S0, DEC_PES, " - "NUM_S2, NUM_S0, NUM_S4, NUM_PES, NUM_NEG " - "FROM NUMERIC_COMBO ORDER BY ID") - result = cursor.fetchone() - self.assertEqual(result[0], Decimal('12345.67')) # DECIMAL(10, 2) - self.assertEqual(result[1], Decimal('12345.6789')) # DECIMAL(15, 4) - self.assertEqual(result[2], Decimal('987654321012345678')) # DECIMAL(18, 0) - self.assertEqual(result[3], Decimal('0.12345')) # DECIMAL(5, 5) - self.assertEqual(result[4], Decimal('99.99')) # NUMERIC(10, 2) - self.assertEqual(result[5], Decimal('42')) # NUMERIC(10, 0) - self.assertEqual(result[6], Decimal('12345.6789')) # NUMERIC(15, 4) - self.assertEqual(result[7], Decimal('0.1234')) # NUMERIC(4, 4) - self.assertEqual(result[8], Decimal('-99.99')) # NUMERIC(10, 2) - - def _numeric_combo_create_sql(self): - return ( - "CREATE TABLE NUMERIC_COMBO (" - "ID INTEGER NOT NULL, " - "DEC_S2 DECIMAL(10, 2), " - "DEC_S4 DECIMAL(15, 4), " - "DEC_S0 DECIMAL(18, 0), " - "DEC_PES DECIMAL(5, 5), " - "NUM_S2 NUMERIC(10, 2), " - "NUM_S0 NUMERIC(10, 0), " - "NUM_S4 NUMERIC(15, 4), " - "NUM_PES NUMERIC(4, 4), " - "NUM_NEG NUMERIC(10, 2), " - "PRIMARY KEY (ID))" - ) - - def _numeric_combo_insert_sql(self): - return ( - "INSERT INTO NUMERIC_COMBO " - "(ID, DEC_S2, DEC_S4, DEC_S0, DEC_PES, " - "NUM_S2, NUM_S0, NUM_S4, NUM_PES, NUM_NEG) " - "VALUES (1, 12345.67, 12345.6789, 987654321012345678, 0.12345, " - "99.99, 42, 12345.6789, 0.1234, -99.99)" - ) - - def _numeric_create_table_sql(self): - return ( - "CREATE TABLE NUMERIC_TEST (" - "ID INTEGER NOT NULL, " - "NUM_COL NUMERIC(10, 2), " - "PRIMARY KEY (ID))" - ) - - def _numeric_teardown(self): - with self.conn.cursor() as cursor: - try: - cursor.execute("DROP TABLE NUMERIC_TEST") - except Exception: - pass - try: - cursor.execute("DROP TABLE NUMERIC_COMBO") - except Exception: - pass - - def _double_create_sql(self): - return "CREATE TABLE DOUBLE_TEST (val DOUBLE)" - - def test_execute_param_none(self): - """Verify that Python None round-trips as SQL NULL via parameter binding.""" - stmt = "insert into ACCOUNT (ACCOUNT_ID, ACCOUNT_NO, BALANCE, BLOCKING) " \ - "values (?, ?, ?, ?)" - account_id = self.dbapi.Timestamp(2010, 1, 26, 14, 31, 59) - with self.conn.cursor() as cursor: - cursor.execute(stmt, (account_id, 30, Decimal('5.0'), None)) - cursor.execute("select BLOCKING from ACCOUNT where ACCOUNT_NO = 30") - result = cursor.fetchone() - self.assertIsNone(result[0]) - -class SqliteTestBase(IntegrationTestBase): - - def setUpSql(self): - self.sql_file(os.path.join(_THIS_DIR, 'data', 'create.sql')) - self.sql_file(os.path.join(_THIS_DIR, 'data', 'insert.sql')) - -class SqlitePyTest(SqliteTestBase, unittest.TestCase): - - JDBC_SUPPORT_TEMPORAL_TYPE = True - - def _numeric_create_table_sql(self): - """Use DECIMAL so sqlite3's detect_types converter fires.""" - return ( - "CREATE TABLE NUMERIC_TEST (" - "ID INTEGER NOT NULL, " - "NUM_COL DECIMAL(10, 2), " - "PRIMARY KEY (ID))" - ) - - class ConnectionWithClosing: - def __init__(self, conn): - from contextlib import closing - self.conn = conn - self.cursor = lambda: closing(self.conn.cursor()) - - def close(self): - self.conn.close() - - def connect(self): - import sqlite3 - sqlite3.register_adapter(Decimal, lambda d: str(d)) - sqlite3.register_converter("decimal", lambda s: Decimal(s.decode('utf-8')) if s is not None else s) - return sqlite3, self.ConnectionWithClosing(sqlite3.connect(':memory:', detect_types=sqlite3.PARSE_DECLTYPES)) - - def test_execute_type_time(self): - self.skipTest("Time type not supported by PySqlite") - - def test_numeric_precision_scale_combos(self): - self.skipTest("SQLite type affinity makes NUMERIC/DECIMAL precision unreliable") - -class SqliteXerialTest(SqliteTestBase, unittest.TestCase): - - JDBC_SUPPORT_TEMPORAL_TYPE = True - - def connect(self): - #http://bitbucket.org/xerial/sqlite-jdbc - # sqlite-jdbc-3.7.2.jar - driver, url = 'org.sqlite.JDBC', 'jdbc:sqlite::memory:' - properties = { - "date_string_format": "yyyy-MM-dd HH:mm:ss" - } - return jaydebeapiarrow, jaydebeapiarrow.connect(driver, url, driver_args=properties) - - def test_execute_and_fetch(self): - """SQLite date_string_format truncates microseconds.""" - with self.conn.cursor() as cursor: - cursor.execute("select ACCOUNT_ID, ACCOUNT_NO, BALANCE, BLOCKING " \ - "from ACCOUNT") - result = cursor.fetchall() - self.assertEqual(result, [ - ( - datetime(2009, 9, 10, 14, 15, 22), - 18, Decimal('12.4'), None), - ( - datetime(2009, 9, 11, 14, 15, 22), - 19, Decimal('12.9'), Decimal('1')) - ]) - - def test_timestamp_microsecond_precision(self): - """SQLite Xerial JDBC truncates microseconds via date_string_format.""" - self.skipTest("SQLite Xerial JDBC truncates microsecond precision") - - def test_execute_and_fetch_parameter(self): - with self.conn.cursor() as cursor: - cursor.execute("select ACCOUNT_ID, ACCOUNT_NO, BALANCE, BLOCKING " \ - "from ACCOUNT where ACCOUNT_NO = ?", (18,)) - result = cursor.fetchall() - self.assertEqual(result, [ - ( - datetime(2009, 9, 10, 14, 15, 22), - 18, Decimal('12.4'), None) - ]) - - def test_execute_and_fetchone(self): - with self.conn.cursor() as cursor: - cursor.execute("select ACCOUNT_ID, ACCOUNT_NO, BALANCE, BLOCKING " \ - "from ACCOUNT order by ACCOUNT_NO") - result = cursor.fetchone() - self.assertEqual(result, ( - datetime(2009, 9, 10, 14, 15, 22), - 18, Decimal('12.4'), None)) - cursor.close() - - def test_execute_and_fetchone_consecutive(self): - with self.conn.cursor() as cursor: - cursor.execute("select ACCOUNT_ID, ACCOUNT_NO, BALANCE, BLOCKING " \ - "from ACCOUNT order by ACCOUNT_NO") - result1 = cursor.fetchone() - result2 = cursor.fetchone() - - self.assertEqual(result1, ( - datetime(2009, 9, 10, 14, 15, 22), - 18, Decimal('12.4'), None)) - - self.assertEqual(result2, ( - datetime(2009, 9, 11, 14, 15, 22), - 19, Decimal('12.9'), Decimal('1'))) - - def test_execute_and_fetchmany(self): - with self.conn.cursor() as cursor: - cursor.execute("select ACCOUNT_ID, ACCOUNT_NO, BALANCE, BLOCKING " \ - "from ACCOUNT order by ACCOUNT_NO") - result = cursor.fetchmany() - self.assertEqual(result, [ - ( - datetime(2009, 9, 10, 14, 15, 22), - 18, Decimal('12.4'), None) - ]) - - def test_execute_types(self): - """ - xerial/sqlite-jdbc has some issues with type mapping: - 1. Timestamp has inconsistent types: JDBC returns it as a VARCHAR, while it's defined as a TIMESTAMP in the DB - 2. Default date_string_format does not handle ISO Date (without microseconds) - 3. SQLite stores DECIMAL values with dynamic typing (integer vs double) - """ - stmt = "insert into ACCOUNT (ACCOUNT_ID, ACCOUNT_NO, BALANCE, " \ - "BLOCKING, DBL_COL, OPENED_AT, VALID, PRODUCT_NAME) " \ - "values (?, ?, ?, ?, ?, ?, ?, ?)" - account_id = self.dbapi.Timestamp(2010, 1, 26, 14, 31, 59) - account_no = 20 - balance = Decimal('1.2') - blocking = Decimal('10.0') - dbl_col = 3.5 - opened_at = self.dbapi.Timestamp(2008, 2, 27, 0, 0, 0) - valid = True - product_name = u'Savings account' - parms = ( - account_id, - account_no, balance, blocking, dbl_col, - opened_at, - valid, product_name - ) - with self.conn.cursor() as cursor: - cursor.execute(stmt, parms) - stmt = "select ACCOUNT_ID, ACCOUNT_NO, BALANCE, BLOCKING, " \ - "DBL_COL, OPENED_AT, VALID, PRODUCT_NAME " \ - "from ACCOUNT where ACCOUNT_NO = ?" - parms = (20,) - cursor.execute(stmt, parms) - result = cursor.fetchone() - - exp = ( - account_id, - account_no, balance, blocking, dbl_col, - opened_at.date(), - valid, product_name - ) - self.assertEqual(result, exp) - - def test_execute_type_time(self): - """SQLite date_string_format truncates microseconds.""" - stmt = "insert into ACCOUNT (ACCOUNT_ID, ACCOUNT_NO, BALANCE, " \ - "OPENED_AT_TIME) " \ - "values (?, ?, ?, ?)" - account_id = self.dbapi.Timestamp(2010, 1, 26, 14, 31, 59) - account_no = 20 - balance = 1.2 - opened_at_time = self.dbapi.Time(13, 59, 59) - parms = (account_id, account_no, balance, opened_at_time) - with self.conn.cursor() as cursor: - cursor.execute(stmt, parms) - stmt = "select ACCOUNT_ID, ACCOUNT_NO, BALANCE, OPENED_AT_TIME " \ - "from ACCOUNT where ACCOUNT_NO = ?" - parms = (20, ) - cursor.execute(stmt, parms) - result = cursor.fetchone() - - exp = ( - account_id, - account_no, Decimal(str(balance)), - self._cast_time('13:59:59', r'%H:%M:%S') - ) - self.assertEqual(result, exp) - - def _numeric_create_table_sql(self): - """SQLite treats NUMERIC as an affinity type — use DECIMAL instead.""" - return ( - "CREATE TABLE NUMERIC_TEST (" - "ID INTEGER NOT NULL, " - "NUM_COL DECIMAL, " - "PRIMARY KEY (ID))" - ) - - def test_timestamp_subsecond_leading_zeros(self): - """SQLite Xerial JDBC truncates microseconds via date_string_format.""" - self.skipTest("SQLite Xerial JDBC truncates microsecond precision") - -class HsqldbTest(IntegrationTestBase, unittest.TestCase): - - def connect(self): - # http://hsqldb.org/ - # hsqldb.jar - driver, url, driver_args = ( 'org.hsqldb.jdbcDriver', - 'jdbc:hsqldb:mem:.', - ['SA', ''] ) - return jaydebeapiarrow, jaydebeapiarrow.connect(driver, url, driver_args) - - def setUpSql(self): - self.sql_file(os.path.join(_THIS_DIR, 'data', 'create_hsqldb.sql')) - self.sql_file(os.path.join(_THIS_DIR, 'data', 'insert.sql')) - - def test_varchar_non_ascii_roundtrip(self): - """Verify that VARCHAR columns containing non-ASCII characters - round-trip correctly through the Arrow path. Regression test for - legacy issue baztian/jaydebeapi#176 where reading VARCHAR columns - with umlauts caused CharConversionException.""" - test_cases = [ - "Grüße aus München", - "café — résumé", - "こんにちは", - "Hello 🌍", - ] - stmt = ("insert into ACCOUNT (ACCOUNT_ID, ACCOUNT_NO, BALANCE, " - "PRODUCT_NAME) values (?, ?, ?, ?)") - with self.conn.cursor() as cursor: - for idx, text in enumerate(test_cases): - ts = self.dbapi.Timestamp(2024, 1, 15, 10, 0, 0, idx * 100000) - cursor.execute(stmt, (ts, 50 + idx, Decimal('1.0'), text)) - cursor.execute( - "select PRODUCT_NAME from ACCOUNT " - "where ACCOUNT_NO >= 50 order by ACCOUNT_NO") - results = cursor.fetchall() - for idx, text in enumerate(test_cases): - self.assertEqual(results[idx][0], text, - f"Failed for text: {text!r}") - - def test_long_query_string_18k_characters(self): - """SQL queries with 18k+ characters must execute correctly. - Regression test for baztian/jaydebeapi#91 where long queries - caused failures in the legacy codebase.""" - long_query = ("SELECT ACCOUNT_NO FROM ACCOUNT WHERE ACCOUNT_NO IN (" - + ",".join(str(i) for i in range(5000)) + ")") - self.assertGreater(len(long_query), 18000, - "Test query must exceed 18k characters") - with self.conn.cursor() as cursor: - cursor.execute(long_query) - result = cursor.fetchall() - self.assertIsInstance(result, list) - self.assertEqual(len(result), 2, - "Both ACCOUNT rows (18, 19) should match the IN clause") - returned_ids = sorted(row[0] for row in result) - self.assertEqual(returned_ids, [18, 19]) - - def test_iterator_closed_after_fetchall(self): - """After fetchall exhausts the result set, the Arrow iterator should - be closed and nulled out (memory leak regression, legacy #227).""" - with self.conn.cursor() as cursor: - cursor.execute("SELECT * FROM Account") - cursor.fetchall() - self.assertIsNone(cursor._iter) - - def test_iterator_closed_after_fetchone_exhaustion(self): - """After fetchone exhausts the result set, iterator should be closed.""" - with self.conn.cursor() as cursor: - cursor.execute("SELECT COUNT(*) FROM Account") - cursor.fetchone() - result = cursor.fetchone() - self.assertIsNone(result) - self.assertIsNone(cursor._iter) - - def test_iterator_closed_after_fetchmany_exhaustion(self): - """After fetchmany exhausts the result set, iterator should be closed.""" - with self.conn.cursor() as cursor: - cursor.execute("SELECT * FROM Account") - cursor.fetchmany(size=1000) - self.assertIsNone(cursor._iter) - - def test_repeated_query_cycles_release_resources(self): - """Repeated execute/fetchall cycles should not accumulate iterators - or buffers (memory leak regression, legacy #227).""" - with self.conn.cursor() as cursor: - for _ in range(5): - cursor.execute("SELECT * FROM Account") - result = cursor.fetchall() - self.assertTrue(len(result) > 0) - self.assertIsNone(cursor._iter) - self.assertEqual(cursor._buffer, []) - - def test_description_returns_column_alias(self): - """cursor.description should return the AS alias, not the table column name.""" - with self.conn.cursor() as cursor: - cursor.execute("SELECT ACCOUNT_NO AS acct_num FROM ACCOUNT") - self.assertEqual(cursor.description[0][0], "ACCT_NUM") - - - def test_timestamp_utc_roundtrip_no_timezone_shift(self): - """Verify TIMESTAMP values round-trip without timezone shifting. - - Regression test for baztian/jaydebeapi#73. Legacy jaydebeapi returned - timestamps in the JVM's local timezone instead of UTC. This test - inserts specific timestamp values via parameter binding and verifies - they are returned as naive datetime objects with exact values — no - timezone offset applied. - """ - test_cases = [ - # (inserted_timestamp, description) - (self.dbapi.Timestamp(2024, 1, 15, 0, 0, 0), - "UTC midnight — legacy bug would shift to previous day in EST"), - (self.dbapi.Timestamp(2024, 6, 15, 14, 30, 0, 123456), - "midday with microseconds"), - (self.dbapi.Timestamp(2024, 12, 31, 23, 59, 59, 999999), - "end-of-day edge case — legacy bug could roll over to next day"), - ] - stmt = ("insert into ACCOUNT (ACCOUNT_ID, ACCOUNT_NO, BALANCE) " - "values (?, ?, ?)") - with self.conn.cursor() as cursor: - for idx, (ts, _desc) in enumerate(test_cases): - cursor.execute(stmt, (ts, 100 + idx, Decimal('1.0'))) - cursor.execute( - "select ACCOUNT_ID from ACCOUNT " - "where ACCOUNT_NO >= 100 order by ACCOUNT_NO") - results = cursor.fetchall() - for idx, (ts, desc) in enumerate(test_cases): - with self.subTest(desc=desc): - self.assertEqual(results[idx][0], ts) - self.assertIsNone(results[idx][0].tzinfo, - "TIMESTAMP must return naive datetime") - - def test_varchar_columns_return_data(self): - """Verify VARCHAR columns return actual data, not empty strings. - - Regression test for legacy issue #119 where Oracle 9i VARCHAR2 columns - returned empty strings while numeric fields worked fine. The original - jaydebeapi used getObject() which could return driver-specific types - (e.g., oracle.sql.CHAR) that JPype couldn't convert. jaydebeapiarrow's - Arrow JDBC adapter uses getString() for VARCHAR columns, which always - returns a proper java.lang.String. - """ - with self.conn.cursor() as cursor: - # Insert rows with VARCHAR data - cursor.execute( - "INSERT INTO ACCOUNT " - "(ACCOUNT_ID, ACCOUNT_NO, BALANCE, PRODUCT_NAME) " - "VALUES ('2010-01-01 00:00:00.000000', 100, 99.99, 'Savings Account')" - ) - cursor.execute( - "INSERT INTO ACCOUNT " - "(ACCOUNT_ID, ACCOUNT_NO, BALANCE, PRODUCT_NAME) " - "VALUES ('2010-01-02 00:00:00.000000', 101, 0.00, 'Checking Account')" - ) - # Query with mixed VARCHAR and numeric columns - cursor.execute( - "SELECT ACCOUNT_NO, BALANCE, PRODUCT_NAME " - "FROM ACCOUNT WHERE ACCOUNT_NO >= 100 ORDER BY ACCOUNT_NO" - ) - result = cursor.fetchall() - self.assertEqual(len(result), 2) - # Verify numeric data is present - self.assertEqual(result[0][0], 100) - self.assertEqual(result[0][1], Decimal('99.99')) - # Verify VARCHAR data is NOT empty - self.assertIsInstance(result[0][2], str) - self.assertEqual(result[0][2], 'Savings Account') - self.assertNotEqual(result[0][2], '') - self.assertEqual(result[1][2], 'Checking Account') - - def test_commit_with_autocommit_enabled(self): - """commit() should not raise when autocommit is enabled.""" - self.conn.jconn.setAutoCommit(True) - self.conn.commit() - - def test_commit_with_autocommit_disabled(self): - """commit() should succeed normally when autocommit is disabled.""" - self.conn.jconn.setAutoCommit(False) - self.conn.commit() - - def test_rollback_with_autocommit_enabled(self): - """rollback() should not raise when autocommit is enabled.""" - self.conn.jconn.setAutoCommit(True) - self.conn.rollback() - - def test_rollback_with_autocommit_disabled(self): - """rollback() should succeed normally when autocommit is disabled.""" - self.conn.jconn.setAutoCommit(False) - self.conn.rollback() - - -class PostgresTest(IntegrationTestBase, unittest.TestCase): - - def connect(self): - - import jpype - - host = os.environ.get("JY_PG_HOST", "localhost") - port = os.environ.get("JY_PG_PORT", "15432") - db_name = os.environ.get("JY_PG_DB", "test_db") - user = os.environ.get("JY_PG_USER", "user") - password = os.environ.get("JY_PG_PASSWORD", "password") - - driver, url, driver_args = ( - 'org.postgresql.Driver', - f'jdbc:postgresql://{host}:{port}/{db_name}', - {'user': user, 'password': password} - ) - - try: - db, conn = jaydebeapiarrow, jaydebeapiarrow.connect(driver, url, driver_args) - except jpype.JException: - self.fail("Can not connect with PostgreSQL. Please check if the instance is up and running.") - else: - return db, conn - - - def setUpSql(self): - self.sql_file(os.path.join(_THIS_DIR, 'data', 'create_postgres.sql')) - self.sql_file(os.path.join(_THIS_DIR, 'data', 'insert.sql')) - - def _double_create_sql(self): - return "CREATE TABLE DOUBLE_TEST (val DOUBLE PRECISION)" - - def test_timestamp_microsecond_precision(self): - """PostgreSQL-specific: verify microsecond precision on both TIMESTAMP - and TIMESTAMPTZ columns.""" - test_cases = [ - (2009, 9, 11, 10, 0, 0, 200000), - (2009, 9, 11, 10, 0, 1, 90000), - (2009, 9, 11, 10, 0, 2, 123456), - (2009, 9, 11, 10, 0, 3, 0), - (2009, 9, 11, 10, 0, 4, 999999), - ] - stmt = ("insert into ACCOUNT (ACCOUNT_ID, ACCOUNT_NO, BALANCE, " - "ACCOUNT_ID_TZ) values (?, ?, ?, ?)") - with self.conn.cursor() as cursor: - cursor.execute("SET TIME ZONE 'UTC'") - for idx, (y, mo, d, h, mi, s, us) in enumerate(test_cases): - ts = self.dbapi.Timestamp(y, mo, d, h, mi, s, us) - cursor.execute(stmt, (ts, 50 + idx, Decimal('1.0'), ts)) - cursor.execute( - "select ACCOUNT_ID, ACCOUNT_ID_TZ from ACCOUNT " - "where ACCOUNT_NO >= 50 order by ACCOUNT_NO") - results = cursor.fetchall() - for idx, (y, mo, d, h, mi, s, us) in enumerate(test_cases): - expected = self._cast_datetime( - f'{y}-{mo:02d}-{d:02d} {h:02d}:{mi:02d}:{s:02d}.{us:06d}', - r'%Y-%m-%d %H:%M:%S.%f') - self.assertEqual(results[idx][0], expected, - f"TIMESTAMP failed for microseconds={us}") - # TIMESTAMPTZ should be timezone-aware (UTC) - self.assertEqual(results[idx][1], - expected.replace(tzinfo=timezone.utc), - f"TIMESTAMPTZ failed for microseconds={us}") - - def test_binary_non_utf8_roundtrip(self): - """PostgreSQL-specific: verify bytea columns preserve all 256 byte values - and non-UTF-8 sequences through the Arrow path. Regression test for - legacy issue baztian/jaydebeapi#147.""" - # Full 256-byte spectrum (every possible byte value) - all_bytes = bytes(range(256)) - # Non-UTF-8 sequences that commonly get corrupted - non_utf8_patterns = [ - bytes([0x80, 0x81, 0xff, 0xfe]), - bytes([0xc0, 0x80]), # overlong null - bytes([0xff, 0xff, 0xff]), - bytes([0x00, 0x00, 0x00, 0x00]), # null bytes - ] - stmt = ("insert into ACCOUNT (ACCOUNT_ID, ACCOUNT_NO, BALANCE, " - "STUFF) values (?, ?, ?, ?)") - with self.conn.cursor() as cursor: - # Test full 256-byte spectrum - account_id = self.dbapi.Timestamp(2009, 9, 11, 14, 15, 22, 123450) - cursor.execute(stmt, (account_id, 20, Decimal('13.1'), - self.dbapi.Binary(all_bytes))) - # Test individual non-UTF-8 patterns - for idx, pattern in enumerate(non_utf8_patterns): - aid = self.dbapi.Timestamp(2010, 1, 1, 0, 0, 0, idx) - cursor.execute(stmt, (aid, 30 + idx, Decimal('1.0'), - self.dbapi.Binary(pattern))) - # Read back and verify - cursor.execute( - "select STUFF from ACCOUNT where ACCOUNT_NO = 20") - result = cursor.fetchone() - self.assertEqual(bytes(result[0]), all_bytes, - "Full 256-byte spectrum mismatch") - for idx, pattern in enumerate(non_utf8_patterns): - cursor.execute( - "select STUFF from ACCOUNT where ACCOUNT_NO = ?", - (30 + idx,)) - result = cursor.fetchone() - self.assertEqual(bytes(result[0]), pattern, - f"Pattern {idx} mismatch: {pattern!r}") - - def test_execute_timestamptz_roundtrip_non_utc_session(self): - """Test TIMESTAMPTZ read/write with a non-UTC session timezone. - - Sets the session to Australia/Sydney (UTC+10 standard / UTC+11 DST), - inserts a naive string via SQL (interpreted as Sydney local time by PG), - then verifies our Arrow bridge correctly normalizes to UTC on read. - """ - with self.conn.cursor() as cursor: - # Use a timezone with DST to make this a real test - cursor.execute("SET TIME ZONE 'Australia/Sydney'") - # Insert via raw SQL — PG interprets this as Sydney time - # January = AEDT (UTC+11), so 10:30 local = 23:30 previous day UTC - cursor.execute( - "INSERT INTO ACCOUNT (ACCOUNT_ID, ACCOUNT_NO, BALANCE, ACCOUNT_ID_TZ) " - "VALUES ('2024-01-15 10:30:00', 30, 5.0, '2024-01-15 10:30:00')" - ) - - # Read back via Arrow bridge — should normalize to UTC - cursor.execute("SELECT ACCOUNT_ID, ACCOUNT_ID_TZ FROM ACCOUNT WHERE ACCOUNT_NO = 30") - result = cursor.fetchone() - - # ACCOUNT_ID (plain TIMESTAMP) is NOT affected by timezone — returns as-is - self.assertEqual(result[0], datetime(2024, 1, 15, 10, 30, 0)) - self.assertIsNone(result[0].tzinfo) - - # ACCOUNT_ID_TZ (TIMESTAMPTZ) is normalized to UTC by the bridge - # 10:30 AEDT (UTC+11) = 2024-01-14 23:30:00 UTC - self.assertEqual(result[1], datetime(2024, 1, 14, 23, 30, 0, tzinfo=timezone.utc)) - self.assertIsNotNone(result[1].tzinfo) - - def test_json_column_read(self): - """Verify JSON columns (JDBC OTHER) are readable as strings via ExplicitTypeMapper.""" - with self.conn.cursor() as cursor: - cursor.execute("CREATE TABLE test_json_type (id INT, data JSON)") - try: - cursor.execute( - "INSERT INTO test_json_type (id, data) VALUES (1, '{\"key\": \"value\"}')" - ) - cursor.execute("SELECT data FROM test_json_type WHERE id = 1") - result = cursor.fetchone() - # Verify data is readable as a string - self.assertIsInstance(result[0], str) - self.assertIn("key", result[0]) - # Verify cursor.description reports STRING type code (OTHER → STRING) - self.assertIs(cursor.description[0][1], jaydebeapiarrow.STRING) - finally: - cursor.execute("DROP TABLE test_json_type") - - def test_uuid_column_read(self): - """Verify UUID columns (JDBC OTHER) are readable as strings via ExplicitTypeMapper.""" - with self.conn.cursor() as cursor: - cursor.execute("CREATE TABLE test_uuid_type (id INT, data UUID)") - try: - cursor.execute( - "INSERT INTO test_uuid_type (id, data) " - "VALUES (1, 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11')" - ) - cursor.execute("SELECT data FROM test_uuid_type WHERE id = 1") - result = cursor.fetchone() - # Verify data is readable as a string - self.assertIsInstance(result[0], str) - self.assertEqual(result[0], "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11") - # Verify cursor.description reports STRING type code (OTHER → STRING) - self.assertIs(cursor.description[0][1], jaydebeapiarrow.STRING) - finally: - cursor.execute("DROP TABLE test_uuid_type") - - def test_xml_column_read(self): - """Verify XML columns are readable as strings via ExplicitTypeMapper. - Regression test for legacy issue baztian/jaydebeapi#223.""" - with self.conn.cursor() as cursor: - cursor.execute("CREATE TABLE test_xml_type (id INT, data XML)") - try: - cursor.execute( - "INSERT INTO test_xml_type (id, data) " - "VALUES (1, 'hello')" - ) - cursor.execute("SELECT data FROM test_xml_type WHERE id = 1") - result = cursor.fetchone() - self.assertIsInstance(result[0], str) - self.assertEqual(result[0], 'hello') - finally: - cursor.execute("DROP TABLE test_xml_type") - - def test_array_column_read(self): - """Verify ARRAY columns are readable as strings via ExplicitTypeMapper VARCHAR fallback.""" - with self.conn.cursor() as cursor: - cursor.execute("CREATE TABLE test_array_type (id INT, data INTEGER[])") - try: - cursor.execute( - "INSERT INTO test_array_type (id, data) VALUES (1, '{1,2,3}')" - ) - cursor.execute("SELECT data FROM test_array_type WHERE id = 1") - result = cursor.fetchone() - # Verify data is readable (degraded VARCHAR fallback — toString representation) - self.assertIsInstance(result[0], str) - # Verify cursor.description reports ARRAY type code - self.assertIs(cursor.description[0][1], jaydebeapiarrow.ARRAY) - finally: - cursor.execute("DROP TABLE test_array_type") - - def test_execute_timestamptz_roundtrip_param_binding(self): - """Test writing a TZ-aware datetime via parameter binding and reading back.""" - # Reset to UTC for a clean parameter-binding round-trip - with self.conn.cursor() as cursor: - cursor.execute("SET TIME ZONE 'UTC'") - naive_id = datetime(2024, 6, 15, 10, 30, 0) - tz_dt = datetime(2024, 6, 15, 10, 30, 0, tzinfo=timezone.utc) - cursor.execute( - "INSERT INTO ACCOUNT (ACCOUNT_ID, ACCOUNT_NO, BALANCE, ACCOUNT_ID_TZ) " - "VALUES (?, ?, ?, ?)", - (naive_id, 31, Decimal('5.0'), tz_dt) - ) - cursor.execute("SELECT ACCOUNT_ID, ACCOUNT_ID_TZ FROM ACCOUNT WHERE ACCOUNT_NO = 31") - result = cursor.fetchone() - - # ACCOUNT_ID (TIMESTAMP) should be naive - self.assertEqual(result[0], datetime(2024, 6, 15, 10, 30, 0)) - self.assertIsNone(result[0].tzinfo) - # ACCOUNT_ID_TZ (TIMESTAMPTZ) should be timezone-aware (UTC) - self.assertEqual(result[1], datetime(2024, 6, 15, 10, 30, 0, tzinfo=timezone.utc)) - self.assertIsNotNone(result[1].tzinfo) - - -class MySQLTest(IntegrationTestBase, unittest.TestCase): - - def connect(self): - - import jpype - - host = os.environ.get("JY_MYSQL_HOST", "localhost") - port = os.environ.get("JY_MYSQL_PORT", "13306") - db_name = os.environ.get("JY_MYSQL_DB", "test_db") - user = os.environ.get("JY_MYSQL_USER", "user") - password = os.environ.get("JY_MYSQL_PASSWORD", "password") - - driver, url, driver_args = ( - 'com.mysql.cj.jdbc.Driver', - f'jdbc:mysql://{host}:{port}/{db_name}?user={user}&password={password}', - None - ) - - try: - db, conn = jaydebeapiarrow, jaydebeapiarrow.connect(driver, url, driver_args) - except jpype.JException as e: - self.fail("Can not connect with MySQL. Please check if the instance is up and running.") - else: - return db, conn - - def setUpSql(self): - self.sql_file(os.path.join(_THIS_DIR, 'data', 'create_mysql.sql')) - self.sql_file(os.path.join(_THIS_DIR, 'data', 'insert.sql')) - - -class MSSQLTest(IntegrationTestBase, unittest.TestCase): - - def connect(self): - - import jpype - - host = os.environ.get("JY_MSSQL_HOST", "localhost") - port = os.environ.get("JY_MSSQL_PORT", "11433") - user = os.environ.get("JY_MSSQL_USER", "sa") - password = os.environ.get("JY_MSSQL_PASSWORD", "Password123!") - - driver, url, driver_args = ( - 'com.microsoft.sqlserver.jdbc.SQLServerDriver', - f'jdbc:sqlserver://{host}:{port};encrypt=false;trustServerCertificate=true', - {'user': user, 'password': password} - ) - - try: - db, conn = jaydebeapiarrow, jaydebeapiarrow.connect(driver, url, driver_args) - except jpype.JException: - self.fail("Can not connect with MS SQL Server. Please check if the instance is up and running.") - else: - return db, conn - - def setUpSql(self): - with self.conn.cursor() as cursor: - cursor.execute("IF DB_ID('test_db') IS NULL CREATE DATABASE test_db") - cursor.execute("USE test_db") - self.sql_file(os.path.join(_THIS_DIR, 'data', 'create_mssql.sql')) - self.sql_file(os.path.join(_THIS_DIR, 'data', 'insert.sql')) - - def tearDown(self): - with self.conn.cursor() as cursor: - cursor.execute("USE test_db") - super().tearDown() - - def _double_create_sql(self): - return "CREATE TABLE DOUBLE_TEST (val FLOAT)" - - def test_blob_null_value(self): - """MSSQL JDBC driver rejects NULL parameter binding for VARBINARY columns.""" - self.skipTest("MSSQL JDBC driver does not support NULL for VARBINARY parameter binding") - - -class TrinoTest(IntegrationTestBase, unittest.TestCase): - - def connect(self): - - import jpype - - host = os.environ.get("JY_TRINO_HOST", "localhost") - port = os.environ.get("JY_TRINO_PORT", "18080") - user = os.environ.get("JY_TRINO_USER", "test") - - driver, url, driver_args = ( - 'io.trino.jdbc.TrinoDriver', - f'jdbc:trino://{host}:{port}/memory/default', - {'user': user} - ) - - try: - db, conn = jaydebeapiarrow, jaydebeapiarrow.connect(driver, url, driver_args) - except jpype.JException: - self.fail("Can not connect with Trino. Please check if the instance is up and running.") - else: - return db, conn - - def setUpSql(self): - self.sql_file(os.path.join(_THIS_DIR, 'data', 'create_trino.sql')) - self.sql_file(os.path.join(_THIS_DIR, 'data', 'insert_trino.sql')) - - def tearDown(self): - with self.conn.cursor() as cursor: - cursor.execute("DROP TABLE IF EXISTS ACCOUNT") - cursor.execute("DROP TABLE IF EXISTS NUMERIC_TEST") - cursor.execute("DROP TABLE IF EXISTS NUMERIC_COMBO") - self.conn.close() - - def test_execute_reset_description_without_execute_result(self): - """Trino memory connector does not support DELETE.""" - self.skipTest("Trino memory connector does not support modifying table rows") - - def test_numeric_types(self): - """Trino memory connector does not support INSERT INTO ... VALUES — use CTAS instead.""" - with self.conn.cursor() as cursor: - cursor.execute("DROP TABLE IF EXISTS NUMERIC_TEST") - cursor.execute( - "CREATE TABLE NUMERIC_TEST AS " - "SELECT 1 AS ID, CAST(NULL AS DECIMAL(10, 2)) AS NUM_COL " - "UNION ALL " - "SELECT 2, CAST(99.99 AS DECIMAL(10, 2)) " - "UNION ALL " - "SELECT 3, CAST(100.00 AS DECIMAL(10, 2))") - cursor.execute("SELECT NUM_COL FROM NUMERIC_TEST ORDER BY ID") - result = cursor.fetchall() - self.assertEqual(len(result), 3) - self.assertIsNone(result[0][0]) - self.assertEqual(result[1][0], Decimal('99.99')) - self.assertEqual(result[2][0], Decimal('100.00')) - - def test_numeric_precision_scale_combos(self): - """Trino memory connector does not support INSERT — use CTAS instead.""" - with self.conn.cursor() as cursor: - cursor.execute("DROP TABLE IF EXISTS NUMERIC_COMBO") - cursor.execute( - "CREATE TABLE NUMERIC_COMBO AS " - "SELECT 1 AS ID, " - "CAST(12345.67 AS DECIMAL(10, 2)) AS DEC_S2, " - "CAST(12345.6789 AS DECIMAL(15, 4)) AS DEC_S4, " - "CAST(987654321012345678 AS DECIMAL(18, 0)) AS DEC_S0, " - "CAST(0.12345 AS DECIMAL(5, 5)) AS DEC_PES, " - "CAST(99.99 AS DECIMAL(10, 2)) AS NUM_S2, " - "CAST(42 AS DECIMAL(10, 0)) AS NUM_S0, " - "CAST(12345.6789 AS DECIMAL(15, 4)) AS NUM_S4, " - "CAST(0.1234 AS DECIMAL(4, 4)) AS NUM_PES, " - "CAST(-99.99 AS DECIMAL(10, 2)) AS NUM_NEG") - cursor.execute("SELECT DEC_S2, DEC_S4, DEC_S0, DEC_PES, " - "NUM_S2, NUM_S0, NUM_S4, NUM_PES, NUM_NEG " - "FROM NUMERIC_COMBO ORDER BY ID") - result = cursor.fetchone() - self.assertEqual(result[0], Decimal('12345.67')) - self.assertEqual(result[1], Decimal('12345.6789')) - self.assertEqual(result[2], Decimal('987654321012345678')) - self.assertEqual(result[3], Decimal('0.12345')) - self.assertEqual(result[4], Decimal('99.99')) - self.assertEqual(result[5], Decimal('42')) - self.assertEqual(result[6], Decimal('12345.6789')) - self.assertEqual(result[7], Decimal('0.1234')) - self.assertEqual(result[8], Decimal('-99.99')) - - def test_timestamp_subsecond_leading_zeros(self): - """Trino's JDBC driver truncates sub-second precision.""" - self.skipTest("Trino JDBC driver truncates sub-second precision") - - def test_timestamp_microsecond_precision(self): - """Trino's JDBC driver does not support getObject(_, LocalDateTime.class).""" - self.skipTest("Trino JDBC driver cannot convert TIMESTAMP to LocalDateTime") - - def test_binary_non_utf8_roundtrip(self): - """Trino memory connector does not support VARBINARY in CTAS for non-UTF-8 bytes.""" - self.skipTest("Trino memory connector does not support VARBINARY round-trip via CTAS") - - -class OracleTest(IntegrationTestBase, unittest.TestCase): - - def connect(self): - - import jpype - - host = os.environ.get("JY_ORACLE_HOST", "localhost") - port = os.environ.get("JY_ORACLE_PORT", "11521") - user = os.environ.get("JY_ORACLE_USER", "system") - password = os.environ.get("JY_ORACLE_PASSWORD", "Password123!") - - driver, url, driver_args = ( - 'oracle.jdbc.OracleDriver', - f'jdbc:oracle:thin:@{host}:{port}/XEPDB1', - {'user': user, 'password': password} - ) - - try: - db, conn = jaydebeapiarrow, jaydebeapiarrow.connect(driver, url, driver_args) - except jpype.JException: - self.fail("Can not connect with Oracle. Please check if the instance is up and running.") - else: - return db, conn - - def setUpSql(self): - self.sql_file(os.path.join(_THIS_DIR, 'data', 'create_oracle.sql')) - self.sql_file(os.path.join(_THIS_DIR, 'data', 'insert_oracle.sql')) - - def _double_create_sql(self): - return "CREATE TABLE DOUBLE_TEST (val BINARY_DOUBLE)" - - def test_execute_types(self): - """Oracle uses NUMBER(1) instead of BOOLEAN — VALID returns int not bool.""" - stmt = "insert into ACCOUNT (ACCOUNT_ID, ACCOUNT_NO, BALANCE, " \ - "BLOCKING, DBL_COL, OPENED_AT, VALID, PRODUCT_NAME) " \ - "values (?, ?, ?, ?, ?, ?, ?, ?)" - account_id = self.dbapi.Timestamp(2010, 1, 26, 14, 31, 59) - account_no = 20 - balance = Decimal('1.2') - blocking = 10.0 - dbl_col = 3.5 - opened_at = self.dbapi.Date(1908, 2, 27) - valid = 1 - product_name = u'Savings account' - parms = (account_id, account_no, balance, blocking, dbl_col, - opened_at, valid, product_name) - with self.conn.cursor() as cursor: - cursor.execute(stmt, parms) - stmt = "select ACCOUNT_ID, ACCOUNT_NO, BALANCE, BLOCKING, " \ - "DBL_COL, OPENED_AT, VALID, PRODUCT_NAME " \ - "from ACCOUNT where ACCOUNT_NO = ?" - parms = (20, ) - cursor.execute(stmt, parms) - result = cursor.fetchone() - # Oracle JDBC quirks: NUMBER/INTEGER columns return BigDecimal with - # full scale, and Oracle DATE maps to TIMESTAMP (includes time part). - exp = ( - self._cast_datetime('2010-01-26 14:31:59', r'%Y-%m-%d %H:%M:%S'), - Decimal('20.00000000000000000'), # INTEGER → NUMERIC → Decimal(scale=17) - Decimal('1.20'), # NUMBER(10,2) preserves scale - Decimal('10.00'), # NUMBER(10,2) preserves scale - dbl_col, - self._cast_datetime('1908-02-27 00:00:00', r'%Y-%m-%d %H:%M:%S'), - Decimal('1'), # NUMBER(1) → Decimal - product_name - ) - self.assertEqual(result, exp) - - def test_execute_type_time(self): - """Oracle has no native TIME type — OPENED_AT_TIME is TIMESTAMP.""" - stmt = "insert into ACCOUNT (ACCOUNT_ID, ACCOUNT_NO, BALANCE, " \ - "OPENED_AT_TIME) " \ - "values (?, ?, ?, ?)" - account_id = self.dbapi.Timestamp(2010, 1, 26, 14, 31, 59) - account_no = 20 - balance = 1.2 - opened_at_time = self.dbapi.Timestamp(1970, 1, 1, 13, 59, 59) - parms = (account_id, account_no, balance, opened_at_time) - with self.conn.cursor() as cursor: - cursor.execute(stmt, parms) - stmt = "select ACCOUNT_ID, ACCOUNT_NO, BALANCE, OPENED_AT_TIME " \ - "from ACCOUNT where ACCOUNT_NO = ?" - parms = (20, ) - cursor.execute(stmt, parms) - result = cursor.fetchone() - - exp = ( - self._cast_datetime('2010-01-26 14:31:59', r'%Y-%m-%d %H:%M:%S'), - account_no, Decimal(str(balance)), - self._cast_datetime('1970-01-01 13:59:59', r'%Y-%m-%d %H:%M:%S') - ) - self.assertEqual(result, exp) - - def _numeric_create_table_sql(self): - """Oracle uses NUMBER instead of NUMERIC/DECIMAL.""" - return ( - "CREATE TABLE NUMERIC_TEST (" - "ID INTEGER NOT NULL, " - "NUM_COL NUMBER(10, 2), " - "PRIMARY KEY (ID))" - ) - - def _numeric_combo_create_sql(self): - return ( - "CREATE TABLE NUMERIC_COMBO (" - "ID INTEGER NOT NULL, " - "DEC_S2 NUMBER(10, 2), " - "DEC_S4 NUMBER(15, 4), " - "DEC_S0 NUMBER(18, 0), " - "DEC_PES NUMBER(5, 5), " - "NUM_S2 NUMBER(10, 2), " - "NUM_S0 NUMBER(10, 0), " - "NUM_S4 NUMBER(15, 4), " - "NUM_PES NUMBER(4, 4), " - "NUM_NEG NUMBER(10, 2), " - "PRIMARY KEY (ID))" - ) - - -class DB2Test(IntegrationTestBase, unittest.TestCase): - - def connect(self): - - import jpype - - host = os.environ.get("JY_DB2_HOST", "localhost") - port = os.environ.get("JY_DB2_PORT", "15000") - user = os.environ.get("JY_DB2_USER", "db2inst1") - password = os.environ.get("JY_DB2_PASSWORD", "Password123!") - - driver, url, driver_args = ( - 'com.ibm.db2.jcc.DB2Driver', - f'jdbc:db2://{host}:{port}/test_db', - {'user': user, 'password': password} - ) - - try: - db, conn = jaydebeapiarrow, jaydebeapiarrow.connect(driver, url, driver_args) - except jpype.JException: - self.fail("Can not connect with DB2. Please check if the instance is up and running.") - else: - return db, conn - - def setUpSql(self): - self.sql_file(os.path.join(_THIS_DIR, 'data', 'create_db2.sql')) - self.sql_file(os.path.join(_THIS_DIR, 'data', 'insert.sql')) - - def test_execute_types(self): - """DB2 uses SMALLINT instead of BOOLEAN — VALID returns int not bool.""" - stmt = "insert into ACCOUNT (ACCOUNT_ID, ACCOUNT_NO, BALANCE, " \ - "BLOCKING, DBL_COL, OPENED_AT, VALID, PRODUCT_NAME) " \ - "values (?, ?, ?, ?, ?, ?, ?, ?)" - account_id = self.dbapi.Timestamp(2010, 1, 26, 14, 31, 59) - account_no = 20 - balance = Decimal('1.2') - blocking = 10.0 - dbl_col = 3.5 - opened_at = self.dbapi.Date(1908, 2, 27) - valid = 1 - product_name = u'Savings account' - parms = (account_id, account_no, balance, blocking, dbl_col, - opened_at, valid, product_name) - with self.conn.cursor() as cursor: - cursor.execute(stmt, parms) - stmt = "select ACCOUNT_ID, ACCOUNT_NO, BALANCE, BLOCKING, " \ - "DBL_COL, OPENED_AT, VALID, PRODUCT_NAME " \ - "from ACCOUNT where ACCOUNT_NO = ?" - parms = (20, ) - cursor.execute(stmt, parms) - result = cursor.fetchone() - exp = ( - self._cast_datetime('2010-01-26 14:31:59', r'%Y-%m-%d %H:%M:%S'), - account_no, balance, blocking, dbl_col, - self._cast_date('1908-02-27', r'%Y-%m-%d'), - valid, product_name - ) - self.assertEqual(result, exp) - - def test_blob_null_value(self): - """DB2 rejects NULL for VARBINARY parameter binding.""" - self.skipTest("DB2 does not support NULL for VARBINARY parameter binding") - - -class DrillTest(IntegrationTestBase, unittest.TestCase): - - def connect(self): - - import jpype - - host = os.environ.get("JY_DRILL_HOST", "localhost") - port = os.environ.get("JY_DRILL_PORT", "31010") - - driver, url, driver_args = ( - 'org.apache.drill.jdbc.Driver', - f'jdbc:drill:drillbit={host}:{port}', - None - ) - - try: - db, conn = jaydebeapiarrow, jaydebeapiarrow.connect(driver, url, driver_args) - except jpype.JException: - self.fail("Can not connect with Drill. Please check if the instance is up and running.") - else: - return db, conn - - def _cast_datetime(self, datetime_str, fmt=r'%Y-%m-%d %H:%M:%S'): - """Drill stores TIMESTAMP as UTC and shifts by JVM timezone on read.""" - dt = super()._cast_datetime(datetime_str, fmt) - import jpype - tz = jpype.JClass('java.util.TimeZone').getDefault() - epoch_ms = int(calendar.timegm(dt.timetuple())) * 1000 - offset_ms = tz.getOffset(epoch_ms) - return dt + timedelta(milliseconds=-offset_ms) - - def setUpSql(self): - jstmt = self.conn.jconn.createStatement() - try: - jstmt.execute("DROP TABLE IF EXISTS dfs.tmp.account") - except Exception: - pass - sql = open(os.path.join(_THIS_DIR, 'data', 'create_drill.sql')).read().strip().rstrip(';') - jstmt.execute(sql) - - def tearDown(self): - jstmt = self.conn.jconn.createStatement() - try: - jstmt.execute("DROP TABLE IF EXISTS dfs.tmp.account") - except Exception: - pass - try: - jstmt.execute("DROP TABLE IF EXISTS dfs.tmp.numeric_test") - except Exception: - pass - try: - jstmt.execute("DROP TABLE IF EXISTS dfs.tmp.blob_test") - except Exception: - pass - try: - jstmt.execute("DROP TABLE IF EXISTS dfs.tmp.numeric_combo") - except Exception: - pass - self.conn.close() - - def _query_table(self, cursor): - cursor.execute("select ACCOUNT_ID, ACCOUNT_NO, BALANCE, BLOCKING " - "from dfs.tmp.account") - - def test_double_column_returns_float(self): - """Drill: use direct JDBC for DDL, cursor for SELECT.""" - jstmt = self.conn.jconn.createStatement() - try: - jstmt.execute( - "CREATE TABLE dfs.tmp.DOUBLE_TEST AS " - "SELECT CAST(c1 AS DOUBLE) AS val FROM " - "(VALUES(3.14), (-1.5), (0.0)) AS t(c1)" - ) - except Exception: - jstmt.execute("DROP TABLE IF EXISTS dfs.tmp.DOUBLE_TEST") - raise - try: - with self.conn.cursor() as cursor: - cursor.execute("SELECT val FROM dfs.tmp.DOUBLE_TEST ORDER BY val") - result = cursor.fetchall() - finally: - jstmt.execute("DROP TABLE IF EXISTS dfs.tmp.DOUBLE_TEST") - self.assertEqual(len(result), 3) - for row in result: - self.assertIsInstance(row[0], float) - self.assertAlmostEqual(result[0][0], -1.5) - self.assertAlmostEqual(result[1][0], 0.0) - self.assertAlmostEqual(result[2][0], 3.14) - - def test_executemany(self): - """Drill has no INSERT INTO ... VALUES — skip executemany test.""" - self.skipTest("Drill does not support INSERT INTO ... VALUES") - - def test_execute_types(self): - """Drill preserves DECIMAL scale; data seeded via CTAS, no INSERT.""" - with self.conn.cursor() as cursor: - cursor.execute( - "SELECT ACCOUNT_ID, ACCOUNT_NO, BALANCE, BLOCKING, " - "DBL_COL, OPENED_AT, VALID, PRODUCT_NAME " - "FROM dfs.tmp.account WHERE ACCOUNT_NO = 20") - result = cursor.fetchone() - exp = ( - self._cast_datetime('2010-01-26 14:31:59', r'%Y-%m-%d %H:%M:%S'), - 20, Decimal('1.20'), Decimal('10.00'), 3.5, - self._cast_date('2024-01-15', r'%Y-%m-%d'), - True, 'Savings account' - ) - self.assertEqual(result, exp) - - def test_execute_type_time(self): - """Drill: TIME data seeded via CTAS, no INSERT needed.""" - with self.conn.cursor() as cursor: - cursor.execute( - "SELECT ACCOUNT_ID, ACCOUNT_NO, BALANCE, OPENED_AT_TIME " - "FROM dfs.tmp.account WHERE ACCOUNT_NO = 20") - result = cursor.fetchone() - exp = ( - self._cast_datetime('2010-01-26 14:31:59', r'%Y-%m-%d %H:%M:%S'), - 20, Decimal('1.20'), - self._cast_time('13:59:59', r'%H:%M:%S') - ) - self.assertEqual(result, exp) - - def test_execute_type_blob(self): - """Drill: seed VARBINARY via separate CTAS, verify read path.""" - jstmt = self.conn.jconn.createStatement() - jstmt.execute('DROP TABLE IF EXISTS dfs.tmp.blob_test') - jstmt.execute( - "CREATE TABLE dfs.tmp.blob_test AS " - "SELECT CAST('abcdef' AS VARBINARY) AS STUFF FROM (VALUES(1))") - with self.conn.cursor() as cursor: - cursor.execute("SELECT STUFF FROM dfs.tmp.blob_test") - result = cursor.fetchone() - binary_stuff = b'abcdef' - self.assertEqual(result[0], memoryview(binary_stuff)) - - def test_binary_non_utf8_roundtrip(self): - """Drill does not support CTAS with VARBINARY hex literals or - parameterized INSERT for binary data with non-UTF-8 bytes.""" - self.skipTest("Drill cannot create VARBINARY with non-UTF-8 bytes via CTAS") - - def test_numeric_types(self): - """Drill: seed NUMERIC_TEST via CTAS, then verify round-trip.""" - jstmt = self.conn.jconn.createStatement() - jstmt.execute('DROP TABLE IF EXISTS dfs.tmp.numeric_test') - jstmt.execute( - "CREATE TABLE dfs.tmp.numeric_test AS " - "SELECT 1 AS ID, CAST(NULL AS DECIMAL(10, 2)) AS NUM_COL " - "UNION ALL " - "SELECT 2, CAST(99.99 AS DECIMAL(10, 2)) " - "UNION ALL " - "SELECT 3, CAST(100.00 AS DECIMAL(10, 2))") - with self.conn.cursor() as cursor: - cursor.execute( - "SELECT NUM_COL FROM dfs.tmp.numeric_test ORDER BY ID") - result = cursor.fetchall() - self.assertEqual(len(result), 3) - self.assertIsNone(result[0][0]) - self.assertEqual(result[1][0], Decimal('99.99')) - self.assertEqual(result[2][0], Decimal('100.00')) - - def test_numeric_precision_scale_combos(self): - """Drill: seed NUMERIC_COMBO via CTAS, then verify round-trip.""" - jstmt = self.conn.jconn.createStatement() - jstmt.execute('DROP TABLE IF EXISTS dfs.tmp.numeric_combo') - jstmt.execute( - "CREATE TABLE dfs.tmp.numeric_combo AS " - "SELECT 1 AS ID, " - "CAST(12345.67 AS DECIMAL(10, 2)) AS DEC_S2, " - "CAST(12345.6789 AS DECIMAL(15, 4)) AS DEC_S4, " - "CAST(987654321012345678 AS DECIMAL(18, 0)) AS DEC_S0, " - "CAST(0.12345 AS DECIMAL(5, 5)) AS DEC_PES, " - "CAST(99.99 AS DECIMAL(10, 2)) AS NUM_S2, " - "CAST(42 AS DECIMAL(10, 0)) AS NUM_S0, " - "CAST(12345.6789 AS DECIMAL(15, 4)) AS NUM_S4, " - "CAST(0.1234 AS DECIMAL(4, 4)) AS NUM_PES, " - "CAST(-99.99 AS DECIMAL(10, 2)) AS NUM_NEG") - with self.conn.cursor() as cursor: - cursor.execute("SELECT DEC_S2, DEC_S4, DEC_S0, DEC_PES, " - "NUM_S2, NUM_S0, NUM_S4, NUM_PES, NUM_NEG " - "FROM dfs.tmp.numeric_combo ORDER BY ID") - result = cursor.fetchone() - self.assertEqual(result[0], Decimal('12345.67')) - self.assertEqual(result[1], Decimal('12345.6789')) - self.assertEqual(result[2], Decimal('987654321012345678')) - self.assertEqual(result[3], Decimal('0.12345')) - self.assertEqual(result[4], Decimal('99.99')) - self.assertEqual(result[5], Decimal('42')) - self.assertEqual(result[6], Decimal('12345.6789')) - self.assertEqual(result[7], Decimal('0.1234')) - self.assertEqual(result[8], Decimal('-99.99')) - - 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_different_rowcounts(self): - """Drill has no INSERT INTO ... VALUES — skip rowcount test.""" - self.skipTest("Drill does not support INSERT INTO ... VALUES") - - def test_lastrowid_none_after_select(self): - """Drill uses different table schema — skip.""" - self.skipTest("Drill test schema differs from standard ACCOUNT table") - - def test_lastrowid_none_after_insert(self): - """Drill has no INSERT INTO ... VALUES — skip.""" - self.skipTest("Drill does not support INSERT INTO ... VALUES") - - def test_lastrowid_none_after_executemany(self): - """Drill has no INSERT INTO ... VALUES — skip.""" - self.skipTest("Drill does not support INSERT INTO ... VALUES") - - def test_execute_reset_description_without_execute_result(self): - """Drill has no DELETE — verify description reset with SELECT only.""" - with self.conn.cursor() as cursor: - cursor.execute("select * from dfs.tmp.account") - self.assertIsNotNone(cursor.description) - cursor.fetchone() - - def test_execute_and_fetch(self): - with self.conn.cursor() as cursor: - cursor.execute("select ACCOUNT_ID, ACCOUNT_NO, BALANCE, BLOCKING " - "from dfs.tmp.account WHERE ACCOUNT_NO <= 19") - result = cursor.fetchall() - self.assertEqual(result, [ - ( - self._cast_datetime('2009-09-10 14:15:22.123', r'%Y-%m-%d %H:%M:%S.%f'), - 18, Decimal('12.40'), None), - ( - self._cast_datetime('2009-09-11 14:15:22.123', r'%Y-%m-%d %H:%M:%S.%f'), - 19, Decimal('12.90'), Decimal('1.00')) - ]) - - def test_execute_and_fetchone(self): - with self.conn.cursor() as cursor: - cursor.execute("select ACCOUNT_ID, ACCOUNT_NO, BALANCE, BLOCKING " - "from dfs.tmp.account WHERE ACCOUNT_NO <= 19 order by ACCOUNT_NO") - result = cursor.fetchone() - self.assertEqual(result, ( - self._cast_datetime('2009-09-10 14:15:22.123', r'%Y-%m-%d %H:%M:%S.%f'), - 18, Decimal('12.40'), None)) - cursor.close() - - def test_execute_and_fetchone_consecutive(self): - with self.conn.cursor() as cursor: - cursor.execute("select ACCOUNT_ID, ACCOUNT_NO, BALANCE, BLOCKING " - "from dfs.tmp.account WHERE ACCOUNT_NO <= 19 order by ACCOUNT_NO") - result1 = cursor.fetchone() - result2 = cursor.fetchone() - - self.assertEqual(result1, ( - self._cast_datetime('2009-09-10 14:15:22.123', r'%Y-%m-%d %H:%M:%S.%f'), - 18, Decimal('12.40'), None)) - - self.assertEqual(result2, ( - self._cast_datetime('2009-09-11 14:15:22.123', r'%Y-%m-%d %H:%M:%S.%f'), - 19, Decimal('12.90'), Decimal('1.00'))) - - def test_execute_and_fetch_no_data(self): - with self.conn.cursor() as cursor: - stmt = "select * from dfs.tmp.account where ACCOUNT_ID is null" - cursor.execute(stmt) - result = cursor.fetchall() - self.assertEqual(result, []) - - def test_execute_and_fetch_parameter(self): - """Drill does not support JDBC parameterized queries.""" - self.skipTest("Drill does not support prepared statement parameters") - - def test_execute_and_fetchone_after_end(self): - with self.conn.cursor() as cursor: - cursor.execute("select * from dfs.tmp.account where ACCOUNT_NO = 18") - cursor.fetchone() - result = cursor.fetchone() - self.assertIsNone(result) - - def test_execute_and_fetchmany(self): - with self.conn.cursor() as cursor: - cursor.execute("select ACCOUNT_ID, ACCOUNT_NO, BALANCE, BLOCKING " - "from dfs.tmp.account WHERE ACCOUNT_NO <= 19 order by ACCOUNT_NO") - result = cursor.fetchmany() - self.assertEqual(result, [ - ( - self._cast_datetime('2009-09-10 14:15:22.123', r'%Y-%m-%d %H:%M:%S.%f'), - 18, Decimal('12.40'), None) - ]) - - def test_timestamp_subsecond_leading_zeros(self): - """Drill does not support parameterized TIMESTAMP INSERT.""" - self.skipTest("Drill does not support parameterized TIMESTAMP INSERT") - - def test_timestamp_microsecond_precision(self): - """Drill does not support TIMESTAMP with microsecond INSERT via parameterized queries.""" - self.skipTest("Drill does not support parameterized TIMESTAMP INSERT") - - def test_blob_non_utf8_roundtrip(self): - """Drill does not support parameterized INSERT.""" - self.skipTest("Drill does not support parameterized INSERT queries") - - def test_blob_all_byte_values_roundtrip(self): - """Drill does not support parameterized INSERT.""" - self.skipTest("Drill does not support parameterized INSERT queries") - - def test_blob_null_value(self): - """Drill does not support parameterized INSERT.""" - self.skipTest("Drill does not support parameterized INSERT queries") - - -class JavaSqlTypesReflectionTest(unittest.TestCase): - """Verify java.sql.Types field access uses standard Java Reflection API - (not deprecated JPype getStaticAttribute). Regression for legacy #111.""" - - def setUp(self): - self.conn = jaydebeapiarrow.connect( - 'org.hsqldb.jdbc.JDBCDriver', - 'jdbc:hsqldb:mem:testreflection.', - ['SA', ''], - ) - - def tearDown(self): - self.conn.close() - - def test_type_constants_accessible_via_reflection(self): - """java.sql.Types constants should be accessible through - standard Java Reflection, not getStaticAttribute().""" - import jpype - Types = jpype.java.sql.Types - # Access via standard attribute access (JPype proxy) - self.assertEqual(Types.INTEGER, 4) - self.assertEqual(Types.VARCHAR, 12) - self.assertEqual(Types.TIMESTAMP, 93) - self.assertEqual(Types.DECIMAL, 3) - - def test_dbapi_type_comparison_with_real_connection(self): - """DBAPITypeObject comparison should work after a real JDBC - connection initializes the type mapping via Reflection.""" - import jpype - Types = jpype.java.sql.Types - # After connecting, _jdbc_const_to_name should be populated - self.assertIsNotNone(jaydebeapiarrow._jdbc_const_to_name) - # Verify type comparisons work - self.assertEqual(jaydebeapiarrow.NUMBER, Types.INTEGER) - self.assertEqual(jaydebeapiarrow.STRING, Types.VARCHAR) - self.assertEqual(jaydebeapiarrow.DATETIME, Types.TIMESTAMP) - - def test_cursor_description_maps_types_correctly(self): - """cursor.description should use correct type names from - Reflection-based type mapping.""" - with self.conn.cursor() as cursor: - cursor.execute("CREATE TABLE test_reflect (id INTEGER, name VARCHAR(50), val DECIMAL(10,2))") - cursor.execute("INSERT INTO test_reflect VALUES (1, 'test', 3.14)") - cursor.execute("SELECT * FROM test_reflect") - desc = cursor.description - # All three columns should have descriptions - self.assertEqual(len(desc), 3) - self.assertEqual(desc[0][0], 'ID') - self.assertEqual(desc[1][0], 'NAME') - self.assertEqual(desc[2][0], 'VAL') - - -class PropertiesDriverArgsPassingTest(unittest.TestCase): - - def test_connect_with_sequence(self): - driver, url, driver_args = ( 'org.hsqldb.jdbcDriver', - 'jdbc:hsqldb:mem:.', - ['SA', ''] ) - c = jaydebeapiarrow.connect(driver, url, driver_args) - c.close() - - def test_connect_with_properties(self): - driver, url, driver_args = ( 'org.hsqldb.jdbcDriver', - 'jdbc:hsqldb:mem:.', - {'user': 'SA', 'password': '' } ) - c = jaydebeapiarrow.connect(driver, url, driver_args) - c.close() - - -class JarPathSpacesIntegrationTest(unittest.TestCase): - """Integration test for JAR paths containing spaces (issue #86). - - Uses HSQLDB driver copied to a path with spaces, run in a subprocess - to avoid JPype single-JVM-per-process limitation. - """ - - def test_hsqldb_jar_path_with_spaces(self): - """HSQLDB connection should work when JAR is in a path with spaces.""" - # Find the HSQLDB JAR - hsqldb_jar = None - jar_dir = os.path.join(_THIS_DIR, 'jars') - if not os.path.isdir(jar_dir): - self.skipTest('test/jars/ directory not found (run download_jdbc_drivers.sh)') - for f in os.listdir(jar_dir): - if 'hsqldb' in f.lower() and f.endswith('.jar'): - hsqldb_jar = os.path.join(jar_dir, f) - break - self.assertIsNotNone(hsqldb_jar, 'HSQLDB JAR not found in test/jars/') - - with tempfile.TemporaryDirectory(prefix='path with spaces ') as tmpdir: - dest = os.path.join(tmpdir, os.path.basename(hsqldb_jar)) - shutil.copy2(hsqldb_jar, dest) - - code = f''' -import jaydebeapiarrow -conn = jaydebeapiarrow.connect( - 'org.hsqldb.jdbcDriver', - 'jdbc:hsqldb:mem:.', - ['SA', ''], - jars={repr(dest)} -) -cursor = conn.cursor() -cursor.execute('SELECT 1 AS col1 FROM (VALUES(0)) AS t') -rows = cursor.fetchall() -print(f'OK: {{rows}}') -cursor.close() -conn.close() -''' - result = subprocess.run( - [sys.executable, '-c', code], - capture_output=True, text=True, timeout=30, - cwd=os.path.dirname(_THIS_DIR) - ) - self.assertTrue(result.stdout.strip().startswith('OK'), - f'Connection failed: {result.stdout}\n{result.stderr}') - - -class ForkSafetyTest(unittest.TestCase): - """Tests for fork-safety guard (legacy issue #232).""" - - def test_fork_after_connect_raises_interface_error(self): - """Simulating a fork by overwriting the PID tracker must raise - InterfaceError when attempting a new connection.""" - import os - original_pid = jaydebeapiarrow._jvm_started_pid - try: - jaydebeapiarrow._jvm_started_pid = os.getpid() + 99999 - with self.assertRaises(jaydebeapiarrow.InterfaceError) as ctx: - jaydebeapiarrow.connect('org.hsqldb.jdbcDriver', - 'jdbc:hsqldb:mem:.', ['SA', '']) - self.assertIn("forked process", str(ctx.exception)) - finally: - jaydebeapiarrow._jvm_started_pid = original_pid - - def test_pid_recorded_after_connect(self): - """After connect(), _jvm_started_pid must equal the current PID.""" - import os - c = jaydebeapiarrow.connect('org.hsqldb.jdbcDriver', - 'jdbc:hsqldb:mem:.', ['SA', '']) - try: - self.assertEqual(jaydebeapiarrow._jvm_started_pid, os.getpid()) - finally: - c.close() - - -class DynamicClasspathIntegrationTest(unittest.TestCase): - """Tests for experimental dynamic_classpath feature with real JDBC driver.""" - - def _find_hsqldb_jar(self): - jar_dir = os.path.join(_THIS_DIR, 'jars') - if not os.path.isdir(jar_dir): - self.skipTest('test/jars/ directory not found (run download_jdbc_drivers.sh)') - for f in os.listdir(jar_dir): - if 'hsqldb' in f.lower() and f.endswith('.jar'): - return os.path.join(jar_dir, f) - self.skipTest('HSQLDB JAR not found in test/jars/') - - def _find_mock_jar(self): - for root, dirs, files in os.walk(_THIS_DIR): - for f in files: - if f.startswith('mockdriver') and f.endswith('.jar'): - return os.path.join(root, f) - self.skipTest('mockdriver JAR not found') - - def _run_in_subprocess(self, code): - return subprocess.run( - [sys.executable, '-c', code], - capture_output=True, text=True, timeout=30, - cwd=os.path.dirname(_THIS_DIR) - ) - - def test_hsqldb_fails_without_dynamic_classpath(self): - """Connecting to HSQLDB after JVM starts with only mock driver on classpath - should fail — the HSQLDB driver is not available.""" - hsqldb_jar = self._find_hsqldb_jar() - mock_jar = self._find_mock_jar() - - # Start JVM with CLASSPATH pointing only to mock JAR (no HSQLDB) - env = {**os.environ, 'CLASSPATH': mock_jar} - code = f''' -import jaydebeapiarrow - -# Start JVM with only the mock driver available -conn1 = jaydebeapiarrow.connect( - 'org.jaydebeapi.mockdriver.MockDriver', - 'jdbc:jaydebeapi://dummyurl' -) -conn1.close() - -# Try to connect to HSQLDB without dynamic classpath — should fail -# because HSQLDB driver was never loaded -try: - conn2 = jaydebeapiarrow.connect( - 'org.hsqldb.jdbcDriver', - 'jdbc:hsqldb:mem:.', - ['SA', ''] - ) - conn2.close() - print('UNEXPECTED_SUCCESS') -except Exception as e: - print(f'EXPECTED_FAIL: {{type(e).__name__}}') -''' - result = subprocess.run( - [sys.executable, '-c', code], - capture_output=True, text=True, timeout=30, - cwd=os.path.dirname(_THIS_DIR), - env=env - ) - self.assertTrue(result.stdout.strip().startswith('EXPECTED_FAIL'), - f'HSQLDB should fail without dynamic classpath.\n' - f'stdout: {result.stdout}\nstderr: {result.stderr}') - - def test_dynamic_load_hsqldb_after_jvm_start(self): - """Dynamically load HSQLDB driver after JVM is already running. - Starts JVM with only the mock driver, then loads HSQLDB from JAR.""" - hsqldb_jar = self._find_hsqldb_jar() - mock_jar = self._find_mock_jar() - - # Start JVM with CLASSPATH pointing only to mock JAR (no HSQLDB) - env = {**os.environ, 'CLASSPATH': mock_jar} - code = f''' -import jaydebeapiarrow - -# Start JVM with only the mock driver on the classpath -conn1 = jaydebeapiarrow.connect( - 'org.jaydebeapi.mockdriver.MockDriver', - 'jdbc:jaydebeapi://dummyurl' -) -conn1.close() - -# Verify HSQLDB is NOT available yet -try: - conn_bad = jaydebeapiarrow.connect( - 'org.hsqldb.jdbcDriver', - 'jdbc:hsqldb:mem:.', - ['SA', ''] - ) - conn_bad.close() - print('HSQQLDB_AVAILABLE_WITHOUT_DYNAMIC') -except Exception: - print('HSQQLDB_NOT_AVAILABLE') - -# Now dynamically load HSQLDB driver from JAR -conn2 = jaydebeapiarrow.connect( - 'org.hsqldb.jdbcDriver', - 'jdbc:hsqldb:mem:.', - ['SA', ''], - jars={repr(hsqldb_jar)}, - experimental={{'dynamic_classpath': True}} -) -cursor = conn2.cursor() - -# Verify it actually works — run real SQL -cursor.execute('CREATE TABLE test_dynamic (id INTEGER, name VARCHAR(50))') -cursor.execute("INSERT INTO test_dynamic VALUES (1, 'hello'), (2, 'world')") -cursor.execute('SELECT id, name FROM test_dynamic ORDER BY id') -rows = cursor.fetchall() -cursor.execute('DROP TABLE test_dynamic') -cursor.close() -conn2.close() - -print(f'DYNAMIC_OK: {{rows}}') -''' - result = subprocess.run( - [sys.executable, '-c', code], - capture_output=True, text=True, timeout=30, - cwd=os.path.dirname(_THIS_DIR), - env=env - ) - lines = result.stdout.strip().split('\n') - self.assertEqual(lines[0], 'HSQQLDB_NOT_AVAILABLE', - f'HSQLDB should not be available before dynamic load.\n' - f'stdout: {result.stdout}\nstderr: {result.stderr}') - self.assertEqual(lines[1], 'DYNAMIC_OK: [(1, \'hello\'), (2, \'world\')]', - f'Dynamic HSQLDB load failed or returned wrong data.\n' - f'stdout: {result.stdout}\nstderr: {result.stderr}') diff --git a/test/test_mock.py b/test/test_mock.py index dd3991d1..e87ea152 100644 --- a/test/test_mock.py +++ b/test/test_mock.py @@ -18,13 +18,14 @@ # . import jaydebeapiarrow -from datetime import datetime, timedelta +from datetime import datetime from decimal import Decimal import os -import shutil -import subprocess -import sys -import tempfile + +try: + from test._base import _SUPPRESS_LOGGING_ARGS +except ImportError: + from _base import _SUPPRESS_LOGGING_ARGS try: import unittest2 as unittest @@ -35,7 +36,8 @@ class MockTest(unittest.TestCase): def setUp(self): self.conn = jaydebeapiarrow.connect('org.jaydebeapi.mockdriver.MockDriver', - 'jdbc:jaydebeapi://dummyurl') + 'jdbc:jaydebeapi://dummyurl', + experimental={'jvm_args': _SUPPRESS_LOGGING_ARGS}) def tearDown(self): self.conn.close() @@ -66,11 +68,6 @@ def test_all_db_api_type_objects_have_valid_mapping(self): with self.conn.cursor() as cursor: cursor.execute("dummy stmt") cursor.fetchone() - # verify = self.conn.jconn.verifyResultSet() - # verify_get = getattr(verify, - # extra_type_mappings.get(db_api_type.group_name, - # 'getObject')) - # verify_get(1) def test_ancient_date_mapped(self): date = datetime(year=70, month=1, day=1).date() @@ -154,8 +151,6 @@ def test_decimal_high_precision_overflow(self): when the data exceeds the vector's configured scale.""" import jpype BigDecimal = jpype.JClass("java.math.BigDecimal") - # Value has scale 20, but vector is configured with scale 2. - # HALF_UP rounds to 2 decimal places. value = BigDecimal("123456789012345678.12345678901234567890") self.conn.jconn.mockHighPrecisionDecimalResult(value, 38, 2) with self.conn.cursor() as cursor: @@ -517,8 +512,6 @@ def test_runtime_exception_on_execute(self): cursor.execute("dummy stmt") self.fail("expected exception") except jaydebeapiarrow.InterfaceError as e: - # JPype 1.4.1: "java.lang.RuntimeException: expected" - # JPype 1.7.0+: "java.lang.java.lang.RuntimeException: java.lang.RuntimeException: expected" self.assertIn("RuntimeException: expected", str(e)) def test_sql_exception_on_commit(self): @@ -535,8 +528,6 @@ def test_runtime_exception_on_commit(self): self.conn.commit() self.fail("expected exception") except jaydebeapiarrow.InterfaceError as e: - # JPype 1.4.1: "java.lang.RuntimeException: expected" - # JPype 1.7.0+: "java.lang.java.lang.RuntimeException: java.lang.RuntimeException: expected" self.assertIn("RuntimeException: expected", str(e)) def test_sql_exception_on_rollback(self): @@ -553,8 +544,6 @@ def test_runtime_exception_on_rollback(self): self.conn.rollback() self.fail("expected exception") except jaydebeapiarrow.InterfaceError as e: - # JPype 1.4.1: "java.lang.RuntimeException: expected" - # JPype 1.7.0+: "java.lang.java.lang.RuntimeException: java.lang.RuntimeException: expected" self.assertIn("RuntimeException: expected", str(e)) def test_cursor_with_statement(self): @@ -566,7 +555,8 @@ def test_cursor_with_statement(self): def test_connection_with_statement(self): with jaydebeapiarrow.connect('org.jaydebeapi.mockdriver.MockDriver', - 'jdbc:jaydebeapi://dummyurl') as conn: + 'jdbc:jaydebeapi://dummyurl', + experimental={'jvm_args': _SUPPRESS_LOGGING_ARGS}) as conn: self.assertEqual(conn._closed, False) self.assertEqual(conn._closed, True) @@ -797,10 +787,7 @@ def test_dbapi_type_rowid_maps_to_rowid(self): # --- Timestamp sub-second leading zero tests (legacy #44) --- def test_timestamp_leading_zero_subsecond_096ms(self): - """Regression: .096 ms must not become .96 ms (legacy #44). - The legacy bug mangled 0.096965169 to 0.960000 by stripping the - leading zero during string-based parsing. Our Arrow path uses - integer nanosecond arithmetic via LocalDateTime.getNano().""" + """Regression: .096 ms must not become .96 ms (legacy #44).""" import jpype LocalDateTime = jpype.JClass("java.time.LocalDateTime") ldt = LocalDateTime.of(2017, 6, 19, 15, 30, 0, 96_965_169) @@ -860,7 +847,6 @@ def test_timestamp_microsecond_precision_200000(self): def test_timestamp_microsecond_precision_90000(self): """90000 microseconds (0.090000s) should round-trip correctly. - Legacy bug caused this to become 900000 (extra zero). Regression test for baztian/jaydebeapi#229.""" import jpype LocalDateTime = jpype.JClass("java.time.LocalDateTime") @@ -912,13 +898,7 @@ def test_timestamp_microsecond_precision_999999(self): # --- Timestamp timezone preservation tests (legacy issue #73) --- def test_timestamp_returns_naive_datetime(self): - """TIMESTAMP columns must return naive Python datetime objects. - - Regression test for baztian/jaydebeapi#73 where legacy jaydebeapi - returned timestamps shifted to the JVM's local timezone. Our Arrow - path normalizes to UTC on the Java side, so the returned datetime - should always be naive and match the stored value exactly. - """ + """TIMESTAMP columns must return naive Python datetime objects.""" self.conn.jconn.mockType("TIMESTAMP") with self.conn.cursor() as cursor: cursor.execute("dummy stmt") @@ -929,13 +909,7 @@ def test_timestamp_returns_naive_datetime(self): self.assertEqual(result[0], datetime(2009, 12, 1, 8, 20, 45)) def test_timestamp_utc_boundary_value(self): - """TIMESTAMP at UTC midnight must not shift to previous day. - - Regression test for baztian/jaydebeapi#73. If the JVM's default - timezone is behind UTC (e.g., EST = UTC-5), a naive implementation - would shift midnight UTC to the previous day. Our Arrow path uses - UTC normalization, so the value must be preserved exactly. - """ + """TIMESTAMP at UTC midnight must not shift to previous day.""" import jpype localDT = jpype.java.time.LocalDateTime.of(2024, 1, 15, 0, 0, 0) self.conn.jconn.mockTimestampResult(localDT) @@ -945,17 +919,11 @@ def test_timestamp_utc_boundary_value(self): self.assertEqual(result[0], datetime(2024, 1, 15, 0, 0, 0)) def test_timestamp_end_of_day_value(self): - """TIMESTAMP near end of day must not overflow to next day. - - Regression test for baztian/jaydebeapi#73. Verifies that a - timestamp near midnight (23:59:59) is preserved exactly without - timezone shifting causing a day rollover. - """ + """TIMESTAMP near end of day must not overflow to next day.""" self.conn.jconn.mockType("TIMESTAMP") with self.conn.cursor() as cursor: cursor.execute("dummy stmt") result = cursor.fetchone() - # The mock returns 2009-12-01T08:20:45 — verify exact value self.assertEqual(result[0].year, 2009) self.assertEqual(result[0].month, 12) self.assertEqual(result[0].day, 1) @@ -967,25 +935,22 @@ def test_timestamp_end_of_day_value(self): def test_no_deprecated_thread_attachment_api(self): """Verify that connect() does not use the deprecated - jpype.isThreadAttachedToJVM(). Regression test for legacy - baztian/jaydebeapi#203 where this triggered a DeprecationWarning.""" + jpype.isThreadAttachedToJVM().""" import inspect - import jaydebeapiarrow source = inspect.getsource(jaydebeapiarrow) self.assertNotIn('isThreadAttachedToJVM', source, - 'Deprecated jpype.isThreadAttachedToJVM() must not be used; ' - 'use jpype.java.lang.Thread.isAttached() instead') + 'Deprecated jpype.isThreadAttachedToJVM() must not be used') def test_connect_no_deprecation_warnings(self): """Verify that connecting via the mock driver emits no - DeprecationWarnings from JPype. Regression test for legacy - baztian/jaydebeapi#203.""" + DeprecationWarnings from JPype.""" import warnings with warnings.catch_warnings(record=True) as caught: warnings.simplefilter('always') self.conn = jaydebeapiarrow.connect( 'org.jaydebeapi.mockdriver.MockDriver', - 'jdbc:jaydebeapi://dummyurl') + 'jdbc:jaydebeapi://dummyurl', + experimental={'jvm_args': _SUPPRESS_LOGGING_ARGS}) jpype_warnings = [w for w in caught if issubclass(w.category, DeprecationWarning) and 'jpype' in str(w.message).lower()] @@ -997,9 +962,7 @@ def test_connect_no_deprecation_warnings(self): # --- Non-ASCII character round-trip tests (legacy issue #176) --- def test_varchar_german_umlauts(self): - """VARCHAR columns with German umlauts must round-trip correctly. - Regression test for baztian/jaydebeapi#176 where reading VARCHAR - columns containing umlauts caused CharConversionException.""" + """VARCHAR columns with German umlauts must round-trip correctly.""" self.conn.jconn.mockStringResult("Grüße aus München") with self.conn.cursor() as cursor: cursor.execute("dummy stmt") @@ -1034,13 +997,11 @@ def test_varchar_emoji(self): def test_long_query_string_18k_characters(self): """SQL strings of 18k+ characters must pass through execute() - and return correct values. Regression test for - baztian/jaydebeapi#91 where long queries caused failures.""" + and return correct values.""" self.conn.jconn.mockBigDecimalResult(1, 0) long_query = ("SELECT * FROM t WHERE id IN (" + ",".join(str(i) for i in range(5000)) + ")") - self.assertGreater(len(long_query), 18000, - "Test query must exceed 18k characters") + self.assertGreater(len(long_query), 18000) with self.conn.cursor() as cursor: cursor.execute(long_query) result = cursor.fetchone() @@ -1060,9 +1021,7 @@ def test_cursor_close_after_partial_fetch(self): self.assertIsNone(cursor._connection) def test_repeated_query_cycles_no_accumulation(self): - """Repeated execute/close cycles should not accumulate stale iterators - or buffers (legacy #227). The mock driver's ResultSet never exhausts, - so we test partial fetch + close cycles instead.""" + """Repeated execute/close cycles should not accumulate stale iterators.""" self.conn.jconn.mockType("INTEGER") for _ in range(10): cursor = self.conn.cursor() @@ -1070,7 +1029,6 @@ def test_repeated_query_cycles_no_accumulation(self): result = cursor.fetchone() self.assertIsNotNone(result) cursor.close() - # After close, iterator and buffer should be cleaned up self.assertIsNone(cursor._iter) self.assertEqual(cursor._buffer, []) @@ -1088,98 +1046,26 @@ def test_close_last_idempotent(self): def test_is_jvm_started_with_api_present(self): """_is_jvm_started() returns True when JVM is running via the standard API.""" - import jpype result = jaydebeapiarrow._is_jvm_started() self.assertTrue(result, "JVM should be started during mock tests") def test_is_jvm_started_fallback_without_public_api(self): - """_is_jvm_started() falls back to internal state when isJVMStarted is missing. - - Simulates JPype versions (e.g. 1.6.0) that removed the public - ``jpype.isJVMStarted()`` API. The helper must still return the - correct value by inspecting ``jpype._core._JVM_started``. - """ + """_is_jvm_started() falls back to internal state when isJVMStarted is missing.""" import jpype - # Save and remove the public API original = getattr(jpype, 'isJVMStarted', None) try: delattr(jpype, 'isJVMStarted') - # JVM is running in this test, so fallback must return True result = jaydebeapiarrow._is_jvm_started() self.assertTrue(result, "Fallback must return True when JVM is running") finally: - # Restore the original API if original is not None: jpype.isJVMStarted = original - # --- JPype field reflection API tests (legacy #111) --- - - def test_java_sql_types_reflection_uses_standard_api(self): - """Verify java.sql.Types constants are accessed via standard Java - Reflection API (field.get/getModifiers/getName), not the deprecated - JPype-specific getStaticAttribute() which was removed in newer JPype.""" - import jpype - Types = jpype.java.sql.Types - fields = Types.class_.getFields() - # Verify we can iterate fields using standard Reflection - static_public_fields = {} - for field in fields: - modifiers = field.getModifiers() - if jpype.java.lang.reflect.Modifier.isStatic(modifiers) and \ - jpype.java.lang.reflect.Modifier.isPublic(modifiers): - value = int(field.get(None)) - static_public_fields[field.getName()] = value - # Spot-check well-known constants - self.assertEqual(static_public_fields['INTEGER'], 4) - self.assertEqual(static_public_fields['VARCHAR'], 12) - self.assertEqual(static_public_fields['TIMESTAMP'], 93) - self.assertEqual(static_public_fields['DECIMAL'], 3) - self.assertEqual(static_public_fields['NUMERIC'], 2) - - def test_jdbc_type_mapping_populates_correctly(self): - """Verify _map_jdbc_type_to_dbapi builds the mapping using - standard Reflection (not getStaticAttribute).""" - import jpype - Types = jpype.java.sql.Types - # Trigger mapping population - result = jaydebeapiarrow.DBAPITypeObject._map_jdbc_type_to_dbapi(Types.INTEGER) - self.assertIs(result, jaydebeapiarrow.NUMBER) - # Verify mapping is populated (not empty dict) - self.assertIsNotNone(jaydebeapiarrow._jdbc_const_to_name) - self.assertGreater(len(jaydebeapiarrow._jdbc_const_to_name), 20) - - def test_dbapi_type_eq_with_jdbc_constants(self): - """Verify DBAPITypeObject.__eq__ works with JDBC type constants - accessed through standard Java Reflection.""" - import jpype - Types = jpype.java.sql.Types - # Trigger mapping population via a call to _map_jdbc_type_to_dbapi - jaydebeapiarrow.DBAPITypeObject._map_jdbc_type_to_dbapi(Types.INTEGER) - # Now __eq__ should work since _jdbc_const_to_name is populated - # Cast Java int to Python int for comparison - # (Java int's __eq__ doesn't delegate to our DBAPITypeObject.__eq__) - self.assertTrue(jaydebeapiarrow.NUMBER == int(Types.INTEGER)) - self.assertTrue(jaydebeapiarrow.NUMBER == int(Types.BIGINT)) - self.assertTrue(jaydebeapiarrow.NUMBER == int(Types.SMALLINT)) - self.assertTrue(jaydebeapiarrow.NUMBER == int(Types.TINYINT)) - # These should match STRING type - self.assertTrue(jaydebeapiarrow.STRING == int(Types.VARCHAR)) - self.assertTrue(jaydebeapiarrow.STRING == int(Types.CHAR)) - # These should match DATETIME type - self.assertTrue(jaydebeapiarrow.DATETIME == int(Types.TIMESTAMP)) - # DATE has its own type object - self.assertTrue(jaydebeapiarrow.DATE == int(Types.DATE)) + # --- VARCHAR data tests --- def test_varchar_returns_data_not_empty(self): - """Verify VARCHAR columns return actual data, not empty strings. - - Regression test for legacy issue #119 where Oracle 9i VARCHAR2 columns - returned empty strings. In the original jaydebeapi, getObject() could - return oracle.sql.CHAR objects that JPype failed to convert. In - jaydebeapiarrow, the Arrow JDBC adapter uses getString() which always - returns a proper java.lang.String. - """ + """Verify VARCHAR columns return actual data, not empty strings.""" self.conn.jconn.mockType("VARCHAR") with self.conn.cursor() as cursor: cursor.execute("dummy stmt") @@ -1189,15 +1075,10 @@ def test_varchar_returns_data_not_empty(self): self.assertNotEqual(result[0], "") def test_varchar_with_multicolumn_result(self): - """Verify VARCHAR data is returned correctly alongside numeric columns. - - Regression test for legacy issue #119: the reporter's query had mixed - VARCHAR and numeric columns, and only numeric data was returned. - """ + """Verify VARCHAR data is returned correctly alongside numeric columns.""" import jpype Types = jpype.java.sql.Types - # Set up a 2-column result: INTEGER + VARCHAR self.conn.jconn.mockMultiColumnResult( [Types.INTEGER, Types.VARCHAR], [42, "Hello World"] @@ -1211,8 +1092,7 @@ def test_varchar_with_multicolumn_result(self): # --- SQLXML type tests --- def test_sqlxml_column_returns_string(self): - """SQLXML columns should return Python strings, not Java objects. - Regression test for legacy issue baztian/jaydebeapi#223.""" + """SQLXML columns should return Python strings, not Java objects.""" self.conn.jconn.mockType("SQLXML") with self.conn.cursor() as cursor: cursor.execute("dummy stmt") @@ -1225,7 +1105,6 @@ def test_sqlxml_column_returns_string(self): def test_commit_skipped_when_autocommit_enabled(self): """commit() should be a no-op when autocommit is enabled.""" self.conn.jconn.mockAutoCommit(True) - # Should not raise even if commit would throw an exception self.conn.jconn.mockExceptionOnCommit("java.sql.SQLException", "Cannot commit when autoCommit is enabled.") self.conn.commit() # must not raise @@ -1233,7 +1112,6 @@ def test_commit_skipped_when_autocommit_enabled(self): def test_commit_called_when_autocommit_disabled(self): """commit() should call jconn.commit() when autocommit is disabled.""" self.conn.jconn.mockAutoCommit(False) - # No exception mock = default mock behavior, commit succeeds silently self.conn.commit() def test_rollback_skipped_when_autocommit_enabled(self): @@ -1272,218 +1150,3 @@ def test_lastrowid_none_after_insert(self): 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") - - # --- Fork-safety tests (legacy issue #232) --- - - def test_fork_after_connect_raises_error(self): - """Connecting in a forked process after JVM start must raise - InterfaceError. Regression test for baztian/jaydebeapi#232 where - JPype's native library was 'already loaded in another classloader'.""" - import os - original_pid = jaydebeapiarrow._jvm_started_pid - try: - jaydebeapiarrow._jvm_started_pid = os.getpid() + 99999 - with self.assertRaises(jaydebeapiarrow.InterfaceError) as ctx: - jaydebeapiarrow.connect('org.jaydebeapi.mockdriver.MockDriver', - 'jdbc:jaydebeapi://dummyurl') - self.assertIn("forked process", str(ctx.exception)) - finally: - jaydebeapiarrow._jvm_started_pid = original_pid - - def test_connect_records_pid_at_jvm_start(self): - """After a successful connect(), _jvm_started_pid must match - the current process PID.""" - import os - self.assertEqual(jaydebeapiarrow._jvm_started_pid, os.getpid()) - - -class JarPathSpacesTest(unittest.TestCase): - """Tests for JAR file paths containing spaces (issue #86). - - These tests must run in a subprocess because JPype only allows - one JVM start per process, and the main test suite already starts it. - """ - - def _find_mock_jar(self): - for root, dirs, files in os.walk(os.path.dirname(__file__)): - for f in files: - if f.startswith('mockdriver') and f.endswith('.jar'): - return os.path.join(root, f) - self.fail('mockdriver JAR not found') - - def _run_connect_in_subprocess(self, jar_path): - """Run a connect call in a fresh subprocess and return success/failure.""" - code = f''' -import jaydebeapiarrow -try: - conn = jaydebeapiarrow.connect( - 'org.jaydebeapi.mockdriver.MockDriver', - 'jdbc:jaydebeapi://dummyurl', - jars={repr(jar_path)} - ) - print('OK') - conn.close() -except Exception as e: - print(f'FAIL: {{type(e).__name__}}: {{e}}') -''' - result = subprocess.run( - [sys.executable, '-c', code], - capture_output=True, text=True, timeout=30, - cwd=os.path.dirname(os.path.dirname(__file__)) - ) - return result.stdout.strip(), result.stderr.strip() - - def test_jar_path_with_spaces(self): - """JAR paths containing spaces should work (issue #86).""" - mock_jar = self._find_mock_jar() - with tempfile.TemporaryDirectory(prefix='path with spaces ') as tmpdir: - dest = os.path.join(tmpdir, os.path.basename(mock_jar)) - shutil.copy2(mock_jar, dest) - stdout, stderr = self._run_connect_in_subprocess(dest) - self.assertEqual(stdout, 'OK', f'Connection failed: {stderr}') - - def test_jar_path_with_special_chars(self): - """JAR paths containing parentheses and special chars should work.""" - mock_jar = self._find_mock_jar() - with tempfile.TemporaryDirectory(prefix='path (x86) & test ') as tmpdir: - dest = os.path.join(tmpdir, os.path.basename(mock_jar)) - shutil.copy2(mock_jar, dest) - stdout, stderr = self._run_connect_in_subprocess(dest) - self.assertEqual(stdout, 'OK', f'Connection failed: {stderr}') - - -class DynamicClasspathTest(unittest.TestCase): - """Tests for experimental dynamic_classpath feature. - - These tests run in subprocesses because the JVM can only be started once - per process, and dynamic loading needs a JVM that is already running. - """ - - def _find_mock_jar(self): - for root, dirs, files in os.walk(os.path.dirname(__file__)): - for f in files: - if f.startswith('mockdriver') and f.endswith('.jar'): - return os.path.join(root, f) - self.fail('mockdriver JAR not found') - - def _run_in_subprocess(self, code): - """Run code in a fresh subprocess and return stdout, stderr.""" - result = subprocess.run( - [sys.executable, '-c', code], - capture_output=True, text=True, timeout=30, - cwd=os.path.dirname(os.path.dirname(__file__)) - ) - return result.stdout.strip(), result.stderr.strip() - - def test_dynamic_load_after_jvm_start(self): - """Connect with a driver JAR after JVM is already running (dynamic_classpath).""" - mock_jar = self._find_mock_jar() - code = f''' -import jaydebeapiarrow - -# First connection starts the JVM normally (no jars needed — mock driver -# is found via CLASSPATH in test harness) -conn1 = jaydebeapiarrow.connect( - 'org.jaydebeapi.mockdriver.MockDriver', - 'jdbc:jaydebeapi://dummyurl' -) -conn1.close() - -# Second connection uses dynamic classpath to load the driver from JAR -conn2 = jaydebeapiarrow.connect( - 'org.jaydebeapi.mockdriver.MockDriver', - 'jdbc:jaydebeapi://dummyurl', - jars={repr(mock_jar)}, - experimental={{'dynamic_classpath': True}} -) -conn2.close() -print('OK') -''' - stdout, stderr = self._run_in_subprocess(code) - self.assertEqual(stdout, 'OK', f'Dynamic load failed: {stderr}') - - def test_dynamic_load_without_flag_raises_error(self): - """Without dynamic_classpath flag, connecting with new JARs after JVM - start should raise InterfaceError (fork guard).""" - mock_jar = self._find_mock_jar() - code = f''' -import jaydebeapiarrow - -# Start JVM with first connection -conn1 = jaydebeapiarrow.connect( - 'org.jaydebeapi.mockdriver.MockDriver', - 'jdbc:jaydebeapi://dummyurl' -) -conn1.close() - -# Try connecting with explicit jars after JVM start — no experimental flag -try: - conn2 = jaydebeapiarrow.connect( - 'org.jaydebeapi.mockdriver.MockDriver', - 'jdbc:jaydebeapi://dummyurl', - jars={repr(mock_jar)} - ) - conn2.close() - print('NO_ERROR') -except jaydebeapiarrow.InterfaceError as e: - if 'forked process' in str(e): - print('FORK_ERROR') - else: - print(f'OTHER_INTERFACE_ERROR: {{e}}') -except Exception as e: - print(f'OTHER_ERROR: {{type(e).__name__}}: {{e}}') -''' - stdout, stderr = self._run_in_subprocess(code) - # Note: the fork guard only triggers if PID differs (fork scenario). - # In a normal subprocess without fork, the PID is the same, so this - # won't raise. The dynamic_classpath flag is primarily for forked - # processes (gunicorn workers). We just verify it doesn't crash. - self.assertIn(stdout, ['OK', 'NO_ERROR', 'FORK_ERROR', 'OTHER_INTERFACE_ERROR'], - f'Unexpected output: {stdout}\nstderr: {stderr}') - - def test_dynamic_load_bypasses_fork_guard(self): - """dynamic_classpath flag bypasses the fork-after-JVM-start guard.""" - mock_jar = self._find_mock_jar() - code = f''' -import jaydebeapiarrow, os - -# Start JVM -conn1 = jaydebeapiarrow.connect( - 'org.jaydebeapi.mockdriver.MockDriver', - 'jdbc:jaydebeapi://dummyurl' -) -conn1.close() - -# Simulate fork: change _jvm_started_pid to a different PID -jaydebeapiarrow._jvm_started_pid = os.getpid() + 99999 - -# Without flag — should raise -try: - conn2 = jaydebeapiarrow.connect( - 'org.jaydebeapi.mockdriver.MockDriver', - 'jdbc:jaydebeapi://dummyurl', - jars={repr(mock_jar)} - ) - print('NO_ERROR') -except jaydebeapiarrow.InterfaceError as e: - print('FORK_ERROR') - -# With flag — should succeed -try: - conn3 = jaydebeapiarrow.connect( - 'org.jaydebeapi.mockdriver.MockDriver', - 'jdbc:jaydebeapi://dummyurl', - jars={repr(mock_jar)}, - experimental={{'dynamic_classpath': True}} - ) - conn3.close() - print('DYNAMIC_OK') -except Exception as e: - print(f'DYNAMIC_FAIL: {{type(e).__name__}}: {{e}}') -''' - stdout, stderr = self._run_in_subprocess(code) - lines = stdout.split('\n') - self.assertEqual(lines[0], 'FORK_ERROR', - f'Expected fork error without flag, got: {stdout}\nstderr: {stderr}') - self.assertEqual(lines[1], 'DYNAMIC_OK', - f'Dynamic load should bypass fork guard, got: {stdout}\nstderr: {stderr}') diff --git a/test/test_mssql.py b/test/test_mssql.py new file mode 100644 index 00000000..bc281152 --- /dev/null +++ b/test/test_mssql.py @@ -0,0 +1,64 @@ +#-*- coding: utf-8 -*- + +import jaydebeapiarrow +import os +import unittest + +try: + from test._base import IntegrationTestBase, _THIS_DIR, _SUPPRESS_LOGGING_ARGS +except ImportError: + from _base import IntegrationTestBase, _THIS_DIR, _SUPPRESS_LOGGING_ARGS + + +class MSSQLTest(IntegrationTestBase, unittest.TestCase): + + def connect(self): + + import jpype + + host = os.environ.get("JY_MSSQL_HOST", "localhost") + port = os.environ.get("JY_MSSQL_PORT", "11433") + user = os.environ.get("JY_MSSQL_USER", "sa") + password = os.environ.get("JY_MSSQL_PASSWORD", "Password123!") + + driver, url, driver_args = ( + 'com.microsoft.sqlserver.jdbc.SQLServerDriver', + f'jdbc:sqlserver://{host}:{port};encrypt=false;trustServerCertificate=true', + {'user': user, 'password': password} + ) + + try: + db, conn = jaydebeapiarrow, jaydebeapiarrow.connect( + driver, url, driver_args, + experimental={'jvm_args': _SUPPRESS_LOGGING_ARGS}) + except jpype.JException: + self.fail("Can not connect with MS SQL Server. Please check if the instance is up and running.") + else: + return db, conn + + def setUpSql(self): + with self.conn.cursor() as cursor: + cursor.execute("IF DB_ID('test_db') IS NULL CREATE DATABASE test_db") + cursor.execute("USE test_db") + self.sql_file(os.path.join(_THIS_DIR, 'data', 'create_mssql.sql')) + self.sql_file(os.path.join(_THIS_DIR, 'data', 'insert.sql')) + + def _cleanup_tables(self): + with self.conn.cursor() as cursor: + try: + cursor.execute("USE test_db") + except Exception: + pass + super()._cleanup_tables() + + def tearDown(self): + with self.conn.cursor() as cursor: + cursor.execute("USE test_db") + super().tearDown() + + def _double_create_sql(self): + return "CREATE TABLE DOUBLE_TEST (val FLOAT)" + + def test_blob_null_value(self): + """MSSQL JDBC driver rejects NULL parameter binding for VARBINARY columns.""" + self.skipTest("MSSQL JDBC driver does not support NULL for VARBINARY parameter binding") diff --git a/test/test_mysql.py b/test/test_mysql.py new file mode 100644 index 00000000..bd6ed10d --- /dev/null +++ b/test/test_mysql.py @@ -0,0 +1,42 @@ +#-*- coding: utf-8 -*- + +import jaydebeapiarrow +import os +import unittest + +try: + from test._base import IntegrationTestBase, _THIS_DIR, _SUPPRESS_LOGGING_ARGS +except ImportError: + from _base import IntegrationTestBase, _THIS_DIR, _SUPPRESS_LOGGING_ARGS + + +class MySQLTest(IntegrationTestBase, unittest.TestCase): + + def connect(self): + + import jpype + + host = os.environ.get("JY_MYSQL_HOST", "localhost") + port = os.environ.get("JY_MYSQL_PORT", "13306") + db_name = os.environ.get("JY_MYSQL_DB", "test_db") + user = os.environ.get("JY_MYSQL_USER", "user") + password = os.environ.get("JY_MYSQL_PASSWORD", "password") + + driver, url, driver_args = ( + 'com.mysql.cj.jdbc.Driver', + f'jdbc:mysql://{host}:{port}/{db_name}?user={user}&password={password}', + None + ) + + try: + db, conn = jaydebeapiarrow, jaydebeapiarrow.connect( + driver, url, driver_args, + experimental={'jvm_args': _SUPPRESS_LOGGING_ARGS}) + except jpype.JException as e: + self.fail("Can not connect with MySQL. Please check if the instance is up and running.") + else: + return db, conn + + def setUpSql(self): + self.sql_file(os.path.join(_THIS_DIR, 'data', 'create_mysql.sql')) + self.sql_file(os.path.join(_THIS_DIR, 'data', 'insert.sql')) diff --git a/test/test_oracle.py b/test/test_oracle.py new file mode 100644 index 00000000..66a888e0 --- /dev/null +++ b/test/test_oracle.py @@ -0,0 +1,139 @@ +#-*- coding: utf-8 -*- + +import jaydebeapiarrow +import os +import unittest + +from decimal import Decimal +from datetime import datetime +try: + from test._base import IntegrationTestBase, _THIS_DIR, _SUPPRESS_LOGGING_ARGS +except ImportError: + from _base import IntegrationTestBase, _THIS_DIR, _SUPPRESS_LOGGING_ARGS + + +class OracleTest(IntegrationTestBase, unittest.TestCase): + + def connect(self): + + import jpype + + host = os.environ.get("JY_ORACLE_HOST", "localhost") + port = os.environ.get("JY_ORACLE_PORT", "11521") + user = os.environ.get("JY_ORACLE_USER", "system") + password = os.environ.get("JY_ORACLE_PASSWORD", "Password123!") + + driver, url, driver_args = ( + 'oracle.jdbc.OracleDriver', + f'jdbc:oracle:thin:@{host}:{port}/XEPDB1', + {'user': user, 'password': password} + ) + + try: + db, conn = jaydebeapiarrow, jaydebeapiarrow.connect( + driver, url, driver_args, + experimental={'jvm_args': _SUPPRESS_LOGGING_ARGS}) + except jpype.JException: + self.fail("Can not connect with Oracle. Please check if the instance is up and running.") + else: + return db, conn + + def setUpSql(self): + self.sql_file(os.path.join(_THIS_DIR, 'data', 'create_oracle.sql')) + self.sql_file(os.path.join(_THIS_DIR, 'data', 'insert_oracle.sql')) + + def _double_create_sql(self): + return "CREATE TABLE DOUBLE_TEST (val BINARY_DOUBLE)" + + def test_execute_types(self): + """Oracle uses NUMBER(1) instead of BOOLEAN — VALID returns int not bool.""" + stmt = "insert into ACCOUNT (ACCOUNT_ID, ACCOUNT_NO, BALANCE, " \ + "BLOCKING, DBL_COL, OPENED_AT, VALID, PRODUCT_NAME) " \ + "values (?, ?, ?, ?, ?, ?, ?, ?)" + account_id = self.dbapi.Timestamp(2010, 1, 26, 14, 31, 59) + account_no = 20 + balance = Decimal('1.2') + blocking = 10.0 + dbl_col = 3.5 + opened_at = self.dbapi.Date(1908, 2, 27) + valid = 1 + product_name = u'Savings account' + parms = (account_id, account_no, balance, blocking, dbl_col, + opened_at, valid, product_name) + with self.conn.cursor() as cursor: + cursor.execute(stmt, parms) + stmt = "select ACCOUNT_ID, ACCOUNT_NO, BALANCE, BLOCKING, " \ + "DBL_COL, OPENED_AT, VALID, PRODUCT_NAME " \ + "from ACCOUNT where ACCOUNT_NO = ?" + parms = (20, ) + cursor.execute(stmt, parms) + result = cursor.fetchone() + # Oracle JDBC quirks: NUMBER/INTEGER columns return BigDecimal with + # full scale, and Oracle DATE maps to TIMESTAMP (includes time part). + exp = ( + self._cast_datetime('2010-01-26 14:31:59', r'%Y-%m-%d %H:%M:%S'), + Decimal('20.00000000000000000'), # INTEGER → NUMERIC → Decimal(scale=17) + Decimal('1.20'), # NUMBER(10,2) preserves scale + Decimal('10.00'), # NUMBER(10,2) preserves scale + dbl_col, + self._cast_datetime('1908-02-27 00:00:00', r'%Y-%m-%d %H:%M:%S'), + Decimal('1'), # NUMBER(1) → Decimal + product_name + ) + self.assertEqual(result, exp) + + def test_execute_type_time(self): + """Oracle has no native TIME type — OPENED_AT_TIME is TIMESTAMP.""" + stmt = "insert into ACCOUNT (ACCOUNT_ID, ACCOUNT_NO, BALANCE, " \ + "OPENED_AT_TIME) " \ + "values (?, ?, ?, ?)" + account_id = self.dbapi.Timestamp(2010, 1, 26, 14, 31, 59) + account_no = 20 + balance = 1.2 + opened_at_time = self.dbapi.Timestamp(1970, 1, 1, 13, 59, 59) + parms = (account_id, account_no, balance, opened_at_time) + with self.conn.cursor() as cursor: + cursor.execute(stmt, parms) + stmt = "select ACCOUNT_ID, ACCOUNT_NO, BALANCE, OPENED_AT_TIME " \ + "from ACCOUNT where ACCOUNT_NO = ?" + parms = (20, ) + cursor.execute(stmt, parms) + result = cursor.fetchone() + + exp = ( + self._cast_datetime('2010-01-26 14:31:59', r'%Y-%m-%d %H:%M:%S'), + account_no, Decimal(str(balance)), + self._cast_datetime('1970-01-01 13:59:59', r'%Y-%m-%d %H:%M:%S') + ) + self.assertEqual(result, exp) + + def _numeric_create_table_sql(self): + """Oracle uses NUMBER instead of NUMERIC/DECIMAL.""" + return ( + "CREATE TABLE NUMERIC_TEST (" + "ID INTEGER NOT NULL, " + "NUM_COL NUMBER(10, 2), " + "PRIMARY KEY (ID))" + ) + + def _numeric_combo_create_sql(self): + return ( + "CREATE TABLE NUMERIC_COMBO (" + "ID INTEGER NOT NULL, " + "DEC_S2 NUMBER(10, 2), " + "DEC_S4 NUMBER(15, 4), " + "DEC_S0 NUMBER(18, 0), " + "DEC_PES NUMBER(5, 5), " + "NUM_S2 NUMBER(10, 2), " + "NUM_S0 NUMBER(10, 0), " + "NUM_S4 NUMBER(15, 4), " + "NUM_PES NUMBER(4, 4), " + "NUM_NEG NUMBER(10, 2), " + "PRIMARY KEY (ID))" + ) + + def test_long_query_string_18k_characters(self): + self.skipTest("Oracle has a 1000-element limit on IN clauses") + + def test_varchar_columns_return_data(self): + self.skipTest("Oracle requires TO_TIMESTAMP for date string literals") diff --git a/test/test_postgres.py b/test/test_postgres.py new file mode 100644 index 00000000..a7773bfa --- /dev/null +++ b/test/test_postgres.py @@ -0,0 +1,242 @@ +#-*- coding: utf-8 -*- + +import jaydebeapiarrow +import os +import unittest + +from decimal import Decimal +from datetime import datetime, timezone +try: + from test._base import IntegrationTestBase, _THIS_DIR, _SUPPRESS_LOGGING_ARGS +except ImportError: + from _base import IntegrationTestBase, _THIS_DIR, _SUPPRESS_LOGGING_ARGS + + +class PostgresTest(IntegrationTestBase, unittest.TestCase): + + def connect(self): + + import jpype + + host = os.environ.get("JY_PG_HOST", "localhost") + port = os.environ.get("JY_PG_PORT", "15432") + db_name = os.environ.get("JY_PG_DB", "test_db") + user = os.environ.get("JY_PG_USER", "user") + password = os.environ.get("JY_PG_PASSWORD", "password") + + driver, url, driver_args = ( + 'org.postgresql.Driver', + f'jdbc:postgresql://{host}:{port}/{db_name}', + {'user': user, 'password': password} + ) + + try: + db, conn = jaydebeapiarrow, jaydebeapiarrow.connect( + driver, url, driver_args, + experimental={'jvm_args': _SUPPRESS_LOGGING_ARGS}) + except jpype.JException: + self.fail("Can not connect with PostgreSQL. Please check if the instance is up and running.") + else: + return db, conn + + + def setUpSql(self): + self.sql_file(os.path.join(_THIS_DIR, 'data', 'create_postgres.sql')) + self.sql_file(os.path.join(_THIS_DIR, 'data', 'insert.sql')) + + def _double_create_sql(self): + return "CREATE TABLE DOUBLE_TEST (val DOUBLE PRECISION)" + + def test_timestamp_microsecond_precision(self): + """PostgreSQL-specific: verify microsecond precision on both TIMESTAMP + and TIMESTAMPTZ columns.""" + test_cases = [ + (2009, 9, 11, 10, 0, 0, 200000), + (2009, 9, 11, 10, 0, 1, 90000), + (2009, 9, 11, 10, 0, 2, 123456), + (2009, 9, 11, 10, 0, 3, 0), + (2009, 9, 11, 10, 0, 4, 999999), + ] + stmt = ("insert into ACCOUNT (ACCOUNT_ID, ACCOUNT_NO, BALANCE, " + "ACCOUNT_ID_TZ) values (?, ?, ?, ?)") + with self.conn.cursor() as cursor: + cursor.execute("SET TIME ZONE 'UTC'") + for idx, (y, mo, d, h, mi, s, us) in enumerate(test_cases): + ts = self.dbapi.Timestamp(y, mo, d, h, mi, s, us) + cursor.execute(stmt, (ts, 50 + idx, Decimal('1.0'), ts)) + cursor.execute( + "select ACCOUNT_ID, ACCOUNT_ID_TZ from ACCOUNT " + "where ACCOUNT_NO >= 50 order by ACCOUNT_NO") + results = cursor.fetchall() + for idx, (y, mo, d, h, mi, s, us) in enumerate(test_cases): + expected = self._cast_datetime( + f'{y}-{mo:02d}-{d:02d} {h:02d}:{mi:02d}:{s:02d}.{us:06d}', + r'%Y-%m-%d %H:%M:%S.%f') + self.assertEqual(results[idx][0], expected, + f"TIMESTAMP failed for microseconds={us}") + # TIMESTAMPTZ should be timezone-aware (UTC) + self.assertEqual(results[idx][1], + expected.replace(tzinfo=timezone.utc), + f"TIMESTAMPTZ failed for microseconds={us}") + + def test_binary_non_utf8_roundtrip(self): + """PostgreSQL-specific: verify bytea columns preserve all 256 byte values + and non-UTF-8 sequences through the Arrow path. Regression test for + legacy issue baztian/jaydebeapi#147.""" + # Full 256-byte spectrum (every possible byte value) + all_bytes = bytes(range(256)) + # Non-UTF-8 sequences that commonly get corrupted + non_utf8_patterns = [ + bytes([0x80, 0x81, 0xff, 0xfe]), + bytes([0xc0, 0x80]), # overlong null + bytes([0xff, 0xff, 0xff]), + bytes([0x00, 0x00, 0x00, 0x00]), # null bytes + ] + stmt = ("insert into ACCOUNT (ACCOUNT_ID, ACCOUNT_NO, BALANCE, " + "STUFF) values (?, ?, ?, ?)") + with self.conn.cursor() as cursor: + # Test full 256-byte spectrum + account_id = self.dbapi.Timestamp(2009, 9, 11, 14, 15, 22, 123450) + cursor.execute(stmt, (account_id, 20, Decimal('13.1'), + self.dbapi.Binary(all_bytes))) + # Test individual non-UTF-8 patterns + for idx, pattern in enumerate(non_utf8_patterns): + aid = self.dbapi.Timestamp(2010, 1, 1, 0, 0, 0, idx) + cursor.execute(stmt, (aid, 30 + idx, Decimal('1.0'), + self.dbapi.Binary(pattern))) + # Read back and verify + cursor.execute( + "select STUFF from ACCOUNT where ACCOUNT_NO = 20") + result = cursor.fetchone() + self.assertEqual(bytes(result[0]), all_bytes, + "Full 256-byte spectrum mismatch") + for idx, pattern in enumerate(non_utf8_patterns): + cursor.execute( + "select STUFF from ACCOUNT where ACCOUNT_NO = ?", + (30 + idx,)) + result = cursor.fetchone() + self.assertEqual(bytes(result[0]), pattern, + f"Pattern {idx} mismatch: {pattern!r}") + + def test_execute_timestamptz_roundtrip_non_utc_session(self): + """Test TIMESTAMPTZ read/write with a non-UTC session timezone. + + Sets the session to Australia/Sydney (UTC+10 standard / UTC+11 DST), + inserts a naive string via SQL (interpreted as Sydney local time by PG), + then verifies our Arrow bridge correctly normalizes to UTC on read. + """ + with self.conn.cursor() as cursor: + # Use a timezone with DST to make this a real test + cursor.execute("SET TIME ZONE 'Australia/Sydney'") + # Insert via raw SQL — PG interprets this as Sydney time + # January = AEDT (UTC+11), so 10:30 local = 23:30 previous day UTC + cursor.execute( + "INSERT INTO ACCOUNT (ACCOUNT_ID, ACCOUNT_NO, BALANCE, ACCOUNT_ID_TZ) " + "VALUES ('2024-01-15 10:30:00', 30, 5.0, '2024-01-15 10:30:00')" + ) + + # Read back via Arrow bridge — should normalize to UTC + cursor.execute("SELECT ACCOUNT_ID, ACCOUNT_ID_TZ FROM ACCOUNT WHERE ACCOUNT_NO = 30") + result = cursor.fetchone() + + # ACCOUNT_ID (plain TIMESTAMP) is NOT affected by timezone — returns as-is + self.assertEqual(result[0], datetime(2024, 1, 15, 10, 30, 0)) + self.assertIsNone(result[0].tzinfo) + + # ACCOUNT_ID_TZ (TIMESTAMPTZ) is normalized to UTC by the bridge + # 10:30 AEDT (UTC+11) = 2024-01-14 23:30:00 UTC + self.assertEqual(result[1], datetime(2024, 1, 14, 23, 30, 0, tzinfo=timezone.utc)) + self.assertIsNotNone(result[1].tzinfo) + + def test_json_column_read(self): + """Verify JSON columns (JDBC OTHER) are readable as strings via ExplicitTypeMapper.""" + with self.conn.cursor() as cursor: + cursor.execute("CREATE TABLE test_json_type (id INT, data JSON)") + try: + cursor.execute( + "INSERT INTO test_json_type (id, data) VALUES (1, '{\"key\": \"value\"}')" + ) + cursor.execute("SELECT data FROM test_json_type WHERE id = 1") + result = cursor.fetchone() + # Verify data is readable as a string + self.assertIsInstance(result[0], str) + self.assertIn("key", result[0]) + # Verify cursor.description reports STRING type code (OTHER → STRING) + self.assertIs(cursor.description[0][1], jaydebeapiarrow.STRING) + finally: + cursor.execute("DROP TABLE test_json_type") + + def test_uuid_column_read(self): + """Verify UUID columns (JDBC OTHER) are readable as strings via ExplicitTypeMapper.""" + with self.conn.cursor() as cursor: + cursor.execute("CREATE TABLE test_uuid_type (id INT, data UUID)") + try: + cursor.execute( + "INSERT INTO test_uuid_type (id, data) " + "VALUES (1, 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11')" + ) + cursor.execute("SELECT data FROM test_uuid_type WHERE id = 1") + result = cursor.fetchone() + # Verify data is readable as a string + self.assertIsInstance(result[0], str) + self.assertEqual(result[0], "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11") + # Verify cursor.description reports STRING type code (OTHER → STRING) + self.assertIs(cursor.description[0][1], jaydebeapiarrow.STRING) + finally: + cursor.execute("DROP TABLE test_uuid_type") + + def test_xml_column_read(self): + """Verify XML columns are readable as strings via ExplicitTypeMapper. + Regression test for legacy issue baztian/jaydebeapi#223.""" + with self.conn.cursor() as cursor: + cursor.execute("CREATE TABLE test_xml_type (id INT, data XML)") + try: + cursor.execute( + "INSERT INTO test_xml_type (id, data) " + "VALUES (1, 'hello')" + ) + cursor.execute("SELECT data FROM test_xml_type WHERE id = 1") + result = cursor.fetchone() + self.assertIsInstance(result[0], str) + self.assertEqual(result[0], 'hello') + finally: + cursor.execute("DROP TABLE test_xml_type") + + def test_array_column_read(self): + """Verify ARRAY columns are readable as strings via ExplicitTypeMapper VARCHAR fallback.""" + with self.conn.cursor() as cursor: + cursor.execute("CREATE TABLE test_array_type (id INT, data INTEGER[])") + try: + cursor.execute( + "INSERT INTO test_array_type (id, data) VALUES (1, '{1,2,3}')" + ) + cursor.execute("SELECT data FROM test_array_type WHERE id = 1") + result = cursor.fetchone() + # Verify data is readable (degraded VARCHAR fallback — toString representation) + self.assertIsInstance(result[0], str) + # Verify cursor.description reports ARRAY type code + self.assertIs(cursor.description[0][1], jaydebeapiarrow.ARRAY) + finally: + cursor.execute("DROP TABLE test_array_type") + + def test_execute_timestamptz_roundtrip_param_binding(self): + """Test writing a TZ-aware datetime via parameter binding and reading back.""" + # Reset to UTC for a clean parameter-binding round-trip + with self.conn.cursor() as cursor: + cursor.execute("SET TIME ZONE 'UTC'") + naive_id = datetime(2024, 6, 15, 10, 30, 0) + tz_dt = datetime(2024, 6, 15, 10, 30, 0, tzinfo=timezone.utc) + cursor.execute( + "INSERT INTO ACCOUNT (ACCOUNT_ID, ACCOUNT_NO, BALANCE, ACCOUNT_ID_TZ) " + "VALUES (?, ?, ?, ?)", + (naive_id, 31, Decimal('5.0'), tz_dt) + ) + cursor.execute("SELECT ACCOUNT_ID, ACCOUNT_ID_TZ FROM ACCOUNT WHERE ACCOUNT_NO = 31") + result = cursor.fetchone() + + # ACCOUNT_ID (TIMESTAMP) should be naive + self.assertEqual(result[0], datetime(2024, 6, 15, 10, 30, 0)) + self.assertIsNone(result[0].tzinfo) + # ACCOUNT_ID_TZ (TIMESTAMPTZ) should be timezone-aware (UTC) + self.assertEqual(result[1], datetime(2024, 6, 15, 10, 30, 0, tzinfo=timezone.utc)) + self.assertIsNotNone(result[1].tzinfo) diff --git a/test/test_sqlite.py b/test/test_sqlite.py new file mode 100644 index 00000000..908a6af9 --- /dev/null +++ b/test/test_sqlite.py @@ -0,0 +1,258 @@ +#-*- coding: utf-8 -*- + +import jaydebeapiarrow +import os +import unittest + +from decimal import Decimal +from datetime import datetime +try: + from test._base import IntegrationTestBase, SqliteTestBase, _THIS_DIR, _SUPPRESS_LOGGING_ARGS +except ImportError: + from _base import IntegrationTestBase, SqliteTestBase, _THIS_DIR, _SUPPRESS_LOGGING_ARGS + + +class SqlitePyTest(SqliteTestBase, unittest.TestCase): + + JDBC_SUPPORT_TEMPORAL_TYPE = True + + def _numeric_create_table_sql(self): + """Use DECIMAL so sqlite3's detect_types converter fires.""" + return ( + "CREATE TABLE NUMERIC_TEST (" + "ID INTEGER NOT NULL, " + "NUM_COL DECIMAL(10, 2), " + "PRIMARY KEY (ID))" + ) + + class ConnectionWithClosing: + def __init__(self, conn): + from contextlib import closing + self.conn = conn + self.cursor = lambda: closing(self.conn.cursor()) + + def close(self): + self.conn.close() + + def connect(self): + import sqlite3 + sqlite3.register_adapter(Decimal, lambda d: str(d)) + sqlite3.register_converter("decimal", lambda s: Decimal(s.decode('utf-8')) if s is not None else s) + return sqlite3, self.ConnectionWithClosing(sqlite3.connect(':memory:', detect_types=sqlite3.PARSE_DECLTYPES)) + + def test_execute_type_time(self): + self.skipTest("Time type not supported by PySqlite") + + def test_numeric_precision_scale_combos(self): + self.skipTest("SQLite type affinity makes NUMERIC/DECIMAL precision unreliable") + + def test_description_returns_column_alias(self): + self.skipTest("Python sqlite3 does not support AS aliases in cursor.description") + + def test_timestamp_utc_roundtrip_no_timezone_shift(self): + self.skipTest("Python sqlite3 does not support parameterized TIMESTAMP INSERT") + + def test_commit_with_autocommit_enabled(self): + self.skipTest("pysqlite uses isolation_level, not JDBC setAutoCommit") + + def test_commit_with_autocommit_disabled(self): + self.skipTest("pysqlite uses isolation_level, not JDBC setAutoCommit") + + def test_rollback_with_autocommit_enabled(self): + self.skipTest("pysqlite uses isolation_level, not JDBC setAutoCommit") + + def test_rollback_with_autocommit_disabled(self): + self.skipTest("pysqlite uses isolation_level, not JDBC setAutoCommit") + + def test_lastrowid_none_after_select(self): + self.skipTest("pysqlite returns actual rowid values, not None") + + def test_lastrowid_none_after_insert(self): + self.skipTest("pysqlite returns actual rowid values, not None") + + def test_lastrowid_none_after_executemany(self): + self.skipTest("pysqlite returns actual rowid values, not None") + + def test_lastrowid_exists_and_is_none(self): + self.skipTest("pysqlite returns actual rowid values, not None") + + def test_iterator_closed_after_fetchall(self): + self.skipTest("cursor._iter is jaydebeapiarrow-specific") + + def test_iterator_closed_after_fetchone_exhaustion(self): + self.skipTest("cursor._iter is jaydebeapiarrow-specific") + + def test_iterator_closed_after_fetchmany_exhaustion(self): + self.skipTest("cursor._iter is jaydebeapiarrow-specific") + + def test_repeated_query_cycles_release_resources(self): + self.skipTest("cursor._iter is jaydebeapiarrow-specific") + + +class SqliteXerialTest(SqliteTestBase, unittest.TestCase): + + JDBC_SUPPORT_TEMPORAL_TYPE = True + + def connect(self): + #http://bitbucket.org/xerial/sqlite-jdbc + # sqlite-jdbc-3.7.2.jar + driver, url = 'org.sqlite.JDBC', 'jdbc:sqlite::memory:' + properties = { + "date_string_format": "yyyy-MM-dd HH:mm:ss" + } + return jaydebeapiarrow, jaydebeapiarrow.connect( + driver, url, driver_args=properties, + experimental={'jvm_args': _SUPPRESS_LOGGING_ARGS}) + + def test_execute_and_fetch(self): + """SQLite date_string_format truncates microseconds.""" + with self.conn.cursor() as cursor: + cursor.execute("select ACCOUNT_ID, ACCOUNT_NO, BALANCE, BLOCKING " \ + "from ACCOUNT") + result = cursor.fetchall() + self.assertEqual(result, [ + ( + datetime(2009, 9, 10, 14, 15, 22), + 18, Decimal('12.4'), None), + ( + datetime(2009, 9, 11, 14, 15, 22), + 19, Decimal('12.9'), Decimal('1')) + ]) + + def test_timestamp_microsecond_precision(self): + """SQLite Xerial JDBC truncates microseconds via date_string_format.""" + self.skipTest("SQLite Xerial JDBC truncates microsecond precision") + + def test_execute_and_fetch_parameter(self): + with self.conn.cursor() as cursor: + cursor.execute("select ACCOUNT_ID, ACCOUNT_NO, BALANCE, BLOCKING " \ + "from ACCOUNT where ACCOUNT_NO = ?", (18,)) + result = cursor.fetchall() + self.assertEqual(result, [ + ( + datetime(2009, 9, 10, 14, 15, 22), + 18, Decimal('12.4'), None) + ]) + + def test_execute_and_fetchone(self): + with self.conn.cursor() as cursor: + cursor.execute("select ACCOUNT_ID, ACCOUNT_NO, BALANCE, BLOCKING " \ + "from ACCOUNT order by ACCOUNT_NO") + result = cursor.fetchone() + self.assertEqual(result, ( + datetime(2009, 9, 10, 14, 15, 22), + 18, Decimal('12.4'), None)) + cursor.close() + + def test_execute_and_fetchone_consecutive(self): + with self.conn.cursor() as cursor: + cursor.execute("select ACCOUNT_ID, ACCOUNT_NO, BALANCE, BLOCKING " \ + "from ACCOUNT order by ACCOUNT_NO") + result1 = cursor.fetchone() + result2 = cursor.fetchone() + + self.assertEqual(result1, ( + datetime(2009, 9, 10, 14, 15, 22), + 18, Decimal('12.4'), None)) + + self.assertEqual(result2, ( + datetime(2009, 9, 11, 14, 15, 22), + 19, Decimal('12.9'), Decimal('1'))) + + def test_execute_and_fetchmany(self): + with self.conn.cursor() as cursor: + cursor.execute("select ACCOUNT_ID, ACCOUNT_NO, BALANCE, BLOCKING " \ + "from ACCOUNT order by ACCOUNT_NO") + result = cursor.fetchmany() + self.assertEqual(result, [ + ( + datetime(2009, 9, 10, 14, 15, 22), + 18, Decimal('12.4'), None) + ]) + + def test_execute_types(self): + """ + xerial/sqlite-jdbc has some issues with type mapping: + 1. Timestamp has inconsistent types: JDBC returns it as a VARCHAR, while it's defined as a TIMESTAMP in the DB + 2. Default date_string_format does not handle ISO Date (without microseconds) + 3. SQLite stores DECIMAL values with dynamic typing (integer vs double) + """ + stmt = "insert into ACCOUNT (ACCOUNT_ID, ACCOUNT_NO, BALANCE, " \ + "BLOCKING, DBL_COL, OPENED_AT, VALID, PRODUCT_NAME) " \ + "values (?, ?, ?, ?, ?, ?, ?, ?)" + account_id = self.dbapi.Timestamp(2010, 1, 26, 14, 31, 59) + account_no = 20 + balance = Decimal('1.2') + blocking = Decimal('10.0') + dbl_col = 3.5 + opened_at = self.dbapi.Timestamp(2008, 2, 27, 0, 0, 0) + valid = True + product_name = u'Savings account' + parms = ( + account_id, + account_no, balance, blocking, dbl_col, + opened_at, + valid, product_name + ) + with self.conn.cursor() as cursor: + cursor.execute(stmt, parms) + stmt = "select ACCOUNT_ID, ACCOUNT_NO, BALANCE, BLOCKING, " \ + "DBL_COL, OPENED_AT, VALID, PRODUCT_NAME " \ + "from ACCOUNT where ACCOUNT_NO = ?" + parms = (20,) + cursor.execute(stmt, parms) + result = cursor.fetchone() + + exp = ( + account_id, + account_no, balance, blocking, dbl_col, + opened_at.date(), + valid, product_name + ) + self.assertEqual(result, exp) + + def test_execute_type_time(self): + """SQLite date_string_format truncates microseconds.""" + stmt = "insert into ACCOUNT (ACCOUNT_ID, ACCOUNT_NO, BALANCE, " \ + "OPENED_AT_TIME) " \ + "values (?, ?, ?, ?)" + account_id = self.dbapi.Timestamp(2010, 1, 26, 14, 31, 59) + account_no = 20 + balance = 1.2 + opened_at_time = self.dbapi.Time(13, 59, 59) + parms = (account_id, account_no, balance, opened_at_time) + with self.conn.cursor() as cursor: + cursor.execute(stmt, parms) + stmt = "select ACCOUNT_ID, ACCOUNT_NO, BALANCE, OPENED_AT_TIME " \ + "from ACCOUNT where ACCOUNT_NO = ?" + parms = (20, ) + cursor.execute(stmt, parms) + result = cursor.fetchone() + + exp = ( + account_id, + account_no, Decimal(str(balance)), + self._cast_time('13:59:59', r'%H:%M:%S') + ) + self.assertEqual(result, exp) + + def _numeric_create_table_sql(self): + """SQLite treats NUMERIC as an affinity type — use DECIMAL instead.""" + return ( + "CREATE TABLE NUMERIC_TEST (" + "ID INTEGER NOT NULL, " + "NUM_COL DECIMAL, " + "PRIMARY KEY (ID))" + ) + + def test_timestamp_subsecond_leading_zeros(self): + """SQLite Xerial JDBC truncates microseconds via date_string_format.""" + self.skipTest("SQLite Xerial JDBC truncates microsecond precision") + + def test_description_returns_column_alias(self): + """Verify quoted alias is preserved by SQLite JDBC.""" + pass # Inherited from IntegrationTestBase — quoted alias works + + def test_timestamp_utc_roundtrip_no_timezone_shift(self): + """SQLite Xerial JDBC truncates microseconds.""" + self.skipTest("SQLite Xerial JDBC truncates microsecond precision") diff --git a/test/test_trino.py b/test/test_trino.py new file mode 100644 index 00000000..ae6556f1 --- /dev/null +++ b/test/test_trino.py @@ -0,0 +1,136 @@ +#-*- coding: utf-8 -*- + +import jaydebeapiarrow +import os +import unittest + +from decimal import Decimal +try: + from test._base import IntegrationTestBase, _THIS_DIR, _SUPPRESS_LOGGING_ARGS +except ImportError: + from _base import IntegrationTestBase, _THIS_DIR, _SUPPRESS_LOGGING_ARGS + + +class TrinoTest(IntegrationTestBase, unittest.TestCase): + + def connect(self): + + import jpype + + host = os.environ.get("JY_TRINO_HOST", "localhost") + port = os.environ.get("JY_TRINO_PORT", "18080") + user = os.environ.get("JY_TRINO_USER", "test") + + driver, url, driver_args = ( + 'io.trino.jdbc.TrinoDriver', + f'jdbc:trino://{host}:{port}/memory/default', + {'user': user} + ) + + try: + db, conn = jaydebeapiarrow, jaydebeapiarrow.connect( + driver, url, driver_args, + experimental={'jvm_args': _SUPPRESS_LOGGING_ARGS}) + except jpype.JException: + self.fail("Can not connect with Trino. Please check if the instance is up and running.") + else: + return db, conn + + def setUpSql(self): + self.sql_file(os.path.join(_THIS_DIR, 'data', 'create_trino.sql')) + self.sql_file(os.path.join(_THIS_DIR, 'data', 'insert_trino.sql')) + + def tearDown(self): + with self.conn.cursor() as cursor: + cursor.execute("DROP TABLE IF EXISTS ACCOUNT") + cursor.execute("DROP TABLE IF EXISTS NUMERIC_TEST") + cursor.execute("DROP TABLE IF EXISTS NUMERIC_COMBO") + self.conn.close() + + def test_execute_reset_description_without_execute_result(self): + """Trino memory connector does not support DELETE.""" + self.skipTest("Trino memory connector does not support modifying table rows") + + def test_numeric_types(self): + """Trino memory connector does not support INSERT INTO ... VALUES — use CTAS instead.""" + with self.conn.cursor() as cursor: + cursor.execute("DROP TABLE IF EXISTS NUMERIC_TEST") + cursor.execute( + "CREATE TABLE NUMERIC_TEST AS " + "SELECT 1 AS ID, CAST(NULL AS DECIMAL(10, 2)) AS NUM_COL " + "UNION ALL " + "SELECT 2, CAST(99.99 AS DECIMAL(10, 2)) " + "UNION ALL " + "SELECT 3, CAST(100.00 AS DECIMAL(10, 2))") + cursor.execute("SELECT NUM_COL FROM NUMERIC_TEST ORDER BY ID") + result = cursor.fetchall() + self.assertEqual(len(result), 3) + self.assertIsNone(result[0][0]) + self.assertEqual(result[1][0], Decimal('99.99')) + self.assertEqual(result[2][0], Decimal('100.00')) + + def test_numeric_precision_scale_combos(self): + """Trino memory connector does not support INSERT — use CTAS instead.""" + with self.conn.cursor() as cursor: + cursor.execute("DROP TABLE IF EXISTS NUMERIC_COMBO") + cursor.execute( + "CREATE TABLE NUMERIC_COMBO AS " + "SELECT 1 AS ID, " + "CAST(12345.67 AS DECIMAL(10, 2)) AS DEC_S2, " + "CAST(12345.6789 AS DECIMAL(15, 4)) AS DEC_S4, " + "CAST(987654321012345678 AS DECIMAL(18, 0)) AS DEC_S0, " + "CAST(0.12345 AS DECIMAL(5, 5)) AS DEC_PES, " + "CAST(99.99 AS DECIMAL(10, 2)) AS NUM_S2, " + "CAST(42 AS DECIMAL(10, 0)) AS NUM_S0, " + "CAST(12345.6789 AS DECIMAL(15, 4)) AS NUM_S4, " + "CAST(0.1234 AS DECIMAL(4, 4)) AS NUM_PES, " + "CAST(-99.99 AS DECIMAL(10, 2)) AS NUM_NEG") + cursor.execute("SELECT DEC_S2, DEC_S4, DEC_S0, DEC_PES, " + "NUM_S2, NUM_S0, NUM_S4, NUM_PES, NUM_NEG " + "FROM NUMERIC_COMBO ORDER BY ID") + result = cursor.fetchone() + self.assertEqual(result[0], Decimal('12345.67')) + self.assertEqual(result[1], Decimal('12345.6789')) + self.assertEqual(result[2], Decimal('987654321012345678')) + self.assertEqual(result[3], Decimal('0.12345')) + self.assertEqual(result[4], Decimal('99.99')) + self.assertEqual(result[5], Decimal('42')) + self.assertEqual(result[6], Decimal('12345.6789')) + self.assertEqual(result[7], Decimal('0.1234')) + self.assertEqual(result[8], Decimal('-99.99')) + + def test_timestamp_subsecond_leading_zeros(self): + """Trino's JDBC driver truncates sub-second precision.""" + self.skipTest("Trino JDBC driver truncates sub-second precision") + + def test_timestamp_microsecond_precision(self): + """Trino's JDBC driver does not support getObject(_, LocalDateTime.class).""" + self.skipTest("Trino JDBC driver cannot convert TIMESTAMP to LocalDateTime") + + def test_binary_non_utf8_roundtrip(self): + """Trino memory connector does not support VARBINARY in CTAS for non-UTF-8 bytes.""" + self.skipTest("Trino memory connector does not support VARBINARY round-trip via CTAS") + + def test_varchar_non_ascii_roundtrip(self): + """Trino memory connector does not support INSERT INTO ... VALUES.""" + self.skipTest("Trino memory connector does not support INSERT INTO ... VALUES") + + def test_timestamp_utc_roundtrip_no_timezone_shift(self): + """Trino memory connector does not support INSERT INTO ... VALUES.""" + self.skipTest("Trino memory connector does not support INSERT INTO ... VALUES") + + def test_varchar_columns_return_data(self): + """Trino memory connector does not support INSERT INTO ... VALUES.""" + self.skipTest("Trino memory connector does not support INSERT INTO ... VALUES") + + def test_commit_with_autocommit_disabled(self): + self.skipTest("Trino memory connector does not support transactions") + + def test_commit_with_autocommit_enabled(self): + self.skipTest("Trino memory connector does not support transactions") + + def test_rollback_with_autocommit_disabled(self): + self.skipTest("Trino memory connector does not support transactions") + + def test_rollback_with_autocommit_enabled(self): + self.skipTest("Trino memory connector does not support transactions") diff --git a/test/testsuite.py b/test/testsuite.py deleted file mode 100644 index abedf786..00000000 --- a/test/testsuite.py +++ /dev/null @@ -1,50 +0,0 @@ -#!/usr/bin/env python -"""Run unittests in the `tests` directory.""" - -from optparse import OptionParser -import sys - -try: - import unittest2 as unittest -except ImportError: - import unittest - -def main(): - parser = OptionParser() - parser.add_option("-x", "--xml", action="store_true", dest="xml", - help="write test report in xunit file format (requires xmlrunner==1.7.4)") - parser.add_option("-s", "--suffix", dest="suffix", - help="append suffix to test class names") - (options, args) = parser.parse_args(sys.argv) - loader = unittest.defaultTestLoader - names = args[1:] - if names: - suite = loader.loadTestsFromNames(names) - else: - suite = loader.discover('test') - - if options.suffix: - def rename_test_classes(suite_or_test): - if isinstance(suite_or_test, unittest.TestSuite): - for test in suite_or_test: - rename_test_classes(test) - elif isinstance(suite_or_test, unittest.TestCase): - cls = suite_or_test.__class__ - if options.suffix not in cls.__name__: - cls.__name__ = f"{cls.__name__}_{options.suffix}" - - rename_test_classes(suite) - - if options.xml: - import xmlrunner - runner = xmlrunner.XMLTestRunner(output='build/test-reports') - else: - runner = unittest.TextTestRunner(verbosity=2) - result = runner.run(suite) - if result.wasSuccessful(): - return 0 - else: - return 1 - -if __name__ == '__main__': - sys.exit(main()) diff --git a/tox.ini b/tox.ini index 24d87f22..1284e7e0 100644 --- a/tox.ini +++ b/tox.ini @@ -7,34 +7,32 @@ python = 3.14: py314-driver-{hsqldb, sqliteXerial, mock, postgres, mysql} [testenv] -# usedevelop required to enable coveralls source code view. usedevelop=True passenv = JY_*,JAVA_HOME,JAVA8_DRIVERS allowlist_externals = mvn, mkdir, bash setenv = CLASSPATH = {tox_root}/test/jars/*:{tox_root}/test/mock-jars/* - driver-mock: TESTNAME=test_mock - driver-hsqldb: TESTNAME=test_integration.HsqldbTest test_integration.PropertiesDriverArgsPassingTest - driver-sqliteXerial: TESTNAME=test_integration.SqliteXerialTest - driver-sqlitePy: TESTNAME=test_integration.SqlitePyTest - driver-postgres: TESTNAME=test_integration.PostgresTest - driver-mysql: TESTNAME=test_integration.MySQLTest - driver-mssql: TESTNAME=test_integration.MSSQLTest - driver-trino: TESTNAME=test_integration.TrinoTest - driver-oracle: TESTNAME=test_integration.OracleTest - driver-db2: TESTNAME=test_integration.DB2Test - driver-drill: TESTNAME=test_integration.DrillTest + driver-mock: TESTNAME=test/test_mock.py test/test_infrastructure.py + driver-hsqldb: TESTNAME=test/test_hsqldb.py + driver-sqliteXerial: TESTNAME=test/test_sqlite.py::SqliteXerialTest + driver-sqlitePy: TESTNAME=test/test_sqlite.py::SqlitePyTest + driver-postgres: TESTNAME=test/test_postgres.py + driver-mysql: TESTNAME=test/test_mysql.py + driver-mssql: TESTNAME=test/test_mssql.py + driver-trino: TESTNAME=test/test_trino.py + driver-oracle: TESTNAME=test/test_oracle.py + driver-db2: TESTNAME=test/test_db2.py + driver-drill: TESTNAME=test/test_drill.py deps = JPype1>=1.0.0 - coverage>=4.5 pyarrow>=16.0.0 numpy - unittest-xml-reporting + pytest>=8.4.2 + pytest-xdist>=3.8.0 commands = python --version bash test/build.sh bash test/download_jdbc_drivers.sh {env:JAVA8_DRIVERS:} driver-mock: mvn compile assembly:single -f mockdriver/pom.xml driver-mock: bash -c 'cp {tox_root}/mockdriver/target/mockdriver*.jar {tox_root}/test/mock-jars/' -; {posargs:coverage run -a --source jaydebeapi test/testsuite.py {env:TESTNAME}} - python test/testsuite.py -x -s {envname} {env:TESTNAME} + pytest {env:TESTNAME} -o "addopts=" --junitxml=build/test-reports/test-results-{envname}.xml -v