Repository: dbt-labs/dbt-core

Issue:
[CT-2121] Make model contracts agnostic to ordering
The current model contract comparison (`assert_columns_equivalent` [macro](https://github.com/dbt-labs/dbt-core/pull/6986/files#diff-28fef0f8080904ca9d7c04e61050beded83aaecc39d332b8f0486eea686d44f1L30)) validates that the user-provided column names and data types are identical: in name, type, _and order_ the column names obtained from the model SQL.

However, it will be cumbersome for users to ensure that the order of configured columns in a model yaml file always matches with the order the database provides. 

For example, the SQL model configured with the schema below fails its contract check:

```sql
-- model.sql
SELECT 1 as a, 2 as b
```
```yaml
-- schema.yml
models:
- model: 
  contract: True
  columns:
    - name: b
       data_type: INT
    - name: a
       data_type: INT
```

The [equality comparison is done directly on formatted columns](https://github.com/dbt-labs/dbt-core/pull/6986/files#diff-28fef0f8080904ca9d7c04e61050beded83aaecc39d332b8f0486eea686d44f1R37) from the SQL + schema yaml, and could instead be done on the intermediate collection of unordered columns. 

Relevant discussion: https://github.com/dbt-labs/dbt-core/pull/6271#discussion_r1106122988

Note: should not be merged before https://github.com/dbt-labs/dbt-core/issues/7064, as this ordering check is currently done in `assert_columns_equivalent` and prevents constraint ddl generation from being generated in an invalid order. 


Discussion:

Resolving patch:
diff --git a/core/dbt/clients/jinja.py b/core/dbt/clients/jinja.py
--- a/core/dbt/clients/jinja.py
+++ b/core/dbt/clients/jinja.py
@@ -483,7 +483,7 @@ def get_environment(
     native: bool = False,
 ) -> jinja2.Environment:
     args: Dict[str, List[Union[str, Type[jinja2.ext.Extension]]]] = {
-        "extensions": ["jinja2.ext.do"]
+        "extensions": ["jinja2.ext.do", "jinja2.ext.loopcontrols"]
     }
 
     if capture_macros:
diff --git a/core/dbt/context/exceptions_jinja.py b/core/dbt/context/exceptions_jinja.py
--- a/core/dbt/context/exceptions_jinja.py
+++ b/core/dbt/context/exceptions_jinja.py
@@ -23,6 +23,7 @@
     PropertyYMLError,
     NotImplementedError,
     RelationWrongTypeError,
+    ContractError,
     ColumnTypeMissingError,
 )
 
@@ -66,6 +67,10 @@ def raise_compiler_error(msg, node=None) -> NoReturn:
     raise CompilationError(msg, node)
 
 
+def raise_contract_error(yaml_columns, sql_columns) -> NoReturn:
+    raise ContractError(yaml_columns, sql_columns)
+
+
 def raise_database_error(msg, node=None) -> NoReturn:
     raise DbtDatabaseError(msg, node)
 
@@ -124,6 +129,7 @@ def column_type_missing(column_names) -> NoReturn:
         raise_invalid_property_yml_version,
         raise_not_implemented,
         relation_wrong_type,
+        raise_contract_error,
         column_type_missing,
     ]
 }
diff --git a/core/dbt/exceptions.py b/core/dbt/exceptions.py
--- a/core/dbt/exceptions.py
+++ b/core/dbt/exceptions.py
@@ -2124,6 +2124,23 @@ def get_message(self) -> str:
         return msg
 
 
+class ContractError(CompilationError):
+    def __init__(self, yaml_columns, sql_columns):
+        self.yaml_columns = yaml_columns
+        self.sql_columns = sql_columns
+        super().__init__(msg=self.get_message())
+
+    def get_message(self) -> str:
+        msg = (
+            "Contracts are enabled for this model. "
+            "Please ensure the name, data_type, and number of columns in your `yml` file "
+            "match the columns in your SQL file.\n"
+            f"Schema File Columns: {self.yaml_columns}\n"
+            f"SQL File Columns: {self.sql_columns}"
+        )
+        return msg
+
+
 # not modifying these since rpc should be deprecated soon
 class UnknownAsyncIDException(Exception):
     CODE = 10012


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
@@ -6,9 +6,9 @@
 }}
 
 select
-  1 as id,
   'blue' as color,
-  cast('2019-01-01' as date) as date_day
+  1 as id,
+  '2019-01-01' as date_day
 """
 
 my_model_wrong_order_sql = """
@@ -21,7 +21,7 @@
 select
   'blue' as color,
   1 as id,
-  cast('2019-01-01' as date) as date_day
+  '2019-01-01' as date_day
 """
 
 my_model_wrong_name_sql = """
@@ -32,9 +32,9 @@
 }}
 
 select
-  1 as error,
   'blue' as color,
-  cast('2019-01-01' as date) as date_day
+  1 as error,
+  '2019-01-01' as date_day
 """
 
 my_model_data_type_sql = """
@@ -60,7 +60,7 @@
   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
