Skip to content

Commit 77cc4b0

Browse files
authored
EmbeddedReference base class + SQLAEmbeddedReference (#28)
1 parent b9d0c64 commit 77cc4b0

File tree

9 files changed

+306
-74
lines changed

9 files changed

+306
-74
lines changed

.gitignore

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ venv
66
# Packages
77
*.egg
88
*.egg-info
9+
.eggs
910
dist
1011
build
1112
eggs
@@ -19,9 +20,10 @@ develop-eggs
1920
# Installer logs
2021
pip-log.txt
2122

22-
# Unit test / coverage reports
23+
# Unit test / coverage reports and cache
2324
.coverage
2425
.tox
26+
.pytest_cache/
2527

2628
#Translations
2729
*.mo

cleancat/__init__.py

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
from cleancat.base import (
2-
Bool, Choices, DateTime, Dict, Email, Embedded, Enum, Field, Integer,
3-
List, Regex, RelaxedURL, Schema, SortedSet, StopValidation, String,
4-
TrimmedString, URL, ValidationError
2+
Bool, Choices, DateTime, Dict, Email, Embedded, EmbeddedReference, Enum,
3+
Field, Integer, List, Regex, RelaxedURL, Schema, SortedSet, StopValidation,
4+
String, TrimmedString, URL, ValidationError
55
)
66

77
__all__ = [
8-
'Bool', 'Choices', 'DateTime', 'Dict', 'Email', 'Embedded', 'Enum',
9-
'Field', 'Integer', 'List', 'Regex', 'RelaxedURL', 'Schema', 'SortedSet',
10-
'StopValidation', 'String', 'TrimmedString', 'URL', 'ValidationError'
8+
'Bool', 'Choices', 'DateTime', 'Dict', 'Email', 'Embedded',
9+
'EmbeddedReference', 'Enum', 'Field', 'Integer', 'List', 'Regex',
10+
'RelaxedURL', 'Schema', 'SortedSet', 'StopValidation', 'String',
11+
'TrimmedString', 'URL', 'ValidationError'
1112
]

cleancat/base.py

Lines changed: 97 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@
77

88

99
if sys.version_info[0] == 3:
10-
basestring = str
10+
str_type = str
11+
else:
12+
str_type = basestring
1113

1214

1315
class ValidationError(Exception):
@@ -67,7 +69,7 @@ def serialize(self, value):
6769

6870

6971
class String(Field):
70-
base_type = basestring
72+
base_type = str_type
7173
blank_value = ''
7274
min_length = None
7375
max_length = None
@@ -96,7 +98,7 @@ def has_value(self, value):
9698

9799

98100
class TrimmedString(String):
99-
base_type = basestring
101+
base_type = str_type
100102
blank_value = ''
101103

102104
def clean(self, value):
@@ -184,7 +186,7 @@ class Email(Regex):
184186

185187
def clean(self, value):
186188
# trim any leading/trailing whitespace before validating the email
187-
if isinstance(value, basestring):
189+
if isinstance(value, str_type):
188190
value = value.strip()
189191
return super(Email, self).clean(value)
190192

@@ -345,6 +347,96 @@ def serialize(self, value):
345347
return self.schema_class(data=value).serialize()
346348

347349

350+
class ReferenceNotFoundError(Exception):
351+
"""Exception to be raised when a referenced object isn't found."""
352+
353+
354+
class EmbeddedReference(Dict):
355+
"""Represents an object which can be referenced by its ID.
356+
357+
This field allows one to submit a dict of values which will then create
358+
a new instance of the object, or it will update fields of an existing
359+
object if a field representing its ID (called `pk_field`) was included
360+
in the submitted dict.
361+
"""
362+
363+
def __init__(self, object_class, schema_class, pk_field='id', **kwargs):
364+
self.object_class = object_class
365+
self.schema_class = schema_class
366+
self.pk_field = pk_field
367+
super(EmbeddedReference, self).__init__(schema_class, **kwargs)
368+
369+
def clean(self, value):
370+
# Clean the dict first.
371+
value = super(EmbeddedReference, self).clean(value)
372+
373+
# Then, depending on whether `pk_field` is in the dict of submitted
374+
# values or not, update an existing object or create a new one.
375+
if value and self.pk_field in value:
376+
return self.clean_existing(value)
377+
return self.clean_new(value)
378+
379+
def serialize(self, obj):
380+
obj_data = self.get_orig_data_from_existing(obj)
381+
serialized = self.schema_class(data=obj_data).serialize()
382+
serialized[self.pk_field] = getattr(obj, self.pk_field)
383+
return serialized
384+
385+
def clean_new(self, value):
386+
"""Return a new object instantiated with cleaned data."""
387+
value = self.schema_class(value).full_clean()
388+
return self.object_class(**value)
389+
390+
def clean_existing(self, value):
391+
"""Clean the data and return an existing document with its fields
392+
updated based on the cleaned values.
393+
"""
394+
existing_pk = value[self.pk_field]
395+
try:
396+
obj = self.fetch_existing(existing_pk)
397+
except ReferenceNotFoundError:
398+
raise ValidationError('Object does not exist.')
399+
orig_data = self.get_orig_data_from_existing(obj)
400+
401+
# Clean the data (passing the new data dict and the original data to
402+
# the schema).
403+
value = self.schema_class(value, orig_data).full_clean()
404+
405+
# Set cleaned data on the object (except for the pk_field).
406+
for field_name, field_value in value.items():
407+
if field_name != self.pk_field:
408+
setattr(obj, field_name, field_value)
409+
410+
return obj
411+
412+
def fetch_existing(self, pk):
413+
"""Fetch an existing object that corresponds to a given ID.
414+
415+
This needs to be subclassed since, depending on the object class,
416+
the fetching mechanism might be different. See implementations of
417+
SQLAEmbeddedResource and MongoEmbeddedResource for a concrete example
418+
of fetching objects from a relational and non-relational database.
419+
420+
:param str pk: ID of the object that's supposed to exist.
421+
:returns: an instance of the object class.
422+
:raises: ReferenceNotFoundError if the object doesn't exist.
423+
"""
424+
raise NotImplementedError # should be subclassed
425+
426+
def get_orig_data_from_existing(self, obj):
427+
"""Return a dictionary of field names and values for a given object.
428+
429+
The values in the dictionary should be in their "cleaned" state (as
430+
in, exactly as they were set on the object, without any serialization).
431+
432+
:param object obj: existing object for which new data is currently
433+
being cleaned.
434+
:returns: dict of fields and values that are currently set on the
435+
object (before the new cleaned data is applied).
436+
"""
437+
raise NotImplementedError # should be subclassed
438+
439+
348440
class Choices(Field):
349441
"""
350442
A field that accepts the given choices.
@@ -366,7 +458,7 @@ def clean(self, value):
366458
if self.case_insensitive:
367459
choices = {choice.lower(): choice for choice in choices}
368460

369-
if not isinstance(value, basestring):
461+
if not isinstance(value, str_type):
370462
raise ValidationError(u'Value needs to be a string.')
371463

372464
if value.lower() not in choices:

cleancat/mongo.py

Lines changed: 22 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,10 @@
66

77
from mongoengine import ValidationError as MongoValidationError
88

9-
from .base import Dict, Embedded, Field, ValidationError
9+
from .base import (
10+
Embedded, EmbeddedReference, Field, ReferenceNotFoundError,
11+
ValidationError, str_type
12+
)
1013

1114

1215
class MongoEmbedded(Embedded):
@@ -26,71 +29,34 @@ def clean(self, value):
2629
return self.document_class(**value)
2730

2831

29-
class MongoEmbeddedReference(MongoEmbedded):
32+
class MongoEmbeddedReference(EmbeddedReference):
3033
"""
31-
Represents a MongoEngine document, which can be created or updated based
32-
on a provided dict of values.
33-
34-
The name of the field that acts as the primary key of the document can be
35-
specified using pk_field (it's 'id' by default). If the passed document
36-
contains the pk_field, we check if a document with that PK exists and then
37-
we update its fields (or fail if it can't be found). If the input dict
38-
does not contain the pk_field, it is assumed that a new document should be
39-
created.
40-
41-
Examples:
42-
{'id': 'existing_id', 'foo': 'bar'} -> valid (updates an existing document)
43-
{'id': 'non-existing_id', 'foo': 'bar'} -> invalid
44-
{'foo': 'bar'} -> valid (creates a new document)
45-
"""
46-
47-
def __init__(self, *args, **kwargs):
48-
self.pk_field = kwargs.pop('pk_field', 'id')
49-
super(MongoEmbeddedReference, self).__init__(*args, **kwargs)
34+
Represents an embedded reference where the object class is a MongoEngine
35+
document.
5036
51-
def clean(self, value):
52-
value = Dict.clean(self, value)
53-
if value and self.pk_field in value:
54-
return self.clean_existing(value)
55-
return self.clean_new(value)
56-
57-
def clean_new(self, value):
58-
"""Return a new document instantiated with cleaned data."""
59-
value = self.schema_class(value).full_clean()
60-
return self.document_class(**value)
37+
Examples of passed data and how it's handled:
38+
{'id': 'existing_id', 'foo': 'bar'} -> updates an existing document
39+
{'id': 'non-existing_id', 'foo': 'bar'} -> fails bcos the PK is not valid
40+
{'foo': 'bar'} -> creates a new document instance
41+
"""
6142

62-
def clean_existing(self, value):
63-
"""Clean the data and return an existing document with its fields
64-
updated based on the cleaned values.
65-
"""
66-
existing_pk = value[self.pk_field]
43+
def fetch_existing(self, pk):
44+
doc_cls = self.object_class
6745
try:
68-
document = self.document_class.objects.get(pk=existing_pk)
69-
except self.document_class.DoesNotExist:
70-
raise ValidationError('Object does not exist.')
46+
return doc_cls.objects.get(pk=pk)
47+
except doc_cls.DoesNotExist:
48+
raise ReferenceNotFoundError
7149
except MongoValidationError as e:
7250
raise ValidationError(str(e))
7351

52+
def get_orig_data_from_existing(self, obj):
7453
# Get a dict of existing document's field names and values.
75-
if hasattr(document, 'to_dict'):
54+
if hasattr(obj, 'to_dict'):
7655
# MongoMallard
77-
document_data = document.to_dict()
56+
return obj.to_dict()
7857
else:
7958
# Upstream MongoEngine
80-
document_data = dict(document._data)
81-
if None in document_data:
82-
del document_data[None]
83-
84-
# Clean the data (passing the new data dict and the original data to
85-
# the schema).
86-
value = self.schema_class(value, document_data).full_clean()
87-
88-
# Set cleaned data on the document (except for the pk_field).
89-
for field_name, field_value in value.items():
90-
if field_name != self.pk_field:
91-
setattr(document, field_name, field_value)
92-
93-
return document
59+
return dict(obj._data)
9460

9561

9662
class MongoReference(Field):
@@ -99,7 +65,7 @@ class MongoReference(Field):
9965
Example document: ReferenceField(Doc)
10066
"""
10167

102-
base_type = basestring
68+
base_type = str_type
10369

10470
def __init__(self, document_class=None, **kwargs):
10571
self.document_class = document_class

cleancat/sqla.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
"""
2+
This module contains CleanCat fields specific to SQLAlchemy. SQLA is not
3+
a required dependency, hence to use these fields, you'll have to import
4+
them via `from cleancat.sqla import ...`.
5+
"""
6+
7+
from sqlalchemy import inspect
8+
9+
from .base import EmbeddedReference, ReferenceNotFoundError
10+
11+
12+
def object_as_dict(obj):
13+
"""Turn an SQLAlchemy model into a dict of field names and values.
14+
15+
Based on https://stackoverflow.com/a/37350445/1579058
16+
"""
17+
return {c.key: getattr(obj, c.key)
18+
for c in inspect(obj).mapper.column_attrs}
19+
20+
21+
class SQLAEmbeddedReference(EmbeddedReference):
22+
"""
23+
Represents an embedded reference where the object class is an SQLAlchemy
24+
model.
25+
26+
Examples of passed data and how it's handled:
27+
{'id': 'existing_id', 'foo': 'bar'} -> updates an existing model
28+
{'id': 'non-existing_id', 'foo': 'bar'} -> fails bcos the ID is not valid
29+
{'foo': 'bar'} -> creates a new model instance
30+
"""
31+
32+
def fetch_existing(self, pk):
33+
model_cls = self.object_class
34+
model = model_cls.query.get(pk)
35+
if not model:
36+
raise ReferenceNotFoundError
37+
return model
38+
39+
def get_orig_data_from_existing(self, obj):
40+
return object_as_dict(obj)

requirements.txt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
11
flake8
2+
mongoengine
23
python-dateutil
4+
pytest
35
pytz
6+
sqlalchemy

setup.cfg

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,10 @@
1+
[aliases]
2+
test=pytest
3+
4+
[tool:pytest]
5+
python_files=tests/*.py
6+
testpaths=tests
7+
18
[flake8]
29
ignore=E501
310
exclude=venv,.tox,.eggs,build

setup.py

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,14 @@
11
from setuptools import setup
22

3-
test_requirements = [
4-
'nose',
3+
install_requirements = [
4+
'python-dateutil',
5+
'pytz',
6+
]
7+
test_requirements = install_requirements + [
8+
'pytest',
59
'coverage',
10+
'mongoengine',
11+
'sqlalchemy'
612
]
713

814
setup(
@@ -19,12 +25,11 @@
1925
packages=[
2026
'cleancat',
2127
],
22-
test_suite='nose.collector',
2328
zip_safe=False,
2429
platforms='any',
25-
install_requires=[
26-
'python-dateutil',
27-
],
30+
install_requires=install_requirements,
31+
setup_requires=['pytest-runner'],
32+
test_suite='tests',
2833
tests_require=test_requirements,
2934
extras_require={'test': test_requirements},
3035
classifiers=[

0 commit comments

Comments
 (0)