Skip to content

Add experimental ADBC backend - #783

Merged
axellpadilla merged 1 commit into
masterfrom
feat/771-sqlserver-adbc-backend
Jul 29, 2026
Merged

Add experimental ADBC backend#783
axellpadilla merged 1 commit into
masterfrom
feat/771-sqlserver-adbc-backend

Conversation

@axellpadilla

@axellpadilla axellpadilla commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds an experimental adbc backend (backend: adbc in a profile target) as a third connection path alongside pyodbc and mssql-python. ADBC (Arrow Database Connectivity) talks to SQL Server via go-mssqldb instead of an ODBC/DB-API bridge, avoiding row-based marshalling overhead.

  • New dbt-sqlserver[adbc] extra (adbc-driver-manager, pyarrow); the driver binary itself ships separately via the dbc CLI (not on PyPI). Full setup/config/troubleshooting guide in docs/adbc_backend.md; README.md gets the new backend row and a matching setup section.
  • SQL Server (user/password) authentication only for now — Azure AD and Windows auth return a clear "not yet supported" error.
  • Query placeholders are rewritten from PEP 249 ? to T-SQL @pN (go-mssqldb doesn't support qmark binding), skipping ? characters that appear inside bracket-quoted identifiers or string literals so a literal ? never misaligns a binding.
  • AdapterResponse.rows_affected is recovered via a scoped @@ROWCOUNT query, since ADBC's own cursor.rowcount is always -1: only run when the statement is DML and has no pending result set to fetch, so it never races an unfetched SELECT on the same connection (no MARS support) and isn't paid for on DDL/SELECT/transaction-control statements. Other backends' rowcount values are untouched.
  • Arrow DataType/type-code column metadata is mapped to the same SQL Server type names pyodbc/mssql-python report; an unrecognized type code raises instead of silently mistyping a column.
  • cursor.nextset() (unsupported by the ADBC driver) is drained safely by checking the driver's exception type rather than pyodbc-style return values.

CI / tooling

  • New adbc Docker CI image stage (installs the dbc driver-manager CLI + mssql driver binary) and matrix entry in publish-docker.yml.
  • New backend: adbc row in integration-tests-sqlserver.yml on Python 3.13 / SQL Server 2025 (latest), marked continue-on-error since the backend is still experimental.
  • devcontainer (setup_env.sh) installs the ADBC driver binary and sets ADBC_DRIVER_PATH automatically on container start.

Closes #771

Test plan

  • pytest tests/unit — 400 passed
  • pytest tests/functional against a live SQL Server with backend: adbc — 320 passed, 48 skipped, 2 xfailed, 0 failed
  • pre-commit run --all-files — clean (ruff check/format, mypy, etc.)
  • publish-docker.yml only rebuilds CI images on push to master, so the new CI-3.13-adbc image won't exist until that runs once after merge — the new integration-test row will fail-but-not-block until then (or trigger it manually via workflow_dispatch).

Adds an experimental `adbc` backend (`backend: adbc`) as a third
connection path alongside pyodbc and mssql-python, talking to SQL
Server via go-mssqldb instead of an ODBC/DB-API bridge.

- New dbt-sqlserver[adbc] extra; driver binary installed separately
  via the `dbc` CLI (not on PyPI). Full guide in docs/adbc_backend.md.
- SQL Server (user/password) authentication only for now.
- ? -> @pn placeholder rewriting, quote/bracket-aware so a literal ?
  in an identifier or string literal never misaligns a binding.
- AdapterResponse.rows_affected recovered via a scoped @@rowcount
  query (ADBC's own cursor.rowcount is always -1), gated to DML
  statements with no pending result set so it never races an
  unfetched SELECT (no MARS support) and isn't paid for on
  DDL/SELECT/transaction-control statements.
- Arrow type-code/DataType column metadata mapped to the same SQL
  Server type names as pyodbc/mssql-python; unrecognized codes raise
  instead of silently mistyping a column.
- cursor.nextset() (unsupported by the driver) drained safely by
  checking the driver's exception type.

CI: new adbc Docker image stage + publish-docker.yml matrix entry,
new backend: adbc row in integration-tests-sqlserver.yml (Python
3.13 / SQL Server 2025, continue-on-error while experimental), and
devcontainer setup now installs the driver binary automatically.

Closes #771
@axellpadilla
axellpadilla merged commit f51fd39 into master Jul 29, 2026
52 of 53 checks passed
@axellpadilla
axellpadilla deleted the feat/771-sqlserver-adbc-backend branch July 29, 2026 04:00
axellpadilla added a commit that referenced this pull request Aug 6, 2026
sqlserver__get_empty_subquery_sql neuters a probe query as
`select * from (...) where 1 = 0`, but a query opening with a CTE cannot be
wrapped that way and is passed through untouched, so it ran in full purely to
have its column names read. A snapshot executed its whole staging query once
for the probe and again to build the staging table; a contract-enforced model
ran twice per build. Draining that result set (previous commit) makes it safe
but not cheap, and the cost tracks data volume.

sp_describe_first_result_set compiles the query and reports its shape without
scanning, which is all this method ever wanted. It is already how
sqlserver__get_columns_in_query handles CTEs (#698). The macro cannot be the
fix point: its output is also embedded in a CREATE VIEW body by dbt-core's
unit-test materialization, where only a bare SELECT is legal, and reading
cursor.description of an `exec sp_describe...` batch would describe the
procedure's own result shape rather than the query's.

Reported names and types are unchanged. Reading cursor.description reports
Python classes, which collapse whole type families -- every integer width
arrives as int, every string type as varchar -- so the describe path is
mapped back onto exactly those names and fed through Column.create as before,
leaving the dbt_sqlserver_use_native_string_types flag working without the
mapping knowing it exists.

Rather than guess, the describe path declines and lets the caller execute
whenever it cannot guarantee agreement: a type outside the mapping, a query
sp_describe_first_result_set refuses to describe such as one reading a #temp
table, or a describe that returns nothing. Every gap degrades to slower,
never to silently different.

The name a type maps back to is also backend-specific, not just SQL Server's:
mssql-python decodes uniqueidentifier as uuid.UUID, datetimeoffset as
datetime and sql_variant as str, where pyodbc hands back str, bytearray and
bytearray for the same three. A fixed table can't serve both, so
_executed_name_for_system_type follows the backend in use (folded in from
master's 3354428, minus its ADBC Arrow-map portion -- no ADBC backend on
this branch). datetimeoffset on pyodbc depends on whether add_query has
registered the -155 output converter yet, so it's left unmapped there and
executes, agreeing with itself by construction; uniqueidentifier on pyodbc
depends on the process-global pyodbc.native_uuid flag, read at probe time.

TestCteProbeAvoidsExecution pins both halves: that a CTE-headed probe no
longer executes its query, and that the two branches agree across every
probed type on the backend under test.

Note: dropped the ADBC-backend guard clause from master's version (checking
is_adbc_backend before trusting the describe path), since v1.11 has no ADBC
backend (added later in #783) to disagree with it. Reapply that guard if
ADBC is ever backported here too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit 7cee257, with the
backend-aware type resolution folded in from 3354428)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Explore an experimental SQL Server ADBC backend

1 participant