Skip to content

Commit a7f5a72

Browse files
committed
Add infrahubctl graphql query-report command
1 parent 42687da commit a7f5a72

5 files changed

Lines changed: 340 additions & 0 deletions

File tree

changelog/+ed38b6b.added.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
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.

docs/docs/infrahubctl/infrahubctl-graphql.mdx

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ $ infrahubctl graphql [OPTIONS] COMMAND [ARGS]...
1818

1919
* `export-schema`: Export the GraphQL schema to a file.
2020
* `generate-return-types`: Create Pydantic Models for GraphQL query...
21+
* `query-report`: Run a GraphQL query through...
2122

2223
## `infrahubctl graphql export-schema`
2324

@@ -54,3 +55,24 @@ $ infrahubctl graphql generate-return-types [OPTIONS] [QUERY]
5455
* `--schema PATH`: Path to the GraphQL schema file. [default: schema.graphql]
5556
* `--config-file TEXT`: [env var: INFRAHUBCTL_CONFIG; default: infrahubctl.toml]
5657
* `--help`: Show this message and exit.
58+
59+
## `infrahubctl graphql query-report`
60+
61+
Run a GraphQL query through InfrahubGraphQLQueryReport and report its analysis.
62+
63+
**Usage**:
64+
65+
```console
66+
$ infrahubctl graphql query-report [OPTIONS] NAME
67+
```
68+
69+
**Arguments**:
70+
71+
* `NAME`: Name of the GraphQL query to analyze. [required]
72+
73+
**Options**:
74+
75+
* `--online`: Fetch the query from the Infrahub server (CoreGraphQLQuery by name) instead of reading it from the local .infrahub.yml file.
76+
* `--branch TEXT`: Branch on which to run the report.
77+
* `--config-file TEXT`: [env var: INFRAHUBCTL_CONFIG; default: infrahubctl.toml]
78+
* `--help`: Show this message and exit.

infrahub_sdk/ctl/graphql.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,16 @@
2121

2222
from ..async_typer import AsyncTyper
2323
from ..ctl.client import initialize_client
24+
from ..ctl.repository import find_repository_config_file, get_repository_config
2425
from ..ctl.utils import catch_exception
26+
from ..graphql.query_renderer import render_query
2527
from ..graphql.utils import (
2628
insert_fragments_inline,
2729
remove_fragment_import,
2830
strip_typename_from_fragment,
2931
strip_typename_from_operation,
3032
)
33+
from ..protocols import CoreGraphQLQuery
3134
from .parameters import CONFIG_PARAM
3235

