Skip to content

Commit 0597033

Browse files
Re-implement casting of HFIDs to a list if the rel peer schema has hfid set.
1 parent d116aa3 commit 0597033

3 files changed

Lines changed: 316 additions & 7 deletions

File tree

infrahub_sdk/spec/object.py

Lines changed: 46 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
from ..exceptions import ObjectValidationError, ValidationError
99
from ..schema import GenericSchemaAPI, RelationshipKind, RelationshipSchema
10+
from ..utils import is_valid_uuid
1011
from ..yaml import InfrahubFile, InfrahubFileKind
1112
from .models import InfrahubObjectParameters
1213
from .processors.factory import DataProcessorFactory
@@ -33,6 +34,36 @@ def validate_list_of_objects(value: list[Any]) -> bool:
3334
return all(isinstance(item, dict) for item in value)
3435

3536

37+
def normalize_hfid_reference(value: str | list[str]) -> str | list[str]:
38+
"""Normalize a reference value to HFID format.
39+
40+
Only call this function when the peer schema has human_friendly_id defined.
41+
42+
Args:
43+
value: Either a string (ID or single-component HFID) or a list of strings (multi-component HFID).
44+
45+
Returns:
46+
- If value is already a list: returns it unchanged as list[str]
47+
- If value is a valid UUID string: returns it unchanged as str (will be treated as an ID)
48+
- If value is a non-UUID string: wraps it in a list as list[str] (single-component HFID)
49+
"""
50+
if isinstance(value, list):
51+
return value
52+
if is_valid_uuid(value):
53+
return value
54+
return [value]
55+
56+
57+
def normalize_hfid_references(values: list[str | list[str]]) -> list[str | list[str]]:
58+
"""Normalize a list of reference values to HFID format.
59+
60+
Only call this function when the peer schema has human_friendly_id defined.
61+
62+
Each string that is not a valid UUID will be wrapped in a list to treat it as a single-component HFID.
63+
"""
64+
return [normalize_hfid_reference(v) for v in values]
65+
66+
3667
class RelationshipDataFormat(str, Enum):
3768
UNKNOWN = "unknown"
3869

@@ -51,6 +82,7 @@ class RelationshipInfo(BaseModel):
5182
peer_rel: RelationshipSchema | None = None
5283
reason_relationship_not_valid: str | None = None
5384
format: RelationshipDataFormat = RelationshipDataFormat.UNKNOWN
85+
peer_has_hfid: bool = False
5486

