Source code for sqlspec.adapters.arrow_odbc.driver

"""arrow-odbc sync driver."""

import contextlib
import re
from collections.abc import Iterable, Mapping
from itertools import chain
from typing import TYPE_CHECKING, Any, Final, cast

from sqlspec.adapters.arrow_odbc._typing import ArrowOdbcConnection, ArrowOdbcCursor, ArrowOdbcError, ArrowOdbcRawCursor
from sqlspec.adapters.arrow_odbc.core import (
    build_statement_config,
    create_mapped_exception,
    driver_profile,
    resolve_dialect_from_dbms_name,
)
from sqlspec.adapters.arrow_odbc.data_dictionary import ArrowOdbcDataDictionary
from sqlspec.core import (
    SQL,
    build_arrow_result_from_reader,
    build_arrow_result_from_table,
    get_cache_config,
    register_driver_profile,
)
from sqlspec.driver import BaseSyncExceptionHandler, SyncDriverAdapterBase, SyncRowStream
from sqlspec.driver._common import validate_savepoint_name
from sqlspec.exceptions import ImproperConfigurationError, SQLSpecError
from sqlspec.utils.module_loader import ensure_pyarrow
from sqlspec.utils.text import quote_identifier, split_qualified_identifier

if TYPE_CHECKING:
    from sqlspec.builder import QueryBuilder
    from sqlspec.core import ArrowResult, Statement, StatementConfig, StatementFilter
    from sqlspec.driver import ExecutionResult
    from sqlspec.storage import StorageBridgeJob, StorageDestination, StorageFormat, StorageTelemetry
    from sqlspec.typing import ArrowRecordBatch, ArrowRecordBatchReader, ArrowReturnFormat, StatementParameters

__all__ = ("ArrowOdbcCursor", "ArrowOdbcDriver", "ArrowOdbcExceptionHandler", "resolve_dialect_from_dbms_name")


def _quote_odbc_table(table: str) -> str:
    return ".".join(quote_identifier(part) for part in split_qualified_identifier(table))


class ArrowOdbcExceptionHandler(BaseSyncExceptionHandler):
    """Sync context manager handling arrow-odbc exceptions."""

    __slots__ = ()

    def _handle_exception(self, exc_type: "type[BaseException] | None", exc_val: "BaseException") -> bool:
        if exc_type is None:
            return False
        if isinstance(exc_val, ArrowOdbcError):
            self.pending_exception = create_mapped_exception(exc_val)
            return True
        return False


class ArrowOdbcStreamSource:
    """Native Arrow ODBC chunk source backed by Arrow record batches."""

    __slots__ = ("_chunk_size", "_driver", "_parameters", "_reader", "_sql")

    def __init__(
        self, driver: "ArrowOdbcDriver", sql: str, parameters: "list[str | None] | None", chunk_size: int
    ) -> None:
        self._driver = driver
        self._sql = sql
        self._parameters = parameters
        self._chunk_size = chunk_size
        self._reader: Any = None

    def start(self) -> None:
        handler = self._driver.handle_database_exceptions()
        with handler:
            reader = self._driver._read_arrow_batches(self._sql, self._parameters, self._chunk_size)
            self._reader = iter(_to_pyarrow_reader(reader))
        self._driver._check_pending_exception(handler)

    def fetch_chunk(self) -> "list[dict[str, Any]]":
        reader = self._reader
        if reader is None:
            return []
        while True:
            try:
                batch = next(reader)
            except StopIteration:
                return []
            rows = cast("list[dict[str, Any]]", batch.to_pylist())
            if rows:
                return rows

    def close(self, error: bool = False) -> None:
        reader = self._reader
        self._reader = None
        close = getattr(reader, "close", None)
        if callable(close):
            with contextlib.suppress(Exception):
                close()


[docs] class ArrowOdbcDriver(SyncDriverAdapterBase): """Sync driver for generic ODBC connections with Arrow-native transfer.""" __slots__ = ( "_chunk_size_val", "_data_dictionary", "_dbms_name", "_dialect", "_max_batch_bytes", "_max_binary_size_val", "_max_text_size_val", "_query_timeout_sec_val", "_transaction_active", "_use_concurrent_fetch", "dialect", )
[docs] def __init__( self, connection: "ArrowOdbcConnection", statement_config: "StatementConfig | None" = None, driver_features: "dict[str, Any] | None" = None, ) -> None: features = dict(driver_features or {}) self._dbms_name = self._resolve_dbms_name(connection, features) self._dialect = resolve_dialect_from_dbms_name(self._dbms_name) statement_dialect = _statement_dialect_for(self._dialect) if statement_config is None: statement_config = build_statement_config(dialect=statement_dialect).replace( enable_caching=get_cache_config().compiled_cache_enabled ) else: statement_config = statement_config.replace(dialect=statement_dialect) super().__init__(connection=connection, statement_config=statement_config, driver_features=features) self._chunk_size_val: int = int(features.get("chunk_size") or 65_536) self._max_batch_bytes: int | None = features.get("max_bytes_per_batch") self._max_binary_size_val: int | None = features.get("max_binary_size") self._max_text_size_val: int | None = features.get("max_text_size") self._query_timeout_sec_val: int | None = features.get("query_timeout_sec") self._use_concurrent_fetch: bool = bool(features.get("fetch_concurrently", True)) self.dialect = statement_dialect self._data_dictionary: ArrowOdbcDataDictionary | None = None self._transaction_active = False
@property def data_dictionary(self) -> "ArrowOdbcDataDictionary": if self._data_dictionary is None: self._data_dictionary = ArrowOdbcDataDictionary(self._dialect) return self._data_dictionary
[docs] def dispatch_execute(self, cursor: "ArrowOdbcRawCursor", statement: "SQL") -> "ExecutionResult": sql, prepared_parameters = self._compiled_sql(statement, self.statement_config) if self._dialect == "mssql": sql, prepared_parameters = _inline_mssql_pagination_parameters(sql, prepared_parameters) parameters = _odbc_parameters(prepared_parameters) if statement.returns_rows(): reader = self._read_arrow_batches(sql, parameters, self._chunk_size()) table = _reader_to_table(reader) rows = table.to_pylist() column_names = table.column_names return self.create_execution_result( cursor, selected_data=rows, column_names=column_names, data_row_count=table.num_rows, is_select_result=True, row_format="dict", ) cursor.execute(query=sql, parameters=parameters) return self.create_execution_result(cursor, rowcount_override=0)
[docs] def dispatch_execute_many(self, cursor: "ArrowOdbcRawCursor", statement: "SQL") -> "ExecutionResult": msg = "arrow-odbc does not expose a row-oriented executemany API; use bulk_insert_arrow() for Arrow ingestion." raise NotImplementedError(msg)
[docs] def dispatch_execute_script(self, cursor: "ArrowOdbcRawCursor", statement: "SQL") -> "ExecutionResult": sql, prepared_parameters = self._compiled_sql(statement, self.statement_config) if self._dialect == "mssql": cursor.execute(query=sql, parameters=_odbc_parameters(prepared_parameters)) return self.create_execution_result( cursor, rowcount_override=-1, statement_count=1, successful_statements=1, is_script_result=True ) statements = self.split_script_statements(sql, statement.statement_config, strip_trailing_semicolon=True) parameters = _odbc_parameters(prepared_parameters) successful_count = 0 for stmt in statements: cursor.execute(query=stmt, parameters=parameters) successful_count += 1 return self.create_execution_result( cursor, statement_count=len(statements), successful_statements=successful_count, is_script_result=True )
[docs] def dispatch_select_stream(self, statement: "SQL", chunk_size: int) -> "SyncRowStream[dict[str, Any]] | None": """Return a native Arrow ODBC row stream backed by record batches.""" if not statement.returns_rows(): return None sql, prepared_parameters = self._compiled_sql(statement, self.statement_config) if self._dialect == "mssql": sql, prepared_parameters = _inline_mssql_pagination_parameters(sql, prepared_parameters) return SyncRowStream(ArrowOdbcStreamSource(self, sql, _odbc_parameters(prepared_parameters), chunk_size))
[docs] def collect_rows(self, cursor: "ArrowOdbcRawCursor", fetched: "list[Any]") -> "tuple[list[Any], list[str], int]": return fetched, [], len(fetched)
[docs] def resolve_rowcount(self, cursor: "ArrowOdbcRawCursor") -> int: return 0
def _connection_in_transaction(self) -> bool: """Return whether SQLSpec holds an explicit transaction on the connection. arrow-odbc does not expose a portable transaction-state API, so the driver tracks the boundary opened by begin() and closed by commit()/rollback(). """ return self._transaction_active
[docs] def begin(self) -> None: try: self.connection.execute("BEGIN TRANSACTION" if self._dialect == "mssql" else "BEGIN") except Exception as exc: msg = f"Failed to begin transaction: {exc}" raise SQLSpecError(msg) from exc self._transaction_active = True
[docs] def commit(self) -> None: try: if self._dialect == "mssql" and self._transaction_active: self.connection.execute("COMMIT TRANSACTION") else: self.connection.commit() except ArrowOdbcError as exc: msg = f"Failed to commit transaction: {exc}" raise SQLSpecError(msg) from exc self._transaction_active = False
[docs] def rollback(self) -> None: try: if self._dialect == "mssql" and self._transaction_active: self.connection.execute("ROLLBACK TRANSACTION") else: self.connection.rollback() except ArrowOdbcError as exc: msg = f"Failed to rollback transaction: {exc}" raise SQLSpecError(msg) from exc finally: self._transaction_active = False
[docs] def with_cursor(self, connection: "ArrowOdbcConnection") -> "ArrowOdbcCursor": return ArrowOdbcCursor(connection)
[docs] def handle_database_exceptions(self) -> "ArrowOdbcExceptionHandler": return ArrowOdbcExceptionHandler()
[docs] def create_savepoint(self, name: str) -> None: safe_name = validate_savepoint_name(name) if self._dialect == "mssql": self.execute_script(f"SAVE TRANSACTION {safe_name}") return self.execute_script(f"SAVEPOINT {safe_name}")
[docs] def release_savepoint(self, name: str) -> None: safe_name = validate_savepoint_name(name) if self._dialect == "mssql": return self.execute_script(f"RELEASE SAVEPOINT {safe_name}")
[docs] def rollback_to_savepoint(self, name: str) -> None: safe_name = validate_savepoint_name(name) if self._dialect == "mssql": self.execute_script(f"ROLLBACK TRANSACTION {safe_name}") return self.execute_script(f"ROLLBACK TO SAVEPOINT {safe_name}")
[docs] def select_to_arrow( self, statement: "Statement | QueryBuilder", /, *parameters: "StatementParameters | StatementFilter", statement_config: "StatementConfig | None" = None, return_format: "ArrowReturnFormat" = "table", native_only: bool = False, batch_size: int | None = None, arrow_schema: Any = None, **kwargs: Any, ) -> "ArrowResult": """Execute a query and return native Arrow results.""" ensure_pyarrow() config = statement_config or self.statement_config prepared_statement = self.prepare_statement(statement, parameters, statement_config=config, kwargs=kwargs) prepared_statement.compile() sql, prepared_parameters = self._compiled_sql(prepared_statement, config) resolved_batch_size = batch_size or self._chunk_size() table: Any | None = None exc_handler = self.handle_database_exceptions() with exc_handler, self.with_cursor(self.connection): reader = self._read_arrow_batches(sql, _odbc_parameters(prepared_parameters), resolved_batch_size) if return_format in {"reader", "batches"}: arrow_reader = _to_pyarrow_reader(reader) return build_arrow_result_from_reader( prepared_statement, arrow_reader, return_format=return_format, batch_size=resolved_batch_size, arrow_schema=arrow_schema, ) table = _reader_to_table(reader) self._check_pending_exception(exc_handler) if table is None: msg = "arrow-odbc did not return an Arrow table." raise SQLSpecError(msg) return build_arrow_result_from_table( prepared_statement, table, return_format=return_format, batch_size=resolved_batch_size, arrow_schema=arrow_schema, )
[docs] def bulk_insert_arrow(self, target_table: str, source: Any, *, chunk_size: int | None = None) -> None: """Insert an Arrow table or reader into a database table.""" ensure_pyarrow() import pyarrow as pa resolved_chunk_size = chunk_size or self._chunk_size() exc_handler = self.handle_database_exceptions() with exc_handler, self.with_cursor(self.connection): if isinstance(source, pa.Table) and hasattr(self.connection, "from_table_to_db"): self.connection.from_table_to_db(source=source, target=target_table, chunk_size=resolved_chunk_size) self._check_pending_exception(exc_handler) return reader = _table_to_reader(source, resolved_chunk_size) if isinstance(source, pa.Table) else source if hasattr(self.connection, "insert_into_table"): self.connection.insert_into_table(reader=reader, table=target_table, chunk_size=resolved_chunk_size) self._check_pending_exception(exc_handler) return self._check_pending_exception(exc_handler) msg = "arrow-odbc connection does not expose table import APIs." raise ImproperConfigurationError(msg)
[docs] def load_from_arrow( self, table: str, source: "ArrowResult | Any", *, partitioner: "dict[str, object] | None" = None, overwrite: bool = False, telemetry: "StorageTelemetry | None" = None, ) -> "StorageBridgeJob": """Load Arrow data into a table via arrow-odbc bulk insert.""" self._require_capability("arrow_import_enabled") arrow_table = self._coerce_arrow_table(source) if overwrite: self.execute(f"DELETE FROM {_quote_odbc_table(table)}") self.bulk_insert_arrow(table, arrow_table) telemetry_payload = self._ingest_telemetry(arrow_table) telemetry_payload["destination"] = table self._attach_partition_telemetry(telemetry_payload, partitioner) return self._storage_job(telemetry_payload, telemetry)
[docs] def load_from_storage( self, table: str, source: "StorageDestination", *, file_format: "StorageFormat", partitioner: "dict[str, object] | None" = None, overwrite: bool = False, ) -> "StorageBridgeJob": """Load staged artifacts from storage into a table via arrow-odbc bulk insert.""" arrow_table, inbound = self._read_storage_arrow(source, file_format=file_format) return self.load_from_arrow(table, arrow_table, partitioner=partitioner, overwrite=overwrite, telemetry=inbound)
def _read_arrow_batches(self, sql: str, parameters: "list[str | None] | None", batch_size: int) -> Any: kwargs: dict[str, Any] = { "query": sql, "batch_size": batch_size, "parameters": parameters, "max_bytes_per_batch": self._max_batch_bytes, "max_text_size": self._max_text_size_val, "max_binary_size": self._max_binary_size_val, "fetch_concurrently": self._use_concurrent_fetch, } if self._query_timeout_sec_val is not None: kwargs["query_timeout_sec"] = self._query_timeout_sec_val return self.connection.read_arrow_batches(**kwargs) def _chunk_size(self) -> int: return self._chunk_size_val @staticmethod def _resolve_dbms_name(connection: "ArrowOdbcConnection", features: "dict[str, Any]") -> str | None: dbms_name = getattr(connection, "dbms_name", None) if dbms_name: return str(dbms_name) dbms_name = features.get("dbms_name") if dbms_name: return str(dbms_name) connection_string = features.get("connection_string") if connection_string: return str(connection_string) return None
def _statement_dialect_for(dialect: str) -> str: if dialect == "mssql": return "tsql" return dialect _MSSQL_OFFSET_FETCH_PATTERN = re.compile( r"OFFSET\s+\?\s+ROWS\s+FETCH\s+(?P<fetch_keyword>NEXT|FIRST)\s+\?\s+ROWS\s+ONLY", re.IGNORECASE ) _MSSQL_PAGINATION_PARAMETER_COUNT: Final = 2 def _inline_mssql_pagination_parameters(sql: str, parameters: object) -> tuple[str, object]: match = _MSSQL_OFFSET_FETCH_PATTERN.search(sql) if ( match is None or not isinstance(parameters, (list, tuple)) or len(parameters) < _MSSQL_PAGINATION_PARAMETER_COUNT ): return sql, parameters offset_value = _pagination_int(parameters[-2]) limit_value = _pagination_int(parameters[-1]) replacement = f"OFFSET {offset_value} ROWS FETCH {match.group('fetch_keyword')} {limit_value} ROWS ONLY" remaining_parameters = parameters[:-2] return _MSSQL_OFFSET_FETCH_PATTERN.sub(replacement, sql, count=1), remaining_parameters def _pagination_int(value: object) -> int: unwrapped = getattr(value, "value", value) return int(cast("Any", unwrapped)) def _unwrap_parameter(value: Any) -> Any: wrapped = getattr(value, "value", value) return None if wrapped is None else str(wrapped) def _odbc_parameters(parameters: Any) -> "list[str | None] | None": if parameters is None: return None if isinstance(parameters, Mapping): return [_unwrap_parameter(value) for value in parameters.values()] if isinstance(parameters, (list, tuple)): if not parameters: return None return [_unwrap_parameter(value) for value in parameters] return [_unwrap_parameter(parameters)] def _reader_to_table(reader: Any) -> Any: ensure_pyarrow() import pyarrow as pa if isinstance(reader, pa.Table): return reader if hasattr(reader, "read_all"): return reader.read_all() batches = list(reader) if not batches: return pa.table({}) return pa.Table.from_batches(batches) def _to_pyarrow_reader(reader: object) -> "ArrowRecordBatchReader": ensure_pyarrow() import pyarrow as pa if isinstance(reader, pa.RecordBatchReader): return reader if isinstance(reader, pa.Table): return pa.RecordBatchReader.from_batches(reader.schema, reader.to_batches()) into_reader = getattr(reader, "into_pyarrow_record_batch_reader", None) if callable(into_reader): return cast("ArrowRecordBatchReader", into_reader()) schema = getattr(reader, "schema", None) if isinstance(schema, pa.Schema): return pa.RecordBatchReader.from_batches(schema, cast("Iterable[ArrowRecordBatch]", reader)) iterator = iter(cast("Iterable[ArrowRecordBatch]", reader)) try: first_batch = next(iterator) except StopIteration: return pa.RecordBatchReader.from_batches(pa.schema([]), []) return pa.RecordBatchReader.from_batches(first_batch.schema, chain((first_batch,), iterator)) def _table_to_reader(table: Any, chunk_size: int) -> Any: ensure_pyarrow() import pyarrow as pa return pa.RecordBatchReader.from_batches(table.schema, table.to_batches(max_chunksize=chunk_size)) register_driver_profile("arrow_odbc", driver_profile)