diff --git a/adbc_drivers_validation/tests/connection.py b/adbc_drivers_validation/tests/connection.py index 6298a4d..843abc5 100644 --- a/adbc_drivers_validation/tests/connection.py +++ b/adbc_drivers_validation/tests/connection.py @@ -1244,6 +1244,38 @@ def test_get_table_schema_schema( ) assert len(schema) == 2 + @pytest.mark.requires_features(["connection_transactions"]) + def test_option_autocommit_int_coherence( + self, + driver: model.DriverQuirks, + conn: adbc_driver_manager.dbapi.Connection, + ) -> None: + # adbc.h (AdbcConnectionGetOptionInt): "For standard options, drivers + # must always support getting the option value (if they support + # getting option values at all) via the type specified in the option. + # (For example, an option set via SetOptionDouble must be retrievable + # via GetOptionDouble.)" So if a driver accepts setting + # adbc.connection.autocommit via SetOptionInt, GetOptionInt on the + # same key must succeed and agree (and the string getter must agree + # too). Drivers that reject the integer-typed set are skipped. + key = "adbc.connection.autocommit" + handle = conn.adbc_connection + try: + # A plain (non-bool) Python int routes through SetOptionInt. + handle.set_options(**{key: 1}) + except conn.Error: + pytest.skip("driver does not accept an integer-typed autocommit") + try: + assert handle.get_option_int(key) == 1 + assert handle.get_option(key) == "true" + + handle.set_options(**{key: 0}) + assert handle.get_option_int(key) == 0 + assert handle.get_option(key) == "false" + finally: + # Restore autocommit (the fixture connection default). + handle.set_options(**{key: True}) + def test_unknown_option( self, subtests: pytest.Subtests, diff --git a/adbc_drivers_validation/tests/query.py b/adbc_drivers_validation/tests/query.py index e7922e5..6d3b0a7 100644 --- a/adbc_drivers_validation/tests/query.py +++ b/adbc_drivers_validation/tests/query.py @@ -145,7 +145,9 @@ def test_query( # TODO: also test with stream # TODO: also test with executequery, not executeupdate # TODO: also test with multiple batches in stream - # TODO: also test with empty stream/empty batch + # TODO: also test with empty stream + # (an empty bound batch on the execute_query path is covered + # by TestStatement.test_parameter_execute_empty_bind) data = subquery.bind_data().combine_chunks().to_batches()[0] with conn.cursor() as cursor: cursor.adbc_statement.set_sql_query(bind) diff --git a/adbc_drivers_validation/tests/statement.py b/adbc_drivers_validation/tests/statement.py index d521b59..7c4a533 100644 --- a/adbc_drivers_validation/tests/statement.py +++ b/adbc_drivers_validation/tests/statement.py @@ -121,6 +121,126 @@ def test_parameter_execute( result = pyarrow.RecordBatchReader._import_from_c(handle.address).read_all() assert result[0].to_pylist() == [2, 3, 4, 5] + @pytest.mark.requires_features(["statement_bind"]) + def test_parameter_execute_empty_bind( + self, + driver: model.DriverQuirks, + conn: adbc_driver_manager.dbapi.Connection, + ) -> None: + # The result schema is a property of the query, not of the number of + # bound rows: executing a parameterized query with a zero-row bound + # batch (e.g. DBAPI executemany with an empty parameter set) must + # return an empty result that still carries the query's real result + # schema, matching what a non-empty execution reports. + query = f"SELECT 1 + {driver.bind_parameter(1)}" + + with conn.cursor() as cursor: + cursor.adbc_statement.set_sql_query(query) + cursor.adbc_statement.bind( + pyarrow.RecordBatch.from_pydict( + {"0": pyarrow.array([1], type=pyarrow.int64())} + ) + ) + cursor.adbc_statement.prepare() + handle, _ = cursor.adbc_statement.execute_query() + expected_schema = ( + pyarrow.RecordBatchReader._import_from_c(handle.address) + .read_all() + .schema + ) + + with conn.cursor() as cursor: + cursor.adbc_statement.set_sql_query(query) + cursor.adbc_statement.bind( + pyarrow.RecordBatch.from_pydict( + {"0": pyarrow.array([], type=pyarrow.int64())} + ) + ) + cursor.adbc_statement.prepare() + handle, _ = cursor.adbc_statement.execute_query() + result = pyarrow.RecordBatchReader._import_from_c(handle.address).read_all() + + assert result.num_rows == 0 + assert result.schema.equals(expected_schema), ( + f"empty bind returned schema {result.schema!r}, " + f"but a non-empty execution returns {expected_schema!r}" + ) + + @pytest.mark.requires_features(["statement_bind"]) + def test_parameter_null_typed( + self, + driver: model.DriverQuirks, + conn: adbc_driver_manager.dbapi.Connection, + sample_table: str, + ) -> None: + # AdbcStatementGetParameterSchema (adbc.h): "If the type cannot be + # determined, the type of the corresponding field will be NA + # (NullType)." So a bind batch built from the driver's own reported + # parameter schema can have null-typed columns; pyarrow likewise + # infers a null-typed column for an all-None parameter set. Binding + # such a column must work and bind NULL for each row. + id_ = driver.quote_identifier("id") + value = driver.quote_identifier("value") + parameters = pyarrow.RecordBatch.from_pydict( + { + "0": pyarrow.array([7001], type=pyarrow.int64()), + "1": pyarrow.nulls(1), + } + ) + with conn.cursor() as cursor: + cursor.adbc_statement.set_sql_query( + f"INSERT INTO {sample_table} ({id_}, {value}) " + f"VALUES ({driver.bind_parameter(1)}, {driver.bind_parameter(2)})" + ) + cursor.adbc_statement.bind(parameters) + cursor.adbc_statement.prepare() + cursor.adbc_statement.execute_update() + + with conn.cursor() as cursor: + cursor.adbc_statement.set_sql_query( + f"SELECT {value} FROM {sample_table} WHERE {id_} = 7001" + ) + handle, _ = cursor.adbc_statement.execute_query() + result = pyarrow.RecordBatchReader._import_from_c(handle.address).read_all() + assert result[0].to_pylist() == [None] + + @pytest.mark.requires_features(["statement_bind"]) + def test_parameter_dictionary_encoded( + self, + driver: model.DriverQuirks, + conn: adbc_driver_manager.dbapi.Connection, + sample_table: str, + ) -> None: + # Dictionary encoding is an encoding of the same logical values, not + # a different logical type (Arrow columnar format, "Dictionary-encoded + # Layout"). A driver that binds string parameters should also accept + # a dictionary-encoded string column (what pandas produces for + # categoricals), decoding it if the database has no equivalent. + id_ = driver.quote_identifier("id") + value = driver.quote_identifier("value") + ids = pyarrow.array([7101, 7102, 7103], type=pyarrow.int64()) + values = pyarrow.array( + ["apple", "banana", None], type=pyarrow.string() + ).dictionary_encode() + parameters = pyarrow.RecordBatch.from_arrays([ids, values], names=["0", "1"]) + with conn.cursor() as cursor: + cursor.adbc_statement.set_sql_query( + f"INSERT INTO {sample_table} ({id_}, {value}) " + f"VALUES ({driver.bind_parameter(1)}, {driver.bind_parameter(2)})" + ) + cursor.adbc_statement.bind(parameters) + cursor.adbc_statement.prepare() + cursor.adbc_statement.execute_update() + + with conn.cursor() as cursor: + cursor.adbc_statement.set_sql_query( + f"SELECT {value} FROM {sample_table} " + f"WHERE {id_} IN (7101, 7102, 7103) ORDER BY {id_}" + ) + handle, _ = cursor.adbc_statement.execute_query() + result = pyarrow.RecordBatchReader._import_from_c(handle.address).read_all() + assert result[0].to_pylist() == ["apple", "banana", None] + def test_parameter_schema( self, driver: model.DriverQuirks, conn: adbc_driver_manager.dbapi.Connection ) -> None: