infrahub-server 1.7.0b0__py3-none-any.whl → 1.7.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 (177) hide show
  1. infrahub/api/exceptions.py +2 -2
  2. infrahub/api/schema.py +5 -0
  3. infrahub/cli/db.py +54 -24
  4. infrahub/core/account.py +12 -9
  5. infrahub/core/branch/models.py +11 -117
  6. infrahub/core/branch/tasks.py +7 -3
  7. infrahub/core/diff/branch_differ.py +1 -1
  8. infrahub/core/diff/conflict_transferer.py +1 -1
  9. infrahub/core/diff/data_check_synchronizer.py +1 -1
  10. infrahub/core/diff/enricher/cardinality_one.py +1 -1
  11. infrahub/core/diff/enricher/hierarchy.py +1 -1
  12. infrahub/core/diff/enricher/labels.py +1 -1
  13. infrahub/core/diff/merger/merger.py +6 -2
  14. infrahub/core/diff/repository/repository.py +3 -1
  15. infrahub/core/graph/__init__.py +1 -1
  16. infrahub/core/graph/constraints.py +1 -1
  17. infrahub/core/initialization.py +2 -1
  18. infrahub/core/ipam/reconciler.py +8 -6
  19. infrahub/core/ipam/utilization.py +8 -15
  20. infrahub/core/manager.py +1 -26
  21. infrahub/core/merge.py +1 -1
  22. infrahub/core/migrations/graph/__init__.py +2 -0
  23. infrahub/core/migrations/graph/m012_convert_account_generic.py +12 -12
  24. infrahub/core/migrations/graph/m013_convert_git_password_credential.py +4 -4
  25. infrahub/core/migrations/graph/m014_remove_index_attr_value.py +3 -2
  26. infrahub/core/migrations/graph/m015_diff_format_update.py +3 -2
  27. infrahub/core/migrations/graph/m016_diff_delete_bug_fix.py +3 -2
  28. infrahub/core/migrations/graph/m017_add_core_profile.py +6 -4
  29. infrahub/core/migrations/graph/m018_uniqueness_nulls.py +3 -4
  30. infrahub/core/migrations/graph/m020_duplicate_edges.py +3 -3
  31. infrahub/core/migrations/graph/m025_uniqueness_nulls.py +3 -4
  32. infrahub/core/migrations/graph/m026_0000_prefix_fix.py +4 -5
  33. infrahub/core/migrations/graph/m028_delete_diffs.py +3 -2
  34. infrahub/core/migrations/graph/m029_duplicates_cleanup.py +3 -2
  35. infrahub/core/migrations/graph/m031_check_number_attributes.py +4 -3
  36. infrahub/core/migrations/graph/m032_cleanup_orphaned_branch_relationships.py +3 -2
  37. infrahub/core/migrations/graph/m034_find_orphaned_schema_fields.py +3 -2
  38. infrahub/core/migrations/graph/m035_orphan_relationships.py +3 -3
  39. infrahub/core/migrations/graph/m036_drop_attr_value_index.py +3 -2
  40. infrahub/core/migrations/graph/m037_index_attr_vals.py +3 -2
  41. infrahub/core/migrations/graph/m038_redo_0000_prefix_fix.py +4 -5
  42. infrahub/core/migrations/graph/m039_ipam_reconcile.py +3 -2
  43. infrahub/core/migrations/graph/m041_deleted_dup_edges.py +4 -3
  44. infrahub/core/migrations/graph/m042_profile_attrs_in_db.py +5 -4
  45. infrahub/core/migrations/graph/m043_create_hfid_display_label_in_db.py +12 -5
  46. infrahub/core/migrations/graph/m044_backfill_hfid_display_label_in_db.py +15 -4
  47. infrahub/core/migrations/graph/m045_backfill_hfid_display_label_in_db_profile_template.py +10 -4
  48. infrahub/core/migrations/graph/m046_fill_agnostic_hfid_display_labels.py +6 -5
  49. infrahub/core/migrations/graph/m047_backfill_or_null_display_label.py +19 -5
  50. infrahub/core/migrations/graph/m048_undelete_rel_props.py +6 -4
  51. infrahub/core/migrations/graph/m049_remove_is_visible_relationship.py +19 -4
  52. infrahub/core/migrations/graph/m050_backfill_vertex_metadata.py +3 -3
  53. infrahub/core/migrations/graph/m051_subtract_branched_from_microsecond.py +39 -0
  54. infrahub/core/migrations/query/__init__.py +2 -2
  55. infrahub/core/migrations/query/schema_attribute_update.py +1 -1
  56. infrahub/core/migrations/runner.py +6 -3
  57. infrahub/core/migrations/schema/attribute_kind_update.py +8 -11
  58. infrahub/core/migrations/schema/attribute_name_update.py +1 -1
  59. infrahub/core/migrations/schema/attribute_supports_profile.py +5 -10
  60. infrahub/core/migrations/schema/models.py +8 -0
  61. infrahub/core/migrations/schema/node_attribute_add.py +11 -14
  62. infrahub/core/migrations/schema/node_attribute_remove.py +1 -1
  63. infrahub/core/migrations/schema/node_kind_update.py +1 -1
  64. infrahub/core/migrations/schema/tasks.py +7 -1
  65. infrahub/core/migrations/shared.py +37 -30
  66. infrahub/core/node/__init__.py +3 -2
  67. infrahub/core/node/base.py +9 -5
  68. infrahub/core/node/delete_validator.py +1 -1
  69. infrahub/core/order.py +30 -0
  70. infrahub/core/protocols.py +1 -0
  71. infrahub/core/protocols_base.py +4 -0
  72. infrahub/core/query/__init__.py +8 -5
  73. infrahub/core/query/attribute.py +3 -3
  74. infrahub/core/query/branch.py +1 -1
  75. infrahub/core/query/delete.py +1 -1
  76. infrahub/core/query/diff.py +3 -3
  77. infrahub/core/query/ipam.py +104 -43
  78. infrahub/core/query/node.py +454 -101
  79. infrahub/core/query/relationship.py +83 -26
  80. infrahub/core/query/resource_manager.py +107 -18
  81. infrahub/core/relationship/constraints/count.py +1 -1
  82. infrahub/core/relationship/constraints/peer_kind.py +1 -1
  83. infrahub/core/relationship/constraints/peer_parent.py +1 -1
  84. infrahub/core/relationship/constraints/peer_relatives.py +1 -1
  85. infrahub/core/relationship/constraints/profiles_kind.py +1 -1
  86. infrahub/core/relationship/constraints/profiles_removal.py +1 -1
  87. infrahub/core/relationship/model.py +8 -2
  88. infrahub/core/schema/attribute_parameters.py +28 -1
  89. infrahub/core/schema/attribute_schema.py +9 -15
  90. infrahub/core/schema/basenode_schema.py +3 -0
  91. infrahub/core/schema/definitions/core/__init__.py +8 -2
  92. infrahub/core/schema/definitions/core/account.py +10 -10
  93. infrahub/core/schema/definitions/core/artifact.py +14 -8
  94. infrahub/core/schema/definitions/core/check.py +10 -4
  95. infrahub/core/schema/definitions/core/generator.py +26 -6
  96. infrahub/core/schema/definitions/core/graphql_query.py +1 -1
  97. infrahub/core/schema/definitions/core/group.py +9 -2
  98. infrahub/core/schema/definitions/core/ipam.py +80 -10
  99. infrahub/core/schema/definitions/core/menu.py +41 -7
  100. infrahub/core/schema/definitions/core/permission.py +16 -2
  101. infrahub/core/schema/definitions/core/profile.py +16 -2
  102. infrahub/core/schema/definitions/core/propose_change.py +24 -4
  103. infrahub/core/schema/definitions/core/propose_change_comment.py +23 -11
  104. infrahub/core/schema/definitions/core/propose_change_validator.py +50 -21
  105. infrahub/core/schema/definitions/core/repository.py +10 -0
  106. infrahub/core/schema/definitions/core/resource_pool.py +8 -1
  107. infrahub/core/schema/definitions/core/template.py +19 -2
  108. infrahub/core/schema/definitions/core/transform.py +11 -5
  109. infrahub/core/schema/definitions/core/webhook.py +27 -9
  110. infrahub/core/schema/manager.py +50 -38
  111. infrahub/core/schema/schema_branch.py +68 -2
  112. infrahub/core/utils.py +3 -3
  113. infrahub/core/validators/aggregated_checker.py +1 -1
  114. infrahub/core/validators/attribute/choices.py +1 -1
  115. infrahub/core/validators/attribute/enum.py +1 -1
  116. infrahub/core/validators/attribute/kind.py +6 -3
  117. infrahub/core/validators/attribute/length.py +1 -1
  118. infrahub/core/validators/attribute/min_max.py +1 -1
  119. infrahub/core/validators/attribute/number_pool.py +1 -1
  120. infrahub/core/validators/attribute/optional.py +1 -1
  121. infrahub/core/validators/attribute/regex.py +1 -1
  122. infrahub/core/validators/node/attribute.py +1 -1
  123. infrahub/core/validators/node/relationship.py +1 -1
  124. infrahub/core/validators/relationship/peer.py +1 -1
  125. infrahub/database/__init__.py +1 -1
  126. infrahub/git/utils.py +1 -1
  127. infrahub/graphql/app.py +2 -2
  128. infrahub/graphql/field_extractor.py +1 -1
  129. infrahub/graphql/manager.py +17 -3
  130. infrahub/graphql/mutations/account.py +1 -1
  131. infrahub/graphql/order.py +14 -0
  132. infrahub/graphql/queries/diff/tree.py +5 -5
  133. infrahub/graphql/queries/resource_manager.py +25 -24
  134. infrahub/graphql/resolvers/ipam.py +3 -3
  135. infrahub/graphql/resolvers/resolver.py +44 -3
  136. infrahub/graphql/types/standard_node.py +8 -4
  137. infrahub/lock.py +7 -0
  138. infrahub/menu/repository.py +1 -1
  139. infrahub/patch/queries/base.py +1 -1
  140. infrahub/pools/number.py +1 -8
  141. infrahub/profiles/node_applier.py +1 -1
  142. infrahub/profiles/queries/get_profile_data.py +1 -1
  143. infrahub/proposed_change/action_checker.py +1 -1
  144. infrahub/services/__init__.py +1 -1
  145. infrahub/services/adapters/cache/nats.py +1 -1
  146. infrahub/services/adapters/cache/redis.py +7 -0
  147. infrahub/webhook/gather.py +1 -1
  148. infrahub/webhook/tasks.py +22 -6
  149. infrahub_sdk/analyzer.py +2 -2
  150. infrahub_sdk/branch.py +12 -39
  151. infrahub_sdk/checks.py +4 -4
  152. infrahub_sdk/client.py +36 -0
  153. infrahub_sdk/ctl/cli_commands.py +2 -1
  154. infrahub_sdk/ctl/graphql.py +15 -4
  155. infrahub_sdk/ctl/utils.py +2 -2
  156. infrahub_sdk/enums.py +6 -0
  157. infrahub_sdk/graphql/renderers.py +21 -0
  158. infrahub_sdk/graphql/utils.py +85 -0
  159. infrahub_sdk/node/attribute.py +12 -2
  160. infrahub_sdk/node/constants.py +11 -0
  161. infrahub_sdk/node/metadata.py +69 -0
  162. infrahub_sdk/node/node.py +65 -14
  163. infrahub_sdk/node/property.py +3 -0
  164. infrahub_sdk/node/related_node.py +24 -1
  165. infrahub_sdk/node/relationship.py +10 -1
  166. infrahub_sdk/operation.py +2 -2
  167. infrahub_sdk/schema/repository.py +1 -2
  168. infrahub_sdk/transforms.py +2 -2
  169. infrahub_sdk/types.py +18 -2
  170. {infrahub_server-1.7.0b0.dist-info → infrahub_server-1.7.1.dist-info}/METADATA +6 -6
  171. {infrahub_server-1.7.0b0.dist-info → infrahub_server-1.7.1.dist-info}/RECORD +176 -172
  172. {infrahub_server-1.7.0b0.dist-info → infrahub_server-1.7.1.dist-info}/entry_points.txt +0 -1
  173. infrahub_testcontainers/models.py +3 -3
  174. infrahub_testcontainers/performance_test.py +1 -1
  175. infrahub/graphql/models.py +0 -36
  176. {infrahub_server-1.7.0b0.dist-info → infrahub_server-1.7.1.dist-info}/WHEEL +0 -0
  177. {infrahub_server-1.7.0b0.dist-info → infrahub_server-1.7.1.dist-info}/licenses/LICENSE.txt +0 -0
