【问题标题】:Duplicate Django Model Instance and All Foreign Keys Pointing to It重复的 Django 模型实例和指向它的所有外键
【发布时间】:2015-11-20 23:40:36
【问题描述】:

我想在 Django 模型上创建一个方法,称为 model.duplicate(),它复制模型实例,包括指向它的所有外键。我知道你可以这样做:

def duplicate(self):
   self.pk = None
   self.save()

...但是这样所有相关模型仍然指向旧实例。

我不能简单地保存对原始对象的引用,因为self 指向的内容在方法执行期间发生了变化:

def duplicate(self):
    original = self
    self.pk = None
    self.save()
    assert original is not self    # fails

我可以尝试只保存对相关对象的引用:

def duplicate(self):
    original_fkeys = self.fkeys.all()
    self.pk = None
    self.save()
    self.fkeys.add(*original_fkeys)

...但这会将它们从原始记录转移到新记录。我需要将它们复制过来并指向新记录。

其他地方的几个答案(在我更新问题之前在这里)建议使用 Python 的 copy,我怀疑它适用于外键在这个模型上,但不是另一个模型上指向的外键它。

def duplicate(self):
    new_model = copy.deepcopy(self)
    new_model.pk = None
    new_model.save()

如果你这样做new_model.fkeys.all()(按照我迄今为止的命名方案)将是空的。

