feat: add get_or_create option to foreign_key_extras (#128) - #156
Conversation
Foreign keys declared in foreign_key_extras always created a brand-new related object. This adds a get_or_create option that first looks for an existing object matching the supplied input and reuses it when found, only creating a new object otherwise. get_or_create accepts either a list of field names to match on, or True to match on every concrete field present in the input. Foreign-key lookup values are resolved via resolve_id and enum values are unwrapped. The matching logic lives in the new DjangoCudBase.get_foreign_key_lookup helper. Implements the get-or-create use case from #119. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: cea72e29a0
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| for name in field_names: | ||
| related_field = get_model_field_or_none(name, related_model) | ||
| if related_field is None or not getattr(related_field, "concrete", False): | ||
| continue | ||
|
|
||
| lookup_value = value.get(name) |
There was a problem hiding this comment.
Honor field-name mappings when building lookups
When the related input type uses field_name_mappings, this lookup is built against unmapped names: get_or_create=True iterates the GraphQL/input keys, which are then rejected as non-model fields, while a configured list like ["email"] reads value.get("email") even though the input only contains the mapped key. In that scenario the lookup is empty or missing the unique field, so an existing related object is not reused and the mutation falls through to creating a duplicate (often hitting the same unique-constraint failure this option is meant to avoid). The lookup needs the input type's reverse field-name mapping before resolving fields/values.
Useful? React with 👍 / 👎.
| if get_or_create: | ||
| lookup = cls.get_foreign_key_lookup(field.related_model, value, get_or_create) | ||
| existing_obj = field.related_model.objects.filter(**lookup).first() if lookup else None |
There was a problem hiding this comment.
Include context fields in get-or-create lookups
When the nested input type relies on auto_context_fields for part of its identity, the new lookup ignores those context-derived values even though the create path immediately below passes them into create_obj. For example, with a related model unique on (owner, tag) where owner comes from info.context, get_or_create: ["owner", "tag"] skips owner because it is not in the submitted input and may reuse another owner's row with the same tag (or miss the correct row and create a duplicate). The lookup should incorporate the nested type's auto context fields before filtering.
Useful? React with 👍 / 👎.
| elif isinstance(lookup_value, enum.Enum): | ||
| lookup[name] = lookup_value.value | ||
| else: | ||
| lookup[name] = lookup_value |
There was a problem hiding this comment.
Apply custom handlers before lookup values
When a nested related field included in get_or_create is normalized by a handle_<field> method, the lookup uses the raw GraphQL value while the create path later applies that handler in create_obj. In that configuration, an existing row stored with the normalized value is missed and the mutation falls through to creating a second row (or raising a unique-constraint error), so lookup values should go through the same per-field handler path as creation.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit cea72e2. Configure here.
| elif isinstance(lookup_value, enum.Enum): | ||
| lookup[name] = lookup_value.value | ||
| else: | ||
| lookup[name] = lookup_value |
There was a problem hiding this comment.
Lookup ignores input field mappings
Medium Severity
get_foreign_key_lookup reads nested input with model field names and raw value keys, but nested create inputs use field_name_mappings (e.g. keeper → keeper_id). create_obj already reverses those names; the lookup path does not, so filters often stay empty and get_or_create always creates instead of reusing matches.
Reviewed by Cursor Bugbot for commit cea72e2. Configure here.


Implements #128 (the get-or-create use case from #119).
Motivation
foreign_key_extraswith a nested input type always creates a new relatedobject. Sometimes you want to reuse an existing one when it already exists —
the canonical example from #119 is a car's number plate: reuse the stored plate
if present, otherwise create it. Today that's impossible, and attempting it hits
a unique-constraint error.
What's new
A
get_or_createoption on aforeign_key_extrasentry:get_or_create: [field, ...]— match an existing related object on those fields.get_or_create: True— match on every concrete field present in the input.If a match is found it is reused; otherwise a new related object is created
exactly as before. Lookup values for foreign-key fields are resolved via
resolve_id, and enum values are unwrapped. The matching logic lives in a newDjangoCudBase.get_foreign_key_lookupclassmethod (overridable).Compatibility
Purely additive and opt-in — without
get_or_createthe behaviour is unchanged.No input-type / schema changes.
Tests
TestCreateMutationForeignKeyGetOrCreate:(both branches fail on
mainwith UNIQUE constraint errors),Trueform: reuses when all provided fields match.Full suite: 112 passed (
poetry run pytest). Docs (nested-fields.rst) andCHANGELOG.mdupdated.🤖 Generated with Claude Code
Note
Low Risk
Opt-in mutation behavior with no schema changes; default nested FK creation is unchanged when
get_or_createis omitted.Overview
Adds an opt-in
get_or_createflag onforeign_key_extrasso nested related objects can be reused when a DB row already matches the input, instead of always callingcreate_obj(which can hit unique constraints).In
DjangoCudBase.get_or_create_foreign_obj, when the extra uses a nested input type andget_or_createis set, the mutation builds a lookup via the new overridableget_foreign_key_lookup(field list orTruefor all concrete fields in the input), runsfilter(...).first(), and returns that PK if found; otherwise behavior is unchanged. Lookups resolve FK IDs and unwrap enums.Docs (
nested-fields.rst),CHANGELOG.md, andTestCreateMutationForeignKeyGetOrCreatecover list vsTruematching and create-vs-reuse paths.Reviewed by Cursor Bugbot for commit cea72e2. Bugbot is set up for automated code reviews on this repo. Configure here.