From 766cd32167951054951fd715529228651d55ae83 Mon Sep 17 00:00:00 2001 From: Axell Padilla <68310020+axellpadilla@users.noreply.github.com> Date: Tue, 2 Jun 2026 11:43:45 -0600 Subject: [PATCH 1/2] Update version to 1.10.0rc1 on release/v1.10 --- dbt/adapters/sqlserver/__version__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dbt/adapters/sqlserver/__version__.py b/dbt/adapters/sqlserver/__version__.py index bd1378f42..c0a9f8d04 100644 --- a/dbt/adapters/sqlserver/__version__.py +++ b/dbt/adapters/sqlserver/__version__.py @@ -1 +1 @@ -version = "1.10.0" +version = "1.10.0rc1" From d0f251c51aeb98cbc5badcdc392d6f9162be4be5 Mon Sep 17 00:00:00 2001 From: Ben Knight Date: Thu, 4 Jun 2026 13:44:08 +0000 Subject: [PATCH 2/2] fix: empty unit test fixtures (rows: []) emit invalid LIMIT 0 syntax (#698) dbt-core's get_fixture_sql/get_expected_sql hardcode `limit 0` for empty fixture rows, which is not valid T-SQL. These macros are not dispatched, so shadow them in the adapter package and emit `select top 0` instead. get_expected_sql's empty branch builds typed nulls rather than selecting from dbt_internal_unit_test_actual, which is out of scope inside the view created by sqlserver__get_unit_test_sql. Also fix sqlserver__get_columns_in_query for queries starting with a CTE (hit by unit tests with an empty `expect` block): such queries cannot be wrapped in a subquery, so describe their result set via sp_describe_first_result_set instead of executing them. Non-CTE queries keep the existing TOP 0 wrapping. The CTE detection is factored into a shared sqlserver__select_starts_with_cte helper. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 6 ++ .../sqlserver/macros/adapters/columns.sql | 34 +++++--- .../models/unit_test/get_fixture_sql.sql | 83 +++++++++++++++++++ .../functional/adapter/dbt/test_unit_tests.py | 53 ++++++++++++ 4 files changed, 165 insertions(+), 11 deletions(-) create mode 100644 dbt/include/sqlserver/macros/materializations/models/unit_test/get_fixture_sql.sql diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e2ce6b1d..2abd58725 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +### v1.10.1 + +#### Bugfixes + +- Fix unit tests with empty fixtures (`rows: []`) generating invalid `limit 0` syntax; emit `top 0` instead. Also fix `get_columns_in_query()` for queries starting with a CTE, which broke unit tests with an empty `expect` block; such queries are now described via `sp_describe_first_result_set` instead of being executed. [#698](https://github.com/dbt-msft/dbt-sqlserver/issues/698) + ### v1.10.0 #### Features diff --git a/dbt/include/sqlserver/macros/adapters/columns.sql b/dbt/include/sqlserver/macros/adapters/columns.sql index a9bc4bfe6..313cc0f12 100644 --- a/dbt/include/sqlserver/macros/adapters/columns.sql +++ b/dbt/include/sqlserver/macros/adapters/columns.sql @@ -1,6 +1,11 @@ -{% macro sqlserver__get_empty_subquery_sql(select_sql, select_sql_header=none) %} +{% macro sqlserver__select_starts_with_cte(select_sql) %} + {#-- Strip comments first so a leading comment does not hide the CTE --#} {%- set select_sql_stripped = modules.re.sub('(?s)/\\*.*?\\*/|--[^\n]*\n', '', select_sql) -%} - {% if select_sql_stripped.strip().lower().startswith('with') %} + {{ return(select_sql_stripped.strip().lower().startswith('with')) }} +{% endmacro %} + +{% macro sqlserver__get_empty_subquery_sql(select_sql, select_sql_header=none) %} + {% if sqlserver__select_starts_with_cte(select_sql) %} {{ select_sql }} {% else -%} select * from ( @@ -13,15 +18,22 @@ {% macro sqlserver__get_columns_in_query(select_sql) %} {% set query_label = get_query_options() %} - {% call statement('get_columns_in_query', fetch_result=True, auto_begin=False) -%} - select TOP 0 * from ( - {{ select_sql }} - ) as __dbt_sbq - where 0 = 1 - {{ query_label }} - {% endcall %} - - {{ return(load_result('get_columns_in_query').table.columns | map(attribute='name') | list) }} + {% if sqlserver__select_starts_with_cte(select_sql) %} + {#-- A query starting with a CTE cannot be wrapped in a subquery; describe its result set instead of executing it (dbt-msft/dbt-sqlserver#698) --#} + {% call statement('get_columns_in_query', fetch_result=True, auto_begin=False) -%} + exec sp_describe_first_result_set @tsql = N'{{ escape_single_quotes(select_sql) }}' + {% endcall %} + {{ return(load_result('get_columns_in_query').table.columns['name'].values() | list) }} + {% else %} + {% call statement('get_columns_in_query', fetch_result=True, auto_begin=False) -%} + select TOP 0 * from ( + {{ select_sql }} + ) as __dbt_sbq + where 0 = 1 + {{ query_label }} + {% endcall %} + {{ return(load_result('get_columns_in_query').table.columns | map(attribute='name') | list) }} + {% endif %} {% endmacro %} {% macro sqlserver__alter_column_type(relation, column_name, new_column_type) %} diff --git a/dbt/include/sqlserver/macros/materializations/models/unit_test/get_fixture_sql.sql b/dbt/include/sqlserver/macros/materializations/models/unit_test/get_fixture_sql.sql new file mode 100644 index 000000000..25a8efdfe --- /dev/null +++ b/dbt/include/sqlserver/macros/materializations/models/unit_test/get_fixture_sql.sql @@ -0,0 +1,83 @@ +{# + dbt-core does not dispatch "get_fixture_sql" or "get_expected_sql", so this file + shadows the implementations from the dbt global project + (macros/unit_test_sql/get_fixture_sql.sql) - adapter package macros take + precedence over the global project. Keep in sync with dbt-core when upgrading. + + Changes from upstream (see dbt-msft/dbt-sqlserver#698): + - get_fixture_sql: the empty-rows branch emits "select top 0" instead of + "limit 0", which is not valid T-SQL. + - get_expected_sql: the empty-rows branch emits a "select top 0" of typed + nulls instead of "select * from dbt_internal_unit_test_actual limit 0". + Besides the invalid "limit", sqlserver__get_unit_test_sql wraps the + expected SQL in its own view, where that CTE name is out of scope. +#} + +{% macro get_fixture_sql(rows, column_name_to_data_types) %} +-- Fixture for {{ model.name }} +{% set default_row = {} %} + +{%- if not column_name_to_data_types -%} +{#-- Use defer_relation IFF it is available in the manifest and 'this' is missing from the database --#} +{%- set this_or_defer_relation = defer_relation if (defer_relation and not load_relation(this)) else this -%} +{%- set columns_in_relation = adapter.get_columns_in_relation(this_or_defer_relation) -%} + +{%- set column_name_to_data_types = {} -%} +{%- set column_name_to_quoted = {} -%} +{%- for column in columns_in_relation -%} + +{#-- This needs to be a case-insensitive comparison --#} +{%- do column_name_to_data_types.update({column.name|lower: column.data_type}) -%} +{%- do column_name_to_quoted.update({column.name|lower: column.quoted}) -%} +{%- endfor -%} +{%- endif -%} + +{%- if not column_name_to_data_types -%} + {{ exceptions.raise_compiler_error("Not able to get columns for unit test '" ~ model.name ~ "' from relation " ~ this ~ " because the relation doesn't exist") }} +{%- endif -%} + +{%- for column_name, column_type in column_name_to_data_types.items() -%} + {%- do default_row.update({column_name: (safe_cast("null", column_type) | trim )}) -%} +{%- endfor -%} + +{{ validate_fixture_rows(rows, row_number) }} + +{%- for row in rows -%} +{%- set formatted_row = format_row(row, column_name_to_data_types) -%} +{%- set default_row_copy = default_row.copy() -%} +{%- do default_row_copy.update(formatted_row) -%} +select +{%- for column_name, column_value in default_row_copy.items() %} {{ column_value }} as {{ column_name_to_quoted[column_name] }}{% if not loop.last -%}, {%- endif %} +{%- endfor %} +{%- if not loop.last %} +union all +{% endif %} +{%- endfor -%} + +{%- if (rows | length) == 0 -%} + select top 0 + {%- for column_name, column_value in default_row.items() %} {{ column_value }} as {{ column_name_to_quoted[column_name] }}{% if not loop.last -%},{%- endif %} + {%- endfor %} +{%- endif -%} +{% endmacro %} + + +{% macro get_expected_sql(rows, column_name_to_data_types, column_name_to_quoted) %} + +{%- if (rows | length) == 0 -%} + select top 0 + {%- for column_name, column_type in column_name_to_data_types.items() %} {{ safe_cast("null", column_type) | trim }} as {{ column_name_to_quoted[column_name] }}{% if not loop.last -%},{%- endif %} + {%- endfor %} +{%- else -%} +{%- for row in rows -%} +{%- set formatted_row = format_row(row, column_name_to_data_types) -%} +select +{%- for column_name, column_value in formatted_row.items() %} {{ column_value }} as {{ column_name_to_quoted[column_name] }}{% if not loop.last -%}, {%- endif %} +{%- endfor %} +{%- if not loop.last %} +union all +{% endif %} +{%- endfor -%} +{%- endif -%} + +{% endmacro %} diff --git a/tests/functional/adapter/dbt/test_unit_tests.py b/tests/functional/adapter/dbt/test_unit_tests.py index e5d50d67d..8567c800e 100644 --- a/tests/functional/adapter/dbt/test_unit_tests.py +++ b/tests/functional/adapter/dbt/test_unit_tests.py @@ -28,6 +28,41 @@ - {{ tested_column: {yaml_value} }} """ +my_union_model_sql = """ +select tested_column from {{ ref('my_upstream_model') }} +union all +select tested_column from {{ ref('my_other_upstream_model') }} +""" + +upstream_model_sql = """ +select 1 as tested_column +""" + +# `rows: []` must not generate `limit 0`, which is invalid T-SQL (issue #698) +test_empty_fixture_yml = """ +unit_tests: + - name: test_empty_given + model: my_union_model + given: + - input: ref('my_upstream_model') + rows: + - {tested_column: 1} + - input: ref('my_other_upstream_model') + rows: [] + expect: + rows: + - {tested_column: 1} + - name: test_empty_expect + model: my_union_model + given: + - input: ref('my_upstream_model') + rows: [] + - input: ref('my_other_upstream_model') + rows: [] + expect: + rows: [] +""" + class TestUnitTestCaseInsensitivity(BaseUnitTestCaseInsensivity): pass @@ -37,6 +72,24 @@ class TestUnitTestInvalidInput(BaseUnitTestInvalidInput): pass +class TestUnitTestEmptyFixture: + @pytest.fixture(scope="class") + def models(self): + return { + "my_upstream_model.sql": upstream_model_sql, + "my_other_upstream_model.sql": upstream_model_sql, + "my_union_model.sql": my_union_model_sql, + "schema.yml": test_empty_fixture_yml, + } + + def test_empty_fixture_rows(self, project): + results = run_dbt(["run"]) + assert len(results) == 3 + + results = run_dbt(["test", "--select", "my_union_model"]) + assert len(results) == 2 + + class TestUnitTestingTypes(BaseUnitTestingTypes): @pytest.fixture def data_types(self):