Skip to main content

Semantic paths

A semantic path addresses a value inside a GraphQL request by what it means rather than by where it sits in the parsed document. It is what the graphql and graphql_delete transforms take as their path.

The same value addressed both ways:

semantic: createUser.args.input.plan
AST JSON: query.Definitions.0.SelectionSet.Selections.0.Arguments.0.Value.Fields.1.Value.Value

The AST path breaks the moment an argument is added, a field is reordered, or the client library changes its output. The semantic path does not.

Grammar

PathAddresses
operationThe operation type — query, mutation, subscription. Read-only.
operationNameThe envelope's operationName.
variables.<gjson path>A value inside the variables JSON object.
<selection path>A field selection. Only graphql_delete accepts a path that stops here — a selection is not a value.
<selection path>.args.<arg path>An argument value.
fragment.<Name>.<selection path>.args.<arg path>The same, inside a named fragment definition.

<selection path> is dotted field names descending the selection set. <arg path> starts with an argument name and then descends input objects by field name and lists by index.

Examples

Against this request:

mutation CreateUser($email: String!) {
createUser(input: {email: $email, plan: "pro", retries: 3, tags: ["beta", "early"]}) {
id
profile { avatar(size: "large") }
__typename
}
}
{ "operationName": "CreateUser", "variables": { "email": "a@b.com" } }
PathValue
operationmutation
operationNameCreateUser
variables.emaila@b.com
createUser.args.input.planpro
createUser.args.input.retries3
createUser.args.input.tags.0beta
createUser.args.input.email$email — see Arguments that reference a variable
createUser.profile.args.sizelarge
createUser.__typenamethe selection itself — deletable, not editable

Rules

Alias before name

A field is addressed by its alias when it has one, and by its name otherwise. In this query the two selections are me and teammate, not user twice:

query Dashboard {
me: user(id: "u-1") { id }
teammate: user(id: "u-2") { id }
}

teammate.args.id addresses u-2. This is the intended way to disambiguate a repeated field: add an alias in the client, or address the value through a variable instead.

Inline fragments are transparent

Fields inside ... on Type { } are reached as though the inline fragment were not there:

query Find($q: String!) {
search(q: $q) {
... on User { profile(scope: "public") { id } }
}
}

search.profile.args.scope addresses public. Named fragments are not transparent — they are separate definitions, addressed with the fragment.<Name> prefix.

Ambiguity is an error

If a path segment matches more than one field in the same selection set — the union case above, or a repeated field with no alias — resolution fails with field "x" is ambiguous (2 matches); use an alias to disambiguate rather than silently picking one. A transform that guessed would corrupt a request in a way nothing downstream could catch.

Choosing the operation

A document may define more than one operation. The transform's op setting picks which one a path applies to:

"type": "graphql",
"config": { "path": "user.args.id", "op": "GetUser" }

Without op, the envelope's operationName chooses. If that is empty and the document defines exactly one operation, that one is used; if it defines several, resolution fails with document has 2 operations; set op to choose one.

op is ignored for variables. paths, which belong to the envelope rather than to any one operation.

Missing paths

A path that resolves cleanly to nothing — a field that is not selected, an argument that is not passed, a variables key that is absent — is treated as not applicable rather than as an error, and the request passes through untouched. This matters when one chain sweeps a whole snapshot: operations that do not carry the field are skipped instead of failing the chain.

Structural problems are different, and do raise errors: ambiguity, an unknown fragment name, an op that names no operation in the document, or a segment that descends into something with no children.

Type preservation

Writing a value back preserves the GraphQL kind of the value it replaced. A string stays a string, an enum stays an enum, and an Int that is handed non-numeric text fails rather than emitting an invalid document:

new value "abc" is not an Int (original argument is an Int)

For variables. paths the JSON type is preserved instead, the same way json_path does it.

Arguments that reference a variable

An argument written as input: {email: $email} holds no literal — it points at a variable. Reading such a path returns $email, and writing to it fails with a message naming the path to use instead:

argument references variable $email; target variables.email instead

Target variables.email and the value reaches the server through the variable, which is where the client intended it to come from.

Composite values

A path must terminate on a scalar. Pointing at a whole input object or list and trying to replace it fails — descend to the field or index you actually mean. Reading one is allowed, and returns its GraphQL source text.

Creating values

create applies only to variables. paths. Document nodes cannot be synthesized: a selection or argument that a query does not contain is one the server's schema may not accept, and inventing it would produce a request the recording never made.

JSON embedded in an argument

APIs frequently pass a JSON document as a GraphQL string argument. Chain a json_path after the graphql transform — the semantic path yields the string, and json_path addresses inside it:

req_body()
→ graphql(path="trackEvent.args.payload")
→ json_path(path="client.os")
→ constant(new="linux")

Each layer re-inserts on the way back out, so the edited JSON is re-embedded in the argument and the argument back into the document. See Recipes.