fix: follow the specification for absent, null, and default input values - #57
Conversation
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.
Behavior comparisonThe table below reports the state of Column 3 is identical to column 1 in every row: setting Schema usedtype Query {
f(a: String, b: String): String
fDef(a: String = "ARGDEF"): String
fNN(a: String! = "NNDEF"): String
fObj(input: Input): String
fObjDef(input: InputDef): String
fNested(input: Nested): String
fList(a: [String]): String
fObjList(input: [InputDef]): String
}
input Input { a: String, b: String }
input InputDef { a: String = "FIELDDEF", b: String }
input Nested { inner: InputDef }Rule 1 — A variable the caller did not supply leaves its argument absent
A resolver can now test for the presence of the key to learn whether the caller sent Rule 2 — An explicit null is a supplied value, so no default stands in for it
A client that sends Rule 3 — A value the caller did not supply does fall back to the default
Rules 2 and 3 are the same principle — a default applies only when no value was Rule 4 — A non-null argument that declares a default is optional
This is the only change to document validation. Spec §5.4.2.1 defines an argument as UnchangedPreserving an explicitly supplied |
| fieldValue := coerceValue(field.Type, v) | ||
| // The key is present and holds null: the caller supplied a value, so | ||
| // the field's default must not stand in for it. | ||
| if !nonSpec && ok && isNullish(v) { |
There was a problem hiding this comment.
This fixes coercion after a value has reached coerceValue, but a valid input object with an omitted non-null field that has a default is rejected earlier by isValidInputValue (and literal validation has the same issue). For example, { input: {} } for input I { value: String! = "default" } fails with Expected "String!", found null. instead of applying "default". Per the spec, an input field is required only when it is non-null and has no default value: https://spec.graphql.org/draft/#sec-Input-Objects. Please make both validation paths exempt non-null fields with defaults and add coverage for variable and literal input objects.
There was a problem hiding this comment.
Fixed in 292cbb8 — thank you, the report was accurate on both paths.
Both validation paths now exempt a non-null input field that declares a default:
isValidInputValue for values arriving through variables, and isValidLiteralValue
for object literals. Coverage was added for both, plus the nested and list-element
cases, and for the two states that must keep failing — a field with no default, and
a field given an explicit null. Coercion needed no change: once validation lets the
value through, coerceValue and valueFromAST already substitute the default.
While confirming the report I checked the rest of the specification for the same rule
and found it missing in two more places, so 292cbb8 covers those as well:
- Variable definitions.
DefaultValuesOfCorrectTypeRulerejected any non-null
variable that declared a default, andgetVariableValuechecked the non-null
requirement before applying the default, so §6.1.2 step 1 never ran. In other words
query Q($x: String! = "d")was unusable. - Variable usage positions. §5.8.5 allows a nullable variable at a non-null
location when either the variable or *the lt
(hasLocationDefaultValue); only the variable's own default was being considered.
TypeInfonow tracks the location's defaul, the way
graphql-js does.
One part deserves a closer look in review. Relaxing the usage-position check exposed a
latent gap: getArgumentValues had no error ing a non-null
argument was handed to the resolver instead of raising the field error §6.4.1 step 2
calls for. Fixing §5.8.5 alone would therefore have let null through silently, so
292cbb8 gives getArgumentValues an error return and handles it at its four call
sites — resolveField, the two @skip / @ieSubscription.
This is the only change that touches execution rather than validation.
All of it sits behind NonSpecArgumentHandling, so a schema that opts out is
unaffected.
Two existing tests asserted the pre-spec beha
TestValidate_VariableDefaultValuesOfCorrectType_NoRequiredVariablesWithDefaultValues
now expects the document to validate; the r the opt-out
instead.TestVariables_NonNullableScalars_PassesAlongNullForNonNullableInputsIfExplicitlySetInTheQuery
expected a required argument to go missing without an error. Its document actually
omits the argument rather than setting it explicitly, and its two sibling tests
already expect an error for the variable eqthe field error
and is renamed...DoesNotAllowNonNullableInputsToBeOmittedDirectly.
Beyond the suite: replaying 43 scenarios through graphql.Do shows the opt-out
matching the pre-fix commit 39d54ab on everse conformance
check against the coercion algorithms moves non-null handling from 5/11 to 11/11 with
optional handling unchanged at 17/17.
k1LoW
left a comment
There was a problem hiding this comment.
It seems there is an inconsistency: the validation layer consistently checks for != nil, while the coercion layer consistently checks for isNullish.
Summary
The GraphQL specification distinguishes three states for an input: not provided,explicitly null, and a value. This fork collapsed the first two, and separately never implemented the rule that an input is required only when its type is non-null and it declares no default value.
This PR restores both distinctions.
SchemaConfig.NonSpecArgumentHandlingopts a schemaback out to the previous behaviour, byte for byte, while code that depends on it migrates.
Schema used in the tables below
Every cell below is measured by replaying the document through
graphql.Do. The "before" column is commit39d54ab, the branch point. "Before" and "flag on" are identical in every row — that is the whole promise of the flag.1. A variable the caller did not supply leaves its argument absent
Spec §6.4.1 CoerceArgumentValues.
NonSpecArgumentHandling: truef(a: $x){}{a: null}{}{a: null}f(a: $x){x: null}{a: null}{a: null}{a: null}fObj(input: {a: $x}){}{input: {a: null}}{input: {}}{input: {a: null}}fObj(input: {a: $x}){x: null}{input: {a: null}}{input: {a: null}}{input: {a: null}}A resolver can now test for the presence of the key to learn whether the caller sent the field at all. Rows 2 and 4 show what does not change: an explicitly supplied
nullhas always reached resolvers in this fork, and still does.2. A default stands in only for a value that was not supplied
Spec §6.1.2 and §6.4.1: the default applies when
hasValueis false. An explicitnullis a supplied value.fDef(a: $x){x: null}{a: "ARGDEF"}{a: null}{a: "ARGDEF"}query ($x: String = "VARDEF") { f(a: $x) }{x: null}{a: "VARDEF"}{a: null}{a: "VARDEF"}fObjDef(input: $in){in: {a: null}}{input: {a: "FIELDDEF"}}{input: {a: null}}{input: {a: "FIELDDEF"}}fObjDef(input: {a: $x}){}{input: {a: null}}{input: {a: "FIELDDEF"}}{input: {a: null}}The first three rows and the last one move in opposite directions, and that is the point: because "absent" and "null" were the same state internally, the answer to "does the default apply?" depended on which code path the value travelled.
3. A non-null input that declares a default is optional
One rule, needed in four places. Spec §5.4.2.1 (arguments, §5.4.3 in draft), §6.1.2 (variable definitions), §5.8.5 (variable usage positions), §3.10 and §5.6.4 (input object fields).
{ fNNDef }{}{a: "NNDEF"}query ($x: String! = "VARDEF") { f(a: $x) }{}{a: "VARDEF"}fNNDef(a: $x){}{a: "NNDEF"}fNNObj(input: {}){}{input: {a: "FIELDDEF"}}fNNObj(input: $in){in: {}}{input: {a: "FIELDDEF"}}fNNReq(input: {}){}The "before" errors, in order:
The last row is the guard: a non-null field with no default stays required in every mode.
4. A supplied null at a non-null position is a field error
Spec §6.4.1 and the fourth rule of §3.10 Input Coercion. Allowing a nullable variable at a non-null location (row 3 of the previous table) makes these documents reachable, so the runtime has to reject the null that validation no longer does.
fNNDef(a: $x){x: null}fNNObj(input: {a: $x}){x: null}fNNObj(input: $in){in: {a: null}}The outcome is unchanged; only the layer that reports it moves. After:
5. @Skip and @include test their
ifargument structurallySpec §6.3.2 CollectFields does not coerce the arguments of
@skipand@include. It asksonly whether
ifistrue, with identical wording in October 2021 and draft:A value that is not
true— a variable carryingnull, say — is simply "not true", andno error is raised. Validation blocks every other route here, so the one document that
reaches it declares a variable with a non-null default and then supplies
nullfor it.a @skip(if: $x) b,$x: Boolean = true{"x": null}{"b"}{"a","b"}{"b"}a @include(if: $x) b,$x: Boolean = true{"x": null}{"a","b"}{"b"}{"a","b"}The
@includerow is a behaviour change beyond argument coercion, and the only one inthis PR that is not about a default value. The pre-fix test was
ok && !includeIf, so anifthat was not a bool failed the type assertion, missed the branch, and left theselection in; the specification keeps it only when
ifistrue.This is also the one place where graphql-js goes beyond the specification: it coerces
directive arguments inside
collectFieldsand fails the whole request withdata: null.The coercion errors this PR adds for field arguments are unaffected.
What changed in the code
values.go— argument and variable coercion follow §6.4.1 and §6.1.2 step by step; a default is applied before the non-null requirement is checked;getArgumentValuesgained an error return and reports any null sitting at a non-null position, with the path to it.rules.go—ProvidedNonNullArgumentsRuleandDefaultValuesOfCorrectTypeRulestop treating a declared default as irrelevant;isValidLiteralValueexempts an input field that declares one;VariablesInAllowedPositionRuleimplements §5.8.5, includinghasLocationDefaultValue.type_info.go,validator.go—TypeInfotracks the default declared by the argument or input object field being visited, andVariableUsagecarries it, so §5.8.5 can be evaluated. List positions carry no default, per the spec's wording.executor.go,subscription.go—resolveFieldandExecuteSubscriptionturn thecoercion error into a field error.
@skipand@includedo not: §6.3.2 CollectFieldstests their
ifargument structurally rather than coercing it, so a value that is nottrue— including one a coercion failure left unusable — simply answers "not true".Every behavioural change is behind
NonSpecArgumentHandling.Verification
go test ./...passes.graphql.DoshowsNonSpecArgumentHandling: truematching
39d54abon every one of them, error messages included.inputs were already correct at 17/17 and stay there; non-null inputs move from 5/11 to
11/11.
test, in both directions of the flag.
Notes
nullliteral in query documents, so the explicitnull cases are exercised through variables.
DefaultValue interface{}cannot expressa: String = null, which §6.4.1 mentions as"defaultValue exists (including null)". That is a schema-expressiveness limit rather
than a coercion bug, and it is unreachable today because the parser rejects the literal.
VariableUsagegained a field. Code constructing it with an unkeyed composite literalwill need field names.