Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
34 changes: 23 additions & 11 deletions dbt/include/sqlserver/macros/adapters/columns.sql
Original file line number Diff line number Diff line change
@@ -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 (
Expand All @@ -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) %}
Expand Down
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't see any other solution than shadowing, that said, inside the shadowed macro we could introduce a call to macro sqlserver__get_fixture_sql(...) instead. This would keep the SQL Server-specific logic isolated and make the implementation closer to what we would want upstream if they use adapter calls later.

This is not that important but ideally with a link to an upstream dbt-core/dbt-adapters issue requesting dispatch support. If upstream adds that dispatch point later, our future patch should mostly be removing the shadow and keeping only the SQL Server-specific sqlserver__get_fixture_sql implementation.

Original file line number Diff line number Diff line change
@@ -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 %}
53 changes: 53 additions & 0 deletions tests/functional/adapter/dbt/test_unit_tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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):
Expand Down