From 2e4a7406b140a60c1f9958ff8f330a357c152a0d 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 251244312238184d754c9a2691fd1b611a65db2e 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 66bb9fe82169b48ca7eb37261b82897717f57b84 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 15350705e3849e51f81b74c467ef076b473fb9f2 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 2f6317c00a8c4cf4bbb9c61a5169ba5afde24eb8 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 ab4f7030b995574d4ab45f24d640818b75a88e2a 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 4a1c1251f6db9e33a7d8250e2b6e15fe72424893 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 bef0a939ac398be16bbfa505b02a7978b03a3864 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 7a41e719ce00d5af86ac54e9e66045caaefe8f9b 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 547b790bda689365bc7f1a1aba54b2039608543b 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 c729a3dacacd398ea119e60aad811a4dbd726ec4 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 355d61142589fa1549ae8be2388f927eed28ee29 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 86faa933e59e444b1b8390a769234363ebe502dd 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 93db5f834855fde1f8e0eb4f1b99b25d6163b58e 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 cdee8043b3002c7b80acfa97d1105c48aefb942e 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 cba2e8e861382f55d4907cde6bb45b200228cddc 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 b5c10b27467801c382de8b1dbbb549e31f2d071d 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 68aed4c130d9233412de3b8d71812ede69db756b 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 c8a86a18fed0ba4237877a8a5e3e7daa87b0f55b 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 a7a7e9815b5b7627dc8e0d664bb522337027783c 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 c12bac6f99cbf8c387855a7c3c4e145b5bc3afa1 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 a3e7da4939fa21a8a6e792fb28905777a4a118dc 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 2c575af8deaefcf938e9191f7b12bd3d82c0c4c4 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 9b1789918e94a0a910d3bc8d25cb62589d7bc908 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 df453d390a83f1334e06bf4422219c277c2dbcc0 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 5a795271c74dfac4b16490800135a98c02eed00b 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 4388510a75f6b515836784e4e9a039ced3999fbc 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 fd2f69a4dbde54bdfaf36703c1e700aae6fe3abb 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 d819ed2e5a6557bfb1cfe88cf549f307c66774f2 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 bb491fe97aa4b798848e75edc4fe4b09b219bf1d 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 fa8cf559f9301e2ed799cece4e28cce0870e38b0 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 5f9a11ba603c86cad764c7daf4402cc07495488e 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 5ac58e18510d03ee08aa9fe494a660730dbef762 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 4645c8e57018fba36ed72491db5ce93646fa6c5c 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 d024f221062f2d68035aa30adfae9397de42ed37 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 2f7f0e5d84cb8c69a421cc91f1becd427dc048a2 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 8d876628c4a9f1ecd5872c50e47a8f57200e99f9 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 b30c765f29fb7f565adb5587e6dd312d50b48751 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 09e6f86717569fbfe9549a01dc2e940efd5ddb47 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 a343179fe6ea6aaf2b0a6aa4e20e9f5efc327192 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 })