|
3 | 3 | import asyncio |
4 | 4 | import copy |
5 | 5 | import logging |
| 6 | +import re |
6 | 7 | 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 |
8 | 9 | from contextlib import asynccontextmanager, contextmanager |
9 | 10 | from datetime import datetime |
10 | 11 | from enum import Enum |
|
33 | 34 | ServerNotReachableError, |
34 | 35 | ServerNotResponsiveError, |
35 | 36 | URLNotFoundError, |
| 37 | + ValidationError, |
36 | 38 | VersionNotSupportedError, |
37 | 39 | ) |
38 | 40 | from .graph_traversal.models import PathTraversalResult, ReachableNodesResult |
@@ -163,6 +165,65 @@ def _resolve_traversal_node_id(node: str | InfrahubNode | InfrahubNodeSync, *, r |
163 | 165 | raise Error(f"Cannot use an unsaved node as the graph traversal {role}; save it first.") from exc |
164 | 166 |
|
165 | 167 |
|
| 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 | + |
166 | 227 | class BaseClient: |
167 | 228 | """Base class for InfrahubClient and InfrahubClientSync.""" |
168 | 229 |
|
@@ -875,6 +936,71 @@ async def reachable_nodes( |
875 | 936 | result = ReachableNodesResult.model_validate(response["InfrahubReachableNodes"]) |
876 | 937 | return result._bind(self, branch or self.default_branch) |
877 | 938 |
|
| 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 | + |
878 | 1004 | @overload |
879 | 1005 | async def all( |
880 | 1006 | self, |
@@ -2571,6 +2697,71 @@ def reachable_nodes( |
2571 | 2697 | result = ReachableNodesResult.model_validate(response["InfrahubReachableNodes"]) |
2572 | 2698 | return result._bind(self, branch or self.default_branch) |
2573 | 2699 |
|
| 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 | + |
2574 | 2765 | @overload |
2575 | 2766 | def all( |
2576 | 2767 | self, |
|
0 commit comments