Repository: dbt-labs/dbt-core

Issue:
[CT-2420] [Bug] Attribute `quote` on a column not used on constraint generation
### Is this a new bug in dbt-core?

- [X] I believe this is a new bug in dbt-core
- [X] I have searched the existing issues, and I could not find an existing issue for this bug

### Current Behavior

It is possible to define `quote: true` on a column, so e.g. `from` will work. But when adding `data_type` constraint it won't be correctly quoted creating invalid SQL (at least for Bigquery):

```
    select * from (
        select
    cast(null as int) as from, cast(null as int) as id, cast(null as int) as currency, cast(null as int) as currency_fullname, cast(null as datetime) as timestamp
    ) as __dbt_sbq
    where false
    limit 0

```

### Expected Behavior

```
    select * from (
        select
    cast(null as int) as `from`, cast(null as int) as id, cast(null as int) as currency, cast(null as int) as currency_fullname, cast(null as datetime) as timestamp
    ) as __dbt_sbq
    where false
    limit 0
```

### Steps To Reproduce

```
models:
  - name: txs_large
    config:
      materialized: table
      contract:
        enforced: true
    constraints:
      - type: primary_key
        columns: [id]
    columns:
      - name: from
        quote: true
        data_type: int
        tests:
          - not_null
```

### Relevant log output

```shell
❯ dbt build
14:34:04  Running with dbt=1.5.0-rc1
14:34:04  [WARNING]: Configuration paths exist in your dbt_project.yml file which do not apply to any resources.
There are 1 unused configuration paths:
- models.mini-dbt.example
14:34:04  Found 6 models, 7 tests, 1 snapshot, 1 analysis, 353 macros, 0 operations, 1 seed file, 1 source, 1 exposure, 2 metrics, 0 groups
14:34:04
14:34:06  Concurrency: 8 threads (target='dev')
14:34:06
14:34:06  1 of 15 START seed file mini_dbt.currencies .................................... [RUN]
14:34:10  1 of 15 OK loaded seed file mini_dbt.currencies ................................ [INSERT 8 in 4.30s]
14:34:10  2 of 15 START test not_null_currencies_currency ................................ [RUN]
14:34:10  3 of 15 START test unique_currencies_currency .................................. [RUN]
14:34:11  3 of 15 PASS unique_currencies_currency ........................................ [PASS in 0.98s]
14:34:12  2 of 15 PASS not_null_currencies_currency ...................................... [PASS in 1.77s]
14:34:12  4 of 15 START sql table model mini_dbt.txs_large ............................... [RUN]
14:34:13  4 of 15 ERROR creating sql table model mini_dbt.txs_large ...................... [ERROR in 0.92s]
14:34:13  5 of 15 SKIP test not_null_txs_large__from_ .................................... [SKIP]
14:34:13  6 of 15 SKIP test not_null_txs_large_currency .................................. [SKIP]
14:34:13  7 of 15 SKIP test not_null_txs_large_currency_fullname ......................... [SKIP]
14:34:13  8 of 15 SKIP test not_null_txs_large_id ........................................ [SKIP]
14:34:13  9 of 15 SKIP test not_null_txs_large_timestamp ................................. [SKIP]
14:34:13  10 of 15 SKIP relation mini_dbt.txs_large_czk .................................. [SKIP]
14:34:13  11 of 15 SKIP relation mini_dbt.txs_large_dkk .................................. [SKIP]
14:34:13  12 of 15 SKIP relation mini_dbt.txs_large_gbp .................................. [SKIP]
14:34:13  13 of 15 SKIP relation mini_dbt.txs_large_huf .................................. [SKIP]
14:34:13  14 of 15 SKIP relation mini_dbt.txs_large_pln .................................. [SKIP]
14:34:13  15 of 15 SKIP relation snapshots.txs_large_snapshot ............................ [SKIP]
14:34:13
14:34:13  Finished running 1 seed, 7 tests, 1 table model, 5 view models, 1 snapshot in 0 hours 0 minutes and 8.24 seconds (8.24s).
14:34:13
14:34:13  Completed with 1 error and 0 warnings:
14:34:13
14:34:13  Database Error in model txs_large (models/base/txs_large.sql)
14:34:13    Syntax error: Unexpected keyword FROM at [4:26]
14:34:13
14:34:13  Done. PASS=3 WARN=0 ERROR=1 SKIP=11 TOTAL=15
```


