Repository: dbt-labs/dbt-core

Issue:
[CT-1922] Support 'constraints' as a model-level property
In addition to `constraints` as a column-level property, support constraints as a model-level attribute for row-level boolean expressions that require multiple columns, or combined primary keys (unenforced & informational-only).

Parsing of model-level `constraints` attribute will be addressed by: https://github.com/dbt-labs/dbt-core/issues/7066

This issue builds on top of https://github.com/dbt-labs/dbt-core/issues/7066 to use the model-level `constraints` property in the `get_columns_spec_ddl` macro to inject additional _supported_ constraints. Postgres spec written in the example below. 

Testing: The existing `test__constraints_ddl` test pattern can be user to create a new unit test `test__model_constraints_ddl` to incorporate model-level constraint configuration: https://github.com/dbt-labs/dbt-core/blob/main/tests/adapter/dbt/tests/adapter/constraints/test_constraints.py#L126. A new unit test probably makes most sense so adapters can skip/pass this unit test as necessary if model-level constraint configuration is not supported (e.g. bigquery)




Discussion:
From https://github.com/dbt-labs/dbt-core/issues/7066: 

```python
class ConstraintOption(StrEnum):
    check = "check"
    not_null = "not_null"
    unique = "unique"
    primary_key = "primary_key"
    foreign_key = "foreign_key"
    custom = "custom"  # I have no idea -- but let's leave it open for the wacky & wild data platforms out there

ColumnLevelConstraint:
    type: ConstraintOption
    name: Optional[str]
    warn_unenforced: bool = True  # raise a warning if this constraint is not enforced by this data platform (but will still be included in templated DDL)
    warn_unsupported: bool = True  # raise a warning if this constraint is not supported by this data platform (and will be excluded from templated DDL)
    expression: Optional[str] # free for all. required if constraint is 'check' or 'foreign_key' -- can we validate via post-init ?
    # additional properties allowed: yes! there are going to be lots of constraint-specific and platform-specific shenanigans

# the same, with an added 'columns' attribute (which isn't needed when the constraint is defined for one column)
ModelLevelConstraint(ColumnLevelConstraint):
    columns: List[str]
```

```yml
models:
  constraints: Optional[List[ModelLevelConstraint]]
```
If `constraints` is set a the model level, it should be a model-level attribute, _not_ a "config." This means it isn't settable in `{{ config() }}` or in `dbt_project.yml`, and that's okay.
### Example

(Borrowing heavily from Postgres docs: https://www.postgresql.org/docs/current/ddl-constraints.html)

```yml
models:
  - name: my_model
    columns:
      - name: first_id
        data_type: int
      # ... rest of columns ...
    constraints:
      - type: primary_key
        columns: [first_id, second_id]
      - type: unique
        columns: [product_id, order_no]
        name: must_be_different
      - type: foreign_key
        columns: [column_a, column_b]
        expression: references other_table (another_x, another_y)  # hmmm do we need to be able to support 'ref' here?
      - type: check
        columns: [discounted_price]  # not technically needed, but I sort of like it as metadata?
        expression: discounted_price > 0
      - type: check
        columns: [price, discounted_price]  # as above
        name: valid_discount
        expression: discounted_price > 0
```

This should template out DDL like:
```sql
create table dbt_jcohen.my_model (
    # all of the columns first
    first_id int,
    ...
    
    # then the model-level constraints
    primary key (first_id, second_id),
    constraint must_be_different unique (product_id, order_no),
    foreign key (column_a, column_b) references other_table (another_x, another_y),
    check (discounted_price > 0),
    constraint valid_discount check (price > discounted_price)
);
```

Try this yourself in `psql`:
```
jerco=# create schema if not exists dbt_jerco;
jerco=# create table dbt_jerco.other_table (another_x int, another_y int, unique (another_x, another_y));
CREATE TABLE
jerco=# create table dbt_jerco.my_model (
    first_id int,
    second_id int,
    product_id int,
    order_no int,
    column_a int,
    column_b int,
    price float,
    discounted_price float,
    primary key (first_id, second_id),
    constraint must_be_different unique (product_id, order_no),
    foreign key (column_a, column_b) references dbt_jerco.other_table (another_x, another_y),
    check (discounted_price > 0),
    constraint valid_discount check (price > discounted_price)
);
CREATE TABLE
```

How should users actually choose to define these, as column-level versus model-level constraints? To quote from the Postgres docs:

> It's a matter of taste.

### Notes
- `name` is always optional. On some data platforms, you can provide a custom name or not. On other data platforms (e.g. Databricks), we'll need to auto-generate hashed names if the user doesn't provide one — this is already how the current implementation works.
- You don't see `not_null` above because `not_null` is _only_ supported at the column level, never the model level
- `check`-type constraint doesn't actually require the `columns` field, if defined at the model level — but I'm sort of inclined to keep it there, and encourage people to use it anyway
- We've defined `constraints` as a top-level property, not a `config`. Meaning that it can't be set via either in-file `{{config()}}` or in `dbt_project.yml`. Let's keep having the larger conversation around configs vs. properties; out of scope for this specific issue.
- Ugh!! If we're serious about supporting `foreign_key` constraints, I think we need a way to let users define `ref` in the `expression` field, like we do for the `to` field in [`relationships`](https://docs.getdbt.com/reference/resource-properties/tests#relationships) tests. Otherwise it can _only_ be defined on a model in the same/default database/schema. (Maybe that's a reasonable limitation...?) 
> Ugh!! If we're serious about supporting foreign_key constraints, I think we need a way to let users define `ref` in the `expression` field, like we do for the `to` field in [`relationships`](https://docs.getdbt.com/reference/resource-properties/tests#relationships) tests. Otherwise it can only be defined on a model in the same/default database/schema. (Maybe that's a reasonable limitation...?)

