Skip to content

Commit

Permalink
model contracts on models materialized as views (#7120)
Browse files Browse the repository at this point in the history
* first pass

* rename tests

* fix failing test

* changelog

* fix functional test

* Update core/dbt/parser/base.py

* Update core/dbt/parser/schemas.py
  • Loading branch information
emmyoop authored Mar 7, 2023
1 parent 8d98752 commit e895fe9
Show file tree
Hide file tree
Showing 7 changed files with 109 additions and 19 deletions.
6 changes: 3 additions & 3 deletions .changes/unreleased/Features-20230222-130632.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
kind: Features
body: get_column_schema_from_query_macro
body: Enforce contracts on models materialized as tables and views
time: 2023-02-22T13:06:32.583743-05:00
custom:
Author: jtcohen6 michelleark
Issue: "6751"
Author: jtcohen6 michelleark emmyoop
Issue: 6751 7034
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,11 @@
{%- set sql_header = config.get('sql_header', none) -%}

{{ sql_header if sql_header is not none }}
create view {{ relation }} as (
create view {{ relation }}
{%- if config.get('contract', False) %}
{{ get_assert_columns_equivalent(sql) }}
{%- endif %}
as (
{{ sql }}
);
{%- endmacro %}
2 changes: 1 addition & 1 deletion core/dbt/parser/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -320,7 +320,7 @@ def update_parsed_node_config(
original_file_path = parsed_node.original_file_path
error_message = "\n `contract=true` can only be configured within `schema.yml` files\n NOT within a model file(ex: .sql, .py) or `dbt_project.yml`."
raise ParsingError(
f"Original File Path: ({original_file_path})\nConstraints must be defined in a `yml` schema configuration file like `schema.yml`.\nOnly the SQL table materialization is supported for constraints. \n`data_type` values must be defined for all columns and NOT be null or blank.{error_message}"
f"Original File Path: ({original_file_path})\nConstraints must be defined in a `yml` schema configuration file like `schema.yml`.\nOnly the SQL table and view materializations are supported for constraints. \n`data_type` values must be defined for all columns and NOT be null or blank.{error_message}"
)

# unrendered_config is used to compare the original database/schema/alias
Expand Down
4 changes: 2 additions & 2 deletions core/dbt/parser/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -973,7 +973,7 @@ def validate_constraints(self, patched_node):
if error_messages:
original_file_path = patched_node.original_file_path
raise ParsingError(
f"Original File Path: ({original_file_path})\nConstraints must be defined in a `yml` schema configuration file like `schema.yml`.\nOnly the SQL table materialization is supported for constraints. \n`data_type` values must be defined for all columns and NOT be null or blank.{self.convert_errors_to_string(error_messages)}"
f"Original File Path: ({original_file_path})\nConstraints must be defined in a `yml` schema configuration file like `schema.yml`.\nOnly the SQL table and view materializations are supported for constraints. \n`data_type` values must be defined for all columns and NOT be null or blank.{self.convert_errors_to_string(error_messages)}"
)

def convert_errors_to_string(self, error_messages: List[str]):
Expand All @@ -995,7 +995,7 @@ def constraints_schema_validator(self, patched_node):

def constraints_materialization_validator(self, patched_node):
materialization_error = {}
if patched_node.config.materialized != "table":
if patched_node.config.materialized not in ["table", "view"]:
materialization_error = {"materialization": patched_node.config.materialized}
materialization_error_msg = f"\n Materialization Error: {materialization_error}"
materialization_error_msg_payload = (
Expand Down
54 changes: 54 additions & 0 deletions tests/adapter/dbt/tests/adapter/constraints/fixtures.py
Original file line number Diff line number Diff line change
Expand Up @@ -139,3 +139,57 @@
- name: wrong_data_type_column_name
data_type: {data_type}
"""

my_model_view_sql = """
{{
config(
materialized = "table"
)
}}
select
1 as id,
'blue' as color,
cast('2019-01-01' as date) as date_day
"""

my_model_view_wrong_order_sql = """
{{
config(
materialized = "view"
)
}}
select
'blue' as color,
1 as id,
cast('2019-01-01' as date) as date_day
"""

my_model_view_wrong_name_sql = """
{{
config(
materialized = "view"
)
}}
select
1 as error,
'blue' as color,
cast('2019-01-01' as date) as date_day
"""

my_model_view_with_nulls_sql = """
{{
config(
materialized = "view"
)
}}
select
-- null value for 'id'
cast(null as {{ dbt.type_int() }}) as id,
-- change the color as well (to test rollback)
'red' as color,
cast('2019-01-01' as date) as date_day
"""
29 changes: 27 additions & 2 deletions tests/adapter/dbt/tests/adapter/constraints/test_constraints.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
my_model_wrong_name_sql,
my_model_data_type_sql,
model_data_type_schema_yml,
my_model_view_wrong_order_sql,
my_model_view_wrong_name_sql,
my_model_with_nulls_sql,
model_schema_yml,
)
Expand Down Expand Up @@ -74,7 +76,6 @@ def test__constraints_wrong_column_order(self, project, string_type, int_type):
assert contract_actual_config is True

expected_compile_error = "Please ensure the name, data_type, order, and number of columns in your `yml` file match the columns in your SQL file."

expected_schema_file_columns = (
f"Schema File Columns: id {int_type}, color {string_type}, date_day DATE"
)
Expand Down Expand Up @@ -284,7 +285,31 @@ def test__constraints_enforcement_rollback(
self.assert_expected_error_messages(failing_results[0].message, expected_error_messages)


class TestConstraintsColumnsEqual(BaseConstraintsColumnsEqual):
class BaseTableConstraintsColumnsEqual(BaseConstraintsColumnsEqual):
@pytest.fixture(scope="class")
def models(self):
return {
"my_model_wrong_order.sql": my_model_wrong_order_sql,
"my_model_wrong_name.sql": my_model_wrong_name_sql,
"constraints_schema.yml": model_schema_yml,
}


class BaseViewConstraintsColumnsEqual(BaseConstraintsColumnsEqual):
@pytest.fixture(scope="class")
def models(self):
return {
"my_model_wrong_order.sql": my_model_view_wrong_order_sql,
"my_model_wrong_name.sql": my_model_view_wrong_name_sql,
"constraints_schema.yml": model_schema_yml,
}


class TestTableConstraintsColumnsEqual(BaseTableConstraintsColumnsEqual):
pass


class TestViewConstraintsColumnsEqual(BaseViewConstraintsColumnsEqual):
pass


Expand Down
27 changes: 17 additions & 10 deletions tests/functional/configs/test_constraint_configs.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,10 @@
cast('2019-01-01' as date) as date_day
"""

my_view_model_sql = """
my_incremental_model_sql = """
{{
config(
materialized = "view"
materialized = "Incremental"
)
}}
Expand Down Expand Up @@ -181,8 +181,9 @@ def test__project_error(self, project):
with pytest.raises(ParsingError) as err_info:
run_dbt(["parse"], expect_pass=False)

exc_str = " ".join(str(err_info.value).split())
error_message_expected = "NOT within a model file(ex: .sql, .py) or `dbt_project.yml`."
assert error_message_expected in str(err_info)
assert error_message_expected in exc_str


class TestModelConstraintsEnabledConfigs:
Expand All @@ -196,8 +197,9 @@ def test__model_error(self, project):
with pytest.raises(ParsingError) as err_info:
run_dbt(["parse"], expect_pass=False)

exc_str = " ".join(str(err_info.value).split())
error_message_expected = "NOT within a model file(ex: .sql, .py) or `dbt_project.yml`."
assert error_message_expected in str(err_info)
assert error_message_expected in exc_str


class TestModelLevelConstraintsDisabledConfigs:
Expand All @@ -223,18 +225,21 @@ class TestModelLevelConstraintsErrorMessages:
@pytest.fixture(scope="class")
def models(self):
return {
"my_model.sql": my_view_model_sql,
"my_model.sql": my_incremental_model_sql,
"constraints_schema.yml": model_schema_errors_yml,
}

def test__config_errors(self, project):
with pytest.raises(ParsingError) as err_info:
run_dbt(["parse"], expect_pass=False)

expected_materialization_error = "Materialization Error: {'materialization': 'view'}"
exc_str = " ".join(str(err_info.value).split())
expected_materialization_error = (
"Materialization Error: {'materialization': 'Incremental'}"
)
expected_empty_data_type_error = "Columns with `data_type` Blank/Null Errors: {'date_day'}"
assert expected_materialization_error in str(err_info)
assert expected_empty_data_type_error in str(err_info)
assert expected_materialization_error in str(exc_str)
assert expected_empty_data_type_error in str(exc_str)


class TestSchemaConstraintsEnabledConfigs:
Expand All @@ -249,8 +254,9 @@ def test__schema_error(self, project):
with pytest.raises(ParsingError) as err_info:
run_dbt(["parse"], expect_pass=False)

exc_str = " ".join(str(err_info.value).split())
schema_error_expected = "Schema Error: `yml` configuration does NOT exist"
assert schema_error_expected in str(err_info)
assert schema_error_expected in str(exc_str)


class TestPythonModelLevelConstraintsErrorMessages:
Expand All @@ -265,5 +271,6 @@ def test__python_errors(self, project):
with pytest.raises(ParsingError) as err_info:
run_dbt(["parse"], expect_pass=False)

exc_str = " ".join(str(err_info.value).split())
expected_python_error = "Language Error: {'language': 'python'}"
assert expected_python_error in str(err_info)
assert expected_python_error in exc_str

0 comments on commit e895fe9

Please sign in to comment.