infrahub-server 1.6.2__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 (253) 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 +12 -9
  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 +26 -6
  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 +11 -6
  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_parameters.py +28 -1
  104. infrahub/core/schema/attribute_schema.py +11 -17
  105. infrahub/core/schema/basenode_schema.py +3 -0
  106. infrahub/core/schema/definitions/core/__init__.py +8 -2
  107. infrahub/core/schema/definitions/core/account.py +10 -10
  108. infrahub/core/schema/definitions/core/artifact.py +14 -8
  109. infrahub/core/schema/definitions/core/check.py +10 -4
  110. infrahub/core/schema/definitions/core/generator.py +26 -6
  111. infrahub/core/schema/definitions/core/graphql_query.py +1 -1
  112. infrahub/core/schema/definitions/core/group.py +9 -2
  113. infrahub/core/schema/definitions/core/ipam.py +80 -10
  114. infrahub/core/schema/definitions/core/menu.py +41 -7
  115. infrahub/core/schema/definitions/core/permission.py +16 -2
  116. infrahub/core/schema/definitions/core/profile.py +16 -2
  117. infrahub/core/schema/definitions/core/propose_change.py +24 -4
  118. infrahub/core/schema/definitions/core/propose_change_comment.py +23 -11
  119. infrahub/core/schema/definitions/core/propose_change_validator.py +50 -21
  120. infrahub/core/schema/definitions/core/repository.py +10 -0
  121. infrahub/core/schema/definitions/core/resource_pool.py +8 -1
  122. infrahub/core/schema/definitions/core/template.py +19 -2
  123. infrahub/core/schema/definitions/core/transform.py +11 -5
  124. infrahub/core/schema/definitions/core/webhook.py +27 -9
  125. infrahub/core/schema/manager.py +63 -43
  126. infrahub/core/schema/relationship_schema.py +6 -2
  127. infrahub/core/schema/schema_branch.py +115 -11
  128. infrahub/core/task/task.py +4 -2
  129. infrahub/core/utils.py +3 -25
  130. infrahub/core/validators/aggregated_checker.py +1 -1
  131. infrahub/core/validators/attribute/choices.py +1 -1
  132. infrahub/core/validators/attribute/enum.py +1 -1
  133. infrahub/core/validators/attribute/kind.py +6 -3
  134. infrahub/core/validators/attribute/length.py +1 -1
  135. infrahub/core/validators/attribute/min_max.py +1 -1
  136. infrahub/core/validators/attribute/number_pool.py +1 -1
  137. infrahub/core/validators/attribute/optional.py +1 -1
  138. infrahub/core/validators/attribute/regex.py +1 -1
  139. infrahub/core/validators/determiner.py +3 -3
  140. infrahub/core/validators/node/attribute.py +1 -1
  141. infrahub/core/validators/node/relationship.py +1 -1
  142. infrahub/core/validators/relationship/peer.py +1 -1
  143. infrahub/database/__init__.py +4 -4
  144. infrahub/dependencies/builder/constraint/grouped/node_runner.py +2 -0
  145. infrahub/dependencies/builder/constraint/relationship_manager/profiles_removal.py +8 -0
  146. infrahub/dependencies/registry.py +2 -0
  147. infrahub/display_labels/tasks.py +12 -3
  148. infrahub/git/integrator.py +18 -18
  149. infrahub/git/tasks.py +1 -1
  150. infrahub/git/utils.py +1 -1
  151. infrahub/graphql/app.py +2 -2
  152. infrahub/graphql/constants.py +3 -0
  153. infrahub/graphql/context.py +1 -1
  154. infrahub/graphql/field_extractor.py +1 -1
  155. infrahub/graphql/initialization.py +11 -0
  156. infrahub/graphql/loaders/account.py +134 -0
  157. infrahub/graphql/loaders/node.py +5 -12
  158. infrahub/graphql/loaders/peers.py +5 -7
  159. infrahub/graphql/manager.py +175 -21
  160. infrahub/graphql/metadata.py +91 -0
  161. infrahub/graphql/mutations/account.py +6 -6
  162. infrahub/graphql/mutations/attribute.py +0 -2
  163. infrahub/graphql/mutations/branch.py +9 -5
  164. infrahub/graphql/mutations/computed_attribute.py +1 -1
  165. infrahub/graphql/mutations/display_label.py +1 -1
  166. infrahub/graphql/mutations/hfid.py +1 -1
  167. infrahub/graphql/mutations/ipam.py +4 -6
  168. infrahub/graphql/mutations/main.py +9 -4
  169. infrahub/graphql/mutations/profile.py +16 -22
  170. infrahub/graphql/mutations/proposed_change.py +4 -4
  171. infrahub/graphql/mutations/relationship.py +40 -10
  172. infrahub/graphql/mutations/repository.py +14 -12
  173. infrahub/graphql/mutations/schema.py +2 -2
  174. infrahub/graphql/order.py +14 -0
  175. infrahub/graphql/queries/branch.py +62 -6
  176. infrahub/graphql/queries/diff/tree.py +5 -5
  177. infrahub/graphql/queries/resource_manager.py +25 -24
  178. infrahub/graphql/resolvers/account_metadata.py +84 -0
  179. infrahub/graphql/resolvers/ipam.py +6 -8
  180. infrahub/graphql/resolvers/many_relationship.py +77 -35
  181. infrahub/graphql/resolvers/resolver.py +59 -14
  182. infrahub/graphql/resolvers/single_relationship.py +87 -23
  183. infrahub/graphql/subscription/graphql_query.py +2 -0
  184. infrahub/graphql/types/__init__.py +0 -1
  185. infrahub/graphql/types/attribute.py +10 -5
  186. infrahub/graphql/types/branch.py +40 -53
  187. infrahub/graphql/types/enums.py +3 -0
  188. infrahub/graphql/types/metadata.py +28 -0
  189. infrahub/graphql/types/node.py +22 -2
  190. infrahub/graphql/types/relationship.py +10 -2
  191. infrahub/graphql/types/standard_node.py +12 -7
  192. infrahub/hfid/tasks.py +12 -3
  193. infrahub/lock.py +7 -0
  194. infrahub/menu/repository.py +1 -1
  195. infrahub/patch/queries/base.py +1 -1
  196. infrahub/pools/number.py +1 -8
  197. infrahub/profiles/gather.py +56 -0
  198. infrahub/profiles/mandatory_fields_checker.py +116 -0
  199. infrahub/profiles/models.py +66 -0
  200. infrahub/profiles/node_applier.py +154 -13
  201. infrahub/profiles/queries/get_profile_data.py +143 -31
  202. infrahub/profiles/tasks.py +79 -27
  203. infrahub/profiles/triggers.py +22 -0
  204. infrahub/proposed_change/action_checker.py +1 -1
  205. infrahub/proposed_change/tasks.py +4 -1
  206. infrahub/services/__init__.py +1 -1
  207. infrahub/services/adapters/cache/nats.py +1 -1
  208. infrahub/services/adapters/cache/redis.py +7 -0
  209. infrahub/tasks/artifact.py +1 -0
  210. infrahub/transformations/tasks.py +2 -2
  211. infrahub/trigger/catalogue.py +2 -0
  212. infrahub/trigger/models.py +1 -0
  213. infrahub/trigger/setup.py +3 -3
  214. infrahub/trigger/tasks.py +3 -0
  215. infrahub/validators/tasks.py +1 -0
  216. infrahub/webhook/gather.py +1 -1
  217. infrahub/webhook/models.py +1 -1
  218. infrahub/webhook/tasks.py +23 -7
  219. infrahub/workers/dependencies.py +9 -3
  220. infrahub/workers/infrahub_async.py +13 -4
  221. infrahub/workflows/catalogue.py +19 -0
  222. infrahub_sdk/analyzer.py +2 -2
  223. infrahub_sdk/branch.py +12 -39
  224. infrahub_sdk/checks.py +4 -4
  225. infrahub_sdk/client.py +36 -0
  226. infrahub_sdk/ctl/cli_commands.py +2 -1
  227. infrahub_sdk/ctl/graphql.py +15 -4
  228. infrahub_sdk/ctl/utils.py +2 -2
  229. infrahub_sdk/enums.py +6 -0
  230. infrahub_sdk/graphql/renderers.py +21 -0
  231. infrahub_sdk/graphql/utils.py +85 -0
  232. infrahub_sdk/node/attribute.py +12 -2
  233. infrahub_sdk/node/constants.py +12 -0
  234. infrahub_sdk/node/metadata.py +69 -0
  235. infrahub_sdk/node/node.py +65 -14
  236. infrahub_sdk/node/property.py +3 -0
  237. infrahub_sdk/node/related_node.py +37 -5
  238. infrahub_sdk/node/relationship.py +18 -1
  239. infrahub_sdk/operation.py +2 -2
  240. infrahub_sdk/schema/repository.py +1 -2
  241. infrahub_sdk/transforms.py +2 -2
  242. infrahub_sdk/types.py +18 -2
  243. {infrahub_server-1.6.2.dist-info → infrahub_server-1.7.0.dist-info}/METADATA +17 -16
  244. {infrahub_server-1.6.2.dist-info → infrahub_server-1.7.0.dist-info}/RECORD +252 -231
  245. infrahub_testcontainers/container.py +3 -3
  246. infrahub_testcontainers/docker-compose-cluster.test.yml +7 -7
  247. infrahub_testcontainers/docker-compose.test.yml +13 -5
  248. infrahub_testcontainers/models.py +3 -3
  249. infrahub_testcontainers/performance_test.py +1 -1
  250. infrahub/graphql/models.py +0 -6
  251. {infrahub_server-1.6.2.dist-info → infrahub_server-1.7.0.dist-info}/WHEEL +0 -0
  252. {infrahub_server-1.6.2.dist-info → infrahub_server-1.7.0.dist-info}/entry_points.txt +0 -0
  253. {infrahub_server-1.6.2.dist-info → infrahub_server-1.7.0.dist-info}/licenses/LICENSE.txt +0 -0
@@ -5,26 +5,39 @@ from collections import defaultdict
5
5
  from copy import copy
6
6
  from dataclasses import dataclass
7
7
  from dataclasses import field as dataclass_field
8
+ from datetime import datetime
8
9
  from enum import Enum
9
10
  from typing import TYPE_CHECKING, Any, AsyncIterator, Generator
10
11
 
12
+ import ujson
13
+ from whenever import ZonedDateTime
14
+
11
15
  from infrahub import config
16
+ from infrahub.constants.enums import OrderDirection
12
17
  from infrahub.core import registry
13
18
  from infrahub.core.constants import (
14
19
  GLOBAL_BRANCH_NAME,
15
20
  PROFILE_NODE_RELATIONSHIP_IDENTIFIER,
16
21
  PROFILE_TEMPLATE_RELATIONSHIP_IDENTIFIER,
17
22
  AttributeDBNodeType,
23
+ MetadataOptions,
18
24
  RelationshipDirection,
19
25
  RelationshipHierarchyDirection,
20
26
  )
27
+ from infrahub.core.order import (
28
+ METADATA_CREATED_AT,
29
+ METADATA_CREATED_BY,
30
+ METADATA_UPDATED_AT,
31
+ METADATA_UPDATED_BY,
32
+ OrderModel,
33
+ )
21
34
  from infrahub.core.query import Query, QueryResult, QueryType
22
35
  from infrahub.core.query.subquery import build_subquery_filter, build_subquery_order
23
36
  from infrahub.core.query.utils import find_node_schema
24
37
  from infrahub.core.schema.attribute_schema import AttributeSchema
38
+ from infrahub.core.timestamp import Timestamp
25
39
  from infrahub.core.utils import build_regex_attrs, extract_field_filters
26
40
  from infrahub.exceptions import QueryError
27
- from infrahub.graphql.models import OrderModel
28
41
 
29
42
  if TYPE_CHECKING:
30
43
  from neo4j.graph import Node as Neo4jNode
@@ -40,18 +53,25 @@ if TYPE_CHECKING:
40
53
  from infrahub.database import InfrahubDatabase
41
54
 
42
55
 
56
+ # Grouped constants for validation/iteration
57
+ METADATA_CREATED_FIELDS = (METADATA_CREATED_AT, METADATA_CREATED_BY)
58
+ METADATA_UPDATED_FIELDS = (METADATA_UPDATED_AT, METADATA_UPDATED_BY)
59
+ NODE_METADATA_PREFIX = "node_metadata__"
60
+
61
+
43
62
  @dataclass
44
63
  class NodeToProcess:
45
64
  schema: NodeSchema | ProfileSchema | TemplateSchema | None
46
65
 
66
+ labels: list[str]
47
67
  node_id: str
48
68
  node_uuid: str
49
-
50
- updated_at: str
51
-
52
69
  branch: str
53
70
 
54
- labels: list[str]
71
+ created_at: Timestamp | None = None
72
+ created_by: str | None = None
73
+ updated_at: Timestamp | None = None
74
+ updated_by: str | None = None
55
75
 
56
76
 
57
77
  @dataclass
@@ -74,13 +94,16 @@ class AttributeFromDB:
74
94
  value: Any
75
95
  content: Any
76
96
 
77
- updated_at: str
78
-
79
97
  branch: str
80
98
 
81
99
  is_default: bool
82
100
  is_from_profile: bool = dataclass_field(default=False)
83
101
 
102
+ updated_at: Timestamp | None = None
103
+ updated_by: str | None = None
104
+ created_at: Timestamp | None = None
105
+ created_by: str | None = None
106
+
84
107
  node_properties: dict[str, AttributeNodePropertyFromDB] = dataclass_field(default_factory=dict)
85
108
  flag_properties: dict[str, bool] = dataclass_field(default_factory=dict)
86
109
 
@@ -108,8 +131,6 @@ class NodeQuery(Query):
108
131
  branch: Branch | None = None,
109
132
  **kwargs,
110
133
  ) -> None:
111
- # TODO Validate that Node is a valid node
112
- # Eventually extract the branch from Node as well
113
134
  self.node = node
