Skip to content

Commit 9e5d638

Browse files
committed
feat(sdk): add client.get_many for batched multi-kind node fetch
Adds InfrahubClient.get_many (and the sync twin) plus the compile_get_many_query helper. Callers pass a kind -> {ids, attributes} spec and get back one GraphQL operation with one aliased block per kind (k0, k1, ...) and one [ID] variable per kind. The cost is a single round-trip regardless of how many kinds are in the spec — the alternative today is one client.filters call per kind, or a hand-rolled execute_graphql with manual alias plumbing. The compile helper validates kind and attribute names as GraphQL identifiers up front and raises ValidationError listing every problem before any HTTP call is made; server-side rejections still propagate as GraphQLError. Hydration reuses InfrahubNode.from_graphql, so callers get typed attribute access; populate_store=True mirrors client.get. Reuses existing primitives end-to-end: - execute_graphql for the network call - from_graphql for hydration - client.store.set for store population - ValidationError / GraphQLError from infrahub_sdk.exceptions
1 parent 0c2a297 commit 9e5d638

4 files changed

Lines changed: 548 additions & 1 deletion

File tree

docs/docs/python-sdk/sdk_ref/infrahub_sdk/client.mdx

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,40 @@ Requires Infrahub 1.10 or later.
216216
- `VersionNotSupportedError`: If the server does not support graph traversal (pre-1.10).
217217
- `GraphQLError`: When the GraphQL response contains errors (e.g. unknown node).
218218

219+
#### `get_many`
220+
221+
```python
222+
get_many(self, spec: Mapping[str, Mapping[str, Sequence[str]]]) -> dict[str, list[InfrahubNode]]
223+
```
224+
225+
Fetch nodes of multiple kinds in a single GraphQL operation.
226+
227+
``spec`` maps each kind to a dict with a non-empty ``ids`` list (node UUIDs)
228+
and an optional ``attributes`` list (attribute names to populate on the
229+
returned nodes). One aliased subselection is emitted per kind, so the cost
230+
is a single round-trip regardless of how many kinds the spec contains.
231+
232+
Returned nodes only have the requested attributes populated; relationships
233+
are not fetched. For full nodes, follow up with :meth:`get` or :meth:`filters`.
234+
235+
**Args:**
236+
237+
- `spec`: Mapping of kind name to ``{"ids"\: [...], "attributes"\: [...]}``.
238+
``attributes`` is optional and defaults to no attributes (only id
239+
and typename are returned).
240+
- `branch`: Name of the branch to query from. Defaults to ``default_branch``.
241+
- `at`: Time of the query. Defaults to now.
242+
- `timeout`: Overrides the default GraphQL timeout, in seconds.
243+
- `populate_store`: When True (default), every hydrated node is added to
244+
``client.store`` so later lookups by id can skip the network.
245+
246+
**Raises:**
247+
248+
- `ValidationError`: If the spec cannot be compiled into a query (empty,
249+
missing ``ids``, malformed kind/attribute identifiers).
250+
- `GraphQLError`: If the server rejects the query (for example, an unknown
251+
kind or attribute name in the loaded schema).
252+
219253
#### `all`
220254