5587
@property
5688
def is_bidirectional(self) -> bool:
@@ -119,6 +151,7 @@ async def get_relationship_info(
119151
info.peer_kind = value["kind"]
120152

121153
peer_schema = await client.schema.get(kind=info.peer_kind, branch=branch)
154+
info.peer_has_hfid = bool(peer_schema.human_friendly_id)
122155

123156
try:
124157
info.peer_rel = peer_schema.get_matching_relationship(
@@ -444,10 +477,19 @@ async def create_node(
444477
# - if the relationship is bidirectional and is mandatory on the other side, then we need to create this object First
445478
# - if the relationship is bidirectional and is not mandatory on the other side, then we need should create the related object First
446479
# - if the relationship is not bidirectional, then we need to create the related object First
447-
if rel_info.is_reference and isinstance(value, list):
448-
clean_data[key] = value
449-
elif rel_info.format == RelationshipDataFormat.ONE_REF and isinstance(value, str):
450-
clean_data[key] = [value]
480+
if rel_info.format == RelationshipDataFormat.MANY_REF and isinstance(value, list):
481+
# Cardinality-many reference: normalize string HFIDs to list format if peer has HFID defined
482+
if rel_info.peer_has_hfid:
483+
clean_data[key] = normalize_hfid_references(value)
484+
else:
485+
clean_data[key] = value
486+
elif rel_info.format == RelationshipDataFormat.ONE_REF:
487+
# Cardinality-one reference: normalize string to HFID list format only if peer has HFID defined
488+
if rel_info.peer_has_hfid:
489+
clean_data[key] = normalize_hfid_reference(value)
490+
else:
491+
# No HFID defined, pass value as-is (string becomes {"id": ...}, list stays as-is)
492+
clean_data[key] = value
451493
elif not rel_info.is_reference and rel_info.is_bidirectional and rel_info.is_mandatory:
452494
remaining_rels.append(key)
453495
elif not rel_info.is_reference and not rel_info.is_mandatory:

tests/fixtures/schema_01.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -242,7 +242,10 @@
242242
"label": null,
243243
"inherit_from": [],
244244
"branch": "aware",
245-
"default_filter": "name__value"
245+
"default_filter": "name__value",
246+
"human_friendly_id": [
247+
"name__value"
248+
]
246249
},
247250
{
248251
"name": "Location",

tests/unit/sdk/spec/test_object.py

Lines changed: 266 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,23 @@
11
from __future__ import annotations
22

3-
from typing import TYPE_CHECKING
3+
from dataclasses import dataclass
4+
from typing import TYPE_CHECKING, Any
5+
from unittest.mock import AsyncMock, MagicMock, patch
46

57
import pytest
68

79
from infrahub_sdk.exceptions import ValidationError
8-
from infrahub_sdk.spec.object import ObjectFile, RelationshipDataFormat, get_relationship_info
10+
from infrahub_sdk.node.related_node import RelatedNode
11+
from infrahub_sdk.spec.object import (
12+
ObjectFile,
13+
RelationshipDataFormat,
14+
get_relationship_info,
15+
normalize_hfid_reference,
16+
)
917

1018
if TYPE_CHECKING:
1119
from infrahub_sdk.client import InfrahubClient
20+
from infrahub_sdk.node import InfrahubNode
1221

1322

1423
@pytest.fixture
@@ -263,3 +272,258 @@ async def test_parameters_non_dict(client_with_schema_01: InfrahubClient, locati
263272
obj = ObjectFile(location="some/path", content=location_with_non_dict_parameters)
264273
with pytest.raises(ValidationError):
265274
await obj.validate_format(client=client_with_schema_01)
275+
276+
277+
@dataclass
278+
class HfidLoadTestCase:
279+
"""Test case for HFID normalization in object loading."""
280+
281+
name: str
282+
data: list[dict[str, Any]]
283+
expected_primary_tag: str | list[str] | None
284+
expected_tags: list[str] | list[list[str]] | None
285+
286+
287+
HFID_NORMALIZATION_TEST_CASES = [
288+
HfidLoadTestCase(
289+
name="cardinality_one_string_hfid_normalized",
290+
data=[{"name": "Mexico", "type": "Country", "primary_tag": "Important"}],
291+
expected_primary_tag=["Important"],
292+
expected_tags=None,
293+
),
294+
HfidLoadTestCase(
295+
name="cardinality_one_list_hfid_unchanged",
296+
data=[{"name": "Mexico", "type": "Country", "primary_tag": ["Important"]}],
297+
expected_primary_tag=["Important"],
298+
expected_tags=None,
299+
),
300+
HfidLoadTestCase(
301+
name="cardinality_one_uuid_unchanged",
302+
data=[{"name": "Mexico", "type": "Country", "primary_tag": "550e8400-e29b-41d4-a716-446655440000"}],
303+
expected_primary_tag="550e8400-e29b-41d4-a716-446655440000",
304+
expected_tags=None,
305+
),
306+
HfidLoadTestCase(
307+
name="cardinality_many_string_hfids_normalized",
308+
data=[{"name": "Mexico", "type": "Country", "tags": ["Important", "Active"]}],
309+
expected_primary_tag=None,
310+
expected_tags=[["Important"], ["Active"]],
311+
),
312+
HfidLoadTestCase(
313+
name="cardinality_many_list_hfids_unchanged",
314+
data=[{"name": "Mexico", "type": "Country", "tags": [["Important"], ["Active"]]}],
315+
expected_primary_tag=None,
316+
expected_tags=[["Important"], ["Active"]],
317+
),
318+
HfidLoadTestCase(
319+
name="cardinality_many_mixed_hfids_normalized",
320+
data=[{"name": "Mexico", "type": "Country", "tags": ["Important", ["namespace", "name"]]}],
321+
expected_primary_tag=None,
322+
expected_tags=[["Important"], ["namespace", "name"]],
323+
),
324+
HfidLoadTestCase(
325+
name="cardinality_many_uuids_unchanged",
326+
data=[
327+
{
328+
"name": "Mexico",
329+
"type": "Country",
330+
"tags": ["550e8400-e29b-41d4-a716-446655440000", "6ba7b810-9dad-11d1-80b4-00c04fd430c8"],
331+
}
332+
],
333+
expected_primary_tag=None,
334+
expected_tags=["550e8400-e29b-41d4-a716-446655440000", "6ba7b810-9dad-11d1-80b4-00c04fd430c8"],
335+
),
336+
]
337+
338+
339+
@pytest.mark.parametrize("test_case", HFID_NORMALIZATION_TEST_CASES, ids=lambda tc: tc.name)
340+
async def test_hfid_normalization_in_object_loading(
341+
client_with_schema_01: InfrahubClient, test_case: HfidLoadTestCase
342+
) -> None:
343+
"""Test that HFIDs are normalized correctly based on cardinality and format."""
344+
345+
root_location = {"apiVersion": "infrahub.app/v1", "kind": "Object", "spec": {"kind": "BuiltinLocation", "data": []}}
346+
location = {
347+
"apiVersion": root_location["apiVersion"],
348+
"kind": root_location["kind"],
349+
"spec": {"kind": root_location["spec"]["kind"], "data": test_case.data},
350+
}
351+
352+
obj = ObjectFile(location="some/path", content=location)
353+
await obj.validate_format(client=client_with_schema_01)
354+
355+
create_calls: list[dict[str, Any]] = []
356+
357+
async def mock_create(
358+
kind: str,
359+
branch: str | None = None,
360+
data: dict | None = None,
361+
**kwargs: Any, # noqa: ANN401
362+
) -> InfrahubNode:
363+
create_calls.append({"kind": kind, "data": data})
364+
original_create = client_with_schema_01.__class__.create
365+
return await original_create(client_with_schema_01, kind=kind, branch=branch, data=data, **kwargs)
366+
367+
client_with_schema_01.create = mock_create
368+
369+
with patch("infrahub_sdk.node.InfrahubNode.save", new_callable=AsyncMock):
370+
await obj.process(client=client_with_schema_01)
371+
372+
assert len(create_calls) == 1
373+
if test_case.expected_primary_tag is not None:
374+
assert create_calls[0]["data"]["primary_tag"] == test_case.expected_primary_tag
375+
if test_case.expected_tags is not None:
376+
assert create_calls[0]["data"]["tags"] == test_case.expected_tags
377+
378+
379+
@dataclass
380+
class GraphQLPayloadTestCase:
381+
"""Test case for verifying data format that leads to correct GraphQL payload.
382+
383+
The RelatedNode interprets data as follows:
384+
- list → stored as hfid → GraphQL: {"hfid": [...]}
385+
- string → stored as id → GraphQL: {"id": "..."}
386+
"""
387+
388+
name: str
389+
peer_has_hfid: bool
390+
input_value: str | list[str]
391+
expected_output_type: str # "list" for hfid, "string" for id
392+
expected_output_value: str | list[str]
393+
394+
395+
GRAPHQL_PAYLOAD_TEST_CASES = [
396+
# Peer HAS HFID - non-UUID string should become list (hfid)
397+
GraphQLPayloadTestCase(
398+
name="hfid_defined_string_becomes_list",
399+
peer_has_hfid=True,
400+
input_value="Important",
401+
expected_output_type="list",
402+
expected_output_value=["Important"],
403+
),
404+
# Peer HAS HFID - UUID string should stay as string (id)
405+
GraphQLPayloadTestCase(
406+
name="hfid_defined_uuid_stays_string",
407+
peer_has_hfid=True,
408+
input_value="550e8400-e29b-41d4-a716-446655440000",
409+
expected_output_type="string",
410+
expected_output_value="550e8400-e29b-41d4-a716-446655440000",
411+
),
412+
# Peer HAS HFID - list stays as list (hfid)
413+
GraphQLPayloadTestCase(
414+
name="hfid_defined_list_stays_list",
415+
peer_has_hfid=True,
416+
input_value=["namespace", "name"],
417+
expected_output_type="list",
418+
expected_output_value=["namespace", "name"],
419+
),
420+
# Peer has NO HFID - non-UUID string stays as string (id lookup)
421+
GraphQLPayloadTestCase(
422+
name="no_hfid_string_stays_string",
423+
peer_has_hfid=False,
424+
input_value="some-string-value",
425+
expected_output_type="string",
426+
expected_output_value="some-string-value",
427+
),
428+
# Peer has NO HFID - UUID stays as string (id)
429+
GraphQLPayloadTestCase(
430+
name="no_hfid_uuid_stays_string",
431+
peer_has_hfid=False,
432+
input_value="550e8400-e29b-41d4-a716-446655440000",
433+
expected_output_type="string",
434+
expected_output_value="550e8400-e29b-41d4-a716-446655440000",
435+
),
436+
]
437+
438+
439+
@pytest.mark.parametrize("test_case", GRAPHQL_PAYLOAD_TEST_CASES, ids=lambda tc: tc.name)
440+
def test_graphql_payload_format(test_case: GraphQLPayloadTestCase) -> None:
441+
"""Test that relationship data is formatted correctly for GraphQL payload.
442+
443+
The RelatedNode class interprets:
444+
- list input → {"hfid": [...]} in GraphQL
445+
- string input → {"id": "..."} in GraphQL
446+
447+
This test verifies the normalization produces the correct format.
448+
"""
449+
if test_case.peer_has_hfid:
450+
# When peer has HFID, use normalization
451+
processed_value = normalize_hfid_reference(test_case.input_value)
452+
else:
453+
# When peer has no HFID, pass value as-is (no normalization)
454+
processed_value = test_case.input_value
455+
456+
# Verify the output type matches expected
457+
if test_case.expected_output_type == "list":
458+
assert isinstance(processed_value, list), (
459+
f"Expected list output for hfid, got {type(processed_value).__name__}: {processed_value}"
460+
)
461+
else:
462+
assert isinstance(processed_value, str), (
463+
f"Expected string output for id, got {type(processed_value).__name__}: {processed_value}"
464+
)
465+
466+
# Verify the actual value
467+
assert processed_value == test_case.expected_output_value, (
468+
f"Expected {test_case.expected_output_value}, got {processed_value}"
469+
)
470+
471+
472+
@dataclass
473+
class RelatedNodePayloadTestCase:
474+
"""Test case for verifying the actual GraphQL payload structure from RelatedNode."""
475+
476+
name: str
477+
input_data: str | list[str]
478+
expected_payload: dict[str, Any]
479+
480+
481+
RELATED_NODE_PAYLOAD_TEST_CASES = [
482+
# String (UUID) → {"id": "uuid"}
483+
RelatedNodePayloadTestCase(
484+
name="uuid_string_becomes_id_payload",
485+
input_data="550e8400-e29b-41d4-a716-446655440000",
486+
expected_payload={"id": "550e8400-e29b-41d4-a716-446655440000"},
487+
),
488+
# List (HFID) → {"hfid": [...]}
489+
RelatedNodePayloadTestCase(
490+
name="list_becomes_hfid_payload",
491+
input_data=["Important"],
492+
expected_payload={"hfid": ["Important"]},
493+
),
494+
# Multi-component HFID list → {"hfid": [...]}
495+
RelatedNodePayloadTestCase(
496+
name="multi_component_hfid_payload",
497+
input_data=["namespace", "name"],
498+
expected_payload={"hfid": ["namespace", "name"]},
499+
),
500+
]
501+
502+
503+
@pytest.mark.parametrize("test_case", RELATED_NODE_PAYLOAD_TEST_CASES, ids=lambda tc: tc.name)
504+
def test_related_node_graphql_payload(test_case: RelatedNodePayloadTestCase) -> None:
505+
"""Test that RelatedNode produces the correct GraphQL payload structure.
506+
507+
This test verifies the actual {"id": ...} or {"hfid": ...} payload
508+
that gets sent in GraphQL mutations.
509+
"""
510+
# Create mock dependencies
511+
mock_client = MagicMock()
512+
mock_schema = MagicMock()
513+
514+
# Create RelatedNode with the input data
515+
related_node = RelatedNode(
516+
schema=mock_schema,
517+
name="test_rel",
518+
branch="main",
519+
client=mock_client,
520+
data=test_case.input_data,
521+
)
522+
523+
# Generate the input data that would go into GraphQL mutation
524+
payload = related_node._generate_input_data()
525+
526+
# Verify the payload structure
527+
assert payload == test_case.expected_payload, (
528+
f"Expected payload {test_case.expected_payload}, got {payload}"
529+
)

0 commit comments

Comments
 (0)