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.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions