Repository: dbt-labs/dbt-core

Issue:
RPC server sometimes returns the wrong type
### Describe the bug

While using the RPC server, some values are returned as the wrong type. It looks like strings are being cast to floats somehow.

### Steps To Reproduce

1. Start up the rpc server (`dbt rpc`)
2. Make a POST request with the following body:
```json
{
    "jsonrpc": "2.0",
    "method": "run_sql",
    "id": "test3",
    "params": {
        "timeout": 60,
        "name": "test query",
        "sql": "d2l0aCBiYXNlIGFzICgKCiAgc2VsZWN0CgogICAgJ3Rlc3QnOjp2YXJjaGFyIGFzIHRlc3RfdmFyY2hhciwKCiAgICAyNTo6dmFyY2hhciBhcyB0ZXN0X2ludF92YXJjaGFyLAoKICAgICctMjUnOjp2YXJjaGFyIGFzIHRoaXNfd29ya3MKCikKCnNlbGVjdCAKCiAgKiwKCiAgTFBBRCh0ZXN0X3ZhcmNoYXIsIDEwLCAnMCcpIGFzIHBhZF92YXJjaGFyLAoKICBMUEFEKHRlc3RfaW50X3ZhcmNoYXIsIDEwLCAnMCcpIGFzIHBhZF9pbnRfdmFyY2hhciwKCiAgTFBBRCh0aGlzX3dvcmtzLCAxMCwgJzAnKSBhcyBwYWRfdGhpc193b3JrcwoKZnJvbSBiYXNlCg=="
    }
}
```

The SQL here is the base64 encoded version of:
```sql
with base as (

  select

    'test'::varchar as test_varchar,

    25::varchar as test_int_varchar,

    '-25'::varchar as this_works

)

select 

  *,

  LPAD(test_varchar, 10, '0') as pad_varchar,

  LPAD(test_int_varchar, 10, '0') as pad_int_varchar,

  LPAD(this_works, 10, '0') as pad_this_works

from base
```

3. Copy the `request_token` from the response
4. Make another request with the following body:
```json
{
    "jsonrpc": "2.0",
    "method": "poll",
    "id": "test4",
    "params": {
        "request_token": <the-copied-request-token>,
        "logs": true,
        "logs_start": 0
    }
}
```

5. Inspect the rows that are returned. You should see something like this:
```
"rows": [
    [
        "test",
        25.0,
        -25.0,
        "000000test",
        25.0,
        "0000000-25"
    ]
]
```

6. If you run the same query in psql, you will get the following output:
```
 test_varchar | test_int_varchar | this_works | pad_varchar | pad_int_varchar | pad_this_works
--------------+------------------+------------+-------------+-----------------+----------------
 test         | 25               | -25        | 000000test  | 0000000025      | 0000000-25
```

7. Notice how `pad_int_varchar` is padded since it is represented as a varchar, and not an integer

### Expected behavior

I would expect that all of the rows returned would be strings (since they were casted), and not floats. As seen in step 5, the second to last row is not padded since it is being represented as a float.

### Screenshots and log output

I don't have any other relevant logs or output, but I'm happy to add more of the RPC response to this issue if that is helpful!

### System information
**Which database are you using dbt with?**
- [x] postgres
- [ ] redshift
- [ ] bigquery
- [ ] snowflake
- [ ] other (specify: ____________)


**The output of `dbt --version`:**
```
installed version: 0.18.1
   latest version: 0.18.1

Up to date!

Plugins:
  - postgres: 0.18.1
  - spark: 0.18.1.1
```

**The operating system you're using:**

macOS Catalina
Version 10.15.7

**The output of `python --version`:**

Python 3.7.7

### Additional context

N/A



Discussion:
Thanks for this reproduction case @aaronraff! I have an even simpler one:
```sql
select '0000000025'::varchar as my_varchar_column
```
base64 encoded: `c2VsZWN0ICcwMDAwMDAwMDI1Jzo6dmFyY2hhciBhcyBteV92YXJjaGFyX2NvbHVtbg==`
```json
"table": {
    "column_names": [
        "my_varchar_column"
    ],
    "rows": [
        [
            25.0
        ]
    ]
}
```

I think the issue here is with how the RPC server handles data types implicitly, rather than storing them explicitly alongside JSON results.
This is definitely an issue with agate. I dived a bit deeper into the example from above. The method that switches this from a padded string to an integer is [`table_from_data_flat`](https://github.com/fishtown-analytics/dbt/blob/1e5a7878e5188fa58f91e2f257041f28fdb948fc/core/dbt/clients/agate_helper.py#L88), called by `execute` &rarr; `get_result_from_cursor`:

https://github.com/fishtown-analytics/dbt/blob/1e5a7878e5188fa58f91e2f257041f28fdb948fc/core/dbt/adapters/sql/connections.py#L112-L125

Here's the simple reproduction case:
```python
>>> import dbt.clients.agate_helper
>>> data = [{'my_varchar_column': '0000000025'}]
>>> column_names = ['my_varchar_column']
>>> agate_tbl = dbt.clients.agate_helper.table_from_data_flat(
...             data,
...             column_names
...         )
>>> agate_tbl.print_table()
| my_varchar_column |
| ----------------- |
|                25 |
```

Agate does a lot of type inference under the hood. We enable user-supplied column type overrides for seeds, but I don't think that makes a lot of sense for one-off RPC queries. Really, we should be getting the data type from the database, though that may mean handling some low-level differences across cursors. Here's what `cursor.description` looks like for Postgres + Redshift:
```
(Column(name='my_varchar_column', type_code=1043),)
```
Versus Snowflake:
```
[('MY_VARCHAR_COLUMN', 2, None, 16777216, None, None, False)]
```
Whereas other databases, e.g. BigQuery, reimplement `adapter.execute` and use other methods to convert fetched results to an agate table. So the intervention needed may vary.

Resolving patch:
diff --git a/core/dbt/clients/agate_helper.py b/core/dbt/clients/agate_helper.py
--- a/core/dbt/clients/agate_helper.py
+++ b/core/dbt/clients/agate_helper.py
@@ -35,7 +35,11 @@ def cast(self, d):
         )
 
 
