From ac9e0f73b5a9a723fd9c792d1d94a71a608c57bf Mon Sep 17 00:00:00 2001 From: Infrahub Date: Thu, 23 Jul 2026 06:20:30 +0000 Subject: [PATCH] perf(client): pass pagination offset and limit as GraphQL variables Queries generated by all()/filters()/get() and the resource pool allocation lookup previously inlined offset and limit into the query text, so every page of a paginated fetch produced a different GraphQL document and could never hit the server-side query cache. Pagination now travels as $offset/$limit variables: the document stays identical across pages, and the query is rendered once per call instead of once per page. generate_query_data() and generate_query_data_init() accept variable placeholder strings (e.g. "$offset") for offset and limit, matching the existing $pool_id convention. Co-Authored-By: Claude Fable 5 --- .../+graphql-pagination-variables.changed.md | 1 + .../sdk_ref/infrahub_sdk/node/node.mdx | 24 ++-- infrahub_sdk/client.py | 76 +++++++----- infrahub_sdk/node/node.py | 116 ++++++++++-------- tests/unit/sdk/test_client.py | 37 +++++- 5 files changed, 161 insertions(+), 93 deletions(-) create mode 100644 changelog/+graphql-pagination-variables.changed.md diff --git a/changelog/+graphql-pagination-variables.changed.md b/changelog/+graphql-pagination-variables.changed.md new file mode 100644 index 000000000..d0e9c10cb --- /dev/null +++ b/changelog/+graphql-pagination-variables.changed.md @@ -0,0 +1 @@ +Paginated queries generated by `all()`, `filters()`, `get()` and resource pool allocation lookups now pass `offset` and `limit` as GraphQL variables instead of inlining them in the query text. The query document stays identical across pages, allowing the Infrahub server to reuse its cached query analysis, and the query is now rendered once per call instead of once per page. `generate_query_data` also accepts variable placeholder strings (for example `"$offset"`) for its `offset` and `limit` arguments. diff --git a/docs/docs/python-sdk/sdk_ref/infrahub_sdk/node/node.mdx b/docs/docs/python-sdk/sdk_ref/infrahub_sdk/node/node.mdx index acd975460..ef2823562 100644 --- a/docs/docs/python-sdk/sdk_ref/infrahub_sdk/node/node.mdx +++ b/docs/docs/python-sdk/sdk_ref/infrahub_sdk/node/node.mdx @@ -301,7 +301,7 @@ overriding the client default for this request only. #### `generate_query_data` ```python -generate_query_data(self, filters: dict[str, Any] | None = None, offset: int | None = None, limit: int | None = None, include: list[str] | None = None, exclude: list[str] | None = None, fragment: bool = False, prefetch_relationships: bool = False, partial_match: bool = False, property: bool = False, order: Order | None = None, include_metadata: bool = False) -> dict[str, Any | dict] +generate_query_data(self, filters: dict[str, Any] | None = None, offset: int | str | None = None, limit: int | str | None = None, include: list[str] | None = None, exclude: list[str] | None = None, fragment: bool = False, prefetch_relationships: bool = False, partial_match: bool = False, property: bool = False, order: Order | None = None, include_metadata: bool = False) -> dict[str, Any | dict] ``` Generate the full GraphQL query payload for this node kind. @@ -314,8 +314,10 @@ relevant attributes are returned alongside the generic fields. **Args:** - `filters`: Filters to apply to the query. -- `offset`: Pagination offset. -- `limit`: Pagination limit. +- `offset`: Pagination offset, either a literal value or a +GraphQL variable placeholder such as ``"$offset"``. +- `limit`: Pagination limit, either a literal value or a +GraphQL variable placeholder such as ``"$limit"``. - `include`: Attributes or relationships to include. - `exclude`: Attributes or relationships to exclude. - `fragment`: When ``True`` and the schema is a generic, emit @@ -828,7 +830,7 @@ overriding the client default for this request only. #### `generate_query_data` ```python -generate_query_data(self, filters: dict[str, Any] | None = None, offset: int | None = None, limit: int | None = None, include: list[str] | None = None, exclude: list[str] | None = None, fragment: bool = False, prefetch_relationships: bool = False, partial_match: bool = False, property: bool = False, order: Order | None = None, include_metadata: bool = False) -> dict[str, Any | dict] +generate_query_data(self, filters: dict[str, Any] | None = None, offset: int | str | None = None, limit: int | str | None = None, include: list[str] | None = None, exclude: list[str] | None = None, fragment: bool = False, prefetch_relationships: bool = False, partial_match: bool = False, property: bool = False, order: Order | None = None, include_metadata: bool = False) -> dict[str, Any | dict] ``` Generate the full GraphQL query payload for this node kind. @@ -841,8 +843,10 @@ relevant attributes are returned alongside the generic fields. **Args:** - `filters`: Filters to apply to the query. -- `offset`: Pagination offset. -- `limit`: Pagination limit. +- `offset`: Pagination offset, either a literal value or a +GraphQL variable placeholder such as ``"$offset"``. +- `limit`: Pagination limit, either a literal value or a +GraphQL variable placeholder such as ``"$limit"``. - `include`: Attributes or relationships to include. - `exclude`: Attributes or relationships to exclude. - `fragment`: When ``True`` and the schema is a generic, emit @@ -1352,7 +1356,7 @@ Return the raw GraphQL payload used to build this node. #### `generate_query_data_init` ```python -generate_query_data_init(self, filters: dict[str, Any] | None = None, offset: int | None = None, limit: int | None = None, include: list[str] | None = None, exclude: list[str] | None = None, partial_match: bool = False, order: Order | None = None, include_metadata: bool = False) -> dict[str, Any | dict] +generate_query_data_init(self, filters: dict[str, Any] | None = None, offset: int | str | None = None, limit: int | str | None = None, include: list[str] | None = None, exclude: list[str] | None = None, partial_match: bool = False, order: Order | None = None, include_metadata: bool = False) -> dict[str, Any | dict] ``` Build the top-level ``count``/``edges`` skeleton of a GraphQL query for this kind. @@ -1364,8 +1368,10 @@ The returned dict is the outer structure consumed by **Args:** - `filters`: Filters to apply to the query. -- `offset`: Pagination offset. -- `limit`: Pagination limit. +- `offset`: Pagination offset, either a literal value or a +GraphQL variable placeholder such as ``"$offset"``. +- `limit`: Pagination limit, either a literal value or a +GraphQL variable placeholder such as ``"$limit"``. - `include`: Attributes or relationships to include. - `exclude`: Attributes or relationships to exclude. - `partial_match`: When ``True``, allow partial matches on filter diff --git a/infrahub_sdk/client.py b/infrahub_sdk/client.py index e8ff8b9f7..1a43c866b 100644 --- a/infrahub_sdk/client.py +++ b/infrahub_sdk/client.py @@ -1169,24 +1169,32 @@ async def filters( # noqa: C901 filters = kwargs pagination_size = self.pagination_size + # Pagination is passed as GraphQL variables so the rendered query text stays + # identical across pages and can hit the server-side query cache. + query_data = await InfrahubNode(client=self, schema=schema, branch=branch).generate_query_data( + offset="$offset", + limit="$limit", + filters=filters, + include=include, + exclude=exclude, + fragment=fragment, + prefetch_relationships=prefetch_relationships, + partial_match=partial_match, + property=property, + order=order, + include_metadata=include_metadata, + ) + query = Query(query=query_data, name=query_name, variables={"offset": int, "limit": int}) + query_str = query.render() + async def process_page(page_offset: int, page_number: int) -> tuple[dict, ProcessRelationsNode]: """Process a single page of results.""" - query_data = await InfrahubNode(client=self, schema=schema, branch=branch).generate_query_data( - offset=page_offset if offset is None else offset, - limit=limit or pagination_size, - filters=filters, - include=include, - exclude=exclude, - fragment=fragment, - prefetch_relationships=prefetch_relationships, - partial_match=partial_match, - property=property, - order=order, - include_metadata=include_metadata, - ) - query = Query(query=query_data, name=query_name) response = await self.execute_graphql( - query=query.render(), + query=query_str, + variables={ + "offset": page_offset if offset is None else offset, + "limit": limit or pagination_size, + }, branch_name=branch, at=at, tracker=f"query-{str(schema.kind).lower()}-page{page_number}", @@ -2968,24 +2976,32 @@ def filters( # noqa: C901 filters = kwargs pagination_size = self.pagination_size + # Pagination is passed as GraphQL variables so the rendered query text stays + # identical across pages and can hit the server-side query cache. + query_data = InfrahubNodeSync(client=self, schema=schema, branch=branch).generate_query_data( + offset="$offset", + limit="$limit", + filters=filters, + include=include, + exclude=exclude, + fragment=fragment, + prefetch_relationships=prefetch_relationships, + partial_match=partial_match, + property=property, + order=order, + include_metadata=include_metadata, + ) + query = Query(query=query_data, name=query_name, variables={"offset": int, "limit": int}) + query_str = query.render() + def process_page(page_offset: int, page_number: int) -> tuple[dict, ProcessRelationsNodeSync]: """Process a single page of results.""" - query_data = InfrahubNodeSync(client=self, schema=schema, branch=branch).generate_query_data( - offset=page_offset if offset is None else offset, - limit=limit or pagination_size, - filters=filters, - include=include, - exclude=exclude, - fragment=fragment, - prefetch_relationships=prefetch_relationships, - partial_match=partial_match, - property=property, - order=order, - include_metadata=include_metadata, - ) - query = Query(query=query_data, name=query_name) response = self.execute_graphql( - query=query.render(), + query=query_str, + variables={ + "offset": page_offset if offset is None else offset, + "limit": limit or pagination_size, + }, branch_name=branch, at=at, timeout=timeout, diff --git a/infrahub_sdk/node/node.py b/infrahub_sdk/node/node.py index 9f150faa0..7be0961e4 100644 --- a/infrahub_sdk/node/node.py +++ b/infrahub_sdk/node/node.py @@ -651,8 +651,8 @@ def _validate_file_object_support(self, message: str) -> None: def generate_query_data_init( self, filters: dict[str, Any] | None = None, - offset: int | None = None, - limit: int | None = None, + offset: int | str | None = None, + limit: int | str | None = None, include: list[str] | None = None, exclude: list[str] | None = None, partial_match: bool = False, @@ -667,8 +667,10 @@ def generate_query_data_init( Args: filters (dict[str, Any], optional): Filters to apply to the query. - offset (int, optional): Pagination offset. - limit (int, optional): Pagination limit. + offset (int | str, optional): Pagination offset, either a literal value or a + GraphQL variable placeholder such as ``"$offset"``. + limit (int | str, optional): Pagination limit, either a literal value or a + GraphQL variable placeholder such as ``"$limit"``. include (list[str], optional): Attributes or relationships to include. exclude (list[str], optional): Attributes or relationships to exclude. partial_match (bool, optional): When ``True``, allow partial matches on filter @@ -1353,8 +1355,8 @@ async def _process_hierarchical_fields( async def generate_query_data( self, filters: dict[str, Any] | None = None, - offset: int | None = None, - limit: int | None = None, + offset: int | str | None = None, + limit: int | str | None = None, include: list[str] | None = None, exclude: list[str] | None = None, fragment: bool = False, @@ -1373,8 +1375,10 @@ async def generate_query_data( Args: filters (dict[str, Any], optional): Filters to apply to the query. - offset (int, optional): Pagination offset. - limit (int, optional): Pagination limit. + offset (int | str, optional): Pagination offset, either a literal value or a + GraphQL variable placeholder such as ``"$offset"``. + limit (int | str, optional): Pagination limit, either a literal value or a + GraphQL variable placeholder such as ``"$limit"``. include (list[str], optional): Attributes or relationships to include. exclude (list[str], optional): Attributes or relationships to exclude. fragment (bool, optional): When ``True`` and the schema is a generic, emit @@ -1850,30 +1854,37 @@ async def get_pool_allocated_resources(self, resource: InfrahubNode) -> list[Inf graphql_query_name = "InfrahubResourcePoolAllocated" node_ids_per_kind: dict[str, list[str]] = {} + query = Query( + query={ + graphql_query_name: { + "@filters": { + "pool_id": "$pool_id", + "resource_id": "$resource_id", + "offset": "$offset", + "limit": "$limit", + }, + "count": None, + "edges": {"node": {"id": None, "kind": None, "branch": None, "identifier": None}}, + } + }, + name="GetAllocatedResourceForPool", + variables={"pool_id": str, "resource_id": str, "offset": int, "limit": int}, + ) + query_str = query.render() + has_remaining_items = True page_number = 1 while has_remaining_items: page_offset = (page_number - 1) * self._client.pagination_size - query = Query( - query={ - graphql_query_name: { - "@filters": { - "pool_id": "$pool_id", - "resource_id": "$resource_id", - "offset": page_offset, - "limit": self._client.pagination_size, - }, - "count": None, - "edges": {"node": {"id": None, "kind": None, "branch": None, "identifier": None}}, - } - }, - name="GetAllocatedResourceForPool", - variables={"pool_id": str, "resource_id": str}, - ) response = await self._client.execute_graphql( - query=query.render(), - variables={"pool_id": self.id, "resource_id": resource.id}, + query=query_str, + variables={ + "pool_id": self.id, + "resource_id": resource.id, + "offset": page_offset, + "limit": self._client.pagination_size, + }, branch_name=self._branch, tracker=f"get-allocated-resources-page{page_number}", ) @@ -2572,8 +2583,8 @@ def _process_hierarchical_fields( def generate_query_data( self, filters: dict[str, Any] | None = None, - offset: int | None = None, - limit: int | None = None, + offset: int | str | None = None, + limit: int | str | None = None, include: list[str] | None = None, exclude: list[str] | None = None, fragment: bool = False, @@ -2592,8 +2603,10 @@ def generate_query_data( Args: filters (dict[str, Any], optional): Filters to apply to the query. - offset (int, optional): Pagination offset. - limit (int, optional): Pagination limit. + offset (int | str, optional): Pagination offset, either a literal value or a + GraphQL variable placeholder such as ``"$offset"``. + limit (int | str, optional): Pagination limit, either a literal value or a + GraphQL variable placeholder such as ``"$limit"``. include (list[str], optional): Attributes or relationships to include. exclude (list[str], optional): Attributes or relationships to exclude. fragment (bool, optional): When ``True`` and the schema is a generic, emit @@ -3072,30 +3085,37 @@ def get_pool_allocated_resources(self, resource: InfrahubNodeSync) -> list[Infra graphql_query_name = "InfrahubResourcePoolAllocated" node_ids_per_kind: dict[str, list[str]] = {} + query = Query( + query={ + graphql_query_name: { + "@filters": { + "pool_id": "$pool_id", + "resource_id": "$resource_id", + "offset": "$offset", + "limit": "$limit", + }, + "count": None, + "edges": {"node": {"id": None, "kind": None, "branch": None, "identifier": None}}, + } + }, + name="GetAllocatedResourceForPool", + variables={"pool_id": str, "resource_id": str, "offset": int, "limit": int}, + ) + query_str = query.render() + has_remaining_items = True page_number = 1 while has_remaining_items: page_offset = (page_number - 1) * self._client.pagination_size - query = Query( - query={ - graphql_query_name: { - "@filters": { - "pool_id": "$pool_id", - "resource_id": "$resource_id", - "offset": page_offset, - "limit": self._client.pagination_size, - }, - "count": None, - "edges": {"node": {"id": None, "kind": None, "branch": None, "identifier": None}}, - } - }, - name="GetAllocatedResourceForPool", - variables={"pool_id": str, "resource_id": str}, - ) response = self._client.execute_graphql( - query=query.render(), - variables={"pool_id": self.id, "resource_id": resource.id}, + query=query_str, + variables={ + "pool_id": self.id, + "resource_id": resource.id, + "offset": page_offset, + "limit": self._client.pagination_size, + }, branch_name=self._branch, tracker=f"get-allocated-resources-page{page_number}", ) diff --git a/tests/unit/sdk/test_client.py b/tests/unit/sdk/test_client.py index 47c4df80a..f26f0f254 100644 --- a/tests/unit/sdk/test_client.py +++ b/tests/unit/sdk/test_client.py @@ -292,6 +292,31 @@ async def test_method_all_multiple_pages( assert len(repos) == 5 +@pytest.mark.parametrize("client_type", client_types) +async def test_method_all_pagination_uses_graphql_variables( + httpx_mock: HTTPXMock, + clients: BothClients, + mock_query_repository_page1_2: HTTPXMock, + mock_query_repository_page2_2: HTTPXMock, + client_type: str, +) -> None: + if client_type == "standard": + repos = await clients.standard.all(kind="CoreRepository", populate_store=False) + else: + repos = clients.sync.all(kind="CoreRepository", populate_store=False) + + assert len(repos) == 5 + + payloads = [json.loads(request.content) for request in httpx_mock.get_requests() if request.method == "POST"] + assert len(payloads) == 2 + page1, page2 = payloads + assert page1["query"] == page2["query"] + assert "offset: $offset" in page1["query"] + assert "limit: $limit" in page1["query"] + assert page1["variables"] == {"offset": 0, "limit": 3} + assert page2["variables"] == {"offset": 3, "limit": 3} + + @pytest.mark.parametrize(("client_type", "use_parallel"), batch_client_types) async def test_method_all_batching( clients: BothClients, @@ -767,7 +792,7 @@ async def test_query_name_all( post_requests = [r for r in httpx_mock.get_requests() if r.method == "POST"] assert len(post_requests) == 1 payload = json.loads(post_requests[0].content) - assert "query MyAllQuery {" in payload["query"] + assert "query MyAllQuery ($offset: Int!, $limit: Int!) {" in payload["query"] assert payload["operationName"] == "MyAllQuery" @@ -783,7 +808,7 @@ async def test_query_name_filters( post_requests = [r for r in httpx_mock.get_requests() if r.method == "POST"] assert len(post_requests) == 1 payload = json.loads(post_requests[0].content) - assert "query MyFiltersQuery {" in payload["query"] + assert "query MyFiltersQuery ($offset: Int!, $limit: Int!) {" in payload["query"] assert payload["operationName"] == "MyFiltersQuery" @@ -801,7 +826,7 @@ async def test_query_name_get( post_requests = [r for r in httpx_mock.get_requests() if r.method == "POST"] assert len(post_requests) == 1 payload = json.loads(post_requests[0].content) - assert "query MyGetQuery {" in payload["query"] + assert "query MyGetQuery ($offset: Int!, $limit: Int!) {" in payload["query"] assert payload["operationName"] == "MyGetQuery" @@ -833,7 +858,7 @@ async def test_query_name_for_all_ommitted( post_requests = [r for r in httpx_mock.get_requests() if r.method == "POST"] assert len(post_requests) == 1 payload = json.loads(post_requests[0].content) - assert "query All_CoreRepository {" in payload["query"] + assert "query All_CoreRepository ($offset: Int!, $limit: Int!) {" in payload["query"] assert payload["operationName"] == "All_CoreRepository" @@ -849,7 +874,7 @@ async def test_query_name_for_filters_ommitted( post_requests = [r for r in httpx_mock.get_requests() if r.method == "POST"] assert len(post_requests) == 1 payload = json.loads(post_requests[0].content) - assert "query Filters_CoreRepository {" in payload["query"] + assert "query Filters_CoreRepository ($offset: Int!, $limit: Int!) {" in payload["query"] assert payload["operationName"] == "Filters_CoreRepository" @@ -865,7 +890,7 @@ async def test_query_name_for_get_ommitted( post_requests = [r for r in httpx_mock.get_requests() if r.method == "POST"] assert len(post_requests) == 1 payload = json.loads(post_requests[0].content) - assert "query Get_CoreRepository {" in payload["query"] + assert "query Get_CoreRepository ($offset: Int!, $limit: Int!) {" in payload["query"] assert payload["operationName"] == "Get_CoreRepository"