Skip to content

Commit 427fd05

Browse files
authored
Merge pull request #821 from Benjamin-Knight/fix/view-skip-refreshview
fix(view): sp_refreshview when a view rebuild is skipped
2 parents 1da1d79 + d8ca5ce commit 427fd05

3 files changed

Lines changed: 84 additions & 25 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515

1616
#### Bugfixes
1717

18+
- Fix a `view` model whose SQL text is unchanged (and so skips its `CREATE`/`ALTER`) going stale when a table it selects `*` from gains, loses, or reorders columns. SQL Server resolves an unqualified `select *` and caches the result at `CREATE`/`ALTER VIEW` time; skipping that statement left the cached column list silently out of sync with the underlying table, so the view kept serving old columns under their old names/positions even though every dbt run reported success. The skip path now runs `sp_refreshview` against the view instead of a no-op, which re-derives the cached metadata from the table's current shape without reissuing the `CREATE`. The refresh carries the model's database as a `USE` prefix, so a cross-database view model refreshes the intended object rather than erroring on a name that does not resolve in the connection's current database. Note that a metadata refresh advances the view's `sys.objects.modify_date` just as an `ALTER` would, so that column no longer distinguishes a skipped run from a rebuild.
1819
- Fix a `view` model silently skipping a rebuild when text was removed from the *start* of its body (e.g. deleting a leading comment or CTE). The skip test compared the stored definition against the model with `endswith()`, so any edit whose new body was a tail of the old one looked unchanged: `dbt run` reported `PASS` but the change never reached the database, and `--full-refresh` did not fix it. The header (`CREATE [OR ALTER] VIEW <name> AS`) is now split off at its separating ` AS ` and the body compared exactly. The comparison also no longer lowercases or strips whitespace, both of which made genuinely different bodies (a string literal differing only in case, or any literal containing spaces) compare equal; where the definition cannot be parsed with certainty the view is rebuilt rather than skipped.
1920
- Fix snapshots failing on their second and later runs with `Invalid object name '..._dbt_tmp'`, and contract-enforced models silently losing their in-transaction `pre_hook` writes. `get_column_schema_from_query` reads a query's column shape by executing it, then returned without fetching the rows or closing the cursor. Closing a cursor whose result set the server is still producing makes the driver cancel the request, and SQL Server answers that cancel by rolling back the open transaction, since every connection runs `SET XACT_ABORT ON` (#718). Nothing is raised for any of it, so the snapshot lost the staging table it had just built and failed against it a statement later. The probe now drains and closes its cursor, as does the row-count probe in `expand_column_types`. Only queries opening with a CTE were affected - anything else is wrapped as `select * from (...) where 1 = 0` by `sqlserver__get_empty_subquery_sql` and returns no rows - which is why snapshot staging queries (`with snapshot_query as ...`, both `check` and `timestamp` strategies) and CTE-headed contract models were the ones that broke.
2021
- Fix models failing with `Incorrect syntax near '\'` when the schema name needs delimiters, such as a domain-qualified `domain\user`. The clustered columnstore index name embeds the schema and was emitted as a bare identifier, so the generated DDL did not parse. [#409](https://github.com/dbt-msft/dbt-sqlserver/issues/409)

dbt/include/sqlserver/macros/materializations/models/view/view.sql

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,21 @@
6969
{% endif %}
7070
{% endif %}
7171
{% if should_skip_view_update %}
72-
{% set build_sql = 'declare @dbt_sqlserver_noop int;' %}
72+
{#- The view's SQL text is unchanged, so the CREATE/ALTER is skipped -
73+
but a *referenced* table can still have changed shape (columns
74+
added, dropped, or reordered) since this view was last built. SQL
75+
Server resolves an unqualified `select *` at CREATE/ALTER time and
76+
caches the result; skipping that statement here means the cached
77+
column list silently goes stale relative to the underlying table,
78+
even though this view's own definition never changed. sp_refreshview
79+
re-derives that cached metadata from the table's current shape
80+
without re-running the CREATE, so a skip stays a skip (no DDL, no
81+
grant/deny churn) while the view keeps reporting the right columns. -#}
82+
{#- sp_refreshview resolves its argument in the *current* database, so this needs
83+
the same USE prefix every other name-resolving statement here carries -
84+
without it a cross-database view model would refresh nothing and error. -#}
85+
{% set object_name = "quotename('" ~ target_relation.schema ~ "') + '.' + quotename('" ~ target_relation.identifier ~ "')" %}
86+
{% set build_sql = get_use_database_sql(target_relation.database) ~ " declare @dbt_sqlserver_refresh_target nvarchar(max) = " ~ object_name ~ "; exec sp_refreshview @dbt_sqlserver_refresh_target;" %}
7387
{% else %}
7488
{% set build_sql = get_create_view_as_sql(target_relation, sql) %}
7589
{% endif %}

tests/functional/adapter/mssql/test_materialize_change.py

Lines changed: 68 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import pytest
22

3-
from dbt.tests.util import get_connection, run_dbt, write_file
3+
from dbt.tests.util import get_connection, run_dbt, run_dbt_and_capture, write_file
44

55
model_sql = """
66
SELECT 1 AS data
@@ -60,6 +60,14 @@
6060
SELECT 'abc' AS source
6161
"""
6262

63+
# A view over a table created outside dbt, so the table survives between runs and
64+
# can change shape underneath the view. `select *` is expanded and cached at
65+
# CREATE/ALTER VIEW time, which is what the skip path has to keep in sync.
66+
select_star_view = """
67+
{{ config(materialized='view') }}
68+
SELECT * FROM {{ this.schema }}.refresh_source
69+
"""
70+
6371
schema = """
6472
version: 2
6573
models:
@@ -141,7 +149,14 @@ def test_public_select_grant_survives_swap(self, project):
141149

142150

143151
class TestViewMaterializationNoOp(BaseTableView):
144-
"""Test that rerunning an unchanged view avoids altering the view."""
152+
"""Test that rerunning an unchanged view avoids rebuilding the view.
153+
154+
``modify_date`` used to be the signal here, but the skip path now runs
155+
``sp_refreshview`` (see TestSkippedViewRefreshesSelectStarColumns), which bumps
156+
``modify_date`` just as an ``ALTER`` would - so that column no longer separates a
157+
skip from a rebuild. The emitted SQL does: a skip issues ``sp_refreshview`` and
158+
no ``CREATE OR ALTER VIEW``, and leaves the stored definition byte-identical.
159+
"""
145160

146161
@pytest.fixture(scope="class")
147162
def models(self):
@@ -150,32 +165,16 @@ def models(self):
150165
def test_unchanged_view_does_not_alter(self, project):
151166
self.create_object(project, f"CREATE VIEW {project.test_schema}.mat_object AS {model_sql}")
152167

153-
before_modify_date = project.run_sql(
154-
f"""
155-
select modify_date
156-
from sys.objects o
157-
join sys.schemas s on o.schema_id = s.schema_id
158-
where upper(s.name) = upper('{project.test_schema}')
159-
and upper(o.name) = upper('mat_object')
160-
""",
161-
fetch="one",
162-
)[0]
168+
before_definition = _stored_view_definition(project)
163169

164-
results = run_dbt(["run"])
170+
results, log_output = run_dbt_and_capture(["--debug", "run"])
165171
assert len(results) == 1
166172

167-
after_modify_date = project.run_sql(
168-
f"""
169-
select modify_date
170-
from sys.objects o
171-
join sys.schemas s on o.schema_id = s.schema_id
172-
where upper(s.name) = upper('{project.test_schema}')
173-
and upper(o.name) = upper('mat_object')
174-
""",
175-
fetch="one",
176-
)[0]
173+
emitted_sql = log_output.lower()
174+
assert "sp_refreshview" in emitted_sql
175+
assert "create or alter view" not in emitted_sql
177176

178-
assert after_modify_date == before_modify_date
177+
assert _stored_view_definition(project) == before_definition
179178

180179

181180
class TestViewtoTable(BaseTableView):
@@ -251,3 +250,48 @@ def test_case_only_change_lands(self, project):
251250
project.run_sql(f"select source from {project.test_schema}.mat_object", fetch="one")[0]
252251
== "abc"
253252
)
253+
254+
255+
def _view_columns(project):
256+
"""The view's cached column list, in order, as SQL Server currently reports it."""
257+
rows = project.run_sql(
258+
f"""
259+
select c.name
260+
from sys.columns c
261+
where c.object_id = object_id('{project.test_schema}.mat_object')
262+
order by c.column_id
263+
""",
264+
fetch="all",
265+
)
266+
return [row[0] for row in rows]
267+
268+
269+
class TestSkippedViewRefreshesSelectStarColumns(BaseTableView):
270+
"""A skipped rebuild must still re-derive a cached ``select *`` column list.
271+
272+
SQL Server expands an unqualified ``select *`` at CREATE/ALTER VIEW time and caches
273+
the result. When the model SQL is unchanged the CREATE is skipped, so a column added
274+
to a referenced table never reached the view: it kept serving the old shape - old
275+
names in old positions - while every dbt run reported success. The skip path runs
276+
``sp_refreshview``, which re-derives that metadata without any DDL on the view.
277+
"""
278+
279+
@pytest.fixture(scope="class")
280+
def models(self):
281+
return {"mat_object.sql": select_star_view, "schema.yml": schema}
282+
283+
def test_added_column_reaches_skipped_view(self, project):
284+
self.create_object(project, f"CREATE TABLE {project.test_schema}.refresh_source (a int)")
285+
286+
run_dbt(["run"])
287+
assert _view_columns(project) == ["a"]
288+
289+
project.run_sql(f"alter table {project.test_schema}.refresh_source add b int")
290+
291+
# The model SQL is unchanged, so the CREATE/ALTER is skipped ...
292+
results, log_output = run_dbt_and_capture(["--debug", "run"])
293+
assert len(results) == 1
294+
assert "create or alter view" not in log_output.lower()
295+
296+
# ... but the view still reports the table's current shape.
297+
assert _view_columns(project) == ["a", "b"]

0 commit comments

Comments
 (0)