infrahub/core/order.py ADDED
@@ -0,0 +1,30 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Self
4
+
5
+ from pydantic import BaseModel, model_validator
6
+
7
+ from infrahub.constants.enums import OrderDirection # noqa: TC001
8
+ from infrahub.exceptions import ValidationError
9
+
10
+ # Metadata field name constants
11
+ METADATA_CREATED_AT = "created_at"
12
+ METADATA_CREATED_BY = "created_by"
13
+ METADATA_UPDATED_AT = "updated_at"
14
+ METADATA_UPDATED_BY = "updated_by"
15
+
16
+
17
+ class NodeMetaOrder(BaseModel):
18
+ created_at: OrderDirection | None = None
19
+ updated_at: OrderDirection | None = None
20
+
21
+
22
+ class OrderModel(BaseModel):
23
+ disable: bool | None = None
24
+ node_metadata: NodeMetaOrder | None = None
25
+
26
+ @model_validator(mode="after")
27
+ def validate_metadata(self) -> Self:
28
+ if self.node_metadata and self.node_metadata.created_at and self.node_metadata.updated_at:
29
+ raise ValidationError("Cannot order by both created_at and updated_at simultaneously.")
30
+ return self
@@ -221,6 +221,7 @@ class CoreValidator(CoreNode):
221
221
  class CoreWebhook(CoreNode):
