Source code for sqlspec.adapters.arrow_odbc.driver

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

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

from sqlglot import exp

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,
    normalize_column_names,
    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.core.parameters._validator import ParameterValidator
from sqlspec.driver import BaseSyncExceptionHandler, SyncDriverAdapterBase, SyncRowStream, validate_savepoint_name
from sqlspec.exceptions import ImproperConfigurationError, SQLSpecError
from sqlspec.typing import import_optional
from sqlspec.utils.module_loader import ensure_pyarrow
from sqlspec.utils.text import quote_identifier, split_qualified_identifier

if TYPE_CHECKING:
    import pyarrow as pa

    from sqlspec.builder import QueryBuilder
    from sqlspec.core import ArrowResult, ParameterProfile, Statement, StatementConfig, StatementFilter
    from sqlspec.driver import ExecutionResult
    from sqlspec.storage import StorageBridgeJob, StorageDestination, StorageFormat, StorageTelemetry
    from sqlspec.typing import ArrowRecordBatch, ArrowRecordBatchReader, ArrowReturnFormat, StatementParameters
else:
    pa = import_optional("pyarrow")

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


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(self._driver._normalize_reader(_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", "_connection_autocommit", "_data_dictionary", "_dbms_name", "_dialect", "_lowercase_column_names", "_max_batch_bytes", "_max_binary_size_val", "_max_text_size_val", "_payload_text_encoding", "_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(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._payload_text_encoding: Any = features.get("payload_text_encoding") self._use_concurrent_fetch: bool = bool(features.get("fetch_concurrently", True)) self._connection_autocommit: bool = bool(features.get("connection_autocommit", True)) self._lowercase_column_names: bool = bool(features.get("enable_lowercase_column_names", self._dialect == "db2")) 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": compiled_statement, prepared_parameters = self._compiled_statement(statement, self.statement_config) sql = compiled_statement.compiled_sql if self._dialect == "mssql": sql, prepared_parameters = _inline_mssql_pagination_parameters( sql, prepared_parameters, compiled_statement.parameter_profile, statement.statement_config ) parameters = _odbc_parameters(prepared_parameters, naive_utc_datetimes=self._dialect == "db2") if statement.returns_rows(): reader = self._read_arrow_batches(sql, parameters, self._chunk_size()) table = self._normalize_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": compiled_statement, parameter_sets = self._compiled_statement(statement, self.statement_config) executed = False for parameter_set in cast("Iterable[Any]", parameter_sets): sql = compiled_statement.compiled_sql if self._dialect == "mssql": sql, parameter_set = _inline_mssql_pagination_parameters( sql, parameter_set, compiled_statement.parameter_profile, statement.statement_config ) cursor.execute( query=sql, parameters=_odbc_parameters(parameter_set, naive_utc_datetimes=self._dialect == "db2") ) executed = True return self.create_execution_result(cursor, rowcount_override=-1 if executed else 0, is_many_result=True)
[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, naive_utc_datetimes=self._dialect == "db2") 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 compiled_statement, prepared_parameters = self._compiled_statement(statement, self.statement_config) sql = compiled_statement.compiled_sql if self._dialect == "mssql": sql, prepared_parameters = _inline_mssql_pagination_parameters( sql, prepared_parameters, compiled_statement.parameter_profile, statement.statement_config ) return SyncRowStream( ArrowOdbcStreamSource( self, sql, _odbc_parameters(prepared_parameters, naive_utc_datetimes=self._dialect == "db2"), 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
[docs] def begin(self) -> None: """Begin an explicit transaction. SQL Server starts one with ``BEGIN TRANSACTION``. Db2 has no begin statement: a connection opened with autocommit off is always inside a unit of work, so only the boundary is recorded, and an autocommit connection is refused because each statement would commit on its own. Raises: ImproperConfigurationError: If the Db2 connection was opened with autocommit on. SQLSpecError: If the begin statement fails. """ if self._dialect == "db2": if self._connection_autocommit: msg = "Db2 transactions through arrow-odbc require connection_config={'autocommit': False}" raise ImproperConfigurationError(msg) self._transaction_active = True return 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 if self._dialect == "db2": self.execute_script(f"SAVEPOINT {safe_name} ON ROLLBACK RETAIN CURSORS") 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) compiled_statement, prepared_parameters = self._compiled_statement(prepared_statement, config) sql = compiled_statement.compiled_sql if self._dialect == "mssql": sql, prepared_parameters = _inline_mssql_pagination_parameters( sql, prepared_parameters, compiled_statement.parameter_profile, config ) resolved_batch_size = batch_size or self._chunk_size() table: Any | None = None arrow_result: ArrowResult | 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, naive_utc_datetimes=self._dialect == "db2"), resolved_batch_size, ) if return_format in {"reader", "batches"}: arrow_reader = self._normalize_reader(_to_pyarrow_reader(reader)) arrow_result = build_arrow_result_from_reader( prepared_statement, arrow_reader, return_format=return_format, batch_size=resolved_batch_size, arrow_schema=arrow_schema, ) else: table = self._normalize_table(_reader_to_table(reader)) self._check_pending_exception(exc_handler) if arrow_result is not None: return arrow_result 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() resolved_chunk_size = chunk_size or self._chunk_size() exc_handler = self.handle_database_exceptions() inserted = False 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) inserted = True else: 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) inserted = True self._check_pending_exception(exc_handler) if inserted: return 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: target = _db2_table_reference(table) if self._dialect == "db2" else _quote_odbc_table(table) self.execute(f"DELETE FROM {target}") 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 _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 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 if self._payload_text_encoding is not None: kwargs["payload_text_encoding"] = self._payload_text_encoding return self.connection.read_arrow_batches(**kwargs) def _chunk_size(self) -> int: return self._chunk_size_val def _normalize_table(self, table: Any) -> Any: """Rename implicit-uppercase columns of an Arrow table when lowercasing is enabled.""" names = normalize_column_names(table.column_names, self._lowercase_column_names) if names == table.column_names: return table return table.rename_columns(names) def _normalize_reader(self, reader: "ArrowRecordBatchReader") -> "ArrowRecordBatchReader": """Wrap a record batch reader so its schema and batches carry normalized column names.""" schema = reader.schema names = normalize_column_names(schema.names, self._lowercase_column_names) if names == schema.names: return reader renamed = schema for index, name in enumerate(names): renamed = renamed.set(index, renamed.field(index).with_name(name)) return pa.RecordBatchReader.from_batches( renamed, (pa.RecordBatch.from_arrays(batch.columns, schema=renamed) for batch in reader) ) @staticmethod def _resolve_dbms_name(features: "dict[str, Any]") -> str | None: 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 _quote_odbc_table(table: str) -> str: return ".".join(quote_identifier(part) for part in split_qualified_identifier(table)) def _db2_table_reference(table: str) -> str: """Render a table name the way Db2 resolves the bulk-insert target. Unquoted parts that are plain identifiers stay unquoted so Db2 folds them to uppercase; quoted parts and anything else are double-quoted verbatim. Args: table: Table name, optionally schema-qualified and quoted. Returns: The table reference for use in a Db2 statement. """ rendered: list[str] = [] for part in exp.to_table(table, dialect="db2").parts: plain = isinstance(part, exp.Identifier) and not part.quoted rendered.append(part.name if plain and _DB2_PLAIN_IDENTIFIER.match(part.name) else quote_identifier(part.name)) return ".".join(rendered) def _statement_dialect_for(dialect: str) -> str: if dialect == "mssql": return "tsql" return dialect _DB2_PLAIN_IDENTIFIER: Final = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") _MSSQL_SQL_GAP: Final = r"(?:\s|/\*.*?\*/|--[^\r\n]*(?:\r?\n|$))*" _MSSQL_PAGINATION_PATTERN: Final = re.compile( rf"\bTOP{_MSSQL_SQL_GAP}\({_MSSQL_SQL_GAP}(?P<top>\?){_MSSQL_SQL_GAP}\)" rf"|\bOFFSET{_MSSQL_SQL_GAP}(?P<offset>\?){_MSSQL_SQL_GAP}ROWS?\b" rf"|\bFETCH{_MSSQL_SQL_GAP}(?:FIRST|NEXT){_MSSQL_SQL_GAP}" rf"(?P<fetch>\?){_MSSQL_SQL_GAP}ROWS?{_MSSQL_SQL_GAP}ONLY\b", re.IGNORECASE | re.DOTALL, ) def _inline_mssql_pagination_parameters( sql: str, parameters: object, parameter_profile: "ParameterProfile | None", statement_config: "StatementConfig | None" = None, ) -> tuple[str, object]: """Inline pagination values using the compiler's lexical parameter positions. The profile excludes placeholders inside comments and quoted strings and preserves binding order across CTEs. Reuse it instead of tokenizing SQL again. """ if not isinstance(parameters, (list, tuple)) or not parameters: return sql, parameters upper_sql = sql.upper() if "TOP" not in upper_sql and "OFFSET" not in upper_sql and "FETCH" not in upper_sql: return sql, parameters parameter_info = parameter_profile.parameters if parameter_profile is not None else () if parameter_profile is None or ( statement_config is not None and ( statement_config.output_transformer is not None or statement_config.parameter_config.output_transformer is not None ) ): parameter_info = tuple(ParameterValidator(cache_max_size=0).extract_parameters(sql)) positions = {parameter.position: parameter.ordinal for parameter in parameter_info} replacements: list[tuple[int, str]] = [] consumed: set[int] = set() for match in _MSSQL_PAGINATION_PATTERN.finditer(sql): group = match.lastgroup if group is None: continue position = match.start(group) ordinal = positions.get(position) if ordinal is None or ordinal >= len(parameters): continue replacements.append((position, str(_pagination_int(parameters[ordinal])))) consumed.add(ordinal) if not consumed: return sql, parameters for position, value in replacements[::-1]: sql = sql[:position] + value + sql[position + 1 :] return sql, [value for index, value in enumerate(parameters) if index not in consumed] def _pagination_int(value: object) -> int: unwrapped = getattr(value, "value", value) integer = int(cast("Any", unwrapped)) if not isinstance(unwrapped, str) and unwrapped != integer: msg = "SQL Server pagination controls must be whole integers" raise ValueError(msg) return integer def _unwrap_parameter(value: Any, naive_utc_datetimes: bool = False) -> Any: wrapped = getattr(value, "value", value) if wrapped is None: return None if naive_utc_datetimes and isinstance(wrapped, datetime) and wrapped.tzinfo is not None: wrapped = wrapped.astimezone(timezone.utc).replace(tzinfo=None) return str(wrapped) def _odbc_parameters(parameters: Any, *, naive_utc_datetimes: bool = False) -> "list[str | None] | None": """Render statement parameters as the text values arrow-odbc binds. Args: parameters: Compiled statement parameters. naive_utc_datetimes: Convert timezone-aware datetimes to naive UTC before rendering. Returns: The text parameters, or ``None`` when the statement has none. """ if parameters is None: return None if isinstance(parameters, Mapping): return [_unwrap_parameter(value, naive_utc_datetimes) for value in parameters.values()] if isinstance(parameters, (list, tuple)): if not parameters: return None return [_unwrap_parameter(value, naive_utc_datetimes) for value in parameters] return [_unwrap_parameter(parameters, naive_utc_datetimes)] def _reader_to_table(reader: Any) -> Any: ensure_pyarrow() if isinstance(reader, pa.Table): return reader if hasattr(reader, "read_all"): return reader.read_all() into_reader = getattr(reader, "into_pyarrow_record_batch_reader", None) if callable(into_reader): return cast("Any", into_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() 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() return pa.RecordBatchReader.from_batches(table.schema, table.to_batches(max_chunksize=chunk_size)) register_driver_profile("arrow_odbc", driver_profile)