infrahub-server 1.6.3__py3-none-any.whl → 1.7.0__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 (250) hide show
  1. infrahub/actions/tasks.py +4 -2
  2. infrahub/api/exceptions.py +2 -2
  3. infrahub/api/schema.py +3 -1
  4. infrahub/artifacts/tasks.py +1 -0
  5. infrahub/auth.py +2 -2
  6. infrahub/cli/db.py +54 -28
  7. infrahub/computed_attribute/gather.py +3 -4
  8. infrahub/computed_attribute/tasks.py +23 -6
  9. infrahub/config.py +8 -0
  10. infrahub/constants/enums.py +12 -0
  11. infrahub/core/account.py +4 -4
  12. infrahub/core/attribute.py +106 -108
  13. infrahub/core/branch/models.py +44 -71
  14. infrahub/core/branch/tasks.py +5 -3
  15. infrahub/core/changelog/diff.py +1 -20
  16. infrahub/core/changelog/models.py +0 -7
  17. infrahub/core/constants/__init__.py +17 -0
  18. infrahub/core/constants/database.py +0 -1
  19. infrahub/core/constants/schema.py +0 -1
  20. infrahub/core/convert_object_type/repository_conversion.py +3 -4
  21. infrahub/core/diff/branch_differ.py +1 -1
  22. infrahub/core/diff/conflict_transferer.py +1 -1
  23. infrahub/core/diff/data_check_synchronizer.py +4 -3
  24. infrahub/core/diff/enricher/cardinality_one.py +2 -2
  25. infrahub/core/diff/enricher/hierarchy.py +1 -1
  26. infrahub/core/diff/enricher/labels.py +1 -1
  27. infrahub/core/diff/merger/merger.py +28 -2
  28. infrahub/core/diff/merger/serializer.py +3 -10
  29. infrahub/core/diff/model/diff.py +1 -1
  30. infrahub/core/diff/query/merge.py +376 -135
  31. infrahub/core/diff/repository/repository.py +3 -1
  32. infrahub/core/graph/__init__.py +1 -1
  33. infrahub/core/graph/constraints.py +3 -3
  34. infrahub/core/graph/schema.py +2 -12
  35. infrahub/core/ipam/reconciler.py +8 -6
  36. infrahub/core/ipam/utilization.py +8 -15
  37. infrahub/core/manager.py +133 -152
  38. infrahub/core/merge.py +1 -1
  39. infrahub/core/metadata/__init__.py +0 -0
  40. infrahub/core/metadata/interface.py +37 -0
  41. infrahub/core/metadata/model.py +31 -0
  42. infrahub/core/metadata/query/__init__.py +0 -0
  43. infrahub/core/metadata/query/node_metadata.py +301 -0
  44. infrahub/core/migrations/graph/__init__.py +4 -0
  45. infrahub/core/migrations/graph/m012_convert_account_generic.py +12 -12
  46. infrahub/core/migrations/graph/m013_convert_git_password_credential.py +7 -12
  47. infrahub/core/migrations/graph/m017_add_core_profile.py +5 -2
  48. infrahub/core/migrations/graph/m018_uniqueness_nulls.py +2 -1
  49. infrahub/core/migrations/graph/m019_restore_rels_to_time.py +0 -10
  50. infrahub/core/migrations/graph/m020_duplicate_edges.py +0 -8
  51. infrahub/core/migrations/graph/m025_uniqueness_nulls.py +2 -1
  52. infrahub/core/migrations/graph/m026_0000_prefix_fix.py +2 -1
  53. infrahub/core/migrations/graph/m029_duplicates_cleanup.py +0 -1
  54. infrahub/core/migrations/graph/m031_check_number_attributes.py +2 -2
  55. infrahub/core/migrations/graph/m038_redo_0000_prefix_fix.py +2 -1
  56. infrahub/core/migrations/graph/m041_deleted_dup_edges.py +1 -1
  57. infrahub/core/migrations/graph/m049_remove_is_visible_relationship.py +53 -0
  58. infrahub/core/migrations/graph/m050_backfill_vertex_metadata.py +168 -0
  59. infrahub/core/migrations/query/__init__.py +2 -2
  60. infrahub/core/migrations/query/attribute_add.py +17 -6
  61. infrahub/core/migrations/query/attribute_remove.py +19 -5
  62. infrahub/core/migrations/query/attribute_rename.py +21 -5
  63. infrahub/core/migrations/query/node_duplicate.py +19 -4
  64. infrahub/core/migrations/query/schema_attribute_update.py +1 -1
  65. infrahub/core/migrations/schema/attribute_kind_update.py +21 -2
  66. infrahub/core/migrations/schema/attribute_name_update.py +1 -1
  67. infrahub/core/migrations/schema/attribute_supports_profile.py +5 -3
  68. infrahub/core/migrations/schema/models.py +3 -0
  69. infrahub/core/migrations/schema/node_attribute_add.py +5 -2
  70. infrahub/core/migrations/schema/node_attribute_remove.py +1 -1
  71. infrahub/core/migrations/schema/node_kind_update.py +1 -1
  72. infrahub/core/migrations/schema/node_remove.py +24 -2
  73. infrahub/core/migrations/schema/tasks.py +4 -1
  74. infrahub/core/migrations/shared.py +13 -6
  75. infrahub/core/models.py +6 -6
  76. infrahub/core/node/__init__.py +157 -58
  77. infrahub/core/node/base.py +9 -5
  78. infrahub/core/node/create.py +7 -3
  79. infrahub/core/node/delete_validator.py +1 -1
  80. infrahub/core/node/standard.py +100 -14
  81. infrahub/core/order.py +30 -0
  82. infrahub/core/property.py +0 -1
  83. infrahub/core/protocols.py +1 -0
  84. infrahub/core/protocols_base.py +10 -2
  85. infrahub/core/query/__init__.py +5 -3
  86. infrahub/core/query/attribute.py +164 -49
  87. infrahub/core/query/branch.py +58 -70
  88. infrahub/core/query/delete.py +1 -1
  89. infrahub/core/query/diff.py +7 -7
  90. infrahub/core/query/ipam.py +104 -43
  91. infrahub/core/query/node.py +1072 -281
  92. infrahub/core/query/relationship.py +531 -325
  93. infrahub/core/query/resource_manager.py +107 -18
  94. infrahub/core/query/standard_node.py +25 -5
  95. infrahub/core/query/utils.py +2 -4
  96. infrahub/core/relationship/constraints/count.py +1 -1
  97. infrahub/core/relationship/constraints/peer_kind.py +1 -1
  98. infrahub/core/relationship/constraints/peer_parent.py +1 -1
  99. infrahub/core/relationship/constraints/peer_relatives.py +1 -1
  100. infrahub/core/relationship/constraints/profiles_kind.py +1 -1
  101. infrahub/core/relationship/constraints/profiles_removal.py +168 -0
  102. infrahub/core/relationship/model.py +293 -139
  103. infrahub/core/schema/attribute_schema.py +2 -2
  104. infrahub/core/schema/basenode_schema.py +3 -0
  105. infrahub/core/schema/definitions/core/__init__.py +8 -2
  106. infrahub/core/schema/definitions/core/account.py +10 -10
  107. infrahub/core/schema/definitions/core/artifact.py +14 -8
  108. infrahub/core/schema/definitions/core/check.py +10 -4
  109. infrahub/core/schema/definitions/core/generator.py +26 -6
  110. infrahub/core/schema/definitions/core/graphql_query.py +1 -1
  111. infrahub/core/schema/definitions/core/group.py +9 -2
  112. infrahub/core/schema/definitions/core/ipam.py +80 -10
  113. infrahub/core/schema/definitions/core/menu.py +41 -7
  114. infrahub/core/schema/definitions/core/permission.py +16 -2
  115. infrahub/core/schema/definitions/core/profile.py +16 -2
  116. infrahub/core/schema/definitions/core/propose_change.py +24 -4
  117. infrahub/core/schema/definitions/core/propose_change_comment.py +23 -11
  118. infrahub/core/schema/definitions/core/propose_change_validator.py +50 -21
  119. infrahub/core/schema/definitions/core/repository.py +10 -0
  120. infrahub/core/schema/definitions/core/resource_pool.py +8 -1
  121. infrahub/core/schema/definitions/core/template.py +19 -2
  122. infrahub/core/schema/definitions/core/transform.py +11 -5
  123. infrahub/core/schema/definitions/core/webhook.py +27 -9
  124. infrahub/core/schema/manager.py +63 -43
  125. infrahub/core/schema/relationship_schema.py +6 -2
  126. infrahub/core/schema/schema_branch.py +48 -10
  127. infrahub/core/task/task.py +4 -2
  128. infrahub/core/utils.py +3 -25
  129. infrahub/core/validators/aggregated_checker.py +1 -1
  130. infrahub/core/validators/attribute/choices.py +1 -1
  131. infrahub/core/validators/attribute/enum.py +1 -1
  132. infrahub/core/validators/attribute/kind.py +1 -1
  133. infrahub/core/validators/attribute/length.py +1 -1
  134. infrahub/core/validators/attribute/min_max.py +1 -1
  135. infrahub/core/validators/attribute/number_pool.py +1 -1
  136. infrahub/core/validators/attribute/optional.py +1 -1
  137. infrahub/core/validators/attribute/regex.py +1 -1
  138. infrahub/core/validators/determiner.py +3 -3
  139. infrahub/core/validators/node/attribute.py +1 -1
  140. infrahub/core/validators/node/relationship.py +1 -1
  141. infrahub/core/validators/relationship/peer.py +1 -1
  142. infrahub/database/__init__.py +4 -4
  143. infrahub/dependencies/builder/constraint/grouped/node_runner.py +2 -0
  144. infrahub/dependencies/builder/constraint/relationship_manager/profiles_removal.py +8 -0
  145. infrahub/dependencies/registry.py +2 -0
  146. infrahub/display_labels/tasks.py +12 -3
  147. infrahub/git/integrator.py +18 -18
  148. infrahub/git/tasks.py +1 -1
  149. infrahub/git/utils.py +1 -1
  150. infrahub/graphql/constants.py +3 -0
  151. infrahub/graphql/context.py +1 -1
  152. infrahub/graphql/field_extractor.py +1 -1
  153. infrahub/graphql/initialization.py +11 -0
  154. infrahub/graphql/loaders/account.py +134 -0
  155. infrahub/graphql/loaders/node.py +5 -12
  156. infrahub/graphql/loaders/peers.py +5 -7
  157. infrahub/graphql/manager.py +175 -21
  158. infrahub/graphql/metadata.py +91 -0
  159. infrahub/graphql/mutations/account.py +6 -6
  160. infrahub/graphql/mutations/attribute.py +0 -2
  161. infrahub/graphql/mutations/branch.py +9 -5
  162. infrahub/graphql/mutations/computed_attribute.py +1 -1
  163. infrahub/graphql/mutations/display_label.py +1 -1
  164. infrahub/graphql/mutations/hfid.py +1 -1
  165. infrahub/graphql/mutations/ipam.py +4 -6
  166. infrahub/graphql/mutations/main.py +9 -4
  167. infrahub/graphql/mutations/profile.py +16 -22
  168. infrahub/graphql/mutations/proposed_change.py +4 -4
  169. infrahub/graphql/mutations/relationship.py +40 -10
  170. infrahub/graphql/mutations/repository.py +14 -12
  171. infrahub/graphql/mutations/schema.py +2 -2
  172. infrahub/graphql/order.py +14 -0
  173. infrahub/graphql/queries/branch.py +62 -6
  174. infrahub/graphql/queries/resource_manager.py +25 -24
  175. infrahub/graphql/resolvers/account_metadata.py +84 -0
  176. infrahub/graphql/resolvers/ipam.py +6 -8
  177. infrahub/graphql/resolvers/many_relationship.py +77 -35
  178. infrahub/graphql/resolvers/resolver.py +59 -14
  179. infrahub/graphql/resolvers/single_relationship.py +87 -23
  180. infrahub/graphql/subscription/graphql_query.py +2 -0
  181. infrahub/graphql/types/__init__.py +0 -1
  182. infrahub/graphql/types/attribute.py +10 -5
  183. infrahub/graphql/types/branch.py +40 -53
  184. infrahub/graphql/types/enums.py +3 -0
  185. infrahub/graphql/types/metadata.py +28 -0
  186. infrahub/graphql/types/node.py +22 -2
  187. infrahub/graphql/types/relationship.py +10 -2
  188. infrahub/graphql/types/standard_node.py +12 -7
  189. infrahub/hfid/tasks.py +12 -3
  190. infrahub/lock.py +7 -0
  191. infrahub/menu/repository.py +1 -1
  192. infrahub/patch/queries/base.py +1 -1
  193. infrahub/pools/number.py +1 -8
  194. infrahub/profiles/gather.py +56 -0
  195. infrahub/profiles/mandatory_fields_checker.py +116 -0
  196. infrahub/profiles/models.py +66 -0
  197. infrahub/profiles/node_applier.py +154 -13
  198. infrahub/profiles/queries/get_profile_data.py +143 -31
  199. infrahub/profiles/tasks.py +79 -27
  200. infrahub/profiles/triggers.py +22 -0
  201. infrahub/proposed_change/action_checker.py +1 -1
  202. infrahub/proposed_change/tasks.py +4 -1
  203. infrahub/services/__init__.py +1 -1
  204. infrahub/services/adapters/cache/nats.py +1 -1
  205. infrahub/services/adapters/cache/redis.py +7 -0
  206. infrahub/tasks/artifact.py +1 -0
  207. infrahub/transformations/tasks.py +2 -2
  208. infrahub/trigger/catalogue.py +2 -0
  209. infrahub/trigger/models.py +1 -0
  210. infrahub/trigger/setup.py +3 -3
  211. infrahub/trigger/tasks.py +3 -0
  212. infrahub/validators/tasks.py +1 -0
  213. infrahub/webhook/gather.py +1 -1
  214. infrahub/webhook/models.py +1 -1
  215. infrahub/webhook/tasks.py +23 -7
  216. infrahub/workers/dependencies.py +9 -3
  217. infrahub/workers/infrahub_async.py +13 -4
  218. infrahub/workflows/catalogue.py +19 -0
  219. infrahub_sdk/analyzer.py +2 -2
  220. infrahub_sdk/branch.py +12 -39
  221. infrahub_sdk/checks.py +4 -4
  222. infrahub_sdk/client.py +36 -0
  223. infrahub_sdk/ctl/cli_commands.py +2 -1
  224. infrahub_sdk/ctl/graphql.py +15 -4
  225. infrahub_sdk/ctl/utils.py +2 -2
  226. infrahub_sdk/enums.py +6 -0
  227. infrahub_sdk/graphql/renderers.py +21 -0
  228. infrahub_sdk/graphql/utils.py +85 -0
  229. infrahub_sdk/node/attribute.py +12 -2
  230. infrahub_sdk/node/constants.py +12 -0
  231. infrahub_sdk/node/metadata.py +69 -0
  232. infrahub_sdk/node/node.py +65 -14
  233. infrahub_sdk/node/property.py +3 -0
  234. infrahub_sdk/node/related_node.py +37 -5
  235. infrahub_sdk/node/relationship.py +18 -1
  236. infrahub_sdk/operation.py +2 -2
  237. infrahub_sdk/schema/repository.py +1 -2
  238. infrahub_sdk/transforms.py +2 -2
  239. infrahub_sdk/types.py +18 -2
  240. {infrahub_server-1.6.3.dist-info → infrahub_server-1.7.0.dist-info}/METADATA +17 -16
  241. {infrahub_server-1.6.3.dist-info → infrahub_server-1.7.0.dist-info}/RECORD +249 -228
  242. infrahub_testcontainers/container.py +3 -3
  243. infrahub_testcontainers/docker-compose-cluster.test.yml +7 -7
  244. infrahub_testcontainers/docker-compose.test.yml +13 -5
  245. infrahub_testcontainers/models.py +3 -3
  246. infrahub_testcontainers/performance_test.py +1 -1
  247. infrahub/graphql/models.py +0 -6
  248. {infrahub_server-1.6.3.dist-info → infrahub_server-1.7.0.dist-info}/WHEEL +0 -0
  249. {infrahub_server-1.6.3.dist-info → infrahub_server-1.7.0.dist-info}/entry_points.txt +0 -0
  250. {infrahub_server-1.6.3.dist-info → infrahub_server-1.7.0.dist-info}/licenses/LICENSE.txt +0 -0