222
222
  name: String
223
223
  event_type: Enum
224
+ active: Boolean
224
225
  branch_scope: Dropdown
225
226
  node_kind: StringOptional
226
227
  description: StringOptional
@@ -106,3 +106,7 @@ class CoreNode(Protocol):
106
106
  ) -> dict: ...
107
107
  async def render_display_label(self, db: InfrahubDatabase | None = None) -> str: ...
108
108
  async def from_graphql(self, data: dict, db: InfrahubDatabase) -> bool: ...
109
+ def _get_created_at(self) -> Timestamp | None: ...
110
+ def _get_created_by(self) -> str | None: ...
111
+ def _get_updated_at(self) -> Timestamp | None: ...
112
+ def _get_updated_by(self) -> str | None: ...
@@ -1,6 +1,5 @@
1
1
  from __future__ import annotations
2
2
 
3
- from abc import ABC, abstractmethod
4
3
  from collections import defaultdict
5
4
  from dataclasses import dataclass, field
6
5
  from enum import Enum
@@ -160,7 +159,7 @@ def cleanup_return_labels(labels: list[str]) -> list[str]:
160
159
 
161
160
 
162
161
  class QueryResult:
163
- def __init__(self, data: list[Neo4jNode | Neo4jRelationship | list[Neo4jNode]], labels: list[str]):
162
+ def __init__(self, data: list[Neo4jNode | Neo4jRelationship | list[Neo4jNode]], labels: list[str]) -> None:
164
163
  self.data = data
165
164
  self.labels = labels
166
165
  self.branch_score: int = 0
@@ -336,7 +335,7 @@ class QueryStat:
336
335
  return cls(**data)
337
336
 
338
337
 
339
- class Query(ABC):
338
+ class Query:
340
339
  name: str = "base-query"
341
340
  type: QueryType
342
341
 
@@ -353,7 +352,7 @@ class Query(ABC):
353
352
  order_by: list[str] | None = None,
354
353
  branch_agnostic: bool = False,
355
354
  user_id: str = SYSTEM_USER_ID,
356
- ):
355
+ ) -> None:
357
356
  if branch:
358
357
  self.branch = branch
359
358
 
@@ -405,8 +404,12 @@ class Query(ABC):
405
404
 
406
405
  return query
407
406
 
408
- @abstractmethod
409
407
  async def query_init(self, db: InfrahubDatabase, **kwargs: Any) -> None:
408
+ # Avoid using this method for new queries and look at migrating older queries. The
409
+ # problem here is that we loose so much information with the `**kwargs` we should instead
410
+ # populate this information via the constructor and anything done within the existing query_init methods
411
+ # could either be handled within __init__ or via dedicated methods within each Query class where appropriate,
412
+ # i.e. things might need to happend in a certain order or we just want to separate the logic better.
410
413
  raise NotImplementedError
