Skip to content
Draft
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
2 changes: 2 additions & 0 deletions backend/infrahub/graphql/queries/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from .account import AccountPermissions, AccountToken
from .branch import BranchQueryList, InfrahubBranchQueryList
from .graphql_query_report import InfrahubGraphQLQueryReport
from .internal import InfrahubInfo
from .ipam import InfrahubIPAddressGetNextAvailable, InfrahubIPPrefixGetNextAvailable
from .proposed_change import ProposedChangeAvailableActions
Expand All @@ -14,6 +15,7 @@
"AccountToken",
"BranchQueryList",
"InfrahubBranchQueryList",
"InfrahubGraphQLQueryReport",
"InfrahubIPAddressGetNextAvailable",
"InfrahubIPPrefixGetNextAvailable",
"InfrahubInfo",
Expand Down
65 changes: 65 additions & 0 deletions backend/infrahub/graphql/queries/graphql_query_report.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
from __future__ import annotations

from typing import TYPE_CHECKING, Any

from graphene import Boolean, Field, ObjectType, String
from graphql import GraphQLError, GraphQLSyntaxError

from infrahub.core import registry
from infrahub.graphql.analyzer import InfrahubGraphQLQueryAnalyzer

if TYPE_CHECKING:
from graphql import GraphQLResolveInfo

from infrahub.graphql.initialization import GraphqlContext


class GraphQLQueryReport(ObjectType):
targets_unique_nodes = Field(
Boolean,
required=True,
description=(
"True if every operation in the submitted query resolves to uniquely identifiable nodes "
"(via a required ids argument or a required field matching the model uniqueness constraints). "
"When true, Infrahub limits artifact regeneration to only the nodes that changed. "
"When false, all artifacts for the definition are regenerated on any relevant node change."
),
)


async def resolve_graphql_query_report(
root: dict, # noqa: ARG001
info: GraphQLResolveInfo,
query: str,
) -> dict[str, Any]:
graphql_context: GraphqlContext = info.context
branch = graphql_context.branch
schema_branch = registry.schema.get_schema_branch(name=branch.name)

try:
analyzer = InfrahubGraphQLQueryAnalyzer(
query=query,
schema=info.schema,
branch=branch,
schema_branch=schema_branch,
)
except GraphQLSyntaxError as exc:
raise GraphQLError(str(exc)) from exc

is_valid, errors = analyzer.is_valid
if not is_valid:
raise GraphQLError(str(errors))

return {"targets_unique_nodes": analyzer.query_report.only_has_unique_targets}


InfrahubGraphQLQueryReport = Field(
GraphQLQueryReport,
query=String(required=True, description="The raw GraphQL query string to analyze."),
description=(
"Analyze a GraphQL query string and return a report describing how Infrahub will interpret it. "
"Branch context is resolved automatically from the request."
),
resolver=resolve_graphql_query_report,
required=True,
)
2 changes: 2 additions & 0 deletions backend/infrahub/graphql/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
AccountToken,
BranchQueryList,
InfrahubBranchQueryList,
InfrahubGraphQLQueryReport,
InfrahubInfo,
InfrahubIPAddressGetNextAvailable,
InfrahubIPPrefixGetNextAvailable,
Expand Down Expand Up @@ -65,6 +66,7 @@ class InfrahubBaseQuery(ObjectType):
Relationship = Relationship

InfrahubBranch = InfrahubBranchQueryList
InfrahubGraphQLQueryReport = InfrahubGraphQLQueryReport
InfrahubInfo = InfrahubInfo
InfrahubStatus = InfrahubStatus

Expand Down
127 changes: 127 additions & 0 deletions backend/tests/component/graphql/queries/test_graphql_query_report.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
from __future__ import annotations

from dataclasses import dataclass
from typing import TYPE_CHECKING

from tests.helpers.graphql import graphql_query

if TYPE_CHECKING:
from infrahub.core.branch import Branch
from infrahub.core.schema.schema_branch import SchemaBranch
from infrahub.database import InfrahubDatabase

QUERY = """
query ($q: String!) {
InfrahubGraphQLQueryReport(query: $q) {
targets_unique_nodes
}
}
"""


@dataclass
class UniqueTargetsTestCase:
analyzed_query: str
expected: bool
description: str


UNIQUE_TARGETS_TEST_CASES = [
UniqueTargetsTestCase(
description="required variable matching uniqueness constraint",
analyzed_query="""
query ($name: String!) {
TestCar(name__value: $name) {
edges { node { id } }
}
}
""",
expected=True,
),
UniqueTargetsTestCase(
description="hardcoded value matching uniqueness constraint",
analyzed_query="""
query {
TestCar(name__value: "mycar") {
edges { node { id } }
}
}
""",
expected=True,
),
UniqueTargetsTestCase(
description="no filter returns all nodes",
analyzed_query="""
query {
TestCar {
edges { node { id } }
}
}
""",
expected=False,
),
UniqueTargetsTestCase(
description="optional (nullable) variable does not guarantee uniqueness",
analyzed_query="""
query ($name: String) {
TestCar(name__value: $name) {
edges { node { id } }
}
}
""",
expected=False,
),
]


