Skip to content

Commit da5bdd5

Browse files
committed
Add new GraphQL query InfrahubGraphQLQueryReport
1 parent 7a97d8e commit da5bdd5

15 files changed

Lines changed: 862 additions & 1 deletion

File tree

backend/infrahub/graphql/queries/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from .account import AccountPermissions, AccountToken
22
from .branch import BranchQueryList, InfrahubBranchQueryList
3+
from .graphql_query_report import InfrahubGraphQLQueryReport
34
from .internal import InfrahubInfo
45
from .ipam import InfrahubIPAddressGetNextAvailable, InfrahubIPPrefixGetNextAvailable
56
from .proposed_change import ProposedChangeAvailableActions
@@ -14,6 +15,7 @@
1415
"AccountToken",
1516
"BranchQueryList",
1617
"InfrahubBranchQueryList",
18+
"InfrahubGraphQLQueryReport",
1719
"InfrahubIPAddressGetNextAvailable",
1820
"InfrahubIPPrefixGetNextAvailable",
1921
"InfrahubInfo",
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
from __future__ import annotations
2+
3+
from typing import TYPE_CHECKING
4+
5+
from graphene import Boolean, Field, ObjectType, String
6+
7+
from infrahub.core import registry
8+
from infrahub.graphql.analyzer import InfrahubGraphQLQueryAnalyzer
9+
10+
if TYPE_CHECKING:
11+
from graphql import GraphQLResolveInfo
12+
13+
from infrahub.graphql.initialization import GraphqlContext
14+
15+
16+
class GraphQLQueryReport(ObjectType):
17+
targets_unique_nodes = Field(
18+
Boolean,
19+
required=True,
20+
description=(
21+
"True if every operation in the submitted query resolves to uniquely identifiable nodes "
22+
"(via a required ids argument or a required field matching the model uniqueness constraints). "
23+
"When true, Infrahub limits artifact regeneration to only the nodes that changed. "
24+
"When false, all artifacts for the definition are regenerated on any relevant node change."
25+
),
26+
)
27+
28+
29+
async def resolve_graphql_query_report(
30+
_root: None,
31+
info: GraphQLResolveInfo,
32+
query: str,
33+
) -> dict[str, bool]:
34+
graphql_context: GraphqlContext = info.context
35+
branch = graphql_context.branch
36+
schema_branch = registry.schema.get_schema_branch(name=branch.name)
37+
38+
analyzer = InfrahubGraphQLQueryAnalyzer(
39+
query=query,
40+
schema=info.schema,
41+
branch=branch,
42+
schema_branch=schema_branch,
43+
)
44+
45+
is_valid, errors = analyzer.is_valid
46+
if not is_valid and errors:
47+
raise errors[0]
48+
49+
return {"targets_unique_nodes": analyzer.query_report.only_has_unique_targets}
50+
51+
52+
InfrahubGraphQLQueryReport = Field(
53+
GraphQLQueryReport,
54+
query=String(required=True, description="The raw GraphQL query string to analyze."),
55+
description="Analyze a GraphQL query string and return a report describing how Infrahub will interpret it.",
56+
resolver=resolve_graphql_query_report,
57+
required=True,
58+
)

backend/infrahub/graphql/schema.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
AccountToken,
3939
BranchQueryList,
4040
InfrahubBranchQueryList,
41+
InfrahubGraphQLQueryReport,
4142
InfrahubInfo,
4243
InfrahubIPAddressGetNextAvailable,
4344
InfrahubIPPrefixGetNextAvailable,
@@ -65,6 +66,7 @@ class InfrahubBaseQuery(ObjectType):
6566
Relationship = Relationship
6667

