infrahub-server 1.7.0b0__py3-none-any.whl → 1.7.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (177) hide show
  1. infrahub/api/exceptions.py +2 -2
  2. infrahub/api/schema.py +5 -0
  3. infrahub/cli/db.py +54 -24
  4. infrahub/core/account.py +12 -9
  5. infrahub/core/branch/models.py +11 -117
  6. infrahub/core/branch/tasks.py +7 -3
  7. infrahub/core/diff/branch_differ.py +1 -1
  8. infrahub/core/diff/conflict_transferer.py +1 -1
  9. infrahub/core/diff/data_check_synchronizer.py +1 -1
  10. infrahub/core/diff/enricher/cardinality_one.py +1 -1
  11. infrahub/core/diff/enricher/hierarchy.py +1 -1
  12. infrahub/core/diff/enricher/labels.py +1 -1
  13. infrahub/core/diff/merger/merger.py +6 -2
  14. infrahub/core/diff/repository/repository.py +3 -1
  15. infrahub/core/graph/__init__.py +1 -1
  16. infrahub/core/graph/constraints.py +1 -1
  17. infrahub/core/initialization.py +2 -1
  18. infrahub/core/ipam/reconciler.py +8 -6
  19. infrahub/core/ipam/utilization.py +8 -15
  20. infrahub/core/manager.py +1 -26
  21. infrahub/core/merge.py +1 -1
  22. infrahub/core/migrations/graph/__init__.py +2 -0
  23. infrahub/core/migrations/graph/m012_convert_account_generic.py +12 -12
  24. infrahub/core/migrations/graph/m013_convert_git_password_credential.py +4 -4
  25. infrahub/core/migrations/graph/m014_remove_index_attr_value.py +3 -2
  26. infrahub/core/migrations/graph/m015_diff_format_update.py +3 -2
  27. infrahub/core/migrations/graph/m016_diff_delete_bug_fix.py +3 -2
  28. infrahub/core/migrations/graph/m017_add_core_profile.py +6 -4
  29. infrahub/core/migrations/graph/m018_uniqueness_nulls.py +3 -4
  30. infrahub/core/migrations/graph/m020_duplicate_edges.py +3 -3
  31. infrahub/core/migrations/graph/m025_uniqueness_nulls.py +3 -4
  32. infrahub/core/migrations/graph/m026_0000_prefix_fix.py +4 -5
  33. infrahub/core/migrations/graph/m028_delete_diffs.py +3 -2
  34. infrahub/core/migrations/graph/m029_duplicates_cleanup.py +3 -2
  35. infrahub/core/migrations/graph/m031_check_number_attributes.py +4 -3
  36. infrahub/core/migrations/graph/m032_cleanup_orphaned_branch_relationships.py +3 -2
  37. infrahub/core/migrations/graph/m034_find_orphaned_schema_fields.py +3 -2
  38. infrahub/core/migrations/graph/m035_orphan_relationships.py +3 -3
  39. infrahub/core/migrations/graph/m036_drop_attr_value_index.py +3 -2
  40. infrahub/core/migrations/graph/m037_index_attr_vals.py +3 -2
  41. infrahub/core/migrations/graph/m038_redo_0000_prefix_fix.py +4 -5
  42. infrahub/core/migrations/graph/m039_ipam_reconcile.py +3 -2
  43. infrahub/core/migrations/graph/m041_deleted_dup_edges.py +4 -3
  44. infrahub/core/migrations/graph/m042_profile_attrs_in_db.py +5 -4
  45. infrahub/core/migrations/graph/m043_create_hfid_display_label_in_db.py +12 -5
  46. infrahub/core/migrations/graph/m044_backfill_hfid_display_label_in_db.py +15 -4
  47. infrahub/core/migrations/graph/m045_backfill_hfid_display_label_in_db_profile_template.py +10 -4
  48. infrahub/core/migrations/graph/m046_fill_agnostic_hfid_display_labels.py +6 -5
  49. infrahub/core/migrations/graph/m047_backfill_or_null_display_label.py +19 -5
  50. infrahub/core/migrations/graph/m048_undelete_rel_props.py +6 -4
  51. infrahub/core/migrations/graph/m049_remove_is_visible_relationship.py +19 -4
  52. infrahub/core/migrations/graph/m050_backfill_vertex_metadata.py +3 -3
  53. infrahub/core/migrations/graph/m051_subtract_branched_from_microsecond.py +39 -0
  54. infrahub/core/migrations/query/__init__.py +2 -2
  55. infrahub/core/migrations/query/schema_attribute_update.py +1 -1
  56. infrahub/core/migrations/runner.py +6 -3
  57. infrahub/core/migrations/schema/attribute_kind_update.py +8 -11
  58. infrahub/core/migrations/schema/attribute_name_update.py +1 -1
  59. infrahub/core/migrations/schema/attribute_supports_profile.py +5 -10
  60. infrahub/core/migrations/schema/models.py +8 -0
  61. infrahub/core/migrations/schema/node_attribute_add.py +11 -14
  62. infrahub/core/migrations/schema/node_attribute_remove.py +1 -1
  63. infrahub/core/migrations/schema/node_kind_update.py +1 -1
  64. infrahub/core/migrations/schema/tasks.py +7 -1
  65. infrahub/core/migrations/shared.py +37 -30
  66. infrahub/core/node/__init__.py +3 -2
  67. infrahub/core/node/base.py +9 -5
  68. infrahub/core/node/delete_validator.py +1 -1
  69. infrahub/core/order.py +30 -0
  70. infrahub/core/protocols.py +1 -0
  71. infrahub/core/protocols_base.py +4 -0
  72. infrahub/core/query/__init__.py +8 -5
  73. infrahub/core/query/attribute.py +3 -3
  74. infrahub/core/query/branch.py +1 -1
  75. infrahub/core/query/delete.py +1 -1
  76. infrahub/core/query/diff.py +3 -3
  77. infrahub/core/query/ipam.py +104 -43
  78. infrahub/core/query/node.py +454 -101
  79. infrahub/core/query/relationship.py +83 -26
  80. infrahub/core/query/resource_manager.py +107 -18
  81. infrahub/core/relationship/constraints/count.py +1 -1
  82. infrahub/core/relationship/constraints/peer_kind.py +1 -1
  83. infrahub/core/relationship/constraints/peer_parent.py +1 -1
  84. infrahub/core/relationship/constraints/peer_relatives.py +1 -1
  85. infrahub/core/relationship/constraints/profiles_kind.py +1 -1
  86. infrahub/core/relationship/constraints/profiles_removal.py +1 -1
  87. infrahub/core/relationship/model.py +8 -2
  88. infrahub/core/schema/attribute_parameters.py +28 -1
  89. infrahub/core/schema/attribute_schema.py +9 -15
  90. infrahub/core/schema/basenode_schema.py +3 -0
  91. infrahub/core/schema/definitions/core/__init__.py +8 -2
  92. infrahub/core/schema/definitions/core/account.py +10 -10
  93. infrahub/core/schema/definitions/core/artifact.py +14 -8
  94. infrahub/core/schema/definitions/core/check.py +10 -4
  95. infrahub/core/schema/definitions/core/generator.py +26 -6
  96. infrahub/core/schema/definitions/core/graphql_query.py +1 -1
  97. infrahub/core/schema/definitions/core/group.py +9 -2
  98. infrahub/core/schema/definitions/core/ipam.py +80 -10
  99. infrahub/core/schema/definitions/core/menu.py +41 -7
  100. infrahub/core/schema/definitions/core/permission.py +16 -2
  101. infrahub/core/schema/definitions/core/profile.py +16 -2
  102. infrahub/core/schema/definitions/core/propose_change.py +24 -4
  103. infrahub/core/schema/definitions/core/propose_change_comment.py +23 -11
  104. infrahub/core/schema/definitions/core/propose_change_validator.py +50 -21
  105. infrahub/core/schema/definitions/core/repository.py +10 -0
  106. infrahub/core/schema/definitions/core/resource_pool.py +8 -1
  107. infrahub/core/schema/definitions/core/template.py +19 -2
  108. infrahub/core/schema/definitions/core/transform.py +11 -5
  109. infrahub/core/schema/definitions/core/webhook.py +27 -9
  110. infrahub/core/schema/manager.py +50 -38
  111. infrahub/core/schema/schema_branch.py +68 -2
  112. infrahub/core/utils.py +3 -3
  113. infrahub/core/validators/aggregated_checker.py +1 -1
  114. infrahub/core/validators/attribute/choices.py +1 -1
  115. infrahub/core/validators/attribute/enum.py +1 -1
  116. infrahub/core/validators/attribute/kind.py +6 -3
  117. infrahub/core/validators/attribute/length.py +1 -1
  118. infrahub/core/validators/attribute/min_max.py +1 -1
  119. infrahub/core/validators/attribute/number_pool.py +1 -1
  120. infrahub/core/validators/attribute/optional.py +1 -1
  121. infrahub/core/validators/attribute/regex.py +1 -1
  122. infrahub/core/validators/node/attribute.py +1 -1
  123. infrahub/core/validators/node/relationship.py +1 -1
  124. infrahub/core/validators/relationship/peer.py +1 -1
  125. infrahub/database/__init__.py +1 -1
  126. infrahub/git/utils.py +1 -1
  127. infrahub/graphql/app.py +2 -2
  128. infrahub/graphql/field_extractor.py +1 -1
  129. infrahub/graphql/manager.py +17 -3
  130. infrahub/graphql/mutations/account.py +1 -1
  131. infrahub/graphql/order.py +14 -0
  132. infrahub/graphql/queries/diff/tree.py +5 -5
  133. infrahub/graphql/queries/resource_manager.py +25 -24
  134. infrahub/graphql/resolvers/ipam.py +3 -3
  135. infrahub/graphql/resolvers/resolver.py +44 -3
  136. infrahub/graphql/types/standard_node.py +8 -4
  137. infrahub/lock.py +7 -0
  138. infrahub/menu/repository.py +1 -1
  139. infrahub/patch/queries/base.py +1 -1
  140. infrahub/pools/number.py +1 -8
  141. infrahub/profiles/node_applier.py +1 -1
  142. infrahub/profiles/queries/get_profile_data.py +1 -1
  143. infrahub/proposed_change/action_checker.py +1 -1
  144. infrahub/services/__init__.py +1 -1
  145. infrahub/services/adapters/cache/nats.py +1 -1
  146. infrahub/services/adapters/cache/redis.py +7 -0
  147. infrahub/webhook/gather.py +1 -1
  148. infrahub/webhook/tasks.py +22 -6
  149. infrahub_sdk/analyzer.py +2 -2
  150. infrahub_sdk/branch.py +12 -39
  151. infrahub_sdk/checks.py +4 -4
  152. infrahub_sdk/client.py +36 -0
  153. infrahub_sdk/ctl/cli_commands.py +2 -1
  154. infrahub_sdk/ctl/graphql.py +15 -4
  155. infrahub_sdk/ctl/utils.py +2 -2
  156. infrahub_sdk/enums.py +6 -0
  157. infrahub_sdk/graphql/renderers.py +21 -0
  158. infrahub_sdk/graphql/utils.py +85 -0
  159. infrahub_sdk/node/attribute.py +12 -2
  160. infrahub_sdk/node/constants.py +11 -0
  161. infrahub_sdk/node/metadata.py +69 -0
  162. infrahub_sdk/node/node.py +65 -14
  163. infrahub_sdk/node/property.py +3 -0
  164. infrahub_sdk/node/related_node.py +24 -1
  165. infrahub_sdk/node/relationship.py +10 -1
  166. infrahub_sdk/operation.py +2 -2
  167. infrahub_sdk/schema/repository.py +1 -2
  168. infrahub_sdk/transforms.py +2 -2
  169. infrahub_sdk/types.py +18 -2
  170. {infrahub_server-1.7.0b0.dist-info → infrahub_server-1.7.1.dist-info}/METADATA +6 -6
  171. {infrahub_server-1.7.0b0.dist-info → infrahub_server-1.7.1.dist-info}/RECORD +176 -172
  172. {infrahub_server-1.7.0b0.dist-info → infrahub_server-1.7.1.dist-info}/entry_points.txt +0 -1
  173. infrahub_testcontainers/models.py +3 -3
  174. infrahub_testcontainers/performance_test.py +1 -1
  175. infrahub/graphql/models.py +0 -36
  176. {infrahub_server-1.7.0b0.dist-info → infrahub_server-1.7.1.dist-info}/WHEEL +0 -0
  177. {infrahub_server-1.7.0b0.dist-info → infrahub_server-1.7.1.dist-info}/licenses/LICENSE.txt +0 -0
