Repository: dbt-labs/dbt-core

Issue:
[CT-2848] [Bug] `dbt show` adding decimal places to non-decimal values
### Is this a regression in a recent version of dbt-core?

- [X] I believe this is a regression in dbt-core functionality
- [X] I have searched the existing issues, and I could not find an existing issue for this regression

### Current Behavior

`dbt show` is adding decimal places to numeric/integer values incorrectly. This is causing problems for users in the dbt Cloud IDE, as the IDE uses that command to render the preview tab. 

### Expected/Previous Behavior

Previously (we think!) this was not an issue, and decimal places were properly handled by dbt show

### Steps To Reproduce

1. use dbt-snowflake>=1.5.0
2. create a model in your project:

```sql
# in my_model.sql
select
  cast(1 as numeric) as _numeric,
  cast(1 as integer) as _integer,
  cast(1 as decimal) as _decimal
```
3. run `dbt show -s my_model --output JSON`
4. get this!
```zsh
❯ dbt show -s my_model --output json
20:33:15  Running with dbt=1.5.3
20:33:16  Registered adapter: snowflake=1.5.2
20:33:16  Unable to do partial parsing because of a version mismatch
20:33:17  Found 3 models, 4 tests, 0 snapshots, 0 analyses, 436 macros, 0 operations, 0 seed files, 0 sources, 0 exposures, 0 metrics, 0 groups
20:33:17  
20:33:18  Concurrency: 8 threads (target='dev')
20:33:18  
20:33:19  {
  "node": "my_model",
  "show": [
    {
      "_NUMERIC": 1.0,
      "_INTEGER": 1.0,
      "_DECIMAL": 1.0
    }
  ]
}
```

### Relevant log output

_No response_

### Environment

```markdown
- OS: mac
- Python: Python 3.9.16
- dbt (working version): unknown
- dbt (regression version): 1.5.3
```


### Which database adapter are you using with dbt?

snowflake

### Additional Context

_No response_


Discussion:
did some more local testing on other versions of 1.5 and observed the same behavior - -this may not be a regression, but just a regular bug!
Copying from #8167 (great minds think alike @dave-connors-3):

## What's going on?

We're just using the `agate` library's type system and JSON serialization:

https://github.com/dbt-labs/dbt-core/blob/eeb057085c19301f9803d1620d79645f27d501b5/core/dbt/task/show.py#L78-L83

This is being returned and saved as a `decimal.Decimal` type, rather than a Python `int` — and the `decimal.Decimal` type serializes to JSON `number` (`1.0`) instead of JSON `integer` (`1`)

```python
ipdb> table
<agate.table.Table object at 0x1078d1360>
ipdb> [list(row) for row in table]
[['0005', 'false', Decimal('10'), False, '01T00000aabbccdd'], ['0006', 'true', Decimal('20'), True, '01T00000aabbccde']]
ipdb> table.print_json()
[{"stringnum": "0005", "stringbool": "false", "intfield": 10.0, "boolfield": false, "dtstring": "01T00000aabbccdd"}, {"stringnum": "0006", "stringbool": "true", "intfield": 20.0, "boolfield": true, "dtstring": "01T00000aabbccde"}]
ipdb> type(table[0][2])
<class 'decimal.Decimal'>
```

Thanks to 2 great minds for opening this up! 

It does look like this was an issue with prior versions of the IDE (before the `dbt show` command existed), so it's not a regression.

