infrahub-server 1.3.0b5__py3-none-any.whl → 1.3.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. infrahub/actions/constants.py +36 -79
  2. infrahub/actions/schema.py +2 -0
  3. infrahub/cli/db.py +7 -5
  4. infrahub/cli/upgrade.py +6 -1
  5. infrahub/core/attribute.py +5 -0
  6. infrahub/core/constraint/node/runner.py +3 -1
  7. infrahub/core/convert_object_type/conversion.py +2 -0
  8. infrahub/core/diff/coordinator.py +8 -1
  9. infrahub/core/diff/query/delete_query.py +8 -4
  10. infrahub/core/diff/query/field_specifiers.py +1 -1
  11. infrahub/core/diff/query/merge.py +2 -2
  12. infrahub/core/diff/repository/repository.py +4 -0
  13. infrahub/core/graph/__init__.py +1 -1
  14. infrahub/core/migrations/graph/__init__.py +2 -0
  15. infrahub/core/migrations/graph/m012_convert_account_generic.py +1 -1
  16. infrahub/core/migrations/graph/m015_diff_format_update.py +1 -2
  17. infrahub/core/migrations/graph/m016_diff_delete_bug_fix.py +1 -2
  18. infrahub/core/migrations/graph/m023_deduplicate_cardinality_one_relationships.py +2 -2
  19. infrahub/core/migrations/graph/m028_delete_diffs.py +1 -2
  20. infrahub/core/migrations/graph/m029_duplicates_cleanup.py +2 -2
  21. infrahub/core/migrations/graph/m031_check_number_attributes.py +102 -0
  22. infrahub/core/migrations/query/attribute_rename.py +1 -1
  23. infrahub/core/node/__init__.py +70 -37
  24. infrahub/core/path.py +14 -0
  25. infrahub/core/query/delete.py +3 -3
  26. infrahub/core/relationship/constraints/count.py +10 -9
  27. infrahub/core/relationship/constraints/interface.py +2 -1
  28. infrahub/core/relationship/constraints/peer_kind.py +2 -1
  29. infrahub/core/relationship/constraints/peer_parent.py +56 -0
  30. infrahub/core/relationship/constraints/peer_relatives.py +1 -1
  31. infrahub/core/relationship/constraints/profiles_kind.py +1 -1
  32. infrahub/core/schema/attribute_parameters.py +12 -5
  33. infrahub/core/schema/basenode_schema.py +107 -1
  34. infrahub/core/schema/definitions/internal.py +8 -1
  35. infrahub/core/schema/generated/relationship_schema.py +6 -1
  36. infrahub/core/schema/schema_branch.py +53 -13
  37. infrahub/core/validators/__init__.py +2 -1
  38. infrahub/core/validators/attribute/min_max.py +7 -2
  39. infrahub/core/validators/relationship/peer.py +174 -4
  40. infrahub/database/__init__.py +0 -1
  41. infrahub/dependencies/builder/constraint/grouped/node_runner.py +2 -0
  42. infrahub/dependencies/builder/constraint/relationship_manager/peer_parent.py +8 -0
  43. infrahub/dependencies/builder/constraint/schema/aggregated.py +2 -0
  44. infrahub/dependencies/builder/constraint/schema/relationship_peer.py +8 -0
  45. infrahub/dependencies/registry.py +2 -0
  46. infrahub/git/tasks.py +1 -0
  47. infrahub/graphql/app.py +5 -1
  48. infrahub/graphql/mutations/convert_object_type.py +16 -7
  49. infrahub/graphql/mutations/relationship.py +32 -0
  50. infrahub/graphql/queries/convert_object_type_mapping.py +3 -5
  51. infrahub/message_bus/operations/refresh/registry.py +3 -6
  52. infrahub/pools/models.py +14 -0
  53. infrahub/pools/tasks.py +71 -1
  54. infrahub/services/adapters/message_bus/nats.py +5 -1
  55. infrahub/services/scheduler.py +5 -1
  56. infrahub_sdk/ctl/generator.py +4 -4
  57. infrahub_sdk/ctl/repository.py +1 -1
  58. infrahub_sdk/node/__init__.py +2 -0
  59. infrahub_sdk/node/node.py +166 -93
  60. infrahub_sdk/pytest_plugin/items/python_transform.py +2 -1
  61. infrahub_sdk/query_groups.py +4 -3
  62. infrahub_sdk/utils.py +7 -20
  63. infrahub_sdk/yaml.py +6 -5
  64. {infrahub_server-1.3.0b5.dist-info → infrahub_server-1.3.1.dist-info}/METADATA +2 -2
  65. {infrahub_server-1.3.0b5.dist-info → infrahub_server-1.3.1.dist-info}/RECORD +68 -63
  66. {infrahub_server-1.3.0b5.dist-info → infrahub_server-1.3.1.dist-info}/LICENSE.txt +0 -0
  67. {infrahub_server-1.3.0b5.dist-info → infrahub_server-1.3.1.dist-info}/WHEEL +0 -0
  68. {infrahub_server-1.3.0b5.dist-info → infrahub_server-1.3.1.dist-info}/entry_points.txt +0 -0
@@ -7,6 +7,7 @@ from infrahub_sdk.template import Jinja2Template
7
7
  from infrahub_sdk.utils import is_valid_uuid
8
8
  from infrahub_sdk.uuidt import UUIDT
9
9
 
10
+ from infrahub import lock
10
11
  from infrahub.core import registry
11
12
  from infrahub.core.changelog.models import NodeChangelog
12
13
  from infrahub.core.constants import (
@@ -34,6 +35,7 @@ from infrahub.core.schema import (
34
35
  from infrahub.core.schema.attribute_parameters import NumberPoolParameters
35
36
  from infrahub.core.timestamp import Timestamp
36
37
  from infrahub.exceptions import InitializationError, NodeNotFoundError, PoolExhaustedError, ValidationError
38
+ from infrahub.pools.models import NumberPoolLockDefinition
37
39
  from infrahub.types import ATTRIBUTE_TYPES
38
40
 
39
41
  from ...graphql.constants import KIND_GRAPHQL_FIELD_NAME
@@ -68,7 +70,9 @@ log = get_logger()
68
70
 
69
71
  class Node(BaseNode, metaclass=BaseNodeMeta):
70
72
  @classmethod
71
- def __init_subclass_with_meta__(cls, _meta=None, default_filter=None, **options) -> None:
73
+ def __init_subclass_with_meta__(
74
+ cls, _meta: BaseNodeOptions | None = None, default_filter: None = None, **options: dict[str, Any]
75
+ ) -> None:
72
76
  if not _meta:
73
77
  _meta = BaseNodeOptions(cls)
74
78
 
@@ -271,7 +275,7 @@ class Node(BaseNode, metaclass=BaseNodeMeta):
271
275
  )
272
276
  except NodeNotFoundError:
273
277
  if number_pool_parameters:
274
- number_pool = await self._create_number_pool(
278
+ number_pool = await self._fetch_or_create_number_pool(
275
279
  db=db, attribute=attribute, number_pool_parameters=number_pool_parameters
276
280
  )
277
281
 
@@ -306,35 +310,49 @@ class Node(BaseNode, metaclass=BaseNodeMeta):
306
310
  )
307
311
  )
308
312
 
