You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
A node hydrated by InfrahubNode.from_graphql from a GraphQL response that did not fetch a given optional one-cardinality relationship will silently null-clear that relationship on the next save(allow_upsert=True). The relationship payload sent to Infrahub looks like <rel_name>: null even though the caller never assigned to it — leaving the peer on the other side as an orphan in subsequent queries.
The root cause is at infrahub_sdk/node/node.py:344-349:
ifrel_schema.cardinality==RelationshipCardinality.ONEandrel_schema.optionalandnotrel.initialized:
# Only include None for existing nodes to allow clearing relationshipsifself._existing:
data[item_name] =Nonecontinue
RelatedNode.initialized is derived (bool(id) or bool(hfid)) and conflates two distinct cases:
Never loaded — the GraphQL response didn't include this rel.
Explicitly cleared — the user wrote node.rel = None.
Both produce id is None AND hfid is None. The inline comment shows the gate was written to support case (2), but case (1) silently falls through it.
Expected Behavior
A node hydrated from a partial GraphQL payload that omits an optional one-cardinality relationship must not include that relationship in the upsert mutation. Only explicit caller mutations (node.rel = None or node.rel = <something>) should produce a relationship entry in the payload.
Steps to Reproduce
Demonstrable as a unit test against _generate_input_data (no live server required):
The real-world hit was an Infrahub demo branch where a generator iterated over BGP neighbors with a cascade query that fetched only id, hfid, __typename, and one attribute on each peer. Calling save(allow_upsert=True) on those hydrated peers silently wiped each one's local_interface relationship. The peers became orphan nodes — they existed in the database but were no longer reachable from interface-anchored queries (GraphQL count for local_interface peers dropped to 0 on the affected branch).
Notes
Cardinality-many is already safe via RelationshipManager.initialized = data is not None (infrahub_sdk/node/relationship.py:149) — fetched-empty and never-fetched are already distinguishable on that path.
Component
Python SDK
Infrahub SDK version
1.21.1 (
infrahub_sdk/node/onstable)Current Behavior
A node hydrated by
InfrahubNode.from_graphqlfrom a GraphQL response that did not fetch a given optional one-cardinality relationship will silently null-clear that relationship on the nextsave(allow_upsert=True). The relationship payload sent to Infrahub looks like<rel_name>: nulleven though the caller never assigned to it — leaving the peer on the other side as an orphan in subsequent queries.The root cause is at
infrahub_sdk/node/node.py:344-349:RelatedNode.initializedis derived (bool(id) or bool(hfid)) and conflates two distinct cases:node.rel = None.Both produce
id is None AND hfid is None. The inline comment shows the gate was written to support case (2), but case (1) silently falls through it.Expected Behavior
A node hydrated from a partial GraphQL payload that omits an optional one-cardinality relationship must not include that relationship in the upsert mutation. Only explicit caller mutations (
node.rel = Noneornode.rel = <something>) should produce a relationship entry in the payload.Steps to Reproduce
Demonstrable as a unit test against
_generate_input_data(no live server required):The real-world hit was an Infrahub demo branch where a generator iterated over BGP neighbors with a cascade query that fetched only
id,hfid,__typename, and one attribute on each peer. Callingsave(allow_upsert=True)on those hydrated peers silently wiped each one'slocal_interfacerelationship. The peers became orphan nodes — they existed in the database but were no longer reachable from interface-anchored queries (GraphQL count forlocal_interfacepeers dropped to 0 on the affected branch).Notes
RelationshipManager.initialized = data is not None(infrahub_sdk/node/relationship.py:149) — fetched-empty and never-fetched are already distinguishable on that path.Attribute.value_has_been_mutated(infrahub_sdk/node/attribute.py:73, 109, 122-123).So the fix is to mirror the same discipline on
RelatedNodeBase. Submitted in #1079.