That being said, we definitely want to enable users to be able to spot check their data using `dbt show` / preview in the IDE. 
Experiencing the same issue here, running dbt version 1.5.0. Following for updates.
I think we might be able to fix this with a couple of additional overrides in [our custom `Number` type](https://github.com/dbt-labs/dbt-core/blob/a433c31d6ebae79bd5b281653551899b7369067c/core/dbt/clients/agate_helper.py#L16-L23), which reimplements `agate`'s built-in one:

```python
class Number(agate.data_types.Number):
    # undo the change in https://github.com/wireservice/agate/pull/733
    # i.e. do not cast True and False to numeric 1 and 0
    def cast(self, d):
        if type(d) == bool:
            raise agate.exceptions.CastError("Do not cast True to 1 or False to 0.")
        # preserve integers as native Python int
        elif type(d) == int:
            return d
        else:
            return super().cast(d)
            
    def jsonify(self, d):
        if d is None:
            return d
        # do not cast integers to floats
        elif type(d) == int:
            return d

        return float(d)
```

```
$ dbt show -s test --output json
20:08:09  Running with dbt=1.7.0-a1
20:08:09  Registered adapter: postgres=1.7.0-a1
20:08:09  Found 1 seed, 1 model, 0 sources, 0 exposures, 0 metrics, 352 macros, 0 groups, 0 semantic models
20:08:09
20:08:09  Concurrency: 5 threads (target='dev')
20:08:09
20:08:09  {
  "node": "test",
  "show": [
    {
      "_numeric": 1.0,
      "_integer": 1,
      "_decimal": 1.0
    }
  ]
}
```

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
@@ -9,10 +9,20 @@
 
 from dbt.exceptions import DbtRuntimeError
 
-
 BOM = BOM_UTF8.decode("utf-8")  # '\ufeff'
 
 
+class Integer(agate.data_types.DataType):
+    def cast(self, d):
+        if type(d) == int:
+            return d
+        else:
+            raise agate.exceptions.CastError('Can not parse value "%s" as Integer.' % d)
+
+    def jsonify(self, d):
+        return d
+
+
 class Number(agate.data_types.Number):
     # undo the change in https://github.com/wireservice/agate/pull/733
     # i.e. do not cast True and False to numeric 1 and 0
@@ -48,6 +58,7 @@ def build_type_tester(
 ) -> agate.TypeTester:
 
     types = [
+        Integer(null_values=("null", "")),
         Number(null_values=("null", "")),
         agate.data_types.Date(null_values=("null", ""), date_format="%Y-%m-%d"),
         agate.data_types.DateTime(null_values=("null", ""), datetime_format="%Y-%m-%d %H:%M:%S"),


Test patch:
diff --git a/tests/functional/show/fixtures.py b/tests/functional/show/fixtures.py
--- a/tests/functional/show/fixtures.py
+++ b/tests/functional/show/fixtures.py
@@ -2,6 +2,14 @@
 select * from {{ ref('sample_seed') }}
 """
 
+models__sample_number_model = """
+select
+  cast(1.0 as int) as float_to_int_field,
+  3.0 as float_field,
+  4.3 as float_with_dec_field,
+  5 as int_field
+"""
+
 models__second_model = """
 select
     sample_num as col_one,
diff --git a/tests/functional/show/test_show.py b/tests/functional/show/test_show.py
--- a/tests/functional/show/test_show.py
+++ b/tests/functional/show/test_show.py
@@ -6,6 +6,7 @@
     models__second_ephemeral_model,
     seeds__sample_seed,
     models__sample_model,
+    models__sample_number_model,
     models__second_model,
     models__ephemeral_model,
     schema_yml,
@@ -19,6 +20,7 @@ class TestShow:
     def models(self):
         return {
             "sample_model.sql": models__sample_model,
+            "sample_number_model.sql": models__sample_number_model,
             "second_model.sql": models__second_model,
             "ephemeral_model.sql": models__ephemeral_model,
             "sql_header.sql": models__sql_header,
@@ -62,6 +64,19 @@ def test_select_single_model_json(self, project):
         assert "sample_num" in log_output
         assert "sample_bool" in log_output
 
+    def test_numeric_values(self, project):
+        run_dbt(["build"])
+        (results, log_output) = run_dbt_and_capture(
+            ["show", "--select", "sample_number_model", "--output", "json"]
+        )
+        assert "Previewing node 'sample_number_model'" not in log_output
+        assert "1.0" not in log_output
+        assert "1" in log_output
+        assert "3.0" in log_output
+        assert "4.3" in log_output
+        assert "5" in log_output
+        assert "5.0" not in log_output
+
     def test_inline_pass(self, project):
         run_dbt(["build"])
         (results, log_output) = run_dbt_and_capture(
diff --git a/tests/unit/test_agate_helper.py b/tests/unit/test_agate_helper.py
--- a/tests/unit/test_agate_helper.py
+++ b/tests/unit/test_agate_helper.py
@@ -121,37 +121,37 @@ def test_datetime_formats(self):
             self.assertEqual(tbl[0][0], expected)
 
     def test_merge_allnull(self):
-        t1 = agate.Table([(1, "a", None), (2, "b", None)], ("a", "b", "c"))
-        t2 = agate.Table([(3, "c", None), (4, "d", None)], ("a", "b", "c"))
+        t1 = agate_helper.table_from_rows([(1, "a", None), (2, "b", None)], ("a", "b", "c"))
+        t2 = agate_helper.table_from_rows([(3, "c", None), (4, "d", None)], ("a", "b", "c"))
         result = agate_helper.merge_tables([t1, t2])
         self.assertEqual(result.column_names, ("a", "b", "c"))
-        assert isinstance(result.column_types[0], agate.data_types.Number)
+        assert isinstance(result.column_types[0], agate_helper.Integer)
         assert isinstance(result.column_types[1], agate.data_types.Text)
         assert isinstance(result.column_types[2], agate.data_types.Number)
         self.assertEqual(len(result), 4)
 
     def test_merge_mixed(self):
-        t1 = agate.Table([(1, "a", None), (2, "b", None)], ("a", "b", "c"))
-        t2 = agate.Table([(3, "c", "dog"), (4, "d", "cat")], ("a", "b", "c"))
-        t3 = agate.Table([(3, "c", None), (4, "d", None)], ("a", "b", "c"))
+        t1 = agate_helper.table_from_rows([(1, "a", None), (2, "b", None)], ("a", "b", "c"))
+        t2 = agate_helper.table_from_rows([(3, "c", "dog"), (4, "d", "cat")], ("a", "b", "c"))
+        t3 = agate_helper.table_from_rows([(3, "c", None), (4, "d", None)], ("a", "b", "c"))
 
         result = agate_helper.merge_tables([t1, t2])
         self.assertEqual(result.column_names, ("a", "b", "c"))
-        assert isinstance(result.column_types[0], agate.data_types.Number)
+        assert isinstance(result.column_types[0], agate_helper.Integer)
         assert isinstance(result.column_types[1], agate.data_types.Text)
         assert isinstance(result.column_types[2], agate.data_types.Text)
         self.assertEqual(len(result), 4)
 
         result = agate_helper.merge_tables([t2, t3])
         self.assertEqual(result.column_names, ("a", "b", "c"))
-        assert isinstance(result.column_types[0], agate.data_types.Number)
+        assert isinstance(result.column_types[0], agate_helper.Integer)
         assert isinstance(result.column_types[1], agate.data_types.Text)
         assert isinstance(result.column_types[2], agate.data_types.Text)
         self.assertEqual(len(result), 4)
 
         result = agate_helper.merge_tables([t1, t2, t3])
         self.assertEqual(result.column_names, ("a", "b", "c"))
-        assert isinstance(result.column_types[0], agate.data_types.Number)
+        assert isinstance(result.column_types[0], agate_helper.Integer)
         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)
@@ -191,7 +191,7 @@ def test_nocast_bool_01(self):
         self.assertEqual(len(tbl), len(result_set))
 
         assert isinstance(tbl.column_types[0], agate.data_types.Boolean)
-        assert isinstance(tbl.column_types[1], agate.data_types.Number)
+        assert isinstance(tbl.column_types[1], agate_helper.Integer)
 
         expected = [
             [True, Decimal(1)],