【问题讨论】:

    标签: python django python-2.7


    【解决方案1】:

    这是一个有点简单的解决方案。这不依赖于任何未记录的 Django API。它假定您要复制单个父记录,以及它的子记录、孙记录等。您以list 的形式传入一个实际上应该复制的类的白名单,其中包含指向其子对象的每个父对象上的一对多关系的名称。此代码假定,给定上述白名单,整个树是自包含的,无需担心外部引用。

    关于这段代码的另一件事:它是真正的递归,因为它为每个新的后代级别调用自己。

    from collections import OrderedDict
    
    def duplicate_model_with_descendants(obj, whitelist, _new_parent_pk=None):
        kwargs = {}
        children_to_clone = OrderedDict()
        for field in obj._meta.get_fields():
            if field.name == "id":
                pass
            elif field.one_to_many:
                if field.name in whitelist:
                    these_children = list(getattr(obj, field.name).all())
                    if children_to_clone.has_key(field.name):
                        children_to_clone[field.name] |= these_children
                    else:
                        children_to_clone[field.name] = these_children
                else:
                    pass
            elif field.many_to_one:
                if _new_parent_pk:
                    kwargs[field.name + '_id'] = _new_parent_pk
            elif field.concrete:
                kwargs[field.name] = getattr(obj, field.name)
            else:
                pass
        new_instance = obj.__class__(**kwargs)
        new_instance.save()
        new_instance_pk = new_instance.pk
        for ky in children_to_clone.keys():
            child_collection = getattr(new_instance, ky)
            for child in children_to_clone[ky]:
                child_collection.add(duplicate_model_with_descendants(child, whitelist=whitelist, _new_parent_pk=new_instance_pk))
        return new_instance
    

    示例用法:

    from django.db import models
    
    class Book(models.Model)
    
    class Chapter(models.Model)
        book = models.ForeignKey(Book, related_name='chapters')
    
    class Page(models.Model)
        chapter = models.ForeignKey(Chapter, related_name='pages')
    
    WHITELIST = ['books', 'chapters', 'pages']
    original_record = models.Book.objects.get(pk=1)
    duplicate_record = duplicate_model_with_descendants(original_record, WHITELIST)
    

    【讨论】:

    • 我已经尝试过您的解决方案,但出现此错误:AttributeError: 'collections.OrderedDict' object has no attribute 'has_key'
    • @BrankoRadojevic 看起来你的答案在这里:stackoverflow.com/a/33727186/5067822 我为 Python 2 编写了代码。对此感到抱歉。
    • 所以在 Python 3 中,if children_to_clone.has_key(field.name): 变成了 if field.name in children_to_clone:,我猜。
    • 谢谢。会试一试的。
    【解决方案2】:

    我在 Django 2.1/Python 3.6 中尝试了其他答案,它们似乎没有复制一对多和多对多相关对象(self._meta.fields 不包括一对多相关字段但self._meta.get_fields() 确实如此)。此外,其他答案需要相关字段名称的先验知识或要复制哪些外键的知识。

    我编写了一种以更通用的方式执行此操作的方法,处理一对多和多对多相关字段。包括评论,欢迎提出建议:

    def duplicate_object(self):
        """
        Duplicate a model instance, making copies of all foreign keys pointing to it.
        There are 3 steps that need to occur in order:
    
            1.  Enumerate the related child objects and m2m relations, saving in lists/dicts
            2.  Copy the parent object per django docs (doesn't copy relations)
            3a. Copy the child objects, relating to the copied parent object
            3b. Re-create the m2m relations on the copied parent object
    
        """
        related_objects_to_copy = []
        relations_to_set = {}
        # Iterate through all the fields in the parent object looking for related fields
        for field in self._meta.get_fields():
            if field.one_to_many:
                # One to many fields are backward relationships where many child objects are related to the
                # parent (i.e. SelectedPhrases). Enumerate them and save a list so we can copy them after
                # duplicating our parent object.
                print(f'Found a one-to-many field: {field.name}')
    
                # 'field' is a ManyToOneRel which is not iterable, we need to get the object attribute itself
                related_object_manager = getattr(self, field.name)
                related_objects = list(related_object_manager.all())
                if related_objects:
                    print(f' - {len(related_objects)} related objects to copy')
                    related_objects_to_copy += related_objects
    
            elif field.many_to_one:
                # In testing so far, these relationships are preserved when the parent object is copied,
                # so they don't need to be copied separately.
                print(f'Found a many-to-one field: {field.name}')
    
            elif field.many_to_many:
                # Many to many fields are relationships where many parent objects can be related to many
                # child objects. Because of this the child objects don't need to be copied when we copy
                # the parent, we just need to re-create the relationship to them on the copied parent.
                print(f'Found a many-to-many field: {field.name}')
                related_object_manager = getattr(self, field.name)
                relations = list(related_object_manager.all())
                if relations:
                    print(f' - {len(relations)} relations to set')
                    relations_to_set[field.name] = relations
    
        # Duplicate the parent object
        self.pk = None
        self.save()
        print(f'Copied parent object ({str(self)})')
    
        # Copy the one-to-many child objects and relate them to the copied parent
        for related_object in related_objects_to_copy:
            # Iterate through the fields in the related object to find the one that relates to the
            # parent model (I feel like there might be an easier way to get at this).
            for related_object_field in related_object._meta.fields:
                if related_object_field.related_model == self.__class__:
                    # If the related_model on this field matches the parent object's class, perform the
                    # copy of the child object and set this field to the parent object, creating the
                    # new child -> parent relationship.
                    related_object.pk = None
                    setattr(related_object, related_object_field.name, self)
                    related_object.save()
    
                    text = str(related_object)
                    text = (text[:40] + '..') if len(text) > 40 else text
                    print(f'|- Copied child object ({text})')
    
        # Set the many-to-many relations on the copied parent
        for field_name, relations in relations_to_set.items():
            # Get the field by name and set the relations, creating the new relationships
            field = getattr(self, field_name)
            field.set(relations)
            text_relations = []
            for relation in relations:
                text_relations.append(str(relation))
            print(f'|- Set {len(relations)} many-to-many relations on {field_name} {text_relations}')
    
        return self
    

    【讨论】:

    • 这不适用于 Django 1.8 和 Python 2.7。我想这并不奇怪......
    【解决方案3】:

    虽然我接受了其他发帖人的回答(因为它帮助我到达这里),但我想发布我最终得到的解决方案,以防它帮助其他人陷入同一个地方。

    def duplicate(self):
        """
        Duplicate a model instance, making copies of all foreign keys pointing
        to it. This is an in-place method in the sense that the record the
        instance is pointing to will change once the method has run. The old
        record is still accessible but must be retrieved again from
        the database.
        """
        # I had a known set of related objects I wanted to carry over, so I
        # listed them explicitly rather than looping over obj._meta.fields
        fks_to_copy = list(self.fkeys_a.all()) + list(self.fkeys_b.all())
    
        # Now we can make the new record
        self.pk = None
        # Make any changes you like to the new instance here, then
        self.save()
    
        foreign_keys = {}
        for fk in fks_to_copy:
            fk.pk = None
            # Likewise make any changes to the related model here
            # However, we avoid calling fk.save() here to prevent
            # hitting the database once per iteration of this loop
            try:
                # Use fk.__class__ here to avoid hard-coding the class name
                foreign_keys[fk.__class__].append(fk)
            except KeyError:
                foreign_keys[fk.__class__] = [fk]
    
        # Now we can issue just two calls to bulk_create,
        # one for fkeys_a and one for fkeys_b
        for cls, list_of_fks in foreign_keys.items():
            cls.objects.bulk_create(list_of_fks)
    

    使用时的样子:

    In [6]: model.id
    Out[6]: 4443
    
    In [7]: model.duplicate()
    
    In [8]: model.id
    Out[8]: 17982
    
    In [9]: old_model = Model.objects.get(id=4443)
    
    In [10]: old_model.fkeys_a.count()
    Out[10]: 2
    
    In [11]: old_model.fkeys_b.count()
    Out[11]: 1
    
    In [12]: model.fkeys_a.count()
    Out[12]: 2
    
    In [13]: model.fkeys_b.count()
    Out[13]: 1
    

    Model 和 related_model 名称已更改以保护无辜者。

    【讨论】:

      【解决方案4】:

      您可以创建新实例并像这样保存它

      def duplicate(self):
          kwargs = {}
          for field in self._meta.fields:
              kwargs[field.name] = getattr(self, field.name)
              # or self.__dict__[field.name]
          kwargs.pop('id')
          new_instance = self.__class__(**kwargs)
          new_instance.save()
          # now you have id for the new instance so you can
          # create related models in similar fashion
          fkeys_qs = self.fkeys.all()
          new_fkeys = []
          for fkey in fkey_qs:
              fkey_kwargs = {}
              for field in fkey._meta.fields:
                  fkey_kwargs[field.name] = getattr(fkey, field.name)
              fkey_kwargs.pop('id')
              fkey_kwargs['foreign_key_field'] = new_instance.id
              new_fkeys.append(fkey_qs.model(**fkey_kwargs))
          fkeys_qs.model.objects.bulk_create(new_fkeys)
          return new_instance
      

      我不确定它会如何处理 ManyToMany 字段。但是对于 simple 字段,它可以工作。您可以随时为新实例弹出您不感兴趣的字段。

      我在 _meta.fields 上迭代的位可以通过复制完成,但重要的是使用新的 id 来代替 foreign_key_field

      我确信可以通过编程检测哪些字段是self.__class__ (foreign_key_field) 的外键,但由于您可以拥有更多字段,因此最好明确命名一个(或多个)。

      【讨论】:

      • 这不解决指向模型的外键。据我所知,它或多或少与其他发布的解决方案相同。
      • 是的,你是对的,我编辑了我的答案。从您的问题中,外键的方向并不完全清楚。
      • 你说得对,这不是很清楚。我认为你在做某事。由于这里 FK 的方向,我似乎需要对它们执行 deepcopy,甚至将它们的 pk 设置为 None 以便它们被复制并且不会从一个模型移动到另一个模型。
      • 我担心它的性能,因为它需要在循环中为每一个调用 save() 函数。我宁愿用更少的数据库操作来做到这一点。查看我的问题的最新编辑。
      • 再一次,你是对的,当有很多 fkey 时,可能会使 db 变得昂贵。我编辑了我的答案以使用 bulk_create 方法 (docs.djangoproject.com/en/dev/ref/models/querysets/…)。
      猜你喜欢
      • 2013-06-19
      • 1970-01-01
      • 2014-10-29
      • 1970-01-01
      • 1970-01-01
      • 2011-04-19
      • 2020-06-22
      • 1970-01-01
      • 1970-01-01
      相关资源
      最近更新 更多