Skip to content

Commit 07e31ab

Browse files
authored
feat(compiler): Allow coercing Int variables to Float (#1011)
The GraphQL spec allows coercing Int values to Float in input positions (see [input coercion]). There are a couple things to note about this. 1. Strings are not allowed to be coerced in this position, even if they are numeric. 2. Ints can only be converted to Float when they are "representable by finite IEEE 754" floating point numbers. Since an IEEE 754 floating point double (f64) has 53 bits of precision, it can safely represent up to the value `2^53 - 1` as a finite value. Beyond that, the round trip from integer to float and back will lose information. I have represented this with a new `MAX_SAFE_INT` constant which is often included in other languages like JavaScript's `Number.MAX_SAFE_INT`. When, we encounter an Int variable in a Float position, we ensure that its value is finitely representable. There is some nuance in that the spec does not say _all_ floats have to be within this range. So, this implementation allows explicitly passed floats which are greater than that bound, only applying the integer conversion limit when coercing a value. [input coercion]: https://spec.graphql.org/September2025/#sec-Float.Input-Coercion
1 parent 253d306 commit 07e31ab

File tree

2 files changed

+147
-2
lines changed

2 files changed

+147
-2
lines changed

crates/apollo-compiler/CHANGELOG.md

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,29 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
7070
.expect("schema parsed successfully");
7171
```
7272
73+
- **Allow coercing Int variables to Float - [tninesling], [pull/1011]**
74+
75+
The GraphQL spec allows coercing Int values to Float in input positions (see
76+
[input coercion]). There are a couple things to note about this.
77+
78+
- Strings are not allowed to be coerced in this position, even if they are
79+
numeric.
80+
- Ints can only be converted to Float when they are "representable by finite
81+
IEEE 754" floating point numbers.
82+
83+
Since an IEEE 754 floating point double (f64) has 53 bits of precision, it can
84+
safely represent up to the value 2^53 - 1 as a finite value. Beyond that, the
85+
round trip from integer to float and back will lose information. This is
86+
represented with a new `MAX_SAFE_INT` constant which is often included in
87+
other languages like JavaScript's `Number.MAX_SAFE_INT`. When, we encounter an
88+
Int variable in a Float position, we ensure that its value is finitely
89+
representable.
90+
91+
There is some nuance in that the spec does not say all floats have to be
92+
within this range. So, this implementation allows explicitly passed floats
93+
which are greater than that bound, only applying the integer conversion limit
94+
when coercing a value.
95+
7396
## Fixes
7497
7598
- **Fix handling of orphan root type extensions - [dariuszkuc], [pull/993](#993)**
@@ -106,12 +129,15 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
106129
Previously added `::iter_origins()` methods on Schema and Type Definitions was not made `pub`.
107130
108131
[dariuszkuc]: https://github.com/dariuszkuc
109-
[duckki]: https://github.com/duckki
132+
[duckki]: https://github.com/duckki
133+
[tninesling]: https://github.com/tninesling
134+
[input coercion]: https://spec.graphql.org/September2025/#sec-Float.Input-Coercion
110135
[pull/994]: https://github.com/apollographql/apollo-rs/pull/994
111136
[pull/993]: https://github.com/apollographql/apollo-rs/pull/993
112137
[pull/990]: https://github.com/apollographql/apollo-rs/pull/990
113138
[pull/989]: https://github.com/apollographql/apollo-rs/pull/989
114139
[pull/987]: https://github.com/apollographql/apollo-rs/pull/987
140+
[pull/1011]: https://github.com/apollographql/apollo-rs/pull/1011
115141
116142
117143
# [1.29.0](https://crates.io/crates/apollo-compiler/1.29.0) - 2025-08-08

crates/apollo-compiler/src/resolvers/input_coercion.rs

Lines changed: 120 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@ use crate::validation::Valid;
1818
use crate::Node;
1919
use crate::Schema;
2020

21+
/// The maximum integer safely representable as an IEEE 754 double-precision float.
22+
const MAX_SAFE_INT: i64 = (1_i64 << 53) - 1;
23+
2124
#[derive(Debug, Clone)]
2225
pub(crate) enum InputCoercionError {
2326
SuspectedValidationBug(SuspectedValidationBug),
@@ -120,7 +123,11 @@ fn coerce_variable_value(
120123
}
121124
"Float" => {
122125
// https://spec.graphql.org/October2021/#sec-Float.Input-Coercion
123-
if value.is_f64() {
126+
if value.is_f64()
127+
|| value
128+
.as_f64()
129+
.is_some_and(|f| f.abs() < MAX_SAFE_INT as f64)
130+
{
124131
return Ok(value.clone());
125132
}
126133
}
@@ -479,3 +486,115 @@ impl InputCoercionError {
479486
}
480487
}
481488
}
489+
490+
#[cfg(test)]
491+
mod tests {
492+
use super::*;
493+
use crate::validation::Valid;
494+
use crate::ExecutableDocument;
495+
use crate::Schema;
496+
497+
fn schema_and_doc_with_float_arg() -> (Valid<Schema>, Valid<ExecutableDocument>) {
498+
let schema = Schema::parse_and_validate(
499+
r#"
500+
type Query {
501+
foo(bar: Float!): Float!
502+
}
503+
"#,
504+
"sdl",
505+
)
506+
.unwrap();
507+
let doc = ExecutableDocument::parse_and_validate(
508+
&schema,
509+
"query ($bar: Float!) { foo(bar: $bar) }",
510+
"op.graphql",
511+
)
512+
.unwrap();
513+
(schema, doc)
514+
}
515+
516+
#[test]
517+
fn coerces_float_to_float() {
518+
let float_beyond_integer_max = (MAX_SAFE_INT as f64) + 0.5;
519+
let variables = serde_json_bytes::json!({ "bar": float_beyond_integer_max });
520+
let (schema, doc) = schema_and_doc_with_float_arg();
521+
522+
// When a float greater than MAX_SAFE_INT is provided, it should be accepted as a float.
523+
let _ = coerce_variable_values(
524+
&schema,
525+
doc.operations.anonymous.as_ref().unwrap(),
526+
variables.as_object().unwrap(),
527+
)
528+
.unwrap();
529+
}
530+
531+
#[test]
532+
fn coerces_int_to_float() {
533+
let variables = serde_json_bytes::json!({ "bar": 14 });
534+
let (schema, doc) = schema_and_doc_with_float_arg();
535+
536+
// When an integer within the safe bounds is provided, it should be accepted as a float.
537+
let _ = coerce_variable_values(
538+
&schema,
539+
doc.operations.anonymous.as_ref().unwrap(),
540+
variables.as_object().unwrap(),
541+
)
542+
.unwrap();
543+
}
544+
545+
#[test]
546+
fn fails_to_coerce_int_to_float_beyond_precision_bound() {
547+
let variables = serde_json_bytes::json!({ "bar": i64::MAX });
548+
let (schema, doc) = schema_and_doc_with_float_arg();
549+
550+
// When an integer cannot be finitely represented as a float, it should be rejected.
551+
let _ = coerce_variable_values(
552+
&schema,
553+
doc.operations.anonymous.as_ref().unwrap(),
554+
variables.as_object().unwrap(),
555+
)
556+
.unwrap_err();
557+
}
558+
559+
#[test]
560+
fn fails_to_numeric_string_to_float() {
561+
let variables = serde_json_bytes::json!({ "bar": "14" });
562+
let (schema, doc) = schema_and_doc_with_float_arg();
563+
564+
// Strings (even numeric ones) should not be coerced to Float in input positions.
565+
let _ = coerce_variable_values(
566+
&schema,
567+
doc.operations.anonymous.as_ref().unwrap(),
568+
variables.as_object().unwrap(),
569+
)
570+
.unwrap_err();
571+
}
572+
573+
#[test]
574+
fn fails_to_coerce_inf_to_float() {
575+
let variables = serde_json_bytes::json!({ "bar": f64::INFINITY });
576+
let (schema, doc) = schema_and_doc_with_float_arg();
577+
578+
// Infinity should not be accepted as a Float input value.
579+
let _ = coerce_variable_values(
580+
&schema,
581+
doc.operations.anonymous.as_ref().unwrap(),
582+
variables.as_object().unwrap(),
583+
)
584+
.unwrap_err();
585+
}
586+
587+
#[test]
588+
fn fails_to_coerce_nan_to_float() {
589+
let variables = serde_json_bytes::json!({ "bar": f64::NAN });
590+
let (schema, doc) = schema_and_doc_with_float_arg();
591+
592+
// NaN should not be accepted as a Float input value.
593+
let _ = coerce_variable_values(
594+
&schema,
595+
doc.operations.anonymous.as_ref().unwrap(),
596+
variables.as_object().unwrap(),
597+
)
598+
.unwrap_err();
599+
}
600+
}

0 commit comments

Comments
 (0)