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
@@ -10,6 +10,7 @@ from typing import TYPE_CHECKING, Any, AsyncIterator, Generator
10
10
  from infrahub import config
11
11
  from infrahub.core import registry
12
12
  from infrahub.core.constants import (
13
+ GLOBAL_BRANCH_NAME,
13
14
  AttributeDBNodeType,
14
15
  RelationshipDirection,
15
16
  RelationshipHierarchyDirection,
@@ -128,7 +129,7 @@ class NodeCreateAllQuery(NodeQuery):
128
129
 
129
130
  raise_error_if_empty: bool = True
130
131
 
131
- async def query_init(self, db: InfrahubDatabase, **kwargs) -> None: # noqa: ARG002
132
+ async def query_init(self, db: InfrahubDatabase, **kwargs) -> None: # noqa: ARG002, PLR0915
132
133
  at = self.at or self.node._at
133
134
  self.params["uuid"] = self.node.id
134
135
  self.params["branch"] = self.branch.name
@@ -151,11 +152,19 @@ class NodeCreateAllQuery(NodeQuery):
151
152
  else:
152
153
  attributes.append(attr_data)
153
154
 
155
+ deepest_branch_name = self.branch.name
156
+ deepest_branch_level = self.branch.hierarchy_level
154
157
  relationships: list[RelationshipCreateData] = []
155
158
  for rel_name in self.node._relationships:
156
159
  rel_manager: RelationshipManager = getattr(self.node, rel_name)
157
160
  for rel in rel_manager._relationships:
158
- relationships.append(await rel.get_create_data(db=db))
161
+ rel_create_data = await rel.get_create_data(db=db, at=at)
162
+ if rel_create_data.peer_branch_level > deepest_branch_level or (
163
+ deepest_branch_name == GLOBAL_BRANCH_NAME and rel_create_data.peer_branch == registry.default_branch
164
+ ):
165
+ deepest_branch_name = rel_create_data.peer_branch
166
+ deepest_branch_level = rel_create_data.peer_branch_level
167
+ relationships.append(rel_create_data)
159
168
 
160
169
  self.params["attrs"] = [attr.model_dump() for attr in attributes]
161
170
  self.params["attrs_iphost"] = [attr.model_dump() for attr in attributes_iphost]
@@ -216,6 +225,8 @@ class NodeCreateAllQuery(NodeQuery):
216
225
  CREATE (a)-[:HAS_VALUE { branch: attr.branch, branch_level: attr.branch_level, status: attr.status, from: $at }]->(av)
217
226
  MERGE (ip:Boolean { value: attr.is_protected })
218
227
  MERGE (iv:Boolean { value: attr.is_visible })
228
+ WITH a, ip, iv
229
+ LIMIT 1
219
230
  CREATE (a)-[:IS_PROTECTED { branch: attr.branch, branch_level: attr.branch_level, status: attr.status, from: $at }]->(ip)
220
231
  CREATE (a)-[:IS_VISIBLE { branch: attr.branch, branch_level: attr.branch_level, status: attr.status, from: $at }]->(iv)
221
232
  FOREACH ( prop IN attr.source_prop |
@@ -230,9 +241,8 @@ class NodeCreateAllQuery(NodeQuery):
230
241
 
231
242
  attrs_iphost_query = """
232
243
  WITH distinct n
233
- UNWIND $attrs_iphost AS attr_iphost
234
- CALL (n, attr_iphost) {
235
- WITH n, attr_iphost AS attr
244
+ UNWIND $attrs_iphost AS attr
245
+ CALL (n, attr) {
236
246
  CREATE (a:Attribute { uuid: attr.uuid, name: attr.name, branch_support: attr.branch_support })
237
247
  CREATE (n)-[:HAS_ATTRIBUTE { branch: attr.branch, branch_level: attr.branch_level, status: attr.status, from: $at }]->(a)
238
248
  MERGE (av:AttributeValue:AttributeIPHost { %(iphost_prop)s })
@@ -241,6 +251,8 @@ class NodeCreateAllQuery(NodeQuery):
241
251
  CREATE (a)-[:HAS_VALUE { branch: attr.branch, branch_level: attr.branch_level, status: attr.status, from: $at }]->(av)
242
252
  MERGE (ip:Boolean { value: attr.is_protected })
243
253
  MERGE (iv:Boolean { value: attr.is_visible })
254
+ WITH a, ip, iv
255
+ LIMIT 1
244
256
  CREATE (a)-[:IS_PROTECTED { branch: attr.branch, branch_level: attr.branch_level, status: attr.status, from: $at }]->(ip)
245
257
  CREATE (a)-[:IS_VISIBLE { branch: attr.branch, branch_level: attr.branch_level, status: attr.status, from: $at }]->(iv)
246
258
  FOREACH ( prop IN attr.source_prop |
@@ -256,9 +268,8 @@ class NodeCreateAllQuery(NodeQuery):
256
268
 
257
269
  attrs_ipnetwork_query = """
258
270
  WITH distinct n
259
- UNWIND $attrs_ipnetwork AS attr_ipnetwork
260
- CALL (n, attr_ipnetwork) {
261
- WITH n, attr_ipnetwork AS attr
271
+ UNWIND $attrs_ipnetwork AS attr
272
+ CALL (n, attr) {
262
273
  CREATE (a:Attribute { uuid: attr.uuid, name: attr.name, branch_support: attr.branch_support })
263
274
  CREATE (n)-[:HAS_ATTRIBUTE { branch: attr.branch, branch_level: attr.branch_level, status: attr.status, from: $at }]->(a)
264
275
  MERGE (av:AttributeValue:AttributeIPNetwork { %(ipnetwork_prop)s })
@@ -267,6 +278,8 @@ class NodeCreateAllQuery(NodeQuery):
267
278
  CREATE (a)-[:HAS_VALUE { branch: attr.branch, branch_level: attr.branch_level, status: attr.status, from: $at }]->(av)
268
279
  MERGE (ip:Boolean { value: attr.is_protected })
269
280
  MERGE (iv:Boolean { value: attr.is_visible })
281
+ WITH a, ip, iv
282
+ LIMIT 1
270
283
  CREATE (a)-[:IS_PROTECTED { branch: attr.branch, branch_level: attr.branch_level, status: attr.status, from: $at }]->(ip)
271
284
  CREATE (a)-[:IS_VISIBLE { branch: attr.branch, branch_level: attr.branch_level, status: attr.status, from: $at }]->(iv)
272
285
  FOREACH ( prop IN attr.source_prop |
@@ -280,16 +293,55 @@ class NodeCreateAllQuery(NodeQuery):
280
293
  }
281
294
  """ % {"ipnetwork_prop": ", ".join(ipnetwork_prop_list)}
282
295
 
296
+ deepest_branch = await registry.get_branch(db=db, branch=deepest_branch_name)
297
+ branch_filter, branch_params = deepest_branch.get_query_filter_path(at=self.at)
298
+ self.params.update(branch_params)
299
+ self.params["global_branch_name"] = GLOBAL_BRANCH_NAME
300
+ self.params["default_branch_name"] = registry.default_branch
301
+
302
+ dest_node_subquery = """
303
+ CALL (rel) {
304
+ MATCH (dest_node:Node { uuid: rel.destination_id })-[r:IS_PART_OF]->(root:Root)
305
+ WHERE (
306
+ // if the relationship is on a branch, use the regular filter
307
+ (rel.peer_branch_level = 2 AND %(branch_filter)s)
308
+ // simplified filter for the global branch
309
+ OR (
310
+ rel.peer_branch_level = 1
311
+ AND rel.peer_branch = $global_branch_name
312
+ AND r.branch = $global_branch_name
313
+ AND r.from <= $at AND (r.to IS NULL or r.to > $at)
314
+ )
315
+ // simplified filter for the default branch
316
+ OR (
317
+ rel.peer_branch_level = 1 AND
318
+ rel.peer_branch = $default_branch_name AND
319
+ r.branch IN [$default_branch_name, $global_branch_name]
320
+ AND r.from <= $at AND (r.to IS NULL or r.to > $at)
321
+ )
322
+ )
323
+ // r.status is a tie-breaker when there are nodes with the same UUID added/deleted at the same time
324
+ ORDER BY r.branch_level DESC, r.from DESC, r.status ASC
325
+ WITH dest_node, r
326
+ LIMIT 1
327
+ WITH dest_node, r
328
+ WHERE r.status = "active"
329
+ RETURN dest_node
330
+ }
331
+ """ % {"branch_filter": branch_filter}
332
+
283
333
  rels_bidir_query = """
284
334
  WITH distinct n
285
335
  UNWIND $rels_bidir AS rel
286
- CALL (n, rel) {
287
- MERGE (d:Node { uuid: rel.destination_id })
336
+ %(dest_node_subquery)s
337
+ CALL (n, rel, dest_node) {
288
338
  CREATE (rl:Relationship { uuid: rel.uuid, name: rel.name, branch_support: rel.branch_support })
289
339
  CREATE (n)-[:IS_RELATED %(rel_prop)s ]->(rl)
290
- CREATE (d)-[:IS_RELATED %(rel_prop)s ]->(rl)
340
+ CREATE (dest_node)-[:IS_RELATED %(rel_prop)s ]->(rl)
291
341
  MERGE (ip:Boolean { value: rel.is_protected })
292
342
  MERGE (iv:Boolean { value: rel.is_visible })
343
+ WITH rl, ip, iv
344
+ LIMIT 1
293
345
  CREATE (rl)-[:IS_PROTECTED { branch: rel.branch, branch_level: rel.branch_level, status: rel.status, from: $at }]->(ip)
294
346
  CREATE (rl)-[:IS_VISIBLE { branch: rel.branch, branch_level: rel.branch_level, status: rel.status, from: $at }]->(iv)
295
347
  FOREACH ( prop IN rel.source_prop |
@@ -301,19 +353,20 @@ class NodeCreateAllQuery(NodeQuery):
301
353
  CREATE (rl)-[:HAS_OWNER { branch: rel.branch, branch_level: rel.branch_level, status: rel.status, from: $at }]->(peer)
302
354
  )
303
355
  }
304
- """ % {"rel_prop": rel_prop_str}
356
+ """ % {"rel_prop": rel_prop_str, "dest_node_subquery": dest_node_subquery}
305
357
 
306
358
  rels_out_query = """
307
359
  WITH distinct n
308
- UNWIND $rels_out AS rel_out
309
- CALL (n, rel_out) {
310
- WITH n, rel_out as rel
311
- MERGE (d:Node { uuid: rel.destination_id })
360
+ UNWIND $rels_out AS rel
361
+ %(dest_node_subquery)s
362
+ CALL (n, rel, dest_node) {
312
363
  CREATE (rl:Relationship { uuid: rel.uuid, name: rel.name, branch_support: rel.branch_support })
313
364
  CREATE (n)-[:IS_RELATED %(rel_prop)s ]->(rl)
314
- CREATE (d)<-[:IS_RELATED %(rel_prop)s ]-(rl)
365
+ CREATE (dest_node)<-[:IS_RELATED %(rel_prop)s ]-(rl)
315
366
  MERGE (ip:Boolean { value: rel.is_protected })
316
367
  MERGE (iv:Boolean { value: rel.is_visible })
368
+ WITH rl, ip, iv
369
+ LIMIT 1
317
370
  CREATE (rl)-[:IS_PROTECTED { branch: rel.branch, branch_level: rel.branch_level, status: rel.status, from: $at }]->(ip)
318
371
  CREATE (rl)-[:IS_VISIBLE { branch: rel.branch, branch_level: rel.branch_level, status: rel.status, from: $at }]->(iv)
319
372
  FOREACH ( prop IN rel.source_prop |
@@ -325,19 +378,20 @@ class NodeCreateAllQuery(NodeQuery):
325
378
  CREATE (rl)-[:HAS_OWNER { branch: rel.branch, branch_level: rel.branch_level, status: rel.status, from: $at }]->(peer)
326
379
  )
327
380
  }
328
- """ % {"rel_prop": rel_prop_str}
381
+ """ % {"rel_prop": rel_prop_str, "dest_node_subquery": dest_node_subquery}
329
382
 
330
383
  rels_in_query = """
331
384
  WITH distinct n
332
- UNWIND $rels_in AS rel_in
333
- CALL (n, rel_in) {
334
- WITH n, rel_in AS rel
335
- MERGE (d:Node { uuid: rel.destination_id })
385
+ UNWIND $rels_in AS rel
386
+ %(dest_node_subquery)s
387
+ CALL (n, rel, dest_node) {
336
388
  CREATE (rl:Relationship { uuid: rel.uuid, name: rel.name, branch_support: rel.branch_support })
337
389
  CREATE (n)<-[:IS_RELATED %(rel_prop)s ]-(rl)
338
- CREATE (d)-[:IS_RELATED %(rel_prop)s ]->(rl)
390
+ CREATE (dest_node)-[:IS_RELATED %(rel_prop)s ]->(rl)
339
391
  MERGE (ip:Boolean { value: rel.is_protected })
340
392
  MERGE (iv:Boolean { value: rel.is_visible })
393
+ WITH rl, ip, iv
394
+ LIMIT 1
341
395
  CREATE (rl)-[:IS_PROTECTED { branch: rel.branch, branch_level: rel.branch_level, status: rel.status, from: $at }]->(ip)
342
396
  CREATE (rl)-[:IS_VISIBLE { branch: rel.branch, branch_level: rel.branch_level, status: rel.status, from: $at }]->(iv)
343
397
  FOREACH ( prop IN rel.source_prop |
@@ -349,7 +403,7 @@ class NodeCreateAllQuery(NodeQuery):
349
403
  CREATE (rl)-[:HAS_OWNER { branch: rel.branch, branch_level: rel.branch_level, status: rel.status, from: $at }]->(peer)
350
404
  )
351
405
  }
352
- """ % {"rel_prop": rel_prop_str}
406
+ """ % {"rel_prop": rel_prop_str, "dest_node_subquery": dest_node_subquery}
353
407
 
354
408
  query = f"""
355
409
  MATCH (root:Root)
@@ -206,16 +206,18 @@ class RelationshipQuery(Query):
206
206
  self.params["source_id"] = self.source_id or self.source.get_id()
207
207
  if source_branch.is_global or source_branch.is_default:
208
208
  source_query_match = """
209
- MATCH (s:Node { uuid: $source_id })
209
+ MATCH (s:Node { uuid: $source_id })-[source_e:IS_PART_OF {branch: $source_branch, status: "active"}]->(:Root)
210
+ WHERE source_e.from <= $at AND (source_e.to IS NULL OR source_e.to > $at)
210
211
  OPTIONAL MATCH (s)-[delete_edge:IS_PART_OF {status: "deleted", branch: $source_branch}]->(:Root)
211
212
  WHERE delete_edge.from <= $at
212
213
  WITH *, s WHERE delete_edge IS NULL
213
214
  """
214
215
  self.params["source_branch"] = source_branch.name
215
- source_filter, source_filter_params = source_branch.get_query_filter_path(
216
- at=self.at, variable_name="r", params_prefix="src_"
217
- )
218
- source_query_match = """
216
+ else:
217
+ source_filter, source_filter_params = source_branch.get_query_filter_path(
218
+ at=self.at, variable_name="r", params_prefix="src_"
219
+ )
220
+ source_query_match = """
219
221
  MATCH (s:Node { uuid: $source_id })
220
222
  CALL (s) {
221
223
  MATCH (s)-[r:IS_PART_OF]->(:Root)
@@ -225,15 +227,16 @@ class RelationshipQuery(Query):
225
227
  LIMIT 1
226
228
  }
227
229
  WITH *, s WHERE s_is_active = TRUE
228
- """ % {"source_filter": source_filter}
229
- self.params.update(source_filter_params)
230
+ """ % {"source_filter": source_filter}
231
+ self.params.update(source_filter_params)
230
232
  self.add_to_query(source_query_match)
231
233
 
232
234
  def add_dest_match_to_query(self, destination_branch: Branch, destination_id: str) -> None:
233
235
  self.params["destination_id"] = destination_id
234
236
  if destination_branch.is_global or destination_branch.is_default:
235
237
  destination_query_match = """
236
- MATCH (d:Node { uuid: $destination_id })
238
+ MATCH (d:Node { uuid: $destination_id })-[dest_e:IS_PART_OF {branch: $destination_branch, status: "active"}]->(:Root)
239
+ WHERE dest_e.from <= $at AND (dest_e.to IS NULL OR dest_e.to > $at)
237
240
  OPTIONAL MATCH (d)-[delete_edge:IS_PART_OF {status: "deleted", branch: $destination_branch}]->(:Root)
238
241
  WHERE delete_edge.from <= $at
239
242
  WITH *, d WHERE delete_edge IS NULL
@@ -63,9 +63,11 @@ class RelationshipCreateData(BaseModel):
63
63
  uuid: str
64
64
  name: str
65
65
  destination_id: str
66
- branch: str | None = None
66
+ branch: str
67
67
  branch_level: int
68
68
  branch_support: str | None = None
69
+ peer_branch: str
70
+ peer_branch_level: int
69
71
  direction: str
70
72
  status: str
71
73
  is_protected: bool
@@ -420,7 +422,7 @@ class Relationship(FlagPropertyMixin, NodePropertyMixin):
420
422
  )
421
423
  await delete_query.execute(db=db)
422
424
 
423
- async def resolve(self, db: InfrahubDatabase) -> None:
425
+ async def resolve(self, db: InfrahubDatabase, at: Timestamp | None = None) -> None:
424
426
  """Resolve the peer of the relationship."""
425
427
 
426
428
  if self._peer is not None:
@@ -470,7 +472,7 @@ class Relationship(FlagPropertyMixin, NodePropertyMixin):
470
472
  if hfid_str:
471
473
  data_from_pool["identifier"] = f"hfid={hfid_str} rel={self.name}"
472
474
 
473
- assigned_peer: Node = await pool.get_resource(db=db, branch=self.branch, **data_from_pool) # type: ignore[attr-defined]
475
+ assigned_peer: Node = await pool.get_resource(db=db, branch=self.branch, at=at, **data_from_pool) # type: ignore[attr-defined]
474
476
  await self.set_peer(value=assigned_peer)
475
477
  self.set_source(value=pool.id)
476
478
 
@@ -526,16 +528,19 @@ class Relationship(FlagPropertyMixin, NodePropertyMixin):
526
528
 
527
529
  return response
528
530
 
529
- async def get_create_data(self, db: InfrahubDatabase) -> RelationshipCreateData:
531
+ async def get_create_data(self, db: InfrahubDatabase, at: Timestamp | None = None) -> RelationshipCreateData:
530
532
  branch = self.get_branch_based_on_support_type()
531
533
 
532
- await self.resolve(db=db)
534
+ await self.resolve(db=db, at=at)
533
535
 
534
536
  peer = await self.get_peer(db=db)
537
+ peer_branch = peer.get_branch()
535
538
  data = RelationshipCreateData(
536
539
  uuid=str(UUIDT()),
537
540
  name=self.schema.get_identifier(),
538
541
  branch=branch.name,
542
+ peer_branch=peer_branch.name,
543
+ peer_branch_level=peer_branch.hierarchy_level,
539
544
  destination_id=peer.id,
540
545
  status="active",
541
546
  direction=self.schema.direction.value,
@@ -59,6 +59,23 @@ class NodeUniquenessQueryRequest(BaseModel):
59
59
  )
60
60
 
61
61
 
62
+ class QueryRelationshipPathValued(BaseModel):
63
+ relationship_schema: RelationshipSchema
64
+ peer_id: str | None
65
+ attribute_name: str | None
66
+ attribute_value: str | bool | int | float | None
67
+
68
+
69
+ class QueryAttributePathValued(BaseModel):
70
+ attribute_name: str
71
+ value: str | bool | int | float
72
+
73
+
74
+ class NodeUniquenessQueryRequestValued(BaseModel):
75
+ kind: str
76
+ unique_valued_paths: list[QueryAttributePathValued | QueryRelationshipPathValued]
77
+
78
+
62
79
  class NonUniqueRelatedAttribute(BaseModel):
63
80
  relationship: RelationshipSchema
64
81
  attribute_name: str
@@ -5,10 +5,12 @@ from typing import TYPE_CHECKING, Any
5
5
  from infrahub.core.constants.relationship_label import RELATIONSHIP_TO_VALUE_LABEL
6
6
  from infrahub.core.query import Query, QueryType
7
7
 
8
+ from .model import QueryAttributePathValued, QueryRelationshipPathValued
9
+
8
10
  if TYPE_CHECKING:
9
11
  from infrahub.database import InfrahubDatabase
10
12
 
11
- from .model import NodeUniquenessQueryRequest
13
+ from .model import NodeUniquenessQueryRequest, NodeUniquenessQueryRequestValued
12
14
 
13
15
 
14
16
  class NodeUniqueAttributeConstraintQuery(Query):
@@ -244,3 +246,212 @@ class NodeUniqueAttributeConstraintQuery(Query):
244
246
  "attr_value",
245
247
  "relationship_identifier",
246
248
  ]
249
+
250
+
251
+ class UniquenessValidationQuery(Query):
252
+ name = "uniqueness_constraint_validation"
253
+ type = QueryType.READ
254
+
255
+ def __init__(
256
+ self,
257
+ query_request: NodeUniquenessQueryRequestValued,
258
+ node_ids_to_exclude: list[str] | None = None,
259
+ **kwargs: Any,
260
+ ) -> None:
261
+ self.query_request = query_request
262
+ self.node_ids_to_exclude = node_ids_to_exclude
263
+ super().__init__(**kwargs)
264
+
265
+ def _build_attr_subquery(
266
+ self, node_kind: str, attr_path: QueryAttributePathValued, index: int, branch_filter: str, is_first_query: bool
267
+ ) -> tuple[str, dict[str, str | int | float | bool]]:
268
+ attr_name_var = f"attr_name_{index}"
269
+ attr_value_var = f"attr_value_{index}"
270
+ if is_first_query:
271
+ first_query_filter = "WHERE $node_ids_to_exclude IS NULL OR NOT node.uuid IN $node_ids_to_exclude"
272
+ else:
273
+ first_query_filter = ""
274
+ attribute_query = """
275
+ MATCH (node:%(node_kind)s)-[:HAS_ATTRIBUTE]->(attr:Attribute {name: $%(attr_name_var)s})-[:HAS_VALUE]->(:AttributeValue {value: $%(attr_value_var)s})
276
+ %(first_query_filter)s
277
+ WITH DISTINCT node
278
+ CALL (node) {
279
+ MATCH (node)-[r:HAS_ATTRIBUTE]->(attr:Attribute {name: $%(attr_name_var)s})
280
+ WHERE %(branch_filter)s
281
+ WITH attr, r.status = "active" AS is_active
282
+ ORDER BY r.branch_level DESC, r.from DESC, r.status ASC
283
+ WITH attr, is_active
284
+ LIMIT 1
285
+ WITH attr, is_active
286
+ WHERE is_active = TRUE
287
+ MATCH (attr)-[r:HAS_VALUE]->(:AttributeValue {value: $%(attr_value_var)s})
288
+ WHERE %(branch_filter)s
289
+ WITH r
290
+ ORDER BY r.branch_level DESC, r.from DESC, r.status ASC
291
+ LIMIT 1
292
+ WITH r
293
+ WHERE r.status = "active"
294
+ RETURN 1 AS is_match_%(index)s
295
+ }
296
+ """ % {
297
+ "first_query_filter": first_query_filter,
298
+ "node_kind": node_kind,
299
+ "attr_name_var": attr_name_var,
300
+ "attr_value_var": attr_value_var,
301
+ "branch_filter": branch_filter,
302
+ "index": index,
303
+ }
304
+ params: dict[str, str | int | float | bool] = {
305
+ attr_name_var: attr_path.attribute_name,
306
+ attr_value_var: attr_path.value,
307
+ }
308
+ return attribute_query, params
309
+
310
+ def _build_rel_subquery(
311
+ self,
312
+ node_kind: str,
313
+ rel_path: QueryRelationshipPathValued,
314
+ index: int,
315
+ branch_filter: str,
316
+ is_first_query: bool,
317
+ ) -> tuple[str, dict[str, str | int | float | bool]]:
318
+ params: dict[str, str | int | float | bool] = {}
319
+ rel_attr_query = ""
320
+ rel_attr_match = ""
321
+ if rel_path.attribute_name and rel_path.attribute_value:
322
+ attr_name_var = f"attr_name_{index}"
323
+ attr_value_var = f"attr_value_{index}"
324
+ rel_attr_query = """
325
+ MATCH (peer)-[r:HAS_ATTRIBUTE]->(attr:Attribute {name: $%(attr_name_var)s})
326
+ WHERE %(branch_filter)s
327
+ WITH attr, r.status = "active" AS is_active
328
+ ORDER BY r.branch_level DESC, r.from DESC, r.status ASC
329
+ WITH attr, is_active
330
+ LIMIT 1
331
+ WITH attr, is_active
332
+ WHERE is_active = TRUE
333
+ MATCH (attr)-[r:HAS_VALUE]->(:AttributeValue {value: $%(attr_value_var)s})
334
+ WHERE %(branch_filter)s
335
+ WITH r
336
+ ORDER BY r.branch_level DESC, r.from DESC, r.status ASC
337
+ LIMIT 1
338
+ WITH r
339
+ WHERE r.status = "active"
340
+ """ % {"attr_name_var": attr_name_var, "attr_value_var": attr_value_var, "branch_filter": branch_filter}
341
+ rel_attr_match = (
342
+ "-[r:HAS_ATTRIBUTE]->(attr:Attribute {name: $%(attr_name_var)s})-[:HAS_VALUE]->(:AttributeValue {value: $%(attr_value_var)s})"
343
+ % {
344
+ "attr_name_var": attr_name_var,
345
+ "attr_value_var": attr_value_var,
346
+ }
347
+ )
348
+ params[attr_name_var] = rel_path.attribute_name
349
+ params[attr_value_var] = rel_path.attribute_value
350
+ query_arrows = rel_path.relationship_schema.get_query_arrows()
351
+ rel_name_var = f"rel_name_{index}"
352
+ # long path MATCH is required to hit an index on the peer or AttributeValue of the peer
353
+ first_match = (
354
+ "MATCH (node:%(node_kind)s)%(lstart)s[:IS_RELATED]%(lend)s(:Relationship {name: $%(rel_name_var)s})%(rstart)s[:IS_RELATED]%(rend)s"
355
+ % {
356
+ "node_kind": node_kind,
357
+ "lstart": query_arrows.left.start,
358
+ "lend": query_arrows.left.end,
359
+ "rstart": query_arrows.right.start,
360
+ "rend": query_arrows.right.end,
361
+ "rel_name_var": rel_name_var,
362
+ }
363
+ )
364
+ peer_where = f"WHERE {branch_filter}"
365
+ if rel_path.peer_id:
366
+ peer_id_var = f"peer_id_{index}"
367
+ peer_where += f" AND peer.uuid = ${peer_id_var}"
368
+ params[peer_id_var] = rel_path.peer_id
369
+ first_match += "(:Node {uuid: $%(peer_id_var)s})" % {"peer_id_var": peer_id_var}
370
+ else:
371
+ peer_where += " AND peer.uuid <> node.uuid"
372
+ first_match += "(:Node)"
373
+ if rel_attr_match:
374
+ first_match += rel_attr_match
375
+ if is_first_query:
376
+ first_query_filter = "WHERE $node_ids_to_exclude IS NULL OR NOT node.uuid IN $node_ids_to_exclude"
377
+ else:
378
+ first_query_filter = ""
379
+ relationship_query = f"""
380
+ {first_match}
381
+ {first_query_filter}
382
+ WITH DISTINCT node
383
+ """
384
+ relationship_query += """
385
+ CALL (node) {
386
+ MATCH (node)%(lstart)s[r:IS_RELATED]%(lend)s(rel:Relationship {name: $%(rel_name_var)s})
387
+ WHERE %(branch_filter)s
388
+ WITH rel, r.status = "active" AS is_active
389
+ ORDER BY r.branch_level DESC, r.from DESC, r.status ASC
390
+ WITH rel, is_active
391
+ LIMIT 1
392
+ WITH rel, is_active
393
+ WHERE is_active = TRUE
394
+ MATCH (rel)%(rstart)s[r:IS_RELATED]%(rend)s(peer:Node)
395
+ %(peer_where)s
396
+ WITH peer, r.status = "active" AS is_active
397
+ ORDER BY r.branch_level DESC, r.from DESC, r.status ASC
398
+ WITH peer, is_active
399
+ LIMIT 1
400
+ WITH peer, is_active
401
+ WHERE is_active = TRUE
402
+ %(rel_attr_query)s
403
+ RETURN 1 AS is_match_%(index)s
404
+ LIMIT 1
405
+ }
406
+ """ % {
407
+ "rel_name_var": rel_name_var,
408
+ "lstart": query_arrows.left.start,
409
+ "lend": query_arrows.left.end,
410
+ "rstart": query_arrows.right.start,
411
+ "rend": query_arrows.right.end,
412
+ "peer_where": peer_where,
413
+ "rel_attr_query": rel_attr_query,
414
+ "branch_filter": branch_filter,
415
+ "index": index,
416
+ }
417
+ params[rel_name_var] = rel_path.relationship_schema.get_identifier()
418
+ return relationship_query, params
419
+
420
+ async def query_init(self, db: InfrahubDatabase, **kwargs: Any) -> None: # noqa: ARG002
421
+ self.params["node_ids_to_exclude"] = self.node_ids_to_exclude
422
+ branch_filter, branch_params = self.branch.get_query_filter_path(at=self.at.to_string(), is_isolated=False)
423
+ self.params.update(branch_params)
424
+
425
+ subqueries = []
426
+ for index, schema_path in enumerate(self.query_request.unique_valued_paths):
427
+ is_first_query = index == 0
428
+ if isinstance(schema_path, QueryAttributePathValued):
429
+ subquery, params = self._build_attr_subquery(
430
+ node_kind=self.query_request.kind,
431
+ attr_path=schema_path,
432
+ index=index,
433
+ branch_filter=branch_filter,
434
+ is_first_query=is_first_query,
435
+ )
436
+ else:
437
+ subquery, params = self._build_rel_subquery(
438
+ node_kind=self.query_request.kind,
439
+ rel_path=schema_path,
440
+ index=index,
441
+ branch_filter=branch_filter,
442
+ is_first_query=is_first_query,
443
+ )
444
+ subqueries.append(subquery)
445
+ self.params.update(params)
446
+
447
+ full_query = "\n".join(subqueries)
448
+ self.add_to_query(full_query)
449
+ self.return_labels = ["node.uuid AS node_uuid", "node.kind AS node_kind"]
450
+
451
+ def get_violation_nodes(self) -> list[tuple[str, str]]:
452
+ violation_tuples = []
453
+ for result in self.results:
454
+ violation_tuples.append(
455
+ (result.get_as_type("node_uuid", return_type=str), result.get_as_type("node_kind", return_type=str))
456
+ )
457
+ return violation_tuples
@@ -1,4 +1,5 @@
1
1
  from infrahub.core.diff.coordinator import DiffCoordinator
2
+ from infrahub.dependencies.builder.diff.locker import DiffLockerDependency
2
3
  from infrahub.dependencies.interface import DependencyBuilder, DependencyBuilderContext
3
4
 
4
5
  from .calculator import DiffCalculatorDependency
@@ -15,6 +16,7 @@ class DiffCoordinatorDependency(DependencyBuilder[DiffCoordinator]):
15
16
  @classmethod
16
17
  def build(cls, context: DependencyBuilderContext) -> DiffCoordinator:
17
18
  return DiffCoordinator(
19
+ db=context.db,
18
20
  diff_repo=DiffRepositoryDependency.build(context=context),
19
21
  diff_calculator=DiffCalculatorDependency.build(context=context),
20
22
  diff_combiner=DiffCombinerDependency.build(context=context),
@@ -23,4 +25,5 @@ class DiffCoordinatorDependency(DependencyBuilder[DiffCoordinator]):
23
25
  labels_enricher=DiffLabelsEnricherDependency.build(context=context),
24
26
  data_check_synchronizer=DiffDataCheckSynchronizerDependency.build(context=context),
25
27
  conflict_transferer=DiffConflictTransfererDependency.build(context=context),
28
+ diff_locker=DiffLockerDependency.build(context=context),
26
29
  )
@@ -0,0 +1,8 @@
1
+ from infrahub.core.diff.diff_locker import DiffLocker
2
+ from infrahub.dependencies.interface import DependencyBuilder, DependencyBuilderContext
3
+
4
+
5
+ class DiffLockerDependency(DependencyBuilder[DiffLocker]):
6
+ @classmethod
7
+ def build(cls, context: DependencyBuilderContext) -> DiffLocker: # noqa: ARG003
8
+ return DiffLocker()
@@ -25,7 +25,7 @@ from infrahub.core.timestamp import Timestamp
25
25
  from infrahub.database import retry_db_transaction
26
26
  from infrahub.dependencies.registry import get_component_registry
27
27
  from infrahub.events.generator import generate_node_mutation_events
28
- from infrahub.exceptions import HFIDViolatedError, InitializationError
28
+ from infrahub.exceptions import HFIDViolatedError, InitializationError, NodeNotFoundError
29
29
  from infrahub.graphql.context import apply_external_context
30
30
  from infrahub.lock import InfrahubMultiLock, build_object_lock_name
31
31
  from infrahub.log import get_log_data, get_logger
@@ -376,15 +376,36 @@ class InfrahubMutationMixin:
376
376
  return updated_obj, mutation, False
377
377
 
378
378
  try:
379
- dict_data.pop("hfid", "unused") # `hfid` is invalid for creation.
380
- created_obj, mutation = await cls.mutate_create(info=info, data=dict_data, branch=branch)
379
+ # This is a hack to avoid sitatuions where a node has an attribute or relationship called "pop"
380
+ # which would have overridden the `pop` method of the InputObjectType object and as such would have
381
+ # caused an error when trying to call `data.pop("hfid", None)`.
382
+ # TypeError: 'NoneType' object is not callable
383
+ data._pop = dict.pop.__get__(data, dict)
384
+ data._pop("hfid", None) # `hfid` is invalid for creation.
385
+ created_obj, mutation = await cls.mutate_create(info=info, data=data, branch=branch)
381
386
  return created_obj, mutation, True
382
387
  except HFIDViolatedError as exc:
383
388
  # Only the HFID constraint has been violated, it means the node exists and we can update without rerunning constraints
384
389
  if len(exc.matching_nodes_ids) > 1:
385
390
  raise RuntimeError(f"Multiple {schema_name} nodes have the same hfid") from exc
386
391
  node_id = list(exc.matching_nodes_ids)[0]
387
- node = await NodeManager.get_one(db=db, id=node_id, kind=schema_name, branch=branch, raise_on_error=True)
392
+
393
+ try:
394
+ node = await NodeManager.get_one(
395
+ db=db, id=node_id, kind=schema_name, branch=branch, raise_on_error=True
396
+ )
397
+ except NodeNotFoundError as exc:
398
+ if branch.is_default:
399
+ raise
400
+ raise NodeNotFoundError(
401
+ node_type=exc.node_type,
402
+ identifier=exc.identifier,
403
+ branch_name=branch.name,
404
+ message=(
405
+ f"Node {exc.identifier} / {exc.node_type} uses this human-friendly ID, but does not exist on"
406
+ f" this branch. Please rebase this branch to access {exc.identifier} / {exc.node_type}"
407
+ ),
408
+ ) from exc
388
409
  updated_obj, mutation = await cls._call_mutate_update(
389
410
  info=info,
390
411
  data=data,
@@ -6,6 +6,7 @@ from infrahub.context import InfrahubContext # noqa: TC001 needed for prefect
6
6
  from infrahub.core import registry
7
7
  from infrahub.core.branch import Branch
8
8
  from infrahub.core.diff.coordinator import DiffCoordinator
9
+ from infrahub.core.diff.diff_locker import DiffLocker
9
10
  from infrahub.core.diff.merger.merger import DiffMerger
10
11
  from infrahub.core.diff.repository.repository import DiffRepository
11
12
  from infrahub.core.merge import BranchMerger
@@ -50,6 +51,7 @@ async def merge_branch_mutation(branch: str, context: InfrahubContext, service:
50
51
  diff_merger=diff_merger,
51
52
  diff_repository=diff_repository,
52
53
  source_branch=obj,
54
+ diff_locker=DiffLocker(),
53
55
  service=service,
54
56
  )
55
57
  candidate_schema = merger.get_candidate_schema()