diff --git a/dev/README.md b/dev/README.md index efffbd2fe41..1a0f1efb753 100644 --- a/dev/README.md +++ b/dev/README.md @@ -47,6 +47,7 @@ Mark deprecated docs clearly. Don't delete—update with pointers to replacement - **Repository Organization**: [guidelines/repository-organization.md](guidelines/repository-organization.md) - How to organize content in dev/ - **Python Backend**: [guidelines/backend/python.md](guidelines/backend/python.md) +- **GraphQL API**: [guidelines/backend/graphql.md](guidelines/backend/graphql.md) - Conventions for hand-written GraphQL queries and resolvers - **TypeScript Frontend**: [guidelines/frontend/typescript.md](guidelines/frontend/typescript.md) - **Git Workflow**: [guidelines/git-workflow.md](guidelines/git-workflow.md) - **Markdown Formatting**: [guidelines/markdown.md](guidelines/markdown.md) @@ -61,6 +62,11 @@ Backend architecture documentation in [knowledge/backend/](knowledge/backend/): - [events.md](knowledge/backend/events.md) - Events system - [async-tasks.md](knowledge/backend/async-tasks.md) - Asynchronous tasks (Prefect) - [message-bus.md](knowledge/backend/message-bus.md) - Message bus system +- [query-target-uniqueness.md](knowledge/backend/query-target-uniqueness.md) - How query targeting scopes artifact and generator regeneration + +## Current ADRs + +Architecture Decision Records in [adr/](adr/) - see the [ADR index](adr/README.md) for the full list. ## Current Guides diff --git a/dev/adr/0020-analyzer-single-source-of-truth-for-query-targeting.md b/dev/adr/0020-analyzer-single-source-of-truth-for-query-targeting.md new file mode 100644 index 00000000000..feb6ceb2f40 --- /dev/null +++ b/dev/adr/0020-analyzer-single-source-of-truth-for-query-targeting.md @@ -0,0 +1,88 @@ +# 20. Analyzer is the single source of truth for query targeting + +**Status:** Accepted +**Date:** 2026-08-14 +**Author:** @opsmill-team + +## Context + +Whether a proposed change regenerates one artifact or every artifact under a definition hinges on a +single verdict: is the definition's GraphQL query guaranteed to resolve to one object? That verdict is +computed by `GraphQLQueryReport.only_has_unique_targets` in the GraphQL query analyzer, and consumed by +the proposed change pipeline to choose between a specific and a full regeneration scope. + +The rules behind the verdict are not obvious from a query alone. They depend on the branch's uniqueness +constraints, on whether a filter argument is a literal, a required variable, or a list, and on every root +operation in the document rather than just the first. Users had no way to reach the verdict: they +discovered it at runtime, as an unexpectedly slow pipeline, and could only explain it by reading backend +source. + +Exposing the verdict meant choosing where the rules live. The tempting shape is to state the rules in +user documentation and let a lighter-weight check - in the CLI, in the frontend, or in a separate +validation helper - reproduce them for users. That check would be cheap, would work offline, and would +not require a server round trip. + +## Decision + +The analyzer holds the rules, and every consumer reads them from it. Nothing reimplements or restates +the targeting logic as executable rules. + +- The proposed change pipeline calls `only_has_unique_targets` as it already did. +- The root GraphQL field `InfrahubGraphQLQueryReport` exposes the same property as + `targets_unique_nodes`, resolving branch and schema from the request so the answer is computed against + the branch the query will actually run on. +- `infrahubctl graphql query-report` calls that GraphQL field. It resolves a query by name and prints the + verdict; it does not analyze the query locally. + +Documentation describes the rules for comprehension and points at the command for the answer. It is not +a specification a second implementation is written against. + +## Consequences + +### Positive + +- A user's answer and the pipeline's decision cannot disagree, because they are the same computation on + the same branch schema. +- Broadening the rules stays a single change. The rules were extended after the introspection query + shipped - `hfid`, cardinality-one relationships, and composite uniqueness constraints were added - and + the exposed verdict followed automatically with no second implementation to update. +- The verdict is reachable before a definition is saved, which is when it is actionable. + +### Negative + +- Checking a query requires a reachable Infrahub instance and a branch. There is no offline linting of a + `.gql` file, and none can be added without reintroducing the divergence this decision avoids. +- Every consumer pays a round trip for a computation that is pure and in-memory on the server. + +### Neutral + +- The verdict is branch-dependent by construction. The same query can report differently on two branches, + because uniqueness constraints live in the schema. +- Documentation of the rules is explanatory and can drift from the analyzer without anything failing. + Behavior does not drift; only the prose can, so it needs review whenever the rules change. + +## Alternatives Considered + +### Reimplement the targeting rules in the CLI or SDK + +Would give offline checks with no server dependency. Rejected: the rules read the branch's uniqueness +constraints, so an offline implementation would need the schema anyway, and any drift between the two +implementations produces the worst possible failure - a tool that confidently reports `true` while the +pipeline regenerates everything. + +### Document the rules and ship no tooling + +Cheapest option, and where the feature started. Rejected because the rules are subtle enough that reading +them is not the same as applying them correctly to a specific query, and the failure mode is silent: a +user gets no signal that a query is expensive until they observe the pipeline. + +### Return the verdict as a bare `Boolean` field + +Simpler schema for the one question being asked. Rejected in favor of an object type, so the other +properties the analyzer already computes (`requested_read`, `variables`, `impacted_models`) can be +surfaced later without a breaking change. + +## Implementation Notes + +- Rules and consumers: [`dev/knowledge/backend/query-target-uniqueness.md`](../knowledge/backend/query-target-uniqueness.md). +- Spec: [`dev/specs/archive/ifc-2504-graphql-query-report/research.md`](../specs/archive/ifc-2504-graphql-query-report/research.md) (RES-001). diff --git a/dev/adr/README.md b/dev/adr/README.md index 7fd0fb829e2..ba8235cf9f1 100644 --- a/dev/adr/README.md +++ b/dev/adr/README.md @@ -11,10 +11,11 @@ We document significant architectural decisions using ADRs. | [0003](0003-asynchronous-tasks.md) | Asynchronous Tasks Execution with Prefect | Accepted | 2024-12-26 | | [0004](0004-message-bus.md) | Message Bus Architecture | Accepted | 2024-12-26 | | [0005](0005-account-group-origin-attribute.md) | `origin` Attribute for `CoreAccountGroup` Provenance Tracking | Accepted | 2025-05-13 | +| [0020](0020-analyzer-single-source-of-truth-for-query-targeting.md) | Analyzer is the single source of truth for query targeting | Accepted | 2026-08-14 | ## Creating a New ADR -1. Copy `template.md` to `NNNN-short-title.md` (use next sequential number) +1. Copy `template.md` to `NNNN-short-title.md`. Pick the next number that is free on **every** long-lived branch, not only the one you are on - `develop` and the `release-*` branches usually carry ADRs that have not reached `stable` yet, so the index here can have gaps. 2. Fill in all sections 3. Submit as PR for review 4. Update this index when merged diff --git a/dev/guidelines/backend/graphql.md b/dev/guidelines/backend/graphql.md new file mode 100644 index 00000000000..a45a8da662b --- /dev/null +++ b/dev/guidelines/backend/graphql.md @@ -0,0 +1,95 @@ +# GraphQL API Standards + +> Part of: `dev/guidelines/backend/` | Related: [Python Standards](python.md), [Python Testing Standards](testing.md) + + + +Conventions for hand-written additions to Infrahub's GraphQL API. Schema-driven node queries and +mutations are generated from the schema and are not covered here. + +## Root-level query fields + +A root-level query field is a custom entry point on the root `Query` type, as opposed to the node +queries generated from the schema. Adding one takes four pieces, in one new module under +`backend/infrahub/graphql/queries/`. + +1. A graphene `ObjectType` describing the response. +2. A standalone `async` resolver function, not a method on a class. +3. A module-level `Field(...)` bound to that resolver, named exactly as the field should appear in the + schema. +4. Registration: export the `Field` from `queries/__init__.py` (import plus `__all__`), then assign it as + a class attribute on `InfrahubBaseQuery` in `backend/infrahub/graphql/schema.py`. + +```python +class QueryStatistics(ObjectType): + node_count = Field(Int, required=True, description="Number of nodes matched by the query.") + + +async def resolve_query_statistics(_root: None, info: GraphQLResolveInfo, query: str) -> dict[str, int]: + graphql_context: GraphqlContext = info.context + ... + + +InfrahubQueryStatistics = Field( + QueryStatistics, + query=String(required=True, description="The raw GraphQL query string to analyze."), + description="Return statistics describing how Infrahub will execute a query.", + resolver=resolve_query_statistics, + required=True, +) +``` + +Use a `graphene.Mutation`-style class only for operations that mutate state. + +### Return a container, not a bare scalar + +Even when the field answers a single yes/no question today, return an `ObjectType` holding that one field +rather than a bare `Boolean`. Adding a second field to an object type is backward compatible; changing a +scalar field into an object type is not. + +### Mark fields required and describe them + +Every field a resolver always populates is declared `required=True`. Give each field and each argument a +`description` - these strings are the API reference, published in the exported GraphQL schema and read by +API consumers who cannot see the resolver. Describe what the value means to a caller, not how it is +computed. + +## Resolver conventions + +- **Branch comes from the context, never from an argument.** Read `info.context` as `GraphqlContext` and + use `graphql_context.branch`. Do not add a `branch` argument to a new field; the request already + carries branch context and every other query resolves it the same way. +- **Reach the schema branch through the registry.** `GraphqlContext` does not expose `SchemaBranch` + directly. Use `registry.schema.get_schema_branch(name=graphql_context.branch.name)`. Deriving it from + `info.schema` does not work: the graphene/graphql-core schema does not carry the Infrahub + `SchemaBranch`. +- **Keep the resolver thin.** A resolver adapts the request to a component and shapes the response. When + it grows real logic, move that logic into a component under `backend/infrahub/` and call it - the + resolver is not a place where behavior should accumulate. Never reimplement analysis or business rules + that already exist elsewhere; call the existing owner so the API and the internal consumer cannot + diverge. + +## Invalid user input + +Input that a caller controls and can get wrong - a query string, an identifier, a filter expression - +must fail loudly. + +- Raise a `GraphQLError` so the failure surfaces in the response's `errors` array rather than as an + unhandled Python exception. +- Never absorb invalid input into a default or falsy result. A caller who submits a malformed query and + receives `false` cannot tell a real answer from a swallowed error. +- Validate before analyzing. Where a helper already exposes a validity check, run it and surface its + errors rather than letting a downstream call fail in a less legible place. +- Keep the message about the input. Do not let stack traces or internal paths reach the caller. + +## Testing + +Resolvers that touch the schema registry or a `SchemaBranch` belong in +`backend/tests/component/graphql/queries/`, executed through the full GraphQL stack with +`prepare_graphql_params` rather than by calling the resolver function directly - the wiring into +`InfrahubBaseQuery` is part of what the test needs to cover. Every input-error path gets its own test +case; assert on the exact error message, as required by +[Python Testing Standards](testing.md). + +Resolver logic that operates purely on in-memory inputs still belongs in a unit test. Pick the cheapest +tier the logic actually needs. diff --git a/dev/knowledge/backend/query-target-uniqueness.md b/dev/knowledge/backend/query-target-uniqueness.md new file mode 100644 index 00000000000..f9347d239e6 --- /dev/null +++ b/dev/knowledge/backend/query-target-uniqueness.md @@ -0,0 +1,123 @@ +# Query target uniqueness + +> Part of: `dev/knowledge/backend/` | Related: [display-labels-and-hfid.md](display-labels-and-hfid.md), [schema-definitions.md](schema-definitions.md) + + + +Infrahub decides how much work a proposed change has to redo by asking one question of every artifact +definition and generator definition query: **is this query guaranteed to resolve to a single object?** +The answer is a single boolean, `only_has_unique_targets`, computed by the GraphQL query analyzer. + +When the answer is yes, Infrahub can map a changed node back to the exact artifacts or generator +instances that depend on it, and regenerate only those. When the answer is no, a changed node cannot be +attributed to any particular target, so every target of the definition is reprocessed. Users experience +the difference as either a quick, surgical pipeline or a full regeneration of every artifact under the +definition. + +## Where it is computed + +`GraphQLQueryReport.only_has_unique_targets` in `backend/infrahub/graphql/analyzer.py` is the single +source of truth. It is a pure function of the parsed query document plus the branch's `SchemaBranch` +(needed to read uniqueness constraints); it runs in memory and issues no database queries. + +`only_has_unique_targets` is `True` only when **every** root operation in the document pins a single +object. One unfiltered root query anywhere in the document makes the whole report `False`, even if the +other operations are fully pinned. + +## The pinning rules + +A root operation pins a single object when either condition holds. + +### 1. Pinned by identifier + +The operation carries an `ids` or `hfid` filter argument that provides a single value. + +### 2. Pinned by uniqueness constraint + +Every component of at least one of the model's uniqueness constraints is pinned by a single-valued +filter argument. Constraint groups are read via +`model.get_unique_constraint_schema_attribute_paths(...)`, and any group being fully pinned is enough. + +- Attribute component: pinned by `__`, where the property defaults to `value`. +- Relationship component: pinned by `__ids` or `__hfid`, and **only for + cardinality-one relationships**. A cardinality-many relationship can never pin a target. + +### What counts as a single value + +An argument provides a single value when it is one of: + +- A static literal, for example `name__value: "red"`. +- A required, non-list variable, for example `$name: String!` used as `name__value: $name`. +- A single-element list literal whose element is either a static literal or a required variable, for + example `ids: [$id]` with `$id: ID!`. + +A required **list-typed** variable is treated differently depending on where it appears: + +| Position | `$ids: [ID!]!` used directly | Why | +|----------|------------------------------|-----| +| Root `ids` / `hfid` filter | Accepted | The target selector is driven once per target member, so at execution time the list carries exactly that member. | +| Relationship component of a uniqueness constraint (`__ids`) | Rejected | Nothing constrains the list to one element, so it can match several objects. | + +An optional variable never pins, whatever its type. `$ids: [ID!]` (optional list of required elements) +is a common near-miss: the elements are non-null but the argument itself may be omitted, so the query +reports `false`. + +## What consumes the result + +`get_field_level_impacted_subscribers` in `backend/infrahub/proposed_change/tasks.py` combines the +uniqueness verdict with the branch diff and returns an `ImpactScope`: + +| Scope | When | Effect | +|-------|------|--------| +| `SPECIFIC` | The query pins unique targets. | Only the subscribers linked to the changed nodes are reprocessed, possibly none. | +| `ALL` | The query does not pin unique targets, but a field the query reads did change. | Every target of the definition is reprocessed. | +| `NONE` | No node of a queried kind had any of its queried fields modified. | Nothing is reprocessed, regardless of the uniqueness verdict. | + +Two separate gates therefore apply, and uniqueness is only the second one. Field-level relevance comes +first: `query_report.requested_read` limits "relevant change" to the attributes and relationships the +query actually reads, so a query that reads `name` is untouched by a change to `description`. Only once +a relevant change exists does the uniqueness verdict decide between `SPECIFIC` and `ALL`. + +The same helper serves both subscriber kinds: `CoreArtifact` for artifact definitions and +`CoreGeneratorInstance` for generator definitions. A generator query with unpinned targets pays the same +full-reprocessing cost an artifact query does. + +## Inspecting a query + +The verdict is exposed so users never have to reason about the rules above from source code. + +The root GraphQL field `InfrahubGraphQLQueryReport` takes a raw query string and returns +`targets_unique_nodes`. Branch context is resolved from the request like any other query, and the +submitted string is validated against that branch's schema before analysis, so an empty string, +malformed GraphQL, or a reference to an unknown node kind comes back as a GraphQL error rather than a +default `false`. + +```graphql +query ($q: String!) { + InfrahubGraphQLQueryReport(query: $q) { + targets_unique_nodes + } +} +``` + +`infrahubctl graphql query-report ` wraps that field for the common case. It resolves the query by +name from the local `.infrahub.yml`, or from the server's `CoreGraphQLQuery` nodes with `--online`, and +prints the verdict. This is the check to run against an artifact definition's query before saving it. + +User-facing documentation calls this property a **single-target query**, and +`docs/docs/development-resources/graphql/single-target-queries.mdx` is where the criteria and the command +are documented for users. Keep that page in step when the rules change. + +The response type is intentionally a container rather than a bare boolean, so further fields already +computed by the analyzer (`requested_read`, `variables`, `impacted_models`) can be surfaced later +without breaking callers. + +## Gotchas + +- Adding a second, unfiltered root operation to an otherwise well-pinned query silently flips the verdict + to `false`. This is the most common cause of an unexpected full regeneration. +- The verdict depends on the branch's schema, because uniqueness constraints live in the schema. The same + query can report differently on two branches, and changing a model's uniqueness constraints changes how + its existing queries are scoped. +- `false` is never incorrect behavior, only expensive behavior: Infrahub falls back to reprocessing every + target, which is safe but slow. diff --git a/dev/specs/archive/ifc-2504-graphql-query-report/EXTRACTED.md b/dev/specs/archive/ifc-2504-graphql-query-report/EXTRACTED.md new file mode 100644 index 00000000000..093d03654c4 --- /dev/null +++ b/dev/specs/archive/ifc-2504-graphql-query-report/EXTRACTED.md @@ -0,0 +1,32 @@ +# Extraction Record + +**Extracted on**: 2026-08-14 +**Extracted by**: speckit.opsmill.extract + +## ADRs Created + +- `dev/adr/0020-analyzer-single-source-of-truth-for-query-targeting.md` (from RES-001) + +## Knowledge Updated + +- `dev/knowledge/backend/query-target-uniqueness.md` (new file - pinning rules, impact scopes, how to inspect a query) + +## Guidelines Updated + +- `dev/guidelines/backend/graphql.md` (new file - root-level query fields, resolver conventions, invalid user input, testing) + +## User-Facing Documentation Updated + +- `docs/docs/artifacts/overview.mdx` (When artifacts regenerate - new "Targeted regeneration and your query" section) +- `python_sdk/infrahub_sdk/ctl/graphql.py` and the regenerated `python_sdk/docs/docs/infrahubctl/infrahubctl-graphql.mdx` (`query-report` help text). Separate repository - needs its own commit and PR. + +## Notes + +The uniqueness rules described in `research.md` (RES-001) and `data-model.md` are the original, +narrower semantics. They were broadened after this spec shipped to cover `hfid`, cardinality-one +relationships, and composite uniqueness constraints. The extracted documentation describes the +current behavior, not the spec text. + +## Archive + +Spec directory moved to `dev/specs/archive/ifc-2504-graphql-query-report/` as a historical record. diff --git a/dev/specs/ifc-2504-graphql-query-report/checklists/requirements.md b/dev/specs/archive/ifc-2504-graphql-query-report/checklists/requirements.md similarity index 100% rename from dev/specs/ifc-2504-graphql-query-report/checklists/requirements.md rename to dev/specs/archive/ifc-2504-graphql-query-report/checklists/requirements.md diff --git a/dev/specs/ifc-2504-graphql-query-report/contracts/graphql_query_report.graphql b/dev/specs/archive/ifc-2504-graphql-query-report/contracts/graphql_query_report.graphql similarity index 100% rename from dev/specs/ifc-2504-graphql-query-report/contracts/graphql_query_report.graphql rename to dev/specs/archive/ifc-2504-graphql-query-report/contracts/graphql_query_report.graphql diff --git a/dev/specs/ifc-2504-graphql-query-report/data-model.md b/dev/specs/archive/ifc-2504-graphql-query-report/data-model.md similarity index 100% rename from dev/specs/ifc-2504-graphql-query-report/data-model.md rename to dev/specs/archive/ifc-2504-graphql-query-report/data-model.md diff --git a/dev/specs/ifc-2504-graphql-query-report/plan.md b/dev/specs/archive/ifc-2504-graphql-query-report/plan.md similarity index 100% rename from dev/specs/ifc-2504-graphql-query-report/plan.md rename to dev/specs/archive/ifc-2504-graphql-query-report/plan.md diff --git a/dev/specs/ifc-2504-graphql-query-report/quickstart.md b/dev/specs/archive/ifc-2504-graphql-query-report/quickstart.md similarity index 100% rename from dev/specs/ifc-2504-graphql-query-report/quickstart.md rename to dev/specs/archive/ifc-2504-graphql-query-report/quickstart.md diff --git a/dev/specs/ifc-2504-graphql-query-report/research.md b/dev/specs/archive/ifc-2504-graphql-query-report/research.md similarity index 100% rename from dev/specs/ifc-2504-graphql-query-report/research.md rename to dev/specs/archive/ifc-2504-graphql-query-report/research.md diff --git a/dev/specs/ifc-2504-graphql-query-report/spec.md b/dev/specs/archive/ifc-2504-graphql-query-report/spec.md similarity index 99% rename from dev/specs/ifc-2504-graphql-query-report/spec.md rename to dev/specs/archive/ifc-2504-graphql-query-report/spec.md index 4074951136f..a8d9be74e50 100644 --- a/dev/specs/ifc-2504-graphql-query-report/spec.md +++ b/dev/specs/archive/ifc-2504-graphql-query-report/spec.md @@ -3,7 +3,7 @@ **Feature Branch**: `ifc-2504-graphql-query-report` **Jira**: IFC-2504 **Created**: 2026-04-25 -**Status**: Draft +**Status**: Extracted **Input**: Add InfrahubGraphQLQueryReport introspection query that reports how Infrahub will interpret a given GraphQL query, specifically whether it targets unique nodes for artifact regeneration purposes. ## User Scenarios & Testing *(mandatory)* diff --git a/dev/specs/ifc-2504-graphql-query-report/tasks.md b/dev/specs/archive/ifc-2504-graphql-query-report/tasks.md similarity index 100% rename from dev/specs/ifc-2504-graphql-query-report/tasks.md rename to dev/specs/archive/ifc-2504-graphql-query-report/tasks.md diff --git a/docs/docs/artifacts/overview.mdx b/docs/docs/artifacts/overview.mdx index d7e2f23b350..6ac88d99f6d 100644 --- a/docs/docs/artifacts/overview.mdx +++ b/docs/docs/artifacts/overview.mdx @@ -86,12 +86,62 @@ For step-by-step instructions, see [Composing artifact content](./content-compos An artifact is the cached output of a Transformation, so Infrahub regenerates it when its inputs change. Three kinds of change trigger regeneration: -- **The target's data changes** — a node read by the artifact's GraphQL query is modified, so that target's artifact is regenerated. +- **The target's data changes** — a node read by the artifact's GraphQL query is modified. Only the fields the query reads count, so a change to a field the query never selects regenerates nothing. How many artifacts are regenerated depends on how the query is written: see [How your query affects regeneration scope](#how-your-query-affects-regeneration-scope). - **A new target joins the group** — an artifact is generated for the new member; existing artifacts are left untouched. - **The definition's code or configuration changes** — when a proposed change commits to a linked repository, Infrahub regenerates an artifact only if the change touches that definition's GraphQL query, its Transformation's [dependency closure](../transformations/overview#dependency-tracking-and-regeneration), or the artifact definition itself. An unrelated commit — a README edit, or a helper no Transformation uses — regenerates nothing. Every regeneration decision during a proposed change is recorded in the pipeline's task log, naming the file, query, or field that triggered it. See [Understanding artifact regeneration](../proposed-changes/overview#understanding-artifact-regeneration). +### How your query affects regeneration scope + +When a target's data changes, Infrahub regenerates only that target's artifact if it can tell which target the changed node belongs to. It can do that when the GraphQL query behind the definition returns a single object, a pattern Infrahub calls a [single-target query](../development-resources/graphql/single-target-queries). When the query can return any number of objects, Infrahub has no way to link a changed node to one target, so it regenerates every artifact under the definition instead. Both paths produce the same content. The difference is how long the pipeline takes on a large group. + +Two parts of your repository's [`.infrahub.yml`](../git-integration/infrahub-yml) decide this, and they are declared separately: + +- **The query.** An artifact definition does not reference a query directly. It names a Transformation, and that Transformation names a query declared under `queries`. This is where the filtering is written. +- **The parameters.** The `parameters` of the artifact definition are the "information to extract from each target" listed in [High level design](#high-level-design) above. Each entry reads a value from the target object and passes it into the query as a variable. + +```yaml +queries: + - name: device_config_query + file_path: "queries/device_config.gql" + +jinja2_transforms: + - name: device_config_transform + query: device_config_query + template_path: "templates/device_config.j2" + +artifact_definitions: + - name: "Device configuration file" + transformation: "device_config_transform" + targets: "DeviceGroup" + content_type: "text/plain" + parameters: + name: "name__value" +``` + +Here each target device passes its own name into `$name`, and the query uses it to select that one device: + +```graphql +# queries/device_config.gql +query DeviceConfig($name: String!) { + NetworkDevice(name__value: $name) { + edges { + node { + name { value } + description { value } + } + } + } +} +``` + +This returns a single device, because `$name` is required and the schema marks `name` as unique. + +For the full criteria a single-target query has to meet, the patterns that break it, and how to check a query with `infrahubctl graphql query-report` before you deploy the definition, see [Single-target queries](../development-resources/graphql/single-target-queries). + +Generator definitions are scoped the same way, from the same `parameters` mapping. See [Query parameter mapping](../generators/overview#query-parameter-mapping) for how the extraction paths are resolved. + ## How artifacts relate to other features - **[Transformations](../transformations/overview)** define the logic that produces an artifact's content; an artifact is the cached output of a Transformation for a specific target. diff --git a/docs/docs/development-resources/graphql/single-target-queries.mdx b/docs/docs/development-resources/graphql/single-target-queries.mdx index 36432a24f4d..d4273fac3bc 100644 --- a/docs/docs/development-resources/graphql/single-target-queries.mdx +++ b/docs/docs/development-resources/graphql/single-target-queries.mdx @@ -158,11 +158,31 @@ query DeviceConfig($device_name: String!) { ## Ensuring your query is single-target -There is currently no automated way to verify that a query is single-target. The best way to verify is to review the query and ensure it meets all the criteria outlined above. +Rather than reviewing a query against the criteria above by hand, ask Infrahub how it will interpret it. Run [`infrahubctl graphql query-report`]($(base_url)infrahubctl/infrahubctl-graphql) with the name of the query as declared under `queries` in your `.infrahub.yml`: -When loaded into the system, Infrahub will analyze the query and determine if it is single-target or not. If it is not single-target, Infrahub will log a warning. +```shell +infrahubctl graphql query-report device_config_query +``` + +```text +Query 'device_config_query' (local: queries/device_config.gql) +Targets unique nodes: true +``` + +`Targets unique nodes: true` means the query is single-target. `false` means it is not, so whatever the query drives runs for every target instead of only the ones that changed. + +The report comes from the server, which analyzes the query exactly as the pipeline does, so the two cannot disagree. Two options are worth knowing: + +- `--online` analyzes the version already loaded into Infrahub instead of the file in your working copy, which is how you check a query that is already deployed. +- `--branch` analyzes the query against a specific branch. Uniqueness constraints come from the schema, so a query can be single-target on one branch and not on another. + +Infrahub also flags the problem after the fact. When a proposed change pipeline works out what to regenerate for an artifact definition or a Generator definition and cannot map a change to specific targets, it records a warning in the pipeline's task log: + +```text +Artifact definition device-config query does not guarantee unique targets. All targets will be processed. +``` -It's planned to add more integrated checks in the future to streamline the development process and ensure that queries are correctly structured directly during development. +This warning appears only when a relevant field actually changed. A pipeline that touches nothing the query reads stays quiet whether or not the query is single-target, so the absence of a warning in one pipeline does not mean the query is fine. Checking with `query-report` is the reliable answer. ## When single-target queries are required