31 марта 2010 г.

И опять про PickleField

Давным-давно я написал более менее работающий пример PickleField'а - поля для хранения любого питоновского объекта, поддерживающего сериализацию при помощи pickle или cPickle.

И все бы неплохо, только на днях мне понадобилось хранить в этих PickleField'ах Django'вские модели, например экземпляры пользователей (auth.User) или проектов (самописная модель). Да, возможно, это не совсем верно, но задача стояла именно такая.

Не надеясь получить какой-то подвох я стал просто сохранять эти instance, и вроде бы все работало, пока я не написал смехотворный тест:

project = choice(Project.objects.all())
user = choice(project.users.all())

PickleModel.objects.create(object_field=project)
obj = PickleModel.objects.get(object_field=project)
obj.object_field = user
obj.save()

obj = PickleModel.objects.get(object_field=user)
obj.delete()

гдe PickleModel простая тестовая модель:

class PickleModel(models.Model):

    object_field = PickleField()

И каково было мое удивление, когда тест завершился ошибкой PickleModel.DoesNotExist на 9 строке вместо чистого прохождения. Интересно, сказал я и полез в код, смотреть почему PickleField при чистом сохранении (PickleModel.objects.create) правильно сохраняет в базу весь инстанс проекта, а при обновлении тестового объекта (obj.object_field = ...) сохраняет что-то не то и не так.

Сначала думал, что ошибка где-то в PickleField, на секунду даже задумался о использовании стороннего решения. Но потом понял, что сам PickleField не причем, и что при обновлении тестового объекта не вызывается PickleField.get_db_prep_save. Интересно, решил я и пошел смотреть на то, как именно проходит процесс обновления объекта в django.db.models.base.Models.save_base, а оттуда в django.db.models.base.sql.UpdateQuery.add_update_fields, где собственно и происходит процесс генерирования sql-запроса для обновления. Там я и нашел причину такого поведения Django.

Причина оказалась весьма логична c одной стороны. Django для всех своих инстансов вызывает prepare_database_save, чтобы хранить не инстанс, а его первичный ключ в базе данных. С другой стороны осталось не до конца понятно, почему такого же поведения нет при создании объектов. Впрочем, как именно и где происходит вызов prepare_database_save для инстансов моделей при создании нового объекта я не проверял, а начал думать как обойти эту особенность Django.

Для начала решил по манки-патчить по-тупому, добавив в PickleField.to_python:

        if hasattr(value, 'prepare_database_save'):
            delattr(value, 'prepare_database_save')

на что Питон сказал мне: Are you crazy, man? И добавил AttributeError: prepare_database_save. Тогда я решил по манки-патчить более умно, в два захода :) Для начала подменил поведение prepare_database_save в том же PickleField.to_python:

        if hasattr(value, 'prepare_database_save'):
            value.prepare_database_save = \
                lambda unused: PickleField().get_db_prep_save(value)

а затем, в PickleField.get_db_prep_value возвращал объекту первичное состояния, чтобы его возможно было сериализовать:

        if hasattr(value, 'prepare_database_save') and \
           isinstance(getattr(value, 'prepare_database_save'),
                      types.LambdaType):
            delattr(value, 'prepare_database_save')

После всего этого, запустил исходный тест, порадовался чистому проходу и решил написать вот этот малость бессмысленный псот :)

Ах да, сам код PickleField доступен как всегда в kikola.db.fields, ну или отдельным гистом.

И еще одна забавная возможность PickleField'a. Благодаря модифицированию стандартного поведения get_default вы можете указывать любой питоновский объект, как дефолтное значение для этого поля. Например:

class PickleModel(models.Model):

    dict_field = PickleField(default={'a': 1, 'b': 2, 'c': 3})
    list_field = PickleField(default=[1, 2, 3])
    tuple_field = PickleField(default=(1, 2, 3))

Весьма полезная для меня возможность, подсмотренная в issues к django-extensions.

Апдейт

Ха, недолго музыка играла для моего предыдущего решения. И я тому весьма рад ибо оно было уж слишком уродливым. Вообщем, суть проблемы прошлого решения в следующем неработающем тесте:

project = choice(Project.objects.all())

obj = PickleModel.objects.create(object_field=project)
obj.save()

obj = PickleModel.objects.get(object_field=project)

а именно в том, что если поле object_field осталось нетронутым при изменении модели, по при вызове save все-таки вызовется метод prepare_database_save и конвертирует модель в строку - первичный ключ.

Но не беда, манки-патчим django.db.models.Model.prepare_database_save и получаем работающий тест и код:

# Make able to store Django model objects in ``PickleField``
def picklefield(func):
    def wrapper(obj, field):
        if isinstance(field, PickleField):
            return field.get_db_prep_save(obj)
        return func(obj, field)
    return wrapper


models.Model.prepare_database_save = \
    picklefield(models.Model.prepare_database_save)

Хотя, по правде говоря, и это решение мне не очень нравится. Dirty hack, не иначе.

blog comments powered by Disqus