Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

DataclassJSONField implementation #77

Open
wants to merge 11 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
3 changes: 3 additions & 0 deletions django_jsonform/contrib/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# contrib

Community contributed modules which extend the core `django-jsonform` project.
Empty file.
3 changes: 3 additions & 0 deletions django_jsonform/contrib/dataclasses/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# `django_jsonform.contrib.dataclasses`

**Status:** experimental
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TODO

Empty file.
Empty file.
20 changes: 20 additions & 0 deletions django_jsonform/contrib/dataclasses/forms/fields.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
from __future__ import annotations

import json
from typing import Optional

from django_jsonform.contrib.dataclasses.typedefs import SerializableValue
from django_jsonform.forms.fields import JSONFormField


class DataclassJSONFormField(JSONFormField):
def prepare_value(self, value: SerializableValue) -> Optional[str]:
if value is None:
return None
if isinstance(value, list):
return json.dumps(
[x if isinstance(x, dict) else x.to_dict(validate=False) for x in value]
)
return json.dumps(
value if isinstance(value, dict) else value.to_dict(validate=False)
)
Empty file.
82 changes: 82 additions & 0 deletions django_jsonform/contrib/dataclasses/models/fields.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
from __future__ import annotations

import json
from typing import TYPE_CHECKING, Any, List, Optional, Type

from django_jsonform.contrib.dataclasses.forms.fields import DataclassJSONFormField
from django_jsonform.contrib.dataclasses.typedefs import DataclassJsonSchema, SerializableValue
from django_jsonform.contrib.dataclasses.utils import json_schema_array
from django_jsonform.exceptions import JSONSchemaValidationError
from django_jsonform.models.fields import JSONField

if TYPE_CHECKING:
from django.db import models
from django.db.backends.base.base import BaseDatabaseWrapper
from django.db.models.expressions import Expression


class DataclassJSONField(JSONField):
form_class = DataclassJSONFormField

def __init__(
self,
dataclass_cls: Type[DataclassJsonSchema],
*,
many: bool = False,
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This many convention comes from Django Rest Framework

But we can consider making this a DataclassArrayJSONField instead

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm going to leave this decision up to you. I'm not familiar with the dataclasses-jsonschema library. So, whatever you think works best, we can go with that.

**kwargs: Any,
) -> None:
self._dataclass_cls = dataclass_cls
self._many = many
self._json_schema = (
json_schema_array(dataclass_cls) if many else dataclass_cls.json_schema()
)

super().__init__(schema=self._json_schema, **kwargs) # type: ignore

def from_db_value(
self,
value: Optional[str],
expression: Expression,
connection: BaseDatabaseWrapper,
) -> SerializableValue:
if value is None:
return None
data = json.loads(value)
if isinstance(data, list):
return [self._dataclass_cls.from_dict(x, validate=False) for x in data]
return self._dataclass_cls.from_dict(data, validate=False)

def get_prep_value(self, value: SerializableValue) -> Optional[str]:
if value is None:
return None
if isinstance(value, list):
return json.dumps(
[x if isinstance(x, dict) else x.to_dict(validate=False) for x in value]
)
return json.dumps(
value if isinstance(value, dict) else value.to_dict(validate=False)
)

def validate(self, value: SerializableValue, model_instance: models.Model) -> None:
if value is None:
if not self.null:
raise JSONSchemaValidationError("Null value in non-nullable field.") # type: ignore
return
values_to_validate = value if isinstance(value, list) else [value]
if len(values_to_validate) == 0:
if not self.blank:
raise JSONSchemaValidationError("Blank value in non-blank field.") # type: ignore
return
errs: List[str] = []
for i, val in enumerate(values_to_validate):
try:
if isinstance(val, dict):
self._dataclass_cls.from_dict(
val, validate=True, validate_enums=True
)
else:
val.to_dict(validate=True, validate_enums=True)
except Exception as e:
errs.append(f"[{i}] {e}")
if errs:
raise JSONSchemaValidationError(errs) # type: ignore
Empty file.
14 changes: 14 additions & 0 deletions django_jsonform/contrib/dataclasses/typedefs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
from __future__ import annotations