Officially kicking this out of scope for now. If it's really important to support `foreign_key` constraints in a first-class way, we can revisit in the future. To be honest, the appetite for us doing that might only come about if/when analytical data platforms support enforcing FKs for real.

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
@@ -18,7 +18,7 @@
     Type,
 )
 
-from dbt.contracts.graph.nodes import ColumnLevelConstraint, ConstraintType
+from dbt.contracts.graph.nodes import ColumnLevelConstraint, ConstraintType, ModelLevelConstraint
 
 import agate
 import pytz
@@ -1298,6 +1298,44 @@ def render_column_constraint(cls, constraint: ColumnLevelConstraint) -> str:
         else:
             return ""
 
+    @classmethod
+    def _parse_model_constraint(cls, raw_constraint: Dict[str, Any]) -> ModelLevelConstraint:
+        try:
+            ModelLevelConstraint.validate(raw_constraint)
+            c = ModelLevelConstraint.from_dict(raw_constraint)
+            return c
+        except Exception:
+            raise DbtValidationError(f"Could not parse constraint: {raw_constraint}")
+
+    @available
+    @classmethod
+    def render_raw_model_constraints(cls, raw_constraints: List[Dict[str, Any]]) -> List[str]:
+        return [c for c in map(cls.render_raw_model_constraint, raw_constraints) if c is not None]
+
+    @classmethod
+    def render_raw_model_constraint(cls, raw_constraint: Dict[str, Any]) -> Optional[str]:
+        constraint = cls._parse_model_constraint(raw_constraint)
+        return cls.render_model_constraint(constraint)
+
+    @classmethod
+    def render_model_constraint(cls, constraint: ModelLevelConstraint) -> Optional[str]:
+        """Render the given constraint as DDL text. Should be overriden by adapters which need custom constraint
+        rendering."""
+        constraint_prefix = f"constraint {constraint.name} " if constraint.name else ""
+        column_list = ", ".join(constraint.columns)
+        if constraint.type == ConstraintType.check and constraint.expression:
+            return f"{constraint_prefix}check {constraint.expression}"
+        elif constraint.type == ConstraintType.unique:
+            return f"{constraint_prefix}unique ({column_list})"
+        elif constraint.type == ConstraintType.primary_key:
+            return f"{constraint_prefix}primary key ({column_list})"
+        elif constraint.type == ConstraintType.foreign_key:
+            return f"{constraint_prefix}foreign key ({column_list})"
+        elif constraint.type == ConstraintType.custom and constraint.expression:
+            return f"{constraint_prefix}{constraint.expression}"
+        else:
+            return None
+
 
 COLUMNS_EQUAL_SQL = """
 with diff_count as (
diff --git a/core/dbt/contracts/graph/nodes.py b/core/dbt/contracts/graph/nodes.py
--- a/core/dbt/contracts/graph/nodes.py
+++ b/core/dbt/contracts/graph/nodes.py
@@ -170,6 +170,11 @@ class ColumnLevelConstraint(dbtClassMixin):
     )
 
 
+@dataclass
+class ModelLevelConstraint(ColumnLevelConstraint):
+    columns: List[str] = field(default_factory=list)
+
+
 @dataclass
 class ColumnInfo(AdditionalPropertiesMixin, ExtensibleDbtClassMixin, Replaceable):
     """Used in all ManifestNodes and SourceDefinition"""
@@ -403,6 +408,9 @@ def same_contract(self, old) -> bool:
 
     def patch(self, patch: "ParsedNodePatch"):
         """Given a ParsedNodePatch, add the new information to the node."""
+        # NOTE: Constraint patching is awkwardly done in the parse_patch function
+        # which calls this one. We need to combine the logic.
+
         # explicitly pick out the parts to update so we don't inadvertently
         # step on the model name or anything
         # Note: config should already be updated
@@ -576,6 +584,7 @@ class HookNode(CompiledNode):
 class ModelNode(CompiledNode):
     resource_type: NodeType = field(metadata={"restrict": [NodeType.Model]})
     access: AccessType = AccessType.Protected
+    constraints: List[ModelLevelConstraint] = field(default_factory=list)
 
 
 # TODO: rm?
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
@@ -39,6 +39,8 @@
     ManifestNode,
     GraphMemberNode,
     ConstraintType,
+    ModelNode,
+    ModelLevelConstraint,
 )
 from dbt.contracts.graph.unparsed import (
     HasColumnDocs,
@@ -128,7 +130,7 @@ def _add(self, column: HasColumnProps):
         if any(
             c
             for c in column.constraints
-            if not c["type"] or not ConstraintType.is_valid(c["type"])
+            if "type" not in c or not ConstraintType.is_valid(c["type"])
         ):
             raise ParsingError(f"Invalid constraint type on column {column.name}")
 
@@ -906,7 +908,7 @@ def parse_patch(self, block: TargetBlock[NodeTarget], refs: ParserRef) -> None:
                     raise ParsingError(msg)
 
                 # all nodes in the disabled dict have the same unique_id so just grab the first one
-                # to append with the uniqe id
+                # to append with the unique id
                 source_file.append_patch(patch.yaml_key, found_nodes[0].unique_id)
                 for node in found_nodes:
                     node.patch_path = source_file.file_id
@@ -940,61 +942,50 @@ def parse_patch(self, block: TargetBlock[NodeTarget], refs: ParserRef) -> None:
                 self.patch_node_config(node, patch)
 
             node.patch(patch)
-            self.validate_constraints(node)
+
+            # TODO: We want to do all the actual patching either in the above node.patch() call
+            # or here, but it will require some thought to the details. For now the patching is
+            # awkwardly split.
+            contract_config = node.config.get("contract")
+            if isinstance(node, ModelNode) and contract_config.enforced is True:
+                self.validate_constraint_prerequisites(node)
+
+                if any(
+                    c
+                    for c in block.target.constraints
+                    if "type" not in c or not ConstraintType.is_valid(c["type"])
+                ):
+                    raise ParsingError(
+                        f"Invalid constraint type on model {block.target.name}: "
+                        f"Type must be one of {[ct.value for ct in ConstraintType]}"
+                    )
+
+                node.constraints = [
+                    ModelLevelConstraint.from_dict(c) for c in block.target.constraints
+                ]
+
             node.build_contract_checksum()
 
-    def validate_constraints(self, patched_node):
-        error_messages = []
-        contract_config = patched_node.config.get("contract")
-        if patched_node.resource_type == "model" and contract_config.enforced is True:
-            validators = [
-                self.constraints_schema_validator(patched_node),
-                self.constraints_materialization_validator(patched_node),
-                self.constraints_language_validator(patched_node),
-            ]
-            error_messages = [validator for validator in validators if validator != "None"]
-
-        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`.\n`data_type` values must be defined for all columns and NOT be null or blank.{self.convert_errors_to_string(error_messages)}"
+    def validate_constraint_prerequisites(self, model_node: ModelNode):
+        errors = []
+        if not model_node.columns:
+            errors.append(
+                "Constraints must be defined in a `yml` schema configuration file like `schema.yml`."
             )
 
-    def convert_errors_to_string(self, error_messages: List[str]):
-        n = len(error_messages)
-        if not n:
-            return ""
-        if n == 1:
-            return error_messages[0]
-        error_messages_string = "".join(error_messages[:-1]) + f"{error_messages[-1]}"
-        return error_messages_string
-
-    def constraints_schema_validator(self, patched_node):
-        schema_error = False
-        if patched_node.columns == {}:
-            schema_error = True
-        schema_error_msg = "\n    Schema Error: `yml` configuration does NOT exist"
-        schema_error_msg_payload = f"{schema_error_msg if schema_error else None}"
-        return schema_error_msg_payload
-
-    def constraints_materialization_validator(self, patched_node):
-        materialization_error = {}
-        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 = (
-            f"{materialization_error_msg if materialization_error else None}"
-        )
-        return materialization_error_msg_payload
-
-    def constraints_language_validator(self, patched_node):
-        language_error = {}
-        language = str(patched_node.language)
-        if language != "sql":
-            language_error = {"language": language}
-        language_error_msg = f"\n    Language Error: {language_error}"
-        language_error_msg_payload = f"{language_error_msg if language_error else None}"
-        return language_error_msg_payload
+        if model_node.config.materialized not in ["table", "view", "incremental"]:
+            errors.append(
+                f"Only table, view, and incremental materializations are supported for constraints, but found '{model_node.config.materialized}'"
+            )
+
+        if str(model_node.language) != "sql":
+            errors.append(f"Language Error: Expected 'sql' but found '{model_node.language}'")
+
+        if errors:
+            raise ParsingError(
+                f"Constraint validation failed for: ({model_node.original_file_path})\n"
+                + "\n".join(errors)
+            )
 
 
 class TestablePatchParser(NodePatchParser[UnparsedNodeUpdate]):


Test patch:
diff --git a/test/unit/test_contracts_graph_compiled.py b/test/unit/test_contracts_graph_compiled.py
--- a/test/unit/test_contracts_graph_compiled.py
+++ b/test/unit/test_contracts_graph_compiled.py
@@ -220,6 +220,7 @@ def basic_compiled_dict():
         "unrendered_config": {},
         "config_call_dict": {},
         "access": "protected",
+        "constraints": [],
     }
 
 
diff --git a/test/unit/test_contracts_graph_parsed.py b/test/unit/test_contracts_graph_parsed.py
--- a/test/unit/test_contracts_graph_parsed.py
+++ b/test/unit/test_contracts_graph_parsed.py
@@ -184,6 +184,7 @@ def base_parsed_model_dict():
         "unrendered_config": {},
         "config_call_dict": {},
         "access": AccessType.Protected.value,
+        "constraints": [],
     }
 
 
@@ -301,6 +302,7 @@ def complex_parsed_model_dict():
         },
         "config_call_dict": {},
         "access": AccessType.Protected.value,
+        "constraints": [],
     }
 
 
diff --git a/test/unit/test_manifest.py b/test/unit/test_manifest.py
--- a/test/unit/test_manifest.py
+++ b/test/unit/test_manifest.py
@@ -88,6 +88,7 @@
         "relation_name",
         "contract",
         "access",
+        "constraints",
     }
 )
 
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
@@ -261,6 +261,37 @@
         data_type: text
 """
 
