From 703862b68734b806c42cf8fc8d86fd248633f8aa Mon Sep 17 00:00:00 2001 From: Josh Markovic Date: Wed, 24 Jun 2026 19:58:04 +0000 Subject: [PATCH 01/18] feat: add SQL Server 2025 support Cover SQL Server 2025 in the integration-test matrix (pyodbc and mssql-python on Python 3.13 / ODBC Driver 18) and add it to the published server CI images. Rename the server Dockerfile build arg SQLServer_VERSION to MSSQL_VERSION to match the build-arg the workflow already passes; with the old name the matrix version was ignored and every image used the 2022 default. Document 2025 as a supported version and refresh the stale dbt-core 0.14 compatibility note to 1.10. --- .../workflows/integration-tests-sqlserver.yml | 14 ++++++++++++- .github/workflows/publish-docker.yml | 2 +- CHANGELOG.md | 1 + README.md | 20 ++++++++++++++++--- devops/server.Dockerfile | 4 ++-- 5 files changed, 34 insertions(+), 7 deletions(-) diff --git a/.github/workflows/integration-tests-sqlserver.yml b/.github/workflows/integration-tests-sqlserver.yml index c31c55f6a..62b9a2b21 100644 --- a/.github/workflows/integration-tests-sqlserver.yml +++ b/.github/workflows/integration-tests-sqlserver.yml @@ -82,7 +82,19 @@ jobs: sqlserver_version: "2019" msodbc_version: "17" collation: SQL_Latin1_General_CP1_CI_AS - # Add the case-sensitive collation only on the latest SQL Server + # SQL Server 2025 (newest): cover both backends on the latest + # Python and ODBC driver (18). + - backend: pyodbc + python_version: "3.13" + sqlserver_version: "2025" + msodbc_version: "18" + collation: SQL_Latin1_General_CP1_CI_AS + - backend: mssql-python + python_version: "3.13" + sqlserver_version: "2025" + msodbc_version: "18" + collation: SQL_Latin1_General_CP1_CI_AS + # Add the case-sensitive collation on the SQL Server 2022 baseline # and latest Python/backend rows. - backend: pyodbc python_version: "3.13" diff --git a/.github/workflows/publish-docker.yml b/.github/workflows/publish-docker.yml index 0a7bdb8fd..ce9a1e9b1 100644 --- a/.github/workflows/publish-docker.yml +++ b/.github/workflows/publish-docker.yml @@ -44,7 +44,7 @@ jobs: publish-docker-server: strategy: matrix: - mssql_version: ["2017", "2019", "2022"] + mssql_version: ["2017", "2019", "2022", "2025"] runs-on: ubuntu-latest permissions: contents: read diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d6a2e9b4..9fe487b5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ - Add `drop_unmanaged_indexes` config (`false` (default) / `warn` / `true`) for indexes dbt didn't create. - Validate cross-index config conflicts (multiple clustered indexes, clustered vs `as_columnstore`). - Document the minimum supported SQL Server version (2017). Partitioning, `XML_COMPRESSION` and ordered columnstore are not yet expressible in the `indexes` config. +- Add SQL Server 2025 to the integration-test matrix (pyodbc and `mssql-python` backends, ODBC Driver 18) and document it as a supported version. ### v1.10.0 diff --git a/README.md b/README.md index 34f25d944..5bf677052 100644 --- a/README.md +++ b/README.md @@ -2,10 +2,24 @@ [dbt](https://www.getdbt.com) adapter for Microsoft SQL Server and Azure SQL services. -The adapter supports dbt-core 0.14 or newer and follows the same versioning scheme. -E.g. version 1.1.x of the adapter will be compatible with dbt-core 1.1.x. +The adapter supports dbt-core 1.10 or newer and follows the same versioning scheme. +E.g. version 1.10.x of the adapter will be compatible with dbt-core 1.10.x. -The minimum supported SQL Server version is SQL Server 2017. +## Supported SQL Server versions + +The adapter is tested against the following SQL Server versions: + +| SQL Server version | Supported | +|---|---| +| SQL Server 2017 | ✅ (minimum supported version) | +| SQL Server 2019 | ✅ | +| SQL Server 2022 | ✅ | +| SQL Server 2025 | ✅ | +| Azure SQL Database | ✅ | +| Azure SQL Managed Instance | ✅ | + +The minimum supported SQL Server version is SQL Server 2017; older versions are not supported. +SQL Server 2017, 2019, 2022, and 2025 are covered by the integration test suite, and Azure SQL services are tested separately. ## Documentation diff --git a/devops/server.Dockerfile b/devops/server.Dockerfile index d5743136d..a7a3fe082 100644 --- a/devops/server.Dockerfile +++ b/devops/server.Dockerfile @@ -1,5 +1,5 @@ -ARG SQLServer_VERSION="2022" -FROM mcr.microsoft.com/mssql/server:${SQLServer_VERSION}-latest +ARG MSSQL_VERSION="2022" +FROM mcr.microsoft.com/mssql/server:${MSSQL_VERSION}-latest ENV COLLATION="SQL_Latin1_General_CP1_CI_AS" From 391080c38043f277d530a70cab4a7c111222a053 Mon Sep 17 00:00:00 2001 From: Josh Markovic Date: Wed, 24 Jun 2026 19:58:04 +0000 Subject: [PATCH 02/18] docs: document the as_columnstore table config Explain that table materializations build a clustered columnstore index by default, and that as_columnstore: false is required for tables with (n)varchar(max)/LOB columns such as dbt store_failures audit tables. --- README.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/README.md b/README.md index 5bf677052..155d02129 100644 --- a/README.md +++ b/README.md @@ -161,6 +161,29 @@ flags: **Compatibility notes:** Enabling `dbt_sqlserver_use_dbt_transactions: true` may expose transaction-state assumptions hidden by autocommit-only mode. Explicit transaction macros may interact with dbt-managed transactions, and cleanup after failed DDL/DML may differ. Review pre/post hooks for in-transaction vs out-of-transaction semantics. +### `as_columnstore` + +*(default: `true`)* When building a table, the adapter creates a [clustered columnstore index](https://learn.microsoft.com/en-us/sql/relational-databases/indexes/columnstore-indexes-overview) (CCI) on it. Set `as_columnstore: false` to build a plain rowstore table instead. + +This matters for any table containing a `(n)varchar(max)` or other LOB column, because SQL Server does not allow those data types to participate in a columnstore index. The table build fails with: + +> Column '...' has a data type that cannot participate in a columnstore index. + +A common case is dbt's [test failure storage](https://docs.getdbt.com/reference/resource-configs/store_failures): the audit tables can contain `VARCHAR(MAX)` columns (dbt's `STRING` type maps to `VARCHAR(MAX)`), so disable the CCI on those resources: + +```yaml +# dbt_project.yml +data_tests: + +store_failures: true + +as_columnstore: false # avoids CCI on (n)varchar(max) audit columns +``` + +You can also set it per model: + +```sql +{{ config(materialized="table", as_columnstore=false) }} +``` + ## Contributing [![Unit tests](https://github.com/dbt-msft/dbt-sqlserver/actions/workflows/unit-tests.yml/badge.svg)](https://github.com/dbt-msft/dbt-sqlserver/actions/workflows/unit-tests.yml) From 7d8e90b0fa0f43cb6184aaf9500f6338763d3b78 Mon Sep 17 00:00:00 2001 From: Josh Markovic Date: Wed, 24 Jun 2026 19:58:04 +0000 Subject: [PATCH 03/18] test: keep empty audit table on passing store-failures run (#601) A passing test run with --store-failures must replace prior failures with an empty audit table rather than drop it. Adds a functional regression test for dbt-msft/dbt-sqlserver#601. --- .../mssql/test_store_failures_passing.py | 101 ++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 tests/functional/adapter/mssql/test_store_failures_passing.py diff --git a/tests/functional/adapter/mssql/test_store_failures_passing.py b/tests/functional/adapter/mssql/test_store_failures_passing.py new file mode 100644 index 000000000..728e08a0d --- /dev/null +++ b/tests/functional/adapter/mssql/test_store_failures_passing.py @@ -0,0 +1,101 @@ +# flake8: noqa: E501 +"""Regression test for dbt-msft/dbt-sqlserver#601. + +With ``--store-failures``, a test that *passes* must leave behind an empty +audit relation, not drop it. dbt's contract: "A test's results will always +replace previous failures for the same test, even if that test results in no +failures." The SQL Server adapter was reported to ``DROP`` the audit table on a +passing test instead of replacing it with an empty table (Postgres creates the +empty table). + +This exercises the exact reported scenario: a passing test configured with +``store_failures`` materialized as a ``table``. It asserts the audit relation +exists, is a base table (not a view), is empty, and survives idempotent re-runs. +""" +import pytest + +from dbt.tests.util import run_dbt + +# the default audit schema (_dbt_test__audit) plus the test schema can exceed +# identifier limits; use a short suffix as the rest of the suite does. +TEST_AUDIT_SCHEMA_SUFFIX = "dbt_test__aud" + +model__chipmunks = """ +select 1 as id, 'alvin' as name +union all +select 2 as id, 'simon' as name +""" + +# returns zero rows -> the test passes +test__passing_601 = """ +{{ config(store_failures=true, store_failures_as='table') }} +select * from {{ ref('chipmunks') }} +where 1 = 2 +""" + + +class TestStoreFailuresPassingKeepsEmptyTable: + @pytest.fixture(scope="class") + def models(self): + return {"chipmunks.sql": model__chipmunks} + + @pytest.fixture(scope="class") + def tests(self): + return {"passing_601.sql": test__passing_601} + + @pytest.fixture(scope="class") + def project_config_update(self): + return { + "vars": {"dbt_sqlserver_use_default_schema_concat": True}, + "data_tests": {"+schema": TEST_AUDIT_SCHEMA_SUFFIX}, + } + + @pytest.fixture(scope="function", autouse=True) + def setup(self, project): + self.audit_schema = f"{project.test_schema}_{TEST_AUDIT_SCHEMA_SUFFIX}" + run_dbt(["run"]) + yield + with project.adapter.connection_named("__test"): + relation = project.adapter.Relation.create( + database=project.database, schema=self.audit_schema + ) + project.adapter.drop_schema(relation) + + def _assert_empty_audit_table(self, project): + # type_desc proves the relation exists AND is a user table (not a view). + # On the #601 bug the relation is dropped, so this returns no rows. + # Queried via sys catalog (lowercase column names) so it is safe under a + # case-sensitive database collation. + rows = project.run_sql( + f""" + select o.type_desc + from sys.objects o + join sys.schemas s on o.schema_id = s.schema_id + where s.name = '{self.audit_schema}' + and o.name = 'passing_601' + """, + fetch="all", + ) + assert len(rows) == 1 and rows[0][0] == "USER_TABLE", ( + f"audit relation [{self.audit_schema}].[passing_601] should be a user " + f"table that persists after a passing store-failures run, got: " + f"{[tuple(r) for r in rows]}" + ) + # and it must be empty (the failures were replaced with nothing) + count = project.run_sql( + f"select count(*) from [{self.audit_schema}].[passing_601]", + fetch="one", + ) + assert count[0] == 0, f"audit table should be empty, has {count[0]} rows" + + def test_passing_test_keeps_empty_audit_table(self, project): + results = run_dbt(["test", "--store-failures"], expect_pass=True) + assert len(results) == 1 + assert results[0].status == "pass" + assert results[0].failures == 0 + self._assert_empty_audit_table(project) + + # idempotency: a second run must still leave the empty table in place + results = run_dbt(["test", "--store-failures"], expect_pass=True) + assert results[0].status == "pass" + self._assert_empty_audit_table(project) From 66634a1c6f2e9b8638c7e0508f565520312feca1 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 24 Jun 2026 20:30:43 +0000 Subject: [PATCH 04/18] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- tests/functional/adapter/mssql/test_store_failures_passing.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/functional/adapter/mssql/test_store_failures_passing.py b/tests/functional/adapter/mssql/test_store_failures_passing.py index 728e08a0d..7aa44f180 100644 --- a/tests/functional/adapter/mssql/test_store_failures_passing.py +++ b/tests/functional/adapter/mssql/test_store_failures_passing.py @@ -12,6 +12,7 @@ ``store_failures`` materialized as a ``table``. It asserts the audit relation exists, is a base table (not a view), is empty, and survives idempotent re-runs. """ + import pytest from dbt.tests.util import run_dbt From c0298e25f2926dcfb4b33ebf95f1f07b92220c80 Mon Sep 17 00:00:00 2001 From: Axell Padilla <68310020+axellpadilla@users.noreply.github.com> Date: Thu, 25 Jun 2026 02:34:21 +0000 Subject: [PATCH 05/18] Add CHANGELOG entries for v1.10.1 release Includes bugfixes for hook transaction safety, columnstore guards, query_tag escaping, float mapping, default port, drop_schema_name, and access token errors. New features for dbt transaction handling, types, persist_docs, and mssql-python backend. Internal improvements consolidating linting to Ruff and dependency updates. --- CHANGELOG.md | 35 ++++++++++++++++++++++++++--------- 1 file changed, 26 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 73568a9c6..83acd0905 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,10 +4,20 @@ #### Features -- Add `dbt_sqlserver_enable_safe_type_expansion` behaviour flag to allow safe column type widening during schema expansion: `varchar` → `nvarchar`, integer family promotions (`bit` → `tinyint` → `smallint` → `int` → `bigint`), and `numeric`/`decimal` precision/scale upgrades. Gated by the per-model `column_type_expansion_max_rows` config (default 1,000,000 rows). See [#699](https://github.com/dbt-msft/dbt-sqlserver/issues/699). +- Add Postgres-style `indexes` model config for tables, incrementals, seeds and snapshots, covering most `CREATE INDEX` options. [#535](https://github.com/dbt-msft/dbt-sqlserver/issues/535) +- Index names are deterministic definition hashes (`dbt_idx_` prefix); creation is idempotent and unchanged definitions are never rebuilt. +- Reconcile indexes against the config on incremental, DML-refresh and snapshot runs, applied as one atomic batch. Constraint-backing, legacy post-hook and `as_columnstore` indexes are never dropped. +- Index introspection reads the catalog lock-free throughout (`NOLOCK` on every `sys` view, not just `sys.indexes`), so it no longer queues behind concurrent index DDL. +- Add `drop_unmanaged_indexes` config (`false` (default) / `warn` / `true`) for indexes dbt didn't create. +- Validate cross-index config conflicts (multiple clustered indexes, clustered vs `as_columnstore`). +- Document the minimum supported SQL Server version (2017). Partitioning, `XML_COMPRESSION` and ordered columnstore are not yet expressible in the `indexes` config. +- Add `dbt_sqlserver_enable_safe_type_expansion` behaviour flag to allow safe column type widening during schema expansion: `varchar` → `nvarchar`, integer family promotions (`bit` → `tinyint` → `smallint` → `int` → `bigint`), and `numeric`/`decimal` precision/scale upgrades. Gated by the per-model `column_type_expansion_max_rows` config (default 1,000,000 rows). [#699](https://github.com/dbt-msft/dbt-sqlserver/issues/699). - Add `prefer_single_alter_column` model config to use a single `ALTER COLUMN` statement instead of the add+update+drop+rename pattern when altering column types on tables. - Add `string_type_instance()` to preserve the NVARCHAR/NCHAR type family during column expansion, fixing incorrect promotion of NVARCHAR/NCHAR to VARCHAR. - Add `tinyint` and `bit` to the `is_integer()` type list for correct type detection. +- Add `dbt_sqlserver_use_dbt_transactions` flag for proper T-SQL `BEGIN TRAN`/`COMMIT`/`ROLLBACK` transaction handling. When enabled, operations use real server-side transactions instead of the default autocommit mode. [#708](https://github.com/dbt-msft/dbt-sqlserver/issues/708) +- Implement relation and column comment persistence via `persist_docs`. [#289](https://github.com/dbt-msft/dbt-sqlserver/issues/289) +- Add optional `mssql-python` connection backend alongside the default `pyodbc` backend. [#681](https://github.com/dbt-msft/dbt-sqlserver/issues/681) #### Bugfixes @@ -15,16 +25,23 @@ - Fix catalog generation for NVARCHAR/NCHAR columns: use `user_type_id` instead of `system_type_id` in catalog.sql, preventing them from appearing as `SYSNAME` in `dbt docs`. [#637](https://github.com/dbt-msft/dbt-sqlserver/issues/637) - Fix `varchar(max)` / `nvarchar(max)` columns being incorrectly treated as size `-1` during type expansion, preventing `varchar(max)` → `varchar(100)` narrowing and properly allowing `varchar(100)` → `varchar(max)` expansion. - Fix seed table ingestion of empty numeric cells by inlining `null` literals instead of binding parameters. [#425](https://github.com/dbt-msft/dbt-sqlserver/issues/425) +- Guard `run_hooks` commit with `@@trancount` check for autocommit safety when running post-hooks. [#444](https://github.com/dbt-msft/dbt-sqlserver/issues/444) +- Fix `columnstore IF EXISTS` guard to check `object_id('schema.table')` correctly. +- Escape single quotes in `query_tag` before building `OPTION (LABEL)` clause. +- Map Python `float` to SQL Server `float`, not `bigint`. +- Set default port to `1433` (instead of Postgres `5432`) in `dbt init` profile template. +- Make `drop_schema_name` usable again. [#563](https://github.com/dbt-msft/dbt-sqlserver/issues/563) +- Raise `DbtRuntimeError` instead of `ValueError` for missing access token. -#### Features +#### Under the hood -- Add Postgres-style `indexes` model config for tables, incrementals, seeds and snapshots, covering most `CREATE INDEX` options. [#535](https://github.com/dbt-msft/dbt-sqlserver/issues/535) -- Index names are deterministic definition hashes (`dbt_idx_` prefix); creation is idempotent and unchanged definitions are never rebuilt. -- Reconcile indexes against the config on incremental, DML-refresh and snapshot runs, applied as one atomic batch. Constraint-backing, legacy post-hook and `as_columnstore` indexes are never dropped. -- Index introspection reads the catalog lock-free throughout (`NOLOCK` on every `sys` view, not just `sys.indexes`), so it no longer queues behind concurrent index DDL. -- Add `drop_unmanaged_indexes` config (`false` (default) / `warn` / `true`) for indexes dbt didn't create. -- Validate cross-index config conflicts (multiple clustered indexes, clustered vs `as_columnstore`). -- Document the minimum supported SQL Server version (2017). Partitioning, `XML_COMPRESSION` and ordered columnstore are not yet expressible in the `indexes` config. +- Consolidate linting tooling (isort, flake8, pycln, absolufy-imports) into Ruff. [#707](https://github.com/dbt-msft/dbt-sqlserver/issues/707) +- Drop Python 3.9 from publish-docker CI matrix. +- Remove dead clone macro and unused view scaffolding variable. +- Standardise devcontainer ODBC environment setup. [#722](https://github.com/dbt-msft/dbt-sqlserver/issues/722) +- Disable dbt telemetry and version check in test environment. +- Add `pytest-cov` and fix dead `from_dict` override found by coverage. +- Bump pip and GitHub Actions dependencies. ### v1.10.0 From bddbfe8b1430eaab580ac82c7f0d3465bffc5d18 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 25 Jun 2026 03:22:20 +0000 Subject: [PATCH 06/18] chore(deps): bump actions/checkout in the github-actions-updates group Bumps the github-actions-updates group with 1 update: [actions/checkout](https://github.com/actions/checkout). Updates `actions/checkout` from 6 to 7 - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v6...v7) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions-updates ... Signed-off-by: dependabot[bot] --- .github/workflows/integration-tests-sqlserver.yml | 2 +- .github/workflows/publish-docker.yml | 4 ++-- .github/workflows/release-version.yml | 2 +- .github/workflows/unit-tests.yml | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/integration-tests-sqlserver.yml b/.github/workflows/integration-tests-sqlserver.yml index c31c55f6a..4cca4171c 100644 --- a/.github/workflows/integration-tests-sqlserver.yml +++ b/.github/workflows/integration-tests-sqlserver.yml @@ -112,7 +112,7 @@ jobs: DBT_TEST_USER_3: DBT_TEST_USER_3 COLLATION: ${{ matrix.collation }} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Install uv run: pip install uv diff --git a/.github/workflows/publish-docker.yml b/.github/workflows/publish-docker.yml index 0a7bdb8fd..553e2759c 100644 --- a/.github/workflows/publish-docker.yml +++ b/.github/workflows/publish-docker.yml @@ -21,7 +21,7 @@ jobs: packages: write steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Log in to the Container registry uses: docker/login-action@v4.2.0 @@ -51,7 +51,7 @@ jobs: packages: write steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Log in to the Container registry uses: docker/login-action@v4.2.0 diff --git a/.github/workflows/release-version.yml b/.github/workflows/release-version.yml index 4711bbace..efa2c58fc 100644 --- a/.github/workflows/release-version.yml +++ b/.github/workflows/release-version.yml @@ -11,7 +11,7 @@ jobs: name: Release new version runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - uses: actions/setup-python@v6 with: diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index a461bac2a..eab65fbd0 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -30,7 +30,7 @@ jobs: password: ${{ secrets.github_token }} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Install uv run: pip install uv From c137d8053e7fcdc40aec19b08a1d8d599827861f Mon Sep 17 00:00:00 2001 From: joshmarkovic <52184130+joshmarkovic@users.noreply.github.com> Date: Wed, 24 Jun 2026 23:26:57 -0400 Subject: [PATCH 07/18] Update README.md Co-authored-by: Axell Padilla <68310020+axellpadilla@users.noreply.github.com> --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 155d02129..839130cc4 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,9 @@ The adapter is tested against the following SQL Server versions: | Azure SQL Managed Instance | ✅ | The minimum supported SQL Server version is SQL Server 2017; older versions are not supported. -SQL Server 2017, 2019, 2022, and 2025 are covered by the integration test suite, and Azure SQL services are tested separately. +## Supported SQL Server versions + +SQL Server 2017, 2019, 2022, and 2025 are covered by the integration test suite. Azure SQL Database and Azure SQL Managed Instance are not covered by the integration test suite, but are expected to be compatible. ## Documentation From ad899620908439470bd182095dfec68ed5a3c52e Mon Sep 17 00:00:00 2001 From: Josh Markovic Date: Thu, 25 Jun 2026 14:25:20 +0000 Subject: [PATCH 08/18] ci: promote SQL Server 2025 to baseline and fix README support section Addresses @axellpadilla's review on #721. - integration tests: make 2025 the matrix baseline, replacing 2022 as the latest tier. Keep 2017, 2019 and 2022 as single legacy rows so all four versions stay covered by CI. - README: remove the duplicate "Supported SQL Server versions" heading and the Azure SQL Database / Managed Instance rows that were marked tested. Azure is now described as not covered by CI but expected to be compatible. --- .../workflows/integration-tests-sqlserver.yml | 31 +++++++------------ README.md | 3 -- 2 files changed, 12 insertions(+), 22 deletions(-) diff --git a/.github/workflows/integration-tests-sqlserver.yml b/.github/workflows/integration-tests-sqlserver.yml index 62b9a2b21..0aa6f2f34 100644 --- a/.github/workflows/integration-tests-sqlserver.yml +++ b/.github/workflows/integration-tests-sqlserver.yml @@ -43,31 +43,31 @@ jobs: matrix: python_version: ["3.10", "3.11", "3.12", "3.13"] backend: [pyodbc, mssql-python] - # Baseline on 2022 - sqlserver_version: ["2022"] + # Baseline on 2025 (newest) + sqlserver_version: ["2025"] msodbc_version: ["18"] collation: [SQL_Latin1_General_CP1_CI_AS] exclude: - backend: mssql-python python_version: "3.10" - sqlserver_version: "2022" + sqlserver_version: "2025" - backend: mssql-python python_version: "3.11" - sqlserver_version: "2022" + sqlserver_version: "2025" - backend: mssql-python python_version: "3.12" - sqlserver_version: "2022" + sqlserver_version: "2025" include: # Keep pyodbc on every supported Python version, but retain # SQL Server ODBC 17 coverage for the oldest and newest Python. - backend: pyodbc python_version: "3.10" - sqlserver_version: "2022" + sqlserver_version: "2025" msodbc_version: "17" collation: SQL_Latin1_General_CP1_CI_AS - backend: pyodbc python_version: "3.13" - sqlserver_version: "2022" + sqlserver_version: "2025" msodbc_version: "17" collation: SQL_Latin1_General_CP1_CI_AS # Older SQL Server versions stay on pyodbc only, with a single @@ -82,29 +82,22 @@ jobs: sqlserver_version: "2019" msodbc_version: "17" collation: SQL_Latin1_General_CP1_CI_AS - # SQL Server 2025 (newest): cover both backends on the latest - # Python and ODBC driver (18). - backend: pyodbc python_version: "3.13" - sqlserver_version: "2025" - msodbc_version: "18" - collation: SQL_Latin1_General_CP1_CI_AS - - backend: mssql-python - python_version: "3.13" - sqlserver_version: "2025" - msodbc_version: "18" + sqlserver_version: "2022" + msodbc_version: "17" collation: SQL_Latin1_General_CP1_CI_AS - # Add the case-sensitive collation on the SQL Server 2022 baseline + # Add the case-sensitive collation on the SQL Server 2025 baseline # and latest Python/backend rows. - backend: pyodbc python_version: "3.13" - sqlserver_version: "2022" + sqlserver_version: "2025" msodbc_version: "17" collation: SQL_Latin1_General_CP1_CS_AS # mssql-python stays on the latest Python only. - backend: mssql-python python_version: "3.13" - sqlserver_version: "2022" + sqlserver_version: "2025" msodbc_version: "18" collation: SQL_Latin1_General_CP1_CS_AS runs-on: ubuntu-latest diff --git a/README.md b/README.md index 7d3aba732..5ce591896 100644 --- a/README.md +++ b/README.md @@ -15,11 +15,8 @@ The adapter is tested against the following SQL Server versions: | SQL Server 2019 | ✅ | | SQL Server 2022 | ✅ | | SQL Server 2025 | ✅ | -| Azure SQL Database | ✅ | -| Azure SQL Managed Instance | ✅ | The minimum supported SQL Server version is SQL Server 2017; older versions are not supported. -## Supported SQL Server versions SQL Server 2017, 2019, 2022, and 2025 are covered by the integration test suite. Azure SQL Database and Azure SQL Managed Instance are not covered by the integration test suite, but are expected to be compatible. From 8d9d883ae56f0bde6744bea40d8b523392c5a710 Mon Sep 17 00:00:00 2001 From: Josh Markovic Date: Sat, 27 Jun 2026 11:33:54 -0400 Subject: [PATCH 09/18] fix: gate 2019+ CREATE INDEX options on detected engine version The index feature emits OPTIMIZE_FOR_SEQUENTIAL_KEY and RESUMABLE (plus RESUMABLE's MAX_DURATION) unconditionally. These are only recognized on SQL Server 2019 (major 15)+, so on a genuine 2017 engine the CREATE INDEX fails with "is not a recognized CREATE INDEX option" (msg 155). This was masked until now: the server-2017/2019/2022 CI images were all built FROM ...:2022-latest because publish-docker passed build-arg MSSQL_VERSION while server.Dockerfile declared SQLServer_VERSION. This PR fixes that arg name, so the 2017 leg finally runs real 2017 and exposed the incompatibility. We support 2017 (Microsoft still does), so detect the engine major version and, when < 15, drop these options (with a warning) and build the index without them instead of failing. ONLINE is kept (recognized on 2017, edition-gated not version-gated). Behavior on 2019+ is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 1 + .../sqlserver/macros/adapters/indexes.sql | 46 +++++++++++++++++-- 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a83fb038..7e5479754 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ - Fix seed table ingestion of empty numeric cells by inlining `null` literals instead of binding parameters. [#425](https://github.com/dbt-msft/dbt-sqlserver/issues/425) - Guard `run_hooks` commit with `@@trancount` check for autocommit safety when running post-hooks. [#444](https://github.com/dbt-msft/dbt-sqlserver/issues/444) - Fix `columnstore IF EXISTS` guard to check `object_id('schema.table')` correctly. +- Gate the `optimize_for_sequential_key` and `resumable` index options on the detected engine version: they require SQL Server 2019+, so on 2017/2016 the index is now built without them (with a warning) instead of failing with "is not a recognized CREATE INDEX option". - Escape single quotes in `query_tag` before building `OPTION (LABEL)` clause. - Map Python `float` to SQL Server `float`, not `bigint`. - Set default port to `1433` (instead of Postgres `5432`) in `dbt init` profile template. diff --git a/dbt/include/sqlserver/macros/adapters/indexes.sql b/dbt/include/sqlserver/macros/adapters/indexes.sql index 72802eab2..464e04087 100644 --- a/dbt/include/sqlserver/macros/adapters/indexes.sql +++ b/dbt/include/sqlserver/macros/adapters/indexes.sql @@ -238,6 +238,20 @@ {% endmacro %} +{% macro sqlserver__server_major_version() -%} + {#- Detected engine major version: 13 = 2016, 14 = 2017, 15 = 2019, + 16 = 2022, 17 = 2025. parsename() on the always-4-part productversion + works on every supported release, unlike + SERVERPROPERTY('ProductMajorVersion') which is 2014+ only. Returns none + outside an executing context (e.g. parse time). -#} + {%- if not execute -%}{{ return(none) }}{%- endif -%} + {%- set result = run_query( + "select cast(parsename(cast(serverproperty('productversion') as varchar(128)), 4) as int) as major_version" + ) -%} + {{ return(result.columns[0].values()[0]) }} +{%- endmacro %} + + {% macro sqlserver__get_create_index_sql(relation, index_dict) -%} {%- set index_config = adapter.parse_index(index_dict) -%} {%- set index_name = index_config.render(relation) -%} @@ -274,6 +288,30 @@ {% if index_config.where -%} where {{ index_config.where }} {% endif %} + {#- optimize_for_sequential_key, RESUMABLE and RESUMABLE's MAX_DURATION are + CREATE INDEX options that SQL Server only recognizes on 2019 (major 15) + and newer. This adapter still supports 2017/2016, so on older engines + they are dropped (with a warning) and the index is built without them, + rather than failing with "is not a recognized CREATE INDEX option". + ONLINE is intentionally kept: it is recognized on 2017 (edition-gated, + not version-gated). The server version is only queried when one of these + options is actually requested, so the common path adds no round-trip. -#} + {%- set v2019_only_build_options = ['resumable', 'max_duration'] -%} + {%- set _build_options = index_config.build_options or {} -%} + {%- set _wants_v2019_option = index_config.optimize_for_sequential_key + or _build_options.get('resumable') or _build_options.get('max_duration') -%} + {%- set drop_v2019_options = false -%} + {%- if _wants_v2019_option -%} + {%- set _major = sqlserver__server_major_version() -%} + {%- if _major is not none and _major < 15 -%} + {%- set drop_v2019_options = true -%} + {%- do log( + "Index [" ~ index_name ~ "] on " ~ relation ~ ": optimize_for_sequential_key" + ~ " / resumable require SQL Server 2019 (15.x) or newer; building the index" + ~ " without them on detected major version " ~ _major ~ ".", info=true + ) -%} + {%- endif -%} + {%- endif -%} {%- set with_options = [] -%} {%- if index_config.data_compression -%} {%- do with_options.append('data_compression = ' ~ index_config.data_compression | upper) -%} @@ -287,14 +325,16 @@ {%- if index_config.ignore_dup_key -%} {%- do with_options.append('ignore_dup_key = on') -%} {%- endif -%} - {%- if index_config.optimize_for_sequential_key -%} + {%- if index_config.optimize_for_sequential_key and not drop_v2019_options -%} {%- do with_options.append('optimize_for_sequential_key = on') -%} {%- endif -%} {%- if index_config.sort_in_tempdb -%} {%- do with_options.append('sort_in_tempdb = on') -%} {%- endif -%} - {%- for option_key, option_value in (index_config.build_options or {}).items() -%} - {%- if option_value is sameas true -%} + {%- for option_key, option_value in _build_options.items() -%} + {%- if drop_v2019_options and option_key in v2019_only_build_options -%} + {#- skipped: not recognized before SQL Server 2019 -#} + {%- elif option_value is sameas true -%} {%- do with_options.append(option_key ~ ' = on') -%} {%- elif option_value is sameas false -%} {%- do with_options.append(option_key ~ ' = off') -%} From 6b0c70b9623aa5a22a3c6a12f998ea8850c72830 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 29 Jun 2026 20:08:58 +0000 Subject: [PATCH 10/18] [pre-commit.ci] pre-commit autoupdate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/astral-sh/ruff-pre-commit: v0.15.17 → v0.15.20](https://github.com/astral-sh/ruff-pre-commit/compare/v0.15.17...v0.15.20) --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b122e48b8..382e1a539 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -28,7 +28,7 @@ repos: - '-d {extends: default, rules: {line-length: disable, document-start: disable}}' - '-s' - repo: 'https://github.com/astral-sh/ruff-pre-commit' - rev: v0.15.17 + rev: v0.15.20 hooks: - id: ruff-check args: From 216e00aee3883c91f31ea42fc46b078b9f9cfe46 Mon Sep 17 00:00:00 2001 From: Josh Markovic Date: Mon, 29 Jun 2026 19:36:00 +0000 Subject: [PATCH 11/18] docs: correct stale version, release, and CI references - README: the adapter requires dbt-core 1.10+ (pyproject pins >=1.10.0,<2.0), not 0.14; update the versioning example to 1.10.x. - CONTRIBUTING: drop the `integration-tests-azure` pipeline from the list; no such workflow exists under .github/workflows. - CONTRIBUTING: releases are cut by publishing a GitHub Release (the release-version workflow triggers on release publish, not a bare tag push), and the dbt-core constraint lives in pyproject.toml dependencies, not a `dbt_version` in a nonexistent setup.py. --- CONTRIBUTING.md | 7 +++---- README.md | 4 ++-- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 23635511a..47b5d28db 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -106,7 +106,6 @@ All CI/CD pipelines are using GitHub Actions. The following pipelines are availa * `publish-docker`: publishes the image we use in all other pipelines. * `unit-tests`: runs the unit tests for each supported Python version. -* `integration-tests-azure`: runs the integration tests for Azure SQL Server. * `integration-tests-sqlserver`: runs the integration tests for SQL Server. * `release-version`: publishes the adapter to PyPI. @@ -128,7 +127,7 @@ The following environment variables are available: ## Releasing a new version -Make sure the version number is bumped in `__version__.py`. Then, create a git tag named `v` and push it to GitHub. -A GitHub Actions workflow will be triggered to build the package and push it to PyPI. +Make sure the version number is bumped in `dbt/adapters/sqlserver/__version__.py`. Then publish a GitHub Release with a tag named `v`. +A GitHub Actions workflow will be triggered to build the package and push it to PyPI. -If you're releasing support for a new version of `dbt-core`, also bump the `dbt_version` in `setup.py`. +If you're releasing support for a new version of `dbt-core`, also bump the `dbt-core` constraint in `dependencies` in `pyproject.toml`. diff --git a/README.md b/README.md index 34f25d944..22a9d4805 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,8 @@ [dbt](https://www.getdbt.com) adapter for Microsoft SQL Server and Azure SQL services. -The adapter supports dbt-core 0.14 or newer and follows the same versioning scheme. -E.g. version 1.1.x of the adapter will be compatible with dbt-core 1.1.x. +The adapter supports dbt-core 1.10 or newer and follows the same versioning scheme. +E.g. version 1.10.x of the adapter is compatible with dbt-core 1.10.x. The minimum supported SQL Server version is SQL Server 2017. From 901d4f442a10afc0344f71018d45049d647ae144 Mon Sep 17 00:00:00 2001 From: Axell Padilla <68310020+axellpadilla@users.noreply.github.com> Date: Tue, 30 Jun 2026 05:21:11 +0000 Subject: [PATCH 12/18] fix: set default value for column_type_expansion_max_rows to 1000000 and add test for widening --- dbt/adapters/sqlserver/sqlserver_configs.py | 2 +- .../adapter/dbt/test_incremental.py | 108 ++++++++++++++++++ 2 files changed, 109 insertions(+), 1 deletion(-) diff --git a/dbt/adapters/sqlserver/sqlserver_configs.py b/dbt/adapters/sqlserver/sqlserver_configs.py index 5ff178ecb..f0277f224 100644 --- a/dbt/adapters/sqlserver/sqlserver_configs.py +++ b/dbt/adapters/sqlserver/sqlserver_configs.py @@ -9,7 +9,7 @@ class SQLServerConfigs(AdapterConfig): auto_provision_aad_principals: Optional[bool] = False prefer_single_alter_column: Optional[bool] = False - column_type_expansion_max_rows: Optional[int] = None + column_type_expansion_max_rows: int = 1000000 indexes: Optional[Tuple[SQLServerIndexConfig, ...]] = None # false (default) | warn | true - how index reconciliation treats # droppable indexes dbt didn't create (YAML may supply bool or str) diff --git a/tests/functional/adapter/dbt/test_incremental.py b/tests/functional/adapter/dbt/test_incremental.py index e08cd7a9e..1edc3ad4f 100644 --- a/tests/functional/adapter/dbt/test_incremental.py +++ b/tests/functional/adapter/dbt/test_incremental.py @@ -8,6 +8,33 @@ TestIncrementalPredicatesDeleteInsert, TestPredicatesDeleteInsert, ) +from dbt.tests.util import run_dbt, write_file + + +def _column_metadata(project, schema, table, column): + rows = project.run_sql( + f""" + select + t.name, + c.max_length, + c.precision, + c.scale + from [{project.database}].sys.columns c + inner join [{project.database}].sys.types t + on c.user_type_id = t.user_type_id + where c.object_id = object_id('[{project.database}].[{schema}].[{table}]') + and c.name = '{column}' + """, + fetch="all", + ) + assert rows, f"Missing column metadata for {schema}.{table}.{column}" + + data_type, max_length, numeric_precision, numeric_scale = rows[0] + if data_type in ("nchar", "nvarchar", "sysname") and max_length is not None: + max_length //= 2 + return data_type, max_length, None, None + return data_type, None, numeric_precision, numeric_scale + _MODELS__INCREMENTAL_IGNORE_SQLServer = """ {{ @@ -99,3 +126,84 @@ class TestIncrementalPredicatesDeleteInsert(TestIncrementalPredicatesDeleteInser class TestPredicatesDeleteInsert(TestPredicatesDeleteInsert): pass + + +_INCREMENTAL__WIDEN_TYPES_SQLServer = """ +{{ + config( + materialized='incremental', + unique_key='id', + on_schema_change='append_new_columns' + ) +}} + +{% if is_incremental() %} +-- incremental branch: uses larger types and values that would fail if table types were not widened +select + 2 as id, + cast(40000 as int) as num_int, + cast('abcdef' as nvarchar(10)) as field1, + cast(100.25 as decimal(10,4)) as num_decimal, + cast(999999999999998.9999 as decimal(20,4)) as num_money +{% else %} +-- full-refresh branch: creates the table with smaller types +select + 1 as id, + cast(1 as smallint) as num_int, + cast('abc' as varchar(5)) as field1, + cast(10.5 as decimal(5,2)) as num_decimal, + cast(1240.14 as money) as num_money +{% endif %} +""" + + +class TestIncrementalOnSchemaChangeExpands: + @pytest.fixture(scope="class") + def project_config_update(self): + return {"flags": {"dbt_sqlserver_enable_safe_type_expansion": True}} + + def test_run_incremental_widen_types(self, project): + """Full-refresh to create small types, then incremental to widen types.""" + write_file(_INCREMENTAL__WIDEN_TYPES_SQLServer, "models", "incremental_change_widen.sql") + + # Full-refresh to create table with smallint and varchar(5) + run_dbt( + ["run", "--models", "incremental_change_widen", "--full-refresh"] + ) # creates small types + + # Run again to trigger incremental insert which requires widened types + # incremental branch inserts larger values + run_dbt(["run", "--models", "incremental_change_widen"]) + + assert _column_metadata( + project, project.test_schema, "incremental_change_widen", "field1" + ) == ( + "nvarchar", + 10, + None, + None, + ) + assert _column_metadata( + project, project.test_schema, "incremental_change_widen", "num_int" + ) == ( + "int", + None, + 10, + 0, + ) + assert _column_metadata( + project, project.test_schema, "incremental_change_widen", "num_decimal" + ) == ( + "decimal", + None, + 10, + 4, + ) + assert _column_metadata( + project, project.test_schema, "incremental_change_widen", "num_money" + ) == ( + "decimal", + None, + 20, + 4, + ) From 541ce1a361e611411deece14e8cb910cf2c28eac Mon Sep 17 00:00:00 2001 From: Josh Markovic Date: Thu, 2 Jul 2026 18:34:05 +0000 Subject: [PATCH 13/18] fix: escape embedded closing braces in pyodbc connection string values The pyodbc branch of format_connection_string_value wrapped values in braces without doubling embedded closing braces, so a password like pa}ss produced PWD={pa}ss} and the driver misparsed the rest of the connection string. The mssql-python branch already escaped correctly. Double the closing brace per the ODBC quoting rules and cover both backends with unit tests. --- dbt/adapters/sqlserver/sqlserver_helpers.py | 10 ++++++++-- .../mssql/test_sqlserver_connection_manager.py | 13 +++++++++++++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/dbt/adapters/sqlserver/sqlserver_helpers.py b/dbt/adapters/sqlserver/sqlserver_helpers.py index 83d940a59..4d40cade2 100644 --- a/dbt/adapters/sqlserver/sqlserver_helpers.py +++ b/dbt/adapters/sqlserver/sqlserver_helpers.py @@ -210,11 +210,17 @@ def build_server_arg(credentials: SQLServerCredentials) -> str: def format_connection_string_value(value: Optional[str], mssql_python_backend: bool) -> str: - """Format a connection-string value for the requested backend.""" + """Format a connection-string value for the requested backend. + + Inside a brace-quoted ODBC value a literal ``}`` must be doubled to ``}}``; + otherwise the driver treats it as the closing brace and misparses the rest + of the connection string. + """ if mssql_python_backend: return escape_connection_string_value(value) - return "{" + ("" if value is None else value) + "}" + text = "" if value is None else str(value) + return "{" + text.replace("}", "}}") + "}" def format_pyodbc_driver_value(value: Optional[str]) -> str: diff --git a/tests/unit/adapters/mssql/test_sqlserver_connection_manager.py b/tests/unit/adapters/mssql/test_sqlserver_connection_manager.py index fb61e875f..58fa2db2a 100644 --- a/tests/unit/adapters/mssql/test_sqlserver_connection_manager.py +++ b/tests/unit/adapters/mssql/test_sqlserver_connection_manager.py @@ -38,6 +38,7 @@ from dbt.adapters.sqlserver.sqlserver_helpers import ( bool_to_connection_string_arg, escape_connection_string_value, + format_connection_string_value, is_mssql_python_backend, sanitize_connection_string_for_logging, validate_connection_requirements, @@ -349,6 +350,18 @@ def test_escape_connection_string_value_quotes_only_when_needed() -> None: assert escape_connection_string_value("trailing ") == "{trailing }" +def test_format_connection_string_value_doubles_braces_for_pyodbc() -> None: + assert format_connection_string_value("plain", mssql_python_backend=False) == "{plain}" + assert format_connection_string_value("pa}ss", mssql_python_backend=False) == "{pa}}ss}" + assert format_connection_string_value("}{", mssql_python_backend=False) == "{}}{}" + assert format_connection_string_value(None, mssql_python_backend=False) == "{}" + + +def test_format_connection_string_value_delegates_for_mssql_python() -> None: + assert format_connection_string_value("plain", mssql_python_backend=True) == "plain" + assert format_connection_string_value("pa}ss", mssql_python_backend=True) == "{pa}}ss}" + + def test_sanitize_connection_string_for_logging_redacts_common_secret_fields() -> None: sanitized = sanitize_connection_string_for_logging( "SERVER=fake;UID=user@example.com;User Id=another@example.com;" From 44f92812d2880c823a42d6f4cb8482ff181e2e13 Mon Sep 17 00:00:00 2001 From: Josh Markovic Date: Mon, 29 Jun 2026 19:34:07 +0000 Subject: [PATCH 14/18] fix: honor configured query retries and stop retry-event crash The query-level retry in SQLServerConnectionManager.add_query had two defects: - A `credentials.retries > 3` guard silently ignored configured retry counts of 1-3 and fell back to a hardcoded limit of 2, so even the default `retries: 3` only attempted a query twice. Use `credentials.retries` directly, matching how open() drives connection retries. - The retry-debug event was built as `AdapterEventDebug(message=...)`, but the event field is `base_msg`; constructing it raised a protobuf ParseError on the first retryable error, so retries crashed instead of retrying. Use `base_msg=` as dbt-core's base add_query does. Add unit tests covering retry-until-success, the retries=3 regression, no-retry at retries=1, and non-retryable errors not being retried. --- .../sqlserver/sqlserver_connections.py | 14 +- .../test_sqlserver_connection_manager.py | 138 ++++++++++++++++++ 2 files changed, 150 insertions(+), 2 deletions(-) diff --git a/dbt/adapters/sqlserver/sqlserver_connections.py b/dbt/adapters/sqlserver/sqlserver_connections.py index 3291d6ca5..057f6064c 100644 --- a/dbt/adapters/sqlserver/sqlserver_connections.py +++ b/dbt/adapters/sqlserver/sqlserver_connections.py @@ -224,8 +224,12 @@ def _execute_query_with_retry( raise e fire_event( + # NB: the field is ``base_msg`` (as in dbt-core's base + # ``add_query``); ``message=`` raises a protobuf ParseError + # at event construction, which previously crashed the first + # retry instead of retrying. AdapterEventDebug( - message=( + base_msg=( f"Got a retryable error {type(e)}. {retry_limit - attempt} " "retries left. Retrying in 1 second.\n" f"Error:\n{e}" @@ -277,7 +281,13 @@ def _execute_query_with_retry( sql=sql, bindings=bindings, retryable_exceptions=retryable_exceptions, - retry_limit=(credentials.retries if credentials.retries > 3 else retry_limit), + # The connection's configured ``retries`` is authoritative for + # query retries, mirroring ``open()`` which passes + # ``credentials.retries`` to ``retry_connection``. A previous + # ``credentials.retries > 3`` guard silently ignored configured + # values of 1-3 and used the hardcoded ``retry_limit`` of 2, so + # even the default ``retries: 3`` only attempted a query twice. + retry_limit=credentials.retries, attempt=1, ) diff --git a/tests/unit/adapters/mssql/test_sqlserver_connection_manager.py b/tests/unit/adapters/mssql/test_sqlserver_connection_manager.py index fb61e875f..da3ed3e87 100644 --- a/tests/unit/adapters/mssql/test_sqlserver_connection_manager.py +++ b/tests/unit/adapters/mssql/test_sqlserver_connection_manager.py @@ -1,5 +1,6 @@ import builtins import importlib +from contextlib import contextmanager from types import SimpleNamespace from typing import Any, Dict, List from unittest.mock import MagicMock, patch @@ -1594,3 +1595,140 @@ def test_rollback_handle_disabled_exception_fires_rollback_failed( SQLServerConnectionManager._rollback_handle(connection) mock_fire_event.assert_called_once() + + +class _FakeRetryableError(Exception): + """Stand-in for a backend retryable exception in add_query retry tests.""" + + +def _make_add_query_manager( + monkeypatch: pytest.MonkeyPatch, + *, + retries: int, + execute_side_effect: Any, +): + """Build a manager + thread connection wired for add_query retry tests. + + Uses ``object.__new__`` (the pattern used elsewhere in this module) so no + real connection pool is constructed; only the collaborators add_query + touches are stubbed. + """ + + manager = object.__new__(SQLServerConnectionManager) + + cursor = MagicMock() + cursor.execute.side_effect = execute_side_effect + cursor.rowcount = 0 + + handle = MagicMock() + handle.cursor.return_value = cursor + + credentials = MagicMock() + credentials.retries = retries + + connection = MagicMock() + connection.handle = handle + connection.credentials = credentials + connection.transaction_open = True + connection.name = "retry-test" + + monkeypatch.setattr(manager, "get_thread_connection", lambda: connection) + + # Isolate the retry loop from error translation / connection release, which + # have their own tests and otherwise depend on global runtime state. + @contextmanager + def _passthrough_exception_handler(_sql): + yield + + monkeypatch.setattr(manager, "exception_handler", _passthrough_exception_handler) + + return manager, connection, cursor + + +def test_add_query_retries_retryable_errors_until_success( + monkeypatch: pytest.MonkeyPatch, +) -> None: + manager, _connection, cursor = _make_add_query_manager( + monkeypatch, + retries=3, + execute_side_effect=[_FakeRetryableError(), _FakeRetryableError(), None], + ) + + with ( + patch("dbt.adapters.sqlserver.sqlserver_connections.fire_event"), + patch("dbt.adapters.sqlserver.sqlserver_connections.time.sleep") as mock_sleep, + ): + _conn, result_cursor = manager.add_query( + "select 1", auto_begin=False, retryable_exceptions=(_FakeRetryableError,) + ) + + assert cursor.execute.call_count == 3 + assert result_cursor is cursor + assert mock_sleep.call_count == 2 + + +def test_add_query_honors_configured_retries_over_method_default( + monkeypatch: pytest.MonkeyPatch, +) -> None: + # Regression guard: a `credentials.retries > 3` branch used to ignore + # configured values of 1-3 and fall back to a hardcoded limit of 2, so the + # default `retries: 3` attempted a query only twice. It must now attempt + # the query three times before giving up. + manager, _connection, cursor = _make_add_query_manager( + monkeypatch, + retries=3, + execute_side_effect=_FakeRetryableError(), + ) + + with ( + patch("dbt.adapters.sqlserver.sqlserver_connections.fire_event"), + patch("dbt.adapters.sqlserver.sqlserver_connections.time.sleep"), + ): + with pytest.raises(_FakeRetryableError): + manager.add_query( + "select 1", auto_begin=False, retryable_exceptions=(_FakeRetryableError,) + ) + + assert cursor.execute.call_count == 3 + + +def test_add_query_does_not_retry_when_retries_is_one( + monkeypatch: pytest.MonkeyPatch, +) -> None: + manager, _connection, cursor = _make_add_query_manager( + monkeypatch, + retries=1, + execute_side_effect=_FakeRetryableError(), + ) + + with ( + patch("dbt.adapters.sqlserver.sqlserver_connections.fire_event"), + patch("dbt.adapters.sqlserver.sqlserver_connections.time.sleep"), + ): + with pytest.raises(_FakeRetryableError): + manager.add_query( + "select 1", auto_begin=False, retryable_exceptions=(_FakeRetryableError,) + ) + + assert cursor.execute.call_count == 1 + + +def test_add_query_does_not_retry_non_retryable_errors( + monkeypatch: pytest.MonkeyPatch, +) -> None: + manager, _connection, cursor = _make_add_query_manager( + monkeypatch, + retries=3, + execute_side_effect=ValueError("not retryable"), + ) + + with ( + patch("dbt.adapters.sqlserver.sqlserver_connections.fire_event"), + patch("dbt.adapters.sqlserver.sqlserver_connections.time.sleep"), + ): + with pytest.raises(ValueError): + manager.add_query( + "select 1", auto_begin=False, retryable_exceptions=(_FakeRetryableError,) + ) + + assert cursor.execute.call_count == 1 From 88ad636142c325ec4c8a4149382f5b26fcea181c Mon Sep 17 00:00:00 2001 From: Josh Markovic Date: Thu, 2 Jul 2026 17:37:42 +0000 Subject: [PATCH 15/18] docs: keep only reader-facing retry comments Drop the event-construction comment narrating the old message= defect and reduce the retry_limit comment to the one fact a reader needs: retries caps total execute attempts, so retries: 1 disables retry. --- dbt/adapters/sqlserver/sqlserver_connections.py | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/dbt/adapters/sqlserver/sqlserver_connections.py b/dbt/adapters/sqlserver/sqlserver_connections.py index 057f6064c..ad50e3b4f 100644 --- a/dbt/adapters/sqlserver/sqlserver_connections.py +++ b/dbt/adapters/sqlserver/sqlserver_connections.py @@ -224,10 +224,6 @@ def _execute_query_with_retry( raise e fire_event( - # NB: the field is ``base_msg`` (as in dbt-core's base - # ``add_query``); ``message=`` raises a protobuf ParseError - # at event construction, which previously crashed the first - # retry instead of retrying. AdapterEventDebug( base_msg=( f"Got a retryable error {type(e)}. {retry_limit - attempt} " @@ -281,12 +277,8 @@ def _execute_query_with_retry( sql=sql, bindings=bindings, retryable_exceptions=retryable_exceptions, - # The connection's configured ``retries`` is authoritative for - # query retries, mirroring ``open()`` which passes - # ``credentials.retries`` to ``retry_connection``. A previous - # ``credentials.retries > 3`` guard silently ignored configured - # values of 1-3 and used the hardcoded ``retry_limit`` of 2, so - # even the default ``retries: 3`` only attempted a query twice. + # ``retries`` caps total execute attempts, so ``retries: 1`` + # means a single attempt with no retry. retry_limit=credentials.retries, attempt=1, ) From 4a1e661793cded621f392544fcd5718954f1126a Mon Sep 17 00:00:00 2001 From: Josh Markovic Date: Thu, 2 Jul 2026 17:37:42 +0000 Subject: [PATCH 16/18] test: parametrize add_query retry failure cases The three failure-path tests differed only in retries, side effect, expected exception and expected attempt count; collapse them into one parametrized test. Also drop the unused connection element from the test helper's return value. --- .../test_sqlserver_connection_manager.py | 95 ++++++++----------- 1 file changed, 41 insertions(+), 54 deletions(-) diff --git a/tests/unit/adapters/mssql/test_sqlserver_connection_manager.py b/tests/unit/adapters/mssql/test_sqlserver_connection_manager.py index da3ed3e87..186f24d60 100644 --- a/tests/unit/adapters/mssql/test_sqlserver_connection_manager.py +++ b/tests/unit/adapters/mssql/test_sqlserver_connection_manager.py @@ -1642,13 +1642,13 @@ def _passthrough_exception_handler(_sql): monkeypatch.setattr(manager, "exception_handler", _passthrough_exception_handler) - return manager, connection, cursor + return manager, cursor def test_add_query_retries_retryable_errors_until_success( monkeypatch: pytest.MonkeyPatch, ) -> None: - manager, _connection, cursor = _make_add_query_manager( + manager, cursor = _make_add_query_manager( monkeypatch, retries=3, execute_side_effect=[_FakeRetryableError(), _FakeRetryableError(), None], @@ -1667,68 +1667,55 @@ def test_add_query_retries_retryable_errors_until_success( assert mock_sleep.call_count == 2 -def test_add_query_honors_configured_retries_over_method_default( - monkeypatch: pytest.MonkeyPatch, -) -> None: - # Regression guard: a `credentials.retries > 3` branch used to ignore - # configured values of 1-3 and fall back to a hardcoded limit of 2, so the - # default `retries: 3` attempted a query only twice. It must now attempt - # the query three times before giving up. - manager, _connection, cursor = _make_add_query_manager( - monkeypatch, - retries=3, - execute_side_effect=_FakeRetryableError(), - ) - - with ( - patch("dbt.adapters.sqlserver.sqlserver_connections.fire_event"), - patch("dbt.adapters.sqlserver.sqlserver_connections.time.sleep"), - ): - with pytest.raises(_FakeRetryableError): - manager.add_query( - "select 1", auto_begin=False, retryable_exceptions=(_FakeRetryableError,) - ) - - assert cursor.execute.call_count == 3 - - -def test_add_query_does_not_retry_when_retries_is_one( - monkeypatch: pytest.MonkeyPatch, -) -> None: - manager, _connection, cursor = _make_add_query_manager( - monkeypatch, - retries=1, - execute_side_effect=_FakeRetryableError(), - ) - - with ( - patch("dbt.adapters.sqlserver.sqlserver_connections.fire_event"), - patch("dbt.adapters.sqlserver.sqlserver_connections.time.sleep"), - ): - with pytest.raises(_FakeRetryableError): - manager.add_query( - "select 1", auto_begin=False, retryable_exceptions=(_FakeRetryableError,) - ) - - assert cursor.execute.call_count == 1 - - -def test_add_query_does_not_retry_non_retryable_errors( +@pytest.mark.parametrize( + ("retries", "execute_side_effect", "expected_exception", "expected_attempts"), + [ + pytest.param( + 3, + _FakeRetryableError(), + _FakeRetryableError, + 3, + id="retryable-error-exhausts-attempt-cap", + ), + pytest.param( + 1, + _FakeRetryableError(), + _FakeRetryableError, + 1, + id="retries-one-means-single-attempt", + ), + pytest.param( + 3, + ValueError("not retryable"), + ValueError, + 1, + id="non-retryable-error-not-retried", + ), + ], +) +def test_add_query_raises_after_expected_attempts( monkeypatch: pytest.MonkeyPatch, + retries: int, + execute_side_effect: Exception, + expected_exception: type, + expected_attempts: int, ) -> None: - manager, _connection, cursor = _make_add_query_manager( + # ``retries`` caps the total number of execute attempts: ``retries: 3`` + # tries a persistently failing query three times, ``retries: 1`` never + # retries, and errors outside ``retryable_exceptions`` raise immediately. + manager, cursor = _make_add_query_manager( monkeypatch, - retries=3, - execute_side_effect=ValueError("not retryable"), + retries=retries, + execute_side_effect=execute_side_effect, ) with ( patch("dbt.adapters.sqlserver.sqlserver_connections.fire_event"), patch("dbt.adapters.sqlserver.sqlserver_connections.time.sleep"), ): - with pytest.raises(ValueError): + with pytest.raises(expected_exception): manager.add_query( "select 1", auto_begin=False, retryable_exceptions=(_FakeRetryableError,) ) - assert cursor.execute.call_count == 1 + assert cursor.execute.call_count == expected_attempts From 2de450771797978fffeb3f0e416f51302791388f Mon Sep 17 00:00:00 2001 From: Josh Markovic Date: Mon, 29 Jun 2026 19:39:52 +0000 Subject: [PATCH 17/18] fix: make cancel() actually cancel the in-flight query SQLServerConnectionManager.cancel() was a no-op (it only logged), so dbt-core's cancel_open() could not stop sibling queries on Ctrl-C or when another thread failed; in-flight statements kept running server-side, holding locks. Track the in-flight cursor on the Connection during add_query and call Cursor.cancel() on it from cancel(). pyodbc exposes Cursor.cancel() (issuing SQLCancel, designed for cross-thread use) and mssql-python's cursor is used the same way when supported. Cancellation is best-effort: it no-ops when no statement is in flight, the cursor is gone, or the backend cursor lacks cancel(), and it swallows errors from a statement that completed between lookup and cancel. Add unit tests for cancel() (in-flight, absent, unsupported, error paths) and for add_query registering then clearing the cursor. --- .../sqlserver/sqlserver_connections.py | 67 ++++++++-- .../test_sqlserver_connection_manager.py | 119 ++++++++++++++++++ 2 files changed, 174 insertions(+), 12 deletions(-) diff --git a/dbt/adapters/sqlserver/sqlserver_connections.py b/dbt/adapters/sqlserver/sqlserver_connections.py index ad50e3b4f..d35df9c93 100644 --- a/dbt/adapters/sqlserver/sqlserver_connections.py +++ b/dbt/adapters/sqlserver/sqlserver_connections.py @@ -61,6 +61,10 @@ logger = AdapterLogger("sqlserver") +# 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" + class SQLServerConnectionManager(SQLConnectionManager): TYPE = "sqlserver" @@ -142,8 +146,40 @@ def connect() -> Any: return conn - def cancel(self, connection: Connection): - logger.debug("Cancel query") + def cancel(self, connection: Connection) -> None: + """Cancel the in-flight query on ``connection``, if any. + + dbt-core's ``cancel_open`` calls this for sibling connections when a + run is interrupted (Ctrl-C) or another thread errors. We cancel by + calling ``Cursor.cancel()`` on the connection's in-flight cursor: + pyodbc exposes it and it is explicitly designed to be called from + another thread (it issues ``SQLCancel``); mssql-python's cursor is + used the same way when it supports it. Cancellation targets statement + execution. If no statement is in flight, the cursor is gone, or the + backend cursor does not support cancellation, this is a best-effort + no-op. + """ + + cursor = getattr(connection, _IN_FLIGHT_CURSOR_ATTR, None) + if cursor is None: + logger.debug(f"No in-flight query to cancel for connection {connection.name}.") + return + + cancel_cursor = getattr(cursor, "cancel", None) + if not callable(cancel_cursor): + logger.debug( + f"Backend cursor for connection {connection.name} does not " + "support cancellation; skipping." + ) + return + + try: + logger.debug(f"Cancelling in-flight query for connection {connection.name}.") + cancel_cursor() + except Exception as exc: + # The statement may have completed between the lookup and the + # cancel; cancellation is best-effort, so swallow and log. + logger.debug(f"Failed to cancel query for connection {connection.name}: {exc}") def add_begin_query(self): if self._dbt_sqlserver_use_dbt_transactions: @@ -270,18 +306,25 @@ def _execute_query_with_retry( pre = time.time() cursor = connection.handle.cursor() + # Track the in-flight cursor so cancel() / cancel_open() can stop it + # from another thread (e.g. on Ctrl-C); cleared once execution + # finishes. See cancel(). + setattr(connection, _IN_FLIGHT_CURSOR_ATTR, cursor) credentials = self.get_credentials(connection.credentials) - _execute_query_with_retry( - cursor=cursor, - sql=sql, - bindings=bindings, - retryable_exceptions=retryable_exceptions, - # ``retries`` caps total execute attempts, so ``retries: 1`` - # means a single attempt with no retry. - retry_limit=credentials.retries, - attempt=1, - ) + try: + _execute_query_with_retry( + cursor=cursor, + sql=sql, + bindings=bindings, + retryable_exceptions=retryable_exceptions, + # ``retries`` caps total execute attempts, so ``retries: 1`` + # means a single attempt with no retry. + retry_limit=credentials.retries, + attempt=1, + ) + finally: + setattr(connection, _IN_FLIGHT_CURSOR_ATTR, None) if is_pyodbc_handle(connection.handle): connection.handle.add_output_converter(-155, byte_array_to_datetime) diff --git a/tests/unit/adapters/mssql/test_sqlserver_connection_manager.py b/tests/unit/adapters/mssql/test_sqlserver_connection_manager.py index 9d5a846af..77b87f4b3 100644 --- a/tests/unit/adapters/mssql/test_sqlserver_connection_manager.py +++ b/tests/unit/adapters/mssql/test_sqlserver_connection_manager.py @@ -1732,3 +1732,122 @@ def test_add_query_raises_after_expected_attempts( ) assert cursor.execute.call_count == expected_attempts + + +@contextmanager +def _passthrough_exception_handler(_sql): + """Stand-in for add_query's exception_handler in in-flight-cursor tests. + + The real exception_handler has its own tests and depends on global runtime + state; here we only care about cursor tracking, so we let exceptions pass + straight through. + """ + + yield + + +def _build_cancel_test_manager(monkeypatch: pytest.MonkeyPatch, cursor: MagicMock): + """Manager + thread connection wired for add_query in-flight-cursor tests.""" + + manager = object.__new__(SQLServerConnectionManager) + + handle = MagicMock() + handle.cursor.return_value = cursor + + credentials = MagicMock() + credentials.retries = 1 + + connection = MagicMock() + connection.handle = handle + connection.credentials = credentials + connection.transaction_open = True + connection.name = "cancel-test" + + monkeypatch.setattr(manager, "get_thread_connection", lambda: connection) + monkeypatch.setattr(manager, "exception_handler", _passthrough_exception_handler) + + return manager, connection + + +def test_cancel_cancels_in_flight_cursor() -> None: + manager = object.__new__(SQLServerConnectionManager) + cursor = MagicMock() + connection = SimpleNamespace(name="conn-1", _dbt_sqlserver_in_flight_cursor=cursor) + + manager.cancel(connection) + + cursor.cancel.assert_called_once_with() + + +def test_cancel_is_noop_without_in_flight_cursor() -> None: + manager = object.__new__(SQLServerConnectionManager) + connection = SimpleNamespace(name="conn-1", _dbt_sqlserver_in_flight_cursor=None) + + # Must not raise when nothing is in flight. + manager.cancel(connection) + + +def test_cancel_is_noop_when_attribute_absent() -> None: + manager = object.__new__(SQLServerConnectionManager) + # A connection that never ran a query has no in-flight cursor attribute. + connection = SimpleNamespace(name="conn-1") + + manager.cancel(connection) + + +def test_cancel_handles_cursor_without_cancel_support() -> None: + manager = object.__new__(SQLServerConnectionManager) + # object() has no .cancel attribute, so cancellation is skipped gracefully. + connection = SimpleNamespace(name="conn-1", _dbt_sqlserver_in_flight_cursor=object()) + + manager.cancel(connection) + + +def test_cancel_swallows_errors_from_cursor_cancel() -> None: + manager = object.__new__(SQLServerConnectionManager) + cursor = MagicMock() + cursor.cancel.side_effect = RuntimeError("statement already completed") + connection = SimpleNamespace(name="conn-1", _dbt_sqlserver_in_flight_cursor=cursor) + + # Best-effort: a failure to cancel must not propagate. + manager.cancel(connection) + + cursor.cancel.assert_called_once_with() + + +def test_add_query_registers_then_clears_in_flight_cursor( + monkeypatch: pytest.MonkeyPatch, +) -> None: + cursor = MagicMock() + cursor.rowcount = 0 + captured: Dict[str, Any] = {} + + manager, connection = _build_cancel_test_manager(monkeypatch, cursor) + + def _record_in_flight(*_args, **_kwargs): + captured["during"] = connection._dbt_sqlserver_in_flight_cursor + + cursor.execute.side_effect = _record_in_flight + + with patch("dbt.adapters.sqlserver.sqlserver_connections.fire_event"): + manager.add_query("select 1", auto_begin=False) + + # Registered while the statement runs, so cancel() can reach it... + assert captured["during"] is cursor + # ...and cleared once execution completes. + assert connection._dbt_sqlserver_in_flight_cursor is None + + +def test_add_query_clears_in_flight_cursor_after_failure( + monkeypatch: pytest.MonkeyPatch, +) -> None: + cursor = MagicMock() + cursor.execute.side_effect = ValueError("boom") # not a retryable exception + + manager, connection = _build_cancel_test_manager(monkeypatch, cursor) + + with patch("dbt.adapters.sqlserver.sqlserver_connections.fire_event"): + with pytest.raises(ValueError): + manager.add_query("select 1", auto_begin=False) + + assert connection._dbt_sqlserver_in_flight_cursor is None From e1c8bb98abbed1253e9fb7f5afbf3139072878b5 Mon Sep 17 00:00:00 2001 From: Josh Markovic Date: Thu, 9 Jul 2026 23:07:03 -0400 Subject: [PATCH 18/18] fix: cast describe_indexes STRING_AGG input to nvarchar(max) (#735) Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 1 + .../sqlserver/macros/adapters/indexes.sql | 6 +-- .../adapter/mssql/test_index_config.py | 51 +++++++++++++++++++ 3 files changed, 55 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e5479754..22650cf3c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,7 @@ - Guard `run_hooks` commit with `@@trancount` check for autocommit safety when running post-hooks. [#444](https://github.com/dbt-msft/dbt-sqlserver/issues/444) - Fix `columnstore IF EXISTS` guard to check `object_id('schema.table')` correctly. - Gate the `optimize_for_sequential_key` and `resumable` index options on the detected engine version: they require SQL Server 2019+, so on 2017/2016 the index is now built without them (with a warning) instead of failing with "is not a recognized CREATE INDEX option". +- Cast the aggregated column names in `sqlserver__describe_indexes` to `nvarchar(max)` so index reconciliation no longer fails with `STRING_AGG` error 9829 on wide tables (e.g. a clustered columnstore index spanning enough columns to exceed the 8000-byte cap). [#735](https://github.com/dbt-msft/dbt-sqlserver/issues/735) - Escape single quotes in `query_tag` before building `OPTION (LABEL)` clause. - Map Python `float` to SQL Server `float`, not `bigint`. - Set default port to `1433` (instead of Postgres `5432`) in `dbt init` profile template. diff --git a/dbt/include/sqlserver/macros/adapters/indexes.sql b/dbt/include/sqlserver/macros/adapters/indexes.sql index 464e04087..27e802551 100644 --- a/dbt/include/sqlserver/macros/adapters/indexes.sql +++ b/dbt/include/sqlserver/macros/adapters/indexes.sql @@ -379,7 +379,7 @@ outer apply ( /* STRING_AGG ... WITHIN GROUP requires SQL Server 2017+, the floor of this adapter's CI matrix */ - select string_agg(col.[name], ', ') within group (order by ic.key_ordinal) as cols + select string_agg(cast(col.[name] as nvarchar(max)), ', ') within group (order by ic.key_ordinal) as cols from sys.index_columns ic {{ information_schema_hints() }} inner join sys.columns col {{ information_schema_hints() }} on col.object_id = ic.object_id and col.column_id = ic.column_id @@ -387,7 +387,7 @@ and ic.is_included_column = 0 ) key_cols outer apply ( - select string_agg(col.[name], ', ') as cols + select string_agg(cast(col.[name] as nvarchar(max)), ', ') as cols from sys.index_columns ic {{ information_schema_hints() }} inner join sys.columns col {{ information_schema_hints() }} on col.object_id = ic.object_id and col.column_id = ic.column_id @@ -395,7 +395,7 @@ and ic.is_included_column = 1 ) incl_cols outer apply ( - select string_agg(col.[name], ', ') as cols + select string_agg(cast(col.[name] as nvarchar(max)), ', ') as cols from sys.index_columns ic {{ information_schema_hints() }} inner join sys.columns col {{ information_schema_hints() }} on col.object_id = ic.object_id and col.column_id = ic.column_id diff --git a/tests/functional/adapter/mssql/test_index_config.py b/tests/functional/adapter/mssql/test_index_config.py index e76d2687b..60b382bf2 100644 --- a/tests/functional/adapter/mssql/test_index_config.py +++ b/tests/functional/adapter/mssql/test_index_config.py @@ -682,6 +682,27 @@ def test_invalid_index_configs(self, project): SET_B = "[{'columns': ['column_b'], 'type': 'nonclustered'}]" +# A wide table whose clustered columnstore index (as_columnstore defaults True) +# spans every column. With ~200 columns of ~45 chars each, the column-name list +# aggregated by sqlserver__describe_indexes is ~18 KB as nvarchar, past +# STRING_AGG's 8000-byte result cap. +WIDE_COLUMNSTORE_COLUMN_COUNT = 200 +_wide_columnstore_columns = ",\n ".join( + f"{i} as wide_column_{i:03d}_with_a_reasonably_long_name" + for i in range(WIDE_COLUMNSTORE_COLUMN_COUNT) +) +models__wide_columnstore_sql = f""" +{{{{ + config( + materialized = "incremental", + as_columnstore = True, + ) +}}}} + +select + {_wide_columnstore_columns} +""" + def get_index_rows(project, unique_schema, table_name): sql = indexes_def.format(schema_name=unique_schema, table_name=table_name) @@ -755,6 +776,36 @@ def test_dml_refresh_reconciles_definition_change(self, project, unique_schema): assert index_summary(second) == [("column_b", "nonclustered")] +class TestSQLServerWideColumnstoreReconcile: + @pytest.fixture(scope="class") + def models(self): + return {"wide_columnstore.sql": models__wide_columnstore_sql} + + def test_wide_columnstore_reconcile_does_not_overflow(self, project, unique_schema): + # First run creates the wide table plus its clustered columnstore index. + run_dbt(["run", "--models", "wide_columnstore"]) + + # The second (non-full-refresh) run reconciles indexes, describing the + # existing columnstore index over all its columns. + results = run_dbt(["run", "--models", "wide_columnstore"]) + assert len(results) == 1 + + # The clustered columnstore index (type 5) survives reconciliation. + cci_count = project.run_sql( + f""" + select count(*) + from sys.indexes i + join sys.objects o on o.object_id = i.object_id + join sys.schemas s on s.schema_id = o.schema_id + where s.name = '{unique_schema}' + and o.name = 'wide_columnstore' + and i.[type] = 5 + """, + fetch="one", + )[0] + assert cci_count == 1 + + class TestSQLServerDropUnmanagedIndexes: @pytest.fixture(scope="class") def models(self):