Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
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
Prev Previous commit
Escaped string values printed by ToQueryString
`Document.ToQueryString` printed a string value between quotes as it was,
so a string with a quote, a backslash or a control character produced an
invalid document. It now escapes them with `appendStringValue`, the string
printer of `ValueLiterals`, which moves to `AstExtensions` so that both
use it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
  • Loading branch information
xperiandri and claude committed Oct 4, 2026
commit 5e950326002f7f50645e60eebb06520d94fdbd2a
1 change: 1 addition & 0 deletions RELEASE_NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -330,4 +330,5 @@
* Changed the checks for options, value options, skippable values and F# lists in input coercion, execution and the `ObjectListFilter` middleware to compare types instead of the culture-sensitive prefixes of their full names: a check takes about a nanosecond instead of hundreds
* Fixed `Helpers.unwrap` and `Helpers.objectOptionCast` throwing `NullReferenceException` for an array of options, which they took for an option
* Fixed introspection reporting the `defaultValue` of an argument or an input object field serialized to JSON instead of as a GraphQL literal, which broke clients that build a schema from introspection, such as graphql-js `buildClientSchema`: an enum value is now reported as its GraphQL name, an input object with unquoted field names, a list with every item printed by its type, and a scalar value as its output coercion serializes it. A default value that has no GraphQL literal, such as a value outside of its enum or a `NaN` float, is no longer reported, while its input stays nullable
* Fixed `Document.ToQueryString` printing a string value unescaped, which produced an invalid document for a string with a quote, a backslash or a control character
* Removed the internal `Observable.withCompletionMarker`
25 changes: 23 additions & 2 deletions src/FSharp.Data.GraphQL.Shared/AstExtensions.fs
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,27 @@ and AstFieldInfo =
| TypeField info -> info.Fields
| FragmentField info -> info.Fields

/// Appends a GraphQL string value holding the text. Like graphql-js, it escapes the quote, the backslash and
/// the control characters; it escapes the line and paragraph separators too, because the parser of this library
/// does not accept them unescaped in a string.
let internal appendStringValue (builder : StringBuilder) (text : string) =
builder.Append ('"') |> ignore
for character in text do
match character with
| '"' -> builder.Append ("\\\"") |> ignore
| '\\' -> builder.Append ("\\\\") |> ignore
| '\b' -> builder.Append ("\\b") |> ignore
| '\f' -> builder.Append ("\\f") |> ignore
| '\n' -> builder.Append ("\\n") |> ignore
| '\r' -> builder.Append ("\\r") |> ignore
| '\t' -> builder.Append ("\\t") |> ignore
// The line and paragraph separators are compared by code, as Fantomas writes their char literals out unescaped
| character when Char.IsControl character || int character = 0x2028 || int character = 0x2029 ->
builder.Append ("\\u") |> ignore
builder.Append ((int character).ToString ("X4", CultureInfo.InvariantCulture)) |> ignore
| character -> builder.Append (character) |> ignore
builder.Append ('"') |> ignore

type internal PaddedStringBuilder () =
let sb = StringBuilder ()
let mutable padCount = 0
Expand All @@ -86,6 +107,7 @@ type internal PaddedStringBuilder () =
sb.AppendLine().Append ("".PadLeft (padCount, ' '))
|> ignore
member _.Append (str : string) = sb.Append (str) |> ignore
member _.AppendStringValue (text : string) = appendStringValue sb text
override _.ToString () = sb.ToString ()

/// Specify voptions when printing an Ast.Document to a query string.
Expand All @@ -104,7 +126,6 @@ type Document with
/// <param name="options">Specify custom printing voptions for the query string.</param>
member x.ToQueryString ([<Optional; DefaultParameterValue (QueryStringPrintingOptions.None)>] options : QueryStringPrintingOptions) =
let sb = PaddedStringBuilder ()
let withQuotes (s : string) = "\"" + s + "\""
let rec printValue x =
let printObjectValue (name, value) =
sb.Append (name)
Expand All @@ -127,7 +148,7 @@ type Document with
| IntValue x -> sb.Append (x.ToString (CultureInfo.InvariantCulture))
| FloatValue x -> sb.Append (x.ToString (CultureInfo.InvariantCulture))
| BooleanValue x -> sb.Append (if x then "true" else "false")
| StringValue x -> sb.Append (withQuotes x)
| StringValue x -> sb.AppendStringValue (x)
| EnumValue x -> sb.Append (x)
| NullValue -> sb.Append ("null")
| ListValue x -> printCompound "[]" printValue x
Expand Down
33 changes: 4 additions & 29 deletions src/FSharp.Data.GraphQL.Shared/ValueLiterals.fs
Original file line number Diff line number Diff line change
Expand Up @@ -14,35 +14,10 @@ open System.Reflection
open System.Text
open System.Text.Json
open FSharp.Reflection
open FSharp.Data.GraphQL.Ast.Extensions
open FSharp.Data.GraphQL.Types
open FSharp.Data.GraphQL.Types.Patterns

/// Appends a GraphQL string value holding the text. Like graphql-js, it escapes the quote, the backslash and
/// the control characters; it escapes the line and paragraph separators too, because the parser of this library
/// does not accept them unescaped in a string.
let private appendString (builder : StringBuilder) (text : string) =
builder.Append ('"') |> ignore
for character in text do
match character with
| '"' -> builder.Append ("\\\"") |> ignore
| '\\' -> builder.Append ("\\\\") |> ignore
| '\b' -> builder.Append ("\\b") |> ignore
| '\f' -> builder.Append ("\\f") |> ignore
| '\n' -> builder.Append ("\\n") |> ignore
| '\r' -> builder.Append ("\\r") |> ignore
| '\t' -> builder.Append ("\\t") |> ignore
// The line and paragraph separators are compared by code, as Fantomas writes their char literals out unescaped
| character when
Char.IsControl character
|| int character = 0x2028
|| int character = 0x2029
->
builder.Append ("\\u") |> ignore
builder.Append ((int character).ToString("X4", CultureInfo.InvariantCulture))
|> ignore
| character -> builder.Append (character) |> ignore
builder.Append ('"') |> ignore

/// Whether the text is a GraphQL name, /[_A-Za-z][_0-9A-Za-z]*/, which can name an input object field.
let private isName (text : string) =
let isNameStart character =
Expand Down Expand Up @@ -88,7 +63,7 @@ let rec private tryAppendJson (builder : StringBuilder) (element : JsonElement)
| JsonValueKind.String ->
match element.GetString () with
| null -> builder.Append ("null") |> ignore
| text -> appendString builder text
| text -> appendStringValue builder text
true
| JsonValueKind.Array ->
builder.Append ('[') |> ignore
Expand Down Expand Up @@ -122,10 +97,10 @@ let private tryAppendSerialized (jsonOptions : JsonSerializerOptions) (builder :
builder.Append (if value then "true" else "false") |> ignore
true
| :? string as value ->
appendString builder value
appendStringValue builder value
true
| :? char as value ->
appendString builder (string value)
appendStringValue builder (string value)
true
| :? sbyte
| :? byte
Expand Down
7 changes: 7 additions & 0 deletions tests/FSharp.Data.GraphQL.Tests/AstExtensionsTests.fs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,13 @@ let ``Can print a query with variables`` () =
}
}"""

[<Fact>]
let ``Can print a query with characters to escape in a string argument`` () =
printAndAssert
"""query q {
hero(name: "\"quoted\" \\ \n \t \u0001")
}"""

[<Fact>]
let ``Can parse a query with an object input in the internal method`` () =
printAndAssert
Expand Down