+constrained_model_schema_yml = """
+version: 2
+models:
+  - name: my_model
+    config:
+      contract:
+        enforced: true
+    constraints:
+      - type: check
+        expression: (id > 0)
+      - type: primary_key
+        columns: [ id ]
+      - type: unique
+        columns: [ color, date_day ]
+        name: strange_uniqueness_requirement
+    columns:
+      - name: id
+        quote: true
+        data_type: integer
+        description: hello
+        constraints:
+          - type: not_null
+        tests:
+          - unique
+      - name: color
+        data_type: text
+      - name: date_day
+        data_type: text
+"""
+
+
 model_data_type_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
@@ -24,6 +24,7 @@
     my_model_with_nulls_sql,
     my_model_incremental_with_nulls_sql,
     model_schema_yml,
+    constrained_model_schema_yml,
 )
 
 
@@ -152,6 +153,10 @@ def test__constraints_correct_column_data_types(self, project, data_types):
             assert contract_actual_config.enforced is True
 
 
+def _normalize_whitespace(input: str) -> str:
+    return re.sub(r"\s+", " ", input).lower().strip()
+
+
 class BaseConstraintsRuntimeDdlEnforcement:
     """
     These constraints pass muster for dbt's preflight checks. Make sure they're
@@ -202,23 +207,15 @@ 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 = re.sub(r"\s+", " ", generated_sql).lower().strip()
+        generated_sql_modified = _normalize_whitespace(generated_sql)
         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()
+        expected_sql_check = _normalize_whitespace(expected_sql)
 
-        assert (
-            expected_sql_check == generated_sql_generic
-        ), f"""
--- GENERATED SQL
-{generated_sql_generic}
-
--- EXPECTED SQL
-{expected_sql_check}
-"""
+        assert expected_sql_check == generated_sql_generic
 
 
 class BaseConstraintsRollback:
@@ -355,3 +352,64 @@ class TestIncrementalConstraintsRuntimeDdlEnforcement(
 
 class TestIncrementalConstraintsRollback(BaseIncrementalConstraintsRollback):
     pass
+
+
+class BaseModelConstraintsRuntimeEnforcement:
+    """
+    These model-level 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_sql,
+            "constraints_schema.yml": constrained_model_schema_yml,
+        }
+
+    @pytest.fixture(scope="class")
+    def expected_sql(self):
+        return """
+create table <model_identifier> (
+    id integer not null,
+    color text,
+    date_day text,
+    check (id > 0),
+    primary key (id),
+    constraint strange_uniqueness_requirement unique (color, date_day)
+) ;
+insert into <model_identifier> (
+    id ,
+    color ,
+    date_day
+)
+(
+    select
+       id,
+       color,
+       date_day
+       from
+    (
+        select
+            1 as id,
+            'blue' as color,
+            '2019-01-01' as date_day
+    ) as model_subq
+);
+"""
+
+    def test__model_constraints_ddl(self, project, expected_sql):
+        results = run_dbt(["run", "-s", "my_model"])
+        assert len(results) == 1
+        generated_sql = read_file("target", "run", "test", "models", "my_model.sql")
+        generated_sql_list = _normalize_whitespace(generated_sql).split(" ")
+        generated_sql_list = [
+            "<model_identifier>" if "my_model" in s else s for s in generated_sql_list
+        ]
+        generated_sql_generic = " ".join(generated_sql_list)
+        assert _normalize_whitespace(expected_sql) == generated_sql_generic
+
+
+class TestModelConstraintsRuntimeEnforcement(BaseModelConstraintsRuntimeEnforcement):
+    pass
diff --git a/tests/functional/artifacts/expected_manifest.py b/tests/functional/artifacts/expected_manifest.py
--- a/tests/functional/artifacts/expected_manifest.py
+++ b/tests/functional/artifacts/expected_manifest.py
@@ -322,6 +322,7 @@ def expected_seeded_manifest(project, model_database=None, quote_model=False):
                         "constraints": [],
                     },
                 },
