infrahub-server 1.3.0b1__py3-none-any.whl → 1.3.0b3__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 (81) hide show
  1. infrahub/actions/constants.py +87 -0
  2. infrahub/actions/gather.py +3 -3
  3. infrahub/actions/models.py +10 -8
  4. infrahub/actions/parsers.py +6 -6
  5. infrahub/actions/schema.py +46 -37
  6. infrahub/actions/tasks.py +4 -11
  7. infrahub/branch/__init__.py +0 -0
  8. infrahub/branch/tasks.py +29 -0
  9. infrahub/branch/triggers.py +22 -0
  10. infrahub/cli/db.py +2 -2
  11. infrahub/computed_attribute/gather.py +3 -1
  12. infrahub/computed_attribute/tasks.py +23 -29
  13. infrahub/core/constants/__init__.py +5 -0
  14. infrahub/core/constants/database.py +1 -0
  15. infrahub/core/convert_object_type/conversion.py +1 -1
  16. infrahub/core/diff/query/save.py +67 -40
  17. infrahub/core/diff/query/time_range_query.py +0 -1
  18. infrahub/core/graph/__init__.py +1 -1
  19. infrahub/core/migrations/graph/__init__.py +6 -0
  20. infrahub/core/migrations/graph/m013_convert_git_password_credential.py +0 -2
  21. infrahub/core/migrations/graph/m029_duplicates_cleanup.py +662 -0
  22. infrahub/core/migrations/graph/m030_illegal_edges.py +82 -0
  23. infrahub/core/migrations/query/attribute_add.py +13 -9
  24. infrahub/core/migrations/query/relationship_duplicate.py +0 -1
  25. infrahub/core/migrations/schema/node_remove.py +0 -1
  26. infrahub/core/node/__init__.py +2 -0
  27. infrahub/core/node/base.py +1 -1
  28. infrahub/core/path.py +1 -1
  29. infrahub/core/protocols.py +4 -3
  30. infrahub/core/query/node.py +1 -1
  31. infrahub/core/query/relationship.py +2 -2
  32. infrahub/core/query/standard_node.py +19 -5
  33. infrahub/core/relationship/constraints/peer_relatives.py +72 -0
  34. infrahub/core/relationship/model.py +1 -1
  35. infrahub/core/schema/attribute_schema.py +26 -6
  36. infrahub/core/schema/basenode_schema.py +2 -2
  37. infrahub/core/schema/definitions/core/resource_pool.py +9 -0
  38. infrahub/core/schema/definitions/internal.py +9 -1
  39. infrahub/core/schema/generated/attribute_schema.py +4 -4
  40. infrahub/core/schema/generated/relationship_schema.py +6 -1
  41. infrahub/core/schema/manager.py +4 -2
  42. infrahub/core/schema/schema_branch.py +14 -5
  43. infrahub/core/validators/tasks.py +1 -1
  44. infrahub/database/__init__.py +1 -1
  45. infrahub/database/validation.py +100 -0
  46. infrahub/dependencies/builder/constraint/grouped/node_runner.py +2 -0
  47. infrahub/dependencies/builder/constraint/relationship_manager/peer_relatives.py +8 -0
  48. infrahub/dependencies/builder/diff/deserializer.py +1 -1
  49. infrahub/dependencies/registry.py +2 -0
  50. infrahub/events/models.py +1 -1
  51. infrahub/graphql/mutations/main.py +1 -1
  52. infrahub/graphql/mutations/resource_manager.py +13 -13
  53. infrahub/graphql/resolvers/many_relationship.py +1 -1
  54. infrahub/graphql/resolvers/resolver.py +2 -2
  55. infrahub/graphql/resolvers/single_relationship.py +1 -1
  56. infrahub/menu/menu.py +5 -4
  57. infrahub/message_bus/operations/refresh/registry.py +3 -3
  58. infrahub/patch/queries/delete_duplicated_edges.py +40 -29
  59. infrahub/pools/registration.py +22 -0
  60. infrahub/pools/tasks.py +56 -0
  61. infrahub/proposed_change/tasks.py +8 -8
  62. infrahub/schema/__init__.py +0 -0
  63. infrahub/schema/tasks.py +27 -0
  64. infrahub/schema/triggers.py +23 -0
  65. infrahub/trigger/catalogue.py +4 -0
  66. infrahub/trigger/models.py +5 -4
  67. infrahub/trigger/setup.py +26 -2
  68. infrahub/trigger/tasks.py +1 -1
  69. infrahub/webhook/tasks.py +6 -9
  70. infrahub/workflows/catalogue.py +27 -1
  71. {infrahub_server-1.3.0b1.dist-info → infrahub_server-1.3.0b3.dist-info}/METADATA +1 -1
  72. {infrahub_server-1.3.0b1.dist-info → infrahub_server-1.3.0b3.dist-info}/RECORD +80 -67
  73. infrahub_testcontainers/container.py +239 -64
  74. infrahub_testcontainers/docker-compose-cluster.test.yml +321 -0
  75. infrahub_testcontainers/docker-compose.test.yml +1 -0
  76. infrahub_testcontainers/helpers.py +15 -1
  77. infrahub_testcontainers/plugin.py +9 -0
  78. infrahub/patch/queries/consolidate_duplicated_nodes.py +0 -106
  79. {infrahub_server-1.3.0b1.dist-info → infrahub_server-1.3.0b3.dist-info}/LICENSE.txt +0 -0
  80. {infrahub_server-1.3.0b1.dist-info → infrahub_server-1.3.0b3.dist-info}/WHEEL +0 -0
  81. {infrahub_server-1.3.0b1.dist-info → infrahub_server-1.3.0b3.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,82 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Any, Sequence
4
+
5
+ from infrahub.core.migrations.shared import GraphMigration, MigrationResult
6
+ from infrahub.log import get_logger
7
+
8
+ from ...query import Query, QueryType
9
+
10
+ if TYPE_CHECKING:
11
+ from infrahub.database import InfrahubDatabase
12
+
13
+ log = get_logger()
14
+
15
+
16
+ class DeletePosthumousEdges(Query):
17
+ name = "delete_posthumous_edges_query"
18
+ type = QueryType.WRITE
19
+ insert_return = False
20
+
21
+ async def query_init(self, db: InfrahubDatabase, **kwargs: dict[str, Any]) -> None: # noqa: ARG002
22
+ query = """
23
+ // ------------
24
+ // find deleted nodes
25
+ // ------------
26
+ MATCH (n:Node)-[e:IS_PART_OF]->(:Root)
27
+ WHERE e.status = "deleted" OR e.to IS NOT NULL
28
+ WITH DISTINCT n, e.branch AS delete_branch, e.branch_level AS delete_branch_level, CASE
29
+ WHEN e.status = "deleted" THEN e.from
30
+ ELSE e.to
31
+ END AS delete_time
32
+ // ------------
33
+ // find the edges added to the deleted node after the delete time
34
+ // ------------
35
+ MATCH (n)-[added_e]-(peer)
36
+ WHERE added_e.from > delete_time
37
+ AND type(added_e) <> "IS_PART_OF"
38
+ // if the node was deleted on a branch (delete_branch_level > 1), and then updated on main/global (added_e.branch_level = 1), we can ignore it
39
+ AND added_e.branch_level >= delete_branch_level
40
+ AND (added_e.branch = delete_branch OR delete_branch_level = 1)
41
+ WITH DISTINCT n, delete_branch, delete_time, added_e, peer
42
+ // ------------
43
+ // get the branched_from for the branch on which the node was deleted
44
+ // ------------
45
+ CALL (added_e) {
46
+ MATCH (b:Branch {name: added_e.branch})
47
+ RETURN b.branched_from AS added_e_branched_from
48
+ }
49
+ // ------------
50
+ // account for the following situations, given that the edge update time is after the node delete time
51
+ // - deleted on main/global, updated on branch
52
+ // - illegal if the delete is before branch.branched_from
53
+ // - deleted on branch, updated on branch
54
+ // - illegal
55
+ // ------------
56
+ WITH n, delete_branch, delete_time, added_e, peer
57
+ WHERE delete_branch = added_e.branch
58
+ OR delete_time < added_e_branched_from
59
+ DELETE added_e
60
+ // --------------
61
+ // the peer _should_ only be an Attribute, but I want to make sure we don't
62
+ // inadvertently delete Root or an AttributeValue or a Boolean
63
+ // --------------
64
+ WITH peer
65
+ WHERE "Attribute" IN labels(peer)
66
+ DETACH DELETE peer
67
+ """
68
+ self.add_to_query(query)
69
+
70
+
71
+ class Migration030(GraphMigration):
72
+ """
73
+ Edges could have been added to Nodes after the Node was deleted, so we need to hard-delete those illegal edges
74
+ """
75
+
76
+ name: str = "030_delete_illegal_edges"
77
+ minimum_version: int = 29
78
+ queries: Sequence[type[Query]] = [DeletePosthumousEdges]
79
+
80
+ async def validate_migration(self, db: InfrahubDatabase) -> MigrationResult: # noqa: ARG002
81
+ result = MigrationResult()
82
+ return result
@@ -62,28 +62,32 @@ class AttributeAddQuery(Query):
62
62
  WITH av, is_protected_value, is_visible_value
63
63
  MATCH p = (n:%(node_kind)s)
64
64
  CALL (n) {
65
- MATCH (root:Root)<-[r1:IS_PART_OF]-(n)
66
- OPTIONAL MATCH (n)-[r2:HAS_ATTRIBUTE]-(:Attribute { name: $attr_name })
67
- WHERE all(r in [r1, r2] WHERE (%(branch_filter)s))
68
- RETURN n as n1, r1 as r11, r2 as r12
69
- ORDER BY r2.branch_level DESC, r2.from ASC, r1.branch_level DESC, r1.from ASC
65
+ MATCH (:Root)<-[r:IS_PART_OF]-(n)
66
+ WHERE %(branch_filter)s
67
+ WITH n, r AS is_part_of_e
68
+ OPTIONAL MATCH (n)-[r:HAS_ATTRIBUTE]-(:Attribute { name: $attr_name })
69
+ WHERE %(branch_filter)s
70
+ WITH is_part_of_e, r AS has_attr_e
71
+ RETURN is_part_of_e, has_attr_e
72
+ ORDER BY has_attr_e.branch_level DESC, has_attr_e.from ASC, is_part_of_e.branch_level DESC, is_part_of_e.from ASC
70
73
  LIMIT 1
71
74
  }
72
- WITH n1 as n, r11 as r1, r12 as r2, av, is_protected_value, is_visible_value
73
- WHERE r1.status = "active" AND (r2 IS NULL OR r2.status = "deleted")
75
+ WITH n, is_part_of_e, has_attr_e, av, is_protected_value, is_visible_value
76
+ WHERE is_part_of_e.status = "active" AND (has_attr_e IS NULL OR has_attr_e.status = "deleted")
74
77
  CREATE (a:Attribute { name: $attr_name, branch_support: $branch_support })
75
78
  CREATE (n)-[:HAS_ATTRIBUTE $rel_props ]->(a)
76
79
  CREATE (a)-[:HAS_VALUE $rel_props ]->(av)
77
80
  CREATE (a)-[:IS_PROTECTED $rel_props]->(is_protected_value)
78
81
  CREATE (a)-[:IS_VISIBLE $rel_props]->(is_visible_value)
79
82
  %(uuid_generation)s
80
- FOREACH (i in CASE WHEN r2.status = "deleted" THEN [1] ELSE [] END |
81
- SET r2.to = $current_time
83
+ FOREACH (i in CASE WHEN has_attr_e.status = "deleted" THEN [1] ELSE [] END |
84
+ SET has_attr_e.to = $current_time
82
85
  )
83
86
  """ % {
84
87
  "branch_filter": branch_filter,
85
88
  "node_kind": self.node_kind,
86
89
  "uuid_generation": db.render_uuid_generation(node_label="a", node_attr="uuid"),
87
90
  }
91
+
88
92
  self.add_to_query(query)
89
93
  self.return_labels = ["n.uuid", "a.uuid"]
@@ -117,7 +117,6 @@ class RelationshipDuplicateQuery(Query):
117
117
 
118
118
  self.add_to_query(self.render_match())
119
119
 
120
- # ruff: noqa: E501
121
120
  query = """
122
121
  CALL (source, rel, destination) {
123
122
  MATCH path = (source)-[r1:IS_RELATED]-(rel)-[r2:IS_RELATED]-(destination)
@@ -58,7 +58,6 @@ class NodeRemoveMigrationBaseQuery(MigrationQuery):
58
58
 
59
59
  node_remove_query = self.render_node_remove_query(branch_filter=branch_filter)
60
60
 
61
- # ruff: noqa: E501
62
61
  query = """
63
62
  // Find all the active nodes
64
63
  MATCH (node:%(node_kind)s)
@@ -16,6 +16,7 @@ from infrahub.core.constants import (
16
16
  BranchSupportType,
17
17
  ComputedAttributeKind,
18
18
  InfrahubKind,
19
+ NumberPoolType,
19
20
  RelationshipCardinality,
20
21
  RelationshipKind,
21
22
  )
@@ -328,6 +329,7 @@ class Node(BaseNode, metaclass=BaseNodeMeta):
328
329
  node_attribute=attribute.schema.name,
329
330
  start_range=number_pool_parameters.start_range,
330
331
  end_range=number_pool_parameters.end_range,
332
+ pool_type=NumberPoolType.SCHEMA.value,
331
333
  )
332
334
  await number_pool.save(db=db)
333
335
  # Do a lookup of the number pool to get the correct mapped type from the registry
@@ -52,7 +52,7 @@ class BaseNodeOptions(BaseOptions):
52
52
 
53
53
 
54
54
  class ObjectNodeMeta(BaseNodeMeta):
55
- def __new__(mcs, name_, bases, namespace, **options): # noqa: N804
55
+ def __new__(mcs, name_, bases, namespace, **options):
56
56
  # Note: it's safe to pass options as keyword arguments as they are still type-checked by NodeOptions.
57
57
 
58
58
  # We create this type, to then overload it with the dataclass attrs
infrahub/core/path.py CHANGED
@@ -125,7 +125,7 @@ class SchemaPath(InfrahubPath):
125
125
  if self.field_name:
126
126
  identifier += f"/{self.field_name}"
127
127
 
128
- if self.property_name and not self.path_type == SchemaPathType.NODE:
128
+ if self.property_name and self.path_type != SchemaPathType.NODE:
129
129
  identifier += f"/{self.property_name}"
130
130
 
131
131
  return identifier
@@ -403,12 +403,12 @@ class CoreGraphQLQueryGroup(CoreGroup):
403
403
 
404
404
 
405
405
  class CoreGroupAction(CoreAction):
406
- add_members: Boolean
406
+ member_action: Dropdown
407
407
  group: RelationshipManager
408
408
 
409
409
 
410
410
  class CoreGroupTriggerRule(CoreTriggerRule):
411
- members_added: Boolean
411
+ member_update: Dropdown
412
412
  group: RelationshipManager
413
413
 
414
414
 
@@ -440,7 +440,7 @@ class CoreNodeTriggerAttributeMatch(CoreNodeTriggerMatch):
440
440
 
441
441
  class CoreNodeTriggerRelationshipMatch(CoreNodeTriggerMatch):
442
442
  relationship_name: String
443
- added: Boolean
443
+ modification_type: Dropdown
444
444
  peer: StringOptional
445
445
 
446
446
 
@@ -455,6 +455,7 @@ class CoreNumberPool(CoreResourcePool, LineageSource):
455
455
  node_attribute: String
456
456
  start_range: Integer
457
457
  end_range: Integer
458
+ pool_type: Enum
458
459
 
459
460
 
460
461
  class CoreObjectPermission(CoreBasePermission):
@@ -1469,7 +1469,7 @@ class NodeGetHierarchyQuery(Query):
1469
1469
 
1470
1470
  clean_filters = extract_field_filters(field_name=self.direction.value, filters=self.filters)
1471
1471
 
1472
- if clean_filters and "id" in clean_filters or "ids" in clean_filters:
1472
+ if (clean_filters and "id" in clean_filters) or "ids" in clean_filters:
1473
1473
  where_clause.append("peer.uuid IN $peer_ids")
1474
1474
  self.params["peer_ids"] = clean_filters.get("ids", [])
1475
1475
  if clean_filters.get("id", None):
@@ -676,7 +676,7 @@ class RelationshipGetPeerQuery(Query):
676
676
  where_clause = ['all(r IN rels WHERE r.status = "active")']
677
677
  clean_filters = extract_field_filters(field_name=self.schema.name, filters=self.filters)
678
678
 
679
- if clean_filters and "id" in clean_filters or "ids" in clean_filters:
679
+ if (clean_filters and "id" in clean_filters) or "ids" in clean_filters:
680
680
  where_clause.append("peer.uuid IN $peer_ids")
681
681
  self.params["peer_ids"] = clean_filters.get("ids", [])
682
682
  if clean_filters.get("id", None):
@@ -1035,7 +1035,7 @@ class RelationshipDeleteAllQuery(Query):
1035
1035
  self.node_id = node_id
1036
1036
  super().__init__(**kwargs)
1037
1037
 
1038
- async def query_init(self, db: InfrahubDatabase, **kwargs) -> None: # noqa: ARG002
1038
+ async def query_init(self, db: InfrahubDatabase, **kwargs) -> None:
1039
1039
  self.params["source_id"] = kwargs["node_id"]
1040
1040
  self.params["branch"] = self.branch.name
1041
1041
 
@@ -3,28 +3,42 @@ from __future__ import annotations
3
3
  from typing import TYPE_CHECKING, Any
4
4
 
5
5
  from infrahub.core.query import Query, QueryType
6
+ from infrahub.exceptions import InitializationError
6
7
 
7
8
  if TYPE_CHECKING:
9
+ from uuid import UUID
10
+
8
11
  from infrahub.core.node.standard import StandardNode
9
12
  from infrahub.database import InfrahubDatabase
10
13
 
11
14
 
12
15
  class StandardNodeQuery(Query):
13
16
  def __init__(
14
- self, node: StandardNode = None, node_id: str | None = None, node_db_id: int | None = None, **kwargs: Any
15
- ):
16
- self.node = node
17
+ self,
18
+ node: StandardNode | None = None,
19
+ node_id: UUID | None = None,
20
+ node_db_id: str | None = None,
21
+ **kwargs: Any,
22
+ ) -> None:
23
+ self._node = node
17
24
  self.node_id = node_id
18
25
  self.node_db_id = node_db_id
19
26
 
20
- if not self.node_id and self.node:
27
+ if not self.node_id and self._node:
21
28
  self.node_id = self.node.uuid
22
29
 
23
- if not self.node_db_id and self.node:
30
+ if not self.node_db_id and self._node:
24
31
  self.node_db_id = self.node.id
25
32
 
26
33
  super().__init__(**kwargs)
27
34
 
35
+ @property
36
+ def node(self) -> StandardNode:
37
+ if self._node:
38
+ return self._node
39
+
40
+ raise InitializationError("The query is not initialized with a node")
41
+
28
42
 
29
43
  class RootNodeCreateQuery(StandardNodeQuery):
30
44
  name = "standard_node_create"
@@ -0,0 +1,72 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import TYPE_CHECKING, Mapping
5
+
6
+ from infrahub.core.constants import RelationshipCardinality
7
+ from infrahub.exceptions import ValidationError
8
+
9
+ from .interface import RelationshipManagerConstraintInterface
10
+
11
+ if TYPE_CHECKING:
12
+ from infrahub.core.branch import Branch
13
+ from infrahub.core.node import Node
14
+ from infrahub.core.schema import MainSchemaTypes, NonGenericSchemaTypes
15
+ from infrahub.database import InfrahubDatabase
16
+
17
+ from ..model import RelationshipManager
18
+
19
+
20
+ @dataclass
21
+ class NodeToValidate:
22
+ uuid: str
23
+ relative_uuids: set[str]
24
+ schema: NonGenericSchemaTypes
25
+
26
+
27
+ class RelationshipPeerRelativesConstraint(RelationshipManagerConstraintInterface):
28
+ def __init__(self, db: InfrahubDatabase, branch: Branch | None = None):
29
+ self.db = db
30
+ self.branch = branch
31
+
32
+ async def _check_relationship_peers_relatives(
33
+ self,
34
+ relm: RelationshipManager,
35
+ node_schema: MainSchemaTypes,
36
+ peers: Mapping[str, Node],
37
+ relationship_name: str,
38
+ ) -> None:
39
+ """Validate that all peers of a given `relm` have the same set of relatives (aka peers) for the given `relationship_name`."""
40
+ nodes_to_validate: list[NodeToValidate] = []
41
+
42
+ for peer in peers.values():
43
+ peer_schema = peer.get_schema()
44
+ peer_relm: RelationshipManager = getattr(peer, relationship_name)
45
+ peer_relm_peers = await peer_relm.get_peers(db=self.db)
46
+
47
+ nodes_to_validate.append(
48
+ NodeToValidate(
49
+ uuid=peer.id, relative_uuids={n.id for n in peer_relm_peers.values()}, schema=peer_schema
50
+ )
51
+ )
52
+
53
+ relative_uuids = nodes_to_validate[0].relative_uuids
54
+ for node in nodes_to_validate[1:]:
55
+ if node.relative_uuids != relative_uuids:
56
+ raise ValidationError(
57
+ f"All the elements of the '{relm.name}' relationship on node {node.uuid} ({node_schema.kind}) must have the same set of peers "
58
+ f"for their '{node.schema.kind}.{relationship_name}' relationship"
59
+ )
60
+
61
+ async def check(self, relm: RelationshipManager, node_schema: MainSchemaTypes) -> None:
62
+ if relm.schema.cardinality != RelationshipCardinality.MANY or not relm.schema.common_relatives:
63
+ return
64
+
65
+ peers = await relm.get_peers(db=self.db)
66
+ if not peers:
67
+ return
68
+
69
+ for rel_name in relm.schema.common_relatives:
70
+ await self._check_relationship_peers_relatives(
71
+ relm=relm, node_schema=node_schema, peers=peers, relationship_name=rel_name
72
+ )
@@ -494,7 +494,7 @@ class Relationship(FlagPropertyMixin, NodePropertyMixin):
494
494
  peer_fields = {
495
495
  key: value
496
496
  for key, value in fields.items()
497
- if not key.startswith(PREFIX_PROPERTY) or not key == "__typename"
497
+ if not key.startswith(PREFIX_PROPERTY) or key != "__typename"
498
498
  }
499
499
  rel_fields = {
500
500
  key.replace(PREFIX_PROPERTY, ""): value
@@ -30,12 +30,6 @@ if TYPE_CHECKING:
30
30
 
31
31
 
32
32
  def get_attribute_schema_class_for_kind(kind: str) -> type[AttributeSchema]:
33
- attribute_schema_class_by_kind: dict[str, type[AttributeSchema]] = {
34
- "NumberPool": NumberPoolSchema,
35
- "Text": TextAttributeSchema,
36
- "TextArea": TextAttributeSchema,
37
- "Number": NumberAttributeSchema,
38
- }
39
33
  return attribute_schema_class_by_kind.get(kind, AttributeSchema)
40
34
 
41
35
 
@@ -43,6 +37,24 @@ class AttributeSchema(GeneratedAttributeSchema):
43
37
  _sort_by: list[str] = ["name"]
44
38
  _enum_class: type[enum.Enum] | None = None
45
39
 
40
+ @classmethod
41
+ def model_json_schema(cls, *args: Any, **kwargs: Any) -> dict[str, Any]:
42
+ schema = super().model_json_schema(*args, **kwargs)
43
+
44
+ # Build conditional schema based on attribute_schema_class_by_kind mapping
45
+ # This override allows people using the Yaml language server to get the correct mappings
46
+ # for the parameters when selecting the appropriate kind
47
+ schema["allOf"] = []
48
+ for kind, schema_class in attribute_schema_class_by_kind.items():
49
+ schema["allOf"].append(
50
+ {
51
+ "if": {"properties": {"kind": {"const": kind}}},
52
+ "then": {"properties": {"parameters": {"$ref": f"#/definitions/{schema_class.__name__}"}}},
53
+ }
54
+ )
55
+
56
+ return schema
57
+
46
58
  @property
47
59
  def is_attribute(self) -> bool:
48
60
  return True
@@ -247,3 +259,11 @@ class NumberAttributeSchema(AttributeSchema):
247
259
  description="Extra parameters specific to number attributes",
248
260
  json_schema_extra={"update": UpdateSupport.VALIDATE_CONSTRAINT.value},
249
261
  )
262
+
263
+
264
+ attribute_schema_class_by_kind: dict[str, type[AttributeSchema]] = {
265
+ "NumberPool": NumberPoolSchema,
266
+ "Text": TextAttributeSchema,
267
+ "TextArea": TextAttributeSchema,
268
+ "Number": NumberAttributeSchema,
269
+ }
@@ -386,7 +386,7 @@ class BaseNodeSchema(GeneratedBaseNodeSchema):
386
386
  if not self.display_labels:
387
387
  return None
388
388
 
389
- fields: dict[str, str | None | dict[str, None]] = {}
389
+ fields: dict[str, str | dict[str, None] | None] = {}
390
390
  for item in self.display_labels:
391
391
  fields.update(self.convert_path_to_graphql_fields(path=item))
392
392
  return fields
@@ -401,7 +401,7 @@ class BaseNodeSchema(GeneratedBaseNodeSchema):
401
401
  if not self.human_friendly_id:
402
402
  return None
403
403
 
404
- fields: dict[str, str | None | dict[str, None]] = {}
404
+ fields: dict[str, str | dict[str, None] | None] = {}
405
405
  for item in self.human_friendly_id:
406
406
  fields.update(self.convert_path_to_graphql_fields(path=item))
407
407
  return fields
@@ -1,6 +1,7 @@
1
1
  from infrahub.core.constants import (
2
2
  BranchSupportType,
3
3
  InfrahubKind,
4
+ NumberPoolType,
4
5
  )
5
6
  from infrahub.core.constants import RelationshipCardinality as Cardinality
6
7
  from infrahub.core.constants import RelationshipKind as RelKind
@@ -186,5 +187,13 @@ core_number_pool = NodeSchema(
186
187
  Attr(
187
188
  name="end_range", kind="Number", optional=False, description="The end range for the pool", order_weight=6000
188
189
  ),
190
+ Attr(
191
+ name="pool_type",
192
+ kind="Text",
193
+ description="Defines how this number pool was created",
194
+ default_value=NumberPoolType.USER.value,
195
+ enum=NumberPoolType.available_types(),
196
+ read_only=True,
197
+ ),
189
198
  ],
190
199
  )
@@ -82,7 +82,7 @@ class SchemaAttribute(BaseModel):
82
82
 
83
83
  @property
84
84
  def optional_in_model(self) -> bool:
85
- if self.optional and self.default_value is None and self.default_factory is None or self.default_to_none:
85
+ if (self.optional and self.default_value is None and self.default_factory is None) or self.default_to_none:
86
86
  return True
87
87
 
88
88
  return False
@@ -754,6 +754,14 @@ relationship_schema = SchemaNode(
754
754
  optional=True,
755
755
  extra={"update": UpdateSupport.VALIDATE_CONSTRAINT},
756
756
  ),
757
+ SchemaAttribute(
758
+ name="common_relatives",
759
+ kind="List",
760
+ internal_kind=str,
761
+ optional=True,
762
+ 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},
764
+ ),
757
765
  SchemaAttribute(
758
766
  name="order_weight",
759
767
  kind="Number",
@@ -9,10 +9,10 @@ from pydantic import Field
9
9
  from infrahub.core.constants import AllowOverrideType, BranchSupportType, HashableModelState
10
10
  from infrahub.core.models import HashableModel
11
11
  from infrahub.core.schema.attribute_parameters import (
12
- AttributeParameters, # noqa: TC001
13
- NumberAttributeParameters, # noqa: TC001
14
- NumberPoolParameters, # noqa: TC001
15
- TextAttributeParameters, # noqa: TC001
12
+ AttributeParameters,
13
+ NumberAttributeParameters,
14
+ NumberPoolParameters,
15
+ TextAttributeParameters,
16
16
  )
17
17
  from infrahub.core.schema.computed_attribute import ComputedAttribute # noqa: TC001
18
18
  from infrahub.core.schema.dropdown import DropdownChoice # noqa: TC001
@@ -12,7 +12,7 @@ from infrahub.core.constants import (
12
12
  RelationshipDeleteBehavior,
13
13
  RelationshipDirection,
14
14
  RelationshipKind,
15
- ) # noqa: TC001
15
+ )
16
16
  from infrahub.core.models import HashableModel
17
17
 
18
18
 
@@ -73,6 +73,11 @@ 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_relatives: list[str] | None = Field(
77
+ default=None,
78
+ 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"},
80
+ )
76
81
  order_weight: int | None = Field(
77
82
  default=None,
78
83
  description="Number used to order the relationship in the frontend (table and view). Lowest value will be ordered first.",
@@ -471,7 +471,7 @@ class SchemaManager(NodeManager):
471
471
  if diff_attributes:
472
472
  for item in node.local_attributes:
473
473
  # if item is in changed and has no ID, then it is being overridden from a generic and must be added
474
- if item.name in diff_attributes.added or item.name in diff_attributes.changed and item.id is None:
474
+ if item.name in diff_attributes.added or (item.name in diff_attributes.changed and item.id is None):
475
475
  created_item = await self.create_attribute_in_db(
476
476
  schema=attribute_schema, item=item, branch=branch, db=db, parent=obj
477
477
  )
@@ -491,7 +491,9 @@ class SchemaManager(NodeManager):
491
491
  if diff_relationships:
492
492
  for item in node.local_relationships:
493
493
  # if item is in changed and has no ID, then it is being overridden from a generic and must be added
494
- if item.name in diff_relationships.added or item.name in diff_relationships.changed and item.id is None:
494
+ if item.name in diff_relationships.added or (
495
+ item.name in diff_relationships.changed and item.id is None
496
+ ):
495
497
  created_rel = await self.create_relationship_in_db(
496
498
  schema=relationship_schema, item=item, branch=branch, db=db, parent=obj
497
499
  )
@@ -91,7 +91,7 @@ class SchemaBranch:
91
91
  self.templates = data.get("templates", {})
92
92
 
93
93
  @classmethod
94
- def validate(cls, data: Any) -> Self: # noqa: ARG003
94
+ def validate(cls, data: Any) -> Self:
95
95
  if isinstance(data, cls):
96
96
  return data
97
97
  if isinstance(data, dict):
@@ -453,7 +453,7 @@ class SchemaBranch:
453
453
  if isinstance(node, NodeSchema | ProfileSchema | TemplateSchema):
454
454
  return node.generate_fields_for_display_label()
455
455
 
456
- fields: dict[str, str | None | dict[str, None]] = {}
456
+ fields: dict[str, str | dict[str, None] | None] = {}
457
457
  if isinstance(node, GenericSchema):
458
458
  for child_node_name in node.used_by:
459
459
  child_node = self.get(name=child_node_name, duplicate=False)
@@ -997,6 +997,13 @@ class SchemaBranch:
997
997
  raise ValueError(
998
998
  f"{node.kind}: Relationship {rel.name!r} is referring an invalid peer {rel.peer!r}"
999
999
  ) from None
1000
+ if rel.common_relatives:
1001
+ peer_schema = self.get(name=rel.peer, duplicate=False)
1002
+ for common_relatives_rel_name in rel.common_relatives:
1003
+ if common_relatives_rel_name not in peer_schema.relationship_names:
1004
+ raise ValueError(
1005
+ f"{node.kind}: Relationship {rel.name!r} set 'common_relatives' with invalid relationship from '{rel.peer}'"
1006
+ ) from None
1000
1007
 
1001
1008
  def validate_attribute_parameters(self) -> None:
1002
1009
  for name in self.generics.keys():
@@ -2015,7 +2022,11 @@ class SchemaBranch:
2015
2022
  )
2016
2023
 
2017
2024
  parent_hfid = f"{relationship.name}__template_name__value"
2018
- if relationship.kind == RelationshipKind.PARENT and parent_hfid not in template_schema.human_friendly_id:
2025
+ if (
2026
+ not isinstance(template_schema, GenericSchema)
2027
+ and relationship.kind == RelationshipKind.PARENT
2028
+ and parent_hfid not in template_schema.human_friendly_id
2029
+ ):
2019
2030
  template_schema.human_friendly_id = [parent_hfid] + template_schema.human_friendly_id
2020
2031
  template_schema.uniqueness_constraints[0].append(relationship.name)
2021
2032
 
@@ -2051,7 +2062,6 @@ class SchemaBranch:
2051
2062
  include_in_menu=False,
2052
2063
  display_labels=["template_name__value"],
2053
2064
  human_friendly_id=["template_name__value"],
2054
- uniqueness_constraints=[["template_name__value"]],
2055
2065
  attributes=[template_name_attr],
2056
2066
  )
2057
2067
 
@@ -2070,7 +2080,6 @@ class SchemaBranch:
2070
2080
  human_friendly_id=["template_name__value"],
2071
2081
  uniqueness_constraints=[["template_name__value"]],
2072
2082
  inherit_from=[InfrahubKind.LINEAGESOURCE, InfrahubKind.NODE, core_template_schema.kind],
2073
- default_filter="template_name__value",
2074
2083
  attributes=[template_name_attr],
2075
2084
  relationships=[
2076
2085
  RelationshipSchema(
@@ -9,7 +9,7 @@ from prefect.logging import get_run_logger
9
9
 
10
10
  from infrahub.core.branch import Branch # noqa: TC001
11
11
  from infrahub.core.path import SchemaPath # noqa: TC001
12
- from infrahub.core.schema import GenericSchema, NodeSchema # noqa: TC001
12
+ from infrahub.core.schema import GenericSchema, NodeSchema
13
13
  from infrahub.core.validators.aggregated_checker import AggregatedConstraintChecker
14
14
  from infrahub.core.validators.model import (
15
15
  SchemaConstraintValidatorRequest,
@@ -339,7 +339,7 @@ class InfrahubDatabase:
339
339
  CONNECTION_POOL_USAGE.labels(self._driver._pool.address).set(float(connpool_usage))
340
340
 
341
341
  if config.SETTINGS.database.max_concurrent_queries:
342
- while connpool_usage > config.SETTINGS.database.max_concurrent_queries: # noqa: ASYNC110
342
+ while connpool_usage > config.SETTINGS.database.max_concurrent_queries:
343
343
  await asyncio.sleep(config.SETTINGS.database.max_concurrent_queries_delay)
344
344
  connpool_usage = self._driver._pool.in_use_connection_count(self._driver._pool.address)
345
345