Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog/+ed38b6b.added.md
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.
22 changes: 22 additions & 0 deletions docs/docs/infrahubctl/infrahubctl-graphql.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Copy link
Copy Markdown

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 InfrahubGraphQLQueryReport that 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
Check if this issue is valid — if so, understand the root cause and fix it. At docs/docs/infrahubctl/infrahubctl-graphql.mdx, line 25:

<comment>Command description references internal class `InfrahubGraphQLQueryReport` that 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.'</comment>

<file context>
@@ -16,9 +16,31 @@ $ infrahubctl graphql [OPTIONS] COMMAND [ARGS]...
 
+## `infrahubctl graphql query-report`
+
+Run a GraphQL query through InfrahubGraphQLQueryReport and report its analysis.
+
+**Usage**:
</file context>
Suggested change
Run a GraphQL query through InfrahubGraphQLQueryReport and report its analysis.
Analyze a GraphQL query and report its characteristics, such as unique-target suitability for artifact definitions.


**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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: --online option description references internal type CoreGraphQLQuery that is not meaningful to users. Replace with user-facing wording such as '...from the Infrahub server (by query name) instead of...'

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At docs/docs/infrahubctl/infrahubctl-graphql.mdx, line 39:

<comment>`--online` option description references internal type `CoreGraphQLQuery` that is not meaningful to users. Replace with user-facing wording such as '...from the Infrahub server (by query name) instead of...'</comment>

<file context>
@@ -16,9 +16,31 @@ $ infrahubctl graphql [OPTIONS] COMMAND [ARGS]...
+
+**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]
</file context>
Suggested change
* `--online`: Fetch the query from the Infrahub server (CoreGraphQLQuery by name) instead of reading it from the local .infrahub.yml file.
* `--online`: Fetch the query from the Infrahub server (by query 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.

## `infrahubctl graphql export-schema`

Export the GraphQL schema to a file.
Expand Down
66 changes: 66 additions & 0 deletions infrahub_sdk/ctl/graphql.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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]

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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(
Expand Down
20 changes: 20 additions & 0 deletions tests/integration/test_infrahubctl.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import json
import os
import re
import shutil
import tempfile
from pathlib import Path
Expand Down Expand Up @@ -116,6 +117,25 @@ def test_infrahubctl_transform_cmd_convert_animal_person(self, repository: str,
"person": "Liam Walker",
}

def test_infrahubctl_graphql_query_report(self, repository: str, base_dataset: None) -> None:
"""Run query-report end-to-end against the live backend resolver."""
with change_directory(repository):
result = runner.invoke(app, ["graphql", "query-report", "tags_query"])

assert result.exit_code == 0, strip_color(result.stdout)
output = re.sub(r"\s+", " ", strip_color(result.stdout))
assert "tags_query" in output
assert "Targets unique nodes: true" in output

def test_infrahubctl_graphql_query_report_branch(self, repository: str, base_dataset: None) -> None:
"""The --branch flag routes the report to the requested branch."""
with change_directory(repository):
result = runner.invoke(app, ["graphql", "query-report", "tags_query", "--branch", "branch01"])

assert result.exit_code == 0, strip_color(result.stdout)
output = re.sub(r"\s+", " ", strip_color(result.stdout))
assert "branch: branch01" in output

async def test_infrahubctl_generator_cmd_animal_tags(
self, repository: str, base_dataset: None, client: InfrahubClient
) -> None:
Expand Down
222 changes: 222 additions & 0 deletions tests/unit/ctl/test_graphql_query_report.py
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