diff --git a/docs/docs/infrahubctl/infrahubctl-graphql.mdx b/docs/docs/infrahubctl/infrahubctl-graphql.mdx index 180bd2b8..fb8248d7 100644 --- a/docs/docs/infrahubctl/infrahubctl-graphql.mdx +++ b/docs/docs/infrahubctl/infrahubctl-graphql.mdx @@ -18,6 +18,7 @@ $ infrahubctl graphql [OPTIONS] COMMAND [ARGS]... * `export-schema`: Export the GraphQL schema to a file. * `generate-return-types`: Create Pydantic Models for GraphQL query... +* `query-report`: Run a GraphQL query through... ## `infrahubctl graphql export-schema` @@ -54,3 +55,24 @@ $ infrahubctl graphql generate-return-types [OPTIONS] [QUERY] * `--schema PATH`: Path to the GraphQL schema file. [default: schema.graphql] * `--config-file TEXT`: [env var: INFRAHUBCTL_CONFIG; default: infrahubctl.toml] * `--help`: Show this message and exit. + +## `infrahubctl graphql query-report` + +Run a GraphQL query through InfrahubGraphQLQueryReport and report its analysis. + +**Usage**: + +```console +$ infrahubctl graphql query-report [OPTIONS] NAME +``` + +**Arguments**: + +* `NAME`: Name of the GraphQL query to analyze. [required] + +**Options**: + +* `--online`: Fetch the query from the Infrahub server (CoreGraphQLQuery by name) instead of reading it from the local .infrahub.yml file. +* `--branch TEXT`: Branch on which to run the report. +* `--config-file TEXT`: [env var: INFRAHUBCTL_CONFIG; default: infrahubctl.toml] +* `--help`: Show this message and exit. diff --git a/infrahub_sdk/ctl/graphql.py b/infrahub_sdk/ctl/graphql.py index ea0158ce..2c9f518a 100644 --- a/infrahub_sdk/ctl/graphql.py +++ b/infrahub_sdk/ctl/graphql.py @@ -21,7 +21,9 @@ from ..async_typer import AsyncTyper from ..ctl.client import initialize_client +from ..ctl.repository import find_repository_config_file, get_repository_config from ..ctl.utils import catch_exception +from ..graphql.query_renderer import render_query from ..graphql.utils import ( insert_fragments_inline, remove_fragment_import, @@ -100,6 +102,76 @@ def callback() -> None: """ +QUERY_REPORT_DOCUMENT = """ +query ($q: String!) { + InfrahubGraphQLQueryReport(query: $q) { + targets_unique_nodes + } +} +""" + + +@app.command(name="query-report") +@catch_exception(console=console) +async def query_report( + name: str = typer.Argument(..., help="Name of the GraphQL query to analyze."), + online: bool = typer.Option( + False, + "--online", + help=( + "Fetch the query from the Infrahub server (CoreGraphQLQuery by name) " + "instead of reading it from the local .infrahub.yml file." + ), + ), + branch: str | None = typer.Option(None, help="Branch on which to run the report."), + _: str = CONFIG_PARAM, +) -> None: + """Run a GraphQL query through InfrahubGraphQLQueryReport and report its analysis.""" + + client = initialize_client(branch=branch) + + if online: + node = await client.get( + kind="CoreGraphQLQuery", + name__value=name, + branch=branch, + raise_when_missing=False, + ) + if node is None: + console.print(f"[red]GraphQL query {name!r} not found on the server") + raise typer.Exit(1) + query_str = node._get_attribute(name="query").value + source_label = f"online: id={node.id}" + else: + repository_config = get_repository_config(find_repository_config_file()) + query_str = render_query(name=name, config=repository_config) + source_label = f"local: {repository_config.get_query(name).file_path}" + + response = await client.execute_graphql( + query=QUERY_REPORT_DOCUMENT, + variables={"q": query_str}, + branch_name=branch, + tracker="query-graphql-query-report", + ) + targets_unique_nodes = response["InfrahubGraphQLQueryReport"]["targets_unique_nodes"] + + header_parts = [source_label] + if branch: + header_parts.append(f"branch: {branch}") + console.print(f"Query {name!r} ({', '.join(header_parts)})") + + if targets_unique_nodes: + console.print( + "Targets unique nodes: [green]true[/green] — " + "Infrahub will limit artifact regeneration to changed nodes only." + ) + else: + console.print( + "Targets unique nodes: [yellow]false[/yellow] — " + "all artifacts for the definition will be regenerated on any relevant node change." + ) + + @app.command() @catch_exception(console=console) async def export_schema( diff --git a/tests/unit/ctl/test_graphql_query_report.py b/tests/unit/ctl/test_graphql_query_report.py new file mode 100644 index 00000000..425f2e85 --- /dev/null +++ b/tests/unit/ctl/test_graphql_query_report.py @@ -0,0 +1,224 @@ +"""Tests for `infrahubctl graphql query-report`.""" + +from __future__ import annotations + +import re +from pathlib import Path +from typing import TYPE_CHECKING + +import pytest +from typer.testing import CliRunner + +from infrahub_sdk.ctl.graphql import app +from tests.constants import FIXTURE_REPOS_DIR +from tests.helpers.utils import strip_color, temp_repo_and_cd + +if TYPE_CHECKING: + from pytest_httpx import HTTPXMock + + +def _flatten(text: str) -> str: + """Strip ANSI colors and collapse whitespace so wrapped Rich output can be substring-matched.""" + return re.sub(r"\s+", " ", strip_color(text)).strip() + + +pytestmark = pytest.mark.httpx_mock(can_send_already_matched_responses=True) + +runner = CliRunner() + +CTL_INTEGRATION_FIXTURE = FIXTURE_REPOS_DIR / "ctl_integration" + +REPORT_RESPONSE_TRUE = {"data": {"InfrahubGraphQLQueryReport": {"targets_unique_nodes": True}}} +REPORT_RESPONSE_FALSE = {"data": {"InfrahubGraphQLQueryReport": {"targets_unique_nodes": False}}} + + +def test_query_report_local_returns_true(httpx_mock: HTTPXMock) -> None: + httpx_mock.add_response( + method="POST", + url="http://mock/graphql/main", + json=REPORT_RESPONSE_TRUE, + match_headers={"X-Infrahub-Tracker": "query-graphql-query-report"}, + ) + + with temp_repo_and_cd(source_dir=CTL_INTEGRATION_FIXTURE): + result = runner.invoke(app, ["query-report", "tags_query"]) + + assert result.exit_code == 0, strip_color(result.stdout) + output = _flatten(result.stdout) + assert "Query 'tags_query' (local: templates/tags_query.gql)" in output + assert "branch:" not in output + assert "Targets unique nodes: true" in output + assert "limit artifact regeneration to changed nodes only" in output + + +def test_query_report_local_returns_false(httpx_mock: HTTPXMock) -> None: + httpx_mock.add_response( + method="POST", + url="http://mock/graphql/main", + json=REPORT_RESPONSE_FALSE, + match_headers={"X-Infrahub-Tracker": "query-graphql-query-report"}, + ) + + with temp_repo_and_cd(source_dir=CTL_INTEGRATION_FIXTURE): + result = runner.invoke(app, ["query-report", "tags_query"]) + + assert result.exit_code == 0, strip_color(result.stdout) + output = _flatten(result.stdout) + assert "Targets unique nodes: false" in output + assert "all artifacts for the definition will be regenerated" in output + + +def test_query_report_local_uses_branch(httpx_mock: HTTPXMock) -> None: + httpx_mock.add_response( + method="POST", + url="http://mock/graphql/feature-x", + json=REPORT_RESPONSE_TRUE, + match_headers={"X-Infrahub-Tracker": "query-graphql-query-report"}, + ) + + with temp_repo_and_cd(source_dir=CTL_INTEGRATION_FIXTURE): + result = runner.invoke(app, ["query-report", "tags_query", "--branch", "feature-x"]) + + assert result.exit_code == 0, strip_color(result.stdout) + output = _flatten(result.stdout) + assert "branch: feature-x" in output + assert "local: templates/tags_query.gql" in output + assert "Targets unique nodes: true" in output + + +def test_query_report_local_unknown_query(httpx_mock: HTTPXMock) -> None: + with temp_repo_and_cd(source_dir=CTL_INTEGRATION_FIXTURE): + result = runner.invoke(app, ["query-report", "does_not_exist"]) + + assert result.exit_code == 1 + assert "does_not_exist" in strip_color(result.stdout) + + +def test_query_report_local_inlines_fragments(httpx_mock: HTTPXMock, tmp_path: Path) -> None: + """When the query uses fragments, the rendered query sent to the server has them inlined.""" + httpx_mock.add_response( + method="POST", + url="http://mock/graphql/main", + json=REPORT_RESPONSE_TRUE, + match_headers={"X-Infrahub-Tracker": "query-graphql-query-report"}, + ) + + config_file = tmp_path / ".infrahub.yml" + config_file.write_text( + """ +queries: + - name: with_fragment + file_path: queries/with_fragment.gql +graphql_fragments: + - name: tag_fields + file_path: fragments/tag_fields.gql +""".strip(), + encoding="UTF-8", + ) + queries_dir = tmp_path / "queries" + queries_dir.mkdir() + (queries_dir / "with_fragment.gql").write_text( + "query WithFragment { BuiltinTag { edges { node { ...tag_fields } } } }", + encoding="UTF-8", + ) + fragments_dir = tmp_path / "fragments" + fragments_dir.mkdir() + (fragments_dir / "tag_fields.gql").write_text( + "fragment tag_fields on BuiltinTag { id name { value } }", + encoding="UTF-8", + ) + + with temp_repo_and_cd(source_dir=tmp_path): + result = runner.invoke(app, ["query-report", "with_fragment"]) + + assert result.exit_code == 0, strip_color(result.stdout) + + requests = httpx_mock.get_requests( + method="POST", + url="http://mock/graphql/main", + match_headers={"X-Infrahub-Tracker": "query-graphql-query-report"}, + ) + assert len(requests) == 1 + sent_body = requests[0].content.decode("utf-8") + assert "fragment tag_fields" in sent_body + assert "...tag_fields" in sent_body + + +@pytest.fixture +def mock_core_graphql_query_lookup(httpx_mock: HTTPXMock) -> HTTPXMock: + response = { + "data": { + "CoreGraphQLQuery": { + "count": 1, + "edges": [ + { + "node": { + "id": "11111111-1111-1111-1111-111111111111", + "display_label": "remote_query", + "__typename": "CoreGraphQLQuery", + "name": { + "value": "remote_query", + "is_default": False, + "is_from_profile": False, + "source": None, + "owner": None, + }, + "query": { + "value": "query Remote { BuiltinTag { edges { node { id } } } }", + "is_default": False, + "is_from_profile": False, + "source": None, + "owner": None, + }, + } + } + ], + } + } + } + httpx_mock.add_response( + method="POST", + url="http://mock/graphql/main", + json=response, + match_headers={"X-Infrahub-Tracker": "query-coregraphqlquery-page1"}, + is_reusable=True, + ) + return httpx_mock + + +def test_query_report_online_happy_path( + mock_schema_query_05: HTTPXMock, + mock_core_graphql_query_lookup: HTTPXMock, +) -> None: + mock_core_graphql_query_lookup.add_response( + method="POST", + url="http://mock/graphql/main", + json=REPORT_RESPONSE_TRUE, + match_headers={"X-Infrahub-Tracker": "query-graphql-query-report"}, + ) + + result = runner.invoke(app, ["query-report", "remote_query", "--online"]) + + assert result.exit_code == 0, strip_color(result.stdout) + output = _flatten(result.stdout) + assert "Query 'remote_query' (online: id=11111111-1111-1111-1111-111111111111)" in output + assert "branch:" not in output + assert "Targets unique nodes: true" in output + + +def test_query_report_online_not_found( + mock_schema_query_05: HTTPXMock, +) -> None: + mock_schema_query_05.add_response( + method="POST", + url="http://mock/graphql/main", + json={"data": {"CoreGraphQLQuery": {"count": 0, "edges": []}}}, + match_headers={"X-Infrahub-Tracker": "query-coregraphqlquery-page1"}, + ) + + result = runner.invoke(app, ["query-report", "missing", "--online"]) + + assert result.exit_code == 1 + output = strip_color(result.stdout) + assert "missing" in output + assert "not found" in output