diff --git a/docs/book/content/SUMMARY.md b/docs/book/content/SUMMARY.md index 49c542f5..24ea171f 100644 --- a/docs/book/content/SUMMARY.md +++ b/docs/book/content/SUMMARY.md @@ -29,6 +29,7 @@ - [Introspection](advanced/introspection.md) - [Non-struct objects](advanced/non_struct_objects.md) + - [Implicit and explicit null](advanced/implicit_and_explicit_null.md) - [Objects and generics](advanced/objects_and_generics.md) - [Multiple operations per request](advanced/multiple_ops_per_request.md) - [Dataloaders](advanced/dataloaders.md) diff --git a/docs/book/content/advanced/implicit_and_explicit_null.md b/docs/book/content/advanced/implicit_and_explicit_null.md new file mode 100644 index 00000000..014249a1 --- /dev/null +++ b/docs/book/content/advanced/implicit_and_explicit_null.md @@ -0,0 +1,116 @@ +# Implicit and explicit null + +There are two ways that a client can submit a null argument or field in a query. + +They can use a null literal: + +```graphql +{ + field(arg: null) +} +``` + +Or they can simply omit the argument: + +```graphql +{ + field +} +``` + +The former is an explicit null and the latter is an implicit null. + +There are some situations where it's useful to know which one the user provided. + +For example, let's say your business logic has a function that allows users to +perform a "patch" operation on themselves. Let's say your users can optionally +have favorite and least favorite numbers, and the input for that might look +like this: + +```rust +/// Updates user attributes. Fields that are `None` are left as-is. +pub struct UserPatch { + /// If `Some`, updates the user's favorite number. + pub favorite_number: Option>, + + /// If `Some`, updates the user's least favorite number. + pub least_favorite_number: Option>, +} + +# fn main() {} +``` + +To set a user's favorite number to 7, you would set `favorite_number` to +`Some(Some(7))`. In GraphQL, that might look like this: + +```graphql +mutation { patchUser(patch: { favoriteNumber: 7 }) } +``` + +To unset the user's favorite number, you would set `favorite_number` to +`Some(None)`. In GraphQL, that might look like this: + +```graphql +mutation { patchUser(patch: { favoriteNumber: null }) } +``` + +If you want to leave the user's favorite number alone, you would set it to +`None`. In GraphQL, that might look like this: + +```graphql +mutation { patchUser(patch: {}) } +``` + +The last two cases rely on being able to distinguish between explicit and implicit null. + +In Juniper, this can be done using the `Nullable` type: + +```rust +# extern crate juniper; +use juniper::{FieldResult, Nullable}; + +#[derive(juniper::GraphQLInputObject)] +struct UserPatchInput { + pub favorite_number: Nullable, + pub least_favorite_number: Nullable, +} + +impl Into for UserPatchInput { + fn into(self) -> UserPatch { + UserPatch { + // The `explicit` function transforms the `Nullable` into an + // `Option>` as expected by the business logic layer. + favorite_number: self.favorite_number.explicit(), + least_favorite_number: self.least_favorite_number.explicit(), + } + } +} + +# pub struct UserPatch { +# pub favorite_number: Option>, +# pub least_favorite_number: Option>, +# } + +# struct Session; +# impl Session { +# fn patch_user(&self, _patch: UserPatch) -> FieldResult<()> { Ok(()) } +# } + +struct Context { + session: Session, +} + +struct Mutation; + +#[juniper::graphql_object(Context=Context)] +impl Mutation { + fn patch_user(ctx: &Context, patch: UserPatchInput) -> FieldResult { + ctx.session.patch_user(patch.into())?; + Ok(true) + } +} +# fn main() {} +``` + +This type functions much like `Option`, but has two empty variants so you can +distinguish between implicit and explicit null. diff --git a/docs/book/content/advanced/index.md b/docs/book/content/advanced/index.md index e46978b3..8db84fc6 100644 --- a/docs/book/content/advanced/index.md +++ b/docs/book/content/advanced/index.md @@ -4,7 +4,8 @@ The chapters below cover some more advanced scenarios. - [Introspection](introspection.md) - [Non-struct objects](non_struct_objects.md) +- [Implicit and explicit null](implicit_and_explicit_null.md) - [Objects and generics](objects_and_generics.md) - [Multiple operations per request](multiple_ops_per_request.md) - [Dataloaders](dataloaders.md) -- [Subscriptions](subscriptions.md) \ No newline at end of file +- [Subscriptions](subscriptions.md) diff --git a/integration_tests/juniper_tests/src/explicit_null.rs b/integration_tests/juniper_tests/src/explicit_null.rs new file mode 100644 index 00000000..3ade4b52 --- /dev/null +++ b/integration_tests/juniper_tests/src/explicit_null.rs @@ -0,0 +1,81 @@ +use juniper::*; + +pub struct Context; + +impl juniper::Context for Context {} + +pub struct Query; + +#[derive(juniper::GraphQLInputObject)] +struct ObjectInput { + field: Nullable, +} + +#[graphql_object(Context=Context)] +impl Query { + fn is_explicit_null(arg: Nullable) -> bool { + arg.is_explicit_null() + } + + fn object_field_is_explicit_null(obj: ObjectInput) -> bool { + obj.field.is_explicit_null() + } +} + +type Schema = juniper::RootNode<'static, Query, EmptyMutation, EmptySubscription>; + +#[tokio::test] +async fn explicit_null() { + let ctx = Context; + + let query = r#" + query Foo($emptyObj: ObjectInput!, $literalNullObj: ObjectInput!) { + literalOneIsExplicitNull: isExplicitNull(arg: 1) + literalNullIsExplicitNull: isExplicitNull(arg: null) + noArgIsExplicitNull: isExplicitNull + literalOneFieldIsExplicitNull: objectFieldIsExplicitNull(obj: {field: 1}) + literalNullFieldIsExplicitNull: objectFieldIsExplicitNull(obj: {field: null}) + noFieldIsExplicitNull: objectFieldIsExplicitNull(obj: {}) + emptyVariableObjectFieldIsExplicitNull: objectFieldIsExplicitNull(obj: $emptyObj) + literalNullVariableObjectFieldIsExplicitNull: objectFieldIsExplicitNull(obj: $literalNullObj) + } + "#; + + let (data, errors) = juniper::execute( + query, + None, + &Schema::new( + Query, + EmptyMutation::::new(), + EmptySubscription::::new(), + ), + &[ + ("emptyObj".to_string(), InputValue::Object(vec![])), + ( + "literalNullObj".to_string(), + InputValue::object(vec![("field", InputValue::null())].into_iter().collect()), + ), + ] + .iter() + .cloned() + .collect(), + &ctx, + ) + .await + .unwrap(); + + assert_eq!(errors.len(), 0); + assert_eq!( + data, + graphql_value!({ + "literalOneIsExplicitNull": false, + "literalNullIsExplicitNull": true, + "noArgIsExplicitNull": false, + "literalOneFieldIsExplicitNull": false, + "literalNullFieldIsExplicitNull": true, + "noFieldIsExplicitNull": false, + "emptyVariableObjectFieldIsExplicitNull": false, + "literalNullVariableObjectFieldIsExplicitNull": true, + }) + ); +} diff --git a/integration_tests/juniper_tests/src/lib.rs b/integration_tests/juniper_tests/src/lib.rs index 17105046..d41a0c07 100644 --- a/integration_tests/juniper_tests/src/lib.rs +++ b/integration_tests/juniper_tests/src/lib.rs @@ -5,6 +5,8 @@ mod codegen; #[cfg(test)] mod custom_scalar; #[cfg(test)] +mod explicit_null; +#[cfg(test)] mod issue_371; #[cfg(test)] mod issue_398; diff --git a/juniper/CHANGELOG.md b/juniper/CHANGELOG.md index 762dbb21..b0026181 100644 --- a/juniper/CHANGELOG.md +++ b/juniper/CHANGELOG.md @@ -34,6 +34,8 @@ - `RuleError` - Support `chrono-tz::Tz` scalar behind a `chrono-tz` feature flag. ([#519](https://github.com/graphql-rust/juniper/pull/519)) + +- Added support for distinguishing between between implicit and explicit null. ([#795](https://github.com/graphql-rust/juniper/pull/795)) ## Fixes diff --git a/juniper/src/ast.rs b/juniper/src/ast.rs index 9ad29d9e..df32ecec 100644 --- a/juniper/src/ast.rs +++ b/juniper/src/ast.rs @@ -153,6 +153,14 @@ pub type Document<'a, S> = Vec>; pub trait FromInputValue: Sized { /// Performs the conversion. fn from_input_value(v: &InputValue) -> Option; + + /// Performs the conversion from an absent value (e.g. to distinguish between implicit and + /// explicit null). The default implementation just uses `from_input_value` as if an explicit + /// null were provided. This conversion must not fail. + fn from_implicit_null() -> Self { + Self::from_input_value(&InputValue::::Null) + .expect("input value conversion from null must not fail") + } } /// Losslessly clones a Rust data type into an InputValue. diff --git a/juniper/src/lib.rs b/juniper/src/lib.rs index 7456ef2c..3cab9b96 100644 --- a/juniper/src/lib.rs +++ b/juniper/src/lib.rs @@ -169,6 +169,7 @@ pub use crate::{ async_await::{DynGraphQLValueAsync, GraphQLTypeAsync, GraphQLValueAsync}, base::{Arguments, DynGraphQLValue, GraphQLType, GraphQLValue, TypeKind}, marker::{self, GraphQLInterface, GraphQLUnion}, + nullable::Nullable, scalars::{EmptyMutation, EmptySubscription, ID}, subscriptions::{ ExecutionOutput, GraphQLSubscriptionType, GraphQLSubscriptionValue, diff --git a/juniper/src/types/base.rs b/juniper/src/types/base.rs index 46072ee6..1b395d44 100644 --- a/juniper/src/types/base.rs +++ b/juniper/src/types/base.rs @@ -91,8 +91,6 @@ where if !args.contains_key(arg.name.as_str()) || args[arg.name.as_str()].is_null() { if let Some(ref default_value) = arg.default_value { args.insert(arg.name.as_str(), default_value.clone()); - } else { - args.insert(arg.name.as_str(), InputValue::null()); } } } diff --git a/juniper/src/types/mod.rs b/juniper/src/types/mod.rs index c6575fc9..8ac005ba 100644 --- a/juniper/src/types/mod.rs +++ b/juniper/src/types/mod.rs @@ -3,6 +3,7 @@ pub mod base; pub mod containers; pub mod marker; pub mod name; +pub mod nullable; pub mod pointers; pub mod scalars; pub mod subscriptions; diff --git a/juniper/src/types/nullable.rs b/juniper/src/types/nullable.rs new file mode 100644 index 00000000..9436e842 --- /dev/null +++ b/juniper/src/types/nullable.rs @@ -0,0 +1,328 @@ +use crate::{ + ast::{FromInputValue, InputValue, Selection, ToInputValue}, + executor::{ExecutionResult, Executor, Registry}, + schema::meta::MetaType, + types::{ + async_await::GraphQLValueAsync, + base::{GraphQLType, GraphQLValue}, + marker::IsInputType, + }, + value::{ScalarValue, Value}, +}; + +/// `Nullable` can be used in situations where you need to distinguish between an implicitly and +/// explicitly null input value. +/// +/// The GraphQL spec states that these two field calls are similar, but are not identical: +/// +/// ```graphql +/// { +/// field(arg: null) +/// field +/// } +/// ``` +/// +/// The first has explicitly provided null to the argument “arg”, while the second has implicitly +/// not provided a value to the argument “arg”. These two forms may be interpreted differently. For +/// example, a mutation representing deleting a field vs not altering a field, respectively. +/// +/// In cases where you do not need to be able to distinguish between the two types of null, you +/// should simply use `Option`. +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub enum Nullable { + /// No value + ImplicitNull, + /// No value, explicitly specified to be null + ExplicitNull, + /// Some value `T` + Some(T), +} + +impl Default for Nullable { + fn default() -> Self { + Self::ImplicitNull + } +} + +impl Nullable { + /// Returns `true` if the nullable is a `ExplicitNull` value. + #[inline] + pub fn is_explicit_null(&self) -> bool { + match self { + Self::ExplicitNull => true, + _ => false, + } + } + + /// Returns `true` if the nullable is a `ImplicitNull` value. + #[inline] + pub fn is_implicit_null(&self) -> bool { + match self { + Self::ImplicitNull => true, + _ => false, + } + } + + /// Returns `true` if the nullable is a `Some` value. + #[inline] + pub fn is_some(&self) -> bool { + match self { + Self::Some(_) => true, + _ => false, + } + } + + /// Returns `true` if the nullable is not a `Some` value. + #[inline] + pub fn is_null(&self) -> bool { + match self { + Self::Some(_) => false, + _ => true, + } + } + + /// Converts from `&mut Nullable` to `Nullable<&mut T>`. + #[inline] + pub fn as_mut(&mut self) -> Nullable<&mut T> { + match *self { + Self::Some(ref mut x) => Nullable::Some(x), + Self::ImplicitNull => Nullable::ImplicitNull, + Self::ExplicitNull => Nullable::ExplicitNull, + } + } + + /// Returns the contained `Some` value, consuming the `self` value. + /// + /// # Panics + /// + /// Panics if the value is not a `Some` with a custom panic message provided by `msg`. + #[inline] + #[track_caller] + pub fn expect(self, msg: &str) -> T { + self.some().expect(msg) + } + + /// Returns the contained `Some` value or a provided default. + #[inline] + pub fn unwrap_or(self, default: T) -> T { + self.some().unwrap_or(default) + } + + /// Returns the contained `Some` value or computes it from a closure. + #[inline] + pub fn unwrap_or_else T>(self, f: F) -> T { + self.some().unwrap_or_else(f) + } + + /// Maps a `Nullable` to `Nullable` by applying a function to a contained value. + #[inline] + pub fn map U>(self, f: F) -> Nullable { + match self { + Self::Some(x) => Nullable::Some(f(x)), + Self::ImplicitNull => Nullable::ImplicitNull, + Self::ExplicitNull => Nullable::ExplicitNull, + } + } + + /// Applies a function to the contained value (if any), or returns the provided default (if + /// not). + #[inline] + pub fn map_or U>(self, default: U, f: F) -> U { + self.some().map_or(default, f) + } + + /// Applies a function to the contained value (if any), or computes a default (if not). + #[inline] + pub fn map_or_else U, F: FnOnce(T) -> U>(self, default: D, f: F) -> U { + self.some().map_or_else(default, f) + } + + /// Transforms the `Nullable` into a `Result`, mapping `Some(v)` to `Ok(v)` and + /// `ImplicitNull` or `ExplicitNull` to `Err(err)`. + #[inline] + pub fn ok_or(self, err: E) -> Result { + self.some().ok_or(err) + } + + /// Transforms the `Nullable` into a `Result`, mapping `Some(v)` to `Ok(v)` and + /// `ImplicitNull` or `ExplicitNull` to `Err(err())`. + #[inline] + pub fn ok_or_else E>(self, err: F) -> Result { + self.some().ok_or_else(err) + } + + /// Returns the nullable if it contains a value, otherwise returns `b`. + #[inline] + pub fn or(self, b: Self) -> Self { + match self { + Self::Some(_) => self, + _ => b, + } + } + + /// Returns the nullable if it contains a value, otherwise calls `f` and + /// returns the result. + #[inline] + pub fn or_else Nullable>(self, f: F) -> Nullable { + match self { + Self::Some(_) => self, + _ => f(), + } + } + + /// Replaces the actual value in the nullable by the value given in parameter, returning the + /// old value if present, leaving a `Some` in its place without deinitializing either one. + #[inline] + pub fn replace(&mut self, value: T) -> Self { + std::mem::replace(self, Self::Some(value)) + } + + /// Converts from `Nullable` to `Option`. + pub fn some(self) -> Option { + match self { + Self::Some(v) => Some(v), + _ => None, + } + } + + /// Converts from `Nullable` to `Option>`, mapping `Some(v)` to `Some(Some(v))`, + /// `ExplicitNull` to `Some(None)`, and `ImplicitNull` to `None`. + pub fn explicit(self) -> Option> { + match self { + Self::Some(v) => Some(Some(v)), + Self::ExplicitNull => Some(None), + Self::ImplicitNull => None, + } + } +} + +impl Nullable<&T> { + /// Maps a `Nullable<&T>` to a `Nullable` by copying the contents of the nullable. + pub fn copied(self) -> Nullable { + self.map(|&t| t) + } +} + +impl Nullable<&mut T> { + /// Maps a `Nullable<&mut T>` to a `Nullable` by copying the contents of the nullable. + pub fn copied(self) -> Nullable { + self.map(|&mut t| t) + } +} + +impl Nullable<&T> { + /// Maps a `Nullable<&T>` to a `Nullable` by cloning the contents of the nullable. + pub fn cloned(self) -> Nullable { + self.map(|t| t.clone()) + } +} + +impl Nullable<&mut T> { + /// Maps a `Nullable<&mut T>` to a `Nullable` by cloning the contents of the nullable. + pub fn cloned(self) -> Nullable { + self.map(|t| t.clone()) + } +} + +impl GraphQLType for Nullable +where + T: GraphQLType, + S: ScalarValue, +{ + fn name(_: &Self::TypeInfo) -> Option<&'static str> { + None + } + + fn meta<'r>(info: &Self::TypeInfo, registry: &mut Registry<'r, S>) -> MetaType<'r, S> + where + S: 'r, + { + registry.build_nullable_type::(info).into_meta() + } +} + +impl GraphQLValue for Nullable +where + S: ScalarValue, + T: GraphQLValue, +{ + type Context = T::Context; + type TypeInfo = T::TypeInfo; + + fn type_name(&self, _: &Self::TypeInfo) -> Option<&'static str> { + None + } + + fn resolve( + &self, + info: &Self::TypeInfo, + _: Option<&[Selection]>, + executor: &Executor, + ) -> ExecutionResult { + match *self { + Self::Some(ref obj) => executor.resolve(info, obj), + _ => Ok(Value::null()), + } + } +} + +impl GraphQLValueAsync for Nullable +where + T: GraphQLValueAsync, + T::TypeInfo: Sync, + T::Context: Sync, + S: ScalarValue + Send + Sync, +{ + fn resolve_async<'a>( + &'a self, + info: &'a Self::TypeInfo, + _: Option<&'a [Selection]>, + executor: &'a Executor, + ) -> crate::BoxFuture<'a, ExecutionResult> { + let f = async move { + let value = match self { + Self::Some(obj) => executor.resolve_into_value_async(info, obj).await, + _ => Value::null(), + }; + Ok(value) + }; + Box::pin(f) + } +} + +impl FromInputValue for Nullable +where + T: FromInputValue, + S: ScalarValue, +{ + fn from_input_value(v: &InputValue) -> Option> { + match v { + &InputValue::Null => Some(Self::ExplicitNull), + v => v.convert().map(Self::Some), + } + } + + fn from_implicit_null() -> Self { + Self::ImplicitNull + } +} + +impl ToInputValue for Nullable +where + T: ToInputValue, + S: ScalarValue, +{ + fn to_input_value(&self) -> InputValue { + match *self { + Self::Some(ref v) => v.to_input_value(), + _ => InputValue::null(), + } + } +} + +impl IsInputType for Nullable +where + T: IsInputType, + S: ScalarValue, +{ +} diff --git a/juniper_codegen/src/impl_object.rs b/juniper_codegen/src/impl_object.rs index b594df5e..ed90f932 100644 --- a/juniper_codegen/src/impl_object.rs +++ b/juniper_codegen/src/impl_object.rs @@ -46,6 +46,13 @@ fn create( let top_attrs = &_impl.attrs; + let scalar = _impl + .attrs + .scalar + .as_ref() + .map(|s| quote!( #s )) + .unwrap_or_else(|| quote!(::juniper::DefaultScalarValue)); + let fields = _impl .methods .iter() @@ -87,10 +94,6 @@ fn create( .apply(&arg_name) }); - let expect_text = format!( - "Internal error: missing argument {} - validation must have failed", - &final_name - ); let mut_modifier = if is_mut { quote!(mut) } else { quote!() }; if final_name.starts_with("__") { @@ -109,7 +112,7 @@ fn create( let resolver = quote!( let #mut_modifier #arg_ident = args .get::<#ty>(#final_name) - .expect(#expect_text); + .unwrap_or_else(::juniper::FromInputValue::<#scalar>::from_implicit_null); ); let field_type = util::GraphQLTypeDefinitionFieldArg { diff --git a/juniper_codegen/src/util/mod.rs b/juniper_codegen/src/util/mod.rs index bd82ed44..f5459280 100644 --- a/juniper_codegen/src/util/mod.rs +++ b/juniper_codegen/src/util/mod.rs @@ -1701,34 +1701,35 @@ impl GraphQLTypeDefiniton { }) .collect::>(); - let from_inputs = self.fields.iter().map(|field| { - let field_ident = &field.resolver_code; - let field_name = &field.name; + let from_inputs = self + .fields + .iter() + .map(|field| { + let field_ident = &field.resolver_code; + let field_name = &field.name; - // Build from_input clause. - let from_input_default = match field.default { - Some(ref def) => { - quote! { - Some(&&::juniper::InputValue::Null) | None if true => #def, + // Build from_input clause. + let from_input_default = match field.default { + Some(ref def) => { + quote! { + Some(&&::juniper::InputValue::Null) | None if true => #def, + } } - } - None => quote! {}, - }; + None => quote! {}, + }; - quote!( - #field_ident: { - // TODO: investigate the unwraps here, they seem dangerous! - match obj.get(#field_name) { - #from_input_default - Some(ref v) => ::juniper::FromInputValue::from_input_value(v).unwrap(), - None => { - ::juniper::FromInputValue::from_input_value(&::juniper::InputValue::<#scalar>::null()) - .unwrap() - }, - } - }, - ) - }).collect::>(); + quote!( + #field_ident: { + // TODO: investigate the unwraps here, they seem dangerous! + match obj.get(#field_name) { + #from_input_default + Some(ref v) => ::juniper::FromInputValue::from_input_value(v).unwrap(), + None => ::juniper::FromInputValue::<#scalar>::from_implicit_null(), + } + }, + ) + }) + .collect::>(); let to_inputs = self .fields