114
135
  self.node_id = node_id or id
115
136
  self.node_db_id = node_db_id
@@ -133,6 +154,7 @@ class NodeCreateAllQuery(NodeQuery):
133
154
 
134
155
  async def query_init(self, db: InfrahubDatabase, **kwargs) -> None: # noqa: ARG002, PLR0915
135
156
  at = self.at or self.node._at
157
+ self.params["user_id"] = self.user_id
136
158
  self.params["uuid"] = self.node.id
137
159
  self.params["branch"] = self.branch.name
138
160
  self.params["branch_level"] = self.branch.hierarchy_level
@@ -188,7 +210,7 @@ class NodeCreateAllQuery(NodeQuery):
188
210
  pass
189
211
  except ValueError:
190
212
  # Relationship has not been initialized yet, it means the peer does not exist in db yet
191
- # typically because it will be allocated from a ressource pool. In that case, the peer
213
+ # typically because it will be allocated from a resource pool. In that case, the peer
192
214
  # will be fetched using `rel.resolve` later.
193
215
  pass
194
216
 
@@ -220,14 +242,40 @@ class NodeCreateAllQuery(NodeQuery):
220
242
  "namespace": self.node._schema.namespace,
221
243
  "branch_support": self.node._schema.branch,
222
244
  }
245
+ if self.branch.is_default or self.branch.is_global:
246
+ self.params["node_prop"].update(
247
+ {
248
+ "created_at": at.to_string(),
249
+ "created_by": self.user_id,
250
+ "updated_at": at.to_string(),
251
+ "updated_by": self.user_id,
252
+ }
253
+ )
223
254
  self.params["node_branch_prop"] = {
224
255
  "branch": self.branch.name,
225
256
  "branch_level": self.branch.hierarchy_level,
226
257
  "status": "active",
227
258
  "from": at.to_string(),
259
+ "from_user_id": self.user_id,
228
260
  }
229
261
 
230
- rel_prop_str = "{ branch: rel.branch, branch_level: rel.branch_level, status: rel.status, hierarchy: rel.hierarchical, from: $at }"
262
+ # set all the property strings that we reuse
263
+ # include the create/updated_at/by metadata if on default or global branch
264
+ attr_edge_prop_str = "{ branch: attr.branch, branch_level: attr.branch_level, status: attr.status, from: $at, from_user_id: $user_id }"
265
+ attr_vertex_prop_str = "{ uuid: attr.uuid, name: attr.name, branch_support: attr.branch_support"
266
+ if self.branch.is_default or self.branch.is_global:
267
+ attr_vertex_prop_str += ", created_at: $at, created_by: $user_id, updated_at: $at, updated_by: $user_id"
268
+ attr_vertex_prop_str += " }"
269
+
270
+ rel_edge_prop_str = "{ branch: rel.branch, branch_level: rel.branch_level, status: rel.status, from: $at, from_user_id: $user_id }"
271
+ rel_edge_prop_str_hierarchy = (
272
+ "{ branch: rel.branch, branch_level: rel.branch_level, "
273
+ "status: rel.status, hierarchy: rel.hierarchical, from: $at, from_user_id: $user_id }"
274
+ )
275
+ rel_vertex_prop_str = "{ uuid: rel.uuid, name: rel.name, branch_support: rel.branch_support"
276
+ if self.branch.is_default or self.branch.is_global:
277
+ rel_vertex_prop_str += ", created_at: $at, created_by: $user_id, updated_at: $at, updated_by: $user_id"
278
+ rel_vertex_prop_str += " }"
231
279
 
232
280
  iphost_prop = {
233
281
  "value": "attr.content.value",
@@ -270,102 +318,102 @@ class NodeCreateAllQuery(NodeQuery):
270
318
  LIMIT 1
271
319
  }
272
320
  CALL (n, attr, av) {
273
- CREATE (a:Attribute { uuid: attr.uuid, name: attr.name, branch_support: attr.branch_support })
274
- CREATE (n)-[:HAS_ATTRIBUTE { branch: attr.branch, branch_level: attr.branch_level, status: attr.status, from: $at }]->(a)
275
- CREATE (a)-[:HAS_VALUE { branch: attr.branch, branch_level: attr.branch_level, status: attr.status, from: $at }]->(av)
321
+ CREATE (a:Attribute %(attr_vertex)s)
322
+ CREATE (n)-[:HAS_ATTRIBUTE %(attr_edge)s]->(a)
323
+ CREATE (a)-[:HAS_VALUE %(attr_edge)s]->(av)
276
324
  MERGE (ip:Boolean { value: attr.is_protected })
277
- MERGE (iv:Boolean { value: attr.is_visible })
278
- WITH a, ip, iv
325
+ WITH a, ip
279
326
  LIMIT 1
280
- CREATE (a)-[:IS_PROTECTED { branch: attr.branch, branch_level: attr.branch_level, status: attr.status, from: $at }]->(ip)
281
- CREATE (a)-[:IS_VISIBLE { branch: attr.branch, branch_level: attr.branch_level, status: attr.status, from: $at }]->(iv)
327
+ CREATE (a)-[:IS_PROTECTED %(attr_edge)s]->(ip)
282
328
  FOREACH ( prop IN attr.source_prop |
283
329
  MERGE (peer:Node { uuid: prop.peer_id })
284
- CREATE (a)-[:HAS_SOURCE { branch: attr.branch, branch_level: attr.branch_level, status: attr.status, from: $at }]->(peer)
330
+ CREATE (a)-[:HAS_SOURCE %(attr_edge)s]->(peer)
285
331
  )
286
332
  FOREACH ( prop IN attr.owner_prop |
287
333
  MERGE (peer:Node { uuid: prop.peer_id })
288
- CREATE (a)-[:HAS_OWNER { branch: attr.branch, branch_level: attr.branch_level, status: attr.status, from: $at }]->(peer)
334
+ CREATE (a)-[:HAS_OWNER %(attr_edge)s]->(peer)
289
335
  )
290
- }"""
336
+ }""" % {"attr_edge": attr_edge_prop_str, "attr_vertex": attr_vertex_prop_str}
291
337
 
292
338
  attrs_indexed_query = """
293
339
  WITH distinct n
294
340
  UNWIND $attrs_indexed AS attr
295
341
  CALL (n, attr) {
296
- CREATE (a:Attribute { uuid: attr.uuid, name: attr.name, branch_support: attr.branch_support })
297
- CREATE (n)-[:HAS_ATTRIBUTE { branch: attr.branch, branch_level: attr.branch_level, status: attr.status, from: $at }]->(a)
342
+ CREATE (a:Attribute %(attr_vertex)s)
343
+ CREATE (n)-[:HAS_ATTRIBUTE %(attr_edge)s]->(a)
298
344
  MERGE (av:AttributeValue:AttributeValueIndexed { value: attr.content.value, is_default: attr.content.is_default })
299
345
  WITH av, a
300
346
  LIMIT 1
301
- CREATE (a)-[:HAS_VALUE { branch: attr.branch, branch_level: attr.branch_level, status: attr.status, from: $at }]->(av)
347
+ CREATE (a)-[:HAS_VALUE %(attr_edge)s]->(av)
302
348
  MERGE (ip:Boolean { value: attr.is_protected })
303
- MERGE (iv:Boolean { value: attr.is_visible })
304
- CREATE (a)-[:IS_PROTECTED { branch: attr.branch, branch_level: attr.branch_level, status: attr.status, from: $at }]->(ip)
305
- CREATE (a)-[:IS_VISIBLE { branch: attr.branch, branch_level: attr.branch_level, status: attr.status, from: $at }]->(iv)
349
+ CREATE (a)-[:IS_PROTECTED %(attr_edge)s]->(ip)
306
350
  FOREACH ( prop IN attr.source_prop |
307
351
  MERGE (peer:Node { uuid: prop.peer_id })
308
- CREATE (a)-[:HAS_SOURCE { branch: attr.branch, branch_level: attr.branch_level, status: attr.status, from: $at }]->(peer)
352
+ CREATE (a)-[:HAS_SOURCE %(attr_edge)s]->(peer)
309
353
  )
310
354
  FOREACH ( prop IN attr.owner_prop |
311
355
  MERGE (peer:Node { uuid: prop.peer_id })
312
- CREATE (a)-[:HAS_OWNER { branch: attr.branch, branch_level: attr.branch_level, status: attr.status, from: $at }]->(peer)
356
+ CREATE (a)-[:HAS_OWNER %(attr_edge)s]->(peer)
313
357
  )
314
- }"""
358
+ }""" % {"attr_edge": attr_edge_prop_str, "attr_vertex": attr_vertex_prop_str}
315
359
 
316
360
  attrs_iphost_query = """
317
361
  WITH distinct n
318
362
  UNWIND $attrs_iphost AS attr
319
363
  CALL (n, attr) {
320
- CREATE (a:Attribute { uuid: attr.uuid, name: attr.name, branch_support: attr.branch_support })
321
- CREATE (n)-[:HAS_ATTRIBUTE { branch: attr.branch, branch_level: attr.branch_level, status: attr.status, from: $at }]->(a)
364
+ CREATE (a:Attribute %(attr_vertex)s)
365
+ CREATE (n)-[:HAS_ATTRIBUTE %(attr_edge)s]->(a)
322
366
  MERGE (av:AttributeValue:AttributeValueIndexed:AttributeIPHost { %(iphost_prop)s })
323
367
  WITH attr, av, a
324
368
  LIMIT 1
325
- CREATE (a)-[:HAS_VALUE { branch: attr.branch, branch_level: attr.branch_level, status: attr.status, from: $at }]->(av)
369
+ CREATE (a)-[:HAS_VALUE %(attr_edge)s]->(av)
326
370
  MERGE (ip:Boolean { value: attr.is_protected })
327
- MERGE (iv:Boolean { value: attr.is_visible })
328
- WITH a, ip, iv
371
+ WITH a, ip
329
372
  LIMIT 1
330
- CREATE (a)-[:IS_PROTECTED { branch: attr.branch, branch_level: attr.branch_level, status: attr.status, from: $at }]->(ip)
331
- CREATE (a)-[:IS_VISIBLE { branch: attr.branch, branch_level: attr.branch_level, status: attr.status, from: $at }]->(iv)
373
+ CREATE (a)-[:IS_PROTECTED %(attr_edge)s]->(ip)
332
374
  FOREACH ( prop IN attr.source_prop |
333
375
  MERGE (peer:Node { uuid: prop.peer_id })
334
- CREATE (a)-[:HAS_SOURCE { branch: attr.branch, branch_level: attr.branch_level, status: attr.status, from: $at }]->(peer)
376
+ CREATE (a)-[:HAS_SOURCE %(attr_edge)s]->(peer)
335
377
  )
336
378
  FOREACH ( prop IN attr.owner_prop |
337
379
  MERGE (peer:Node { uuid: prop.peer_id })
338
- CREATE (a)-[:HAS_OWNER { branch: attr.branch, branch_level: attr.branch_level, status: attr.status, from: $at }]->(peer)
380
+ CREATE (a)-[:HAS_OWNER %(attr_edge)s]->(peer)
339
381
  )
340
382
  }
341
- """ % {"iphost_prop": ", ".join(iphost_prop_list)}
383
+ """ % {
384
+ "iphost_prop": ", ".join(iphost_prop_list),
385
+ "attr_edge": attr_edge_prop_str,
386
+ "attr_vertex": attr_vertex_prop_str,
387
+ }
342
388
 