@@ -0,0 +1,84 @@
1
+ """Resolvers for account metadata fields (created_by, updated_by)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, Any
6
+
7
+ from infrahub.graphql.field_extractor import extract_graphql_fields
8
+ from infrahub.graphql.loaders.account import AccountDataLoader, AccountLoaderParams
9
+
10
+ if TYPE_CHECKING:
11
+ from graphql import GraphQLResolveInfo
12
+
13
+ from infrahub.core.branch.models import Branch
14
+ from infrahub.core.timestamp import Timestamp
15
+ from infrahub.database import InfrahubDatabase
16
+ from infrahub.graphql.initialization import GraphqlContext
17
+
18
+
19
+ class AccountMetadataResolver:
20
+ """Resolver class for account metadata fields (created_by, updated_by).
21
+
22
+ This class maintains DataLoader instances to enable batching and caching
23
+ of account lookups across multiple fields within the same request.
24
+ """
25
+
26
+ def __init__(self) -> None:
27
+ self._data_loader_instances: dict[AccountLoaderParams, AccountDataLoader] = {}
28
+
29
+ def _get_or_create_loader(
30
+ self,
31
+ db: InfrahubDatabase,
32
+ branch: Branch,
33
+ at: Timestamp | None,
34
+ fields: dict[str, Any],
35
+ ) -> AccountDataLoader:
36
+ """Get an existing loader or create a new one for the given parameters."""
37
+ params = AccountLoaderParams(branch=branch, at=at, fields=fields)
38
+
39
+ if params not in self._data_loader_instances:
40
+ self._data_loader_instances[params] = AccountDataLoader(db=db, params=params)
41
+
42
+ return self._data_loader_instances[params]
43
+
44
+ async def resolve(
45
+ self,
46
+ parent: dict[str, Any],
47
+ info: GraphQLResolveInfo,
48
+ ) -> dict[str, Any] | None:
49
+ """Resolve created_by/updated_by fields in metadata objects.
50
+
51
+ The parent dict should contain {"id": "account-uuid", "__kind__": "CoreAccount"}
52
+ for the field being resolved, or the field value may be None.
53
+ """
54
+ field_name = info.field_name # "created_by" or "updated_by"
55
+ account_data = parent.get(field_name)
56
+
57
+ if not account_data or not account_data.get("id"):
58
+ return None
59
+
60
+ account_id = account_data["id"]
61
+ graphql_context: GraphqlContext = info.context
62
+
63
+ # Extract the fields requested for this account
64
+ fields = extract_graphql_fields(info=info)
65
+
66
+ # Get or create a loader for these parameters
67
+ loader = self._get_or_create_loader(
68
+ db=graphql_context.db,
69
+ branch=graphql_context.branch,
70
+ at=graphql_context.at,
71
+ fields=fields,
72
+ )
73
+
74
+ return await loader.load(account_id)
75
+
76
+
77
+ async def account_metadata_resolver(
78
+ parent: dict[str, Any],
79
+ info: GraphQLResolveInfo,
80
+ ) -> dict[str, Any] | None:
81
+ """Function resolver that delegates to the AccountMetadataResolver on the context."""
82
+ graphql_context: GraphqlContext = info.context
83
+ resolver = graphql_context.account_metadata_resolver
84
+ return await resolver.resolve(parent=parent, info=info)
@@ -9,7 +9,7 @@ from netaddr import IPSet
9
9
  from opentelemetry import trace
10
10
 
11
11
  from infrahub.core import registry
12
- from infrahub.core.constants import InfrahubKind
12
+ from infrahub.core.constants import InfrahubKind, MetadataOptions
13
13
  from infrahub.core.ipam.constants import PrefixMemberType
14
14
  from infrahub.core.manager import NodeManager
15
15
  from infrahub.core.node import Node
@@ -19,7 +19,7 @@ from infrahub.exceptions import ValidationError
19
19
  from infrahub.graphql.parser import extract_selection
20
20
  from infrahub.graphql.permissions import get_permissions
21
21
 
22
- from ..models import OrderModel
22
+ from ..order import deserialize_order_input
23
23
 
24
24
  if TYPE_CHECKING:
25
25
  from collections.abc import Sequence
@@ -31,7 +31,6 @@ if TYPE_CHECKING:
31
31
  from infrahub.core.schema import NodeSchema
32
32
  from infrahub.database import InfrahubDatabase
33
33
  from infrahub.graphql.initialization import GraphqlContext
34
- from infrahub.graphql.models import OrderModel
35
34
 
36
35
 
37
36
  def _ip_range_display_label(node: Node) -> str:
@@ -311,7 +310,7 @@ async def ipam_paginated_list_resolver( # noqa: PLR0915
311
310
  info: GraphQLResolveInfo,
312
311
  offset: int | None = None,
313
312
  limit: int | None = None,
314
- order: OrderModel | None = None,
313
+ order: dict[str, Any] | None = None,
315
314
  partial_match: bool = False,
316
315
  **kwargs: dict[str, Any],
317
316
  ) -> dict[str, Any]:
@@ -324,6 +323,7 @@ async def ipam_paginated_list_resolver( # noqa: PLR0915
324
323
  if not isinstance(schema, GenericSchema) or schema.kind not in [InfrahubKind.IPADDRESS, InfrahubKind.IPPREFIX]:
325
324
  raise ValidationError(f"{schema.kind} is not {InfrahubKind.IPADDRESS} or {InfrahubKind.IPPREFIX}")
326
325
 
326
+ order_model = deserialize_order_input(input_data=order)
327
327
  fields = await extract_selection(info=info, schema=schema)
328
328
  resolve_available = bool(kwargs.pop("include_available", False))
329
329
  kinds_to_filter: list[str] = kwargs.pop("kinds", []) # type: ignore[assignment]
@@ -399,11 +399,9 @@ async def ipam_paginated_list_resolver( # noqa: PLR0915
399
399
  branch=graphql_context.branch,
400
400
  limit=query_limit,
401
401
  offset=offset,
402
- account=graphql_context.account_session,
403
- include_source=True,
404
- include_owner=True,
402
+ include_metadata=MetadataOptions.LINKED_NODES,
405
403
  partial_match=partial_match,
406
- order=order,
404
+ order=order_model,
407
405
  )
408
406
 
409
407
  if fetch_first_node_context and len(objs) > 2:
@@ -3,15 +3,20 @@ from typing import TYPE_CHECKING, Any
3
3
  from graphql import GraphQLResolveInfo
4
4
 
5
5
  from infrahub.core.branch.models import Branch
6
- from infrahub.core.constants import BranchSupportType, RelationshipHierarchyDirection
6
+ from infrahub.core.constants import (
7
+ BranchSupportType,
8
+ RelationshipHierarchyDirection,
9
+ )
7
10
  from infrahub.core.manager import NodeManager
11
+ from infrahub.core.metadata.model import MetadataQueryOptions
8
12
  from infrahub.core.query.node import NodeGetHierarchyQuery
13
+ from infrahub.core.relationship import Relationship
9
14
  from infrahub.core.schema.node_schema import NodeSchema
10
15
  from infrahub.core.schema.relationship_schema import RelationshipSchema
11
16
  from infrahub.core.timestamp import Timestamp
12
17
  from infrahub.database import InfrahubDatabase
13
18
  from infrahub.graphql.field_extractor import extract_graphql_fields
14
- from infrahub.utils import has_any_key
19
+ from infrahub.graphql.metadata import build_metadata_query_options, get_metadata_options_from_fields
15
20
 
16
21
  from ..loaders.peers import PeerRelationshipsDataLoader, QueryPeerParams
17
22
  from ..types import RELATIONS_PROPERTY_MAP, RELATIONS_PROPERTY_MAP_REVERSED
@@ -68,6 +73,25 @@ class ManyRelationshipResolver:
68
73
  branch_agnostic=rel_schema.branch is BranchSupportType.AGNOSTIC,
69
74
  )
70
75
 
76
+ def _build_relationship_meta_response(
77
+ self, relationship: Relationship, metadata_fields: dict[str, Any]
78
+ ) -> dict[str, Any]:
79
+ data: dict[str, Any] = {}
80
+ for meta_field in metadata_fields.keys():
81
+ if meta_field == "created_at":
82
+ created_at = relationship._get_created_at()
83
+ data["created_at"] = created_at.to_datetime() if created_at else None
84
+ elif meta_field == "created_by":
85
+ account_id = relationship._get_created_by()
86
+ data["created_by"] = {"id": account_id} if account_id else None
87
+ elif meta_field == "updated_at":
88
+ updated_at = relationship._get_updated_at()
89
+ data["updated_at"] = updated_at.to_datetime() if updated_at else None
90
+ elif meta_field == "updated_by":
91
+ account_id = relationship._get_updated_by()
92
+ data["updated_by"] = {"id": account_id} if account_id else None
93
+ return data
94
+
71
95
  async def resolve(
72
96
  self,
73
97
  parent: dict,
@@ -93,6 +117,10 @@ class ManyRelationshipResolver:
93
117
  edges = fields.get("edges", {})
94
118
  node_fields = edges.get("node", {})
95
119
  property_fields = edges.get("properties", {})
120
+ metadata_fields = {
121
+ "node_metadata": edges.get("node_metadata", {}),
122
+ "relationship_metadata": edges.get("relationship_metadata", {}),
123
+ }
96
124
  for key, value in property_fields.items():
97
125
  mapped_name = RELATIONS_PROPERTY_MAP[key]
98
126
  node_fields[mapped_name] = value
@@ -138,45 +166,71 @@ class ManyRelationshipResolver:
138
166
  if not node_fields:
139
167
  return response
140
168
 
169
+ include_metadata = build_metadata_query_options(
170
+ node_metadata_fields=metadata_fields.get("node_metadata"),
171
+ relationship_metadata_fields=metadata_fields.get("relationship_metadata"),
172
+ node_fields=node_fields,
173
+ )
174
+ # Add relationship properties metadata to relationship_level
175
+ include_metadata |= MetadataQueryOptions(relationship_level=get_metadata_options_from_fields(property_fields))
176
+
141
177
  if offset or limit:
142
- node_graph = await self._get_entities_simple(
178
+ relationships = await self._get_entities_simple(
143
179
  db=graphql_context.db,
144
180
  branch=graphql_context.branch,
145
181
  ids=ids,
146
182
  at=graphql_context.at,
147
- related_node_ids=graphql_context.related_node_ids,
148
183
  source_kind=source_kind,
149
184
  rel_schema=node_rel,
150
185
  filters=filters,
151
186
  node_fields=node_fields,
187
+ include_metadata=include_metadata,
152
188
  offset=offset,
153
189
  limit=limit,
154
190
  )
155
191
  else:
156
- node_graph = await self._get_entities_with_data_loader(
192
+ relationships = await self._get_entities_with_data_loader(
157
193
  db=graphql_context.db,
158
194
  branch=graphql_context.branch,
159
195
  ids=ids,
160
196
  at=graphql_context.at,
161
- related_node_ids=graphql_context.related_node_ids,
162
197
  source_kind=source_kind,
163
198
  rel_schema=node_rel,
164
199
  filters=filters,
165
200
  node_fields=node_fields,
201
+ include_metadata=include_metadata,
166
202
  )
167
203
 
168
- if not node_graph:
204
+ if not relationships:
169
205
  return response
170
206
 
171
207
  entries = []
172
- for node in node_graph:
173
- entry: dict[str, dict[str, Any]] = {"node": {}, "properties": {}}
174
- for key, mapped in RELATIONS_PROPERTY_MAP_REVERSED.items():
175
- value = node.pop(key, None)
176
- if value:
177
- entry["properties"][mapped] = value
178
- entry["node"] = node
179
- entries.append(entry)
208
+ async with graphql_context.db.start_session(read_only=True) as db:
209
+ for rel in relationships:
210
+ node = await rel.to_graphql(
211
+ db=db,
212
+ fields=node_fields,
213
+ related_node_ids=graphql_context.related_node_ids,
214
+ )
215
+ entry: dict[str, dict[str, Any]] = {"node": {}, "properties": {}}
216
+ for key, mapped in RELATIONS_PROPERTY_MAP_REVERSED.items():
217
+ value = node.pop(key, None)
218
+ if value:
219
+ entry["properties"][mapped] = value
220
+ entry["node"] = node
221
+
222
+ if metadata_fields.get("node_metadata"):
223
+ peer = await rel.get_peer(db=db)
224
+ if peer:
225
+ entry["node_metadata"] = await peer._build_meta_response("node_metadata", edges)
226
+
227
+ if metadata_fields.get("relationship_metadata"):
228
+ entry["relationship_metadata"] = self._build_relationship_meta_response(
229
+ relationship=rel,
230
+ metadata_fields=metadata_fields["relationship_metadata"],
231
+ )
232
+
233
+ entries.append(entry)
180
234
 
181
235
  response["edges"] = entries
182
236
  return response
@@ -187,17 +241,14 @@ class ManyRelationshipResolver:
187
241
  branch: Branch,
188
242
  ids: list[str],
189
243
  at: Timestamp | None,
190
- related_node_ids: set[str] | None,
191
244
  source_kind: str,
192
245
  rel_schema: RelationshipSchema,
193
246
  filters: dict[str, Any],
194
247
  node_fields: dict[str, Any],
248
+ include_metadata: MetadataQueryOptions,
195
249
  offset: int | None = None,
196
250
  limit: int | None = None,
197
- ) -> list[dict[str, Any]] | None:
198
- include_source = has_any_key(data=node_fields, keys=["_relation__source", "source"])
199
- include_owner = has_any_key(data=node_fields, keys=["_relation__owner", "owner"])
200
-
251
+ ) -> list[Relationship] | None:
201
252
  async with db.start_session(read_only=True) as dbs:
202
253
  objs = await NodeManager.query_peers(
203
254
  db=dbs,
@@ -212,12 +263,11 @@ class ManyRelationshipResolver:
212
263
  branch=branch,
213
264
  branch_agnostic=rel_schema.branch is BranchSupportType.AGNOSTIC,
214
265
  fetch_peers=True,
215
- include_source=include_source,
216
- include_owner=include_owner,
266
+ include_metadata=include_metadata,
217
267
  )
218
268
  if not objs:
219
269
  return None
220
- return [await obj.to_graphql(db=dbs, fields=node_fields, related_node_ids=related_node_ids) for obj in objs]
270
+ return objs
221
271
 
222
272
  async def _get_entities_with_data_loader(
223
273
  self,
@@ -225,18 +275,15 @@ class ManyRelationshipResolver:
225
275
  branch: Branch,
226
276
  ids: list[str],
227
277
  at: Timestamp | None,
228
- related_node_ids: set[str] | None,
229
278
  source_kind: str,
230
279
  rel_schema: RelationshipSchema,
231
280
  filters: dict[str, Any],
232
281
  node_fields: dict[str, Any],
233
- ) -> list[dict[str, Any]] | None:
282
+ include_metadata: MetadataQueryOptions,
283
+ ) -> list[Relationship] | None:
234
284
  if node_fields and "hfid" in node_fields:
235
285
  node_fields["human_friendly_id"] = None
236
286
 
237
- include_source = has_any_key(data=node_fields, keys=["_relation__source", "source"])
238
- include_owner = has_any_key(data=node_fields, keys=["_relation__owner", "owner"])
239
-
240
287
  query_params = QueryPeerParams(
241
288
  branch=branch,
242
289
  source_kind=source_kind,
@@ -245,8 +292,7 @@ class ManyRelationshipResolver:
245
292
  fields=node_fields,
246
293
  at=at,
247
294
  branch_agnostic=rel_schema.branch is BranchSupportType.AGNOSTIC,
248
- include_source=include_source,
249
- include_owner=include_owner,
295
+ include_metadata=include_metadata,
250
296
  )
251
297
  if query_params in self._data_loader_instances:
252
298
  loader = self._data_loader_instances[query_params]
@@ -259,8 +305,4 @@ class ManyRelationshipResolver:
259
305
  all_peer_rels.extend(node_peer_rels)
260
306
  if not all_peer_rels:
261
307
  return None
262
- async with db.start_session(read_only=True) as dbs:
263
- return [
264
- await obj.to_graphql(db=dbs, fields=node_fields, related_node_ids=related_node_ids)
265
- for obj in all_peer_rels
266
- ]
308
+ return all_peer_rels
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ from datetime import datetime, timedelta
3
4
  from typing import TYPE_CHECKING, Any
4
5
 
5
6
  from graphql.type.definition import GraphQLNonNull
@@ -7,18 +8,19 @@ from opentelemetry import trace
7
8
 
8
9
  from infrahub.core.constants import BranchSupportType, InfrahubKind, RelationshipHierarchyDirection
9
10
  from infrahub.core.manager import NodeManager
11
+ from infrahub.core.order import OrderModel
10
12
  from infrahub.exceptions import NodeNotFoundError
11
13
  from infrahub.graphql.field_extractor import extract_graphql_fields
12
- from infrahub.utils import has_any_key
14
+ from infrahub.graphql.metadata import build_metadata_query_options
13
15
 
14
- from ..models import OrderModel
16
+ from ..order import deserialize_order_input
15
17
  from ..parser import extract_selection
16
18
  from ..permissions import get_permissions
17
19
 
18
20
  if TYPE_CHECKING:
19
21
  from graphql import GraphQLResolveInfo
20
22
 
21
- from infrahub.core.schema import NodeSchema
23
+ from infrahub.core.schema import MainSchemaTypes, NodeSchema
22
24
  from infrahub.graphql.initialization import GraphqlContext
23
25
 
24
26
 
@@ -140,22 +142,63 @@ async def parent_field_name_resolver(parent: dict[str, dict], info: GraphQLResol
140
142
  return parent[info.field_name]
141
143
 
142
144
 
145
+ def _transform_metadata_day_filters(filters: dict[str, Any]) -> dict[str, Any]:
146
+ """Transform metadata datetime filters with 00:00:00 time into day range filters.
147
+
148
+ When a filter like `node_metadata__created_at="2025-02-03T00:00:00"` has a time
149
+ of exactly midnight, transform it into __after and __before filters to match
150
+ the entire day (inclusive of midnight).
151
+
152
+ If __after or __before filters are already explicitly defined, they will not be
153
+ overwritten by the generated day range filters.
154
+ """
155
+ result = dict(filters)
156
+ metadata_datetime_fields = ("node_metadata__created_at", "node_metadata__updated_at")
157
+
158
+ for field in metadata_datetime_fields:
159
+ if field not in result:
160
+ continue
161
+ value = result[field]
162
+ if not isinstance(value, datetime):
163
+ continue
164
+ # Check if time is midnight (00:00:00)
165
+ if value.hour == 0 and value.minute == 0 and value.second == 0 and value.microsecond == 0:
166
+ # Remove the exact match filter
167
+ del result[field]
168
+ # Add __after filter with one microsecond before midnight to include objects at exactly midnight
169
+ # Skip if __after is already explicitly defined
170
+ after_key = f"{field}__after"
171
+ if after_key not in result:
172
+ one_microsecond_before = value - timedelta(microseconds=1)
173
+ result[after_key] = one_microsecond_before
174
+ # Add __before filter with next day (exclusive: <)
175
+ # Skip if __before is already explicitly defined
176
+ before_key = f"{field}__before"
177
+ if before_key not in result:
178
+ next_day = value + timedelta(days=1)
179
+ result[before_key] = next_day
180
+
181
+ return result
182
+
183
+
143
184
  @trace.get_tracer(__name__).start_as_current_span("default_paginated_list_resolver")
144
185
  async def default_paginated_list_resolver(
145
186
  root: dict, # noqa: ARG001
146
187
  info: GraphQLResolveInfo,
147
188
  offset: int | None = None,
148
189
  limit: int | None = None,
149
- order: OrderModel | None = None,
190
+ order: dict | None = None,
150
191
  partial_match: bool = False,
151
192
  **kwargs: dict[str, Any],
152
193
  ) -> dict[str, Any]:
153
- schema: NodeSchema = (
194
+ schema: MainSchemaTypes = (
154
195
  info.return_type.of_type.graphene_type._meta.schema
155
196
  if isinstance(info.return_type, GraphQLNonNull)
156
197
  else info.return_type.graphene_type._meta.schema
157
198
  )
158
199
 
200
+ order_model = deserialize_order_input(input_data=order)
201
+
159
202
  fields = await extract_selection(info=info, schema=schema)
160
203
 
161
204
  graphql_context: GraphqlContext = info.context
@@ -164,9 +207,15 @@ async def default_paginated_list_resolver(
164
207
  filters = {
165
208
  key: value for key, value in kwargs.items() if ("__" in key and value is not None) or key in ("ids", "hfid")
166
209
  }
210
+ filters = _transform_metadata_day_filters(filters)
167
211
 
168
- edges = fields.get("edges", {})
212
+ edges: dict[str, Any] = fields.get("edges", {})
169
213
  node_fields = edges.get("node", {})
214
+ node_metadata_fields: dict[str, Any] = edges.get("node_metadata", {})
215
+ include_metadata = build_metadata_query_options(
216
+ node_metadata_fields=node_metadata_fields,
217
+ node_fields=node_fields,
218
+ )
170
219
  if "hfid" in node_fields:
171
220
  node_fields["human_friendly_id"] = None
172
221
 
@@ -186,23 +235,18 @@ async def default_paginated_list_resolver(
186
235
 
187
236
  objs = []
188
237
  if edges or "hfid" in filters:
189
- include_source = has_any_key(data=node_fields, keys=["_relation__source", "source"])
190
- include_owner = has_any_key(data=node_fields, keys=["_relation__owner", "owner"])
191
-
192
238
  objs = await NodeManager.query(
193
239
  db=db,
194
240
  schema=schema,
195
241
  filters=filters or None,
196
242
  fields=node_fields,
243
+ include_metadata=include_metadata,
197
244
  at=graphql_context.at,
198
245
  branch=graphql_context.branch,
199
246
  limit=limit,
200
247
  offset=offset,
201
- account=graphql_context.account_session,
202
- include_source=include_source,
203
- include_owner=include_owner,
204
248
  partial_match=partial_match,
205
- order=order,
249
+ order=order_model,
206
250
  )
207
251
 
208
252
  if "count" in fields:
@@ -227,7 +271,8 @@ async def default_paginated_list_resolver(
227
271
  fields=node_fields,
228
272
  related_node_ids=graphql_context.related_node_ids,
229
273
  permissions=permission_set,
230
- )
274
+ ),
275
+ "node_metadata": await obj._build_meta_response("node_metadata", edges),
231
276
  }
232
277
  for obj in objs
233
278
  ]