diff --git a/backend/infrahub/core/convert_object_type/object_conversion.py b/backend/infrahub/core/convert_object_type/object_conversion.py index 29873b31a4..a2607e44d8 100644 --- a/backend/infrahub/core/convert_object_type/object_conversion.py +++ b/backend/infrahub/core/convert_object_type/object_conversion.py @@ -1,6 +1,6 @@ -from typing import Any, Self, assert_never +from typing import Any, assert_never -from pydantic import BaseModel, model_validator +from infrahub_sdk.convert_object_type import ConversionFieldInput, ConversionFieldValue from infrahub.core.attribute import BaseAttribute from infrahub.core.branch import Branch @@ -20,52 +20,14 @@ from infrahub.workers.dependencies import get_message_bus -class InputDataForDestField(BaseModel): # Only one of these fields can be not None - attribute_value: Any | None = None - peer_id: str | None = None - peers_ids: list[str] | None = None - - @model_validator(mode="after") - def check_only_one_field(self) -> Self: - fields = [self.attribute_value, self.peer_id, self.peers_ids] - set_fields = [f for f in fields if f is not None] - if len(set_fields) != 1: - raise ValueError("Exactly one of attribute_value, peer_id, or peers_ids must be set") - return self - - @property - def value(self) -> Any: - if self.attribute_value is not None: - return self.attribute_value - if self.peer_id is not None: - return self.peer_id - if self.peers_ids is not None: - return self.peers_ids - - raise ValueError( - "Exactly one of attribute_value, peer_id, or peers_ids must be set, model has not been validated correctly." - ) - - -class InputForDestField(BaseModel): # Only one of these fields can be not None - source_field: str | None = None - data: InputDataForDestField | None = None - - @model_validator(mode="after") - def check_only_one_field(self) -> Self: - if self.source_field is not None and self.data is not None: - raise ValueError("Only one of source_field or data can be set") - if self.source_field is None and self.data is None: - raise ValueError("Either source_field or data must be set") - return self - - @property - def value(self) -> Any: - if self.source_field is not None: - return self.source_field - if self.data is not None: - return self.data - raise ValueError("Either source_field or data must be set, model has not been validated correctly.") +def _get_conversion_field_raw_value(conv_field_value: ConversionFieldValue) -> Any: + if conv_field_value.attribute_value is not None: + return conv_field_value.attribute_value + if conv_field_value.peer_id is not None: + return conv_field_value.peer_id + if conv_field_value.peers_ids is not None: + return conv_field_value.peers_ids + raise ValueError("ConversionFieldValue has not been validated correctly.") async def get_out_rels_peers_ids(node: Node, db: InfrahubDatabase, at: Timestamp) -> list[str]: @@ -77,15 +39,15 @@ async def get_out_rels_peers_ids(node: Node, db: InfrahubDatabase, at: Timestamp return all_peers_ids -async def build_data_new_node(db: InfrahubDatabase, mapping: dict[str, InputForDestField], node: Node) -> dict: +async def build_data_new_node(db: InfrahubDatabase, mapping: dict[str, ConversionFieldInput], node: Node) -> dict: """Value of a given field on the target kind to convert is either an input source attribute/relationship of the source node, or a raw value.""" data = {} - for dest_field_name, input_for_dest_field in mapping.items(): - value = input_for_dest_field.value - if isinstance(value, str): # source_field - item = getattr(node, value) + for dest_field_name, conv_field_input in mapping.items(): + if conv_field_input.source_field is not None: + # Fetch the value of the corresponding field from the node being converted. + item = getattr(node, conv_field_input.source_field) if isinstance(item, BaseAttribute): data[dest_field_name] = item.value elif isinstance(item, RelationshipManager): @@ -98,8 +60,12 @@ async def build_data_new_node(db: InfrahubDatabase, mapping: dict[str, InputForD data[dest_field_name] = [{"id": peer.id} for _, peer in (await item.get_peers(db=db)).items()] else: assert_never(item.schema.cardinality) - else: # user input data - data[dest_field_name] = value.value + elif conv_field_input.data is not None: + data[dest_field_name] = _get_conversion_field_raw_value(conv_field_input.data) + elif conv_field_input.use_default_value is True: + pass # default value will be used automatically when creating the node + else: + raise ValueError("ConversionFieldInput has not been validated correctly.") return data @@ -129,7 +95,7 @@ async def _get_other_active_branches(db: InfrahubDatabase) -> list[Branch]: return [branch for branch in branches if not (branch.is_global or branch.is_default)] -def _has_pass_thru_aware_attributes(node_schema: NodeSchema, mapping: dict[str, InputForDestField]) -> bool: +def _has_pass_thru_aware_attributes(node_schema: NodeSchema, mapping: dict[str, ConversionFieldInput]) -> bool: aware_attributes = [attr for attr in node_schema.attributes if attr.branch != BranchSupportType.AGNOSTIC] aware_attributes_pass_thru = [ attr.name for attr in aware_attributes if attr.name in mapping and mapping[attr.name].source_field is not None @@ -157,7 +123,7 @@ async def validate_conversion( async def convert_and_validate_object_type( node: Node, target_schema: NodeSchema, - mapping: dict[str, InputForDestField], + mapping: dict[str, ConversionFieldInput], branch: Branch, db: InfrahubDatabase, ) -> Node: @@ -180,7 +146,7 @@ async def convert_and_validate_object_type( async def convert_object_type( node: Node, target_schema: NodeSchema, - mapping: dict[str, InputForDestField], + mapping: dict[str, ConversionFieldInput], branch: Branch, db: InfrahubDatabase, ) -> Node: diff --git a/backend/infrahub/core/convert_object_type/repository_conversion.py b/backend/infrahub/core/convert_object_type/repository_conversion.py index e6f93cb0da..62530c2149 100644 --- a/backend/infrahub/core/convert_object_type/repository_conversion.py +++ b/backend/infrahub/core/convert_object_type/repository_conversion.py @@ -2,7 +2,7 @@ from infrahub.core.branch import Branch from infrahub.core.constants.infrahubkind import REPOSITORYVALIDATOR, USERVALIDATOR from infrahub.core.convert_object_type.object_conversion import ( - InputForDestField, + ConversionFieldInput, convert_object_type, validate_conversion, ) @@ -20,7 +20,7 @@ async def convert_repository_type( repository: CoreRepository | CoreReadOnlyRepository, target_schema: NodeSchema, - mapping: dict[str, InputForDestField], + mapping: dict[str, ConversionFieldInput], branch: Branch, db: InfrahubDatabase, repository_post_creator: RepositoryFinalizer, diff --git a/backend/infrahub/graphql/mutations/convert_object_type.py b/backend/infrahub/graphql/mutations/convert_object_type.py index 636d67f489..eba0d5b500 100644 --- a/backend/infrahub/graphql/mutations/convert_object_type.py +++ b/backend/infrahub/graphql/mutations/convert_object_type.py @@ -6,7 +6,7 @@ from infrahub.core import registry from infrahub.core.constants.infrahubkind import READONLYREPOSITORY, REPOSITORY -from infrahub.core.convert_object_type.object_conversion import InputForDestField, convert_and_validate_object_type +from infrahub.core.convert_object_type.object_conversion import ConversionFieldInput, convert_and_validate_object_type from infrahub.core.convert_object_type.repository_conversion import convert_repository_type from infrahub.core.convert_object_type.schema_mapping import get_schema_mapping from infrahub.core.manager import NodeManager @@ -47,12 +47,12 @@ async def mutate( source_schema = registry.get_node_schema(name=node_to_convert.get_kind(), branch=graphql_context.branch) target_schema = registry.get_node_schema(name=str(data.target_kind), branch=graphql_context.branch) - fields_mapping: dict[str, InputForDestField] = {} + fields_mapping: dict[str, ConversionFieldInput] = {} if not isinstance(data.fields_mapping, dict): raise ValueError(f"Expected `fields_mapping` to be a `dict`, got {type(data.fields_mapping)}") for field_name, input_for_dest_field_str in data.fields_mapping.items(): - fields_mapping[field_name] = InputForDestField(**input_for_dest_field_str) + fields_mapping[field_name] = ConversionFieldInput(**input_for_dest_field_str) node_to_convert = await NodeManager.get_one( id=str(data.node_id), db=graphql_context.db, branch=graphql_context.branch @@ -62,7 +62,7 @@ async def mutate( mapping = get_schema_mapping(source_schema=source_schema, target_schema=target_schema) for field_name, mapping_value in mapping.items(): if mapping_value.source_field_name is not None and field_name not in fields_mapping: - fields_mapping[field_name] = InputForDestField(source_field=mapping_value.source_field_name) + fields_mapping[field_name] = ConversionFieldInput(source_field=mapping_value.source_field_name) if target_schema.kind in [REPOSITORY, READONLYREPOSITORY]: new_node = await convert_repository_type( diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 8db256afd4..0c82c9a7c5 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -1255,6 +1255,7 @@ async def schemas_conversion(db: InfrahubDatabase, node_group_schema, data_schem "attributes": [ {"name": "name", "kind": "Text", "unique": True}, {"name": "height", "kind": "Number", "optional": True}, + {"name": "favorite_color", "kind": "Text", "optional": True, "default_value": "blue"}, ], "relationships": [ { diff --git a/backend/tests/functional/convert_object_type/test_convert_object_type.py b/backend/tests/functional/convert_object_type/test_convert_object_type.py index f53fdd2677..b8eb433d64 100644 --- a/backend/tests/functional/convert_object_type/test_convert_object_type.py +++ b/backend/tests/functional/convert_object_type/test_convert_object_type.py @@ -3,9 +3,9 @@ from typing import TYPE_CHECKING import pytest +from infrahub_sdk.convert_object_type import ConversionFieldInput, ConversionFieldValue from infrahub.core.constants.infrahubkind import NUMBERPOOL -from infrahub.core.convert_object_type.object_conversion import InputDataForDestField, InputForDestField from infrahub.core.query.resource_manager import NumberPoolGetReserved from infrahub.core.schema import AttributeSchema, GenericSchema, NodeSchema, SchemaRoot from tests.helpers.test_app import TestInfrahubApp @@ -59,6 +59,11 @@ async def test_get_fields_mapping(self, client: InfrahubClient, schemas_conversi "age": {"is_mandatory": True, "source_field_name": None, "relationship_cardinality": None}, "name": {"is_mandatory": True, "source_field_name": "name", "relationship_cardinality": None}, "height": {"is_mandatory": False, "source_field_name": "height", "relationship_cardinality": None}, + "favorite_color": { + "is_mandatory": False, + "source_field_name": "favorite_color", + "relationship_cardinality": None, + }, "subscriber_of_groups": { "is_mandatory": False, "source_field_name": "subscriber_of_groups", @@ -112,18 +117,20 @@ async def test_convert_object_type(self, client: InfrahubClient, schemas_convers kind="TestconvPerson1", name="Jack", height=170, + favorite_color="green", favorite_car=car_1, fastest_cars=[car_1, car_2], ) await jack_1.save() mapping = { - "name": InputForDestField(source_field="name"), - "age": InputForDestField(data=InputDataForDestField(attribute_value=25)), - "worst_car": InputForDestField(data=InputDataForDestField(peer_id=car_1.id)), - "fastest_cars": InputForDestField(source_field="fastest_cars"), - "slowest_cars": InputForDestField(data=InputDataForDestField(peers_ids=[car_1.id])), - "bags": InputForDestField(data=InputDataForDestField(peers_ids=[])), + "name": ConversionFieldInput(source_field="name"), + "age": ConversionFieldInput(data=ConversionFieldValue(attribute_value=25)), + "worst_car": ConversionFieldInput(data=ConversionFieldValue(peer_id=car_1.id)), + "fastest_cars": ConversionFieldInput(source_field="fastest_cars"), + "slowest_cars": ConversionFieldInput(data=ConversionFieldValue(peers_ids=[car_1.id])), + "bags": ConversionFieldInput(data=ConversionFieldValue(peers_ids=[])), + "favorite_color": ConversionFieldInput(use_default_value=True), } mapping_dict = {field_name: model.model_dump(mode="json") for field_name, model in mapping.items()} @@ -143,6 +150,7 @@ async def test_convert_object_type(self, client: InfrahubClient, schemas_convers assert res_node["age"]["value"] == 25 assert res_node["name"]["value"] == "Jack" assert res_node["height"]["value"] == 170 + assert res_node["favorite_color"]["value"] == "blue" class TestConvertObjectTypeResourcePool(TestInfrahubApp): diff --git a/backend/tests/functional/convert_object_type/test_convert_repositories.py b/backend/tests/functional/convert_object_type/test_convert_repositories.py index 038b90363b..d063c099ab 100644 --- a/backend/tests/functional/convert_object_type/test_convert_repositories.py +++ b/backend/tests/functional/convert_object_type/test_convert_repositories.py @@ -9,7 +9,7 @@ from infrahub.core.branch.enums import BranchStatus from infrahub.core.branch.models import Branch -from infrahub.core.convert_object_type.object_conversion import InputDataForDestField, InputForDestField +from infrahub.core.convert_object_type.object_conversion import ConversionFieldInput, ConversionFieldValue from infrahub.core.initialization import create_branch from infrahub.core.node import Node from infrahub.core.query.delete import DeleteAfterTimeQuery @@ -210,11 +210,11 @@ async def test_convert_repo_to_read_only( mapping = {} for field_name, field_infos in conversion_response["FieldsMappingTypeConversion"]["mapping"].items(): if field_infos["source_field_name"] is not None: - mapping[field_name] = InputForDestField(source_field=field_infos["source_field_name"]) + mapping[field_name] = ConversionFieldInput(source_field=field_infos["source_field_name"]) else: assert field_name == "ref" - mapping["ref"] = InputForDestField(data=InputDataForDestField(attribute_value=repository.commit.value)) + mapping["ref"] = ConversionFieldInput(data=ConversionFieldValue(attribute_value=repository.commit.value)) mapping_dict = {field_name: model.model_dump(mode="json") for field_name, model in mapping.items()} with patch("infrahub.git.tasks.lock"): @@ -356,11 +356,11 @@ async def test_convert_read_only_to_read_write( mapping = {} for field_name, field_infos in conversion_response["FieldsMappingTypeConversion"]["mapping"].items(): if field_infos["source_field_name"] is not None: - mapping[field_name] = InputForDestField(source_field=field_infos["source_field_name"]) + mapping[field_name] = ConversionFieldInput(source_field=field_infos["source_field_name"]) else: assert field_name == "default_branch" - mapping["default_branch"] = InputForDestField(data=InputDataForDestField(attribute_value=default_branch.name)) + mapping["default_branch"] = ConversionFieldInput(data=ConversionFieldValue(attribute_value=default_branch.name)) mapping_dict = {field_name: model.model_dump(mode="json") for field_name, model in mapping.items()} with patch("infrahub.git.tasks.lock"): @@ -495,11 +495,11 @@ async def test_convert_to_read_write_on_main_create_branch_before( mapping = {} for field_name, field_infos in conversion_response["FieldsMappingTypeConversion"]["mapping"].items(): if field_infos["source_field_name"] is not None: - mapping[field_name] = InputForDestField(source_field=field_infos["source_field_name"]) + mapping[field_name] = ConversionFieldInput(source_field=field_infos["source_field_name"]) else: assert field_name == "default_branch" - mapping["default_branch"] = InputForDestField(data=InputDataForDestField(attribute_value=default_branch.name)) + mapping["default_branch"] = ConversionFieldInput(data=ConversionFieldValue(attribute_value=default_branch.name)) mapping_dict = {field_name: model.model_dump(mode="json") for field_name, model in mapping.items()} with patch("infrahub.git.tasks.lock"): diff --git a/backend/tests/unit/core/convert_object_type/test_convert_object_type.py b/backend/tests/unit/core/convert_object_type/test_convert_object_type.py index 28a6d535cb..21b159e54f 100644 --- a/backend/tests/unit/core/convert_object_type/test_convert_object_type.py +++ b/backend/tests/unit/core/convert_object_type/test_convert_object_type.py @@ -9,8 +9,8 @@ from infrahub.core.branch.enums import BranchStatus from infrahub.core.constants import GLOBAL_BRANCH_NAME, RelationshipCardinality from infrahub.core.convert_object_type.object_conversion import ( - InputDataForDestField, - InputForDestField, + ConversionFieldInput, + ConversionFieldValue, convert_and_validate_object_type, ) from infrahub.core.convert_object_type.schema_mapping import SchemaMappingValue, get_schema_mapping @@ -36,12 +36,14 @@ async def test_schema_conversion_mapping( source_schema = registry.get_node_schema(name="TestconvPerson1", branch=branch) target_schema = registry.get_node_schema(name="TestconvPerson2", branch=branch) mapping = get_schema_mapping(source_schema=source_schema, target_schema=target_schema) - assert len(mapping) == 12 + assert len(mapping) == 13 assert mapping["name"] == SchemaMappingValue(source_field_name="name", is_mandatory=True) assert mapping["height"] == SchemaMappingValue(source_field_name="height", is_mandatory=False) assert mapping["age"] == SchemaMappingValue(source_field_name=None, is_mandatory=True) assert mapping["citizenship"] == SchemaMappingValue(source_field_name=None, is_mandatory=False) - + assert mapping["favorite_color"] == SchemaMappingValue( + source_field_name="favorite_color", relationship_cardinality=None, is_mandatory=False + ) assert mapping["favorite_car"] == SchemaMappingValue( source_field_name="favorite_car", relationship_cardinality=RelationshipCardinality.ONE, is_mandatory=False ) @@ -105,14 +107,14 @@ async def test_convert_object_type( ) mapping = { - "name": InputForDestField(source_field="name"), - "height": InputForDestField(source_field="height"), - "age": InputForDestField(data=InputDataForDestField(attribute_value=25)), - "favorite_car": InputForDestField(source_field="favorite_car"), - "worst_car": InputForDestField(data=InputDataForDestField(peer_id=car_3.id)), - "fastest_cars": InputForDestField(source_field="fastest_cars"), - "slowest_cars": InputForDestField(data=InputDataForDestField(peers_ids=[car_3.id])), - "bags": InputForDestField(source_field="bags"), + "name": ConversionFieldInput(source_field="name"), + "height": ConversionFieldInput(source_field="height"), + "age": ConversionFieldInput(data=ConversionFieldValue(attribute_value=25)), + "favorite_car": ConversionFieldInput(source_field="favorite_car"), + "worst_car": ConversionFieldInput(data=ConversionFieldValue(peer_id=car_3.id)), + "fastest_cars": ConversionFieldInput(source_field="fastest_cars"), + "slowest_cars": ConversionFieldInput(data=ConversionFieldValue(peers_ids=[car_3.id])), + "bags": ConversionFieldInput(source_field="bags"), } person_2_schema = registry.get_node_schema(name="TestconvPerson2", branch=branch) @@ -136,6 +138,7 @@ async def test_convert_object_type( assert jack_2.name.value == jack_1.name.value assert jack_2.height.value == jack_1.height.value assert jack_2.age.value == 25 + assert jack_2.favorite_color.value == "blue" assert jack_2.citizenship.value is None assert (await jack_2.favorite_car.get_peer(db=db)).id == car_1.id @@ -176,7 +179,7 @@ async def test_raise_on_break_mandatory_relationship( ) mapping = { - "name": InputForDestField(source_field="name"), + "name": ConversionFieldInput(source_field="name"), } person_2_schema = registry.get_node_schema(name="TestmoPerson2", branch=branch) @@ -187,8 +190,8 @@ async def test_raise_on_break_mandatory_relationship( # And make sure it works when setting a new owner to the car mapping = { - "name": InputForDestField(source_field="name"), - "my_car": InputForDestField(data=InputDataForDestField(peer_id=car_1.id)), + "name": ConversionFieldInput(source_field="name"), + "my_car": ConversionFieldInput(data=ConversionFieldValue(peer_id=car_1.id)), } await convert_and_validate_object_type( node=jack_1, target_schema=person_2_schema, mapping=mapping, db=db, branch=branch @@ -225,7 +228,7 @@ async def test_raise_on_break_mandatory_unidirectional_relationship( ) mapping = { - "name": InputForDestField(source_field="name"), + "name": ConversionFieldInput(source_field="name"), } person_2_schema = registry.get_node_schema(name="TestudPerson2", branch=default_branch) @@ -254,9 +257,9 @@ async def test_agnostic_attributes( ) mapping = { - "name_agnostic": InputForDestField(source_field="name_agnostic"), - "age_2_aware": InputForDestField(source_field="age_1_agnostic"), - "height_2_agnostic": InputForDestField(source_field="height_1_aware"), + "name_agnostic": ConversionFieldInput(source_field="name_agnostic"), + "age_2_aware": ConversionFieldInput(source_field="age_1_agnostic"), + "height_2_agnostic": ConversionFieldInput(source_field="height_1_aware"), } person_2_schema = registry.get_node_schema(name="TestbsPerson2", branch=default_branch) @@ -305,11 +308,11 @@ async def test_agnostic_node_with_aware_attributes( _ = await create_branch(branch_name=branch_name, db=db) mapping = { - "name_agnostic": InputForDestField(source_field="name_agnostic"), - "age_aware": InputForDestField(source_field="age_aware"), - "height_aware": InputForDestField(data=InputDataForDestField(attribute_value=170)), - "favorite_car": InputForDestField(source_field="favorite_car"), - "other_cars": InputForDestField(source_field="other_cars"), + "name_agnostic": ConversionFieldInput(source_field="name_agnostic"), + "age_aware": ConversionFieldInput(source_field="age_aware"), + "height_aware": ConversionFieldInput(data=ConversionFieldValue(attribute_value=170)), + "favorite_car": ConversionFieldInput(source_field="favorite_car"), + "other_cars": ConversionFieldInput(source_field="other_cars"), } person_2_schema = registry.get_node_schema(name="TestaaPerson2", branch=default_branch) diff --git a/python_sdk b/python_sdk index 23a55e2e30..6cdd7809db 160000 --- a/python_sdk +++ b/python_sdk @@ -1 +1 @@ -Subproject commit 23a55e2e3013824c4bf7119c0e9657c27523ba8b +Subproject commit 6cdd7809db2f53887c71dd33313d1d93ad335b66