343
389
  attrs_ipnetwork_query = """
344
390
  WITH distinct n
345
391
  UNWIND $attrs_ipnetwork AS attr
346
392
  CALL (n, attr) {
347
- CREATE (a:Attribute { uuid: attr.uuid, name: attr.name, branch_support: attr.branch_support })
348
- CREATE (n)-[:HAS_ATTRIBUTE { branch: attr.branch, branch_level: attr.branch_level, status: attr.status, from: $at }]->(a)
393
+ CREATE (a:Attribute %(attr_vertex)s)
394
+ CREATE (n)-[:HAS_ATTRIBUTE %(attr_edge)s]->(a)
349
395
  MERGE (av:AttributeValue:AttributeValueIndexed:AttributeIPNetwork { %(ipnetwork_prop)s })
350
396
  WITH attr, av, a
351
397
  LIMIT 1
352
- CREATE (a)-[:HAS_VALUE { branch: attr.branch, branch_level: attr.branch_level, status: attr.status, from: $at }]->(av)
398
+ CREATE (a)-[:HAS_VALUE %(attr_edge)s]->(av)
353
399
  MERGE (ip:Boolean { value: attr.is_protected })
354
- MERGE (iv:Boolean { value: attr.is_visible })
355
- WITH a, ip, iv
400
+ WITH a, ip
356
401
  LIMIT 1
357
- CREATE (a)-[:IS_PROTECTED { branch: attr.branch, branch_level: attr.branch_level, status: attr.status, from: $at }]->(ip)
358
- CREATE (a)-[:IS_VISIBLE { branch: attr.branch, branch_level: attr.branch_level, status: attr.status, from: $at }]->(iv)
402
+ CREATE (a)-[:IS_PROTECTED %(attr_edge)s]->(ip)
359
403
  FOREACH ( prop IN attr.source_prop |
360
404
  MERGE (peer:Node { uuid: prop.peer_id })
361
- CREATE (a)-[:HAS_SOURCE { branch: attr.branch, branch_level: attr.branch_level, status: attr.status, from: $at }]->(peer)
405
+ CREATE (a)-[:HAS_SOURCE %(attr_edge)s]->(peer)
362
406
  )
363
407
  FOREACH ( prop IN attr.owner_prop |
364
408
  MERGE (peer:Node { uuid: prop.peer_id })
365
- CREATE (a)-[:HAS_OWNER { branch: attr.branch, branch_level: attr.branch_level, status: attr.status, from: $at }]->(peer)
409
+ CREATE (a)-[:HAS_OWNER %(attr_edge)s]->(peer)
366
410
  )
367
411
  }
368
- """ % {"ipnetwork_prop": ", ".join(ipnetwork_prop_list)}
412
+ """ % {
413
+ "ipnetwork_prop": ", ".join(ipnetwork_prop_list),
414
+ "attr_edge": attr_edge_prop_str,
415
+ "attr_vertex": attr_vertex_prop_str,
416
+ }
369
417
 
370
418
  deepest_branch = await registry.get_branch(db=db, branch=deepest_branch_name)
371
419
  branch_filter, branch_params = deepest_branch.get_query_filter_path(at=self.at)
@@ -409,75 +457,84 @@ class NodeCreateAllQuery(NodeQuery):
409
457
  UNWIND $rels_bidir AS rel
410
458
  %(dest_node_subquery)s
411
459
  CALL (n, rel, dest_node) {
412
- CREATE (rl:Relationship { uuid: rel.uuid, name: rel.name, branch_support: rel.branch_support })
413
- CREATE (n)-[:IS_RELATED %(rel_prop)s ]->(rl)
414
- CREATE (dest_node)-[:IS_RELATED %(rel_prop)s ]->(rl)
460
+ CREATE (rl:Relationship %(rel_vertex)s)
461
+ CREATE (n)-[:IS_RELATED %(rel_edge_hierarchy)s ]->(rl)
462
+ CREATE (dest_node)-[:IS_RELATED %(rel_edge_hierarchy)s ]->(rl)
415
463
  MERGE (ip:Boolean { value: rel.is_protected })
416
- MERGE (iv:Boolean { value: rel.is_visible })
417
- WITH rl, ip, iv
464
+ WITH rl, ip
418
465
  LIMIT 1
419
- CREATE (rl)-[:IS_PROTECTED { branch: rel.branch, branch_level: rel.branch_level, status: rel.status, from: $at }]->(ip)
420
- CREATE (rl)-[:IS_VISIBLE { branch: rel.branch, branch_level: rel.branch_level, status: rel.status, from: $at }]->(iv)
466
+ CREATE (rl)-[:IS_PROTECTED %(rel_edge)s]->(ip)
421
467
  FOREACH ( prop IN rel.source_prop |
422
468
  MERGE (peer:Node { uuid: prop.peer_id })
423
- CREATE (rl)-[:HAS_SOURCE { branch: rel.branch, branch_level: rel.branch_level, status: rel.status, from: $at }]->(peer)
469
+ CREATE (rl)-[:HAS_SOURCE %(rel_edge)s]->(peer)
424
470
  )
425
471
  FOREACH ( prop IN rel.owner_prop |
426
472
  MERGE (peer:Node { uuid: prop.peer_id })
427
- CREATE (rl)-[:HAS_OWNER { branch: rel.branch, branch_level: rel.branch_level, status: rel.status, from: $at }]->(peer)
473
+ CREATE (rl)-[:HAS_OWNER %(rel_edge)s]->(peer)
428
474
  )
429
475
  }
430
- """ % {"rel_prop": rel_prop_str, "dest_node_subquery": dest_node_subquery}
476
+ """ % {
477
+ "rel_edge": rel_edge_prop_str,
478
+ "rel_edge_hierarchy": rel_edge_prop_str_hierarchy,
479
+ "rel_vertex": rel_vertex_prop_str,
480
+ "dest_node_subquery": dest_node_subquery,
481
+ }
431
482
 
432
483
  rels_out_query = """
433
484
  WITH distinct n
434
485
  UNWIND $rels_out AS rel
435
486
  %(dest_node_subquery)s
436
487
  CALL (n, rel, dest_node) {
437
- CREATE (rl:Relationship { uuid: rel.uuid, name: rel.name, branch_support: rel.branch_support })
438
- CREATE (n)-[:IS_RELATED %(rel_prop)s ]->(rl)
439
- CREATE (dest_node)<-[:IS_RELATED %(rel_prop)s ]-(rl)
488
+ CREATE (rl:Relationship %(rel_vertex)s)
489
+ CREATE (n)-[:IS_RELATED %(rel_edge_hierarchy)s ]->(rl)
490
+ CREATE (dest_node)<-[:IS_RELATED %(rel_edge_hierarchy)s ]-(rl)
440
491
  MERGE (ip:Boolean { value: rel.is_protected })
441
- MERGE (iv:Boolean { value: rel.is_visible })
442
- WITH rl, ip, iv
492
+ WITH rl, ip
443
493
  LIMIT 1
444
- CREATE (rl)-[:IS_PROTECTED { branch: rel.branch, branch_level: rel.branch_level, status: rel.status, from: $at }]->(ip)
445
- CREATE (rl)-[:IS_VISIBLE { branch: rel.branch, branch_level: rel.branch_level, status: rel.status, from: $at }]->(iv)
494
+ CREATE (rl)-[:IS_PROTECTED %(rel_edge)s]->(ip)
446
495
  FOREACH ( prop IN rel.source_prop |
447
496
  MERGE (peer:Node { uuid: prop.peer_id })
448
- CREATE (rl)-[:HAS_SOURCE { branch: rel.branch, branch_level: rel.branch_level, status: rel.status, from: $at }]->(peer)
497
+ CREATE (rl)-[:HAS_SOURCE %(rel_edge)s]->(peer)
449
498
  )
450
499
  FOREACH ( prop IN rel.owner_prop |
451
500
  MERGE (peer:Node { uuid: prop.peer_id })
452
- CREATE (rl)-[:HAS_OWNER { branch: rel.branch, branch_level: rel.branch_level, status: rel.status, from: $at }]->(peer)
501
+ CREATE (rl)-[:HAS_OWNER %(rel_edge)s]->(peer)
453
502
  )
454
503
  }
455
- """ % {"rel_prop": rel_prop_str, "dest_node_subquery": dest_node_subquery}
504
+ """ % {
505
+ "rel_edge": rel_edge_prop_str,
506
+ "rel_edge_hierarchy": rel_edge_prop_str_hierarchy,
507
+ "rel_vertex": rel_vertex_prop_str,
508
+ "dest_node_subquery": dest_node_subquery,
509
+ }
456
510
 
457
511
  rels_in_query = """
458
512
  WITH distinct n
459
513
  UNWIND $rels_in AS rel
460
514
  %(dest_node_subquery)s
461
515
  CALL (n, rel, dest_node) {
462
- CREATE (rl:Relationship { uuid: rel.uuid, name: rel.name, branch_support: rel.branch_support })
463
- CREATE (n)<-[:IS_RELATED %(rel_prop)s ]-(rl)
464
- CREATE (dest_node)-[:IS_RELATED %(rel_prop)s ]->(rl)
516
+ CREATE (rl:Relationship %(rel_vertex)s)
517
+ CREATE (n)<-[:IS_RELATED %(rel_edge_hierarchy)s ]-(rl)
518
+ CREATE (dest_node)-[:IS_RELATED %(rel_edge_hierarchy)s ]->(rl)
465
519
  MERGE (ip:Boolean { value: rel.is_protected })
466
- MERGE (iv:Boolean { value: rel.is_visible })
467
- WITH rl, ip, iv
520
+ WITH rl, ip
468
521
  LIMIT 1
469
- CREATE (rl)-[:IS_PROTECTED { branch: rel.branch, branch_level: rel.branch_level, status: rel.status, from: $at }]->(ip)
470
- CREATE (rl)-[:IS_VISIBLE { branch: rel.branch, branch_level: rel.branch_level, status: rel.status, from: $at }]->(iv)
522
+ CREATE (rl)-[:IS_PROTECTED %(rel_edge)s]->(ip)
471
523
  FOREACH ( prop IN rel.source_prop |
472
524
  MERGE (peer:Node { uuid: prop.peer_id })
473
- CREATE (rl)-[:HAS_SOURCE { branch: rel.branch, branch_level: rel.branch_level, status: rel.status, from: $at }]->(peer)
525
+ CREATE (rl)-[:HAS_SOURCE %(rel_edge)s]->(peer)
474
526
  )
475
527
  FOREACH ( prop IN rel.owner_prop |
476
528
  MERGE (peer:Node { uuid: prop.peer_id })
477
- CREATE (rl)-[:HAS_OWNER { branch: rel.branch, branch_level: rel.branch_level, status: rel.status, from: $at }]->(peer)
529
+ CREATE (rl)-[:HAS_OWNER %(rel_edge)s]->(peer)
478
530
  )
479
531
  }
480
- """ % {"rel_prop": rel_prop_str, "dest_node_subquery": dest_node_subquery}
532
+ """ % {
533
+ "rel_edge": rel_edge_prop_str,
534
+ "rel_edge_hierarchy": rel_edge_prop_str_hierarchy,
535
+ "rel_vertex": rel_vertex_prop_str,
536
+ "dest_node_subquery": dest_node_subquery,
537
+ }
481
538
 
482
539
  query = f"""
483
540
  MATCH (root:Root)
@@ -526,48 +583,83 @@ class NodeCreateAllQuery(NodeQuery):
526
583
 
527
584
  class NodeDeleteQuery(NodeQuery):
528
585
  name = "node_delete"
529
-
530
586
  type: QueryType = QueryType.WRITE
531
-
532
- raise_error_if_empty: bool = True
587
+ insert_return = False
588
+ raise_error_if_empty = False
533
589
 
534
590
  async def query_init(self, db: InfrahubDatabase, **kwargs) -> None: # noqa: ARG002
591
+ self.params["user_id"] = self.user_id
535
592
  self.params["uuid"] = self.node_id
536
593
  self.params["branch"] = self.branch.name
537
594
  self.params["branch_level"] = self.branch.hierarchy_level
595
+ self.params["at"] = self.at.to_string()
538
596
 
539
597
  if self.branch.is_global or self.branch.is_default:
598
+ # update the updated_at/by metadata on the Node if we're on the global or default branch
540
599
  node_query_match = """
541
- MATCH (n:Node { uuid: $uuid })
542
- OPTIONAL MATCH (n)-[delete_edge:IS_PART_OF {status: "deleted", branch: $branch}]->(:Root)
543
- WHERE delete_edge.from <= $at
544
- WITH n WHERE delete_edge IS NULL
600
+ MATCH (n:Node { uuid: $uuid })-[r:IS_PART_OF { branch_level: 1, status: "active" }]->(:Root)
601
+ WHERE r.to IS NULL
602
+ OPTIONAL MATCH (n)-[delete_edge:IS_PART_OF {status: "deleted", branch: $branch}]->(:Root)
603
+ WHERE delete_edge.from <= $at
604
+ WITH n, r
605
+ WHERE delete_edge IS NULL
606
+ SET n.updated_at = $at, n.updated_by = $user_id
607
+ WITH n, r
545
608
  """
546
609
  else:
547
610
  node_filter, node_filter_params = self.branch.get_query_filter_path(at=self.at, variable_name="r")
548
611
  node_query_match = """
549
- MATCH (n:Node { uuid: $uuid })
550
- CALL (n) {
551
- MATCH (n)-[r:IS_PART_OF]->(:Root)
552
- WHERE %(node_filter)s
553
- RETURN r.status = "active" AS is_active
554
- ORDER BY r.from DESC
555
- LIMIT 1
556
- }
557
- WITH n WHERE is_active = TRUE
612
+ MATCH (n:Node { uuid: $uuid })
613
+ CALL (n) {
614
+ MATCH (n)-[r:IS_PART_OF]->(:Root)
615
+ WHERE %(node_filter)s
616
+ RETURN r
617
+ ORDER BY r.from DESC
618
+ LIMIT 1
619
+ }
620
+ WITH n, r
621
+ WHERE r.status = "active"
558
622
  """ % {"node_filter": node_filter}
559
623
  self.params.update(node_filter_params)
560
624
  self.add_to_query(node_query_match)
561
625
 
626
+ # set the to time/user_id if the active IS_PART_OF edge is on this branch
562
627
  query = """
563
- MATCH (root:Root)
564
- CREATE (n)-[r:IS_PART_OF { branch: $branch, branch_level: $branch_level, status: "deleted", from: $at }]->(root)
628
+ MATCH (root:Root)
629
+ LIMIT 1
630
+ CREATE (n)-[delete_edge:IS_PART_OF { branch: $branch, branch_level: $branch_level, status: "deleted", from: $at, from_user_id: $user_id }]->(root)
631
+ WITH r
632
+ WHERE r.branch = $branch
633
+ SET r.to = $at
634
+ SET r.to_user_id = $user_id
565
635
  """
636
+ self.add_to_query(query)
637
+
638
+
639
+ class NodeUpdateMetadataQuery(NodeQuery):
640
+ name = "node_update_metadata"
641
+ type: QueryType = QueryType.WRITE
642
+ insert_return = False
643
+ raise_error_if_empty = False
566
644
 
645
+ async def query_init(self, db: InfrahubDatabase, **kwargs) -> None: # noqa: ARG002
646
+ if not self.branch.is_default and not self.branch.is_global:
647
+ raise ValueError("NodeUpdateMetadataQuery can only be used on the default or global branch")
648
+ self.params["uuid"] = self.node_id
649
+ self.params["branch"] = self.branch.name
567
650
  self.params["at"] = self.at.to_string()
651
+ self.params["user_id"] = self.user_id
568
652
 
653
+ query = """
654
+ MATCH (n:Node { uuid: $uuid })-[r:IS_PART_OF { branch_level: 1, status: "active" }]->(:Root)
655
+ WHERE r.to IS NULL
656
+ OPTIONAL MATCH (n)-[delete_edge:IS_PART_OF {status: "deleted", branch: $branch}]->(:Root)
657
+ WHERE delete_edge.from <= $at
658
+ WITH n, r
659
+ WHERE delete_edge IS NULL
660
+ SET n.updated_at = $at, n.updated_by = $user_id
661
+ """
569
662
  self.add_to_query(query)
570
- self.return_labels = ["n"]
571
663
 
572
664
 
573
665
  class NodeCheckIDQuery(Query):
