infrahub-server 1.3.1__py3-none-any.whl → 1.3.3__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 (42) hide show
  1. infrahub/cli/db.py +194 -13
  2. infrahub/core/branch/enums.py +8 -0
  3. infrahub/core/branch/models.py +28 -5
  4. infrahub/core/branch/tasks.py +5 -7
  5. infrahub/core/diff/calculator.py +4 -1
  6. infrahub/core/diff/coordinator.py +32 -34
  7. infrahub/core/diff/diff_locker.py +26 -0
  8. infrahub/core/diff/query_parser.py +23 -32
  9. infrahub/core/graph/__init__.py +1 -1
  10. infrahub/core/initialization.py +4 -3
  11. infrahub/core/merge.py +31 -16
  12. infrahub/core/migrations/graph/__init__.py +24 -0
  13. infrahub/core/migrations/graph/m012_convert_account_generic.py +4 -3
  14. infrahub/core/migrations/graph/m013_convert_git_password_credential.py +4 -3
  15. infrahub/core/migrations/graph/m032_cleanup_orphaned_branch_relationships.py +105 -0
  16. infrahub/core/migrations/graph/m033_deduplicate_relationship_vertices.py +97 -0
  17. infrahub/core/node/__init__.py +3 -0
  18. infrahub/core/node/constraints/grouped_uniqueness.py +88 -132
  19. infrahub/core/node/resource_manager/ip_address_pool.py +5 -3
  20. infrahub/core/node/resource_manager/ip_prefix_pool.py +7 -4
  21. infrahub/core/node/resource_manager/number_pool.py +3 -1
  22. infrahub/core/node/standard.py +4 -0
  23. infrahub/core/query/branch.py +25 -56
  24. infrahub/core/query/node.py +78 -24
  25. infrahub/core/query/relationship.py +11 -8
  26. infrahub/core/relationship/model.py +10 -5
  27. infrahub/core/validators/uniqueness/model.py +17 -0
  28. infrahub/core/validators/uniqueness/query.py +212 -1
  29. infrahub/dependencies/builder/diff/coordinator.py +3 -0
  30. infrahub/dependencies/builder/diff/locker.py +8 -0
  31. infrahub/graphql/mutations/main.py +25 -4
  32. infrahub/graphql/mutations/tasks.py +2 -0
  33. infrahub_sdk/node/node.py +22 -10
  34. infrahub_sdk/node/related_node.py +7 -0
  35. {infrahub_server-1.3.1.dist-info → infrahub_server-1.3.3.dist-info}/METADATA +1 -1
  36. {infrahub_server-1.3.1.dist-info → infrahub_server-1.3.3.dist-info}/RECORD +42 -37
  37. infrahub_testcontainers/container.py +1 -1
  38. infrahub_testcontainers/docker-compose-cluster.test.yml +3 -0
  39. infrahub_testcontainers/docker-compose.test.yml +1 -0
  40. {infrahub_server-1.3.1.dist-info → infrahub_server-1.3.3.dist-info}/LICENSE.txt +0 -0
  41. {infrahub_server-1.3.1.dist-info → infrahub_server-1.3.3.dist-info}/WHEEL +0 -0
  42. {infrahub_server-1.3.1.dist-info → infrahub_server-1.3.3.dist-info}/entry_points.txt +0 -0
@@ -486,6 +486,7 @@ class DiffQueryParser:
486
486
  self._previous_node_field_specifiers = previous_node_field_specifiers or NodeFieldSpecifierMap()
487
487
  self._new_node_field_specifiers: NodeFieldSpecifierMap | None = None
488
488
  self._current_node_field_specifiers: NodeFieldSpecifierMap | None = None
489
+ self._diff_node_field_specifiers: NodeFieldSpecifierMap = NodeFieldSpecifierMap()
489
490
 
490
491
  def get_branches(self) -> set[str]:
491
492
  return set(self._final_diff_root_by_branch.keys())
@@ -497,33 +498,17 @@ class DiffQueryParser:
497
498
  return self._final_diff_root_by_branch[branch]
498
499
  return DiffRoot(from_time=self.from_time, to_time=self.to_time, uuid=str(uuid4()), branch=branch, nodes=[])
