Skip to content

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

Description

@iddocohen

Component

Python SDK

Infrahub SDK version

1.21.1 (infrahub_sdk/node/ on stable)

Current Behavior

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:

if rel_schema.cardinality == RelationshipCardinality.ONE and rel_schema.optional and not rel.initialized:
    # Only include None for existing nodes to allow clearing relationships
    if self._existing:
        data[item_name] = None
    continue

RelatedNode.initialized is derived (bool(id) or bool(hfid)) and conflates two distinct cases:

  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 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):

from infrahub_sdk import InfrahubNode

# location_schema has optional one-cardinality rel `primary_tag`
data = {
    "id": "existing-node-id",
    "name": {"value": "JFK1"},
    "description": {"value": "JFK Airport"},
    "type": {"value": "SITE"},
    # primary_tag deliberately absent — the upstream query didn't fetch it
}
node = InfrahubNode(client=client, schema=location_schema, data=data)
payload = node._generate_input_data()["data"]["data"]

assert "primary_tag" not in payload  # FAILS today — payload contains primary_tag: None

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.
  • Hierarchical relationships were handled separately in Fix: exclude uninitialized hierarchy relationships from mutation data #810.
  • Attribute side already mitigates the analogous case via 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions