From 5b8047c9bcc6972051efef1ed7ceac8685b0e5f6 Mon Sep 17 00:00:00 2001 From: Iddo Date: Sun, 28 Jun 2026 16:33:00 +0200 Subject: [PATCH] feat(sdk): add client.get_many for batched multi-kind node fetch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 (ids_0, ids_1, ...). 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. Scalar str/bytes passed where a list is expected for ids or attributes are rejected explicitly — Python's iteration over a bare string would otherwise turn one id or attribute into N one-character entries, silently producing an invalid query. 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 - InfrahubNode.from_graphql / InfrahubNodeSync.from_graphql for hydration - client.store.set for store population - ValidationError / GraphQLError from infrahub_sdk.exceptions No new exception classes, no new result types, no new dependencies. The return is dict[str, list[InfrahubNode]]. --- .../sdk_ref/infrahub_sdk/client.mdx | 92 ++++++ infrahub_sdk/client.py | 206 +++++++++++- tests/unit/sdk/conftest.py | 1 + tests/unit/sdk/test_get_many.py | 292 ++++++++++++++++++ 4 files changed, 590 insertions(+), 1 deletion(-) create mode 100644 tests/unit/sdk/test_get_many.py diff --git a/docs/docs/python-sdk/sdk_ref/infrahub_sdk/client.mdx b/docs/docs/python-sdk/sdk_ref/infrahub_sdk/client.mdx index c23de529..85c05ec9 100644 --- a/docs/docs/python-sdk/sdk_ref/infrahub_sdk/client.mdx +++ b/docs/docs/python-sdk/sdk_ref/infrahub_sdk/client.mdx @@ -216,6 +216,41 @@ Requires Infrahub 1.10 or later. - `VersionNotSupportedError`: If the server does not support graph traversal (pre-1.10). - `GraphQLError`: When the GraphQL response contains errors (e.g. unknown node). +#### `get_many` + +```python +get_many(self, spec: Mapping[str, Mapping[str, Sequence[str]]]) -> dict[str, list[InfrahubNode]] +``` + +Fetch nodes of multiple kinds in a single GraphQL operation. + +``spec`` maps each kind to a dict with a non-empty ``ids`` list (node UUIDs) +and an optional ``attributes`` list (attribute names to populate on the +returned nodes). One aliased subselection is emitted per kind, so the cost +is a single round-trip regardless of how many kinds the spec contains. + +Returned nodes only have the requested attributes populated; relationships +are not fetched. For full nodes, follow up with :meth:`get` or :meth:`filters`. + +**Args:** + +- `spec`: Mapping of kind name to a sub-mapping with keys ``ids`` (list of +node UUIDs, required and non-empty) and ``attributes`` (list of +attribute names, optional - defaults to none, in which case only +the id and typename are returned). +- `branch`: Name of the branch to query from. Defaults to ``default_branch``. +- `at`: Time of the query. Defaults to now. +- `timeout`: Overrides the default GraphQL timeout, in seconds. +- `populate_store`: When True (default), every hydrated node is added to +``client.store`` so later lookups by id can skip the network. + +**Raises:** + +- `ValidationError`: If the spec cannot be compiled into a query (empty, +missing ``ids``, malformed kind/attribute identifiers). +- `GraphQLError`: If the server rejects the query (for example, an unknown +kind or attribute name in the loaded schema). + #### `all` ```python @@ -758,6 +793,41 @@ Requires Infrahub 1.10 or later. - `VersionNotSupportedError`: If the server does not support graph traversal (pre-1.10). - `GraphQLError`: When the GraphQL response contains errors (e.g. unknown node). +#### `get_many` + +```python +get_many(self, spec: Mapping[str, Mapping[str, Sequence[str]]]) -> dict[str, list[InfrahubNodeSync]] +``` + +Fetch nodes of multiple kinds in a single GraphQL operation. + +``spec`` maps each kind to a dict with a non-empty ``ids`` list (node UUIDs) +and an optional ``attributes`` list (attribute names to populate on the +returned nodes). One aliased subselection is emitted per kind, so the cost +is a single round-trip regardless of how many kinds the spec contains. + +Returned nodes only have the requested attributes populated; relationships +are not fetched. For full nodes, follow up with :meth:`get` or :meth:`filters`. + +**Args:** + +- `spec`: Mapping of kind name to a sub-mapping with keys ``ids`` (list of +node UUIDs, required and non-empty) and ``attributes`` (list of +attribute names, optional - defaults to none, in which case only +the id and typename are returned). +- `branch`: Name of the branch to query from. Defaults to ``default_branch``. +- `at`: Time of the query. Defaults to now. +- `timeout`: Overrides the default GraphQL timeout, in seconds. +- `populate_store`: When True (default), every hydrated node is added to +``client.store`` so later lookups by id can skip the network. + +**Raises:** + +- `ValidationError`: If the spec cannot be compiled into a query (empty, +missing ``ids``, malformed kind/attribute identifiers). +- `GraphQLError`: If the server rejects the query (for example, an unknown +kind or attribute name in the loaded schema). + #### `all` ```python @@ -1075,3 +1145,25 @@ handle_relogin_sync(func: Callable[..., httpx.Response]) -> Callable[..., httpx. ```python get_kind_as_string(kind: str | type[SchemaType | SchemaTypeSync]) -> str ``` + +### `compile_get_many_query` + +```python +compile_get_many_query(spec: Mapping[str, Mapping[str, Sequence[str]]]) -> tuple[str, dict[str, list[str]], list[str]] +``` + +Compile a kind-to-spec mapping into a single aliased GraphQL query. + +Each entry of the input maps a kind name to a sub-mapping with an +``ids`` list (required, non-empty) and an optional ``attributes`` list. + +Returns ``(query, variables, kinds_ordered)``. The query is one operation with one +aliased block per kind (``k0``, ``k1``, ...) and one ``[ID]`` variable per kind +(``ids_0``, ``ids_1``, ...). Ids and attribute names are deduplicated and sorted +so the emitted query is deterministic. + +**Raises:** + +- `ValidationError`: If the spec is empty, has empty ``ids`` for a kind, omits +``ids`` entirely, or contains kind/attribute names that are not valid +GraphQL identifiers. diff --git a/infrahub_sdk/client.py b/infrahub_sdk/client.py index 01e8d5f0..8b794c45 100644 --- a/infrahub_sdk/client.py +++ b/infrahub_sdk/client.py @@ -3,8 +3,9 @@ import asyncio import copy import logging +import re import time -from collections.abc import AsyncIterator, Callable, Coroutine, Iterable, Iterator, Mapping, MutableMapping +from collections.abc import AsyncIterator, Callable, Coroutine, Iterable, Iterator, Mapping, MutableMapping, Sequence from contextlib import asynccontextmanager, contextmanager from datetime import datetime from enum import Enum @@ -33,6 +34,7 @@ ServerNotReachableError, ServerNotResponsiveError, URLNotFoundError, + ValidationError, VersionNotSupportedError, ) from .graph_traversal.models import PathTraversalResult, ReachableNodesResult @@ -163,6 +165,76 @@ def _resolve_traversal_node_id(node: str | InfrahubNode | InfrahubNodeSync, *, r raise Error(f"Cannot use an unsaved node as the graph traversal {role}; save it first.") from exc +_GRAPHQL_IDENTIFIER_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") + + +def compile_get_many_query( + spec: Mapping[str, Mapping[str, Sequence[str]]], +) -> tuple[str, dict[str, list[str]], list[str]]: + """Compile a kind-to-spec mapping into a single aliased GraphQL query. + + Each entry of the input maps a kind name to a sub-mapping with an + ``ids`` list (required, non-empty) and an optional ``attributes`` list. + + Returns ``(query, variables, kinds_ordered)``. The query is one operation with one + aliased block per kind (``k0``, ``k1``, ...) and one ``[ID]`` variable per kind + (``ids_0``, ``ids_1``, ...). Ids and attribute names are deduplicated and sorted + so the emitted query is deterministic. + + Raises: + ValidationError: If the spec is empty, has empty ``ids`` for a kind, omits + ``ids`` entirely, or contains kind/attribute names that are not valid + GraphQL identifiers. + + """ + if not spec: + raise ValidationError(identifier="get_many.spec", message="spec must contain at least one kind") + + problems: list[str] = [] + kinds_ordered: list[str] = [] + variables: dict[str, list[str]] = {} + blocks: list[str] = [] + + for index, (kind, entry) in enumerate(spec.items()): + if not isinstance(kind, str) or not _GRAPHQL_IDENTIFIER_RE.match(kind): + problems.append(f"kind {kind!r} is not a valid GraphQL identifier") + continue + if not isinstance(entry, Mapping): + problems.append(f"{kind}: entry must be a mapping with an 'ids' key") + continue + ids = entry.get("ids") + if not ids or isinstance(ids, (str, bytes)): + # A bare string passed for ``ids`` would iterate character-by-character + # below and turn one id into N one-character GraphQL ids — silently + # wrong. Reject up front. + problems.append(f"{kind}: 'ids' must be a non-empty list of id strings") + continue + attributes = entry.get("attributes") or [] + if isinstance(attributes, (str, bytes)): + # Same trap as ``ids``: a bare string would split into single-char + # field names that each pass the identifier regex. + problems.append(f"{kind}: 'attributes' must be a list of attribute names") + continue + bad_attrs = [a for a in attributes if not isinstance(a, str) or not _GRAPHQL_IDENTIFIER_RE.match(a)] + if bad_attrs: + problems.append(f"{kind}: invalid attribute names {bad_attrs!r}") + continue + + var_name = f"ids_{index}" + variables[var_name] = sorted({str(node_id) for node_id in ids}) + attrs_selection = " ".join(f"{name} {{ value }}" for name in sorted(set(attributes))) + block_inner = f"__typename id {attrs_selection}".strip() + blocks.append(f"k{index}: {kind}(ids: ${var_name}) {{ edges {{ node {{ {block_inner} }} }} }}") + kinds_ordered.append(kind) + + if problems: + raise ValidationError(identifier="get_many.spec", messages=problems) + + declarations = ", ".join(f"${name}: [ID]" for name in variables) + query = f"query GetMany({declarations}) {{ {' '.join(blocks)} }}" + return query, variables, kinds_ordered + + class BaseClient: """Base class for InfrahubClient and InfrahubClientSync.""" @@ -875,6 +947,72 @@ async def reachable_nodes( result = ReachableNodesResult.model_validate(response["InfrahubReachableNodes"]) return result._bind(self, branch or self.default_branch) + async def get_many( + self, + spec: Mapping[str, Mapping[str, Sequence[str]]], + *, + branch: str | None = None, + at: Timestamp | str | None = None, + timeout: int | None = None, + populate_store: bool = True, + ) -> dict[str, list[InfrahubNode]]: + """Fetch nodes of multiple kinds in a single GraphQL operation. + + ``spec`` maps each kind to a dict with a non-empty ``ids`` list (node UUIDs) + and an optional ``attributes`` list (attribute names to populate on the + returned nodes). One aliased subselection is emitted per kind, so the cost + is a single round-trip regardless of how many kinds the spec contains. + + Returned nodes only have the requested attributes populated; relationships + are not fetched. For full nodes, follow up with :meth:`get` or :meth:`filters`. + + Args: + spec: Mapping of kind name to a sub-mapping with keys ``ids`` (list of + node UUIDs, required and non-empty) and ``attributes`` (list of + attribute names, optional - defaults to none, in which case only + the id and typename are returned). + branch: Name of the branch to query from. Defaults to ``default_branch``. + at: Time of the query. Defaults to now. + timeout: Overrides the default GraphQL timeout, in seconds. + populate_store: When True (default), every hydrated node is added to + ``client.store`` so later lookups by id can skip the network. + + Raises: + ValidationError: If the spec cannot be compiled into a query (empty, + missing ``ids``, malformed kind/attribute identifiers). + GraphQLError: If the server rejects the query (for example, an unknown + kind or attribute name in the loaded schema). + + """ + query, variables, kinds_ordered = compile_get_many_query(spec) + branch = branch or self.default_branch + response = await self.execute_graphql( + query=query, + variables=variables, + branch_name=branch, + at=Timestamp(at) if at else None, + timeout=timeout, + tracker="query-get-many", + operation_name="GetMany", + ) + + results: dict[str, list[InfrahubNode]] = {} + for index, kind in enumerate(kinds_ordered): + block = (response or {}).get(f"k{index}") or {} + nodes: list[InfrahubNode] = [] + for edge in block.get("edges") or []: + node = await InfrahubNode.from_graphql( + client=self, + branch=branch, + data=edge, + timeout=timeout, + ) + if populate_store: + self.store.set(node=node) + nodes.append(node) + results[kind] = nodes + return results + @overload async def all( self, @@ -2571,6 +2709,72 @@ def reachable_nodes( result = ReachableNodesResult.model_validate(response["InfrahubReachableNodes"]) return result._bind(self, branch or self.default_branch) + def get_many( + self, + spec: Mapping[str, Mapping[str, Sequence[str]]], + *, + branch: str | None = None, + at: Timestamp | str | None = None, + timeout: int | None = None, + populate_store: bool = True, + ) -> dict[str, list[InfrahubNodeSync]]: + """Fetch nodes of multiple kinds in a single GraphQL operation. + + ``spec`` maps each kind to a dict with a non-empty ``ids`` list (node UUIDs) + and an optional ``attributes`` list (attribute names to populate on the + returned nodes). One aliased subselection is emitted per kind, so the cost + is a single round-trip regardless of how many kinds the spec contains. + + Returned nodes only have the requested attributes populated; relationships + are not fetched. For full nodes, follow up with :meth:`get` or :meth:`filters`. + + Args: + spec: Mapping of kind name to a sub-mapping with keys ``ids`` (list of + node UUIDs, required and non-empty) and ``attributes`` (list of + attribute names, optional - defaults to none, in which case only + the id and typename are returned). + branch: Name of the branch to query from. Defaults to ``default_branch``. + at: Time of the query. Defaults to now. + timeout: Overrides the default GraphQL timeout, in seconds. + populate_store: When True (default), every hydrated node is added to + ``client.store`` so later lookups by id can skip the network. + + Raises: + ValidationError: If the spec cannot be compiled into a query (empty, + missing ``ids``, malformed kind/attribute identifiers). + GraphQLError: If the server rejects the query (for example, an unknown + kind or attribute name in the loaded schema). + + """ + query, variables, kinds_ordered = compile_get_many_query(spec) + branch = branch or self.default_branch + response = self.execute_graphql( + query=query, + variables=variables, + branch_name=branch, + at=Timestamp(at) if at else None, + timeout=timeout, + tracker="query-get-many", + operation_name="GetMany", + ) + + results: dict[str, list[InfrahubNodeSync]] = {} + for index, kind in enumerate(kinds_ordered): + block = (response or {}).get(f"k{index}") or {} + nodes: list[InfrahubNodeSync] = [] + for edge in block.get("edges") or []: + node = InfrahubNodeSync.from_graphql( + client=self, + branch=branch, + data=edge, + timeout=timeout, + ) + if populate_store: + self.store.set(node=node) + nodes.append(node) + results[kind] = nodes + return results + @overload def all( self, diff --git a/tests/unit/sdk/conftest.py b/tests/unit/sdk/conftest.py index ad5b70fd..43ec3599 100644 --- a/tests/unit/sdk/conftest.py +++ b/tests/unit/sdk/conftest.py @@ -76,6 +76,7 @@ def return_annotation_map() -> dict[str, str]: "InfrahubClient": "InfrahubClientSync", "InfrahubNode": "InfrahubNodeSync", "list[InfrahubNode]": "list[InfrahubNodeSync]", + "dict[str, list[InfrahubNode]]": "dict[str, list[InfrahubNodeSync]]", "Optional[InfrahubNode]": "Optional[InfrahubNodeSync]", "Optional[type[SchemaType]]": "Optional[type[SchemaTypeSync]]", "Optional[Union[CoreNode, SchemaType]]": "Optional[Union[CoreNodeSync, SchemaTypeSync]]", diff --git a/tests/unit/sdk/test_get_many.py b/tests/unit/sdk/test_get_many.py new file mode 100644 index 00000000..f5a6d1cf --- /dev/null +++ b/tests/unit/sdk/test_get_many.py @@ -0,0 +1,292 @@ +"""Tests for InfrahubClient.get_many and its query compiler. + +Pure-function tests cover the compiler's output and error paths; +parametrized BothClients tests cover the full HTTP round trip via +``httpx_mock`` at the transport boundary. +""" + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING + +import pytest + +from infrahub_sdk.client import compile_get_many_query +from infrahub_sdk.exceptions import NodeNotFoundError, ValidationError + +if TYPE_CHECKING: + from pytest_httpx import HTTPXMock + + from infrahub_sdk.schema import NodeSchemaAPI + + from .conftest import BothClients + + +GET_MANY_URL = "http://mock/graphql/main" + + +# --- compiler (pure logic) -------------------------------------------------- + + +def test_single_kind_single_id_single_attribute() -> None: + query, variables, kinds = compile_get_many_query({"InfraDevice": {"ids": ["dev-1"], "attributes": ["name"]}}) + assert kinds == ["InfraDevice"] + assert variables == {"ids_0": ["dev-1"]} + assert query == ( + "query GetMany($ids_0: [ID]) { " + "k0: InfraDevice(ids: $ids_0) " + "{ edges { node { __typename id name { value } } } } }" + ) + + +def test_multiple_kinds_emit_one_alias_per_block() -> None: + query, variables, kinds = compile_get_many_query( + { + "InfraAutonomousSystem": {"ids": ["as-1", "as-2"], "attributes": ["asn"]}, + "InfraDevice": {"ids": ["dev-1"], "attributes": ["role", "name"]}, + } + ) + assert kinds == ["InfraAutonomousSystem", "InfraDevice"] + assert variables == {"ids_0": ["as-1", "as-2"], "ids_1": ["dev-1"]} + assert "k0: InfraAutonomousSystem(ids: $ids_0)" in query + assert "k1: InfraDevice(ids: $ids_1)" in query + assert "asn { value }" in query + # Attribute selection is sorted within a block. + assert query.index("name { value }") < query.index("role { value }") + assert "$ids_0: [ID]" in query + assert "$ids_1: [ID]" in query + + +def test_duplicate_ids_are_deduplicated_and_sorted() -> None: + _query, variables, _kinds = compile_get_many_query( + {"InfraDevice": {"ids": ["dev-2", "dev-1", "dev-1", "dev-2"], "attributes": ["name"]}} + ) + assert variables == {"ids_0": ["dev-1", "dev-2"]} + + +def test_duplicate_attributes_are_deduplicated() -> None: + query, _variables, _kinds = compile_get_many_query( + {"InfraDevice": {"ids": ["dev-1"], "attributes": ["name", "name", "role"]}} + ) + assert query.count("name { value }") == 1 + assert query.count("role { value }") == 1 + + +def test_omitted_attributes_emits_minimal_selection() -> None: + query, _variables, _kinds = compile_get_many_query({"InfraDevice": {"ids": ["dev-1"]}}) + assert "node { __typename id }" in query + assert "{ value }" not in query + + +def test_empty_attributes_list_emits_minimal_selection() -> None: + query, _variables, _kinds = compile_get_many_query({"InfraDevice": {"ids": ["dev-1"], "attributes": []}}) + assert "node { __typename id }" in query + + +def test_kind_order_in_spec_is_preserved() -> None: + _query, _variables, kinds = compile_get_many_query( + {"Zeta": {"ids": ["z"]}, "Alpha": {"ids": ["a"]}, "Mu": {"ids": ["m"]}} + ) + assert kinds == ["Zeta", "Alpha", "Mu"] + + +def test_empty_spec_raises() -> None: + with pytest.raises(ValidationError, match="spec must contain at least one kind"): + compile_get_many_query({}) + + +def test_empty_ids_list_raises() -> None: + with pytest.raises(ValidationError, match="'ids' must be a non-empty list"): + compile_get_many_query({"InfraDevice": {"ids": [], "attributes": ["name"]}}) + + +def test_missing_ids_key_raises() -> None: + with pytest.raises(ValidationError, match="'ids' must be a non-empty list"): + compile_get_many_query({"InfraDevice": {"attributes": ["name"]}}) + + +def test_non_mapping_entry_raises() -> None: + with pytest.raises(ValidationError, match="entry must be a mapping"): + compile_get_many_query({"InfraDevice": ["dev-1"]}) # type: ignore[dict-item] + + +def test_invalid_kind_identifier_raises() -> None: + with pytest.raises(ValidationError, match="not a valid GraphQL identifier"): + compile_get_many_query({"Infra Device": {"ids": ["dev-1"]}}) + + +def test_invalid_attribute_name_raises() -> None: + with pytest.raises(ValidationError, match="invalid attribute names"): + compile_get_many_query({"InfraDevice": {"ids": ["dev-1"], "attributes": ["name", "bad name"]}}) + + +def test_string_ids_rejected_not_iterated_as_chars() -> None: + # Passing a bare string would silently iterate character-by-character and + # send N one-character GraphQL ids. Reject up front. + with pytest.raises(ValidationError, match="'ids' must be a non-empty list"): + compile_get_many_query({"InfraDevice": {"ids": "dev-1"}}) # type: ignore[dict-item] + + +def test_bytes_ids_rejected() -> None: + with pytest.raises(ValidationError, match="'ids' must be a non-empty list"): + compile_get_many_query({"InfraDevice": {"ids": b"dev-1"}}) # type: ignore[dict-item] + + +def test_string_attributes_rejected_not_iterated_as_chars() -> None: + # Same trap as ``ids``: a bare string would split into single-character + # field names that each pass the GraphQL identifier regex. + with pytest.raises(ValidationError, match="'attributes' must be a list"): + compile_get_many_query({"InfraDevice": {"ids": ["dev-1"], "attributes": "name"}}) # type: ignore[dict-item] + + +def test_bytes_attributes_rejected() -> None: + with pytest.raises(ValidationError, match="'attributes' must be a list"): + compile_get_many_query({"InfraDevice": {"ids": ["dev-1"], "attributes": b"name"}}) # type: ignore[dict-item] + + +def test_multiple_problems_are_collected() -> None: + with pytest.raises(ValidationError) as excinfo: + compile_get_many_query( + { + "InfraDevice": {"ids": []}, + "Bad Kind": {"ids": ["a"]}, + "InfraSite": {"ids": ["s"], "attributes": ["bad attr"]}, + } + ) + assert excinfo.value.messages is not None + assert len(excinfo.value.messages) == 3 + + +# --- client methods (httpx_mock at the transport boundary) ------------------ + + +def _seed_schema(clients: BothClients, schema: NodeSchemaAPI) -> None: + cache_data = {"version": "1.0", "nodes": [schema.model_dump()]} + clients.standard.schema.set_cache(cache_data) + clients.sync.schema.set_cache(cache_data) + + +def _location_response(*, ids: list[str]) -> dict: + return { + "data": { + "k0": { + "edges": [ + { + "node": { + "__typename": "BuiltinLocation", + "id": node_id, + "name": {"value": f"site-{node_id}"}, + "type": {"value": "datacenter"}, + } + } + for node_id in ids + ] + } + } + } + + +@pytest.mark.parametrize("client_type", ["standard", "sync"]) +async def test_get_many_single_kind_round_trip( + clients: BothClients, + client_type: str, + location_schema: NodeSchemaAPI, + httpx_mock: HTTPXMock, +) -> None: + _seed_schema(clients, location_schema) + httpx_mock.add_response( + method="POST", + url=GET_MANY_URL, + match_headers={"X-Infrahub-Tracker": "query-get-many"}, + json=_location_response(ids=["loc-1", "loc-2"]), + ) + spec = {"BuiltinLocation": {"ids": ["loc-2", "loc-1"], "attributes": ["name", "type"]}} + + if client_type == "standard": + result = await clients.standard.get_many(spec) + else: + result = clients.sync.get_many(spec) + + assert list(result.keys()) == ["BuiltinLocation"] + nodes = result["BuiltinLocation"] + assert len(nodes) == 2 + assert {node.id for node in nodes} == {"loc-1", "loc-2"} + assert {node.get_kind() for node in nodes} == {"BuiltinLocation"} + + # The compiled query uses sorted/deduplicated ids + sent = json.loads(httpx_mock.get_requests()[0].content) + assert sent["variables"] == {"ids_0": ["loc-1", "loc-2"]} + assert "k0: BuiltinLocation(ids: $ids_0)" in sent["query"] + + +@pytest.mark.parametrize("client_type", ["standard", "sync"]) +async def test_get_many_populates_store_when_enabled( + clients: BothClients, + client_type: str, + location_schema: NodeSchemaAPI, + httpx_mock: HTTPXMock, +) -> None: + _seed_schema(clients, location_schema) + httpx_mock.add_response( + method="POST", + url=GET_MANY_URL, + match_headers={"X-Infrahub-Tracker": "query-get-many"}, + json=_location_response(ids=["loc-1"]), + ) + spec = {"BuiltinLocation": {"ids": ["loc-1"], "attributes": ["name"]}} + + if client_type == "standard": + await clients.standard.get_many(spec) + stored = clients.standard.store.get(key="loc-1") + else: + clients.sync.get_many(spec) + stored = clients.sync.store.get(key="loc-1") + + assert stored.id == "loc-1" + + +@pytest.mark.parametrize("client_type", ["standard", "sync"]) +async def test_get_many_skips_store_when_disabled( + clients: BothClients, + client_type: str, + location_schema: NodeSchemaAPI, + httpx_mock: HTTPXMock, +) -> None: + _seed_schema(clients, location_schema) + httpx_mock.add_response( + method="POST", + url=GET_MANY_URL, + match_headers={"X-Infrahub-Tracker": "query-get-many"}, + json=_location_response(ids=["loc-1"]), + ) + spec = {"BuiltinLocation": {"ids": ["loc-1"], "attributes": ["name"]}} + + if client_type == "standard": + await clients.standard.get_many(spec, populate_store=False) + store = clients.standard.store + else: + clients.sync.get_many(spec, populate_store=False) + store = clients.sync.store + + with pytest.raises(NodeNotFoundError): + store.get(key="loc-1") + + +@pytest.mark.parametrize("client_type", ["standard", "sync"]) +async def test_get_many_raises_on_invalid_spec_without_calling_server( + clients: BothClients, + client_type: str, + httpx_mock: HTTPXMock, +) -> None: + spec = {"BuiltinLocation": {"ids": []}} + + if client_type == "standard": + with pytest.raises(ValidationError, match="'ids' must be a non-empty list"): + await clients.standard.get_many(spec) + else: + with pytest.raises(ValidationError, match="'ids' must be a non-empty list"): + clients.sync.get_many(spec) + + # The compile failure short-circuits before any HTTP call is made. + assert httpx_mock.get_requests() == []