-def build_type_tester(text_columns: Iterable[str]) -> agate.TypeTester:
+def build_type_tester(
+    text_columns: Iterable[str],
+    string_null_values: Optional[Iterable[str]] = ('null', '')
+) -> agate.TypeTester:
+
     types = [
         agate.data_types.Number(null_values=('null', '')),
         agate.data_types.Date(null_values=('null', ''),
@@ -46,10 +50,10 @@ def build_type_tester(text_columns: Iterable[str]) -> agate.TypeTester:
         agate.data_types.Boolean(true_values=('true',),
                                  false_values=('false',),
                                  null_values=('null', '')),
-        agate.data_types.Text(null_values=('null', ''))
+        agate.data_types.Text(null_values=string_null_values)
     ]
     force = {
-        k: agate.data_types.Text(null_values=('null', ''))
+        k: agate.data_types.Text(null_values=string_null_values)
         for k in text_columns
     }
     return agate.TypeTester(force=force, types=types)
@@ -66,7 +70,13 @@ def table_from_rows(
     if text_only_columns is None:
         column_types = DEFAULT_TYPE_TESTER
     else:
-        column_types = build_type_tester(text_only_columns)
+        # If text_only_columns are present, prevent coercing empty string or
+        # literal 'null' strings to a None representation.
+        column_types = build_type_tester(
+            text_only_columns,
+            string_null_values=()
+        )
+
     return agate.Table(rows, column_names, column_types=column_types)
 
 
@@ -86,19 +96,34 @@ def table_from_data(data, column_names: Iterable[str]) -> agate.Table:
 
 
 def table_from_data_flat(data, column_names: Iterable[str]) -> agate.Table:
-    "Convert list of dictionaries into an Agate table"
+    """
+    Convert a list of dictionaries into an Agate table. This method does not
+    coerce string values into more specific types (eg. '005' will not be
+    coerced to '5'). Additionally, this method does not coerce values to
+    None (eg. '' or 'null' will retain their string literal representations).
+    """
 
     rows = []
+    text_only_columns = set()
     for _row in data:
         row = []
-        for value in list(_row.values()):
+        for col_name in column_names:
+            value = _row[col_name]
             if isinstance(value, (dict, list, tuple)):
-                row.append(json.dumps(value, cls=dbt.utils.JSONEncoder))
-            else:
-                row.append(value)
+                # Represent container types as json strings
+                value = json.dumps(value, cls=dbt.utils.JSONEncoder)
+                text_only_columns.add(col_name)
+            elif isinstance(value, str):
+                text_only_columns.add(col_name)
+            row.append(value)
+
         rows.append(row)
 
-    return table_from_rows(rows=rows, column_names=column_names)
+    return table_from_rows(
+        rows=rows,
+        column_names=column_names,
+        text_only_columns=text_only_columns
+    )
 
 
 def empty_table():


Test patch:
diff --git a/test/integration/100_rpc_test/sql/generic.sql b/test/integration/100_rpc_test/sql/generic.sql
new file mode 100644
--- /dev/null
+++ b/test/integration/100_rpc_test/sql/generic.sql
@@ -0,0 +1,29 @@
+
+/*
+    Every column returned by this query should be interpreted
+    as a string. If any result is coerced into a non-string (eg.
+    an int, float, bool, date, NoneType, etc) then dbt did the
+    wrong thing
+*/
+
+select
+    '' as str_empty_string,
+
+    'null' as str_null,
+
+    '1' as str_int,
+    '00005' as str_int_2,
+    '00' as str_int_3,
+
+    '1.1' as str_float,
+    '00001.1' as str_float_2,
+
+    'true' as str_bool,
+    'True' as str_bool_2,
+
+    '2021-01-01' as str_date,
+    '2021-01-01T12:00:00Z' as str_datetime,
+
+    -- this is obviously not a date... but Agate used to think it was!
+    -- see: https://github.com/fishtown-analytics/dbt/issues/2984
+    '0010T00000aabbccdd' as str_obviously_not_date
diff --git a/test/integration/100_rpc_test/test_execute_fetch_and_serialize.py b/test/integration/100_rpc_test/test_execute_fetch_and_serialize.py
--- a/test/integration/100_rpc_test/test_execute_fetch_and_serialize.py
+++ b/test/integration/100_rpc_test/test_execute_fetch_and_serialize.py
@@ -39,6 +39,12 @@ def do_test_file(self, filename):
         self.assertTrue(len(table.rows) > 0, "agate table had no rows")
 
         self.do_test_pickle(table)
+        return table
+
+    def assert_all_columns_are_strings(self, table):
+        for row in table:
+            for value in row:
+                self.assertEqual(type(value), str, f'Found a not-string: {value} in row {row}')
 
     @use_profile('bigquery')
     def test__bigquery_fetch_and_serialize(self):
@@ -51,3 +57,18 @@ def test__snowflake_fetch_and_serialize(self):
     @use_profile('redshift')
     def test__redshift_fetch_and_serialize(self):
         self.do_test_file('redshift.sql')
+
+    @use_profile('bigquery')
+    def test__bigquery_type_coercion(self):
+        table = self.do_test_file('generic.sql')
+        self.assert_all_columns_are_strings(table)
+
+    @use_profile('snowflake')
+    def test__snowflake_type_coercion(self):
+        table = self.do_test_file('generic.sql')
+        self.assert_all_columns_are_strings(table)
+
+    @use_profile('redshift')
+    def test__redshift_type_coercion(self):
+        table = self.do_test_file('generic.sql')
+        self.assert_all_columns_are_strings(table)
diff --git a/test/unit/test_agate_helper.py b/test/unit/test_agate_helper.py
--- a/test/unit/test_agate_helper.py
+++ b/test/unit/test_agate_helper.py
@@ -133,3 +133,24 @@ def test_merge_mixed(self):
         assert isinstance(result.column_types[1], agate.data_types.Text)
         assert isinstance(result.column_types[2], agate.data_types.Text)
         self.assertEqual(len(result), 6)
+
+    def test_nocast_string_types(self):
+        # String fields should not be coerced into a representative type
+        # See: https://github.com/fishtown-analytics/dbt/issues/2984
+
+        column_names = ['a', 'b', 'c', 'd', 'e']
+        result_set = [
+            {'a': '0005', 'b': '01T00000aabbccdd', 'c': 'true', 'd': 10, 'e': False},
+            {'a': '0006', 'b': '01T00000aabbccde', 'c': 'false', 'd': 11, 'e': True},
+        ]
+
+        tbl = agate_helper.table_from_data_flat(data=result_set, column_names=column_names)
+        self.assertEqual(len(tbl), len(result_set))
+
+        expected = [
+            ['0005', '01T00000aabbccdd', 'true', Decimal(10), False],
+            ['0006', '01T00000aabbccde', 'false', Decimal(11), True],
+        ]
+
+        for i, row in enumerate(tbl):
+            self.assertEqual(list(row), expected[i])