Skip to content

Commit a47f6a5

Browse files
committed
Fix: Handle view alterations and existing table replacements
This commit addresses the following: - Modifies the `create view` macro to use `create or alter view` when the view already exists, preventing errors during re-materialization. - Updates the view materialization logic to correctly avoid renaming if no table exists. - Adds test cases to verify that materializing an existing view updates its definition correctly, including column changes, previous grants and avoids table changes if view fails.
1 parent 3940f81 commit a47f6a5

5 files changed

Lines changed: 145 additions & 14 deletions

File tree

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

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,13 @@
2626
{%- set preexisting_backup_relation = load_cached_relation(backup_relation) -%}
2727
-- grab current tables grants config for comparision later on
2828
{% set grant_config = config.get('grants') %}
29+
{% set preserved_grants = {} %}
30+
31+
{% if existing_relation is not none and existing_relation.type != 'view' %}
32+
{% set current_grants_table = run_query(get_show_grant_sql(existing_relation)) %}
33+
{% set current_grants_dict = adapter.standardize_grants_dict(current_grants_table) %}
34+
{% set preserved_grants = diff_of_two_dicts(current_grants_dict, grant_config) %}
35+
{% endif %}
2936

3037
{{ run_hooks(pre_hooks, inside_transaction=False) }}
3138

@@ -36,26 +43,34 @@
3643
-- `BEGIN` happens here:
3744
{{ run_hooks(pre_hooks, inside_transaction=True) }}
3845

39-
-- build model
40-
{% call statement('main') -%}
41-
{{ get_create_view_as_sql(intermediate_relation, sql) }}
42-
{%- endcall %}
46+
{% if existing_relation is not none and existing_relation.type != 'view' %}
47+
-- build model
48+
{% call statement('main') -%}
49+
{{ get_create_view_as_sql(intermediate_relation, sql) }}
50+
{%- endcall %}
4351

44-
-- cleanup
45-
-- move the existing view out of the way
46-
{% if existing_relation is not none %}
47-
/* Do the equivalent of rename_if_exists. 'existing_relation' could have been dropped
48-
since the variable was first set. */
52+
-- cleanup
53+
-- move the existing relation out of the way
4954
{% set existing_relation = load_cached_relation(existing_relation) %}
5055
{% if existing_relation is not none %}
5156
{{ adapter.rename_relation(existing_relation, backup_relation) }}
5257
{% endif %}
58+
59+
{{ adapter.rename_relation(intermediate_relation, target_relation) }}
60+
{% else %}
61+
-- build model
62+
{% call statement('main') -%}
63+
{{ get_create_view_as_sql(target_relation, sql) }}
64+
{%- endcall %}
5365
{% endif %}
54-
{{ adapter.rename_relation(intermediate_relation, target_relation) }}
5566

5667
{% set should_revoke = should_revoke(existing_relation, full_refresh_mode=True) %}
5768
{% do apply_grants(target_relation, grant_config, should_revoke=should_revoke) %}
5869

70+
{% if preserved_grants %}
71+
{% do apply_grants(target_relation, preserved_grants, should_revoke=False) %}
72+
{% endif %}
73+
5974
{% do persist_docs(target_relation, model) %}
6075

6176
{{ run_hooks(post_hooks, inside_transaction=True) }}

dbt/include/sqlserver/macros/relations/views/create.sql

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
{%- endif %}
88

99
{% set query %}
10-
create view {{ relation.include(database=False) }} as {{ sql }};
10+
CREATE OR ALTER VIEW {{ relation.include(database=False) }} AS {{ sql }};
1111
{% endset %}
1212

1313
{% set tst %}

