Repository: dbt-labs/dbt-core

Issue:
dbt should use jinja's StrictUndefined setting
## Feature: Enable StrictUndefined in Jinja

### Feature description
See the Jinja docs here: http://jinja.pocoo.org/docs/2.10/api/#undefined-types

In dbt versions <= 0.13, a user can invoke an object that hasn't been defined, and it will compile to a falsy empty string. Instead, dbt should raise an exception during compilation, since invoking an undefined object is often the result of a typo or a namespace issue.

For example, the following model file (with a typo `partiion`) compiles and executes:

```
{% set partiion = 'partition by account_id order by deleted_on asc nulls last rows between unbounded preceding and unbounded following' %}
last_value(id) over ({{partition}}) as id,
last_value(address_id) over ({{partition}}) as address_id,
```
Compiles to:
```
last_value(id) over () as id,
last_value(address_id) over () as address_id,
```

### Who will this benefit?
This will benefit any user that sets Jinja objects in their models.

This would be a breaking change for any users intentionally using undefined objects, but I can't think of why that would be desired. Objects that may not be defined could always be checked with `{% if object_name %}`



Discussion:
More broadly, there have been a couple of times where I've wanted to configure my own Jinja environment, e.g. enabling `trim_blocks` and `lstrip_blocks`. I wonder if maybe providing a way for people to override their jinja environment is a good idea? Even just writing this makes me feel irksome though for some reason. But on the other hand, it might be a way to enable people to build custom filters as per #480 🤷‍♀️ 

I'm sure you've thought about this before Drew so I'll stop thinking any more on it :) 
@clrcrl we're likely not going to enable `trim_blocks` and `lstrip_blocks` anytime soon because it would break other parts of dbt parsing. other aspects of the environment (e.g. custom filters) are really interesting and worth considering for sure!

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
@@ -224,6 +224,72 @@ def __call__(self, *args, **kwargs):
     return ParserMacroCapture
 
 