+                "constraints": [],
                 "contract": {"checksum": None, "enforced": False},
                 "patch_path": "test://" + model_schema_yml_path,
                 "docs": {"node_color": None, "show": False},
@@ -411,6 +412,7 @@ def expected_seeded_manifest(project, model_database=None, quote_model=False):
                         "constraints": [],
                     },
                 },
+                "constraints": [],
                 "contract": {"checksum": None, "enforced": False},
                 "patch_path": "test://" + model_schema_yml_path,
                 "docs": {"node_color": None, "show": False},
@@ -934,6 +936,7 @@ def expected_references_manifest(project):
                 "checksum": checksum_file(ephemeral_copy_path),
                 "unrendered_config": get_unrendered_model_config(materialized="ephemeral"),
                 "access": "protected",
+                "constraints": [],
             },
             "model.test.ephemeral_summary": {
                 "alias": "ephemeral_summary",
@@ -996,6 +999,7 @@ def expected_references_manifest(project):
                     materialized="table", group="test_group"
                 ),
                 "access": "protected",
+                "constraints": [],
             },
             "model.test.view_summary": {
                 "alias": "view_summary",
@@ -1054,6 +1058,7 @@ def expected_references_manifest(project):
                 "checksum": checksum_file(view_summary_path),
                 "unrendered_config": get_unrendered_model_config(materialized="view"),
                 "access": "protected",
+                "constraints": [],
             },
             "seed.test.seed": {
                 "alias": "seed",
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
@@ -376,7 +376,7 @@ 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': 'ephemeral'}"
+        expected_materialization_error = "Only table, view, and incremental materializations are supported for constraints, but found 'ephemeral'"
         assert expected_materialization_error in str(exc_str)
         # 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']"
@@ -396,7 +396,7 @@ def test__schema_error(self, project):
             run_dbt(["parse"], expect_pass=False)
 
         exc_str = " ".join(str(err_info.value).split())
-        schema_error_expected = "Schema Error: `yml` configuration does NOT exist"
+        schema_error_expected = "Constraints must be defined in a `yml` schema configuration file"
         assert schema_error_expected in str(exc_str)
 
 
@@ -413,5 +413,5 @@ def test__python_errors(self, project):
             run_dbt(["parse"], expect_pass=False)
 
         exc_str = " ".join(str(err_info.value).split())
-        expected_python_error = "Language Error: {'language': 'python'}"
+        expected_python_error = "Language Error: Expected 'sql' but found 'python'"
         assert expected_python_error in exc_str