Skip to content

Commit

Permalink
Fixed #24604 -- Added JSONField to contrib.postgres.
Browse files Browse the repository at this point in the history
  • Loading branch information
mjtamlyn committed May 30, 2015
1 parent 74fe442 commit 33ea472
Show file tree
Hide file tree
Showing 11 changed files with 540 additions and 3 deletions.
1 change: 1 addition & 0 deletions django/contrib/postgres/fields/__init__.py
@@ -1,3 +1,4 @@
from .array import * # NOQA
from .hstore import * # NOQA
from .jsonb import * # NOQA
from .ranges import * # NOQA
99 changes: 99 additions & 0 deletions django/contrib/postgres/fields/jsonb.py
@@ -0,0 +1,99 @@
import json

from psycopg2.extras import Json

from django.contrib.postgres import forms, lookups
from django.core import exceptions
from django.db.models import Field, Transform
from django.utils.translation import ugettext_lazy as _

__all__ = ['JSONField']


class JSONField(Field):
empty_strings_allowed = False
description = _('A JSON object')
default_error_messages = {
'invalid': _("Value must be valid JSON."),
}

def db_type(self, connection):
return 'jsonb'

def get_transform(self, name):
transform = super(JSONField, self).get_transform(name)
if transform:
return transform
return KeyTransformFactory(name)

def get_prep_value(self, value):
if value is not None:
return Json(value)
return value

def get_prep_lookup(self, lookup_type, value):
if lookup_type in ('has_key', 'has_keys', 'has_any_keys'):
return value
if isinstance(value, (dict, list)):
return Json(value)
return super(JSONField, self).get_prep_lookup(lookup_type, value)

def validate(self, value, model_instance):
super(JSONField, self).validate(value, model_instance)
try:
json.dumps(value)
except TypeError:
raise exceptions.ValidationError(
self.error_messages['invalid'],
code='invalid',
params={'value': value},
)

def value_to_string(self, obj):
value = self._get_val_from_obj(obj)
return value

def formfield(self, **kwargs):
defaults = {'form_class': forms.JSONField}
defaults.update(kwargs)
return super(JSONField, self).formfield(**defaults)


JSONField.register_lookup(lookups.DataContains)
JSONField.register_lookup(lookups.ContainedBy)
JSONField.register_lookup(lookups.HasKey)
JSONField.register_lookup(lookups.HasKeys)
JSONField.register_lookup(lookups.HasAnyKeys)


class KeyTransform(Transform):

def __init__(self, key_name, *args, **kwargs):
super(KeyTransform, self).__init__(*args, **kwargs)
self.key_name = key_name

def as_sql(self, compiler, connection):
key_transforms = [self.key_name]
previous = self.lhs
while isinstance(previous, KeyTransform):
key_transforms.insert(0, previous.key_name)
previous = previous.lhs
lhs, params = compiler.compile(previous)
if len(key_transforms) > 1:
return "{} #> %s".format(lhs), [key_transforms] + params
try:
int(self.key_name)
except ValueError:
lookup = "'%s'" % self.key_name
else:
lookup = "%s" % self.key_name
return "%s -> %s" % (lhs, lookup), params


class KeyTransformFactory(object):

def __init__(self, key_name):
self.key_name = key_name

def __call__(self, *args, **kwargs):
return KeyTransform(self.key_name, *args, **kwargs)
1 change: 1 addition & 0 deletions django/contrib/postgres/forms/__init__.py
@@ -1,3 +1,4 @@
from .array import * # NOQA
from .hstore import * # NOQA
from .jsonb import * # NOQA
from .ranges import * # NOQA
31 changes: 31 additions & 0 deletions django/contrib/postgres/forms/jsonb.py
@@ -0,0 +1,31 @@
import json

from django import forms
from django.utils.translation import ugettext_lazy as _

__all__ = ['JSONField']


class JSONField(forms.CharField):
default_error_messages = {
'invalid': _("'%(value)s' value must be valid JSON."),
}

def __init__(self, **kwargs):
kwargs.setdefault('widget', forms.Textarea)
super(JSONField, self).__init__(**kwargs)

def to_python(self, value):
if value in self.empty_values:
return None
try:
return json.loads(value)
except ValueError:
raise forms.ValidationError(
self.error_messages['invalid'],
code='invalid',
params={'value': value},
)

def prepare_value(self, value):
return json.dumps(value)
105 changes: 105 additions & 0 deletions docs/ref/contrib/postgres/fields.txt
Expand Up @@ -450,6 +450,111 @@ using in conjunction with lookups on
>>> Dog.objects.filter(data__values__contains=['collie'])
[<Dog: Meg>]

JSONField
---------

.. versionadded:: 1.9

.. class:: JSONField(**options)

A field for storing JSON encoded data. In Python the data is represented in
its Python native format: dictionaries, lists, strings, numbers, booleans
and ``None``.

.. note::

PostgreSQL has two native JSON based data types: ``json`` and ``jsonb``.
The main difference between them is how they are stored and how they can be
queried. PostgreSQL's ``json`` field is stored as the original string
representation of the JSON and must be decoded on the fly when queried
based on keys. The ``jsonb`` field is stored based on the actual structure
of the JSON which allows indexing. The trade-off is a small additional cost
on writing to the ``jsonb`` field. ``JSONField`` uses ``jsonb``.

**As a result, the usage of this field is only supported on PostgreSQL
versions at least 9.4**.

Querying JSONField
^^^^^^^^^^^^^^^^^^

We will use the following example model::

from django.contrib.postgres.fields import JSONField
from django.db import models

class Dog(models.Model):
name = models.CharField(max_length=200)
data = JSONField()

def __str__(self): # __unicode__ on Python 2
return self.name

.. fieldlookup:: jsonfield.key

Key, index, and path lookups
~~~~~~~~~~~~~~~~~~~~~~~~~~~~

To query based on a given dictionary key, simply use that key as the lookup
name::

>>> Dog.objects.create(name='Rufus', data={
... 'breed': 'labrador',
... 'owner': {
... 'name': 'Bob',
... 'other_pets': [{
... 'name': 'Fishy',
... }],
... },
... })
>>> Dog.objects.create(name='Meg', data={'breed': 'collie'})

>>> Dog.objects.filter(data__breed='collie')
[<Dog: Meg>]

Multiple keys can be chained together to form a path lookup::

>>> Dog.objects.filter(data__owner__name='Bob')
[<Dog: Rufus>]

If the key is an integer, it will be interpreted as an index lookup in an
array::

>>> Dog.objects.filter(data__owner__other_pets__0__name='Fishy')
[<Dog: Rufus>]

If the key you wish to query by clashes with the name of another lookup, use
the :lookup:`jsonfield.contains` lookup instead.

If only one key or index is used, the SQL operator ``->`` is used. If multiple
operators are used then the ``#>`` operator is used.

.. warning::

Since any string could be a key in a JSON object, any lookup other than
those listed below will be interpreted as a key lookup. No errors are
raised. Be extra careful for typing mistakes, and always check your queries
work as you intend.

Containment and key operations
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

.. fieldlookup:: jsonfield.contains
.. fieldlookup:: jsonfield.contained_by
.. fieldlookup:: jsonfield.has_key
.. fieldlookup:: jsonfield.has_any_keys
.. fieldlookup:: jsonfield.has_keys

:class:`~django.contrib.postgres.fields.JSONField` shares lookups relating to
containment and keys with :class:`~django.contrib.postgres.fields.HStoreField`.

- :lookup:`contains <hstorefield.contains>` (accepts any JSON rather than
just a dictionary of strings)
- :lookup:`contained_by <hstorefield.contained_by>` (accepts any JSON
rather than just a dictionary of strings)
- :lookup:`has_key <hstorefield.has_key>`
- :lookup:`has_any_keys <hstorefield.has_any_keys>`
- :lookup:`has_keys <hstorefield.has_keys>`

.. _range-fields:

Range Fields
Expand Down
15 changes: 15 additions & 0 deletions docs/ref/contrib/postgres/forms.txt
Expand Up @@ -155,6 +155,21 @@ HStoreField
valid for a given field. This can be done using the
:class:`~django.contrib.postgres.validators.KeysValidator`.

JSONField
---------

.. class:: JSONField

A field which accepts JSON encoded data for a
:class:`~django.contrib.postgres.fields.JSONField`. It is represented by an
HTML ``<textarea>``.

.. admonition:: User friendly forms

``JSONField`` is not particularly user friendly in most cases, however
it is a useful way to format data from a client-side widget for
submission to the server.

Range Fields
------------

Expand Down
1 change: 1 addition & 0 deletions docs/releases/1.9.txt
Expand Up @@ -91,6 +91,7 @@ Minor features
:mod:`django.contrib.postgres`
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

* Added :class:`~django.contrib.postgres.fields.JSONField`.
* Added :doc:`/ref/contrib/postgres/aggregates`.

:mod:`django.contrib.redirects`
Expand Down
3 changes: 2 additions & 1 deletion tests/postgres_tests/fields.py
Expand Up @@ -7,7 +7,7 @@
try:
from django.contrib.postgres.fields import (
ArrayField, BigIntegerRangeField, DateRangeField, DateTimeRangeField,
FloatRangeField, HStoreField, IntegerRangeField,
FloatRangeField, HStoreField, IntegerRangeField, JSONField,
)
except ImportError:
class DummyArrayField(models.Field):
Expand All @@ -29,3 +29,4 @@ def deconstruct(self):
FloatRangeField = models.Field
HStoreField = models.Field
IntegerRangeField = models.Field
JSONField = models.Field
15 changes: 15 additions & 0 deletions tests/postgres_tests/migrations/0002_create_test_models.py
Expand Up @@ -150,6 +150,19 @@ class Migration(migrations.Migration):
),
]

pg_94_operations = [
migrations.CreateModel(
name='JSONModel',
fields=[
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
('field', JSONField(null=True, blank=True)),
],
options={
},
bases=(models.Model,),
),
]

def apply(self, project_state, schema_editor, collect_sql=False):
try:
PG_VERSION = schema_editor.connection.pg_version
Expand All @@ -158,4 +171,6 @@ def apply(self, project_state, schema_editor, collect_sql=False):
else:
if PG_VERSION >= 90200:
self.operations = self.operations + self.pg_92_operations
if PG_VERSION >= 90400:
self.operations = self.operations + self.pg_94_operations
return super(Migration, self).apply(project_state, schema_editor, collect_sql)
14 changes: 12 additions & 2 deletions tests/postgres_tests/models.py
Expand Up @@ -2,7 +2,7 @@

from .fields import (
ArrayField, BigIntegerRangeField, DateRangeField, DateTimeRangeField,
FloatRangeField, HStoreField, IntegerRangeField,
FloatRangeField, HStoreField, IntegerRangeField, JSONField,
)


Expand Down Expand Up @@ -52,7 +52,7 @@ class TextFieldModel(models.Model):
field = models.TextField()


# Only create this model for databases which support it
# Only create this model for postgres >= 9.2
if connection.vendor == 'postgresql' and connection.pg_version >= 90200:
class RangesModel(PostgreSQLModel):
ints = IntegerRangeField(blank=True, null=True)
Expand All @@ -66,6 +66,16 @@ class RangesModel(object):
pass


# Only create this model for postgres >= 9.4
if connection.vendor == 'postgresql' and connection.pg_version >= 90400:
class JSONModel(models.Model):
field = JSONField(blank=True, null=True)
else:
# create an object with this name so we don't have failing imports
class JSONModel(object):
pass


class ArrayFieldSubclass(ArrayField):
def __init__(self, *args, **kwargs):
super(ArrayFieldSubclass, self).__init__(models.IntegerField())
Expand Down

0 comments on commit 33ea472

Please sign in to comment.