6768
InfrahubBranch = InfrahubBranchQueryList
69+
InfrahubGraphQLQueryReport = InfrahubGraphQLQueryReport
6870
InfrahubInfo = InfrahubInfo
6971
InfrahubStatus = InfrahubStatus
7072

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
from __future__ import annotations
2+
3+
from dataclasses import dataclass
4+
from typing import TYPE_CHECKING
5+
6+
from tests.helpers.graphql import graphql_query
7+
8+
if TYPE_CHECKING:
9+
from infrahub.core.branch import Branch
10+
from infrahub.core.schema.schema_branch import SchemaBranch
11+
from infrahub.database import InfrahubDatabase
12+
13+
QUERY = """
14+
query ($q: String!) {
15+
InfrahubGraphQLQueryReport(query: $q) {
16+
targets_unique_nodes
17+
}
18+
}
19+
"""
20+
21+
22+
@dataclass
23+
class UniqueTargetsTestCase:
24+
analyzed_query: str
25+
expected: bool
26+
description: str
27+
28+
29+
UNIQUE_TARGETS_TEST_CASES = [
30+
UniqueTargetsTestCase(
31+
description="required variable matching uniqueness constraint",
32+
analyzed_query="""
33+
query ($name: String!) {
34+
TestCar(name__value: $name) {
35+
edges { node { id } }
36+
}
37+
}
38+
""",
39+
expected=True,
40+
),
41+
UniqueTargetsTestCase(
42+
description="hardcoded value matching uniqueness constraint",
43+
analyzed_query="""
44+
query {
45+
TestCar(name__value: "mycar") {
46+
edges { node { id } }
47+
}
48+
}
49+
""",
50+
expected=True,
51+
),
52+
UniqueTargetsTestCase(
53+
description="no filter returns all nodes",
54+
analyzed_query="""
55+
query {
56+
TestCar {
57+
edges { node { id } }
58+
}
59+
}
60+
""",
61+
expected=False,
62+
),
63+
UniqueTargetsTestCase(
64+
description="optional (nullable) variable does not guarantee uniqueness",
65+
analyzed_query="""
66+
query ($name: String) {
67+
TestCar(name__value: $name) {
68+
edges { node { id } }
69+
}
70+
}
71+
""",
72+
expected=False,
73+
),
74+
]
75+
76+
77+
async def test_targets_unique_nodes(
78+
db: InfrahubDatabase,
79+
default_branch: Branch,
80+
car_person_schema: SchemaBranch,
81+
) -> None:
82+
assert UNIQUE_TARGETS_TEST_CASES, "No test cases defined for unique targets test"
83+
for case in UNIQUE_TARGETS_TEST_CASES:
84+
response = await graphql_query(query=QUERY, db=db, branch=default_branch, variables={"q": case.analyzed_query})
85+
86+
assert not response.errors, f"Unexpected errors for case '{case.description}': {response.errors}"
87+
assert response.data
88+
result = response.data["InfrahubGraphQLQueryReport"]["targets_unique_nodes"]
89+
assert result is case.expected, f"Case '{case.description}': expected {case.expected}, got {result}"
90+
91+
92+
async def test_error_on_empty_query_string(
93+
db: InfrahubDatabase,
94+
default_branch: Branch,
95+
car_person_schema: SchemaBranch,
96+
) -> None:
97+
response = await graphql_query(query=QUERY, db=db, branch=default_branch, variables={"q": ""})
98+
99+
assert response.errors
100+
error = response.errors[0]
101+
assert "Syntax Error: Unexpected <EOF>." in error.message
102+
assert error.locations
103+
assert error.locations[0].line == 1
104+
105+
106+
async def test_error_on_invalid_graphql_syntax(
107+
db: InfrahubDatabase,
108+
default_branch: Branch,
109+
car_person_schema: SchemaBranch,
110+
) -> None:
111+
response = await graphql_query(query=QUERY, db=db, branch=default_branch, variables={"q": "not valid graphql {"})
112+
113+
assert response.errors
114+
error = response.errors[0]
115+
assert "Syntax Error: Unexpected Name 'not'." in error.message
116+
# Locations must point into the analyzed query string (line 1), not the
117+
# outer wrapper query — proves the inner GraphQLSyntaxError is re-raised
118+
# rather than wrapped in a fresh, location-less GraphQLError.
119+
assert error.locations
120+
assert error.locations[0].line == 1
121+
122+
123+
async def test_error_on_nonexistent_node_type(
124+
db: InfrahubDatabase,
125+
default_branch: Branch,
126+
car_person_schema: SchemaBranch,
127+
) -> None:
128+
inner_query = "query { NonExistentType123 { edges { node { id } } } }"
129+
response = await graphql_query(
130+
query=QUERY,
131+
db=db,
132+
branch=default_branch,
133+
variables={"q": inner_query},
134+
)
135+
136+
assert response.errors
137+
error = response.errors[0]
138+
assert "Cannot query field 'NonExistentType123' on type 'Query'." in error.message
139+
assert error.locations
140+
assert error.locations[0].line == 1
141+
assert error.locations[0].column == inner_query.index("NonExistentType123") + 1

