Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
e05c326
Use deterministic SHA-256 based documentId generation
Copilot May 17, 2026
e4fcfd3
Update introspection fixture documentId values
Copilot May 17, 2026
83c9abb
Address review feedback on deterministic documentId changes
Copilot May 17, 2026
30999e3
Document expected deterministic documentId in test
Copilot May 17, 2026
e1ad222
Clarify hash truncation and expected documentId derivation
Copilot May 17, 2026
69a70fa
Refine hash span usage and test value explanation
Copilot May 17, 2026
5f18153
Address review feedback on literals and documentId hashing
Copilot May 17, 2026
6643d98
Switch documentId to deterministic SHA-256 string
Copilot May 17, 2026
c0e61ac
Apply follow-up review suggestions
Copilot May 17, 2026
48362e8
Fixed ReadMe
xperiandri May 17, 2026
5ac9ba6
Use ValidationResultKey directly in cache instead of GetHashCode()
Copilot May 17, 2026
7fc28ff
Make SchemaId deterministic using SHA-256 hash of introspection schem…
Copilot May 17, 2026
54f138e
Apply code review feedback: use uppercase hex for Unicode escapes and…
Copilot May 17, 2026
7b16475
Optimize: cache JsonSerializerOptions and reduce StringBuilder overhead
Copilot May 17, 2026
d16133f
Minor optimization: avoid sprintf in character loop for better perfor…
Copilot May 17, 2026
8399f0d
Fix trailing whitespace in string escaping code
Copilot May 17, 2026
18d0715
Ensure deterministic JSON serialization and use lowercase hex for Uni…
Copilot May 17, 2026
7d0f33a
Add documentation for UnsafeRelaxedJsonEscaping usage and SHA256 inst…
Copilot May 17, 2026
fca3129
Optimize SchemaId serialization and improve code formatting
Copilot May 17, 2026
98ebcfe
Add comprehensive test coverage for documentId and validation cache
Copilot May 17, 2026
568a630
AI review fix
xperiandri May 17, 2026
7565c5c
Cache schema ID at Executor level to avoid recomputing on every request
Copilot May 17, 2026
7de25ba
Fix schema ID computation to run after middleware compilation
Copilot May 17, 2026
42b5487
Add comprehensive test coverage for ToQueryString string escaping
Copilot May 17, 2026
6aa547d
Simplify SchemaId generation to use GetHashCode
Copilot May 17, 2026
1a94ecc
Clarify SchemaId GetHashCode scope in XML docs
Copilot May 17, 2026
66bc73b
Change validation SchemaId type to int
Copilot May 17, 2026
da4d951
Remove unnecessary SchemaId helper module
Copilot May 17, 2026
ec333b2
Formatted changed files
xperiandri May 18, 2026
e926838
Escape U+2028/U+2029 in ToQueryString and add regression tests
Copilot May 18, 2026
51b7def
Made `ExecutionTests` asynchronous
xperiandri May 18, 2026
01c75cb
Fixed comments
xperiandri May 18, 2026
a89e07c
Removed unnecessary test
xperiandri May 18, 2026
f76de93
Make concurrent cache test actually run in parallel
Copilot May 18, 2026
76cbccd
Rename worker readiness gate in concurrent cache test
Copilot May 18, 2026
31639fc
Fix cross-platform documentId hashing and AstExtensions List.choose
Copilot May 18, 2026
ce57938
Improved query normalization
xperiandri May 18, 2026
398c17a
Simplified cache test
xperiandri May 18, 2026
3c0a672
Execution plan cache fix
xperiandri May 18, 2026
7371e7d
Fix escaped-string documentId test to execute valid argument path
Copilot May 18, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
</PackageReference>
<PackageReference Update="FSharp.Control.FusionTasks" Version="2.6.*" />
<PackageReference Update="FSharp.Control.Reactive" Version="6.*" />
<PackageReference Update="FSharp.Data.LiteralProviders" Version="1.0.3" />
<PackageReference Update="FSharp.SystemTextJson" Version="1.*" />
<PackageReference Update="FsToolkit.ErrorHandling" Version="$(FsToolkitVersion)" />
<PackageReference Update="FsToolkit.ErrorHandling.TaskResult" Version="$(FsToolkitVersion)" />
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -230,7 +230,7 @@ printfn "Custom data: %A\n" result.CustomData
// Errors: <null>
// Custom data: map [("documentId", 1221427401)]
// Custom data: map [("documentId", "<SHA-256 of the executed query document>")]
```

For more information about how to use the client provider, see the [examples folder](samples/client-provider).
Expand Down
4 changes: 2 additions & 2 deletions docs/execution-pipeline.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
This result can then be serialized and returned to the client.
2 changes: 1 addition & 1 deletion samples/star-wars-fabulous-client/StarWars/Common.fs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,6 @@ module Commands =
let IntrospectionPath = "../../../tests/FSharp.Data.GraphQL.IntegrationTests/introspection.json"

type GraphQLApi = GraphQLProvider<IntrospectionPath>
let GetCharactersData = GraphQLApi.Operation<"queries/FetchCharacters.graphql">()
let GetCharactersData = GraphQLApi.Operation<"queries/FetchCharacters.graphql"> ()

type Character = GraphQLApi.Operations.FetchCharacters.Types.CharactersFields.Character
Original file line number Diff line number Diff line change
Expand Up @@ -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.fromCanonicalQuery (queryAst.ToQueryString()); SchemaId = schema.GetHashCode() }
let refMaker = lazy Validation.Ast.validateDocument schema queryAst
if clientQueryValidation then
refMaker.Force
Expand Down
14 changes: 9 additions & 5 deletions src/FSharp.Data.GraphQL.Server/Executor.fs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
namespace FSharp.Data.GraphQL

open System
open System.Collections.Concurrent
open System.Collections.Immutable
open System.Runtime.InteropServices
Expand All @@ -9,6 +10,7 @@ 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
Expand Down Expand Up @@ -99,6 +101,9 @@ type Executor<'Root>(schema: ISchema<'Root>, middlewares : IExecutorMiddleware s
| 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()

let eval (executionPlan: ExecutionPlan, data: 'Root option, variables: ImmutableDictionary<string, JsonElement>, getInputContext : InputExecutionContextProvider): Async<GQLExecutionResult> =
let documentId = executionPlan.DocumentId
let prepareOutput res =
Expand Down Expand Up @@ -137,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 = ast.GetHashCode()
let documentId = DocumentId.fromCanonicalQueryUnsafe (ast.ToQueryString())
Comment thread
xperiandri marked this conversation as resolved.
result {
match findOperation ast operationName with
| Some operation ->
Expand All @@ -159,7 +164,6 @@ type Executor<'Root>(schema: ISchema<'Root>, middlewares : IExecutorMiddleware s
ErrorKind.Validation
)]
do!
let schemaId = schema.Introspected.GetHashCode()
let key = { DocumentId = documentId; SchemaId = schemaId }
Comment thread
xperiandri marked this conversation as resolved.
let producer = fun () -> Validation.Ast.validateDocument schema.Introspected ast
validationCache.GetOrAdd producer key
Expand All @@ -185,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).
/// </summary>
Expand All @@ -198,7 +202,7 @@ type Executor<'Root>(schema: ISchema<'Root>, middlewares : IExecutorMiddleware s

/// <summary>
/// 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).
/// </summary>
Expand All @@ -216,7 +220,7 @@ type Executor<'Root>(schema: ISchema<'Root>, middlewares : IExecutorMiddleware s

/// <summary>
/// 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).
/// </summary>
Expand Down
6 changes: 3 additions & 3 deletions src/FSharp.Data.GraphQL.Server/IO.fs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ open FSharp.Data.GraphQL.Types
type Output = IDictionary<string, obj>

type GQLResponse =
{ DocumentId: int
{ DocumentId: string
Data : Output Skippable
Errors : GQLProblemDetails list Skippable }
static member Direct(documentId, data, errors) =
Expand All @@ -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) =
Expand Down Expand Up @@ -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) =
Expand Down
42 changes: 33 additions & 9 deletions src/FSharp.Data.GraphQL.Shared/AstExtensions.fs
Original file line number Diff line number Diff line change
Expand Up @@ -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, [<Optional>] 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, [<Optional>] 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 =
Expand Down Expand Up @@ -102,9 +105,30 @@ type Document with
/// Generates a GraphQL query string from this document.
/// </summary>
/// <param name="options">Specify custom printing voptions for the query string.</param>
member x.ToQueryString ([<Optional; DefaultParameterValue (QueryStringPrintingOptions.None)>] options : QueryStringPrintingOptions) =
member x.ToQueryString ([<Optional; DefaultParameterValue(QueryStringPrintingOptions.None)>] options : QueryStringPrintingOptions) =
let sb = PaddedStringBuilder ()
let withQuotes (s : string) = "\"" + s + "\""
let escapeGraphQLString (s : string) =
let escaped = StringBuilder (s.Length + s.Length / 4 + 2)
escaped.Append ('"') |> ignore
for c in s do
let appendStr =
match c with
| '"' -> "\\\""
| '\\' -> "\\\\"
| '\b' -> "\\b"
| '\f' -> "\\f"
| '\n' -> "\\n"
| '\r' -> "\\r"
| '\t' -> "\\t"
| '\u2028' -> "\\u2028"
| '\u2029' -> "\\u2029"
| c when c < '\u0020' ->
let hex = (int c).ToString ("x4", CultureInfo.InvariantCulture)
"\\u" + hex
Comment thread
xperiandri marked this conversation as resolved.
| c -> string c
Comment thread
xperiandri marked this conversation as resolved.
escaped.Append (appendStr) |> ignore
escaped.Append('"').ToString ()
let withQuotes = escapeGraphQLString
let rec printValue x =
let printObjectValue (name, value) =
sb.Append (name)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
<Compile Include="Helpers\Extensions.fs" />
<Compile Include="Helpers\Reflection.fs" />
<Compile Include="Helpers\MemoryCache.fs" />
<Compile Include="Helpers\DocumentId.fs" />
<Compile Include="Errors.fs" />
<Compile Include="Exception.fs" />
<Compile Include="ValidationTypes.fs" />
Expand Down
44 changes: 44 additions & 0 deletions src/FSharp.Data.GraphQL.Shared/Helpers/DocumentId.fs
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
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)

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 ""


Comment thread
xperiandri marked this conversation as resolved.
/// <summary>
/// Computes a deterministic document identifier from a canonical GraphQL query string.
/// </summary>
/// <param name="canonicalQuery">The canonical GraphQL query string (must already be properly escaped according to GraphQL specification).</param>
/// <returns>A lowercase hexadecimal SHA-256 hash string that uniquely identifies the document content.</returns>
[<CompiledName("FromCanonicalQuery")>]
let fromCanonicalQuery (canonicalQuery : string) =
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
7 changes: 4 additions & 3 deletions src/FSharp.Data.GraphQL.Shared/TypeSystem.fs
Original file line number Diff line number Diff line change
Expand Up @@ -671,7 +671,8 @@ and PlanningContext = {
RootDef : ObjectDef
Document : Document
Operation : OperationDefinition
DocumentId : int
/// Unique identifier of the current document's AST.
DocumentId : string
Metadata : Metadata
}

Expand Down Expand Up @@ -887,8 +888,8 @@ 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.
DocumentId : int
/// Unique identifier of the current document's AST.
DocumentId : string
/// AST definition of current operation.
Operation : OperationDefinition
/// Definition of the root type (either query or mutation) used by the
Expand Down
8 changes: 3 additions & 5 deletions src/FSharp.Data.GraphQL.Shared/ValidationResultCache.fs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ open FSharp.Data.GraphQL
open System

type ValidationResultKey =
{ DocumentId : int
{ DocumentId : string
SchemaId : int }

type ValidationResultProducer =
Expand All @@ -13,12 +13,10 @@ type ValidationResultProducer =
type IValidationResultCache =
abstract GetOrAdd : ValidationResultProducer -> ValidationResultKey -> ValidationResult<GQLProblemDetails>


/// 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<int, ValidationResult<GQLProblemDetails>>(expirationPolicy)
let internalCache = MemoryCache<ValidationResultKey, ValidationResult<GQLProblemDetails>>(expirationPolicy)
interface IValidationResultCache with
member _.GetOrAdd producer key =
let internalKey = key.GetHashCode()
internalCache.GetOrAddResult internalKey producer
internalCache.GetOrAddResult key producer
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"documentId": 986164407,
"documentId": "547d9dc982284840b3e020dfcbf43ae96cef7595afa007145ed954794363d148",
"data": {
"__schema": {
"queryType": {
Expand Down Expand Up @@ -1926,4 +1926,4 @@
]
}
}
}
}
4 changes: 2 additions & 2 deletions tests/FSharp.Data.GraphQL.IntegrationTests/introspection.json
Comment thread
xperiandri marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"documentId": 195530235,
"documentId": "547d9dc982284840b3e020dfcbf43ae96cef7595afa007145ed954794363d148",
"data": {
"__schema": {
"queryType": {
Expand Down Expand Up @@ -1862,4 +1862,4 @@
]
}
}
}
}
Loading
Loading