Skip to content

Unable to tell a recipe that it should not generate relationΒ #26

@GeeWee

Description

@GeeWee

Short summary

If we have a recipe where we want to override the creation of a ForeignKey, by setting it to None, this does not work as expected. The ForeignKey is created, and then afterwards set to None. This makes it hard if we're interested in doing counts of objects in test, as there'll be "phantom" objects floating around.

Expected behavior

I expected this test to pass:
Example:

#mommy_recipes.py:
order = Recipe(Order, location=foreign_key(location))
offloading = Recipe(
    Offloading, facility=foreign_key(facility), standard_order=foreign_key(order), amount=201.88
)

#test.py
def test_model_mommy():
    service = mommy.make_recipe('facilities.offloading', standard_order=None, template_order=template)

    assert service.standard_order is None # True
    assert Order.objects.count() is 0 # FALSE! One order has been created!

Actual behavior

A phantom object is created, and the test fails. This is really troublesome if you're doing counts in your tests, as sometimes they won't be right and it's very hard to figure out why.

After looking at the code I've determined where the issue is - it's in Recipe::_mapping:

    def _mapping(self, new_attrs):
        _save_related = new_attrs.get('_save_related', True)
        rel_fields_attrs = dict((k, v) for k, v in new_attrs.items() if '__' in k)
        new_attrs = dict((k, v) for k, v in new_attrs.items() if '__' not in k)
        mapping = self.attr_mapping.copy()
        for k, v in self.attr_mapping.items():
            # do not generate values if field value is provided
           # <<<<--- The mistake is on the next line. attrs.get() will return None both if the field
          # does not exist, AND if the field is None. So if I set the field to None, it will *not* skip 
          # model creation
            if new_attrs.get(k):
                continue
            elif mommy.is_iterator(v):
                if isinstance(self._model, string_types):
                    m = finder.get_model(self._model)
                else:
                    m = self._model
                if k not in self._iterator_backups or m.objects.count() == 0:
                    self._iterator_backups[k] = itertools.tee(
                        self._iterator_backups.get(k, [v])[0]
                    )
                mapping[k] = self._iterator_backups[k][1]
            elif isinstance(v, RecipeForeignKey):
                a = {}
                for key, value in list(rel_fields_attrs.items()):
                    if key.startswith('%s__' % k):
                        a[key] = rel_fields_attrs.pop(key)
                recipe_attrs = mommy.filter_rel_attrs(k, **a)
                if _save_related:
                    mapping[k] = v.recipe.make(**recipe_attrs)
                else:
                    mapping[k] = v.recipe.prepare(**recipe_attrs)
            elif isinstance(v, related):
                mapping[k] = v.make()
       # Here we update the mapping with the new_attrs, that also transfers None. This is why
       # the final object has the attribute correctly set to None
        mapping.update(new_attrs)
        mapping.update(rel_fields_attrs)
        return mapping

A fix I've monkeypatched my own recipe is reasonably simple, just use an empty class to distinguish from user-supplied None, and "no-field", DRF does the same:

# empty class
class empty:
    pass

#recipe::_mapping check then looks like this
if new_attrs.get(k, empty) is not empty:
      continue

I've confirmed this fixes the issue. I'm willing to submit a PR if you'll accept it.

Versions

Python: 3.7
Django: 2.2
Model Mommy: 1.6.0

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions