From e05c326af94b2e1946cd980732752f302d951428 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 17 May 2026 18:35:58 +0000 Subject: [PATCH 01/40] Use deterministic SHA-256 based documentId generation Agent-Logs-Url: https://github.com/fsprojects/FSharp.Data.GraphQL/sessions/020d0296-28b1-416d-bd12-9dd0790516ba Co-authored-by: xperiandri <2365592+xperiandri@users.noreply.github.com> --- src/FSharp.Data.GraphQL.Server/Executor.fs | 13 ++++++++++++- .../FSharp.Data.GraphQL.Tests/ExecutionTests.fs | 16 ++++++++++++++-- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/src/FSharp.Data.GraphQL.Server/Executor.fs b/src/FSharp.Data.GraphQL.Server/Executor.fs index 85089c99..df53e418 100644 --- a/src/FSharp.Data.GraphQL.Server/Executor.fs +++ b/src/FSharp.Data.GraphQL.Server/Executor.fs @@ -1,14 +1,19 @@ namespace FSharp.Data.GraphQL +open System open System.Collections.Concurrent open System.Collections.Immutable +open System.Buffers.Binary +open System.Security.Cryptography open System.Runtime.InteropServices +open System.Text open System.Text.Json open FsToolkit.ErrorHandling open FSharp.Data.GraphQL.Types open FSharp.Data.GraphQL.Execution open FSharp.Data.GraphQL.Ast +open FSharp.Data.GraphQL.Ast.Extensions open FSharp.Data.GraphQL.Validation open FSharp.Data.GraphQL.Parser open FSharp.Data.GraphQL.Planning @@ -77,6 +82,12 @@ type Executor<'Root>(schema: ISchema<'Root>, middlewares : IExecutorMiddleware s let middlewaresList = Seq.toList middlewares + let getDocumentId (document : Document) = + let canonicalQuery = document.ToQueryString() + let queryBytes = Encoding.UTF8.GetBytes canonicalQuery + let hash = SHA256.HashData queryBytes + BinaryPrimitives.ReadInt32BigEndian(ReadOnlySpan(hash, 0, 4)) + let rec runMiddlewares (phaseSel : IExecutorMiddleware -> ('ctx -> ('ctx -> 'res) -> 'res) option) (initialCtx : 'ctx) (onComplete : 'ctx -> 'res) @@ -137,7 +148,7 @@ type Executor<'Root>(schema: ISchema<'Root>, middlewares : IExecutorMiddleware s eval (executionPlan, data, variables, getInputContext) let createExecutionPlan (ast: Document, operationName: string option, meta : Metadata) = - let documentId = ast.GetHashCode() + let documentId = getDocumentId ast result { match findOperation ast operationName with | Some operation -> diff --git a/tests/FSharp.Data.GraphQL.Tests/ExecutionTests.fs b/tests/FSharp.Data.GraphQL.Tests/ExecutionTests.fs index 07bc5827..378dbdab 100644 --- a/tests/FSharp.Data.GraphQL.Tests/ExecutionTests.fs +++ b/tests/FSharp.Data.GraphQL.Tests/ExecutionTests.fs @@ -5,6 +5,9 @@ module FSharp.Data.GraphQL.Tests.ExecutionTests open Xunit open System +open System.Buffers.Binary +open System.Security.Cryptography +open System.Text open System.Text.Json open System.Text.Json.Serialization open System.Collections.Immutable @@ -17,6 +20,7 @@ open FSharp.Data.GraphQL.Shared open FSharp.Data.GraphQL.Types open FSharp.Data.GraphQL.Parser open FSharp.Data.GraphQL.Execution +open FSharp.Data.GraphQL.Ast.Extensions type TestSubject = { a: string @@ -383,9 +387,17 @@ let ``Execution when querying returns unique document id with response`` () = Define.Field("a", StringType, fun _ x -> x.A) Define.Field("b", IntType, fun _ x -> x.B) ])) - let result1 = sync <| Executor(schema).AsyncExecute("query Example { a, b, a }", getMockInputContext, { A = "aa"; B = 2 }) - let result2 = sync <| Executor(schema).AsyncExecute("query Example { a, b, a }", getMockInputContext, { A = "aa"; B = 2 }) + let query = "query Example { a, b, a }" + let expectedDocumentId = + let canonicalQuery = + let ast = parse query + ast.ToQueryString() + let hash = SHA256.HashData(Encoding.UTF8.GetBytes canonicalQuery) + BinaryPrimitives.ReadInt32BigEndian(ReadOnlySpan(hash, 0, 4)) + let result1 = sync <| Executor(schema).AsyncExecute(query, getMockInputContext, { A = "aa"; B = 2 }) + let result2 = sync <| Executor(schema).AsyncExecute(query, getMockInputContext, { A = "aa"; B = 2 }) result1.DocumentId |> notEquals Unchecked.defaultof + result1.DocumentId |> equals expectedDocumentId result1.DocumentId |> equals result2.DocumentId match result1,result2 with | Direct(data1, errors1), Direct(data2, errors2) -> From e4fcfd3efc858c44aaf52399d74476393970983e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 17 May 2026 18:42:33 +0000 Subject: [PATCH 02/40] Update introspection fixture documentId values Agent-Logs-Url: https://github.com/fsprojects/FSharp.Data.GraphQL/sessions/020d0296-28b1-416d-bd12-9dd0790516ba Co-authored-by: xperiandri <2365592+xperiandri@users.noreply.github.com> --- .../integration-introspection.json | 4 ++-- tests/FSharp.Data.GraphQL.IntegrationTests/introspection.json | 4 ++-- tests/FSharp.Data.GraphQL.Tests/Literals.fs | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/FSharp.Data.GraphQL.IntegrationTests/integration-introspection.json b/tests/FSharp.Data.GraphQL.IntegrationTests/integration-introspection.json index ad448c42..c76d1d74 100644 --- a/tests/FSharp.Data.GraphQL.IntegrationTests/integration-introspection.json +++ b/tests/FSharp.Data.GraphQL.IntegrationTests/integration-introspection.json @@ -1,5 +1,5 @@ { - "documentId": 986164407, + "documentId": 1417518537, "data": { "__schema": { "queryType": { @@ -1926,4 +1926,4 @@ ] } } -} \ No newline at end of file +} diff --git a/tests/FSharp.Data.GraphQL.IntegrationTests/introspection.json b/tests/FSharp.Data.GraphQL.IntegrationTests/introspection.json index a961111f..3b81047b 100644 --- a/tests/FSharp.Data.GraphQL.IntegrationTests/introspection.json +++ b/tests/FSharp.Data.GraphQL.IntegrationTests/introspection.json @@ -1,5 +1,5 @@ { - "documentId": 195530235, + "documentId": 1417518537, "data": { "__schema": { "queryType": { @@ -1862,4 +1862,4 @@ ] } } -} \ No newline at end of file +} diff --git a/tests/FSharp.Data.GraphQL.Tests/Literals.fs b/tests/FSharp.Data.GraphQL.Tests/Literals.fs index 57d11d47..04402bd4 100644 --- a/tests/FSharp.Data.GraphQL.Tests/Literals.fs +++ b/tests/FSharp.Data.GraphQL.Tests/Literals.fs @@ -1,7 +1,7 @@ module FSharp.Data.GraphQL.Tests.Literals let [] IntrospectionSchemaJson = """{ - "documentId": 869718943, + "documentId": 1417518537, "data": { "__schema": { "queryType": { @@ -1589,4 +1589,4 @@ let [] IntrospectionSchemaJson = """{ ] } } - }""" \ No newline at end of file + }""" From 83c9abbd8a8b0dd4f752aa808b06a34e10a4c8c1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 17 May 2026 18:46:29 +0000 Subject: [PATCH 03/40] Address review feedback on deterministic documentId changes Agent-Logs-Url: https://github.com/fsprojects/FSharp.Data.GraphQL/sessions/020d0296-28b1-416d-bd12-9dd0790516ba Co-authored-by: xperiandri <2365592+xperiandri@users.noreply.github.com> --- src/FSharp.Data.GraphQL.Server/Executor.fs | 1 + tests/FSharp.Data.GraphQL.Tests/ExecutionTests.fs | 11 +---------- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/src/FSharp.Data.GraphQL.Server/Executor.fs b/src/FSharp.Data.GraphQL.Server/Executor.fs index df53e418..bd3c92fa 100644 --- a/src/FSharp.Data.GraphQL.Server/Executor.fs +++ b/src/FSharp.Data.GraphQL.Server/Executor.fs @@ -82,6 +82,7 @@ type Executor<'Root>(schema: ISchema<'Root>, middlewares : IExecutorMiddleware s let middlewaresList = Seq.toList middlewares + /// Generates a deterministic document identifier from the canonical query string. let getDocumentId (document : Document) = let canonicalQuery = document.ToQueryString() let queryBytes = Encoding.UTF8.GetBytes canonicalQuery diff --git a/tests/FSharp.Data.GraphQL.Tests/ExecutionTests.fs b/tests/FSharp.Data.GraphQL.Tests/ExecutionTests.fs index 378dbdab..3fcd0af7 100644 --- a/tests/FSharp.Data.GraphQL.Tests/ExecutionTests.fs +++ b/tests/FSharp.Data.GraphQL.Tests/ExecutionTests.fs @@ -5,9 +5,6 @@ module FSharp.Data.GraphQL.Tests.ExecutionTests open Xunit open System -open System.Buffers.Binary -open System.Security.Cryptography -open System.Text open System.Text.Json open System.Text.Json.Serialization open System.Collections.Immutable @@ -20,7 +17,6 @@ open FSharp.Data.GraphQL.Shared open FSharp.Data.GraphQL.Types open FSharp.Data.GraphQL.Parser open FSharp.Data.GraphQL.Execution -open FSharp.Data.GraphQL.Ast.Extensions type TestSubject = { a: string @@ -388,12 +384,7 @@ let ``Execution when querying returns unique document id with response`` () = Define.Field("b", IntType, fun _ x -> x.B) ])) let query = "query Example { a, b, a }" - let expectedDocumentId = - let canonicalQuery = - let ast = parse query - ast.ToQueryString() - let hash = SHA256.HashData(Encoding.UTF8.GetBytes canonicalQuery) - BinaryPrimitives.ReadInt32BigEndian(ReadOnlySpan(hash, 0, 4)) + let expectedDocumentId = -2063861555 let result1 = sync <| Executor(schema).AsyncExecute(query, getMockInputContext, { A = "aa"; B = 2 }) let result2 = sync <| Executor(schema).AsyncExecute(query, getMockInputContext, { A = "aa"; B = 2 }) result1.DocumentId |> notEquals Unchecked.defaultof From 30999e35b63c9bba03a5b7e79d1e55a4a0b185b1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 17 May 2026 18:47:24 +0000 Subject: [PATCH 04/40] Document expected deterministic documentId in test Agent-Logs-Url: https://github.com/fsprojects/FSharp.Data.GraphQL/sessions/020d0296-28b1-416d-bd12-9dd0790516ba Co-authored-by: xperiandri <2365592+xperiandri@users.noreply.github.com> --- tests/FSharp.Data.GraphQL.Tests/ExecutionTests.fs | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/FSharp.Data.GraphQL.Tests/ExecutionTests.fs b/tests/FSharp.Data.GraphQL.Tests/ExecutionTests.fs index 3fcd0af7..82c89eae 100644 --- a/tests/FSharp.Data.GraphQL.Tests/ExecutionTests.fs +++ b/tests/FSharp.Data.GraphQL.Tests/ExecutionTests.fs @@ -384,6 +384,7 @@ let ``Execution when querying returns unique document id with response`` () = Define.Field("b", IntType, fun _ x -> x.B) ])) let query = "query Example { a, b, a }" + // Deterministic SHA-256-based documentId for the canonical AST query string above. let expectedDocumentId = -2063861555 let result1 = sync <| Executor(schema).AsyncExecute(query, getMockInputContext, { A = "aa"; B = 2 }) let result2 = sync <| Executor(schema).AsyncExecute(query, getMockInputContext, { A = "aa"; B = 2 }) From e1ad2227629120dd7b4b76921d4650ba24bfc1d1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 17 May 2026 18:48:29 +0000 Subject: [PATCH 05/40] Clarify hash truncation and expected documentId derivation Agent-Logs-Url: https://github.com/fsprojects/FSharp.Data.GraphQL/sessions/020d0296-28b1-416d-bd12-9dd0790516ba Co-authored-by: xperiandri <2365592+xperiandri@users.noreply.github.com> --- src/FSharp.Data.GraphQL.Server/Executor.fs | 1 + tests/FSharp.Data.GraphQL.Tests/ExecutionTests.fs | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/FSharp.Data.GraphQL.Server/Executor.fs b/src/FSharp.Data.GraphQL.Server/Executor.fs index bd3c92fa..0d264acf 100644 --- a/src/FSharp.Data.GraphQL.Server/Executor.fs +++ b/src/FSharp.Data.GraphQL.Server/Executor.fs @@ -83,6 +83,7 @@ type Executor<'Root>(schema: ISchema<'Root>, middlewares : IExecutorMiddleware s let middlewaresList = Seq.toList middlewares /// Generates a deterministic document identifier from the canonical query string. + /// The SHA-256 hash is truncated to 32 bits to preserve the existing int32 documentId contract. let getDocumentId (document : Document) = let canonicalQuery = document.ToQueryString() let queryBytes = Encoding.UTF8.GetBytes canonicalQuery diff --git a/tests/FSharp.Data.GraphQL.Tests/ExecutionTests.fs b/tests/FSharp.Data.GraphQL.Tests/ExecutionTests.fs index 82c89eae..530c757a 100644 --- a/tests/FSharp.Data.GraphQL.Tests/ExecutionTests.fs +++ b/tests/FSharp.Data.GraphQL.Tests/ExecutionTests.fs @@ -384,7 +384,8 @@ let ``Execution when querying returns unique document id with response`` () = Define.Field("b", IntType, fun _ x -> x.B) ])) let query = "query Example { a, b, a }" - // Deterministic SHA-256-based documentId for the canonical AST query string above. + // Deterministic SHA-256-based documentId for canonical `query Example { a b a }`, + // using the first 4 bytes of the hash as a big-endian int32. let expectedDocumentId = -2063861555 let result1 = sync <| Executor(schema).AsyncExecute(query, getMockInputContext, { A = "aa"; B = 2 }) let result2 = sync <| Executor(schema).AsyncExecute(query, getMockInputContext, { A = "aa"; B = 2 }) From 69a70fa406ba4da54ec07cf687f58b1767d0db34 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 17 May 2026 18:49:34 +0000 Subject: [PATCH 06/40] Refine hash span usage and test value explanation Agent-Logs-Url: https://github.com/fsprojects/FSharp.Data.GraphQL/sessions/020d0296-28b1-416d-bd12-9dd0790516ba Co-authored-by: xperiandri <2365592+xperiandri@users.noreply.github.com> --- src/FSharp.Data.GraphQL.Server/Executor.fs | 2 +- tests/FSharp.Data.GraphQL.Tests/ExecutionTests.fs | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/FSharp.Data.GraphQL.Server/Executor.fs b/src/FSharp.Data.GraphQL.Server/Executor.fs index 0d264acf..c46ac59f 100644 --- a/src/FSharp.Data.GraphQL.Server/Executor.fs +++ b/src/FSharp.Data.GraphQL.Server/Executor.fs @@ -88,7 +88,7 @@ type Executor<'Root>(schema: ISchema<'Root>, middlewares : IExecutorMiddleware s let canonicalQuery = document.ToQueryString() let queryBytes = Encoding.UTF8.GetBytes canonicalQuery let hash = SHA256.HashData queryBytes - BinaryPrimitives.ReadInt32BigEndian(ReadOnlySpan(hash, 0, 4)) + BinaryPrimitives.ReadInt32BigEndian(hash.AsSpan(0, 4)) let rec runMiddlewares (phaseSel : IExecutorMiddleware -> ('ctx -> ('ctx -> 'res) -> 'res) option) (initialCtx : 'ctx) diff --git a/tests/FSharp.Data.GraphQL.Tests/ExecutionTests.fs b/tests/FSharp.Data.GraphQL.Tests/ExecutionTests.fs index 530c757a..af41c609 100644 --- a/tests/FSharp.Data.GraphQL.Tests/ExecutionTests.fs +++ b/tests/FSharp.Data.GraphQL.Tests/ExecutionTests.fs @@ -386,6 +386,7 @@ let ``Execution when querying returns unique document id with response`` () = let query = "query Example { a, b, a }" // Deterministic SHA-256-based documentId for canonical `query Example { a b a }`, // using the first 4 bytes of the hash as a big-endian int32. + // Computed once via parse + ToQueryString + SHA-256 and kept fixed to catch regressions. let expectedDocumentId = -2063861555 let result1 = sync <| Executor(schema).AsyncExecute(query, getMockInputContext, { A = "aa"; B = 2 }) let result2 = sync <| Executor(schema).AsyncExecute(query, getMockInputContext, { A = "aa"; B = 2 }) From 5f18153e8c660b60f0c28dc754f9bace47e6bb3d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 17 May 2026 19:33:18 +0000 Subject: [PATCH 07/40] Address review feedback on literals and documentId hashing Agent-Logs-Url: https://github.com/fsprojects/FSharp.Data.GraphQL/sessions/5d78ecae-85c3-42af-9ad0-1750b8aa7fff Co-authored-by: xperiandri <2365592+xperiandri@users.noreply.github.com> --- Packages.props | 1 + src/FSharp.Data.GraphQL.Server/Executor.fs | 10 +- .../integration-introspection.json | 2 +- .../introspection.json | 2 +- .../ExecutionTests.fs | 4 +- .../FSharp.Data.GraphQL.Tests.fsproj | 1 + tests/FSharp.Data.GraphQL.Tests/Literals.fs | 1594 +---------------- 7 files changed, 18 insertions(+), 1596 deletions(-) diff --git a/Packages.props b/Packages.props index 323f603f..182b72ce 100644 --- a/Packages.props +++ b/Packages.props @@ -17,6 +17,7 @@ + diff --git a/src/FSharp.Data.GraphQL.Server/Executor.fs b/src/FSharp.Data.GraphQL.Server/Executor.fs index c46ac59f..7b5ff6a3 100644 --- a/src/FSharp.Data.GraphQL.Server/Executor.fs +++ b/src/FSharp.Data.GraphQL.Server/Executor.fs @@ -83,12 +83,18 @@ type Executor<'Root>(schema: ISchema<'Root>, middlewares : IExecutorMiddleware s let middlewaresList = Seq.toList middlewares /// Generates a deterministic document identifier from the canonical query string. - /// The SHA-256 hash is truncated to 32 bits to preserve the existing int32 documentId contract. + /// The full SHA-256 hash is folded into 32 bits to preserve the existing int32 documentId contract. let getDocumentId (document : Document) = let canonicalQuery = document.ToQueryString() let queryBytes = Encoding.UTF8.GetBytes canonicalQuery let hash = SHA256.HashData queryBytes - BinaryPrimitives.ReadInt32BigEndian(hash.AsSpan(0, 4)) + [ 0 .. 7 ] + |> List.fold + (fun acc index -> + let start = index * 4 + let hashChunk = BinaryPrimitives.ReadInt32BigEndian(hash.AsSpan(start, 4)) + acc ^^^ hashChunk) + 0 let rec runMiddlewares (phaseSel : IExecutorMiddleware -> ('ctx -> ('ctx -> 'res) -> 'res) option) (initialCtx : 'ctx) diff --git a/tests/FSharp.Data.GraphQL.IntegrationTests/integration-introspection.json b/tests/FSharp.Data.GraphQL.IntegrationTests/integration-introspection.json index c76d1d74..d1d1ff82 100644 --- a/tests/FSharp.Data.GraphQL.IntegrationTests/integration-introspection.json +++ b/tests/FSharp.Data.GraphQL.IntegrationTests/integration-introspection.json @@ -1,5 +1,5 @@ { - "documentId": 1417518537, + "documentId": 1890859023, "data": { "__schema": { "queryType": { diff --git a/tests/FSharp.Data.GraphQL.IntegrationTests/introspection.json b/tests/FSharp.Data.GraphQL.IntegrationTests/introspection.json index 3b81047b..f7ebae18 100644 --- a/tests/FSharp.Data.GraphQL.IntegrationTests/introspection.json +++ b/tests/FSharp.Data.GraphQL.IntegrationTests/introspection.json @@ -1,5 +1,5 @@ { - "documentId": 1417518537, + "documentId": 1890859023, "data": { "__schema": { "queryType": { diff --git a/tests/FSharp.Data.GraphQL.Tests/ExecutionTests.fs b/tests/FSharp.Data.GraphQL.Tests/ExecutionTests.fs index af41c609..0b2c9564 100644 --- a/tests/FSharp.Data.GraphQL.Tests/ExecutionTests.fs +++ b/tests/FSharp.Data.GraphQL.Tests/ExecutionTests.fs @@ -385,9 +385,9 @@ let ``Execution when querying returns unique document id with response`` () = ])) let query = "query Example { a, b, a }" // Deterministic SHA-256-based documentId for canonical `query Example { a b a }`, - // using the first 4 bytes of the hash as a big-endian int32. + // folding all 32 hash bytes into an int32 via 8 big-endian chunks. // Computed once via parse + ToQueryString + SHA-256 and kept fixed to catch regressions. - let expectedDocumentId = -2063861555 + let expectedDocumentId = 154204461 let result1 = sync <| Executor(schema).AsyncExecute(query, getMockInputContext, { A = "aa"; B = 2 }) let result2 = sync <| Executor(schema).AsyncExecute(query, getMockInputContext, { A = "aa"; B = 2 }) result1.DocumentId |> notEquals Unchecked.defaultof 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 1bc22455..1eda8da3 100644 --- a/tests/FSharp.Data.GraphQL.Tests/FSharp.Data.GraphQL.Tests.fsproj +++ b/tests/FSharp.Data.GraphQL.Tests/FSharp.Data.GraphQL.Tests.fsproj @@ -16,6 +16,7 @@ + diff --git a/tests/FSharp.Data.GraphQL.Tests/Literals.fs b/tests/FSharp.Data.GraphQL.Tests/Literals.fs index 04402bd4..64585ca4 100644 --- a/tests/FSharp.Data.GraphQL.Tests/Literals.fs +++ b/tests/FSharp.Data.GraphQL.Tests/Literals.fs @@ -1,1592 +1,6 @@ module FSharp.Data.GraphQL.Tests.Literals -let [] IntrospectionSchemaJson = """{ - "documentId": 1417518537, - "data": { - "__schema": { - "queryType": { - "name": "Query" - }, - "mutationType": { - "name": "Mutation" - }, - "subscriptionType": { - "name": "Subscription" - }, - "types": [ - { - "kind": "SCALAR", - "name": "Int", - "description": "The `Int` scalar type represents non-fractional signed whole numeric values. Int can represent values between -(2^31) and 2^31 - 1.", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "SCALAR", - "name": "String", - "description": "The `String` scalar type represents textual data, represented as UTF-8 character sequences. The String type is most often used by GraphQL to represent free-form human-readable text.", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "SCALAR", - "name": "Boolean", - "description": "The `Boolean` scalar type represents `true` or `false`.", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "SCALAR", - "name": "Float", - "description": "The `Float` scalar type represents signed double-precision fractional values as specified by [IEEE 754](http://en.wikipedia.org/wiki/IEEE_floating_point).", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "SCALAR", - "name": "ID", - "description": "The `ID` scalar type represents a unique identifier, often used to refetch an object or as key for a cache. The ID type appears in a JSON response as a String; however, it is not intended to be human-readable. When expected as an input type, any string (such as `\"4\"`) or integer (such as `4`) input value will be accepted as an ID.", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "SCALAR", - "name": "Date", - "description": "The `Date` scalar type represents a Date value with Time component. The Date type appears in a JSON response as a String representation compatible with ISO-8601 format.", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "SCALAR", - "name": "URI", - "description": "The `URI` scalar type represents a string resource identifier compatible with URI standard. The URI type appears in a JSON response as a String.", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "__Schema", - "description": "A GraphQL Schema defines the capabilities of a GraphQL server. It exposes all available types and directives on the server, as well as the entry points for query, mutation, and subscription operations.", - "fields": [ - { - "name": "directives", - "description": "A list of all directives supported by this server.", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "OBJECT", - "name": "__Directive" - } - } - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "mutationType", - "description": "If this server supports mutation, the type that mutation operations will be rooted at.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "__Type", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "queryType", - "description": "The type that query operations will be rooted at.", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "OBJECT", - "name": "__Type", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "subscriptionType", - "description": "If this server support subscription, the type that subscription operations will be rooted at.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "__Type", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "types", - "description": "A list of all types supported by this server.", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "OBJECT", - "name": "__Type" - } - } - } - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "__Directive", - "description": "A Directive provides a way to describe alternate runtime execution and type validation behavior in a GraphQL document. In some cases, you need to provide options to alter GraphQL’s execution behavior in ways field arguments will not suffice, such as conditionally including or skipping a field. Directives provide this by describing additional information to the executor.", - "fields": [ - { - "name": "args", - "description": null, - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "OBJECT", - "name": "__InputValue" - } - } - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "description", - "description": null, - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "locations", - "description": null, - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "ENUM", - "name": "__DirectiveLocation" - } - } - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "name", - "description": null, - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "String", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "onField", - "description": null, - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "Boolean", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "onFragment", - "description": null, - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "Boolean", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "onOperation", - "description": null, - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "Boolean", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "__InputValue", - "description": "Arguments provided to Fields or Directives and the input fields of an InputObject are represented as Input Values which describe their type and optionally a default value.", - "fields": [ - { - "name": "defaultValue", - "description": null, - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "description", - "description": null, - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "name", - "description": null, - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "String", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "type", - "description": null, - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "OBJECT", - "name": "__Type", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "__Type", - "description": "The fundamental unit of any GraphQL Schema is the type. There are many kinds of types in GraphQL as represented by the `__TypeKind` enum. Depending on the kind of a type, certain fields describe information about that type. Scalar types provide no information beyond a name and description, while Enum types provide their values. Object and Interface types provide the fields they describe. Abstract types, Union and Interface, provide the Object types possible at runtime. List and NonNull types compose other types.", - "fields": [ - { - "name": "description", - "description": null, - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "enumValues", - "description": null, - "args": [ - { - "name": "includeDeprecated", - "description": null, - "type": { - "kind": "SCALAR", - "name": "Boolean", - "ofType": null - }, - "defaultValue": "False" - } - ], - "type": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "OBJECT", - "name": "__EnumValue", - "ofType": null - } - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "fields", - "description": null, - "args": [ - { - "name": "includeDeprecated", - "description": null, - "type": { - "kind": "SCALAR", - "name": "Boolean", - "ofType": null - }, - "defaultValue": "False" - } - ], - "type": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "OBJECT", - "name": "__Field", - "ofType": null - } - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "inputFields", - "description": null, - "args": [], - "type": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "OBJECT", - "name": "__InputValue", - "ofType": null - } - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "interfaces", - "description": null, - "args": [], - "type": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "OBJECT", - "name": "__Type", - "ofType": null - } - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "kind", - "description": null, - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "ENUM", - "name": "__TypeKind", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "name", - "description": null, - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "ofType", - "description": null, - "args": [], - "type": { - "kind": "OBJECT", - "name": "__Type", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "possibleTypes", - "description": null, - "args": [], - "type": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "OBJECT", - "name": "__Type", - "ofType": null - } - } - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "__EnumValue", - "description": "One possible value for a given Enum. Enum values are unique values, not a placeholder for a string or numeric value. However an Enum value is returned in a JSON response as a string.", - "fields": [ - { - "name": "deprecationReason", - "description": null, - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "description", - "description": null, - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "isDeprecated", - "description": null, - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "Boolean", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "name", - "description": null, - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "String", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "__Field", - "description": "Object and Interface types are described by a list of Fields, each of which has a name, potentially a list of arguments, and a return type.", - "fields": [ - { - "name": "args", - "description": null, - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "OBJECT", - "name": "__InputValue" - } - } - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "deprecationReason", - "description": null, - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "description", - "description": null, - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "isDeprecated", - "description": null, - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "Boolean", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "name", - "description": null, - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "String", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "type", - "description": null, - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "OBJECT", - "name": "__Type", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "ENUM", - "name": "__TypeKind", - "description": "An enum describing what kind of type a given __Type is.", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": [ - { - "name": "SCALAR", - "description": "Indicates this type is a scalar.", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "OBJECT", - "description": "Indicates this type is an object. `fields` and `interfaces` are valid fields.", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "INTERFACE", - "description": "Indicates this type is an interface. `fields` and `possibleTypes` are valid fields.", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "UNION", - "description": "Indicates this type is a union. `possibleTypes` is a valid field.", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "ENUM", - "description": "Indicates this type is an enum. `enumValues` is a valid field.", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "INPUT_OBJECT", - "description": "Indicates this type is an input object. `inputFields` is a valid field.", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "LIST", - "description": "Indicates this type is a list. `ofType` is a valid field.", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "NON_NULL", - "description": "Indicates this type is a non-null. `ofType` is a valid field.", - "isDeprecated": false, - "deprecationReason": null - } - ], - "possibleTypes": null - }, - { - "kind": "ENUM", - "name": "__DirectiveLocation", - "description": "A Directive can be adjacent to many parts of the GraphQL language, a __DirectiveLocation describes one such possible adjacencies.", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": [ - { - "name": "QUERY", - "description": "Location adjacent to a query operation.", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "MUTATION", - "description": "Location adjacent to a mutation operation.", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "SUBSCRIPTION", - "description": "Location adjacent to a subscription operation.", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "FIELD", - "description": "Location adjacent to a field.", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "FRAGMENT_DEFINITION", - "description": "Location adjacent to a fragment definition.", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "FRAGMENT_SPREAD", - "description": "Location adjacent to a fragment spread.", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "INLINE_FRAGMENT", - "description": "Location adjacent to an inline fragment.", - "isDeprecated": false, - "deprecationReason": null - } - ], - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "Query", - "description": null, - "fields": [ - { - "name": "droid", - "description": "Gets droid", - "args": [ - { - "name": "id", - "description": null, - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "String", - "ofType": null - } - }, - "defaultValue": null - } - ], - "type": { - "kind": "OBJECT", - "name": "Droid", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "hero", - "description": "Gets human hero", - "args": [ - { - "name": "id", - "description": null, - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "String", - "ofType": null - } - }, - "defaultValue": null - } - ], - "type": { - "kind": "OBJECT", - "name": "Human", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "planet", - "description": "Gets planet", - "args": [ - { - "name": "id", - "description": null, - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "String", - "ofType": null - } - }, - "defaultValue": null - } - ], - "type": { - "kind": "OBJECT", - "name": "Planet", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "things", - "description": "Gets things", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "INTERFACE", - "name": "Thing" - } - } - } - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "Droid", - "description": "A mechanical creature in the Star Wars universe.", - "fields": [ - { - "name": "appearsIn", - "description": "Which movies they appear in.", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "ENUM", - "name": "Episode" - } - } - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "friends", - "description": "The friends of the Droid, or an empty list if they have none.", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "UNION", - "name": "Character", - "ofType": null - } - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "id", - "description": "The id of the droid.", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "String", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "name", - "description": "The name of the Droid.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "primaryFunction", - "description": "The primary function of the droid.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "ENUM", - "name": "Episode", - "description": "One of the films in the Star Wars Trilogy", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": [ - { - "name": "NewHope", - "description": "Released in 1977.", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "Empire", - "description": "Released in 1980.", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "Jedi", - "description": "Released in 1983.", - "isDeprecated": false, - "deprecationReason": null - } - ], - "possibleTypes": null - }, - { - "kind": "UNION", - "name": "Character", - "description": "A character in the Star Wars Trilogy", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": null, - "possibleTypes": [ - { - "kind": "OBJECT", - "name": "Human", - "ofType": null - }, - { - "kind": "OBJECT", - "name": "Droid", - "ofType": null - } - ] - }, - { - "kind": "OBJECT", - "name": "Human", - "description": "A humanoid creature in the Star Wars universe.", - "fields": [ - { - "name": "appearsIn", - "description": "Which movies they appear in.", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "ENUM", - "name": "Episode" - } - } - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "friends", - "description": "The friends of the human, or an empty list if they have none.", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "UNION", - "name": "Character", - "ofType": null - } - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "homePlanet", - "description": "The home planet of the human, or null if unknown.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "id", - "description": "The id of the human.", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "String", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "name", - "description": "The name of the human.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "Planet", - "description": "A planet in the Star Wars universe.", - "fields": [ - { - "name": "id", - "description": "The id of the planet", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "String", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "isMoon", - "description": "Is that a moon?", - "args": [], - "type": { - "kind": "SCALAR", - "name": "Boolean", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "name", - "description": "The name of the planet.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INTERFACE", - "name": "Thing", - "description": "Gets the shape of the thing.", - "fields": [ - { - "name": "format", - "description": "The format of the shape", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "String", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "id", - "description": "The ID of the shape", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "String", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": null, - "enumValues": null, - "possibleTypes": [ - { - "kind": "OBJECT", - "name": "Box", - "ofType": null - }, - { - "kind": "OBJECT", - "name": "Ball", - "ofType": null - } - ] - }, - { - "kind": "OBJECT", - "name": "Subscription", - "description": null, - "fields": [ - { - "name": "watchMoon", - "description": "Watches to see if a planet is a moon", - "args": [ - { - "name": "id", - "description": null, - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "String", - "ofType": null - } - }, - "defaultValue": null - } - ], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "OBJECT", - "name": "Planet", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "Mutation", - "description": null, - "fields": [ - { - "name": "setMoon", - "description": "Sets a moon status", - "args": [ - { - "name": "id", - "description": null, - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "String", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "isMoon", - "description": null, - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "Boolean", - "ofType": null - } - }, - "defaultValue": null - } - ], - "type": { - "kind": "OBJECT", - "name": "Planet", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "Ball", - "description": "A ball.", - "fields": [ - { - "name": "format", - "description": "The format of the ball", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "String", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "id", - "description": "The ID of the ball", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "String", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [ - { - "kind": "INTERFACE", - "name": "Thing", - "ofType": null - } - ], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "Box", - "description": "A box.", - "fields": [ - { - "name": "format", - "description": "The format of the box", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "String", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "id", - "description": "The ID of the box", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "String", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [ - { - "kind": "INTERFACE", - "name": "Thing", - "ofType": null - } - ], - "enumValues": null, - "possibleTypes": null - } - ], - "directives": [ - { - "name": "include", - "description": "Directs the executor to include this field or fragment only when the `if` argument is true.", - "locations": [ - "FIELD", - "FRAGMENT_SPREAD", - "INLINE_FRAGMENT" - ], - "args": [ - { - "name": "if", - "description": "Included when true.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "Boolean", - "ofType": null - } - }, - "defaultValue": null - } - ] - }, - { - "name": "skip", - "description": "Directs the executor to skip this field or fragment when the `if` argument is true.", - "locations": [ - "FIELD", - "FRAGMENT_SPREAD", - "INLINE_FRAGMENT" - ], - "args": [ - { - "name": "if", - "description": "Skipped when true.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "Boolean", - "ofType": null - } - }, - "defaultValue": null - } - ] - }, - { - "name": "defer", - "description": "Defers the resolution of this field or fragment", - "locations": [ - "FIELD", - "FRAGMENT_DEFINITION", - "FRAGMENT_SPREAD", - "INLINE_FRAGMENT" - ], - "args": [] - }, - { - "name": "stream", - "description": "Streams the resolution of this field or fragment", - "locations": [ - "FIELD", - "FRAGMENT_DEFINITION", - "FRAGMENT_SPREAD", - "INLINE_FRAGMENT" - ], - "args": [] - }, - { - "name": "live", - "description": "Subscribes for live updates of this field or fragment", - "locations": [ - "FIELD", - "FRAGMENT_DEFINITION", - "FRAGMENT_SPREAD", - "INLINE_FRAGMENT" - ], - "args": [] - } - ] - } - } - }""" +open FSharp.Data.LiteralProviders + +let [] IntrospectionSchemaJson = + TextFile<"../FSharp.Data.GraphQL.IntegrationTests/introspection.json", EnsureExists = true>.Text From 6643d98c8b43cf7fd36bbb8b6e8138558f50615f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 17 May 2026 20:09:46 +0000 Subject: [PATCH 08/40] Switch documentId to deterministic SHA-256 string Agent-Logs-Url: https://github.com/fsprojects/FSharp.Data.GraphQL/sessions/1cf6548c-e575-44c8-832d-e662b59c9121 Co-authored-by: xperiandri <2365592+xperiandri@users.noreply.github.com> --- README.md | 2 +- docs/execution-pipeline.md | 4 ++-- .../ProvidedTypesHelper.fs | 2 +- src/FSharp.Data.GraphQL.Server/Executor.fs | 15 +-------------- src/FSharp.Data.GraphQL.Server/IO.fs | 6 +++--- .../FSharp.Data.GraphQL.Shared.fsproj | 1 + .../Helpers/DocumentId.fs | 19 +++++++++++++++++++ src/FSharp.Data.GraphQL.Shared/TypeSystem.fs | 4 ++-- .../ValidationResultCache.fs | 2 +- .../integration-introspection.json | 2 +- .../introspection.json | 2 +- .../ExecutionTests.fs | 18 +++++++++--------- 12 files changed, 42 insertions(+), 35 deletions(-) create mode 100644 src/FSharp.Data.GraphQL.Shared/Helpers/DocumentId.fs diff --git a/README.md b/README.md index 39fd14c8..0cfebf08 100644 --- a/README.md +++ b/README.md @@ -230,7 +230,7 @@ printfn "Custom data: %A\n" result.CustomData // Errors: -// Custom data: map [("documentId", 1221427401)] +// Custom data: map [("documentId", "84fbf8cde7d1ce2c00b8e92e5f3472919b89c97c8c853b6c95619a0cb7fb3c6f")] ``` For more information about how to use the client provider, see the [examples folder](samples/client-provider). diff --git a/docs/execution-pipeline.md b/docs/execution-pipeline.md index 8b3cab7b..b71812c3 100644 --- a/docs/execution-pipeline.md +++ b/docs/execution-pipeline.md @@ -52,8 +52,8 @@ The execution phase can be performed using one of the two strategies: The result of a GraphQL query execution is a `GQLResponse` object with the following fields: -- `documentId`: which is the hash code of the query's AST document - it can be used to implement execution plan caching (persistent queries). +- `documentId`: deterministic SHA-256 hash (lowercase hex string) of the canonical query document - it can be used to implement execution plan caching (persistent queries). - `data`: optional, a formatted GraphQL response matching the requested query (`KeyValuePair seq`). Absent in case of an error that does not allow continuing processing and returning any GraphQL results. - `errors`: optional, contains a list of errors (`GQLProblemDetails`) that occurred during query execution. -This result can then be serialized and returned to the client. \ No newline at end of file +This result can then be serialized and returned to the client. diff --git a/src/FSharp.Data.GraphQL.Client.DesignTime/ProvidedTypesHelper.fs b/src/FSharp.Data.GraphQL.Client.DesignTime/ProvidedTypesHelper.fs index 504207bb..4cfb51b0 100644 --- a/src/FSharp.Data.GraphQL.Client.DesignTime/ProvidedTypesHelper.fs +++ b/src/FSharp.Data.GraphQL.Client.DesignTime/ProvidedTypesHelper.fs @@ -799,7 +799,7 @@ module internal Provider = match validationResult with | ValidationError msgs -> failwith (formatValidationExceptionMessage msgs) | Success -> () - let key = { DocumentId = queryAst.GetHashCode(); SchemaId = schema.GetHashCode() } + let key = { DocumentId = DocumentId.fromDocument queryAst; SchemaId = schema.GetHashCode() } let refMaker = lazy Validation.Ast.validateDocument schema queryAst if clientQueryValidation then refMaker.Force diff --git a/src/FSharp.Data.GraphQL.Server/Executor.fs b/src/FSharp.Data.GraphQL.Server/Executor.fs index 7b5ff6a3..7c722325 100644 --- a/src/FSharp.Data.GraphQL.Server/Executor.fs +++ b/src/FSharp.Data.GraphQL.Server/Executor.fs @@ -3,10 +3,7 @@ namespace FSharp.Data.GraphQL open System open System.Collections.Concurrent open System.Collections.Immutable -open System.Buffers.Binary -open System.Security.Cryptography open System.Runtime.InteropServices -open System.Text open System.Text.Json open FsToolkit.ErrorHandling @@ -83,18 +80,8 @@ type Executor<'Root>(schema: ISchema<'Root>, middlewares : IExecutorMiddleware s let middlewaresList = Seq.toList middlewares /// Generates a deterministic document identifier from the canonical query string. - /// The full SHA-256 hash is folded into 32 bits to preserve the existing int32 documentId contract. let getDocumentId (document : Document) = - let canonicalQuery = document.ToQueryString() - let queryBytes = Encoding.UTF8.GetBytes canonicalQuery - let hash = SHA256.HashData queryBytes - [ 0 .. 7 ] - |> List.fold - (fun acc index -> - let start = index * 4 - let hashChunk = BinaryPrimitives.ReadInt32BigEndian(hash.AsSpan(start, 4)) - acc ^^^ hashChunk) - 0 + DocumentId.fromDocument document let rec runMiddlewares (phaseSel : IExecutorMiddleware -> ('ctx -> ('ctx -> 'res) -> 'res) option) (initialCtx : 'ctx) diff --git a/src/FSharp.Data.GraphQL.Server/IO.fs b/src/FSharp.Data.GraphQL.Server/IO.fs index 463d8c3a..238d5ad0 100644 --- a/src/FSharp.Data.GraphQL.Server/IO.fs +++ b/src/FSharp.Data.GraphQL.Server/IO.fs @@ -10,7 +10,7 @@ open FSharp.Data.GraphQL.Types type Output = IDictionary type GQLResponse = - { DocumentId: int + { DocumentId: string Data : Output Skippable Errors : GQLProblemDetails list Skippable } static member Direct(documentId, data, errors) = @@ -27,7 +27,7 @@ type GQLResponse = Errors = Include errors } type GQLExecutionResult = - { DocumentId: int + { DocumentId: string Content : GQLResponseContent Metadata : Metadata } static member Direct(documentId, data, errors, meta) = @@ -59,7 +59,7 @@ type GQLExecutionResult = static member Error(documentId, msg, meta) = GQLExecutionResult.RequestError(documentId, [ GQLProblemDetails.Create msg ], meta) - static member ErrorFromException(documentId : int, ex : Exception, meta : Metadata) = + static member ErrorFromException(documentId : string, ex : Exception, meta : Metadata) = GQLExecutionResult.RequestError(documentId, [ GQLProblemDetails.Create (ex.Message, ex) ], meta) static member Invalid(documentId, errors, meta) = diff --git a/src/FSharp.Data.GraphQL.Shared/FSharp.Data.GraphQL.Shared.fsproj b/src/FSharp.Data.GraphQL.Shared/FSharp.Data.GraphQL.Shared.fsproj index a116e8ea..c3cdbbde 100644 --- a/src/FSharp.Data.GraphQL.Shared/FSharp.Data.GraphQL.Shared.fsproj +++ b/src/FSharp.Data.GraphQL.Shared/FSharp.Data.GraphQL.Shared.fsproj @@ -55,6 +55,7 @@ + diff --git a/src/FSharp.Data.GraphQL.Shared/Helpers/DocumentId.fs b/src/FSharp.Data.GraphQL.Shared/Helpers/DocumentId.fs new file mode 100644 index 00000000..97b7c6de --- /dev/null +++ b/src/FSharp.Data.GraphQL.Shared/Helpers/DocumentId.fs @@ -0,0 +1,19 @@ +module FSharp.Data.GraphQL.DocumentId + +open System.Globalization +open System.Security.Cryptography +open System.Text +open FSharp.Data.GraphQL.Ast +open FSharp.Data.GraphQL.Ast.Extensions + +let private formatByteAsLowerHex (value : byte) = + value.ToString("x2", CultureInfo.InvariantCulture) + +let fromDocument (document : Document) = + let canonicalQuery = document.ToQueryString() + let queryBytes = Encoding.UTF8.GetBytes canonicalQuery + use sha256 = SHA256.Create() + let hash = sha256.ComputeHash queryBytes + hash + |> Array.map formatByteAsLowerHex + |> String.concat "" diff --git a/src/FSharp.Data.GraphQL.Shared/TypeSystem.fs b/src/FSharp.Data.GraphQL.Shared/TypeSystem.fs index be8e79b5..55b3f930 100644 --- a/src/FSharp.Data.GraphQL.Shared/TypeSystem.fs +++ b/src/FSharp.Data.GraphQL.Shared/TypeSystem.fs @@ -671,7 +671,7 @@ and PlanningContext = { RootDef : ObjectDef Document : Document Operation : OperationDefinition - DocumentId : int + DocumentId : string Metadata : Metadata } @@ -888,7 +888,7 @@ and SchemaCompileContext = { /// It is used by the execution process to execute an operation. and ExecutionPlan = { /// Unique identifier of the current execution plan. - DocumentId : int + DocumentId : string /// AST definition of current operation. Operation : OperationDefinition /// Definition of the root type (either query or mutation) used by the diff --git a/src/FSharp.Data.GraphQL.Shared/ValidationResultCache.fs b/src/FSharp.Data.GraphQL.Shared/ValidationResultCache.fs index e1690260..33749439 100644 --- a/src/FSharp.Data.GraphQL.Shared/ValidationResultCache.fs +++ b/src/FSharp.Data.GraphQL.Shared/ValidationResultCache.fs @@ -4,7 +4,7 @@ open FSharp.Data.GraphQL open System type ValidationResultKey = - { DocumentId : int + { DocumentId : string SchemaId : int } type ValidationResultProducer = diff --git a/tests/FSharp.Data.GraphQL.IntegrationTests/integration-introspection.json b/tests/FSharp.Data.GraphQL.IntegrationTests/integration-introspection.json index d1d1ff82..4afcbf26 100644 --- a/tests/FSharp.Data.GraphQL.IntegrationTests/integration-introspection.json +++ b/tests/FSharp.Data.GraphQL.IntegrationTests/integration-introspection.json @@ -1,5 +1,5 @@ { - "documentId": 1890859023, + "documentId": "547d9dc982284840b3e020dfcbf43ae96cef7595afa007145ed954794363d148", "data": { "__schema": { "queryType": { diff --git a/tests/FSharp.Data.GraphQL.IntegrationTests/introspection.json b/tests/FSharp.Data.GraphQL.IntegrationTests/introspection.json index f7ebae18..b990b61c 100644 --- a/tests/FSharp.Data.GraphQL.IntegrationTests/introspection.json +++ b/tests/FSharp.Data.GraphQL.IntegrationTests/introspection.json @@ -1,5 +1,5 @@ { - "documentId": 1890859023, + "documentId": "547d9dc982284840b3e020dfcbf43ae96cef7595afa007145ed954794363d148", "data": { "__schema": { "queryType": { diff --git a/tests/FSharp.Data.GraphQL.Tests/ExecutionTests.fs b/tests/FSharp.Data.GraphQL.Tests/ExecutionTests.fs index 0b2c9564..dc7c24a4 100644 --- a/tests/FSharp.Data.GraphQL.Tests/ExecutionTests.fs +++ b/tests/FSharp.Data.GraphQL.Tests/ExecutionTests.fs @@ -385,12 +385,12 @@ let ``Execution when querying returns unique document id with response`` () = ])) let query = "query Example { a, b, a }" // Deterministic SHA-256-based documentId for canonical `query Example { a b a }`, - // folding all 32 hash bytes into an int32 via 8 big-endian chunks. + // represented as lowercase hex string. // Computed once via parse + ToQueryString + SHA-256 and kept fixed to catch regressions. - let expectedDocumentId = 154204461 + let expectedDocumentId = "84fbf8cde7d1ce2c00b8e92e5f3472919b89c97c8c853b6c95619a0cb7fb3c6f" let result1 = sync <| Executor(schema).AsyncExecute(query, getMockInputContext, { A = "aa"; B = 2 }) let result2 = sync <| Executor(schema).AsyncExecute(query, getMockInputContext, { A = "aa"; B = 2 }) - result1.DocumentId |> notEquals Unchecked.defaultof + result1.DocumentId |> notEquals Unchecked.defaultof result1.DocumentId |> equals expectedDocumentId result1.DocumentId |> equals result2.DocumentId match result1,result2 with @@ -442,7 +442,7 @@ let ``Execution handles errors: properly propagates errors`` () = let variables = { Inner = { Kaboom = null }; InnerPartialSuccess = { Kaboom = "Yes, Rico, Kaboom" } } sync <| Executor(schema).AsyncExecute("query Example { inner { kaboom } partialSuccess { kaboom } }", getMockInputContext, variables) ensureDirect result <| fun data errors -> - result.DocumentId |> notEquals Unchecked.defaultof + result.DocumentId |> notEquals Unchecked.defaultof data |> equals (upcast expectedData) errors |> equals expectedErrors @@ -480,7 +480,7 @@ let ``Execution handles errors: nullable list fields`` () = ] let result = sync <| Executor(schema).AsyncExecute("query Test { list { error } }", getMockInputContext, ()) ensureDirect result <| fun data errors -> - result.DocumentId |> notEquals Unchecked.defaultof + result.DocumentId |> notEquals Unchecked.defaultof data |> equals (upcast expectedData) errors |> equals expectedErrors @@ -516,7 +516,7 @@ let ``Execution handles errors: additional error added when exception is raised let variables = { Inner = { Kaboom = null }; InnerPartialSuccess = { Kaboom = "Yes, Rico, Kaboom" } } sync <| Executor(schema).AsyncExecute("query Example { inner { kaboom } }", getMockInputContext, variables) ensureDirect result <| fun data errors -> - result.DocumentId |> notEquals Unchecked.defaultof + result.DocumentId |> notEquals Unchecked.defaultof data |> equals (upcast expectedData) errors |> equals expectedErrors @@ -550,7 +550,7 @@ let ``Execution handles errors: additional error added when None returned from a let variables = { Inner = { Kaboom = null }; InnerPartialSuccess = { Kaboom = "Yes, Rico, Kaboom" } } sync <| Executor(schema).AsyncExecute("query Example { inner { kaboom } }", getMockInputContext, variables) ensureDirect result <| fun data errors -> - result.DocumentId |> notEquals Unchecked.defaultof + result.DocumentId |> notEquals Unchecked.defaultof data |> equals (upcast expectedData) errors |> equals expectedErrors @@ -579,7 +579,7 @@ let ``Execution handles errors: additional error added when exception is rised i let variables = { Inner = { Kaboom = "Yes, Rico, Kaboom" }; InnerPartialSuccess = { Kaboom = "Yes, Rico, Kaboom" } } sync <| Executor(schema).AsyncExecute("query Example { inner { kaboom } }", getMockInputContext, variables) ensureRequestError result <| fun errors -> - result.DocumentId |> notEquals Unchecked.defaultof + result.DocumentId |> notEquals Unchecked.defaultof errors |> equals expectedErrors [] @@ -607,5 +607,5 @@ let ``Execution handles errors: additional error added and when null returned fr let variables = { Inner = { Kaboom = "Yes, Rico, Kaboom" }; InnerPartialSuccess = { Kaboom = "Yes, Rico, Kaboom" } } sync <| Executor(schema).AsyncExecute("query Example { inner { kaboom } }", getMockInputContext, variables) ensureRequestError result <| fun errors -> - result.DocumentId |> notEquals Unchecked.defaultof + result.DocumentId |> notEquals Unchecked.defaultof errors |> equals expectedErrors From c0e61ac8773dee305ae37d126348f8137eacec6a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 17 May 2026 20:28:41 +0000 Subject: [PATCH 09/40] Apply follow-up review suggestions Agent-Logs-Url: https://github.com/fsprojects/FSharp.Data.GraphQL/sessions/f1a0e6cb-a5dd-40d9-a7dc-28e030b8e56f Co-authored-by: xperiandri <2365592+xperiandri@users.noreply.github.com> --- docs/execution-pipeline.md | 2 +- .../ProvidedTypesHelper.fs | 2 +- src/FSharp.Data.GraphQL.Server/Executor.fs | 6 +----- .../FSharp.Data.GraphQL.Shared.fsproj | 2 +- src/FSharp.Data.GraphQL.Shared/Helpers/DocumentId.fs | 7 ++----- 5 files changed, 6 insertions(+), 13 deletions(-) diff --git a/docs/execution-pipeline.md b/docs/execution-pipeline.md index b71812c3..26a948cf 100644 --- a/docs/execution-pipeline.md +++ b/docs/execution-pipeline.md @@ -52,7 +52,7 @@ The execution phase can be performed using one of the two strategies: The result of a GraphQL query execution is a `GQLResponse` object with the following fields: -- `documentId`: deterministic SHA-256 hash (lowercase hex string) of the canonical query document - it can be used to implement execution plan caching (persistent queries). +- `documentId`: deterministic SHA-256 hash (lowercase hex string) of the canonical query document – it can be used to implement execution plan caching (persistent queries). - `data`: optional, a formatted GraphQL response matching the requested query (`KeyValuePair seq`). Absent in case of an error that does not allow continuing processing and returning any GraphQL results. - `errors`: optional, contains a list of errors (`GQLProblemDetails`) that occurred during query execution. diff --git a/src/FSharp.Data.GraphQL.Client.DesignTime/ProvidedTypesHelper.fs b/src/FSharp.Data.GraphQL.Client.DesignTime/ProvidedTypesHelper.fs index 4cfb51b0..984a8335 100644 --- a/src/FSharp.Data.GraphQL.Client.DesignTime/ProvidedTypesHelper.fs +++ b/src/FSharp.Data.GraphQL.Client.DesignTime/ProvidedTypesHelper.fs @@ -799,7 +799,7 @@ module internal Provider = match validationResult with | ValidationError msgs -> failwith (formatValidationExceptionMessage msgs) | Success -> () - let key = { DocumentId = DocumentId.fromDocument queryAst; SchemaId = schema.GetHashCode() } + let key = { DocumentId = DocumentId.fromCanonicalQuery (queryAst.ToQueryString()); SchemaId = schema.GetHashCode() } let refMaker = lazy Validation.Ast.validateDocument schema queryAst if clientQueryValidation then refMaker.Force diff --git a/src/FSharp.Data.GraphQL.Server/Executor.fs b/src/FSharp.Data.GraphQL.Server/Executor.fs index 7c722325..2358af6c 100644 --- a/src/FSharp.Data.GraphQL.Server/Executor.fs +++ b/src/FSharp.Data.GraphQL.Server/Executor.fs @@ -79,10 +79,6 @@ type Executor<'Root>(schema: ISchema<'Root>, middlewares : IExecutorMiddleware s let middlewaresList = Seq.toList middlewares - /// Generates a deterministic document identifier from the canonical query string. - let getDocumentId (document : Document) = - DocumentId.fromDocument document - let rec runMiddlewares (phaseSel : IExecutorMiddleware -> ('ctx -> ('ctx -> 'res) -> 'res) option) (initialCtx : 'ctx) (onComplete : 'ctx -> 'res) @@ -143,7 +139,7 @@ type Executor<'Root>(schema: ISchema<'Root>, middlewares : IExecutorMiddleware s eval (executionPlan, data, variables, getInputContext) let createExecutionPlan (ast: Document, operationName: string option, meta : Metadata) = - let documentId = getDocumentId ast + let documentId = DocumentId.fromCanonicalQuery (ast.ToQueryString()) result { match findOperation ast operationName with | Some operation -> diff --git a/src/FSharp.Data.GraphQL.Shared/FSharp.Data.GraphQL.Shared.fsproj b/src/FSharp.Data.GraphQL.Shared/FSharp.Data.GraphQL.Shared.fsproj index c3cdbbde..e0e6fcf3 100644 --- a/src/FSharp.Data.GraphQL.Shared/FSharp.Data.GraphQL.Shared.fsproj +++ b/src/FSharp.Data.GraphQL.Shared/FSharp.Data.GraphQL.Shared.fsproj @@ -49,13 +49,13 @@ + - diff --git a/src/FSharp.Data.GraphQL.Shared/Helpers/DocumentId.fs b/src/FSharp.Data.GraphQL.Shared/Helpers/DocumentId.fs index 97b7c6de..a1a9b2ba 100644 --- a/src/FSharp.Data.GraphQL.Shared/Helpers/DocumentId.fs +++ b/src/FSharp.Data.GraphQL.Shared/Helpers/DocumentId.fs @@ -3,17 +3,14 @@ module FSharp.Data.GraphQL.DocumentId open System.Globalization open System.Security.Cryptography open System.Text -open FSharp.Data.GraphQL.Ast -open FSharp.Data.GraphQL.Ast.Extensions let private formatByteAsLowerHex (value : byte) = value.ToString("x2", CultureInfo.InvariantCulture) -let fromDocument (document : Document) = - let canonicalQuery = document.ToQueryString() +let fromCanonicalQuery (canonicalQuery : string) = let queryBytes = Encoding.UTF8.GetBytes canonicalQuery use sha256 = SHA256.Create() let hash = sha256.ComputeHash queryBytes hash - |> Array.map formatByteAsLowerHex + |> Seq.map formatByteAsLowerHex |> String.concat "" From 48362e8841bed0e5b30d44369138e1c9c22952ca Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Sun, 17 May 2026 22:43:39 +0200 Subject: [PATCH 10/40] Fixed ReadMe Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 0cfebf08..0e7b4e3f 100644 --- a/README.md +++ b/README.md @@ -230,7 +230,7 @@ printfn "Custom data: %A\n" result.CustomData // Errors: -// Custom data: map [("documentId", "84fbf8cde7d1ce2c00b8e92e5f3472919b89c97c8c853b6c95619a0cb7fb3c6f")] +// Custom data: map [("documentId", "")] ``` For more information about how to use the client provider, see the [examples folder](samples/client-provider). From 5ac9ba6841879661a7dda9e60c7fd7e14b3ab9ea Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 17 May 2026 20:46:50 +0000 Subject: [PATCH 11/40] Use ValidationResultKey directly in cache instead of GetHashCode() Agent-Logs-Url: https://github.com/fsprojects/FSharp.Data.GraphQL/sessions/987870da-d259-4995-894e-9fce715836d7 Co-authored-by: xperiandri <2365592+xperiandri@users.noreply.github.com> --- src/FSharp.Data.GraphQL.Shared/ValidationResultCache.fs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/FSharp.Data.GraphQL.Shared/ValidationResultCache.fs b/src/FSharp.Data.GraphQL.Shared/ValidationResultCache.fs index 33749439..2ea719e5 100644 --- a/src/FSharp.Data.GraphQL.Shared/ValidationResultCache.fs +++ b/src/FSharp.Data.GraphQL.Shared/ValidationResultCache.fs @@ -17,8 +17,7 @@ type IValidationResultCache = /// An in-memory cache for the results of schema/document validations, with a lifetime of 30 seconds. type MemoryValidationResultCache () = let expirationPolicy = CacheExpirationPolicy.SlidingExpiration(TimeSpan.FromSeconds 30.0) - let internalCache = MemoryCache>(expirationPolicy) + let internalCache = MemoryCache>(expirationPolicy) interface IValidationResultCache with member _.GetOrAdd producer key = - let internalKey = key.GetHashCode() - internalCache.GetOrAddResult internalKey producer + internalCache.GetOrAddResult key producer From 7fc28ff151ce8f7d3b31b914a3c4110779e2d480 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 17 May 2026 20:59:15 +0000 Subject: [PATCH 12/40] Make SchemaId deterministic using SHA-256 hash of introspection schema JSON Agent-Logs-Url: https://github.com/fsprojects/FSharp.Data.GraphQL/sessions/ce0fba11-9043-452f-b948-e03c8b644f26 Co-authored-by: xperiandri <2365592+xperiandri@users.noreply.github.com> --- .../ProvidedTypesHelper.fs | 2 +- src/FSharp.Data.GraphQL.Server/Executor.fs | 2 +- .../AstExtensions.fs | 17 +++++++++++- .../Helpers/DocumentId.fs | 7 +++++ .../ValidationResultCache.fs | 26 ++++++++++++++++++- 5 files changed, 50 insertions(+), 4 deletions(-) diff --git a/src/FSharp.Data.GraphQL.Client.DesignTime/ProvidedTypesHelper.fs b/src/FSharp.Data.GraphQL.Client.DesignTime/ProvidedTypesHelper.fs index 984a8335..b1a1dd30 100644 --- a/src/FSharp.Data.GraphQL.Client.DesignTime/ProvidedTypesHelper.fs +++ b/src/FSharp.Data.GraphQL.Client.DesignTime/ProvidedTypesHelper.fs @@ -799,7 +799,7 @@ module internal Provider = match validationResult with | ValidationError msgs -> failwith (formatValidationExceptionMessage msgs) | Success -> () - let key = { DocumentId = DocumentId.fromCanonicalQuery (queryAst.ToQueryString()); SchemaId = schema.GetHashCode() } + let key = { DocumentId = DocumentId.fromCanonicalQuery (queryAst.ToQueryString()); SchemaId = SchemaId.fromIntrospectionSchema schema } let refMaker = lazy Validation.Ast.validateDocument schema queryAst if clientQueryValidation then refMaker.Force diff --git a/src/FSharp.Data.GraphQL.Server/Executor.fs b/src/FSharp.Data.GraphQL.Server/Executor.fs index 2358af6c..59c267a9 100644 --- a/src/FSharp.Data.GraphQL.Server/Executor.fs +++ b/src/FSharp.Data.GraphQL.Server/Executor.fs @@ -161,7 +161,7 @@ type Executor<'Root>(schema: ISchema<'Root>, middlewares : IExecutorMiddleware s ErrorKind.Validation )] do! - let schemaId = schema.Introspected.GetHashCode() + let schemaId = SchemaId.fromIntrospectionSchema schema.Introspected let key = { DocumentId = documentId; SchemaId = schemaId } let producer = fun () -> Validation.Ast.validateDocument schema.Introspected ast validationCache.GetOrAdd producer key diff --git a/src/FSharp.Data.GraphQL.Shared/AstExtensions.fs b/src/FSharp.Data.GraphQL.Shared/AstExtensions.fs index dde73940..fd577398 100644 --- a/src/FSharp.Data.GraphQL.Shared/AstExtensions.fs +++ b/src/FSharp.Data.GraphQL.Shared/AstExtensions.fs @@ -104,7 +104,22 @@ type Document with /// Specify custom printing voptions for the query string. member x.ToQueryString ([] options : QueryStringPrintingOptions) = let sb = PaddedStringBuilder () - let withQuotes (s : string) = "\"" + s + "\"" + let escapeGraphQLString (s : string) = + let escaped = StringBuilder(s.Length + 2) + escaped.Append('"') |> ignore + for c in s do + match c with + | '"' -> escaped.Append("\\\"") |> ignore + | '\\' -> escaped.Append("\\\\") |> ignore + | '\b' -> escaped.Append("\\b") |> ignore + | '\f' -> escaped.Append("\\f") |> ignore + | '\n' -> escaped.Append("\\n") |> ignore + | '\r' -> escaped.Append("\\r") |> ignore + | '\t' -> escaped.Append("\\t") |> ignore + | c when c < '\u0020' -> escaped.AppendFormat("\\u{0:x4}", int c) |> ignore + | c -> escaped.Append(c) |> ignore + escaped.Append('"').ToString() + let withQuotes = escapeGraphQLString let rec printValue x = let printObjectValue (name, value) = sb.Append (name) diff --git a/src/FSharp.Data.GraphQL.Shared/Helpers/DocumentId.fs b/src/FSharp.Data.GraphQL.Shared/Helpers/DocumentId.fs index a1a9b2ba..1e5cc800 100644 --- a/src/FSharp.Data.GraphQL.Shared/Helpers/DocumentId.fs +++ b/src/FSharp.Data.GraphQL.Shared/Helpers/DocumentId.fs @@ -1,12 +1,19 @@ module FSharp.Data.GraphQL.DocumentId open System.Globalization +open System.Runtime.CompilerServices open System.Security.Cryptography open System.Text let private formatByteAsLowerHex (value : byte) = value.ToString("x2", CultureInfo.InvariantCulture) +/// +/// Computes a deterministic document identifier from a canonical GraphQL query string. +/// +/// The canonical GraphQL query string (must already be properly escaped according to GraphQL specification). +/// A lowercase hexadecimal SHA-256 hash string that uniquely identifies the document content. +[] let fromCanonicalQuery (canonicalQuery : string) = let queryBytes = Encoding.UTF8.GetBytes canonicalQuery use sha256 = SHA256.Create() diff --git a/src/FSharp.Data.GraphQL.Shared/ValidationResultCache.fs b/src/FSharp.Data.GraphQL.Shared/ValidationResultCache.fs index 2ea719e5..3701b64c 100644 --- a/src/FSharp.Data.GraphQL.Shared/ValidationResultCache.fs +++ b/src/FSharp.Data.GraphQL.Shared/ValidationResultCache.fs @@ -1,11 +1,15 @@ namespace FSharp.Data.GraphQL.Validation open FSharp.Data.GraphQL +open FSharp.Data.GraphQL.Types.Introspection open System +open System.Security.Cryptography +open System.Text +open System.Text.Json type ValidationResultKey = { DocumentId : string - SchemaId : int } + SchemaId : string } type ValidationResultProducer = unit -> ValidationResult @@ -13,6 +17,26 @@ type ValidationResultProducer = type IValidationResultCache = abstract GetOrAdd : ValidationResultProducer -> ValidationResultKey -> ValidationResult +module SchemaId = + let private formatByteAsLowerHex (value : byte) = + value.ToString("x2", System.Globalization.CultureInfo.InvariantCulture) + + /// + /// Computes a deterministic schema identifier from an introspection schema. + /// + /// The introspection schema to hash. + /// A lowercase hexadecimal SHA-256 hash string that uniquely identifies the schema structure. + let fromIntrospectionSchema (introspectionSchema : IntrospectionSchema) = + let options = JsonSerializerOptions() + options.WriteIndented <- false + options.DefaultIgnoreCondition <- System.Text.Json.Serialization.JsonIgnoreCondition.Never + let json = JsonSerializer.Serialize(introspectionSchema, options) + let jsonBytes = Encoding.UTF8.GetBytes json + use sha256 = SHA256.Create() + let hash = sha256.ComputeHash jsonBytes + hash + |> Seq.map formatByteAsLowerHex + |> String.concat "" /// An in-memory cache for the results of schema/document validations, with a lifetime of 30 seconds. type MemoryValidationResultCache () = From 54f138ef571130d06171a235571e83be1fc04b21 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 17 May 2026 21:03:47 +0000 Subject: [PATCH 13/40] Apply code review feedback: use uppercase hex for Unicode escapes and object initializer syntax Agent-Logs-Url: https://github.com/fsprojects/FSharp.Data.GraphQL/sessions/ce0fba11-9043-452f-b948-e03c8b644f26 Co-authored-by: xperiandri <2365592+xperiandri@users.noreply.github.com> --- src/FSharp.Data.GraphQL.Shared/AstExtensions.fs | 2 +- src/FSharp.Data.GraphQL.Shared/ValidationResultCache.fs | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/FSharp.Data.GraphQL.Shared/AstExtensions.fs b/src/FSharp.Data.GraphQL.Shared/AstExtensions.fs index fd577398..a03a9146 100644 --- a/src/FSharp.Data.GraphQL.Shared/AstExtensions.fs +++ b/src/FSharp.Data.GraphQL.Shared/AstExtensions.fs @@ -116,7 +116,7 @@ type Document with | '\n' -> escaped.Append("\\n") |> ignore | '\r' -> escaped.Append("\\r") |> ignore | '\t' -> escaped.Append("\\t") |> ignore - | c when c < '\u0020' -> escaped.AppendFormat("\\u{0:x4}", int c) |> ignore + | c when c < '\u0020' -> escaped.AppendFormat("\\u{0:X4}", int c) |> ignore | c -> escaped.Append(c) |> ignore escaped.Append('"').ToString() let withQuotes = escapeGraphQLString diff --git a/src/FSharp.Data.GraphQL.Shared/ValidationResultCache.fs b/src/FSharp.Data.GraphQL.Shared/ValidationResultCache.fs index 3701b64c..f62ea8ae 100644 --- a/src/FSharp.Data.GraphQL.Shared/ValidationResultCache.fs +++ b/src/FSharp.Data.GraphQL.Shared/ValidationResultCache.fs @@ -27,9 +27,10 @@ module SchemaId = /// The introspection schema to hash. /// A lowercase hexadecimal SHA-256 hash string that uniquely identifies the schema structure. let fromIntrospectionSchema (introspectionSchema : IntrospectionSchema) = - let options = JsonSerializerOptions() - options.WriteIndented <- false - options.DefaultIgnoreCondition <- System.Text.Json.Serialization.JsonIgnoreCondition.Never + let options = JsonSerializerOptions( + WriteIndented = false, + DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.Never + ) let json = JsonSerializer.Serialize(introspectionSchema, options) let jsonBytes = Encoding.UTF8.GetBytes json use sha256 = SHA256.Create() From 7b16475d75dbbd9ee1229ec2471782cc029412ab Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 17 May 2026 21:08:08 +0000 Subject: [PATCH 14/40] Optimize: cache JsonSerializerOptions and reduce StringBuilder overhead Agent-Logs-Url: https://github.com/fsprojects/FSharp.Data.GraphQL/sessions/ce0fba11-9043-452f-b948-e03c8b644f26 Co-authored-by: xperiandri <2365592+xperiandri@users.noreply.github.com> --- .../AstExtensions.fs | 22 ++++++++++--------- .../ValidationResultCache.fs | 11 +++++----- 2 files changed, 18 insertions(+), 15 deletions(-) diff --git a/src/FSharp.Data.GraphQL.Shared/AstExtensions.fs b/src/FSharp.Data.GraphQL.Shared/AstExtensions.fs index a03a9146..d53d2334 100644 --- a/src/FSharp.Data.GraphQL.Shared/AstExtensions.fs +++ b/src/FSharp.Data.GraphQL.Shared/AstExtensions.fs @@ -108,16 +108,18 @@ type Document with let escaped = StringBuilder(s.Length + 2) escaped.Append('"') |> ignore for c in s do - match c with - | '"' -> escaped.Append("\\\"") |> ignore - | '\\' -> escaped.Append("\\\\") |> ignore - | '\b' -> escaped.Append("\\b") |> ignore - | '\f' -> escaped.Append("\\f") |> ignore - | '\n' -> escaped.Append("\\n") |> ignore - | '\r' -> escaped.Append("\\r") |> ignore - | '\t' -> escaped.Append("\\t") |> ignore - | c when c < '\u0020' -> escaped.AppendFormat("\\u{0:X4}", int c) |> ignore - | c -> escaped.Append(c) |> ignore + let appendStr = + match c with + | '"' -> "\\\"" + | '\\' -> "\\\\" + | '\b' -> "\\b" + | '\f' -> "\\f" + | '\n' -> "\\n" + | '\r' -> "\\r" + | '\t' -> "\\t" + | c when c < '\u0020' -> sprintf "\\u%04X" (int c) + | c -> string c + escaped.Append(appendStr) |> ignore escaped.Append('"').ToString() let withQuotes = escapeGraphQLString let rec printValue x = diff --git a/src/FSharp.Data.GraphQL.Shared/ValidationResultCache.fs b/src/FSharp.Data.GraphQL.Shared/ValidationResultCache.fs index f62ea8ae..c27adccc 100644 --- a/src/FSharp.Data.GraphQL.Shared/ValidationResultCache.fs +++ b/src/FSharp.Data.GraphQL.Shared/ValidationResultCache.fs @@ -21,17 +21,18 @@ module SchemaId = let private formatByteAsLowerHex (value : byte) = value.ToString("x2", System.Globalization.CultureInfo.InvariantCulture) + let private jsonOptions = JsonSerializerOptions( + WriteIndented = false, + DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.Never + ) + /// /// Computes a deterministic schema identifier from an introspection schema. /// /// The introspection schema to hash. /// A lowercase hexadecimal SHA-256 hash string that uniquely identifies the schema structure. let fromIntrospectionSchema (introspectionSchema : IntrospectionSchema) = - let options = JsonSerializerOptions( - WriteIndented = false, - DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.Never - ) - let json = JsonSerializer.Serialize(introspectionSchema, options) + let json = JsonSerializer.Serialize(introspectionSchema, jsonOptions) let jsonBytes = Encoding.UTF8.GetBytes json use sha256 = SHA256.Create() let hash = sha256.ComputeHash jsonBytes From d16133fbb28dfd1006e6d2e9b256a2099098e362 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 17 May 2026 21:11:53 +0000 Subject: [PATCH 15/40] Minor optimization: avoid sprintf in character loop for better performance Agent-Logs-Url: https://github.com/fsprojects/FSharp.Data.GraphQL/sessions/ce0fba11-9043-452f-b948-e03c8b644f26 Co-authored-by: xperiandri <2365592+xperiandri@users.noreply.github.com> --- src/FSharp.Data.GraphQL.Shared/AstExtensions.fs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/FSharp.Data.GraphQL.Shared/AstExtensions.fs b/src/FSharp.Data.GraphQL.Shared/AstExtensions.fs index d53d2334..ac11c972 100644 --- a/src/FSharp.Data.GraphQL.Shared/AstExtensions.fs +++ b/src/FSharp.Data.GraphQL.Shared/AstExtensions.fs @@ -117,7 +117,9 @@ type Document with | '\n' -> "\\n" | '\r' -> "\\r" | '\t' -> "\\t" - | c when c < '\u0020' -> sprintf "\\u%04X" (int c) + | c when c < '\u0020' -> + let hex = (int c).ToString("X4", CultureInfo.InvariantCulture) + "\\u" + hex | c -> string c escaped.Append(appendStr) |> ignore escaped.Append('"').ToString() From 8399f0dcb9a5d3a5395aad5c48681169b5a5a9da Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 17 May 2026 21:15:28 +0000 Subject: [PATCH 16/40] Fix trailing whitespace in string escaping code Agent-Logs-Url: https://github.com/fsprojects/FSharp.Data.GraphQL/sessions/ce0fba11-9043-452f-b948-e03c8b644f26 Co-authored-by: xperiandri <2365592+xperiandri@users.noreply.github.com> --- src/FSharp.Data.GraphQL.Shared/AstExtensions.fs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/FSharp.Data.GraphQL.Shared/AstExtensions.fs b/src/FSharp.Data.GraphQL.Shared/AstExtensions.fs index ac11c972..274d8cce 100644 --- a/src/FSharp.Data.GraphQL.Shared/AstExtensions.fs +++ b/src/FSharp.Data.GraphQL.Shared/AstExtensions.fs @@ -110,14 +110,14 @@ type Document with for c in s do let appendStr = match c with - | '"' -> "\\\"" + | '"' -> "\\\"" | '\\' -> "\\\\" | '\b' -> "\\b" | '\f' -> "\\f" | '\n' -> "\\n" | '\r' -> "\\r" | '\t' -> "\\t" - | c when c < '\u0020' -> + | c when c < '\u0020' -> let hex = (int c).ToString("X4", CultureInfo.InvariantCulture) "\\u" + hex | c -> string c From 18d07151c061fbbfdcb377454429f73c4ce58157 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 17 May 2026 21:19:27 +0000 Subject: [PATCH 17/40] Ensure deterministic JSON serialization and use lowercase hex for Unicode escapes Agent-Logs-Url: https://github.com/fsprojects/FSharp.Data.GraphQL/sessions/ce0fba11-9043-452f-b948-e03c8b644f26 Co-authored-by: xperiandri <2365592+xperiandri@users.noreply.github.com> --- src/FSharp.Data.GraphQL.Shared/AstExtensions.fs | 2 +- src/FSharp.Data.GraphQL.Shared/ValidationResultCache.fs | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/FSharp.Data.GraphQL.Shared/AstExtensions.fs b/src/FSharp.Data.GraphQL.Shared/AstExtensions.fs index 274d8cce..3d793da9 100644 --- a/src/FSharp.Data.GraphQL.Shared/AstExtensions.fs +++ b/src/FSharp.Data.GraphQL.Shared/AstExtensions.fs @@ -118,7 +118,7 @@ type Document with | '\r' -> "\\r" | '\t' -> "\\t" | c when c < '\u0020' -> - let hex = (int c).ToString("X4", CultureInfo.InvariantCulture) + let hex = (int c).ToString("x4", CultureInfo.InvariantCulture) "\\u" + hex | c -> string c escaped.Append(appendStr) |> ignore diff --git a/src/FSharp.Data.GraphQL.Shared/ValidationResultCache.fs b/src/FSharp.Data.GraphQL.Shared/ValidationResultCache.fs index c27adccc..adc3c99b 100644 --- a/src/FSharp.Data.GraphQL.Shared/ValidationResultCache.fs +++ b/src/FSharp.Data.GraphQL.Shared/ValidationResultCache.fs @@ -23,7 +23,9 @@ module SchemaId = let private jsonOptions = JsonSerializerOptions( WriteIndented = false, - DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.Never + DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.Never, + PropertyNamingPolicy = null, + Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping ) /// From 7d0f33a6e7fd422499183f5d04b7902a06b1b032 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 17 May 2026 21:23:41 +0000 Subject: [PATCH 18/40] Add documentation for UnsafeRelaxedJsonEscaping usage and SHA256 instance creation Agent-Logs-Url: https://github.com/fsprojects/FSharp.Data.GraphQL/sessions/ce0fba11-9043-452f-b948-e03c8b644f26 Co-authored-by: xperiandri <2365592+xperiandri@users.noreply.github.com> --- src/FSharp.Data.GraphQL.Shared/ValidationResultCache.fs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/FSharp.Data.GraphQL.Shared/ValidationResultCache.fs b/src/FSharp.Data.GraphQL.Shared/ValidationResultCache.fs index adc3c99b..ec647ad1 100644 --- a/src/FSharp.Data.GraphQL.Shared/ValidationResultCache.fs +++ b/src/FSharp.Data.GraphQL.Shared/ValidationResultCache.fs @@ -21,6 +21,8 @@ module SchemaId = let private formatByteAsLowerHex (value : byte) = value.ToString("x2", System.Globalization.CultureInfo.InvariantCulture) + // Note: UnsafeRelaxedJsonEscaping is used here only for deterministic hashing, + // not for output to untrusted contexts. The JSON is never exposed externally. let private jsonOptions = JsonSerializerOptions( WriteIndented = false, DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.Never, @@ -36,6 +38,8 @@ module SchemaId = let fromIntrospectionSchema (introspectionSchema : IntrospectionSchema) = let json = JsonSerializer.Serialize(introspectionSchema, jsonOptions) let jsonBytes = Encoding.UTF8.GetBytes json + // Note: Creating SHA256 instance per call is acceptable since schema ID computation + // happens infrequently (typically once per schema during validation cache key creation) use sha256 = SHA256.Create() let hash = sha256.ComputeHash jsonBytes hash From fca3129b1d28a1d45eebc99b15c8c40eafb8fc3a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 17 May 2026 21:30:15 +0000 Subject: [PATCH 19/40] Optimize SchemaId serialization and improve code formatting Agent-Logs-Url: https://github.com/fsprojects/FSharp.Data.GraphQL/sessions/fec46b15-afce-40da-85b4-306eae430ce0 Co-authored-by: xperiandri <2365592+xperiandri@users.noreply.github.com> --- .../ValidationResultCache.fs | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/src/FSharp.Data.GraphQL.Shared/ValidationResultCache.fs b/src/FSharp.Data.GraphQL.Shared/ValidationResultCache.fs index ec647ad1..c05f23c6 100644 --- a/src/FSharp.Data.GraphQL.Shared/ValidationResultCache.fs +++ b/src/FSharp.Data.GraphQL.Shared/ValidationResultCache.fs @@ -3,9 +3,12 @@ namespace FSharp.Data.GraphQL.Validation open FSharp.Data.GraphQL open FSharp.Data.GraphQL.Types.Introspection open System +open System.IO open System.Security.Cryptography open System.Text +open System.Text.Encodings.Web open System.Text.Json +open System.Text.Json.Serialization type ValidationResultKey = { DocumentId : string @@ -18,6 +21,7 @@ type IValidationResultCache = abstract GetOrAdd : ValidationResultProducer -> ValidationResultKey -> ValidationResult module SchemaId = + let private formatByteAsLowerHex (value : byte) = value.ToString("x2", System.Globalization.CultureInfo.InvariantCulture) @@ -25,9 +29,9 @@ module SchemaId = // not for output to untrusted contexts. The JSON is never exposed externally. let private jsonOptions = JsonSerializerOptions( WriteIndented = false, - DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.Never, + DefaultIgnoreCondition = JsonIgnoreCondition.Never, PropertyNamingPolicy = null, - Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping ) /// @@ -36,12 +40,13 @@ module SchemaId = /// The introspection schema to hash. /// A lowercase hexadecimal SHA-256 hash string that uniquely identifies the schema structure. let fromIntrospectionSchema (introspectionSchema : IntrospectionSchema) = - let json = JsonSerializer.Serialize(introspectionSchema, jsonOptions) - let jsonBytes = Encoding.UTF8.GetBytes json + use stream = new MemoryStream() + JsonSerializer.Serialize(stream, introspectionSchema, jsonOptions) + stream.Position <- 0L // Note: Creating SHA256 instance per call is acceptable since schema ID computation // happens infrequently (typically once per schema during validation cache key creation) use sha256 = SHA256.Create() - let hash = sha256.ComputeHash jsonBytes + let hash = sha256.ComputeHash stream hash |> Seq.map formatByteAsLowerHex |> String.concat "" From 98ebcfebc09f67874207aabed1d176719e322063 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 17 May 2026 21:41:04 +0000 Subject: [PATCH 20/40] Add comprehensive test coverage for documentId and validation cache Agent-Logs-Url: https://github.com/fsprojects/FSharp.Data.GraphQL/sessions/fec46b15-afce-40da-85b4-306eae430ce0 Co-authored-by: xperiandri <2365592+xperiandri@users.noreply.github.com> --- .../ValidationResultCache.fs | 3 +- .../DocumentIdTests.fs | 65 ++++++++ .../ExecutionTests.fs | 48 ++++++ .../FSharp.Data.GraphQL.Tests.fsproj | 2 + .../ValidationCacheTests.fs | 142 ++++++++++++++++++ 5 files changed, 258 insertions(+), 2 deletions(-) create mode 100644 tests/FSharp.Data.GraphQL.Tests/DocumentIdTests.fs create mode 100644 tests/FSharp.Data.GraphQL.Tests/ValidationCacheTests.fs diff --git a/src/FSharp.Data.GraphQL.Shared/ValidationResultCache.fs b/src/FSharp.Data.GraphQL.Shared/ValidationResultCache.fs index c05f23c6..40fa8d27 100644 --- a/src/FSharp.Data.GraphQL.Shared/ValidationResultCache.fs +++ b/src/FSharp.Data.GraphQL.Shared/ValidationResultCache.fs @@ -42,11 +42,10 @@ module SchemaId = let fromIntrospectionSchema (introspectionSchema : IntrospectionSchema) = use stream = new MemoryStream() JsonSerializer.Serialize(stream, introspectionSchema, jsonOptions) - stream.Position <- 0L // Note: Creating SHA256 instance per call is acceptable since schema ID computation // happens infrequently (typically once per schema during validation cache key creation) use sha256 = SHA256.Create() - let hash = sha256.ComputeHash stream + let hash = sha256.ComputeHash(stream.ToArray()) hash |> Seq.map formatByteAsLowerHex |> String.concat "" diff --git a/tests/FSharp.Data.GraphQL.Tests/DocumentIdTests.fs b/tests/FSharp.Data.GraphQL.Tests/DocumentIdTests.fs new file mode 100644 index 00000000..6bc1e05e --- /dev/null +++ b/tests/FSharp.Data.GraphQL.Tests/DocumentIdTests.fs @@ -0,0 +1,65 @@ +// The MIT License (MIT) +// Copyright (c) 2016 Bazinga Technologies Inc + +module FSharp.Data.GraphQL.Tests.DocumentIdTests + +open Xunit +open FSharp.Data.GraphQL + +[] +let ``DocumentId.fromCanonicalQuery produces deterministic hash`` () = + let query = "query Example { a b }" + let hash1 = DocumentId.fromCanonicalQuery query + let hash2 = DocumentId.fromCanonicalQuery query + equals hash1 hash2 + equals 64 hash1.Length // SHA-256 hex string is 64 chars + +[] +let ``DocumentId.fromCanonicalQuery produces different hashes for different queries`` () = + let query1 = "query Example1 { a }" + let query2 = "query Example2 { b }" + let hash1 = DocumentId.fromCanonicalQuery query1 + let hash2 = DocumentId.fromCanonicalQuery query2 + notEquals hash1 hash2 + +[] +let ``DocumentId.fromCanonicalQuery handles empty string`` () = + let query = "" + let hash = DocumentId.fromCanonicalQuery query + equals 64 hash.Length + // SHA-256 of empty string + equals "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" hash + +[] +let ``DocumentId.fromCanonicalQuery produces lowercase hex`` () = + let query = "query Test { field }" + let hash = DocumentId.fromCanonicalQuery query + equals hash (hash.ToLowerInvariant()) + Assert.True(hash |> Seq.forall (fun c -> (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f'))) + +[] +let ``DocumentId.fromCanonicalQuery handles special characters in strings`` () = + // Test with escaped characters that should be in the canonical form + let query1 = """query Test { field(arg: "test\"quote") }""" + let query2 = """query Test { field(arg: "test\nline") }""" + let query3 = """query Test { field(arg: "test\ttab") }""" + let hash1 = DocumentId.fromCanonicalQuery query1 + let hash2 = DocumentId.fromCanonicalQuery query2 + let hash3 = DocumentId.fromCanonicalQuery query3 + // All should produce valid hashes + equals 64 hash1.Length + equals 64 hash2.Length + equals 64 hash3.Length + // All should be different + notEquals hash1 hash2 + notEquals hash2 hash3 + notEquals hash1 hash3 + +[] +let ``DocumentId.fromCanonicalQuery is consistent with known SHA-256 values`` () = + // Test a simple known case + let query = "test" + let hash = DocumentId.fromCanonicalQuery query + // SHA-256 of "test" + equals "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08" hash + diff --git a/tests/FSharp.Data.GraphQL.Tests/ExecutionTests.fs b/tests/FSharp.Data.GraphQL.Tests/ExecutionTests.fs index dc7c24a4..cc28c27f 100644 --- a/tests/FSharp.Data.GraphQL.Tests/ExecutionTests.fs +++ b/tests/FSharp.Data.GraphQL.Tests/ExecutionTests.fs @@ -399,6 +399,54 @@ let ``Execution when querying returns unique document id with response`` () = equals errors1 errors2 | response -> fail $"Expected a 'Direct' GQLResponse but got\n{response}" +[] +let ``Execution documentId handles escaped string values correctly`` () = + let schema = + Schema(Define.Object( + "Type", [ + Define.Field("a", StringType, fun _ x -> x.A) + Define.Field("b", IntType, fun _ x -> x.B) + ])) + // Query with string containing special characters that need escaping + let query = """query Example { a(arg: "test\"quote\nline\ttab\\backslash") }""" + let result = sync <| Executor(schema).AsyncExecute(query, getMockInputContext, { A = "test"; B = 1 }) + // DocumentId should be deterministic and not empty + result.DocumentId |> notEquals Unchecked.defaultof + result.DocumentId.Length |> equals 64 // SHA-256 hex string is always 64 chars + +[] +let ``Execution documentId is different for different queries`` () = + let schema = + Schema(Define.Object( + "Type", [ + Define.Field("a", StringType, fun _ x -> x.A) + Define.Field("b", IntType, fun _ x -> x.B) + ])) + let query1 = "query Example1 { a }" + let query2 = "query Example2 { b }" + let result1 = sync <| Executor(schema).AsyncExecute(query1, getMockInputContext, { A = "aa"; B = 2 }) + let result2 = sync <| Executor(schema).AsyncExecute(query2, getMockInputContext, { A = "aa"; B = 2 }) + result1.DocumentId |> notEquals result2.DocumentId + +[] +let ``Execution documentId is same for semantically identical queries`` () = + let schema = + Schema(Define.Object( + "Type", [ + Define.Field("a", StringType, fun _ x -> x.A) + Define.Field("b", IntType, fun _ x -> x.B) + ])) + // Same query with different whitespace/formatting + let query1 = "query Example { a b }" + let query2 = "query Example{a b}" + let query3 = "query Example { a, b }" + let result1 = sync <| Executor(schema).AsyncExecute(query1, getMockInputContext, { A = "aa"; B = 2 }) + let result2 = sync <| Executor(schema).AsyncExecute(query2, getMockInputContext, { A = "aa"; B = 2 }) + let result3 = sync <| Executor(schema).AsyncExecute(query3, getMockInputContext, { A = "aa"; B = 2 }) + // All should produce the same documentId since they parse to the same AST + result1.DocumentId |> equals result2.DocumentId + result1.DocumentId |> equals result3.DocumentId + type InnerNullableTest = { Kaboom : string } type NullableTest = { Inner : InnerNullableTest 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 1eda8da3..57ab028a 100644 --- a/tests/FSharp.Data.GraphQL.Tests/FSharp.Data.GraphQL.Tests.fsproj +++ b/tests/FSharp.Data.GraphQL.Tests/FSharp.Data.GraphQL.Tests.fsproj @@ -30,6 +30,8 @@ + + diff --git a/tests/FSharp.Data.GraphQL.Tests/ValidationCacheTests.fs b/tests/FSharp.Data.GraphQL.Tests/ValidationCacheTests.fs new file mode 100644 index 00000000..336246d3 --- /dev/null +++ b/tests/FSharp.Data.GraphQL.Tests/ValidationCacheTests.fs @@ -0,0 +1,142 @@ +// The MIT License (MIT) +// Copyright (c) 2016 Bazinga Technologies Inc + +module FSharp.Data.GraphQL.Tests.ValidationCacheTests + +open Xunit +open FSharp.Data.GraphQL +open FSharp.Data.GraphQL.Validation +open System.Threading + +[] +let ``MemoryValidationResultCache caches results for same key`` () = + let cache = MemoryValidationResultCache() :> IValidationResultCache + let mutable callCount = 0 + let producer () = + Interlocked.Increment(&callCount) |> ignore + Success + + let key = { DocumentId = "doc1"; SchemaId = "schema1" } + + // First call should invoke producer + let result1 = cache.GetOrAdd producer key + equals 1 callCount + equals Success result1 + + // Second call with same key should NOT invoke producer (cached) + let result2 = cache.GetOrAdd producer key + equals 1 callCount // Still 1, not 2 + equals Success result2 + +[] +let ``MemoryValidationResultCache uses different cache entries for different DocumentIds`` () = + let cache = MemoryValidationResultCache() :> IValidationResultCache + let mutable callCount = 0 + let producer () = + Interlocked.Increment(&callCount) |> ignore + Success + + let key1 = { DocumentId = "doc1"; SchemaId = "schema1" } + let key2 = { DocumentId = "doc2"; SchemaId = "schema1" } + + // First call + let result1 = cache.GetOrAdd producer key1 + equals 1 callCount + + // Second call with different DocumentId should invoke producer again + let result2 = cache.GetOrAdd producer key2 + equals 2 callCount // Should be 2 now + +[] +let ``MemoryValidationResultCache uses different cache entries for different SchemaIds`` () = + let cache = MemoryValidationResultCache() :> IValidationResultCache + let mutable callCount = 0 + let producer () = + Interlocked.Increment(&callCount) |> ignore + Success + + let key1 = { DocumentId = "doc1"; SchemaId = "schema1" } + let key2 = { DocumentId = "doc1"; SchemaId = "schema2" } + + // First call + let result1 = cache.GetOrAdd producer key1 + equals 1 callCount + + // Second call with different SchemaId should invoke producer again + let result2 = cache.GetOrAdd producer key2 + equals 2 callCount // Should be 2 now + +[] +let ``MemoryValidationResultCache distinguishes keys with same hash code`` () = + let cache = MemoryValidationResultCache() :> IValidationResultCache + + // Create two different keys that might have hash collisions + // Using very similar but different strings + let key1 = { DocumentId = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; SchemaId = "schema1" } + let key2 = { DocumentId = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaab"; SchemaId = "schema1" } + + let mutable callCount = 0 + let producer () = + Interlocked.Increment(&callCount) |> ignore + Success + + // First call + let result1 = cache.GetOrAdd producer key1 + equals 1 callCount + + // Second call with different key should invoke producer even if hash codes collide + let result2 = cache.GetOrAdd producer key2 + equals 2 callCount // Should be 2, proving we use full key not just hash + +[] +let ``MemoryValidationResultCache caches error results`` () = + let cache = MemoryValidationResultCache() :> IValidationResultCache + let mutable callCount = 0 + let error = GQLProblemDetails.Create("Test error") + let producer () = + Interlocked.Increment(&callCount) |> ignore + ValidationError [error] + + let key = { DocumentId = "doc1"; SchemaId = "schema1" } + + // First call should invoke producer + let result1 = cache.GetOrAdd producer key + equals 1 callCount + match result1 with + | ValidationError errors -> equals 1 (Seq.length errors) + | Success -> fail "Expected ValidationError" + + // Second call with same key should NOT invoke producer (cached) + let result2 = cache.GetOrAdd producer key + equals 1 callCount // Still 1, not 2 + match result2 with + | ValidationError errors -> equals 1 (Seq.length errors) + | Success -> fail "Expected ValidationError" + +[] +let ``MemoryValidationResultCache handles concurrent access`` () = + let cache = MemoryValidationResultCache() :> IValidationResultCache + let mutable callCount = 0 + let producer () = + Interlocked.Increment(&callCount) |> ignore + Thread.Sleep(10) // Simulate some work + Success + + let key = { DocumentId = "doc1"; SchemaId = "schema1" } + + // Call cache from multiple threads simultaneously + let tasks = + [1..10] + |> List.map (fun _ -> + async { + return cache.GetOrAdd producer key + }) + + let results = tasks |> Async.Parallel |> Async.RunSynchronously + + // All results should be Success + results |> Array.iter (fun r -> equals Success r) + + // Producer should be called at least once, but possibly more due to race conditions + // The important thing is it's not called 10 times + Assert.True(callCount >= 1 && callCount < 10, $"Expected callCount between 1 and 9, got {callCount}") From 568a6307259541c8b41ccea0eafd77be2483be88 Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Sun, 17 May 2026 23:56:29 +0200 Subject: [PATCH 21/40] AI review fix Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/FSharp.Data.GraphQL.Shared/ValidationResultCache.fs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/FSharp.Data.GraphQL.Shared/ValidationResultCache.fs b/src/FSharp.Data.GraphQL.Shared/ValidationResultCache.fs index 40fa8d27..6f3fff83 100644 --- a/src/FSharp.Data.GraphQL.Shared/ValidationResultCache.fs +++ b/src/FSharp.Data.GraphQL.Shared/ValidationResultCache.fs @@ -39,6 +39,7 @@ module SchemaId = /// /// The introspection schema to hash. /// A lowercase hexadecimal SHA-256 hash string that uniquely identifies the schema structure. + [] let fromIntrospectionSchema (introspectionSchema : IntrospectionSchema) = use stream = new MemoryStream() JsonSerializer.Serialize(stream, introspectionSchema, jsonOptions) From 7565c5c6bf6d3b71f95f4ef76368ddd6a40d1a7d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 17 May 2026 21:59:12 +0000 Subject: [PATCH 22/40] Cache schema ID at Executor level to avoid recomputing on every request Agent-Logs-Url: https://github.com/fsprojects/FSharp.Data.GraphQL/sessions/38a5449a-9904-46f6-b33a-acf9f32a7151 Co-authored-by: xperiandri <2365592+xperiandri@users.noreply.github.com> --- src/FSharp.Data.GraphQL.Server/Executor.fs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/FSharp.Data.GraphQL.Server/Executor.fs b/src/FSharp.Data.GraphQL.Server/Executor.fs index 59c267a9..0414fd24 100644 --- a/src/FSharp.Data.GraphQL.Server/Executor.fs +++ b/src/FSharp.Data.GraphQL.Server/Executor.fs @@ -66,6 +66,9 @@ type ExecutorMiddleware(?compile, ?postCompile, ?plan, ?execute) = /// An optional pre-existing validation cache can be supplied. If not, one is created and used internally. type Executor<'Root>(schema: ISchema<'Root>, middlewares : IExecutorMiddleware seq, [] validationCache : IValidationResultCache voption) = let validationCache = validationCache |> ValueOption.defaultWith (fun () -> upcast MemoryValidationResultCache()) + + // Compute schema ID once and cache it for the lifetime of this Executor instance + let schemaId = SchemaId.fromIntrospectionSchema schema.Introspected let fieldExecuteMap = FieldExecuteMap(compileField) @@ -161,7 +164,6 @@ type Executor<'Root>(schema: ISchema<'Root>, middlewares : IExecutorMiddleware s ErrorKind.Validation )] do! - let schemaId = SchemaId.fromIntrospectionSchema schema.Introspected let key = { DocumentId = documentId; SchemaId = schemaId } let producer = fun () -> Validation.Ast.validateDocument schema.Introspected ast validationCache.GetOrAdd producer key From 7de25ba13860dca4f97352e9aae23cdf3809cc28 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 17 May 2026 22:06:26 +0000 Subject: [PATCH 23/40] Fix schema ID computation to run after middleware compilation Agent-Logs-Url: https://github.com/fsprojects/FSharp.Data.GraphQL/sessions/38a5449a-9904-46f6-b33a-acf9f32a7151 Co-authored-by: xperiandri <2365592+xperiandri@users.noreply.github.com> --- src/FSharp.Data.GraphQL.Server/Executor.fs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/FSharp.Data.GraphQL.Server/Executor.fs b/src/FSharp.Data.GraphQL.Server/Executor.fs index 0414fd24..e3b4a698 100644 --- a/src/FSharp.Data.GraphQL.Server/Executor.fs +++ b/src/FSharp.Data.GraphQL.Server/Executor.fs @@ -66,9 +66,6 @@ type ExecutorMiddleware(?compile, ?postCompile, ?plan, ?execute) = /// An optional pre-existing validation cache can be supplied. If not, one is created and used internally. type Executor<'Root>(schema: ISchema<'Root>, middlewares : IExecutorMiddleware seq, [] validationCache : IValidationResultCache voption) = let validationCache = validationCache |> ValueOption.defaultWith (fun () -> upcast MemoryValidationResultCache()) - - // Compute schema ID once and cache it for the lifetime of this Executor instance - let schemaId = SchemaId.fromIntrospectionSchema schema.Introspected let fieldExecuteMap = FieldExecuteMap(compileField) @@ -103,6 +100,9 @@ type Executor<'Root>(schema: ISchema<'Root>, middlewares : IExecutorMiddleware s match Validation.Types.validateTypeMap schema.TypeMap with | Success -> () | ValidationError errors -> raise (GQLMessageException (System.String.Join("\n", errors))) + + // Compute schema ID once after middleware has run and cache it for the lifetime of this Executor instance + let schemaId = SchemaId.fromIntrospectionSchema schema.Introspected let eval (executionPlan: ExecutionPlan, data: 'Root option, variables: ImmutableDictionary, getInputContext : InputExecutionContextProvider): Async = let documentId = executionPlan.DocumentId From 42b548727ca9582a7912454d7552c403a65bb06a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 17 May 2026 22:11:59 +0000 Subject: [PATCH 24/40] Add comprehensive test coverage for ToQueryString string escaping Agent-Logs-Url: https://github.com/fsprojects/FSharp.Data.GraphQL/sessions/5ee4bb8d-2ee3-4b01-ad1e-b1052d41e738 Co-authored-by: xperiandri <2365592+xperiandri@users.noreply.github.com> --- .../AstExtensionsTests.fs | 121 ++++++++++++++++++ 1 file changed, 121 insertions(+) diff --git a/tests/FSharp.Data.GraphQL.Tests/AstExtensionsTests.fs b/tests/FSharp.Data.GraphQL.Tests/AstExtensionsTests.fs index 0b4e8e37..4bfd2f56 100644 --- a/tests/FSharp.Data.GraphQL.Tests/AstExtensionsTests.fs +++ b/tests/FSharp.Data.GraphQL.Tests/AstExtensionsTests.fs @@ -4,6 +4,7 @@ module FSharp.Data.GraphQL.Tests.AstExtensionsTests open Xunit +open FSharp.Data.GraphQL open FSharp.Data.GraphQL.Parser open FSharp.Data.GraphQL.Ast.Extensions @@ -356,3 +357,123 @@ let ``Should generate information map correctly`` () = ] actual |> equals expected + +[] +let ``ToQueryString escapes double quotes in string values`` () = + let query = """query q { hero(name: "test\"quote") }""" + let document = parse query + let printed = document.ToQueryString() + // Verify the printed query contains the escaped quote + Assert.Contains("\\\"", printed) + // Verify it can be parsed back + let reparsed = parse printed + equals (document.ToQueryString()) (reparsed.ToQueryString()) + +[] +let ``ToQueryString escapes backslashes in string values`` () = + let query = """query q { hero(path: "C:\\Users\\test") }""" + let document = parse query + let printed = document.ToQueryString() + // Verify the printed query contains escaped backslashes + Assert.Contains("\\\\", printed) + // Verify it can be parsed back + let reparsed = parse printed + equals (document.ToQueryString()) (reparsed.ToQueryString()) + +[] +let ``ToQueryString escapes newlines in string values`` () = + let query = """query q { hero(text: "line1\nline2") }""" + let document = parse query + let printed = document.ToQueryString() + // Verify the printed query contains the escaped newline within the string value + Assert.Contains("\\n", printed) + // Verify the string value itself doesn't contain an actual newline (it should be escaped) + // The printed output will have formatting newlines, but the string value should have \n + Assert.Contains("\"line1\\nline2\"", printed) + // Verify it can be parsed back + let reparsed = parse printed + equals (document.ToQueryString()) (reparsed.ToQueryString()) + +[] +let ``ToQueryString escapes tabs in string values`` () = + let query = """query q { hero(text: "col1\tcol2") }""" + let document = parse query + let printed = document.ToQueryString() + // Verify the printed query contains the escaped tab within the string value + Assert.Contains("\\t", printed) + Assert.Contains("\"col1\\tcol2\"", printed) + // Verify it can be parsed back + let reparsed = parse printed + equals (document.ToQueryString()) (reparsed.ToQueryString()) + +[] +let ``ToQueryString escapes carriage returns in string values`` () = + let query = """query q { hero(text: "line1\rline2") }""" + let document = parse query + let printed = document.ToQueryString() + // Verify the printed query contains the escaped carriage return + Assert.Contains("\\r", printed) + // Verify it can be parsed back + let reparsed = parse printed + equals (document.ToQueryString()) (reparsed.ToQueryString()) + +[] +let ``ToQueryString escapes backspace in string values`` () = + let query = """query q { hero(text: "test\bback") }""" + let document = parse query + let printed = document.ToQueryString() + // Verify the printed query contains the escaped backspace + Assert.Contains("\\b", printed) + // Verify it can be parsed back + let reparsed = parse printed + equals (document.ToQueryString()) (reparsed.ToQueryString()) + +[] +let ``ToQueryString escapes form feed in string values`` () = + let query = """query q { hero(text: "page1\fpage2") }""" + let document = parse query + let printed = document.ToQueryString() + // Verify the printed query contains the escaped form feed + Assert.Contains("\\f", printed) + // Verify it can be parsed back + let reparsed = parse printed + equals (document.ToQueryString()) (reparsed.ToQueryString()) + +[] +let ``ToQueryString escapes control characters as unicode in string values`` () = + // Test with a control character (e.g., ASCII 0x01) + let query = "query q { hero(text: \"test\u0001control\") }" + let document = parse query + let printed = document.ToQueryString() + // Verify the printed query contains the unicode escape (lowercase hex) + Assert.Contains("\\u0001", printed) + // Verify it can be parsed back + let reparsed = parse printed + equals (document.ToQueryString()) (reparsed.ToQueryString()) + +[] +let ``ToQueryString escapes multiple special characters correctly`` () = + let query = """query q { hero(text: "quote:\"newline:\nslash:\\tab:\t") }""" + let document = parse query + let printed = document.ToQueryString() + // Verify all escapes are present + Assert.Contains("\\\"", printed) + Assert.Contains("\\n", printed) + Assert.Contains("\\\\", printed) + Assert.Contains("\\t", printed) + // Verify it can be parsed back + let reparsed = parse printed + equals (document.ToQueryString()) (reparsed.ToQueryString()) + +[] +let ``ToQueryString produces deterministic output for escaped strings`` () = + // This test verifies that the same query with escaped strings produces + // the same canonical output, which is critical for documentId stability + let query = """query Test { field(arg: "test\"quote\nline\ttab\\back") }""" + let document = parse query + let printed1 = document.ToQueryString() + let printed2 = document.ToQueryString() + equals printed1 printed2 + // Verify the documentId is deterministic + let documentId = DocumentId.fromCanonicalQuery printed1 + equals 64 documentId.Length // SHA-256 hex string is always 64 chars From 6aa547d3ca689921ae599828b6b1c0d5d646bee2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 17 May 2026 22:29:08 +0000 Subject: [PATCH 25/40] Simplify SchemaId generation to use GetHashCode Agent-Logs-Url: https://github.com/fsprojects/FSharp.Data.GraphQL/sessions/70eb1609-2aab-4948-8d69-52c900443420 Co-authored-by: xperiandri <2365592+xperiandri@users.noreply.github.com> --- .../ValidationResultCache.fs | 35 +++---------------- 1 file changed, 4 insertions(+), 31 deletions(-) diff --git a/src/FSharp.Data.GraphQL.Shared/ValidationResultCache.fs b/src/FSharp.Data.GraphQL.Shared/ValidationResultCache.fs index 6f3fff83..62fb41e0 100644 --- a/src/FSharp.Data.GraphQL.Shared/ValidationResultCache.fs +++ b/src/FSharp.Data.GraphQL.Shared/ValidationResultCache.fs @@ -3,12 +3,6 @@ namespace FSharp.Data.GraphQL.Validation open FSharp.Data.GraphQL open FSharp.Data.GraphQL.Types.Introspection open System -open System.IO -open System.Security.Cryptography -open System.Text -open System.Text.Encodings.Web -open System.Text.Json -open System.Text.Json.Serialization type ValidationResultKey = { DocumentId : string @@ -21,35 +15,14 @@ type IValidationResultCache = abstract GetOrAdd : ValidationResultProducer -> ValidationResultKey -> ValidationResult module SchemaId = - - let private formatByteAsLowerHex (value : byte) = - value.ToString("x2", System.Globalization.CultureInfo.InvariantCulture) - - // Note: UnsafeRelaxedJsonEscaping is used here only for deterministic hashing, - // not for output to untrusted contexts. The JSON is never exposed externally. - let private jsonOptions = JsonSerializerOptions( - WriteIndented = false, - DefaultIgnoreCondition = JsonIgnoreCondition.Never, - PropertyNamingPolicy = null, - Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping - ) - /// - /// Computes a deterministic schema identifier from an introspection schema. + /// Computes an in-memory schema identifier from an introspection schema instance. /// - /// The introspection schema to hash. - /// A lowercase hexadecimal SHA-256 hash string that uniquely identifies the schema structure. + /// The introspection schema. + /// A string representation of the schema hash code. [] let fromIntrospectionSchema (introspectionSchema : IntrospectionSchema) = - use stream = new MemoryStream() - JsonSerializer.Serialize(stream, introspectionSchema, jsonOptions) - // Note: Creating SHA256 instance per call is acceptable since schema ID computation - // happens infrequently (typically once per schema during validation cache key creation) - use sha256 = SHA256.Create() - let hash = sha256.ComputeHash(stream.ToArray()) - hash - |> Seq.map formatByteAsLowerHex - |> String.concat "" + introspectionSchema.GetHashCode().ToString(System.Globalization.CultureInfo.InvariantCulture) /// An in-memory cache for the results of schema/document validations, with a lifetime of 30 seconds. type MemoryValidationResultCache () = From 1a94eccae3f358dbe5e1f7b4b5ed1acbb44f05b0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 17 May 2026 22:32:25 +0000 Subject: [PATCH 26/40] Clarify SchemaId GetHashCode scope in XML docs Agent-Logs-Url: https://github.com/fsprojects/FSharp.Data.GraphQL/sessions/70eb1609-2aab-4948-8d69-52c900443420 Co-authored-by: xperiandri <2365592+xperiandri@users.noreply.github.com> --- src/FSharp.Data.GraphQL.Shared/ValidationResultCache.fs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/FSharp.Data.GraphQL.Shared/ValidationResultCache.fs b/src/FSharp.Data.GraphQL.Shared/ValidationResultCache.fs index 62fb41e0..288e850e 100644 --- a/src/FSharp.Data.GraphQL.Shared/ValidationResultCache.fs +++ b/src/FSharp.Data.GraphQL.Shared/ValidationResultCache.fs @@ -17,9 +17,10 @@ type IValidationResultCache = module SchemaId = /// /// Computes an in-memory schema identifier from an introspection schema instance. + /// This identifier is valid only within the current process/runtime and is not deterministic across process restarts. /// /// The introspection schema. - /// A string representation of the schema hash code. + /// A string representation of the schema hash code for in-process cache keys. [] let fromIntrospectionSchema (introspectionSchema : IntrospectionSchema) = introspectionSchema.GetHashCode().ToString(System.Globalization.CultureInfo.InvariantCulture) From 66bc73bbd73211a404d13f420353622445274f57 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 17 May 2026 22:45:13 +0000 Subject: [PATCH 27/40] Change validation SchemaId type to int Agent-Logs-Url: https://github.com/fsprojects/FSharp.Data.GraphQL/sessions/50df7657-a96d-4b4f-89a9-b012c920b492 Co-authored-by: xperiandri <2365592+xperiandri@users.noreply.github.com> --- .../ValidationResultCache.fs | 6 +++--- .../ValidationCacheTests.fs | 18 +++++++++--------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/FSharp.Data.GraphQL.Shared/ValidationResultCache.fs b/src/FSharp.Data.GraphQL.Shared/ValidationResultCache.fs index 288e850e..123730a8 100644 --- a/src/FSharp.Data.GraphQL.Shared/ValidationResultCache.fs +++ b/src/FSharp.Data.GraphQL.Shared/ValidationResultCache.fs @@ -6,7 +6,7 @@ open System type ValidationResultKey = { DocumentId : string - SchemaId : string } + SchemaId : int } type ValidationResultProducer = unit -> ValidationResult @@ -20,10 +20,10 @@ module SchemaId = /// This identifier is valid only within the current process/runtime and is not deterministic across process restarts. /// /// The introspection schema. - /// A string representation of the schema hash code for in-process cache keys. + /// The schema hash code for in-process cache keys. [] let fromIntrospectionSchema (introspectionSchema : IntrospectionSchema) = - introspectionSchema.GetHashCode().ToString(System.Globalization.CultureInfo.InvariantCulture) + introspectionSchema.GetHashCode() /// An in-memory cache for the results of schema/document validations, with a lifetime of 30 seconds. type MemoryValidationResultCache () = diff --git a/tests/FSharp.Data.GraphQL.Tests/ValidationCacheTests.fs b/tests/FSharp.Data.GraphQL.Tests/ValidationCacheTests.fs index 336246d3..556a22fd 100644 --- a/tests/FSharp.Data.GraphQL.Tests/ValidationCacheTests.fs +++ b/tests/FSharp.Data.GraphQL.Tests/ValidationCacheTests.fs @@ -16,7 +16,7 @@ let ``MemoryValidationResultCache caches results for same key`` () = Interlocked.Increment(&callCount) |> ignore Success - let key = { DocumentId = "doc1"; SchemaId = "schema1" } + let key = { DocumentId = "doc1"; SchemaId = 1 } // First call should invoke producer let result1 = cache.GetOrAdd producer key @@ -36,8 +36,8 @@ let ``MemoryValidationResultCache uses different cache entries for different Doc Interlocked.Increment(&callCount) |> ignore Success - let key1 = { DocumentId = "doc1"; SchemaId = "schema1" } - let key2 = { DocumentId = "doc2"; SchemaId = "schema1" } + let key1 = { DocumentId = "doc1"; SchemaId = 1 } + let key2 = { DocumentId = "doc2"; SchemaId = 1 } // First call let result1 = cache.GetOrAdd producer key1 @@ -55,8 +55,8 @@ let ``MemoryValidationResultCache uses different cache entries for different Sch Interlocked.Increment(&callCount) |> ignore Success - let key1 = { DocumentId = "doc1"; SchemaId = "schema1" } - let key2 = { DocumentId = "doc1"; SchemaId = "schema2" } + let key1 = { DocumentId = "doc1"; SchemaId = 1 } + let key2 = { DocumentId = "doc1"; SchemaId = 2 } // First call let result1 = cache.GetOrAdd producer key1 @@ -72,8 +72,8 @@ let ``MemoryValidationResultCache distinguishes keys with same hash code`` () = // Create two different keys that might have hash collisions // Using very similar but different strings - let key1 = { DocumentId = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; SchemaId = "schema1" } - let key2 = { DocumentId = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaab"; SchemaId = "schema1" } + let key1 = { DocumentId = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; SchemaId = 1 } + let key2 = { DocumentId = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaab"; SchemaId = 1 } let mutable callCount = 0 let producer () = @@ -97,7 +97,7 @@ let ``MemoryValidationResultCache caches error results`` () = Interlocked.Increment(&callCount) |> ignore ValidationError [error] - let key = { DocumentId = "doc1"; SchemaId = "schema1" } + let key = { DocumentId = "doc1"; SchemaId = 1 } // First call should invoke producer let result1 = cache.GetOrAdd producer key @@ -122,7 +122,7 @@ let ``MemoryValidationResultCache handles concurrent access`` () = Thread.Sleep(10) // Simulate some work Success - let key = { DocumentId = "doc1"; SchemaId = "schema1" } + let key = { DocumentId = "doc1"; SchemaId = 1 } // Call cache from multiple threads simultaneously let tasks = From da4d951d051e625014c403093fbcb1c91d1231da Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 17 May 2026 22:55:18 +0000 Subject: [PATCH 28/40] Remove unnecessary SchemaId helper module Agent-Logs-Url: https://github.com/fsprojects/FSharp.Data.GraphQL/sessions/345ce2da-408c-4257-81ba-49eeac04c122 Co-authored-by: xperiandri <2365592+xperiandri@users.noreply.github.com> --- .../ProvidedTypesHelper.fs | 2 +- src/FSharp.Data.GraphQL.Server/Executor.fs | 2 +- .../ValidationResultCache.fs | 12 ------------ 3 files changed, 2 insertions(+), 14 deletions(-) diff --git a/src/FSharp.Data.GraphQL.Client.DesignTime/ProvidedTypesHelper.fs b/src/FSharp.Data.GraphQL.Client.DesignTime/ProvidedTypesHelper.fs index b1a1dd30..984a8335 100644 --- a/src/FSharp.Data.GraphQL.Client.DesignTime/ProvidedTypesHelper.fs +++ b/src/FSharp.Data.GraphQL.Client.DesignTime/ProvidedTypesHelper.fs @@ -799,7 +799,7 @@ module internal Provider = match validationResult with | ValidationError msgs -> failwith (formatValidationExceptionMessage msgs) | Success -> () - let key = { DocumentId = DocumentId.fromCanonicalQuery (queryAst.ToQueryString()); SchemaId = SchemaId.fromIntrospectionSchema schema } + let key = { DocumentId = DocumentId.fromCanonicalQuery (queryAst.ToQueryString()); SchemaId = schema.GetHashCode() } let refMaker = lazy Validation.Ast.validateDocument schema queryAst if clientQueryValidation then refMaker.Force diff --git a/src/FSharp.Data.GraphQL.Server/Executor.fs b/src/FSharp.Data.GraphQL.Server/Executor.fs index e3b4a698..f369e6a8 100644 --- a/src/FSharp.Data.GraphQL.Server/Executor.fs +++ b/src/FSharp.Data.GraphQL.Server/Executor.fs @@ -102,7 +102,7 @@ type Executor<'Root>(schema: ISchema<'Root>, middlewares : IExecutorMiddleware s | ValidationError errors -> raise (GQLMessageException (System.String.Join("\n", errors))) // Compute schema ID once after middleware has run and cache it for the lifetime of this Executor instance - let schemaId = SchemaId.fromIntrospectionSchema schema.Introspected + let schemaId = schema.Introspected.GetHashCode() let eval (executionPlan: ExecutionPlan, data: 'Root option, variables: ImmutableDictionary, getInputContext : InputExecutionContextProvider): Async = let documentId = executionPlan.DocumentId diff --git a/src/FSharp.Data.GraphQL.Shared/ValidationResultCache.fs b/src/FSharp.Data.GraphQL.Shared/ValidationResultCache.fs index 123730a8..1d2f12dd 100644 --- a/src/FSharp.Data.GraphQL.Shared/ValidationResultCache.fs +++ b/src/FSharp.Data.GraphQL.Shared/ValidationResultCache.fs @@ -1,7 +1,6 @@ namespace FSharp.Data.GraphQL.Validation open FSharp.Data.GraphQL -open FSharp.Data.GraphQL.Types.Introspection open System type ValidationResultKey = @@ -14,17 +13,6 @@ type ValidationResultProducer = type IValidationResultCache = abstract GetOrAdd : ValidationResultProducer -> ValidationResultKey -> ValidationResult -module SchemaId = - /// - /// Computes an in-memory schema identifier from an introspection schema instance. - /// This identifier is valid only within the current process/runtime and is not deterministic across process restarts. - /// - /// The introspection schema. - /// The schema hash code for in-process cache keys. - [] - let fromIntrospectionSchema (introspectionSchema : IntrospectionSchema) = - introspectionSchema.GetHashCode() - /// An in-memory cache for the results of schema/document validations, with a lifetime of 30 seconds. type MemoryValidationResultCache () = let expirationPolicy = CacheExpirationPolicy.SlidingExpiration(TimeSpan.FromSeconds 30.0) From ec333b2c09ff49adf9e9538eca5dbac78d72c152 Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Mon, 18 May 2026 11:26:22 +0200 Subject: [PATCH 29/40] Formatted changed files --- .../StarWars/Common.fs | 2 +- .../AstExtensionsTests.fs | 77 ++--- .../DocumentIdTests.fs | 10 +- .../ExecutionTests.fs | 28 +- .../PlanningTests.fs | 305 ++++++++++-------- .../ValidationCacheTests.fs | 103 +++--- 6 files changed, 280 insertions(+), 245 deletions(-) diff --git a/samples/star-wars-fabulous-client/StarWars/Common.fs b/samples/star-wars-fabulous-client/StarWars/Common.fs index 1de5ae4e..44dfe52a 100644 --- a/samples/star-wars-fabulous-client/StarWars/Common.fs +++ b/samples/star-wars-fabulous-client/StarWars/Common.fs @@ -9,6 +9,6 @@ module Commands = let IntrospectionPath = "../../../tests/FSharp.Data.GraphQL.IntegrationTests/introspection.json" type GraphQLApi = GraphQLProvider - let GetCharactersData = GraphQLApi.Operation<"queries/FetchCharacters.graphql">() + let GetCharactersData = GraphQLApi.Operation<"queries/FetchCharacters.graphql"> () type Character = GraphQLApi.Operations.FetchCharacters.Types.CharactersFields.Character diff --git a/tests/FSharp.Data.GraphQL.Tests/AstExtensionsTests.fs b/tests/FSharp.Data.GraphQL.Tests/AstExtensionsTests.fs index 4bfd2f56..1e4412d5 100644 --- a/tests/FSharp.Data.GraphQL.Tests/AstExtensionsTests.fs +++ b/tests/FSharp.Data.GraphQL.Tests/AstExtensionsTests.fs @@ -344,7 +344,12 @@ let ``Should generate information map correctly`` () = Name = "friends" Alias = ValueNone Fields = [ - FragmentField { Name = "primaryFunction"; Alias = ValueNone; TypeCondition = "Droid"; Fields = [] } + FragmentField { + Name = "primaryFunction" + Alias = ValueNone + TypeCondition = "Droid" + Fields = [] + } FragmentField { Name = "id"; Alias = ValueNone; TypeCondition = "Droid"; Fields = [] } FragmentField { Name = "homePlanet"; Alias = ValueNone; TypeCondition = "Human"; Fields = [] } FragmentField { Name = "id"; Alias = ValueNone; TypeCondition = "Human"; Fields = [] } @@ -362,108 +367,108 @@ let ``Should generate information map correctly`` () = let ``ToQueryString escapes double quotes in string values`` () = let query = """query q { hero(name: "test\"quote") }""" let document = parse query - let printed = document.ToQueryString() + let printed = document.ToQueryString () // Verify the printed query contains the escaped quote - Assert.Contains("\\\"", printed) + Assert.Contains ("\\\"", printed) // Verify it can be parsed back let reparsed = parse printed - equals (document.ToQueryString()) (reparsed.ToQueryString()) + equals (document.ToQueryString ()) (reparsed.ToQueryString ()) [] let ``ToQueryString escapes backslashes in string values`` () = let query = """query q { hero(path: "C:\\Users\\test") }""" let document = parse query - let printed = document.ToQueryString() + let printed = document.ToQueryString () // Verify the printed query contains escaped backslashes - Assert.Contains("\\\\", printed) + Assert.Contains ("\\\\", printed) // Verify it can be parsed back let reparsed = parse printed - equals (document.ToQueryString()) (reparsed.ToQueryString()) + equals (document.ToQueryString ()) (reparsed.ToQueryString ()) [] let ``ToQueryString escapes newlines in string values`` () = let query = """query q { hero(text: "line1\nline2") }""" let document = parse query - let printed = document.ToQueryString() + let printed = document.ToQueryString () // Verify the printed query contains the escaped newline within the string value - Assert.Contains("\\n", printed) + Assert.Contains ("\\n", printed) // Verify the string value itself doesn't contain an actual newline (it should be escaped) // The printed output will have formatting newlines, but the string value should have \n - Assert.Contains("\"line1\\nline2\"", printed) + Assert.Contains ("\"line1\\nline2\"", printed) // Verify it can be parsed back let reparsed = parse printed - equals (document.ToQueryString()) (reparsed.ToQueryString()) + equals (document.ToQueryString ()) (reparsed.ToQueryString ()) [] let ``ToQueryString escapes tabs in string values`` () = let query = """query q { hero(text: "col1\tcol2") }""" let document = parse query - let printed = document.ToQueryString() + let printed = document.ToQueryString () // Verify the printed query contains the escaped tab within the string value - Assert.Contains("\\t", printed) - Assert.Contains("\"col1\\tcol2\"", printed) + Assert.Contains ("\\t", printed) + Assert.Contains ("\"col1\\tcol2\"", printed) // Verify it can be parsed back let reparsed = parse printed - equals (document.ToQueryString()) (reparsed.ToQueryString()) + equals (document.ToQueryString ()) (reparsed.ToQueryString ()) [] let ``ToQueryString escapes carriage returns in string values`` () = let query = """query q { hero(text: "line1\rline2") }""" let document = parse query - let printed = document.ToQueryString() + let printed = document.ToQueryString () // Verify the printed query contains the escaped carriage return - Assert.Contains("\\r", printed) + Assert.Contains ("\\r", printed) // Verify it can be parsed back let reparsed = parse printed - equals (document.ToQueryString()) (reparsed.ToQueryString()) + equals (document.ToQueryString ()) (reparsed.ToQueryString ()) [] let ``ToQueryString escapes backspace in string values`` () = let query = """query q { hero(text: "test\bback") }""" let document = parse query - let printed = document.ToQueryString() + let printed = document.ToQueryString () // Verify the printed query contains the escaped backspace - Assert.Contains("\\b", printed) + Assert.Contains ("\\b", printed) // Verify it can be parsed back let reparsed = parse printed - equals (document.ToQueryString()) (reparsed.ToQueryString()) + equals (document.ToQueryString ()) (reparsed.ToQueryString ()) [] let ``ToQueryString escapes form feed in string values`` () = let query = """query q { hero(text: "page1\fpage2") }""" let document = parse query - let printed = document.ToQueryString() + let printed = document.ToQueryString () // Verify the printed query contains the escaped form feed - Assert.Contains("\\f", printed) + Assert.Contains ("\\f", printed) // Verify it can be parsed back let reparsed = parse printed - equals (document.ToQueryString()) (reparsed.ToQueryString()) + equals (document.ToQueryString ()) (reparsed.ToQueryString ()) [] let ``ToQueryString escapes control characters as unicode in string values`` () = // Test with a control character (e.g., ASCII 0x01) let query = "query q { hero(text: \"test\u0001control\") }" let document = parse query - let printed = document.ToQueryString() + let printed = document.ToQueryString () // Verify the printed query contains the unicode escape (lowercase hex) - Assert.Contains("\\u0001", printed) + Assert.Contains ("\\u0001", printed) // Verify it can be parsed back let reparsed = parse printed - equals (document.ToQueryString()) (reparsed.ToQueryString()) + equals (document.ToQueryString ()) (reparsed.ToQueryString ()) [] let ``ToQueryString escapes multiple special characters correctly`` () = let query = """query q { hero(text: "quote:\"newline:\nslash:\\tab:\t") }""" let document = parse query - let printed = document.ToQueryString() + let printed = document.ToQueryString () // Verify all escapes are present - Assert.Contains("\\\"", printed) - Assert.Contains("\\n", printed) - Assert.Contains("\\\\", printed) - Assert.Contains("\\t", printed) + Assert.Contains ("\\\"", printed) + Assert.Contains ("\\n", printed) + Assert.Contains ("\\\\", printed) + Assert.Contains ("\\t", printed) // Verify it can be parsed back let reparsed = parse printed - equals (document.ToQueryString()) (reparsed.ToQueryString()) + equals (document.ToQueryString ()) (reparsed.ToQueryString ()) [] let ``ToQueryString produces deterministic output for escaped strings`` () = @@ -471,9 +476,9 @@ let ``ToQueryString produces deterministic output for escaped strings`` () = // the same canonical output, which is critical for documentId stability let query = """query Test { field(arg: "test\"quote\nline\ttab\\back") }""" let document = parse query - let printed1 = document.ToQueryString() - let printed2 = document.ToQueryString() + let printed1 = document.ToQueryString () + let printed2 = document.ToQueryString () equals printed1 printed2 // Verify the documentId is deterministic let documentId = DocumentId.fromCanonicalQuery printed1 - equals 64 documentId.Length // SHA-256 hex string is always 64 chars + equals 64 documentId.Length // SHA-256 hex string is always 64 chars diff --git a/tests/FSharp.Data.GraphQL.Tests/DocumentIdTests.fs b/tests/FSharp.Data.GraphQL.Tests/DocumentIdTests.fs index 6bc1e05e..21069a6e 100644 --- a/tests/FSharp.Data.GraphQL.Tests/DocumentIdTests.fs +++ b/tests/FSharp.Data.GraphQL.Tests/DocumentIdTests.fs @@ -12,7 +12,7 @@ let ``DocumentId.fromCanonicalQuery produces deterministic hash`` () = let hash1 = DocumentId.fromCanonicalQuery query let hash2 = DocumentId.fromCanonicalQuery query equals hash1 hash2 - equals 64 hash1.Length // SHA-256 hex string is 64 chars + equals 64 hash1.Length // SHA-256 hex string is 64 chars [] let ``DocumentId.fromCanonicalQuery produces different hashes for different queries`` () = @@ -34,8 +34,11 @@ let ``DocumentId.fromCanonicalQuery handles empty string`` () = let ``DocumentId.fromCanonicalQuery produces lowercase hex`` () = let query = "query Test { field }" let hash = DocumentId.fromCanonicalQuery query - equals hash (hash.ToLowerInvariant()) - Assert.True(hash |> Seq.forall (fun c -> (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f'))) + equals hash (hash.ToLowerInvariant ()) + Assert.True ( + hash + |> Seq.forall (fun c -> (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')) + ) [] let ``DocumentId.fromCanonicalQuery handles special characters in strings`` () = @@ -62,4 +65,3 @@ let ``DocumentId.fromCanonicalQuery is consistent with known SHA-256 values`` () let hash = DocumentId.fromCanonicalQuery query // SHA-256 of "test" equals "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08" hash - diff --git a/tests/FSharp.Data.GraphQL.Tests/ExecutionTests.fs b/tests/FSharp.Data.GraphQL.Tests/ExecutionTests.fs index cc28c27f..091a2191 100644 --- a/tests/FSharp.Data.GraphQL.Tests/ExecutionTests.fs +++ b/tests/FSharp.Data.GraphQL.Tests/ExecutionTests.fs @@ -19,23 +19,23 @@ open FSharp.Data.GraphQL.Parser open FSharp.Data.GraphQL.Execution type TestSubject = { - a: string - b: string - c: string - d: string - e: string - f: string - deep: DeepTestSubject - pic: int voption -> string - promise: Async + a : string + b : string + c : string + d : string + e : string + f : string + deep : DeepTestSubject + pic : int voption -> string + promise : Async } and DeepTestSubject = { - a: string - b: string - c: string option - d: string voption - l: string option list + a : string + b : string + c : string option + d : string voption + l : string option list } and DUArg = diff --git a/tests/FSharp.Data.GraphQL.Tests/PlanningTests.fs b/tests/FSharp.Data.GraphQL.Tests/PlanningTests.fs index ad7cf74d..46d6ebe5 100644 --- a/tests/FSharp.Data.GraphQL.Tests/PlanningTests.fs +++ b/tests/FSharp.Data.GraphQL.Tests/PlanningTests.fs @@ -14,75 +14,80 @@ open FSharp.Data.GraphQL.Parser open FSharp.Data.GraphQL.Planning open FSharp.Data.GraphQL.Execution -type Person = - { firstName : string - lastName : string - age : int } +type Person = { firstName : string; lastName : string; age : int } -type Animal = - { name : string - species : string } +type Animal = { name : string; species : string } type Named = | Animal of Animal | Person of Person -let people = - [ { firstName = "John" - lastName = "Doe" - age = 21 } ] +let people = [ { firstName = "John"; lastName = "Doe"; age = 21 } ] -let animals = - [ { name = "Max" - species = "Dog" } ] +let animals = [ { name = "Max"; species = "Dog" } ] let rec Person = - DefineRec.Object( + DefineRec.Object ( name = "Person", - fieldsFn = (fun () -> - [ Define.Field("firstName", StringType, fun _ person -> person.firstName) - Define.Field("lastName", StringType, fun _ person -> person.lastName) - Define.Field("age", IntType, fun _ person -> person.age) - Define.Field("name", StringType, fun _ person -> person.firstName + " " + person.lastName) - Define.Field("friends", ListOf Person, fun _ _ -> []) ]), interfaces = [ INamed ]) + fieldsFn = + (fun () -> [ + Define.Field ("firstName", StringType, fun _ person -> person.firstName) + Define.Field ("lastName", StringType, fun _ person -> person.lastName) + Define.Field ("age", IntType, fun _ person -> person.age) + Define.Field ("name", StringType, fun _ person -> person.firstName + " " + person.lastName) + Define.Field ("friends", ListOf Person, fun _ _ -> []) + ]), + interfaces = [ INamed ] + ) and Animal = - Define.Object(name = "Animal", - fields = [ Define.Field("name", StringType, fun _ animal -> animal.name) - Define.Field("species", StringType, fun _ animal -> animal.species) ], interfaces = [ INamed ]) + Define.Object ( + name = "Animal", + fields = [ + Define.Field ("name", StringType, fun _ animal -> animal.name) + Define.Field ("species", StringType, fun _ animal -> animal.species) + ], + interfaces = [ INamed ] + ) -and INamed = Define.Interface("INamed", [ Define.Field("name", StringType) ]) +and INamed = Define.Interface ("INamed", [ Define.Field ("name", StringType) ]) and UNamed = - Define.Union( - "UNamed", [ Person; Animal ], + Define.Union ( + "UNamed", + [ Person; Animal ], function | Animal a -> box a - | Person p -> upcast p) + | Person p -> upcast p + ) [] -let ``Planning must retain correct types for leafs``() = - let schema = Schema(Person) - let schemaProcessor = Executor(schema) - let query = """{ +let ``Planning must retain correct types for leafs`` () = + let schema = Schema (Person) + let schemaProcessor = Executor (schema) + let query = + """{ firstName lastName age }""" - let plan = schemaProcessor.CreateExecutionPlanOrFail(query) + let plan = schemaProcessor.CreateExecutionPlanOrFail (query) plan.RootDef |> equals (upcast Person) equals 3 plan.Fields.Length plan.Fields |> List.map (fun info -> (info.Identifier, info.ParentDef, info.ReturnDef)) - |> equals [ ("firstName", upcast Person, upcast StringType) - ("lastName", upcast Person, upcast StringType) - ("age", upcast Person, upcast IntType) ] + |> equals [ + ("firstName", upcast Person, upcast StringType) + ("lastName", upcast Person, upcast StringType) + ("age", upcast Person, upcast IntType) + ] [] -let ``Planning must work with fragments``() = - let schema = Schema(Person) - let schemaProcessor = Executor(schema) - let query = """query Example { +let ``Planning must work with fragments`` () = + let schema = Schema (Person) + let schemaProcessor = Executor (schema) + let query = + """query Example { ...named age } @@ -90,20 +95,23 @@ let ``Planning must work with fragments``() = firstName lastName }""" - let plan = schemaProcessor.CreateExecutionPlanOrFail(query) + let plan = schemaProcessor.CreateExecutionPlanOrFail (query) plan.RootDef |> equals (upcast Person) equals 3 plan.Fields.Length plan.Fields |> List.map (fun info -> (info.Identifier, info.ParentDef, info.ReturnDef)) - |> equals [ ("firstName", upcast Person, upcast StringType) - ("lastName", upcast Person, upcast StringType) - ("age", upcast Person, upcast IntType) ] + |> equals [ + ("firstName", upcast Person, upcast StringType) + ("lastName", upcast Person, upcast StringType) + ("age", upcast Person, upcast IntType) + ] [] -let ``Planning must work with parallel fragments``() = - let schema = Schema(Person) - let schemaProcessor = Executor(schema) - let query = """query Example { +let ``Planning must work with parallel fragments`` () = + let schema = Schema (Person) + let schemaProcessor = Executor (schema) + let query = + """query Example { ...fnamed ...lnamed age @@ -115,21 +123,24 @@ let ``Planning must work with parallel fragments``() = lastName } """ - let plan = schemaProcessor.CreateExecutionPlanOrFail(query) + let plan = schemaProcessor.CreateExecutionPlanOrFail (query) plan.RootDef |> equals (upcast Person) equals 3 plan.Fields.Length plan.Fields |> List.map (fun info -> (info.Identifier, info.ParentDef, info.ReturnDef)) - |> equals [ ("firstName", upcast Person, upcast StringType) - ("lastName", upcast Person, upcast StringType) - ("age", upcast Person, upcast IntType) ] + |> equals [ + ("firstName", upcast Person, upcast StringType) + ("lastName", upcast Person, upcast StringType) + ("age", upcast Person, upcast IntType) + ] [] -let ``Planning must retain correct types for lists``() = - let Query = Define.Object("Query", [ Define.Field("people", ListOf Person, fun _ () -> people) ]) - let schema = Schema(Query) - let schemaProcessor = Executor(schema) - let query = """{ +let ``Planning must retain correct types for lists`` () = + let Query = Define.Object ("Query", [ Define.Field ("people", ListOf Person, fun _ () -> people) ]) + let schema = Schema (Query) + let schemaProcessor = Executor (schema) + let query = + """{ people { firstName lastName @@ -140,31 +151,35 @@ let ``Planning must retain correct types for lists``() = } }""" let PersonList : OutputDef = ListOf Person - let plan = schemaProcessor.CreateExecutionPlanOrFail(query) + let plan = schemaProcessor.CreateExecutionPlanOrFail (query) equals 1 plan.Fields.Length let listInfo = plan.Fields.Head listInfo.Identifier |> equals "people" listInfo.ReturnDef |> equals (upcast PersonList) - let (ResolveCollection(info)) = listInfo.Kind + let (ResolveCollection (info)) = listInfo.Kind info.ParentDef |> equals (upcast PersonList) info.ReturnDef |> equals (upcast Person) - let (SelectFields(innerFields)) = info.Kind + let (SelectFields (innerFields)) = info.Kind equals 3 innerFields.Length innerFields |> List.map (fun i -> (i.Identifier, i.ParentDef, i.ReturnDef)) - |> equals [ ("firstName", upcast Person, upcast StringType) - ("lastName", upcast Person, upcast StringType) - ("friends", upcast Person, upcast PersonList) ] - let (ResolveCollection(friendInfo)) = (innerFields |> List.find (fun i -> i.Identifier = "friends")).Kind + |> equals [ + ("firstName", upcast Person, upcast StringType) + ("lastName", upcast Person, upcast StringType) + ("friends", upcast Person, upcast PersonList) + ] + let (ResolveCollection (friendInfo)) = + (innerFields |> List.find (fun i -> i.Identifier = "friends")).Kind friendInfo.ParentDef |> equals (upcast PersonList) friendInfo.ReturnDef |> equals (upcast Person) [] -let ``Planning must work with interfaces``() = - let Query = Define.Object("Query", [ Define.Field("names", ListOf INamed, fun _ () -> []) ]) - let schema = Schema(query = Query, config = { SchemaConfig.Default with Types = [ Person; Animal ] }) - let schemaProcessor = Executor(schema) - let query = """query Example { +let ``Planning must work with interfaces`` () = + let Query = Define.Object ("Query", [ Define.Field ("names", ListOf INamed, fun _ () -> []) ]) + let schema = Schema (query = Query, config = { SchemaConfig.Default with Types = [ Person; Animal ] }) + let schemaProcessor = Executor (schema) + let query = + """query Example { names { name ... on Animal { @@ -176,31 +191,34 @@ let ``Planning must work with interfaces``() = fragment ageFragment on Person { age }""" - let plan = schemaProcessor.CreateExecutionPlanOrFail(query) + let plan = schemaProcessor.CreateExecutionPlanOrFail (query) equals 1 plan.Fields.Length let INamedList : OutputDef = ListOf INamed let listInfo = plan.Fields.Head listInfo.Identifier |> equals "names" listInfo.ReturnDef |> equals (upcast INamedList) - let (ResolveCollection(info)) = listInfo.Kind + let (ResolveCollection (info)) = listInfo.Kind info.ParentDef |> equals (upcast INamedList) info.ReturnDef |> equals (upcast INamed) - let (ResolveAbstraction(innerFields)) = info.Kind + let (ResolveAbstraction (innerFields)) = info.Kind innerFields - |> Map.map (fun typeName fields -> fields |> List.map (fun i -> (i.Identifier, i.ParentDef, i.ReturnDef))) - |> equals (Map.ofList [ "Person", - [ ("name", upcast INamed, upcast StringType) - ("age", upcast INamed, upcast IntType) ] - "Animal", - [ ("name", upcast INamed, upcast StringType) - ("species", upcast INamed, upcast StringType) ] ]) + |> Map.map (fun typeName fields -> + fields + |> List.map (fun i -> (i.Identifier, i.ParentDef, i.ReturnDef))) + |> equals ( + Map.ofList [ + "Person", [ ("name", upcast INamed, upcast StringType); ("age", upcast INamed, upcast IntType) ] + "Animal", [ ("name", upcast INamed, upcast StringType); ("species", upcast INamed, upcast StringType) ] + ] + ) [] -let ``Planning must work with unions``() = - let Query = Define.Object("Query", [ Define.Field("names", ListOf UNamed, fun _ () -> []) ]) - let schema = Schema(Query) - let schemaProcessor = Executor(schema) - let query = """query Example { +let ``Planning must work with unions`` () = + let Query = Define.Object ("Query", [ Define.Field ("names", ListOf UNamed, fun _ () -> []) ]) + let schema = Schema (Query) + let schemaProcessor = Executor (schema) + let query = + """query Example { names { ... on Animal { name @@ -212,27 +230,29 @@ let ``Planning must work with unions``() = } } }""" - let plan = schemaProcessor.CreateExecutionPlanOrFail(query) + let plan = schemaProcessor.CreateExecutionPlanOrFail (query) equals 1 plan.Fields.Length let listInfo = plan.Fields.Head let UNamedList : OutputDef = ListOf UNamed listInfo.Identifier |> equals "names" listInfo.ReturnDef |> equals (upcast UNamedList) - let (ResolveCollection(info)) = listInfo.Kind + let (ResolveCollection (info)) = listInfo.Kind info.ParentDef |> equals (upcast UNamedList) info.ReturnDef |> equals (upcast UNamed) - let (ResolveAbstraction(innerFields)) = info.Kind + let (ResolveAbstraction (innerFields)) = info.Kind innerFields - |> Map.map (fun typeName fields -> fields |> List.map (fun i -> (i.Identifier, i.ParentDef, i.ReturnDef))) - |> equals (Map.ofList [ "Animal", - [ ("name", upcast UNamed, upcast StringType) - ("species", upcast UNamed, upcast StringType) ] - "Person", - [ ("name", upcast UNamed, upcast StringType) - ("age", upcast UNamed, upcast IntType) ] ]) + |> Map.map (fun typeName fields -> + fields + |> List.map (fun i -> (i.Identifier, i.ParentDef, i.ReturnDef))) + |> equals ( + Map.ofList [ + "Animal", [ ("name", upcast UNamed, upcast StringType); ("species", upcast UNamed, upcast StringType) ] + "Person", [ ("name", upcast UNamed, upcast StringType); ("age", upcast UNamed, upcast IntType) ] + ] + ) [] -let ``Planning must handle inline fragment with non-matching type condition in unions``() = +let ``Planning must handle inline fragment with non-matching type condition in unions`` () = // ═══════════════════════════════════════════════════════════════════════════ // REGRESSION TEST for Planning_ResolveDeferred_Bug // ═══════════════════════════════════════════════════════════════════════════ @@ -272,20 +292,24 @@ let ``Planning must handle inline fragment with non-matching type condition in u // Create a third type that is NOT part of UNamed union let Robot = - Define.Object( + Define.Object ( name = "Robot", - fields = - [ Define.Field("modelNumber", StringType, fun _ (robot: string) -> robot) - Define.Field("name", StringType, fun _ _ -> "Robot") ]) + fields = [ + Define.Field ("modelNumber", StringType, fun _ (robot : string) -> robot) + Define.Field ("name", StringType, fun _ _ -> "Robot") + ] + ) - let Query = Define.Object("Query", [ Define.Field("names", ListOf UNamed, fun _ () -> []) ]) - let schema = Schema(query = Query, config = { SchemaConfig.Default with Types = [ Person; Animal; Robot ] }) - let schemaProcessor = Executor(schema) + let Query = Define.Object ("Query", [ Define.Field ("names", ListOf UNamed, fun _ () -> []) ]) + let schema = + Schema (query = Query, config = { SchemaConfig.Default with Types = [ Person; Animal; Robot ] }) + let schemaProcessor = Executor (schema) // GraphQL Query: // UNamed union = Person | Animal (Robot is NOT in this union) // The "... on Robot" fragment below will never match any objects - let query = """query Example { + let query = + """query Example { names { ... on Animal { name @@ -304,7 +328,7 @@ let ``Planning must handle inline fragment with non-matching type condition in u // TEST ASSERTION: // This must succeed per GraphQL spec – non-matching fragments are valid // Bug would cause: "Expected an Abstraction!" runtime error during planning - let plan = schemaProcessor.CreateExecutionPlanOrFail(query) + let plan = schemaProcessor.CreateExecutionPlanOrFail (query) // Verify the execution plan structure equals 1 plan.Fields.Length @@ -312,27 +336,29 @@ let ``Planning must handle inline fragment with non-matching type condition in u let UNamedList : OutputDef = ListOf UNamed listInfo.Identifier |> equals "names" listInfo.ReturnDef |> equals (upcast UNamedList) - let (ResolveCollection(info)) = listInfo.Kind + let (ResolveCollection (info)) = listInfo.Kind info.ParentDef |> equals (upcast UNamedList) info.ReturnDef |> equals (upcast UNamed) // Must successfully extract abstraction info // Bug would fail here with wrong execution info kind - let (ResolveAbstraction(innerFields)) = info.Kind + let (ResolveAbstraction (innerFields)) = info.Kind // Result: Only Animal and Person fields (Robot is filtered out) // This is correct GraphQL behavior – non-matching fragments produce no fields innerFields - |> Map.map (fun typeName fields -> fields |> List.map (fun i -> (i.Identifier, i.ParentDef, i.ReturnDef))) - |> equals (Map.ofList [ "Animal", - [ ("name", upcast UNamed, upcast StringType) - ("species", upcast UNamed, upcast StringType) ] - "Person", - [ ("name", upcast UNamed, upcast StringType) - ("age", upcast UNamed, upcast IntType) ] ]) + |> Map.map (fun typeName fields -> + fields + |> List.map (fun i -> (i.Identifier, i.ParentDef, i.ReturnDef))) + |> equals ( + Map.ofList [ + "Animal", [ ("name", upcast UNamed, upcast StringType); ("species", upcast UNamed, upcast StringType) ] + "Person", [ ("name", upcast UNamed, upcast StringType); ("age", upcast UNamed, upcast IntType) ] + ] + ) [] -let ``Planning must handle nested inline fragments with non-matching type conditions``() = +let ``Planning must handle nested inline fragments with non-matching type conditions`` () = // REGRESSION TEST for Planning_ResolveDeferred_Bug (nested scenario) // // GraphQL SCENARIO: @@ -352,28 +378,27 @@ let ``Planning must handle nested inline fragments with non-matching type condit // Define Robot type (not part of UNamed union) let RobotType = - Define.Object( + Define.Object ( name = "Robot", - fields = - [ Define.Field("modelNumber", StringType, fun _ (robot: string) -> robot) - Define.Field("name", StringType, fun _ _ -> "Robot") ]) + fields = [ + Define.Field ("modelNumber", StringType, fun _ (robot : string) -> robot) + Define.Field ("name", StringType, fun _ _ -> "Robot") + ] + ) // Container type with nested union list – creates deeper nesting let ContainerType = - Define.Object( - name = "Container", - fields = [ Define.Field("nested", ListOf UNamed, fun _ () -> []) ]) + Define.Object (name = "Container", fields = [ Define.Field ("nested", ListOf UNamed, fun _ () -> []) ]) - let Query = - Define.Object( - "Query", - [ Define.Field("container", ContainerType, fun _ () -> ()) ]) + let Query = Define.Object ("Query", [ Define.Field ("container", ContainerType, fun _ () -> ()) ]) - let schema = Schema(query = Query, config = { SchemaConfig.Default with Types = [ Person; Animal; RobotType ] }) - let schemaProcessor = Executor(schema) + let schema = + Schema (query = Query, config = { SchemaConfig.Default with Types = [ Person; Animal; RobotType ] }) + let schemaProcessor = Executor (schema) // Nested query with non-matching fragment - let query = """query Example { + let query = + """query Example { container { nested { ... on Animal { @@ -392,14 +417,14 @@ let ``Planning must handle nested inline fragments with non-matching type condit }""" // Must succeed – nested non-matching fragments are valid per GraphQL spec - let plan = schemaProcessor.CreateExecutionPlanOrFail(query) + let plan = schemaProcessor.CreateExecutionPlanOrFail (query) // Verify the plan structure is correct equals 1 plan.Fields.Length plan.Fields.Head.Identifier |> equals "container" [] -let ``Planning must return ResolveAbstraction even when all fragments are non-matching``() = +let ``Planning must return ResolveAbstraction even when all fragments are non-matching`` () = // REGRESSION TEST for Planning_ResolveDeferred_Bug (extreme case) // // GraphQL SCENARIO – EDGE CASE: @@ -431,18 +456,18 @@ let ``Planning must return ResolveAbstraction even when all fragments are non-ma // Robot is NOT in UNamed union let RobotType = - Define.Object( - name = "Robot", - fields = [ Define.Field("modelNumber", StringType, fun _ (robot: string) -> robot) ]) + Define.Object (name = "Robot", fields = [ Define.Field ("modelNumber", StringType, fun _ (robot : string) -> robot) ]) - let Query = Define.Object("Query", [ Define.Field("names", ListOf UNamed, fun _ () -> []) ]) - let schema = Schema(query = Query, config = { SchemaConfig.Default with Types = [ Person; Animal; RobotType ] }) - let schemaProcessor = Executor(schema) + let Query = Define.Object ("Query", [ Define.Field ("names", ListOf UNamed, fun _ () -> []) ]) + let schema = + Schema (query = Query, config = { SchemaConfig.Default with Types = [ Person; Animal; RobotType ] }) + let schemaProcessor = Executor (schema) // GraphQL Query – ONLY non-matching fragment! // UNamed union = Person | Animal (NOT Robot) // This query will match zero objects at runtime - let query = """query Example { + let query = + """query Example { names { ... on Robot { modelNumber @@ -453,15 +478,15 @@ let ``Planning must return ResolveAbstraction even when all fragments are non-ma // TEST ASSERTION: // Must succeed per GraphQL spec – empty result is valid, not an error // Bug would cause: Runtime crash "Expected an Abstraction!" during planning - let plan = schemaProcessor.CreateExecutionPlanOrFail(query) + let plan = schemaProcessor.CreateExecutionPlanOrFail (query) // Verify the plan was created successfully equals 1 plan.Fields.Length let listInfo = plan.Fields.Head - let (ResolveCollection(info)) = listInfo.Kind + let (ResolveCollection (info)) = listInfo.Kind // Must successfully extract abstraction info - let (ResolveAbstraction(innerFields)) = info.Kind + let (ResolveAbstraction (innerFields)) = info.Kind // Result: Empty map – no matching types // This is CORRECT per GraphQL spec – valid query, just matches nothing diff --git a/tests/FSharp.Data.GraphQL.Tests/ValidationCacheTests.fs b/tests/FSharp.Data.GraphQL.Tests/ValidationCacheTests.fs index 556a22fd..f764e093 100644 --- a/tests/FSharp.Data.GraphQL.Tests/ValidationCacheTests.fs +++ b/tests/FSharp.Data.GraphQL.Tests/ValidationCacheTests.fs @@ -10,133 +10,136 @@ open System.Threading [] let ``MemoryValidationResultCache caches results for same key`` () = - let cache = MemoryValidationResultCache() :> IValidationResultCache + let cache = MemoryValidationResultCache () :> IValidationResultCache let mutable callCount = 0 let producer () = - Interlocked.Increment(&callCount) |> ignore + Interlocked.Increment (&callCount) |> ignore Success - + let key = { DocumentId = "doc1"; SchemaId = 1 } - + // First call should invoke producer let result1 = cache.GetOrAdd producer key equals 1 callCount equals Success result1 - + // Second call with same key should NOT invoke producer (cached) let result2 = cache.GetOrAdd producer key - equals 1 callCount // Still 1, not 2 + equals 1 callCount // Still 1, not 2 equals Success result2 [] let ``MemoryValidationResultCache uses different cache entries for different DocumentIds`` () = - let cache = MemoryValidationResultCache() :> IValidationResultCache + let cache = MemoryValidationResultCache () :> IValidationResultCache let mutable callCount = 0 let producer () = - Interlocked.Increment(&callCount) |> ignore + Interlocked.Increment (&callCount) |> ignore Success - + let key1 = { DocumentId = "doc1"; SchemaId = 1 } let key2 = { DocumentId = "doc2"; SchemaId = 1 } - + // First call let result1 = cache.GetOrAdd producer key1 equals 1 callCount - + // Second call with different DocumentId should invoke producer again let result2 = cache.GetOrAdd producer key2 - equals 2 callCount // Should be 2 now + equals 2 callCount // Should be 2 now [] let ``MemoryValidationResultCache uses different cache entries for different SchemaIds`` () = - let cache = MemoryValidationResultCache() :> IValidationResultCache + let cache = MemoryValidationResultCache () :> IValidationResultCache let mutable callCount = 0 let producer () = - Interlocked.Increment(&callCount) |> ignore + Interlocked.Increment (&callCount) |> ignore Success - + let key1 = { DocumentId = "doc1"; SchemaId = 1 } let key2 = { DocumentId = "doc1"; SchemaId = 2 } - + // First call let result1 = cache.GetOrAdd producer key1 equals 1 callCount - + // Second call with different SchemaId should invoke producer again let result2 = cache.GetOrAdd producer key2 - equals 2 callCount // Should be 2 now + equals 2 callCount // Should be 2 now [] let ``MemoryValidationResultCache distinguishes keys with same hash code`` () = - let cache = MemoryValidationResultCache() :> IValidationResultCache - + let cache = MemoryValidationResultCache () :> IValidationResultCache + // Create two different keys that might have hash collisions // Using very similar but different strings - let key1 = { DocumentId = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; SchemaId = 1 } - let key2 = { DocumentId = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaab"; SchemaId = 1 } - + let key1 = { + DocumentId = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + SchemaId = 1 + } + let key2 = { + DocumentId = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaab" + SchemaId = 1 + } + let mutable callCount = 0 let producer () = - Interlocked.Increment(&callCount) |> ignore + Interlocked.Increment (&callCount) |> ignore Success - + // First call let result1 = cache.GetOrAdd producer key1 equals 1 callCount - + // Second call with different key should invoke producer even if hash codes collide let result2 = cache.GetOrAdd producer key2 - equals 2 callCount // Should be 2, proving we use full key not just hash + equals 2 callCount // Should be 2, proving we use full key not just hash [] let ``MemoryValidationResultCache caches error results`` () = - let cache = MemoryValidationResultCache() :> IValidationResultCache + let cache = MemoryValidationResultCache () :> IValidationResultCache let mutable callCount = 0 - let error = GQLProblemDetails.Create("Test error") + let error = GQLProblemDetails.Create ("Test error") let producer () = - Interlocked.Increment(&callCount) |> ignore - ValidationError [error] - + Interlocked.Increment (&callCount) |> ignore + ValidationError [ error ] + let key = { DocumentId = "doc1"; SchemaId = 1 } - + // First call should invoke producer let result1 = cache.GetOrAdd producer key equals 1 callCount match result1 with | ValidationError errors -> equals 1 (Seq.length errors) | Success -> fail "Expected ValidationError" - + // Second call with same key should NOT invoke producer (cached) let result2 = cache.GetOrAdd producer key - equals 1 callCount // Still 1, not 2 + equals 1 callCount // Still 1, not 2 match result2 with | ValidationError errors -> equals 1 (Seq.length errors) | Success -> fail "Expected ValidationError" [] let ``MemoryValidationResultCache handles concurrent access`` () = - let cache = MemoryValidationResultCache() :> IValidationResultCache + let cache = MemoryValidationResultCache () :> IValidationResultCache let mutable callCount = 0 let producer () = - Interlocked.Increment(&callCount) |> ignore - Thread.Sleep(10) // Simulate some work + Interlocked.Increment (&callCount) |> ignore + Thread.Sleep (10) // Simulate some work Success - + let key = { DocumentId = "doc1"; SchemaId = 1 } - + // Call cache from multiple threads simultaneously - let tasks = - [1..10] - |> List.map (fun _ -> - async { - return cache.GetOrAdd producer key - }) - + let tasks = + [ 1..10 ] + |> List.map (fun _ -> async { return cache.GetOrAdd producer key }) + let results = tasks |> Async.Parallel |> Async.RunSynchronously - + // All results should be Success results |> Array.iter (fun r -> equals Success r) - + // Producer should be called at least once, but possibly more due to race conditions // The important thing is it's not called 10 times - Assert.True(callCount >= 1 && callCount < 10, $"Expected callCount between 1 and 9, got {callCount}") + Assert.True (callCount >= 1 && callCount < 10, $"Expected callCount between 1 and 9, got {callCount}") From e92683810dc380e6210a807c2332726ca2c101f0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 18 May 2026 10:58:49 +0000 Subject: [PATCH 30/40] Escape U+2028/U+2029 in ToQueryString and add regression tests Agent-Logs-Url: https://github.com/fsprojects/FSharp.Data.GraphQL/sessions/fe5050da-a1a6-43d1-978f-18d01b969c4c Co-authored-by: xperiandri <2365592+xperiandri@users.noreply.github.com> --- .../AstExtensions.fs | 31 +++++++++++-------- .../AstExtensionsTests.fs | 18 +++++++++++ 2 files changed, 36 insertions(+), 13 deletions(-) diff --git a/src/FSharp.Data.GraphQL.Shared/AstExtensions.fs b/src/FSharp.Data.GraphQL.Shared/AstExtensions.fs index 3d793da9..86b0c040 100644 --- a/src/FSharp.Data.GraphQL.Shared/AstExtensions.fs +++ b/src/FSharp.Data.GraphQL.Shared/AstExtensions.fs @@ -36,13 +36,16 @@ and internal AstSelectionInfo = { } with member x.AliasOrName = x.Alias |> ValueOption.defaultValue x.Name - static member Create (typeCondition : string voption, path : FieldPath, name : string, alias : string voption, [] fields : AstSelectionInfo list) = { - TypeCondition = typeCondition - Name = name - Alias = alias - Path = path - Fields = if obj.ReferenceEquals (fields, null) then [] else fields - } + static member Create + (typeCondition : string voption, path : FieldPath, name : string, alias : string voption, [] fields : AstSelectionInfo list) + = + { + TypeCondition = typeCondition + Name = name + Alias = alias + Path = path + Fields = if obj.ReferenceEquals (fields, null) then [] else fields + } member x.SetFields (fields : AstSelectionInfo list) = x.Fields <- fields and AstFieldInfo = @@ -102,11 +105,11 @@ type Document with /// Generates a GraphQL query string from this document. /// /// Specify custom printing voptions for the query string. - member x.ToQueryString ([] options : QueryStringPrintingOptions) = + member x.ToQueryString ([] options : QueryStringPrintingOptions) = let sb = PaddedStringBuilder () let escapeGraphQLString (s : string) = - let escaped = StringBuilder(s.Length + 2) - escaped.Append('"') |> ignore + let escaped = StringBuilder (s.Length + 2) + escaped.Append ('"') |> ignore for c in s do let appendStr = match c with @@ -117,12 +120,14 @@ type Document with | '\n' -> "\\n" | '\r' -> "\\r" | '\t' -> "\\t" + | '\u2028' -> "\\u2028" + | '\u2029' -> "\\u2029" | c when c < '\u0020' -> - let hex = (int c).ToString("x4", CultureInfo.InvariantCulture) + let hex = (int c).ToString ("x4", CultureInfo.InvariantCulture) "\\u" + hex | c -> string c - escaped.Append(appendStr) |> ignore - escaped.Append('"').ToString() + escaped.Append (appendStr) |> ignore + escaped.Append('"').ToString () let withQuotes = escapeGraphQLString let rec printValue x = let printObjectValue (name, value) = diff --git a/tests/FSharp.Data.GraphQL.Tests/AstExtensionsTests.fs b/tests/FSharp.Data.GraphQL.Tests/AstExtensionsTests.fs index 1e4412d5..8996b547 100644 --- a/tests/FSharp.Data.GraphQL.Tests/AstExtensionsTests.fs +++ b/tests/FSharp.Data.GraphQL.Tests/AstExtensionsTests.fs @@ -456,6 +456,24 @@ let ``ToQueryString escapes control characters as unicode in string values`` () let reparsed = parse printed equals (document.ToQueryString ()) (reparsed.ToQueryString ()) +[] +let ``ToQueryString escapes unicode line separator in string values`` () = + let query = """query q { hero(text: "\u2028") }""" + let document = parse query + let printed = document.ToQueryString () + Assert.Contains ("\\u2028", printed) + let reparsed = parse printed + equals (document.ToQueryString ()) (reparsed.ToQueryString ()) + +[] +let ``ToQueryString escapes unicode paragraph separator in string values`` () = + let query = """query q { hero(text: "\u2029") }""" + let document = parse query + let printed = document.ToQueryString () + Assert.Contains ("\\u2029", printed) + let reparsed = parse printed + equals (document.ToQueryString ()) (reparsed.ToQueryString ()) + [] let ``ToQueryString escapes multiple special characters correctly`` () = let query = """query q { hero(text: "quote:\"newline:\nslash:\\tab:\t") }""" From 51b7defe1453567a6f03b2aaaaee496ba02913d3 Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Mon, 18 May 2026 13:20:47 +0200 Subject: [PATCH 31/40] Made `ExecutionTests` asynchronous --- .../AstExtensionsTests.fs | 7 +- .../ExecutionTests.fs | 899 ++++++++++-------- .../ValidationCacheTests.fs | 15 +- 3 files changed, 491 insertions(+), 430 deletions(-) diff --git a/tests/FSharp.Data.GraphQL.Tests/AstExtensionsTests.fs b/tests/FSharp.Data.GraphQL.Tests/AstExtensionsTests.fs index 8996b547..77411caa 100644 --- a/tests/FSharp.Data.GraphQL.Tests/AstExtensionsTests.fs +++ b/tests/FSharp.Data.GraphQL.Tests/AstExtensionsTests.fs @@ -344,12 +344,7 @@ let ``Should generate information map correctly`` () = Name = "friends" Alias = ValueNone Fields = [ - FragmentField { - Name = "primaryFunction" - Alias = ValueNone - TypeCondition = "Droid" - Fields = [] - } + FragmentField { Name = "primaryFunction"; Alias = ValueNone; TypeCondition = "Droid"; Fields = [] } FragmentField { Name = "id"; Alias = ValueNone; TypeCondition = "Droid"; Fields = [] } FragmentField { Name = "homePlanet"; Alias = ValueNone; TypeCondition = "Human"; Fields = [] } FragmentField { Name = "id"; Alias = ValueNone; TypeCondition = "Human"; Fields = [] } diff --git a/tests/FSharp.Data.GraphQL.Tests/ExecutionTests.fs b/tests/FSharp.Data.GraphQL.Tests/ExecutionTests.fs index 091a2191..b0b425c1 100644 --- a/tests/FSharp.Data.GraphQL.Tests/ExecutionTests.fs +++ b/tests/FSharp.Data.GraphQL.Tests/ExecutionTests.fs @@ -5,6 +5,7 @@ module FSharp.Data.GraphQL.Tests.ExecutionTests open Xunit open System +open System.Threading.Tasks open System.Text.Json open System.Text.Json.Serialization open System.Collections.Immutable @@ -47,29 +48,32 @@ and EnumArg = | Enum2 = 2 [] -let ``Execution handles basic tasks: executes arbitrary code`` () = - let rec data = - { - a = "Apple" - b = "Banana" - c = "Cookie" - d = "Donut" - e = "Egg" - f = "Fish" - pic = (fun size -> "Pic of size: " + (if size.IsSome then size.Value else 50).ToString()) - promise = async { return data } - deep = deep - } - and deep = - { - a = "Already Been Done" - b = "Boring" - c = Some "Contrived" - d = ValueSome "Donut" - l = [Some "Contrived"; None; Some "Confusing"] - } - - let ast = parse """query Example($size: Int) { +let ``Execution handles basic tasks: executes arbitrary code`` () : Task = + let rec data = { + a = "Apple" + b = "Banana" + c = "Cookie" + d = "Donut" + e = "Egg" + f = "Fish" + pic = + (fun size -> + "Pic of size: " + + (if size.IsSome then size.Value else 50).ToString ()) + promise = async { return data } + deep = deep + } + and deep = { + a = "Already Been Done" + b = "Boring" + c = Some "Contrived" + d = ValueSome "Donut" + l = [ Some "Contrived"; None; Some "Confusing" ] + } + + let ast = + parse + """query Example($size: Int) { a, b, x: c @@ -105,54 +109,72 @@ let ``Execution handles basic tasks: executes arbitrary code`` () = "f", upcast "Fish" "pic", upcast "Pic of size: 100" "promise", upcast NameValueLookup.ofList [ "a", upcast "Apple" ] - "deep", upcast NameValueLookup.ofList [ - "a", "Already Been Done" :> obj - "b", upcast "Boring" - "c", upcast "Contrived" - "d", upcast "Donut" - "l", upcast ["Contrived" :> obj; null; upcast "Confusing"] - ] + "deep", + upcast + NameValueLookup.ofList [ + "a", "Already Been Done" :> obj + "b", upcast "Boring" + "c", upcast "Contrived" + "d", upcast "Donut" + "l", upcast [ "Contrived" :> obj; null; upcast "Confusing" ] + ] ] let DeepDataType = - Define.Object( - "DeepDataType", [ - Define.Field("a", StringType, (fun _ dt -> dt.a)) - Define.Field("b", StringType, (fun _ dt -> dt.b)) - Define.Field("c", Nullable StringType, (fun _ dt -> dt.c)) - Define.Field("d", StructNullable StringType, (fun _ dt -> dt.d)) - Define.Field("l", (ListOf (Nullable StringType)), (fun _ dt -> dt.l)) - ]) + Define.Object ( + "DeepDataType", + [ + Define.Field ("a", StringType, (fun _ dt -> dt.a)) + Define.Field ("b", StringType, (fun _ dt -> dt.b)) + Define.Field ("c", Nullable StringType, (fun _ dt -> dt.c)) + Define.Field ("d", StructNullable StringType, (fun _ dt -> dt.d)) + Define.Field ("l", (ListOf (Nullable StringType)), (fun _ dt -> dt.l)) + ] + ) let rec DataType = - DefineRec.Object( - "DataType", - fieldsFn = fun () -> - [ - Define.Field("a", StringType, resolve = fun _ dt -> dt.a) - Define.Field("b", StringType, resolve = fun _ dt -> dt.b) - Define.Field("c", StringType, resolve = fun _ dt -> dt.c) - Define.Field("d", StringType, resolve = fun _ dt -> dt.d) - Define.Field("e", StringType, fun _ dt -> dt.e) - Define.Field("f", StringType, fun _ dt -> dt.f) - Define.Field("pic", StringType, "Picture resizer", [ Define.Input("size", Nullable IntType) ], fun ctx dt -> dt.pic(ctx.TryArg("size"))) - Define.AsyncField("promise", DataType, fun _ dt -> dt.promise) - Define.Field("deep", DeepDataType, fun _ dt -> dt.deep) - ]) - - let schema = Schema(DataType) - let schemaProcessor = Executor(schema) - let params' = JsonDocument.Parse("""{"size":100}""").RootElement.Deserialize>(serializerOptions) - let result = sync <| schemaProcessor.AsyncExecute(ast, getMockInputContext, data, variables = params', operationName = "Example") - ensureDirect result <| fun data errors -> - empty errors - data |> equals (upcast expected) - -type TestThing = { mutable Thing: string } + DefineRec.Object ( + "DataType", + fieldsFn = + fun () -> [ + Define.Field ("a", StringType, resolve = (fun _ dt -> dt.a)) + Define.Field ("b", StringType, resolve = (fun _ dt -> dt.b)) + Define.Field ("c", StringType, resolve = (fun _ dt -> dt.c)) + Define.Field ("d", StringType, resolve = (fun _ dt -> dt.d)) + Define.Field ("e", StringType, fun _ dt -> dt.e) + Define.Field ("f", StringType, fun _ dt -> dt.f) + Define.Field ( + "pic", + StringType, + "Picture resizer", + [ Define.Input ("size", Nullable IntType) ], + fun ctx dt -> dt.pic (ctx.TryArg ("size")) + ) + Define.AsyncField ("promise", DataType, fun _ dt -> dt.promise) + Define.Field ("deep", DeepDataType, fun _ dt -> dt.deep) + ] + ) + + let schema = Schema (DataType) + let schemaProcessor = Executor (schema) + let params' = + JsonDocument.Parse("""{"size":100}""").RootElement.Deserialize> (serializerOptions) + + task { + let! result = schemaProcessor.AsyncExecute (ast, getMockInputContext, data, variables = params', operationName = "Example") + ensureDirect result + <| fun data errors -> + empty errors + data |> equals (upcast expected) + } + +type TestThing = { mutable Thing : string } [] -let ``Execution handles basic tasks: merges parallel fragments`` () = - let ast = parse """{ a, ...FragOne, ...FragTwo } +let ``Execution handles basic tasks: merges parallel fragments`` () : Task = + let ast = + parse + """{ a, ...FragOne, ...FragTwo } fragment FragOne on Type { b @@ -165,495 +187,538 @@ let ``Execution handles basic tasks: merges parallel fragments`` () = }""" let rec Type = - DefineRec.Object( - name = "Type", - fieldsFn = fun () -> - [ - Define.Field("a", StringType, fun _ _ -> "Apple") - Define.Field("b", StringType, fun _ _ -> "Banana") - Define.Field("c", StringType, fun _ _ -> "Cherry") - Define.Field("deep", Type, fun _ v -> v) - ]) - - let schema = Schema(Type) - let schemaProcessor = Executor(schema) + DefineRec.Object ( + name = "Type", + fieldsFn = + fun () -> [ + Define.Field ("a", StringType, fun _ _ -> "Apple") + Define.Field ("b", StringType, fun _ _ -> "Banana") + Define.Field ("c", StringType, fun _ _ -> "Cherry") + Define.Field ("deep", Type, fun _ v -> v) + ] + ) + + let schema = Schema (Type) + let schemaProcessor = Executor (schema) let expected = NameValueLookup.ofList [ "a", upcast "Apple" "b", upcast "Banana" - "deep", upcast NameValueLookup.ofList [ - "b", upcast "Banana" - "deeper", upcast NameValueLookup.ofList [ - "b", "Banana" :> obj + "deep", + upcast + NameValueLookup.ofList [ + "b", upcast "Banana" + "deeper", upcast NameValueLookup.ofList [ "b", "Banana" :> obj; "c", upcast "Cherry" ] "c", upcast "Cherry" ] - "c", upcast "Cherry" - ] "c", upcast "Cherry" ] - let result = sync <| schemaProcessor.AsyncExecute(ast, getMockInputContext, obj()) - ensureDirect result <| fun data errors -> - empty errors - data |> equals (upcast expected) + task { + let! result = schemaProcessor.AsyncExecute (ast, getMockInputContext, obj ()) + ensureDirect result + <| fun data errors -> + empty errors + data |> equals (upcast expected) + } [] -let ``Execution handles basic tasks: threads root value context correctly`` () = +let ``Execution handles basic tasks: threads root value context correctly`` () : Task = let query = "query Example { a }" let data = { Thing = "" } - let Thing = Define.Object("Type", [ Define.Field("a", StringType, fun _ value -> value.Thing <- "thing"; value.Thing) ]) - let result = sync <| Executor(Schema(Thing)).AsyncExecute(parse query, getMockInputContext, data) - ensureDirect result <| fun _ errors -> empty errors - equals "thing" data.Thing + let Thing = + Define.Object ( + "Type", + [ + Define.Field ( + "a", + StringType, + fun _ value -> + value.Thing <- "thing" + value.Thing + ) + ] + ) + task { + let! result = Executor(Schema (Thing)).AsyncExecute (parse query, getMockInputContext, data) + ensureDirect result <| fun _ errors -> empty errors + equals "thing" data.Thing + } -type TestTarget = - { mutable Num: int voption - mutable Str: string voption } +type TestTarget = { mutable Num : int voption; mutable Str : string voption } [] -let ``Execution handles basic tasks: correctly threads arguments`` () = - let query = """query Example { +let ``Execution handles basic tasks: correctly threads arguments`` () : Task = + let query = + """query Example { b(numArg: 123, stringArg: "foo") }""" let data = { Num = ValueNone; Str = ValueNone } let Type = - Define.Object("Type", - [ Define.Field("b", StructNullable StringType, "", [ Define.Input("numArg", IntType); Define.Input("stringArg", StringType) ], - fun ctx value -> - value.Num <- ctx.TryArg("numArg") - value.Str <- ctx.TryArg("stringArg") - value.Str) ]) - - let result = sync <| Executor(Schema(Type)).AsyncExecute(parse query, getMockInputContext,data) - ensureDirect result <| fun _ errors -> empty errors - equals (ValueSome 123) data.Num - equals (ValueSome "foo") data.Str + Define.Object ( + "Type", + [ + Define.Field ( + "b", + StructNullable StringType, + "", + [ Define.Input ("numArg", IntType); Define.Input ("stringArg", StringType) ], + fun ctx value -> + value.Num <- ctx.TryArg ("numArg") + value.Str <- ctx.TryArg ("stringArg") + value.Str + ) + ] + ) + task { + let! result = Executor(Schema (Type)).AsyncExecute (parse query, getMockInputContext, data) + ensureDirect result <| fun _ errors -> empty errors + equals (ValueSome 123) data.Num + equals (ValueSome "foo") data.Str + } [] -let ``Execution handles basic tasks: correctly handles null arguments`` () = - let query = """query Example { +let ``Execution handles basic tasks: correctly handles null arguments`` () : Task = + let query = + """query Example { b(numArg: null, stringArg: null) }""" let data = { Num = ValueNone; Str = ValueNone } let Type = - Define.Object("Type", - [ Define.Field("b", StructNullable StringType, "", [ Define.Input("numArg", Nullable IntType); Define.Input("stringArg", Nullable StringType) ], - fun ctx value -> - value.Num <- ctx.TryArg("numArg") - value.Str <- ctx.TryArg("stringArg") - value.Str) ]) - - let result = sync <| Executor(Schema(Type)).AsyncExecute(parse query, getMockInputContext, data) - ensureDirect result <| fun _ errors -> empty errors - equals ValueNone data.Num - equals ValueNone data.Str + Define.Object ( + "Type", + [ + Define.Field ( + "b", + StructNullable StringType, + "", + [ Define.Input ("numArg", Nullable IntType); Define.Input ("stringArg", Nullable StringType) ], + fun ctx value -> + value.Num <- ctx.TryArg ("numArg") + value.Str <- ctx.TryArg ("stringArg") + value.Str + ) + ] + ) + task { + let! result = Executor(Schema (Type)).AsyncExecute (parse query, getMockInputContext, data) + ensureDirect result <| fun _ errors -> empty errors + equals ValueNone data.Num + equals ValueNone data.Str + } -type InlineTest = { A: string } +type InlineTest = { A : string } [] -let ``Execution handles basic tasks: correctly handles discriminated union arguments`` () = - let query = """query Example { +let ``Execution handles basic tasks: correctly handles discriminated union arguments`` () : Task = + let query = + """query Example { b(enumArg: Case1) }""" let EnumType = - Define.Enum( + Define.Enum ( name = "EnumArg", - options = - [ Define.EnumValue("Case1", DUArg.Case1, "Case 1") - Define.EnumValue("Case2", DUArg.Case2, "Case 2") ]) + options = [ + Define.EnumValue ("Case1", DUArg.Case1, "Case 1") + Define.EnumValue ("Case2", DUArg.Case2, "Case 2") + ] + ) let data = { Num = ValueNone; Str = ValueNone } let Type = - Define.Object("Type", - [ Define.Field("b", StructNullable StringType, "", [ Define.Input("enumArg", EnumType) ], - fun ctx value -> - let arg = ctx.TryArg("enumArg") - match arg with - | ValueSome (Case1) -> - value.Str <- ValueSome "foo" - value.Num <- ValueSome 123 - value.Str - | _ -> ValueNone) ]) - let result = sync <| Executor(Schema(Type)).AsyncExecute(parse query, getMockInputContext, data) - ensureDirect result <| fun _ errors -> empty errors - equals (ValueSome 123) data.Num - equals (ValueSome "foo") data.Str + Define.Object ( + "Type", + [ + Define.Field ( + "b", + StructNullable StringType, + "", + [ Define.Input ("enumArg", EnumType) ], + fun ctx value -> + let arg = ctx.TryArg ("enumArg") + match arg with + | ValueSome (Case1) -> + value.Str <- ValueSome "foo" + value.Num <- ValueSome 123 + value.Str + | _ -> ValueNone + ) + ] + ) + task { + let! result = Executor(Schema (Type)).AsyncExecute (parse query, getMockInputContext, data) + ensureDirect result <| fun _ errors -> empty errors + equals (ValueSome 123) data.Num + equals (ValueSome "foo") data.Str + } [] -let ``Execution handles basic tasks: correctly handles Enum arguments`` () = - let query = """query Example { +let ``Execution handles basic tasks: correctly handles Enum arguments`` () : Task = + let query = + """query Example { b(enumArg: Enum1) }""" let EnumType = - Define.Enum( + Define.Enum ( name = "EnumArg", - options = - [ Define.EnumValue("Enum1", EnumArg.Enum1, "Enum 1") - Define.EnumValue("Enum2", EnumArg.Enum2, "Enum 2") ]) + options = [ + Define.EnumValue ("Enum1", EnumArg.Enum1, "Enum 1") + Define.EnumValue ("Enum2", EnumArg.Enum2, "Enum 2") + ] + ) let data = { Num = ValueNone; Str = ValueNone } let Type = - Define.Object("Type", - [ Define.Field("b", StructNullable StringType, "", [ Define.Input("enumArg", EnumType) ], - fun ctx value -> - let arg = ctx.TryArg("enumArg") - match arg with - | ValueSome _ -> - value.Str <- ValueSome "foo" - value.Num <- ValueSome 123 - value.Str - | _ -> ValueNone) ]) - let result = sync <| Executor(Schema(Type)).AsyncExecute(parse query, getMockInputContext, data) - ensureDirect result <| fun _ errors -> empty errors - equals (ValueSome 123) data.Num - equals (ValueSome "foo") data.Str + Define.Object ( + "Type", + [ + Define.Field ( + "b", + StructNullable StringType, + "", + [ Define.Input ("enumArg", EnumType) ], + fun ctx value -> + let arg = ctx.TryArg ("enumArg") + match arg with + | ValueSome _ -> + value.Str <- ValueSome "foo" + value.Num <- ValueSome 123 + value.Str + | _ -> ValueNone + ) + ] + ) + task { + let! result = Executor(Schema (Type)).AsyncExecute (parse query, getMockInputContext, data) + ensureDirect result <| fun _ errors -> empty errors + equals (ValueSome 123) data.Num + equals (ValueSome "foo") data.Str + } [] -let ``Execution handles basic tasks: uses the inline operation if no operation name is provided`` () = +let ``Execution handles basic tasks: uses the inline operation if no operation name is provided`` () : Task = let schema = - Schema(Define.Object( - "Type", [ - Define.Field("a", StringType, fun _ x -> x.A) - ])) - let result = sync <| Executor(schema).AsyncExecute(parse "{ a }", getMockInputContext, { A = "b" }) - ensureDirect result <| fun data errors -> - empty errors - data |> equals (upcast NameValueLookup.ofList ["a", "b" :> obj]) + Schema (Define.Object ("Type", [ Define.Field ("a", StringType, fun _ x -> x.A) ])) + task { + let! result = Executor(schema).AsyncExecute (parse "{ a }", getMockInputContext, { A = "b" }) + ensureDirect result + <| fun data errors -> + empty errors + data + |> equals (upcast NameValueLookup.ofList [ "a", "b" :> obj ]) + } [] -let ``Execution handles basic tasks: uses the only operation if no operation name is provided`` () = +let ``Execution handles basic tasks: uses the only operation if no operation name is provided`` () : Task = let schema = - Schema(Define.Object( - "Type", [ - Define.Field("a", StringType, fun _ x -> x.A) - ])) - let result = sync <| Executor(schema).AsyncExecute(parse "query Example { a }", getMockInputContext, { A = "b" }) - ensureDirect result <| fun data errors -> - empty errors - data |> equals (upcast NameValueLookup.ofList ["a", "b" :> obj]) + Schema (Define.Object ("Type", [ Define.Field ("a", StringType, fun _ x -> x.A) ])) + task { + let! result = Executor(schema).AsyncExecute (parse "query Example { a }", getMockInputContext, { A = "b" }) + ensureDirect result + <| fun data errors -> + empty errors + data + |> equals (upcast NameValueLookup.ofList [ "a", "b" :> obj ]) + } [] -let ``Execution handles basic tasks: uses the named operation if operation name is provided`` () = +let ``Execution handles basic tasks: uses the named operation if operation name is provided`` () : Task = let schema = - Schema(Define.Object( - "Type", [ - Define.Field("a", StringType, fun _ x -> x.A) - ])) + Schema (Define.Object ("Type", [ Define.Field ("a", StringType, fun _ x -> x.A) ])) let query = "query Example { first: a } query OtherExample { second: a }" - let result = sync <| Executor(schema).AsyncExecute(parse query, getMockInputContext, { A = "b" }, operationName = "OtherExample") - ensureDirect result <| fun data errors -> - empty errors - data |> equals (upcast NameValueLookup.ofList ["second", "b" :> obj]) + task { + let! result = Executor(schema).AsyncExecute (parse query, getMockInputContext, { A = "b" }, operationName = "OtherExample") + ensureDirect result + <| fun data errors -> + empty errors + data + |> equals (upcast NameValueLookup.ofList [ "second", "b" :> obj ]) + } [] -let ``Execution handles basic tasks: list of scalars`` () = +let ``Execution handles basic tasks: list of scalars`` () : Task = let schema = - Schema(Define.Object( - "Type", [ - Define.Field("strings", ListOf StringType, fun _ _ -> ["foo"; "bar"; "baz"]) - ])) - let result = sync <| Executor(schema).AsyncExecute("query Example { strings }", getMockInputContext) - ensureDirect result <| fun data errors -> - empty errors - data |> equals (upcast NameValueLookup.ofList ["strings", box [ box "foo"; upcast "bar"; upcast "baz" ]]) + Schema (Define.Object ("Type", [ Define.Field ("strings", ListOf StringType, fun _ _ -> [ "foo"; "bar"; "baz" ]) ])) + task { + let! result = Executor(schema).AsyncExecute ("query Example { strings }", getMockInputContext) + ensureDirect result + <| fun data errors -> + empty errors + data + |> equals (upcast NameValueLookup.ofList [ "strings", box [ box "foo"; upcast "bar"; upcast "baz" ] ]) + } type TwiceTest = { A : string; B : int } [] -let ``Execution when querying the same field twice will return it`` () = +let ``Execution when querying the same field twice will return it`` () : Task = let schema = - Schema(Define.Object( - "Type", [ - Define.Field("a", StringType, fun _ x -> x.A) - Define.Field("b", IntType, fun _ x -> x.B) - ])) + Schema (Define.Object ("Type", [ Define.Field ("a", StringType, fun _ x -> x.A); Define.Field ("b", IntType, fun _ x -> x.B) ])) let query = "query Example { a, b, a }" - let result = sync <| Executor(schema).AsyncExecute(query, getMockInputContext, { A = "aa"; B = 2 }); - let expected = - NameValueLookup.ofList [ - "a", upcast "aa" - "b", upcast 2] - ensureDirect result <| fun data errors -> - empty errors - data |> equals (upcast expected) + let expected = NameValueLookup.ofList [ "a", upcast "aa"; "b", upcast 2 ] + task { + let! result = Executor(schema).AsyncExecute (query, getMockInputContext, { A = "aa"; B = 2 }) + ensureDirect result + <| fun data errors -> + empty errors + data |> equals (upcast expected) + } [] -let ``Execution when querying returns unique document id with response`` () = +let ``Execution when querying returns unique document id with response`` () : Task = let schema = - Schema(Define.Object( - "Type", [ - Define.Field("a", StringType, fun _ x -> x.A) - Define.Field("b", IntType, fun _ x -> x.B) - ])) + Schema (Define.Object ("Type", [ Define.Field ("a", StringType, fun _ x -> x.A); Define.Field ("b", IntType, fun _ x -> x.B) ])) let query = "query Example { a, b, a }" // Deterministic SHA-256-based documentId for canonical `query Example { a b a }`, // represented as lowercase hex string. // Computed once via parse + ToQueryString + SHA-256 and kept fixed to catch regressions. let expectedDocumentId = "84fbf8cde7d1ce2c00b8e92e5f3472919b89c97c8c853b6c95619a0cb7fb3c6f" - let result1 = sync <| Executor(schema).AsyncExecute(query, getMockInputContext, { A = "aa"; B = 2 }) - let result2 = sync <| Executor(schema).AsyncExecute(query, getMockInputContext, { A = "aa"; B = 2 }) - result1.DocumentId |> notEquals Unchecked.defaultof - result1.DocumentId |> equals expectedDocumentId - result1.DocumentId |> equals result2.DocumentId - match result1,result2 with - | Direct(data1, errors1), Direct(data2, errors2) -> - equals data1 data2 - equals errors1 errors2 - | response -> fail $"Expected a 'Direct' GQLResponse but got\n{response}" + task { + let executor = Executor(schema) + let! result1 = executor.AsyncExecute (query, getMockInputContext, { A = "aa"; B = 2 }) + let! result2 = executor.AsyncExecute (query, getMockInputContext, { A = "aa"; B = 2 }) + result1.DocumentId |> notEquals Unchecked.defaultof + result1.DocumentId |> equals expectedDocumentId + result1.DocumentId |> equals result2.DocumentId + match result1, result2 with + | Direct (data1, errors1), Direct (data2, errors2) -> + equals data1 data2 + equals errors1 errors2 + | response -> fail $"Expected a 'Direct' GQLResponse but got\n{response}" + } [] -let ``Execution documentId handles escaped string values correctly`` () = +let ``Execution documentId handles escaped string values correctly`` () : Task = let schema = - Schema(Define.Object( - "Type", [ - Define.Field("a", StringType, fun _ x -> x.A) - Define.Field("b", IntType, fun _ x -> x.B) - ])) + Schema (Define.Object ("Type", [ Define.Field ("a", StringType, fun _ x -> x.A); Define.Field ("b", IntType, fun _ x -> x.B) ])) // Query with string containing special characters that need escaping let query = """query Example { a(arg: "test\"quote\nline\ttab\\backslash") }""" - let result = sync <| Executor(schema).AsyncExecute(query, getMockInputContext, { A = "test"; B = 1 }) - // DocumentId should be deterministic and not empty - result.DocumentId |> notEquals Unchecked.defaultof - result.DocumentId.Length |> equals 64 // SHA-256 hex string is always 64 chars + task { + let! result = Executor(schema).AsyncExecute (query, getMockInputContext, { A = "test"; B = 1 }) + // DocumentId should be deterministic and not empty + result.DocumentId |> notEquals Unchecked.defaultof + result.DocumentId.Length |> equals 64 // SHA-256 hex string is always 64 chars + } [] -let ``Execution documentId is different for different queries`` () = +let ``Execution documentId is different for different queries`` () : Task = let schema = - Schema(Define.Object( - "Type", [ - Define.Field("a", StringType, fun _ x -> x.A) - Define.Field("b", IntType, fun _ x -> x.B) - ])) + Schema (Define.Object ("Type", [ Define.Field ("a", StringType, fun _ x -> x.A); Define.Field ("b", IntType, fun _ x -> x.B) ])) let query1 = "query Example1 { a }" let query2 = "query Example2 { b }" - let result1 = sync <| Executor(schema).AsyncExecute(query1, getMockInputContext, { A = "aa"; B = 2 }) - let result2 = sync <| Executor(schema).AsyncExecute(query2, getMockInputContext, { A = "aa"; B = 2 }) - result1.DocumentId |> notEquals result2.DocumentId + task { + let executor = Executor(schema) + let! result1 = executor.AsyncExecute (query1, getMockInputContext, { A = "aa"; B = 2 }) + let! result2 = executor.AsyncExecute (query2, getMockInputContext, { A = "aa"; B = 2 }) + result1.DocumentId |> notEquals result2.DocumentId + } [] -let ``Execution documentId is same for semantically identical queries`` () = +let ``Execution documentId is same for semantically identical queries`` () : Task = let schema = - Schema(Define.Object( - "Type", [ - Define.Field("a", StringType, fun _ x -> x.A) - Define.Field("b", IntType, fun _ x -> x.B) - ])) + Schema (Define.Object ("Type", [ Define.Field ("a", StringType, fun _ x -> x.A); Define.Field ("b", IntType, fun _ x -> x.B) ])) // Same query with different whitespace/formatting let query1 = "query Example { a b }" let query2 = "query Example{a b}" let query3 = "query Example { a, b }" - let result1 = sync <| Executor(schema).AsyncExecute(query1, getMockInputContext, { A = "aa"; B = 2 }) - let result2 = sync <| Executor(schema).AsyncExecute(query2, getMockInputContext, { A = "aa"; B = 2 }) - let result3 = sync <| Executor(schema).AsyncExecute(query3, getMockInputContext, { A = "aa"; B = 2 }) - // All should produce the same documentId since they parse to the same AST - result1.DocumentId |> equals result2.DocumentId - result1.DocumentId |> equals result3.DocumentId + task { + let executor = Executor(schema) + let! result1 = executor.AsyncExecute (query1, getMockInputContext, { A = "aa"; B = 2 }) + let! result2 = executor.AsyncExecute (query2, getMockInputContext, { A = "aa"; B = 2 }) + let! result3 = executor.AsyncExecute (query3, getMockInputContext, { A = "aa"; B = 2 }) + // All should produce the same documentId since they parse to the same AST + result1.DocumentId |> equals result2.DocumentId + result1.DocumentId |> equals result3.DocumentId + } type InnerNullableTest = { Kaboom : string } -type NullableTest = { - Inner : InnerNullableTest - InnerPartialSuccess : InnerNullableTest -} +type NullableTest = { Inner : InnerNullableTest; InnerPartialSuccess : InnerNullableTest } [] -let ``Execution handles errors: properly propagates errors`` () = +let ``Execution handles errors: properly propagates errors`` () : Task = let InnerObjType = - Define.Object( - "Inner", [ - Define.Field("kaboom", StringType, fun _ x -> x.Kaboom) - ]) + Define.Object ("Inner", [ Define.Field ("kaboom", StringType, fun _ x -> x.Kaboom) ]) let InnerPartialSuccessObjType = // executeResolvers/resolveWith, case 5 let resolvePartialSuccess (ctx : ResolveFieldContext) (_ : InnerNullableTest) = - ctx.AddError { new IGQLError with member _.Message = "Some non-critical error" } + ctx.AddError + { new IGQLError with + member _.Message = "Some non-critical error" + } "Yes, Rico, Kaboom" - Define.Object( - "InnerPartialSuccess", [ - Define.Field("kaboom", StringType, resolvePartialSuccess) - ]) + Define.Object ("InnerPartialSuccess", [ Define.Field ("kaboom", StringType, resolvePartialSuccess) ]) let schema = - Schema(Define.Object( - "Type", [ - Define.Field("inner", Nullable InnerObjType, fun _ x -> Some x.Inner) - Define.Field("partialSuccess", Nullable InnerPartialSuccessObjType, fun _ x -> Some x.InnerPartialSuccess) - ])) + Schema ( + Define.Object ( + "Type", + [ + Define.Field ("inner", Nullable InnerObjType, fun _ x -> Some x.Inner) + Define.Field ("partialSuccess", Nullable InnerPartialSuccessObjType, fun _ x -> Some x.InnerPartialSuccess) + ] + ) + ) let expectedData = - NameValueLookup.ofList [ - "inner", null - "partialSuccess", NameValueLookup.ofList [ - "kaboom", "Yes, Rico, Kaboom" - ] - ] + NameValueLookup.ofList [ "inner", null; "partialSuccess", NameValueLookup.ofList [ "kaboom", "Yes, Rico, Kaboom" ] ] let expectedErrors = [ GQLProblemDetails.CreateWithKind ("Non-Null field kaboom resolved as a null!", Execution, [ box "inner"; "kaboom" ]) GQLProblemDetails.CreateWithKind ("Some non-critical error", Execution, [ box "partialSuccess"; "kaboom" ]) ] - let result = - let variables = { Inner = { Kaboom = null }; InnerPartialSuccess = { Kaboom = "Yes, Rico, Kaboom" } } - sync <| Executor(schema).AsyncExecute("query Example { inner { kaboom } partialSuccess { kaboom } }", getMockInputContext, variables) - ensureDirect result <| fun data errors -> - result.DocumentId |> notEquals Unchecked.defaultof - data |> equals (upcast expectedData) - errors |> equals expectedErrors + let variables = { + Inner = { Kaboom = null } + InnerPartialSuccess = { Kaboom = "Yes, Rico, Kaboom" } + } + task { + let! result = Executor(schema).AsyncExecute ("query Example { inner { kaboom } partialSuccess { kaboom } }", getMockInputContext, variables) + ensureDirect result + <| fun data errors -> + result.DocumentId |> notEquals Unchecked.defaultof + data |> equals (upcast expectedData) + errors |> equals expectedErrors + } [] -let ``Execution handles errors: exceptions`` () = +let ``Execution handles errors: exceptions`` () : Task = let schema = - Schema(Define.Object( - "Type", [ - Define.Field("a", StringType, fun _ _ -> failwith "Resolver Error!") - ])) + Schema (Define.Object ("Type", [ Define.Field ("a", StringType, fun _ _ -> failwith "Resolver Error!") ])) let expectedError = GQLProblemDetails.CreateWithKind ("Resolver Error!", Execution, [ box "a" ]) - let result = sync <| Executor(schema).AsyncExecute("query Test { a }", getMockInputContext, ()) - ensureRequestError result <| fun [ error ] -> error |> equals expectedError + task { + let! result = Executor(schema).AsyncExecute ("query Test { a }", getMockInputContext, ()) + ensureRequestError result + <| fun [ error ] -> error |> equals expectedError + } [] -let ``Execution handles errors: nullable list fields`` () = +let ``Execution handles errors: nullable list fields`` () : Task = let InnerObject = - Define.Object( - "Inner", [ - Define.Field("error", StringType, fun _ _ -> failwith "Resolver Error!") - ]) + Define.Object ("Inner", [ Define.Field ("error", StringType, fun _ _ -> failwith "Resolver Error!") ]) let schema = - Schema(Define.Object( - "Type", [ - Define.Field("list", ListOf (Nullable InnerObject), fun _ _ -> [Some 1; Some 2; None]) - ])) - let expectedData = - NameValueLookup.ofList [ - "list", upcast [null; null; null] - ] - let expectedErrors = - [ - GQLProblemDetails.CreateWithKind ("Resolver Error!", Execution, [ box "list"; 0; "error" ]) - GQLProblemDetails.CreateWithKind ("Resolver Error!", Execution, [ box "list"; 1; "error" ]) - ] - let result = sync <| Executor(schema).AsyncExecute("query Test { list { error } }", getMockInputContext, ()) - ensureDirect result <| fun data errors -> - result.DocumentId |> notEquals Unchecked.defaultof - data |> equals (upcast expectedData) - errors |> equals expectedErrors + Schema (Define.Object ("Type", [ Define.Field ("list", ListOf (Nullable InnerObject), fun _ _ -> [ Some 1; Some 2; None ]) ])) + let expectedData = NameValueLookup.ofList [ "list", upcast [ null; null; null ] ] + let expectedErrors = [ + GQLProblemDetails.CreateWithKind ("Resolver Error!", Execution, [ box "list"; 0; "error" ]) + GQLProblemDetails.CreateWithKind ("Resolver Error!", Execution, [ box "list"; 1; "error" ]) + ] + task { + let! result = Executor(schema).AsyncExecute ("query Test { list { error } }", getMockInputContext, ()) + ensureDirect result + <| fun data errors -> + result.DocumentId |> notEquals Unchecked.defaultof + data |> equals (upcast expectedData) + errors |> equals expectedErrors + } [] -let ``Execution handles errors: additional error added when exception is raised in a nullable field resolver`` () = +let ``Execution handles errors: additional error added when exception is raised in a nullable field resolver`` () : Task = let InnerNullableExceptionObjType = // executeResolvers/resolveWith, case 1 let resolveWithException (ctx : ResolveFieldContext) (_ : InnerNullableTest) : string option = - ctx.AddError { new IGQLError with member _.Message = "Non-critical error" } + ctx.AddError + { new IGQLError with + member _.Message = "Non-critical error" + } raise (Exception "Unexpected error") - Define.Object( - "InnerNullableException", [ - Define.Field("kaboom", Nullable StringType, resolve = resolveWithException) - ]) + Define.Object ("InnerNullableException", [ Define.Field ("kaboom", Nullable StringType, resolve = resolveWithException) ]) let schema = - Schema(Define.Object( - "Type", [ - Define.Field("inner", Nullable InnerNullableExceptionObjType, fun _ x -> Some x.Inner) - ])) - let expectedData = - NameValueLookup.ofList [ - "inner", NameValueLookup.ofList [ - "kaboom", null - ] - ] - let expectedErrors = - [ - GQLProblemDetails.CreateWithKind ("Unexpected error", Execution, [ box "inner"; "kaboom" ]) - GQLProblemDetails.CreateWithKind ("Non-critical error", Execution, [ box "inner"; "kaboom" ]) - ] - let result = - let variables = { Inner = { Kaboom = null }; InnerPartialSuccess = { Kaboom = "Yes, Rico, Kaboom" } } - sync <| Executor(schema).AsyncExecute("query Example { inner { kaboom } }", getMockInputContext, variables) - ensureDirect result <| fun data errors -> - result.DocumentId |> notEquals Unchecked.defaultof - data |> equals (upcast expectedData) - errors |> equals expectedErrors + Schema (Define.Object ("Type", [ Define.Field ("inner", Nullable InnerNullableExceptionObjType, fun _ x -> Some x.Inner) ])) + let expectedData = NameValueLookup.ofList [ "inner", NameValueLookup.ofList [ "kaboom", null ] ] + let expectedErrors = [ + GQLProblemDetails.CreateWithKind ("Unexpected error", Execution, [ box "inner"; "kaboom" ]) + GQLProblemDetails.CreateWithKind ("Non-critical error", Execution, [ box "inner"; "kaboom" ]) + ] + let variables = { + Inner = { Kaboom = null } + InnerPartialSuccess = { Kaboom = "Yes, Rico, Kaboom" } + } + task { + let! result = Executor(schema).AsyncExecute ("query Example { inner { kaboom } }", getMockInputContext, variables) + ensureDirect result + <| fun data errors -> + result.DocumentId |> notEquals Unchecked.defaultof + data |> equals (upcast expectedData) + errors |> equals expectedErrors + } [] -let ``Execution handles errors: additional error added when None returned from a nullable field resolver`` () = +let ``Execution handles errors: additional error added when None returned from a nullable field resolver`` () : Task = let InnerNullableNoneObjType = // executeResolvers/resolveWith, case 2 let resolveWithNone (ctx : ResolveFieldContext) (_ : InnerNullableTest) : string option = - ctx.AddError { new IGQLError with member _.Message = "Non-critical error" } + ctx.AddError + { new IGQLError with + member _.Message = "Non-critical error" + } None - Define.Object( - "InnerNullableException", [ - Define.Field("kaboom", Nullable StringType, resolve = resolveWithNone) - ]) + Define.Object ("InnerNullableException", [ Define.Field ("kaboom", Nullable StringType, resolve = resolveWithNone) ]) let schema = - Schema(Define.Object( - "Type", [ - Define.Field("inner", Nullable InnerNullableNoneObjType, fun _ x -> Some x.Inner) - ])) - let expectedData = - NameValueLookup.ofList [ - "inner", NameValueLookup.ofList [ - "kaboom", null - ] - ] - let expectedErrors = - [ - GQLProblemDetails.CreateWithKind ("Non-critical error", Execution, [ box "inner"; "kaboom" ]) - ] - let result = - let variables = { Inner = { Kaboom = null }; InnerPartialSuccess = { Kaboom = "Yes, Rico, Kaboom" } } - sync <| Executor(schema).AsyncExecute("query Example { inner { kaboom } }", getMockInputContext, variables) - ensureDirect result <| fun data errors -> - result.DocumentId |> notEquals Unchecked.defaultof - data |> equals (upcast expectedData) - errors |> equals expectedErrors + Schema (Define.Object ("Type", [ Define.Field ("inner", Nullable InnerNullableNoneObjType, fun _ x -> Some x.Inner) ])) + let expectedData = NameValueLookup.ofList [ "inner", NameValueLookup.ofList [ "kaboom", null ] ] + let expectedErrors = [ GQLProblemDetails.CreateWithKind ("Non-critical error", Execution, [ box "inner"; "kaboom" ]) ] + let variables = { + Inner = { Kaboom = null } + InnerPartialSuccess = { Kaboom = "Yes, Rico, Kaboom" } + } + task { + let! result = Executor(schema).AsyncExecute ("query Example { inner { kaboom } }", getMockInputContext, variables) + ensureDirect result + <| fun data errors -> + result.DocumentId |> notEquals Unchecked.defaultof + data |> equals (upcast expectedData) + errors |> equals expectedErrors + } [] -let ``Execution handles errors: additional error added when exception is rised in a non-nullable field resolver`` () = +let ``Execution handles errors: additional error added when exception is rised in a non-nullable field resolver`` () : Task = let InnerNonNullableExceptionObjType = // executeResolvers/resolveWith, case 3 let resolveWithException (ctx : ResolveFieldContext) (_ : InnerNullableTest) : string = - ctx.AddError { new IGQLError with member _.Message = "Non-critical error" } + ctx.AddError + { new IGQLError with + member _.Message = "Non-critical error" + } raise (Exception "Fatal error") - Define.Object( - "InnerNonNullableException", [ - Define.Field("kaboom", StringType, resolve = resolveWithException) - ]) + Define.Object ("InnerNonNullableException", [ Define.Field ("kaboom", StringType, resolve = resolveWithException) ]) let schema = - Schema(Define.Object( - "Type", [ - Define.Field("inner", InnerNonNullableExceptionObjType, fun _ x -> x.Inner) - ])) - let expectedErrors = - [ - GQLProblemDetails.CreateWithKind ("Fatal error", Execution, [ box "inner"; "kaboom" ]) - GQLProblemDetails.CreateWithKind ("Non-critical error", Execution, [ box "inner"; "kaboom" ]) - ] - let result = - let variables = { Inner = { Kaboom = "Yes, Rico, Kaboom" }; InnerPartialSuccess = { Kaboom = "Yes, Rico, Kaboom" } } - sync <| Executor(schema).AsyncExecute("query Example { inner { kaboom } }", getMockInputContext, variables) - ensureRequestError result <| fun errors -> - result.DocumentId |> notEquals Unchecked.defaultof - errors |> equals expectedErrors + Schema (Define.Object ("Type", [ Define.Field ("inner", InnerNonNullableExceptionObjType, fun _ x -> x.Inner) ])) + let expectedErrors = [ + GQLProblemDetails.CreateWithKind ("Fatal error", Execution, [ box "inner"; "kaboom" ]) + GQLProblemDetails.CreateWithKind ("Non-critical error", Execution, [ box "inner"; "kaboom" ]) + ] + let variables = { + Inner = { Kaboom = "Yes, Rico, Kaboom" } + InnerPartialSuccess = { Kaboom = "Yes, Rico, Kaboom" } + } + task { + let! result = Executor(schema).AsyncExecute ("query Example { inner { kaboom } }", getMockInputContext, variables) + ensureRequestError result + <| fun errors -> + result.DocumentId |> notEquals Unchecked.defaultof + errors |> equals expectedErrors + } [] -let ``Execution handles errors: additional error added and when null returned from a non-nullable field resolver`` () = +let ``Execution handles errors: additional error added and when null returned from a non-nullable field resolver`` () : Task = let InnerNonNullableNullObjType = // executeResolvers/resolveWith, case 4 let resolveWithNull (ctx : ResolveFieldContext) (_ : InnerNullableTest) : string = - ctx.AddError { new IGQLError with member _.Message = "Non-critical error" } + ctx.AddError + { new IGQLError with + member _.Message = "Non-critical error" + } null - Define.Object( - "InnerNonNullableNull", [ - Define.Field("kaboom", StringType, resolveWithNull) - ]) + Define.Object ("InnerNonNullableNull", [ Define.Field ("kaboom", StringType, resolveWithNull) ]) let schema = - Schema(Define.Object( - "Type", [ - Define.Field("inner", InnerNonNullableNullObjType, fun _ x -> x.Inner) - ])) - let expectedErrors = - [ - GQLProblemDetails.CreateWithKind ("Non-Null field kaboom resolved as a null!", Execution, [ box "inner"; "kaboom" ]) - GQLProblemDetails.CreateWithKind ("Non-critical error", Execution, [ box "inner"; "kaboom" ]) - ] - let result = - let variables = { Inner = { Kaboom = "Yes, Rico, Kaboom" }; InnerPartialSuccess = { Kaboom = "Yes, Rico, Kaboom" } } - sync <| Executor(schema).AsyncExecute("query Example { inner { kaboom } }", getMockInputContext, variables) - ensureRequestError result <| fun errors -> - result.DocumentId |> notEquals Unchecked.defaultof - errors |> equals expectedErrors + Schema (Define.Object ("Type", [ Define.Field ("inner", InnerNonNullableNullObjType, fun _ x -> x.Inner) ])) + let expectedErrors = [ + GQLProblemDetails.CreateWithKind ("Non-Null field kaboom resolved as a null!", Execution, [ box "inner"; "kaboom" ]) + GQLProblemDetails.CreateWithKind ("Non-critical error", Execution, [ box "inner"; "kaboom" ]) + ] + let variables = { + Inner = { Kaboom = "Yes, Rico, Kaboom" } + InnerPartialSuccess = { Kaboom = "Yes, Rico, Kaboom" } + } + task { + let! result = Executor(schema).AsyncExecute ("query Example { inner { kaboom } }", getMockInputContext, variables) + ensureRequestError result + <| fun errors -> + result.DocumentId |> notEquals Unchecked.defaultof + errors |> equals expectedErrors + } diff --git a/tests/FSharp.Data.GraphQL.Tests/ValidationCacheTests.fs b/tests/FSharp.Data.GraphQL.Tests/ValidationCacheTests.fs index f764e093..1b1d0f64 100644 --- a/tests/FSharp.Data.GraphQL.Tests/ValidationCacheTests.fs +++ b/tests/FSharp.Data.GraphQL.Tests/ValidationCacheTests.fs @@ -3,10 +3,11 @@ module FSharp.Data.GraphQL.Tests.ValidationCacheTests +open System.Threading +open System.Threading.Tasks open Xunit open FSharp.Data.GraphQL open FSharp.Data.GraphQL.Validation -open System.Threading [] let ``MemoryValidationResultCache caches results for same key`` () = @@ -120,7 +121,7 @@ let ``MemoryValidationResultCache caches error results`` () = | Success -> fail "Expected ValidationError" [] -let ``MemoryValidationResultCache handles concurrent access`` () = +let ``MemoryValidationResultCache handles concurrent access`` () : Task = task { let cache = MemoryValidationResultCache () :> IValidationResultCache let mutable callCount = 0 let producer () = @@ -131,11 +132,10 @@ let ``MemoryValidationResultCache handles concurrent access`` () = let key = { DocumentId = "doc1"; SchemaId = 1 } // Call cache from multiple threads simultaneously - let tasks = - [ 1..10 ] - |> List.map (fun _ -> async { return cache.GetOrAdd producer key }) - - let results = tasks |> Async.Parallel |> Async.RunSynchronously + let! results = + [| 1..10 |] + |> Seq.map (fun _ -> task { return cache.GetOrAdd producer key }) + |> Task.WhenAll // All results should be Success results |> Array.iter (fun r -> equals Success r) @@ -143,3 +143,4 @@ let ``MemoryValidationResultCache handles concurrent access`` () = // Producer should be called at least once, but possibly more due to race conditions // The important thing is it's not called 10 times Assert.True (callCount >= 1 && callCount < 10, $"Expected callCount between 1 and 9, got {callCount}") +} From 01c75cb646556a5ae2e967825222417208b3ccca Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Mon, 18 May 2026 13:48:47 +0200 Subject: [PATCH 32/40] Fixed comments --- src/FSharp.Data.GraphQL.Server/Executor.fs | 8 ++++---- src/FSharp.Data.GraphQL.Shared/AstExtensions.fs | 4 ++-- src/FSharp.Data.GraphQL.Shared/TypeSystem.fs | 3 ++- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/src/FSharp.Data.GraphQL.Server/Executor.fs b/src/FSharp.Data.GraphQL.Server/Executor.fs index f369e6a8..a418b948 100644 --- a/src/FSharp.Data.GraphQL.Server/Executor.fs +++ b/src/FSharp.Data.GraphQL.Server/Executor.fs @@ -100,7 +100,7 @@ type Executor<'Root>(schema: ISchema<'Root>, middlewares : IExecutorMiddleware s match Validation.Types.validateTypeMap schema.TypeMap with | Success -> () | ValidationError errors -> raise (GQLMessageException (System.String.Join("\n", errors))) - + // Compute schema ID once after middleware has run and cache it for the lifetime of this Executor instance let schemaId = schema.Introspected.GetHashCode() @@ -189,7 +189,7 @@ type Executor<'Root>(schema: ISchema<'Root>, middlewares : IExecutorMiddleware s /// Asynchronously executes a provided execution plan. In case of repetitive queries, execution plan may be preprocessed /// and cached using `documentId` as an identifier. /// Returned value is a readonly dictionary consisting of following top level entries: - /// 'documentId' (unique identifier of current document's AST, it can be used as a key/identifier of ExecutionPlan as well), + /// 'documentId' (unique identifier of the current document's AST, it can be used as a key/identifier of ExecutionPlan as well), /// 'data' (GraphQL response matching the structure provided in GraphQL query string), and /// 'errors' (optional, contains a list of errors that occurred while executing a GraphQL operation). /// @@ -202,7 +202,7 @@ type Executor<'Root>(schema: ISchema<'Root>, middlewares : IExecutorMiddleware s /// /// Asynchronously executes parsed GraphQL query AST. Returned value is a readonly dictionary consisting of following top level entries: - /// 'documentId' (unique identifier of current document's AST, it can be used as a key/identifier of ExecutionPlan as well), + /// 'documentId' (unique identifier of the current document's AST, it can be used as a key/identifier of ExecutionPlan as well), /// 'data' (GraphQL response matching the structure provided in GraphQL query string), and /// 'errors' (optional, contains a list of errors that occurred while executing a GraphQL operation). /// @@ -220,7 +220,7 @@ type Executor<'Root>(schema: ISchema<'Root>, middlewares : IExecutorMiddleware s /// /// Asynchronously executes unparsed GraphQL query AST. Returned value is a readonly dictionary consisting of following top level entries: - /// 'documentId' (unique identifier of current document's AST, it can be used as a key/identifier of ExecutionPlan as well), + /// 'documentId' (unique identifier of the current document's AST, it can be used as a key/identifier of ExecutionPlan as well), /// 'data' (GraphQL response matching the structure provided in GraphQL query string), and /// 'errors' (optional, contains a list of errors that occurred while executing a GraphQL operation). /// diff --git a/src/FSharp.Data.GraphQL.Shared/AstExtensions.fs b/src/FSharp.Data.GraphQL.Shared/AstExtensions.fs index 86b0c040..ec60bb91 100644 --- a/src/FSharp.Data.GraphQL.Shared/AstExtensions.fs +++ b/src/FSharp.Data.GraphQL.Shared/AstExtensions.fs @@ -108,7 +108,7 @@ type Document with member x.ToQueryString ([] options : QueryStringPrintingOptions) = let sb = PaddedStringBuilder () let escapeGraphQLString (s : string) = - let escaped = StringBuilder (s.Length + 2) + let escaped = StringBuilder (s.Length + s.Length / 4 + 2) escaped.Append ('"') |> ignore for c in s do let appendStr = @@ -328,7 +328,7 @@ type Document with | None -> failwithf "Can not get information about fragment \"%s\". Fragment spread definition was not found in the query." name let operations = this.Definitions - |> List.choose (function + |> List_choose (function | FragmentDefinition _ -> None | OperationDefinition def -> Some def) |> List.map (fun operation -> operation.Name, operation) diff --git a/src/FSharp.Data.GraphQL.Shared/TypeSystem.fs b/src/FSharp.Data.GraphQL.Shared/TypeSystem.fs index 55b3f930..8c2f54b9 100644 --- a/src/FSharp.Data.GraphQL.Shared/TypeSystem.fs +++ b/src/FSharp.Data.GraphQL.Shared/TypeSystem.fs @@ -671,6 +671,7 @@ and PlanningContext = { RootDef : ObjectDef Document : Document Operation : OperationDefinition + /// Unique identifier of the current document's AST. DocumentId : string Metadata : Metadata } @@ -887,7 +888,7 @@ and SchemaCompileContext = { /// A planning of an execution phase. /// It is used by the execution process to execute an operation. and ExecutionPlan = { - /// Unique identifier of the current execution plan. + /// Unique identifier of the current document's AST. DocumentId : string /// AST definition of current operation. Operation : OperationDefinition From a89e07c913727d6e12cbf2f4c4b20305c2842421 Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Mon, 18 May 2026 13:48:59 +0200 Subject: [PATCH 33/40] Removed unnecessary test --- .../ExecutionTests.fs | 23 ------------------- 1 file changed, 23 deletions(-) diff --git a/tests/FSharp.Data.GraphQL.Tests/ExecutionTests.fs b/tests/FSharp.Data.GraphQL.Tests/ExecutionTests.fs index b0b425c1..5d4c52c6 100644 --- a/tests/FSharp.Data.GraphQL.Tests/ExecutionTests.fs +++ b/tests/FSharp.Data.GraphQL.Tests/ExecutionTests.fs @@ -462,29 +462,6 @@ let ``Execution when querying the same field twice will return it`` () : Task = data |> equals (upcast expected) } -[] -let ``Execution when querying returns unique document id with response`` () : Task = - let schema = - Schema (Define.Object ("Type", [ Define.Field ("a", StringType, fun _ x -> x.A); Define.Field ("b", IntType, fun _ x -> x.B) ])) - let query = "query Example { a, b, a }" - // Deterministic SHA-256-based documentId for canonical `query Example { a b a }`, - // represented as lowercase hex string. - // Computed once via parse + ToQueryString + SHA-256 and kept fixed to catch regressions. - let expectedDocumentId = "84fbf8cde7d1ce2c00b8e92e5f3472919b89c97c8c853b6c95619a0cb7fb3c6f" - task { - let executor = Executor(schema) - let! result1 = executor.AsyncExecute (query, getMockInputContext, { A = "aa"; B = 2 }) - let! result2 = executor.AsyncExecute (query, getMockInputContext, { A = "aa"; B = 2 }) - result1.DocumentId |> notEquals Unchecked.defaultof - result1.DocumentId |> equals expectedDocumentId - result1.DocumentId |> equals result2.DocumentId - match result1, result2 with - | Direct (data1, errors1), Direct (data2, errors2) -> - equals data1 data2 - equals errors1 errors2 - | response -> fail $"Expected a 'Direct' GQLResponse but got\n{response}" - } - [] let ``Execution documentId handles escaped string values correctly`` () : Task = let schema = From f76de93afb8fc7ff52bc2e490bb20da558992c39 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 18 May 2026 11:58:15 +0000 Subject: [PATCH 34/40] Make concurrent cache test actually run in parallel Agent-Logs-Url: https://github.com/fsprojects/FSharp.Data.GraphQL/sessions/95956635-01ea-45da-a2fc-e89c2faafbbd Co-authored-by: xperiandri <2365592+xperiandri@users.noreply.github.com> --- .../ValidationCacheTests.fs | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/tests/FSharp.Data.GraphQL.Tests/ValidationCacheTests.fs b/tests/FSharp.Data.GraphQL.Tests/ValidationCacheTests.fs index 1b1d0f64..4fe0a3e1 100644 --- a/tests/FSharp.Data.GraphQL.Tests/ValidationCacheTests.fs +++ b/tests/FSharp.Data.GraphQL.Tests/ValidationCacheTests.fs @@ -132,10 +132,21 @@ let ``MemoryValidationResultCache handles concurrent access`` () : Task = task { let key = { DocumentId = "doc1"; SchemaId = 1 } // Call cache from multiple threads simultaneously - let! results = - [| 1..10 |] - |> Seq.map (fun _ -> task { return cache.GetOrAdd producer key }) - |> Task.WhenAll + let workerCount = 10 + use ready = CountdownEvent workerCount + use startGate = new ManualResetEventSlim false + + let workers = + [| 1..workerCount |] + |> Seq.map (fun _ -> + Task.Run (fun () -> + ready.Signal () |> ignore + startGate.Wait () + cache.GetOrAdd producer key)) + + ready.Wait () + startGate.Set () + let! results = workers |> Task.WhenAll // All results should be Success results |> Array.iter (fun r -> equals Success r) From 76cbccd9fc5a45bd0cd2e19f6152708c1753522d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 18 May 2026 11:59:18 +0000 Subject: [PATCH 35/40] Rename worker readiness gate in concurrent cache test Agent-Logs-Url: https://github.com/fsprojects/FSharp.Data.GraphQL/sessions/95956635-01ea-45da-a2fc-e89c2faafbbd Co-authored-by: xperiandri <2365592+xperiandri@users.noreply.github.com> --- tests/FSharp.Data.GraphQL.Tests/ValidationCacheTests.fs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/FSharp.Data.GraphQL.Tests/ValidationCacheTests.fs b/tests/FSharp.Data.GraphQL.Tests/ValidationCacheTests.fs index 4fe0a3e1..20933a78 100644 --- a/tests/FSharp.Data.GraphQL.Tests/ValidationCacheTests.fs +++ b/tests/FSharp.Data.GraphQL.Tests/ValidationCacheTests.fs @@ -133,18 +133,18 @@ let ``MemoryValidationResultCache handles concurrent access`` () : Task = task { // Call cache from multiple threads simultaneously let workerCount = 10 - use ready = CountdownEvent workerCount + use workersReady = CountdownEvent workerCount use startGate = new ManualResetEventSlim false let workers = [| 1..workerCount |] |> Seq.map (fun _ -> Task.Run (fun () -> - ready.Signal () |> ignore + workersReady.Signal () |> ignore startGate.Wait () cache.GetOrAdd producer key)) - ready.Wait () + workersReady.Wait () startGate.Set () let! results = workers |> Task.WhenAll From 31639fc82ce383a03a5a31c9cd166b7d0ea0c5fa Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 18 May 2026 12:26:50 +0000 Subject: [PATCH 36/40] Fix cross-platform documentId hashing and AstExtensions List.choose Agent-Logs-Url: https://github.com/fsprojects/FSharp.Data.GraphQL/sessions/7ce8c26c-d831-4c14-a520-93ba8f1f1622 Co-authored-by: xperiandri <2365592+xperiandri@users.noreply.github.com> --- src/FSharp.Data.GraphQL.Shared/AstExtensions.fs | 2 +- .../Helpers/DocumentId.fs | 12 +++++------- .../ValidationCacheTests.fs | 15 +++++++-------- 3 files changed, 13 insertions(+), 16 deletions(-) diff --git a/src/FSharp.Data.GraphQL.Shared/AstExtensions.fs b/src/FSharp.Data.GraphQL.Shared/AstExtensions.fs index ec60bb91..34ea5ce7 100644 --- a/src/FSharp.Data.GraphQL.Shared/AstExtensions.fs +++ b/src/FSharp.Data.GraphQL.Shared/AstExtensions.fs @@ -328,7 +328,7 @@ type Document with | None -> failwithf "Can not get information about fragment \"%s\". Fragment spread definition was not found in the query." name let operations = this.Definitions - |> List_choose (function + |> List.choose (function | FragmentDefinition _ -> None | OperationDefinition def -> Some def) |> List.map (fun operation -> operation.Name, operation) diff --git a/src/FSharp.Data.GraphQL.Shared/Helpers/DocumentId.fs b/src/FSharp.Data.GraphQL.Shared/Helpers/DocumentId.fs index 1e5cc800..9b94a10c 100644 --- a/src/FSharp.Data.GraphQL.Shared/Helpers/DocumentId.fs +++ b/src/FSharp.Data.GraphQL.Shared/Helpers/DocumentId.fs @@ -5,8 +5,7 @@ open System.Runtime.CompilerServices open System.Security.Cryptography open System.Text -let private formatByteAsLowerHex (value : byte) = - value.ToString("x2", CultureInfo.InvariantCulture) +let private formatByteAsLowerHex (value : byte) = value.ToString ("x2", CultureInfo.InvariantCulture) /// /// Computes a deterministic document identifier from a canonical GraphQL query string. @@ -15,9 +14,8 @@ let private formatByteAsLowerHex (value : byte) = /// A lowercase hexadecimal SHA-256 hash string that uniquely identifies the document content. [] let fromCanonicalQuery (canonicalQuery : string) = - let queryBytes = Encoding.UTF8.GetBytes canonicalQuery - use sha256 = SHA256.Create() + let normalizedCanonicalQuery = canonicalQuery.Replace("\r\n", "\n").Replace ("\r", "\n") + let queryBytes = Encoding.UTF8.GetBytes normalizedCanonicalQuery + use sha256 = SHA256.Create () let hash = sha256.ComputeHash queryBytes - hash - |> Seq.map formatByteAsLowerHex - |> String.concat "" + hash |> Seq.map formatByteAsLowerHex |> String.concat "" diff --git a/tests/FSharp.Data.GraphQL.Tests/ValidationCacheTests.fs b/tests/FSharp.Data.GraphQL.Tests/ValidationCacheTests.fs index 20933a78..d083f252 100644 --- a/tests/FSharp.Data.GraphQL.Tests/ValidationCacheTests.fs +++ b/tests/FSharp.Data.GraphQL.Tests/ValidationCacheTests.fs @@ -133,25 +133,24 @@ let ``MemoryValidationResultCache handles concurrent access`` () : Task = task { // Call cache from multiple threads simultaneously let workerCount = 10 - use workersReady = CountdownEvent workerCount - use startGate = new ManualResetEventSlim false + use workersReadyGate = new CountdownEvent (workerCount) + use startGate = new ManualResetEventSlim (false) let workers = [| 1..workerCount |] - |> Seq.map (fun _ -> + |> Array.map (fun _ -> Task.Run (fun () -> - workersReady.Signal () |> ignore + workersReadyGate.Signal () |> ignore startGate.Wait () cache.GetOrAdd producer key)) - workersReady.Wait () + workersReadyGate.Wait () startGate.Set () let! results = workers |> Task.WhenAll // All results should be Success results |> Array.iter (fun r -> equals Success r) - // Producer should be called at least once, but possibly more due to race conditions - // The important thing is it's not called 10 times - Assert.True (callCount >= 1 && callCount < 10, $"Expected callCount between 1 and 9, got {callCount}") + // Producer should be called at least once, but can run up to workerCount times due to ConcurrentDictionary.GetOrAdd factory semantics. + Assert.True (callCount >= 1 && callCount <= workerCount, $"Expected callCount between 1 and {workerCount}, got {callCount}") } From ce579384390e6bcf7df5b20e0d66e8b818dc831c Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Mon, 18 May 2026 16:06:58 +0200 Subject: [PATCH 37/40] Improved query normalization --- .../ProvidedTypesHelper.fs | 2 +- src/FSharp.Data.GraphQL.Server/Executor.fs | 2 +- .../Helpers/DocumentId.fs | 33 ++++++++++++++++--- .../DocumentIdTests.fs | 24 ++++++++++++++ 4 files changed, 54 insertions(+), 7 deletions(-) diff --git a/src/FSharp.Data.GraphQL.Client.DesignTime/ProvidedTypesHelper.fs b/src/FSharp.Data.GraphQL.Client.DesignTime/ProvidedTypesHelper.fs index 984a8335..aa3e69cd 100644 --- a/src/FSharp.Data.GraphQL.Client.DesignTime/ProvidedTypesHelper.fs +++ b/src/FSharp.Data.GraphQL.Client.DesignTime/ProvidedTypesHelper.fs @@ -799,7 +799,7 @@ module internal Provider = match validationResult with | ValidationError msgs -> failwith (formatValidationExceptionMessage msgs) | Success -> () - let key = { DocumentId = DocumentId.fromCanonicalQuery (queryAst.ToQueryString()); SchemaId = schema.GetHashCode() } + let key = { DocumentId = DocumentId.fromCanonicalQueryUnsafe (queryAst.ToQueryString()); SchemaId = schema.GetHashCode() } let refMaker = lazy Validation.Ast.validateDocument schema queryAst if clientQueryValidation then refMaker.Force diff --git a/src/FSharp.Data.GraphQL.Server/Executor.fs b/src/FSharp.Data.GraphQL.Server/Executor.fs index a418b948..0167aa3d 100644 --- a/src/FSharp.Data.GraphQL.Server/Executor.fs +++ b/src/FSharp.Data.GraphQL.Server/Executor.fs @@ -142,7 +142,7 @@ type Executor<'Root>(schema: ISchema<'Root>, middlewares : IExecutorMiddleware s eval (executionPlan, data, variables, getInputContext) let createExecutionPlan (ast: Document, operationName: string option, meta : Metadata) = - let documentId = DocumentId.fromCanonicalQuery (ast.ToQueryString()) + let documentId = DocumentId.fromCanonicalQueryUnsafe (ast.ToQueryString()) result { match findOperation ast operationName with | Some operation -> diff --git a/src/FSharp.Data.GraphQL.Shared/Helpers/DocumentId.fs b/src/FSharp.Data.GraphQL.Shared/Helpers/DocumentId.fs index 9b94a10c..e80876b7 100644 --- a/src/FSharp.Data.GraphQL.Shared/Helpers/DocumentId.fs +++ b/src/FSharp.Data.GraphQL.Shared/Helpers/DocumentId.fs @@ -7,6 +7,13 @@ open System.Text let private formatByteAsLowerHex (value : byte) = value.ToString ("x2", CultureInfo.InvariantCulture) +let internal fromCanonicalQueryUnsafe (canonicalQuery : string) = + let queryBytes = Encoding.UTF8.GetBytes canonicalQuery + use sha256 = SHA256.Create () + let hash = sha256.ComputeHash queryBytes + hash |> Seq.map formatByteAsLowerHex |> String.concat "" + + /// /// Computes a deterministic document identifier from a canonical GraphQL query string. /// @@ -14,8 +21,24 @@ let private formatByteAsLowerHex (value : byte) = value.ToString ("x2", CultureI /// A lowercase hexadecimal SHA-256 hash string that uniquely identifies the document content. [] let fromCanonicalQuery (canonicalQuery : string) = - let normalizedCanonicalQuery = canonicalQuery.Replace("\r\n", "\n").Replace ("\r", "\n") - let queryBytes = Encoding.UTF8.GetBytes normalizedCanonicalQuery - use sha256 = SHA256.Create () - let hash = sha256.ComputeHash queryBytes - hash |> Seq.map formatByteAsLowerHex |> String.concat "" + let normalizedCanonicalQuery = + let crIndex = canonicalQuery.IndexOf '\r' + if crIndex < 0 then + canonicalQuery + else + let sb = StringBuilder (canonicalQuery.Length) + sb.Append (canonicalQuery, 0, crIndex) |> ignore + let mutable i = crIndex + while i < canonicalQuery.Length do + let c = canonicalQuery[i] + if c = '\r' then + sb.Append '\n' |> ignore + if i + 1 < canonicalQuery.Length && canonicalQuery[i + 1] = '\n' then + i <- i + 2 + else + i <- i + 1 + else + sb.Append c |> ignore + i <- i + 1 + sb.ToString () + fromCanonicalQueryUnsafe normalizedCanonicalQuery diff --git a/tests/FSharp.Data.GraphQL.Tests/DocumentIdTests.fs b/tests/FSharp.Data.GraphQL.Tests/DocumentIdTests.fs index 21069a6e..b8a86610 100644 --- a/tests/FSharp.Data.GraphQL.Tests/DocumentIdTests.fs +++ b/tests/FSharp.Data.GraphQL.Tests/DocumentIdTests.fs @@ -6,6 +6,30 @@ module FSharp.Data.GraphQL.Tests.DocumentIdTests open Xunit open FSharp.Data.GraphQL +[] +let ``DocumentId.fromCanonicalQueryUnsafe produces deterministic hash`` () = + let query = "query Example { a b }" + let hash1 = DocumentId.fromCanonicalQueryUnsafe query + let hash2 = DocumentId.fromCanonicalQueryUnsafe query + equals hash1 hash2 + equals 64 hash1.Length // SHA-256 hex string is 64 chars + +[] +let ``DocumentId.fromCanonicalQueryUnsafe produces different hashes for different queries`` () = + let query1 = "query Example1 { a }" + let query2 = "query Example2 { b }" + let hash1 = DocumentId.fromCanonicalQueryUnsafe query1 + let hash2 = DocumentId.fromCanonicalQueryUnsafe query2 + notEquals hash1 hash2 + +[] +let ``DocumentId.fromCanonicalQueryUnsafe handles empty string`` () = + let query = "" + let hash = DocumentId.fromCanonicalQueryUnsafe query + equals 64 hash.Length + // SHA-256 of empty string + equals "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" hash + [] let ``DocumentId.fromCanonicalQuery produces deterministic hash`` () = let query = "query Example { a b }" From 398c17aff86718b902a844ee947db2f05705fc99 Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Mon, 18 May 2026 16:07:16 +0200 Subject: [PATCH 38/40] Simplified cache test --- .../ValidationCacheTests.fs | 21 +++++-------------- 1 file changed, 5 insertions(+), 16 deletions(-) diff --git a/tests/FSharp.Data.GraphQL.Tests/ValidationCacheTests.fs b/tests/FSharp.Data.GraphQL.Tests/ValidationCacheTests.fs index d083f252..c6d93202 100644 --- a/tests/FSharp.Data.GraphQL.Tests/ValidationCacheTests.fs +++ b/tests/FSharp.Data.GraphQL.Tests/ValidationCacheTests.fs @@ -121,7 +121,7 @@ let ``MemoryValidationResultCache caches error results`` () = | Success -> fail "Expected ValidationError" [] -let ``MemoryValidationResultCache handles concurrent access`` () : Task = task { +let ``MemoryValidationResultCache handles concurrent access`` () = let cache = MemoryValidationResultCache () :> IValidationResultCache let mutable callCount = 0 let producer () = @@ -131,26 +131,15 @@ let ``MemoryValidationResultCache handles concurrent access`` () : Task = task { let key = { DocumentId = "doc1"; SchemaId = 1 } - // Call cache from multiple threads simultaneously let workerCount = 10 - use workersReadyGate = new CountdownEvent (workerCount) - use startGate = new ManualResetEventSlim (false) + let results = Array.zeroCreate workerCount - let workers = - [| 1..workerCount |] - |> Array.map (fun _ -> - Task.Run (fun () -> - workersReadyGate.Signal () |> ignore - startGate.Wait () - cache.GetOrAdd producer key)) - - workersReadyGate.Wait () - startGate.Set () - let! results = workers |> Task.WhenAll + Parallel.For (0, workerCount, fun i -> + results.[i] <- cache.GetOrAdd producer key + ) |> ignore // All results should be Success results |> Array.iter (fun r -> equals Success r) // Producer should be called at least once, but can run up to workerCount times due to ConcurrentDictionary.GetOrAdd factory semantics. Assert.True (callCount >= 1 && callCount <= workerCount, $"Expected callCount between 1 and {workerCount}, got {callCount}") -} From 3c0a672221337c423908487dfc84b46430ab077e Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Mon, 18 May 2026 19:25:11 +0200 Subject: [PATCH 39/40] Execution plan cache fix Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../ProvidedTypesHelper.fs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/FSharp.Data.GraphQL.Client.DesignTime/ProvidedTypesHelper.fs b/src/FSharp.Data.GraphQL.Client.DesignTime/ProvidedTypesHelper.fs index aa3e69cd..984a8335 100644 --- a/src/FSharp.Data.GraphQL.Client.DesignTime/ProvidedTypesHelper.fs +++ b/src/FSharp.Data.GraphQL.Client.DesignTime/ProvidedTypesHelper.fs @@ -799,7 +799,7 @@ module internal Provider = match validationResult with | ValidationError msgs -> failwith (formatValidationExceptionMessage msgs) | Success -> () - let key = { DocumentId = DocumentId.fromCanonicalQueryUnsafe (queryAst.ToQueryString()); SchemaId = schema.GetHashCode() } + let key = { DocumentId = DocumentId.fromCanonicalQuery (queryAst.ToQueryString()); SchemaId = schema.GetHashCode() } let refMaker = lazy Validation.Ast.validateDocument schema queryAst if clientQueryValidation then refMaker.Force From 7371e7d2baf693f5b6164336f99f97ee594db9c3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 18 May 2026 17:27:39 +0000 Subject: [PATCH 40/40] Fix escaped-string documentId test to execute valid argument path Agent-Logs-Url: https://github.com/fsprojects/FSharp.Data.GraphQL/sessions/1b4b4e4f-e994-4b23-92e4-8f31ed38a207 Co-authored-by: xperiandri <2365592+xperiandri@users.noreply.github.com> --- .../ExecutionTests.fs | 23 ++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/tests/FSharp.Data.GraphQL.Tests/ExecutionTests.fs b/tests/FSharp.Data.GraphQL.Tests/ExecutionTests.fs index 5d4c52c6..aae97f40 100644 --- a/tests/FSharp.Data.GraphQL.Tests/ExecutionTests.fs +++ b/tests/FSharp.Data.GraphQL.Tests/ExecutionTests.fs @@ -465,7 +465,24 @@ let ``Execution when querying the same field twice will return it`` () : Task = [] let ``Execution documentId handles escaped string values correctly`` () : Task = let schema = - Schema (Define.Object ("Type", [ Define.Field ("a", StringType, fun _ x -> x.A); Define.Field ("b", IntType, fun _ x -> x.B) ])) + Schema ( + Define.Object ( + "Type", + [ + Define.Field ( + "a", + StringType, + "", + [ Define.Input ("arg", StringType) ], + fun ctx x -> + match ctx.TryArg ("arg") with + | ValueSome arg -> arg + | ValueNone -> x.A + ) + Define.Field ("b", IntType, fun _ x -> x.B) + ] + ) + ) // Query with string containing special characters that need escaping let query = """query Example { a(arg: "test\"quote\nline\ttab\\backslash") }""" task { @@ -482,7 +499,7 @@ let ``Execution documentId is different for different queries`` () : Task = let query1 = "query Example1 { a }" let query2 = "query Example2 { b }" task { - let executor = Executor(schema) + let executor = Executor (schema) let! result1 = executor.AsyncExecute (query1, getMockInputContext, { A = "aa"; B = 2 }) let! result2 = executor.AsyncExecute (query2, getMockInputContext, { A = "aa"; B = 2 }) result1.DocumentId |> notEquals result2.DocumentId @@ -497,7 +514,7 @@ let ``Execution documentId is same for semantically identical queries`` () : Tas let query2 = "query Example{a b}" let query3 = "query Example { a, b }" task { - let executor = Executor(schema) + let executor = Executor (schema) let! result1 = executor.AsyncExecute (query1, getMockInputContext, { A = "aa"; B = 2 }) let! result2 = executor.AsyncExecute (query2, getMockInputContext, { A = "aa"; B = 2 }) let! result3 = executor.AsyncExecute (query3, getMockInputContext, { A = "aa"; B = 2 })