3336
app = AsyncTyper()
@@ -100,6 +103,76 @@ def callback() -> None:
100103
"""
101104

102105

106+
QUERY_REPORT_DOCUMENT = """
107+
query ($q: String!) {
108+
InfrahubGraphQLQueryReport(query: $q) {
109+
targets_unique_nodes
110+
}
111+
}
112+
"""
113+
114+
115+
@app.command(name="query-report")
116+
@catch_exception(console=console)
117+
async def query_report(
118+
name: str = typer.Argument(..., help="Name of the GraphQL query to analyze."),
119+
online: bool = typer.Option(
120+
False,
121+
"--online",
122+
help=(
123+
"Fetch the query from the Infrahub server (CoreGraphQLQuery by name) "
124+
"instead of reading it from the local .infrahub.yml file."
125+
),
126+
),
127+
branch: str | None = typer.Option(None, help="Branch on which to run the report."),
128+
_: str = CONFIG_PARAM,
129+
) -> None:
130+
"""Run a GraphQL query through InfrahubGraphQLQueryReport and report its analysis."""
131+
132+
client = initialize_client(branch=branch)
133+
134+
if online:
135+
node = await client.get(
136+
kind=CoreGraphQLQuery, # type: ignore[type-abstract]
137+
name__value=name,
138+
branch=branch,
139+
raise_when_missing=False,
140+
)
141+
if node is None:
142+
console.print(f"[red]GraphQL query {name!r} not found on the server")
143+
raise typer.Exit(1)
144+
query_str = node.query.value
145+
source_label = f"online: id={node.id}"
146+
else:
147+
repository_config = get_repository_config(find_repository_config_file())
148+
query_str = render_query(name=name, config=repository_config)
149+
source_label = f"local: {repository_config.get_query(name).file_path}"
150+
151+
response = await client.execute_graphql(
152+
query=QUERY_REPORT_DOCUMENT,
153+
variables={"q": query_str},
154+
branch_name=branch,
155+
tracker="query-graphql-query-report",
156+
)
157+
targets_unique_nodes = response["InfrahubGraphQLQueryReport"]["targets_unique_nodes"]
158+
159+
header_parts = [source_label]
160+
if branch:
161+
header_parts.append(f"branch: {branch}")
162+
console.print(f"Query {name!r} ({', '.join(header_parts)})")
163+
164+
if targets_unique_nodes:
165+
console.print(
166+
"Targets unique nodes: [green]true[/green] — "
167+
"Infrahub will limit artifact regeneration to changed nodes only."
168+
)
169+
else:
170+
console.print(
171+
"Targets unique nodes: [yellow]false[/yellow] — "
172+
"all artifacts for the definition will be regenerated on any relevant node change."
173+
)
174+
175+
103176
@app.command()
104177
@catch_exception(console=console)
105178
async def export_schema(

tests/integration/test_infrahubctl.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import json
44
import os
5+
import re
56
import shutil
67
import tempfile
78
from pathlib import Path
@@ -118,6 +119,25 @@ def test_infrahubctl_transform_cmd_convert_animal_person(self, repository: str,
118119
"person": "Liam Walker",
119120
}
120121

122+
def test_infrahubctl_graphql_query_report(self, repository: str, base_dataset: None) -> None:
123+
"""Run query-report end-to-end against the live backend resolver."""
124+
with change_directory(repository):
125+
result = runner.invoke(app, ["graphql", "query-report", "tags_query"])
126+
127+
assert result.exit_code == 0, strip_color(result.stdout)
128+
output = re.sub(r"\s+", " ", strip_color(result.stdout))
129+
assert "tags_query" in output
130+
assert "Targets unique nodes: true" in output
131+
132+
def test_infrahubctl_graphql_query_report_branch(self, repository: str, base_dataset: None) -> None:
133+
"""The --branch flag routes the report to the requested branch."""
134+
with change_directory(repository):
135+
result = runner.invoke(app, ["graphql", "query-report", "tags_query", "--branch", "branch01"])
136+
137+
assert result.exit_code == 0, strip_color(result.stdout)
138+
output = re.sub(r"\s+", " ", strip_color(result.stdout))
139+
assert "branch: branch01" in output
140+
121141
async def test_infrahubctl_generator_cmd_animal_tags(
122142
self, repository: str, base_dataset: None, client: InfrahubClient
123143
) -> None:
Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
"""Tests for `infrahubctl graphql query-report`."""
2+
3+
from __future__ import annotations
4+
5+
import re
6+
from pathlib import Path
7+
from typing import TYPE_CHECKING
8+
9+
import pytest
10+
from typer.testing import CliRunner
11+
12+
from infrahub_sdk.ctl.graphql import app
13+
from tests.constants import FIXTURE_REPOS_DIR
14+
from tests.helpers.utils import strip_color, temp_repo_and_cd
15+
16+
if TYPE_CHECKING:
17+
from pytest_httpx import HTTPXMock
18+
19+
20+
def _flatten(text: str) -> str:
21+
"""Strip ANSI colors and collapse whitespace so wrapped Rich output can be substring-matched."""
22+
return re.sub(r"\s+", " ", strip_color(text)).strip()
23+
24+
25+
pytestmark = pytest.mark.httpx_mock(can_send_already_matched_responses=True)
26+
27+
runner = CliRunner()
28+
29+
CTL_INTEGRATION_FIXTURE = FIXTURE_REPOS_DIR / "ctl_integration"
30+
31+
REPORT_RESPONSE_TRUE = {"data": {"InfrahubGraphQLQueryReport": {"targets_unique_nodes": True}}}
32+
REPORT_RESPONSE_FALSE = {"data": {"InfrahubGraphQLQueryReport": {"targets_unique_nodes": False}}}
33+
34+
35+
def test_query_report_local_returns_true(httpx_mock: HTTPXMock) -> None:
36+
httpx_mock.add_response(
37+
method="POST",
38+
url="http://mock/graphql/main",
39+
json=REPORT_RESPONSE_TRUE,
40+
match_headers={"X-Infrahub-Tracker": "query-graphql-query-report"},
41+
)
42+
43+
with temp_repo_and_cd(source_dir=CTL_INTEGRATION_FIXTURE):
44+
result = runner.invoke(app, ["query-report", "tags_query"])
45+
46+
assert result.exit_code == 0, strip_color(result.stdout)
47+
output = _flatten(result.stdout)
48+
assert "Query 'tags_query' (local: templates/tags_query.gql)" in output
49+
assert "branch:" not in output
50+
assert "Targets unique nodes: true" in output
51+
assert "limit artifact regeneration to changed nodes only" in output
52+
53+
54+
def test_query_report_local_returns_false(httpx_mock: HTTPXMock) -> None:
55+
httpx_mock.add_response(
56+
method="POST",
57+
url="http://mock/graphql/main",
58+
json=REPORT_RESPONSE_FALSE,
59+
match_headers={"X-Infrahub-Tracker": "query-graphql-query-report"},
60+
)
61+
62+
with temp_repo_and_cd(source_dir=CTL_INTEGRATION_FIXTURE):
63+
result = runner.invoke(app, ["query-report", "tags_query"])
64+
65+
assert result.exit_code == 0, strip_color(result.stdout)
66+
output = _flatten(result.stdout)
67+
assert "Targets unique nodes: false" in output
68+
assert "all artifacts for the definition will be regenerated" in output
69+
70+
71+
def test_query_report_local_uses_branch(httpx_mock: HTTPXMock) -> None:
72+
httpx_mock.add_response(
73+
method="POST",
74+
url="http://mock/graphql/feature-x",
75+
json=REPORT_RESPONSE_TRUE,
76+
match_headers={"X-Infrahub-Tracker": "query-graphql-query-report"},
77+
)
78+
79+
with temp_repo_and_cd(source_dir=CTL_INTEGRATION_FIXTURE):
80+
result = runner.invoke(app, ["query-report", "tags_query", "--branch", "feature-x"])
81+
82+
assert result.exit_code == 0, strip_color(result.stdout)
83+
output = _flatten(result.stdout)
84+
assert "branch: feature-x" in output
85+
assert "local: templates/tags_query.gql" in output
86+
assert "Targets unique nodes: true" in output
87+
88+
89+
def test_query_report_local_unknown_query(httpx_mock: HTTPXMock) -> None:
90+
with temp_repo_and_cd(source_dir=CTL_INTEGRATION_FIXTURE):
91+
result = runner.invoke(app, ["query-report", "does_not_exist"])
92+
93+
assert result.exit_code == 1
94+
assert "does_not_exist" in strip_color(result.stdout)
95+
96+
97+
def test_query_report_local_inlines_fragments(httpx_mock: HTTPXMock, tmp_path: Path) -> None:
98+
"""When the query uses fragments, the rendered query sent to the server has them inlined."""
99+
httpx_mock.add_response(
100+
method="POST",
101+
url="http://mock/graphql/main",
102+
json=REPORT_RESPONSE_TRUE,
103+
match_headers={"X-Infrahub-Tracker": "query-graphql-query-report"},
104+
)
105+
106+
config_file = tmp_path / ".infrahub.yml"
107+
config_file.write_text(
108+
"""
109+
queries:
110+
- name: with_fragment
111+
file_path: queries/with_fragment.gql
112+
graphql_fragments:
113+
- name: tag_fields
114+
file_path: fragments/tag_fields.gql
115+
""".strip(),
116+
encoding="UTF-8",
117+
)
118+
queries_dir = tmp_path / "queries"
119+
queries_dir.mkdir()
120+
(queries_dir / "with_fragment.gql").write_text(
121+
"query WithFragment { BuiltinTag { edges { node { ...tag_fields } } } }",
122+
encoding="UTF-8",
123+
)
124+
fragments_dir = tmp_path / "fragments"
125+
fragments_dir.mkdir()
126+
(fragments_dir / "tag_fields.gql").write_text(
127+
"fragment tag_fields on BuiltinTag { id name { value } }",
128+
encoding="UTF-8",
129+
)
130+
131+
with temp_repo_and_cd(source_dir=tmp_path):
132+
result = runner.invoke(app, ["query-report", "with_fragment"])
133+
134+
assert result.exit_code == 0, strip_color(result.stdout)
135+
136+
requests = httpx_mock.get_requests(
137+
method="POST",
138+
url="http://mock/graphql/main",
139+
match_headers={"X-Infrahub-Tracker": "query-graphql-query-report"},
140+
)
141+
assert len(requests) == 1
142+
sent_body = requests[0].content.decode("utf-8")
143+
assert "fragment tag_fields" in sent_body
144+
assert "...tag_fields" in sent_body
145+
146+
147+
@pytest.fixture
148+
def mock_core_graphql_query_lookup(httpx_mock: HTTPXMock) -> HTTPXMock:
149+
response = {
150+
"data": {
151+
"CoreGraphQLQuery": {
152+
"count": 1,
153+
"edges": [
154+
{
155+
"node": {
156+
"id": "11111111-1111-1111-1111-111111111111",
157+
"display_label": "remote_query",
158+
"__typename": "CoreGraphQLQuery",
159+
"name": {
160+
"value": "remote_query",
161+
"is_default": False,
162+
"is_from_profile": False,
163+
"source": None,
164+
"owner": None,
165+
},
166+
"query": {
167+
"value": "query Remote { BuiltinTag { edges { node { id } } } }",
168+
"is_default": False,
169+
"is_from_profile": False,
170+
"source": None,
171+
"owner": None,
172+
},
173+
}
174+
}
175+
],
176+
}
177+
}
178+
}
179+
httpx_mock.add_response(
180+
method="POST",
181+
url="http://mock/graphql/main",
182+
json=response,
183+
match_headers={"X-Infrahub-Tracker": "query-coregraphqlquery-page1"},
184+
is_reusable=True,
185+
)
186+
return httpx_mock
187+
188+
189+
def test_query_report_online_happy_path(
190+
mock_schema_query_05: HTTPXMock,
191+
mock_core_graphql_query_lookup: HTTPXMock,
192+
) -> None:
193+
mock_core_graphql_query_lookup.add_response(
194+
method="POST",
195+
url="http://mock/graphql/main",
196+
json=REPORT_RESPONSE_TRUE,
197+
match_headers={"X-Infrahub-Tracker": "query-graphql-query-report"},
198+
)
199+
200+
result = runner.invoke(app, ["query-report", "remote_query", "--online"])
201+
202+
assert result.exit_code == 0, strip_color(result.stdout)
203+
output = _flatten(result.stdout)
204+
assert "Query 'remote_query' (online: id=11111111-1111-1111-1111-111111111111)" in output
205+
assert "branch:" not in output
206+
assert "Targets unique nodes: true" in output
207+
208+
209+
def test_query_report_online_not_found(
210+
mock_schema_query_05: HTTPXMock,
211+
) -> None:
212+
mock_schema_query_05.add_response(
213+
method="POST",
214+
url="http://mock/graphql/main",
215+
json={"data": {"CoreGraphQLQuery": {"count": 0, "edges": []}}},
216+
match_headers={"X-Infrahub-Tracker": "query-coregraphqlquery-page1"},
217+
)
218+
219+
result = runner.invoke(app, ["query-report", "missing", "--online"])
220+
221+
assert result.exit_code == 1
222+
output = strip_color(result.stdout)
223+
assert "missing" in output
224+
assert "not found" in output

0 commit comments

Comments
 (0)