infrahub-server 1.6.3__py3-none-any.whl → 1.7.0b0__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 (161) hide show
  1. infrahub/actions/tasks.py +4 -2
  2. infrahub/api/schema.py +3 -1
  3. infrahub/artifacts/tasks.py +1 -0
  4. infrahub/auth.py +2 -2
  5. infrahub/cli/db.py +6 -6
  6. infrahub/computed_attribute/gather.py +3 -4
  7. infrahub/computed_attribute/tasks.py +23 -6
  8. infrahub/config.py +8 -0
  9. infrahub/constants/enums.py +12 -0
  10. infrahub/core/account.py +5 -8
  11. infrahub/core/attribute.py +106 -108
  12. infrahub/core/branch/models.py +44 -71
  13. infrahub/core/branch/tasks.py +5 -3
  14. infrahub/core/changelog/diff.py +1 -20
  15. infrahub/core/changelog/models.py +0 -7
  16. infrahub/core/constants/__init__.py +17 -0
  17. infrahub/core/constants/database.py +0 -1
  18. infrahub/core/constants/schema.py +0 -1
  19. infrahub/core/convert_object_type/repository_conversion.py +3 -4
  20. infrahub/core/diff/data_check_synchronizer.py +3 -2
  21. infrahub/core/diff/enricher/cardinality_one.py +1 -1
  22. infrahub/core/diff/merger/merger.py +27 -1
  23. infrahub/core/diff/merger/serializer.py +3 -10
  24. infrahub/core/diff/model/diff.py +1 -1
  25. infrahub/core/diff/query/merge.py +376 -135
  26. infrahub/core/graph/__init__.py +1 -1
  27. infrahub/core/graph/constraints.py +2 -2
  28. infrahub/core/graph/schema.py +2 -12
  29. infrahub/core/manager.py +132 -126
  30. infrahub/core/metadata/__init__.py +0 -0
  31. infrahub/core/metadata/interface.py +37 -0
  32. infrahub/core/metadata/model.py +31 -0
  33. infrahub/core/metadata/query/__init__.py +0 -0
  34. infrahub/core/metadata/query/node_metadata.py +301 -0
  35. infrahub/core/migrations/graph/__init__.py +4 -0
  36. infrahub/core/migrations/graph/m013_convert_git_password_credential.py +3 -8
  37. infrahub/core/migrations/graph/m017_add_core_profile.py +5 -2
  38. infrahub/core/migrations/graph/m018_uniqueness_nulls.py +2 -1
  39. infrahub/core/migrations/graph/m019_restore_rels_to_time.py +0 -10
  40. infrahub/core/migrations/graph/m020_duplicate_edges.py +0 -8
  41. infrahub/core/migrations/graph/m025_uniqueness_nulls.py +2 -1
  42. infrahub/core/migrations/graph/m026_0000_prefix_fix.py +2 -1
  43. infrahub/core/migrations/graph/m029_duplicates_cleanup.py +0 -1
  44. infrahub/core/migrations/graph/m031_check_number_attributes.py +2 -2
  45. infrahub/core/migrations/graph/m038_redo_0000_prefix_fix.py +2 -1
  46. infrahub/core/migrations/graph/m049_remove_is_visible_relationship.py +38 -0
  47. infrahub/core/migrations/graph/m050_backfill_vertex_metadata.py +168 -0
  48. infrahub/core/migrations/query/attribute_add.py +17 -6
  49. infrahub/core/migrations/query/attribute_remove.py +19 -5
  50. infrahub/core/migrations/query/attribute_rename.py +21 -5
  51. infrahub/core/migrations/query/node_duplicate.py +19 -4
  52. infrahub/core/migrations/schema/attribute_kind_update.py +25 -7
  53. infrahub/core/migrations/schema/attribute_supports_profile.py +3 -1
  54. infrahub/core/migrations/schema/models.py +3 -0
  55. infrahub/core/migrations/schema/node_attribute_add.py +4 -1
  56. infrahub/core/migrations/schema/node_remove.py +24 -2
  57. infrahub/core/migrations/schema/tasks.py +4 -1
  58. infrahub/core/migrations/shared.py +13 -6
  59. infrahub/core/models.py +6 -6
  60. infrahub/core/node/__init__.py +156 -57
  61. infrahub/core/node/create.py +7 -3
  62. infrahub/core/node/standard.py +100 -14
  63. infrahub/core/property.py +0 -1
  64. infrahub/core/protocols_base.py +6 -2
  65. infrahub/core/query/__init__.py +6 -7
  66. infrahub/core/query/attribute.py +161 -46
  67. infrahub/core/query/branch.py +57 -69
  68. infrahub/core/query/diff.py +4 -4
  69. infrahub/core/query/node.py +618 -180
  70. infrahub/core/query/relationship.py +449 -300
  71. infrahub/core/query/standard_node.py +25 -5
  72. infrahub/core/query/utils.py +2 -4
  73. infrahub/core/relationship/constraints/profiles_removal.py +168 -0
  74. infrahub/core/relationship/model.py +293 -139
  75. infrahub/core/schema/attribute_parameters.py +1 -28
  76. infrahub/core/schema/attribute_schema.py +17 -11
  77. infrahub/core/schema/manager.py +63 -43
  78. infrahub/core/schema/relationship_schema.py +6 -2
  79. infrahub/core/schema/schema_branch.py +48 -76
  80. infrahub/core/task/task.py +4 -2
  81. infrahub/core/utils.py +0 -22
  82. infrahub/core/validators/attribute/kind.py +2 -5
  83. infrahub/core/validators/determiner.py +3 -3
  84. infrahub/database/__init__.py +3 -3
  85. infrahub/dependencies/builder/constraint/grouped/node_runner.py +2 -0
  86. infrahub/dependencies/builder/constraint/relationship_manager/profiles_removal.py +8 -0
  87. infrahub/dependencies/registry.py +2 -0
  88. infrahub/display_labels/tasks.py +12 -3
  89. infrahub/git/integrator.py +18 -18
  90. infrahub/git/tasks.py +1 -1
  91. infrahub/graphql/app.py +2 -2
  92. infrahub/graphql/constants.py +3 -0
  93. infrahub/graphql/context.py +1 -1
  94. infrahub/graphql/initialization.py +11 -0
  95. infrahub/graphql/loaders/account.py +134 -0
  96. infrahub/graphql/loaders/node.py +5 -12
  97. infrahub/graphql/loaders/peers.py +5 -7
  98. infrahub/graphql/manager.py +158 -18
  99. infrahub/graphql/metadata.py +91 -0
  100. infrahub/graphql/models.py +33 -3
  101. infrahub/graphql/mutations/account.py +5 -5
  102. infrahub/graphql/mutations/attribute.py +0 -2
  103. infrahub/graphql/mutations/branch.py +9 -5
  104. infrahub/graphql/mutations/computed_attribute.py +1 -1
  105. infrahub/graphql/mutations/display_label.py +1 -1
  106. infrahub/graphql/mutations/hfid.py +1 -1
  107. infrahub/graphql/mutations/ipam.py +4 -6
  108. infrahub/graphql/mutations/main.py +9 -4
  109. infrahub/graphql/mutations/profile.py +16 -22
  110. infrahub/graphql/mutations/proposed_change.py +4 -4
  111. infrahub/graphql/mutations/relationship.py +40 -10
  112. infrahub/graphql/mutations/repository.py +14 -12
  113. infrahub/graphql/mutations/schema.py +2 -2
  114. infrahub/graphql/queries/branch.py +62 -6
  115. infrahub/graphql/queries/diff/tree.py +5 -5
  116. infrahub/graphql/resolvers/account_metadata.py +84 -0
  117. infrahub/graphql/resolvers/ipam.py +6 -8
  118. infrahub/graphql/resolvers/many_relationship.py +77 -35
  119. infrahub/graphql/resolvers/resolver.py +16 -12
  120. infrahub/graphql/resolvers/single_relationship.py +87 -23
  121. infrahub/graphql/subscription/graphql_query.py +2 -0
  122. infrahub/graphql/types/__init__.py +0 -1
  123. infrahub/graphql/types/attribute.py +10 -5
  124. infrahub/graphql/types/branch.py +40 -53
  125. infrahub/graphql/types/enums.py +3 -0
  126. infrahub/graphql/types/metadata.py +28 -0
  127. infrahub/graphql/types/node.py +22 -2
  128. infrahub/graphql/types/relationship.py +10 -2
  129. infrahub/graphql/types/standard_node.py +4 -3
  130. infrahub/hfid/tasks.py +12 -3
  131. infrahub/profiles/gather.py +56 -0
  132. infrahub/profiles/mandatory_fields_checker.py +116 -0
  133. infrahub/profiles/models.py +66 -0
  134. infrahub/profiles/node_applier.py +153 -12
  135. infrahub/profiles/queries/get_profile_data.py +143 -31
  136. infrahub/profiles/tasks.py +79 -27
  137. infrahub/profiles/triggers.py +22 -0
  138. infrahub/proposed_change/tasks.py +4 -1
  139. infrahub/tasks/artifact.py +1 -0
  140. infrahub/transformations/tasks.py +2 -2
  141. infrahub/trigger/catalogue.py +2 -0
  142. infrahub/trigger/models.py +1 -0
  143. infrahub/trigger/setup.py +3 -3
  144. infrahub/trigger/tasks.py +3 -0
  145. infrahub/validators/tasks.py +1 -0
  146. infrahub/webhook/models.py +1 -1
  147. infrahub/webhook/tasks.py +1 -1
  148. infrahub/workers/dependencies.py +9 -3
  149. infrahub/workers/infrahub_async.py +13 -4
  150. infrahub/workflows/catalogue.py +19 -0
  151. infrahub_sdk/node/constants.py +1 -0
  152. infrahub_sdk/node/related_node.py +13 -4
  153. infrahub_sdk/node/relationship.py +8 -0
  154. {infrahub_server-1.6.3.dist-info → infrahub_server-1.7.0b0.dist-info}/METADATA +17 -16
  155. {infrahub_server-1.6.3.dist-info → infrahub_server-1.7.0b0.dist-info}/RECORD +161 -143
  156. infrahub_testcontainers/container.py +3 -3
  157. infrahub_testcontainers/docker-compose-cluster.test.yml +7 -7
  158. infrahub_testcontainers/docker-compose.test.yml +13 -5
  159. {infrahub_server-1.6.3.dist-info → infrahub_server-1.7.0b0.dist-info}/WHEEL +0 -0
  160. {infrahub_server-1.6.3.dist-info → infrahub_server-1.7.0b0.dist-info}/entry_points.txt +0 -0
  161. {infrahub_server-1.6.3.dist-info → infrahub_server-1.7.0b0.dist-info}/licenses/LICENSE.txt +0 -0
