Skip to content

Commit

Permalink
Converters and improvements. (#11)
Browse files Browse the repository at this point in the history
* Simple dependencies creation.

* Remove extra converter and pytz dependency.

* Move field converters to separate file.

* Improve test coverage.

* Allow registering custom field converters.

* Update object_manager.py
  • Loading branch information
K0Te committed Oct 10, 2018
1 parent e3c5f4c commit cd6a785
Show file tree
Hide file tree
Showing 6 changed files with 160 additions and 102 deletions.
1 change: 1 addition & 0 deletions django_object_manager/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@
from .object_manager import ObjectManager, ObjManagerMixin

del object_manager
del field_converters
103 changes: 103 additions & 0 deletions django_object_manager/field_converters.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
"""Field converters module, converter determines how field is created."""

from functools import partial
from collections import namedtuple

from django.db.models import (
ForeignKey,
ManyToManyRel,
ManyToManyField,
OneToOneRel,
)

__all__ = ('default_converters',)

FieldConverterResult = namedtuple('FieldConverterResult',
'field_value post_actions pass_field_value')


def create_foreign_key(object_manager, field, value):
foreign_model = field.remote_field.model
name = foreign_model.__name__.lower()
post_actions = []
pass_field_value = True
if isinstance(value, foreign_model):
# FK value is already initialized, nothing to do here
value = value
else:
assert isinstance(value, str), \
'Related values must be either instances or str ids'
# TODO Use type to select related model, name can be misleading !
value = object_manager._get_or_create(
name,
value,
**object_manager._data[name][value])
return FieldConverterResult(value, post_actions, pass_field_value)

# TODO M2M are similar, probably they can be combined
def create_m2m_reverse(object_manager, field, values):
def cb(field, field_val, instance):
args = \
{instance._meta.model.__name__.lower(): instance,
field.related_model.__name__.lower(): field_val}
# Create dependency after main object, using
# M2M "through" model
field.through(**args).save()
return FieldConverterResult(
[],
[partial(cb, field, related_val) for related_val in values],
False)


def create_m2m_forward(self, field, values):

def cb(field, field_val, instance):
field_val.save()
# Delay forward M2M dependency,
# use RelatedManager helper
# TODO no related manager if `through` model has extra attributes ???
getattr(instance, field.name).add(field_val)
foreing_model = field.related_model
name = foreing_model.__name__.lower()

def gen():
for value in values:
if isinstance(value, foreing_model):
yield value
else:
assert isinstance(value, str), \
'Related values must be either instances or str ids'
yield self._get_or_create(name,
value,
**self._data[name][value])
res = list(gen())
return FieldConverterResult(
res,
[partial(cb, field, related_val) for related_val in res],
False)


def create_one2one(object_manager, field, value):
foreing_model = field.related_model
name = foreing_model.__name__.lower()
assert isinstance(value, str)
assert value not in object_manager._instances[name]
# DB record will be created during "main" model creation
def cb(field_val, instance):
setattr(field_val,
instance._meta.model.__name__.lower(),
instance)
# Delay 1-to-1 dependency object creation
field_val.save()
return FieldConverterResult(
object_manager._get_or_create(name, value, _create_in_db=False,
**object_manager._data[name][value]),
[partial(cb, value)],
True)


default_converters = {ForeignKey: create_foreign_key,
ManyToManyRel: create_m2m_reverse,
ManyToManyField: create_m2m_forward,
OneToOneRel: create_one2one,
}
113 changes: 16 additions & 97 deletions django_object_manager/object_manager.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,7 @@
from collections import namedtuple, defaultdict
from datetime import datetime
from functools import partial
from copy import copy

from django.db.models import (
ForeignKey,
ManyToManyRel,
DateTimeField,
OneToOneRel,
ManyToManyField)
from pytz import utc
from .field_converters import default_converters


class ContextCallable:
Expand Down Expand Up @@ -36,11 +29,7 @@ class ObjectManager:
def __init__(self):
"""Initialize object creator."""
self._instances = defaultdict(dict)
self._converters = {ForeignKey: self._create_foreing,
ManyToManyRel: self._create_m2m_rel,
ManyToManyField: self._create_m2m_field,
DateTimeField: self._parse_datetime,
OneToOneRel: self._make1to1}
self._converters = copy(default_converters)

@classmethod
def register(cls, model, data):
Expand All @@ -49,6 +38,11 @@ def register(cls, model, data):
cls._data[name] = data
cls._registered_models[name] = model

@classmethod
def register_converter(cls, field_type, converter):
"""Register new converter."""
cls._converters[field_type] = converter

def __getattribute__(self, item):
"""Set context for object creation."""
if not item.startswith('get_'):
Expand Down Expand Up @@ -98,20 +92,21 @@ def _get(self, _name, _key):
return None

def _create_dependencies(self, model, params):
cbs = []
post_actions = []
for field in model._meta.get_fields():
if field.name in params:
if params[field.name] is None:
continue
for (converter_type, converter) in self._converters.items():
if isinstance(field, converter_type):
# TODO use namedtuple here ?
params[field.name], cbs, pop_params = converter(field,
params[field.name])
if pop_params:
result = converter(self, field, params[field.name])
post_actions.extend(result.post_actions)
if not result.pass_field_value:
params.pop(field.name)

return cbs
else:
params[field.name] = result.field_value
break
return post_actions

def _get_or_create(self, _name, _key, _create_in_db=True, _custom=False,
**kwargs):
Expand All @@ -131,82 +126,6 @@ def _get_or_create(self, _name, _key, _create_in_db=True, _custom=False,
self._instances[_name][_key] = instance
return instance

def _create_foreing(self, field, value):
foreing_model = field.remote_field.model
name = foreing_model.__name__.lower()
if isinstance(value, foreing_model):
return value, [], False
else:
assert isinstance(value, str), \
'Related values must be either instances or str ids'
# TODO Use type to select related model, name can be misleading !
return self._get_or_create(name,
value,
**self._data[name][value]), [], False

# TODO M2M are similar, probably they can be combined
def _create_m2m_rel(self, field, values):
def cb(field, field_val, instance):
args = \
{instance._meta.model.__name__.lower(): instance,
field.related_model.__name__.lower(): field_val}
# Create dependency after main object, using
# M2M "through" model
field.through(**args).save()
foreing_model = field.related_model
name = foreing_model.__name__.lower()
def gen():
for value in values:
if isinstance(value, foreing_model):
yield value
else:
assert isinstance(value, str), \
'Related values must be either instances or str ids'
yield self._get_or_create(name,
value,
**self._data[name][value])
return gen(), [partial(cb, field, related_val) for related_val in values], True

def _create_m2m_field(self, field, values):
def cb(field, field_val, instance):
field_val.save()
# Delay forward M2M dependency,
# use RelatedManager helper
# TODO no related manager if `through` model has extra attributes ???
getattr(instance, field.name).add(field_val)
foreing_model = field.related_model
name = foreing_model.__name__.lower()
def gen():
for value in values:
if isinstance(value, foreing_model):
yield value
else:
assert isinstance(value, str), \
'Related values must be either instances or str ids'
yield self._get_or_create(name,
value,
**self._data[name][value])
res = list(gen())
return res, [partial(cb, field, related_val) for related_val in res], True

def _make1to1(self, field, value):
foreing_model = field.related_model
name = foreing_model.__name__.lower()
assert isinstance(value, str)
assert value not in self._instances[name]
# DB record will be created during "main" model creation
def cb(field_val, instance):
setattr(field_val,
instance._meta.model.__name__.lower(),
instance)
# Delay 1-to-1 dependency object creation
field_val.save()
return self._get_or_create(name, value, _create_in_db=False,
**self._data[name][value]), [partial(cb, value)], [], False

def _parse_datetime(self, _field, value):
return datetime.strptime(value, '%b %d %Y').replace(tzinfo=utc), [], False


class ObjManagerMixin:
"""Mixin for easy test object creation."""
Expand Down
8 changes: 8 additions & 0 deletions tests/app/models.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,18 @@
from django.db import models
from django.db.models import SET_NULL


class User(models.Model):
id = models.AutoField('Identifier', primary_key=True)
name = models.CharField('Name', max_length=70)
email = models.EmailField()
extra_info = models.OneToOneField('UserExtraInfo',
null=True,
on_delete=SET_NULL)


class UserExtraInfo(models.Model):
address = models.CharField('Address', max_length=128)


class FilmCategory(models.Model):
Expand Down
16 changes: 13 additions & 3 deletions tests/app/tests.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""Tests helpers for application."""

from django_object_manager.object_manager import ObjectManager
from .models import User, Film, FilmCategory
from .models import User, Film, FilmCategory, UserExtraInfo

ObjectManager.register(
User,
Expand Down Expand Up @@ -29,7 +29,17 @@
},
'anime': {
'name': 'Anime',
'parent': 'serious',
'parent_category': 'serious',
},
})
ObjectManager.register(Film, {})
ObjectManager.register(
Film,
{
})
ObjectManager.register(
UserExtraInfo,
{
'extra_info_1': {
'address': 'NY'
}
})
21 changes: 19 additions & 2 deletions tests/functional/test_object_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,9 +75,11 @@ def test_many_to_many_reversed_predefined(self):
film2 = self.object_manager.get_film(name='The Godfather',
year=1974,
uploaded_by='bob')
self.object_manager.get_filmcategory('crime',
films=[film1, film2])
category = self.object_manager.get_filmcategory('crime',
films=[film1, film2])
self.assertEqual(models.FilmCategory.objects.count(), 1)
assert film1 in category.films.all()
assert film2 in category.films.all()

def test_many_to_many_forward_predefined(self):
"""Ensure that object with M2M relation can be created."""
Expand All @@ -101,3 +103,18 @@ def test_customized(self):
user_1 = self.object_manager.get_user('bob', email='bob@bob.com')
user_2 = self.object_manager.get_user('bob', email='bob@bob.com')
self.assertTrue(user_1 is not user_2)

def test_multiple_ending_in_ies(self):
"""Ensure that model ending in 'y'->'ies' is supported."""
self.object_manager.get_filmcategories()

def test_overwrite_param_with_none(self):
"""Ensure that parameter can be overwritten with None."""
anime = self.object_manager.get_filmcategory('anime',
parent_category=None)
assert anime.parent_category is None

def test_one2one(self):
"""Ensure that one2one field can be created."""
bob = self.object_manager.get_user('bob', extra_info='extra_info_1')
assert bob.extra_info.address == 'NY'

0 comments on commit cd6a785

Please sign in to comment.