@@ -5,12 +5,15 @@ 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
 
11
12
  import ujson
13
+ from whenever import ZonedDateTime
12
14
 
13
15
  from infrahub import config
16
+ from infrahub.constants.enums import OrderDirection
14
17
  from infrahub.core import registry
15
18
  from infrahub.core.constants import (
16
19
  GLOBAL_BRANCH_NAME,
@@ -21,6 +24,13 @@ from infrahub.core.constants import (
21
24
  RelationshipDirection,
22
25
  RelationshipHierarchyDirection,
23
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
+ )
24
34
  from infrahub.core.query import Query, QueryResult, QueryType
25
35
  from infrahub.core.query.subquery import build_subquery_filter, build_subquery_order
26
36
  from infrahub.core.query.utils import find_node_schema
@@ -28,7 +38,6 @@ from infrahub.core.schema.attribute_schema import AttributeSchema
28
38
  from infrahub.core.timestamp import Timestamp
29
39
  from infrahub.core.utils import build_regex_attrs, extract_field_filters
30
40
  from infrahub.exceptions import QueryError
31
- from infrahub.graphql.models import OrderModel
32
41
 
33
42
  if TYPE_CHECKING:
34
43
  from neo4j.graph import Node as Neo4jNode
@@ -44,6 +53,12 @@ if TYPE_CHECKING:
44
53
  from infrahub.database import InfrahubDatabase
45
54
 
46
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
+
47
62
  @dataclass
48
63
  class NodeToProcess:
49
64
  schema: NodeSchema | ProfileSchema | TemplateSchema | None
@@ -656,7 +671,7 @@ class NodeCheckIDQuery(Query):
656
671
  self,
657
672
  node_id: str,
658
673
  **kwargs,
659
- ):
674
+ ) -> None:
660
675
  self.node_id = node_id
