Skip to content

Commit e813d7f

Browse files
authored
Merge pull request #610 from axellpadilla/view_materialization_improvement
Fix: Handle view materialization alterations
2 parents f15aee4 + f07f239 commit e813d7f

6 files changed

Lines changed: 220 additions & 14 deletions

File tree

dbt/include/sqlserver/macros/adapters/metadata.sql

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,20 @@
9090
{{ return(load_result('get_relation_without_caching').table) }}
9191
{% endmacro %}
9292

93+
{% macro get_view_definition_sql(relation) %}
94+
{{ return(adapter.dispatch('get_view_definition_sql')(relation)) }}
95+
{% endmacro %}
96+
97+
{% macro sqlserver__get_view_definition_sql(relation) -%}
98+
{{ get_use_database_sql(relation.database) }}
99+
select object_definition(v.object_id) as definition
100+
from sys.views as v {{ information_schema_hints() }}
101+
inner join sys.schemas as s {{ information_schema_hints() }}
102+
on v.schema_id = s.schema_id
103+
where upper(s.name) = upper('{{ relation.schema }}')
104+
and upper(v.name) = upper('{{ relation.identifier }}')
105+
{% endmacro %}
106+
93107
{% macro sqlserver__get_relation_last_modified(information_schema, relations) -%}
94108
{%- call statement('last_modified', fetch_result=True) -%}
95109
select

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

Lines changed: 48 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,36 @@
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+
{% set should_skip_view_update = false %}
31+
{% set build_sql = none %}
32+
33+
{% if existing_relation is not none and existing_relation.type != 'view' %}
34+
{% set current_grants_table = run_query(get_show_grant_sql(existing_relation)) %}
35+
{% set current_grants_dict = adapter.standardize_grants_dict(current_grants_table) %}
36+
{% set preserved_grants = {} %}
37+
{% for privilege, grantees in diff_of_two_dicts(current_grants_dict, grant_config).items() %}
38+
{% if privilege | lower in ['select', 'insert', 'update', 'delete'] %}
39+
{% do preserved_grants.update({privilege: grantees}) %}
40+
{% endif %}
41+
{% endfor %}
42+
{% set build_sql = get_create_view_as_sql(intermediate_relation, sql) %}
43+
{% elif existing_relation is not none and existing_relation.type == 'view' %}
44+
{% set current_view_definition_table = run_query(get_view_definition_sql(existing_relation)) %}
45+
{% if current_view_definition_table is not none and current_view_definition_table.rows | length > 0 %}
46+
{% set normalized_relation = target_relation.include(database=False) | lower | replace('\n', '') | replace('\r', '') | replace('\t', '') | replace(' ', '') | replace(';', '') %}
47+
{% set normalized_sql = sql | lower | replace('\n', '') | replace('\r', '') | replace('\t', '') | replace(' ', '') | replace(';', '') %}
48+
{% set normalized_definition = current_view_definition_table.rows[0][0] | lower | replace('\n', '') | replace('\r', '') | replace('\t', '') | replace(' ', '') | replace(';', '') %}
49+
{% set should_skip_view_update = normalized_definition.endswith(normalized_sql) %}
50+
{% endif %}
51+
{% if should_skip_view_update %}
52+
{% set build_sql = 'declare @dbt_sqlserver_noop int;' %}
53+
{% else %}
54+
{% set build_sql = get_create_view_as_sql(target_relation, sql) %}
55+
{% endif %}
56+
{% else %}
57+
{% set build_sql = get_create_view_as_sql(target_relation, sql) %}
58+
{% endif %}
2959

3060
{{ run_hooks(pre_hooks, inside_transaction=False) }}
3161

@@ -36,26 +66,34 @@
3666
-- `BEGIN` happens here:
3767
{{ run_hooks(pre_hooks, inside_transaction=True) }}
3868

39-
-- build model
40-
{% call statement('main') -%}
41-
{{ get_create_view_as_sql(intermediate_relation, sql) }}
42-
{%- endcall %}
69+
{% if existing_relation is not none and existing_relation.type != 'view' %}
70+
-- build model
71+
{% call statement('main') -%}
72+
{{ build_sql }}
73+
{%- endcall %}
4374

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. */
75+
-- cleanup
76+
-- move the existing relation out of the way
4977
{% set existing_relation = load_cached_relation(existing_relation) %}
5078
{% if existing_relation is not none %}
5179
{{ adapter.rename_relation(existing_relation, backup_relation) }}
5280
{% endif %}
81+
82+
{{ adapter.rename_relation(intermediate_relation, target_relation) }}
83+
{% else %}
84+
-- build model
85+
{% call statement('main') -%}
86+
{{ build_sql }}
87+
{%- endcall %}
5388
{% endif %}
54-
{{ adapter.rename_relation(intermediate_relation, target_relation) }}
5589

5690
{% set should_revoke = should_revoke(existing_relation, full_refresh_mode=True) %}
5791
{% do apply_grants(target_relation, grant_config, should_revoke=should_revoke) %}
5892

93+
{% if preserved_grants %}
94+
{% do apply_grants(target_relation, preserved_grants, should_revoke=False) %}
95+
{% endif %}
96+
5997
{% do persist_docs(target_relation, model) %}
6098

6199
{{ 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: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import os
2+
13
import pytest
24

35
from dbt.tests.adapter.basic.test_adapter_methods import BaseAdapterMethod
@@ -10,10 +12,62 @@
1012
from dbt.tests.adapter.basic.test_singular_tests_ephemeral import BaseSingularTestsEphemeral
1113
from dbt.tests.adapter.basic.test_snapshot_check_cols import BaseSnapshotCheckCols
1214
from dbt.tests.adapter.basic.test_snapshot_timestamp import BaseSnapshotTimestamp
15+
from dbt.tests.util import run_dbt
1316

1417

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

1872

1973
class TestSingularTests(BaseSingularTests):

tests/functional/adapter/dbt/test_constraints.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -472,7 +472,7 @@ def models(self):
472472
@pytest.fixture(scope="class")
473473
def expected_sql(self):
474474
return """
475-
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> WITH (TABLOCK) ( [id], [color], [date_day] ) SELECT [id], [color], [date_day] FROM <model_identifier> ') EXEC('DROP VIEW IF EXISTS <model_identifier>
475+
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>
476476
"""
477477

478478
# EXEC('DROP view IF EXISTS <model_identifier>
@@ -592,7 +592,7 @@ def models(self):
592592
@pytest.fixture(scope="class")
593593
def expected_sql(self):
594594
return """
595-
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> WITH (TABLOCK) ( [id], [color], [date_day] ) SELECT [id], [color], [date_day] FROM <model_identifier> ') EXEC('DROP VIEW IF EXISTS <model_identifier>
595+
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>
596596
"""
597597

598598
def test__model_constraints_ddl(self, project, expected_sql):

tests/functional/adapter/mssql/test_materialize_change.py

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

27+
invalid_view_mat = """
28+
{{
29+
config({
30+
"materialized": 'view'
31+
})
32+
}}
33+
SELECT * FROM missing_relation
34+
"""
35+
2736
schema = """
2837
version: 2
2938
models:
@@ -51,6 +60,97 @@ def test_passes(self, project):
5160
run_dbt(["run"])
5261

5362

63+
class TestTabletoViewRollback(BaseTableView):
64+
"""Test that a failed table to view replacement leaves the original table intact."""
65+
66+
@pytest.fixture(scope="class")
67+
def models(self):
68+
return {"mat_object.sql": invalid_view_mat, "schema.yml": schema}
69+
70+
def test_existing_table_is_preserved(self, project):
71+
self.create_object(
72+
project, f"SELECT * INTO {project.test_schema}.mat_object FROM ({model_sql}) t"
73+
)
74+
75+
failing_results = run_dbt(["run"], expect_pass=False)
76+
assert len(failing_results) == 1
77+
78+
rows = project.run_sql(f"select * from {project.test_schema}.mat_object", fetch="all")
79+
assert len(rows) == 1
80+
assert rows[0][0] == 1
81+
82+
83+
class TestTabletoViewPreservesGrants(BaseTableView):
84+
"""Test that grants on the existing table are preserved on the replaced view."""
85+
86+
@pytest.fixture(scope="class")
87+
def models(self):
88+
return {"mat_object.sql": view_mat, "schema.yml": schema}
89+
90+
def test_public_select_grant_survives_swap(self, project):
91+
self.create_object(
92+
project, f"SELECT * INTO {project.test_schema}.mat_object FROM ({model_sql}) t"
93+
)
94+
project.run_sql(f"""grant select, insert, update, delete
95+
on object::{project.test_schema}.mat_object to public""")
96+
97+
run_dbt(["run"])
98+
99+
grant_count = project.run_sql(
100+
f"""
101+
select count(*)
102+
from sys.database_permissions pe
103+
join sys.objects o on pe.major_id = o.object_id
104+
join sys.schemas s on o.schema_id = s.schema_id
105+
join sys.database_principals pr
106+
on pe.grantee_principal_id = pr.principal_id
107+
where s.name = '{project.test_schema}'
108+
and o.name = 'mat_object'
109+
and pe.permission_name in ('SELECT', 'INSERT', 'UPDATE', 'DELETE')
110+
""",
111+
fetch="one",
112+
)
113+
assert grant_count[0] == 4
114+
115+
116+
class TestViewMaterializationNoOp(BaseTableView):
117+
"""Test that rerunning an unchanged view avoids altering the view."""
118+
119+
@pytest.fixture(scope="class")
120+
def models(self):
121+
return {"mat_object.sql": view_mat, "schema.yml": schema}
122+
123+
def test_unchanged_view_does_not_alter(self, project):
124+
self.create_object(project, f"CREATE VIEW {project.test_schema}.mat_object AS {model_sql}")
125+
126+
before_modify_date = project.run_sql(
127+
f"""
128+
select modify_date
129+
from sys.objects o
130+
join sys.schemas s on o.schema_id = s.schema_id
131+
where upper(s.name) = upper('{project.test_schema}')
132+
and upper(o.name) = upper('mat_object')
133+
""",
134+
fetch="one",
135+
)[0]
136+
137+
results = run_dbt(["run"])
138+
assert len(results) == 1
139+
140+
after_modify_date = project.run_sql(
141+
f"""
142+
select modify_date
143+
from sys.objects o
144+
join sys.schemas s on o.schema_id = s.schema_id
145+
where upper(s.name) = upper('{project.test_schema}')
146+
and upper(o.name) = upper('mat_object')
147+
""",
148+
fetch="one",
149+
)[0]
150+
151+
assert after_modify_date == before_modify_date
152+
153+
54154
class TestViewtoTable(BaseTableView):
55155
"""Test if changing from a view object to a table object correctly replaces"""
56156

0 commit comments

Comments
 (0)