Skip to content

Fix django_get_or_create for factories built through SubFactory - #1161

Open
inchang-ing wants to merge 1 commit into
FactoryBoy:masterfrom
inchang-ing:fix/subfactory-get-or-create
Open

inchang-ing wants to merge 1 commit into
FactoryBoy:masterfrom
inchang-ing:fix/subfactory-get-or-create

Conversation

@inchang-ing

Copy link
Copy Markdown

Fixes #979

Problem

django_get_or_create works when the factory is called directly (or used through BaseFactory), but not when it is reached through a SubFactory: on a unique-constraint collision, instead of reusing the existing object, the call fails with an IntegrityError (an AttributeError on _original_params in 3.2.1, before the guard added in #981).

Root cause

DjangoModelFactory._get_or_create recovers the user-provided lookup from cls._original_params when the initial get_or_create hits an IntegrityError. That attribute is only recorded by DjangoModelFactory._generate, which is on the direct-call path (Factory.create() → _generate() → StepBuilder.build()). The SubFactory path enters at StepBuilder.build() via BuildStep.recurse() and never calls _generate, so _original_params stays None and the fallback cannot run.

Approach (and the lesson from #981)

#981 added the failing testcase and a guard that re-raises the IntegrityError when _original_params is None. In the discussion on #979, @francoisfreitag explained why initializing _original_params to an empty dict (the alternative proposed there) was not acceptable: _original_params must distinguish user-provided values (usable as a get() lookup) from values generated by the factory (usable only in create()), and no place in the library preserved that distinction for the SubFactory path.

That distinction is actually available: in StepBuilder, extras contains exactly the user-provided params for the factory being built — call-site overrides (pair_factory(base_currency__name="Bitcoin")) and declaration-level params (SubFactory(CurrencyFactory, name="Bitcoin")) — and never generated values (those only appear after declaration resolution in the step attributes). Moreover, for a direct call, extras is the very same dict that _generate receives as params.

So this PR records the params in StepBuilder.build():

if hasattr(self.factory_meta.factory, '_original_params'):
    self.factory_meta.factory._original_params = self.extras

This covers both paths consistently (the direct-call recording in DjangoModelFactory._generate becomes redundant but is left untouched), applies the same fix to SQLAlchemyModelFactory for free, and keeps the #981 behavior intact: when the user params do not cover the django_get_or_create fields, the IntegrityError is still re-raised (verified by the testcase from #981, which still passes).

Testcase

Two regression tests mirroring the issue report were added to tests/test_django.py (SubFactoryGetOrCreateTests), one for declaration-level params (SubFactory(CurrencyFactory, name="Bitcoin") style) and one for call-time nested params (multifield__slug=... style). Both fail with IntegrityError before the fix and pass after it.

Full test suite: 441 tests, all passing (439 before, +2 new).

django_get_or_create relies on DjangoModelFactory._original_params to
recover the user-provided lookup when the initial creation hits an
IntegrityError. That attribute was only recorded by Factory._generate,
which is bypassed when the factory is instantiated through a SubFactory,
resulting in an IntegrityError (or an AttributeError before the guard
added in FactoryBoy#981) instead of reusing the existing object.

Record the params in StepBuilder.build instead: it covers both direct
calls and SubFactory recursion, and the params received there contain
only user-provided values (call-site overrides and declaration-level
params), not values generated by declarations — preserving the
distinction required for the get_or_create fallback lookup.

Fixes FactoryBoy#979
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

django_get_or_create with SubFactory

1 participant