661
676
  super().__init__(**kwargs)
662
677
 
@@ -688,7 +703,7 @@ class NodeListGetAttributeQuery(Query):
688
703
  fields: dict | None = None,
689
704
  include_metadata: MetadataOptions = MetadataOptions.NONE,
690
705
  **kwargs,
691
- ):
706
+ ) -> None:
692
707
  self.ids = ids
693
708
  self.fields = fields
694
709
  self.include_metadata = include_metadata
@@ -954,7 +969,7 @@ CALL (a) {
954
969
 
955
970
 
956
971
  class GroupedPeerNodes:
957
- def __init__(self):
972
+ def __init__(self) -> None:
958
973
  # {node_id: [rel_name, ...]}
959
974
  self._rel_names_by_node_id: dict[str, set[str]] = defaultdict(set)
960
975
  # {(node_id, rel_name): {RelationshipDirection: {peer_id, ...}}}
@@ -1024,7 +1039,7 @@ class NodeListGetRelationshipsQuery(Query):
1024
1039
  bidirectional_identifiers: list[str] | None = None,
1025
1040
  include_metadata: MetadataOptions = MetadataOptions.NONE,
1026
1041
  **kwargs,
1027
- ):
1042
+ ) -> None:
1028
1043
  self.ids = ids
1029
1044
  self.outbound_identifiers = outbound_identifiers
