Repository: dbt-labs/dbt-core
Pull request: Add a special as_text filter to the native env (#2384)
resolves #2384 

### Description
Add a special as_text filter to the native env that tells dbt to render the value as a string no matter what.

Use like `dataset: "{{ 1991 | as_text }}"` -> `dataset: "1991"`
Also, I moved the weird special quoting handling logic out and instead avoid calling the renderer on column_name arguments entirely. Much better than doing weird things with quotes!

I added some very basic unit tests that should exercise this behavior.

I don't _love_ this fix, but I think it's satisfactory and has the benefit of being really, really simple. There's just so little that can go horribly wrong!

### Checklist
 - [X] I have signed the [CLA](https://docs.getdbt.com/docs/contributor-license-agreements)
 - [X] I have run this code in development and it appears to resolve the stated issue
 - [X] This PR includes tests, or tests are not required/relevant for this PR
 - [X] I have updated the `CHANGELOG.md` and added information about my change to the "dbt next" section.


Problem statement:
Regression: dbt does not allow integer dataset names on BigQuery
### Describe the bug
BigQuery allows stringy dataset names that contain only digits, but dbt fails at startup if such a dataset name is provided. Is this a function of dbt's new NativeEnvironment rendering for jinja?

### Steps To Reproduce
Use a profile like:
```
    dev:
      type: bigquery
      method: service-account
      project: '...'
      dataset: "1991"    # This is the important part
      threads: 1
      keyfile: ...
      location: US
      timeout_seconds: 1
      retries: 3
```

A `dbt run` will fail indicating that the provided profile is invalid:
```
Credentials in profile "bq", target "dev" invalid: 1991 is not of type 'string'
```

### Expected behavior
dbt should create models in a dataset named `1991`

### dbt version
```
dev/octavius-catto
```

Hints from issue discussion:
>Is this a function of dbt's new NativeEnvironment rendering for jinja?

Yes, almost certainly. I can think of two ways to approach this:
 - figure out how to tell the renderer that "this value should come out as a string". I'm not sure if that's hard or not, but I bet it isn't simple. This probably involves something neat with native_concat and marker types/custom filters?
 - figure out how to tell the validator "if you expected a string, call `str` on it". That's actually kind of tricky in the general case, because we don't really control validation, so we'd probably need to separately descend in, comparing it to the dataclass being serialized, and mutate the source dict before we get to the validator. Hideous!

Commit message: add missing as_text to sources tests

Patch to review:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6d5fb85c847..5c02b5dce67 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -23,6 +23,7 @@
 - schema.yml files are now fully rendered in a context that is aware of vars declared in from dbt_project.yml files ([#2269](https://github.com/fishtown-analytics/dbt/issues/2269), [#2357](https://github.com/fishtown-analytics/dbt/pull/2357))
 - Sources from dependencies can be overridden in schema.yml files ([#2287](https://github.com/fishtown-analytics/dbt/issues/2287), [#2357](https://github.com/fishtown-analytics/dbt/pull/2357))
 - Implement persist_docs for both `relation` and `comments` on postgres and redshift, and extract them when getting the catalog. ([#2333](https://github.com/fishtown-analytics/dbt/issues/2333), [#2378](https://github.com/fishtown-analytics/dbt/pull/2378))
+- Added a filter named `as_text` to the native environment rendering code that allows users to mark a value as always being a string ([#2384](https://github.com/fishtown-analytics/dbt/issues/2384), [#2395](https://github.com/fishtown-analytics/dbt/pull/2395))
 
 ### Fixes
 - When a jinja value is undefined, give a helpful error instead of failing with cryptic "cannot pickle ParserMacroCapture" errors ([#2110](https://github.com/fishtown-analytics/dbt/issues/2110), [#2184](https://github.com/fishtown-analytics/dbt/pull/2184))
diff --git a/core/dbt/clients/jinja.py b/core/dbt/clients/jinja.py
index 3ea08256434..aa789186f38 100644
--- a/core/dbt/clients/jinja.py
+++ b/core/dbt/clients/jinja.py
@@ -8,7 +8,7 @@
 from contextlib import contextmanager
 from itertools import chain, islice
 from typing import (
-    List, Union, Set, Optional, Dict, Any, Iterator, Type, NoReturn
+    List, Union, Set, Optional, Dict, Any, Iterator, Type, NoReturn, Tuple
 )
 
 import jinja2
@@ -104,6 +104,13 @@ class NativeSandboxEnvironment(MacroFuzzEnvironment):
     code_generator_class = jinja2.nativetypes.NativeCodeGenerator
 
 
+class TextMarker(str):
+    """A special native-env marker that indicates that a value is text and is
+    not to be evaluated. Use this to prevent your numbery-strings from becoming
+    numbers!
+    """
+
+
 def quoted_native_concat(nodes):
     """This is almost native_concat from the NativeTemplate, except in the
     special case of a single argument that is a quoted string and returns a
@@ -116,6 +123,8 @@ def quoted_native_concat(nodes):
 
     if len(head) == 1:
         raw = head[0]
+        if isinstance(raw, TextMarker):
+            return str(raw)
     else:
         raw = "".join([str(v) for v in chain(head, nodes)])
 
@@ -124,10 +133,7 @@ def quoted_native_concat(nodes):
     except (ValueError, SyntaxError, MemoryError):
         return raw
 
-    if len(head) == 1 and len(raw) > 2 and isinstance(result, str):
-        return _requote_result(raw, result)
-    else:
-        return result
+    return result
 
 
 class NativeSandboxTemplate(jinja2.nativetypes.NativeTemplate):  # mypy: ignore
@@ -428,7 +434,11 @@ def get_environment(
     else:
         env_cls = MacroFuzzEnvironment
 
-    return env_cls(**args)
+    env = env_cls(**args)
+    if native:
+        env.filters['as_text'] = TextMarker
+
+    return env
 
 
 @contextmanager
@@ -541,9 +551,15 @@ def add_rendered_test_kwargs(
     """
     looks_like_func = r'^\s*(env_var|ref|var|source|doc)\s*\(.+\)\s*$'
 
-    # we never care about the keypath
-    def _convert_function(value: Any, _: Any) -> Any:
+    def _convert_function(
+        value: Any, keypath: Tuple[Union[str, int], ...]
+    ) -> Any:
         if isinstance(value, str):
+            if keypath == ('column_name',):
+                # special case: Don't render column names as native, make them
+                # be strings
+                return value
+
             if re.match(looks_like_func, value) is not None:
                 # curly braces to make rendering happy
                 value = f'{{{{ {value} }}}}'
diff --git a/test/integration/042_sources_test/models/schema.yml b/test/integration/042_sources_test/models/schema.yml
index ecf400d224f..f02eb13453d 100644
--- a/test/integration/042_sources_test/models/schema.yml
+++ b/test/integration/042_sources_test/models/schema.yml
@@ -22,7 +22,7 @@ sources:
     tables:
       - name: test_table
         identifier: source
-        loaded_at_field: "{{ var('test_loaded_at') }}"
+        loaded_at_field: "{{ var('test_loaded_at') | as_text }}"
         freshness:
           error_after: {count: 18, period: hour}
         tags:
@@ -63,7 +63,7 @@ sources:
               - id_column
       - name: disabled_test_table
         freshness: null
-        loaded_at_field: "{{ var('test_loaded_at') }}"
+        loaded_at_field: "{{ var('test_loaded_at') | as_text }}"
   - name: other_source
     schema: "{{ var('test_run_schema') }}"
     quoting:
diff --git a/test/unit/test_jinja.py b/test/unit/test_jinja.py
index 5d10eaf61ca..a6353809b88 100644
--- a/test/unit/test_jinja.py
+++ b/test/unit/test_jinja.py
@@ -1,5 +1,6 @@
 import unittest
 
+from dbt.clients.jinja import get_rendered
 from dbt.clients.jinja import get_template
 from dbt.clients.jinja import extract_toplevel_blocks
 from dbt.exceptions import CompilationException
@@ -13,6 +14,29 @@ def test_do(self):
         mod = template.make_module()
         self.assertEqual(mod.my_dict, {'a': 1})
 
+    def test_regular_render(self):
+        s = '{{ "some_value" }}'
+        value = get_rendered(s, {}, native=False)
+        assert value == 'some_value'
+        s = '{{ 1991 }}'
+        value = get_rendered(s, {}, native=False)
+        assert value == '1991'
+
+    def test_native_render(self):
+        s = '{{ "some_value" }}'
+        value = get_rendered(s, {}, native=True)
+        assert value == 'some_value'
+        s = '{{ 1991 }}'
+        value = get_rendered(s, {}, native=True)
+        assert value == 1991
+
+        s = '{{ "some_value" | as_text }}'
+        value = get_rendered(s, {}, native=True)
+        assert value == 'some_value'
+        s = '{{ 1991 | as_text }}'
+        value = get_rendered(s, {}, native=True)
+        assert value == '1991'
+
 
 class TestBlockLexer(unittest.TestCase):
     def test_basic(self):
@@ -380,3 +404,5 @@ def test_if_endfor_newlines(self):
 hi
 {% endmaterialization %}
 '''
+
+


Code review comments:
diff_hunk: @@ -428,7 +434,11 @@ def get_environment(
     else:
         env_cls = MacroFuzzEnvironment
 
-    return env_cls(**args)
+    env = env_cls(**args)
+    if native:
+        env.filters['as_text'] = TextMarker
path: core/dbt/clients/jinja.py
text: @user1:
can we always add this?

@author:
Sure! I think in the else case we actually want to set it to `str`.