Repository: pydantic/pydantic

Issue:
Can't roundtrip json encode/prase properly with ConstrainedDecimal
### Checks

* [x] I added a descriptive title to this issue
* [x] I have searched (google, github) for similar issues and couldn't find anything
* [x] I have read and followed [the docs](https://pydantic-docs.helpmanual.io/) and still think this is a bug

<!-- Sorry to sound so draconian, but every second saved replying to issues is time spend improving pydantic :-) -->

# Bug

Output of `python -c "import pydantic.utils; print(pydantic.utils.version_info())"`:
```
             pydantic version: 1.7.3
            pydantic compiled: True
                 install path: /Users/hultner/Library/Caches/pypoetry/virtualenvs/hantera-backend-5YPPF0bI-py3.9/lib/python3.9/site-packages/pydantic
               python version: 3.9.0 (default, Oct 10 2020, 11:40:52)  [Clang 11.0.3 (clang-1103.0.32.62)]
                     platform: macOS-10.15.7-x86_64-i386-64bit
     optional deps. installed: ['typing-extensions', 'email-validator', 'devtools']
```
<!-- or if you're using pydantic prior to v1.3, manually include: OS, python version and pydantic version -->

<!-- Please read the [docs](https://pydantic-docs.helpmanual.io/) and search through issues to
confirm your bug hasn't already been reported. -->

<!-- Where possible please include a self-contained code snippet describing your bug: -->

When using a `ConstrainedDecimal` with zero decimal_places (as provided in example below), pydantic incorrectly encodes the value as a float, thus resulting in failure if one tries to parse the recently encoded json object. 

Using a decimal with `max_digits = x` and `decimal_places = 0` is a great way of representing for instance a `Numeric(22,0)` (where x is 22) from many SQL database schemas. Certain database engines like the popular pyodbc will properly handle and convert such a Decimal value, but won't handle it as an int as this is implicitly interpreted as a 32 bit int by pyodbc. Having a fixed number of max_digits also allows ones query-engine to pre-compile reusable query plans, which other wise would have to be recomputed for every length of of the given number.
In other words, using a ConstrainedDecimal for this type of data is ideal.

I have provided a minimal test/example which can both be executed directly but also ran with pytest to showcase the issue at hand.

```py
"""
Self contained example showcasing problem with decimals using pydantics
default encoder. 
"""
from pydantic import ConstrainedDecimal, BaseModel
from decimal import Decimal


class Id(ConstrainedDecimal):
    max_digits = 22
    decimal_places = 0
    ge = 0


ObjId = Id


class Obj(BaseModel):
    id: ObjId
    name: str
    price: Decimal = Decimal(0.00)


class ObjWithEncoder(BaseModel):
    id: ObjId
    name: str
    price: Decimal = Decimal(0.00)

    class Config:
        json_encoders = {
            Id: int,
        }


def test_con_decimal_encode() -> None:
    test_obj = Obj(id=1, name="Test Obj")
    cycled_obj = Obj.parse_raw(test_obj.json())
    assert test_obj == cycled_obj


def test_con_decimal_encode_custom_encoder() -> None:
    test_obj = ObjWithEncoder(id=1, name="Test Obj")
    cycled_obj = ObjWithEncoder.parse_raw(test_obj.json())
    assert test_obj == cycled_obj


if __name__ == "__main__":
    from pprint import pprint

    print_obj = Obj(id=1, name="Test Obj", price=1.23)
    json_obj = print_obj.json()
    pprint(json_obj)
```

I have a small patch for pydantic.json-module which I can provide as a pull-request. When using said patch all the tests above will pass, notice that I both handle the case where Decimals do have an negative exponent (decimal_places) and the case where it doesn't. The patch handles both these cases as expected and is written in a minimal, and of course readable fashion. 

I am right now in the process of reading through the contributor guidelines to ensure that my patch is up to the standards which the project holds for contributions.


Discussion:

Resolving patch:
diff --git a/pydantic/json.py b/pydantic/json.py
--- a/pydantic/json.py
+++ b/pydantic/json.py
@@ -18,6 +18,27 @@ def isoformat(o: Union[datetime.date, datetime.time]) -> str:
     return o.isoformat()
 
 
+def decimal_encoder(dec_value: Decimal) -> Union[int, float]:
+    """
+    Encodes a Decimal as int of there's no exponent, otherwise float
+
+    This is useful when we use ConstrainedDecimal to represent Numeric(x,0)
+    where a integer (but not int typed) is used. Encoding this as a float
+    results in failed round-tripping between encode and prase.
+    Our Id type is a prime example of this.
+
+    >>> decimal_encoder(Decimal("1.0"))
+    1.0
+
+    >>> decimal_encoder(Decimal("1"))
+    1
+    """
+    if dec_value.as_tuple().exponent >= 0:
+        return int(dec_value)
+    else:
+        return float(dec_value)
+
+
 ENCODERS_BY_TYPE: Dict[Type[Any], Callable[[Any], Any]] = {
     bytes: lambda o: o.decode(),
     Color: str,
@@ -25,7 +46,7 @@ def isoformat(o: Union[datetime.date, datetime.time]) -> str:
     datetime.datetime: isoformat,
     datetime.time: isoformat,
     datetime.timedelta: lambda td: td.total_seconds(),
-    Decimal: float,
+    Decimal: decimal_encoder,
     Enum: lambda o: o.value,
     frozenset: list,
     deque: list,


Test patch:
diff --git a/tests/test_json.py b/tests/test_json.py
--- a/tests/test_json.py
+++ b/tests/test_json.py
@@ -16,7 +16,7 @@
 from pydantic.color import Color
 from pydantic.dataclasses import dataclass as pydantic_dataclass
 from pydantic.json import pydantic_encoder, timedelta_isoformat
-from pydantic.types import DirectoryPath, FilePath, SecretBytes, SecretStr
+from pydantic.types import ConstrainedDecimal, DirectoryPath, FilePath, SecretBytes, SecretStr
 
 
 class MyEnum(Enum):
@@ -170,6 +170,25 @@ class Config:
     assert m.json() == '{"x": "P0DT0H2M3.000000S"}'
 
 
+def test_con_decimal_encode() -> None:
+    """
+    Makes sure a decimal with decimal_places = 0, as well as one with places
+    can handle a encode/decode roundtrip.
+    """
+
+    class Id(ConstrainedDecimal):
+        max_digits = 22
+        decimal_places = 0
+        ge = 0
+
+    class Obj(BaseModel):
+        id: Id
+        price: Decimal = Decimal('0.01')
+
+    assert Obj(id=1).json() == '{"id": 1, "price": 0.01}'
+    assert Obj.parse_raw('{"id": 1, "price": 0.01}') == Obj(id=1)
+
+
 def test_json_encoder_simple_inheritance():
     class Parent(BaseModel):
         dt: datetime.datetime = datetime.datetime.now()