async def test_targets_unique_nodes(
db: InfrahubDatabase,
default_branch: Branch,
car_person_schema: SchemaBranch,
) -> None:
assert UNIQUE_TARGETS_TEST_CASES, "No test cases defined for unique targets test"
for case in UNIQUE_TARGETS_TEST_CASES:
response = await graphql_query(query=QUERY, db=db, branch=default_branch, variables={"q": case.analyzed_query})

assert not response.errors, f"Unexpected errors for case '{case.description}': {response.errors}"
assert response.data
result = response.data["InfrahubGraphQLQueryReport"]["targets_unique_nodes"]
assert result is case.expected, f"Case '{case.description}': expected {case.expected}, got {result}"


async def test_error_on_empty_query_string(
db: InfrahubDatabase,
default_branch: Branch,
car_person_schema: SchemaBranch,
) -> None:
response = await graphql_query(query=QUERY, db=db, branch=default_branch, variables={"q": ""})

assert response.errors
assert "Syntax Error: Unexpected <EOF>." in response.errors[0].message


async def test_error_on_invalid_graphql_syntax(
db: InfrahubDatabase,
default_branch: Branch,
car_person_schema: SchemaBranch,
) -> None:
response = await graphql_query(query=QUERY, db=db, branch=default_branch, variables={"q": "not valid graphql {"})

assert response.errors
assert "Syntax Error: Unexpected Name 'not'." in response.errors[0].message


async def test_error_on_nonexistent_node_type(
db: InfrahubDatabase,
default_branch: Branch,
car_person_schema: SchemaBranch,
) -> None:
response = await graphql_query(
query=QUERY,
db=db,
branch=default_branch,
variables={"q": "query { NonExistentType123 { edges { node { id } } } }"},
)

assert response.errors
assert "Cannot query field 'NonExistentType123' on type 'Query'." in response.errors[0].message
1 change: 1 addition & 0 deletions changelog/IFC-2504.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Added InfrahubGraphQLQueryReport introspection query to report whether a GraphQL query targets unique nodes for artifact regeneration.
34 changes: 34 additions & 0 deletions dev/specs/ifc-2504-graphql-query-report/checklists/requirements.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Specification Quality Checklist: GraphQL Query Report Introspection

**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-04-25
**Feature**: [spec.md](../spec.md)

## Content Quality

- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed

## Requirement Completeness

- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified

## Feature Readiness

- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification

## Notes

All checklist items pass. Spec is ready for `/speckit.clarify` or `/speckit.plan`.
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# GraphQL Contract: InfrahubGraphQLQueryReport
# Feature: IFC-2504
# Pattern: Follows InfrahubStatus (backend/infrahub/graphql/queries/status.py)

"""
Analysis report for a submitted GraphQL query string.
"""
type InfrahubGraphQLQueryReport {
"""
True if every operation in the submitted query resolves to uniquely
identifiable nodes (via required ids argument or required uniqueness
constraint field). When true, Infrahub can limit artifact regeneration
to only changed nodes. When false, all artifacts for the definition
will be regenerated on any relevant node change.
"""
targets_unique_nodes: Boolean!
}

extend type Query {
"""
Analyze a raw GraphQL query string and return a report describing how
Infrahub will interpret it. Branch context is resolved automatically
from the request — no branch argument is needed.
"""
InfrahubGraphQLQueryReport(
"The raw GraphQL query string to analyze."
query: String!
): InfrahubGraphQLQueryReport!
}
54 changes: 54 additions & 0 deletions dev/specs/ifc-2504-graphql-query-report/data-model.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# Data Model: GraphQL Query Report Introspection

**Feature**: IFC-2504 | **Date**: 2026-04-25

## Overview

This feature introduces no new graph database entities. The `InfrahubGraphQLQueryReport` is a **transient response type** — computed on-the-fly from the submitted query string and discarded after the response. Nothing is persisted.

---

## Response Type: InfrahubGraphQLQueryReport

A structured analysis result for a submitted GraphQL query string.

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `targets_unique_nodes` | Boolean | Yes | `true` if every operation in the query resolves to uniquely identifiable nodes; `false` otherwise. |

### Uniqueness Definition

`targets_unique_nodes` is `true` if and only if, for every top-level operation in the submitted query:

- The operation uses an `ids` argument **as a required argument**, OR
- The operation uses a field that matches the model's uniqueness constraints **as a required argument**

"Required argument" means the argument is either a non-nullable variable declared in the query, or a static literal value.

When `true`, Infrahub can limit artifact regeneration to only the nodes that changed. When `false`, all artifacts for the definition are regenerated on any relevant node change.

### Future Extension

The response type is designed to accommodate additional fields from `GraphQLQueryReport` without breaking existing callers:

- `requested_read` — which node kinds and fields the query reads
- `variables` — which variables the query declares
- `impacted_models` — which Infrahub models the query touches

These are already computed by `InfrahubGraphQLQueryAnalyzer.query_report`; this feature makes them accessible for future ad-hoc inspection.

---

## Input

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `query` | String | Yes | Raw GraphQL query string to analyze. Must be syntactically valid GraphQL and reference types that exist in the current branch schema. |

### Error Conditions

| Condition | Behavior |
|-----------|----------|
| Empty string | GraphQL error returned |
| Syntactically invalid GraphQL | GraphQL error returned (raised during parse) |
| References non-existent node types | GraphQL error returned (caught by schema validation) |
Loading
Loading