Repository: dbt-labs/dbt-core

Issue:
[CT-2167] Enforce model contracts for default view materializations
Currently, we [assert column equivalence during table creation](https://github.com/dbt-labs/dbt-core/pull/6271/files#diff-7246f19f4d12042d172ac61b3d0c4564ea27dd8825870b35425ede1ea41625dbR28-R31). 

We'll need to similarly assert column equivalence in [default view materializations](https://github.com/dbt-labs/dbt-core/blob/main/core/dbt/include/global_project/macros/materializations/models/view/create_view_as.sql). 

Note - we won't be able to generate `column_spec_ddl` as column spec ddl is generally unsupported for view creation. 




Discussion:
Also add tests but they can follow the pattern for the table version but for a view.

Resolving patch:
diff --git a/core/dbt/parser/base.py b/core/dbt/parser/base.py
--- 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
--- 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 = (


Test patch:
diff --git a/tests/adapter/dbt/tests/adapter/constraints/fixtures.py b/tests/adapter/dbt/tests/adapter/constraints/fixtures.py
--- 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
--- 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
--- 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