Issue: JSONField - Peewee cannot serialize Pendulum DateTime
Author: johnziebro
State: closed
Created: 2021-11-15 17:38:08

### Issue
The Pendulum module's purpose is to be a drop-in replacement for the standard datetime class. In most cases Pendulum's DateTime class can be treated the same as datetime.datetime. However, when attempting to save with Peewee into a JSONField, DateTime throws a TypeError as not serializable.

Per [Pendulum's FAQ](https://pendulum.eustace.io/faq/) this issue is to be expected:
> Unlike other datetime libraries for Python, Pendulum is a drop-in replacement for the standard datetime class (it inherits from it), so, basically, you can replace all your datetime instances by DateTime instances in you code (**_exceptions exist for libraries that check the type of the objects by using the type function like sqlite3 or PyMySQL for instance_**).

### Peewee Doc's Solution
Per Peewee docs for [JSONField](http://docs.peewee-orm.com/en/latest/peewee/sqlite_ext.html?highlight=JSONField#JSONField), the solution is to customize the serialization:
> To customize the JSON serialization or de-serialization, you can specify a custom json_dumps and json_loads callables. These functions should accept a single paramter: the object to serialize, and the JSON string, respectively. To modify the parameters of the stdlib JSON functions, you can use functools.partial
```
# Do not escape unicode code-points.
my_json_dumps = functools.partial(json.dumps, ensure_ascii=False)

class SomeModel(Model):
    # Specify our custom serialization function.
    json_data = JSONField(json_dumps=my_json_dumps)
```
### Questions
I'd like to better understand the roundtrip process for serializing and deserializing. In the future, it would be helpful for a more extensive example in the docs. The use case is to be able to save and load Pendulum DateTimes into and from Peewee. I don't exactly see how to accomplish this using partial with json.dumps. My understanding is that I would need to subclass json.JSONEncoder and json.JSONDecoder.

**Could you provide some additional direction in how to accomplish this or a more efficient route? Maybe I am missing something about using json.dumps. Thank you in advance.**  

### Error
> Traceback (most recent call last):
>   ...
>   File "/home/----/Projects/rf/.venv/lib/python3.8/site-packages/playhouse/sqlite_ext.py", line 142, in db_value
>     value = fn.json(self._json_dumps(value))
>   File "/usr/lib/python3.8/json/__init__.py", line 231, in dumps
>     return _default_encoder.encode(obj)
>   File "/usr/lib/python3.8/json/encoder.py", line 199, in encode
>     chunks = self.iterencode(o, _one_shot=True)
>   File "/usr/lib/python3.8/json/encoder.py", line 257, in iterencode
>     return _iterencode(o, 0)
>   File "/usr/lib/python3.8/json/encoder.py", line 179, in default
>     raise TypeError(f'Object of type {o.__class__.__name__} '
> TypeError: Object of type DateTime is not JSON serializable
> python-BaseException

>I'd like to better understand the roundtrip process for serializing and deserializing.

The sqlite3 json data-type requires the data to be serialized into json. Simple as that. When reading row data off a cursor the JSONField will deserialize it back to an object.

The problem is actually rather from python's own `json` module not supporting `datetime`-like out of the box. Which is a pretty glaring oversight in my opinion, but something to take up with the core team.

@coleifer could you let me know if this would be a good strategy to follow?

Generally speaking, the [direction for encoding per this stackoverflow](https://stackoverflow.com/a/27058505/1663382) is to subclass json.JSONEncoder/JSONDecoder with a custom class, then provide it to json.dumps/loads as the 'cls' parameter using functools.partial. I believe what I was hung up on from the Peewee doc's was how to use the custom encoder and that json_loads was missing from the example.

```
class DateTimeEncoder(JSONEncoder)
   # custom encode implementation here
    ...

class DateTimeDecoder(JSONDecoder)
   # custom decode implementation here
    ...

# assign dumps and loads functions
my_json_dumps = functools.partial(json.dumps, cls=DateTimeEncoder)
my_json_loads = functools.partial(json.loads, cls=DateTimeDecoder)

class SomeModel(Model):
    # Specify our custom serialization function.
    json_data = JSONField(json_dumps=my_json_dumps, json_loads=my_json_loads)
```

There are a number of options: https://stackoverflow.com/questions/11875770/how-to-overcome-datetime-datetime-not-json-serializable

For posterity or if anyone else needs an example as I did not see this in any other closed issues, @coleifer thanks again! 

### Solution
```
import json
import pendulum as pdlm
from functools import partial


class PendulumDateTimeEncoder(json.JSONEncoder):
    """
    Provides json encoding as string for pendulum.DateTime objects
    including timezone or UTC.
    """

    def default(self, object):

        # handle pendulum.DateTime objects
        if isinstance(object, pdlm.DateTime):

            # generate ISO 8601 string
            return object.to_iso8601_string()

        return super().default(object)


class PendulumDateTimeDecoder(json.JSONDecoder):
    """
    Provides json datetime stored as string decoding for
    pendulum.DateTime objects.
    """

    def decode(self, object):

        # handle pendulum.DateTime objects
        if isinstance(object, str):

            # load as json string
            object = json.loads(object)

            # return ISO 8601 string as pendulum DateTime
            return pdlm.parse(object)

        return super().decode(object)


# assign dumps and loads functions
custom_dumps = partial(json.dumps, cls=PendulumDateTimeEncoder)
custom_loads = partial(json.loads, cls=PendulumDateTimeDecoder)
```

### Test
```
now = pdlm.now('UTC')
dumped = custom_dumps(now)
loaded = custom_loads(dumped)
assert loaded == now, "Round-trip serialization failed."
assert loaded.tz.name == "UTC", "Timezone round-trip failure."
```

### ORM Class
```
class TimelineModel(BaseModel):
    """ ORM model for Timeline. """
    intervals = JSONField(json_dumps=custom_dumps, json_loads=custom_loads)
```