221255
```python
@@ -758,6 +792,40 @@ Requires Infrahub 1.10 or later.
758792
- `VersionNotSupportedError`: If the server does not support graph traversal (pre-1.10).
759793
- `GraphQLError`: When the GraphQL response contains errors (e.g. unknown node).
760794

795+
#### `get_many`
796+
797+
```python
798+
get_many(self, spec: Mapping[str, Mapping[str, Sequence[str]]]) -> dict[str, list[InfrahubNodeSync]]
799+
```
800+
801+
Fetch nodes of multiple kinds in a single GraphQL operation.
802+
803+
``spec`` maps each kind to a dict with a non-empty ``ids`` list (node UUIDs)
804+
and an optional ``attributes`` list (attribute names to populate on the
805+
returned nodes). One aliased subselection is emitted per kind, so the cost
806+
is a single round-trip regardless of how many kinds the spec contains.
807+
808+
Returned nodes only have the requested attributes populated; relationships
809+
are not fetched. For full nodes, follow up with :meth:`get` or :meth:`filters`.
810+
811+
**Args:**
812+
813+
- `spec`: Mapping of kind name to ``{"ids"\: [...], "attributes"\: [...]}``.
814+
``attributes`` is optional and defaults to no attributes (only id
815+
and typename are returned).
816+
- `branch`: Name of the branch to query from. Defaults to ``default_branch``.
817+
- `at`: Time of the query. Defaults to now.
818+
- `timeout`: Overrides the default GraphQL timeout, in seconds.
819+
- `populate_store`: When True (default), every hydrated node is added to
820+
``client.store`` so later lookups by id can skip the network.
821+
822+
**Raises:**
823+
824+
- `ValidationError`: If the spec cannot be compiled into a query (empty,
825+
missing ``ids``, malformed kind/attribute identifiers).
826+
- `GraphQLError`: If the server rejects the query (for example, an unknown
827+
kind or attribute name in the loaded schema).
828+
761829
#### `all`
762830

763831
```python
@@ -1075,3 +1143,22 @@ handle_relogin_sync(func: Callable[..., httpx.Response]) -> Callable[..., httpx.
10751143
```python
10761144
get_kind_as_string(kind: str | type[SchemaType | SchemaTypeSync]) -> str
10771145
```
1146+
1147+
### `compile_get_many_query`
1148+
1149+
```python
1150+
compile_get_many_query(spec: Mapping[str, Mapping[str, Sequence[str]]]) -> tuple[str, dict[str, list[str]], list[str]]
1151+
```
1152+
1153+
Compile a kind->{ids, attributes} spec into a single aliased GraphQL query.
1154+
1155+
Returns ``(query, variables, kinds_ordered)``. The query is one operation with one
1156+
aliased block per kind (``k0``, ``k1``, ...) and one ``[ID]`` variable per kind
1157+
(``ids_0``, ``ids_1``, ...). Ids and attribute names are deduplicated and sorted
1158+
so the emitted query is deterministic.
1159+
1160+
**Raises:**
1161+
1162+
- `ValidationError`: If the spec is empty, has empty ``ids`` for a kind, omits
1163+
``ids`` entirely, or contains kind/attribute names that are not valid
1164+
GraphQL identifiers.

infrahub_sdk/client.py

Lines changed: 192 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,9 @@
33
import asyncio
44
import copy
55
import logging
6+
import re
67
import time
7-
from collections.abc import AsyncIterator, Callable, Coroutine, Iterable, Iterator, Mapping, MutableMapping
8+
from collections.abc import AsyncIterator, Callable, Coroutine, Iterable, Iterator, Mapping, MutableMapping, Sequence
89
from contextlib import asynccontextmanager, contextmanager
910
from datetime import datetime
1011
from enum import Enum
@@ -33,6 +34,7 @@
3334
ServerNotReachableError,
3435
ServerNotResponsiveError,
3536
URLNotFoundError,
37+
ValidationError,
3638
VersionNotSupportedError,
3739
)
3840
from .graph_traversal.models import PathTraversalResult, ReachableNodesResult
@@ -163,6 +165,65 @@ def _resolve_traversal_node_id(node: str | InfrahubNode | InfrahubNodeSync, *, r
163165
raise Error(f"Cannot use an unsaved node as the graph traversal {role}; save it first.") from exc
164166

165167

168+
_GRAPHQL_IDENTIFIER_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
169+
170+
171+
def compile_get_many_query(
172+
spec: Mapping[str, Mapping[str, Sequence[str]]],
173+
) -> tuple[str, dict[str, list[str]], list[str]]:
174+
"""Compile a kind->{ids, attributes} spec into a single aliased GraphQL query.
175+
176+
Returns ``(query, variables, kinds_ordered)``. The query is one operation with one
177+
aliased block per kind (``k0``, ``k1``, ...) and one ``[ID]`` variable per kind
178+
(``ids_0``, ``ids_1``, ...). Ids and attribute names are deduplicated and sorted
179+
so the emitted query is deterministic.
180+
181+
Raises:
182+
ValidationError: If the spec is empty, has empty ``ids`` for a kind, omits
183+
``ids`` entirely, or contains kind/attribute names that are not valid
184+
GraphQL identifiers.
185+
186+
"""
187+
if not spec:
188+
raise ValidationError(identifier="get_many.spec", message="spec must contain at least one kind")
189+
190+
problems: list[str] = []
191+
kinds_ordered: list[str] = []
192+
variables: dict[str, list[str]] = {}
193+
blocks: list[str] = []
194+
195+
for index, (kind, entry) in enumerate(spec.items()):
196+
if not isinstance(kind, str) or not _GRAPHQL_IDENTIFIER_RE.match(kind):
197+
problems.append(f"kind {kind!r} is not a valid GraphQL identifier")
198+
continue
199+
if not isinstance(entry, Mapping):
200+
problems.append(f"{kind}: entry must be a mapping with an 'ids' key")
201+
continue
202+
ids = entry.get("ids")
203+
if not ids:
204+
problems.append(f"{kind}: 'ids' must be a non-empty list")
205+
continue
206+
attributes = entry.get("attributes") or []
207+
bad_attrs = [a for a in attributes if not isinstance(a, str) or not _GRAPHQL_IDENTIFIER_RE.match(a)]
208+
if bad_attrs:
209+
problems.append(f"{kind}: invalid attribute names {bad_attrs!r}")
210+
continue
211+
212+
var_name = f"ids_{index}"
213+
variables[var_name] = sorted({str(node_id) for node_id in ids})
214+
attrs_selection = " ".join(f"{name} {{ value }}" for name in sorted(set(attributes)))
215+
block_inner = f"__typename id {attrs_selection}".strip()
216+
blocks.append(f"k{index}: {kind}(ids: ${var_name}) {{ edges {{ node {{ {block_inner} }} }} }}")
217+
kinds_ordered.append(kind)
218+
219+
if problems:
220+
raise ValidationError(identifier="get_many.spec", messages=problems)
221+
222+
declarations = ", ".join(f"${name}: [ID]" for name in variables)
223+
query = f"query GetMany({declarations}) {{ {' '.join(blocks)} }}"
224+
return query, variables, kinds_ordered
225+
226+
166227
class BaseClient:
167228
"""Base class for InfrahubClient and InfrahubClientSync."""
168229

@@ -875,6 +936,71 @@ async def reachable_nodes(
875936
result = ReachableNodesResult.model_validate(response["InfrahubReachableNodes"])
876937
return result._bind(self, branch or self.default_branch)
877938

939+
async def get_many(
940+
self,
941+
spec: Mapping[str, Mapping[str, Sequence[str]]],
942+
*,
943+
branch: str | None = None,
944+
at: Timestamp | str | None = None,
945+
timeout: int | None = None,
946+
populate_store: bool = True,
947+
) -> dict[str, list[InfrahubNode]]:
948+
"""Fetch nodes of multiple kinds in a single GraphQL operation.
949+
950+
``spec`` maps each kind to a dict with a non-empty ``ids`` list (node UUIDs)
951+
and an optional ``attributes`` list (attribute names to populate on the
952+
returned nodes). One aliased subselection is emitted per kind, so the cost
953+
is a single round-trip regardless of how many kinds the spec contains.
954+
955+
Returned nodes only have the requested attributes populated; relationships
956+
are not fetched. For full nodes, follow up with :meth:`get` or :meth:`filters`.
957+
958+
Args:
959+
spec: Mapping of kind name to ``{"ids": [...], "attributes": [...]}``.
960+
``attributes`` is optional and defaults to no attributes (only id
961+
and typename are returned).
962+
branch: Name of the branch to query from. Defaults to ``default_branch``.
963+
at: Time of the query. Defaults to now.
964+
timeout: Overrides the default GraphQL timeout, in seconds.
965+
populate_store: When True (default), every hydrated node is added to
966+
``client.store`` so later lookups by id can skip the network.
967+
968+
Raises:
969+
ValidationError: If the spec cannot be compiled into a query (empty,
970+
missing ``ids``, malformed kind/attribute identifiers).
971+
GraphQLError: If the server rejects the query (for example, an unknown
972+
kind or attribute name in the loaded schema).
973+
974+
"""
975+
query, variables, kinds_ordered = compile_get_many_query(spec)
976+
branch = branch or self.default_branch
977+
response = await self.execute_graphql(
978+
query=query,
979+
variables=variables,
980+
branch_name=branch,
981+
at=Timestamp(at) if at else None,
982+
timeout=timeout,
983+
tracker="query-get-many",
984+
operation_name="GetMany",
985+
)
986+
987+
results: dict[str, list[InfrahubNode]] = {}
988+
for index, kind in enumerate(kinds_ordered):
989+
block = (response or {}).get(f"k{index}") or {}
990+
nodes: list[InfrahubNode] = []
991+
for edge in block.get("edges") or []:
992+
node = await InfrahubNode.from_graphql(
993+
client=self,
994+
branch=branch,
995+
data=edge,
996+
timeout=timeout,
997+
)
998+
if populate_store:
999+
self.store.set(node=node)
1000+
nodes.append(node)
1001+
results[kind] = nodes
1002+
return results
1003+
8781004
@overload
8791005
async def all(
8801006
self,
@@ -2571,6 +2697,71 @@ def reachable_nodes(
25712697
result = ReachableNodesResult.model_validate(response["InfrahubReachableNodes"])
25722698
return result._bind(self, branch or self.default_branch)
25732699

2700+
def get_many(
2701+
self,
2702+
spec: Mapping[str, Mapping[str, Sequence[str]]],
2703+
*,
2704+
branch: str | None = None,
2705+
at: Timestamp | str | None = None,
2706+
timeout: int | None = None,
2707+
populate_store: bool = True,
2708+
) -> dict[str, list[InfrahubNodeSync]]:
2709+
"""Fetch nodes of multiple kinds in a single GraphQL operation.
2710+
2711+
``spec`` maps each kind to a dict with a non-empty ``ids`` list (node UUIDs)
2712+
and an optional ``attributes`` list (attribute names to populate on the
2713+
returned nodes). One aliased subselection is emitted per kind, so the cost
2714+
is a single round-trip regardless of how many kinds the spec contains.
2715+
2716+
Returned nodes only have the requested attributes populated; relationships
2717+
are not fetched. For full nodes, follow up with :meth:`get` or :meth:`filters`.
2718+
2719+
Args:
2720+
spec: Mapping of kind name to ``{"ids": [...], "attributes": [...]}``.
2721+
``attributes`` is optional and defaults to no attributes (only id
2722+
and typename are returned).
2723+
branch: Name of the branch to query from. Defaults to ``default_branch``.
2724+
at: Time of the query. Defaults to now.
2725+
timeout: Overrides the default GraphQL timeout, in seconds.
2726+
populate_store: When True (default), every hydrated node is added to
2727+
``client.store`` so later lookups by id can skip the network.
2728+
2729+
Raises:
2730+
ValidationError: If the spec cannot be compiled into a query (empty,
2731+
missing ``ids``, malformed kind/attribute identifiers).
2732+
GraphQLError: If the server rejects the query (for example, an unknown
2733+
kind or attribute name in the loaded schema).
2734+
2735+
"""
2736+
query, variables, kinds_ordered = compile_get_many_query(spec)
2737+
branch = branch or self.default_branch
2738+
response = self.execute_graphql(
2739+
query=query,
2740+
variables=variables,
2741+
branch_name=branch,
2742+
at=Timestamp(at) if at else None,
2743+
timeout=timeout,
2744+
tracker="query-get-many",
2745+
operation_name="GetMany",
2746+
)
2747+
2748+
results: dict[str, list[InfrahubNodeSync]] = {}
2749+
for index, kind in enumerate(kinds_ordered):
2750+
block = (response or {}).get(f"k{index}") or {}
2751+
nodes: list[InfrahubNodeSync] = []
2752+
for edge in block.get("edges") or []:
2753+
node = InfrahubNodeSync.from_graphql(
2754+
client=self,
2755+
branch=branch,
2756+
data=edge,
2757+
timeout=timeout,
2758+
)
2759+
if populate_store:
2760+
self.store.set(node=node)
2761+
nodes.append(node)
2762+
results[kind] = nodes
2763+
return results
2764+
25742765
@overload
25752766
def all(
25762767
self,

tests/unit/sdk/conftest.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ def return_annotation_map() -> dict[str, str]:
7676
"InfrahubClient": "InfrahubClientSync",
7777
"InfrahubNode": "InfrahubNodeSync",
7878
"list[InfrahubNode]": "list[InfrahubNodeSync]",
79+
"dict[str, list[InfrahubNode]]": "dict[str, list[InfrahubNodeSync]]",
7980
"Optional[InfrahubNode]": "Optional[InfrahubNodeSync]",
8081
"Optional[type[SchemaType]]": "Optional[type[SchemaTypeSync]]",
8182
"Optional[Union[CoreNode, SchemaType]]": "Optional[Union[CoreNodeSync, SchemaTypeSync]]",

0 commit comments

Comments
 (0)