@@ -579,7 +671,7 @@ class NodeCheckIDQuery(Query):
579
671
  self,
580
672
  node_id: str,
581
673
  **kwargs,
582
- ):
674
+ ) -> None:
583
675
  self.node_id = node_id
584
676
  super().__init__(**kwargs)
585
677
 
@@ -603,30 +695,140 @@ class NodeListGetAttributeQuery(Query):
603
695
  "HAS_OWNER": ("rel_owner", "owner"),
604
696
  "HAS_SOURCE": ("rel_source", "source"),
605
697
  "IS_PROTECTED": ("rel_isp", "isp"),
606
- "IS_VISIBLE": ("rel_isv", "isv"),
607
698
  }
608
699
 
609
700
  def __init__(
610
701
  self,
611
702
  ids: list[str],
612
703
  fields: dict | None = None,
613
- include_source: bool = False,
614
- include_owner: bool = False,
615
- account=None,
704
+ include_metadata: MetadataOptions = MetadataOptions.NONE,
616
705
  **kwargs,
617
- ):
618
- self.account = account
706
+ ) -> None:
619
707
  self.ids = ids
620
708
  self.fields = fields
621
- self.include_source = include_source
622
- self.include_owner = include_owner
623
-
709
+ self.include_metadata = include_metadata
624
710
  super().__init__(order_by=["n.uuid", "a.name"], **kwargs)
625
711
 
712
+ @property
713
+ def _include_source(self) -> bool:
714
+ return bool(self.include_metadata & MetadataOptions.SOURCE)
715
+
716
+ @property
717
+ def _include_owner(self) -> bool:
718
+ return bool(self.include_metadata & MetadataOptions.OWNER)
719
+
720
+ @property
721
+ def _include_updated_metadata(self) -> bool:
722
+ return bool(self.include_metadata & (MetadataOptions.UPDATED_AT | MetadataOptions.UPDATED_BY))
723
+
724
+ @property
725
+ def _include_created_metadata(self) -> bool:
726
+ return bool(self.include_metadata & (MetadataOptions.CREATED_AT | MetadataOptions.CREATED_BY))
727
+
728
+ def _add_source_to_query(self, branch_filter_str: str) -> None:
729
+ if not self._include_source:
730
+ return
731
+ source_query = """
732
+ CALL (a) {
733
+ OPTIONAL MATCH (a)-[rel_source:HAS_SOURCE]-(source)
734
+ WHERE all(r IN [rel_source] WHERE ( %(branch_filter)s ))
735
+ RETURN source, rel_source
736
+ ORDER BY rel_source.branch_level DESC, rel_source.from DESC, rel_source.status ASC
737
+ LIMIT 1
738
+ }
739
+ WITH *,
740
+ CASE WHEN rel_source.status = "active" THEN source ELSE NULL END AS source,
741
+ CASE WHEN rel_source.status = "active" THEN rel_source ELSE NULL END AS rel_source
742
+ """ % {"branch_filter": branch_filter_str}
743
+ self.add_to_query(source_query)
744
+ self.return_labels.extend(["source", "rel_source"])
745
+
746
+ def _add_owner_to_query(self, branch_filter_str: str) -> None:
747
+ if not self._include_owner:
748
+ return
749
+ owner_query = """
750
+ CALL (a) {
751
+ OPTIONAL MATCH (a)-[rel_owner:HAS_OWNER]-(owner)
752
+ WHERE all(r IN [rel_owner] WHERE ( %(branch_filter)s ))
753
+ RETURN owner, rel_owner
754
+ ORDER BY rel_owner.branch_level DESC, rel_owner.from DESC, rel_owner.status ASC
755
+ LIMIT 1
756
+ }
757
+ WITH *,
758
+ CASE WHEN rel_owner.status = "active" THEN owner ELSE NULL END AS owner,
759
+ CASE WHEN rel_owner.status = "active" THEN rel_owner ELSE NULL END AS rel_owner
760
+ """ % {"branch_filter": branch_filter_str}
761
+ self.add_to_query(owner_query)
762
+ self.return_labels.extend(["owner", "rel_owner"])
763
+
764
+ def _add_created_metadata_to_query(self) -> None:
765
+ if not self._include_created_metadata:
766
+ return
767
+ if self.branch.is_default or self.branch.is_global:
768
+ last_created_query = """
769
+ WITH *, a.created_at AS created_at, a.created_by AS created_by
770
+ """
771
+ else:
772
+ last_created_query = """
773
+ CALL (a) {
774
+ MATCH ()-[e:HAS_ATTRIBUTE {status: "active"}]->(a)
775
+ RETURN e.from AS created_at, e.from_user_id AS created_by
776
+ ORDER BY e.from ASC
777
+ LIMIT 1
778
+ }
779
+ """
780
+ self.add_to_query(last_created_query)
781
+ self.return_labels.extend(["created_at", "created_by"])
782
+
783
+ def _add_updated_metadata_to_query(self, branch_filter_str: str) -> None:
784
+ if not self._include_updated_metadata:
785
+ return
786
+ if self.branch.is_default or self.branch.is_global:
787
+ last_updated_query = """
788
+ WITH *, a.updated_at AS updated_at, a.updated_by AS updated_by
789
+ """
790
+ else:
791
+ if self.branch_agnostic:
792
+ time_details = """
793
+ WITH [r.from, r.from_user_id] AS from_details, [r.to, r.to_user_id] AS to_details
794
+ """
795
+ else:
796
+ time_details = """
797
+ WITH CASE
798
+ WHEN r.branch IN $branch0 AND r.from < $time0 THEN [r.from, r.from_user_id]
799
+ WHEN r.branch IN $branch1 AND r.from < $time1 THEN [r.from, r.from_user_id]
800
+ ELSE [NULL, NULL]
801
+ END AS from_details,
802
+ CASE
803
+ WHEN r.branch IN $branch0 AND r.to < $time0 THEN [r.to, r.to_user_id]
804
+ WHEN r.branch IN $branch1 AND r.to < $time1 THEN [r.to, r.to_user_id]
805
+ ELSE [NULL, NULL]
806
+ END AS to_details
807
+ """
808
+ last_updated_query = """
809
+ CALL (a) {
810
+ MATCH (a)-[r]->(property)
811
+ WHERE %(branch_filter)s
812
+ %(time_details)s
813
+ WITH collect(from_details) AS from_details_list, collect(to_details) AS to_details_list
814
+ WITH from_details_list + to_details_list AS details_list
815
+ UNWIND details_list AS one_details
816
+ WITH one_details[0] AS updated_at, one_details[1] AS updated_by
817
+ WHERE updated_at IS NOT NULL
818
+ WITH updated_at, updated_by
819
+ ORDER BY updated_at DESC
820
+ LIMIT 1
821
+ RETURN updated_at, updated_by
822
+ }
823
+ """ % {"branch_filter": branch_filter_str, "time_details": time_details}
824
+ self.add_to_query(last_updated_query)
825
+ self.return_labels.extend(["updated_at", "updated_by"])
826
+
626
827
  async def query_init(self, db: InfrahubDatabase, **kwargs) -> None: # noqa: ARG002
627
828
  self.params["ids"] = self.ids
628
829
  self.params["profile_node_relationship_name"] = PROFILE_NODE_RELATIONSHIP_IDENTIFIER
629
830
  self.params["profile_template_relationship_name"] = PROFILE_TEMPLATE_RELATIONSHIP_IDENTIFIER
831
+ self.params["field_names"] = list(self.fields.keys()) if self.fields else []
630
832
 
631
833
  branch_filter, branch_params = self.branch.get_query_filter_path(
632
834
  at=self.at, branch_agnostic=self.branch_agnostic
@@ -640,11 +842,9 @@ class NodeListGetAttributeQuery(Query):
640
842
  exists((n)-[:IS_RELATED]-(:Relationship {name: $profile_template_relationship_name}))
641
843
  ) AS might_use_profile
642
844
  MATCH (n)-[:HAS_ATTRIBUTE]-(a:Attribute)
845
+ WHERE (a.name IN $field_names OR size($field_names) = 0)
846
+ WITH DISTINCT n, a, might_use_profile
643
847
  """
644
- if self.fields:
645
- query += "\n WHERE a.name IN $field_names"
646
- self.params["field_names"] = list(self.fields.keys())
647
-
648
848
  self.add_to_query(query)
649
849
 
650
850
  query = """
@@ -680,15 +880,8 @@ WHERE r2.status = "active"
680
880
 
681
881
  self.return_labels = ["n", "a", "av", "r1", "r2", "is_from_profile"]
682
882
 
683
- # Add Is_Protected and Is_visible
883
+ # Add Is_Protected
684
884
  query = """
685
- CALL (a) {
686
- MATCH (a)-[r:IS_VISIBLE]-(isv:Boolean)
687
- WHERE (%(branch_filter)s)
688
- RETURN r AS rel_isv, isv
689
- ORDER BY rel_isv.branch_level DESC, rel_isv.from DESC, rel_isv.status ASC
690
- LIMIT 1
691
- }
692
885
  CALL (a) {
693
886
  MATCH (a)-[r:IS_PROTECTED]-(isp:Boolean)
694
887
  WHERE (%(branch_filter)s)
@@ -699,39 +892,12 @@ CALL (a) {
699
892
  """ % {"branch_filter": branch_filter}
700
893
  self.add_to_query(query)
701
894
 
702
- self.return_labels.extend(["isv", "isp", "rel_isv", "rel_isp"])
895
+ self.return_labels.extend(["isp", "rel_isp"])
703
896
 
704
- if self.include_source:
705
- query = """
706
- CALL (a) {
707
- OPTIONAL MATCH (a)-[rel_source:HAS_SOURCE]-(source)
708
- WHERE all(r IN [rel_source] WHERE ( %(branch_filter)s ))
709
- RETURN source, rel_source
710
- ORDER BY rel_source.branch_level DESC, rel_source.from DESC, rel_source.status ASC
711
- LIMIT 1
712
- }
713
- WITH *,
714
- CASE WHEN rel_source.status = "active" THEN source ELSE NULL END AS source,
715
- CASE WHEN rel_source.status = "active" THEN rel_source ELSE NULL END AS rel_source
716
- """ % {"branch_filter": branch_filter}
717
- self.add_to_query(query)
718
- self.return_labels.extend(["source", "rel_source"])
719
-
720
- if self.include_owner:
721
- query = """
722
- CALL (a) {
723
- OPTIONAL MATCH (a)-[rel_owner:HAS_OWNER]-(owner)
724
- WHERE all(r IN [rel_owner] WHERE ( %(branch_filter)s ))
725
- RETURN owner, rel_owner
726
- ORDER BY rel_owner.branch_level DESC, rel_owner.from DESC, rel_owner.status ASC
727
- LIMIT 1
728
- }
729
- WITH *,
730
- CASE WHEN rel_owner.status = "active" THEN owner ELSE NULL END AS owner,
731
- CASE WHEN rel_owner.status = "active" THEN rel_owner ELSE NULL END AS rel_owner
732
- """ % {"branch_filter": branch_filter}
733
- self.add_to_query(query)
734
- self.return_labels.extend(["owner", "rel_owner"])
897
+ self._add_source_to_query(branch_filter_str=branch_filter)
898
+ self._add_owner_to_query(branch_filter_str=branch_filter)
899
+ self._add_created_metadata_to_query()
900
+ self._add_updated_metadata_to_query(branch_filter_str=branch_filter)
735
901
 
736
902
  def get_attributes_group_by_node(self) -> dict[str, NodeAttributesFromDB]:
737
903
  attrs_by_node: dict[str, NodeAttributesFromDB] = {}
