diff --git a/changelog/1152.fixed.md b/changelog/1152.fixed.md new file mode 100644 index 000000000..39ec8da21 --- /dev/null +++ b/changelog/1152.fixed.md @@ -0,0 +1 @@ +Assigning to a cardinality-many relationship (e.g. `node.members = [peer]`) now populates the relationship manager, and assigning a non-list value raises a clear error at assignment time instead of being silently accepted and failing later in `save()` with a confusing `'InfrahubNode' object has no attribute 'initialized'` message. diff --git a/infrahub_sdk/node/node.py b/infrahub_sdk/node/node.py index 9bdaa20cc..713066b84 100644 --- a/infrahub_sdk/node/node.py +++ b/infrahub_sdk/node/node.py @@ -978,6 +978,29 @@ def __setattr__(self, name: str, value: Any) -> None: self._relationship_cardinality_one_data[name] = new_rel return + if "_relationship_cardinality_many_data" in self.__dict__ and name in self._relationship_cardinality_many_data: + rel_schemas = [rel_schema for rel_schema in self._schema.relationships if rel_schema.name == name] + if not rel_schemas: + raise SchemaNotFoundError( + identifier=self._schema.kind, + message=f"Unable to find relationship schema for '{name}' on {self._schema.kind}", + ) + rel_schema = rel_schemas[0] + # RelationshipManager validates that a cardinality-many value is a list and raises a + # helpful error otherwise, so assigning a single node fails here instead of silently + # corrupting the node and blowing up later in save(). + new_many_rel = RelationshipManager( + name=rel_schema.name, + client=self._client, + node=self, + branch=self._branch, + schema=rel_schema, + data=value, + ) + new_many_rel._has_update = True + self._relationship_cardinality_many_data[name] = new_many_rel + return + super().__setattr__(name, value) async def generate(self, nodes: list[str] | None = None) -> None: @@ -2169,6 +2192,29 @@ def __setattr__(self, name: str, value: Any) -> None: self._relationship_cardinality_one_data[name] = new_rel return + if "_relationship_cardinality_many_data" in self.__dict__ and name in self._relationship_cardinality_many_data: + rel_schemas = [rel_schema for rel_schema in self._schema.relationships if rel_schema.name == name] + if not rel_schemas: + raise SchemaNotFoundError( + identifier=self._schema.kind, + message=f"Unable to find relationship schema for '{name}' on {self._schema.kind}", + ) + rel_schema = rel_schemas[0] + # RelationshipManagerSync validates that a cardinality-many value is a list and raises a + # helpful error otherwise, so assigning a single node fails here instead of silently + # corrupting the node and blowing up later in save(). + new_many_rel = RelationshipManagerSync( + name=rel_schema.name, + client=self._client, + node=self, + branch=self._branch, + schema=rel_schema, + data=value, + ) + new_many_rel._has_update = True + self._relationship_cardinality_many_data[name] = new_many_rel + return + super().__setattr__(name, value) def generate(self, nodes: list[str] | None = None) -> None: diff --git a/tests/unit/sdk/test_node.py b/tests/unit/sdk/test_node.py index ab0d72ec0..8a4954ae5 100644 --- a/tests/unit/sdk/test_node.py +++ b/tests/unit/sdk/test_node.py @@ -342,6 +342,89 @@ async def test_cardinality_many_accepts_list( assert len(node.tags.peers) == 2 +@pytest.mark.parametrize("client_type", client_types) +async def test_cardinality_many_assignment_requires_list( + client: InfrahubClient, location_schema: NodeSchemaAPI, client_type: str +) -> None: + """Assigning a single node to a cardinality-many relationship must fail at assignment time. + + Previously this was silently accepted and only blew up later in save() with a confusing + "'InfrahubNode' object has no attribute 'initialized'" error. + """ + if client_type == "standard": + node = InfrahubNode(client=client, schema=location_schema, data={"name": {"value": "JFK1"}}) + else: + node = InfrahubNodeSync(client=client, schema=location_schema, data={"name": {"value": "JFK1"}}) + + with pytest.raises(ValueError, match=r"expects a list of nodes"): + node.tags = {"id": "pppppppp"} + + +@pytest.mark.parametrize("client_type", client_types) +async def test_cardinality_many_assignment_accepts_list( + client: InfrahubClient, location_schema: NodeSchemaAPI, client_type: str +) -> None: + """Assigning a list to a cardinality-many relationship populates the manager and marks it updated.""" + if client_type == "standard": + node = InfrahubNode(client=client, schema=location_schema, data={"name": {"value": "JFK1"}}) + else: + node = InfrahubNodeSync(client=client, schema=location_schema, data={"name": {"value": "JFK1"}}) + + node.tags = [{"id": "aaaaaa"}, {"id": "bbbb"}] + + assert isinstance(node.tags, RelationshipManagerBase) + assert node.tags.peer_ids == ["aaaaaa", "bbbb"] + # has_update must be set so save() actually persists the assignment instead of stripping it. + assert node.tags.has_update is True + + +@pytest.mark.parametrize("client_type", client_types) +async def test_cardinality_many_assignment_included_in_input_data( + client: InfrahubClient, location_schema: NodeSchemaAPI, client_type: str +) -> None: + """A list assigned after construction is carried into the mutation payload built by save(). + + This is the exact path that used to raise 'InfrahubNode' object has no attribute 'initialized', + and it also guards that _strip_unmodified keeps the assignment (thanks to has_update). + """ + data = {"name": {"value": "JFK1"}, "type": {"value": "SITE"}} + if client_type == "standard": + node = InfrahubNode(client=client, schema=location_schema, data=data) + else: + node = InfrahubNodeSync(client=client, schema=location_schema, data=data) + + node.tags = [{"id": "aaaaaa"}, {"id": "bbbb"}] + + input_data = node._generate_input_data()["data"]["data"] + assert input_data["tags"] == [{"id": "aaaaaa"}, {"id": "bbbb"}] + + +@pytest.mark.parametrize("client_type", client_types) +async def test_cardinality_many_assignment_saved( + httpx_mock: HTTPXMock, clients: BothClients, location_schema: NodeSchemaAPI, client_type: str +) -> None: + """Assigning a list then calling save() succeeds and sends the peers in the create mutation.""" + httpx_mock.add_response( + method="POST", + json={"data": {"BuiltinLocationCreate": {"ok": True, "object": {"id": "location-123"}}}}, + ) + data = {"name": {"value": "JFK1"}, "type": {"value": "SITE"}} + client = getattr(clients, client_type) + if client_type == "standard": + node = InfrahubNode(client=client, schema=location_schema, data=data) + node.tags = [{"id": "aaaaaa"}, {"id": "bbbb"}] + await node.save() + else: + node = InfrahubNodeSync(client=client, schema=location_schema, data=data) + node.tags = [{"id": "aaaaaa"}, {"id": "bbbb"}] + node.save() + + assert node.id == "location-123" + body = httpx_mock.get_requests()[-1].content.decode() + assert "aaaaaa" in body + assert "bbbb" in body + + @pytest.mark.parametrize("client_type", client_types) async def test_query_data_no_filters_property( clients: BothClients, location_schema: NodeSchemaAPI, client_type: str