Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

model contracts on models materialized as views #7120

Merged
merged 7 commits into from
Mar 7, 2023
Merged
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: 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