Skip to content

fix(node): preserve unfetched optional one-cardinality relationships on upsert#1079

Open
iddocohen wants to merge 1 commit into
stablefrom
ic-from-graphql-preserves-unfetched-rels
Open

fix(node): preserve unfetched optional one-cardinality relationships on upsert#1079
iddocohen wants to merge 1 commit into
stablefrom
ic-from-graphql-preserves-unfetched-rels

Conversation

@iddocohen

@iddocohen iddocohen commented Jun 15, 2026

Copy link
Copy Markdown
Contributor

Closes #1080

Summary

A node hydrated by from_graphql from a partial GraphQL response would silently null-clear any optional cardinality-one relationship the response did not fetch, on the next save(allow_upsert=True). This is the kind of bug that surfaces as data corruption in downstream queries — orphan nodes that exist but are no longer reachable from their previous side of the relationship.

The root cause is a conflation in the serialization gate at infrahub_sdk/node/node.py:344-349. For an _existing node, an "uninitialized" optional one-cardinality rel is emitted as null. But RelatedNode.initialized is a derived property (bool(id) or bool(hfid)), and that derivation cannot tell apart:

  1. Never loaded — the GraphQL response didn't include this rel.
  2. Explicitly cleared — the user wrote node.rel = None.

Both produce id is None AND hfid is None. The gate was written to support case (2), but case (1) silently falls through it and clears data the user never intended to touch.

Fix

Mirror the existing Attribute.value_has_been_mutated pattern on relationships:

  • Add _peer_has_been_mutated: bool = False to RelatedNodeBase.__init__.
  • Flip it to True in both async and sync Node.__setattr__ when the user assigns to a cardinality-one rel.
  • Tighten the serialization gate to require self._existing AND rel._peer_has_been_mutated before emitting null.

Net effect: nodes hydrated from partial payloads no longer leak rel: null into upsert mutations. Explicit node.rel = None still clears the rel.

Scope

Test plan

  • New test test_update_input_data_existing_node_preserves_untouched_optional_relationship fails on the old code, passes after the fix (both [standard] and [sync] variants).
  • Existing test_update_input_data_existing_node_with_optional_relationship updated to actually call node.primary_tag = None (matching what its docstring already claimed) — verifies the explicit-clear path still works.
  • Full SDK unit suite passes (967/967).
  • uv run invoke format lint-code clean.
  • Integration tests TBD via CI.

…on upsert

A node hydrated by `from_graphql` from a partial response would silently null-clear
any optional cardinality-one relationship the response did not fetch, because the
serialization gate could not distinguish "never loaded" from "explicitly cleared".

Add `_peer_has_been_mutated` on `RelatedNodeBase`, flipped by `Node.__setattr__` on
explicit assignment, and require it in the gate. Mirrors the existing
`value_has_been_mutated` pattern on attributes.
@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying infrahub-sdk-python with  Cloudflare Pages  Cloudflare Pages

Latest commit: 73a5d35
Status: ✅  Deploy successful!
Preview URL: https://7cc79b1e.infrahub-sdk-python.pages.dev
Branch Preview URL: https://ic-from-graphql-preserves-un.infrahub-sdk-python.pages.dev

View logs

@codecov

codecov Bot commented Jun 15, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

@@           Coverage Diff           @@
##           stable    #1079   +/-   ##
=======================================
  Coverage   81.79%   81.79%           
=======================================
  Files         135      135           
  Lines       11643    11648    +5     
  Branches     1761     1761           
=======================================
+ Hits         9523     9528    +5     
  Misses       1572     1572           
  Partials      548      548           
Flag Coverage Δ
integration-tests 41.65% <100.00%> (+0.02%) ⬆️
python-3.10 55.20% <100.00%> (+0.01%) ⬆️
python-3.11 55.20% <100.00%> (+0.01%) ⬆️
python-3.12 55.20% <100.00%> (+0.01%) ⬆️
python-3.13 55.21% <100.00%> (+0.03%) ⬆️
python-3.14 55.21% <100.00%> (+0.01%) ⬆️
python-filler-3.12 22.35% <0.00%> (-0.01%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
infrahub_sdk/node/node.py 87.47% <100.00%> (+0.04%) ⬆️
infrahub_sdk/node/related_node.py 92.48% <100.00%> (+0.04%) ⬆️
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found across 4 files

Re-trigger cubic

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.

bug: from_graphql + save(allow_upsert=True) silently clears unfetched optional one-cardinality relationships

1 participant