tests/functional/adapter/dbt/test_basic.py

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import os
2+
13
import pytest
24
from dbt.tests.adapter.basic.test_adapter_methods import BaseAdapterMethod
35
from dbt.tests.adapter.basic.test_base import BaseSimpleMaterializations
@@ -9,10 +11,64 @@
911
from dbt.tests.adapter.basic.test_singular_tests_ephemeral import BaseSingularTestsEphemeral
1012
from dbt.tests.adapter.basic.test_snapshot_check_cols import BaseSnapshotCheckCols
1113
from dbt.tests.adapter.basic.test_snapshot_timestamp import BaseSnapshotTimestamp
14+
from dbt.tests.util import run_dbt
1215

1316

1417
class TestSimpleMaterializations(BaseSimpleMaterializations):
15-
pass
18+
19+
def test_existing_view_materialization(self, project, models):
20+
"""Test that materializing an existing view works correctly."""
21+
# Create a temporary model file directly in the project
22+
model_path = os.path.join(project.project_root, "models", "view_model_exists.sql")
23+
24+
# Write the initial model without the value column
25+
with open(model_path, "w") as f:
26+
f.write(
27+
"""
28+
{{ config(materialized='view') }}
29+
select
30+
1 as id
31+
{% if var('include_value_column', false) %}
32+
, 2 as value
33+
{% endif %}
34+
"""
35+
)
36+
37+
# First run to create the view without the extra column
38+
results = run_dbt(["run", "-m", "view_model_exists"])
39+
assert len(results) == 1
40+
41+
# Generate catalog to get column information
42+
catalog = run_dbt(["docs", "generate"])
43+
44+
# Check columns in the catalog
45+
node_id = "model.base.view_model_exists"
46+
assert node_id in catalog.nodes
47+
48+
# Get columns from the catalog
49+
columns = catalog.nodes[node_id].columns
50+
column_names = [name.lower() for name in columns.keys()]
51+
52+
# Verify only the id column exists
53+
assert "id" in column_names
54+
assert "value" not in column_names
55+
56+
# Second run with a variable to include the extra column
57+
results = run_dbt(
58+
["run", "-m", "view_model_exists", "--vars", '{"include_value_column": true}']
59+
)
60+
assert len(results) == 1
61+
62+
# Generate catalog again to get updated column information
63+
catalog = run_dbt(["docs", "generate"])
64+
65+
# Get updated columns from the catalog
66+
columns = catalog.nodes[node_id].columns
67+
column_names = [name.lower() for name in columns.keys()]
68+
69+
# Verify both columns exist now
70+
assert "id" in column_names
71+
assert "value" in column_names
1672

1773

1874
class TestSingularTests(BaseSingularTests):

tests/functional/adapter/dbt/test_constraints.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -471,7 +471,7 @@ def models(self):
471471
@pytest.fixture(scope="class")
472472
def expected_sql(self):
473473
return """
474-
EXEC(' create view <model_identifier> as -- depends_on: <foreign_key_model_identifier> select ''blue'' as color, 1 as id, ''2019-01-01'' as date_day; ') EXEC(' CREATE TABLE <model_identifier> ( id int not null , color varchar(100), date_day varchar(100) ) INSERT INTO <model_identifier> ( [id], [color], [date_day] ) SELECT [id], [color], [date_day] FROM <model_identifier> ') EXEC('DROP VIEW IF EXISTS <model_identifier>
474+
EXEC(' CREATE OR ALTER VIEW <model_identifier> AS -- depends_on: <foreign_key_model_identifier> select ''blue'' as color, 1 as id, ''2019-01-01'' as date_day; ') EXEC(' CREATE TABLE <model_identifier> ( id int not null , color varchar(100), date_day varchar(100) ) INSERT INTO <model_identifier> ( [id], [color], [date_day] ) SELECT [id], [color], [date_day] FROM <model_identifier> ') EXEC('DROP VIEW IF EXISTS <model_identifier>
475475
"""
476476

477477
# EXEC('DROP view IF EXISTS <model_identifier>
@@ -591,7 +591,7 @@ def models(self):
591591
@pytest.fixture(scope="class")
592592
def expected_sql(self):
593593
return """
594-
EXEC(' create view <model_identifier> as -- depends_on: <foreign_key_model_identifier> select ''blue'' as color, 1 as id, ''2019-01-01'' as date_day; ') EXEC(' CREATE TABLE <model_identifier> ( id int not null , color varchar(100), date_day varchar(100) ) INSERT INTO <model_identifier> ( [id], [color], [date_day] ) SELECT [id], [color], [date_day] FROM <model_identifier> ') EXEC('DROP VIEW IF EXISTS <model_identifier>
594+
EXEC(' CREATE OR ALTER VIEW <model_identifier> AS -- depends_on: <foreign_key_model_identifier> select ''blue'' as color, 1 as id, ''2019-01-01'' as date_day; ') EXEC(' CREATE TABLE <model_identifier> ( id int not null , color varchar(100), date_day varchar(100) ) INSERT INTO <model_identifier> ( [id], [color], [date_day] ) SELECT [id], [color], [date_day] FROM <model_identifier> ') EXEC('DROP VIEW IF EXISTS <model_identifier>
595595
"""
596596

597597
def test__model_constraints_ddl(self, project, expected_sql):

tests/functional/adapter/mssql/test_materialize_change.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,15 @@
2323
SELECT 1 AS data
2424
"""
2525

26+
invalid_view_mat = """
27+
{{
28+
config({
29+
"materialized": 'view'
30+
})
31+
}}
32+
SELECT * FROM missing_relation
33+
"""
34+
2635
schema = """
2736
version: 2
2837
models:
@@ -50,6 +59,57 @@ def test_passes(self, project):
5059
run_dbt(["run"])
5160

5261

62+
class TestTabletoViewRollback(BaseTableView):
63+
"""Test that a failed table to view replacement leaves the original table intact."""
64+
65+
@pytest.fixture(scope="class")
66+
def models(self):
67+
return {"mat_object.sql": invalid_view_mat, "schema.yml": schema}
68+
69+
def test_existing_table_is_preserved(self, project):
70+
self.create_object(
71+
project, f"SELECT * INTO {project.test_schema}.mat_object FROM ({model_sql}) t"
72+
)
73+
74+
failing_results = run_dbt(["run"], expect_pass=False)
75+
assert len(failing_results) == 1
76+
77+
rows = project.run_sql(f"select * from {project.test_schema}.mat_object", fetch="all")
78+
assert len(rows) == 1
79+
assert rows[0][0] == 1
80+
81+
82+
class TestTabletoViewPreservesGrants(BaseTableView):
83+
"""Test that grants on the existing table are preserved on the replaced view."""
84+
85+
@pytest.fixture(scope="class")
86+
def models(self):
87+
return {"mat_object.sql": view_mat, "schema.yml": schema}
88+
89+
def test_public_select_grant_survives_swap(self, project):
90+
self.create_object(
91+
project, f"SELECT * INTO {project.test_schema}.mat_object FROM ({model_sql}) t"
92+
)
93+
project.run_sql(f"grant select on object::{project.test_schema}.mat_object to public")
94+
95+
run_dbt(["run"])
96+
97+
grants = project.run_sql(
98+
f"""
99+
select pr.name as grantee, pe.permission_name
100+
from sys.database_permissions pe
101+
join sys.objects o on pe.major_id = o.object_id
102+
join sys.schemas s on o.schema_id = s.schema_id
103+
join sys.database_principals pr
104+
on pe.grantee_principal_id = pr.principal_id
105+
where s.name = '{project.test_schema}'
106+
and o.name = 'mat_object'
107+
""",
108+
fetch="all",
109+
)
110+
assert ("PUBLIC", "SELECT") in [(row[0].upper(), row[1].upper()) for row in grants]
111+
112+
53113
class TestViewtoTable(BaseTableView):
54114
"""Test if changing from a view object to a table object correctly replaces"""
55115

0 commit comments

Comments
 (0)