Skip to content

fix: preserve absent vs explicit-null distinction in argument coercion#57

Open
toiroakr wants to merge 1 commit into
mainfrom
fix/preserve-undefined-vs-null-distinction
Open

fix: preserve absent vs explicit-null distinction in argument coercion#57
toiroakr wants to merge 1 commit into
mainfrom
fix/preserve-undefined-vs-null-distinction

Conversation

@toiroakr
Copy link
Copy Markdown

@toiroakr toiroakr commented May 12, 2026

Summary

  • Variables declared in an operation but not supplied by the caller used to arrive at resolvers as explicit nil, making it impossible to distinguish "field omitted" from "field explicitly null". This breaks downstream patterns that rely on arg != nil/arg !== undefined to mean "the caller actually sent this field".
  • Restore the three-state semantics required by the spec (CoerceArgumentValues / CoerceVariableValues) while keeping this fork's existing behavior of preserving explicit nulls.

Background

The current behavior was introduced by #17, which added handling so that explicit null variables would survive into p.Args (previously they were dropped, matching upstream graphql-go but conflicting with this fork's needs). That change achieved its goal for explicit nulls but, as a side effect, also marked omitted variables as present-with-nil — collapsing the spec's three states (absent / null / value) into two on the resolver side.

This PR keeps the intent of #17 (explicit nulls remain visible) and only fixes the omitted-variable case.

Changes

  • getVariableValues: insert a coerced value into the result map only when the caller actually supplied the variable, or when the definition declares a default value. Omitted variables stay omitted instead of being recorded as nil.
  • getArgumentValues: treat an argument that resolves to a reference to an unprovided variable as absent. Explicit nulls still surface as nil.
  • valueFromAST (*InputObject case): nested fields whose values come from unprovided variables stay absent in the resulting map, instead of being included as nil.
  • Add argument_coercion_test.go covering the three states (absent / explicit null / value) for both scalar arguments and input-object fields, plus input-object literals with omitted fields.

Behavior comparison

Each row is for the operation query Q($x: Int) { f(x: $x) } (or the input-object equivalent, query Q($y: Int) { f(input: {x: $y}) }). "Resolver sees" describes the state of p.Args (or the nested input map) on the resolver side.

Case Client sends upstream graphql-go this fork before (since #17) this fork after
Variable omitted — caller did not include the variable at all {} absent present as nil ❌ absent
Variable explicitly null — caller deliberately sent null to mean "clear this field" {"x": null} absent ❌ present as nil ✅ present as nil ✅ (preserved)
Variable with value {"x": 1} 1 1 1
Input-object field whose variable was omitted {} absent present as nil ❌ absent
Input-object field whose variable was explicitly null {"y": null} absent ❌ present as nil ✅ present as nil ✅ (preserved)

The two highlighted rows are the whole point of this fork's divergence from upstream: clients use explicit null to express "clear this field" in partial-update operations, and that signal must reach resolvers. Upstream collapses it with omission; the fork has always preserved it (since #17) and continues to do so after this PR. What changes is only that omission now stays omission instead of being relabeled as null.

Notes

  • All existing tests in ./... continue to pass.
  • The parser still does not accept the null literal in query documents, so the new tests exercise that case via variables only.

Related

Variables declared in an operation but not supplied by the caller used to
arrive at resolvers as explicit nil, making it impossible to tell "field
omitted" from "field explicitly null". Restore the three-state semantics
required by the spec (CoerceArgumentValues / CoerceVariableValues) while
keeping the existing behavior of preserving explicit nulls.

- getVariableValues: only insert a coerced value when the caller supplied
  the variable or when the definition declares a default value.
- getArgumentValues: treat an argument that resolves to an unprovided
  variable reference as absent, but still surface explicit nulls.
- valueFromAST (InputObject): fields whose values come from unprovided
  variables stay absent in the resulting map.
- Add argument_coercion_test.go covering the three states for scalars,
  input objects, and input-object literals.
@toiroakr toiroakr marked this pull request as ready for review May 13, 2026 01:05
@toiroakr toiroakr requested a review from a team as a code owner May 13, 2026 01:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant