Django克隆递归对象

时间:2020-05-04 03:05:54

标签: python django django-models

以前,当我要递归克隆对象时遇到问题。 我知道克隆对象的简单方法是这样的:

obj = Foo.objects.get(pk=<some_existing_pk>)
obj.pk = None
obj.save()

但是,我想做得更多。例如,我有一个models.py

class Post(TimeStampedModel):
    author = models.ForeignKey(User, related_name='posts',
                               on_delete=models.CASCADE)
    title = models.CharField(_('Title'), max_length=200)
    content = models.TextField(_('Content'))

    ...


class Comment(TimeStampedModel):
    author = models.ForeignKey(User, related_name='comments',
                               on_delete=models.CASCADE)
    post = models.ForeignKey(Post, on_delete=models.CASCADE)
    comment = models.TextField(_('Comment'))

    ...


class CommentAttribute(TimeStampedModel):
    comment = models.OneToOneField(Comment, related_name='comment_attribute',
                                   on_delete=models.CASCADE)
    is_bookmark = models.BooleanField(default=False)

    ...


class PostComment(TimeStampedModel):
    post = models.ForeignKey(Post, related_name='post_comments',
                             on_delete=models.CASCADE)
    comments = models.ManyToManyField(Comment)

    ...

当我从Post克隆父对象时,像CommentCommentAttributePostComment这样的子对象也将通过跟随新克隆的Post对象来克隆。 子模型是 动态 。因此,我想通过创建对象克隆器之类的工具使其变得简单。

下面的代码段是我所做的;

from django.db.utils import IntegrityError


class ObjectCloner(object):
    """
    [1]. The simple way with global configuration:
    >>> cloner = ObjectCloner()
    >>> cloner.set_objects = [obj1, obj2]   # or can be queryset
    >>> cloner.include_childs = True
    >>> cloner.max_clones = 1
    >>> cloner.execute()

    [2]. Clone the objects with custom configuration per-each objects.
    >>> cloner = ObjectCloner()
    >>> cloner.set_objects = [
        {
            'object': obj1,
            'include_childs': True,
            'max_clones': 2
        },
        {
            'object': obj2,
            'include_childs': False,
            'max_clones': 1
        }
    ]
    >>> cloner.execute()
    """
    set_objects = []            # list/queryset of objects to clone.
    include_childs = True       # include all their childs or not.
    max_clones = 1              # maximum clone per-objects.

    def clone_object(self, object):
        """
        function to clone the object.
        :param `object` is an object to clone, e.g: <Post: object(1)>
        :return new object.
        """
        try:
            object.pk = None
            object.save()
            return object
        except IntegrityError:
            return None

    def clone_childs(self, object):
        """
        function to clone all childs of current `object`.
        :param `object` is a cloned parent object, e.g: <Post: object(1)>
        :return
        """
        # bypass the none object.
        if object is None:
            return

        # find the related objects contains with this current object.
        # e.g: (<ManyToOneRel: app.comment>,)
        related_objects = object._meta.related_objects

        if len(related_objects) > 0:
            for relation in related_objects:
                # find the related field name in the child object, e.g: 'post'
                remote_field_name = relation.remote_field.name

                # find all childs who have the same parent.
                # e.g: childs = Comment.objects.filter(post=object)
                childs = relation.related_model.objects.all()

                for old_child in childs:
                    new_child = self.clone_object(old_child)

                    if new_child is not None:
                        # FIXME: When the child field as M2M field, we gote this error.
                        # "TypeError: Direct assignment to the forward side of a many-to-many set is prohibited. Use comments.set() instead."
                        # how can I clone that M2M values?
                        setattr(new_child, remote_field_name, object)
                        new_child.save()

                    self.clone_childs(new_child)
        return

    def execute(self):
        include_childs = self.include_childs
        max_clones = self.max_clones
        new_objects = []

        for old_object in self.set_objects:
            # custom per-each objects by using dict {}.
            if isinstance(old_object, dict):
                include_childs = old_object.get('include_childs', True)
                max_clones = old_object.get('max_clones', 1)
                old_object = old_object.get('object')  # assigned as object or None.

            for _ in range(max_clones):
                new_object = self.clone_object(old_object)
                if new_object is not None:
                    if include_childs:
                        self.clone_childs(new_object)
                    new_objects.append(new_object)

        return new_objects

但是,问题是当子字段作为M2M字段时,我们得到了此错误。

>>> cloner.set_objects = [post]
>>> cloner.execute()
Traceback (most recent call last):
  File "<console>", line 1, in <module>
  File "/home/agus/envs/env-django-cloner/django-object-cloner/object_cloner_demo/app/utils.py", line 114, in execute
    self.clone_childs(new_object)
  File "/home/agus/envs/env-django-cloner/django-object-cloner/object_cloner_demo/app/utils.py", line 79, in clone_childs
    self.clone_childs(new_child)
  File "/home/agus/envs/env-django-cloner/django-object-cloner/object_cloner_demo/app/utils.py", line 76, in clone_childs
    setattr(new_child, remote_field_name, object)
  File "/home/agus/envs/env-django-cloner/lib/python3.7/site-packages/django/db/models/fields/related_descriptors.py", line 546, in __set__
    % self._get_set_deprecation_msg_params(),
TypeError: Direct assignment to the forward side of a many-to-many set is prohibited. Use comments.set() instead.
>>> 

来自setattr(...)的错误,以及“改为使用comments.set()” ,但是我仍然困惑如何更新该m2m值?

new_child = self.clone_object(old_child)

if new_child is not None:
    setattr(new_child, remote_field_name, object)
    new_child.save()

我也在下面尝试了此代码段,但仍然存在错误。克隆的m2m对象很多,没有填充m2m值。

if new_child is not None:
    # check the object_type
    object_type = getattr(new_child, remote_field_name)

    if hasattr(object_type, 'pk'):
        # this mean is `object_type` as real object.
        # so, we can directly use the `setattr(...)`
        # to update the old relation value with new relation value.
        setattr(new_child, remote_field_name, object)

    elif hasattr(object_type, '_queryset_class'):
        # this mean is `object_type` as m2m queryset (ManyRelatedManager).
        # django.db.models.fields.related_descriptors.\
        # create_forward_many_to_many_manager.<locals>.ManyRelatedManager

        # check the old m2m values, and assign into new object.
        # FIXME: IN THIS CASE STILL GOT AN ERROR
        old_m2m_values = getattr(old_child, remote_field_name).all()
        object_type.add(*old_m2m_values)

    new_child.save()

4 个答案:

答案 0 :(得分:1)

我试图用一些有效的代码来解决这个有趣的问题……这比我最初想象的要困难!

因为我在遵循ObjectCloner逻辑上遇到了一些困难,所以我偏离了您的原始解决方案。

以下是我能想到的最简单的解决方案;我选择使用单个帮助函数 clone_object()来处理单个对象,而不是使用类。

您当然可以使用第二个函数来处理对象列表或查询集,方法是扫描序列并多次调用clone_object()。

def clone_object(obj, attrs={}):

    # we start by building a "flat" clone
    clone = obj._meta.model.objects.get(pk=obj.pk)
    clone.pk = None

    # if caller specified some attributes to be overridden, 
    # use them
    for key, value in attrs.items():
        setattr(clone, key, value)

    # save the partial clone to have a valid ID assigned
    clone.save()

    # Scan field to further investigate relations
    fields = clone._meta.get_fields()
    for field in fields:

        # Manage M2M fields by replicating all related records 
        # found on parent "obj" into "clone"
        if not field.auto_created and field.many_to_many:
            for row in getattr(obj, field.name).all():
                getattr(clone, field.name).add(row)

        # Manage 1-N and 1-1 relations by cloning child objects
        if field.auto_created and field.is_relation:
            if field.many_to_many:
                # do nothing
                pass
            else:
                # provide "clone" object to replace "obj" 
                # on remote field
                attrs = {
                    field.remote_field.name: clone
                }
                children = field.related_model.objects.filter(**{field.remote_field.name: obj})
                for child in children:
                    clone_object(child, attrs)

    return clone

一个经过Python 3.7.6和Django 3.0.6测试的POC示例项目已保存在github上的公共仓库中。

https://github.com/morlandi/test-django-clone

答案 1 :(得分:0)

由于具有M2M关系,因此需要在相关表中创建新记录。 为此,add()似乎更合适。 您可以尝试这样的事情:

for old_child in relation.related_model.objects.all():
    new_child = self.clone_object(old_child)
    setattr(new_child, remote_field_name, object)
    relation.related_model.objects.add(new_child)

请注意,此代码未试用,因此可能需要进行一些调整。

答案 2 :(得分:0)

最初的一些担忧:

  • related_objects是受限制的,它仅返回反向关系-如果子级对此父级具有外键,则不具有此父级对子级的外键。虽然这可能是有效的方法(从上到下),但实际上关系可能以不同的方式组织,或者子代可能具有另一个模型的另一个外键(也可能需要克隆)。需要查看所有字段和关系。

更不用说Django推荐了

related_objects)专用API,仅供Django本身使用; get_fields() 结合字段属性过滤是用于 获取此字段列表。


可能会建议一些不同的方法。

使用get_fields()代替related_objects

fields = object._meta.get_fields()将返回模型中所有字段的列表-在模型本身以及正向/反向访问字段中定义,这些字段由Django自动添加(如related_objects返回的字段)

可以过滤此列表以仅获取必填字段:

  • field.is_relation-对于关系和ForeignKey字段,ManyToMany字段等,将为True

  • field.auto_created-对于由django自动创建的字段,将为True-反向关系,pk / id为AutoField(但它将具有is_relation == False)

  • field.many_to_many-对于ManyToMany字段和关系而言都是正确的

通过这种方式,您可以选择必填字段或关系,无论是正向还是反向,无论多对多。并且知道确切的关系类型-相应地创建对象,即为ManyToMany添加设置。

关系字段值(一个或多个相关对象)可以通过getattr,_meta访问或类似以下查询来访问:

children = field.related_model.objects.filter(
    **{field.remote_field.name: object}
)

对关系和具有关系的字段有效。


注意:

  • 因为这很可能是特定于应用程序的,因此您想要向上,向下和横向克隆关系的距离(包括父母及其父母;孩子的孩子;与fk on 模型的关系或与fk to 模型的关系;将fks跟随到另一个关于孩子/父母的模型)或过滤允许使用的模型,依此类推-将clone方法更多地绑定到特定模型可能是可以的结构

  • 还存在隐藏关系-在ForeignKey或ManyToManyField上用related_name = "+"定义的关系。这些仍然可以通过 include_hidden 参数发现:object._meta.get_fields(include_hidden=True)

答案 3 :(得分:0)

如果您可以告诉您正在使用的Django版本以及通过克隆实际实现的目标,那么对帮助的理解会更容易理解。 Django相关字段在不同版本中的工作方式有所不同,因为前向引用和后向引用的处理方式不同。 因此,如果您能说出您要使用代码执行的实际操作?