@@ -768,7 +934,6 @@ CALL (a) {
768
934
  attr_uuid=attr.get("uuid"),
769
935
  attr_value_id=attr_value.element_id,
770
936
  attr_value_uuid=attr_value.get("uuid"),
771
- updated_at=result.get_rel("r2").get("from"),
772
937
  value=attr_value.get("value"),
773
938
  is_default=attr_value.get("is_default"),
774
939
  is_from_profile=is_from_profile,
@@ -776,16 +941,26 @@ CALL (a) {
776
941
  branch=self.branch.name,
777
942
  flag_properties={
778
943
  "is_protected": result.get("isp").get("value"),
779
- "is_visible": result.get("isv").get("value"),
780
944
  },
781
945
  )
782
946
 
783
- if self.include_source and result.get("source"):
947
+ if self.include_metadata & MetadataOptions.CREATED_AT:
948
+ created_at_str = result.get_as_str("created_at")
949
+ data.created_at = Timestamp(created_at_str) if created_at_str else None
950
+ if self.include_metadata & MetadataOptions.CREATED_BY:
951
+ data.created_by = result.get_as_str("created_by")
952
+ if self.include_metadata & MetadataOptions.UPDATED_AT:
953
+ updated_at_str = result.get_as_str("updated_at")
954
+ data.updated_at = Timestamp(updated_at_str) if updated_at_str else None
955
+ if self.include_metadata & MetadataOptions.UPDATED_BY:
956
+ data.updated_by = result.get_as_str("updated_by")
957
+
958
+ if self._include_source and result.get("source"):
784
959
  data.node_properties["source"] = AttributeNodePropertyFromDB(
785
960
  uuid=result.get_node("source").get("uuid"), labels=list(result.get_node("source").labels)
786
961
  )
787
962
 
788
- if self.include_owner and result.get("owner"):
963
+ if self._include_owner and result.get("owner"):
789
964
  data.node_properties["owner"] = AttributeNodePropertyFromDB(
790
965
  uuid=result.get_node("owner").get("uuid"), labels=list(result.get_node("owner").labels)
791
966
  )
@@ -794,17 +969,42 @@ CALL (a) {
794
969
 
795
970
 
796
971
  class GroupedPeerNodes:
797
- def __init__(self):
972
+ def __init__(self) -> None:
798
973
  # {node_id: [rel_name, ...]}
799
974
  self._rel_names_by_node_id: dict[str, set[str]] = defaultdict(set)
800
975
  # {(node_id, rel_name): {RelationshipDirection: {peer_id, ...}}}
801
976
  self._rel_directions_map: dict[tuple[str, str], dict[RelationshipDirection, set[str]]] = defaultdict(dict)
977
+ # {(node_id, rel_name, direction, peer_Id): {MetadataOptions: value}}
978
+ self._metadata_map: dict[
979
+ tuple[str, str, RelationshipDirection, str], dict[MetadataOptions, Timestamp | str | None]
980
+ ] = {}
802
981
 
803
- def add_peer(self, node_id: str, rel_name: str, peer_id: str, direction: RelationshipDirection) -> None:
982
+ def add_peer(
983
+ self,
984
+ node_id: str,
985
+ rel_name: str,
986
+ peer_id: str,
987
+ direction: RelationshipDirection,
988
+ created_at: Timestamp | None = None,
989
+ created_by: str | None = None,
990
+ updated_at: Timestamp | None = None,
991
+ updated_by: str | None = None,
992
+ ) -> None:
804
993
  self._rel_names_by_node_id[node_id].add(rel_name)
805
994
  if direction not in self._rel_directions_map[node_id, rel_name]:
806
995
  self._rel_directions_map[node_id, rel_name][direction] = set()
807
996
  self._rel_directions_map[node_id, rel_name][direction].add(peer_id)
997
+ key = (node_id, rel_name, direction, peer_id)
998
+ if created_at is not None or created_by is not None or updated_at is not None or updated_by is not None:
999
+ self._metadata_map[key] = {}
1000
+ if created_at is not None:
1001
+ self._metadata_map[key][MetadataOptions.CREATED_AT] = created_at
1002
+ if created_by is not None:
1003
+ self._metadata_map[key][MetadataOptions.CREATED_BY] = created_by
1004
+ if updated_at is not None:
1005
+ self._metadata_map[key][MetadataOptions.UPDATED_AT] = updated_at
1006
+ if updated_by is not None:
1007
+ self._metadata_map[key][MetadataOptions.UPDATED_BY] = updated_by
808
1008
 
809
1009
  def get_peer_ids(self, node_id: str, rel_name: str, direction: RelationshipDirection) -> set[str]:
810
1010
  if (node_id, rel_name) not in self._rel_directions_map:
@@ -821,11 +1021,15 @@ class GroupedPeerNodes:
821
1021
  def has_node(self, node_id: str) -> bool:
822
1022
  return node_id in self._rel_names_by_node_id
823
1023
 
1024
+ def get_metadata_map(
1025
+ self, node_id: str, rel_name: str, direction: RelationshipDirection, peer_id: str
1026
+ ) -> dict[MetadataOptions, Timestamp | str | None]:
1027
+ return self._metadata_map.get((node_id, rel_name, direction, peer_id), {})
1028
+
824
1029
 
825
1030
  class NodeListGetRelationshipsQuery(Query):
826
1031
  name: str = "node_list_get_relationship"
827
1032
  type: QueryType = QueryType.READ
828
- insert_return: bool = False
829
1033
 
830
1034
  def __init__(
831
1035
  self,
@@ -833,14 +1037,80 @@ class NodeListGetRelationshipsQuery(Query):
833
1037
  outbound_identifiers: list[str] | None = None,
834
1038
  inbound_identifiers: list[str] | None = None,
835
1039
  bidirectional_identifiers: list[str] | None = None,
1040
+ include_metadata: MetadataOptions = MetadataOptions.NONE,
836
1041
  **kwargs,
837
- ):
1042
+ ) -> None:
838
1043
  self.ids = ids
839
1044
  self.outbound_identifiers = outbound_identifiers
840
1045
  self.inbound_identifiers = inbound_identifiers
841
1046
  self.bidirectional_identifiers = bidirectional_identifiers
1047
+ self.include_metadata = include_metadata
842
1048
  super().__init__(**kwargs)
843
1049
 
1050
+ def _add_created_metadata_to_query(self) -> None:
1051
+ if not (self.include_metadata & (MetadataOptions.CREATED_AT | MetadataOptions.CREATED_BY)):
1052
+ return
1053
+ if self.branch.is_default or self.branch.is_global:
1054
+ last_created_query = """
1055
+ WITH *, rel.created_at AS created_at, rel.created_by AS created_by
1056
+ """
1057
+ else:
1058
+ last_created_query = """
1059
+ WITH *, CASE
1060
+ WHEN r1.from < r2.from THEN [r1.from, r1.from_user_id]
1061
+ ELSE [r2.from, r2.from_user_id]
1062
+ END AS created_details
1063
+ WITH *, created_details[0] AS created_at, created_details[1] AS created_by
1064
+ """
1065
+ self.add_to_query(last_created_query)
1066
+ self.return_labels.extend(["created_at", "created_by"])
1067
+
1068
+ def _add_updated_metadata_to_query(self, branch_filter_str: str) -> None:
1069
+ if not (self.include_metadata & (MetadataOptions.UPDATED_AT | MetadataOptions.UPDATED_BY)):
1070
+ return
1071
+ if self.branch.is_default or self.branch.is_global:
1072
+ last_updated_query = """
1073
+ WITH *, rel.updated_at AS updated_at, rel.updated_by AS updated_by
1074
+ """
1075
+ else:
1076
+ if self.branch_agnostic:
1077
+ time_details = """
1078
+ WITH [r.from, r.from_user_id] AS from_details, [r.to, r.to_user_id] AS to_details
1079
+ """
1080
+ else:
1081
+ time_details = """
1082
+ WITH CASE
1083
+ WHEN r.branch IN $branch0 AND r.from < $time0 THEN [r.from, r.from_user_id]
1084
+ WHEN r.branch IN $branch1 AND r.from < $time1 THEN [r.from, r.from_user_id]
1085
+ ELSE [NULL, NULL]
1086
+ END AS from_details,
1087
+ CASE
1088
+ WHEN r.branch IN $branch0 AND r.to < $time0 THEN [r.to, r.to_user_id]
1089
+ WHEN r.branch IN $branch1 AND r.to < $time1 THEN [r.to, r.to_user_id]
1090
+ ELSE [NULL, NULL]
1091
+ END AS to_details
1092
+ """
1093
+ last_updated_query = """
1094
+ CALL (rel) {
1095
+ // don't use IS_RELATED edges to handle the case when at least one of the
1096
+ // peers is a migrated-kind node
1097
+ MATCH (rel)-[r:!IS_RELATED]->(property)
1098
+ WHERE %(branch_filter)s
1099
+ %(time_details)s
1100
+ WITH collect(from_details) AS from_details_list, collect(to_details) AS to_details_list
1101
+ WITH from_details_list + to_details_list AS details_list
1102
+ UNWIND details_list AS one_details
1103
+ WITH one_details[0] AS updated_at, one_details[1] AS updated_by
1104
+ WHERE updated_at IS NOT NULL
1105
+ WITH updated_at, updated_by
1106
+ ORDER BY updated_at DESC
1107
+ LIMIT 1
1108
+ RETURN updated_at, updated_by
1109
+ }
1110
+ """ % {"branch_filter": branch_filter_str, "time_details": time_details}
1111
+ self.add_to_query(last_updated_query)
1112
+ self.return_labels.extend(["updated_at", "updated_by"])
1113
+
844
1114
  async def query_init(self, db: InfrahubDatabase, **kwargs) -> None: # noqa: ARG002
845
1115
  self.params["ids"] = self.ids
846
1116
  self.params["outbound_identifiers"] = self.outbound_identifiers
@@ -872,9 +1142,9 @@ class NodeListGetRelationshipsQuery(Query):
872
1142
  LIMIT 1
873
1143
  WITH r1, r AS r2
874
1144
  WHERE r2.status = "active"
875
- RETURN 1 AS is_active
1145
+ RETURN r1, r2
876
1146
  }
877
- RETURN n.uuid AS n_uuid, rel.name AS rel_name, peer.uuid AS peer_uuid, "inbound" as direction
1147
+ RETURN n.uuid AS n_uuid, rel, peer.uuid AS peer_uuid, "inbound" as direction, r1, r2
878
1148
  UNION
879
1149
  WITH n
880
1150
  MATCH (n)-[:IS_RELATED]->(rel:Relationship)-[:IS_RELATED]->(peer)
@@ -896,9 +1166,9 @@ class NodeListGetRelationshipsQuery(Query):
896
1166
  LIMIT 1
897
1167
  WITH r1, r AS r2
898
1168
  WHERE r2.status = "active"
899
- RETURN 1 AS is_active
1169
+ RETURN r1, r2
900
1170
  }
901
- RETURN n.uuid AS n_uuid, rel.name AS rel_name, peer.uuid AS peer_uuid, "outbound" as direction
1171
+ RETURN n.uuid AS n_uuid, rel, peer.uuid AS peer_uuid, "outbound" as direction, r1, r2
902
1172
  UNION
903
1173
  WITH n
904
1174
  MATCH (n)-[:IS_RELATED]->(rel:Relationship)<-[:IS_RELATED]-(peer)
@@ -920,15 +1190,21 @@ class NodeListGetRelationshipsQuery(Query):
920
1190
  LIMIT 1
921
1191
  WITH r1, r AS r2
922
1192
  WHERE r2.status = "active"
923
- RETURN 1 AS is_active
1193
+ RETURN r1, r2
924
1194
  }
925
- RETURN n.uuid AS n_uuid, rel.name AS rel_name, peer.uuid AS peer_uuid, "bidirectional" as direction
1195
+ RETURN n.uuid AS n_uuid, rel, peer.uuid AS peer_uuid, "bidirectional" as direction, r1, r2
926
1196
  }
927
- RETURN DISTINCT n_uuid, rel_name, peer_uuid, direction
928
1197
  """ % {"filters": rels_filter}
929
1198
  self.add_to_query(query)
1199
+
930
1200
  self.order_by = ["n_uuid", "rel_name", "peer_uuid", "direction"]
931
- self.return_labels = ["n_uuid", "rel_name", "peer_uuid", "direction"]
1201
+ self.return_labels = ["n_uuid", "peer_uuid", "direction"]
1202
+
1203
+ self._add_created_metadata_to_query()
1204
+ self._add_updated_metadata_to_query(branch_filter_str=rels_filter)
1205
+ return_labels_str = ", ".join(sorted(self.return_labels))
1206
+ self.add_to_query(f"WITH DISTINCT {return_labels_str}, rel.name AS rel_name")
1207
+ self.return_labels.append("rel_name")
932
1208
 
933
1209
  def get_peers_group_by_node(self) -> GroupedPeerNodes:
934
1210
  gpn = GroupedPeerNodes()
@@ -937,12 +1213,40 @@ class NodeListGetRelationshipsQuery(Query):
937
1213
  rel_name = result.get("rel_name")
938
1214
  peer_id = result.get("peer_uuid")
939
1215
  direction = str(result.get("direction"))
1216
+
1217
+ created_at = None
1218
+ if self.include_metadata & MetadataOptions.CREATED_AT:
1219
+ created_at_str = result.get("created_at")
1220
+ created_at = Timestamp(created_at_str) if created_at_str else None
1221
+
1222
+ created_by_str = None
1223
+ if self.include_metadata & MetadataOptions.CREATED_BY:
1224
+ created_by_str = result.get("created_by")
1225
+
1226
+ updated_at = None
1227
+ if self.include_metadata & MetadataOptions.UPDATED_AT:
1228
+ updated_at_str = result.get("updated_at")
1229
+ updated_at = Timestamp(updated_at_str) if updated_at_str else None
1230
+
1231
+ updated_by_str = None
1232
+ if self.include_metadata & MetadataOptions.UPDATED_BY:
1233
+ updated_by_str = result.get("updated_by")
1234
+
940
1235
  direction_enum = {
941
1236
  "inbound": RelationshipDirection.INBOUND,
942
1237
  "outbound": RelationshipDirection.OUTBOUND,
943
1238
  "bidirectional": RelationshipDirection.BIDIR,
944
1239
  }.get(direction)
945
- gpn.add_peer(node_id=node_id, rel_name=rel_name, peer_id=peer_id, direction=direction_enum)
1240
+ gpn.add_peer(
1241
+ node_id=node_id,
1242
+ rel_name=rel_name,
1243
+ peer_id=peer_id,
1244
+ direction=direction_enum,
1245
+ created_at=created_at,
1246
+ created_by=created_by_str,
1247
+ updated_at=updated_at,
1248
+ updated_by=updated_by_str,
1249
+ )
946
1250
 
947
1251
  return gpn
948
1252
 
@@ -986,11 +1290,81 @@ class NodeListGetInfoQuery(Query):
986
1290
  name = "node_list_get_info"
987
1291
  type = QueryType.READ
988
1292
 
989
- def __init__(self, ids: list[str], account=None, **kwargs: Any) -> None:
990
- self.account = account
1293
+ def __init__(self, ids: list[str], include_metadata: MetadataOptions = MetadataOptions.NONE, **kwargs: Any) -> None:
991
1294
  self.ids = ids
1295
+ self.include_metadata = include_metadata
992
1296
  super().__init__(**kwargs)
993
1297
 
1298
+ def _needs_user_timestamp_metadata(self) -> bool:
1299
+ return bool(self.include_metadata & MetadataOptions.USER_TIMESTAMPS)
1300
+
1301
+ def _add_created_metadata_to_query(self, branch_filter_str: str) -> None:
1302
+ if self.branch.is_default or self.branch.is_global:
1303
+ created_metadata_query = """
1304
+ WITH *, n.created_at AS created_at, n.created_by AS created_by
1305
+ """
1306
+ else:
1307
+ created_metadata_query = """
1308
+ CALL (n) {
1309
+ MATCH (:Node {uuid: n.uuid})-[r:IS_PART_OF {status: "active"}]->(:Root)
1310
+ WHERE %(branch_filter)s
1311
+ RETURN r.from AS created_at, r.from_user_id AS created_by
1312
+ ORDER BY r.from ASC
1313
+ LIMIT 1
1314
+ }
1315
+ """ % {"branch_filter": branch_filter_str}
1316
+ self.add_to_query(created_metadata_query)
1317
+ self.return_labels.extend(["created_at", "created_by"])
1318
+
1319
+ def _add_updated_metadata_to_query(self, branch_filter_str: str) -> None:
1320
+ if self.branch.is_default or self.branch.is_global:
1321
+ last_update_query = """
1322
+ WITH *, n.updated_at AS updated_at, n.updated_by AS updated_by
1323
+ """
1324
+ else:
1325
+ if self.branch_agnostic:
1326
+ time_details = """
1327
+ WITH [r.from, r.from_user_id] AS from_details, [r.to, r.to_user_id] AS to_details
1328
+ """
1329
+ else:
1330
+ time_details = """
1331
+ WITH CASE
1332
+ WHEN r.branch IN $branch0 AND r.from < $time0 THEN [r.from, r.from_user_id]
1333
+ WHEN r.branch IN $branch1 AND r.from < $time1 THEN [r.from, r.from_user_id]
1334
+ ELSE [NULL, NULL]
1335
+ END AS from_details,
1336
+ CASE
1337
+ WHEN r.branch IN $branch0 AND r.to < $time0 THEN [r.to, r.to_user_id]
1338
+ WHEN r.branch IN $branch1 AND r.to < $time1 THEN [r.to, r.to_user_id]
1339
+ ELSE [NULL, NULL]
1340
+ END AS to_details
1341
+ """
1342
+ last_update_query = """
1343
+ MATCH (n)-[r:HAS_ATTRIBUTE|IS_RELATED]-(field:Attribute|Relationship)
1344
+ WHERE %(branch_filter)s
1345
+ WITH DISTINCT n, r_is_part_of, field
1346
+ CALL (field) {
1347
+ MATCH (field)-[r]-(property)
1348
+ WHERE %(branch_filter)s
1349
+ %(time_details)s
1350
+ WITH collect(from_details) AS from_details_list, collect(to_details) AS to_details_list
1351
+ WITH from_details_list + to_details_list AS details_list
1352
+ UNWIND details_list AS one_details
1353
+ WITH one_details[0] AS updated_at, one_details[1] AS updated_by
1354
+ WHERE updated_at IS NOT NULL
1355
+ WITH updated_at, updated_by
1356
+ ORDER BY updated_at DESC
1357
+ LIMIT 1
1358
+ RETURN updated_at, updated_by
1359
+ }
1360
+ WITH n, r_is_part_of, updated_at, updated_by
1361
+ // updated_by ordering preferences non "__system__" users
1362
+ ORDER BY elementId(n), updated_at DESC, updated_by DESC
1363
+ WITH n, r_is_part_of, head(collect(updated_at)) AS updated_at, head(collect(updated_by)) AS updated_by
1364
+ """ % {"branch_filter": branch_filter_str, "time_details": time_details}
1365
+ self.add_to_query(last_update_query)
1366
+ self.return_labels.extend(["updated_at", "updated_by"])
1367
+
994
1368
  async def query_init(self, db: InfrahubDatabase, **kwargs: Any) -> None: # noqa: ARG002
995
1369
  branch_filter, branch_params = self.branch.get_query_filter_path(
996
1370
  at=self.at, branch_agnostic=self.branch_agnostic
@@ -1005,36 +1379,115 @@ class NodeListGetInfoQuery(Query):
1005
1379
  CALL (root, n) {
1006
1380
  MATCH (root:Root)<-[r:IS_PART_OF]-(n:Node)
1007
1381
  WHERE %(branch_filter)s
1008
- RETURN n as n1, r as r1
1009
- ORDER BY r.branch_level DESC, r.from DESC
1382
+ RETURN r AS r_is_part_of
1383
+ ORDER BY r.branch_level DESC, r.from DESC, r.status ASC
1010
1384
  LIMIT 1
1011
1385
  }
1012
- WITH n1 as n, r1 as rb
1013
- WHERE rb.status = "active"
1386
+ WITH n, r_is_part_of
1387
+ WHERE r_is_part_of.status = "active"
1014
1388
  """ % {"branch_filter": branch_filter}
1015
-
1016
1389
  self.add_to_query(query)
1390
+ self.return_labels = [
1391
+ "labels(n) AS node_labels",
1392
+ "r_is_part_of.branch AS branch",
1393
+ "elementId(n) AS node_database_id",
1394
+ "n.uuid AS node_uuid",
1395
+ ]
1017
1396
 
1018
- self.return_labels = ["n", "rb"]
1397
+ if self._needs_user_timestamp_metadata():
1398
+ self._add_updated_metadata_to_query(branch_filter_str=branch_filter)
1399
+ self._add_created_metadata_to_query(branch_filter_str=branch_filter)
1019
1400
 
1020
1401
  async def get_nodes(self, db: InfrahubDatabase, duplicate: bool = False) -> AsyncIterator[NodeToProcess]:
1021
1402
  """Return all the node objects as NodeToProcess."""
1022
1403
 
1023
- for result in self.get_results_group_by(("n", "uuid")):
1024
- schema = find_node_schema(db=db, node=result.get_node("n"), branch=self.branch, duplicate=duplicate)
1404
+ for result in self.get_results():
1405
+ raw_labels: list[str] = result.get_as_type(label="node_labels", return_type=list)
1406
+ labels = [str(lbl) for lbl in raw_labels]
1407
+ schema = find_node_schema(db=db, branch=self.branch, labels=labels, duplicate=duplicate)
1025
1408
  node_branch = self.branch
1026
1409
  if self.branch_agnostic:
1027
- node_branch = result.get_rel("rb").get("branch")
1410
+ node_branch = result.get_as_type(label="branch", return_type=str)
1411
+
1412
+ created_at = None
1413
+ created_by = None
1414
+ if self.include_metadata & (MetadataOptions.CREATED_AT | MetadataOptions.UPDATED_AT):
1415
+ raw_created_at = result.get_as_str(label="created_at")
1416
+ created_at = Timestamp(raw_created_at) if raw_created_at else None
1417
+ if self.include_metadata & (MetadataOptions.CREATED_BY | MetadataOptions.UPDATED_BY):
1418
+ created_by = result.get_as_str(label="created_by")
1419
+ updated_at = None
1420
+ updated_by = None
1421
+ if self.include_metadata & MetadataOptions.UPDATED_AT:
1422
+ raw_updated_at = result.get_as_str(label="updated_at")
1423
+ updated_at = Timestamp(raw_updated_at) if raw_updated_at else None
1424
+ if self.include_metadata & MetadataOptions.UPDATED_BY:
1425
+ updated_by = result.get_as_str(label="updated_by")
1426
+
1028
1427
  yield NodeToProcess(
1029
1428
  schema=schema,
1030
- node_id=result.get_node("n").element_id,
1031
- node_uuid=result.get_node("n").get("uuid"),
1032
- updated_at=result.get_rel("rb").get("from"),
1429
+ node_id=result.get_as_type(label="node_database_id", return_type=str),
1430
+ node_uuid=result.get_as_type(label="node_uuid", return_type=str),
1033
1431
  branch=node_branch,
1034
- labels=list(result.get_node("n").labels),
1432
+ labels=labels,
1433
+ created_at=created_at,
1434
+ created_by=created_by,
1435
+ updated_at=updated_at or created_at,
1436
+ updated_by=updated_by or created_by,
1035
1437
  )
1036
1438
 
1037
1439
 
1440
+ class NodeGetByHFIDQuery(Query):
1441
+ """Query to lookup nodes by their HFID.
1442
+
1443
+ This query uses the stored `human_friendly_id` attribute on nodes.
1444
+ """
1445
+
1446
+ name = "node_get_by_hfid"
1447
+ type = QueryType.READ
1448
+
1449
+ def __init__(self, node_kind: str, hfids: list[list[str]], **kwargs: Any) -> None:
1450
+ self.node_kind = node_kind
1451
+ self.hfids = hfids
1452
+ super().__init__(**kwargs)
1453
+
1454
+ async def query_init(self, db: InfrahubDatabase, **kwargs: Any) -> None: # noqa: ARG002
1455
+ branch_filter, branch_params = self.branch.get_query_filter_path(at=self.at)
1456
+ self.params.update(branch_params)
1457
+ # The list is stored as a string in the database
1458
+ self.params["hfid_values"] = [ujson.dumps(hfid) for hfid in self.hfids]
1459
+
1460
+ query = """
1461
+ MATCH (n:%(node_kind)s)
1462
+ CALL (n) {
1463
+ MATCH (n)-[r:IS_PART_OF]->(:Root)
1464
+ WHERE %(branch_filter)s
1465
+ RETURN r AS r_part_of
1466
+ ORDER BY r.branch_level DESC, r.from DESC, r.status ASC
1467
+ LIMIT 1
1468
+ }
1469
+ WITH n, r_part_of
1470
+ WHERE r_part_of.status = "active"
1471
+ MATCH (n)-[:HAS_ATTRIBUTE]->(attr:Attribute {name: "human_friendly_id"})
1472
+ CALL (attr) {
1473
+ MATCH (attr)-[r:HAS_VALUE]->(av)
1474
+ WHERE %(branch_filter)s
1475
+ RETURN av, r AS r_attr
1476
+ ORDER BY r.branch_level DESC, r.from DESC, r.status ASC
1477
+ LIMIT 1
1478
+ }
1479
+ WITH n, av, r_attr
1480
+ WHERE r_attr.status = "active" AND av.value IN $hfid_values
1481
+ """ % {"branch_filter": branch_filter, "node_kind": self.node_kind}
1482
+
1483
+ self.add_to_query(query)
1484
+ self.return_labels = ["n.uuid AS node_uuid", "av.value AS hfid"]
1485
+
1486
+ def get_node_uuids(self) -> list[str]:
1487
+ """Get the list of node UUIDs from the query results."""
1488
+ return [result.get_as_type(label="node_uuid", return_type=str) for result in self.get_results()]
1489
+
1490
+
1038
1491
  class FieldAttributeRequirementType(Enum):
1039
1492
  FILTER = "filter"
1040
1493
  ORDER = "order"
@@ -1048,6 +1501,9 @@ class FieldAttributeRequirement:
1048
1501
  field_attr_value: Any
1049
1502
  index: int
1050
1503
  types: list[FieldAttributeRequirementType] = dataclass_field(default_factory=list)
1504
+ order_direction: OrderDirection | None = None
1505
+ # created_at, updated_at, created_by, updated_by
1506
+ is_metadata: bool = False
1051
1507
 
1052
1508
  @property
1053
1509
  def is_attribute_value(self) -> bool:
@@ -1061,6 +1517,14 @@ class FieldAttributeRequirement:
1061
1517
  def is_order(self) -> bool:
1062
1518
  return FieldAttributeRequirementType.ORDER in self.types
1063
1519
 
1520
+ @property
1521
+ def is_metadata_order(self) -> bool:
1522
+ return self.is_metadata and FieldAttributeRequirementType.ORDER in self.types
1523
+
1524
+ @property
1525
+ def is_metadata_filter(self) -> bool:
1526
+ return self.is_metadata and FieldAttributeRequirementType.FILTER in self.types
1527
+
1064
1528
  @property
1065
1529
  def node_value_query_variable(self) -> str:
1066
1530
  return f"attr{self.index}_node_value"
@@ -1069,8 +1533,12 @@ class FieldAttributeRequirement:
1069
1533
  def comparison_operator(self) -> str:
1070
1534
  if self.field_attr_name == "isnull":
1071
1535
  return "=" if self.field_attr_value is True else "<>"
1072
- if self.field_attr_name == "values":
1536
+ if self.field_attr_name in ("values", "ids"):
1073
1537
  return "IN"
1538
+ if self.field_attr_name == "before":
1539
+ return "<"
1540
+ if self.field_attr_name == "after":
1541
+ return ">"
1074
1542
  return "="
1075
1543
 
1076
1544
  @property
@@ -1106,7 +1574,7 @@ class NodeGetListQuery(Query):
1106
1574
  order = copy(order)
1107
1575
  order.disable = True
1108
1576
 
1109
- self.order = order
1577
+ self.requested_order = order
1110
1578
 
1111
1579
  super().__init__(**kwargs)
1112
1580
 
@@ -1122,6 +1590,25 @@ class NodeGetListQuery(Query):
1122
1590
  return True
1123
1591
  return False
1124
1592
 
1593
+ def _get_metadata_order_fields(self) -> list[tuple[str, OrderDirection]]:
1594
+ """Return the metadata field and direction to order by, or None."""
1595
+ if not self.requested_order or not self.requested_order.node_metadata:
1596
+ return []
1597
+ fields: list[tuple[str, OrderDirection]] = []
1598
+ nm = self.requested_order.node_metadata
1599
+ if nm.created_at:
1600
+ fields.append((METADATA_CREATED_AT, nm.created_at))
1601
+ if nm.updated_at:
1602
+ fields.append((METADATA_UPDATED_AT, nm.updated_at))
1603
+ return fields
1604
+
1605
+ @property
1606
+ def _has_metadata_filters(self) -> bool:
1607
+ """Check if any metadata filters are requested."""
1608
+ if not self.filters:
1609
+ return False
1610
+ return any(key.startswith(NODE_METADATA_PREFIX) for key in self.filters)
1611
+
1125
1612
  def _validate_filters(self) -> None:
1126
1613
  if not self.filters:
1127
1614
  return
@@ -1146,6 +1633,130 @@ class NodeGetListQuery(Query):
1146
1633
  def _get_tracked_variables(self) -> list[str]:
1147
1634
  return self._variables_to_track
1148
1635
 