309
- async def _create_number_pool(
313
+ async def _fetch_or_create_number_pool(
310
314
  self, db: InfrahubDatabase, attribute: BaseAttribute, number_pool_parameters: NumberPoolParameters
311
315
  ) -> CoreNumberPool:
312
- schema = db.schema.get_node_schema(name="CoreNumberPool", duplicate=False)
313
-
314
- pool_node = self._schema.kind
315
- schema_attribute = self._schema.get_attribute(attribute.schema.name)
316
- if schema_attribute.inherited:
317
- for generic_name in self._schema.inherit_from:
318
- generic_node = db.schema.get_generic_schema(name=generic_name, duplicate=False)
319
- if attribute.schema.name in generic_node.attribute_names:
320
- pool_node = generic_node.kind
321
- break
322
-
323
- number_pool = await Node.init(db=db, schema=schema, branch=self._branch)
324
- await number_pool.new(
325
- db=db,
326
- id=number_pool_parameters.number_pool_id,
327
- name=f"{pool_node}.{attribute.schema.name} [{number_pool_parameters.number_pool_id}]",
328
- node=pool_node,
329
- node_attribute=attribute.schema.name,
330
- start_range=number_pool_parameters.start_range,
331
- end_range=number_pool_parameters.end_range,
332
- pool_type=NumberPoolType.SCHEMA.value,
333
- )
334
- await number_pool.save(db=db)
316
+ number_pool_from_db: CoreNumberPool | None = None
317
+ lock_definition = NumberPoolLockDefinition(pool_id=str(number_pool_parameters.number_pool_id))
318
+ async with lock.registry.get(
319
+ name=lock_definition.lock_name, namespace=lock_definition.namespace_name, local=False
320
+ ):
321
+ try:
322
+ number_pool_from_db = await registry.manager.get_one_by_id_or_default_filter(
323
+ db=db, id=str(number_pool_parameters.number_pool_id), kind=CoreNumberPool
324
+ )
325
+ except NodeNotFoundError:
326
+ schema = db.schema.get_node_schema(name="CoreNumberPool", duplicate=False)
327
+
328
+ pool_node = self._schema.kind
329
+ schema_attribute = self._schema.get_attribute(attribute.schema.name)
330
+ if schema_attribute.inherited:
331
+ for generic_name in self._schema.inherit_from:
332
+ generic_node = db.schema.get_generic_schema(name=generic_name, duplicate=False)
333
+ if attribute.schema.name in generic_node.attribute_names:
334
+ pool_node = generic_node.kind
335
+ break
336
+
337
+ number_pool = await Node.init(db=db, schema=schema, branch=self._branch)
338
+ await number_pool.new(
339
+ db=db,
340
+ id=number_pool_parameters.number_pool_id,
341
+ name=f"{pool_node}.{attribute.schema.name} [{number_pool_parameters.number_pool_id}]",
342
+ node=pool_node,
343
+ node_attribute=attribute.schema.name,
344
+ start_range=number_pool_parameters.start_range,
345
+ end_range=number_pool_parameters.end_range,
346
+ pool_type=NumberPoolType.SCHEMA.value,
347
+ )
348
+ await number_pool.save(db=db)
349
+
335
350
  # Do a lookup of the number pool to get the correct mapped type from the registry
336
351
  # without this we don't get access to the .get_resource() method.
337
- return await registry.manager.get_one_by_id_or_default_filter(db=db, id=number_pool.id, kind=CoreNumberPool)
352
+ created_pool: CoreNumberPool = number_pool_from_db or await registry.manager.get_one_by_id_or_default_filter(
353
+ db=db, id=number_pool.id, kind=CoreNumberPool
354
+ )
355
+ return created_pool
338
356
 
339
357
  async def handle_object_template(self, fields: dict, db: InfrahubDatabase, errors: list) -> None:
340
358
  """Fill the `fields` parameters with values from an object template if one is in use."""
@@ -541,17 +559,21 @@ class Node(BaseNode, metaclass=BaseNodeMeta):
541
559
  relationship_attribute: RelationshipManager = getattr(
542
560
  self, attribute_path.active_relationship_schema.name
543
561
  )
544
- peer = await relationship_attribute.get_peer(db=db, raise_on_error=True)
545
-
546
- related_node = await registry.manager.get_one_by_id_or_default_filter(
547
- db=db, id=peer.id, kind=attribute_path.active_relationship_schema.peer, branch=self._branch.name
548
- )
562
+ if peer := await relationship_attribute.get_peer(db=db, raise_on_error=False):
563
+ related_node = await registry.manager.get_one_by_id_or_default_filter(
564
+ db=db,
565
+ id=peer.id,
566
+ kind=attribute_path.active_relationship_schema.peer,
567
+ branch=self._branch.name,
568
+ )
549
569
 
550
- attribute: BaseAttribute = getattr(
551
- getattr(related_node, attribute_path.active_attribute_schema.name),
552
- attribute_path.active_attribute_property_name,
553
- )
554
- variables[variable] = attribute
570
+ attribute: BaseAttribute = getattr(
571
+ getattr(related_node, attribute_path.active_attribute_schema.name),
572
+ attribute_path.active_attribute_property_name,
573
+ )
574
+ variables[variable] = attribute
575
+ else:
576
+ variables[variable] = None
555
577
 
556
578
  elif attribute_path.is_type_attribute:
557
579
  attribute = getattr(
@@ -939,6 +961,8 @@ class Node(BaseNode, metaclass=BaseNodeMeta):
939
961
  if relationship.kind == RelationshipKind.PARENT:
940
962
  return relationship.name
941
963
 
964
+ return None
965
+
942
966
  async def get_object_template(self, db: InfrahubDatabase) -> CoreObjectTemplate | None:
943
967
  object_template: RelationshipManager = getattr(self, OBJECT_TEMPLATE_RELATIONSHIP_NAME, None)
944
968
  return (
@@ -962,3 +986,12 @@ class Node(BaseNode, metaclass=BaseNodeMeta):
962
986
  for name in self._relationships:
963
987
  relm: RelationshipManager = getattr(self, name)
964
988
  relm.validate()
989
+
990
+ async def get_parent_relationship_peer(self, db: InfrahubDatabase, name: str) -> Node | None:
991
+ """When a node has a parent relationship of a given name, this method returns the peer of that relationship."""
992
+ relationship = self.get_schema().get_relationship(name=name)
993
+ if relationship.kind != RelationshipKind.PARENT:
994
+ raise ValueError(f"Relationship '{name}' is not of kind 'parent'")
995
+
996
+ relm: RelationshipManager = getattr(self, name)
997
+ return await relm.get_peer(db=db)
infrahub/core/path.py CHANGED
@@ -63,6 +63,20 @@ class DataPath(InfrahubPath):
63
63
  peer_id: str | None = Field(default=None, description="")
64
64
  value: Any | None = Field(default=None, description="Optional value of the resource")
65
65
 
66
+ def __hash__(self) -> int:
67
+ return hash(
68
+ (
69
+ self.branch,
70
+ self.path_type,
71
+ self.node_id,
72
+ self.kind,
73
+ self.field_name,
74
+ self.property_name,
75
+ self.peer_id,
76
+ str(self.value),
77
+ )
78
+ )
79
+
66
80
  @property
67
81
  def resource_type(self) -> PathResourceType:
68
82
  return PathResourceType.DATA
@@ -21,7 +21,7 @@ class DeleteAfterTimeQuery(Query):
21
21
  // ---------------------
22
22
  // Reset edges with to time after timestamp
23
23
  // ---------------------
24
- CALL {
24
+ CALL () {
25
25
  OPTIONAL MATCH (p)-[r]-(q)
26
26
  WHERE r.to > $timestamp
27
27
  SET r.to = NULL
@@ -33,7 +33,7 @@ class DeleteAfterTimeQuery(Query):
33
33
  // ---------------------
34
34
  // Delete edges with from time after timestamp timestamp
35
35
  // ---------------------
36
- CALL {
36
+ CALL () {
37
37
  OPTIONAL MATCH (p)-[r]->(q)
38
38
  WHERE r.from > $timestamp
39
39
  DELETE r
@@ -49,7 +49,7 @@ class DeleteAfterTimeQuery(Query):
49
49
  // ---------------------
50
50
  // Delete edges with from time after timestamp timestamp
51
51
  // ---------------------
52
- CALL {
52
+ CALL () {
53
53
  OPTIONAL MATCH (p)-[r]->(q)
54
54
  WHERE r.from > $timestamp
55
55
  DELETE r
@@ -3,6 +3,7 @@ from dataclasses import dataclass
3
3
  from infrahub.core import registry
4
4
  from infrahub.core.branch import Branch
5
5
  from infrahub.core.constants import RelationshipCardinality, RelationshipDirection
6
+ from infrahub.core.node import Node
6
7
  from infrahub.core.query.relationship import RelationshipCountPerNodeQuery
7
8
  from infrahub.core.schema import MainSchemaTypes
8
9
  from infrahub.database import InfrahubDatabase
@@ -25,7 +26,7 @@ class RelationshipCountConstraint(RelationshipManagerConstraintInterface):
25
26
  self.db = db
26
27
  self.branch = branch
27
28
 
28
- async def check(self, relm: RelationshipManager, node_schema: MainSchemaTypes) -> None: # noqa: ARG002
29
+ async def check(self, relm: RelationshipManager, node_schema: MainSchemaTypes, node: Node) -> None: # noqa: ARG002
29
30
  branch = await registry.get_branch(db=self.db) if not self.branch else self.branch
30
31
 
31
32
  # NOTE adding resolve here because we need to retrieve the real ID
@@ -63,7 +64,7 @@ class RelationshipCountConstraint(RelationshipManagerConstraintInterface):
63
64
 
64
65
  query = await RelationshipCountPerNodeQuery.init(
65
66
  db=self.db,
66
- node_ids=[node.uuid for node in nodes_to_validate],
67
+ node_ids=[n.uuid for n in nodes_to_validate],
67
68
  identifier=relm.schema.identifier,
68
69
  direction=relm.schema.direction.neighbor_direction,
69
70
  branch=branch,
@@ -74,14 +75,14 @@ class RelationshipCountConstraint(RelationshipManagerConstraintInterface):
74
75
  # Need to adjust the number based on what we will add / remove
75
76
  # +1 for max_count
76
77
  # -1 for min_count
77
- for node in nodes_to_validate:
78
- if node.max_count and count_per_peer[node.uuid] + 1 > node.max_count:
78
+ for node_to_validate in nodes_to_validate:
79
+ if node_to_validate.max_count and count_per_peer[node_to_validate.uuid] + 1 > node_to_validate.max_count:
79
80
  raise ValidationError(
80
- f"Node {node.uuid} has {count_per_peer[node.uuid] + 1} peers "
81
- f"for {relm.schema.identifier}, maximum of {node.max_count} allowed",
81
+ f"Node {node_to_validate.uuid} has {count_per_peer[node_to_validate.uuid] + 1} peers "
82
+ f"for {relm.schema.identifier}, maximum of {node_to_validate.max_count} allowed",
82
83
  )
83
- if node.min_count and count_per_peer[node.uuid] - 1 < node.min_count:
84
+ if node_to_validate.min_count and count_per_peer[node_to_validate.uuid] - 1 < node_to_validate.min_count:
84
85
  raise ValidationError(
85
- f"Node {node.uuid} has {count_per_peer[node.uuid] - 1} peers "
86
- f"for {relm.schema.identifier}, no fewer than {node.min_count} allowed",
86
+ f"Node {node_to_validate.uuid} has {count_per_peer[node_to_validate.uuid] - 1} peers "
87
+ f"for {relm.schema.identifier}, no fewer than {node_to_validate.min_count} allowed",
87
88
  )
@@ -1,5 +1,6 @@
1
1
  from abc import ABC, abstractmethod
2
2
 
3
+ from infrahub.core.node import Node
3
4
  from infrahub.core.schema import MainSchemaTypes
4
5
 
5
6
  from ..model import RelationshipManager
@@ -7,4 +8,4 @@ from ..model import RelationshipManager
7
8
 
8
9
  class RelationshipManagerConstraintInterface(ABC):
9
10
  @abstractmethod
10
- async def check(self, relm: RelationshipManager, node_schema: MainSchemaTypes) -> None: ...
11
+ async def check(self, relm: RelationshipManager, node_schema: MainSchemaTypes, node: Node) -> None: ...
@@ -3,6 +3,7 @@ from dataclasses import dataclass
3
3
  from infrahub.core import registry
4
4
  from infrahub.core.branch import Branch
5
5
  from infrahub.core.constants import RelationshipCardinality
6
+ from infrahub.core.node import Node
6
7
  from infrahub.core.query.node import NodeListGetInfoQuery
7
8
  from infrahub.core.schema import MainSchemaTypes
8
9
  from infrahub.core.schema.generic_schema import GenericSchema
@@ -26,7 +27,7 @@ class RelationshipPeerKindConstraint(RelationshipManagerConstraintInterface):
26
27
  self.db = db
27
28
  self.branch = branch
28
29
 
29
- async def check(self, relm: RelationshipManager, node_schema: MainSchemaTypes) -> None: # noqa: ARG002
30
+ async def check(self, relm: RelationshipManager, node_schema: MainSchemaTypes, node: Node) -> None: # noqa: ARG002
30
31
  branch = await registry.get_branch(db=self.db) if not self.branch else self.branch
31
32
  peer_schema = registry.schema.get(name=relm.schema.peer, branch=branch, duplicate=False)
32
33
  if isinstance(peer_schema, GenericSchema):
@@ -0,0 +1,56 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Mapping
4
+
5
+ from infrahub.exceptions import ValidationError
6
+
7
+ from .interface import RelationshipManagerConstraintInterface
8
+
9
+ if TYPE_CHECKING:
10
+ from infrahub.core.branch import Branch
11
+ from infrahub.core.node import Node
12
+ from infrahub.core.schema import MainSchemaTypes
13
+ from infrahub.database import InfrahubDatabase
14
+
15
+ from ..model import RelationshipManager
16
+
17
+
18
+ class RelationshipPeerParentConstraint(RelationshipManagerConstraintInterface):
19
+ def __init__(self, db: InfrahubDatabase, branch: Branch | None = None):
20
+ self.db = db
21
+ self.branch = branch
22
+
23
+ async def _check_relationship_peers_parent(
24
+ self, relm: RelationshipManager, parent_rel_name: str, node: Node, peers: Mapping[str, Node]
25
+ ) -> None:
26
+ """Validate that all peers of a given `relm` have the same parent for the given `relationship_name`."""
27
+ node_parent = await node.get_parent_relationship_peer(db=self.db, name=parent_rel_name)
28
+ if not node_parent:
29
+ # If the schema is properly validated we are not expecting this to happen
30
+ raise ValidationError(f"Node {node.id} ({node.get_kind()}) does not have a parent peer")
31
+
32
+ parents: set[str] = {node_parent.id}
33
+ for peer in peers.values():
34
+ parent = await peer.get_parent_relationship_peer(db=self.db, name=parent_rel_name)
35
+ if not parent:
36
+ # If the schema is properly validated we are not expecting this to happen
37
+ raise ValidationError(f"Peer {peer.id} ({peer.get_kind()}) does not have a parent peer")
38
+ parents.add(parent.id)
39
+
40
+ if len(parents) != 1:
41
+ raise ValidationError(
42
+ f"All the elements of the '{relm.name}' relationship on node {node.id} ({node.get_kind()}) must have the same parent "
43
+ "as the node"
44
+ )
45
+
46
+ async def check(self, relm: RelationshipManager, node_schema: MainSchemaTypes, node: Node) -> None: # noqa: ARG002
47
+ if not relm.schema.common_parent:
48
+ return
49
+
50
+ peers = await relm.get_peers(db=self.db)
51
+ if not peers:
52
+ return
53
+
54
+ await self._check_relationship_peers_parent(
55
+ relm=relm, parent_rel_name=relm.schema.common_parent, node=node, peers=peers
56
+ )
@@ -58,7 +58,7 @@ class RelationshipPeerRelativesConstraint(RelationshipManagerConstraintInterface
58
58
  f"for their '{node.schema.kind}.{relationship_name}' relationship"
59
59
  )
60
60
 
61
- async def check(self, relm: RelationshipManager, node_schema: MainSchemaTypes) -> None:
61
+ async def check(self, relm: RelationshipManager, node_schema: MainSchemaTypes, node: Node) -> None: # noqa: ARG002
62
62
  if relm.schema.cardinality != RelationshipCardinality.MANY or not relm.schema.common_relatives:
63
63
  return
64
64
 
@@ -23,7 +23,7 @@ class RelationshipProfilesKindConstraint(RelationshipManagerConstraintInterface)
23
23
  self.branch = branch
24
24
  self.schema_branch = registry.schema.get_schema_branch(branch.name if branch else registry.default_branch)
25
25
 
26
- async def check(self, relm: RelationshipManager, node_schema: MainSchemaTypes) -> None:
26
+ async def check(self, relm: RelationshipManager, node_schema: MainSchemaTypes, node: Node) -> None: # noqa: ARG002
27
27
  if relm.name != "profiles" or not isinstance(node_schema, NodeSchema):
28
28
  return
29
29
 
@@ -8,6 +8,7 @@ from pydantic import ConfigDict, Field, model_validator
8
8
  from infrahub import config
9
9
  from infrahub.core.constants.schema import UpdateSupport
10
10
  from infrahub.core.models import HashableModel
11
+ from infrahub.exceptions import ValidationError
11
12
 
12
13
 
13
14
  def get_attribute_parameters_class_for_kind(kind: str) -> type[AttributeParameters]:
@@ -124,16 +125,22 @@ class NumberAttributeParameters(AttributeParameters):
124
125
  return ranges
125
126
 
126
127
  def is_valid_value(self, value: int) -> bool:
127
- if self.min_value is not None and value < self.min_value:
128
+ try:
129
+ self.check_valid_value(value=value, name="UNUSED")
130
+ except ValidationError:
128
131
  return False
132
+ return True
133
+
134
+ def check_valid_value(self, value: int, name: str) -> None:
135
+ if self.min_value is not None and value < self.min_value:
136
+ raise ValidationError({name: f"{value} is lower than the minimum allowed value {self.min_value!r}"})
129
137
  if self.max_value is not None and value > self.max_value:
130
- return False
138
+ raise ValidationError({name: f"{value} is higher than the maximum allowed value {self.max_value!r}"})
131
139
  if value in self.get_excluded_single_values():
132
- return False
140
+ raise ValidationError({name: f"{value} is in the excluded values"})
133
141
  for start, end in self.get_excluded_ranges():
134
142
  if start <= value <= end:
135
- return False
136
- return True
143
+ raise ValidationError({name: f"{value} is in an the excluded range {start}-{end}"})
137
144
 
138
145
 
139
146
  class NumberPoolParameters(AttributeParameters):
@@ -3,6 +3,7 @@ from __future__ import annotations
3
3
  import hashlib
4
4
  import keyword
5
5
  import os
6
+ from collections import defaultdict
6
7
  from dataclasses import asdict, dataclass
7
8
  from enum import Enum
8
9
  from typing import TYPE_CHECKING, Any, Callable, Iterable, Literal, overload
@@ -10,7 +11,7 @@ from typing import TYPE_CHECKING, Any, Callable, Iterable, Literal, overload
10
11
  from infrahub_sdk.utils import compare_lists, intersection
11
12
  from pydantic import field_validator
12
13
 
13
- from infrahub.core.constants import RelationshipCardinality, RelationshipKind
14
+ from infrahub.core.constants import HashableModelState, RelationshipCardinality, RelationshipKind
14
15
  from infrahub.core.models import HashableModel, HashableModelDiff
15
16
 
16
17
  from .attribute_schema import AttributeSchema, get_attribute_schema_class_for_kind
@@ -514,7 +515,86 @@ class BaseNodeSchema(GeneratedBaseNodeSchema):
514
515
  return UniquenessConstraintType.SUBSET_OF_HFID
515
516
  return UniquenessConstraintType.STANDARD
516
517
 
518
+ def _update_schema_paths(
519
+ self, schema_paths_list: list[str], field_name_update_map: dict[str, str], deleted_field_names: set[str]
520
+ ) -> list[str]:
521
+ """
522
+ For each schema_path (eg name__value, device__name_value), update the field name if the current name is
523
+ in field_name_update_map, remove the path if the field name is in deleted_field_names
524
+ """
525
+ updated_element_list = []
526
+ for schema_path in schema_paths_list:
527
+ split_path = schema_path.split("__", maxsplit=1)
528
+ current_field_name = split_path[0]
529
+ if current_field_name in deleted_field_names:
530
+ continue
531
+ new_field_name = field_name_update_map.get(current_field_name)
532
+ if not new_field_name:
533
+ updated_element_list.append(schema_path)
534
+ continue
535
+ rest_of_path = f"__{split_path[1]}" if len(split_path) > 1 else ""
536
+ new_element_str = f"{new_field_name}{rest_of_path}"
537
+ updated_element_list.append(new_element_str)
538
+ return updated_element_list
539
+
540
+ def handle_field_renames_and_deletes(self, other: BaseNodeSchema) -> None:
541
+ properties_to_update = [self.uniqueness_constraints, self.human_friendly_id, self.display_labels, self.order_by]
542
+ if not any(p for p in properties_to_update):
543
+ return
544
+
545
+ deleted_names: set[str] = set()
546
+ field_names_by_id = defaultdict(list)
547
+ for field in self.attributes + self.relationships:
548
+ if not field.id:
549
+ continue
550
+ field_names_by_id[field.id].append(field.name)
551
+ for field in other.attributes + other.relationships:
552
+ # identify fields deleted in the other schema
553
+ if field.state is HashableModelState.ABSENT:
554
+ deleted_names.add(field.name)
555
+ if not field.id:
556
+ continue
557
+ if field.name not in field_names_by_id[field.id]:
558
+ field_names_by_id[field.id].append(field.name)
559
+ # identify fields renamed from this schema to the other schema
560
+ renamed_field_name_map = {v[0]: v[-1] for v in field_names_by_id.values() if len(v) > 1}
561
+
562
+ if self.uniqueness_constraints:
563
+ updated_constraints = []
564
+ for constraint in self.uniqueness_constraints:
565
+ updated_constraint = self._update_schema_paths(
566
+ schema_paths_list=constraint,
567
+ field_name_update_map=renamed_field_name_map,
568
+ deleted_field_names=deleted_names,
569
+ )
570
+ if updated_constraint:
571
+ updated_constraints.append(updated_constraint)
572
+ self.uniqueness_constraints = updated_constraints
573
+ if self.human_friendly_id:
574
+ self.human_friendly_id = self._update_schema_paths(
575
+ schema_paths_list=self.human_friendly_id,
576
+ field_name_update_map=renamed_field_name_map,
577
+ deleted_field_names=deleted_names,
578
+ )
579
+ if self.display_labels:
580
+ self.display_labels = self._update_schema_paths(
581
+ schema_paths_list=self.display_labels,
582
+ field_name_update_map=renamed_field_name_map,
583
+ deleted_field_names=deleted_names,
584
+ )
585
+ if self.order_by:
586
+ self.order_by = self._update_schema_paths(
587
+ schema_paths_list=self.order_by,
588
+ field_name_update_map=renamed_field_name_map,
589
+ deleted_field_names=deleted_names,
590
+ )
591
+
517
592
  def update(self, other: HashableModel) -> Self:
593
+ # handle renamed/deleted field updates for schema properties here
594
+ # so that they can still be overridden during the call to `update()` below
595
+ if isinstance(other, BaseNodeSchema):
596
+ self.handle_field_renames_and_deletes(other=other)
597
+
518
598
  super().update(other=other)
519
599
 
520
600
  # Allow to specify empty string to remove existing fields values
@@ -551,6 +631,24 @@ class SchemaAttributePath:
551
631
  attribute_schema: AttributeSchema | None = None
552
632
  attribute_property_name: str | None = None
553
633
 
634
+ def __str__(self) -> str:
635
+ return self.to_string()
636
+
637
+ def to_string(self, field_name_override: str | None = None) -> str:
638
+ str_path = ""
639
+ if self.relationship_schema:
640
+ str_path += field_name_override or self.relationship_schema.name
641
+ if self.attribute_schema:
642
+ if str_path:
643
+ str_path += "__"
644
+ attr_name = self.attribute_schema.name
645
+ else:
646
+ attr_name = field_name_override or self.attribute_schema.name
647
+ str_path += attr_name
648
+ if self.attribute_property_name:
649
+ str_path += f"__{self.attribute_property_name}"
650
+ return str_path
651
+
554
652
  @property
555
653
  def is_type_attribute(self) -> bool:
556
654
  return bool(self.attribute_schema and not self.related_schema and not self.relationship_schema)
@@ -563,6 +661,14 @@ class SchemaAttributePath:
563
661
  def has_property(self) -> bool:
564
662
  return bool(self.attribute_property_name)
565
663
 
664
+ @property
665
+ def field_name(self) -> str | None:
666
+ if self.relationship_schema:
667
+ return self.relationship_schema.name
668
+ if self.attribute_schema:
669
+ return self.attribute_schema.name
670
+ return None
671
+
566
672
  @property
567
673
  def active_relationship_schema(self) -> RelationshipSchema:
568
674
  if self.relationship_schema:
@@ -754,13 +754,20 @@ relationship_schema = SchemaNode(
754
754
  optional=True,
755
755
  extra={"update": UpdateSupport.VALIDATE_CONSTRAINT},
756
756
  ),
757
+ SchemaAttribute(
758
+ name="common_parent",
759
+ kind="Text",
760
+ optional=True,
761
+ description="Name of a parent relationship on the peer schema that must share the same related object with the object's parent.",
762
+ extra={"update": UpdateSupport.VALIDATE_CONSTRAINT},
763
+ ),
757
764
  SchemaAttribute(
758
765
  name="common_relatives",
759
766
  kind="List",
760
767
  internal_kind=str,
761
768
  optional=True,
762
769
  description="List of relationship names on the peer schema for which all objects must share the same set of peers.",
763
- extra={"update": UpdateSupport.VALIDATE_CONSTRAINT},
770
+ extra={"update": UpdateSupport.ALLOWED},
764
771
  ),
765
772
  SchemaAttribute(
766
773
  name="order_weight",
@@ -73,10 +73,15 @@ class GeneratedRelationshipSchema(HashableModel):
73
73
  description="Defines the maximum objects allowed on the other side of the relationship.",
74
74
  json_schema_extra={"update": "validate_constraint"},
75
75
  )
76
+ common_parent: str | None = Field(
77
+ default=None,
78
+ description="Name of a parent relationship on the peer schema that must share the same related object with the object's parent.",
79
+ json_schema_extra={"update": "validate_constraint"},
80
+ )
76
81
  common_relatives: list[str] | None = Field(
77
82
  default=None,
78
83
  description="List of relationship names on the peer schema for which all objects must share the same set of peers.",
79
- json_schema_extra={"update": "validate_constraint"},
84
+ json_schema_extra={"update": "allowed"},
80
85
  )
81
86
  order_weight: int | None = Field(
82
87
  default=None,