From e1c8bb98abbed1253e9fb7f5afbf3139072878b5 Mon Sep 17 00:00:00 2001 From: Josh Markovic Date: Thu, 9 Jul 2026 23:07:03 -0400 Subject: [PATCH] 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 7e547975..22650cf3 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 464e0408..27e80255 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 e76d2687..60b382bf 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):