411
414
 
412
415
  def get_context(self) -> dict[str, str]:
@@ -31,7 +31,7 @@ class AttributeQuery(Query):
31
31
  at: Timestamp | str | None = None,
32
32
  branch: Branch | None = None,
33
33
  **kwargs: Any,
34
- ):
34
+ ) -> None:
35
35
  self.attr = attr
36
36
  self.attr_id = attr_id or attr.db_id
37
37
 
@@ -171,7 +171,7 @@ class AttributeUpdateNodePropertyQuery(AttributeQuery):
171
171
  prop_name: str,
172
172
  prop_id: str | None = None,
173
173
  **kwargs: Any,
174
- ):
174
+ ) -> None:
175
175
  self.prop_name = prop_name
176
176
  self.prop_id = prop_id
177
177
 
@@ -246,7 +246,7 @@ class AttributeClearNodePropertyQuery(AttributeQuery):
246
246
  self,
247
247
  prop_name: str,
248
248
  **kwargs: Any,
249
- ):
249
+ ) -> None:
250
250
  self.prop_name = prop_name
251
251
 
252
252
  super().__init__(**kwargs)
@@ -17,7 +17,7 @@ class DeleteBranchRelationshipsQuery(Query):
17
17
 
18
18
  type: QueryType = QueryType.WRITE
19
19
 
20
- def __init__(self, branch_name: str, **kwargs: Any):
20
+ def __init__(self, branch_name: str, **kwargs: Any) -> None:
21
21
  self.branch_name = branch_name
22
22
  super().__init__(**kwargs)
23
23
 
@@ -11,7 +11,7 @@ class DeleteAfterTimeQuery(Query):
11
11
  insert_return: bool = False
12
12
  type: QueryType = QueryType.WRITE
13
13
 
14
- def __init__(self, timestamp: Timestamp, **kwargs: Any):
14
+ def __init__(self, timestamp: Timestamp, **kwargs: Any) -> None:
15
15
  self.timestamp = timestamp
16
16
  super().__init__(**kwargs)
17
17
 
@@ -26,7 +26,7 @@ class DiffQuery(Query):
26
26
  diff_from: Timestamp | str = None,
27
27
  diff_to: Timestamp | str = None,
28
28
  **kwargs,
29
- ):
29
+ ) -> None:
30
30
  """A diff is always in the context of a branch"""
31
31
 
32
32
  if not diff_from and branch.is_default:
@@ -59,7 +59,7 @@ class DiffCountChanges(Query):
59
59
  diff_from: Timestamp,
60
60
  diff_to: Timestamp,
61
61
  **kwargs,
62
- ):
62
+ ) -> None:
63
63
  self.branch_names = branch_names
64
64
  self.diff_from = diff_from
65
65
  self.diff_to = diff_to
@@ -122,7 +122,7 @@ class DiffCalculationQuery(DiffQuery):
122
122
  current_node_field_specifiers: NodeFieldSpecifierMap | None = None,
123
123
  new_node_field_specifiers: NodeFieldSpecifierMap | None = None,
124
124
  **kwargs: Any,
125
- ):
125
+ ) -> None:
126
126
  self.base_branch = base_branch
127
127
  self.diff_branch_from_time = diff_branch_from_time
128
128
  self.current_node_field_specifiers = current_node_field_specifiers
@@ -7,7 +7,7 @@ from typing import TYPE_CHECKING, Iterable
7
7
  from infrahub.core.constants import InfrahubKind
8
8
  from infrahub.core.graph.schema import GraphAttributeIPHostNode, GraphAttributeIPNetworkNode
9
9
  from infrahub.core.ipam.constants import AllIPTypes, IPAddressType, IPNetworkType
10
- from infrahub.core.query import QueryType
10
+ from infrahub.core.query import QueryResult, QueryType
11
11
  from infrahub.core.registry import registry
12
12
  from infrahub.core.utils import convert_ip_to_binary_str
13
13
 
@@ -57,7 +57,7 @@ class IPPrefixSubnetFetch(Query):
57
57
  obj: IPNetworkType,
58
58
  namespace: Node | str | None = None,
59
59
  **kwargs,
60
- ):
60
+ ) -> None:
61
61
  self.obj = obj
62
62
  self.namespace_id = _get_namespace_id(namespace)
63
63
 
@@ -146,7 +146,7 @@ class IPPrefixIPAddressFetch(Query):
146
146
  obj: IPNetworkType,
147
147
  namespace: Node | str | None = None,
148
148
  **kwargs,
149
- ):
149
+ ) -> None:
150
150
  self.obj = obj
151
151
  self.namespace_id = _get_namespace_id(namespace)
152
152
 
@@ -242,11 +242,53 @@ async def get_ip_addresses(
242
242
  return query.get_addresses()
243
243
 
244
244
 
245
+ @dataclass(frozen=True)
246
+ class IPPrefixUtilizationResult:
247
+ """Result from IPPrefixUtilization containing prefix child allocation data."""
248
+
249
+ prefix_uuid: str
250
+ """UUID of the parent prefix node."""
251
+
252
+ child_uuid: str
253
+ """UUID of the child node (prefix or address)."""
254
+
255
+ child_kind: str
256
+ """Kind/type of the child node."""
257
+
258
+ child_labels: tuple[str, ...]
259
+ """Labels of the child node (used to determine if IPADDRESS or IPPREFIX)."""
260
+
261
+ ip_value: str
262
+ """IP value (address or prefix) of the child."""
263
+
264
+ prefixlen: int
265
+ """Prefix length of the child IP value."""
266
+
267
+ branch: str
268
+ """Branch name where this allocation exists."""
269
+
270
+ @classmethod
271
+ def from_db(cls, result: QueryResult) -> IPPrefixUtilizationResult:
272
+ """Convert raw QueryResult to typed dataclass."""
273
+ pfx = result.get_node("pfx")
274
+ child = result.get_node("child")
275
+ av = result.get_node("av")
276
+ return cls(
277
+ prefix_uuid=str(pfx.get("uuid")),
278
+ child_uuid=str(child.get("uuid")),
279
+ child_kind=child.get("kind"),
280
+ child_labels=tuple(child.labels),
281
+ ip_value=av.get("value"),
282
+ prefixlen=av.get("prefixlen"),
283
+ branch=str(result.get("branch")),
284
+ )
285
+
286
+
245
287
  class IPPrefixUtilization(Query):
246
288
  name = "ipprefix_utilization_prefix"
247
289
  type = QueryType.READ
248
290
 
249
- def __init__(self, ip_prefixes: list[str], allocated_kinds: list[str], **kwargs):
291
+ def __init__(self, ip_prefixes: list[str], allocated_kinds: list[str], **kwargs) -> None:
250
292
  self.ip_prefixes = ip_prefixes
251
293
  self.allocated_kinds: list[str] = []
252
294
  self.allocated_kinds_rel: list[str] = []
@@ -315,6 +357,55 @@ class IPPrefixUtilization(Query):
315
357
  self.return_labels = ["pfx", "child", "av", "branch_level", "branch"]
316
358
  self.add_to_query(query)
317
359
 
360
+ def get_data(self) -> list[IPPrefixUtilizationResult]:
361
+ """Return results as typed dataclass instances.
362
+
363
+ Returns:
364
+ List of IPPrefixUtilizationResult containing prefix child allocation data.
365
+ """
366
+ return [IPPrefixUtilizationResult.from_db(result) for result in self.get_results()]
367
+
368
+
369
+ @dataclass(frozen=True)
370
+ class IPPrefixReconcileQueryResult:
371
+ """Result from IPPrefixReconcileQuery containing IP reconciliation data."""
372
+
373
+ ip_node_uuid: str | None
374
+ """UUID of the IP node being reconciled, or None if not found."""
375
+
376
+ current_parent_uuid: str | None
377
+ """UUID of the current parent prefix, or None if no parent exists."""
378
+
379
+ calculated_parent_uuid: str | None
380
+ """UUID of the calculated correct parent prefix, or None if should be top-level."""
381
+
382
+ current_children_uuids: tuple[str, ...]
383
+ """UUIDs of current child prefixes/addresses."""
384
+
385
+ calculated_children_uuids: tuple[str, ...]
386
+ """UUIDs of calculated correct child prefixes/addresses."""
387
+
388
+ @classmethod
389
+ def from_db(cls, result: QueryResult) -> IPPrefixReconcileQueryResult:
390
+ """Convert raw QueryResult to typed dataclass."""
391
+
392
+ def get_optional_node_uuid(label: str) -> str | None:
393
+ """Extract UUID from an optional node (may be None from OPTIONAL MATCH)."""
394
+ node = result.get(label)
395
+ return str(node.get("uuid")) if node and node.get("uuid") else None
396
+
397
+ def get_collection_uuids(label: str) -> tuple[str, ...]:
398
+ """Extract UUIDs from a node collection (may contain None from COLLECT)."""
399
+ return tuple(str(n.get("uuid")) for n in result.get_node_collection(label) if n and n.get("uuid"))
400
+
401
+ return cls(
402
+ ip_node_uuid=get_optional_node_uuid("ip_node"),
403
+ current_parent_uuid=get_optional_node_uuid("current_parent"),
404
+ calculated_parent_uuid=get_optional_node_uuid("new_parent"),
405
+ current_children_uuids=get_collection_uuids("current_children"),
406
+ calculated_children_uuids=get_collection_uuids("new_children"),
407
+ )
408
+
318
409
 
319
410
  class IPPrefixReconcileQuery(Query):
320
411
  name = "ip_prefix_reconcile"
@@ -326,7 +417,7 @@ class IPPrefixReconcileQuery(Query):
326
417
  namespace: Node | str | None = None,
327
418
  node_uuid: str | None = None,
328
419
  **kwargs,
329
- ):
420
+ ) -> None:
330
421
  self.ip_value = ip_value