1636
+ def _add_created_metadata_subquery(self, branch_filter: str) -> None:
1637
+ """Add subquery to extract both created_at and created_by metadata.
1638
+
1639
+ Returns both values since they come from the same source (node properties or IS_PART_OF relationship).
1640
+ This subquery can be used for both filtering and ordering.
1641
+ """
1642
+ tracked_vars = ", ".join(self._get_tracked_variables())
1643
+
1644
+ if self.branch.is_default or self.branch.is_global:
1645
+ created_query = f"WITH {tracked_vars}, n.created_at AS created_at, n.created_by AS created_by"
1646
+ else:
1647
+ created_query = """
1648
+ CALL (n) {
1649
+ MATCH (:Node {uuid: n.uuid})-[r:IS_PART_OF {status: "active"}]->(:Root)
1650
+ WHERE %(branch_filter)s
1651
+ RETURN r.from AS created_at, r.from_user_id AS created_by
1652
+ ORDER BY r.from ASC
1653
+ LIMIT 1
1654
+ }
1655
+ WITH %(tracked_vars)s, created_at, created_by
1656
+ """ % {"branch_filter": branch_filter, "tracked_vars": tracked_vars}
1657
+
1658
+ self.add_to_query(created_query)
1659
+ self._track_variable("created_at")
1660
+ self._track_variable("created_by")
1661
+
1662
+ def _add_updated_metadata_subquery(self, branch_filter: str) -> None:
1663
+ """Add subquery to extract both updated_at and updated_by metadata.
1664
+
1665
+ Returns both values since they come from the same source (node properties or attribute/relationship traversal).
1666
+ This subquery can be used for both filtering and ordering.
1667
+ """
1668
+ tracked_vars = ", ".join(self._get_tracked_variables())
1669
+
1670
+ if self.branch.is_default or self.branch.is_global:
1671
+ updated_query = f"WITH {tracked_vars}, n.updated_at AS updated_at, n.updated_by AS updated_by"
1672
+ else:
1673
+ if self.branch_agnostic:
1674
+ time_details = """
1675
+ WITH [r.from, r.from_user_id] AS from_details, [r.to, r.to_user_id] AS to_details
1676
+ """
1677
+ else:
1678
+ time_details = """
1679
+ WITH CASE
1680
+ WHEN r.branch IN $branch0 AND r.from < $time0 THEN [r.from, r.from_user_id]
1681
+ WHEN r.branch IN $branch1 AND r.from < $time1 THEN [r.from, r.from_user_id]
1682
+ ELSE [NULL, NULL]
1683
+ END AS from_details,
1684
+ CASE
1685
+ WHEN r.branch IN $branch0 AND r.to < $time0 THEN [r.to, r.to_user_id]
1686
+ WHEN r.branch IN $branch1 AND r.to < $time1 THEN [r.to, r.to_user_id]
1687
+ ELSE [NULL, NULL]
1688
+ END AS to_details
1689
+ """
1690
+
1691
+ updated_query = """
1692
+ MATCH (n)-[r:HAS_ATTRIBUTE|IS_RELATED]-(field:Attribute|Relationship)
1693
+ WHERE %(branch_filter)s
1694
+ WITH DISTINCT %(tracked_vars)s, field
1695
+ CALL (field) {
1696
+ MATCH (field)-[r]-(property)
1697
+ WHERE %(branch_filter)s
1698
+ %(time_details)s
1699
+ WITH collect(from_details) AS from_details_list, collect(to_details) AS to_details_list
1700
+ WITH from_details_list + to_details_list AS details_list
1701
+ UNWIND details_list AS one_details
1702
+ WITH one_details[0] AS updated_at_val, one_details[1] AS updated_by_val
1703
+ WHERE updated_at_val IS NOT NULL
1704
+ ORDER BY updated_at_val DESC
1705
+ LIMIT 1
1706
+ RETURN updated_at_val, updated_by_val
1707
+ }
1708
+ WITH %(tracked_vars)s, updated_at_val, updated_by_val
1709
+ ORDER BY elementId(n), updated_at_val DESC
1710
+ WITH %(tracked_vars)s,
1711
+ head(collect(updated_at_val)) AS updated_at,
1712
+ head(collect(updated_by_val)) AS updated_by
1713
+ """ % {"branch_filter": branch_filter, "time_details": time_details, "tracked_vars": tracked_vars}
1714
+
1715
+ self.add_to_query(updated_query)
1716
+ self._track_variable("updated_at")
1717
+ self._track_variable("updated_by")
1718
+
1719
+ def _add_metadata_subqueries(
1720
+ self,
1721
+ field_requirements: list[FieldAttributeRequirement],
1722
+ branch_filter: str,
1723
+ ) -> None:
1724
+ """Add unified subqueries for metadata filtering and ordering.
1725
+
1726
+ Uses a single subquery per metadata type (created or updated) that returns both
1727
+ _at and _by values, since they come from the same source. This is more efficient
1728
+ than separate subqueries for filtering and ordering.
1729
+ """
1730
+ # Configuration for each metadata type: (allowed_fields, at_field, by_field, subquery_method)
1731
+ metadata_configs = [
1732
+ (METADATA_CREATED_FIELDS, METADATA_CREATED_AT, METADATA_CREATED_BY, self._add_created_metadata_subquery),
1733
+ (METADATA_UPDATED_FIELDS, METADATA_UPDATED_AT, METADATA_UPDATED_BY, self._add_updated_metadata_subquery),
1734
+ ]
1735
+
1736
+ for allowed_fields, at_field, by_field, add_subquery in metadata_configs:
1737
+ requirements = [far for far in field_requirements if far.is_metadata and far.field_name in allowed_fields]
1738
+ if not requirements:
1739
+ continue
1740
+
1741
+ add_subquery(branch_filter)
1742
+
1743
+ is_first_filter = True
1744
+ for far in requirements:
1745
+ field = at_field if far.field_name == at_field else by_field
1746
+
1747
+ if far.is_metadata_filter:
1748
+ param_name = f"metadata_filter_{far.field_name}_{far.field_attr_name}_{far.index}"
1749
+ if is_first_filter:
1750
+ self.add_to_query(f"WHERE {field} {far.comparison_operator} ${param_name}")
1751
+ is_first_filter = False
1752
+ else:
1753
+ self.add_to_query(f"AND {field} {far.comparison_operator} ${param_name}")
1754
+ self.params[param_name] = far.field_attr_value
1755
+
1756
+ if far.is_metadata_order:
1757
+ direction = far.order_direction or OrderDirection.ASC
1758
+ self.order_by.append(f"{field} {direction.value}")
1759
+
1149
1760
  async def query_init(self, db: InfrahubDatabase, **kwargs: Any) -> None: # noqa: ARG002
1150
1761
  self.order_by = []
1151
1762
 
@@ -1187,8 +1798,20 @@ class NodeGetListQuery(Query):
1187
1798
  self.add_to_query(" AND n.uuid = $uuid")
1188
1799
  return
1189
1800
 
1190
- disable_order = not self.schema.order_by or (self.order is not None and self.order.disable)
1191
- if not self.has_filters and disable_order:
1801
+ # Determine ordering behavior
1802
+ disable_order = self.requested_order is not None and self.requested_order.disable
1803
+ has_any_order = bool(self.schema.order_by) or self._get_metadata_order_fields()
1804
+
1805
+ # needs ordering or filter if...
1806
+ needs_order_or_filter = bool(
1807
+ # any filters are set
1808
+ self.has_filters
1809
+ or self._has_metadata_filters
1810
+ # or any ordering is set and ordering is not disabled
1811
+ or (has_any_order and not disable_order)
1812
+ )
1813
+
1814
+ if not needs_order_or_filter:
1192
1815
  # Always order by uuid to guarantee pagination, see https://github.com/opsmill/infrahub/pull/4704.
1193
1816
  self.order_by = ["n.uuid"]
1194
1817
  return
@@ -1197,32 +1820,38 @@ class NodeGetListQuery(Query):
1197
1820
  self.add_to_query("AND n.uuid IN $node_ids")
1198
1821
  self.params["node_ids"] = self.filters["ids"]
1199
1822
 
1823
+ # Get unified field requirements for filtering and ordering
1200
1824
  field_attribute_requirements = self._get_field_requirements(disable_order=disable_order)
1825
+
1826
+ is_default_or_global = self.branch.is_default or self.branch.is_global
1827
+ # Apply metadata subqueries first if default/global branch b/c they will be fast
1828
+ # Uses single subquery per metadata type for both filtering and ordering
1829
+ if is_default_or_global:
1830
+ self._add_metadata_subqueries(field_requirements=field_attribute_requirements, branch_filter=branch_filter)
1831
+
1832
+ # Apply regular attribute/relationship filter subqueries
1201
1833
  await self._add_node_filter_attributes(
1202
1834
  db=db, field_attribute_requirements=field_attribute_requirements, branch_filter=branch_filter
1203
1835
  )
1204
1836
 
1205
- if not disable_order:
1206
- await self._add_node_order_attributes(
1207
- db=db, field_attribute_requirements=field_attribute_requirements, branch_filter=branch_filter
1208
- )
1209
- for far in field_attribute_requirements:
1210
- if not far.is_order:
1211
- continue
1212
- self.order_by.append(far.node_value_query_variable)
1837
+ # Apply metadata subqueries last if not default/global branch b/c they will be slow
1838
+ if not is_default_or_global:
1839
+ self._add_metadata_subqueries(field_requirements=field_attribute_requirements, branch_filter=branch_filter)
1840
+
1841
+ # Apply order subqueries for non-metadata attributes (metadata ordering handled by _add_metadata_subqueries)
1842
+ await self._add_node_order_attributes(
1843
+ db=db, field_requirements=field_attribute_requirements, branch_filter=branch_filter
1844
+ )
1213
1845
 
1214
1846
  # Always order by uuid to guarantee pagination, see https://github.com/opsmill/infrahub/pull/4704.
1215
1847
  self.order_by.append("n.uuid")
1216
1848
 
1217
- self._add_final_filter(field_attribute_requirements=field_attribute_requirements)
1218
-
1219
1849
  async def _add_node_filter_attributes(
1220
1850
  self,
1221
1851
  db: InfrahubDatabase,
1222
1852
  field_attribute_requirements: list[FieldAttributeRequirement],
1223
1853
  branch_filter: str,
1224
1854
  ) -> None:
1225
- field_attribute_requirements = [far for far in field_attribute_requirements if far.is_filter]
1226
1855
  if not field_attribute_requirements:
1227
1856
  return
1228
1857
 
@@ -1230,6 +1859,10 @@ class NodeGetListQuery(Query):
1230
1859
  filter_params: dict[str, Any] = {}
1231
1860
 
1232
1861
  for far in field_attribute_requirements:
1862
+ # Only process non-metadata filters; metadata filters are handled by _add_metadata_subqueries
1863
+ if not far.is_filter or far.is_metadata:
1864
+ continue
1865
+
1233
1866
  extra_tail_properties = {far.node_value_query_variable: "value"}
