From 40eb72c7be9dd7d2037062f7c4ddded4c4b53cd3 Mon Sep 17 00:00:00 2001 From: Alex Gittings Date: Mon, 9 Feb 2026 11:48:39 +0000 Subject: [PATCH] Fix: exclude uninitialized hierarchy relationships from mutation data When saving a hierarchical node with allow_upsert=True, the SDK was including `parent: null` in the GraphQL mutation for nodes without a parent (e.g. top-level fabric nodes). Infrahub rejects this because hierarchy relationships are not assignable via mutations. Skip hierarchy relationships in _generate_input_data() unless they were explicitly initialized with data. Co-Authored-By: Claude Opus 4.6 --- infrahub_sdk/node/node.py | 4 ++ tests/unit/sdk/test_hierarchical_nodes.py | 58 +++++++++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/infrahub_sdk/node/node.py b/infrahub_sdk/node/node.py index 25d9d191..b38c4e06 100644 --- a/infrahub_sdk/node/node.py +++ b/infrahub_sdk/node/node.py @@ -258,6 +258,10 @@ def _generate_input_data( # noqa: C901, PLR0915 rel: RelatedNodeBase | RelationshipManagerBase = getattr(self, item_name) + # Skip hierarchy relationships (parent/children) unless explicitly set + if rel_schema.hierarchical and not rel.initialized: + continue + if rel_schema.cardinality == RelationshipCardinality.ONE and rel_schema.optional and not rel.initialized: # Only include None for existing nodes to allow clearing relationships # For new nodes, omit the field to allow object template defaults to be applied diff --git a/tests/unit/sdk/test_hierarchical_nodes.py b/tests/unit/sdk/test_hierarchical_nodes.py index 3165effe..7b043362 100644 --- a/tests/unit/sdk/test_hierarchical_nodes.py +++ b/tests/unit/sdk/test_hierarchical_nodes.py @@ -47,6 +47,10 @@ async def hierarchical_schema() -> NodeSchemaAPI: # Set hierarchy field manually since it's not part of NodeSchema but only NodeSchemaAPI # This field would normally be set by the backend schema_api.hierarchy = "InfraLocation" + # Set hierarchical field on parent/children relationships to match server behavior + for rel in schema_api.relationships: + if rel.name in {"parent", "children"}: + rel.hierarchical = "InfraLocation" return schema_api @@ -548,3 +552,57 @@ def test_hierarchical_node_sync_no_infinite_recursion_with_children( assert "children" not in children_node_data assert "ancestors" not in children_node_data assert "descendants" not in children_node_data + + +@pytest.mark.parametrize("client_type", ["standard", "sync"]) +async def test_hierarchical_node_generate_input_data_excludes_uninitialized_parent( + client: InfrahubClient, client_sync: InfrahubClientSync, hierarchical_schema: NodeSchemaAPI, client_type: str +) -> None: + """Test that _generate_input_data excludes uninitialized hierarchy relationships. + + When a hierarchical node (e.g. a top-level fabric with no parent) is saved with + allow_upsert=True, the SDK should not include 'parent: null' in the mutation data + because hierarchy relationships are not assignable via mutations. + """ + # Simulate an existing node with no parent set + data = { + "id": "existing-node-id", + "name": {"value": "Location-A"}, + "description": {"value": "A location"}, + } + + if client_type == "standard": + node = InfrahubNode(client=client, schema=hierarchical_schema, data=data) + else: + node = InfrahubNodeSync(client=client_sync, schema=hierarchical_schema, data=data) + + input_data = node._generate_input_data() + + # parent and children should NOT be in the mutation data since they are + # hierarchy relationships that were not explicitly set + assert "parent" not in input_data["data"]["data"] + assert "children" not in input_data["data"]["data"] + + +@pytest.mark.parametrize("client_type", ["standard", "sync"]) +async def test_hierarchical_node_generate_input_data_includes_initialized_parent( + client: InfrahubClient, client_sync: InfrahubClientSync, hierarchical_schema: NodeSchemaAPI, client_type: str +) -> None: + """Test that _generate_input_data includes hierarchy relationships when explicitly set.""" + data = { + "id": "child-node-id", + "name": {"value": "Location-B"}, + "description": {"value": "A child location"}, + "parent": {"node": {"id": "parent-node-id"}}, + } + + if client_type == "standard": + node = InfrahubNode(client=client, schema=hierarchical_schema, data=data) + else: + node = InfrahubNodeSync(client=client_sync, schema=hierarchical_schema, data=data) + + input_data = node._generate_input_data() + + # parent SHOULD be included since it was explicitly initialized with data + assert "parent" in input_data["data"]["data"] + assert input_data["data"]["data"]["parent"] == {"id": "parent-node-id"}