Repository: dbt-labs/dbt-core

Issue:
[CT-2293] Enforce model contracts for incremental materializations 
When `contract` is true, model contracts are [enforced prior to table creation for table materializations](https://github.com/dbt-labs/dbt-core/pull/6271/files#diff-7246f19f4d12042d172ac61b3d0c4564ea27dd8825870b35425ede1ea41625dbR28-R31).

Similarly, Incremental models would need to enforce contracts as part of the existing model contract validation that occurs in adapter-dispatched `create_table_as` macro calls in incremental materializations. 

Acceptance criteria: 
- Introduce new adapter Integration tests that validate model contract is enforced and constraint DDL is generated for models with incremental materializations with `contract: true`. Ensure adapter tests pass in spark, redshift, snowflake, and bigquery repos. 
- Remove any existing detection / error messaging around limitations of using `contract:true` with certain SQL materializations. For example, [here](https://github.com/dbt-labs/dbt-core/blob/main/core/dbt/parser/base.py#L323) and [here](https://github.com/dbt-labs/dbt-core/blob/main/core/dbt/parser/schemas.py#L959)
- Validate that and raise an error during parsing to ensure that materialized models with contract:true use the `on_schema_change: append_new_columns` option. (perhaps in the [__post_init__ method on the NodeConfig class here](https://github.com/dbt-labs/dbt-core/blob/main/core/dbt/contracts/graph/model_config.py#L457).

Related discussion / spiking: https://github.com/dbt-labs/dbt-core/issues/6755




Discussion:

Resolving patch:
diff --git a/core/dbt/contracts/graph/model_config.py b/core/dbt/contracts/graph/model_config.py
--- a/core/dbt/contracts/graph/model_config.py
+++ b/core/dbt/contracts/graph/model_config.py
@@ -453,8 +453,8 @@ class NodeConfig(NodeAndTestConfig):
     )
     contract: bool = False
 
-    # we validate that node_color has a suitable value to prevent dbt-docs from crashing
     def __post_init__(self):
+        # we validate that node_color has a suitable value to prevent dbt-docs from crashing
         if self.docs.node_color:
             node_color = self.docs.node_color
             if not validate_color(node_color):
@@ -463,6 +463,17 @@ def __post_init__(self):
                     "It is neither a valid HTML color name nor a valid HEX code."
                 )
 
+        if (
+            self.contract
+            and self.materialized == "incremental"
+            and self.on_schema_change != "append_new_columns"
+        ):
+            raise ValidationError(
+                f"Invalid value for on_schema_change: {self.on_schema_change}. Models "
+                "materialized as incremental with contracts enabled must set "
+                "on_schema_change to 'append_new_columns'"
+            )
+
     @classmethod
     def __pre_deserialize__(cls, data):
         data = super().__pre_deserialize__(data)
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
@@ -951,7 +951,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 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)}"
+                f"Original File Path: ({original_file_path})\nConstraints must be defined in a `yml` schema configuration file like `schema.yml`.\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]):
@@ -973,7 +973,7 @@ def constraints_schema_validator(self, patched_node):
 
     def constraints_materialization_validator(self, patched_node):
         materialization_error = {}
-        if patched_node.config.materialized not in ["table", "view"]:
+        if patched_node.config.materialized not in ["table", "view", "incremental"]:
             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/core/dbt/tests/util.py b/core/dbt/tests/util.py
--- a/core/dbt/tests/util.py
+++ b/core/dbt/tests/util.py
@@ -322,7 +322,7 @@ def relation_from_name(adapter, name: str):
 
 
 # Ensure that models with different materialiations have the
-# corrent table/view.
+# current table/view.
 # Uses:
 #   adapter.list_relations_without_caching
 def check_relation_types(adapter, relation_to_type):
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
@@ -1,3 +1,4 @@
+# base mode definitions
 my_model_sql = """
 {{
   config(
@@ -6,11 +7,39 @@
 }}
 
 select
+  1 as id,
   'blue' as color,
+  '2019-01-01' as date_day
+"""
+
+my_model_view_sql = """
+{{
+  config(
+    materialized = "view"
+  )
+}}
+
+select
   1 as id,
+  'blue' as color,
   '2019-01-01' as date_day
 """
 
+my_incremental_model_sql = """
+{{
+  config(
+    materialized = "incremental",
+    on_schema_change='append_new_columns'
+  )
+}}
+
+select
+  1 as id,
+  'blue' as color,
+  '2019-01-01' as date_day
+"""
+
+# model columns in a different order to schema definitions
 my_model_wrong_order_sql = """
 {{
   config(
@@ -24,6 +53,34 @@
   '2019-01-01' as date_day
 """
 
+my_model_view_wrong_order_sql = """
+{{
+  config(
+    materialized = "view"
+  )
+}}
+
+select
+  'blue' as color,
+  1 as id,
+  '2019-01-01' as date_day
+"""
+
+my_model_incremental_wrong_order_sql = """
+{{
+  config(
+    materialized = "incremental",
+    on_schema_change='append_new_columns'
+  )
+}}
+
+select
+  'blue' as color,
+  1 as id,
+  '2019-01-01' as date_day
+"""
+
+# model columns name different to schema definitions
 my_model_wrong_name_sql = """
 {{
   config(
@@ -37,6 +94,34 @@
   '2019-01-01' as date_day
 """
 
+my_model_view_wrong_name_sql = """
+{{
+  config(
+    materialized = "view"
+  )
+}}
+
+select
+  'blue' as color,
+  1 as error,
+  '2019-01-01' as date_day
+"""
+
+my_model_incremental_wrong_name_sql = """
+{{
+  config(
+    materialized = "incremental",
+    on_schema_change='append_new_columns'
+  )
+}}
+
+select
+  'blue' as color,
+  1 as error,
+  '2019-01-01' as date_day
+"""
+
+# model columns data types different to schema definitions
 my_model_data_type_sql = """
 {{{{
   config(
@@ -48,6 +133,7 @@
   {sql_value} as wrong_data_type_column_name
 """
 
+# model breaking constraints
 my_model_with_nulls_sql = """
 {{
   config(
@@ -63,6 +149,36 @@
   '2019-01-01' 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,
+  '2019-01-01' as date_day
+"""
+
+my_model_incremental_with_nulls_sql = """
+{{
+  config(
+    materialized = "incremental",
+    on_schema_change='append_new_columns'  )
+}}
+
+select
+  -- null value for 'id'
+  cast(null as {{ dbt.type_int() }}) as id,
+  -- change the color as well (to test rollback)
+  'red' as color,
+  '2019-01-01' as date_day
+"""
+
 model_schema_yml = """
 version: 2
 models:
@@ -151,57 +267,3 @@
       - name: wrong_data_type_column_name
         data_type: {data_type}
 """
-
-my_model_view_sql = """
-{{
-  config(
-    materialized = "table"
-  )
-}}
-
-select
-  1 as id,
-  'blue' as color,
-  '2019-01-01' as date_day
-"""
-
-my_model_view_wrong_order_sql = """
-{{
-  config(
-    materialized = "view"
-  )
-}}
-
-select
-  'blue' as color,
-  1 as id,
-  '2019-01-01' as date_day
-"""
-
-my_model_view_wrong_name_sql = """
-{{
-  config(
-    materialized = "view"
-  )
-}}
-
-select
-  'blue' as color,
-  1 as error,
-  '2019-01-01' 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,
-  '2019-01-01' 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
@@ -12,13 +12,17 @@
 
 from dbt.tests.adapter.constraints.fixtures import (
     my_model_sql,
+    my_incremental_model_sql,
     my_model_wrong_order_sql,
     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_incremental_wrong_order_sql,
+    my_model_incremental_wrong_name_sql,
     my_model_with_nulls_sql,
+    my_model_incremental_with_nulls_sql,
     model_schema_yml,
 )
 
@@ -28,14 +32,6 @@ class BaseConstraintsColumnsEqual:
     dbt should catch these mismatches during its "preflight" checks.
     """
 
-    @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,
-        }
-
     @pytest.fixture
     def string_type(self):
         return "TEXT"
@@ -63,11 +59,9 @@ def data_types(self, schema_int_type, int_type, string_type):
             ["""'{"bar": "baz", "balance": 7.77, "active": false}'::json""", "json", "JSON"],
         ]
 
-    def test__constraints_wrong_column_order(self, project, string_type, int_type):
+    def test__constraints_wrong_column_order(self, project):
         # This no longer causes an error, since we enforce yaml column order
-        results, log_output = run_dbt_and_capture(
-            ["run", "-s", "my_model_wrong_order"], expect_pass=True
-        )
+        run_dbt(["run", "-s", "my_model_wrong_order"], expect_pass=True)
         manifest = get_manifest(project.project_root)
         model_id = "model.test.my_model_wrong_order"
         my_model_config = manifest.nodes[model_id].config
@@ -172,15 +166,29 @@ def test__constraints_correct_column_data_types(self, project, data_types):
             assert contract_actual_config is True
 
 