+def create_warn_undefined_env(node):
+    class WarnUndefined(jinja2.Undefined):
+        __slots__ = ('node',)
+
+        def __init__(self, hint=None, obj=None, name=None, exc=None):
+            super(WarnUndefined, self).__init__(hint=hint, name=name)
+            self.node = node
+
+        def _fail_or_warn_with_undefined_error(self, *args, **kwargs):
+            try:
+                self._fail_with_undefined_error(*args, **kwargs)
+            except Exception as exc:
+                if dbt.flags.WARN_ERROR:
+                    raise
+                else:
+                    msg = 'Compilation warning in {}:\n\t{}'
+                    model_desc = "{} {} ({})".format(
+                        self.node.get('resource_type'),
+                        self.node.get('name', 'unknown'),
+                        self.node.get('original_file_path')
+                    )
+                    logger.warning(msg.format(model_desc, str(exc)))
+
+        def __eq__(self, other):
+            self._fail_or_warn_with_undefined_error(other)
+
+            return type(self) is type(other)
+
+        def __ne__(self, other):
+            self._fail_or_warn_with_undefined_error(other)
+
+            return not self.__eq__(other)
+
+        def __hash__(self):
+            self._fail_or_warn_with_undefined_error()
+
+            return id(type(self))
+
+        def __str__(self):
+            self._fail_or_warn_with_undefined_error()
+
+            return u''
+
+        def __len__(self):
+            self._fail_or_warn_with_undefined_error()
+
+            return 0
+
+        def __iter__(self):
+            self._fail_or_warn_with_undefined_error()
+
+            if 0:
+                yield None
+
+        def __nonzero__(self):
+            self._fail_or_warn_with_undefined_error()
+
+            return False
+        __bool__ = __nonzero__
+
+        def __repr__(self):
+            return 'Undefined'
+
+    return WarnUndefined
+
+
 def get_environment(node=None, capture_macros=False):
     args = {
         'extensions': ['jinja2.ext.do']
@@ -231,6 +297,8 @@ def get_environment(node=None, capture_macros=False):
 
     if capture_macros:
         args['undefined'] = create_macro_capture_env(node)
+    else:
+        args['undefined'] = create_warn_undefined_env(node)
 
     args['extensions'].append(MaterializationExtension)
     args['extensions'].append(DocumentationExtension)


Test patch:
diff --git a/test/integration/005_simple_seed_test/macros/schema_test.sql b/test/integration/005_simple_seed_test/macros/schema_test.sql
--- a/test/integration/005_simple_seed_test/macros/schema_test.sql
+++ b/test/integration/005_simple_seed_test/macros/schema_test.sql
@@ -8,7 +8,7 @@
         {% set _ = col_types.update({col.name: col.data_type}) %}
     {% endfor %}
 
-    {% set val = 0 if col_types[column_name] == type else 1 %}
+    {% set val = 0 if col_types.get(column_name) == type else 1 %}
 
     select {{ val }} as pass_fail
 
diff --git a/test/integration/013_context_var_tests/models/context.sql b/test/integration/013_context_var_tests/models/context.sql
--- a/test/integration/013_context_var_tests/models/context.sql
+++ b/test/integration/013_context_var_tests/models/context.sql
@@ -19,7 +19,7 @@ select
     '{{ target.schema }}'  as "target.schema",
     '{{ target.type }}'    as "target.type",
     '{{ target.user }}'    as "target.user",
-    '{{ target.pass }}'    as "target.pass", -- not actually included, here to test that it is _not_ present!
+    '{{ target.get("pass", "") }}'    as "target.pass", -- not actually included, here to test that it is _not_ present!
     {{ target.port }}      as "target.port",
     {{ target.threads }}   as "target.threads",
 
diff --git a/test/integration/014_hook_tests/configured-models/hooks.sql b/test/integration/014_hook_tests/configured-models/hooks.sql
--- a/test/integration/014_hook_tests/configured-models/hooks.sql
+++ b/test/integration/014_hook_tests/configured-models/hooks.sql
@@ -23,7 +23,7 @@
                 '{{ target.schema }}',\
                 '{{ target.type }}',\
                 '{{ target.user }}',\
-                '{{ target.pass }}',\
+                '{{ target.get(\"pass\", \"\") }}',\
                 {{ target.port }},\
                 {{ target.threads }},\
                 '{{ run_started_at }}',\
@@ -51,7 +51,7 @@
                 '{{ target.schema }}',\
                 '{{ target.type }}',\
                 '{{ target.user }}',\
-                '{{ target.pass }}',\
+                '{{ target.get(\"pass\", \"\") }}',\
                 {{ target.port }},\
                 {{ target.threads }},\
                 '{{ run_started_at }}',\
diff --git a/test/integration/014_hook_tests/error-models/hooks.sql b/test/integration/014_hook_tests/error-models/hooks.sql
--- a/test/integration/014_hook_tests/error-models/hooks.sql
+++ b/test/integration/014_hook_tests/error-models/hooks.sql
@@ -23,7 +23,7 @@
                 '{{ target.schema }}',\
                 '{{ target.type }}',\
                 '{{ target.user }}',\
-                '{{ target.pass }}',\
+                '{{ target.get(\"pass\", \"\") }}',\
                 {{ target.port }},\
                 {{ target.threads }},\
                 '{{ run_started_at }}',\
@@ -51,7 +51,7 @@
                 '{{ target.schema }}',\
                 '{{ target.type }}',\
                 '{{ target.user }}',\
-                '{{ target.pass }}',\
+                '{{ target.get(\"pass\", \"\") }}',\
                 {{ target.port }},\
                 {{ target.threads }},\
                 '{{ run_started_at }}',\
@@ -79,7 +79,7 @@
                 '{{ target.schema }}',\
                 '{{ target.type }}',\
                 '{{ target.user }}',\
-                '{{ target.pass }}',\
+                '{{ target.get(\"pass\", \"\") }}',\
                 {{ target.port }},\
                 {{ target.threads }},\
                 '{{ run_started_at }}',\
diff --git a/test/integration/014_hook_tests/kwargs-models/hooks.sql b/test/integration/014_hook_tests/kwargs-models/hooks.sql
--- a/test/integration/014_hook_tests/kwargs-models/hooks.sql
+++ b/test/integration/014_hook_tests/kwargs-models/hooks.sql
@@ -23,7 +23,7 @@
                 '{{ target.schema }}',\
                 '{{ target.type }}',\
                 '{{ target.user }}',\
-                '{{ target.pass }}',\
+                '{{ target.get(\"pass\", \"\") }}',\
                 {{ target.port }},\
                 {{ target.threads }},\
                 '{{ run_started_at }}',\
@@ -51,7 +51,7 @@
                 '{{ target.schema }}',\
                 '{{ target.type }}',\
                 '{{ target.user }}',\
-                '{{ target.pass }}',\
+                '{{ target.get(\"pass\", \"\") }}',\
                 {{ target.port }},\
                 {{ target.threads }},\
                 '{{ run_started_at }}',\
diff --git a/test/integration/014_hook_tests/macros/before-and-after-bq.sql b/test/integration/014_hook_tests/macros/before-and-after-bq.sql
--- a/test/integration/014_hook_tests/macros/before-and-after-bq.sql
+++ b/test/integration/014_hook_tests/macros/before-and-after-bq.sql
@@ -15,13 +15,13 @@
         invocation_id
    ) VALUES (
     '{{ state }}',
-    '{{ target.dbname }}',
-    '{{ target.host }}',
+    '{{ target.database }}',
+    '', {# bigquery has no host value #}
     '{{ target.name }}',
     '{{ target.schema }}',
     '{{ target.type }}',
-    '{{ target.user }}',
-    '{{ target.pass }}',
+    '', {# bigquery has no user value #}
+    '{{ target.get("pass", "") }}',
     {{ target.threads }},
     '{{ run_started_at }}',
     '{{ invocation_id }}'
diff --git a/test/integration/014_hook_tests/macros/before-and-after.sql b/test/integration/014_hook_tests/macros/before-and-after.sql
--- a/test/integration/014_hook_tests/macros/before-and-after.sql
+++ b/test/integration/014_hook_tests/macros/before-and-after.sql
@@ -22,7 +22,7 @@
     '{{ target.schema }}',
     '{{ target.type }}',
     '{{ target.user }}',
-    '{{ target.pass }}',
+    '{{ target.get("pass", "") }}',
     {{ target.port }},
     {{ target.threads }},
     '{{ run_started_at }}',
diff --git a/test/integration/014_hook_tests/test_model_hooks.py b/test/integration/014_hook_tests/test_model_hooks.py
--- a/test/integration/014_hook_tests/test_model_hooks.py
+++ b/test/integration/014_hook_tests/test_model_hooks.py
@@ -24,7 +24,7 @@
     '{{ target.schema }}',
     '{{ target.type }}',
     '{{ target.user }}',
-    '{{ target.pass }}',
+    '{{ target.get("pass", "") }}',
     {{ target.port }},
     {{ target.threads }},
     '{{ run_started_at }}',
@@ -54,7 +54,7 @@
     '{{ target.schema }}',
     '{{ target.type }}',
     '{{ target.user }}',
-    '{{ target.pass }}',
+    '{{ target.get("pass", "") }}',
     {{ target.port }},
     {{ target.threads }},
     '{{ run_started_at }}',
diff --git a/test/integration/046_strict_undefined_test/models/undefined_model.sql b/test/integration/046_strict_undefined_test/models/undefined_model.sql
new file mode 100644
--- /dev/null
+++ b/test/integration/046_strict_undefined_test/models/undefined_model.sql
@@ -0,0 +1,2 @@
+{% set invalid = None %}
+select '{{ invalid.value or "hello" }}' as value
diff --git a/test/integration/046_strict_undefined_test/test_strict_undefined.py b/test/integration/046_strict_undefined_test/test_strict_undefined.py
new file mode 100644
--- /dev/null
+++ b/test/integration/046_strict_undefined_test/test_strict_undefined.py
@@ -0,0 +1,20 @@
+from test.integration.base import DBTIntegrationTest, use_profile
+
+
+class TestStrictUndefined(DBTIntegrationTest):
+
+    @property
+    def schema(self):
+        return 'strict_undefined_046'
+
+    @property
+    def models(self):
+        return 'test/integration/046_strict_undefined_test/models'
+
+    @use_profile('postgres')
+    def test_postgres_strict_undefined(self):
+        self.run_dbt(['run'], strict=True, expect_pass=False)
+
+    @use_profile('postgres')
+    def test_postgres_nonstrict_undefined(self):
+        self.run_dbt(['run'], strict=False, expect_pass=True)