Skip to content
Open
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
26 changes: 26 additions & 0 deletions docs/docs/infrahubctl/infrahubctl-graphql.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,35 @@ $ infrahubctl graphql [OPTIONS] COMMAND [ARGS]...

**Commands**:

* `check`: Check if GraphQL queries target single or...
* `export-schema`: Export the GraphQL schema to a file.
* `generate-return-types`: Create Pydantic Models for GraphQL query...

## `infrahubctl graphql check`

Check if GraphQL queries target single or multiple objects.

A single-target query is one that will return at most one object per query operation.
This is determined by checking if the query uses uniqueness constraints (like filtering by ID or name).

Multi-target queries may return multiple objects and should be used with caution in artifact definitions.

**Usage**:

```console
$ infrahubctl graphql check [OPTIONS] [QUERY]
```

**Arguments**:

* `[QUERY]`: Path to the GraphQL query file or directory. Defaults to current directory if not specified.

**Options**:

* `--branch TEXT`: Branch to use for schema.
* `--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
135 changes: 134 additions & 1 deletion infrahub_sdk/ctl/graphql.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import ast
from collections import defaultdict
from pathlib import Path
from typing import TYPE_CHECKING

import typer
from ariadne_codegen.client_generators.package import PackageGenerator, get_package_generator
Expand All @@ -16,15 +17,96 @@
)
from ariadne_codegen.settings import ClientSettings, CommentsStrategy
from ariadne_codegen.utils import ast_to_str
from graphql import DefinitionNode, GraphQLSchema, NoUnusedFragmentsRule, parse, specified_rules, validate
from graphql import DefinitionNode, GraphQLSchema, NoUnusedFragmentsRule, build_schema, parse, specified_rules, validate
from rich.console import Console

from ..async_typer import AsyncTyper
from ..ctl.client import initialize_client
from ..ctl.utils import catch_exception
from ..graphql.utils import insert_fragments_inline, remove_fragment_import
from ..query_analyzer import GraphQLQueryReport, InfrahubQueryAnalyzer
from .parameters import CONFIG_PARAM

if TYPE_CHECKING:
from ..schema import BranchSchema


class CheckResults:
"""Container for check command results."""

def __init__(self) -> None:
self.single_target_count = 0
self.multi_target_count = 0
self.error_count = 0


def _print_query_result(console: Console, report: GraphQLQueryReport, results: CheckResults) -> None:
"""Print the result for a single query analysis."""
if report.only_has_unique_targets:
console.print("[green] Result: Single-target query (good)[/green]")
console.print(" This query targets unique nodes, enabling selective artifact regeneration.")
results.single_target_count += 1
else:
console.print("[yellow] Result: Multi-target query[/yellow]")
console.print(" May cause excessive artifact regeneration. Fix: filter by ID or unique attribute.")
results.multi_target_count += 1


def _analyze_query_file(
console: Console,
query_file: Path,
branch_schema: BranchSchema,
graphql_schema: GraphQLSchema,
idx: int,
total_files: int,
results: CheckResults,
) -> None:
"""Analyze a single GraphQL query file and print results."""
query_content = query_file.read_text(encoding="utf-8")

analyzer = InfrahubQueryAnalyzer(
query=query_content,
schema_branch=branch_schema,
schema=graphql_schema,
)

console.print(f"[dim]{'─' * 60}[/dim]")
console.print(f"[bold cyan][{idx}/{total_files}][/bold cyan] {query_file}")

is_valid, errors = analyzer.is_valid
if not is_valid:
console.print("[red] Validation failed:[/red]")
for error in errors or []:
console.print(f" - {error.message}")
results.error_count += 1
return

report = analyzer.query_report
console.print(f"[bold] Top-level kinds:[/bold] {', '.join(report.top_level_kinds) or 'None'}")

if not report.top_level_kinds:
console.print("[yellow] Warning: No Infrahub models found in query.[/yellow]")
console.print(" The query may reference types not in the schema, or only use non-model fields.")
results.error_count += 1
return

_print_query_result(console, report, results)


def _print_summary(console: Console, results: CheckResults) -> None:
"""Print the summary of check results."""
console.print(f"[dim]{'─' * 60}[/dim]")
console.print()
console.print("[bold]Summary:[/bold]")
if results.single_target_count:
console.print(f" [green]{results.single_target_count} single-target[/green]")
if results.multi_target_count:
console.print(f" [yellow]{results.multi_target_count} multi-target[/yellow]")
console.print(" See: https://docs.infrahub.app/topics/graphql")
if results.error_count:
console.print(f" [red]{results.error_count} errors[/red]")


app = AsyncTyper()
console = Console()

Expand Down Expand Up @@ -181,3 +263,54 @@ async def generate_return_types(

for file_name in package_generator._result_types_files:
console.print(f"[green]Generated {file_name} in {directory}")


@app.command()
@catch_exception(console=console)
async def check(
query: Path | None = typer.Argument(
None, help="Path to the GraphQL query file or directory. Defaults to current directory if not specified."
),
branch: str = typer.Option(None, help="Branch to use for schema."),
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Fix the type hint to allow None.

The branch parameter has a type hint of str but accepts None as the default value. This will cause type checker errors.

🔎 Proposed fix
-    branch: str = typer.Option(None, help="Branch to use for schema."),
+    branch: str | None = typer.Option(None, help="Branch to use for schema."),

As per coding guidelines, type hints are required on all function signatures.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
branch: str = typer.Option(None, help="Branch to use for schema."),
branch: str | None = typer.Option(None, help="Branch to use for schema."),
🤖 Prompt for AI Agents
In infrahub_sdk/ctl/graphql.py around line 274, the branch parameter is
annotated as str but has a default of None; update the annotation to allow None
(e.g., Optional[str] or str | None depending on project typing target) and
ensure you import Optional from typing if used or rely on PEP 604 union syntax;
keep the help text and default unchanged.

_: str = CONFIG_PARAM,
) -> None:
"""Check if GraphQL queries target single or multiple objects.

A single-target query is one that will return at most one object per query operation.
This is determined by checking if the query uses uniqueness constraints (like filtering by ID or name).

Multi-target queries may return multiple objects and should be used with caution in artifact definitions.
"""
query = Path.cwd() if query is None else query

try:
gql_files = find_gql_files(query)
except FileNotFoundError as exc:
console.print(f"[red]{exc}")
raise typer.Exit(1) from exc

if not gql_files:
console.print(f"[red]No .gql files found in: {query}")
raise typer.Exit(1)

client = initialize_client()

await client.schema.all(branch=branch)
branch_schema = client.schema.cache[branch or client.default_branch]

graphql_schema_text = await client.schema.get_graphql_schema()
graphql_schema = build_schema(graphql_schema_text)

total_files = len(gql_files)
console.print(f"[bold]Checking {total_files} GraphQL file{'s' if total_files > 1 else ''}...[/bold]")
console.print()

results = CheckResults()

for idx, query_file in enumerate(gql_files, 1):
_analyze_query_file(console, query_file, branch_schema, graphql_schema, idx, total_files, results)

_print_summary(console, results)

if results.error_count:
raise typer.Exit(1)
Loading
Loading