+  '2019-01-01' as date_day
 """
 
 model_schema_yml = """
@@ -81,7 +81,7 @@
       - name: color
         data_type: text
       - name: date_day
-        data_type: date
+        data_type: text
   - name: my_model_error
     config:
       contract: true
@@ -96,7 +96,7 @@
       - name: color
         data_type: text
       - name: date_day
-        data_type: date
+        data_type: text
   - name: my_model_wrong_order
     config:
       contract: true
@@ -111,7 +111,7 @@
       - name: color
         data_type: text
       - name: date_day
-        data_type: date
+        data_type: text
   - name: my_model_wrong_name
     config:
       contract: true
@@ -126,7 +126,7 @@
       - name: color
         data_type: text
       - name: date_day
-        data_type: date
+        data_type: text
 """
 
 model_data_type_schema_yml = """
@@ -150,7 +150,7 @@
 select
   1 as id,
   'blue' as color,
-  cast('2019-01-01' as date) as date_day
+  '2019-01-01' as date_day
 """
 
 my_model_view_wrong_order_sql = """
@@ -163,7 +163,7 @@
 select
   'blue' as color,
   1 as id,
-  cast('2019-01-01' as date) as date_day
+  '2019-01-01' as date_day
 """
 
 my_model_view_wrong_name_sql = """
@@ -174,9 +174,9 @@
 }}
 
 select
-  1 as error,
   'blue' as color,
-  cast('2019-01-01' as date) as date_day
+  1 as error,
+  '2019-01-01' as date_day
 """
 
 my_model_view_with_nulls_sql = """
@@ -191,5 +191,5 @@
   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
+  '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
@@ -54,7 +54,6 @@ def data_types(self, schema_int_type, int_type, string_type):
         return [
             ["1", schema_int_type, int_type],
             ["'1'", string_type, string_type],
-            ["cast('2019-01-01' as date)", "date", "DATE"],
             ["true", "bool", "BOOL"],
             ["'2013-11-03 00:00:00-07'::timestamptz", "timestamptz", "DATETIMETZ"],
             ["'2013-11-03 00:00:00-07'::timestamp", "timestamp", "DATETIME"],
@@ -65,8 +64,9 @@ def data_types(self, schema_int_type, int_type, string_type):
         ]
 
     def test__constraints_wrong_column_order(self, project, string_type, int_type):
+        # 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=False
+            ["run", "-s", "my_model_wrong_order"], expect_pass=True
         )
         manifest = get_manifest(project.project_root)
         model_id = "model.test.my_model_wrong_order"
