From e4be21afaf6bdc53c908c4d7b1f896429d2bfd68 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 17 May 2026 14:43:08 +0000 Subject: [PATCH 1/7] Enforce ListOf/Nullable wrapper kind safety and add tests Agent-Logs-Url: https://github.com/fsprojects/FSharp.Data.GraphQL/sessions/7e806543-4139-4145-940a-64322cdd35cd Co-authored-by: xperiandri <2365592+xperiandri@users.noreply.github.com> --- .../Connections.fs | 14 ++-- src/FSharp.Data.GraphQL.Server/Schema.fs | 4 +- .../Introspection.fs | 20 ++--- .../SchemaDefinitions.fs | 76 +++++++++++++++---- .../SchemaDefinitionsExtensions.fs | 4 +- src/FSharp.Data.GraphQL.Shared/TypeSystem.fs | 36 ++++++--- .../FSharp.Data.GraphQL.Tests.fsproj | 1 + .../PlanningTests.fs | 8 +- .../TypeWrappersKindSafetyTests.fs | 37 +++++++++ .../Variables and Inputs/InputComplexTests.fs | 2 +- .../Variables and Inputs/InputNestedTests.fs | 2 +- 11 files changed, 152 insertions(+), 52 deletions(-) create mode 100644 tests/FSharp.Data.GraphQL.Tests/TypeWrappersKindSafetyTests.fs diff --git a/src/FSharp.Data.GraphQL.Server.Relay/Connections.fs b/src/FSharp.Data.GraphQL.Server.Relay/Connections.fs index 369d6d3ca..84754a316 100644 --- a/src/FSharp.Data.GraphQL.Server.Relay/Connections.fs +++ b/src/FSharp.Data.GraphQL.Server.Relay/Connections.fs @@ -159,13 +159,13 @@ module Definitions = ) Define.AsyncField ( "startCursor", - Nullable StringType, + Nullable (StringType :> OutputDef), "When paginating backwards, the cursor to continue.", fun _ pageInfo -> pageInfo.StartCursor ) Define.AsyncField ( "endCursor", - Nullable StringType, + Nullable (StringType :> OutputDef), "When paginating forwards, the cursor to continue.", fun _ pageInfo -> pageInfo.EndCursor ) @@ -225,7 +225,7 @@ module Definitions = fields = [ Define.AsyncField ( "totalCount", - Nullable IntType, + Nullable (IntType :> OutputDef), """A count of the total number of objects in this connection, ignoring pagination. This allows a client to fetch the first five objects by passing \"5\" as the argument to `first`, then fetch the total count so it could display \"5 of 83\", for example. In cases where we employ infinite scrolling or don't have an exact count of entries, this field will return `null`.""", fun _ conn -> conn.TotalCount ) @@ -303,7 +303,9 @@ module Connection = /// /// /// - let forwardArgs = [ Define.Input ("first", Nullable IntType); Define.Input ("after", Nullable StringType) ] + let forwardArgs = + [ Define.Input ("first", Nullable (IntType :> InputDef)) + Define.Input ("after", Nullable (StringType :> InputDef)) ] /// /// Argument definitions for backward pagination ("last" and "before"). @@ -311,7 +313,9 @@ module Connection = /// /// /// - let backwardArgs = [ Define.Input ("last", Nullable IntType); Define.Input ("before", Nullable StringType) ] + let backwardArgs = + [ Define.Input ("last", Nullable (IntType :> InputDef)) + Define.Input ("before", Nullable (StringType :> InputDef)) ] /// /// Complete set of argument definitions for bidirectional pagination. diff --git a/src/FSharp.Data.GraphQL.Server/Schema.fs b/src/FSharp.Data.GraphQL.Server/Schema.fs index 67146948d..cf1d9c83e 100644 --- a/src/FSharp.Data.GraphQL.Server/Schema.fs +++ b/src/FSharp.Data.GraphQL.Server/Schema.fs @@ -152,14 +152,14 @@ type SchemaConfig = let args = [| Define.Input( "interval", - Nullable IntType, + Nullable (IntType :> InputDef), defaultValue = streamOptions.Interval, description = "An optional argument used to buffer stream results. " + "When it's value is greater than zero, stream results will be buffered for milliseconds equal to the value, then sent to the client. " + "After that, starts buffering again until all results are streamed.") Define.Input( "preferredBatchSize", - Nullable IntType, + Nullable (IntType :> InputDef), defaultValue = streamOptions.PreferredBatchSize, description = "An optional argument used to buffer stream results. " + "When it's value is greater than zero, stream results will be buffered until item count reaches this value, then sent to the client. " + diff --git a/src/FSharp.Data.GraphQL.Shared/Introspection.fs b/src/FSharp.Data.GraphQL.Shared/Introspection.fs index cefebc018..c2439066e 100644 --- a/src/FSharp.Data.GraphQL.Shared/Introspection.fs +++ b/src/FSharp.Data.GraphQL.Shared/Introspection.fs @@ -90,8 +90,8 @@ let rec __Type = fieldsFn = fun () -> [ Define.Field ("kind", __TypeKind, (fun _ t -> t.Kind)) - Define.Field ("name", Nullable StringType, resolve = (fun _ t -> t.Name)) - Define.Field ("description", Nullable StringType, resolve = (fun _ t -> t.Description)) + Define.Field ("name", Nullable (StringType :> OutputDef), resolve = (fun _ t -> t.Name)) + Define.Field ("description", Nullable (StringType :> OutputDef), resolve = (fun _ t -> t.Description)) Define.Field ( "fields", Nullable (ListOf __Field), @@ -173,9 +173,9 @@ and __InputValue = fieldsFn = fun () -> [ Define.Field ("name", StringType, resolve = (fun _ f -> f.Name)) - Define.Field ("description", Nullable StringType, resolve = (fun _ f -> f.Description)) + Define.Field ("description", Nullable (StringType :> OutputDef), resolve = (fun _ f -> f.Description)) Define.Field ("type", __Type, resolve = (fun _ f -> f.Type)) - Define.Field ("defaultValue", Nullable StringType, (fun _ f -> f.DefaultValue)) + Define.Field ("defaultValue", Nullable (StringType :> OutputDef), (fun _ f -> f.DefaultValue)) ] ) @@ -189,11 +189,11 @@ and __Field = fieldsFn = fun () -> [ Define.Field ("name", StringType, (fun _ f -> f.Name)) - Define.Field ("description", Nullable StringType, (fun _ f -> f.Description)) + Define.Field ("description", Nullable (StringType :> OutputDef), (fun _ f -> f.Description)) Define.Field ("args", ListOf __InputValue, (fun _ f -> f.Args)) Define.Field ("type", __Type, (fun _ f -> f.Type)) Define.Field ("isDeprecated", BooleanType, resolve = (fun _ f -> f.IsDeprecated)) - Define.Field ("deprecationReason", Nullable StringType, (fun _ f -> f.DeprecationReason)) + Define.Field ("deprecationReason", Nullable (StringType :> OutputDef), (fun _ f -> f.DeprecationReason)) ] ) @@ -208,9 +208,9 @@ and __EnumValue = fieldsFn = fun () -> [ Define.Field ("name", StringType, resolve = (fun _ e -> e.Name)) - Define.Field ("description", Nullable StringType, resolve = (fun _ e -> e.Description)) + Define.Field ("description", Nullable (StringType :> OutputDef), resolve = (fun _ e -> e.Description)) Define.Field ("isDeprecated", BooleanType, resolve = (fun _ e -> Option.isSome e.DeprecationReason)) - Define.Field ("deprecationReason", Nullable StringType, resolve = (fun _ e -> e.DeprecationReason)) + Define.Field ("deprecationReason", Nullable (StringType :> OutputDef), resolve = (fun _ e -> e.DeprecationReason)) ] ) @@ -231,8 +231,8 @@ and __Directive = fieldsFn = fun () -> [ Define.Field ("name", StringType, resolve = (fun _ directive -> directive.Name)) - Define.Field ("description", Nullable StringType, resolve = (fun _ directive -> directive.Description)) - Define.Field ("locations", ListOf __DirectiveLocation, resolve = (fun _ directive -> directive.Locations)) + Define.Field ("description", Nullable (StringType :> OutputDef), resolve = (fun _ directive -> directive.Description)) + Define.Field ("locations", ListOf (__DirectiveLocation :> OutputDef), resolve = (fun _ directive -> directive.Locations)) Define.Field ("args", ListOf __InputValue, resolve = (fun _ directive -> directive.Args)) Define.Field ( "onOperation", diff --git a/src/FSharp.Data.GraphQL.Shared/SchemaDefinitions.fs b/src/FSharp.Data.GraphQL.Shared/SchemaDefinitions.fs index 51f8682c5..40ac980d0 100644 --- a/src/FSharp.Data.GraphQL.Shared/SchemaDefinitions.fs +++ b/src/FSharp.Data.GraphQL.Shared/SchemaDefinitions.fs @@ -392,17 +392,63 @@ module SchemaDefinitions = | false, _ -> getParseError destinationType s | InlineConstant value -> value.GetCoerceError destinationType - /// Wraps a GraphQL type definition, allowing defining field/argument - /// to take option of provided value. - let Nullable(innerDef : #TypeDef<'Val>) : NullableDef<'Val> = upcast { NullableDefinition.OfType = innerDef } - - /// Wraps a GraphQL type definition, allowing defining field/argument - /// to take voption of provided value. - let StructNullable(innerDef : #TypeDef<'Val>) : StructNullableDef<'Val> = upcast { StructNullableDefinition.OfType = innerDef } - - /// Wraps a GraphQL type definition, allowing defining field/argument - /// to take collection of provided value. - let ListOf(innerDef : #TypeDef<'Val>) : ListOfDef<'Val, 'Seq> = upcast { ListOfDefinition.OfType = innerDef } + type TypeWrapperDispatch = + static member Nullable<'Val>(innerDef : InputOutputDef<'Val>) : NullableDef<'Val> = + let ofType : TypeDef<'Val> = upcast innerDef + upcast { NullableDefinition.OfType = ofType } + + static member Nullable<'Val>(innerDef : InputDef<'Val>) : InputDef<'Val option> = + let ofType : TypeDef<'Val> = upcast innerDef + upcast { NullableDefinition.OfType = ofType } + + static member Nullable<'Val>(innerDef : OutputDef<'Val>) : OutputDef<'Val option> = + let ofType : TypeDef<'Val> = upcast innerDef + upcast { NullableDefinition.OfType = ofType } + + static member StructNullable<'Val>(innerDef : InputOutputDef<'Val>) : StructNullableDef<'Val> = + let ofType : TypeDef<'Val> = upcast innerDef + upcast { StructNullableDefinition.OfType = ofType } + + static member StructNullable<'Val>(innerDef : InputDef<'Val>) : InputDef<'Val voption> = + let ofType : TypeDef<'Val> = upcast innerDef + upcast { StructNullableDefinition.OfType = ofType } + + static member StructNullable<'Val>(innerDef : OutputDef<'Val>) : OutputDef<'Val voption> = + let ofType : TypeDef<'Val> = upcast innerDef + upcast { StructNullableDefinition.OfType = ofType } + + static member ListOf<'Val, 'Seq when 'Seq :> 'Val seq>(innerDef : InputOutputDef<'Val>) : ListOfDef<'Val, 'Seq> = + let ofType : TypeDef<'Val> = upcast innerDef + upcast { ListOfDefinition.OfType = ofType } + + static member ListOf<'Val, 'Seq when 'Seq :> 'Val seq>(innerDef : InputDef<'Val>) : InputDef<'Seq> = + let ofType : TypeDef<'Val> = upcast innerDef + upcast { ListOfDefinition.OfType = ofType } + + static member ListOf<'Val, 'Seq when 'Seq :> 'Val seq>(innerDef : OutputDef<'Val>) : OutputDef<'Seq> = + let ofType : TypeDef<'Val> = upcast innerDef + upcast { ListOfDefinition.OfType = ofType } + + /// Wraps a GraphQL input or output type definition, allowing defining field/argument + /// to take option of provided value while preserving input/output kind of wrapped type. + let inline Nullable< ^Def, ^Wrapped when (^Def or TypeWrapperDispatch) : (static member Nullable : ^Def -> ^Wrapped) > + (innerDef : ^Def) + : ^Wrapped = + ((^Def or TypeWrapperDispatch) : (static member Nullable : ^Def -> ^Wrapped) innerDef) + + /// Wraps a GraphQL input or output type definition, allowing defining field/argument + /// to take voption of provided value while preserving input/output kind of wrapped type. + let inline StructNullable< ^Def, ^Wrapped when (^Def or TypeWrapperDispatch) : (static member StructNullable : ^Def -> ^Wrapped) > + (innerDef : ^Def) + : ^Wrapped = + ((^Def or TypeWrapperDispatch) : (static member StructNullable : ^Def -> ^Wrapped) innerDef) + + /// Wraps a GraphQL input or output type definition, allowing defining field/argument + /// to take collection of provided value while preserving input/output kind of wrapped type. + let inline ListOf< ^Def, ^Wrapped when (^Def or TypeWrapperDispatch) : (static member ListOf : ^Def -> ^Wrapped) > + (innerDef : ^Def) + : ^Wrapped = + ((^Def or TypeWrapperDispatch) : (static member ListOf : ^Def -> ^Wrapped) innerDef) let internal variableOrElse other (_ : InputExecutionContextProvider) value (variables : IReadOnlyDictionary) = match value with @@ -1415,13 +1461,14 @@ module SchemaDefinitions = /// 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 = + let typedef : InputDef<'In> = upcast typedef 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 + match (box typedef) with + | :? NullableDef<'In> as n -> (n :> InputDef<'In option>) + | _ -> Nullable typedef DefaultValue = None ExecuteInput = Unchecked.defaultof } @@ -1539,4 +1586,3 @@ module SchemaDefinitions = Description = description FieldsFn = fun () -> fieldsFn() |> List.toArray ResolveType = resolveType } - diff --git a/src/FSharp.Data.GraphQL.Shared/SchemaDefinitionsExtensions.fs b/src/FSharp.Data.GraphQL.Shared/SchemaDefinitionsExtensions.fs index b11909644..b2147c54c 100644 --- a/src/FSharp.Data.GraphQL.Shared/SchemaDefinitionsExtensions.fs +++ b/src/FSharp.Data.GraphQL.Shared/SchemaDefinitionsExtensions.fs @@ -27,8 +27,8 @@ type internal CustomFieldsObjectDefinition<'Val> (source : ObjectDef<'Val>, fiel member _.Implements = source.Implements member _.IsTypeOf = source.IsTypeOf interface TypeDef with - member this.MakeList () = upcast (ListOf this) - member this.MakeNullable () = upcast (Nullable this) + member this.MakeList () = upcast { ListOfDefinition.OfType = this } + member this.MakeNullable () = upcast { NullableDefinition.OfType = this } member _.Type = (source :> TypeDef).Type interface NamedDef with member _.Name = (source :> NamedDef).Name diff --git a/src/FSharp.Data.GraphQL.Shared/TypeSystem.fs b/src/FSharp.Data.GraphQL.Shared/TypeSystem.fs index 11006387b..41b2b95cb 100644 --- a/src/FSharp.Data.GraphQL.Shared/TypeSystem.fs +++ b/src/FSharp.Data.GraphQL.Shared/TypeSystem.fs @@ -609,6 +609,21 @@ and OutputDef<'Val> = inherit TypeDef<'Val> end +/// Representation of all type definitions, that can be used as both inputs and outputs. +and InputOutputDef = + interface + inherit InputDef + inherit OutputDef + end + +/// Representation of all type definitions, that can be used as both inputs and outputs. +and InputOutputDef<'Val> = + interface + inherit InputOutputDef + inherit InputDef<'Val> + inherit OutputDef<'Val> + end + /// Representation of leaf type definitions. Leaf types represents leafs /// of the GraphQL query tree. Each query path must end with a leaf. /// By default only scalars and enums are valid leaf types. @@ -1067,8 +1082,7 @@ and ScalarDef = abstract CoerceOutput : obj -> obj option inherit TypeDef inherit NamedDef - inherit InputDef - inherit OutputDef + inherit InputOutputDef inherit LeafDef end @@ -1098,6 +1112,7 @@ and [] ScalarDefinition<'Primitive, 'Val> = { interface InputDef interface OutputDef + interface InputOutputDef interface ScalarDef with member x.Name = x.Name @@ -1107,6 +1122,7 @@ and [] ScalarDefinition<'Primitive, 'Val> = { interface InputDef<'Val> interface OutputDef<'Val> + interface InputOutputDef<'Val> interface LeafDef interface NamedDef with @@ -1182,8 +1198,7 @@ and EnumDef = /// List of available enum cases. abstract Options : EnumVal[] inherit TypeDef - inherit InputDef - inherit OutputDef + inherit InputOutputDef inherit LeafDef inherit NamedDef end @@ -1197,8 +1212,7 @@ and EnumDef<'Val> = abstract Options : EnumValue<'Val>[] inherit EnumDef inherit TypeDef<'Val> - inherit InputDef<'Val> - inherit OutputDef<'Val> + inherit InputOutputDef<'Val> end and internal EnumDefinition<'Val> = { @@ -1212,6 +1226,7 @@ and internal EnumDefinition<'Val> = { interface InputDef interface OutputDef + interface InputOutputDef interface TypeDef with member _.Type = typeof<'Val> @@ -1523,8 +1538,7 @@ and ListOfDef<'Val, 'Seq when 'Seq :> 'Val seq> = /// GraphQL type definition of the container element type. abstract OfType : TypeDef<'Val> inherit TypeDef<'Seq> - inherit InputDef<'Seq> - inherit OutputDef<'Seq> + inherit InputOutputDef<'Seq> inherit ListOfDef end @@ -1574,8 +1588,7 @@ and NullableDef<'Val> = interface /// GraphQL type definition of the nested type. abstract OfType : TypeDef<'Val> - inherit InputDef<'Val option> - inherit OutputDef<'Val option> + inherit InputOutputDef<'Val option> inherit NullableDef end @@ -1614,8 +1627,7 @@ and StructNullableDef<'Val> = interface /// GraphQL type definition of the nested type. abstract OfType : TypeDef<'Val> - inherit InputDef<'Val voption> - inherit OutputDef<'Val voption> + inherit InputOutputDef<'Val voption> inherit NullableDef end 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 763d841ec..a543c66b8 100644 --- a/tests/FSharp.Data.GraphQL.Tests/FSharp.Data.GraphQL.Tests.fsproj +++ b/tests/FSharp.Data.GraphQL.Tests/FSharp.Data.GraphQL.Tests.fsproj @@ -41,6 +41,7 @@ + diff --git a/tests/FSharp.Data.GraphQL.Tests/PlanningTests.fs b/tests/FSharp.Data.GraphQL.Tests/PlanningTests.fs index 57b330da4..ad7cf74d5 100644 --- a/tests/FSharp.Data.GraphQL.Tests/PlanningTests.fs +++ b/tests/FSharp.Data.GraphQL.Tests/PlanningTests.fs @@ -139,7 +139,7 @@ let ``Planning must retain correct types for lists``() = } } }""" - let PersonList : ListOfDef = ListOf Person + let PersonList : OutputDef = ListOf Person let plan = schemaProcessor.CreateExecutionPlanOrFail(query) equals 1 plan.Fields.Length let listInfo = plan.Fields.Head @@ -178,7 +178,7 @@ let ``Planning must work with interfaces``() = }""" let plan = schemaProcessor.CreateExecutionPlanOrFail(query) equals 1 plan.Fields.Length - let INamedList : ListOfDef = ListOf INamed + let INamedList : OutputDef = ListOf INamed let listInfo = plan.Fields.Head listInfo.Identifier |> equals "names" listInfo.ReturnDef |> equals (upcast INamedList) @@ -215,7 +215,7 @@ let ``Planning must work with unions``() = let plan = schemaProcessor.CreateExecutionPlanOrFail(query) equals 1 plan.Fields.Length let listInfo = plan.Fields.Head - let UNamedList : ListOfDef = ListOf UNamed + let UNamedList : OutputDef = ListOf UNamed listInfo.Identifier |> equals "names" listInfo.ReturnDef |> equals (upcast UNamedList) let (ResolveCollection(info)) = listInfo.Kind @@ -309,7 +309,7 @@ let ``Planning must handle inline fragment with non-matching type condition in u // Verify the execution plan structure equals 1 plan.Fields.Length let listInfo = plan.Fields.Head - let UNamedList : ListOfDef = ListOf UNamed + let UNamedList : OutputDef = ListOf UNamed listInfo.Identifier |> equals "names" listInfo.ReturnDef |> equals (upcast UNamedList) let (ResolveCollection(info)) = listInfo.Kind diff --git a/tests/FSharp.Data.GraphQL.Tests/TypeWrappersKindSafetyTests.fs b/tests/FSharp.Data.GraphQL.Tests/TypeWrappersKindSafetyTests.fs new file mode 100644 index 000000000..f8550ad55 --- /dev/null +++ b/tests/FSharp.Data.GraphQL.Tests/TypeWrappersKindSafetyTests.fs @@ -0,0 +1,37 @@ +module FSharp.Data.GraphQL.Tests.TypeWrappersKindSafetyTests + +open FSharp.Data.GraphQL.Types +open Xunit + +type private InputOnly = { Value : int } +type private OutputOnly = { Value : int } + +let private InputOnlyType = + Define.InputObject( + name = "InputOnlyType", + fields = [ Define.Input("value", IntType) ] + ) + +let private OutputOnlyType = + Define.Object( + name = "OutputOnlyType", + fields = [ Define.Field("value", IntType, fun _ x -> x.Value) ] + ) + +[] +let ``ListOf keeps input-output direction`` () = + let _ : InputDef = ListOf InputOnlyType + let _ : OutputDef = ListOf OutputOnlyType + Assert.True true + +[] +let ``Nullable keeps input-output direction`` () = + let _ : InputDef = Nullable InputOnlyType + let _ : OutputDef = Nullable OutputOnlyType + Assert.True true + +[] +let ``StructNullable keeps input-output direction`` () = + let _ : InputDef = StructNullable InputOnlyType + let _ : OutputDef = StructNullable OutputOnlyType + Assert.True true diff --git a/tests/FSharp.Data.GraphQL.Tests/Variables and Inputs/InputComplexTests.fs b/tests/FSharp.Data.GraphQL.Tests/Variables and Inputs/InputComplexTests.fs index 954ace7ec..1e7053574 100644 --- a/tests/FSharp.Data.GraphQL.Tests/Variables and Inputs/InputComplexTests.fs +++ b/tests/FSharp.Data.GraphQL.Tests/Variables and Inputs/InputComplexTests.fs @@ -46,7 +46,7 @@ type TestInput = optArr : string option array option voptArr : string option array voption } // string voption array voption is too hard to implement -let InputArrayOf (innerDef : #TypeDef<'Val>) : ListOfDef<'Val, 'Val array> = ListOf innerDef +let InputArrayOf (innerDef : #InputDef<'Val>) : InputDef<'Val array> = ListOf (innerDef :> InputDef<'Val>) let TestInputObject = Define.InputObject ( diff --git a/tests/FSharp.Data.GraphQL.Tests/Variables and Inputs/InputNestedTests.fs b/tests/FSharp.Data.GraphQL.Tests/Variables and Inputs/InputNestedTests.fs index 69fc2b825..de5730092 100644 --- a/tests/FSharp.Data.GraphQL.Tests/Variables and Inputs/InputNestedTests.fs +++ b/tests/FSharp.Data.GraphQL.Tests/Variables and Inputs/InputNestedTests.fs @@ -14,7 +14,7 @@ open FSharp.Data.GraphQL.Types open FSharp.Data.GraphQL.Parser open FSharp.Data.GraphQL.Shared -let InputArrayOf (innerDef : #TypeDef<'Val>) : ListOfDef<'Val, 'Val array> = ListOf innerDef +let InputArrayOf (innerDef : #InputDef<'Val>) : InputDef<'Val array> = ListOf (innerDef :> InputDef<'Val>) let TestInputObject = InputComplexTests.TestInputObject From c0f5f02b5309a3b03e6a8700fc431e1bb188b33b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 17 May 2026 14:59:08 +0000 Subject: [PATCH 2/7] Document SRTP wrapper dispatch and finalize type safety changes Agent-Logs-Url: https://github.com/fsprojects/FSharp.Data.GraphQL/sessions/7e806543-4139-4145-940a-64322cdd35cd Co-authored-by: xperiandri <2365592+xperiandri@users.noreply.github.com> --- .../SchemaDefinitions.fs | 23 +++++++++++++------ .../SchemaDefinitionsExtensions.fs | 2 ++ src/FSharp.Data.GraphQL.Shared/TypeSystem.fs | 7 ++++-- .../TypeWrappersKindSafetyTests.fs | 21 +++++++++-------- 4 files changed, 35 insertions(+), 18 deletions(-) diff --git a/src/FSharp.Data.GraphQL.Shared/SchemaDefinitions.fs b/src/FSharp.Data.GraphQL.Shared/SchemaDefinitions.fs index 40ac980d0..7eb9cf462 100644 --- a/src/FSharp.Data.GraphQL.Shared/SchemaDefinitions.fs +++ b/src/FSharp.Data.GraphQL.Shared/SchemaDefinitions.fs @@ -392,7 +392,7 @@ module SchemaDefinitions = | false, _ -> getParseError destinationType s | InlineConstant value -> value.GetCoerceError destinationType - type TypeWrapperDispatch = + type TypeWrapperStaticDispatch = static member Nullable<'Val>(innerDef : InputOutputDef<'Val>) : NullableDef<'Val> = let ofType : TypeDef<'Val> = upcast innerDef upcast { NullableDefinition.OfType = ofType } @@ -431,24 +431,33 @@ module SchemaDefinitions = /// Wraps a GraphQL input or output type definition, allowing defining field/argument /// to take option of provided value while preserving input/output kind of wrapped type. - let inline Nullable< ^Def, ^Wrapped when (^Def or TypeWrapperDispatch) : (static member Nullable : ^Def -> ^Wrapped) > + /// Input wrappers produce input definitions, output wrappers produce output definitions, + /// and wrappers over types implementing both kinds keep both capabilities. + /// Dispatch is selected at compile time via SRTP. + let inline Nullable< ^Def, ^Wrapped when (^Def or TypeWrapperStaticDispatch) : (static member Nullable : ^Def -> ^Wrapped) > (innerDef : ^Def) : ^Wrapped = - ((^Def or TypeWrapperDispatch) : (static member Nullable : ^Def -> ^Wrapped) innerDef) + ((^Def or TypeWrapperStaticDispatch) : (static member Nullable : ^Def -> ^Wrapped) innerDef) /// Wraps a GraphQL input or output type definition, allowing defining field/argument /// to take voption of provided value while preserving input/output kind of wrapped type. - let inline StructNullable< ^Def, ^Wrapped when (^Def or TypeWrapperDispatch) : (static member StructNullable : ^Def -> ^Wrapped) > + /// Input wrappers produce input definitions, output wrappers produce output definitions, + /// and wrappers over types implementing both kinds keep both capabilities. + /// Dispatch is selected at compile time via SRTP. + let inline StructNullable< ^Def, ^Wrapped when (^Def or TypeWrapperStaticDispatch) : (static member StructNullable : ^Def -> ^Wrapped) > (innerDef : ^Def) : ^Wrapped = - ((^Def or TypeWrapperDispatch) : (static member StructNullable : ^Def -> ^Wrapped) innerDef) + ((^Def or TypeWrapperStaticDispatch) : (static member StructNullable : ^Def -> ^Wrapped) innerDef) /// Wraps a GraphQL input or output type definition, allowing defining field/argument /// to take collection of provided value while preserving input/output kind of wrapped type. - let inline ListOf< ^Def, ^Wrapped when (^Def or TypeWrapperDispatch) : (static member ListOf : ^Def -> ^Wrapped) > + /// Input wrappers produce input definitions, output wrappers produce output definitions, + /// and wrappers over types implementing both kinds keep both capabilities. + /// Dispatch is selected at compile time via SRTP. + let inline ListOf< ^Def, ^Wrapped when (^Def or TypeWrapperStaticDispatch) : (static member ListOf : ^Def -> ^Wrapped) > (innerDef : ^Def) : ^Wrapped = - ((^Def or TypeWrapperDispatch) : (static member ListOf : ^Def -> ^Wrapped) innerDef) + ((^Def or TypeWrapperStaticDispatch) : (static member ListOf : ^Def -> ^Wrapped) innerDef) let internal variableOrElse other (_ : InputExecutionContextProvider) value (variables : IReadOnlyDictionary) = match value with diff --git a/src/FSharp.Data.GraphQL.Shared/SchemaDefinitionsExtensions.fs b/src/FSharp.Data.GraphQL.Shared/SchemaDefinitionsExtensions.fs index b2147c54c..1da3341c7 100644 --- a/src/FSharp.Data.GraphQL.Shared/SchemaDefinitionsExtensions.fs +++ b/src/FSharp.Data.GraphQL.Shared/SchemaDefinitionsExtensions.fs @@ -27,6 +27,8 @@ type internal CustomFieldsObjectDefinition<'Val> (source : ObjectDef<'Val>, fiel member _.Implements = source.Implements member _.IsTypeOf = source.IsTypeOf interface TypeDef with + // We construct wrappers directly here because this API works with untyped TypeDef values. + // The public ListOf/Nullable helpers use SRTP dispatch and require statically known direction. member this.MakeList () = upcast { ListOfDefinition.OfType = this } member this.MakeNullable () = upcast { NullableDefinition.OfType = this } member _.Type = (source :> TypeDef).Type diff --git a/src/FSharp.Data.GraphQL.Shared/TypeSystem.fs b/src/FSharp.Data.GraphQL.Shared/TypeSystem.fs index 41b2b95cb..be8e79b55 100644 --- a/src/FSharp.Data.GraphQL.Shared/TypeSystem.fs +++ b/src/FSharp.Data.GraphQL.Shared/TypeSystem.fs @@ -609,17 +609,20 @@ and OutputDef<'Val> = inherit TypeDef<'Val> end -/// Representation of all type definitions, that can be used as both inputs and outputs. +/// Representation of type definitions that can be used as both inputs and outputs +/// (for example scalars and enums). This marker is also used by SRTP wrapper dispatch. and InputOutputDef = interface inherit InputDef inherit OutputDef end -/// Representation of all type definitions, that can be used as both inputs and outputs. +/// Representation of all type definitions, that can be used as both inputs and outputs +/// and are constrained to represent the provided .NET type. and InputOutputDef<'Val> = interface inherit InputOutputDef + inherit TypeDef<'Val> inherit InputDef<'Val> inherit OutputDef<'Val> end diff --git a/tests/FSharp.Data.GraphQL.Tests/TypeWrappersKindSafetyTests.fs b/tests/FSharp.Data.GraphQL.Tests/TypeWrappersKindSafetyTests.fs index f8550ad55..d7e5cdbb3 100644 --- a/tests/FSharp.Data.GraphQL.Tests/TypeWrappersKindSafetyTests.fs +++ b/tests/FSharp.Data.GraphQL.Tests/TypeWrappersKindSafetyTests.fs @@ -20,18 +20,21 @@ let private OutputOnlyType = [] let ``ListOf keeps input-output direction`` () = - let _ : InputDef = ListOf InputOnlyType - let _ : OutputDef = ListOf OutputOnlyType - Assert.True true + let inputList : InputDef = ListOf InputOnlyType + let outputList : OutputDef = ListOf OutputOnlyType + Assert.Equal ("[InputOnlyType!]!", inputList.ToString ()) + Assert.Equal ("[OutputOnlyType!]!", outputList.ToString ()) [] let ``Nullable keeps input-output direction`` () = - let _ : InputDef = Nullable InputOnlyType - let _ : OutputDef = Nullable OutputOnlyType - Assert.True true + let nullableInput : InputDef = Nullable InputOnlyType + let nullableOutput : OutputDef = Nullable OutputOnlyType + Assert.Equal ("InputOnlyType", nullableInput.ToString ()) + Assert.Equal ("OutputOnlyType", nullableOutput.ToString ()) [] let ``StructNullable keeps input-output direction`` () = - let _ : InputDef = StructNullable InputOnlyType - let _ : OutputDef = StructNullable OutputOnlyType - Assert.True true + let nullableInput : InputDef = StructNullable InputOnlyType + let nullableOutput : OutputDef = StructNullable OutputOnlyType + Assert.Equal ("InputOnlyType", nullableInput.ToString ()) + Assert.Equal ("OutputOnlyType", nullableOutput.ToString ()) From b9ec6a96885e9185b77ee8cbcfa6d145300b2226 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 17 May 2026 15:08:52 +0000 Subject: [PATCH 3/7] Use typed helpers to infer Nullable wrapper direction in Relay connections Agent-Logs-Url: https://github.com/fsprojects/FSharp.Data.GraphQL/sessions/1a91a357-4922-4da3-92d3-9ffe65b3cb73 Co-authored-by: xperiandri <2365592+xperiandri@users.noreply.github.com> --- .../Connections.fs | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/src/FSharp.Data.GraphQL.Server.Relay/Connections.fs b/src/FSharp.Data.GraphQL.Server.Relay/Connections.fs index 84754a316..cb353cc54 100644 --- a/src/FSharp.Data.GraphQL.Server.Relay/Connections.fs +++ b/src/FSharp.Data.GraphQL.Server.Relay/Connections.fs @@ -114,6 +114,8 @@ type SliceInfo<'Cursor> = [] module Definitions = + let inline private nullableOutput (innerDef : #OutputDef<'Val>) : OutputDef<'Val option> = + Nullable (innerDef :> OutputDef<'Val>) /// /// Active pattern that extracts Relay pagination arguments from a GraphQL field context. @@ -159,13 +161,13 @@ module Definitions = ) Define.AsyncField ( "startCursor", - Nullable (StringType :> OutputDef), + nullableOutput StringType, "When paginating backwards, the cursor to continue.", fun _ pageInfo -> pageInfo.StartCursor ) Define.AsyncField ( "endCursor", - Nullable (StringType :> OutputDef), + nullableOutput StringType, "When paginating forwards, the cursor to continue.", fun _ pageInfo -> pageInfo.EndCursor ) @@ -225,7 +227,7 @@ module Definitions = fields = [ Define.AsyncField ( "totalCount", - Nullable (IntType :> OutputDef), + nullableOutput IntType, """A count of the total number of objects in this connection, ignoring pagination. This allows a client to fetch the first five objects by passing \"5\" as the argument to `first`, then fetch the total count so it could display \"5 of 83\", for example. In cases where we employ infinite scrolling or don't have an exact count of entries, this field will return `null`.""", fun _ conn -> conn.TotalCount ) @@ -276,6 +278,8 @@ module Edge = [] module Connection = + let inline private nullableInput (innerDef : #InputDef<'Val>) : InputDef<'Val option> = + Nullable (innerDef :> InputDef<'Val>) /// /// Transforms a into a @@ -304,8 +308,8 @@ module Connection = /// /// let forwardArgs = - [ Define.Input ("first", Nullable (IntType :> InputDef)) - Define.Input ("after", Nullable (StringType :> InputDef)) ] + [ Define.Input ("first", nullableInput IntType) + Define.Input ("after", nullableInput StringType) ] /// /// Argument definitions for backward pagination ("last" and "before"). @@ -314,8 +318,8 @@ module Connection = /// /// let backwardArgs = - [ Define.Input ("last", Nullable (IntType :> InputDef)) - Define.Input ("before", Nullable (StringType :> InputDef)) ] + [ Define.Input ("last", nullableInput IntType) + Define.Input ("before", nullableInput StringType) ] /// /// Complete set of argument definitions for bidirectional pagination. From 3d9a9c6218800808aa698a2b116256ac09256438 Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Sun, 17 May 2026 19:24:01 +0200 Subject: [PATCH 4/7] Add type wrapper kind safety tests using F# scripts Introduce a new test suite to verify compile-time enforcement of input/output directionality for ListOf, Nullable, and StructNullable wrappers. Replace previous inline tests with F# script-based checks that are executed via dotnet fsi. Update the test project to include these scripts as content files and ensure References.fsx is managed and ignored appropriately. --- .../FSharp.Data.GraphQL.Tests.fsproj | 10 +- .../TypeWrappersKindSafety/.gitignore | 1 + .../ListOf.InputAsOutput.fsx | 15 ++ .../ListOf.OutputAsInput.fsx | 15 ++ .../Nullable.InputAsOutput.fsx | 15 ++ .../Nullable.OutputAsInput.fsx | 15 ++ .../StructNullable.InputAsOutput.fsx | 15 ++ .../StructNullable.OutputAsInput.fsx | 15 ++ .../TypeWrappersKindSafety/Valid.fsx | 26 +++ .../TypeWrappersKindSafetyTests.fs | 162 ++++++++++++++---- 10 files changed, 259 insertions(+), 30 deletions(-) create mode 100644 tests/FSharp.Data.GraphQL.Tests/TypeWrappersKindSafety/.gitignore create mode 100644 tests/FSharp.Data.GraphQL.Tests/TypeWrappersKindSafety/ListOf.InputAsOutput.fsx create mode 100644 tests/FSharp.Data.GraphQL.Tests/TypeWrappersKindSafety/ListOf.OutputAsInput.fsx create mode 100644 tests/FSharp.Data.GraphQL.Tests/TypeWrappersKindSafety/Nullable.InputAsOutput.fsx create mode 100644 tests/FSharp.Data.GraphQL.Tests/TypeWrappersKindSafety/Nullable.OutputAsInput.fsx create mode 100644 tests/FSharp.Data.GraphQL.Tests/TypeWrappersKindSafety/StructNullable.InputAsOutput.fsx create mode 100644 tests/FSharp.Data.GraphQL.Tests/TypeWrappersKindSafety/StructNullable.OutputAsInput.fsx create mode 100644 tests/FSharp.Data.GraphQL.Tests/TypeWrappersKindSafety/Valid.fsx 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 a543c66b8..2a4e49b3e 100644 --- a/tests/FSharp.Data.GraphQL.Tests/FSharp.Data.GraphQL.Tests.fsproj +++ b/tests/FSharp.Data.GraphQL.Tests/FSharp.Data.GraphQL.Tests.fsproj @@ -38,10 +38,18 @@ + - + + + + + + + + diff --git a/tests/FSharp.Data.GraphQL.Tests/TypeWrappersKindSafety/.gitignore b/tests/FSharp.Data.GraphQL.Tests/TypeWrappersKindSafety/.gitignore new file mode 100644 index 000000000..a6b4595a5 --- /dev/null +++ b/tests/FSharp.Data.GraphQL.Tests/TypeWrappersKindSafety/.gitignore @@ -0,0 +1 @@ +References.fsx diff --git a/tests/FSharp.Data.GraphQL.Tests/TypeWrappersKindSafety/ListOf.InputAsOutput.fsx b/tests/FSharp.Data.GraphQL.Tests/TypeWrappersKindSafety/ListOf.InputAsOutput.fsx new file mode 100644 index 000000000..c13f43223 --- /dev/null +++ b/tests/FSharp.Data.GraphQL.Tests/TypeWrappersKindSafety/ListOf.InputAsOutput.fsx @@ -0,0 +1,15 @@ +#load "References.fsx" + +open FSharp.Data.GraphQL.Types + +type InputOnly = { Value: int } +type OutputOnly = { Value: int } + +let inputOnlyType = + Define.InputObject( + name = "InputOnlyType", + fields = [ Define.Input("value", IntType) ] + ) + +// This should fail: InputDef cannot be assigned to OutputDef +let _ : OutputDef = ListOf inputOnlyType diff --git a/tests/FSharp.Data.GraphQL.Tests/TypeWrappersKindSafety/ListOf.OutputAsInput.fsx b/tests/FSharp.Data.GraphQL.Tests/TypeWrappersKindSafety/ListOf.OutputAsInput.fsx new file mode 100644 index 000000000..00849d1db --- /dev/null +++ b/tests/FSharp.Data.GraphQL.Tests/TypeWrappersKindSafety/ListOf.OutputAsInput.fsx @@ -0,0 +1,15 @@ +#load "References.fsx" + +open FSharp.Data.GraphQL.Types + +type InputOnly = { Value: int } +type OutputOnly = { Value: int } + +let outputOnlyType = + Define.Object( + name = "OutputOnlyType", + fields = [ Define.Field("value", IntType, fun _ x -> x.Value) ] + ) + +// This should fail: OutputDef cannot be assigned to InputDef +let _ : InputDef = ListOf outputOnlyType diff --git a/tests/FSharp.Data.GraphQL.Tests/TypeWrappersKindSafety/Nullable.InputAsOutput.fsx b/tests/FSharp.Data.GraphQL.Tests/TypeWrappersKindSafety/Nullable.InputAsOutput.fsx new file mode 100644 index 000000000..f94d9e297 --- /dev/null +++ b/tests/FSharp.Data.GraphQL.Tests/TypeWrappersKindSafety/Nullable.InputAsOutput.fsx @@ -0,0 +1,15 @@ +#load "References.fsx" + +open FSharp.Data.GraphQL.Types + +type InputOnly = { Value: int } +type OutputOnly = { Value: int } + +let inputOnlyType = + Define.InputObject( + name = "InputOnlyType", + fields = [ Define.Input("value", IntType) ] + ) + +// This should fail: InputDef cannot be assigned to OutputDef +let _ : OutputDef = Nullable inputOnlyType diff --git a/tests/FSharp.Data.GraphQL.Tests/TypeWrappersKindSafety/Nullable.OutputAsInput.fsx b/tests/FSharp.Data.GraphQL.Tests/TypeWrappersKindSafety/Nullable.OutputAsInput.fsx new file mode 100644 index 000000000..65a876354 --- /dev/null +++ b/tests/FSharp.Data.GraphQL.Tests/TypeWrappersKindSafety/Nullable.OutputAsInput.fsx @@ -0,0 +1,15 @@ +#load "References.fsx" + +open FSharp.Data.GraphQL.Types + +type InputOnly = { Value: int } +type OutputOnly = { Value: int } + +let outputOnlyType = + Define.Object( + name = "OutputOnlyType", + fields = [ Define.Field("value", IntType, fun _ x -> x.Value) ] + ) + +// This should fail: OutputDef cannot be assigned to InputDef +let _ : InputDef = Nullable outputOnlyType diff --git a/tests/FSharp.Data.GraphQL.Tests/TypeWrappersKindSafety/StructNullable.InputAsOutput.fsx b/tests/FSharp.Data.GraphQL.Tests/TypeWrappersKindSafety/StructNullable.InputAsOutput.fsx new file mode 100644 index 000000000..ffd651eaf --- /dev/null +++ b/tests/FSharp.Data.GraphQL.Tests/TypeWrappersKindSafety/StructNullable.InputAsOutput.fsx @@ -0,0 +1,15 @@ +#load "References.fsx" + +open FSharp.Data.GraphQL.Types + +type InputOnly = { Value: int } +type OutputOnly = { Value: int } + +let inputOnlyType = + Define.InputObject( + name = "InputOnlyType", + fields = [ Define.Input("value", IntType) ] + ) + +// This should fail: InputDef cannot be assigned to OutputDef +let _ : OutputDef = StructNullable inputOnlyType diff --git a/tests/FSharp.Data.GraphQL.Tests/TypeWrappersKindSafety/StructNullable.OutputAsInput.fsx b/tests/FSharp.Data.GraphQL.Tests/TypeWrappersKindSafety/StructNullable.OutputAsInput.fsx new file mode 100644 index 000000000..6ec0f4950 --- /dev/null +++ b/tests/FSharp.Data.GraphQL.Tests/TypeWrappersKindSafety/StructNullable.OutputAsInput.fsx @@ -0,0 +1,15 @@ +#load "References.fsx" + +open FSharp.Data.GraphQL.Types + +type InputOnly = { Value: int } +type OutputOnly = { Value: int } + +let outputOnlyType = + Define.Object( + name = "OutputOnlyType", + fields = [ Define.Field("value", IntType, fun _ x -> x.Value) ] + ) + +// This should fail: OutputDef cannot be assigned to InputDef +let _ : InputDef = StructNullable outputOnlyType diff --git a/tests/FSharp.Data.GraphQL.Tests/TypeWrappersKindSafety/Valid.fsx b/tests/FSharp.Data.GraphQL.Tests/TypeWrappersKindSafety/Valid.fsx new file mode 100644 index 000000000..74c56dce3 --- /dev/null +++ b/tests/FSharp.Data.GraphQL.Tests/TypeWrappersKindSafety/Valid.fsx @@ -0,0 +1,26 @@ +#load "References.fsx" + +open FSharp.Data.GraphQL.Types + +type InputOnly = { Value: int } +type OutputOnly = { Value: int } + +let inputOnlyType = + Define.InputObject( + name = "InputOnlyType", + fields = [ Define.Input("value", IntType) ] + ) + +let outputOnlyType = + Define.Object( + name = "OutputOnlyType", + fields = [ Define.Field("value", IntType, fun _ x -> x.Value) ] + ) + +// These are all valid assignments and must compile successfully +let _inputList : InputDef = ListOf inputOnlyType +let _outputList : OutputDef = ListOf outputOnlyType +let _inputNullable : InputDef = Nullable inputOnlyType +let _outputNullable : OutputDef = Nullable outputOnlyType +let _inputStruct : InputDef = StructNullable inputOnlyType +let _outputStruct : OutputDef = StructNullable outputOnlyType diff --git a/tests/FSharp.Data.GraphQL.Tests/TypeWrappersKindSafetyTests.fs b/tests/FSharp.Data.GraphQL.Tests/TypeWrappersKindSafetyTests.fs index d7e5cdbb3..be458cbc8 100644 --- a/tests/FSharp.Data.GraphQL.Tests/TypeWrappersKindSafetyTests.fs +++ b/tests/FSharp.Data.GraphQL.Tests/TypeWrappersKindSafetyTests.fs @@ -1,5 +1,8 @@ module FSharp.Data.GraphQL.Tests.TypeWrappersKindSafetyTests +open System +open System.Diagnostics +open System.Threading.Tasks open FSharp.Data.GraphQL.Types open Xunit @@ -7,34 +10,135 @@ type private InputOnly = { Value : int } type private OutputOnly = { Value : int } let private InputOnlyType = - Define.InputObject( - name = "InputOnlyType", - fields = [ Define.Input("value", IntType) ] - ) + Define.InputObject (name = "InputOnlyType", fields = [ Define.Input ("value", IntType) ]) let private OutputOnlyType = - Define.Object( - name = "OutputOnlyType", - fields = [ Define.Field("value", IntType, fun _ x -> x.Value) ] - ) - -[] -let ``ListOf keeps input-output direction`` () = - let inputList : InputDef = ListOf InputOnlyType - let outputList : OutputDef = ListOf OutputOnlyType - Assert.Equal ("[InputOnlyType!]!", inputList.ToString ()) - Assert.Equal ("[OutputOnlyType!]!", outputList.ToString ()) - -[] -let ``Nullable keeps input-output direction`` () = - let nullableInput : InputDef = Nullable InputOnlyType - let nullableOutput : OutputDef = Nullable OutputOnlyType - Assert.Equal ("InputOnlyType", nullableInput.ToString ()) - Assert.Equal ("OutputOnlyType", nullableOutput.ToString ()) - -[] -let ``StructNullable keeps input-output direction`` () = - let nullableInput : InputDef = StructNullable InputOnlyType - let nullableOutput : OutputDef = StructNullable OutputOnlyType - Assert.Equal ("InputOnlyType", nullableInput.ToString ()) - Assert.Equal ("OutputOnlyType", nullableOutput.ToString ()) + Define.Object (name = "OutputOnlyType", fields = [ Define.Field ("value", IntType, fun _ x -> x.Value) ]) + +type TypeWrappersKindSafetyFixture () = + + let scriptsDir = IO.Path.Combine (AppContext.BaseDirectory, "TypeWrappersKindSafety") + let referencesPath = IO.Path.Combine (scriptsDir, "References.fsx") + let sourceProjectDir = + IO.Path.GetFullPath (IO.Path.Combine (AppContext.BaseDirectory, "..", "..", "..")) + let sourceScriptsDir = IO.Path.Combine (sourceProjectDir, "TypeWrappersKindSafety") + let sourceReferencesPath = IO.Path.Combine (sourceScriptsDir, "References.fsx") + + let ensureFileContentAsync (path : string) (content : string) : Task = task { + if IO.File.Exists (path) then + let! existing = IO.File.ReadAllTextAsync (path) + + if not (String.Equals (existing, content, StringComparison.Ordinal)) then + do! IO.File.WriteAllTextAsync (path, content) + else + do! IO.File.WriteAllTextAsync (path, content) + } + + member _.ScriptPath (name : string) = IO.Path.Combine (scriptsDir, name) + + member _.RunFsiCheckAsync (scriptPath : string) : Task = task { + let psi = + ProcessStartInfo ( + "dotnet", + sprintf "fsi --noninteractive \"%s\"" scriptPath, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + WorkingDirectory = AppContext.BaseDirectory + ) + + use proc = Process.Start (psi) + do! proc.WaitForExitAsync () + return proc.ExitCode + } + + member private _.ReferencesContent = + let sharedAssembly = IO.Path.Combine (AppContext.BaseDirectory, "FSharp.Data.GraphQL.Shared.dll") + let serverAssembly = IO.Path.Combine (AppContext.BaseDirectory, "FSharp.Data.GraphQL.Server.dll") + + [ sprintf "#r @\"%s\"" sharedAssembly; sprintf "#r @\"%s\"" serverAssembly ] + |> String.concat "\n" + + interface IAsyncLifetime with + + member this.InitializeAsync () : Task = + task { + IO.Directory.CreateDirectory (scriptsDir) |> ignore + IO.Directory.CreateDirectory (sourceScriptsDir) |> ignore + + let content = this.ReferencesContent + + do! ensureFileContentAsync referencesPath content + do! ensureFileContentAsync sourceReferencesPath content + } + + member _.DisposeAsync () = Task.CompletedTask + +type TypeWrappersKindSafetyTests (fixture : TypeWrappersKindSafetyFixture) = + interface IClassFixture + + [] + member _.``ListOf keeps input-output direction`` () : Task = task { + let inputList : InputDef = ListOf InputOnlyType + let outputList : OutputDef = ListOf OutputOnlyType + Assert.Equal ("[InputOnlyType!]!", inputList.ToString ()) + Assert.Equal ("[OutputOnlyType!]!", outputList.ToString ()) + } + + [] + member _.``Nullable keeps input-output direction`` () : Task = task { + let nullableInput : InputDef = Nullable InputOnlyType + let nullableOutput : OutputDef = Nullable OutputOnlyType + Assert.Equal ("InputOnlyType", nullableInput.ToString ()) + Assert.Equal ("OutputOnlyType", nullableOutput.ToString ()) + } + + [] + member _.``StructNullable keeps input-output direction`` () : Task = task { + let nullableInput : InputDef = StructNullable InputOnlyType + let nullableOutput : OutputDef = StructNullable OutputOnlyType + Assert.Equal ("InputOnlyType", nullableInput.ToString ()) + Assert.Equal ("OutputOnlyType", nullableOutput.ToString ()) + } + + [] + member _.``Valid script compiles successfully`` () : Task = task { + let! exitCode = fixture.RunFsiCheckAsync (fixture.ScriptPath ("Valid.fsx")) + Assert.Equal (0, exitCode) + } + + [] + member _.``ListOf rejects output type as input at compile time`` () : Task = task { + let! exitCode = fixture.RunFsiCheckAsync (fixture.ScriptPath ("ListOf.OutputAsInput.fsx")) + Assert.NotEqual (0, exitCode) + } + + [] + member _.``ListOf rejects input type as output at compile time`` () : Task = task { + let! exitCode = fixture.RunFsiCheckAsync (fixture.ScriptPath ("ListOf.InputAsOutput.fsx")) + Assert.NotEqual (0, exitCode) + } + + [] + member _.``Nullable rejects output type as input at compile time`` () : Task = task { + let! exitCode = fixture.RunFsiCheckAsync (fixture.ScriptPath ("Nullable.OutputAsInput.fsx")) + Assert.NotEqual (0, exitCode) + } + + [] + member _.``Nullable rejects input type as output at compile time`` () : Task = task { + let! exitCode = fixture.RunFsiCheckAsync (fixture.ScriptPath ("Nullable.InputAsOutput.fsx")) + Assert.NotEqual (0, exitCode) + } + + [] + member _.``StructNullable rejects output type as input at compile time`` () : Task = task { + let! exitCode = fixture.RunFsiCheckAsync (fixture.ScriptPath ("StructNullable.OutputAsInput.fsx")) + Assert.NotEqual (0, exitCode) + } + + [] + member _.``StructNullable rejects input type as output at compile time`` () : Task = task { + let! exitCode = fixture.RunFsiCheckAsync (fixture.ScriptPath ("StructNullable.InputAsOutput.fsx")) + Assert.NotEqual (0, exitCode) + } From c883c99f1fb564b3286978d597b5860e248c8162 Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Sun, 17 May 2026 19:24:29 +0200 Subject: [PATCH 5/7] Removed unnecessary GraphQL types upcasts --- .../Connections.fs | 18 +++++++---------- src/FSharp.Data.GraphQL.Server/Schema.fs | 4 ++-- .../Introspection.fs | 20 +++++++++---------- .../SchemaDefinitions.fs | 1 + 4 files changed, 20 insertions(+), 23 deletions(-) diff --git a/src/FSharp.Data.GraphQL.Server.Relay/Connections.fs b/src/FSharp.Data.GraphQL.Server.Relay/Connections.fs index cb353cc54..f32447526 100644 --- a/src/FSharp.Data.GraphQL.Server.Relay/Connections.fs +++ b/src/FSharp.Data.GraphQL.Server.Relay/Connections.fs @@ -114,8 +114,6 @@ type SliceInfo<'Cursor> = [] module Definitions = - let inline private nullableOutput (innerDef : #OutputDef<'Val>) : OutputDef<'Val option> = - Nullable (innerDef :> OutputDef<'Val>) /// /// Active pattern that extracts Relay pagination arguments from a GraphQL field context. @@ -161,13 +159,13 @@ module Definitions = ) Define.AsyncField ( "startCursor", - nullableOutput StringType, + Nullable StringType, "When paginating backwards, the cursor to continue.", fun _ pageInfo -> pageInfo.StartCursor ) Define.AsyncField ( "endCursor", - nullableOutput StringType, + Nullable StringType, "When paginating forwards, the cursor to continue.", fun _ pageInfo -> pageInfo.EndCursor ) @@ -227,7 +225,7 @@ module Definitions = fields = [ Define.AsyncField ( "totalCount", - nullableOutput IntType, + Nullable IntType, """A count of the total number of objects in this connection, ignoring pagination. This allows a client to fetch the first five objects by passing \"5\" as the argument to `first`, then fetch the total count so it could display \"5 of 83\", for example. In cases where we employ infinite scrolling or don't have an exact count of entries, this field will return `null`.""", fun _ conn -> conn.TotalCount ) @@ -278,8 +276,6 @@ module Edge = [] module Connection = - let inline private nullableInput (innerDef : #InputDef<'Val>) : InputDef<'Val option> = - Nullable (innerDef :> InputDef<'Val>) /// /// Transforms a into a @@ -308,8 +304,8 @@ module Connection = /// /// let forwardArgs = - [ Define.Input ("first", nullableInput IntType) - Define.Input ("after", nullableInput StringType) ] + [ Define.Input ("first", Nullable IntType) + Define.Input ("after", Nullable StringType) ] /// /// Argument definitions for backward pagination ("last" and "before"). @@ -318,8 +314,8 @@ module Connection = /// /// let backwardArgs = - [ Define.Input ("last", nullableInput IntType) - Define.Input ("before", nullableInput StringType) ] + [ Define.Input ("last", Nullable IntType) + Define.Input ("before", Nullable StringType) ] /// /// Complete set of argument definitions for bidirectional pagination. diff --git a/src/FSharp.Data.GraphQL.Server/Schema.fs b/src/FSharp.Data.GraphQL.Server/Schema.fs index cf1d9c83e..67146948d 100644 --- a/src/FSharp.Data.GraphQL.Server/Schema.fs +++ b/src/FSharp.Data.GraphQL.Server/Schema.fs @@ -152,14 +152,14 @@ type SchemaConfig = let args = [| Define.Input( "interval", - Nullable (IntType :> InputDef), + Nullable IntType, defaultValue = streamOptions.Interval, description = "An optional argument used to buffer stream results. " + "When it's value is greater than zero, stream results will be buffered for milliseconds equal to the value, then sent to the client. " + "After that, starts buffering again until all results are streamed.") Define.Input( "preferredBatchSize", - Nullable (IntType :> InputDef), + Nullable IntType, defaultValue = streamOptions.PreferredBatchSize, description = "An optional argument used to buffer stream results. " + "When it's value is greater than zero, stream results will be buffered until item count reaches this value, then sent to the client. " + diff --git a/src/FSharp.Data.GraphQL.Shared/Introspection.fs b/src/FSharp.Data.GraphQL.Shared/Introspection.fs index c2439066e..cefebc018 100644 --- a/src/FSharp.Data.GraphQL.Shared/Introspection.fs +++ b/src/FSharp.Data.GraphQL.Shared/Introspection.fs @@ -90,8 +90,8 @@ let rec __Type = fieldsFn = fun () -> [ Define.Field ("kind", __TypeKind, (fun _ t -> t.Kind)) - Define.Field ("name", Nullable (StringType :> OutputDef), resolve = (fun _ t -> t.Name)) - Define.Field ("description", Nullable (StringType :> OutputDef), resolve = (fun _ t -> t.Description)) + Define.Field ("name", Nullable StringType, resolve = (fun _ t -> t.Name)) + Define.Field ("description", Nullable StringType, resolve = (fun _ t -> t.Description)) Define.Field ( "fields", Nullable (ListOf __Field), @@ -173,9 +173,9 @@ and __InputValue = fieldsFn = fun () -> [ Define.Field ("name", StringType, resolve = (fun _ f -> f.Name)) - Define.Field ("description", Nullable (StringType :> OutputDef), resolve = (fun _ f -> f.Description)) + Define.Field ("description", Nullable StringType, resolve = (fun _ f -> f.Description)) Define.Field ("type", __Type, resolve = (fun _ f -> f.Type)) - Define.Field ("defaultValue", Nullable (StringType :> OutputDef), (fun _ f -> f.DefaultValue)) + Define.Field ("defaultValue", Nullable StringType, (fun _ f -> f.DefaultValue)) ] ) @@ -189,11 +189,11 @@ and __Field = fieldsFn = fun () -> [ Define.Field ("name", StringType, (fun _ f -> f.Name)) - Define.Field ("description", Nullable (StringType :> OutputDef), (fun _ f -> f.Description)) + Define.Field ("description", Nullable StringType, (fun _ f -> f.Description)) Define.Field ("args", ListOf __InputValue, (fun _ f -> f.Args)) Define.Field ("type", __Type, (fun _ f -> f.Type)) Define.Field ("isDeprecated", BooleanType, resolve = (fun _ f -> f.IsDeprecated)) - Define.Field ("deprecationReason", Nullable (StringType :> OutputDef), (fun _ f -> f.DeprecationReason)) + Define.Field ("deprecationReason", Nullable StringType, (fun _ f -> f.DeprecationReason)) ] ) @@ -208,9 +208,9 @@ and __EnumValue = fieldsFn = fun () -> [ Define.Field ("name", StringType, resolve = (fun _ e -> e.Name)) - Define.Field ("description", Nullable (StringType :> OutputDef), resolve = (fun _ e -> e.Description)) + Define.Field ("description", Nullable StringType, resolve = (fun _ e -> e.Description)) Define.Field ("isDeprecated", BooleanType, resolve = (fun _ e -> Option.isSome e.DeprecationReason)) - Define.Field ("deprecationReason", Nullable (StringType :> OutputDef), resolve = (fun _ e -> e.DeprecationReason)) + Define.Field ("deprecationReason", Nullable StringType, resolve = (fun _ e -> e.DeprecationReason)) ] ) @@ -231,8 +231,8 @@ and __Directive = fieldsFn = fun () -> [ Define.Field ("name", StringType, resolve = (fun _ directive -> directive.Name)) - Define.Field ("description", Nullable (StringType :> OutputDef), resolve = (fun _ directive -> directive.Description)) - Define.Field ("locations", ListOf (__DirectiveLocation :> OutputDef), resolve = (fun _ directive -> directive.Locations)) + Define.Field ("description", Nullable StringType, resolve = (fun _ directive -> directive.Description)) + Define.Field ("locations", ListOf __DirectiveLocation, resolve = (fun _ directive -> directive.Locations)) Define.Field ("args", ListOf __InputValue, resolve = (fun _ directive -> directive.Args)) Define.Field ( "onOperation", diff --git a/src/FSharp.Data.GraphQL.Shared/SchemaDefinitions.fs b/src/FSharp.Data.GraphQL.Shared/SchemaDefinitions.fs index 7eb9cf462..bc33ab3e8 100644 --- a/src/FSharp.Data.GraphQL.Shared/SchemaDefinitions.fs +++ b/src/FSharp.Data.GraphQL.Shared/SchemaDefinitions.fs @@ -393,6 +393,7 @@ module SchemaDefinitions = | InlineConstant value -> value.GetCoerceError destinationType type TypeWrapperStaticDispatch = + static member Nullable<'Val>(innerDef : InputOutputDef<'Val>) : NullableDef<'Val> = let ofType : TypeDef<'Val> = upcast innerDef upcast { NullableDefinition.OfType = ofType } From edbdab1953420672e21e0a61debea99987247288 Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Sun, 17 May 2026 19:29:22 +0200 Subject: [PATCH 6/7] fixup! Add type wrapper kind safety tests using F# scripts --- .../FSharp.Data.GraphQL.Tests/FSharp.Data.GraphQL.Tests.fsproj | 3 ++- tests/FSharp.Data.GraphQL.Tests/TypeWrappersKindSafetyTests.fs | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) 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 2a4e49b3e..1bc224559 100644 --- a/tests/FSharp.Data.GraphQL.Tests/FSharp.Data.GraphQL.Tests.fsproj +++ b/tests/FSharp.Data.GraphQL.Tests/FSharp.Data.GraphQL.Tests.fsproj @@ -38,10 +38,11 @@ - + + diff --git a/tests/FSharp.Data.GraphQL.Tests/TypeWrappersKindSafetyTests.fs b/tests/FSharp.Data.GraphQL.Tests/TypeWrappersKindSafetyTests.fs index be458cbc8..cececf2dd 100644 --- a/tests/FSharp.Data.GraphQL.Tests/TypeWrappersKindSafetyTests.fs +++ b/tests/FSharp.Data.GraphQL.Tests/TypeWrappersKindSafetyTests.fs @@ -56,7 +56,7 @@ type TypeWrappersKindSafetyFixture () = let sharedAssembly = IO.Path.Combine (AppContext.BaseDirectory, "FSharp.Data.GraphQL.Shared.dll") let serverAssembly = IO.Path.Combine (AppContext.BaseDirectory, "FSharp.Data.GraphQL.Server.dll") - [ sprintf "#r @\"%s\"" sharedAssembly; sprintf "#r @\"%s\"" serverAssembly ] + [| sprintf "#r @\"%s\"" sharedAssembly; sprintf "#r @\"%s\"" serverAssembly |] |> String.concat "\n" interface IAsyncLifetime with From 0e567bd197f54ca9b46c8f5be053b1efa57e272b Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Sun, 17 May 2026 19:45:52 +0200 Subject: [PATCH 7/7] fixup! Removed unnecessary GraphQL types upcasts --- src/FSharp.Data.GraphQL.Server.Relay/Connections.fs | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/FSharp.Data.GraphQL.Server.Relay/Connections.fs b/src/FSharp.Data.GraphQL.Server.Relay/Connections.fs index f32447526..369d6d3ca 100644 --- a/src/FSharp.Data.GraphQL.Server.Relay/Connections.fs +++ b/src/FSharp.Data.GraphQL.Server.Relay/Connections.fs @@ -303,9 +303,7 @@ module Connection = /// /// /// - let forwardArgs = - [ Define.Input ("first", Nullable IntType) - Define.Input ("after", Nullable StringType) ] + let forwardArgs = [ Define.Input ("first", Nullable IntType); Define.Input ("after", Nullable StringType) ] /// /// Argument definitions for backward pagination ("last" and "before"). @@ -313,9 +311,7 @@ module Connection = /// /// /// - let backwardArgs = - [ Define.Input ("last", Nullable IntType) - Define.Input ("before", Nullable StringType) ] + let backwardArgs = [ Define.Input ("last", Nullable IntType); Define.Input ("before", Nullable StringType) ] /// /// Complete set of argument definitions for bidirectional pagination.