From 63540d9c752cc8676d9703a3af748d945768aa77 Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Sun, 8 Dec 2024 04:55:09 +0400 Subject: [PATCH 1/2] Implemented the ability to recognize if an input field is null or not present at all --- src/FSharp.Data.GraphQL.Server/Planning.fs | 1 + .../ReflectionHelper.fs | 19 +- src/FSharp.Data.GraphQL.Server/Values.fs | 71 +++- .../Helpers/Reflection.fs | 47 ++- .../SchemaDefinitions.fs | 24 ++ src/FSharp.Data.GraphQL.Shared/TypeSystem.fs | 5 + .../FSharp.Data.GraphQL.Tests.fsproj | 1 + .../Variables and Inputs/InputRecordTests.fs | 109 +++++- .../SkippablesNormalizationTests.fs | 359 ++++++++++++++++++ 9 files changed, 596 insertions(+), 40 deletions(-) create mode 100644 tests/FSharp.Data.GraphQL.Tests/Variables and Inputs/SkippablesNormalizationTests.fs diff --git a/src/FSharp.Data.GraphQL.Server/Planning.fs b/src/FSharp.Data.GraphQL.Server/Planning.fs index 13eabadde..bd27e87e9 100644 --- a/src/FSharp.Data.GraphQL.Server/Planning.fs +++ b/src/FSharp.Data.GraphQL.Server/Planning.fs @@ -31,6 +31,7 @@ let TypeMetaFieldDef = args = [ { Name = "name" Description = None + IsSkippable = false TypeDef = StringType DefaultValue = None ExecuteInput = variableOrElse(InlineConstant >> coerceStringInput >> Result.map box) } diff --git a/src/FSharp.Data.GraphQL.Server/ReflectionHelper.fs b/src/FSharp.Data.GraphQL.Server/ReflectionHelper.fs index 14eb7d32c..69815c294 100644 --- a/src/FSharp.Data.GraphQL.Server/ReflectionHelper.fs +++ b/src/FSharp.Data.GraphQL.Server/ReflectionHelper.fs @@ -132,18 +132,24 @@ module internal ReflectionHelper = let [] OptionTypeName = "Microsoft.FSharp.Core.FSharpOption`1" let [] ValueOptionTypeName = "Microsoft.FSharp.Core.FSharpValueOption`1" + let [] SkippableTypeName = "System.Text.Json.Serialization.Skippable`1" let [] ListTypeName = "Microsoft.FSharp.Collections.FSharpList`1" let [] ArrayTypeName = "System.Array`1" let [] IEnumerableTypeName = "System.Collections.IEnumerable" let [] IEnumerableGenericTypeName = "System.Collections.Generic.IEnumerable`1" + let rec isTypeOptional (t: Type) = + t.FullName.StartsWith OptionTypeName + || t.FullName.StartsWith ValueOptionTypeName + || (t.FullName.StartsWith SkippableTypeName && isTypeOptional (t.GetGenericArguments().[0])) + let isParameterOptional (p: ParameterInfo) = - p.IsOptional - || p.ParameterType.FullName.StartsWith OptionTypeName - || p.ParameterType.FullName.StartsWith ValueOptionTypeName + p.IsOptional || isTypeOptional p.ParameterType let isPrameterMandatory = not << isParameterOptional + let isParameterSkippable (p: ParameterInfo) = p.ParameterType.FullName.StartsWith SkippableTypeName + let unwrapOptions (ty : Type) = if ty.FullName.StartsWith OptionTypeName || ty.FullName.StartsWith ValueOptionTypeName then ty.GetGenericArguments().[0] @@ -172,12 +178,15 @@ module internal ReflectionHelper = false let actualFrom = - if from.FullName.StartsWith OptionTypeName || from.FullName.StartsWith ValueOptionTypeName then + if from.FullName.StartsWith OptionTypeName || + from.FullName.StartsWith ValueOptionTypeName + then from.GetGenericArguments()[0] else from let actualTo = if ``to``.FullName.StartsWith OptionTypeName || - ``to``.FullName.StartsWith ValueOptionTypeName + ``to``.FullName.StartsWith ValueOptionTypeName || + ``to``.FullName.StartsWith SkippableTypeName then ``to``.GetGenericArguments()[0] else ``to`` diff --git a/src/FSharp.Data.GraphQL.Server/Values.fs b/src/FSharp.Data.GraphQL.Server/Values.fs index 6490eff10..4a4af46a0 100644 --- a/src/FSharp.Data.GraphQL.Server/Values.fs +++ b/src/FSharp.Data.GraphQL.Server/Values.fs @@ -111,6 +111,7 @@ let rec internal compileByType let parametersMap = let typeMismatchParameters = HashSet () + let skippableMismatchParameters = HashSet () let nullableMismatchParameters = HashSet () let missingParameters = HashSet () @@ -123,22 +124,32 @@ let rec internal compileByType |> Array.tryFind (fun field -> field.Name = param.Name) with | Some field -> + let isParameterSkippable = ReflectionHelper.isParameterSkippable param match field.TypeDef with + | Nullable _ when field.IsSkippable <> isParameterSkippable -> + skippableMismatchParameters.Add param.Name |> ignore | Nullable _ when - ReflectionHelper.isPrameterMandatory param + not (isParameterSkippable) + && ReflectionHelper.isPrameterMandatory param && field.DefaultValue.IsNone -> nullableMismatchParameters.Add param.Name |> ignore | inputDef -> - let inputType, paramType = inputDef.Type, param.ParameterType + let inputType, paramType = + if isParameterSkippable then + inputDef.Type, param.ParameterType.GenericTypeArguments.[0] + else + inputDef.Type, param.ParameterType if ReflectionHelper.isAssignableWithUnwrap inputType paramType then - allParameters.Add (struct (ValueSome field, param)) - |> ignore + allParameters.Add (struct (ValueSome field, param)) |> ignore else // TODO: Consider improving by specifying type mismatches typeMismatchParameters.Add param.Name |> ignore | None -> - if ReflectionHelper.isParameterOptional param then + if + ReflectionHelper.isParameterSkippable param + || ReflectionHelper.isParameterOptional param + then allParameters.Add <| struct (ValueNone, param) |> ignore else missingParameters.Add param.Name |> ignore @@ -157,6 +168,11 @@ let rec internal compileByType let ``params`` = String.Join ("', '", nullableMismatchParameters) $"Input object %s{objDef.Name} refers to type '%O{objtype}', but constructor parameters for optional GraphQL fields '%s{``params``}' are not optional" InvalidInputTypeException (message, nullableMismatchParameters.ToImmutableHashSet ()) + if skippableMismatchParameters.Any () then + let message = + let ``params`` = String.Join ("', '", skippableMismatchParameters) + $"Input object %s{objDef.Name} refers to type '%O{objtype}', but skippable '%s{``params``}' GraphQL fields and constructor parameters do not match" + InvalidInputTypeException (message, skippableMismatchParameters.ToImmutableHashSet ()) if typeMismatchParameters.Any () then let message = let ``params`` = String.Join ("', '", typeMismatchParameters) @@ -204,15 +220,26 @@ let rec internal compileByType parametersMap |> Seq.map (fun struct (field, param) -> match field with - | ValueSome field -> - match Map.tryFind field.Name props with - | None -> - Ok - <| wrapOptionalNone param.ParameterType field.TypeDef.Type - | Some prop -> - field.ExecuteInput prop variables - |> Result.map (normalizeOptional param.ParameterType) - |> attachErrorExtensionsIfScalar inputSource inputObjectPath originalInputDef field + | ValueSome field -> result { + match Map.tryFind field.Name props with + | None when field.IsSkippable -> return Activator.CreateInstance param.ParameterType + | None -> return wrapOptionalNone param.ParameterType field.TypeDef.Type + | Some prop -> + let! value = + field.ExecuteInput prop variables + |> attachErrorExtensionsIfScalar inputSource inputObjectPath originalInputDef field + if field.IsSkippable then + let innerType = param.ParameterType.GenericTypeArguments[0] + if not (ReflectionHelper.isTypeOptional innerType) && + (value = null || (innerType.IsValueType && value = Activator.CreateInstance innerType)) + then + return Activator.CreateInstance param.ParameterType + else + let ``include``, _ = ReflectionHelper.ofSkippable param.ParameterType + return normalizeOptional innerType value |> ``include`` + else + return normalizeOptional param.ParameterType value + } | ValueNone -> Ok <| wrapOptionalNone param.ParameterType typeof) |> Seq.toList @@ -236,12 +263,25 @@ let rec internal compileByType parametersMap |> Seq.map (fun struct (field, param) -> result { match field with + | ValueSome field when field.IsSkippable && not (objectFields.ContainsKey field.Name) -> + return (Activator.CreateInstance param.ParameterType) | ValueSome field -> let! value = field.ExecuteInput (VariableName field.Name) objectFields // TODO: Take into account variable name |> attachErrorExtensionsIfScalar inputSource inputObjectPath originalInputDef field - return normalizeOptional param.ParameterType value + if field.IsSkippable then + let innerType = param.ParameterType.GenericTypeArguments[0] + if not (ReflectionHelper.isTypeOptional innerType) && + (value = null || (innerType.IsValueType && value = Activator.CreateInstance innerType)) + then + return Activator.CreateInstance param.ParameterType + else + let normalizedValue = normalizeOptional innerType value + let ``include``, _ = ReflectionHelper.ofSkippable param.ParameterType + return ``include`` normalizedValue + else + return normalizeOptional param.ParameterType value | ValueNone -> return wrapOptionalNone param.ParameterType typeof }) |> Seq.toList @@ -506,6 +546,7 @@ and private coerceVariableInputObject inputObjectPath (originalObjDef, objDef) ( KeyValuePair (field.Name, value) match input.TryGetProperty field.Name with | true, value -> coerce value |> ValueSome + | false, _ when field.IsSkippable -> ValueNone | false, _ -> match field.DefaultValue with | Some value -> KeyValuePair (field.Name, Ok value) diff --git a/src/FSharp.Data.GraphQL.Shared/Helpers/Reflection.fs b/src/FSharp.Data.GraphQL.Shared/Helpers/Reflection.fs index 07c801e74..f8e8b0c19 100644 --- a/src/FSharp.Data.GraphQL.Shared/Helpers/Reflection.fs +++ b/src/FSharp.Data.GraphQL.Shared/Helpers/Reflection.fs @@ -4,9 +4,10 @@ namespace FSharp.Data.GraphQL open System -open System.Reflection open System.Collections.Generic open System.Collections.Immutable +open System.Reflection +open System.Text.Json.Serialization /// General helper functions and types. module Helpers = @@ -133,3 +134,47 @@ module internal ReflectionHelper = else input else input (some, none, value) + + /// + /// Returns pair of function constructors for `include(value)` and `skip` + /// used to create option of type given at runtime. + /// + /// Type used for result option constructors as type param + let ofSkippable (skippableType : Type) = + let skippableType = skippableType.GetTypeInfo () + let skip = + let x = skippableType.GetDeclaredProperty "Skip" + x.GetValue(null) + let ``include`` = + let createInclude = skippableType.GetDeclaredMethod "NewInclude" + fun value -> + let valueType = + match value with + | null -> null + | _ -> value.GetType().GetTypeInfo() + if valueType = skippableType + then value + else createInclude.Invoke(null, [| value |]) + (``include``, skip) + + /// + /// Returns pair of function constructors for `include(value)` and `skip` + /// used to create option of type given at runtime. + /// + /// Type used for result option constructors as type param + let skippableOfType t = + let skippableType = typedefof<_ Skippable>.GetTypeInfo().MakeGenericType([|t|]).GetTypeInfo() + let skip = + let x = skippableType.GetDeclaredProperty "Skip" + x.GetValue(null) + let ``include`` = + let createInclude = skippableType.GetDeclaredMethod "NewInclude" + fun value -> + let valueType = + match value with + | null -> null + | _ -> value.GetType().GetTypeInfo() + if valueType = skippableType + then value + else createInclude.Invoke(null, [| value |]) + (``include``, skip) diff --git a/src/FSharp.Data.GraphQL.Shared/SchemaDefinitions.fs b/src/FSharp.Data.GraphQL.Shared/SchemaDefinitions.fs index 5bed229bb..b86fda44b 100644 --- a/src/FSharp.Data.GraphQL.Shared/SchemaDefinitions.fs +++ b/src/FSharp.Data.GraphQL.Shared/SchemaDefinitions.fs @@ -479,6 +479,7 @@ module SchemaDefinitions = Args = [| { InputFieldDefinition.Name = "if" Description = Some "Included when true." + IsSkippable = false TypeDef = BooleanType DefaultValue = None ExecuteInput = variableOrElse (InlineConstant >> coerceBoolInput >> Result.map box) } |] } @@ -492,6 +493,7 @@ module SchemaDefinitions = Args = [| { InputFieldDefinition.Name = "if" Description = Some "Skipped when true." + IsSkippable = false TypeDef = BooleanType DefaultValue = None ExecuteInput = variableOrElse (InlineConstant >> coerceBoolInput >> Result.map box) } |] } @@ -1313,10 +1315,32 @@ module SchemaDefinitions = static member Input(name : string, typedef : #InputDef<'In>, ?defaultValue : 'In, ?description : string) : InputFieldDef = upcast { InputFieldDefinition.Name = name Description = description + IsSkippable = false TypeDef = typedef DefaultValue = defaultValue ExecuteInput = Unchecked.defaultof } + /// + /// Creates an input field. Input fields are used like ordinary fileds in case of s, + /// and can be used to define arguments to objects and interfaces fields. + /// + /// + /// Field name. Must be unique in scope of the defining input object or withing field's argument list. + /// + /// GraphQL type definition of the current input type + /// If defined, this value will be used when no matching input has been provided by the requester. + /// Optional input description. Usefull for generating documentation. + static member SkippableInput(name : string, typedef : #InputDef<'In>, ?description : string) : InputFieldDef = + upcast { InputFieldDefinition.Name = name + Description = description |> Option.map (fun s -> s + " Skip this field if you want to avoid saving it") + IsSkippable = true + TypeDef = + match (box typedef) with + | :? NullableDef<'In> as n -> n + | _ -> Nullable typedef + DefaultValue = None + ExecuteInput = Unchecked.defaultof } + /// /// Creates a custom GraphQL interface type. It's needs to be implemented by object types and should not be used alone. /// diff --git a/src/FSharp.Data.GraphQL.Shared/TypeSystem.fs b/src/FSharp.Data.GraphQL.Shared/TypeSystem.fs index d31c7c6cb..f1a066591 100644 --- a/src/FSharp.Data.GraphQL.Shared/TypeSystem.fs +++ b/src/FSharp.Data.GraphQL.Shared/TypeSystem.fs @@ -1637,6 +1637,8 @@ and InputFieldDef = abstract Name : string /// Optional input field / argument description. abstract Description : string option + /// Not applied to input object if field is missing but does not allow null. + abstract IsSkippable : bool /// GraphQL type definition of the input type. abstract TypeDef : InputDef /// Optional default input value - used when no input was provided. @@ -1654,6 +1656,8 @@ and [] InputFieldDefinition<'In> = { Name : string /// Optional input field / argument description. Description : string option + /// Not applied to input object if field is missing but does not allow null. + IsSkippable : bool /// GraphQL type definition of the input type. TypeDef : InputDef<'In> /// Optional default input value - used when no input was provided. @@ -1666,6 +1670,7 @@ and [] InputFieldDefinition<'In> = { interface InputFieldDef with member x.Name = x.Name member x.Description = x.Description + member x.IsSkippable = x.IsSkippable member x.TypeDef = upcast x.TypeDef member x.DefaultValue = x.DefaultValue |> Option.map (fun x -> upcast x) diff --git a/tests/FSharp.Data.GraphQL.Tests/FSharp.Data.GraphQL.Tests.fsproj b/tests/FSharp.Data.GraphQL.Tests/FSharp.Data.GraphQL.Tests.fsproj index f67afae82..c5613e691 100644 --- a/tests/FSharp.Data.GraphQL.Tests/FSharp.Data.GraphQL.Tests.fsproj +++ b/tests/FSharp.Data.GraphQL.Tests/FSharp.Data.GraphQL.Tests.fsproj @@ -53,6 +53,7 @@ + diff --git a/tests/FSharp.Data.GraphQL.Tests/Variables and Inputs/InputRecordTests.fs b/tests/FSharp.Data.GraphQL.Tests/Variables and Inputs/InputRecordTests.fs index c9bcc8632..fc19e33d8 100644 --- a/tests/FSharp.Data.GraphQL.Tests/Variables and Inputs/InputRecordTests.fs +++ b/tests/FSharp.Data.GraphQL.Tests/Variables and Inputs/InputRecordTests.fs @@ -7,6 +7,7 @@ module FSharp.Data.GraphQL.Tests.InputRecordTests open Xunit open System.Collections.Immutable open System.Text.Json +open System.Text.Json.Serialization open FSharp.Data.GraphQL open FSharp.Data.GraphQL.Types @@ -30,7 +31,40 @@ let InputRecordOptionalType = Define.Input ("c", Nullable StringType) ] ) -type InputRecordNested = { a : InputRecord; b : InputRecordOptional option; c : InputRecordOptional voption; l : InputRecord list } +type InputRecordSkippable = { a : string Skippable; b : string option Skippable; c : string voption Skippable } + with + member r.VerifyAllInclude = + Assert.True r.a.isInclude + Assert.True r.b.isInclude + Assert.True r.c.isInclude + member r.VerifyAllSkip = + Assert.True r.a.isSkip + Assert.True r.b.isSkip + Assert.True r.c.isSkip + member r.VerifySkipAndIncludeNull = + Assert.True r.a.isSkip + match r.b with + | Include None -> () + | _ -> fail "Expected b to be 'Include None'" + match r.c with + | Include ValueNone -> () + | _ -> fail "Expected c to be 'include ValueNone'" + +let InputRecordSkippableType = + Define.InputObject ( + "InputRecordSkippable", + [ Define.SkippableInput ("a", StringType) + Define.SkippableInput ("b", Nullable StringType) + Define.SkippableInput ("c", Nullable StringType) ] + ) + +type InputRecordNested = { + a : InputRecord; + b : InputRecordOptional option; + c : InputRecordOptional voption; + s : InputRecordSkippable voption; + l : InputRecord list +} let InputRecordNestedType = Define.InputObject ( @@ -38,6 +72,7 @@ let InputRecordNestedType = [ Define.Input ("a", InputRecordType) Define.Input ("b", Nullable InputRecordOptionalType) Define.Input ("c", Nullable InputRecordOptionalType) + Define.Input ("s", Nullable InputRecordSkippableType) Define.Input ("l", ListOf InputRecordType) ] ) @@ -65,7 +100,26 @@ let InputObjectOptionalType = Define.Input ("c", Nullable StringType) ] ) -let schema = +type InputObjectSkippable (a : string Skippable, b : string voption Skippable, c : string option Skippable) = + member val A = a + member val B = b + member val C = c + +let InputObjectSkippableType = + Define.InputObject ( + "InputObjectSkippable", + [ Define.SkippableInput ("a", StringType) + Define.SkippableInput ("b", Nullable StringType) + Define.SkippableInput ("c", Nullable StringType) ] + ) + +type Verify = + | Nothing + | AllInclude + | AllSkip + | SkipAndIncludeNull + +let schema verify = let schema = Schema ( query = @@ -77,7 +131,15 @@ let schema = [ Define.Input ("record", InputRecordType) Define.Input ("recordOptional", Nullable InputRecordOptionalType) Define.Input ("recordNested", Nullable InputRecordNestedType) ], - stringifyInput + (fun ctx name -> + let recordNested = ctx.Arg "recordNested" + match verify with + | Nothing -> () + | AllInclude -> recordNested.s |> ValueOption.iter _.VerifyAllInclude + | AllSkip -> recordNested.s |> ValueOption.iter _.VerifyAllSkip + | SkipAndIncludeNull -> recordNested.s |> ValueOption.iter _.VerifySkipAndIncludeNull + stringifyInput ctx name + ) ) // TODO: add all args stringificaiton Define.Field ( "objectInputs", @@ -98,10 +160,16 @@ let ``Execute handles creation of inline input records with all fields`` () = recordInputs( record: { a: "a", b: "b", c: "c" }, recordOptional: { a: "a", b: "b", c: "c" }, - recordNested: { a: { a: "a", b: "b", c: "c" }, b: { a: "a", b: "b", c: "c" }, c: { a: "a", b: "b", c: "c" }, l: [{ a: "a", b: "b", c: "c" }] } + recordNested: { + a: { a: "a", b: "b", c: "c" }, + b: { a: "a", b: "b", c: "c" }, + c: { a: "a", b: "b", c: "c" }, + s: { a: "a", b: "b", c: "c" }, + l: [{ a: "a", b: "b", c: "c" }] + } ) }""" - let result = sync <| schema.AsyncExecute(parse query) + let result = sync <| (schema AllInclude).AsyncExecute(parse query) ensureDirect result <| fun data errors -> empty errors [] @@ -111,10 +179,10 @@ let ``Execute handles creation of inline input records with optional null fields recordInputs( record: { a: "a", b: "b", c: "c" }, recordOptional: null, - recordNested: { a: { a: "a", b: "b", c: "c" }, b: null, c: null, l: [] } + recordNested: { a: { a: "a", b: "b", c: "c" }, b: null, c: null, s: null, l: [] } ) }""" - let result = sync <| schema.AsyncExecute(parse query) + let result = sync <| (schema Nothing).AsyncExecute(parse query) ensureDirect result <| fun data errors -> empty errors [] @@ -126,14 +194,15 @@ let ``Execute handles creation of inline input records with mandatory only field recordNested: { a: { a: "a", b: "b", c: "c" }, l: [{ a: "a", b: "b", c: "c" }] } ) }""" - let result = sync <| schema.AsyncExecute(parse query) + let result = sync <| (schema Nothing).AsyncExecute(parse query) ensureDirect result <| fun data errors -> empty errors -let variablesWithAllInputs (record, optRecord) = +let variablesWithAllInputs (record, optRecord, skippable) = $""" {{ "record":%s{record}, "optRecord":%s{optRecord}, + "skippable": %s{skippable}, "list":[%s{record}] }} """ @@ -146,16 +215,17 @@ let paramsWithValues variables = [] let ``Execute handles creation of input records from variables with all fields`` () = let query = - """query ($record: InputRecord!, $optRecord: InputRecordOptional, $list: [InputRecord!]!){ + """query ($record: InputRecord!, $optRecord: InputRecordOptional, $skippable: InputRecordSkippable, $list: [InputRecord!]!){ recordInputs( record: $record, recordOptional: $optRecord, - recordNested: { a: $record, b: $optRecord, c: $optRecord, l: $list } + recordNested: { a: $record, b: $optRecord, c: $optRecord, s: $skippable, l: $list } ) }""" let testInputObject = """{"a":"a","b":"b","c":"c"}""" - let params' = variablesWithAllInputs(testInputObject, testInputObject) |> paramsWithValues - let result = sync <| schema.AsyncExecute(parse query, variables = params') + let params' = + variablesWithAllInputs(testInputObject, testInputObject, testInputObject) |> paramsWithValues + let result = sync <| (schema AllInclude).AsyncExecute(parse query, variables = params') //let expected = NameValueLookup.ofList [ "recordInputs", upcast testInputObject ] ensureDirect result <| fun data errors -> empty errors @@ -164,16 +234,17 @@ let ``Execute handles creation of input records from variables with all fields`` [] let ``Execute handles creation of input records from variables with optional null fields`` () = let query = - """query ($record: InputRecord!, $optRecord: InputRecordOptional, $list: [InputRecord!]!){ + """query ($record: InputRecord!, $optRecord: InputRecordOptional, $skippable: InputRecordSkippable, $list: [InputRecord!]!){ recordInputs( record: $record, recordOptional: $optRecord, - recordNested: { a: $record, b: $optRecord, c: $optRecord, l: $list } + recordNested: { a: $record, b: $optRecord, c: $optRecord, s: $skippable, l: $list } ) }""" let testInputObject = """{"a":"a","b":"b","c":"c"}""" - let params' = variablesWithAllInputs(testInputObject, "null") |> paramsWithValues - let result = sync <| schema.AsyncExecute(parse query, variables = params') + let testInputSkippable = """{ "a": null, "b": null, "c": null }""" + let params' = variablesWithAllInputs(testInputObject, "null", testInputSkippable) |> paramsWithValues + let result = sync <| (schema SkipAndIncludeNull).AsyncExecute(parse query, variables = params') ensureDirect result <| fun data errors -> empty errors [] @@ -186,6 +257,6 @@ let ``Execute handles creation of input records from variables with mandatory on ) }""" let testInputObject = """{"a":"a","b":"b","c":"c"}""" - let params' = variablesWithAllInputs(testInputObject, "null") |> paramsWithValues - let result = sync <| schema.AsyncExecute(parse query, variables = params') + let params' = variablesWithAllInputs(testInputObject, "null", "{}") |> paramsWithValues + let result = sync <| (schema AllSkip).AsyncExecute(parse query, variables = params') ensureDirect result <| fun data errors -> empty errors diff --git a/tests/FSharp.Data.GraphQL.Tests/Variables and Inputs/SkippablesNormalizationTests.fs b/tests/FSharp.Data.GraphQL.Tests/Variables and Inputs/SkippablesNormalizationTests.fs new file mode 100644 index 000000000..b67c0b0ea --- /dev/null +++ b/tests/FSharp.Data.GraphQL.Tests/Variables and Inputs/SkippablesNormalizationTests.fs @@ -0,0 +1,359 @@ +// The MIT License (MIT) + +[] +module FSharp.Data.GraphQL.Tests.SkippablesNormalizationTests + +#nowarn "25" + +open Xunit +open System +open System.Collections.Immutable +open System.Text.Json +open System.Text.Json.Serialization + +open FSharp.Data.GraphQL +open FSharp.Data.GraphQL.Types +open FSharp.Data.GraphQL.Parser + +open FSharp.Data.GraphQL.Tests.OptionalsNormalizationTests + +type AddressRecord = { + Line1: AddressLine1 Skippable + Line2: AddressLine2 voption Skippable + City: City Skippable + State: State Skippable + ZipCode: ZipCode Skippable +} with + member r.VerifyAllSkip = + Assert.True r.Line1.isSkip + Assert.True r.Line2.isSkip + Assert.True r.City.isSkip + Assert.True r.State.isSkip + Assert.True r.ZipCode.isSkip + member r.VerifySkipAndIncludeNull = + Assert.True r.Line1.isSkip + match r.Line2 with + | Skip -> fail "Expected Line2 to be 'Include ValueNone'" + | Include ValueNone -> () + | Include _ -> fail "Expected Line2 to be 'Include ValueNone'" + Assert.True r.City.isSkip + Assert.True r.State.isSkip + Assert.True r.ZipCode.isSkip + +type AddressClass(zipCode, city, state, line1, line2) = + member _.Line1 : AddressLine1 Skippable = line1 + member _.Line2 : AddressLine2 voption Skippable = line2 + member _.City : City Skippable = city + member _.State : State Skippable = state + member _.ZipCode : ZipCode Skippable = zipCode + + member _.VerifyAllSkip = + Assert.True line1.isSkip + Assert.True line2.isSkip + Assert.True city.isSkip + Assert.True state.isSkip + Assert.True zipCode.isSkip + member _.VerifySkipAndIncludeNull = + Assert.True line1.isSkip + match line2 with + | Skip -> fail "Expected Line2 to be 'Include ValueNone'" + | Include ValueNone -> () + | Include _ -> fail "Expected Line2 to be 'Include ValueNone'" + Assert.True city.isSkip + Assert.True state.isSkip + Assert.True zipCode.isSkip + +[] +type AddressStruct ( + zipCode : ZipCode Skippable, + city : City Skippable, + state : State Skippable, + line1 : AddressLine1 Skippable, + line2 : AddressLine2 voption Skippable +) = + member _.Line1 = line1 + member _.Line2 = line2 + member _.City = city + member _.State = state + member _.ZipCode = zipCode + + member _.VerifyAllSkip = + Assert.True line1.isSkip + Assert.True line2.isSkip + Assert.True city.isSkip + Assert.True state.isSkip + Assert.True zipCode.isSkip + member _.VerifySkipAndIncludeNull = + Assert.True line1.isSkip + match line2 with + | Skip -> fail "Expected Line2 to be 'Include ValueNone'" + | Include ValueNone -> () + | Include _ -> fail "Expected Line2 to be 'Include ValueNone'" + Assert.True city.isSkip + Assert.True state.isSkip + Assert.True zipCode.isSkip + + +open Validus +open FSharp.Data.GraphQL.Tests.OptionalsNormalizationTests.String +open FSharp.Data.GraphQL.Tests.OptionalsNormalizationTests.Operators + +[] +module State = + + open ValidString + open Validus.Operators + + let create : Validator = + (Check.String.lessThanLen 100 <+> validateStringCharacters) *|* ValidString + + let createOrWhitespace : Validator = + (allowEmpty ?=> (Check.String.lessThanLen 100 <+> validateStringCharacters)) *|* ValueOption.map ValidString + +module Address = + + open ValidString + open Validus.Operators + + let createLine1 : Validator = + (Check.String.lessThanLen 1000 <+> validateStringCharacters) *|* ValidString + + let createLine2 : Validator = + (allowEmpty ?=> (Check.String.lessThanLen 1000 <+> validateStringCharacters)) *|* ValueOption.map ValidString + + let createZipCode : Validator = + (Check.String.lessThanLen 100 <+> validateStringCharacters) *|* ValidString + + let createCity : Validator = + (Check.String.lessThanLen 100 <+> validateStringCharacters) *|* ValidString + + + open Scalars + + let Line1Type = Define.ValidStringScalar("AddressLine1", createLine1, ValidString.value, "Address line 1") + let Line2Type = Define.ValidStringScalar("AddressLine2", createLine2, ValidString.value, "Address line 2") + let ZipCodeType = Define.ValidStringScalar("AddressZipCode", createZipCode, ValidString.value, "Address zip code") + let CityType = Define.ValidStringScalar("City", createCity, ValidString.value) + let StateType = Define.ValidStringScalar("State", State.createOrWhitespace, ValidString.value) + +let InputAddressRecordType = + Define.InputObject( + name = "InputAddressRecord", + fields = [ + Define.SkippableInput("line1", Address.Line1Type) + Define.SkippableInput("line2", Nullable Address.Line2Type) + Define.SkippableInput("zipCode", Address.ZipCodeType) + Define.SkippableInput("city", Address.CityType) + Define.SkippableInput("state", Address.StateType) + ] + ) + +let InputAddressClassType = + Define.InputObject( + name = "InputAddressObject", + fields = [ + Define.SkippableInput("line1", Address.Line1Type) + Define.SkippableInput("line2", Nullable Address.Line2Type) + Define.SkippableInput("zipCode", Address.ZipCodeType) + Define.SkippableInput("city", Address.CityType) + Define.SkippableInput("state", Address.StateType) + ] + ) + +let InputAddressStructType = + Define.InputObject( + name = "InputAddressStruct", + fields = [ + Define.SkippableInput("line1", Address.Line1Type) + Define.SkippableInput("line2", Nullable Address.Line2Type) + Define.SkippableInput("zipCode", Address.ZipCodeType) + Define.SkippableInput("city", Address.CityType) + Define.SkippableInput("state", Address.StateType) + ] + ) + +open FSharp.Data.GraphQL.Execution +open FSharp.Data.GraphQL.Validation +open FSharp.Data.GraphQL.Validation.ValidationResult +open ErrorHelpers + +let createSingleError message = + [{ new IGQLError with member _.Message = message }] + +type InputRecordNested = { HomeAddress : AddressRecord; WorkAddress : AddressRecord Skippable; MailingAddress : AddressRecord Skippable } + +let InputRecordNestedType = + Define.InputObject ( + "InputRecordNested", + [ Define.Input ("homeAddress", InputAddressRecordType) + Define.SkippableInput ("workAddress", InputAddressRecordType) + Define.SkippableInput ("mailingAddress", InputAddressRecordType) ], + fun inputRecord -> + match inputRecord.MailingAddress, inputRecord.WorkAddress with + | Skip, Skip -> ValidationError <| createSingleError "MailingAddress or WorkAddress must be provided" + | _ -> Success + @@ + if inputRecord.MailingAddress.isInclude && inputRecord.HomeAddress = (inputRecord.MailingAddress |> Skippable.toValueOption).Value then + ValidationError <| createSingleError "HomeAddress and MailingAddress must be different" + else + Success + ) + +type Verify = + | Nothing + | Skip + | SkipAndIncludeNull + +let schema verify = + let schema = + Schema ( + query = + Define.Object ( + "Query", + [ Define.Field ( + "recordInputs", + StringType, + [ Define.Input ("record", InputAddressRecordType) + Define.Input ("recordOptional", Nullable InputAddressRecordType) + Define.Input ("recordNested", Nullable InputRecordNestedType) ], + (fun ctx name -> + let record = ctx.Arg("record") + let recordOptional = ctx.TryArg("recordOptional") + let recordNested = ctx.Arg("recordNested") + match verify with + | Nothing -> () + | Skip -> + record.VerifyAllSkip + recordOptional |> Option.iter _.VerifyAllSkip + recordNested.HomeAddress.VerifyAllSkip + | SkipAndIncludeNull -> + record.VerifySkipAndIncludeNull + recordOptional |> Option.iter _.VerifySkipAndIncludeNull + recordNested.HomeAddress.VerifySkipAndIncludeNull + stringifyInput ctx name + ) + ) // TODO: add all args stringificaiton + Define.Field ( + "objectInputs", + StringType, + [ Define.Input ("object", InputAddressClassType) + Define.Input ("objectOptional", Nullable InputAddressClassType) ], + (fun ctx name -> + let obj = ctx.Arg("object") + let objOptional = ctx.TryArg("objectOptional") + match verify with + | Nothing -> () + | Skip -> + obj.VerifyAllSkip + objOptional |> Option.iter _.VerifyAllSkip + | SkipAndIncludeNull -> + obj.VerifySkipAndIncludeNull + objOptional |> Option.iter _.VerifySkipAndIncludeNull + stringifyInput ctx name + ) + ) // TODO: add all args stringificaiton + Define.Field ( + "structInputs", + StringType, + [ Define.Input ("struct", InputAddressStructType) + Define.Input ("structOptional", Nullable InputAddressStructType) ], + (fun ctx name -> + let obj = ctx.Arg("struct") + let objOptional = ctx.TryArg("structOptional") + match verify with + | Nothing -> () + | Skip -> + obj.VerifyAllSkip + objOptional |> Option.iter _.VerifyAllSkip + | SkipAndIncludeNull -> + obj.VerifySkipAndIncludeNull + objOptional |> Option.iter _.VerifySkipAndIncludeNull + stringifyInput ctx name + ) + ) ] // TODO: add all args stringificaiton + ) + ) + + Executor schema + + +[] +let ``Execute handles validation of valid inline input records with all fields`` () = + let query = + """{ + recordInputs( + record: { zipCode: "12345", city: "Miami" }, + recordOptional: { zipCode: "12345", city: "Miami" }, + recordNested: { homeAddress: { zipCode: "12345", city: "Miami" }, workAddress: { zipCode: "67890", city: "Miami" } } + ) + objectInputs( + object: { zipCode: "12345", city: "Miami" }, + objectOptional: { zipCode: "12345", city: "Miami" } + ) + structInputs( + struct: { zipCode: "12345", city: "Miami" }, + structOptional: { zipCode: "12345", city: "Miami" } + ) + }""" + let result = sync <| (schema Nothing).AsyncExecute(parse query) + ensureDirect result <| fun data errors -> empty errors + +[] +let ``Execute handles validation of valid inline input records with mandatory-only fields`` () = + let query = + """{ + recordInputs( + record: { zipCode: "12345", city: "Miami" }, + recordNested: { homeAddress: { zipCode: "12345", city: "Miami" }, workAddress: { zipCode: "67890", city: "Miami" } } + ) + objectInputs( + object: { zipCode: "12345", city: "Miami" }, + ) + structInputs( + struct: { zipCode: "12345", city: "Miami" }, + ) + }""" + let result = sync <| (schema Nothing).AsyncExecute(parse query) + ensureDirect result <| fun data errors -> empty errors + +[] +let ``Execute handles validation of valid inline input records with null mandatory skippable fields`` () = + let query = + """{ + recordInputs( + record: { zipCode: null, city: null }, + recordOptional: { zipCode: null, city: null }, + recordNested: { homeAddress: { zipCode: null, city: null }, workAddress: { zipCode: null, city: null } } + ) + objectInputs( + object: { zipCode: null, city: null }, + objectOptional: { zipCode: null, city: null } + ) + structInputs( + struct: { zipCode: null, city: null }, + structOptional: { zipCode: null, city: null } + ) + }""" + let result = sync <| (schema Skip).AsyncExecute(parse query) + ensureDirect result <| fun data errors -> empty errors + +[] +let ``Execute handles validation of valid inline input records with null optional field`` () = + let query = + """{ + recordInputs( + record: { line2: null }, + recordOptional: { line2: null }, + recordNested: { homeAddress: { line2: null }, workAddress: { line2: null }, mailingAddress: { line1: "", line2: null } } + ) + objectInputs( + object: { line2: null }, + objectOptional: { line2: null } + ) + structInputs( + struct: { line2: null }, + structOptional: { line2: null } + ) + }""" + let result = sync <| (schema SkipAndIncludeNull).AsyncExecute(parse query) + ensureDirect result <| fun data errors -> empty errors From 60cbbc7cbc63480e2d66123b42748a2d73db1558 Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Sun, 8 Dec 2024 04:55:36 +0400 Subject: [PATCH 2/2] Implemented a sample demonstrating approach of patching an entity --- samples/star-wars-api/Schema.fs | 39 ++++++++++++++++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/samples/star-wars-api/Schema.fs b/samples/star-wars-api/Schema.fs index cb0403a66..ec34aaf2d 100644 --- a/samples/star-wars-api/Schema.fs +++ b/samples/star-wars-api/Schema.fs @@ -1,5 +1,6 @@ namespace FSharp.Data.GraphQL.Samples.StarWarsApi +open System.Text.Json.Serialization open FSharp.Data.GraphQL open FSharp.Data.GraphQL.Types open FSharp.Data.GraphQL.Server.Relay @@ -27,9 +28,15 @@ type Droid = AppearsIn : Episode list PrimaryFunction : string option } +type PatchPlanet = + { Name : string option Skippable + SatelitesCount : int Skippable + IsMoon : bool option Skippable } + type Planet = { Id : string - Name : string option + mutable Name : string option + mutable SatelitesCount : int mutable IsMoon : bool option } member x.SetMoon b = @@ -94,12 +101,15 @@ module Schema = let planets = [ { Id = "1" Name = Some "Tatooine" + SatelitesCount = 2 IsMoon = Some false} { Id = "2" Name = Some "Endor" + SatelitesCount = 1 IsMoon = Some true} { Id = "3" Name = Some "Death Star" + SatelitesCount = 0 IsMoon = Some false} ] let getHuman id = humans |> List.tryFind (fun h -> h.Id = id) @@ -228,10 +238,22 @@ module Schema = fields = [ Define.Field ("id", StringType, "The id of the planet", (fun _ p -> p.Id)) Define.Field ("name", Nullable StringType, "The name of the planet.", (fun _ p -> p.Name)) + Define.Field ("satelitesCount", IntType, "The number of satelites of the planet.", (fun _ p -> p.SatelitesCount)) Define.Field ("isMoon", Nullable BooleanType, "Is that a moon?", (fun _ p -> p.IsMoon)) ] ) + and PatchPlanetType = + Define.InputObject ( + name = "InputPatchPlanet", + description = "A planet in the Star Wars universe.", + fields = [ + Define.SkippableInput ("name", Nullable StringType) + Define.SkippableInput ("satelitesCount", IntType) + Define.SkippableInput ("isMoon", Nullable BooleanType) + ] + ) + and RootType = Define.Object ( name = "Root", @@ -299,6 +321,21 @@ module Schema = //).WithAuthorizationPolicies(Policies.CanSetMoon) // For build verification purposes ).WithAuthorizationPolicies(Policies.Dummy) + Define.Field( + "patchPlanet", + PlanetType, + [ Define.Input ("id", StringType); Define.Input ("planet", PatchPlanetType) ], + resolve = (fun ctx _ -> + match getPlanet (ctx.Arg ("id")) with + | None -> raise (GQLMessageException "Planet not found") + | Some planet -> + let patch = ctx.Arg "planet" + patch.Name |> Skippable.iter (fun n -> planet.Name <- n) + patch.SatelitesCount |> Skippable.iter (fun s -> planet.SatelitesCount <- s) + patch.IsMoon |> Skippable.iter (fun m -> planet.IsMoon <- m) + planet + ) + ) ] )