Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Duplicate Django Model Instance and All Foreign Keys Pointing to It

I want to create a method on a Django model, call it model.duplicate(), that duplicates the model instance, including all the foreign keys pointing to it. I know that you can do this:

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

...but this way all the related models still point to the old instance.

I can't simply save a reference to the original object because what self points to changes during execution of the method:

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

I could try to save a reference to just the related object:

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

...but this transfers them from the original record to the new one. I need them copied over and pointed at the new record.

Several answers elsewhere (and here before I updated the question) have suggested using Python's copy, which I suspect works for foreign keys on this model, but not foreign keys on another model pointing to it.

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

If you do this new_model.fkeys.all() (to follow my naming scheme thus far) will be empty.

like image 392
Two-Bit Alchemist Avatar asked Aug 26 '15 19:08

Two-Bit Alchemist


2 Answers

You can create new instance and save it like this

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

I'm not sure how it'll behave with ManyToMany fields. But for simple fields it works. And you can always pop the fields you are not interested in for your new instance.

The bits where I'm iterating over _meta.fields may be done with copy but the important thing is to use the new id for the foreign_key_field.

I'm sure it's programmatically possible to detect which fields are foreign keys to the self.__class__ (foreign_key_field) but since you can have more of them it'll better to name the one (or more) explicitly.

like image 108
beezz Avatar answered Oct 07 '22 04:10

beezz


Although I accepted the other poster's answer (since it helped me get here), I wanted to post the solution I ended up with in case it helps someone else stuck in the same place.

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)

What it looks like when you use it:

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 and related_model names changed to protect the innocent.

like image 34
Two-Bit Alchemist Avatar answered Oct 07 '22 06:10

Two-Bit Alchemist