1234
1867
  subquery, subquery_params, subquery_result_name = await build_subquery_filter(
1235
1868
  db=db,
@@ -1258,6 +1891,11 @@ class NodeGetListQuery(Query):
1258
1891
  filter_query.append("}")
1259
1892
  filter_query.append(f"WITH {with_str}")
1260
1893
 
1894
+ # Add WHERE clause immediately after the filter subquery for better performance
1895
+ where_clause = self._build_filter_where_clause(far)
1896
+ if where_clause:
1897
+ filter_query.append(where_clause)
1898
+
1261
1899
  if filter_query:
1262
1900
  self.add_to_query(filter_query)
1263
1901
  self.params.update(filter_params)
@@ -1265,22 +1903,28 @@ class NodeGetListQuery(Query):
1265
1903
  async def _add_node_order_attributes(
1266
1904
  self,
1267
1905
  db: InfrahubDatabase,
1268
- field_attribute_requirements: list[FieldAttributeRequirement],
1906
+ field_requirements: list[FieldAttributeRequirement],
1269
1907
  branch_filter: str,
1270
1908
  ) -> None:
1271
- field_attribute_requirements = [
1272
- far for far in field_attribute_requirements if far.is_order and not far.is_filter
1273
- ]
1274
- if not field_attribute_requirements:
1275
- return
1909
+ """Add ordering subqueries for schema attributes.
1276
1910
 
1277
- sort_query: list[str] = []
1278
- sort_params: dict[str, Any] = {}
1911
+ Note: Metadata ordering (created_at, updated_at) is handled by _add_metadata_subqueries.
1912
+ """
1913
+ for far in field_requirements:
1914
+ # Skip metadata ordering - handled by _add_metadata_subqueries
1915
+ if far.is_metadata:
1916
+ continue
1279
1917
 
1280
- for far in field_attribute_requirements:
1918
+ # Handle schema attribute ordering
1281
1919
  if far.field is None:
1282
1920
  continue
1283
1921
 
1922
+ # If this field is also used for filtering, the filter subquery already
1923
+ # extracted the value - just add it to order_by, don't create another subquery
1924
+ if far.is_filter:
1925
+ self.order_by.append(far.node_value_query_variable)
1926
+ continue
1927
+
1284
1928
  subquery, subquery_params, _ = await build_subquery_order(
1285
1929
  db=db,
1286
1930
  field=far.field,
@@ -1294,96 +1938,243 @@ class NodeGetListQuery(Query):
1294
1938
  self._track_variable(far.node_value_query_variable)
1295
1939
  with_str = ", ".join(self._get_tracked_variables())
1296
1940
 
1297
- sort_params.update(subquery_params)
1298
- sort_query.append("CALL (n) {")
1299
- sort_query.append(subquery)
1300
- sort_query.append("}")
1301
- sort_query.append(f"WITH {with_str}")
1941
+ self.params.update(subquery_params)
1942
+ self.add_to_query(["CALL (n) {", subquery, "}", f"WITH {with_str}"])
1943
+ self.order_by.append(far.node_value_query_variable)
1302
1944
 
1303
- if sort_query:
1304
- self.add_to_query(sort_query)
1305
- self.params.update(sort_params)
1945
+ def _build_filter_where_clause(self, far: FieldAttributeRequirement) -> str | None:
1946
+ """Build a WHERE clause for a single filter requirement.
1306
1947
 
1307
- def _add_final_filter(self, field_attribute_requirements: list[FieldAttributeRequirement]) -> None:
1308
- where_parts = []
1309
- where_str = ""
1310
- for far in field_attribute_requirements:
1311
- if not far.is_filter or not far.is_attribute_value:
1312
- continue
1313
- var_name = f"final_attr_value{far.index}"
1314
- self.params[var_name] = far.field_attr_comparison_value
1315
- if self.partial_match:
1316
- if isinstance(far.field_attr_comparison_value, list):
1317
- # If the any filter is an array/list
1318
- var_array = f"{var_name}_array"
1319
- where_parts.append(
1320
- f"any({var_array} IN ${var_name} WHERE toLower(toString({far.node_value_query_variable})) CONTAINS toLower({var_array}))"
1321
- )
1322
- else:
1323
- where_parts.append(
1324
- f"toLower(toString({far.node_value_query_variable})) CONTAINS toLower(toString(${var_name}))"
1948
+ Returns the WHERE clause string, or None if no clause is needed.
1949
+ """
1950
+ if not far.is_filter or not far.is_attribute_value:
1951
+ return None
1952
+
1953
+ var_name = f"final_attr_value{far.index}"
1954
+ self.params[var_name] = far.field_attr_comparison_value
1955
+
1956
+ if self.partial_match:
1957
+ if isinstance(far.field_attr_comparison_value, list):
1958
+ # If the any filter is an array/list
1959
+ var_array = f"{var_name}_array"
1960
+ return f"WHERE any({var_array} IN ${var_name} WHERE toLower(toString({far.node_value_query_variable})) CONTAINS toLower({var_array}))"
1961
+ return f"WHERE toLower(toString({far.node_value_query_variable})) CONTAINS toLower(toString(${var_name}))"
1962
+
1963
+ if far.field and isinstance(far.field, AttributeSchema) and far.field.kind == "List":
1964
+ if isinstance(far.field_attr_comparison_value, list):
1965
+ self.params[var_name] = build_regex_attrs(values=far.field_attr_comparison_value)
1966
+ else:
1967
+ self.params[var_name] = build_regex_attrs(values=[far.field_attr_comparison_value])
1968
+ return f"WHERE toString({far.node_value_query_variable}) =~ ${var_name}"
1969
+
1970
+ return f"WHERE {far.node_value_query_variable} {far.comparison_operator} ${var_name}"
1971
+
1972
+ def _get_metadata_field_details(self, filter_key: str) -> tuple[str, str] | None:
1973
+ """Parse a metadata filter key into field name and operator.
1974
+
1975
+ Args:
1976
+ filter_key: Filter key like "node_metadata__created_at__before"
1977
+
1978
+ Returns:
1979
+ Tuple of (field_name, operator) like ("created_at", "before"), or None if not a metadata filter.
1980
+ """
1981
+ if not filter_key.startswith(NODE_METADATA_PREFIX):
1982
+ return None
1983
+ parts = filter_key.split("__")
1984
+ metadata_field_name = parts[1] # created_at, updated_at, created_by, updated_by
1985
+ metadata_operator = parts[2] if len(parts) > 2 else "value" # value, before, after, ids
1986
+ return metadata_field_name, metadata_operator
1987
+
1988
+ def _build_metadata_filter_requirement(
1989
+ self,
1990
+ field_name: str,
1991
+ operator: str,
1992
+ value: Any,
1993
+ index: int,
1994
+ ) -> FieldAttributeRequirement:
1995
+ """Build a FieldAttributeRequirement for a metadata filter."""
1996
+ if isinstance(value, datetime):
1997
+ timestamp = Timestamp(ZonedDateTime.from_py_datetime(value))
1998
+ value = timestamp.to_string()
1999
+ return FieldAttributeRequirement(
2000
+ field_name=field_name,
2001
+ field=None,
2002
+ field_attr_name=operator,
2003
+ field_attr_value=value,
2004
+ index=index,
2005
+ types=[FieldAttributeRequirementType.FILTER],
2006
+ is_metadata=True,
2007
+ )
2008
+
2009
+ def _build_attribute_filter_requirement(
2010
+ self,
2011
+ field_name: str,
2012
+ field: AttributeSchema | RelationshipSchema | None,
2013
+ attr_name: str,
2014
+ attr_value: Any,
2015
+ index: int,
2016
+ ) -> FieldAttributeRequirement:
2017
+ """Build a FieldAttributeRequirement for an attribute/relationship filter."""
2018
+ return FieldAttributeRequirement(
2019
+ field_name=field_name,
2020
+ field=field,
2021
+ field_attr_name=attr_name,
2022
+ field_attr_value=attr_value.value if isinstance(attr_value, Enum) else attr_value,
2023
+ index=index,
2024
+ types=[FieldAttributeRequirementType.FILTER],
2025
+ )
2026
+
2027
+ def _get_filter_requirements(self, start_index: int) -> list[FieldAttributeRequirement]:
2028
+ """Build filter requirements from self.filters.
2029
+
2030
+ Processes both metadata and attribute/relationship filters in a single pass.
2031
+ Returns list of FieldAttributeRequirement objects.
2032
+ """
2033
+ if not self.filters:
2034
+ return []
2035
+
2036
+ requirements: list[FieldAttributeRequirement] = []
2037
+ internal_filters = ["any", "attribute", "relationship"]
2038
+ processed_fields: set[str] = set()
2039
+ index = start_index
2040
+
2041
+ for filter_key in self.filters:
2042
+ # Check if this is a metadata filter
2043
+ metadata_details = self._get_metadata_field_details(filter_key)
2044
+ if metadata_details:
2045
+ field_name, operator = metadata_details
2046
+ requirements.append(
2047
+ self._build_metadata_filter_requirement(
2048
+ field_name=field_name,
2049
+ operator=operator,
2050
+ value=self.filters[filter_key],
2051
+ index=index,
1325
2052
  )
2053
+ )
2054
+ index += 1
1326
2055
  continue
1327
- if far.field and isinstance(far.field, AttributeSchema) and far.field.kind == "List":
1328
- if isinstance(far.field_attr_comparison_value, list):
1329
- self.params[var_name] = build_regex_attrs(values=far.field_attr_comparison_value)
1330
- else:
1331
- self.params[var_name] = build_regex_attrs(values=[far.field_attr_comparison_value])
1332
2056
 
1333
- where_parts.append(f"toString({far.node_value_query_variable}) =~ ${var_name}")
2057
+ # Handle attribute/relationship filter
2058
+ # "height__value" -> "height"
2059
+ field_name = filter_key.split("__", maxsplit=1)[0]
2060
+ if field_name not in self.schema.valid_input_names + internal_filters:
1334
2061
  continue
1335
2062
 
1336
- where_parts.append(f"{far.node_value_query_variable} {far.comparison_operator} ${var_name}")
1337
- if where_parts:
1338
- where_str = "WHERE " + " AND ".join(where_parts)
1339
- self.add_to_query(where_str)
2063
+ # Skip if we've already processed this field (extract_field_filters handles all attrs for a field)
2064
+ if field_name in processed_fields:
2065
+ continue
2066
+ processed_fields.add(field_name)
1340
2067
 
1341
- def _get_field_requirements(self, disable_order: bool) -> list[FieldAttributeRequirement]:
1342
- internal_filters = ["any", "attribute", "relationship"]
1343
- field_requirements_map: dict[tuple[str, str], FieldAttributeRequirement] = {}
1344
- index = 1
1345
- if self.filters:
1346
- for field_name in self.schema.valid_input_names + internal_filters:
1347
- attr_filters = extract_field_filters(field_name=field_name, filters=self.filters)
1348
- if not attr_filters:
1349
- continue
1350
- field = self.schema.get_field(field_name, raise_on_error=False)
1351
- for field_attr_name, field_attr_value in attr_filters.items():
1352
- field_requirements_map[field_name, field_attr_name] = FieldAttributeRequirement(
2068
+ attr_filters = extract_field_filters(field_name=field_name, filters=self.filters)
2069
+ if not attr_filters:
2070
+ continue
2071
+
2072
+ field = self.schema.get_field(field_name, raise_on_error=False)
2073
+ for attr_name, attr_value in attr_filters.items():
2074
+ requirements.append(
2075
+ self._build_attribute_filter_requirement(
1353
2076
  field_name=field_name,
1354
2077
  field=field,
1355
- field_attr_name=field_attr_name,
1356
- field_attr_value=field_attr_value.value
1357
- if isinstance(field_attr_value, Enum)
1358
- else field_attr_value,
2078
+ attr_name=attr_name,
2079
+ attr_value=attr_value,
1359
2080
  index=index,
1360
- types=[FieldAttributeRequirementType.FILTER],
1361
2081
  )
1362
- index += 1
2082
+ )
2083
+ index += 1
1363
2084
 
1364
- if disable_order:
1365
- return list(field_requirements_map.values())
2085
+ return requirements
2086
+
2087
+ def _get_order_requirements(
2088
+ self,
2089
+ filter_requirements: list[FieldAttributeRequirement],
2090
+ start_index: int,
2091
+ ) -> list[FieldAttributeRequirement]:
2092
+ """Build ordering requirements.
2093
+
2094
+ Handles both metadata ordering and schema order_by.
2095
+ May modify existing requirements in filter_requirements to add ORDER type.
2096
+ Returns list of new FieldAttributeRequirement objects for order-only fields.
2097
+ """
2098
+ # Build nested lookup map: field_name -> {field_attr_name -> requirement}
2099
+ requirements_map: dict[str | None, dict[str, FieldAttributeRequirement]] = {}
2100
+ for req in filter_requirements:
2101
+ if req.field_name not in requirements_map:
2102
+ requirements_map[req.field_name] = {}
2103
+ requirements_map[req.field_name][req.field_attr_name] = req
2104
+
2105
+ new_requirements: list[FieldAttributeRequirement] = []
2106
+ index = start_index
2107
+
2108
+ # Add metadata ordering requirements first
2109
+ for metadata_field, direction in self._get_metadata_order_fields():
2110
+ # Check if any filter exists for this metadata field
2111
+ field_reqs = requirements_map.get(metadata_field)
2112
+ existing_req = next(iter(field_reqs.values()), None) if field_reqs else None
2113
+
2114
+ if existing_req:
2115
+ # Field already used for filtering, add ORDER type
2116
+ existing_req.types.append(FieldAttributeRequirementType.ORDER)
2117
+ existing_req.order_direction = direction
2118
+ else:
2119
+ new_requirements.append(
2120
+ FieldAttributeRequirement(
2121
+ field_name=metadata_field,
2122
+ field=None,
2123
+ field_attr_name=metadata_field,
2124
+ field_attr_value=None,
2125
+ index=index,
2126
+ types=[FieldAttributeRequirementType.ORDER],
2127
+ order_direction=direction,
2128
+ is_metadata=True,
2129
+ )
2130
+ )
2131
+ index += 1
1366
2132
 
1367
- for order_by_path in self.schema.order_by:
2133
+ # Add schema order_by requirements
2134
+ for order_by_path in self.schema.order_by or []:
1368
2135
  order_by_field_name, order_by_attr_property_name = order_by_path.split("__", maxsplit=1)
1369
2136
 
1370
2137
  field = self.schema.get_field(order_by_field_name)
1371
- field_req = field_requirements_map.get(
1372
- (order_by_field_name, order_by_attr_property_name),
1373
- FieldAttributeRequirement(
1374
- field_name=order_by_field_name,
1375
- field=field,
1376
- field_attr_name=order_by_attr_property_name,
1377
- field_attr_value=None,
1378
- index=index,
1379
- types=[],
1380
- ),
1381
- )
1382
- field_req.types.append(FieldAttributeRequirementType.ORDER)
1383
- field_requirements_map[order_by_field_name, order_by_attr_property_name] = field_req
1384
- index += 1
2138
+ field_reqs = requirements_map.get(order_by_field_name)
2139
+ existing_req = field_reqs.get(order_by_attr_property_name) if field_reqs else None
2140
+ if existing_req:
2141
+ # Field already used for filtering, add ORDER type
2142
+ existing_req.types.append(FieldAttributeRequirementType.ORDER)
2143
+ existing_req.order_direction = OrderDirection.ASC
2144
+ else:
2145
+ # New field requirement for ordering only
2146
+ new_requirements.append(
2147
+ FieldAttributeRequirement(
2148
+ field_name=order_by_field_name,
2149
+ field=field,
2150
+ field_attr_name=order_by_attr_property_name,
2151
+ field_attr_value=None,
2152
+ index=index,
2153
+ types=[FieldAttributeRequirementType.ORDER],
2154
+ order_direction=OrderDirection.ASC,
2155
+ )
2156
+ )
2157
+ index += 1
2158
+
2159
+ return new_requirements
2160
+
2161
+ def _get_field_requirements(self, disable_order: bool = False) -> list[FieldAttributeRequirement]:
2162
+ """Build unified list of field requirements for filtering and ordering.
2163
+
2164
+ Iterates through filters once, using _get_metadata_field_details to determine
2165
+ whether each filter is metadata or attribute/relationship based.
2166
+ """
2167
+ # Get filter requirements (single pass through self.filters)
2168
+ filter_requirements = self._get_filter_requirements(start_index=1)
2169
+
2170
+ if disable_order:
2171
+ return filter_requirements
2172
+
2173
+ # Get ordering requirements (may modify filter_requirements to add ORDER type)
2174
+ next_index = len(filter_requirements) + 1
2175
+ order_requirements = self._get_order_requirements(filter_requirements, start_index=next_index)
1385
2176
 
1386
- return list(field_requirements_map.values())
2177
+ return filter_requirements + order_requirements
1387
2178
 
1388
2179
  def get_node_ids(self) -> list[str]:
1389
2180
  return [str(result.get("n.uuid")) for result in self.get_results()]