Skip to content

Commit 576608e

Browse files
committed
Add support for GraphQL fragment inlining
1 parent 9871167 commit 576608e

18 files changed

Lines changed: 1185 additions & 6 deletions

File tree

Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
# SDK Spec: GraphQL Fragment Inlining
2+
3+
**Jira**: INFP-496
4+
**Created**: 2026-03-13
5+
**Status**: Implemented
6+
**Parent spec**: [infrahub/dev/specs/infp-496-graphql-fragment-inlining/spec.md](../../../../dev/specs/infp-496-graphql-fragment-inlining/spec.md)
7+
8+
## Scope
9+
10+
This spec covers the SDK-side responsibilities for the GraphQL Fragment Inlining feature (FR-015). The Infrahub server and backend integration are documented in the parent spec.
11+
12+
Per the architecture decision in the parent spec:
13+
14+
> Fragment parsing, resolution, and rendering is **SDK responsibility**, not server responsibility.
15+
16+
The SDK must provide:
17+
18+
1. **Config model extension**`graphql_fragments` in `.infrahub.yml`
19+
2. **Fragment renderer** — parse, resolve (transitively), and render queries
20+
3. **CLI integration**`infrahubctl` local workflows apply fragment rendering automatically
21+
22+
---
23+
24+
## Component Responsibilities
25+
26+
| Responsibility | SDK Module |
27+
| --- | --- |
28+
| `InfrahubRepositoryFragmentConfig` model | `infrahub_sdk/schema/repository.py` |
29+
| `graphql_fragments` field on `InfrahubRepositoryConfig` | `infrahub_sdk/schema/repository.py` |
30+
| Read fragment file content from disk | `InfrahubRepositoryFragmentConfig.load_fragments()` |
31+
| Parse `.gql` fragment files into AST | `infrahub_sdk/graphql/fragment_renderer.py` |
32+
| Build fragment name index (across all declared files) | `infrahub_sdk/graphql/fragment_renderer.py` |
33+
| Detect duplicate fragment names across files | `infrahub_sdk/graphql/fragment_renderer.py` |
34+
| Resolve transitive fragment dependencies | `infrahub_sdk/graphql/fragment_renderer.py` |
35+
| Detect circular fragment dependencies | `infrahub_sdk/graphql/fragment_renderer.py` |
36+
| Render self-contained query document | `infrahub_sdk/graphql/fragment_renderer.py` |
37+
| High-level `render_query()` entry point (load + render) | `infrahub_sdk/graphql/query_renderer.py` |
38+
| Typed error exceptions | `infrahub_sdk/exceptions.py` |
39+
| Apply rendering in `infrahubctl` execution | `infrahub_sdk/ctl/utils.py` |
40+
| Apply rendering in `infrahubctl` transform | `infrahub_sdk/ctl/cli_commands.py` |
41+
42+
---
43+
44+
## API Contract: Fragment Renderer
45+
46+
### CLI-facing entry point
47+
48+
```python
49+
# infrahub_sdk/graphql/query_renderer.py
50+
51+
def render_query(name: str, config: InfrahubRepositoryConfig, relative_path: str = ".") -> str:
52+
"""Return a self-contained GraphQL document for the named query, with fragment definitions inlined.
53+
54+
Loads the query file and all declared fragment files from config, then delegates to
55+
render_query_with_fragments.
56+
57+
Raises:
58+
ResourceNotDefinedError: Query name not found in config.
59+
FragmentFileNotFoundError: A declared fragment file path does not exist.
60+
DuplicateFragmentError: Same fragment name declared in multiple files.
61+
FragmentNotFoundError: Query references a fragment not found in any declared file.
62+
CircularFragmentError: Circular dependency detected among fragments.
63+
"""
64+
```
65+
66+
### Low-level entry point
67+
68+
```python
69+
# infrahub_sdk/graphql/fragment_renderer.py
70+
71+
def render_query_with_fragments(query_str: str, fragment_files: list[str]) -> str:
72+
"""Return a self-contained GraphQL document with required fragment definitions inlined.
73+
74+
If the query contains no fragment spreads, query_str is returned unchanged.
75+
76+
Raises:
77+
QuerySyntaxError: Query string or a fragment file contains invalid GraphQL syntax.
78+
DuplicateFragmentError: Same fragment name declared in multiple files.
79+
FragmentNotFoundError: Query references a fragment not found in any declared file.
80+
CircularFragmentError: Circular dependency detected among fragments.
81+
"""
82+
```
83+
84+
### Public helpers in `fragment_renderer.py`
85+
86+
```python
87+
def build_fragment_index(fragment_files: list[str]) -> dict[str, FragmentDefinitionNode]:
88+
"""Parse all fragment file contents and return a mapping from fragment name to its AST node."""
89+
90+
def collect_required_fragments(
91+
query_doc: DocumentNode,
92+
fragment_index: dict[str, FragmentDefinitionNode],
93+
) -> list[str]:
94+
"""Walk query_doc and collect all fragment names required (transitively).
95+
96+
Returns a topologically ordered list of unique fragment names.
97+
"""
98+
```
99+
100+
### Error types (additions to `infrahub_sdk/exceptions.py`)
101+
102+
```python
103+
class GraphQLQueryError(Error):
104+
"""Base class for all errors raised during GraphQL query rendering."""
105+
106+
107+
class QuerySyntaxError(GraphQLQueryError):
108+
def __init__(self, syntax_error: str) -> None: ...
109+
# message: f"GraphQL syntax error: {syntax_error}"
110+
111+
112+
class FragmentNotFoundError(GraphQLQueryError):
113+
def __init__(self, fragment_name: str, query_file: str | None = None, message: str | None = None) -> None: ...
114+
# message: f"Fragment '{fragment_name}' not found." (or mentions query_file if provided)
115+
116+
117+
class DuplicateFragmentError(GraphQLQueryError):
118+
def __init__(self, fragment_name: str, message: str | None = None) -> None: ...
119+
# message: f"Fragment '{fragment_name}' is defined more than once across declared fragment files."
120+
121+
122+
class CircularFragmentError(GraphQLQueryError):
123+
def __init__(self, cycle: list[str], message: str | None = None) -> None: ...
124+
# message: f"Circular fragment dependency detected: {' -> '.join(cycle)}."
125+
126+
127+
class FragmentFileNotFoundError(GraphQLQueryError):
128+
def __init__(self, file_path: str, message: str | None = None) -> None: ...
129+
# message: f"Fragment file '{file_path}' declared in graphql_fragments does not exist."
130+
```
131+
132+
`GraphQLQueryError` is also handled in `handle_exception()` in `ctl/utils.py` so CLI commands print
133+
a clean error message and exit instead of raising an unhandled exception.
134+
135+
---
136+
137+
## Config Model Extension
138+
139+
```python
140+
# infrahub_sdk/schema/repository.py
141+
142+
class InfrahubRepositoryFragmentConfig(InfrahubRepositoryConfigElement):
143+
model_config = ConfigDict(extra="forbid")
144+
name: str = Field(..., description="Logical name for this fragment file or directory")
145+
file_path: Path = Field(..., description="Path to a .gql file or directory of .gql files, relative to repo root")
146+
147+
def load_fragments(self, relative_path: str = ".") -> list[str]:
148+
"""Read and return raw content of all fragment files at file_path.
149+
150+
If file_path is a .gql file, returns a single-element list.
151+
If file_path is a directory, returns one entry per .gql file found (sorted).
152+
Raises FragmentFileNotFoundError if file_path does not exist.
153+
"""
154+
resolved = Path(f"{relative_path}/{self.file_path}")
155+
if not resolved.exists():
156+
raise FragmentFileNotFoundError(file_path=str(self.file_path))
157+
if resolved.is_dir():
158+
return [f.read_text(encoding="UTF-8") for f in sorted(resolved.glob("*.gql"))]
159+
return [resolved.read_text(encoding="UTF-8")]
160+
161+
162+
class InfrahubRepositoryConfig(BaseModel):
163+
# ... existing fields ...
164+
graphql_fragments: list[InfrahubRepositoryFragmentConfig] = Field(
165+
default_factory=list, description="GraphQL fragment files"
166+
)
167+
```
168+
169+
---
170+
171+
## infrahubctl Integration
172+
173+
Both CLI call sites use `render_query()` from `query_renderer.py`, which handles loading fragment
174+
files from config and delegating to `render_query_with_fragments`.
175+
176+
### `execute_graphql_query()` in `ctl/utils.py`
177+
178+
```python
179+
# Before
180+
query_str = query_object.load_query()
181+
182+
# After
183+
query_str = render_query(name=query, config=repository_config)
184+
```
185+
186+
### `transform()` in `ctl/cli_commands.py`
187+
188+
```python
189+
# Before
190+
query_str = repository_config.get_query(name=transform.query).load_query()
191+
192+
# After
193+
query_str = render_query(name=transform.query, config=repository_config)
194+
```
195+
196+
---
197+
198+
## Testing Requirements
199+
200+
### Unit tests — `tests/unit/sdk/graphql/test_fragment_renderer.py`
201+
202+
- Render query with single direct fragment spread → correct output
203+
- Render query with fragment spreads across two files → correct output
204+
- Render query with transitive dependency (A → B across files) → correct output
205+
- Render query with no fragment spreads → returned unchanged
206+
- Same fragment spread used twice → fragment definition appears once in output
207+
- Only required fragments included, not all from the file
208+
- `FragmentNotFoundError` raised for unresolved spread
209+
- `DuplicateFragmentError` raised for duplicate name across multiple content strings
210+
- `DuplicateFragmentError` raised for duplicate name within the same content string
211+
- `CircularFragmentError` raised for A→B→A cycle
212+
- `QuerySyntaxError` raised for invalid GraphQL syntax in query or fragment file
213+
214+
### Unit tests — `tests/unit/sdk/graphql/test_query_renderer.py`
215+
216+
- `render_query()` loads query + fragments from config and returns rendered document
217+
- `render_query()` with no `graphql_fragments` in config returns query unchanged
218+
219+
### Unit tests — `tests/unit/sdk/test_repository.py`
220+
221+
- `InfrahubRepositoryConfig` parses `graphql_fragments` YAML correctly
222+
- `InfrahubRepositoryFragmentConfig.load_fragments()` with a file path returns a single-element list with the file content
223+
- `InfrahubRepositoryFragmentConfig.load_fragments()` with a directory path returns one entry per `.gql` file found
224+
- `load_fragments()` raises `FragmentFileNotFoundError` for a path that does not exist
225+
226+
### Integration / functional tests — caller-side
227+
228+
- See main repo plan for backend integration tests and E2E fixtures
229+
230+
---
231+
232+
## Constraints
233+
234+
- Fragment rendering uses only `graphql-core` (already a dependency). No new dependencies.
235+
- All new public functions carry full type hints.
236+
- Both async and sync `InfrahubClient` paths are unaffected — rendering is a pure string transformation with no I/O.
237+
- Generated files (`protocols.py`) are not touched.
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
# Tasks: GraphQL Fragment Inlining — SDK Scope
2+
3+
**Input**: `python_sdk/dev/specs/infp-496-graphql-fragment-inlining/spec.md`
4+
**Parent tasks**: `specs/infp-496-graphql-fragment-inlining/tasks.md` (full feature view including backend)
5+
**Scope**: All work inside `python_sdk/` only (FR-015: all fragment logic lives in the SDK)
6+
7+
**Path note**: All file paths below are relative to the **infrahub repo root** (e.g.,
8+
`python_sdk/infrahub_sdk/...`). The `python_sdk/` directory is a git submodule — changes inside
9+
it must be committed separately from the main infrahub repo.
10+
11+
## Format: `[ID] [P?] [Story] Description`
12+
13+
- **[P]**: Can run in parallel (different files, no dependencies)
14+
- **[Story]**: Which user story this task belongs to
15+
- Include exact file paths in descriptions
16+
17+
---
18+
19+
## Phase 1: Setup (Fixture Repository)
20+
21+
**Purpose**: Create the fixture repository used by SDK unit tests and backend component tests.
22+
All fixture files live inside the `python_sdk` submodule.
23+
24+
- [ ] T001 Create fixture repo directory structure at `python_sdk/tests/fixtures/repos/fragment_inlining/` (subdirectories: `fragments/`, `queries/`)
25+
- [ ] T002 [P] Create `python_sdk/tests/fixtures/repos/fragment_inlining/fragments/interfaces.gql` (defines `interfaceFragment`, `portFragment`) and `python_sdk/tests/fixtures/repos/fragment_inlining/fragments/devices.gql` (defines `deviceFragment` that spreads `...interfaceFragment`, and `chassisFragment`)
26+
- [ ] T003 [P] Create `python_sdk/tests/fixtures/repos/fragment_inlining/queries/query_two_files.gql` (spreads `...interfaceFragment` and `...deviceFragment`), `query_no_fragments.gql` (no spreads), `query_transitive.gql` (spreads `...deviceFragment` only), `query_missing_fragment.gql` (spreads `...undeclaredFragment`)
27+
- [ ] T004 Create `python_sdk/tests/fixtures/repos/fragment_inlining/.infrahub.yml` declaring `graphql_fragments` (both fragment files under `fragments/`) and `graphql_queries` (all four query files under `queries/`)
28+
29+
---
30+
31+
## Phase 2: Foundational (SDK Core — Blocking Prerequisites)
32+
33+
**Purpose**: Exception types, config model, and renderer are needed by all user story phases.
34+
35+
**⚠️ CRITICAL**: No user story work can begin until this phase is complete.
36+
37+
- [x] T005 Add `GraphQLQueryError` base class plus five typed exception classes (`QuerySyntaxError`, `FragmentNotFoundError`, `DuplicateFragmentError`, `CircularFragmentError`, `FragmentFileNotFoundError`) to `python_sdk/infrahub_sdk/exceptions.py` — all use `__init__`-based pattern, all extend `GraphQLQueryError`; update `handle_exception()` in `ctl/utils.py` to also catch `GraphQLQueryError`
38+
- [x] T006 Add `InfrahubRepositoryFragmentConfig` class with `name: str`, `file_path: Path`, and `load_fragments(relative_path: str = ".") -> list[str]` method to `python_sdk/infrahub_sdk/schema/repository.py` — mirror the existing `InfrahubRepositoryGraphQLConfig` pattern
39+
- [x] T007 Add `graphql_fragments: list[InfrahubRepositoryFragmentConfig] = Field(default_factory=list)` field to `InfrahubRepositoryConfig` in `python_sdk/infrahub_sdk/schema/repository.py`
40+
- [x] T008 Create `python_sdk/infrahub_sdk/graphql/fragment_renderer.py` with **public** functions `build_fragment_index(fragment_files: list[str]) -> dict[str, FragmentDefinitionNode]` and `collect_required_fragments(query_doc: DocumentNode, fragment_index: dict[str, FragmentDefinitionNode]) -> list[str]`
41+
- [x] T009 Add `render_query_with_fragments(query_str: str, fragment_files: list[str]) -> str` to `python_sdk/infrahub_sdk/graphql/fragment_renderer.py`; early-return when query has no fragment spreads (FR-011); also raises `QuerySyntaxError` for invalid syntax in query or fragment files
42+
- [x] T009b Create `python_sdk/infrahub_sdk/graphql/query_renderer.py` with `render_query(name: str, config: InfrahubRepositoryConfig, relative_path: str = ".") -> str` — high-level entry point used by CLI: loads query + fragment files from the configuration, delegates to `render_query_with_fragments`
43+
44+
**Checkpoint**: SDK core complete — all test and CLI phases can now proceed
45+
46+
---
47+
48+
## Phase 3: User Story 1 — Basic Fragment Import (Priority: P1) 🎯 MVP
49+
50+
**Goal**: The renderer correctly inlines required fragments from multiple files and excludes
51+
unreferenced ones. Repository configuration parses `graphql_fragments` YAML correctly.
52+
53+
**Independent Test**:
54+
55+
```bash
56+
cd python_sdk && uv run pytest tests/unit/sdk/graphql/test_fragment_renderer.py -v
57+
cd python_sdk && uv run pytest tests/unit/sdk/test_repository.py -v -k fragment
58+
```
59+
60+
- [ ] T010 [P] [US1] Write unit tests covering: single direct spread from one file → renders correctly; spreads across two files → both rendered; no spreads → query returned unchanged; same spread used twice → definition appears once; surplus definitions excluded — in `python_sdk/tests/unit/sdk/graphql/test_fragment_renderer.py`
61+
- [ ] T011 [P] [US1] Write unit tests covering: `InfrahubRepositoryConfig` parses `graphql_fragments` YAML section; `load_fragments()` with a file path returns single-element list with file content; `load_fragments()` with a directory path returns one entry per `.gql` file (sorted alphabetically); `load_fragments()` raises `FragmentFileNotFoundError` for a path that does not exist — in `python_sdk/tests/unit/sdk/test_repository.py`
62+
63+
**Checkpoint**: US1 SDK work fully tested and independently verifiable
64+
65+
---
66+
67+
## Phase 4: User Story 2 — Transitive Fragment Dependencies (Priority: P2)
68+
69+
**Goal**: `collect_required_fragments` resolves transitive spreads so both A and its dependency B
70+
are included even when the query only references A directly.
71+
72+
**Independent Test**:
73+
74+
```bash
75+
cd python_sdk && uv run pytest tests/unit/sdk/graphql/test_fragment_renderer.py -v -k transitive
76+
```
77+
78+
- [ ] T013 [P] [US2] Write unit tests covering: transitive dependency across two files (query spreads `...deviceFragment`; `deviceFragment` spreads `...interfaceFragment` in a different file → both definitions in output); only directly/transitively required fragments included, not all from the files — in `python_sdk/tests/unit/sdk/graphql/test_fragment_renderer.py`
79+
80+
**Checkpoint**: US1 + US2 SDK logic fully tested
81+
82+
---
83+
84+
## Phase 5: User Story 4 — Graceful Failure for Unresolved Fragments (Priority: P2)
85+
86+
**Goal**: All four error types are raised correctly under their documented conditions.
87+
88+
**Independent Test**:
89+
90+
```bash
91+
cd python_sdk && uv run pytest tests/unit/sdk/graphql/test_fragment_renderer.py -v -k error
92+
```
93+
94+
- [ ] T014 [P] [US4] Write unit tests covering: `FragmentNotFoundError` raised when spread references name absent from all fragment files; `DuplicateFragmentError` raised when same name appears in two separate content strings; `DuplicateFragmentError` raised when same name appears twice within one content string; `CircularFragmentError` raised for A→B→A cycle — in `python_sdk/tests/unit/sdk/graphql/test_fragment_renderer.py`
95+
96+
**Checkpoint**: All renderer error paths tested
97+
98+
---
99+
100+
## Phase 6: infrahubctl CLI Integration (enables US1 + US4 for local workflows)
101+
102+
**Goal**: `infrahubctl` local execution paths apply fragment rendering automatically when
103+
`graphql_fragments` is declared in `.infrahub.yml` (FR-016).
104+
105+
**Independent Test**: Run `infrahubctl run` pointing at the fixture repository; the query executes
106+
without unresolved-spread errors.
107+
108+
- [x] T019 [P] [US1] Update `execute_graphql_query()` in `python_sdk/infrahub_sdk/ctl/utils.py`: replace `query_object.load_query()` with `render_query(name=query, config=repository_config)` from `query_renderer.py`
109+
- [x] T020 [P] [US1] Update `transform()` in `python_sdk/infrahub_sdk/ctl/cli_commands.py`: replace `repository_config.get_query(name=...).load_query()` with `render_query(name=transform.query, config=repository_config)` from `query_renderer.py`
110+
111+
**Checkpoint**: Both server sync and infrahubctl CLI paths apply fragment rendering
112+
113+
---
114+
115+
## Phase 7: Polish & SDK-Specific Concerns
116+
117+
- [ ] T021 [P] Run `cd python_sdk && uv run invoke format lint-code` to verify no ruff/mypy violations in modified files (`exceptions.py`, `schema/repository.py`, `graphql/fragment_renderer.py`, `ctl/utils.py`, `ctl/cli_commands.py`)
118+
- [ ] T022 Run `cd python_sdk && uv run invoke docs-generate` to regenerate SDK CLI + configuration docs after docstring additions
119+
120+
---
121+
122+
## Dependencies & Execution Order
123+
124+
### Phase Dependencies
125+
126+
- **Setup (Phase 1)**: No dependencies — start immediately
127+
- **Foundational (Phase 2)**: Depends on Phase 1 — BLOCKS all user story phases
128+
- **US1 (Phase 3)**: Depends on Phase 2; T010 and T011 are independent [P]
129+
- **US2 (Phase 4)**: Depends on Phase 2 (renderer transitive logic already in T008)
130+
- **US4 (Phase 5)**: Depends on Phase 2 (error types in T005, error logic in T008)
131+
- **CLI Integration (Phase 6)**: Depends on Phase 2; T019 and T020 are independent [P]
132+
- **Polish (Phase 7)**: After all phases complete
133+
134+
### Parallel Opportunities
135+
136+
```bash
137+
# Phase 1 — after T001 completes:
138+
T002 python_sdk/tests/fixtures/repos/fragment_inlining/fragments/*.gql
139+
T003 python_sdk/tests/fixtures/repos/fragment_inlining/queries/*.gql
140+
141+
# Phase 3 — after Phase 2 completes:
142+
T010 python_sdk/tests/unit/sdk/graphql/test_fragment_renderer.py
143+
T011 python_sdk/tests/unit/sdk/test_repository.py
144+
145+
# Phase 6 — after Phase 2 completes (independent of Phase 3):
146+
T019 python_sdk/infrahub_sdk/ctl/utils.py
147+
T020 python_sdk/infrahub_sdk/ctl/cli_commands.py
148+
```
149+
150+
---
151+
152+
## Notes
153+
154+
- `python_sdk/` is a git submodule — commit changes there separately from the main Infrahub repository
155+
- Run `cd python_sdk && uv run invoke format lint-code` before committing any Python changes
156+
- Run `cd python_sdk && uv run invoke docs-generate` after any docstring or CLI command changes
157+
- Backend integration tasks (updating `backend/infrahub/git/integrator.py` and writing component tests) are in `specs/infp-496-graphql-fragment-inlining/tasks.md`

0 commit comments

Comments
 (0)