331
422
  self.ip_uuid = node_uuid
332
423
  self.namespace_id = _get_namespace_id(namespace)
@@ -702,44 +793,14 @@ class IPPrefixReconcileQuery(Query):
702
793
  self.order_by = ["ip_node.uuid"]
703
794
  self.return_labels = ["ip_node", "current_parent", "current_children", "new_parent", "new_children"]
704
795
 
705
- def _get_uuid_from_query(self, node_name: str) -> str | None:
706
- results = list(self.get_results())
707
- if not results:
708
- return None
709
- result = results[0]
710
- node = result.get(node_name)
711
- if not node:
712
- return None
713
- node_uuid = node.get("uuid")
714
- if node_uuid:
715
- return str(node_uuid)
716
- return None
796
+ def get_data(self) -> IPPrefixReconcileQueryResult | None:
797
+ """Return single result as typed dataclass instance.
717
798
 
718
- def _get_uuids_from_query_list(self, alias_name: str) -> list[str]:
799
+ Returns:
800
+ IPPrefixReconcileQueryResult containing reconciliation data,
801
+ or None if no results found.
802
+ """
719
803
  results = list(self.get_results())
720
804
  if not results:
721
- return []
722
- result = results[0]
723
- element_uuids = []
724
- for element in result.get(alias_name):
725
- if not element:
726
- continue
727
- element_uuid = element.get("uuid")
728
- if element_uuid:
729
- element_uuids.append(str(element_uuid))
730
- return element_uuids
731
-
732
- def get_ip_node_uuid(self) -> str | None:
733
- return self._get_uuid_from_query("ip_node")
734
-
735
- def get_current_parent_uuid(self) -> str | None:
736
- return self._get_uuid_from_query("current_parent")
737
-
738
- def get_calculated_parent_uuid(self) -> str | None:
739
- return self._get_uuid_from_query("new_parent")
740
-
741
- def get_current_children_uuids(self) -> list[str]:
742
- return self._get_uuids_from_query_list("current_children")
743
-
744
- def get_calculated_children_uuids(self) -> list[str]:
745
- return self._get_uuids_from_query_list("new_children")
805
+ return None
806
+ return IPPrefixReconcileQueryResult.from_db(results[0])