Skip to content

Commit aea909a

Browse files
committed
Add convert_object_type method
1 parent 654fc80 commit aea909a

File tree

5 files changed

+227
-0
lines changed

5 files changed

+227
-0
lines changed
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add `convert_object_type` method to allow converting an object to another type.

infrahub_sdk/client.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
)
3434
from .config import Config
3535
from .constants import InfrahubClientMode
36+
from .convert_object_type import CONVERT_OBJECT_MUTATION, ConversionFieldInput
3637
from .data import RepositoryBranchInfo, RepositoryData
3738
from .diff import NodeDiff, diff_tree_node_to_node_diff, get_diff_summary_query
3839
from .exceptions import (
@@ -1670,6 +1671,37 @@ async def __aexit__(
16701671

16711672
self.mode = InfrahubClientMode.DEFAULT
16721673

1674+
async def convert_object_type(
1675+
self,
1676+
node_id: str,
1677+
target_kind: str,
1678+
branch: str | None = None,
1679+
fields_mapping: dict[str, ConversionFieldInput] | None = None,
1680+
) -> InfrahubNode:
1681+
"""
1682+
Convert a given node to another kind on a given branch. `fields_mapping` keys are target fields names
1683+
and its values indicate how to fill in these fields. Any mandatory field not having an equivalent field
1684+
in the source kind should be specified in this mapping. See https://docs.infrahub.app/guides/object-convert-type
1685+
for more information.
1686+
"""
1687+
1688+
if fields_mapping is None:
1689+
mapping_dict = {}
1690+
else:
1691+
mapping_dict = {field_name: model.model_dump(mode="json") for field_name, model in fields_mapping.items()}
1692+
1693+
branch_name = branch or self.default_branch
1694+
response = await self.execute_graphql(
1695+
query=CONVERT_OBJECT_MUTATION,
1696+
variables={
1697+
"node_id": node_id,
1698+
"fields_mapping": mapping_dict,
1699+
"target_kind": target_kind,
1700+
},
1701+
branch_name=branch_name,
1702+
)
1703+
return await InfrahubNode.from_graphql(client=self, branch=branch_name, data=response["ConvertObjectType"])
1704+
16731705

16741706
class InfrahubClientSync(BaseClient):
16751707
schema: InfrahubSchemaSync
@@ -2984,3 +3016,34 @@ def __exit__(
29843016
self.group_context.update_group()
29853017

29863018
self.mode = InfrahubClientMode.DEFAULT
3019+
3020+
def convert_object_type(
3021+
self,
3022+
node_id: str,
3023+
target_kind: str,
3024+
branch: str | None = None,
3025+
fields_mapping: dict[str, ConversionFieldInput] | None = None,
3026+
) -> InfrahubNodeSync:
3027+
"""
3028+
Convert a given node to another kind on a given branch. `fields_mapping` keys are target fields names
3029+
and its values indicate how to fill in these fields. Any mandatory field not having an equivalent field
3030+
in the source kind should be specified in this mapping. See https://docs.infrahub.app/guides/object-convert-type
3031+
for more information.
3032+
"""
3033+
3034+
if fields_mapping is None:
3035+
mapping_dict = {}
3036+
else:
3037+
mapping_dict = {field_name: model.model_dump(mode="json") for field_name, model in fields_mapping.items()}
3038+
3039+
branch_name = branch or self.default_branch
3040+
response = self.execute_graphql(
3041+
query=CONVERT_OBJECT_MUTATION,
3042+
variables={
3043+
"node_id": node_id,
3044+
"fields_mapping": mapping_dict,
3045+
"target_kind": target_kind,
3046+
},
3047+
branch_name=branch_name,
3048+
)
3049+
return InfrahubNodeSync.from_graphql(client=self, branch=branch_name, data=response["ConvertObjectType"])
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
from __future__ import annotations
2+
3+
from typing import Any, Self
4+
5+
from pydantic import BaseModel, model_validator
6+
7+
CONVERT_OBJECT_MUTATION = """
8+
mutation($node_id: String!, $target_kind: String!, $fields_mapping: GenericScalar!) {
9+
ConvertObjectType(data: {
10+
node_id: $node_id,
11+
target_kind: $target_kind,
12+
fields_mapping: $fields_mapping
13+
}) {
14+
ok
15+
node
16+
}
17+
}
18+
"""
19+
20+
21+
class ConversionFieldValue(BaseModel): # Only one of these fields can be not None
22+
"""
23+
Holds the new value of the destination field during an object conversion.
24+
Use `attribute_value` to specify the new raw value of an attribute.
25+
Use `peer_id` to specify new peer of a cardinality one relationship.
26+
Use `peer_ids` to specify new peers of a cardinality many relationship.
27+
Only one of `attribute_value`, `peer_id` and `peers_ids` can be specified.
28+
"""
29+
30+
attribute_value: Any | None = None
31+
peer_id: str | None = None
32+
peers_ids: list[str] | None = None
33+
34+
@model_validator(mode="after")
35+
def check_only_one_field(self) -> Self:
36+
fields = [self.attribute_value, self.peer_id, self.peers_ids]
37+
set_fields = [f for f in fields if f is not None]
38+
if len(set_fields) != 1:
39+
raise ValueError("Exactly one of attribute_value, peer_id, or peers_ids must be set")
40+
return self
41+
42+
43+
class ConversionFieldInput(BaseModel):
44+
"""
45+
Indicates how to fill in the value of the destination field during an object conversion.
46+
Use `source_field` to reuse the value of the corresponding field of the object being converted.
47+
Use `data` to specify the new value for the field.
48+
Only one of `source_field` or `data` can be specified.
49+
"""
50+
51+
source_field: str | None = None
52+
data: ConversionFieldValue | None = None
53+
54+
@model_validator(mode="after")
55+
def check_only_one_field(self) -> Self:
56+
if self.source_field is not None and self.data is not None:
57+
raise ValueError("Only one of source_field or data can be set")
58+
if self.source_field is None and self.data is None:
59+
raise ValueError("Either source_field or data must be set")
60+
return self

tests/constants.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
CLIENT_TYPE_ASYNC = "standard"
2+
CLIENT_TYPE_SYNC = "sync"
3+
CLIENT_TYPES = [CLIENT_TYPE_ASYNC, CLIENT_TYPE_SYNC]
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
from __future__ import annotations
2+
3+
import uuid
4+
from typing import Any
5+
6+
import pytest
7+
8+
from infrahub_sdk.convert_object_type import ConversionFieldInput, ConversionFieldValue
9+
from infrahub_sdk.testing.docker import TestInfrahubDockerClient
10+
from tests.constants import CLIENT_TYPE_ASYNC, CLIENT_TYPES
11+
12+
SCHEMA: dict[str, Any] = {
13+
"version": "1.0",
14+
"generics": [
15+
{
16+
"name": "PersonGeneric",
17+
"namespace": "Testconv",
18+
"human_friendly_id": ["name__value"],
19+
"attributes": [
20+
{"name": "name", "kind": "Text", "unique": True},
21+
],
22+
},
23+
],
24+
"nodes": [
25+
{
26+
"name": "Person1",
27+
"namespace": "Testconv",
28+
"inherit_from": ["TestconvPersonGeneric"],
29+
},
30+
{
31+
"name": "Person2",
32+
"namespace": "Testconv",
33+
"inherit_from": ["TestconvPersonGeneric"],
34+
"attributes": [
35+
{"name": "age", "kind": "Number"},
36+
],
37+
"relationships": [
38+
{
39+
"name": "my_car",
40+
"peer": "TestconvCar",
41+
"cardinality": "one",
42+
"identifier": "person__mandatory_owner",
43+
},
44+
{
45+
"name": "fastest_cars",
46+
"peer": "TestconvCar",
47+
"cardinality": "many",
48+
"identifier": "person__fastest_cars",
49+
},
50+
],
51+
},
52+
{
53+
"name": "Car",
54+
"namespace": "Testconv",
55+
"human_friendly_id": ["name__value"],
56+
"attributes": [
57+
{"name": "name", "kind": "Text"},
58+
],
59+
},
60+
],
61+
}
62+
63+
64+
class TestConvertObjectType(TestInfrahubDockerClient):
65+
@pytest.mark.parametrize("client_type", CLIENT_TYPES)
66+
async def test_convert_object_type(self, client, client_sync, client_type) -> None:
67+
resp = await client.schema.load(schemas=[SCHEMA], wait_until_converged=True)
68+
assert not resp.errors
69+
70+
person_1 = await client.create(kind="TestconvPerson1", name=f"person_{uuid.uuid4()}")
71+
await person_1.save()
72+
car_1 = await client.create(kind="TestconvCar", name=f"car_{uuid.uuid4()}")
73+
await car_1.save()
74+
75+
new_age = 25
76+
fields_mapping = {
77+
"name": ConversionFieldInput(source_field="name"),
78+
"age": ConversionFieldInput(data=ConversionFieldValue(attribute_value=new_age)),
79+
"worst_car": ConversionFieldInput(data=ConversionFieldValue(peer_id=car_1.id)),
80+
"fastest_cars": ConversionFieldInput(data=ConversionFieldValue(peers_ids=[car_1.id])),
81+
}
82+
83+
if client_type == CLIENT_TYPE_ASYNC:
84+
person_2 = await client.convert_object_type(
85+
node_id=person_1.id,
86+
target_kind="TestconvPerson2",
87+
branch=client.default_branch,
88+
fields_mapping=fields_mapping,
89+
)
90+
else:
91+
person_2 = client_sync.convert_object_type(
92+
node_id=person_1.id,
93+
target_kind="TestconvPerson2",
94+
branch=client.default_branch,
95+
fields_mapping=fields_mapping,
96+
)
97+
98+
assert person_2.get_kind() == "TestconvPerson2"
99+
assert person_2.name.value == person_1.name.value
100+
assert person_2.age.value == new_age

0 commit comments

Comments
 (0)