Skip to content

Commit b66d379

Browse files
authored
Merge pull request #20 from timheap/feature/serialization
Support Django serialization framework, and dumpdata/loaddata
2 parents 8849d76 + 4f368f6 commit b66d379

File tree

7 files changed

+171
-31
lines changed

7 files changed

+171
-31
lines changed

postgres_composite_types/__init__.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,10 +35,12 @@
3535
"""
3636

3737
import inspect
38+
import json
3839
import logging
3940
import sys
4041
from collections import OrderedDict
4142

43+
from django.core.exceptions import ValidationError
4244
from django.db import migrations, models
4345
from django.db.backends.postgresql.base import \
4446
DatabaseWrapper as PostgresDatabaseWrapper
@@ -111,6 +113,10 @@ class BaseField(models.Field):
111113

112114
Meta = None
113115

116+
default_error_messages = {
117+
'bad_json': "to_python() received a string that was not valid JSON",
118+
}
119+
114120
def db_type(self, connection):
115121
LOGGER.debug("db_type")
116122

@@ -132,6 +138,42 @@ def formfield(self, **kwargs): # pylint:disable=arguments-differ
132138

133139
return super().formfield(**defaults)
134140

141+
def to_python(self, value):
142+
"""
143+
Convert a value to the correct type for this field. Values from the
144+
database will already be of the correct type, due to the the caster
145+
registered with psycopg2. The field can also be serialized as a string
146+
via value_to_string, where it is encoded as a JSON object.
147+
"""
148+
# Composite types are serialized as JSON blobs. If BaseField.to_python
149+
# is called with a string, assume it was produced by value_to_string
150+
# and decode it
151+
if isinstance(value, str):
152+
try:
153+
value = json.loads(value)
154+
except ValueError:
155+
raise ValidationError(
156+
self.error_messages['bad_json'],
157+
code='bad_json',
158+
)
159+
return self.Meta.model(**{
160+
name: field.to_python(value.get(name))
161+
for name, field in self.Meta.fields
162+
})
163+
164+
return super().to_python(value)
165+
166+
def value_to_string(self, obj):
167+
"""
168+
Serialize this as a JSON object {name: field.value_to_string(...)} for
169+
each child field.
170+
"""
171+
value = self.value_from_object(obj)
172+
return json.dumps({
173+
name: field.value_to_string(value)
174+
for name, field in self.Meta.fields
175+
})
176+
135177

136178
class BaseOperation(migrations.operations.base.Operation):
137179
"""Base class for the DB operation that relates to this type."""

test_requirements.in

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
nose
22
-egit+https://github.com/django-nose/django-nose.git@master#egg=django-nose
3-
django_fake_model
43

54
flake8
65
pylint

tests/migrations/0001_initial.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
from django.db import migrations
77

8-
from ..base import (
8+
from ..models import (
99
Box,
1010
Card,
1111
DateRange,

tests/migrations/0002_models.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
# Generated by Django 2.0.2 on 2018-03-04 23:12
2+
3+
import django.contrib.postgres.fields
4+
from django.db import migrations, models
5+
6+
import tests.models
7+
8+
9+
class Migration(migrations.Migration):
10+
11+
initial = True
12+
13+
dependencies = [
14+
('tests', '0001_initial'),
15+
]
16+
17+
operations = [
18+
migrations.CreateModel(
19+
name='DescriptorModel',
20+
fields=[
21+
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
22+
('field', tests.models.DescriptorTypeField()),
23+
],
24+
),
25+
migrations.CreateModel(
26+
name='Hand',
27+
fields=[
28+
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
29+
('cards', django.contrib.postgres.fields.ArrayField(base_field=tests.models.CardField(), size=None)),
30+
],
31+
),
32+
migrations.CreateModel(
33+
name='Item',
34+
fields=[
35+
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
36+
('name', models.CharField(max_length=20)),
37+
('bounding_box', tests.models.BoxField()),
38+
],
39+
),
40+
migrations.CreateModel(
41+
name='NamedDateRange',
42+
fields=[
43+
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
44+
('name', models.TextField()),
45+
('date_range', tests.models.DateRangeField()),
46+
],
47+
),
48+
migrations.CreateModel(
49+
name='OptionalModel',
50+
fields=[
51+
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
52+
('optional_field', tests.models.OptionalBitsField(blank=True, null=True)),
53+
],
54+
),
55+
migrations.CreateModel(
56+
name='SimpleModel',
57+
fields=[
58+
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
59+
('test_field', tests.models.SimpleTypeField()),
60+
],
61+
),
62+
]

tests/base.py renamed to tests/models.py

Lines changed: 7 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,11 @@
11
"""Models and types for the tests"""
22
from django.contrib.postgres.fields.array import ArrayField
33
from django.db import models
4-
from django_fake_model.models import FakeModel
54

65
from postgres_composite_types import CompositeType
76

87
from .fields import TriplingIntegerField
98

10-
# pylint:disable=no-member
11-
129

1310
class SimpleType(CompositeType):
1411
"""A test type."""
@@ -21,14 +18,11 @@ class Meta:
2118
c = models.DateTimeField(verbose_name='A date')
2219

2320

24-
class SimpleModel(FakeModel):
21+
class SimpleModel(models.Model):
2522
"""A test model."""
2623
# pylint:disable=invalid-name
2724
test_field = SimpleType.Field()
2825

29-
class Meta:
30-
app_label = 'test'
31-
3226

3327
class OptionalBits(CompositeType):
3428
"""A type with an optional field"""
@@ -39,7 +33,7 @@ class Meta:
3933
db_type = 'optional_type'
4034

4135

42-
class OptionalModel(FakeModel):
36+
class OptionalModel(models.Model):
4337
"""A model with an optional composity type"""
4438
optional_field = OptionalBits.Field(null=True, blank=True)
4539

@@ -53,7 +47,7 @@ class Meta:
5347
rank = models.CharField(max_length=2)
5448

5549

56-
class Hand(FakeModel):
50+
class Hand(models.Model):
5751
"""A hand of cards."""
5852
cards = ArrayField(base_field=Card.Field())
5953

@@ -79,17 +73,19 @@ class Meta:
7973
@property
8074
def bottom_left(self):
8175
"""The bottom-left corner of the box."""
76+
# pylint:disable=no-member
8277
return Point(x=self.top_left.x,
8378
y=self.bottom_right.y)
8479

8580
@property
8681
def top_right(self):
8782
"""The top-right corner of the box."""
83+
# pylint:disable=no-member
8884
return Point(x=self.bottom_right.x,
8985
y=self.top_left.y)
9086

9187

92-
class Item(FakeModel):
88+
class Item(models.Model):
9389
"""An item that exists somewhere on a cartesian plane."""
9490
name = models.CharField(max_length=20)
9591
bounding_box = Box.Field()
@@ -104,7 +100,7 @@ class Meta:
104100
end = models.DateTimeField() # uses reserved keyword
105101

106102

107-
class NamedDateRange(FakeModel):
103+
class NamedDateRange(models.Model):
108104
"""A date-range with a name"""
109105
name = models.TextField()
110106
date_range = DateRange.Field()

tests/test_field.py

Lines changed: 55 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,21 @@
11
"""Tests for composite field."""
22

33
import datetime
4+
import json
45
from unittest import mock
56

7+
from django.core import serializers
8+
from django.core.exceptions import ValidationError
69
from django.db import connection
710
from django.db.migrations.executor import MigrationExecutor
811
from django.test import TestCase, TransactionTestCase
912
from psycopg2.extensions import adapt
1013

1114
from postgres_composite_types import composite_type_created
1215

13-
from .base import (
14-
DateRange, NamedDateRange, OptionalBits, OptionalModel, SimpleModel,
15-
SimpleType)
16+
from .models import (
17+
Box, DateRange, Item, NamedDateRange, OptionalBits, OptionalModel, Point,
18+
SimpleModel, SimpleType)
1619

1720

1821
class TestMigrations(TransactionTestCase):
@@ -91,8 +94,6 @@ def test_migration_quoting(self):
9194
self.assertTrue(self.does_type_exist(DateRange._meta.db_type))
9295

9396

94-
@SimpleModel.fake_me
95-
@NamedDateRange.fake_me
9697
class FieldTests(TestCase):
9798
"""Tests for composite field."""
9899

@@ -101,7 +102,7 @@ def test_field_save_and_load(self):
101102
# pylint:disable=invalid-name
102103
t = SimpleType(a=1, b="β ☃", c=datetime.datetime(1985, 10, 26, 9, 0))
103104
m = SimpleModel(test_field=t)
104-
m.save() # pylint:disable=no-member
105+
m.save()
105106

106107
# Retrieve from DB
107108
m = SimpleModel.objects.get(id=1)
@@ -150,8 +151,50 @@ def test_adapted_sql(self):
150151
b"(1, 'b', '1985-10-26T09:00:00'::timestamp)::test_type",
151152
adapted.getquoted())
152153

154+
def test_serialize(self):
155+
"""
156+
Check that composite values are correctly handled through Django's
157+
serialize/deserialize helpers, used for dumpdata/loaddata.
158+
"""
159+
old = Item(
160+
name="table",
161+
bounding_box=Box(top_left=Point(x=1, y=1),
162+
bottom_right=Point(x=4, y=2)))
163+
out = serializers.serialize("json", [old])
164+
new = next(serializers.deserialize("json", out)).object
165+
166+
self.assertEqual(old.bounding_box,
167+
new.bounding_box)
168+
169+
def test_to_python(self):
170+
"""
171+
Test the Field.to_python() method interprets strings as JSON data.
172+
"""
173+
start = datetime.datetime.now()
174+
end = datetime.datetime.now() + datetime.timedelta(days=1)
175+
176+
field = NamedDateRange._meta.get_field('date_range')
177+
out = field.to_python(json.dumps({
178+
"start": start.isoformat(),
179+
"end": end.isoformat(),
180+
}))
181+
182+
self.assertEqual(out, DateRange(start=start, end=end))
183+
184+
def test_to_python_bad_json(self):
185+
"""
186+
Test the Field.to_python() handles bad JSON data by raising
187+
a ValidationError
188+
"""
189+
field = NamedDateRange._meta.get_field('date_range')
190+
191+
with self.assertRaises(ValidationError) as context:
192+
field.to_python("bogus JSON")
193+
194+
exception = context.exception
195+
self.assertEqual(exception.code, 'bad_json')
196+
153197

154-
@OptionalModel.fake_me
155198
class TestOptionalFields(TestCase):
156199
"""
157200
Test optional composite type fields, and optional fields on composite types
@@ -160,18 +203,18 @@ class TestOptionalFields(TestCase):
160203
def test_null_field_save_and_load(self):
161204
"""Save and load a null composite field"""
162205
model = OptionalModel(optional_field=None)
163-
model.save() # pylint:disable=no-member
206+
model.save()
164207

165-
model = OptionalModel.objects.get(id=1)
208+
model = OptionalModel.objects.get()
166209
self.assertIsNone(model.optional_field)
167210

168211
def test_null_subfield_save_and_load(self):
169212
"""Save and load a null composite field"""
170213
model = OptionalModel(optional_field=OptionalBits(
171214
required='foo', optional=None))
172-
model.save() # pylint:disable=no-member
215+
model.save()
173216

174-
model = OptionalModel.objects.get(id=1)
217+
model = OptionalModel.objects.get()
175218
self.assertIsNotNone(model.optional_field)
176219
self.assertEqual(model.optional_field, OptionalBits(
177220
required='foo', optional=None))
@@ -183,7 +226,7 @@ def test_all_filled(self):
183226
"""
184227
model = OptionalModel(optional_field=OptionalBits(
185228
required='foo', optional='bar'))
186-
model.save() # pylint:disable=no-member
229+
model.save()
187230

188231
model = OptionalModel.objects.get(id=1)
189232
self.assertIsNotNone(model.optional_field)

tests/test_more.py

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,10 @@
55
from django.db.migrations.writer import MigrationWriter
66
from django.test import TestCase
77

8-
from .base import (
8+
from .models import (
99
Box, Card, DescriptorModel, DescriptorType, Hand, Item, Point)
1010

1111

12-
@Hand.fake_me
1312
class TestArrayFields(TestCase):
1413
"""
1514
Test ArrayFields combined with CompositeType.Fields
@@ -27,7 +26,7 @@ def test_saving_and_loading_array_field(self):
2726
Card('♡', 'J'),
2827
Card('♡', '10'),
2928
])
30-
hand.save() # pylint:disable=no-member
29+
hand.save()
3130

3231
hand = Hand.objects.get()
3332
self.assertEqual(hand.cards, [
@@ -49,7 +48,7 @@ def test_querying_array_field_contains(self):
4948
Card('♡', 'J'),
5049
Card('♡', '10'),
5150
])
52-
hand.save() # pylint:disable=no-member
51+
hand.save()
5352

5453
queen_of_hearts = Card('♡', 'Q')
5554
jack_of_spades = Card('♠', 'J')
@@ -71,7 +70,6 @@ def test_generate_migrations(self):
7170
self.assertIn(expected_deconstruction, text)
7271

7372

74-
@Item.fake_me
7573
class TestNestedCompositeTypes(TestCase):
7674
"""
7775
Test CompositeTypes within CompositeTypes
@@ -84,7 +82,7 @@ def test_saving_and_loading_nested_composite_types(self):
8482
item = Item(name="table",
8583
bounding_box=Box(top_left=Point(x=1, y=1),
8684
bottom_right=Point(x=4, y=2)))
87-
item.save() # pylint:disable=no-member
85+
item.save()
8886

8987
item = Item.objects.get()
9088
self.assertEqual(item.name, "table")

0 commit comments

Comments
 (0)