1030
1045
  self.inbound_identifiers = inbound_identifiers
@@ -1486,6 +1501,9 @@ class FieldAttributeRequirement:
1486
1501
  field_attr_value: Any
1487
1502
  index: int
1488
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
1489
1507
 
1490
1508
  @property
1491
1509
  def is_attribute_value(self) -> bool:
@@ -1499,6 +1517,14 @@ class FieldAttributeRequirement:
1499
1517
  def is_order(self) -> bool:
1500
1518
  return FieldAttributeRequirementType.ORDER in self.types
1501
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
+
1502
1528
  @property
1503
1529
  def node_value_query_variable(self) -> str:
1504
1530
  return f"attr{self.index}_node_value"
@@ -1507,8 +1533,12 @@ class FieldAttributeRequirement:
1507
1533
  def comparison_operator(self) -> str:
1508
1534
  if self.field_attr_name == "isnull":
1509
1535
  return "=" if self.field_attr_value is True else "<>"
1510
- if self.field_attr_name == "values":
1536
+ if self.field_attr_name in ("values", "ids"):
1511
1537
  return "IN"
1538
+ if self.field_attr_name == "before":
1539
+ return "<"
1540
+ if self.field_attr_name == "after":
1541
+ return ">"
1512
1542
  return "="
1513
1543
 
1514
1544
  @property
@@ -1544,7 +1574,7 @@ class NodeGetListQuery(Query):
1544
1574
  order = copy(order)
1545
1575
  order.disable = True
1546
1576
 
1547
- self.order = order
1577
+ self.requested_order = order
1548
1578
 
1549
1579
  super().__init__(**kwargs)
1550
1580
 
@@ -1560,6 +1590,25 @@ class NodeGetListQuery(Query):
1560
1590
  return True
1561
1591
  return False
1562
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
+
1563
1612
  def _validate_filters(self) -> None:
1564
1613
  if not self.filters:
1565
1614
  return
@@ -1584,6 +1633,130 @@ class NodeGetListQuery(Query):
1584
1633
  def _get_tracked_variables(self) -> list[str]:
1585
1634
  return self._variables_to_track
1586
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
+
1587
1760
  async def query_init(self, db: InfrahubDatabase, **kwargs: Any) -> None: # noqa: ARG002
1588
1761
  self.order_by = []
1589
1762
 
@@ -1625,8 +1798,20 @@ class NodeGetListQuery(Query):
1625
1798
  self.add_to_query(" AND n.uuid = $uuid")
1626
1799
  return
1627
1800
 
1628
- disable_order = not self.schema.order_by or (self.order is not None and self.order.disable)
1629
- 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:
1630
1815
  # Always order by uuid to guarantee pagination, see https://github.com/opsmill/infrahub/pull/4704.
1631
1816
  self.order_by = ["n.uuid"]
1632
1817
  return
@@ -1635,32 +1820,38 @@ class NodeGetListQuery(Query):
1635
1820
  self.add_to_query("AND n.uuid IN $node_ids")
1636
1821
  self.params["node_ids"] = self.filters["ids"]
1637
1822
 
1823
+ # Get unified field requirements for filtering and ordering
1638
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
1639
1833
  await self._add_node_filter_attributes(
1640
1834
  db=db, field_attribute_requirements=field_attribute_requirements, branch_filter=branch_filter
1641
1835
  )
1642
1836
 
1643
- if not disable_order:
1644
- await self._add_node_order_attributes(
1645
- db=db, field_attribute_requirements=field_attribute_requirements, branch_filter=branch_filter
1646
- )
1647
- for far in field_attribute_requirements:
1648
- if not far.is_order:
1649
- continue
1650
- 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
+ )
1651
1845
 
1652
1846
  # Always order by uuid to guarantee pagination, see https://github.com/opsmill/infrahub/pull/4704.
1653
1847
  self.order_by.append("n.uuid")
1654
1848
 
1655
- self._add_final_filter(field_attribute_requirements=field_attribute_requirements)
1656
-
1657
1849
  async def _add_node_filter_attributes(
1658
1850
  self,
1659
1851
  db: InfrahubDatabase,
1660
1852
  field_attribute_requirements: list[FieldAttributeRequirement],
1661
1853
  branch_filter: str,
1662
1854
  ) -> None:
1663
- field_attribute_requirements = [far for far in field_attribute_requirements if far.is_filter]
1664
1855
  if not field_attribute_requirements:
1665
1856
  return
1666
1857
 
@@ -1668,6 +1859,10 @@ class NodeGetListQuery(Query):
1668
1859
  filter_params: dict[str, Any] = {}
1669
1860
 
1670
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
+
1671
1866
  extra_tail_properties = {far.node_value_query_variable: "value"}
1672
1867
  subquery, subquery_params, subquery_result_name = await build_subquery_filter(
1673
1868
  db=db,
@@ -1696,6 +1891,11 @@ class NodeGetListQuery(Query):
1696
1891
  filter_query.append("}")
1697
1892
  filter_query.append(f"WITH {with_str}")
1698
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
+
1699
1899
  if filter_query:
1700
1900
  self.add_to_query(filter_query)
1701
1901
  self.params.update(filter_params)
@@ -1703,22 +1903,28 @@ class NodeGetListQuery(Query):
1703
1903
  async def _add_node_order_attributes(
1704
1904
  self,
1705
1905
  db: InfrahubDatabase,
1706
- field_attribute_requirements: list[FieldAttributeRequirement],
1906
+ field_requirements: list[FieldAttributeRequirement],
1707
1907
  branch_filter: str,
1708
1908
  ) -> None:
1709
- field_attribute_requirements = [
1710
- far for far in field_attribute_requirements if far.is_order and not far.is_filter
1711
- ]
1712
- if not field_attribute_requirements:
1713
- return
1909
+ """Add ordering subqueries for schema attributes.
1714
1910
 
1715
- sort_query: list[str] = []
1716
- 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
1717
1917
 
1718
- for far in field_attribute_requirements:
1918
+ # Handle schema attribute ordering
1719
1919
  if far.field is None:
1720
1920
  continue
1721
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
+
1722
1928
  subquery, subquery_params, _ = await build_subquery_order(
1723
1929
  db=db,
1724
1930
  field=far.field,
@@ -1732,96 +1938,243 @@ class NodeGetListQuery(Query):
1732
1938
  self._track_variable(far.node_value_query_variable)
1733
1939
  with_str = ", ".join(self._get_tracked_variables())
1734
1940
 
1735
- sort_params.update(subquery_params)
1736
- sort_query.append("CALL (n) {")
1737
- sort_query.append(subquery)
1738
- sort_query.append("}")
1739
- 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)
1740
1944
 
1741
- if sort_query:
1742
- self.add_to_query(sort_query)
1743
- 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.
1744
1947
 
1745
- def _add_final_filter(self, field_attribute_requirements: list[FieldAttributeRequirement]) -> None:
1746
- where_parts = []
1747
- where_str = ""
1748
- for far in field_attribute_requirements:
1749
- if not far.is_filter or not far.is_attribute_value:
1750
- continue
1751
- var_name = f"final_attr_value{far.index}"
1752
- self.params[var_name] = far.field_attr_comparison_value
1753
- if self.partial_match:
1754
- if isinstance(far.field_attr_comparison_value, list):
1755
- # If the any filter is an array/list
1756
- var_array = f"{var_name}_array"
1757
- where_parts.append(
1758
- f"any({var_array} IN ${var_name} WHERE toLower(toString({far.node_value_query_variable})) CONTAINS toLower({var_array}))"
1759
- )
1760
- else:
1761
- where_parts.append(
1762
- 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,
1763
2052
  )
2053
+ )
2054
+ index += 1
1764
2055
  continue
1765
- if far.field and isinstance(far.field, AttributeSchema) and far.field.kind == "List":
1766
- if isinstance(far.field_attr_comparison_value, list):
1767
- self.params[var_name] = build_regex_attrs(values=far.field_attr_comparison_value)
1768
- else:
1769
- self.params[var_name] = build_regex_attrs(values=[far.field_attr_comparison_value])
1770
2056
 
1771
- 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:
1772
2061
  continue
1773
2062
 
1774
- where_parts.append(f"{far.node_value_query_variable} {far.comparison_operator} ${var_name}")
1775
- if where_parts:
1776
- where_str = "WHERE " + " AND ".join(where_parts)
1777
- 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)
1778
2067
 
1779
- def _get_field_requirements(self, disable_order: bool) -> list[FieldAttributeRequirement]:
1780
- internal_filters = ["any", "attribute", "relationship"]
1781
- field_requirements_map: dict[tuple[str, str], FieldAttributeRequirement] = {}
1782
- index = 1
1783
- if self.filters:
1784
- for field_name in self.schema.valid_input_names + internal_filters:
1785
- attr_filters = extract_field_filters(field_name=field_name, filters=self.filters)
1786
- if not attr_filters:
1787
- continue
1788
- field = self.schema.get_field(field_name, raise_on_error=False)
1789
- for field_attr_name, field_attr_value in attr_filters.items():
1790
- 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(
1791
2076
  field_name=field_name,
1792
2077
  field=field,
1793
- field_attr_name=field_attr_name,
1794
- field_attr_value=field_attr_value.value
1795
- if isinstance(field_attr_value, Enum)
1796
- else field_attr_value,
2078
+ attr_name=attr_name,
2079
+ attr_value=attr_value,
1797
2080
  index=index,
1798
- types=[FieldAttributeRequirementType.FILTER],
1799
2081
  )
1800
- index += 1
2082
+ )
2083
+ index += 1
1801
2084
 
1802
- if disable_order:
1803
- 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
1804
2132
 
1805
- 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 []:
1806
2135
  order_by_field_name, order_by_attr_property_name = order_by_path.split("__", maxsplit=1)
1807
2136
 
1808
2137
  field = self.schema.get_field(order_by_field_name)
1809
- field_req = field_requirements_map.get(
1810
- (order_by_field_name, order_by_attr_property_name),
1811
- FieldAttributeRequirement(
1812
- field_name=order_by_field_name,
1813
- field=field,
1814
- field_attr_name=order_by_attr_property_name,
1815
- field_attr_value=None,
1816
- index=index,
1817
- types=[],
1818
- ),
1819
- )
1820
- field_req.types.append(FieldAttributeRequirementType.ORDER)
1821
- field_requirements_map[order_by_field_name, order_by_attr_property_name] = field_req
1822
- 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)
1823
2176
 
1824
- return list(field_requirements_map.values())
2177
+ return filter_requirements + order_requirements
1825
2178
 
1826
2179
  def get_node_ids(self) -> list[str]:
1827
2180
  return [str(result.get("n.uuid")) for result in self.get_results()]