diff --git a/.changes/unreleased/Features-20230222-130632.yaml b/.changes/unreleased/Features-20230222-130632.yaml index 008052b284a..fd61355286c 100644 --- a/.changes/unreleased/Features-20230222-130632.yaml +++ b/.changes/unreleased/Features-20230222-130632.yaml @@ -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 diff --git a/core/dbt/include/global_project/macros/materializations/models/view/create_view_as.sql b/core/dbt/include/global_project/macros/materializations/models/view/create_view_as.sql index 241e40cd12c..bd596f1a59d 100644 --- a/core/dbt/include/global_project/macros/materializations/models/view/create_view_as.sql +++ b/core/dbt/include/global_project/macros/materializations/models/view/create_view_as.sql @@ -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 %} diff --git a/core/dbt/parser/base.py b/core/dbt/parser/base.py index 5ef48d58b03..fbd4c836f9d 100644 --- a/core/dbt/parser/base.py +++ b/core/dbt/parser/base.py @@ -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 diff --git a/core/dbt/parser/schemas.py b/core/dbt/parser/schemas.py index dac4895ccac..4e294e42846 100644 --- a/core/dbt/parser/schemas.py +++ b/core/dbt/parser/schemas.py @@ -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]): @@ -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 = ( diff --git a/tests/adapter/dbt/tests/adapter/constraints/fixtures.py b/tests/adapter/dbt/tests/adapter/constraints/fixtures.py index acf2c8b4bcd..c6923d94e53 100644 --- a/tests/adapter/dbt/tests/adapter/constraints/fixtures.py +++ b/tests/adapter/dbt/tests/adapter/constraints/fixtures.py @@ -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 +""" diff --git a/tests/adapter/dbt/tests/adapter/constraints/test_constraints.py b/tests/adapter/dbt/tests/adapter/constraints/test_constraints.py index 5f08fdc845d..1b08e9f8bae 100644 --- a/tests/adapter/dbt/tests/adapter/constraints/test_constraints.py +++ b/tests/adapter/dbt/tests/adapter/constraints/test_constraints.py @@ -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, ) @@ -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" ) @@ -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 diff --git a/tests/functional/configs/test_constraint_configs.py b/tests/functional/configs/test_constraint_configs.py index 98452fce501..7a753eab17f 100644 --- a/tests/functional/configs/test_constraint_configs.py +++ b/tests/functional/configs/test_constraint_configs.py @@ -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" ) }} @@ -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: @@ -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: @@ -223,7 +225,7 @@ 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, } @@ -231,10 +233,13 @@ 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: @@ -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: @@ -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