@@ -75,18 +75,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"
-        )
-        expected_sql_file_columns = (
-            f"SQL File Columns: color {string_type}, id {int_type}, date_day DATE"
-        )
-
-        assert expected_compile_error in log_output
-        assert expected_schema_file_columns in log_output
-        assert expected_sql_file_columns in log_output
-
     def test__constraints_wrong_column_names(self, project, string_type, int_type):
         results, log_output = run_dbt_and_capture(
             ["run", "-s", "my_model_wrong_name"], expect_pass=False
@@ -98,12 +86,12 @@ def test__constraints_wrong_column_names(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_compile_error = "Please ensure the name, data_type, 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"
+            f"Schema File Columns: id {int_type}, color {string_type}, date_day {string_type}"
         )
         expected_sql_file_columns = (
-            f"SQL File Columns: error {int_type}, color {string_type}, date_day DATE"
+            f"SQL File Columns: color {string_type}, error {int_type}, date_day {string_type}"
         )
 
         assert expected_compile_error in log_output
@@ -147,7 +135,7 @@ def test__constraints_wrong_column_data_types(
 
             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_compile_error = "Please ensure the name, data_type, and number of columns in your `yml` file match the columns in your SQL file."
             expected_sql_file_columns = (
                 f"SQL File Columns: wrong_data_type_column_name {error_data_type}"
             )
@@ -190,17 +178,25 @@ def test__constraints_correct_column_data_types(self, project, data_types):
 create table {0} (
     id integer not null primary key check (id > 0) ,
     color text ,
-    date_day date
+    date_day text
 ) ;
 insert into {0} (
     id ,
     color ,
     date_day
-) (
+)
+(
     select
-        1 as id,
-        'blue' as color,
-        cast('2019-01-01' as date) as date_day
+       id,
+       color,
+       date_day
+       from
+    (
+        select
+            'blue' as color,
+            1 as id,
+            '2019-01-01' as date_day
+    ) as model_subq
 );
 """
 
@@ -248,10 +244,10 @@ def test__constraints_ddl(self, project, expected_sql):
             expected_sql_check == generated_sql_check
         ), f"""
 -- GENERATED SQL
-{generated_sql}
+{generated_sql_check}
 
 -- EXPECTED SQL
-{expected_sql}
+{expected_sql_check}
 """
 
     def test__constraints_enforcement_rollback(
diff --git a/tests/functional/configs/test_constraint_configs.py b/tests/functional/configs/test_contract_configs.py
similarity index 89%
rename from tests/functional/configs/test_constraint_configs.py
rename to tests/functional/configs/test_contract_configs.py
--- a/tests/functional/configs/test_constraint_configs.py
+++ b/tests/functional/configs/test_contract_configs.py
@@ -1,6 +1,6 @@
 import pytest
 from dbt.exceptions import ParsingError
-from dbt.tests.util import run_dbt, get_manifest, run_dbt_and_capture
+from dbt.tests.util import run_dbt, get_manifest, get_artifact, run_dbt_and_capture
 
 my_model_sql = """
 {{
@@ -10,8 +10,8 @@
 }}
 
 select
-  1 as id,
   'blue' as color,
+  1 as id,
   cast('2019-01-01' as date) as date_day
 """
 
@@ -29,7 +29,7 @@
   cast('2019-01-01' as date) as date_day
 """
 
-my_model_constraints_disabled_sql = """
+my_model_contract_disabled_sql = """
 {{
   config(
     materialized = "table",
@@ -171,7 +171,7 @@ def model(dbt, _):
 """
 
 
-class TestModelLevelConstraintsEnabledConfigs:
+class TestModelLevelContractEnabledConfigs:
     @pytest.fixture(scope="class")
     def models(self):
         return {
@@ -180,11 +180,12 @@ def models(self):
         }
 
     def test__model_contract_true(self, project):
-        run_dbt(["parse"])
+        run_dbt(["run"])
         manifest = get_manifest(project.project_root)
         model_id = "model.test.my_model"
-        my_model_columns = manifest.nodes[model_id].columns
-        my_model_config = manifest.nodes[model_id].config
+        model = manifest.nodes[model_id]
+        my_model_columns = model.columns
+        my_model_config = model.config
         contract_actual_config = my_model_config.contract
 
         assert contract_actual_config is True
@@ -193,8 +194,17 @@ def test__model_contract_true(self, project):
 
         assert expected_columns == str(my_model_columns)
 
+        # compiled fields aren't in the manifest above because it only has parsed fields
+        manifest_json = get_artifact(project.project_root, "target", "manifest.json")
+        compiled_code = manifest_json["nodes"][model_id]["compiled_code"]
+        cleaned_code = " ".join(compiled_code.split())
+        assert (
+            "select 'blue' as color, 1 as id, cast('2019-01-01' as date) as date_day"
+            == cleaned_code
+        )
+
 
-class TestProjectConstraintsEnabledConfigs:
+class TestProjectContractEnabledConfigs:
     @pytest.fixture(scope="class")
     def project_config_update(self):
         return {
@@ -221,7 +231,7 @@ def test_defined_column_type(self, project):
         assert contract_actual_config is True
 
 
-class TestProjectConstraintsEnabledConfigsError:
+class TestProjectContractEnabledConfigsError:
     @pytest.fixture(scope="class")
     def project_config_update(self):
         return {
@@ -253,7 +263,7 @@ def test_undefined_column_type(self, project):
         assert expected_compile_error in log_output
 
 
-class TestModelConstraintsEnabledConfigs:
+class TestModelContractEnabledConfigs:
     @pytest.fixture(scope="class")
     def models(self):
         return {"my_model.sql": my_model_contract_sql, "constraints_schema.yml": model_schema_yml}
@@ -267,7 +277,7 @@ def test__model_contract(self, project):
         assert contract_actual_config is True
 
 
-class TestModelConstraintsEnabledConfigsMissingDataTypes:
+class TestModelContractEnabledConfigsMissingDataTypes:
     @pytest.fixture(scope="class")
     def models(self):
         return {
@@ -289,11 +299,11 @@ def test_undefined_column_type(self, project):
         assert expected_compile_error in log_output
 
 
-class TestModelLevelConstraintsDisabledConfigs:
+class TestModelLevelContractDisabledConfigs:
     @pytest.fixture(scope="class")
     def models(self):
         return {
-            "my_model.sql": my_model_constraints_disabled_sql,
+            "my_model.sql": my_model_contract_disabled_sql,
             "constraints_schema.yml": model_schema_yml,
         }
 
@@ -308,7 +318,7 @@ def test__model_contract_false(self, project):
         assert contract_actual_config is False
 
 
-class TestModelLevelConstraintsErrorMessages:
+class TestModelLevelContractErrorMessages:
     @pytest.fixture(scope="class")
     def models(self):
         return {
@@ -330,7 +340,7 @@ def test__config_errors(self, project):
         assert expected_empty_data_type_error not in str(exc_str)
 
 
-class TestSchemaConstraintsEnabledConfigs:
+class TestSchemaContractEnabledConfigs:
     @pytest.fixture(scope="class")
     def models(self):
         return {
@@ -347,7 +357,7 @@ def test__schema_error(self, project):
         assert schema_error_expected in str(exc_str)
 
 
-class TestPythonModelLevelConstraintsErrorMessages:
+class TestPythonModelLevelContractErrorMessages:
     @pytest.fixture(scope="class")
     def models(self):
         return {