diff --git a/.devcontainer/setup_env.sh b/.devcontainer/setup_env.sh index 9d6a493f..79a88ee6 100644 --- a/.devcontainer/setup_env.sh +++ b/.devcontainer/setup_env.sh @@ -25,4 +25,18 @@ source .venv/bin/activate # plus the dev dependency group (pre-commit, pytest, etc.). Groups are installed # explicitly rather than relying on uv's default-group behaviour, which varies by version. uv sync --all-extras --group dev + +# `--all-extras` above installs the adbc-driver-manager/pyarrow *client +# library* for the adbc extra, but not the driver binary that speaks the SQL +# Server wire protocol -- that ships separately via the `dbc` CLI and is not +# on PyPI. See docs/adbc_backend.md. `dbc` installs into $VIRTUAL_ENV when a +# venv is active (it is here), so point ADBC_DRIVER_PATH there. +pip install --quiet dbc +dbc install mssql +ADBC_DRIVER_PATH="$(pwd)/.venv/etc/adbc/drivers" +if ! grep -qF 'ADBC_DRIVER_PATH' ~/.bashrc 2>/dev/null; then + echo "export ADBC_DRIVER_PATH=\"$ADBC_DRIVER_PATH\"" >> ~/.bashrc +fi +export ADBC_DRIVER_PATH + uv run pre-commit install diff --git a/.github/workflows/integration-tests-sqlserver.yml b/.github/workflows/integration-tests-sqlserver.yml index 7a26bdc2..4cc6c748 100644 --- a/.github/workflows/integration-tests-sqlserver.yml +++ b/.github/workflows/integration-tests-sqlserver.yml @@ -97,9 +97,19 @@ jobs: sqlserver_version: "2025" msodbc_version: "18" collation: SQL_Latin1_General_CP1_CS_AS + # ADBC (experimental, see docs/adbc_backend.md) gets a single + # coverage row on the latest Python and SQL Server baseline. + - backend: adbc + python_version: "3.13" + sqlserver_version: "2025" + msodbc_version: "18" + collation: SQL_Latin1_General_CP1_CI_AS runs-on: ubuntu-latest + # The ADBC backend is experimental (docs/adbc_backend.md): don't block + # the required check on it while it's still stabilizing. + continue-on-error: ${{ matrix.backend == 'adbc' }} container: - image: ghcr.io/${{ github.repository }}:CI-${{ matrix.python_version }}-${{ matrix.backend == 'pyodbc' && format('msodbc{0}', matrix.msodbc_version) || 'mssql' }} + image: ghcr.io/${{ github.repository }}:CI-${{ matrix.python_version }}-${{ matrix.backend == 'pyodbc' && format('msodbc{0}', matrix.msodbc_version) || (matrix.backend == 'adbc' && 'adbc' || 'mssql') }} credentials: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} @@ -121,7 +131,7 @@ jobs: - name: Install dependencies env: - INSTALL_EXTRA: ${{ matrix.backend == 'pyodbc' && 'pyodbc' || 'mssql' }} + INSTALL_EXTRA: ${{ matrix.backend == 'pyodbc' && 'pyodbc' || (matrix.backend == 'adbc' && 'adbc' || 'mssql') }} run: uv pip install --system -e ".[$INSTALL_EXTRA]" --group dev - name: Run functional tests diff --git a/.github/workflows/publish-docker.yml b/.github/workflows/publish-docker.yml index b45864d3..61ae9032 100644 --- a/.github/workflows/publish-docker.yml +++ b/.github/workflows/publish-docker.yml @@ -14,7 +14,7 @@ jobs: strategy: matrix: python_version: ["3.11", "3.12", "3.13", "3.14"] - docker_target: ["msodbc17", "msodbc18", "mssql"] + docker_target: ["msodbc17", "msodbc18", "mssql", "adbc"] runs-on: ubuntu-latest permissions: contents: read diff --git a/CHANGELOG.md b/CHANGELOG.md index 12383a74..0a4cd3eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ - Add Python 3.14 support. - **Behavior change:** `dbt_sqlserver_use_dbt_transactions` now defaults to `True`: dbt-managed transaction hooks (begin/commit) emit real `BEGIN TRANSACTION` / `COMMIT TRANSACTION` T-SQL instead of no-ops, so a failed model rolls back its own statements instead of leaving a partial result behind on autocommit. The `False` (legacy autocommit) behavior is deprecated and will be removed in a future release. - **Behavior change:** `dbt_sqlserver_use_native_string_types` now defaults to `True`: `STRING` maps to `VARCHAR(MAX)`, `NCHAR` to `NCHAR(1)`, and `NVARCHAR` to `NVARCHAR(4000)`, instead of the legacy `VARCHAR(8000)`/`CHAR(1)` mappings. The `False` (legacy) behavior is deprecated and will be removed in a future release. +- Add an experimental `adbc` backend (`backend: adbc`), an alternative to `pyodbc`/`mssql-python` built on [ADBC](https://arrow.apache.org/adbc/) that talks to SQL Server via `go-mssqldb` instead of an ODBC/DB-API bridge. Install with `dbt-sqlserver[adbc]` plus the separate `dbc` CLI-installed driver binary; supports SQL Server (user/password) authentication only for now. See [docs/adbc_backend.md](docs/adbc_backend.md). [#771](https://github.com/dbt-msft/dbt-sqlserver/issues/771) #### Bugfixes diff --git a/README.md b/README.md index 64806822..faf037b8 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,7 @@ Latest pre-release: ![GitHub tag (latest SemVer pre-release)](https://img.shield |---|---|---| | `pyodbc` | `dbt-sqlserver[pyodbc]` or `pyodbc` | `unixodbc-dev` plus the Microsoft ODBC Driver for SQL Server | | `mssql-python` | `dbt-sqlserver[mssql]` or `mssql-python` | `libltdl7`, `libkrb5-3`, `libgssapi-krb5-2` | +| `adbc` *(experimental)* | `dbt-sqlserver[adbc]` | none (driver binary installed separately via the `dbc` CLI) | ### `pyodbc` backend @@ -118,6 +119,16 @@ your_profile: backend: mssql-python # <-- enables this backend ``` +### `adbc` backend *(experimental)* + +An Arrow-native backend built on [ADBC](https://arrow.apache.org/adbc/), avoiding the row-based ODBC/DB-API bridge entirely. SQL Server authentication only (no Azure AD / Windows auth yet). + +```shell +pip install -U "dbt-sqlserver[adbc]" +``` + +The driver binary is not on PyPI and must be installed once via the `dbc` CLI. See [docs/adbc_backend.md](docs/adbc_backend.md) for the full setup, configuration, and known-differences guide. + ## Changelog See [the changelog](CHANGELOG.md) @@ -151,7 +162,7 @@ The same setting is also honoured via `vars:` for backwards compatibility; the b ### `backend` -*(default: `pyodbc`)* Set to `mssql-python` in a profile target to use the `mssql-python` backend instead of `pyodbc`. The adapter fails if the required backend package (Python dependency), such as `pyodbc` or `mssql-python`, is not installed. +*(default: `pyodbc`)* Set to `mssql-python` or `adbc` (experimental, see [docs/adbc_backend.md](docs/adbc_backend.md)) in a profile target to use that backend instead of `pyodbc`. The adapter fails if the required backend package (Python dependency), such as `pyodbc`, `mssql-python`, or `adbc-driver-manager`, is not installed. ### `dbt_sqlserver_enable_safe_type_expansion` diff --git a/dbt/adapters/sqlserver/sqlserver_adapter.py b/dbt/adapters/sqlserver/sqlserver_adapter.py index 45017045..929c4ad6 100644 --- a/dbt/adapters/sqlserver/sqlserver_adapter.py +++ b/dbt/adapters/sqlserver/sqlserver_adapter.py @@ -1,4 +1,5 @@ -from typing import Any, Dict, List, Optional +import datetime as _dt +from typing import Any, Dict, List, Optional, Tuple, Union import agate import dbt_common.exceptions @@ -37,6 +38,44 @@ logger = AdapterLogger("SQLServer") +def _normalize_result_datetimes( + result: Union[Tuple, List[Tuple], None], +) -> Union[Tuple, List[Tuple], None]: + """Strip spurious ``tzinfo=UTC`` that ADBC attaches to SQL Server + DATETIME2 / DATETIME values. + + SQL Server does **not** store timezone offsets for these types, so the + correct Python representation is a naive ``datetime``. ADBC's Arrow + backend wraps every ``timestamp`` column as ``timestamp[us, tz=UTC]`` + regardless of the source semantics, producing ``datetime(…, tzinfo=UTC)``. + We revert that to match SQL Server semantics and to stay compatible with + the existing pyodbc / mssql-python backends. + """ + if result is None: + return None + + if isinstance(result, tuple): + return tuple( + (v.replace(tzinfo=None) if isinstance(v, _dt.datetime) and v.tzinfo is not None else v) + for v in result + ) + + if isinstance(result, list): + return [ + tuple( + ( + v.replace(tzinfo=None) + if isinstance(v, _dt.datetime) and v.tzinfo is not None + else v + ) + for v in row + ) + for row in result + ] + + return result + + class SQLServerAdapter(SQLAdapter): """ Controls actual implementation of adapter, and ability to override certain methods. @@ -267,9 +306,9 @@ def run_sql_for_tests(self, sql, fetch, conn): if not fetch: conn.handle.commit() if fetch == "one": - return cursor.fetchone() + return _normalize_result_datetimes(cursor.fetchone()) elif fetch == "all": - return cursor.fetchall() + return _normalize_result_datetimes(cursor.fetchall()) else: return except BaseException: diff --git a/dbt/adapters/sqlserver/sqlserver_auth.py b/dbt/adapters/sqlserver/sqlserver_auth.py index 8985ff66..69fcbc91 100644 --- a/dbt/adapters/sqlserver/sqlserver_auth.py +++ b/dbt/adapters/sqlserver/sqlserver_auth.py @@ -43,6 +43,14 @@ def is_mssql_python_backend(backend: "SQLServerBackend") -> bool: return backend.value == SQLSERVER_BACKEND_MSSQL_PYTHON +def is_adbc_backend(backend: "SQLServerBackend") -> bool: + """Return whether the coerced backend enum targets ``adbc``.""" + + from dbt.adapters.sqlserver.sqlserver_credentials import SQLServerBackend + + return backend == SQLServerBackend.adbc + + def normalize_authentication_key(value: Optional[str]) -> str: """Normalize a SQL Server auth or lookup key for cross-layer comparisons.""" diff --git a/dbt/adapters/sqlserver/sqlserver_backend.py b/dbt/adapters/sqlserver/sqlserver_backend.py index 3b3a4486..aee2b513 100644 --- a/dbt/adapters/sqlserver/sqlserver_backend.py +++ b/dbt/adapters/sqlserver/sqlserver_backend.py @@ -8,6 +8,7 @@ from __future__ import annotations +import urllib.parse from contextlib import suppress from typing import Any, Callable, Tuple @@ -42,6 +43,7 @@ _RUNTIME_STATE, MssqlPythonModuleProtocol, PyodbcModuleProtocol, + _get_adbc, ) logger = AdapterLogger("sqlserver") @@ -259,12 +261,19 @@ def log_connection_string(connection_string: str) -> None: def is_pyodbc_handle(handle: Any) -> bool: - """Detect a pyodbc handle without importing pyodbc from the caller.""" + """Detect a pyodbc handle without importing pyodbc from the caller. + + ADBC handles (from ``adbc_driver_manager``) must always return False, + even when the handle is a mock in test code. + """ handle_type = type(handle) module_name = getattr(handle_type, "__module__", "") or "" class_name = getattr(handle_type, "__name__", "") or "" + if "adbc" in module_name.lower() or "adbc" in class_name.lower(): + return False + if "pyodbc" in module_name or "pyodbc" in class_name: return True @@ -340,3 +349,88 @@ def _connect_pyodbc( timeout=credentials.login_timeout, ) return _finalize_connection_handle(handle, credentials) + + +def _sanitize_adbc_uri_for_logging(uri: str) -> str: + """Redact the password component of an ADBC URI for safe logging.""" + parsed = urllib.parse.urlparse(uri) + if parsed.password: + safe_netloc = parsed.netloc.replace( + f":{parsed.password}@", + ":***@", + ) + sanitized = parsed._replace(netloc=safe_netloc).geturl() + return sanitized + return uri + + +def build_adbc_connection_uri(credentials: SQLServerCredentials) -> str: + """Build a go-mssqldb URI for the ADBC backend. + + Constructs URIs in the format: + ``sqlserver://[user:password@]host[:port]?query_params`` + + - User and password are URL-encoded so special characters (``@``, ``:``, + ``/``, etc.) are escaped per RFC 3986. + - Named instances (host containing ``\\``) omit the port. + - Query parameters: ``database``, ``encrypt``, ``TrustServerCertificate``, + and optional ``connection timeout``. + """ + + host = (credentials.host or "").strip() + port = credentials.port or 1433 + database = credentials.database or "" + uid = credentials.UID or "" + pwd = credentials.PWD or "" + + userinfo = f"{urllib.parse.quote(uid, safe='')}:{urllib.parse.quote(pwd, safe='')}" + + if "\\" in host: + authority = f"{userinfo}@{host}" + else: + authority = f"{userinfo}@{host}:{port}" + + query_parts = [ + f"database={urllib.parse.quote(database, safe='')}", + f"encrypt={str(credentials.encrypt).lower()}", + f"TrustServerCertificate={str(credentials.trust_cert).lower()}", + ] + + if credentials.login_timeout and credentials.login_timeout > 0: + query_parts.append(f"connection timeout={credentials.login_timeout}") + + query_string = "&".join(query_parts) + return f"sqlserver://{authority}?{query_string}" + + +def _connect_adbc(credentials: SQLServerCredentials, uri: str) -> Any: + """Open an ADBC connection using the constructed go-mssqldb URI. + + The ADBC mssql driver (``"mssql"``) is the only supported driver. + Connections are opened with ``autocommit=True`` so that DDL statements + (``CREATE SCHEMA``, ``CREATE TABLE``, …) auto-commit without extra + round-trips. When ``dbt_sqlserver_use_dbt_transactions: true``, dbt + emits explicit ``BEGIN TRANSACTION`` / ``COMMIT TRANSACTION`` SQL + that correctly nests on top of the autocommit session -- exactly the + same strategy used by the pyodbc backend. + """ + + sanitized = _sanitize_adbc_uri_for_logging(uri) + logger.debug(f"Connecting to SQL Server with ADBC: {sanitized}") + + adbc_module = _get_adbc() + return adbc_module.connect( + driver="mssql", + db_kwargs={"uri": uri}, + autocommit=True, + ) + + +def get_adbc_retryable_exceptions() -> Tuple[type[Exception], ...]: + """Return the ADBC exception types that the connection manager may retry.""" + + adbc_module = _get_adbc() + return ( + adbc_module.InternalError, + adbc_module.OperationalError, + ) diff --git a/dbt/adapters/sqlserver/sqlserver_connections.py b/dbt/adapters/sqlserver/sqlserver_connections.py index c3cec7a8..5bd501e8 100644 --- a/dbt/adapters/sqlserver/sqlserver_connections.py +++ b/dbt/adapters/sqlserver/sqlserver_connections.py @@ -4,6 +4,9 @@ from contextlib import contextmanager from typing import ( Any, + Dict, + Iterable, + Iterator, Optional, Tuple, Type, @@ -32,13 +35,17 @@ ) from dbt.adapters.sql.connections import SQLConnectionManager from dbt.adapters.sqlserver.sqlserver_auth import ( + is_adbc_backend, is_mssql_python_backend, ) from dbt.adapters.sqlserver.sqlserver_backend import ( + _connect_adbc, _connect_mssql_python, _connect_pyodbc, + build_adbc_connection_uri, build_mssql_python_connection_string, build_pyodbc_connection_string, + get_adbc_retryable_exceptions, get_mssql_python_retryable_exceptions, get_pyodbc_retryable_exceptions, handle_backend_database_error, @@ -49,18 +56,238 @@ from dbt.adapters.sqlserver.sqlserver_credentials import SQLServerCredentials from dbt.adapters.sqlserver.sqlserver_helpers import ( byte_array_to_datetime, + validate_adbc_requirements, validate_connection_requirements, validate_mssql_python_requirements, validate_pyodbc_requirements, ) from dbt.adapters.sqlserver.sqlserver_runtime import ( _RUNTIME_STATE, + _get_adbc, _get_mssql_python, _get_pyodbc, ) logger = AdapterLogger("sqlserver") + +def _cursor_is_adbc(cursor: Any) -> bool: + """Return True if *cursor* came from an ADBC dbapi connection.""" + return type(cursor).__module__ == "adbc_driver_manager.dbapi" + + +def _skip_quoted_span(sql: str, i: int) -> Optional[int]: + """If ``sql[i]`` opens a bracket-quoted identifier (``[...]``) or a + single-quoted string literal (``'...'``), return the index just past + its matching close quote (``]]``/``''`` are escaped doubled quotes). + Returns ``None`` if ``sql[i]`` does not open either. + """ + ch = sql[i] + if ch not in ("[", "'"): + return None + close = "]" if ch == "[" else "'" + n = len(sql) + end = i + 1 + while end < n: + if sql[end] == close: + if end + 1 < n and sql[end + 1] == close: + end += 2 + continue + return end + 1 + end += 1 + return end + + +def _replace_qmark_with_at_pn(sql: str, num_bindings: int) -> str: + """Replace ``?`` placeholders with ``@p1``, ``@p2``, ... for ADBC. + + ADBC's go-mssqldb driver does not recognise PEP 249 ``qmark`` + placeholders but accepts T-SQL named parameters (``@p1``, ``@p2``, + ...) that are bound positionally by the driver manager. + + Only ``?`` characters outside bracket-quoted identifiers (``[...]``, + e.g. a seed column named ``[satisfied?]``) and single-quoted string + literals (``'...'``) are treated as placeholders -- either may + legally contain a literal ``?`` that must not consume a binding. + """ + out = [] + i = 0 + n = len(sql) + placeholder_num = 1 + while i < n: + span_end = _skip_quoted_span(sql, i) + if span_end is not None: + out.append(sql[i:span_end]) + i = span_end + continue + ch = sql[i] + if ch == "?" and placeholder_num <= num_bindings: + out.append(f"@p{placeholder_num}") + placeholder_num += 1 + i += 1 + continue + out.append(ch) + i += 1 + return "".join(out) + + +def _split_sql_statements(sql: str) -> Iterator[str]: + """Split *sql* on top-level ``;`` statement separators. + + ``;`` characters inside bracket-quoted identifiers or string literals + are not treated as separators. + """ + start = 0 + i = 0 + n = len(sql) + while i < n: + span_end = _skip_quoted_span(sql, i) + if span_end is not None: + i = span_end + continue + if sql[i] == ";": + yield sql[start:i] + i += 1 + start = i + continue + i += 1 + yield sql[start:] + + +def _get_adbc_rowcount(handle: Any) -> int: + """Query ``@@ROWCOUNT`` on a temporary ADBC cursor. + + Must be called **before** draining the original cursor's resultsets + (``nextset()``), as ``nextset()`` resets ``@@ROWCOUNT`` on the server. + """ + try: + rc_cursor = handle.cursor() + try: + rc_cursor.execute("SELECT @@ROWCOUNT AS rc") + row = rc_cursor.fetchone() + if row and row[0] is not None and row[0] >= 0: + return int(row[0]) + finally: + rc_cursor.close() + except Exception: + pass + return 0 + + +_ADBC_ROWCOUNT_DML_KEYWORDS = ("INSERT", "UPDATE", "DELETE", "MERGE") + + +def _sql_affects_rows(sql: str) -> bool: + """Best-effort check that *sql* contains DML whose row count dbt reports. + + dbt frequently batches multiple ``;``-separated statements into one + string -- e.g. the delete+insert incremental strategy emits + ``SET NOCOUNT ON; delete ...; SET NOCOUNT OFF; insert ...`` as a single + call to ``add_query``. Checking only the leading keyword of the whole + string would miss the DML hiding behind a leading ``SET`` or comment, + so every top-level statement is checked: if any one of them leads with + INSERT/UPDATE/DELETE/MERGE, the ADBC ``@@ROWCOUNT`` round trip (see + ``_get_adbc_rowcount``) is worth paying for. Pure DDL, SELECT, and + transaction-control (BEGIN/COMMIT/ROLLBACK) batches skip it. + """ + for statement in _split_sql_statements(sql): + text = statement + while True: + text = text.lstrip() + if text.startswith("/*"): + end = text.find("*/") + if end == -1: + text = "" + break + text = text[end + 2 :] + continue + if text.startswith("--"): + newline = text.find("\n") + text = "" if newline == -1 else text[newline + 1 :] + continue + break + if text.upper().startswith(_ADBC_ROWCOUNT_DML_KEYWORDS): + return True + return False + + +def _try_drain_nextset(cursor: Any) -> bool: + """Drain one additional result set from *cursor*, if supported. + + Returns ``True`` if a further result set was consumed, ``False`` if the + cursor has no more result sets, or the backend does not support + ``nextset()`` (notably ADBC). + """ + try: + return bool(cursor.nextset()) + except Exception as e: + # Duck-typed on purpose: adbc_driver_manager is an optional + # dependency, so it cannot be imported unconditionally to check + # ``isinstance(e, NotSupportedError)`` here. The message text is + # not checked -- only the DB-API 2.0-mandated exception name and + # the module that raised it, both of which are stable across + # adbc_driver_manager versions (unlike free-form message wording). + if type(e).__name__ == "NotSupportedError" and type(e).__module__.startswith( + "adbc_driver_manager" + ): + return False + raise + + +# Mapping of Apache Arrow type codes (integers) to SQL Server type names. +# ADBC cursors report column types as Arrow type codes; this map translates +# them for use in get_column_schema_from_query() and related column expansion. +# Reference: pyarrow type enum values (pa.int32().__class__.__name__ yields the +# Arrow type name string, and the type's ``id`` property is the integer code). +ARROW_TYPE_CODE_TO_NAME: dict[int, str] = { + 1: "bit", # pa.bool_() + 3: "varchar", # pa.string() / pa.utf8() + 4: "varbinary", # pa.binary() + 5: "varchar(max)", # pa.large_string() / pa.large_utf8() + 6: "real", # pa.float32() + 7: "float", # pa.float64() + 8: "int", # pa.int32() + 9: "bigint", # pa.int64() + 10: "smallint", # pa.int8() + 11: "smallint", # pa.int16() + 12: "decimal", # pa.decimal128() + 14: "date", # pa.date32() + 16: "date", # pa.date64() + 17: "datetime2(6)", # pa.timestamp() + 18: "time", # pa.time32() + 19: "time", # pa.time64() +} + +# Some ADBC drivers / cursor implementations report the Arrow type name as a +# string (e.g. "int32") rather than the integer code. Map those here. +ARROW_STRING_TYPE_TO_NAME: dict[str, str] = { + "int8": "smallint", + "int16": "smallint", + "int32": "int", + "int64": "bigint", + "float32": "real", + "float64": "float", + "float": "float", + "double": "float", + "string": "varchar", + "utf8": "varchar", + "large_string": "varchar(max)", + "large_utf8": "varchar(max)", + "bool": "bit", + "boolean": "bit", + "decimal128": "decimal", + "decimal": "decimal", + "date32": "date", + "date64": "date", + "date": "date", + "time32": "time", + "time64": "time", + "time": "time", + "timestamp": "datetime2(6)", + "binary": "varbinary", + "large_binary": "varbinary", +} + # Attribute used to stash the in-flight pyodbc / mssql-python cursor on a # Connection so cancel() can reach it from another thread. See cancel(). _IN_FLIGHT_CURSOR_ATTR = "_dbt_sqlserver_in_flight_cursor" @@ -90,7 +317,9 @@ def exception_handler(self, sql): except Exception as e: credentials = self.get_thread_connection().credentials - if is_mssql_python_backend(credentials.backend): + if is_adbc_backend(credentials.backend): + database_error = _RUNTIME_STATE.get_adbc_database_error() + elif is_mssql_python_backend(credentials.backend): database_error = _RUNTIME_STATE.get_mssql_python_database_error() else: database_error = _RUNTIME_STATE.get_pyodbc_database_error() @@ -130,6 +359,16 @@ def connect() -> Any: log_connection_string(con_str_concat) return _connect_mssql_python(mssql_python, credentials, con_str_concat) + elif is_adbc_backend(credentials.backend): + _get_adbc() + validate_adbc_requirements(credentials) + con_str_concat = build_adbc_connection_uri(credentials) + retryable_exceptions = get_adbc_retryable_exceptions() + + def connect() -> Any: + log_connection_string(con_str_concat) + return _connect_adbc(credentials, con_str_concat) + else: pyodbc = _get_pyodbc() validate_pyodbc_requirements(credentials) @@ -174,7 +413,7 @@ def _apply_session_settings(cls, connection: Connection) -> None: finally: cursor.close() - if not connection.handle.autocommit: + if getattr(connection.handle, "autocommit", True) is False: connection.handle.commit() except Exception: connection.handle.close() @@ -316,6 +555,8 @@ def _execute_query_with_retry( (binding.isoformat() if isinstance(binding, dt.datetime) else binding) for binding in bindings ] + if _cursor_is_adbc(cursor): + sql = _replace_qmark_with_at_pn(sql, len(bindings)) cursor.execute(sql, bindings) except retryable_exceptions as e: if attempt >= retry_limit: @@ -399,16 +640,69 @@ def _execute_query_with_retry( ) ) + # ADBC cursor.rowcount is always -1 (the dbapi always calls + # execute_query). Capture @@ROWCOUNT now, BEFORE any draining + # or subsequent statements reset it -- but only when it's both + # meaningful (DML) and safe: opening a second cursor on the + # same connection while this cursor's result set is still + # unfetched would race an unfetched SELECT, since go-mssqldb + # has no MARS support. ``cursor.description`` is empty for + # DML/DDL (no pending rows to fetch) and populated for + # anything that returns a result set. + if _cursor_is_adbc(cursor) and not cursor.description and _sql_affects_rows(sql): + setattr( + cursor, + "__dbt_sqlserver_adbc_rowcount", + _get_adbc_rowcount(connection.handle), + ) + return connection, cursor @classmethod def get_credentials(cls, credentials: SQLServerCredentials) -> SQLServerCredentials: return credentials + @classmethod + def process_results( + cls, column_names: "Iterable[str]", rows: "Iterable[Any]" + ) -> "Iterator[Dict[str, Any]]": + """Normalize datetime values by stripping timezone info. + + ADBC cursors return Arrow-backed timestamps with ``tzinfo=UTC``, + but dbt and its test suite expect naive datetimes. This override + ensures consistent output across all backends. + """ + unique_col_names: "dict[str, int]" = {} + col_names_list = list(column_names) + for idx, col_name in enumerate(col_names_list): + if col_name in unique_col_names: + unique_col_names[col_name] += 1 + col_names_list[idx] = f"{col_name}_{unique_col_names[col_name]}" + else: + unique_col_names[col_name] = 1 + + for row in rows: + normalized = tuple( + ( + val.replace(tzinfo=None) + if isinstance(val, dt.datetime) and val.tzinfo is not None + else val + ) + for val in row + ) + yield dict(zip(col_names_list, normalized)) + @classmethod def get_response(cls, cursor: Any) -> AdapterResponse: message = "OK" rows = cursor.rowcount + if _cursor_is_adbc(cursor) and rows is not None and rows < 0: + # Only the ADBC path needs recovering: its rowcount is always + # -1. pyodbc/mssql-python already report a real rowcount, or + # -1 for statements (e.g. SELECT) where "unknown" is the + # correct, historical value -- that must pass through as-is. + stashed = getattr(cursor, "__dbt_sqlserver_adbc_rowcount", None) + rows = stashed if stashed is not None else rows return AdapterResponse( _message=message, rows_affected=rows, @@ -416,12 +710,34 @@ def get_response(cls, cursor: Any) -> AdapterResponse: @classmethod def data_type_code_to_name(cls, type_code: Union[int, str]) -> str: + # Arrow integer type codes (from ADBC cursors) take priority. + # Non-ADBC backends (pyodbc / mssql-python) never emit bare integers, + # so receiving one is a reliable signal that we are on the ADBC path. if isinstance(type_code, int): + if type_code in ARROW_TYPE_CODE_TO_NAME: + return ARROW_TYPE_CODE_TO_NAME[type_code] raise dbt_common.exceptions.DbtRuntimeError( "Unsupported SQL Server type code " - f"{type_code!r}: integer type codes are not mapped" + f"{type_code!r}: no matching entry found in datatypes mapping" ) + # Arrow DataType objects (e.g. ``DataType(int32)``) from ADBC cursors. + # We convert to string and check the known Arrow type-name map. + if not isinstance(type_code, (int, str)): + as_str = str(type_code) + # Strip precision/tz qualifiers: "timestamp[us, tz=UTC]" -> "timestamp" + # and "decimal128(5, 2)" -> "decimal128" + base_type = as_str.split("[")[0].split("(")[0] + if base_type in ARROW_STRING_TYPE_TO_NAME: + return ARROW_STRING_TYPE_TO_NAME[base_type] + if as_str in ARROW_STRING_TYPE_TO_NAME: + return ARROW_STRING_TYPE_TO_NAME[as_str] + + # Arrow type name strings ("int32", "utf8", …) also from ADBC cursors. + if isinstance(type_code, str) and type_code in ARROW_STRING_TYPE_TO_NAME: + return ARROW_STRING_TYPE_TO_NAME[type_code] + + # --- existing pyodbc / mssql-python type-repr handling below --- if isinstance(type_code, str) and type_code in datatypes: return datatypes[type_code] @@ -462,12 +778,12 @@ def execute( try: response = self.get_response(cursor) if fetch: - while cursor.description is None and cursor.nextset(): + while cursor.description is None and _try_drain_nextset(cursor): pass table = self.get_result_from_cursor(cursor, limit) else: table = empty_table() - while cursor.nextset(): + while _try_drain_nextset(cursor): pass return response, table finally: diff --git a/dbt/adapters/sqlserver/sqlserver_constants.py b/dbt/adapters/sqlserver/sqlserver_constants.py index 9ee5fdcb..8dbb5596 100644 --- a/dbt/adapters/sqlserver/sqlserver_constants.py +++ b/dbt/adapters/sqlserver/sqlserver_constants.py @@ -4,11 +4,15 @@ SQLSERVER_BACKEND_PYODBC = "pyodbc" SQLSERVER_BACKEND_MSSQL_PYTHON = "mssql-python" +SQLSERVER_BACKEND_ADBC = "adbc" SUPPORTED_SQLSERVER_BACKENDS = ( SQLSERVER_BACKEND_PYODBC, SQLSERVER_BACKEND_MSSQL_PYTHON, + SQLSERVER_BACKEND_ADBC, +) +SUPPORTED_SQLSERVER_BACKENDS_MESSAGE = ( + "Supported backends are 'pyodbc', 'mssql-python', and 'adbc'." ) -SUPPORTED_SQLSERVER_BACKENDS_MESSAGE = "Supported backends are 'pyodbc' and 'mssql-python'." MSSQL_AUTH_ACTIVE_DIRECTORY_MSI = "ActiveDirectoryMSI" MSSQL_AUTH_ACTIVE_DIRECTORY_INTEGRATED = "ActiveDirectoryIntegrated" diff --git a/dbt/adapters/sqlserver/sqlserver_credentials.py b/dbt/adapters/sqlserver/sqlserver_credentials.py index b08c49ae..14dcef63 100644 --- a/dbt/adapters/sqlserver/sqlserver_credentials.py +++ b/dbt/adapters/sqlserver/sqlserver_credentials.py @@ -8,6 +8,7 @@ from dbt.adapters.sqlserver.sqlserver_auth import normalize_authentication_key from dbt.adapters.sqlserver.sqlserver_constants import ( MSSQL_AUTH_ACTIVE_DIRECTORY_SERVICE_PRINCIPAL, + SQLSERVER_BACKEND_ADBC, SQLSERVER_BACKEND_MSSQL_PYTHON, SQLSERVER_BACKEND_PYODBC, SUPPORTED_SQLSERVER_BACKENDS_MESSAGE, @@ -18,6 +19,7 @@ class SQLServerBackend(StrEnum): pyodbc = SQLSERVER_BACKEND_PYODBC mssql_python = SQLSERVER_BACKEND_MSSQL_PYTHON + adbc = SQLSERVER_BACKEND_ADBC DEFAULT_SQLSERVER_BACKEND = cast(SQLServerBackend, SQLServerBackend.pyodbc) diff --git a/dbt/adapters/sqlserver/sqlserver_helpers.py b/dbt/adapters/sqlserver/sqlserver_helpers.py index 4d40cade..4e4a0a91 100644 --- a/dbt/adapters/sqlserver/sqlserver_helpers.py +++ b/dbt/adapters/sqlserver/sqlserver_helpers.py @@ -14,6 +14,7 @@ import dbt_common.exceptions +from dbt.adapters.events.logging import AdapterLogger from dbt.adapters.sqlserver.sqlserver_auth import ( is_active_directory_authentication, is_mssql_python_backend, @@ -25,6 +26,8 @@ SENSITIVE_CONNECTION_STRING_KEYS, ) +logger = AdapterLogger("sqlserver") + if TYPE_CHECKING: from dbt.adapters.sqlserver.sqlserver_credentials import SQLServerCredentials @@ -110,6 +113,39 @@ def validate_mssql_python_requirements(credentials: SQLServerCredentials) -> Non ) +def validate_adbc_requirements(credentials: SQLServerCredentials) -> None: + """Backend-specific validation for the ADBC connection path. + + ADBC currently supports SQL authentication only. Windows login and all + Azure Active Directory modes are rejected with a clear message. + """ + + if credentials.windows_login: + raise dbt_common.exceptions.DbtRuntimeError( + "Windows login is not supported with the ADBC backend." + ) + + authentication = normalize_connection_authentication(credentials.authentication, True) + + if authentication and is_active_directory_authentication(authentication): + raise dbt_common.exceptions.DbtRuntimeError( + "Azure AD authentication is not yet supported with the ADBC backend. " + "Use authentication: 'sql' or use the pyodbc/mssql-python backend." + ) + + if not credentials.UID or not credentials.PWD: + raise dbt_common.exceptions.DbtRuntimeError( + "The ADBC backend requires a user (UID) and password (PWD) for SQL authentication." + ) + + if credentials.query_timeout not in (None, 0): + logger.warning( + "query_timeout=%r is set but the ADBC backend may not support " + "per-connection query timeouts; the setting will be ignored.", + credentials.query_timeout, + ) + + def normalize_connection_string_key(key: str) -> str: """Normalize a connection-string key for secret-field lookups.""" diff --git a/dbt/adapters/sqlserver/sqlserver_runtime.py b/dbt/adapters/sqlserver/sqlserver_runtime.py index 414a9778..ab4e0527 100644 --- a/dbt/adapters/sqlserver/sqlserver_runtime.py +++ b/dbt/adapters/sqlserver/sqlserver_runtime.py @@ -65,6 +65,16 @@ def pooling( def connect(self, *args: Any, **kwargs: Any) -> Any: ... +class AdbcModuleProtocol(Protocol): + DatabaseError: type[Exception] + InternalError: type[Exception] + OperationalError: type[Exception] + InterfaceError: type[Exception] + Cursor: type + + def connect(self, *args: Any, **kwargs: Any) -> Any: ... + + @dataclass class AccessToken: token: str @@ -79,6 +89,8 @@ class SQLServerRuntimeSnapshot: pyodbc_import_error: Optional[ModuleNotFoundError] mssql_python_module: Any mssql_python_import_error: Optional[ModuleNotFoundError] + adbc_module: Any + adbc_import_error: Optional[ModuleNotFoundError] azure_credentials_module: Any azure_credentials_import_error: Optional[ModuleNotFoundError] azure_identity_module: Any @@ -108,6 +120,9 @@ def __init__(self) -> None: self.pyodbc_import_error: Optional[ModuleNotFoundError] = None self.mssql_python_module: Any = None self.mssql_python_import_error: Optional[ModuleNotFoundError] = None + self.adbc_module: Optional[AdbcModuleProtocol] = None + self.adbc_import_error: Optional[ModuleNotFoundError] = None + self._adbc_db_error: Optional[type[Exception]] = None self.azure_credentials_module: Any = None self.azure_credentials_import_error: Optional[ModuleNotFoundError] = None self.azure_identity_module: Any = None @@ -127,6 +142,9 @@ def reset_modules(self) -> None: self.pyodbc_import_error = None self.mssql_python_module = None self.mssql_python_import_error = None + self.adbc_module = None + self.adbc_import_error = None + self._adbc_db_error = None self.azure_credentials_module = None self.azure_credentials_import_error = None self.azure_identity_module = None @@ -165,6 +183,15 @@ def get_mssql_python_database_error(self) -> Optional[type[Exception]]: return self._mssql_python_db_error return None + def get_adbc_database_error(self) -> Optional[type[Exception]]: + with self.module_load_lock: + if self._adbc_db_error is not None: + return self._adbc_db_error + if self.adbc_module is not None: + self._adbc_db_error = self.adbc_module.DatabaseError + return self._adbc_db_error + return None + def get_cached_access_token( self, cache_key: Any, @@ -201,6 +228,8 @@ def snapshot(self) -> SQLServerRuntimeSnapshot: pyodbc_import_error = self.pyodbc_import_error mssql_python_module = self.mssql_python_module mssql_python_import_error = self.mssql_python_import_error + adbc_module = self.adbc_module + adbc_import_error = self.adbc_import_error azure_credentials_module = self.azure_credentials_module azure_credentials_import_error = self.azure_credentials_import_error azure_identity_module = self.azure_identity_module @@ -215,6 +244,8 @@ def snapshot(self) -> SQLServerRuntimeSnapshot: pyodbc_import_error=pyodbc_import_error, mssql_python_module=mssql_python_module, mssql_python_import_error=mssql_python_import_error, + adbc_module=adbc_module, + adbc_import_error=adbc_import_error, azure_credentials_module=azure_credentials_module, azure_credentials_import_error=azure_credentials_import_error, azure_identity_module=azure_identity_module, @@ -230,6 +261,8 @@ def configure_for_test( pyodbc_import_error: Any = _UNSET, mssql_python_module: Any = _UNSET, mssql_python_import_error: Any = _UNSET, + adbc_module: Any = _UNSET, + adbc_import_error: Any = _UNSET, azure_credentials_module: Any = _UNSET, azure_credentials_import_error: Any = _UNSET, azure_identity_module: Any = _UNSET, @@ -248,6 +281,10 @@ def configure_for_test( self.mssql_python_module = mssql_python_module if mssql_python_import_error is not _UNSET: self.mssql_python_import_error = mssql_python_import_error + if adbc_module is not _UNSET: + self.adbc_module = adbc_module + if adbc_import_error is not _UNSET: + self.adbc_import_error = adbc_import_error if azure_credentials_module is not _UNSET: self.azure_credentials_module = azure_credentials_module if azure_credentials_import_error is not _UNSET: @@ -448,3 +485,44 @@ def _get_cached_access_token( cache_key = _access_token_cache_key(credentials, authentication, scope) return cast(AccessTokenProtocol, _RUNTIME_STATE.get_cached_access_token(cache_key, loader)) + + +def _missing_adbc_error() -> dbt_common.exceptions.DbtRuntimeError: + return dbt_common.exceptions.DbtRuntimeError( + "The `adbc` backend was requested, but the optional dependency " + "`adbc-driver-manager` is not installed. Install it with " + "`pip install adbc-driver-manager adbc-driver-mssql`. " + "Then set `backend: adbc` in your profile." + ) + + +def _get_adbc() -> AdbcModuleProtocol: + """Import and cache ADBC DBAPI module on first use. + + Expected Inputs: None. + Invariants: Thread-safe lazy import protected by module_load_lock. Raises + DbtRuntimeError if adbc-driver-manager is missing. + Integration: Provides the ADBC dbapi module to the connection manager. + """ + + with _RUNTIME_STATE.module_load_lock: + if _RUNTIME_STATE.adbc_module is not None: + return _RUNTIME_STATE.adbc_module + + if _RUNTIME_STATE.adbc_import_error is not None: + raise _missing_adbc_error() from _RUNTIME_STATE.adbc_import_error + + try: + # type: ignore[import] + import adbc_driver_manager.dbapi as imported_adbc + except ModuleNotFoundError as exc: + _RUNTIME_STATE.adbc_import_error = exc + raise _missing_adbc_error() from exc + + _RUNTIME_STATE.adbc_module = cast(AdbcModuleProtocol, imported_adbc) + return _RUNTIME_STATE.adbc_module + + +def get_adbc_database_error() -> Optional[type[Exception]]: + """Return the cached ADBC DatabaseError type for exception handling.""" + return _RUNTIME_STATE.get_adbc_database_error() diff --git a/devops/CI.Dockerfile b/devops/CI.Dockerfile index db148cf0..550072c5 100644 --- a/devops/CI.Dockerfile +++ b/devops/CI.Dockerfile @@ -50,6 +50,17 @@ RUN apt-get update && \ apt-get clean && \ rm -rf /var/lib/apt/lists/* +FROM base AS adbc + +# `dbc` is the driver-manager CLI for ADBC; it fetches the go-mssqldb-based +# `adbc-driver-mssql` binary (not on PyPI) that the adbc-driver-manager +# Python package talks to. See docs/adbc_backend.md. +ENV ACCEPT_EULA=Y +RUN pip install --no-cache-dir dbc && \ + dbc install mssql + +ENV ADBC_DRIVER_PATH="/root/.config/adbc/drivers" + FROM base AS msodbc17 # install ODBC driver 17 diff --git a/docs/adbc_backend.md b/docs/adbc_backend.md new file mode 100644 index 00000000..301ef880 --- /dev/null +++ b/docs/adbc_backend.md @@ -0,0 +1,212 @@ +# ADBC Backend (Experimental) + +dbt-sqlserver supports an **experimental ADBC backend** as an alternative to +pyodbc and mssql-python. ADBC (Arrow Database Connectivity) uses the +[Apache Arrow](https://arrow.apache.org/) columnar format natively, avoiding +the overhead of row-based ODBC/DB-API bridges. + +> **Experimental.** The ADBC backend is new and opt-in. It passes the full +> dbt-sqlserver functional test suite (320 passed, 0 failed, as of v1.12.0-rc1) +> and is covered by a dedicated CI job against the latest SQL Server. Please +> report issues at +> [dbt-msft/dbt-sqlserver#771](https://github.com/dbt-msft/dbt-sqlserver/issues/771). + +--- + +## 1. Install + +### 1.1 Python packages + +```bash +pip install dbt-sqlserver[adbc] +``` + +This pulls in: + +| Package | Purpose | +|-----------------------|----------------------------------------------| +| `adbc-driver-manager` | Python DB-API 2.0 wrapper for ADBC drivers | +| `pyarrow` | Apache Arrow columnar format (result buffer) | + +### 1.2 ADBC driver binary (one-time) + +The Python packages above provide the *client library*. You also need the +**driver binary** that speaks the SQL Server wire protocol. It is shipped by +[Columnar Technologies](https://columnar.com/) and is **not** on PyPI. + +Install it via the `dbc` CLI: + +```bash +# Install the dbc driver manager (one-time) +pip install dbc + +# Install the mssql driver +dbc install mssql +``` + +The driver lands at: + +``` +~/.config/adbc/drivers/mssql.toml # driver manifest +~/.config/adbc/drivers/mssql_linux_amd64_v1.5.0/ +``` + +Set the `ADBC_DRIVER_PATH` environment variable so the adapter can find it: + +```bash +export ADBC_DRIVER_PATH="$HOME/.config/adbc/drivers" +``` + +Add that line to your shell profile (`~/.bashrc`, `~/.zshrc`) so every new +terminal picks it up automatically. + +### 1.3 Verify + +```bash +dbc list +# DRIVER VERSION LEVEL LOCATION +# mssql 1.5.0 user /home/…/.config/adbc/drivers +``` + +--- + +## 2. Configure your profile + +Add `backend: adbc` to your dbt profile (`~/.dbt/profiles.yml`): + +```yaml +your_profile: + target: dev + outputs: + dev: + type: sqlserver + backend: adbc # <-- opt-in + host: localhost + port: 1433 + user: SA + password: YourPassword + database: TestDB + schema: dbo + encrypt: true + trust_cert: true + retries: 2 +``` + +The ``driver`` field (ODBC Driver 18 / mssql-python) is **not used** by the +ADBC backend and may be omitted. + +The adapter constructs a **go-mssqldb URI** internally: + +``` +sqlserver://SA:YourPassword@localhost:1433?database=TestDB&encrypt=true&TrustServerCertificate=true +``` + +--- + +## 3. Authentication + +The ADBC backend currently supports **SQL Server authentication** (user + +password) only. + +| Authentication mode | Supported? | +|------------------------------|-------------------------------| +| SQL Server (`user`/`password`) | Yes | +| Azure Active Directory | No (returns a clear error) | +| Windows Integrated | No (returns a clear error) | +| Service Principal | No (returns a clear error) | + +Azure AD and Windows auth are planned. Track progress in +[#771](https://github.com/dbt-msft/dbt-sqlserver/issues/771). + +--- + +## 4. How it works under the hood + +The ADBC backend follows the same connection lifecycle as pyodbc and +mssql-python: + +``` +profiles.yml → build_adbc_connection_uri() + ↓ + _connect_adbc() + ↓ + adbc_driver_manager.dbapi.connect( + driver="mssql", + db_kwargs={"uri": "sqlserver://…"}, + autocommit=True, + ) + ↓ + adbc_driver_manager → libadbc_driver_mssql.so + ↓ + go-mssqldb (Microsoft's TDS client) + ↓ + SQL Server +``` + +Key design decisions: + +- **Transactions via explicit SQL.** ADBC connections are opened with + `autocommit=True` so DDL statements (`CREATE SCHEMA`, `CREATE TABLE`) + auto-commit without extra round-trips. When + `dbt_sqlserver_use_dbt_transactions: true` (the default since 1.12), + dbt emits explicit `BEGIN TRANSACTION` / `COMMIT TRANSACTION` SQL + which correctly nest on top of the autocommit session, giving each model + its own transaction boundary. This is the same strategy pyodbc uses. + +- **`?` → `@pN` parameter conversion.** go-mssqldb does not understand PEP + 249 `qmark` (`?`) placeholders. The adapter automatically rewrites them to + T-SQL named parameters (`@p1`, `@p2`, …) at query time, so all dbt + operations -- including seed CSV loading -- work without changes. + +- **Arrow-native types in cursor descriptions.** The ADBC driver returns + PyArrow `DataType` objects (e.g. `DataType(int32)`, `TimestampType(…)`) + instead of the integer type codes that pyodbc emits. The adapter maps these + to the same SQL Server type names used by the other backends. + +- **Timezones.** SQL Server `DATETIME2` / `DATETIME` do *not* store a + timezone offset. The ADBC driver wraps them as UTC by convention, but the + adapter strips the `tzinfo` attribute so downstream code sees naive + `datetime` objects -- identical to pyodbc and mssql-python. + +--- + +## 5. Known differences from pyodbc / mssql-python + +| Area | pyodbc / mssql-python | ADBC | +|-------------------|----------------------------------|-------------------------------| +| Connection URI | ODBC connection string | go-mssqldb URI | +| Driver install | OS package / pip wheel | `dbc install mssql` | +| Type system | ODBC SQL type codes | Arrow `DataType` objects | +| Null handling | Native DB-API `None` | Arrow null bitmap → `None` | +| Azure AD | Fully supported | Not yet (clear error) | +| Windows auth | Fully supported | Not yet (clear error) | +| `autocommit` attr | Yes | No (always autocommit) | +| `cursor.nextset` | Supported | Not supported (drained safely)| + +--- + +## 6. Troubleshooting + +### "adbc-driver-manager is not installed" + +```bash +pip install "dbt-sqlserver[adbc]" +``` + +### "Could not load `mssql`" / driver not found + +```bash +# Verify the driver is installed +dbc list + +# Set the search path +export ADBC_DRIVER_PATH="$HOME/.config/adbc/drivers" +``` + +--- + +## 7. Feedback + +This is an experimental feature. We welcome bug reports, performance +benchmarks, and feature requests at +[Issues](https://github.com/dbt-msft/dbt-sqlserver/issues). diff --git a/pyproject.toml b/pyproject.toml index 624de423..ddfee033 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,7 +31,8 @@ dependencies = [ "dbt-core>=1.12.0,<2.0", "dbt-common>=1.22.0,<2.0", "dbt-adapters>=1.24.5,<2.0", - "pyodbc>=5.2.0", + # pyodbc, mssql-python, adbc-driver-manager + # are listed under [project.optional-dependencies] ] dynamic = ["version"] @@ -44,8 +45,12 @@ pyodbc = [ ] mssql = [ "mssql-python>=1.7.1", - "azure-identity>=1.12.0", - "azure-core>=1.0.0", +] +adbc = [ + "adbc-driver-manager>=1.0.0", + "pyarrow>=14.0.0", + # The driver binary itself + # (adbc-driver-mssql) is not on PyPI -- install it via `dbc`. ] [dependency-groups] @@ -54,6 +59,8 @@ dev = [ "azure-identity>=1.12.0", "pyodbc>=5.2.0", "mssql-python>=1.7.1", + "adbc-driver-manager>=1.0.0", + "pyarrow>=14.0.0", "build", "bumpversion", "flaky", diff --git a/test.env.sample b/test.env.sample index 10502ea6..63fe3aa6 100644 --- a/test.env.sample +++ b/test.env.sample @@ -7,6 +7,7 @@ SQLSERVER_TEST_DBNAME=TestDB SQLSERVER_TEST_ENCRYPT=True SQLSERVER_TEST_TRUST_CERT=True # SQLSERVER_TEST_BACKEND=pyodbc +# SQLSERVER_TEST_BACKEND=adbc SQLSERVER_TEST_BACKEND=mssql-python DBT_TEST_USER_1=DBT_TEST_USER_1 DBT_TEST_USER_2=DBT_TEST_USER_2 diff --git a/tests/unit/adapters/mssql/test_adbc_backend.py b/tests/unit/adapters/mssql/test_adbc_backend.py new file mode 100644 index 00000000..9bcdb173 --- /dev/null +++ b/tests/unit/adapters/mssql/test_adbc_backend.py @@ -0,0 +1,979 @@ +"""Unit tests for the ADBC backend integration. + +Covers: +- Runtime-state lazy import, caching, and lifecycle. +- ``build_adbc_connection_uri`` URI construction. +- ``validate_adbc_requirements`` backend-specific preflight. +- Connection-manager ``open`` dispatch and state updates. +- ``exception_handler`` routing for ADBC ``DatabaseError``. +- ``is_pyodbc_handle`` recognition of ADBC handles. +- ``get_adbc_retryable_exceptions`` return type. +""" + +import builtins +from types import SimpleNamespace +from typing import Any, Dict + +import pytest +from dbt_common.exceptions import DbtDatabaseError, DbtRuntimeError + +from dbt.adapters.contracts.connection import Connection, ConnectionState +from dbt.adapters.sqlserver import sqlserver_connections +from dbt.adapters.sqlserver.sqlserver_auth import is_adbc_backend +from dbt.adapters.sqlserver.sqlserver_backend import ( + build_adbc_connection_uri, + get_adbc_retryable_exceptions, +) +from dbt.adapters.sqlserver.sqlserver_backend import ( + is_pyodbc_handle as _is_pyodbc_handle, +) +from dbt.adapters.sqlserver.sqlserver_connections import ( + SQLServerConnectionManager, + _get_adbc_rowcount, + _replace_qmark_with_at_pn, + _split_sql_statements, + _sql_affects_rows, + _try_drain_nextset, +) +from dbt.adapters.sqlserver.sqlserver_credentials import ( + SQLServerBackend, + SQLServerCredentials, +) +from dbt.adapters.sqlserver.sqlserver_helpers import validate_adbc_requirements +from dbt.adapters.sqlserver.sqlserver_runtime import ( + _get_adbc, + configure_runtime_state_for_test, + get_adbc_database_error, + get_runtime_state_for_test, + reset_runtime_state_for_test, +) + +# --------------------------------------------------------------------------- +# helpers +# --------------------------------------------------------------------------- + + +def _fake_adbc_module(connect=None): + """Create a fake ``adbc_driver_manager.dbapi`` namespace for testing.""" + + if connect is None: + + def connect(driver, db_kwargs, autocommit): + return _FakeADBCHandle() + + return SimpleNamespace( + connect=connect, + DatabaseError=type("FakeADBCDatabaseError", (Exception,), {}), + InternalError=type("FakeADBCInternalError", (Exception,), {}), + OperationalError=type("FakeADBCOperationalError", (Exception,), {}), + InterfaceError=type("FakeADBCInterfaceError", (Exception,), {}), + ) + + +class _FakeADBCHandle: + """Minimal handle standing in for an ADBC connection.""" + + def __init__(self): + self.timeout = None + self.autocommit = True + + def cursor(self): + return SimpleNamespace(execute=lambda sql: None, close=lambda: None) + + def close(self): + pass + + +def _fake_retry_connection_stub( + captured: Dict[str, Any] | None = None, +): + def fake_retry_connection( + cls, + connection, + connect, + logger, + retry_limit, + retryable_exceptions, + ): + if captured is not None: + captured["retry_limit"] = retry_limit + captured["retryable_exceptions"] = retryable_exceptions + handle = connect() + connection.handle = handle + connection.state = ConnectionState.OPEN + return connection + + return fake_retry_connection + + +@pytest.fixture +def adbc_credentials() -> SQLServerCredentials: + """Credentials pre-configured for the ADBC backend.""" + return SQLServerCredentials( + backend=SQLServerBackend.adbc, + host="fake.sql.sqlserver.net", + database="dbt", + schema="sqlserver", + encrypt=True, + trust_cert=False, + authentication="sql", + UID="dbt_user", + PWD="super-secret", + ) + + +# ============================================================================ +# 1. Runtime-state lazy-import tests +# ============================================================================ + + +def test_get_adbc_caches_module() -> None: + """``_get_adbc()`` returns the same cached module on repeated calls.""" + fake_adbc = SimpleNamespace(name="cached-adbc") + reset_runtime_state_for_test() + configure_runtime_state_for_test(adbc_module=fake_adbc, adbc_import_error=None) + + first = _get_adbc() + second = _get_adbc() + + assert first is fake_adbc + assert second is fake_adbc + assert first is second + + +def test_get_adbc_raises_when_missing(monkeypatch: pytest.MonkeyPatch) -> None: + """``_get_adbc()`` raises DbtRuntimeError when adbc-driver-manager is missing.""" + reset_runtime_state_for_test() + original_import = builtins.__import__ + + def missing_adbc(name, globals=None, locals=None, fromlist=(), level=0): + if name == "adbc_driver_manager": + raise ModuleNotFoundError("No module named 'adbc_driver_manager'") + return original_import(name, globals, locals, fromlist, level) + + monkeypatch.setattr(builtins, "__import__", missing_adbc) + + with pytest.raises(DbtRuntimeError, match="adbc"): + _get_adbc() + + +def test_adbc_runtime_state_reset_clears_fields() -> None: + """``reset_runtime_state_for_test()`` clears adbc_module and adbc_import_error.""" + configure_runtime_state_for_test( + adbc_module=SimpleNamespace(name="cached"), + adbc_import_error=ModuleNotFoundError("boom"), + ) + + reset_runtime_state_for_test() + + snapshot = get_runtime_state_for_test() + assert snapshot.adbc_module is None + assert snapshot.adbc_import_error is None + + +def test_adbc_runtime_state_snapshot_captures_fields() -> None: + """``get_runtime_state_for_test()`` snapshot captures adbc_module and error.""" + fake_module = SimpleNamespace(name="snap-module") + fake_error = ModuleNotFoundError("no adbc") + + reset_runtime_state_for_test() + configure_runtime_state_for_test( + adbc_module=fake_module, + adbc_import_error=fake_error, + ) + + snapshot = get_runtime_state_for_test() + assert snapshot.adbc_module is fake_module + assert snapshot.adbc_import_error is fake_error + + +def test_configure_for_test_accepts_adbc_module() -> None: + """``configure_runtime_state_for_test(adbc_module=...)`` sets the field.""" + reset_runtime_state_for_test() + fake_module = SimpleNamespace(name="test-module") + + configure_runtime_state_for_test(adbc_module=fake_module) + + snapshot = get_runtime_state_for_test() + assert snapshot.adbc_module is fake_module + + +def test_get_adbc_database_error_returns_none_when_not_loaded() -> None: + """``get_adbc_database_error()`` returns None before the module is loaded.""" + reset_runtime_state_for_test() + + assert get_adbc_database_error() is None + + +def test_get_adbc_database_error_returns_type_after_import() -> None: + """``get_adbc_database_error()`` returns the DatabaseError class after import.""" + fake_module = _fake_adbc_module() + reset_runtime_state_for_test() + configure_runtime_state_for_test(adbc_module=fake_module, adbc_import_error=None) + + db_error = get_adbc_database_error() + + assert db_error is fake_module.DatabaseError + + +# ============================================================================ +# 2. URI construction tests +# ============================================================================ + + +def test_build_adbc_uri_sql_auth(adbc_credentials: SQLServerCredentials) -> None: + """ADBC URI contains user, host, port, database, encrypt, TrustServerCertificate.""" + uri = build_adbc_connection_uri(adbc_credentials) + + assert uri.startswith("sqlserver://") + assert "dbt_user:super-secret" in uri + assert "@fake.sql.sqlserver.net:1433" in uri + assert "database=dbt" in uri + assert "encrypt=true" in uri + assert "TrustServerCertificate=false" in uri + + +def test_build_adbc_uri_no_port_defaults_1433() -> None: + """When port is falsy the URI defaults to 1433.""" + credentials = SQLServerCredentials( + backend=SQLServerBackend.adbc, + host="fake.sql.sqlserver.net", + database="dbt", + schema="sqlserver", + port=0, + encrypt=True, + UID="user", + PWD="pass", + ) + + uri = build_adbc_connection_uri(credentials) + + assert ":1433" in uri + + +@pytest.mark.parametrize( + "password, expected_encoded", + [ + ("p@ss", "p%40ss"), + ("a:b", "a%3Ab"), + ("with/slash", "with%2Fslash"), + ("q?uery", "q%3Fuery"), + ("hash#tag", "hash%23tag"), + ], +) +def test_build_adbc_uri_encodes_special_password_chars( + password: str, expected_encoded: str +) -> None: + """Passwords with URI-significant chars are percent-encoded.""" + credentials = SQLServerCredentials( + backend=SQLServerBackend.adbc, + host="fake.sql.sqlserver.net", + database="dbt", + schema="sqlserver", + UID="user", + PWD=password, + ) + + uri = build_adbc_connection_uri(credentials) + + assert f":{expected_encoded}@" in uri + + +def test_build_adbc_uri_login_timeout() -> None: + """login_timeout > 0 appends ``connection timeout=N`` to the query string.""" + credentials = SQLServerCredentials( + backend=SQLServerBackend.adbc, + host="fake.sql.sqlserver.net", + database="dbt", + schema="sqlserver", + login_timeout=30, + UID="user", + PWD="pass", + ) + + uri = build_adbc_connection_uri(credentials) + + assert "connection timeout=30" in uri + + +def test_build_adbc_uri_omits_login_timeout_when_zero() -> None: + """login_timeout=0 must not appear in the query string.""" + credentials = SQLServerCredentials( + backend=SQLServerBackend.adbc, + host="fake.sql.sqlserver.net", + database="dbt", + schema="sqlserver", + login_timeout=0, + UID="user", + PWD="pass", + ) + + uri = build_adbc_connection_uri(credentials) + + assert "connection timeout" not in uri.lower() + + +# ============================================================================ +# 3. Validation tests +# ============================================================================ + + +@pytest.mark.parametrize( + "authentication", + [ + "ActiveDirectoryPassword", + "ActiveDirectoryMSI", + "ActiveDirectoryDefault", + "ActiveDirectoryIntegrated", + "ActiveDirectoryInteractive", + "ActiveDirectoryDeviceCode", + "ActiveDirectoryServicePrincipal", + "serviceprincipal", + "msi", + "auto", + "default", + ], +) +def test_validate_adbc_rejects_active_directory_auth( + authentication: str, +) -> None: + """ADBC must reject all ActiveDirectory-based authentication modes.""" + credentials = SQLServerCredentials( + backend=SQLServerBackend.adbc, + host="fake.sql.sqlserver.net", + database="dbt", + schema="sqlserver", + authentication=authentication, + UID="dbt_user", + PWD="super-secret", + encrypt=True, + trust_cert=True, + ) + + with pytest.raises(DbtRuntimeError, match="AD"): + validate_adbc_requirements(credentials) + + +def test_validate_adbc_rejects_active_directory_access_token() -> None: + """'ActiveDirectoryAccessToken' is also an ActiveDirectory mode and must be rejected.""" + credentials = SQLServerCredentials( + backend=SQLServerBackend.adbc, + host="fake.sql.sqlserver.net", + database="dbt", + schema="sqlserver", + authentication="ActiveDirectoryAccessToken", + UID="dbt_user", + PWD="super-secret", + encrypt=True, + trust_cert=True, + ) + + with pytest.raises(DbtRuntimeError, match="AD"): + validate_adbc_requirements(credentials) + + +def test_validate_adbc_rejects_windows_login() -> None: + """ADBC must reject windows_login=True.""" + credentials = SQLServerCredentials( + backend=SQLServerBackend.adbc, + host="fake.sql.sqlserver.net", + database="dbt", + schema="sqlserver", + windows_login=True, + authentication="sql", + UID="dbt_user", + PWD="super-secret", + encrypt=True, + trust_cert=True, + ) + + with pytest.raises(DbtRuntimeError, match="Windows login"): + validate_adbc_requirements(credentials) + + +def test_validate_adbc_passes_sql_auth() -> None: + """SQL authentication with UID and PWD must pass validation.""" + credentials = SQLServerCredentials( + backend=SQLServerBackend.adbc, + host="fake.sql.sqlserver.net", + database="dbt", + schema="sqlserver", + authentication="sql", + UID="dbt_user", + PWD="super-secret", + encrypt=True, + trust_cert=True, + ) + + # Must not raise. + validate_adbc_requirements(credentials) + + +def test_validate_adbc_passes_default_sql_auth() -> None: + """When authentication is left at the default ('sql') it must pass.""" + credentials = SQLServerCredentials( + backend=SQLServerBackend.adbc, + host="fake.sql.sqlserver.net", + database="dbt", + schema="sqlserver", + UID="dbt_user", + PWD="super-secret", + encrypt=True, + trust_cert=True, + ) + + # authentication defaults to "sql" + assert credentials.authentication == "sql" + + validate_adbc_requirements(credentials) + + +def test_validate_adbc_requires_uid_and_pwd() -> None: + """Missing UID or PWD must raise a clear error.""" + missing_uid = SQLServerCredentials( + backend=SQLServerBackend.adbc, + host="fake.sql.sqlserver.net", + database="dbt", + schema="sqlserver", + PWD="super-secret", + encrypt=True, + trust_cert=True, + ) + missing_pwd = SQLServerCredentials( + backend=SQLServerBackend.adbc, + host="fake.sql.sqlserver.net", + database="dbt", + schema="sqlserver", + UID="dbt_user", + encrypt=True, + trust_cert=True, + ) + + with pytest.raises(DbtRuntimeError, match="requires a user"): + validate_adbc_requirements(missing_uid) + + with pytest.raises(DbtRuntimeError, match="requires a user"): + validate_adbc_requirements(missing_pwd) + + +def test_validate_adbc_logs_warning_for_query_timeout() -> None: + """query_timeout > 0 triggers a logged warning (but does not raise).""" + credentials = SQLServerCredentials( + backend=SQLServerBackend.adbc, + host="fake.sql.sqlserver.net", + database="dbt", + schema="sqlserver", + UID="dbt_user", + PWD="super-secret", + encrypt=True, + trust_cert=True, + query_timeout=30, + ) + + # Must not raise. + validate_adbc_requirements(credentials) + + +# ============================================================================ +# 4. Connection manager dispatch tests +# ============================================================================ + + +def test_open_with_adbc_backend_calls_connect_with_uri( + adbc_credentials: SQLServerCredentials, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """``open()`` for ADBC backend must invoke the fake module's ``connect``.""" + captured: Dict[str, Any] = {} + + def fake_connect(driver, db_kwargs, autocommit): + captured["driver"] = driver + captured["db_kwargs"] = db_kwargs + captured["autocommit"] = autocommit + return _FakeADBCHandle() + + fake_module = _fake_adbc_module(fake_connect) + + reset_runtime_state_for_test() + configure_runtime_state_for_test(adbc_module=fake_module, adbc_import_error=None) + monkeypatch.setattr( + SQLServerConnectionManager, + "retry_connection", + classmethod(_fake_retry_connection_stub()), + ) + + connection = Connection(type="sqlserver", name="adbc-test", credentials=adbc_credentials) + opened = SQLServerConnectionManager.open(connection) + + assert opened is connection + assert opened.state == ConnectionState.OPEN + assert captured["driver"] == "mssql" + assert captured["autocommit"] is True + uri = captured["db_kwargs"]["uri"] + assert uri.startswith("sqlserver://") + assert "dbt_user:super-secret" in uri + + +def test_open_with_adbc_backend_updates_state_to_OPEN( + adbc_credentials: SQLServerCredentials, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """After ``open()`` the connection state must be OPEN.""" + fake_module = _fake_adbc_module() + + reset_runtime_state_for_test() + configure_runtime_state_for_test(adbc_module=fake_module, adbc_import_error=None) + monkeypatch.setattr( + SQLServerConnectionManager, + "retry_connection", + classmethod(_fake_retry_connection_stub()), + ) + + connection = Connection(type="sqlserver", name="adbc-state-test", credentials=adbc_credentials) + assert connection.state == ConnectionState.INIT + + opened = SQLServerConnectionManager.open(connection) + + assert opened.state == ConnectionState.OPEN + + +def test_is_adbc_backend_returns_true() -> None: + """``is_adbc_backend`` returns True for ``SQLServerBackend.adbc``.""" + assert is_adbc_backend(SQLServerBackend.adbc) is True + + +def test_is_adbc_backend_returns_false_for_pyodbc() -> None: + """``is_adbc_backend`` returns False for ``SQLServerBackend.pyodbc``.""" + assert is_adbc_backend(SQLServerBackend.pyodbc) is False + + +def test_is_adbc_backend_returns_false_for_mssql_python() -> None: + """``is_adbc_backend`` returns False for ``SQLServerBackend.mssql_python``.""" + assert is_adbc_backend(SQLServerBackend.mssql_python) is False + + +# ============================================================================ +# 5. Exception handler tests +# ============================================================================ + + +def test_exception_handler_routes_adbc_database_errors( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """ADBC ``DatabaseError`` is caught and re-raised as ``DbtDatabaseError``.""" + fake_module = _fake_adbc_module() + error_cls = fake_module.DatabaseError + + reset_runtime_state_for_test() + configure_runtime_state_for_test(adbc_module=fake_module, adbc_import_error=None) + + manager = object.__new__(SQLServerConnectionManager) + credentials = SQLServerCredentials( + backend=SQLServerBackend.adbc, + host="fake.sql.sqlserver.net", + database="dbt", + schema="sqlserver", + encrypt=True, + trust_cert=True, + UID="dbt_user", + PWD="super-secret", + ) + release_calls: list[int] = [] + handler_calls: list[tuple[str, str]] = [] + debug_messages: list[str] = [] + + try: + monkeypatch.setattr( + manager, + "get_thread_connection", + lambda: SimpleNamespace(credentials=credentials), + ) + monkeypatch.setattr(manager, "release", lambda: release_calls.append(1)) + + def fake_handle_backend_database_error( + error: Exception, + database_error: type[Exception] | None, + release_connection: Any, + ) -> None: + handler_calls.append( + ( + type(error).__name__, + database_error.__name__ if database_error else "", + ) + ) + release_connection() + raise DbtDatabaseError(str(error).strip()) from error + + monkeypatch.setattr( + sqlserver_connections, + "handle_backend_database_error", + fake_handle_backend_database_error, + ) + monkeypatch.setattr( + sqlserver_connections.logger, + "debug", + lambda message, *args: debug_messages.append(message % args if args else message), + ) + + with pytest.raises(DbtDatabaseError, match="ADBC error"): + with manager.exception_handler("select 1"): + raise error_cls("ADBC error") + + assert handler_calls == [(error_cls.__name__, error_cls.__name__)] + assert release_calls == [1] + assert all("Rolling back transaction." not in message for message in debug_messages) + assert all("Error running SQL:" not in message for message in debug_messages) + finally: + reset_runtime_state_for_test() + + +# ============================================================================ +# 6. Handle detection tests +# ============================================================================ + + +def test_is_pyodbc_handle_false_for_adbc_handle() -> None: + """An ADBC connection handle must not be identified as pyodbc.""" + handle = type("AdbcConnection", (), {"__module__": "adbc_driver_manager._lib"})() + assert _is_pyodbc_handle(handle) is False + + +def test_is_pyodbc_handle_false_for_adbc_class_name() -> None: + """A handle whose class name contains 'adbc' is not a pyodbc handle.""" + handle = type("AdbcThing", (), {"__module__": "something.else"})() + assert _is_pyodbc_handle(handle) is False + + +def test_is_pyodbc_handle_false_for_adbc_class_name_case_insensitive() -> None: + """A handle class whose module contains 'ADBC' (case-insensitive) is rejected.""" + handle = type("SomeHandle", (), {"__module__": "adbc_driver_manager._lib"})() + assert _is_pyodbc_handle(handle) is False + + +# ============================================================================ +# 7. Retryable exceptions tests +# ============================================================================ + + +def test_get_adbc_retryable_exceptions_returns_tuple() -> None: + """``get_adbc_retryable_exceptions`` returns a tuple of exception types.""" + fake_module = _fake_adbc_module() + reset_runtime_state_for_test() + configure_runtime_state_for_test(adbc_module=fake_module, adbc_import_error=None) + + retryable = get_adbc_retryable_exceptions() + + assert isinstance(retryable, tuple) + assert fake_module.InternalError in retryable + assert fake_module.OperationalError in retryable + + +def test_retryable_exceptions_excludes_database_error() -> None: + """DatabaseError is not a retryable exception for ADBC.""" + fake_module = _fake_adbc_module() + reset_runtime_state_for_test() + configure_runtime_state_for_test(adbc_module=fake_module, adbc_import_error=None) + + retryable = get_adbc_retryable_exceptions() + + assert fake_module.DatabaseError not in retryable + + +def test_retryable_exceptions_excludes_interface_error() -> None: + """InterfaceError is not a retryable exception for ADBC.""" + fake_module = _fake_adbc_module() + reset_runtime_state_for_test() + configure_runtime_state_for_test(adbc_module=fake_module, adbc_import_error=None) + + retryable = get_adbc_retryable_exceptions() + + assert fake_module.InterfaceError not in retryable + + +# ============================================================================ +# 8. ``?`` -> ``@pN`` placeholder substitution +# ============================================================================ + + +def test_replace_qmark_with_at_pn_basic() -> None: + """Plain placeholders are replaced positionally.""" + sql = "INSERT INTO t VALUES (?, ?, ?)" + assert _replace_qmark_with_at_pn(sql, 3) == "INSERT INTO t VALUES (@p1, @p2, @p3)" + + +def test_replace_qmark_with_at_pn_ignores_bracket_quoted_identifier() -> None: + """A literal '?' inside a bracket-quoted identifier is not a placeholder.""" + sql = "INSERT INTO [dbo].[t] ([satisfied?]) VALUES (?)" + result = _replace_qmark_with_at_pn(sql, 1) + assert result == "INSERT INTO [dbo].[t] ([satisfied?]) VALUES (@p1)" + + +def test_replace_qmark_with_at_pn_ignores_string_literal() -> None: + """A literal '?' inside a single-quoted string literal is not a placeholder.""" + sql = "INSERT INTO t (note, val) VALUES ('are you sure?', ?)" + result = _replace_qmark_with_at_pn(sql, 1) + assert result == "INSERT INTO t (note, val) VALUES ('are you sure?', @p1)" + + +def test_replace_qmark_with_at_pn_handles_escaped_quotes() -> None: + """Doubled '' and ]] escapes inside literals/identifiers are handled.""" + sql = "INSERT INTO [a]]b] (col) VALUES ('it''s a ?')" + # num_bindings=0: there is no real placeholder in this statement, so the + # literal '?' inside the escaped string must be left untouched. + result = _replace_qmark_with_at_pn(sql, 0) + assert result == sql + + +def test_replace_qmark_with_at_pn_multiple_bindings_after_quoted_qmark() -> None: + """Bindings after a quoted '?' still line up correctly.""" + sql = "INSERT INTO [satisfied?] (a, b) VALUES (?, ?)" + result = _replace_qmark_with_at_pn(sql, 2) + assert result == "INSERT INTO [satisfied?] (a, b) VALUES (@p1, @p2)" + + +# ============================================================================ +# 9. ``_try_drain_nextset`` ADBC NotSupportedError handling +# ============================================================================ + + +def test_try_drain_nextset_returns_true_when_supported() -> None: + """A cursor that supports nextset() and has more results returns True.""" + cursor = SimpleNamespace(nextset=lambda: True) + assert _try_drain_nextset(cursor) is True + + +def test_try_drain_nextset_returns_false_when_no_more_results() -> None: + """A cursor that supports nextset() but has no more results returns False.""" + cursor = SimpleNamespace(nextset=lambda: False) + assert _try_drain_nextset(cursor) is False + + +def test_try_drain_nextset_swallows_adbc_not_supported_error() -> None: + """An ADBC NotSupportedError (module-scoped) is treated as 'no more results'.""" + + def raise_not_supported(): + error_cls = type("NotSupportedError", (Exception,), {}) + error_cls.__module__ = "adbc_driver_manager" + raise error_cls("Cursor.nextset") + + cursor = SimpleNamespace(nextset=raise_not_supported) + assert _try_drain_nextset(cursor) is False + + +def test_try_drain_nextset_swallows_regardless_of_message_text() -> None: + """The check no longer depends on the exception message containing 'nextset'.""" + + def raise_not_supported(): + error_cls = type("NotSupportedError", (Exception,), {}) + error_cls.__module__ = "adbc_driver_manager" + raise error_cls("some future wording that says nothing about the method") + + cursor = SimpleNamespace(nextset=raise_not_supported) + assert _try_drain_nextset(cursor) is False + + +def test_try_drain_nextset_reraises_not_supported_from_other_module() -> None: + """A same-named exception from an unrelated module must not be swallowed.""" + + def raise_unrelated(): + error_cls = type("NotSupportedError", (Exception,), {}) + error_cls.__module__ = "some_other_driver" + raise error_cls("nextset") + + cursor = SimpleNamespace(nextset=raise_unrelated) + with pytest.raises(Exception, match="nextset"): + _try_drain_nextset(cursor) + + +def test_try_drain_nextset_reraises_unrelated_errors() -> None: + """Errors unrelated to nextset support must propagate.""" + + def raise_other(): + raise RuntimeError("connection lost") + + cursor = SimpleNamespace(nextset=raise_other) + with pytest.raises(RuntimeError, match="connection lost"): + _try_drain_nextset(cursor) + + +# ============================================================================ +# 10. ``_get_adbc_rowcount`` +# ============================================================================ + + +class _FakeRowcountCursor: + def __init__(self, row): + self._row = row + self.closed = False + + def execute(self, sql): + pass + + def fetchone(self): + return self._row + + def close(self): + self.closed = True + + +def test_get_adbc_rowcount_returns_value() -> None: + """A valid non-negative @@ROWCOUNT is returned as an int.""" + rc_cursor = _FakeRowcountCursor((3,)) + handle = SimpleNamespace(cursor=lambda: rc_cursor) + + assert _get_adbc_rowcount(handle) == 3 + assert rc_cursor.closed is True + + +def test_get_adbc_rowcount_returns_zero_for_none_row() -> None: + """No row returned falls back to 0.""" + rc_cursor = _FakeRowcountCursor(None) + handle = SimpleNamespace(cursor=lambda: rc_cursor) + + assert _get_adbc_rowcount(handle) == 0 + + +def test_get_adbc_rowcount_returns_zero_on_exception() -> None: + """Any failure opening/executing the rowcount cursor is swallowed.""" + + def cursor(): + raise RuntimeError("no MARS") + + handle = SimpleNamespace(cursor=cursor) + + assert _get_adbc_rowcount(handle) == 0 + + +def test_get_adbc_rowcount_closes_cursor_even_on_fetch_error() -> None: + """The temporary cursor is closed even if fetchone() raises.""" + + class _RaisingCursor: + def __init__(self): + self.closed = False + + def execute(self, sql): + pass + + def fetchone(self): + raise RuntimeError("boom") + + def close(self): + self.closed = True + + rc_cursor = _RaisingCursor() + handle = SimpleNamespace(cursor=lambda: rc_cursor) + + assert _get_adbc_rowcount(handle) == 0 + assert rc_cursor.closed is True + + +# ============================================================================ +# 11. ``_sql_affects_rows`` DML detection +# ============================================================================ + + +@pytest.mark.parametrize( + "sql", + [ + "INSERT INTO t VALUES (1)", + " update t set a = 1", + "DELETE FROM t WHERE a = 1", + "merge into t using src on t.a = src.a when matched then update set a = 1;", + "/* dbt query comment */\nINSERT INTO t VALUES (1)", + "-- a leading line comment\nUPDATE t SET a = 1", + "/* multi */ /* comment */ DELETE FROM t", + # Regression: sqlserver__get_delete_insert_merge_sql emits + # "SET NOCOUNT ON; delete ...; SET NOCOUNT OFF; insert ..." as one + # batch when unique_key is set -- the DML is not the first + # top-level statement. + "SET NOCOUNT ON;\ndelete from t where exists (select null)\nSET NOCOUNT OFF;", + "SET NOCOUNT ON;\ninsert into t (a) (select a from s)", + ], +) +def test_sql_affects_rows_true_for_dml(sql: str) -> None: + assert _sql_affects_rows(sql) is True + + +@pytest.mark.parametrize( + "sql", + [ + "SELECT * FROM t", + "CREATE TABLE t (a int)", + "DROP TABLE t", + "BEGIN TRANSACTION", + "IF @@TRANCOUNT > 0 COMMIT TRANSACTION", + "/* dbt query comment */\nSELECT 1", + ], +) +def test_sql_affects_rows_false_for_non_dml(sql: str) -> None: + assert _sql_affects_rows(sql) is False + + +def test_split_sql_statements_basic() -> None: + assert list(_split_sql_statements("a; b; c")) == ["a", " b", " c"] + + +def test_split_sql_statements_ignores_semicolon_in_string_literal() -> None: + sql = "insert into t (a) values ('x;y'); select 1" + parts = list(_split_sql_statements(sql)) + assert parts[0] == "insert into t (a) values ('x;y')" + assert parts[1] == " select 1" + + +def test_split_sql_statements_ignores_semicolon_in_bracket_identifier() -> None: + sql = "select [a;b] from t; select 1" + parts = list(_split_sql_statements(sql)) + assert parts[0] == "select [a;b] from t" + assert parts[1] == " select 1" + + +def test_split_sql_statements_no_separator_returns_whole_string() -> None: + assert list(_split_sql_statements("select 1")) == ["select 1"] + + +# ============================================================================ +# 12. ``get_response`` rowcount clamping +# ============================================================================ + + +def test_get_response_uses_stashed_rowcount_for_adbc_cursor() -> None: + """An ADBC cursor's -1 rowcount is replaced by the stashed @@ROWCOUNT value.""" + cursor_cls = type("Cursor", (), {}) + cursor_cls.__module__ = "adbc_driver_manager.dbapi" + cursor = cursor_cls() + cursor.rowcount = -1 + setattr(cursor, "__dbt_sqlserver_adbc_rowcount", 5) + + response = SQLServerConnectionManager.get_response(cursor) + + assert response.rows_affected == 5 + + +def test_get_response_falls_back_to_negative_one_when_not_stashed() -> None: + """An ADBC cursor without a stashed value keeps -1 (unknown), not 0.""" + cursor_cls = type("Cursor", (), {}) + cursor_cls.__module__ = "adbc_driver_manager.dbapi" + cursor = cursor_cls() + cursor.rowcount = -1 + + response = SQLServerConnectionManager.get_response(cursor) + + assert response.rows_affected == -1 + + +def test_get_response_does_not_clamp_non_adbc_negative_rowcount() -> None: + """pyodbc/mssql-python's -1 (unknown, e.g. for SELECT) passes through unchanged.""" + cursor = SimpleNamespace(rowcount=-1) + + response = SQLServerConnectionManager.get_response(cursor) + + assert response.rows_affected == -1 + + +def test_get_response_passes_through_positive_rowcount() -> None: + """A normal positive rowcount (any backend) is untouched.""" + cursor = SimpleNamespace(rowcount=7) + + response = SQLServerConnectionManager.get_response(cursor) + + assert response.rows_affected == 7 diff --git a/tests/unit/adapters/mssql/test_sqlserver_connection_manager.py b/tests/unit/adapters/mssql/test_sqlserver_connection_manager.py index 100a0468..206c31e4 100644 --- a/tests/unit/adapters/mssql/test_sqlserver_connection_manager.py +++ b/tests/unit/adapters/mssql/test_sqlserver_connection_manager.py @@ -265,6 +265,48 @@ def test_connection_keys_do_not_mutate_authentication() -> None: assert credentials.authentication == original_authentication +def test_sqlserver_backend_adbc_enum_member_exists() -> None: + """ADBC backend must be a recognised member of SQLServerBackend.""" + assert str(SQLServerBackend.adbc) == "adbc" + + +def test_coerce_backend_accepts_adbc() -> None: + """coerce_backend('adbc') must return SQLServerBackend.adbc.""" + from dbt.adapters.sqlserver.sqlserver_credentials import coerce_backend + + assert coerce_backend("adbc") is SQLServerBackend.adbc + + +def test_coerce_backend_rejects_unknown_with_adbc_in_message() -> None: + """coerce_backend('unknown') must raise DbtRuntimeError mentioning adbc.""" + from dbt.adapters.sqlserver.sqlserver_credentials import coerce_backend + + with pytest.raises(DbtRuntimeError, match="adbc"): + coerce_backend("unknown") + + +def test_supported_backends_includes_adbc() -> None: + """SUPPORTED_SQLSERVER_BACKENDS must include 'adbc'.""" + from dbt.adapters.sqlserver.sqlserver_constants import SUPPORTED_SQLSERVER_BACKENDS + + assert "adbc" in SUPPORTED_SQLSERVER_BACKENDS + + +def test_connection_keys_exclude_driver_for_adbc() -> None: + """adbc backend must not include 'driver' in connection keys (like mssql-python).""" + adbc_credentials = SQLServerCredentials( + backend=SQLServerBackend.adbc, + driver="ODBC Driver 18 for SQL Server", + host="fake.sql.sqlserver.net", + database="dbt", + schema="sqlserver", + ) + + assert "driver" not in adbc_credentials._connection_keys() + assert "windows_login" in adbc_credentials._connection_keys() + assert "backend" in adbc_credentials._connection_keys() + + def test_connection_keys_include_driver_only_for_pyodbc() -> None: pyodbc_credentials = SQLServerCredentials( backend=SQLServerBackend.pyodbc, @@ -643,12 +685,33 @@ def fake_handle_backend_database_error( reset_runtime_state_for_test() -def test_data_type_code_to_name_handles_repr_and_rejects_integer_codes() -> None: +def test_data_type_code_to_name_handles_repr_and_arrow_codes() -> None: + # Existing pyodbc / mssql-python string repr handling still works. assert SQLServerConnectionManager.data_type_code_to_name("") == "varchar" assert SQLServerConnectionManager.data_type_code_to_name("int") == "int" - with pytest.raises(DbtRuntimeError, match="integer type codes are not mapped"): - SQLServerConnectionManager.data_type_code_to_name(7) + # Arrow integer type codes (ADBC path). + assert SQLServerConnectionManager.data_type_code_to_name(1) == "bit" # bool_ + assert SQLServerConnectionManager.data_type_code_to_name(3) == "varchar" # string / utf8 + assert SQLServerConnectionManager.data_type_code_to_name(8) == "int" # int32 + assert SQLServerConnectionManager.data_type_code_to_name(7) == "float" # float64 + assert SQLServerConnectionManager.data_type_code_to_name(17) == "datetime2(6)" # timestamp + assert SQLServerConnectionManager.data_type_code_to_name(10) == "smallint" # int8 + assert SQLServerConnectionManager.data_type_code_to_name(5) == "varchar(max)" # large_string + + # Unrecognised Arrow integer code raises rather than silently + # mis-reporting the column type. + with pytest.raises(DbtRuntimeError, match="99"): + SQLServerConnectionManager.data_type_code_to_name(99) + + # Arrow string type names (ADBC path). + assert SQLServerConnectionManager.data_type_code_to_name("int32") == "int" + assert SQLServerConnectionManager.data_type_code_to_name("utf8") == "varchar" + assert SQLServerConnectionManager.data_type_code_to_name("timestamp") == "datetime2(6)" + + # Unknown string codes still raise on the non-ADBC path. + with pytest.raises(DbtRuntimeError, match="no matching entry found"): + SQLServerConnectionManager.data_type_code_to_name("nonexistent_type") def test_mssql_python_active_directory_default_passes() -> None: diff --git a/uv.lock b/uv.lock index 7e01c671..b6120038 100644 --- a/uv.lock +++ b/uv.lock @@ -9,6 +9,47 @@ supported-markers = [ "python_full_version >= '3.11'", ] +[[package]] +name = "adbc-driver-manager" +version = "1.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9c/f8/ed6475b49a7cf35ea888d5c95e7d4bc9dc6568f9d741f14c0573d622cc1e/adbc_driver_manager-1.12.0.tar.gz", hash = "sha256:45991f0c2de369d330c6a211ca2edbcce6389c5dc81cde70461bdeb6f8f7b268", size = 217579, upload-time = "2026-07-28T00:43:03.512Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/53/2c47920ca9a5bf29893294db2ac765e26823eb3246d0071374d29abdc276/adbc_driver_manager-1.12.0-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:ca18599e19a40da990bffe964475ee27523a87bb770a1ffa77f15c6e73790822", size = 599962, upload-time = "2026-07-28T00:41:45.02Z" }, + { url = "https://files.pythonhosted.org/packages/53/8b/b66dec201f2dcb36d1a794afd5f18310c1252cdf6ee84dd9b58e17a526e0/adbc_driver_manager-1.12.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6166c5a8ea0904d2ab811f575747ade35ce4cabc1c5acc3cc6468ca158d620e9", size = 610987, upload-time = "2026-07-28T00:41:46.946Z" }, + { url = "https://files.pythonhosted.org/packages/01/9e/3617960d056bdc9f2f2cef0ff902b6e3dd767f3a3f232856edcf113a8eac/adbc_driver_manager-1.12.0-cp310-cp310-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41dadba88e1806eba6cb3eb30b7a2e9f804001bb002dd18ed6a15edb6f5d096f", size = 4596654, upload-time = "2026-07-28T00:41:49.282Z" }, + { url = "https://files.pythonhosted.org/packages/53/a0/224464451cf28baea8033cae16fd1819d5d769ecd72346363de6c3189e3a/adbc_driver_manager-1.12.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:63048664b31c964ae9cc0c1bf3902ec7c26751bee110ab320d78f8d1af7e0b6a", size = 4679094, upload-time = "2026-07-28T00:41:51.455Z" }, + { url = "https://files.pythonhosted.org/packages/35/cf/8089661f92a3991edcd8938c2fe96cbb7a8d1298623aaceafdf78f8ff8cc/adbc_driver_manager-1.12.0-cp310-cp310-win_amd64.whl", hash = "sha256:bf7764d4f1ac9b54e442d6c3b6afbefce639268a7e505a05629507209fe0e3f7", size = 763065, upload-time = "2026-07-28T00:41:53.11Z" }, + { url = "https://files.pythonhosted.org/packages/73/2d/e41ea911f9486c497534ae181dfdab19adca21f71abc8a1fcaf2c27251a8/adbc_driver_manager-1.12.0-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:3c0c73670c8aa6fe42de1d5e71a0b329c4b37f7c55c560c23f6f3a1609200c1f", size = 600030, upload-time = "2026-07-28T00:41:54.753Z" }, + { url = "https://files.pythonhosted.org/packages/10/ea/1a8b51999785d7dce17079dd635c9ee2372c75ec7328423ce862741cb503/adbc_driver_manager-1.12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6943c7adcf3c7c9f7c4b5bdb7589c331027a347e3c77471eb3f656b1a881e351", size = 611058, upload-time = "2026-07-28T00:41:56.306Z" }, + { url = "https://files.pythonhosted.org/packages/85/a2/5ede53173a420742fa71d6c26792e2295fc73f25cf39055f912a5385b1f0/adbc_driver_manager-1.12.0-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78c9936adb280e2c10e90632e41b58aa23be358e1136d8fb3c52862b72818a95", size = 4663735, upload-time = "2026-07-28T00:41:58.793Z" }, + { url = "https://files.pythonhosted.org/packages/1c/1a/781561d0f55e05a0b884244ed563dab14b165b4dd74abd8af3f8efd95e3d/adbc_driver_manager-1.12.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:30d96ab4a2594b4109496fb4913646f41a5bf1ecce79b4313847d240a2a62db3", size = 4747090, upload-time = "2026-07-28T00:42:00.746Z" }, + { url = "https://files.pythonhosted.org/packages/c6/fa/47c755a74ea4887968c52a968e02736007da4042fc0820292dc8c6827a94/adbc_driver_manager-1.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:67419b92c286646944426992069f56fed90c2ceac83521f6d66d7d3cbf6c17ea", size = 760952, upload-time = "2026-07-28T00:42:02.432Z" }, + { url = "https://files.pythonhosted.org/packages/de/8c/cd3fe16df716719116a6c79e64a768fe994f6ded55d5a8f091bb4f42d6f0/adbc_driver_manager-1.12.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:fd02364c65b8b376c5627e3b77410f457fcbbf983e52e8d15ca099da3a7ae314", size = 599054, upload-time = "2026-07-28T00:42:04.072Z" }, + { url = "https://files.pythonhosted.org/packages/49/4a/2f060ff6bd61420ea1613670e1f85a22a8714934c235186dc3803de8ddac/adbc_driver_manager-1.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d8dcf62621090e8d9c8216e08dfc4043f16331872522186af61a5de9478e9c63", size = 609964, upload-time = "2026-07-28T00:42:05.82Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f1/0746db149828ae91e4a6cf49f8d0e49210eec20c03ad80044454139c8240/adbc_driver_manager-1.12.0-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:efa5dbbf101962d212b176f25e6fc509dacf07afd4cf70b5027d81ec6871bdec", size = 4685726, upload-time = "2026-07-28T00:42:08.1Z" }, + { url = "https://files.pythonhosted.org/packages/b9/c3/f8e9c5157b19e986df719259eb3502dad1268df9f7a1034f65ca220ab2ea/adbc_driver_manager-1.12.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8b340679a005a8adf6b0b58754dbc638dff00db7b2559c140406a1d92678b48c", size = 4768774, upload-time = "2026-07-28T00:42:10.359Z" }, + { url = "https://files.pythonhosted.org/packages/92/51/f8e625af691e6b4c54945790854524356a02a0a69063e888f7cfee1b2e50/adbc_driver_manager-1.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:47f428a922d224fd486b661deeaf9520e5faec558b3d144832bed09a080cac88", size = 760087, upload-time = "2026-07-28T00:42:11.871Z" }, + { url = "https://files.pythonhosted.org/packages/9a/f9/674c5bbc5093617d72c4f58a5dab67982710b2320cc9aa826050a6aaa131/adbc_driver_manager-1.12.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:c42ca4d9caa22b3a5ce76bde8729169f403bb7393e3671734b9416634c207125", size = 596815, upload-time = "2026-07-28T00:42:13.64Z" }, + { url = "https://files.pythonhosted.org/packages/56/5f/c1d888d787330801edae282d2a9def3765e8157547cc20e71154ff38c1bb/adbc_driver_manager-1.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c894117c8f5c484b902c8b070bcfd9d31d90efe0288b2b58a3ddab97c80f66e7", size = 608277, upload-time = "2026-07-28T00:42:15.643Z" }, + { url = "https://files.pythonhosted.org/packages/06/4b/ee799babf171e39690ef45560451096f869d9e7387bc0e5a754bb243ed2a/adbc_driver_manager-1.12.0-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:214f80f9b65562f08b4d1c52a756b5db557530e3c0652f587c43aaa80039579a", size = 4667230, upload-time = "2026-07-28T00:42:17.97Z" }, + { url = "https://files.pythonhosted.org/packages/00/c6/a35e38ef5e0db391be79e0e14c019ce378b87d9d7e31d1dfcd451e9d291f/adbc_driver_manager-1.12.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:532ab290b3d923ce0a75bca21dc6e13f55835625f78808e1664755939f3ebdf6", size = 4745299, upload-time = "2026-07-28T00:42:20.189Z" }, + { url = "https://files.pythonhosted.org/packages/16/e2/62bacd6844859036d79ea229401b5200056fb5050c82dc3a2e28b08ff49b/adbc_driver_manager-1.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:034da82c1a6e195d67ca1f0c97a1a517046037ec3029ab9a0ea8f7ccb14056e4", size = 758878, upload-time = "2026-07-28T00:42:21.598Z" }, + { url = "https://files.pythonhosted.org/packages/50/ea/f53b434fe36d0f138d147fc10a95784c8c0eeea1bec1f3f31eee5ec8bdb5/adbc_driver_manager-1.12.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a740d634118722f42af31176374fddbad3846fa2e6536f497bac145e9511cecc", size = 597579, upload-time = "2026-07-28T00:42:23.216Z" }, + { url = "https://files.pythonhosted.org/packages/ba/57/6208e66d9256550c2aff75db4a323a855a0d5d2d1bd639526f825d3e08b4/adbc_driver_manager-1.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8a77ae39832e67946009816d83c321e540a3024aad1419ccba24ddeb7b6a01f4", size = 610337, upload-time = "2026-07-28T00:42:25.051Z" }, + { url = "https://files.pythonhosted.org/packages/1d/cd/f5ea3f08191af5ae15041821fcb52bf35837dce1a9ac16fa039b3bfe308c/adbc_driver_manager-1.12.0-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:690f140ca67d49f995afac59f85441c3d5e896cd2fc8fd381423fe900e51f1f7", size = 4664297, upload-time = "2026-07-28T00:42:27.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/81/823a71a515078545eab8a4be8381206887129e11b91e9bf51ca2a9eea44d/adbc_driver_manager-1.12.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fd568c94874c0586d82f99de2bb5d2c02b4fa9c5bafe3d0d8ab353bddf9d2fd6", size = 4733739, upload-time = "2026-07-28T00:42:29.814Z" }, + { url = "https://files.pythonhosted.org/packages/cf/f7/7612d078d935344aee679a44a6283de6aae9008eb8e0ef80e475dd12dffa/adbc_driver_manager-1.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:57f5101fb2a853b1ffb81ff807b5e29a51ba14c64032eb0038b8dfd433b6d533", size = 777952, upload-time = "2026-07-28T00:42:40.881Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ad/2478338aaece38b8b72259dbfd4d4c84d9a038421e25bbc283e510d47555/adbc_driver_manager-1.12.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bb9db6e4a3bcd73153435a900b5ae40ad36f5875df93a8faf784d9fcf6833983", size = 615694, upload-time = "2026-07-28T00:42:31.932Z" }, + { url = "https://files.pythonhosted.org/packages/bc/a0/0592c85e653f005aa28de7733b3c3c4f0282238301694f76806e5f3cc1e1/adbc_driver_manager-1.12.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:07cae26bd5ccee6caa4227f817c0fd57f9ac131c2dd98e0c5d7fecfef61819c7", size = 628341, upload-time = "2026-07-28T00:42:33.481Z" }, + { url = "https://files.pythonhosted.org/packages/9d/00/65705a72f768bc2dda82623a74cf816609dfdff56f3ad22b073d4a1ea7f8/adbc_driver_manager-1.12.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:442ed2ee8ea62c475bf3478385555bb4f0b25d9d551087ffe40c73b91bf5431e", size = 4730268, upload-time = "2026-07-28T00:42:35.661Z" }, + { url = "https://files.pythonhosted.org/packages/44/b9/60ecde5d9dde5acc5576cb0ba5ffa34e154464e07fa295c57cd975ea27c7/adbc_driver_manager-1.12.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c2aa05c5dc52164692284b2df27fba5680dbc967b8e3ca704aabf5399667996", size = 4777527, upload-time = "2026-07-28T00:42:37.709Z" }, + { url = "https://files.pythonhosted.org/packages/ac/76/6749e0c0c437219780c65487cff67dc09a556c1fccf577a2b27f7b92a704/adbc_driver_manager-1.12.0-cp314-cp314t-win_amd64.whl", hash = "sha256:cfa08f8c7c63e3fa92eb4e26ef4d8a9520cf92a39281cd011821f6f16a963080", size = 793451, upload-time = "2026-07-28T00:42:39.222Z" }, +] + [[package]] name = "agate" version = "1.9.1" @@ -722,16 +763,17 @@ dependencies = [ { name = "dbt-adapters" }, { name = "dbt-common" }, { name = "dbt-core" }, - { name = "pyodbc" }, ] [package.optional-dependencies] +adbc = [ + { name = "adbc-driver-manager" }, + { name = "pyarrow" }, +] azure = [ { name = "azure-identity" }, ] mssql = [ - { name = "azure-core" }, - { name = "azure-identity" }, { name = "mssql-python" }, ] pyodbc = [ @@ -740,6 +782,7 @@ pyodbc = [ [package.dev-dependencies] dev = [ + { name = "adbc-driver-manager" }, { name = "azure-identity" }, { name = "build" }, { name = "bumpversion" }, @@ -750,6 +793,7 @@ dev = [ { name = "mssql-python" }, { name = "mypy" }, { name = "pre-commit" }, + { name = "pyarrow" }, { name = "pyodbc" }, { name = "pytest" }, { name = "pytest-cov" }, @@ -764,20 +808,20 @@ dev = [ [package.metadata] requires-dist = [ - { name = "azure-core", marker = "extra == 'mssql'", specifier = ">=1.0.0" }, + { name = "adbc-driver-manager", marker = "extra == 'adbc'", specifier = ">=1.0.0" }, { name = "azure-identity", marker = "extra == 'azure'", specifier = ">=1.12.0" }, - { name = "azure-identity", marker = "extra == 'mssql'", specifier = ">=1.12.0" }, { name = "dbt-adapters", specifier = ">=1.24.5,<2.0" }, { name = "dbt-common", specifier = ">=1.22.0,<2.0" }, { name = "dbt-core", specifier = ">=1.12.0,<2.0" }, { name = "mssql-python", marker = "extra == 'mssql'", specifier = ">=1.7.1" }, - { name = "pyodbc", specifier = ">=5.2.0" }, + { name = "pyarrow", marker = "extra == 'adbc'", specifier = ">=14.0.0" }, { name = "pyodbc", marker = "extra == 'pyodbc'", specifier = ">=5.2.0" }, ] -provides-extras = ["azure", "pyodbc", "mssql"] +provides-extras = ["azure", "pyodbc", "mssql", "adbc"] [package.metadata.requires-dev] dev = [ + { name = "adbc-driver-manager", specifier = ">=1.0.0" }, { name = "azure-identity", specifier = ">=1.12.0" }, { name = "build" }, { name = "bumpversion" }, @@ -788,6 +832,7 @@ dev = [ { name = "mssql-python", specifier = ">=1.7.1" }, { name = "mypy", specifier = "==2.1.0" }, { name = "pre-commit" }, + { name = "pyarrow", specifier = ">=14.0.0" }, { name = "pyodbc", specifier = ">=5.2.0" }, { name = "pytest" }, { name = "pytest-cov" }, @@ -1799,6 +1844,56 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8e/37/efad0257dc6e593a18957422533ff0f87ede7c9c6ea010a2177d738fb82f/pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0", size = 11842, upload-time = "2024-07-21T12:58:20.04Z" }, ] +[[package]] +name = "pyarrow" +version = "25.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/27/f3/95428098d1fa7d04432fb750eed06b41304c2f6a5d3319985e64db2d9d41/pyarrow-25.0.0.tar.gz", hash = "sha256:d2d697008b5ec06d75952ef260c2e9a8a0f6ccfce24266c04c9c8ade927cb3b4", size = 1199181, upload-time = "2026-07-10T08:29:50.116Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4d/2a/eaa70e6d6ed430c2e90c0599e2831a41a50251879e44788ccdbc73115af1/pyarrow-25.0.0-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:ce0ca222802087b9a8cb031a6468442cb6b67c290a45a601cac64753d34954d3", size = 35945551, upload-time = "2026-07-10T08:25:23.153Z" }, + { url = "https://files.pythonhosted.org/packages/df/e0/917086af6b246143012cdc8a7c886b018b53204f3d69fc5f9be5857a8b80/pyarrow-25.0.0-cp310-cp310-macosx_12_0_x86_64.whl", hash = "sha256:7d6da02ffc7a3a9bda3b7ded4cc2a27ff73969ab37153f3afd46bbbc1ba4f0f7", size = 37636698, upload-time = "2026-07-10T08:25:28.031Z" }, + { url = "https://files.pythonhosted.org/packages/68/6a/c87829f92503f84993721791c942f3d9aa81044de51a8cfb1da5810e5345/pyarrow-25.0.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:dbf9fa5d4bde73b1cc16377dcaaa010f971e6fa7f5083f5d44f34b50bc1d74af", size = 46858364, upload-time = "2026-07-10T08:25:34.527Z" }, + { url = "https://files.pythonhosted.org/packages/cc/ba/2030d454c2747e26cce23e4a0338067ee0830a155b7894da04caa96783a5/pyarrow-25.0.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:b72d943ff4e10fec8d48aedb23322d8f6ea8bc2d698b81db37e73730f69e4862", size = 50056398, upload-time = "2026-07-10T08:25:40.785Z" }, + { url = "https://files.pythonhosted.org/packages/78/ce/ba7a5ce7bf0cfc372ec48203a34ece42f73aa2f3231706f61c55e105ecd0/pyarrow-25.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5fb2d837960f1df7f679ff9f1a55065e306347d379e0768cebf14781254d6194", size = 49958146, upload-time = "2026-07-10T08:25:46.98Z" }, + { url = "https://files.pythonhosted.org/packages/75/eb/c34a29fb7a70dca2f903c7d85a928928ef55af20cd56e99de6b4c0d897bc/pyarrow-25.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:add690feafa0953c443cdba9e9e87f5eaa198f1ea2e43a3b146ea83f202262d0", size = 53096264, upload-time = "2026-07-10T08:25:53.925Z" }, + { url = "https://files.pythonhosted.org/packages/36/f9/35b1f83a0727d84951588e4034aca2feb76dfb45b0725918c0037b0a48f7/pyarrow-25.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:d293e9959b29a24c82d936d04ab2b7fd8b8d334030de2e56a99aba94f008ad7a", size = 27840572, upload-time = "2026-07-10T08:25:58.966Z" }, + { url = "https://files.pythonhosted.org/packages/a7/98/ae2b5acf9876dbeffa6f320776242c52caab062df55c8ac5501ed2679e74/pyarrow-25.0.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:2e3b6544e26e393fe2cd530f523e36c1c8d3c345bbbb60cca3fd866be8322517", size = 35939080, upload-time = "2026-07-10T08:26:04.53Z" }, + { url = "https://files.pythonhosted.org/packages/80/09/3de2a968edbd496c86cb8b932cdbee2d4b08c4a28e9884a15e5c705a646b/pyarrow-25.0.0-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:b724d127783b4c19f088fcdfc844cbc318809246a30307bcabd5ed02045e890e", size = 37633420, upload-time = "2026-07-10T08:26:10.354Z" }, + { url = "https://files.pythonhosted.org/packages/19/86/8399243a4ce080426ec37db18d5e29148b7ec960a8a8c7f9059a7bf6ef0a/pyarrow-25.0.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:244f98a595f70fa4fd35faa7508c4ae67e14a173397a4b3b49d2b3c360fb0062", size = 46861050, upload-time = "2026-07-10T08:26:16.397Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/72d704b02bc5fc6d06954d76a0208c1e79cad3ab370f6d6a91ffe5078870/pyarrow-25.0.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:0222f0071d13313962a88d21bf28b80d355ac39d81bfa6ff3fe00eeaf748e4be", size = 50056458, upload-time = "2026-07-10T08:26:23.271Z" }, + { url = "https://files.pythonhosted.org/packages/06/5d/3c31a60b6403d63cad2e0f829096f5fc5763a129ead4207a5d4690b96448/pyarrow-25.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b58726f118c079f9d4ed7e904975d4f15fd69d0741ba511a4e2dcaa4ef16354f", size = 49957793, upload-time = "2026-07-10T08:26:30.232Z" }, + { url = "https://files.pythonhosted.org/packages/34/f7/8f8a019061f9863a831915329264372a87ed25eaf9109ce56eb0e84012c5/pyarrow-25.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:38a2c887cb3883e241b70201688db34133b6dfadd04f03c8f9213df53770c18e", size = 53100544, upload-time = "2026-07-10T08:26:36.414Z" }, + { url = "https://files.pythonhosted.org/packages/f1/e2/738071e95c5ddad7b3dfc12f569ffa992db89d7d7b4a95258fd184191249/pyarrow-25.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:161649d60a7a46c613a19fd795763ea8a88c36ba997dd99d9bc66e6794ee36e8", size = 27848311, upload-time = "2026-07-10T08:26:41.429Z" }, + { url = "https://files.pythonhosted.org/packages/73/44/fdd3a4377807b7dcabe2d4b5aa99dbbc98e2e5df3f1ca4e7f0aec492d987/pyarrow-25.0.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:149730a3d1f0fb59d663a0b8aa210adfd9c17c27cd94a0d143e60daea8320d4e", size = 35850884, upload-time = "2026-07-10T08:26:47.357Z" }, + { url = "https://files.pythonhosted.org/packages/bf/71/9f053177a7709b8c90abb00a2375b916286f9f0d6cfb21a5cadd4ef811e8/pyarrow-25.0.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:0721332c30fdd453fdd1fc203b2ac1f4c9db5aea28fa38d41f2574c4b068b9ec", size = 37616197, upload-time = "2026-07-10T08:26:53.564Z" }, + { url = "https://files.pythonhosted.org/packages/95/1a/22bfb6597dcdc861fa83c39c06e1457cb56f698940eff42fbb25de30e8e5/pyarrow-25.0.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:fa1482b3da10cac2d4db6e26b81da543e237616af2ef6d466018b31ca586496f", size = 46841966, upload-time = "2026-07-10T08:27:07.685Z" }, + { url = "https://files.pythonhosted.org/packages/55/0e/cd705c042bc4fe7022478db577fcab4abdcfabb9bc37ab7a75556b3fcb2b/pyarrow-25.0.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:5d1dbf24e151042f2fa3c129563f65d66674128868496fb008c4272b16bdf778", size = 50088993, upload-time = "2026-07-10T08:27:14.268Z" }, + { url = "https://files.pythonhosted.org/packages/98/ee/d822e1ee31fe31ec5d057210e0605c950b975dcd8d9a332976cc859a9df8/pyarrow-25.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:20887a762dd61dcc530f93a140840ab1f6aa7836b33270e42d627ab3cf11e537", size = 49941005, upload-time = "2026-07-10T08:27:21.274Z" }, + { url = "https://files.pythonhosted.org/packages/33/1b/207a90cc64619a095eb75a263ae069735f2810056d43c667befd573ec083/pyarrow-25.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:58d1ab556b0cea1c93fdb799b24ad58adb2f2a2788dbce782a94f64ae1a5cc9b", size = 53112355, upload-time = "2026-07-10T08:27:27.911Z" }, + { url = "https://files.pythonhosted.org/packages/7e/fe/81d1e5f8beed15c01e98649d5c6e2167b67fd395884a2488f18bf1cf0dba/pyarrow-25.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:3f356afe61186395c861d5cd63dc21ff7d5fa335012a4668d979257df7fea0f5", size = 27945954, upload-time = "2026-07-10T08:27:32.903Z" }, + { url = "https://files.pythonhosted.org/packages/6c/c8/098ce17d778fd9d29e40bb8c5f19a40cc90c3f0b46c9057b0d7993f42f54/pyarrow-25.0.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:8831a3ba52fa7cdb78d368d968b1dcd06171e6dff5461e16d90de91d371e47bc", size = 35844549, upload-time = "2026-07-10T08:27:37.956Z" }, + { url = "https://files.pythonhosted.org/packages/bc/66/24c28877219abf6263d909b1592c97ff82c59f13a59acbed11fc87c0654f/pyarrow-25.0.0-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:5f4bacb60f91dd2fca6c52f1b9a0012cd090e0294f1f781dc1881a247a352f8e", size = 37610397, upload-time = "2026-07-10T08:27:43.803Z" }, + { url = "https://files.pythonhosted.org/packages/53/55/6d1d5f5aff317ec5de9421594679ed51ed828fe7e2ce209327f819d801e4/pyarrow-25.0.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:59516c822d5fd8e544aaa0dfe72f36fed5d4c24ea8390aab1bcd31d7e959c6be", size = 46841701, upload-time = "2026-07-10T08:27:49.741Z" }, + { url = "https://files.pythonhosted.org/packages/b5/5d/f790fb6965ab54c9da0dda7856abc75fd0d7648d865f8d603c111d203a64/pyarrow-25.0.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:6f9dbd83e91c239a1f5ee7ce13f108b5f6c0efbe40a4375260d8f08b43ad05e9", size = 50090118, upload-time = "2026-07-10T08:27:56.051Z" }, + { url = "https://files.pythonhosted.org/packages/0c/8c/faf025357ebf31bc96777f234277aa31e2aeca6dd4ecaa391f29085473c2/pyarrow-25.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:18dcc8cc50b5e72eae6fcbfc6c8776c21a007176b27a3cdec5c2f5bcf126708d", size = 49945559, upload-time = "2026-07-10T08:28:01.927Z" }, + { url = "https://files.pythonhosted.org/packages/07/a1/bd051871708ea99a5e0fc711926c26c6f2c6d0130c7aaac8093e34998af6/pyarrow-25.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4ec1895a87aa834c3b99b7a1e758747eb8bb57f922b32c0e0fa04afb8d6998b1", size = 53114238, upload-time = "2026-07-10T08:28:08.594Z" }, + { url = "https://files.pythonhosted.org/packages/7c/31/737f0c3cffcd6af647849477d1dd68045deac2e3963c3f9f211bedc48540/pyarrow-25.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:77c8d1ae46a44b4006e8db1cc977bbcc6ce4873c92f74137d68e45503b97fb18", size = 27861162, upload-time = "2026-07-10T08:28:12.975Z" }, + { url = "https://files.pythonhosted.org/packages/55/c7/581ccbcdb3d897eb2893328d68db3d52eca373bf2a7e964d0a6276b8e85b/pyarrow-25.0.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:72132b9a8a0a1840197794d4dea26080069b6b0981c116bc078762dc9691b21b", size = 35878945, upload-time = "2026-07-10T08:28:18.222Z" }, + { url = "https://files.pythonhosted.org/packages/64/d1/ccb01db7329ea0411ef4fbd9b62a04d3268b36777d4e758d5e39b91ddeab/pyarrow-25.0.0-cp314-cp314-macosx_12_0_x86_64.whl", hash = "sha256:e009ef945e498dca2f050ea10d2e9764cb44017254826fc4574fdb8d2530173b", size = 37630854, upload-time = "2026-07-10T08:28:23.452Z" }, + { url = "https://files.pythonhosted.org/packages/af/9f/2d81ba89d1e4198d0cb25fe7529de936830fdaec0db926bb52a1ef7080d4/pyarrow-25.0.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:f57a39dbcb416345401c2e77a4373669b45fd111a1768e6cf267a7a0607ff0ec", size = 46905617, upload-time = "2026-07-10T08:28:29.376Z" }, + { url = "https://files.pythonhosted.org/packages/6a/29/0ed312ec800fb536f93783215126cee4b8977dcfeccba6f0f44df0cc87d7/pyarrow-25.0.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:447df764beb07c544f0178a5f6b70ef44b9ecf382b3cdfad4c2d7867353c3887", size = 50119765, upload-time = "2026-07-10T08:28:35.826Z" }, + { url = "https://files.pythonhosted.org/packages/ca/88/cab5063ba0c4d46a9f6b4b7eb1c9029dc0302d65cd5ab3510c949a386568/pyarrow-25.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ac5dfeee59f9ceb4d45ba76e83b026c38c24334135bb329d8274baa49cec3c62", size = 50027563, upload-time = "2026-07-10T08:28:43.848Z" }, + { url = "https://files.pythonhosted.org/packages/7b/fb/4d24f1b7fe2e042dc4ef315ef75e4e702d8e46fe10c37e63caff00502b03/pyarrow-25.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f0f100dacf2c0f400601664a79d1a907ced4740514bb2b00917341038e2ce76f", size = 53162437, upload-time = "2026-07-10T08:28:52.819Z" }, + { url = "https://files.pythonhosted.org/packages/fa/65/da20806de93ca6ee91e72cb6a9b08b3ac890b46efc8d94a7326c651c4c81/pyarrow-25.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:2e093efbecb5317372f819228fa4b4e6157eee48d3f0a7b0303705ebf81a7104", size = 28613262, upload-time = "2026-07-10T08:29:47.544Z" }, + { url = "https://files.pythonhosted.org/packages/86/9f/c632afb1d3ef4a7814cee236718235f3a47eac46e97eb87df40f550b6b48/pyarrow-25.0.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:26be35b80780d2d21f4bae3d568b1666337c3a89722cc1794c956a77017cb24e", size = 36120702, upload-time = "2026-07-10T08:28:59.577Z" }, + { url = "https://files.pythonhosted.org/packages/36/0a/093d53a0e72ad06e45d6443e00651bbc2d21af4211295086cbf4d873d3b9/pyarrow-25.0.0-cp314-cp314t-macosx_12_0_x86_64.whl", hash = "sha256:6f4812bfbf11ca7d8faf59eb8fff8bf4dd25ce3a38b62baa010cc17a0926d1b2", size = 37750674, upload-time = "2026-07-10T08:29:06.916Z" }, + { url = "https://files.pythonhosted.org/packages/8a/18/b37fc31a69cff4bdfb8842683def5612f551b93fff6f44375e4a4a6a5535/pyarrow-25.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:b8af8ceedf0c9c160fd2b63440f2d205b9404db85866c1217bfea601de7cfb50", size = 46912304, upload-time = "2026-07-10T08:29:14.656Z" }, + { url = "https://files.pythonhosted.org/packages/32/35/5cae19ba72493e5598022468b56f6a5571f399f485bf412f157356476caa/pyarrow-25.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:c70a5fd9a82bd1a702fd482bdc62d38dcb672fb2b449b1d7c0d7d1f4be7b7bfe", size = 50073652, upload-time = "2026-07-10T08:29:22.467Z" }, + { url = "https://files.pythonhosted.org/packages/2e/a5/ddd508424bdfd5e6945765e9e2ffc687e2f6115972badc8ecf423076c407/pyarrow-25.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0490a7f8b38ffe11cc26526b50c65d111cb54ddac3717cec781806793f1244dc", size = 50058654, upload-time = "2026-07-10T08:29:29.689Z" }, + { url = "https://files.pythonhosted.org/packages/5f/a4/324d0db203ff5eebe8694ec2d6ec5a23f9aaa5d02e5b8c692914c518c33c/pyarrow-25.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e83916bbcf380866b4e14255850b33323ff678dc9758411d0409cdd2523880b0", size = 53140153, upload-time = "2026-07-10T08:29:36.041Z" }, + { url = "https://files.pythonhosted.org/packages/bd/8d/d236e9c82fe315f9128885c8be3ec719f41965a1eb6b6f4b42470904cd41/pyarrow-25.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:13240f0d3dc5932ccd0bfa90cd76d835680b9d94a7661c635df4b703d40ce849", size = 28743657, upload-time = "2026-07-10T08:29:42.742Z" }, +] + [[package]] name = "pycparser" version = "3.0" @@ -2257,6 +2352,17 @@ version = "3.14.5" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/2c/21/ef6157213316e85790041254259907eb722e00b03480256c0545d98acd33/rapidfuzz-3.14.5.tar.gz", hash = "sha256:ba10ac57884ce82112f7ed910b67e7fb6072d8ef2c06e30dc63c0f604a112e0e", size = 57901753, upload-time = "2026-04-07T11:16:31.931Z" } wheels = [ + { url = "https://files.pythonhosted.org/packages/4f/b1/d6d6e7737fe3d0eb2ac2ac337686420d538f83f28495acc3cc32201c0dbf/rapidfuzz-3.14.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:071d96b957a33b9296b9284b6350a0fb6d030b154a04efd7c15e56b98b79a517", size = 1953508, upload-time = "2026-04-07T11:13:37.733Z" }, + { url = "https://files.pythonhosted.org/packages/2b/7b/94c1c953ac818bdd88b43213a9d38e4a41e953b786af3c3b2444d4a8f96d/rapidfuzz-3.14.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:667f40fe9c81ad129b198d236881b00dd9e8314d9cc72d03c3e16bdfe5879051", size = 1160895, upload-time = "2026-04-07T11:13:39.278Z" }, + { url = "https://files.pythonhosted.org/packages/7f/60/a67a7ca7c2532c6c1a4b5cd797917780eed43798b82c98b6df734a086c95/rapidfuzz-3.14.5-cp310-cp310-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f9fff308486bbd2c8c24f25e8e152c7594d3fe8db265a2d6a1ce24d58671127f", size = 1382245, upload-time = "2026-04-07T11:13:41.054Z" }, + { url = "https://files.pythonhosted.org/packages/95/ff/a42c9ce9f9e90ceb5b51136e0b8e8e6e5113ba0b45d986effbd671e7dddf/rapidfuzz-3.14.5-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dfa552338f51aec280f17b02d28bace1e162d1a84ccd80e3339a57f98aedb56b", size = 3163974, upload-time = "2026-04-07T11:13:42.662Z" }, + { url = "https://files.pythonhosted.org/packages/e3/3c/11e2d41075e6e48b7dad373631b379b7e40491f71d5412c5a98d3c58f60f/rapidfuzz-3.14.5-cp310-cp310-manylinux_2_39_riscv64.whl", hash = "sha256:068b3e965ca9d9ee4debe40001ae7c3938ba646308afd33cf0c66618147db65c", size = 1475540, upload-time = "2026-04-07T11:13:44.687Z" }, + { url = "https://files.pythonhosted.org/packages/29/fa/09be143dcc22c79f09cf90168a574725dbda49f02cbbd55d0447da8bec86/rapidfuzz-3.14.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:88b7d31ff1cc5e9bc0e4406e6b1fa00b6d37163d50bb58091e9b976ff1129faa", size = 2404128, upload-time = "2026-04-07T11:13:46.641Z" }, + { url = "https://files.pythonhosted.org/packages/32/f9/1aeb504cdcfde42881825e9c86f48238d4e01ba8a1530491e82eb17e5689/rapidfuzz-3.14.5-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:eacb434410b8d9ca99a8d42352ef085cf423e3c76c1f0b86be2fcba3bff2952c", size = 2508455, upload-time = "2026-04-07T11:13:48.726Z" }, + { url = "https://files.pythonhosted.org/packages/10/8e/b1b5eed8d887a29b0e18fd3222c46ca60fddfb528e7e1c41267ce42d5522/rapidfuzz-3.14.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:649712823f3abcdc48427147a5384fac15623ba435d0013959b52e6462521397", size = 4274060, upload-time = "2026-04-07T11:13:50.805Z" }, + { url = "https://files.pythonhosted.org/packages/e3/c4/7e5b0353693d4f47b8b0f96e941efc377cfb2034b67ef92d082ac4441a0f/rapidfuzz-3.14.5-cp310-cp310-win32.whl", hash = "sha256:13cb79c23ef5516e4c4e3830877be8b19aa75203636be1163d690d37803f6504", size = 1727457, upload-time = "2026-04-07T11:13:52.45Z" }, + { url = "https://files.pythonhosted.org/packages/d9/6e/f530a39b946fa71c009bc9c81fdb6b48a77bbc57ee8572ac0302b3bf6308/rapidfuzz-3.14.5-cp310-cp310-win_amd64.whl", hash = "sha256:f2073495a7f9b75e57e600747ac09510d67683fd64d3228e009740b7ef88f9fe", size = 1544657, upload-time = "2026-04-07T11:13:54.952Z" }, + { url = "https://files.pythonhosted.org/packages/bc/01/02fa075f9f59ff766d374fecbd042b3ac9782dcd5abc52d909a54f587eeb/rapidfuzz-3.14.5-cp310-cp310-win_arm64.whl", hash = "sha256:8166efddea49fdbc61185559f47593239e4794fd7c9044dd5a789d1a90af852d", size = 816587, upload-time = "2026-04-07T11:13:56.418Z" }, { url = "https://files.pythonhosted.org/packages/e1/f9/3c41a7be8855803f4f6c713b472226a98d31d41869d98f64f4ca790510d6/rapidfuzz-3.14.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e251126d48615e1f02b4a178f2cd0cd4f0332b8a019c01a2e10480f7552554b4", size = 1952372, upload-time = "2026-04-07T11:13:58.32Z" }, { url = "https://files.pythonhosted.org/packages/9e/89/c2557e37531d03465193bff0ab9de70b468420a807d71a26a65100635459/rapidfuzz-3.14.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5ab449c9abd0d4e1f8145dce0798a4c822a1a1933d613c764a641bea88b8bdab", size = 1159782, upload-time = "2026-04-07T11:14:00.127Z" }, { url = "https://files.pythonhosted.org/packages/1a/b2/ffeeb7eca1a897d51b998f4c0ef0281696c3b06abcca4f88f9def708ffe1/rapidfuzz-3.14.5-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cb2829fedd672dd7107267189dabe2bbe07972801d636014417c6861eb89e358", size = 1383677, upload-time = "2026-04-07T11:14:01.696Z" },