499
500
 
500
- def get_diff_node_field_specifiers(self) -> NodeFieldSpecifierMap:
501
- node_field_specifiers_map = NodeFieldSpecifierMap()
502
- if self.diff_branch_name not in self._diff_root_by_branch:
503
- return node_field_specifiers_map
504
- diff_root = self._diff_root_by_branch[self.diff_branch_name]
505
- for node in diff_root.nodes_by_identifier.values():
506
- for attribute_name in node.attributes_by_name:
507
- node_field_specifiers_map.add_entry(node_uuid=node.uuid, kind=node.kind, field_name=attribute_name)
508
- for relationship_diff in node.relationships_by_identifier.values():
509
- node_field_specifiers_map.add_entry(
510
- node_uuid=node.uuid, kind=node.kind, field_name=relationship_diff.identifier
511
- )
512
- return node_field_specifiers_map
513
-
514
501
  def get_new_node_field_specifiers(self) -> NodeFieldSpecifierMap:
515
- if self._new_node_field_specifiers is not None:
516
- return self._new_node_field_specifiers
517
- branch_node_specifiers = self.get_diff_node_field_specifiers()
518
- self._new_node_field_specifiers = branch_node_specifiers - self._previous_node_field_specifiers
519
- return self._new_node_field_specifiers
520
-
521
- def get_current_node_field_specifiers(self) -> NodeFieldSpecifierMap:
522
- if self._current_node_field_specifiers is not None:
523
- return self._current_node_field_specifiers
524
- new_node_field_specifiers = self.get_new_node_field_specifiers()
525
- self._current_node_field_specifiers = self._previous_node_field_specifiers - new_node_field_specifiers
526
- return self._current_node_field_specifiers
502
+ return self._diff_node_field_specifiers - self._previous_node_field_specifiers
503
+
504
+ def is_new_node_field_specifier(self, node_uuid: str, kind: str, field_name: str) -> bool:
505
+ if not self._diff_node_field_specifiers.has_entry(node_uuid=node_uuid, kind=kind, field_name=field_name):
506
+ return False
507
+ if self._previous_node_field_specifiers and self._previous_node_field_specifiers.has_entry(
508
+ node_uuid=node_uuid, kind=kind, field_name=field_name
509
+ ):
510
+ return False
511
+ return True
527
512
 
528
513
  def read_result(self, query_result: QueryResult) -> None:
529
514
  try:
@@ -533,8 +518,6 @@ class DiffQueryParser:
533
518
  return
534
519
  database_path = DatabasePath.from_cypher_path(cypher_path=path)
535
520
  self._parse_path(database_path=database_path)
536
- self._current_node_field_specifiers = None
537
- self._new_node_field_specifiers = None
538
521
 
539
522
  def parse(self, include_unchanged: bool = False) -> None:
540
523
  self._new_node_field_specifiers = None
@@ -617,11 +600,15 @@ class DiffQueryParser:
617
600
  branch_name = database_path.deepest_branch
618
601
  from_time = self.from_time
619
602
  if branch_name == self.base_branch_name:
620
- new_node_field_specifiers = self.get_new_node_field_specifiers()
621
- if new_node_field_specifiers.has_entry(
603
+ if self.is_new_node_field_specifier(
622
604
  node_uuid=diff_node.uuid, kind=diff_node.kind, field_name=attribute_name
623
605
  ):
624
606
  from_time = self.diff_branched_from_time
607
+ else:
608
+ # Add to diff node field specifiers if this is the diff branch
609
+ self._diff_node_field_specifiers.add_entry(
610
+ node_uuid=diff_node.uuid, kind=diff_node.kind, field_name=attribute_name
611
+ )
625
612
  if attribute_name not in diff_node.attributes_by_name:
626
613
  diff_node.attributes_by_name[attribute_name] = DiffAttributeIntermediate(
627
614
  uuid=database_path.attribute_id,
@@ -663,11 +650,15 @@ class DiffQueryParser:
663
650
  branch_name = database_path.deepest_branch
664
651
  from_time = self.from_time
665
652
  if branch_name == self.base_branch_name:
666
- new_node_field_specifiers = self.get_new_node_field_specifiers()
667
- if new_node_field_specifiers.has_entry(
653
+ if self.is_new_node_field_specifier(
668
654
  node_uuid=diff_node.uuid, kind=diff_node.kind, field_name=relationship_schema.get_identifier()
669
655
  ):
670
656
  from_time = self.diff_branched_from_time
657
+ else:
658
+ # Add to diff node field specifiers if this is the diff branch
659
+ self._diff_node_field_specifiers.add_entry(
660
+ node_uuid=diff_node.uuid, kind=diff_node.kind, field_name=relationship_schema.get_identifier()
661
+ )
671
662
  diff_relationship = DiffRelationshipIntermediate(
672
663
  name=relationship_schema.name,
673
664
  cardinality=relationship_schema.cardinality,
@@ -1 +1 @@
1
- GRAPH_VERSION = 31
1
+ GRAPH_VERSION = 33
@@ -6,6 +6,7 @@ from infrahub import config, lock
6
6
  from infrahub.constants.database import DatabaseType
7
7
  from infrahub.core import registry
8
8
  from infrahub.core.branch import Branch
9
+ from infrahub.core.branch.enums import BranchStatus
9
10
  from infrahub.core.constants import (
10
11
  DEFAULT_IP_NAMESPACE,
11
12
  GLOBAL_BRANCH_NAME,
@@ -224,7 +225,7 @@ async def create_root_node(db: InfrahubDatabase) -> Root:
224
225
  async def create_default_branch(db: InfrahubDatabase) -> Branch:
225
226
  branch = Branch(
226
227
  name=registry.default_branch,
227
- status="OPEN",
228
+ status=BranchStatus.OPEN,
228
229
  description="Default Branch",
229
230
  hierarchy_level=1,
230
231
  is_default=True,
@@ -241,7 +242,7 @@ async def create_default_branch(db: InfrahubDatabase) -> Branch:
241
242
  async def create_global_branch(db: InfrahubDatabase) -> Branch:
242
243
  branch = Branch(
243
244
  name=GLOBAL_BRANCH_NAME,
244
- status="OPEN",
245
+ status=BranchStatus.OPEN,
245
246
  description="Global Branch",
246
247
  hierarchy_level=1,
247
248
  is_global=True,
@@ -264,7 +265,7 @@ async def create_branch(
264
265
  description = description or f"Branch {branch_name}"
265
266
  branch = Branch(
266
267
  name=branch_name,
267
- status="OPEN",
268
+ status=BranchStatus.OPEN,
268
269
  hierarchy_level=2,
269
270
  description=description,
270
271
  is_default=False,
infrahub/core/merge.py CHANGED
@@ -9,7 +9,7 @@ from infrahub.core.models import SchemaUpdateValidationResult
9
9
  from infrahub.core.protocols import CoreRepository
10
10
  from infrahub.core.registry import registry
11
11
  from infrahub.core.timestamp import Timestamp
12
- from infrahub.exceptions import ValidationError
12
+ from infrahub.exceptions import MergeFailedError, ValidationError
13
13
  from infrahub.log import get_logger
14
14
 
15
15
  from ..git.models import GitRepositoryMerge
@@ -18,6 +18,7 @@ from ..workflows.catalogue import GIT_REPOSITORIES_MERGE
18
18
  if TYPE_CHECKING:
19
19
  from infrahub.core.branch import Branch
20
20
  from infrahub.core.diff.coordinator import DiffCoordinator
21
+ from infrahub.core.diff.diff_locker import DiffLocker
21
22
  from infrahub.core.diff.merger.merger import DiffMerger
22
23
  from infrahub.core.diff.model.path import EnrichedDiffRoot
23
24
  from infrahub.core.diff.repository.repository import DiffRepository
@@ -39,6 +40,7 @@ class BranchMerger:
39
40
  diff_coordinator: DiffCoordinator,
40
41
  diff_merger: DiffMerger,
41
42
  diff_repository: DiffRepository,
43
+ diff_locker: DiffLocker,
42
44
  destination_branch: Branch | None = None,
43
45
  service: InfrahubServices | None = None,
44
46
  ):
@@ -48,6 +50,7 @@ class BranchMerger:
48
50
  self.diff_coordinator = diff_coordinator
49
51
  self.diff_merger = diff_merger
50
52
  self.diff_repository = diff_repository
53
+ self.diff_locker = diff_locker
51
54
  self.migrations: list[SchemaUpdateMigrationInfo] = []
52
55
  self._merge_at = Timestamp()
53
56
 
@@ -185,22 +188,34 @@ class BranchMerger:
185
188
  )
186
189
  log.info("Diff updated for merge")
187
190
 
188
- errors: list[str] = []
189
- async for conflict_path, conflict in self.diff_repository.get_all_conflicts_for_diff(
190
- diff_branch_name=self.source_branch.name, tracking_id=BranchTrackingId(name=self.source_branch.name)
191
+ log.info("Acquiring lock for merge")
192
+ async with self.diff_locker.acquire_lock(
193
+ target_branch_name=self.destination_branch.name,
194
+ source_branch_name=self.source_branch.name,
195
+ is_incremental=False,
191
196
  ):
192
- if conflict.selected_branch is None or conflict.resolvable is False:
193
- errors.append(conflict_path)
194
-
195
- if errors:
196
- raise ValidationError(
197
- f"Unable to merge the branch '{self.source_branch.name}', conflict resolution missing: {', '.join(errors)}"
198
- )
199
-
200
- # TODO need to find a way to properly communicate back to the user any issue that could come up during the merge
201
- # From the Graph or From the repositories
202
- self._merge_at = Timestamp(at)
203
- branch_diff = await self.diff_merger.merge_graph(at=self._merge_at)
197
+ log.info("Lock acquired for merge")
198
+ try:
199
+ errors: list[str] = []
200
+ async for conflict_path, conflict in self.diff_repository.get_all_conflicts_for_diff(
201
+ diff_branch_name=self.source_branch.name, tracking_id=BranchTrackingId(name=self.source_branch.name)
202
+ ):
203
+ if conflict.selected_branch is None or conflict.resolvable is False:
204
+ errors.append(conflict_path)
205
+
206
+ if errors:
207
+ raise ValidationError(
208
+ f"Unable to merge the branch '{self.source_branch.name}', conflict resolution missing: {', '.join(errors)}"
209
+ )
210
+
211
+ # TODO need to find a way to properly communicate back to the user any issue that could come up during the merge
212
+ # From the Graph or From the repositories
213
+ self._merge_at = Timestamp(at)
214
+ branch_diff = await self.diff_merger.merge_graph(at=self._merge_at)
215
+ except Exception as exc:
216
+ log.exception("Merge failed, beginning rollback")
217
+ await self.rollback()
218
+ raise MergeFailedError(branch_name=self.source_branch.name) from exc
204
219
  await self.merge_repositories()
205
220
  return branch_diff
206
221
 
@@ -33,6 +33,8 @@ from .m028_delete_diffs import Migration028
33
33
  from .m029_duplicates_cleanup import Migration029
34
34
  from .m030_illegal_edges import Migration030
35
35
  from .m031_check_number_attributes import Migration031
36
+ from .m032_cleanup_orphaned_branch_relationships import Migration032
37
+ from .m033_deduplicate_relationship_vertices import Migration033
36
38
 
37
39
  if TYPE_CHECKING:
38
40
  from infrahub.core.root import Root
@@ -71,6 +73,8 @@ MIGRATIONS: list[type[GraphMigration | InternalSchemaMigration | ArbitraryMigrat
71
73
  Migration029,
72
74
  Migration030,
73
75
  Migration031,
76
+ Migration032,
77
+ Migration033,
74
78
  ]
75
79
 
76
80
 
@@ -85,3 +89,23 @@ async def get_graph_migrations(
85
89
  applicable_migrations.append(migration)
86
90
 
87
91
  return applicable_migrations
92
+
93
+
94
+ def get_migration_by_number(
95
+ migration_number: int | str,
96
+ ) -> GraphMigration | InternalSchemaMigration | ArbitraryMigration:
97
+ # Convert to string and pad with zeros if needed
98
+ try:
99
+ num = int(migration_number)
100
+ migration_str = f"{num:03d}"
101
+ except (ValueError, TypeError) as exc:
102
+ raise ValueError(f"Invalid migration number: {migration_number}") from exc
103
+
104
+ migration_name = f"Migration{migration_str}"
105
+
106
+ # Find the migration in the MIGRATIONS list
107
+ for migration_class in MIGRATIONS:
108
+ if migration_class.__name__ == migration_name:
109
+ return migration_class.init()
110
+
111
+ raise ValueError(f"Migration {migration_number} not found")
@@ -3,6 +3,7 @@ from __future__ import annotations
3
3
  from typing import TYPE_CHECKING, Any, Sequence
4
4
 
5
5
  from infrahub.core.branch import Branch
6
+ from infrahub.core.branch.enums import BranchStatus
6
7
  from infrahub.core.constants import GLOBAL_BRANCH_NAME, BranchSupportType, InfrahubKind
7
8
  from infrahub.core.migrations.shared import MigrationResult
8
9
  from infrahub.core.query import Query, QueryType
@@ -20,7 +21,7 @@ if TYPE_CHECKING:
20
21
 
21
22
  global_branch = Branch(
22
23
  name=GLOBAL_BRANCH_NAME,
23
- status="OPEN",
24
+ status=BranchStatus.OPEN,
24
25
  description="Global Branch",
25
26
  hierarchy_level=1,
26
27
  is_global=True,
@@ -29,7 +30,7 @@ global_branch = Branch(
29
30
 
30
31
  default_branch = Branch(
31
32
  name="main",
32
- status="OPEN",
33
+ status=BranchStatus.OPEN,
33
34
  description="Default Branch",
34
35
  hierarchy_level=1,
35
36
  is_global=False,
@@ -105,7 +106,7 @@ class Migration012AddLabelData(NodeDuplicateQuery):
105
106
 
106
107
  branch = Branch(
107
108
  name=GLOBAL_BRANCH_NAME,
108
- status="OPEN",
109
+ status=BranchStatus.OPEN,
109
110
  description="Global Branch",
110
111
  hierarchy_level=1,
111
112
  is_global=True,
@@ -3,6 +3,7 @@ from __future__ import annotations
3
3
  from typing import TYPE_CHECKING, Any, Sequence
4
4
 
5
5
  from infrahub.core.branch import Branch
6
+ from infrahub.core.branch.enums import BranchStatus
6
7
  from infrahub.core.constants import (
7
8
  GLOBAL_BRANCH_NAME,
8
9
  BranchSupportType,
@@ -23,7 +24,7 @@ if TYPE_CHECKING:
23
24
 
24
25
  default_branch = Branch(
25
26
  name="main",
26
- status="OPEN",
27
+ status=BranchStatus.OPEN,
27
28
  description="Default Branch",
28
29
  hierarchy_level=1,
29
30
  is_global=False,
@@ -42,7 +43,7 @@ class Migration013ConvertCoreRepositoryWithCred(Query):
42
43
 
43
44
  global_branch = Branch(
44
45
  name=GLOBAL_BRANCH_NAME,
45
- status="OPEN",
46
+ status=BranchStatus.OPEN,
46
47
  description="Global Branch",
47
48
  hierarchy_level=1,
48
49
  is_global=True,
@@ -176,7 +177,7 @@ class Migration013ConvertCoreRepositoryWithoutCred(Query):
176
177
 
177
178
  global_branch = Branch(
178
179
  name=GLOBAL_BRANCH_NAME,
179
- status="OPEN",
180
+ status=BranchStatus.OPEN,
180
181
  description="Global Branch",
181
182
  hierarchy_level=1,
182
183
  is_global=True,
@@ -0,0 +1,105 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Any
4
+
5
+ from infrahub.core.migrations.shared import MigrationResult
6
+ from infrahub.core.query import Query, QueryType
7
+ from infrahub.core.query.branch import DeleteBranchRelationshipsQuery
8
+ from infrahub.log import get_logger
9
+
10
+ from ..shared import ArbitraryMigration
11
+
12
+ if TYPE_CHECKING:
13
+ from infrahub.database import InfrahubDatabase
14
+
15
+ log = get_logger()
16
+
17
+
18
+ class DeletedBranchCleanupQuery(Query):
19
+ """
20
+ Find all unique edge branch names for which there is no Branch object
21
+ """
22
+
23
+ name = "deleted_branch_cleanup"
24
+ type = QueryType.WRITE
25
+ insert_return = False
26
+
27
+ async def query_init(self, db: InfrahubDatabase, **kwargs: dict[str, Any]) -> None: # noqa: ARG002
28
+ query = """
29
+ MATCH (b:Branch)
30
+ WITH collect(DISTINCT b.name) AS branch_names
31
+ MATCH ()-[e]->()
32
+ WHERE e.branch IS NOT NULL
33
+ AND NOT e.branch IN branch_names
34
+ RETURN DISTINCT (e.branch) AS branch_name
35
+ """
36
+ self.add_to_query(query)
37
+ self.return_labels = ["branch_name"]
38
+
39
+
40
+ class DeleteOrphanRelationshipsQuery(Query):
41
+ """
42
+ Find all Relationship vertices that link to fewer than 2 Node vertices and delete them
43
+ """
44
+
45
+ name = "delete_orphan_relationships"
46
+ type = QueryType.WRITE
47
+ insert_return = False
48
+
49
+ async def query_init(self, db: InfrahubDatabase, **kwargs: dict[str, Any]) -> None: # noqa: ARG002
50
+ query = """
51
+ MATCH (r:Relationship)-[:IS_RELATED]-(n:Node)
52
+ WITH DISTINCT r, n
53
+ WITH r, count(*) AS node_count
54
+ WHERE node_count < 2
55
+ DETACH DELETE r
56
+ """
57
+ self.add_to_query(query)
58
+
59
+
60
+ class Migration032(ArbitraryMigration):
61
+ """
62
+ Delete edges for branches that were not completely deleted
63
+ """
64
+
65
+ name: str = "032_cleanup_deleted_branches"
66
+ minimum_version: int = 31
67
+
68
+ async def validate_migration(self, db: InfrahubDatabase) -> MigrationResult: # noqa: ARG002
69
+ return MigrationResult()
70
+
71
+ async def execute(self, db: InfrahubDatabase) -> MigrationResult:
72
+ migration_result = MigrationResult()
73
+
74
+ try:
75
+ log.info("Get partially deleted branch names...")
76
+ orphaned_branches_query = await DeletedBranchCleanupQuery.init(db=db)
77
+ await orphaned_branches_query.execute(db=db)
78
+
79
+ orphaned_branch_names = []
80
+ for result in orphaned_branches_query.get_results():
81
+ branch_name = result.get_as_type("branch_name", str)
82
+ orphaned_branch_names.append(branch_name)
83
+
84
+ if not orphaned_branch_names:
85
+ log.info("No partially deleted branches found. All done.")
86
+ return migration_result
87
+
88
+ log.info(f"Found {len(orphaned_branch_names)} orphaned branch names: {orphaned_branch_names}")
89
+
90
+ for branch_name in orphaned_branch_names:
91
+ log.info(f"Cleaning up branch '{branch_name}'...")
92
+ delete_query = await DeleteBranchRelationshipsQuery.init(db=db, branch_name=branch_name)
93
+ await delete_query.execute(db=db)
94
+ log.info(f"Branch '{branch_name}' cleaned up.")
95
+
96
+ log.info("Deleting orphaned relationships...")
97
+ delete_relationships_query = await DeleteOrphanRelationshipsQuery.init(db=db)
98
+ await delete_relationships_query.execute(db=db)
99
+ log.info("Orphaned relationships deleted.")
100
+
101
+ except Exception as exc:
102
+ migration_result.errors.append(str(exc))
103
+ log.exception("Error during branch cleanup")
104
+
105
+ return migration_result
@@ -0,0 +1,97 @@
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 DeduplicateRelationshipVerticesQuery(Query):
17
+ """
18
+ For each group of duplicate Relationships with the same UUID, delete any Relationship that meets the following criteria:
19
+ - is linked to a deleted node (only if the delete time is before the Relationship's from time)
20
+ - is linked to a node on an incorrect branch (ie Relationship added on main, but Node is on a branch)
21
+ """
22
+
23
+ name = "deduplicate_relationship_vertices"
24
+ type = QueryType.WRITE
25
+ insert_return = False
26
+
27
+ async def query_init(self, db: InfrahubDatabase, **kwargs: dict[str, Any]) -> None: # noqa: ARG002
28
+ query = """
29
+ MATCH (root:Root)
30
+ WITH root.default_branch AS default_branch_name
31
+ // ------------
32
+ // Find all Relationship vertices with duplicate UUIDs
33
+ // ------------
34
+ MATCH (r:Relationship)
35
+ WITH r.uuid AS r_uuid, default_branch_name, count(*) AS num_dups
36
+ WHERE num_dups > 1
37
+ WITH DISTINCT r_uuid, default_branch_name
38
+ // ------------
39
+ // get the branched_from time for each relationship edge and node
40
+ // ------------
41
+ MATCH (rel:Relationship {uuid: r_uuid})
42
+ CALL (rel) {
43
+ MATCH (rel)-[is_rel_e:IS_RELATED {status: "active"}]-(n:Node)
44
+ MATCH (rel_branch:Branch {name: is_rel_e.branch})
45
+ RETURN is_rel_e, rel_branch.branched_from AS rel_branched_from, n
46
+ }
47
+ // ------------
48
+ // for each IS_RELATED edge of the relationship, check the IS_PART_OF edges of the Node vertex
49
+ // to determine if this side of the relationship is legal
50
+ // ------------
51
+ CALL (n, is_rel_e, rel_branched_from, default_branch_name) {
52
+ OPTIONAL MATCH (n)-[is_part_of_e:IS_PART_OF {status: "active"}]->(:Root)
53
+ WHERE (
54
+ // the Node's create time must precede the Relationship's create time
55
+ is_part_of_e.from <= is_rel_e.from AND (is_part_of_e.to >= is_rel_e.from OR is_part_of_e.to IS NULL)
56
+ // the Node must have been created on a branch of equal or lesser depth than the Relationship
57
+ AND is_part_of_e.branch_level <= is_rel_e.branch_level
58
+ // if the Node and Relationships were created on branch_level = 2, then they must be on the same branch
59
+ AND (
60
+ is_part_of_e.branch_level = 1
61
+ OR is_part_of_e.branch = is_rel_e.branch
62
+ )
63
+ // if the Node was created on the default branch, and the Relationship was created on a branch,
64
+ // then the Node must have been created after the branched_from time of the Relationship's branch
65
+ AND (
66
+ is_part_of_e.branch <> default_branch_name
67
+ OR is_rel_e.branch_level = 1
68
+ OR is_part_of_e.from <= rel_branched_from
69
+ )
70
+ )
71
+ WITH is_part_of_e IS NOT NULL AS is_legal
72
+ ORDER BY is_legal DESC
73
+ RETURN is_legal
74
+ LIMIT 1
75
+ }
76
+ WITH rel, is_legal
77
+ ORDER BY rel, is_legal ASC
78
+ WITH rel, head(collect(is_legal)) AS is_legal
79
+ WHERE is_legal = false
80
+ DETACH DELETE rel
81
+ """
82
+ self.add_to_query(query)
83
+
84
+
85
+ class Migration033(GraphMigration):
86
+ """
87
+ Identifies duplicate Relationship vertices that have the same UUID property. Deletes any duplicates that
88
+ are linked to deleted nodes or nodes on in incorrect branch.
89
+ """
90
+
91
+ name: str = "033_deduplicate_relationship_vertices"
92
+ minimum_version: int = 31
93
+ queries: Sequence[type[Query]] = [DeduplicateRelationshipVerticesQuery]
94
+
95
+ async def validate_migration(self, db: InfrahubDatabase) -> MigrationResult: # noqa: ARG002
96
+ result = MigrationResult()
97
+ return result
@@ -82,6 +82,9 @@ class Node(BaseNode, metaclass=BaseNodeMeta):
82
82
  def get_schema(self) -> NonGenericSchemaTypes:
83
83
  return self._schema
84
84
 
85
+ def get_branch(self) -> Branch:
86
+ return self._branch
87
+
85
88
  def get_kind(self) -> str:
86
89
  """Return the main Kind of the Object."""
87
90
  return self._schema.kind