Отношения один ко многим с Factory Boy

У меня есть отношения «многие к одному» в моих моделях SQLAlchemy. В одном отчете много примеров (упрощенных для краткости):

class Sample(db.Model, CRUDMixin):
    sample_id = Column(Integer, primary_key=True)
    report_id = Column(Integer, ForeignKey('report.report_id', ondelete='CASCADE'), index=True, nullable=False)
    report = relationship('Report', back_populates='samples')

class Report(db.Model, CRUDMixin):
    report_id = Column(Integer, primary_key=True)
    samples = relationship('Sample', back_populates='report')

Теперь в своих тестах я хочу иметь возможность генерировать экземпляр Sample или экземпляр Report и заполнять недостающие отношения.

class ReportFactory(BaseFactory):
    class Meta:
        model = models.Report
    report_id = Faker('pyint')
    samples = RelatedFactoryList('tests.factories.SampleFactory', size=3)

class SampleFactory(BaseFactory):
    class Meta:
        model = models.Sample
    sample_id = Faker('pyint')
    report = SubFactory(ReportFactory)

Когда я собираюсь создать их экземпляр, фабрики застревают в бесконечном цикле:

RecursionError: maximum recursion depth exceeded in comparison

Однако, если я попытаюсь использовать SelfAttributes для остановки бесконечного цикла, я получу отчет без каких-либо образцов:

class ReportFactory(BaseFactory):
    samples = RelatedFactoryList('tests.factories.SampleFactory', size=3, report_id=SelfAttribute('..report_id'))

class SampleFactory(BaseFactory):
    report = SubFactory(ReportFactory, samples=[])
report = factories.ReportFactory()
l = len(report.samples) # 0

Однако, если я сгенерирую Sample с SampleFactory(), он правильно будет иметь объект Report.

Как мне правильно спроектировать свои фабрики так, чтобы SampleFactory() генерировал Sample со связанными Report, а ReportFactory() генерировал Report с 2 связанными Samples без бесконечных циклов?


person Migwell    schedule 15.08.2019    source источник


Ответы (2)


Объявление RelatedFactory оценивается после создания экземпляра:

  1. Создается экземпляр Report
  2. Выполняется 3 вызова SampleFactory
  3. Report, созданный на шаге 1, возвращается

Чтобы заполнить поле для экземпляров Report, на шаге 2 необходимо связать экземпляры Sample с экземплярами Report.

Возможная реализация будет:

class SampleFactory(BaseFactory):
    class Meta:
        model = Sample

    @classmethod
    def _after_postgeneration(cls, instance, create, results=None):
        if instance.report is not None and instance not in instance.report.samples:
            instance.report.samples.append(instance)

    id = factory.Faker('pyint')
    # Enfore `post_samples = None` to prevent creating additional samples
    report = factory.SubFactory('example.ReportFactory', samples=[], post_samples=None)
    report_id = factory.SelfAttribute('report.id')

class ReportFactory(factory.Factory):
    class Meta:
        model = Report

    id = factory.Faker('pyint')
    # Set samples = [] if needed by `Report.__init__`
    samples = []
    # Named `post_samples` to mark that they are instantiated
    # *after* the `Report` is ready (and never passed to the `samples` kwarg)
    post_samples = factory.RelatedFactoryList(SampleFactory, 'report', size=3)

С помощью этого кода при вызове ReportFactory вы:

  1. Создайте Report без каких-либо образцов
  2. Сгенерируйте 3 образца, передав им ссылку на только что сгенерированный отчет
  3. При создании эти Sample экземпляры присоединяются к Report.samples
person Xelnor    schedule 15.08.2019
comment
Но поскольку вы назвали RelatedFactoryList post_samples, не будет ли в сгенерированном Report Sample? - person Migwell; 16.08.2019
comment
Нет, потому что мы прикрепляем каждый Sample к его Report вручную при его создании в хуке _after_postgeneration. - person Xelnor; 23.08.2019

Мое окончательное решение оказалось намного проще, чем я думал:

class ReportFactory(BaseFactory):
    class Meta:
        model = models.Report

    samples = RelatedFactoryList('tests.factories.SampleFactory', 'report', size=3)


class SampleFactory(BaseFactory):
    class Meta:
        model = models.Sample

    report = SubFactory(ReportFactory, samples=[])

Ключевым моментом было использование второго аргумента RelatedFactoryList, который должен соответствовать родительской ссылке на дочернем элементе, в данном случае 'report'. Кроме того, я использовал SubFactory(ReportFactory, samples=[]), что гарантирует, что в родительском объекте не будут созданы дополнительные образцы, если я создам один образец.

С помощью этой настройки я могу создать образец, с которым будет связан Report, и у этого отчета будет только 1 дочерний элемент Sample. И наоборот, я могу построить Report, который будет автоматически заполнен тремя дочерними образцами.

Я не думаю, что есть необходимость генерировать фактические идентификаторы моделей, потому что SQLAlchemy сделает это автоматически, как только модели будут фактически вставлены в базу данных. Однако, если вы хотите сделать это без использования базы данных, я думаю, что решение @Xelnor report_id = factory.SelfAttribute('report.id') будет работать.

Единственная нерешенная проблема связана с переопределением списка образцов в отчете (например, ReportFactory(samples = [SampleFactory()])), но я открыл проблему, документирующую эту ошибку: https://github.com/FactoryBoy/factory_boy/issues/636

person Migwell    schedule 16.08.2019
comment
Это тоже работает, но только из-за базовых функций вашего ORM: когда вы читаете report.samples, SQLAlchemy динамически извлекает список Sample объектов в БД (или сеанс), указывающих на этот конкретный Report. Если вы не работаете с ORM, вам придется связать их вручную. - person Xelnor; 23.08.2019
comment
Благодарю за разъяснение. Однако я упомянул SQLAlchemy в вопросе. - person Migwell; 24.08.2019