@@ -21,8 +21,10 @@ from infrahub.core.schema import (
21
21
  from infrahub.graphql.mutations.attribute import BaseAttributeCreate, BaseAttributeUpdate
22
22
  from infrahub.graphql.mutations.graphql_query import InfrahubGraphQLQueryMutation
23
23
  from infrahub.graphql.mutations.profile import InfrahubProfileMutation
24
+ from infrahub.graphql.types.metadata import OrderInput
24
25
  from infrahub.types import ATTRIBUTE_TYPES, InfrahubDataType, get_attribute_type
25
26
 
27
+ from .constants import NODE_METADATA_TYPE, RELATIONSHIP_METADATA_TYPE
26
28
  from .directives import DIRECTIVES
27
29
  from .enums import generate_graphql_enum, get_enum_attribute_type_name
28
30
  from .metrics import SCHEMA_GENERATE_GRAPHQL_METRICS
@@ -42,6 +44,7 @@ from .mutations.resource_manager import (
42
44
  )
43
45
  from .mutations.webhook import InfrahubWebhookMutation
44
46
  from .registry import registry
47
+ from .resolvers.account_metadata import account_metadata_resolver
45
48
  from .resolvers.ipam import ipam_paginated_list_resolver
46
49
  from .resolvers.resolver import (
47
50
  account_resolver,
@@ -65,8 +68,10 @@ from .types import (
65
68
  )
66
69
  from .types.attribute import BaseAttribute as BaseAttributeType
67
70
  from .types.attribute import TextAttributeType
71
+ from .types.branch import InfrahubBranchEdge
68
72
  from .types.context import ContextInput
69
73
  from .types.event import EVENT_TYPES
74
+ from .types.node import InfrahubObjectWithoutMeta
70
75
 
71
76
  if TYPE_CHECKING:
72
77
  from graphql import GraphQLSchema
@@ -82,10 +87,6 @@ class DeleteInput(graphene.InputObjectType):
82
87
  GraphQLTypes = type[InfrahubMutation] | type[BaseAttributeType] | type[graphene.Interface] | type[graphene.ObjectType]
83
88
 
84
89
 
85
- class OrderInput(graphene.InputObjectType):
86
- disable = graphene.Boolean(required=False)
87
-
88
-
89
90
  @dataclass
90
91
  class GraphqlMutations:
91
92
  create: type[InfrahubMutation]
@@ -243,15 +244,37 @@ class GraphQLSchemaManager:
243
244
  self.set_type(name=event._meta.name, graphql_type=event)
244
245
 
245
246
  def _load_node_interface(self) -> None:
247
+ """Load the base CoreNode interface. Edged/paginated objects are created later in generate_object_types."""
248
+ node_interface_schema = GenericSchema(
249
+ name="Node", namespace="Core", description="Interface for all nodes in Infrahub"
250
+ )
251
+ self.generate_interface_object(schema=node_interface_schema, populate_cache=True)
252
+
253
+ def _complete_node_interface(self, node_metadata: type[InfrahubObject]) -> None:
254
+ """Complete the CoreNode interface by creating its edged and paginated objects."""
246
255
  node_interface_schema = GenericSchema(
247
256
  name="Node", namespace="Core", description="Interface for all nodes in Infrahub"
248
257
  )
249
- interface = self.generate_interface_object(schema=node_interface_schema, populate_cache=True)
258
+ # Re-call generate_interface_object to get the InterfaceReference (will use cached version)
259
+ interface = self.generate_interface_object(schema=node_interface_schema, populate_cache=False)
250
260
  edged_interface = self.generate_graphql_edged_object(
251
- schema=node_interface_schema, node=interface, populate_cache=True
261
+ schema=node_interface_schema, node=interface, node_metadata=node_metadata, populate_cache=True
252
262
  )
253
263
  self.generate_graphql_paginated_object(schema=node_interface_schema, edge=edged_interface, populate_cache=True)
254
264
 
265
+ def _patch_static_types(self, node_metadata: type[InfrahubObject]) -> None:
266
+ """Patch statically defined GraphQL types to use dynamically generated types.
267
+
268
+ Some GraphQL types like InfrahubBranchEdge are defined statically but need to
269
+ reference dynamically generated types (like node_metadata with GenericAccount).
270
+ This method patches those static types after the dynamic types are created.
271
+
272
+ The method checks if the patch has already been applied to avoid redundant updates.
273
+ """
274
+ current_field = InfrahubBranchEdge._meta.fields.get("node_metadata")
275
+ if current_field is None or current_field.type != node_metadata:
276
+ InfrahubBranchEdge._meta.fields["node_metadata"] = graphene.Field(node_metadata, required=True)
277
+
255
278
  def _load_all_enum_types(self, node_schemas: Iterable[MainSchemaTypes]) -> None:
256
279
  for node_schema in node_schemas:
257
280
  self._load_enum_type(node_schema=node_schema)
@@ -311,25 +334,49 @@ class GraphQLSchemaManager:
311
334
 
312
335
  full_schema = self.schema.get_all(duplicate=False)
313
336
 
314
- # Generate all GraphQL Interface Object first and store them in the registry
337
+ # Pass 1: Generate all GraphQL Interface objects first (without edged/paginated)
338
+ # This ensures GENERICACCOUNT exists before we create node_metadata
315
339
  for node_schema in full_schema.values():
316
- if not isinstance(node_schema, GenericSchema):
317
- continue
318
- interface = self.generate_interface_object(schema=node_schema, populate_cache=True)
319
- edged_interface = self.generate_graphql_edged_object(
320
- schema=node_schema, node=interface, populate_cache=True
321
- )
322
- self.generate_graphql_paginated_object(schema=node_schema, edge=edged_interface, populate_cache=True)
340
+ if isinstance(node_schema, GenericSchema):
341
+ self.generate_interface_object(schema=node_schema, populate_cache=True)
323
342
 
324
343
  # Define LineageSource and LineageOwner
325
344
  data_source = self.get_type(name=InfrahubKind.LINEAGESOURCE)
326
345
  data_owner = self.get_type(name=InfrahubKind.LINEAGEOWNER)
327
346
  self.define_relationship_property(data_source=data_source, data_owner=data_owner)
347
+
348
+ # Now that GENERICACCOUNT exists, create node_metadata and relationship_metadata
349
+ account_type = self.get_type(name=InfrahubKind.GENERICACCOUNT)
350
+ self.define_node_metadata(account_type=account_type)
351
+ self.define_relationship_metadata(account_type=account_type)
352
+ node_metadata = self.get_type(name=NODE_METADATA_TYPE)
353
+ relationship_metadata = self.get_type(name=RELATIONSHIP_METADATA_TYPE)
354
+
355
+ # Complete the CoreNode interface (edged/paginated) now that node_metadata exists
356
+ self._complete_node_interface(node_metadata=node_metadata)
357
+
358
+ # Patch statically defined types to use dynamically generated types
359
+ self._patch_static_types(node_metadata=node_metadata)
360
+
328
361
  relationship_property = self.get_type(name="RelationshipProperty")
329
362
  for data_type in ATTRIBUTE_TYPES.values():
330
363
  gql_type = self.get_type(name=data_type.get_graphql_type_name())
331
364
  gql_type._meta.fields["source"] = graphene.Field(data_source)
332
365
  gql_type._meta.fields["owner"] = graphene.Field(data_owner)
366
+ gql_type._meta.fields["updated_by"] = graphene.Field(
367
+ account_type, required=False, resolver=account_metadata_resolver
368
+ )
369
+
370
+ # Pass 2: Generate edged/paginated objects for all GenericSchema interfaces
371
+ for node_schema in full_schema.values():
372
+ if not isinstance(node_schema, GenericSchema):
373
+ continue
374
+ # Re-call generate_interface_object to get the InterfaceReference (will use cached version)
375
+ interface = self.generate_interface_object(schema=node_schema, populate_cache=False)
376
+ edged_interface = self.generate_graphql_edged_object(
377
+ schema=node_schema, node=interface, node_metadata=node_metadata, populate_cache=True
378
+ )
379
+ self.generate_graphql_paginated_object(schema=node_schema, edge=edged_interface, populate_cache=True)
333
380
 
334
381
  # Generate all Nested, Edged and NestedEdged Interfaces and store them in the registry
335
382
  for node_name, node_schema in full_schema.items():
@@ -340,7 +387,9 @@ class GraphQLSchemaManager:
340
387
  nested_edged_interface = self.generate_nested_interface_object(
341
388
  schema=node_schema,
342
389
  base_interface=node_interface,
390
+ node_metadata=node_metadata,
343
391
  relation_property=relationship_property,
392
+ relationship_metadata=relationship_metadata,
344
393
  )
345
394
 
346
395
  nested_interface = self.generate_paginated_interface_object(
@@ -356,11 +405,13 @@ class GraphQLSchemaManager:
356
405
  if isinstance(node_schema, NodeSchema | ProfileSchema | TemplateSchema):
357
406
  node_object_type = self.generate_graphql_object(schema=node_schema, populate_cache=True)
358
407
  node_type_edged = self.generate_graphql_edged_object(
359
- schema=node_schema, node=node_object_type, populate_cache=True
408
+ schema=node_schema, node=node_object_type, node_metadata=node_metadata, populate_cache=True
360
409
  )
361
410
  nested_node_type_edged = self.generate_graphql_edged_object(
362
411
  schema=node_schema,
363
412
  node=node_object_type,
413
+ node_metadata=node_metadata,
414
+ relationship_metadata=relationship_metadata,
364
415
  relation_property=relationship_property,
365
416
  populate_cache=True,
366
417
  )
@@ -615,7 +666,6 @@ class GraphQLSchemaManager:
615
666
  }
616
667
 
617
668
  main_attrs = {
618
- "is_visible": graphene.Boolean(required=False),
619
669
  "is_protected": graphene.Boolean(required=False),
620
670
  "updated_at": graphene.DateTime(required=False),
621
671
  "source": graphene.Field(data_source),
@@ -627,6 +677,42 @@ class GraphQLSchemaManager:
627
677
 
628
678
  self.set_type(name=type_name, graphql_type=relationship_property)
629
679
 
680
+ def define_node_metadata(self, account_type: type[InfrahubObject]) -> None:
681
+ meta_attrs = {
682
+ "name": NODE_METADATA_TYPE,
683
+ "description": "Defines node metadata information",
684
+ }
685
+
686
+ main_attrs = {
687
+ "created_at": graphene.DateTime(required=False),
688
+ "created_by": graphene.Field(account_type, required=False, resolver=account_metadata_resolver),
689
+ "updated_at": graphene.DateTime(required=False),
690
+ "updated_by": graphene.Field(account_type, required=False, resolver=account_metadata_resolver),
691
+ "Meta": type("Meta", (object,), meta_attrs),
692
+ }
693
+
694
+ node_metadata = type(NODE_METADATA_TYPE, (graphene.ObjectType,), main_attrs)
695
+
696
+ self.set_type(name=NODE_METADATA_TYPE, graphql_type=node_metadata)
697
+
698
+ def define_relationship_metadata(self, account_type: type[InfrahubObject]) -> None:
699
+ meta_attrs = {
700
+ "name": RELATIONSHIP_METADATA_TYPE,
701
+ "description": "Defines relationship metadata information",
702
+ }
703
+
704
+ main_attrs = {
705
+ "created_at": graphene.DateTime(required=False),
706
+ "created_by": graphene.Field(account_type, required=False, resolver=account_metadata_resolver),
707
+ "updated_at": graphene.DateTime(required=False),
708
+ "updated_by": graphene.Field(account_type, required=False, resolver=account_metadata_resolver),
709
+ "Meta": type("Meta", (object,), meta_attrs),
710
+ }
711
+
712
+ relationship_metadata = type(RELATIONSHIP_METADATA_TYPE, (graphene.ObjectType,), main_attrs)
713
+
714
+ self.set_type(name=RELATIONSHIP_METADATA_TYPE, graphql_type=relationship_metadata)
715
+
630
716
  def generate_graphql_mutations(
631
717
  self,
632
718
  schema: NodeSchema | ProfileSchema | TemplateSchema,
@@ -935,6 +1021,9 @@ class GraphQLSchemaManager:
935
1021
  filters.update(get_attribute_type().get_graphql_filters(name="any"))
936
1022
  filters["partial_match"] = graphene.Boolean()
937
1023
 
1024
+ # Add metadata filters for filtering by created_by, updated_by, created_at, updated_at
1025
+ filters.update(self._generate_metadata_filters())
1026
+
938
1027
  if schema.kind in [InfrahubKind.IPADDRESS, InfrahubKind.IPPREFIX]:
939
1028
  # This is only available for IPAM generics
940
1029
  filters["include_available"] = graphene.Boolean()
@@ -961,10 +1050,53 @@ class GraphQLSchemaManager:
961
1050
 
962
1051
  return filters
963
1052
 
1053
+ def _generate_metadata_filters(self) -> dict[str, Any]:
1054
+ """Generate GraphQL filters for object-level metadata fields.
1055
+
1056
+ These filters allow querying nodes based on their metadata:
1057
+ - created_by: Filter by the account that created the node
1058
+ - updated_by: Filter by the account that last updated the node
1059
+ - created_at: Filter by creation timestamp
1060
+ - updated_at: Filter by last update timestamp
1061
+
1062
+ Returns:
1063
+ dict: Filter definitions with names as keys and graphene types as values
1064
+ """
1065
+ return {
1066
+ # Account-based filters (created_by)
1067
+ "node_metadata__created_by__id": graphene.ID(description="Filter by exact creator account UUID"),
1068
+ "node_metadata__created_by__ids": graphene.List(
1069
+ graphene.ID, description="Filter by list of creator account UUIDs"
1070
+ ),
1071
+ # Account-based filters (updated_by)
1072
+ "node_metadata__updated_by__id": graphene.ID(description="Filter by exact updater account UUID"),
1073
+ "node_metadata__updated_by__ids": graphene.List(
1074
+ graphene.ID, description="Filter by list of updater account UUIDs"
1075
+ ),
1076
+ # DateTime-based filters (created_at)
1077
+ "node_metadata__created_at": graphene.DateTime(description="Filter by exact creation timestamp"),
1078
+ "node_metadata__created_at__before": graphene.DateTime(
1079
+ description="Filter for objects created before this timestamp"
1080
+ ),
1081
+ "node_metadata__created_at__after": graphene.DateTime(
1082
+ description="Filter for objects created after this timestamp"
1083
+ ),
1084
+ # DateTime-based filters (updated_at)
1085
+ "node_metadata__updated_at": graphene.DateTime(description="Filter by exact update timestamp"),
1086
+ "node_metadata__updated_at__before": graphene.DateTime(
1087
+ description="Filter for objects updated before this timestamp"
1088
+ ),
1089
+ "node_metadata__updated_at__after": graphene.DateTime(
1090
+ description="Filter for objects updated after this timestamp"
1091
+ ),
1092
+ }
1093
+
964
1094
  def generate_graphql_edged_object(
965
1095
  self,
966
1096
  schema: MainSchemaTypes,
967
1097
  node: InterfaceReference | InfrahubObjectReference,
1098
+ node_metadata: type[InfrahubObject],
1099
+ relationship_metadata: type[InfrahubObject] | None = None,
968
1100
  relation_property: type[InfrahubObject] | None = None,
969
1101
  populate_cache: bool = False,
970
1102
  ) -> InfrahubEdgedReference:
@@ -987,15 +1119,18 @@ class GraphQLSchemaManager:
987
1119
 
988
1120
  main_attrs: dict[str, Any] = {
989
1121
  "node": graphene.Field(node.reference, required=False),
1122
+ "node_metadata": graphene.Field(node_metadata, required=False),
990
1123
  "Meta": type("Meta", (object,), meta_attrs),
991
1124
  }
992
1125
 
993
1126
  if relation_property:
994
1127
  main_attrs["properties"] = graphene.Field(relation_property, required=False)
1128
+ if relationship_metadata:
1129
+ main_attrs["relationship_metadata"] = graphene.Field(relationship_metadata, required=False)
995
1130
 
996
1131
  graphql_edged_object = registry.get_edge_type(reference_hash=edge_hash, schema_hash=self.schema_hash)
997
1132
  if not graphql_edged_object:
998
- graphql_edged_object = type(object_name, (InfrahubObject,), main_attrs)
1133
+ graphql_edged_object = type(object_name, (InfrahubObjectWithoutMeta,), main_attrs)
999
1134
  registry.set_edge_type(
1000
1135
  reference=graphql_edged_object, reference_hash=edge_hash, schema_hash=self.schema_hash
1001
1136
  )
@@ -1039,7 +1174,7 @@ class GraphQLSchemaManager:
1039
1174
  )
1040
1175
  if not graphql_paginated_object:
1041
1176
  main_attrs["Meta"] = type("Meta", (object,), meta_attrs)
1042
- graphql_paginated_object = type(object_name, (InfrahubObject,), main_attrs)
1177
+ graphql_paginated_object = type(object_name, (InfrahubObjectWithoutMeta,), main_attrs)
1043
1178
  registry.set_paginated_type(
1044
1179
  reference=graphql_paginated_object, reference_hash=paginated_hash, schema_hash=self.schema_hash
1045
1180
  )
@@ -1054,6 +1189,8 @@ class GraphQLSchemaManager:
1054
1189
  schema: GenericSchema,
1055
1190
  relation_property: graphene.ObjectType,
1056
1191
  base_interface: graphene.ObjectType,
1192
+ node_metadata: type[InfrahubObject],
1193
+ relationship_metadata: type[InfrahubObject] | None = None,
1057
1194
  populate_cache: bool = False,
1058
1195
  ) -> type[InfrahubObject]:
1059
1196
  meta_attrs: dict[str, Any] = {
@@ -1065,11 +1202,14 @@ class GraphQLSchemaManager:
1065
1202
  main_attrs: dict[str, Any] = {
1066
1203
  "node": graphene.Field(base_interface, required=False),
1067
1204
  "_updated_at": graphene.DateTime(required=False),
1205
+ "node_metadata": graphene.Field(node_metadata, required=True),
1068
1206
  "Meta": type("Meta", (object,), meta_attrs),
1069
1207
  }
1070
1208
 
1071
1209
  if relation_property:
1072
1210
  main_attrs["properties"] = graphene.Field(relation_property, required=False)
1211
+ if relationship_metadata:
1212
+ main_attrs["relationship_metadata"] = graphene.Field(relationship_metadata, required=False)
1073
1213
 
1074
1214
  object_name = f"NestedEdged{schema.kind}"
1075
1215
  md5hash = hashlib.md5(usedforsecurity=False)
@@ -0,0 +1,91 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from infrahub.core.constants import MetadataOptions
6
+ from infrahub.core.metadata.model import MetadataQueryOptions
7
+
8
+
9
+ def get_metadata_options_from_fields(fields: dict[str, Any]) -> MetadataOptions:
10
+ """Convert a dict of requested metadata fields to MetadataOptions flags."""
11
+ options = MetadataOptions.NONE
12
+ if "created_at" in fields:
13
+ options |= MetadataOptions.CREATED_AT
14
+ if "created_by" in fields:
15
+ options |= MetadataOptions.CREATED_BY
16
+ if "updated_at" in fields or "_updated_at" in fields:
17
+ options |= MetadataOptions.UPDATED_AT
18
+ if "updated_by" in fields:
19
+ options |= MetadataOptions.UPDATED_BY
20
+ if "source" in fields or "_relation__source" in fields:
21
+ options |= MetadataOptions.SOURCE
22
+ if "owner" in fields or "_relation__owner" in fields:
23
+ options |= MetadataOptions.OWNER
24
+ if "is_protected" in fields:
25
+ options |= MetadataOptions.IS_PROTECTED
26
+ return options
27
+
28
+
29
+ def _extract_attribute_metadata_from_node_fields(node_fields: dict[str, Any]) -> MetadataOptions:
30
+ """Extract attribute-level metadata options from nested node fields.
31
+
32
+ This handles GraphQL query structures like:
33
+ name {
34
+ value
35
+ updated_by { id }
36
+ updated_at
37
+ }
38
+
39
+ Where `updated_by` and `updated_at` are attribute-level metadata.
40
+ """
41
+ attribute_metadata_options = MetadataOptions.NONE
42
+
43
+ for field_properties_dict in node_fields.values():
44
+ if not field_properties_dict or not isinstance(field_properties_dict, dict):
45
+ continue
46
+
47
+ # For attributes, metadata fields are at the same level as 'value'
48
+ # Check if this looks like an attribute (has metadata fields directly)
49
+ metadata_properties_dict = field_properties_dict
50
+
51
+ # Extract metadata from attribute fields
52
+ if "updated_at" in metadata_properties_dict or "_updated_at" in metadata_properties_dict:
53
+ attribute_metadata_options |= MetadataOptions.UPDATED_AT
54
+ if "updated_by" in metadata_properties_dict:
55
+ attribute_metadata_options |= MetadataOptions.UPDATED_BY
56
+ if "created_at" in metadata_properties_dict:
57
+ attribute_metadata_options |= MetadataOptions.CREATED_AT
58
+ if "created_by" in metadata_properties_dict:
59
+ attribute_metadata_options |= MetadataOptions.CREATED_BY
60
+ if "source" in metadata_properties_dict or "_relation__source" in metadata_properties_dict:
61
+ attribute_metadata_options |= MetadataOptions.SOURCE
62
+ if "owner" in metadata_properties_dict or "_relation__owner" in metadata_properties_dict:
63
+ attribute_metadata_options |= MetadataOptions.OWNER
64
+
65
+ return attribute_metadata_options
66
+
67
+
68
+ def build_metadata_query_options(
69
+ node_metadata_fields: dict[str, Any] | None = None,
70
+ relationship_metadata_fields: dict[str, Any] | None = None,
71
+ node_fields: dict[str, Any] | None = None,
72
+ ) -> MetadataQueryOptions:
73
+ """Build MetadataQueryOptions from GraphQL extracted fields.
74
+
75
+ Args:
76
+ node_metadata_fields: Fields from the node_metadata section of a GraphQL query.
77
+ relationship_metadata_fields: Fields from the relationship_metadata section.
78
+ node_fields: The node fields dict to extract attribute-level metadata from.
79
+
80
+ Returns:
81
+ MetadataQueryOptions with appropriate flags set for each level.
82
+ """
83
+ node_level = get_metadata_options_from_fields(node_metadata_fields or {})
84
+ relationship_level = get_metadata_options_from_fields(relationship_metadata_fields or {})
85
+ attribute_level = _extract_attribute_metadata_from_node_fields(node_fields or {})
86
+
87
+ return MetadataQueryOptions(
88
+ node_level=node_level,
89
+ attribute_level=attribute_level,
90
+ relationship_level=relationship_level,
91
+ )
@@ -1,6 +1,36 @@
1
- from pydantic import BaseModel
1
+ from __future__ import annotations
2
2
 
3
+ from dataclasses import dataclass
4
+ from typing import TYPE_CHECKING, Any
3
5
 
4
- # Corresponds to infrahub.graphql.manager.OrderInput
5
- class OrderModel(BaseModel):
6
+ from infrahub.exceptions import ValidationError
7
+
8
+ if TYPE_CHECKING:
9
+ from infrahub.constants.enums import OrderDirection
10
+
11
+
12
+ @dataclass
13
+ class NodeMetaOrder:
14
+ created_at: OrderDirection | None = None
15
+ updated_at: OrderDirection | None = None
16
+
17
+
18
+ @dataclass
19
+ class OrderModel:
20
+ # Corresponds to infrahub.graphql.manager.OrderInput
6
21
  disable: bool | None = None
22
+ node_metadata: NodeMetaOrder | None = None
23
+
24
+ @classmethod
25
+ def from_input(cls, input_data: dict[str, Any] | None) -> OrderModel | None:
26
+ """Convert the dictionary type input data from GraphQL into an OrderModel instance."""
27
+ if not input_data:
28
+ return None
29
+
30
+ order_model = cls(**input_data)
31
+ order_model.validate()
32
+ return order_model
33
+
34
+ def validate(self) -> None:
35
+ if self.node_metadata and self.node_metadata.created_at and self.node_metadata.updated_at:
36
+ raise ValidationError("Cannot order by both created_at and updated_at simultaneously.")
@@ -1,6 +1,6 @@
1
1
  from typing import TYPE_CHECKING, Any
2
2
 
3
- from graphene import Boolean, Field, InputField, InputObjectType, Mutation, String
3
+ from graphene import Boolean, Field, InputField, InputObjectType, Mutation, ObjectType, String
4
4
  from graphql import GraphQLResolveInfo
5
5
  from infrahub_sdk.uuidt import UUIDT
6
6
  from typing_extensions import Self
@@ -36,7 +36,7 @@ class InfrahubAccountUpdateSelfInput(InputObjectType):
36
36
  description = InputField(String(required=False), description="Description to use instead of the current one")
37
37
 
38
38
 
39
- class ValueType(InfrahubObjectType):
39
+ class ValueType(ObjectType):
40
40
  value = String(required=True)
41
41
 
42
42
 
@@ -99,7 +99,7 @@ class AccountMixin:
99
99
  )
100
100
 
101
101
  async with db.start_transaction() as dbt:
102
- await obj.save(db=dbt)
102
+ await obj.save(db=dbt, user_id=account.id)
103
103
 
104
104
  fields = extract_graphql_fields(info=info)
105
105
  return cls(object=await obj.to_graphql(db=db, fields=fields.get("object", {})), ok=True) # type: ignore[call-arg]
@@ -126,7 +126,7 @@ class AccountMixin:
126
126
  raise NodeNotFoundError(node_type="AccountToken", identifier=token_id)
127
127
 
128
128
  async with db.start_transaction() as dbt:
129
- await results[0].delete(db=dbt)
129
+ await results[0].delete(db=dbt, user_id=account.id)
130
130
 
131
131
  return cls(ok=True) # type: ignore[call-arg]
132
132
 
@@ -144,7 +144,7 @@ class AccountMixin:
144
144
  getattr(account, field).value = value
145
145
 
146
146
  async with db.start_transaction() as dbt:
147
- await account.save(db=dbt)
147
+ await account.save(db=dbt, user_id=account.id)
148
148
 
149
149
  return cls(ok=True) # type: ignore[call-arg]
150
150
 
@@ -8,7 +8,6 @@ from infrahub.graphql.types.attribute import GenericPoolInput
8
8
 
9
9
 
10
10
  class BaseAttributeCreate(InputObjectType):
11
- is_visible = Boolean(required=False)
12
11
  is_protected = Boolean(required=False)
13
12
  source = String(required=False)
14
13
  owner = String(required=False)
@@ -21,7 +20,6 @@ class BaseAttributeCreate(InputObjectType):
21
20
 
22
21
  class BaseAttributeUpdate(InputObjectType):
23
22
  is_default = Boolean(required=False)
24
- is_visible = Boolean(required=False)
25
23
  is_protected = Boolean(required=False)
26
24
  source = String(required=False)
27
25
  owner = String(required=False)
@@ -98,7 +98,11 @@ class BranchCreate(Mutation):
98
98
  # Retrieve created branch
99
99
  obj = await Branch.get_by_name(db=graphql_context.db, name=model.name)
100
100
  fields = extract_graphql_fields(info=info)
101
- return cls(object=await obj.to_graphql(fields=fields.get("object", {})), ok=True, task=task)
101
+ return cls(
102
+ object=await obj.to_graphql_flat(fields=fields.get("object", {})),
103
+ ok=True,
104
+ task=task,
105
+ )
102
106
 
103
107
 
104
108
  class BranchNameInput(InputObjectType):
@@ -172,7 +176,7 @@ class BranchUpdate(Mutation):
172
176
  setattr(obj, field_name, data[field_name])
173
177
 
174
178
  async with graphql_context.db.start_transaction() as db:
175
- await obj.save(db=db)
179
+ await obj.save(db=db, user_id=graphql_context.active_account_session.account_id)
176
180
 
177
181
  return cls(ok=True)
178
182
 
@@ -218,7 +222,7 @@ class BranchRebase(Mutation):
218
222
  fields = extract_graphql_fields(info=info)
219
223
  ok = True
220
224
 
221
- return cls(object=await obj.to_graphql(fields=fields.get("object", {})), ok=ok, task=task)
225
+ return cls(object=await obj.to_graphql_flat(fields=fields.get("object", {})), ok=ok, task=task)
222
226
 
223
227
 
224
228
  class BranchValidate(Mutation):
@@ -260,7 +264,7 @@ class BranchValidate(Mutation):
260
264
 
261
265
  fields = extract_graphql_fields(info=info)
262
266
 
263
- return cls(object=await obj.to_graphql(fields=fields.get("object", {})), ok=ok, task=task)
267
+ return cls(object=await obj.to_graphql_flat(fields=fields.get("object", {})), ok=ok, task=task)
264
268
 
265
269
 
266
270
  class BranchMerge(Mutation):
@@ -315,4 +319,4 @@ class BranchMerge(Mutation):
315
319
  fields = extract_graphql_fields(info=info)
316
320
  ok = True
317
321
 
318
- return cls(object=await obj.to_graphql(fields=fields.get("object", {})), ok=ok, task=task)
322
+ return cls(object=await obj.to_graphql_flat(fields=fields.get("object", {})), ok=ok, task=task)
@@ -102,7 +102,7 @@ class UpdateComputedAttribute(Mutation):
102
102
  if attribute_field.value != str(data.value):
103
103
  attribute_field.value = str(data.value)
104
104
  async with graphql_context.db.start_transaction() as dbt:
105
- await target_node.save(db=dbt, fields=[str(data.attribute)])
105
+ await target_node.save(db=dbt, fields=[str(data.attribute)], user_id=graphql_context.assigned_user_id)
106
106
 
107
107
  log_data = get_log_data()
108
108
  request_id = log_data.get("request_id", "")
@@ -93,7 +93,7 @@ class UpdateDisplayLabel(Mutation):
93
93
  await target_node.set_display_label(value=str(data.value))
94
94
 
95
95
  async with graphql_context.db.start_transaction() as dbt:
96
- await target_node.save(db=dbt, fields=["display_label"])
96
+ await target_node.save(db=dbt, fields=["display_label"], user_id=graphql_context.assigned_user_id)
97
97
 
98
98
  log_data = get_log_data()
99
99
  request_id = log_data.get("request_id", "")
@@ -100,7 +100,7 @@ class UpdateHFID(Mutation):
100
100
  await target_node.set_human_friendly_id(value=updated_hfid)
101
101
 
102
102
  async with graphql_context.db.start_transaction() as dbt:
103
- await target_node.save(db=dbt, fields=["human_friendly_id"])
103
+ await target_node.save(db=dbt, fields=["human_friendly_id"], user_id=graphql_context.assigned_user_id)
104
104
 
105
105
  log_data = get_log_data()
106
106
  request_id = log_data.get("request_id", "")
@@ -9,7 +9,7 @@ from typing_extensions import Self
9
9
  from infrahub import lock
10
10
  from infrahub.core import registry
11
11
  from infrahub.core.branch import Branch
12
- from infrahub.core.constants import InfrahubKind
12
+ from infrahub.core.constants import InfrahubKind, MetadataOptions
13
13
  from infrahub.core.ipam.reconciler import IpamReconciler
14
14
  from infrahub.core.manager import NodeManager
15
15
  from infrahub.core.node import Node
@@ -196,8 +196,7 @@ class InfrahubIPAddressMutation(InfrahubMutationMixin, Mutation):
196
196
  kind=cls._meta.schema.kind,
197
197
  id=data.get("id"),
198
198
  branch=branch,
199
- include_owner=True,
200
- include_source=True,
199
+ include_metadata=MetadataOptions.LINKED_NODES,
201
200
  )
202
201
  namespace = await address.ip_namespace.get_peer(db)
203
202
  namespace_id = await validate_namespace(db=db, branch=branch, data=data, existing_namespace_id=namespace.id)
@@ -356,8 +355,7 @@ class InfrahubIPPrefixMutation(InfrahubMutationMixin, Mutation):
356
355
  kind=cls._meta.schema.kind,
357
356
  id=data.get("id"),
358
357
  branch=branch,
359
- include_owner=True,
360
- include_source=True,
358
+ include_metadata=MetadataOptions.LINKED_NODES,
361
359
  )
362
360
  namespace = await prefix.ip_namespace.get_peer(db)
363
361
  namespace_id = await validate_namespace(db=db, branch=branch, data=data, existing_namespace_id=namespace.id)
@@ -435,7 +433,7 @@ class InfrahubIPPrefixMutation(InfrahubMutationMixin, Mutation):
435
433
  data.get("id"), graphql_context.db, branch=branch, prefetch_relationships=True
436
434
  )
437
435
  if not prefix:
438
- raise NodeNotFoundError(branch, cls._meta.schema.kind, data.get("id"))
436
+ raise NodeNotFoundError(node_type=cls._meta.schema.kind, identifier=data.get("id"), branch_name=branch.name)
439
437
 
440
438
  namespace_rels = await prefix.ip_namespace.get_relationships(db=db)
441
439
  namespace_id = namespace_rels[0].peer_id