changelog/IFC-2504.added.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Added InfrahubGraphQLQueryReport introspection query to report whether a GraphQL query targets unique nodes for artifact regeneration.
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
# Specification Quality Checklist: GraphQL Query Report Introspection
2+
3+
**Purpose**: Validate specification completeness and quality before proceeding to planning
4+
**Created**: 2026-04-25
5+
**Feature**: [spec.md](../spec.md)
6+
7+
## Content Quality
8+
9+
- [x] No implementation details (languages, frameworks, APIs)
10+
- [x] Focused on user value and business needs
11+
- [x] Written for non-technical stakeholders
12+
- [x] All mandatory sections completed
13+
14+
## Requirement Completeness
15+
16+
- [x] No [NEEDS CLARIFICATION] markers remain
17+
- [x] Requirements are testable and unambiguous
18+
- [x] Success criteria are measurable
19+
- [x] Success criteria are technology-agnostic (no implementation details)
20+
- [x] All acceptance scenarios are defined
21+
- [x] Edge cases are identified
22+
- [x] Scope is clearly bounded
23+
- [x] Dependencies and assumptions identified
24+
25+
## Feature Readiness
26+
27+
- [x] All functional requirements have clear acceptance criteria
28+
- [x] User scenarios cover primary flows
29+
- [x] Feature meets measurable outcomes defined in Success Criteria
30+
- [x] No implementation details leak into specification
31+
32+
## Notes
33+
34+
All checklist items pass. Spec is ready for `/speckit.clarify` or `/speckit.plan`.
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# GraphQL Contract: InfrahubGraphQLQueryReport
2+
# Feature: IFC-2504
3+
# Pattern: Follows InfrahubStatus (backend/infrahub/graphql/queries/status.py)
4+
5+
"""
6+
Analysis report for a submitted GraphQL query string.
7+
"""
8+
type InfrahubGraphQLQueryReport {
9+
"""
10+
True if every operation in the submitted query resolves to uniquely
11+
identifiable nodes (via required ids argument or required uniqueness
12+
constraint field). When true, Infrahub can limit artifact regeneration
13+
to only changed nodes. When false, all artifacts for the definition
14+
will be regenerated on any relevant node change.
15+
"""
16+
targets_unique_nodes: Boolean!
17+
}
18+
19+
extend type Query {
20+
"""
21+
Analyze a raw GraphQL query string and return a report describing how
22+
Infrahub will interpret it. Branch context is resolved automatically
23+
from the request — no branch argument is needed.
24+
"""
25+
InfrahubGraphQLQueryReport(
26+
"The raw GraphQL query string to analyze."
27+
query: String!
28+
): InfrahubGraphQLQueryReport!
29+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
# Data Model: GraphQL Query Report Introspection
2+
3+
**Feature**: IFC-2504 | **Date**: 2026-04-25
4+
5+
## Overview
6+
7+
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.
8+
9+
---
10+
11+
## Response Type: InfrahubGraphQLQueryReport
12+
13+
A structured analysis result for a submitted GraphQL query string.
14+
15+
| Field | Type | Required | Description |
16+
|-------|------|----------|-------------|
17+
| `targets_unique_nodes` | Boolean | Yes | `true` if every operation in the query resolves to uniquely identifiable nodes; `false` otherwise. |
18+
19+
### Uniqueness Definition
20+
21+
`targets_unique_nodes` is `true` if and only if, for every top-level operation in the submitted query:
22+
23+
- The operation uses an `ids` argument **as a required argument**, OR
24+
- The operation uses a field that matches the model's uniqueness constraints **as a required argument**
25+
26+
"Required argument" means the argument is either a non-nullable variable declared in the query, or a static literal value.
27+
28+
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.
29+
30+
### Future Extension
31+
32+
The response type is designed to accommodate additional fields from `GraphQLQueryReport` without breaking existing callers:
33+
34+
- `requested_read` — which node kinds and fields the query reads
35+
- `variables` — which variables the query declares
36+
- `impacted_models` — which Infrahub models the query touches
37+
38+
These are already computed by `InfrahubGraphQLQueryAnalyzer.query_report`; this feature makes them accessible for future ad-hoc inspection.
39+
40+
---
41+
42+
## Input
43+
44+
| Parameter | Type | Required | Description |
45+
|-----------|------|----------|-------------|
46+
| `query` | String | Yes | Raw GraphQL query string to analyze. Must be syntactically valid GraphQL and reference types that exist in the current branch schema. |
47+
48+
### Error Conditions
49+
50+
| Condition | Behavior |
51+
|-----------|----------|
52+
| Empty string | GraphQL error returned |
53+
| Syntactically invalid GraphQL | GraphQL error returned (raised during parse) |
54+
| References non-existent node types | GraphQL error returned (caught by schema validation) |

0 commit comments

Comments
 (0)