-# This is SUPER specific to Postgres, and will need replacing on other adapters
-# TODO: make more generic
-_expected_sql = """
-create table {0} (
+class BaseConstraintsRuntimeDdlEnforcement:
+    """
+    These constraints pass muster for dbt's preflight checks. Make sure they're
+    passed into the DDL statement. If they don't match up with the underlying data,
+    the data platform should raise an error at runtime.
+    """
+
+    @pytest.fixture(scope="class")
+    def models(self):
+        return {
+            "my_model.sql": my_model_wrong_order_sql,
+            "constraints_schema.yml": model_schema_yml,
+        }
+
+    @pytest.fixture(scope="class")
+    def expected_sql(self):
+        return """
+create table <model_identifier> (
     id integer not null primary key check (id > 0),
     color text,
     date_day text
 ) ;
-insert into {0} (
+insert into <model_identifier> (
     id ,
     color ,
     date_day
@@ -200,14 +208,34 @@ def test__constraints_correct_column_data_types(self, project, data_types):
 );
 """
 
+    def test__constraints_ddl(self, project, expected_sql):
+        results = run_dbt(["run", "-s", "my_model"])
+        assert len(results) == 1
+
+        # grab the sql and replace the model identifier to make it generic for all adapters
+        # the name is not what we're testing here anyways and varies based on materialization
+        # TODO: consider refactoring this to introspect logs instead
+        generated_sql = read_file("target", "run", "test", "models", "my_model.sql")
+        generated_sql_modified = re.sub(r"\s+", " ", generated_sql).lower().strip()
+        generated_sql_list = generated_sql_modified.split(" ")
+        for idx in [n for n, x in enumerate(generated_sql_list) if "my_model" in x]:
+            generated_sql_list[idx] = "<model_identifier>"
+        generated_sql_generic = " ".join(generated_sql_list)
+
+        expected_sql_check = re.sub(r"\s+", " ", expected_sql).lower().strip()
+
+        assert (
+            expected_sql_check == generated_sql_generic
+        ), f"""
+-- GENERATED SQL
+{generated_sql_generic}
+
+-- EXPECTED SQL
+{expected_sql_check}
+"""
 
-class BaseConstraintsRuntimeEnforcement:
-    """
-    These constraints pass muster for dbt's preflight checks. Make sure they're
-    passed into the DDL statement. If they don't match up with the underlying data,
-    the data platform should raise an error at runtime.
-    """
 
+class BaseConstraintsRollback:
     @pytest.fixture(scope="class")
     def models(self):
         return {
@@ -216,10 +244,8 @@ def models(self):
         }
 
     @pytest.fixture(scope="class")
-    def expected_sql(self, project):
-        relation = relation_from_name(project.adapter, "my_model")
-        tmp_relation = relation.incorporate(path={"identifier": relation.identifier + "__dbt_tmp"})
-        return _expected_sql.format(tmp_relation)
+    def null_model_sql(self):
+        return my_model_with_nulls_sql
 
     @pytest.fixture(scope="class")
     def expected_color(self):
@@ -232,32 +258,14 @@ def expected_error_messages(self):
     def assert_expected_error_messages(self, error_message, expected_error_messages):
         assert all(msg in error_message for msg in expected_error_messages)
 
-    def test__constraints_ddl(self, project, expected_sql):
-        results = run_dbt(["run", "-s", "my_model"])
-        assert len(results) == 1
-        # TODO: consider refactoring this to introspect logs instead
-        generated_sql = read_file("target", "run", "test", "models", "my_model.sql")
-
-        generated_sql_check = re.sub(r"\s+", " ", generated_sql).lower().strip()
-        expected_sql_check = re.sub(r"\s+", " ", expected_sql).lower().strip()
-        assert (
-            expected_sql_check == generated_sql_check
-        ), f"""
--- GENERATED SQL
-{generated_sql_check}
-
--- EXPECTED SQL
-{expected_sql_check}
-"""
-
     def test__constraints_enforcement_rollback(
-        self, project, expected_color, expected_error_messages
+        self, project, expected_color, expected_error_messages, null_model_sql
     ):
         results = run_dbt(["run", "-s", "my_model"])
         assert len(results) == 1
 
         # Make a contract-breaking change to the model
-        write_file(my_model_with_nulls_sql, "models", "my_model.sql")
+        write_file(null_model_sql, "models", "my_model.sql")
 
         failing_results = run_dbt(["run", "-s", "my_model"], expect_pass=False)
         assert len(failing_results) == 1
@@ -301,6 +309,38 @@ def models(self):
         }
 
 
+class BaseIncrementalConstraintsColumnsEqual(BaseConstraintsColumnsEqual):
+    @pytest.fixture(scope="class")
+    def models(self):
+        return {
+            "my_model_wrong_order.sql": my_model_incremental_wrong_order_sql,
+            "my_model_wrong_name.sql": my_model_incremental_wrong_name_sql,
+            "constraints_schema.yml": model_schema_yml,
+        }
+
+
+class BaseIncrementalConstraintsRuntimeDdlEnforcement(BaseConstraintsRuntimeDdlEnforcement):
+    @pytest.fixture(scope="class")
+    def models(self):
+        return {
+            "my_model.sql": my_model_incremental_wrong_order_sql,
+            "constraints_schema.yml": model_schema_yml,
+        }
+
+
+class BaseIncrementalConstraintsRollback(BaseConstraintsRollback):
+    @pytest.fixture(scope="class")
+    def models(self):
+        return {
+            "my_model.sql": my_incremental_model_sql,
+            "constraints_schema.yml": model_schema_yml,
+        }
+
+    @pytest.fixture(scope="class")
+    def null_model_sql(self):
+        return my_model_incremental_with_nulls_sql
+
+
 class TestTableConstraintsColumnsEqual(BaseTableConstraintsColumnsEqual):
     pass
 
@@ -309,5 +349,23 @@ class TestViewConstraintsColumnsEqual(BaseViewConstraintsColumnsEqual):
     pass
 
 
-class TestConstraintsRuntimeEnforcement(BaseConstraintsRuntimeEnforcement):
+class TestIncrementalConstraintsColumnsEqual(BaseIncrementalConstraintsColumnsEqual):
+    pass
+
+
+class TestTableConstraintsRuntimeDdlEnforcement(BaseConstraintsRuntimeDdlEnforcement):
+    pass
+
+
+class TestTableConstraintsRollback(BaseConstraintsRollback):
+    pass
+
+
+class TestIncrementalConstraintsRuntimeDdlEnforcement(
+    BaseIncrementalConstraintsRuntimeDdlEnforcement
+):
+    pass
+
+
+class TestIncrementalConstraintsRollback(BaseIncrementalConstraintsRollback):
     pass
diff --git a/tests/functional/configs/test_contract_configs.py b/tests/functional/configs/test_contract_configs.py
--- a/tests/functional/configs/test_contract_configs.py
+++ b/tests/functional/configs/test_contract_configs.py
@@ -1,5 +1,5 @@
 import pytest
-from dbt.exceptions import ParsingError
+from dbt.exceptions import ParsingError, ValidationError
 from dbt.tests.util import run_dbt, get_manifest, get_artifact, run_dbt_and_capture
 
 my_model_sql = """
@@ -46,7 +46,20 @@
 my_incremental_model_sql = """
 {{
   config(
-    materialized = "Incremental"
+    materialized = "incremental"
+  )
+}}
+
+select
+  1 as id,
+  'blue' as color,
+  cast('2019-01-01' as date) as date_day
+"""
+
+my_ephemeral_model_sql = """
+{{
+  config(
+    materialized = "ephemeral"
   )
 }}
 
@@ -338,6 +351,23 @@ class TestModelLevelContractErrorMessages:
     def models(self):
         return {
             "my_model.sql": my_incremental_model_sql,
+            "constraints_schema.yml": model_schema_yml,
+        }
+
+    def test__config_errors(self, project):
+        with pytest.raises(ValidationError) as err_info:
+            run_dbt(["run"], expect_pass=False)
+
+        exc_str = " ".join(str(err_info.value).split())
+        expected_materialization_error = "Invalid value for on_schema_change: ignore. Models materialized as incremental with contracts enabled must set on_schema_change to 'append_new_columns'"
+        assert expected_materialization_error in str(exc_str)
+
+
+class TestModelLevelConstraintsErrorMessages:
+    @pytest.fixture(scope="class")
+    def models(self):
+        return {
+            "my_model.sql": my_ephemeral_model_sql,
             "constraints_schema.yml": model_schema_errors_yml,
         }
 
@@ -346,11 +376,9 @@ def test__config_errors(self, project):
             run_dbt(["run"], expect_pass=False)
 
         exc_str = " ".join(str(err_info.value).split())
-        expected_materialization_error = (
-            "Materialization Error: {'materialization': 'Incremental'}"
-        )
+        expected_materialization_error = "Materialization Error: {'materialization': 'ephemeral'}"
         assert expected_materialization_error in str(exc_str)
-        # This is a compile time error and we won't get here because the materialization is parse time
+        # This is a compile time error and we won't get here because the materialization check is parse time
         expected_empty_data_type_error = "Columns with `data_type` Blank/Null not allowed on contracted models. Columns Blank/Null: ['date_day']"
         assert expected_empty_data_type_error not in str(exc_str)