Storing factory-boy RelatedFactory Object on Parent Factory
Asked Answered
S

1

8

I have two Django models (Customer and CustomerAddress) that both contain ForeignKeys to each other. I am using factory-boy to manage creation of these models, and cannot save a child factory instance onto the parent factory (using relationships defined using the RelatedFactory class).

My two models:

class ExampleCustomerAddress(models.Model):
    # Every customer mailing address is assigned to a single Customer,
    # though Customers may have multiple addresses.
    customer = models.ForeignKey('ExampleCustomer', on_delete=models.CASCADE)

class ExampleCustomer(models.Model):
    # Each customer has a single (optional) default billing address:
    default_billto = models.ForeignKey(
        'ExampleCustomerAddress',
        on_delete=models.SET_NULL,
        blank=True,
        null=True,
        related_name='+')

I have two factories, one for each model:

class ExampleCustomerAddressFactory(factory.django.DjangoModelFactory):
    class Meta:
        model = ExampleCustomerAddress

    customer = factory.SubFactory(
        'ExampleCustomerFactory',
        default_billto=None)  # Set to None to prevent recursive address creation.

class ExampleCustomerFactory(factory.django.DjangoModelFactory):
    class Meta:
        model = ExampleCustomer

    default_billto = factory.RelatedFactory(ExampleCustomerAddressFactory,
                                            'customer')

When creating a ExampleCustomerFactory, default_billto is None, even though a ExampleCustomerAddress has been created:

In [14]: ec = ExampleCustomerFactory.build()

In [15]: ec.default_billto is None
Out[15]: True

(When using create(), a new ExampleCustomerAddress exists in the database. I am using build() here to simplify the example).

Creating an ExampleCustomerAddress works as expected, with the Customer being automatically created:

In [22]: eca = ExampleCustomerAddressFactory.build()

In [23]: eca.customer
Out[23]: <ExampleCustomer: ExampleCustomer object>

In [24]: eca.customer.default_billto is None
Out[24]: True  <-- I was expecting this to be set to an `ExampleCustomerAddress!`.

I feel like I am going crazy here, missing something very simple. I get the impression I am encountering this error because of how both models contain ForeignKeys to each other.

Sutlej answered 11/4, 2020 at 23:41 Comment(0)
M
12

First, a simple rule of thumb: when you're following a ForeignKey, always prefer a SubFactory; RelatedFactory is intended to follow a reverse relationship.

Let's take each factory in turn.

ExampleCustomerAddressFactory

When we call this factory without a customer, we'll want to get an address, linked to a customer, and used as the default address for that customer.

However, when we call it with a customer, don't alter it.

The following would work:

class ExampleCustomerAddressFactory(factory.django.DjangoModelFactory):
    class Meta:
        model = ExampleCustomerAddress

    # Fill the Customer unless provided
    customer = factory.SubFactory(
        ExampleCustomerFactory,
        # We can't provide ourself there, since we aren't saved to the database yet.
        default_billto=None,
    )

    @factory.post_generation
    def set_customer_billto(obj, create, *args, **kwargs):
        """Set the default billto of the customer to ourselves if empty"""
        if obj.customer.default_billto is None:
            obj.customer.default_billto = obj
            if create:
                obj.customer.save()

Here, we'll set the newly created customer's value to "us"; note that this logic could also be moved to ExampleCustomerAddress.save().

ExampleCustomerFactory

For this factory, the rules are simpler: when creating a customer, create a default billing address (unless a value has been provided).

class ExampleCustomerFactory(factory.django.DjangoModelFactory):
    class Meta:
        model = ExampleCustomer

    # We can't use a SubFactory here, since that would be evaluated before
    # the Customer has been saved.
    default_billto = factory.RelatedFactory(
        ExampleCustomerAddressFactory,
        'customer',
    )

This factory will run as follows:

  1. Create the ExampleCustomer instance with default_billto=None;
  2. Call ExampleCustomerAddressFactory(customer=obj) with the newly created customer;
  3. That factory will create an ExampleCustomerAddress with that customer;
  4. The post-generation hook in that factory will then detect that the customer has no default_billto, and will override it.

Notes

  • I didn't test this, so some typos or minor bugs could occur;
  • It's up to you to decide which factory is declared first, using the target factory's path instead of a direct reference;
  • As stated above, the logic to set the default billing address of a customer when it's empty and an address is added to that customer could be moved to your model's .save() method.
Morena answered 12/4, 2020 at 10:59 Comment(1)
Thank you sooo much! This worked perfectly. For some reason, I got hung up adding the post-generation hook onto the CustomerFactory. It never occurred to me to place it onto the CustomerAddressFactory. One quick edit to your answer: the @factory.post_declaration decorator should be @factory.post_generation.Sutlej

© 2022 - 2024 — McMap. All rights reserved.