-
Notifications
You must be signed in to change notification settings - Fork 8
Add infrahubctl graphql query-report command
#976
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| Add `infrahubctl graphql query-report` to analyze a GraphQL query and report whether it targets unique nodes, which controls whether Infrahub limits artifact regeneration to changed nodes or regenerates all artifacts on any relevant node change. Supports `--online` to fetch the query from the server by name. |
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -16,9 +16,31 @@ $ infrahubctl graphql [OPTIONS] COMMAND [ARGS]... | |||||
|
|
||||||
| **Commands**: | ||||||
|
|
||||||
| * `query-report`: Run a GraphQL query through... | ||||||
| * `export-schema`: Export the GraphQL schema to a file. | ||||||
| * `generate-return-types`: Create Pydantic Models for GraphQL query... | ||||||
|
|
||||||
| ## `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. | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P3: Prompt for AI agents
Suggested change
|
||||||
| * `--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. | ||||||
|
|
||||||
| ## `infrahubctl graphql export-schema` | ||||||
|
|
||||||
| Export the GraphQL schema to a file. | ||||||
|
|
||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -21,13 +21,16 @@ | |
|
|
||
| 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, | ||
| strip_typename_from_fragment, | ||
| strip_typename_from_operation, | ||
| ) | ||
| from ..protocols import CoreGraphQLQuery | ||
| from .parameters import CONFIG_PARAM | ||
|
|
||
| app = AsyncTyper() | ||
|
|
@@ -97,6 +100,69 @@ def callback() -> None: | |
| """Various GraphQL related commands.""" | ||
|
|
||
|
|
||
| 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, # type: ignore[type-abstract] | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Temporary ignore until we can resolve the protocol issues within the SDK. |
||
| 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.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]") | ||
| else: | ||
| console.print("Targets unique nodes: [yellow]false[/yellow]") | ||
|
|
||
|
|
||
| @app.command() | ||
| @catch_exception(console=console) | ||
| async def export_schema( | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,222 @@ | ||
| """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 | ||
|
|
||
|
|
||
| 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 | ||
|
|
||
|
|
||
| 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 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P3: Command description references internal class
InfrahubGraphQLQueryReportthat is meaningless to end users. Replace with user-facing description such as 'Analyze a GraphQL query and report its characteristics, such as unique-target suitability for artifact definitions.'Prompt for AI agents