from typing import List, Optional, Union

from dataclasses_jsonschema import JsonSchemaMixin
from dataclasses_jsonschema.type_defs import JsonDict

DataclassJsonSchema = JsonSchemaMixin
"""TODO: Make this a Protocol or something. Not sure."""


SerializableValue = Optional[
Union[DataclassJsonSchema, JsonDict, List[Union[DataclassJsonSchema, JsonDict]]]
]
24 changes: 24 additions & 0 deletions django_jsonform/contrib/dataclasses/utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
from __future__ import annotations

import dataclasses
from typing import Type

from dataclasses_jsonschema import JsonSchemaMixin
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we should add this as a dependency to this project.

Need to research how to make this contrib module optional.

Maybe removing the contrib/__init__,py would work?

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is okay.

To make it an optional dependency, please modify the setup.cfg file and add this at the end:

+ 
+ [options.extras_require]
+ contrib-dataclasses = dataclasses-jsonschema

Users who wish to use this feature can install the dependencies by:

pip install "django-jsonform[contrib-dataclasses]"

from dataclasses_jsonschema.type_defs import JsonDict


def json_schema_array(dataclass_cls: Type[JsonSchemaMixin]) -> JsonDict:
"""Get JSON schema representing an array of `dataclass_cls`."""
WrapperDataClass = type("WrapperDataClass", (JsonSchemaMixin,), {})
WrapperDataClass.__annotations__["item"] = dataclass_cls
WrapperDataClass = dataclasses.dataclass(WrapperDataClass)

schema: JsonDict = WrapperDataClass.json_schema() # type: ignore

schema["type"] = "array"
schema["description"] = f"An array of {dataclass_cls.__name__} objects."
del schema["required"]
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The code example in your comment above gives me KeyError: 'required'. Perhaps use this instead:

Suggested change
del schema["required"]
schema.pop("required", None)

schema["items"] = schema["properties"]["item"]
Copy link
Owner

@bhch bhch Nov 8, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Line 21 also results in a KeyError: 'item'.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm.. This shouldn't be happening.

What Python and dataclasses-jsonschema versions are you using?

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Python: 3.8

dataclasses-jsonschema: 2.16.0

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm using dataclasses-jsonschema[fast-validation]==2.16.0 and Python 3.10

Will have to try 3.8 later (or make a separate package and drop support for older versions)

I noticed you're supporting a lot of older Django versions too. I only tested with Django==4.1.3

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just tested with Python 3.10 and Django 4.1.3. Everything works fine this time.

del schema["properties"]

return schema
8 changes: 6 additions & 2 deletions django_jsonform/models/fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ class DjangoArrayField:


class JSONField(DjangoJSONField):
form_class = JSONFormField
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

so it's overridable


def __init__(self, *args, **kwargs):
self.schema = kwargs.pop('schema', {})
self.pre_save_hook = kwargs.pop('pre_save_hook', None)
Expand All @@ -32,7 +34,7 @@ def __init__(self, *args, **kwargs):

def formfield(self, **kwargs):
return super().formfield(**{
'form_class': JSONFormField,
'form_class': self.form_class,
'schema': self.schema,
'model_name': self.model.__name__,
'file_handler': self.file_handler,
Expand All @@ -49,6 +51,8 @@ def pre_save(self, model_instance, add):


class ArrayField(DjangoArrayField):
form_class = ArrayFormField

def __init__(self, *args, **kwargs):
if hasattr(DjangoArrayField, 'mock_field'):
raise ImproperlyConfigured('ArrayField requires psycopg2 to be installed.')
Expand All @@ -57,4 +61,4 @@ def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)

def formfield(self, **kwargs):
return super().formfield(**{'form_class': ArrayFormField, 'nested': self.nested, **kwargs})
return super().formfield(**{'form_class': self.form_class, 'nested': self.nested, **kwargs})