### Environment

```markdown
- OS: Macos
- Python: 3.11.3
- dbt: 1.5.0-rc1
```


### Which database adapter are you using with dbt?

bigquery

### Additional Context

_No response_


Discussion:
@lustefaniak Good catch! Thanks for opening the bug report.

I think this is the line in the `get_empty_schema_sql` macro that needs updating, for the "pre-flight" check (column names + data types), which is where you're seeing the error currently:
https://github.com/dbt-labs/dbt-core/blob/814eb65d59ba71a0a200618021f35429dfba9da6/core/dbt/include/global_project/macros/adapters/columns.sql#L49

Although we'd also want to take a look at the code that renders out the eventual DDL, including column name + data type + constraints (if any), which is the (Python) adapter method `render_raw_columns_constraints`:
https://github.com/dbt-labs/dbt-core/blob/828d723512fced809c63e369a82c7eb570a74986/core/dbt/adapters/base/impl.py#L1331

IMO we do NOT need or want to support a contracted model with columns that are identical, save for a difference of quoting/casing (e.g. `"from" and "FROM"`). So our equality checks can remain case-insensitive and unquoted.

Exit criteria:
- It's possible to define a contracted model with a column name that's a reserved word (e.g. `from`) that requires `quote: true`
Create a test just for postgres.  Manually ensure the other adapters continue to work but no tests needed.

Resolving patch:
diff --git a/core/dbt/adapters/base/impl.py b/core/dbt/adapters/base/impl.py
--- a/core/dbt/adapters/base/impl.py
+++ b/core/dbt/adapters/base/impl.py
@@ -1346,7 +1346,8 @@ def render_raw_columns_constraints(cls, raw_columns: Dict[str, Dict[str, Any]])
         rendered_column_constraints = []
 
         for v in raw_columns.values():
-            rendered_column_constraint = [f"{v['name']} {v['data_type']}"]
+            col_name = cls.quote(v["name"]) if v.get("quote") else v["name"]
+            rendered_column_constraint = [f"{col_name} {v['data_type']}"]
             for con in v.get("constraints", None):
                 constraint = cls._parse_column_constraint(con)
                 c = cls.process_parsed_constraint(constraint, cls.render_column_constraint)
diff --git a/core/dbt/adapters/sql/impl.py b/core/dbt/adapters/sql/impl.py
--- a/core/dbt/adapters/sql/impl.py
+++ b/core/dbt/adapters/sql/impl.py
@@ -197,6 +197,7 @@ def list_relations_without_caching(
             )
         return relations
 
+    @classmethod
     def quote(self, identifier):
         return '"{}"'.format(identifier)
 


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
@@ -251,6 +251,16 @@
   '2019-01-01' as date_day
 """
 
+
+# 'from' is a reserved word, so it must be quoted
+my_model_with_quoted_column_name_sql = """
+select
+  'blue' as {{ adapter.quote('from') }},
+  1 as id,
+  '2019-01-01' as date_day
+"""
+
+
 model_schema_yml = """
 version: 2
 models:
@@ -260,7 +270,6 @@
         enforced: true
     columns:
       - name: id
-        quote: true
         data_type: integer
         description: hello
         constraints:
@@ -344,7 +353,6 @@
         enforced: true
     columns:
       - name: id
-        quote: true
         data_type: integer
         description: hello
         constraints:
@@ -454,7 +462,6 @@
         expression: {schema}.foreign_key_model (id)
     columns:
       - name: id
-        quote: true
         data_type: integer
         description: hello
         constraints:
@@ -490,6 +497,37 @@
         data_type: {data_type}
 """
 
+
+model_quoted_column_schema_yml = """
+version: 2
+models:
+  - name: my_model
+    config:
+      contract:
+        enforced: true
+      materialized: table
+    constraints:
+      - type: check
+        # this one is the on the user
+        expression: ("from" = 'blue')
+        columns: [ '"from"' ]
+    columns:
+      - name: id
+        data_type: integer
+        description: hello
+        constraints:
+          - type: not_null
+        tests:
+          - unique
+      - name: from  # reserved word
+        quote: true
+        data_type: text
+        constraints:
+          - type: not_null
+      - name: date_day
+        data_type: text
+"""
+
 model_contract_header_schema_yml = """
 version: 2
 models:
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
@@ -23,9 +23,11 @@
     my_model_incremental_wrong_name_sql,
     my_model_with_nulls_sql,
     my_model_incremental_with_nulls_sql,
+    my_model_with_quoted_column_name_sql,
     model_schema_yml,
     model_fk_constraint_schema_yml,
     constrained_model_schema_yml,
+    model_quoted_column_schema_yml,
     foreign_key_model_sql,
     my_model_wrong_order_depends_on_fk_sql,
     my_model_incremental_wrong_order_depends_on_fk_sql,
@@ -165,11 +167,12 @@ def test__constraints_correct_column_data_types(self, project, data_types):
 
 
 def _normalize_whitespace(input: str) -> str:
-    return re.sub(r"\s+", " ", input).lower().strip()
+    subbed = re.sub(r"\s+", " ", input)
+    return re.sub(r"\s?([\(\),])\s?", r"\1", subbed).lower().strip()
 
 
 def _find_and_replace(sql, find, replace):
-    sql_tokens = sql.split(" ")
+    sql_tokens = sql.split()
     for idx in [n for n, x in enumerate(sql_tokens) if find in x]:
         sql_tokens[idx] = replace
     return " ".join(sql_tokens)
@@ -235,17 +238,12 @@ def test__constraints_ddl(self, project, expected_sql):
         # 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 = _normalize_whitespace(generated_sql)
-        generated_sql_generic = _find_and_replace(
-            generated_sql_modified, "my_model", "<model_identifier>"
-        )
+        generated_sql_generic = _find_and_replace(generated_sql, "my_model", "<model_identifier>")
         generated_sql_generic = _find_and_replace(
             generated_sql_generic, "foreign_key_model", "<foreign_key_model_identifier>"
         )
 
-        expected_sql_check = _normalize_whitespace(expected_sql)
-
-        assert expected_sql_check == generated_sql_generic
+        assert _normalize_whitespace(expected_sql) == _normalize_whitespace(generated_sql_generic)
 
 
 class BaseConstraintsRollback:
@@ -485,15 +483,50 @@ def test__model_constraints_ddl(self, project, expected_sql):
         # assert at least my_model was run - additional upstreams may or may not be provided to the test setup via models fixture
         assert len(results) >= 1
         generated_sql = read_file("target", "run", "test", "models", "my_model.sql")
-        generated_sql_modified = _normalize_whitespace(generated_sql)
-        generated_sql_generic = _find_and_replace(
-            generated_sql_modified, "my_model", "<model_identifier>"
-        )
+
+        generated_sql_generic = _find_and_replace(generated_sql, "my_model", "<model_identifier>")
         generated_sql_generic = _find_and_replace(
             generated_sql_generic, "foreign_key_model", "<foreign_key_model_identifier>"
         )
-        assert _normalize_whitespace(expected_sql) == generated_sql_generic
+
+        assert _normalize_whitespace(expected_sql) == _normalize_whitespace(generated_sql_generic)
 
 
 class TestModelConstraintsRuntimeEnforcement(BaseModelConstraintsRuntimeEnforcement):
     pass
+
+
+class BaseConstraintQuotedColumn(BaseConstraintsRuntimeDdlEnforcement):
+    @pytest.fixture(scope="class")
+    def models(self):
+        return {
+            "my_model.sql": my_model_with_quoted_column_name_sql,
+            "constraints_schema.yml": model_quoted_column_schema_yml,
+        }
+
+    @pytest.fixture(scope="class")
+    def expected_sql(self):
+        return """
+create table <model_identifier> (
+    id integer not null,
+    "from" text not null,
+    date_day text,
+    check (("from" = 'blue'))
+) ;
+insert into <model_identifier> (
+    id, "from", date_day
+)
+(
+    select id, "from", date_day
+    from (
+        select
+          'blue' as "from",
+          1 as id,
+          '2019-01-01' as date_day
+    ) as model_subq
+);
+"""
+
+
+class TestConstraintQuotedColumn(BaseConstraintQuotedColumn):
+    pass