From bf5be2036e9a1cc9687d73175fbc60e5f62f018d Mon Sep 17 00:00:00 2001 From: XperiAndri Date: Sat, 10 Oct 2026 02:28:22 +0200 Subject: [PATCH 1/3] test: record how the emulator evaluates the queries the translator needs The quotation translator planned in ADR 0001 turns F# expressions into Cosmos DB SQL, and several of its rules depend on behaviour the documentation leaves open: what a comparison with a missing or null value returns, what ?? does with null, how INTDIV rounds, and whether a subquery may hold TOP, ORDER BY or OFFSET LIMIT. QuerySemanticsTests asks the emulator and asserts its exact answers, so that those rules are written from facts and a change in the engine shows up as a failing test. Each test seeds the documents it needs as raw JSON into a database of its own and reads the answer through the stream iterator in one logical partition, so no serializer sits between the engine and the assertion and a member the answer lacks is undefined. The 18 test methods (43 cases with their data rows) cover equality and ordering across JSON types, comparisons with a parameter and their negation, the coalesce operator, INTDIV and INTMOD, division by zero, a parameter as the ignore-case flag of string functions, as the whole filter and as an indexer, STRINGEQUALS over non-strings, case folding against .NET OrdinalIgnoreCase, object and array equality, ObjectToArray, accepted and rejected subquery shapes (a filter outside a GROUP BY subquery included, which emulates HAVING), ORDER BY over values a subquery computes, the sources IN accepts, and ORDER BY over missing values and mixed types. The Windows emulator and the Linux vNext emulator that CI runs give the same answers, except that the vNext emulator's ignore-case flag also equates a few letters with their lowercase forms, and that it reports substatus 0 where the Windows emulator reports 4001 for a returned infinity. The tests concerned state both, telling the emulators apart through the new Emulator.readKindAsync: the Windows emulator answers an unauthenticated request to its root with 401, the vNext emulator with a success status. The vNext emulator also indents the JSON of its errors, so a rejection is recognised by its quoted error code alone. Co-Authored-By: Claude Opus 5.5 --- tests/Cosmos.Tests.Infrastructure/Emulator.fs | 25 + .../FSharp.Azure.Cosmos.Tests.fsproj | 1 + tests/Cosmos.Tests/QuerySemanticsTests.fs | 742 ++++++++++++++++++ tests/Cosmos.Tests/TestCategories.fs | 8 + 4 files changed, 776 insertions(+) create mode 100644 tests/Cosmos.Tests/QuerySemanticsTests.fs diff --git a/tests/Cosmos.Tests.Infrastructure/Emulator.fs b/tests/Cosmos.Tests.Infrastructure/Emulator.fs index b73d667f..48b0da4c 100644 --- a/tests/Cosmos.Tests.Infrastructure/Emulator.fs +++ b/tests/Cosmos.Tests.Infrastructure/Emulator.fs @@ -203,6 +203,31 @@ let private isReachableAsync (cancellationToken : CancellationToken) : Task return false } +/// +/// The emulator that answers at . +/// +[] +type Kind = + /// The Windows emulator. + | Windows + /// The Linux vNext preview emulator, which CI and the dev container run. + | VNext + +/// +/// Tells which of emulator answers at , for the few query semantics in which +/// the two differ: the Windows emulator answers an unauthenticated request to its root with HTTP 401, the vNext +/// emulator with a success status. +/// +let readKindAsync (cancellationToken : CancellationToken) : Task = task { + use httpClient = new HttpClient (createHttpMessageHandler (), true, Timeout = reachabilityTimeout) + use! response = httpClient.GetAsync (endpoint, cancellationToken) + return + if response.IsSuccessStatusCode then + Kind.VNext + else + Kind.Windows +} + /// /// Deletes the databases whose identifier starts with and that stayed /// unmodified for at least the leftover age (): databases of tests whose cleanup failed diff --git a/tests/Cosmos.Tests/FSharp.Azure.Cosmos.Tests.fsproj b/tests/Cosmos.Tests/FSharp.Azure.Cosmos.Tests.fsproj index 37cbaf0c..612b942d 100644 --- a/tests/Cosmos.Tests/FSharp.Azure.Cosmos.Tests.fsproj +++ b/tests/Cosmos.Tests/FSharp.Azure.Cosmos.Tests.fsproj @@ -38,6 +38,7 @@ + diff --git a/tests/Cosmos.Tests/QuerySemanticsTests.fs b/tests/Cosmos.Tests/QuerySemanticsTests.fs new file mode 100644 index 00000000..a2d18565 --- /dev/null +++ b/tests/Cosmos.Tests/QuerySemanticsTests.fs @@ -0,0 +1,742 @@ +namespace FSharp.Azure.Cosmos.Tests.Integration + +open System +open System.Collections.Immutable +open System.Globalization +open System.IO +open System.Net +open System.Text +open System.Text.Json.Nodes +open System.Threading.Tasks +open Microsoft.Azure.Cosmos +open Microsoft.VisualStudio.TestTools.UnitTesting +open FSharp.Azure.Cosmos.Tests + +/// The answer of the emulator to one query. +type private QueryOutcome = + /// The query ran and returned these items, in the order of the emulator's pages. + | Returned of items : JsonArray + /// The emulator refused to run the query; the substatus is the value of the x-ms-substatus header. + | Rejected of statusCode : HttpStatusCode * subStatusCode : string * message : string + +/// +/// Assertions on a . JSON is compared after both sides are written compactly, so an expected +/// answer may be laid out for reading. +/// +[] +module private QueryAssert = + + let private compact (json : string) = (nonNull (JsonNode.Parse json)).ToJsonString() + + let private itemTexts (items : JsonArray) = + items + |> Seq.map (fun item -> + match item with + | null -> "null" + | item -> item.ToJsonString () + ) + |> Seq.sort + |> Seq.toArray + + /// + /// Returns the items of a query that ran; fails the test with when the emulator rejected + /// the query. + /// + let WantItems (message : string) (outcome : QueryOutcome) : JsonArray = + match outcome with + | Returned items -> items + | Rejected (statusCode, _, error) -> + Assert.Fail $"{message} The emulator rejected the query with {statusCode}: {error}" + JsonArray () + + /// + /// Fails the test with unless the query returned exactly , a + /// JSON array, in the same order. + /// + let Returns (expected : string) (message : string) (outcome : QueryOutcome) = + Assert.AreEqual (compact expected, (WantItems message outcome).ToJsonString(), message) + + /// + /// Fails the test with unless the query returned the items of , + /// a JSON array, in any order. + /// + let ReturnsInAnyOrder (expected : string) (message : string) (outcome : QueryOutcome) = + let expectedItems = (nonNull (JsonNode.Parse expected)).AsArray() + CollectionAssert.AreEqual (itemTexts expectedItems, itemTexts (WantItems message outcome), message) + + /// + /// Fails the test with unless the emulator rejected the query as a bad request whose + /// error contains . + /// + let RejectsSaying (text : string) (message : string) (outcome : QueryOutcome) = + match outcome with + | Returned items -> Assert.Fail $"{message} The query ran and returned {items.ToJsonString ()}." + | Rejected (statusCode, _, error) -> + Assert.AreEqual (HttpStatusCode.BadRequest, statusCode, message) + Assert.Contains (text, error, StringComparison.Ordinal, message) + + /// + /// Fails the test with unless the emulator rejected the query as a bad request whose + /// error carries the syntax or semantic error code , such as SC2203. + /// + /// + /// Only the quoted code is looked for, because the emulators lay out the JSON of the error differently: the Windows + /// emulator writes it compactly, the vNext emulator indented. + /// + let Rejects (errorCode : string) (message : string) (outcome : QueryOutcome) = + RejectsSaying $"\"{errorCode}\"" message outcome + +/// +/// Records how the emulator evaluates the query constructs that the planned translation of F# quotations into Cosmos DB +/// SQL relies on: comparisons with missing and values, the coalesce operator, integer division, +/// string functions with a case flag, parameters in unusual positions, object equality and subqueries. +/// +/// +/// +/// Each test seeds the documents it needs as raw JSON, so that a document can lack a property, hold an explicit +/// or mix JSON types, and reads the answer through +/// , +/// so that no serializer stands between the answer and the assertion: a member the answer lacks is undefined. Every +/// query targets one logical partition, so the SDK hands it to the query engine without a cross-partition pipeline of +/// its own. +/// +/// +/// The Windows emulator and the Linux vNext emulator, which CI runs, give the same answers, except where a test states +/// both and tells the emulators apart through . ADR 0001 records what the +/// translator makes of them. +/// +/// +[] +type QuerySemanticsTests () = + inherit IntegrationTestBase () + + // Every document of these tests lives in this logical partition + static let partitionKeyValue = "p" + + // A property v of every JSON type, an explicit null, and a document that lacks the property + static let valuesOfEveryType = + seq { + """{"id": "missing"}""" + """{"id": "null", "v": null}""" + """{"id": "false", "v": false}""" + """{"id": "true", "v": true}""" + """{"id": "zero", "v": 0}""" + """{"id": "one", "v": 1}""" + """{"id": "empty", "v": ""}""" + """{"id": "a", "v": "a"}""" + """{"id": "array", "v": [3, 1, 2]}""" + """{"id": "object", "v": {"x": 1, "y": 2}}""" + } + |> ImmutableArray.CreateRange + + // Documents with a number, a group and, in the first, an array, for the subquery tests + static let subqueryDocuments = + seq { + """{"id": "a", "n": 1, "g": "x", "arr": [3, 1, 2]}""" + """{"id": "b", "n": 2, "g": "x"}""" + """{"id": "c", "n": 3, "g": "y"}""" + } + |> ImmutableArray.CreateRange + + static let invariant (value : int) = value.ToString CultureInfo.InvariantCulture + + /// + /// Creates the container of the running test and seeds , JSON objects to which the + /// partition key property is added. + /// + member private this.SeedAsync (documents : string seq) : Task = task { + let! container = + this.Application.GetOrCreateContainerAsync ("query-semantics", "/pk", this.CancellationToken) + + for document in documents do + let item = (nonNull (JsonNode.Parse document)).AsObject() + item["pk"] <- JsonValue.Create partitionKeyValue + use content = new MemoryStream (Encoding.UTF8.GetBytes (item.ToJsonString ())) + + use! response = + container.CreateItemStreamAsync ( + content, + PartitionKey partitionKeyValue, + cancellationToken = this.CancellationToken + ) + + Assert.AreEqual (HttpStatusCode.Created, response.StatusCode, $"Seeding should create the document {document}.") + + return container + } + + /// + /// Runs in the logical partition of the seeded documents and collects the items of every + /// page, or the answer of the emulator when it refuses the query. + /// + member private this.QueryAsync (container : Container, query : QueryDefinition) : Task = task { + use iterator = + container.GetItemQueryStreamIterator ( + query, + requestOptions = QueryRequestOptions (PartitionKey = PartitionKey partitionKeyValue) + ) + + let items = JsonArray () + let mutable rejection = ValueNone + + while rejection.IsNone && iterator.HasMoreResults do + use! response = iterator.ReadNextAsync this.CancellationToken + + if response.IsSuccessStatusCode then + let page = nonNull (JsonNode.Parse response.Content) + + for item in (nonNull page["Documents"]).AsArray() do + // An item belongs to its page; a copy can join the items of every page + items.Add ( + match item with + | null -> null + | item -> item.DeepClone () + ) + else + let error = + match response.ErrorMessage with + | null -> "" + | error -> error + + let subStatusCode = + match response.Headers["x-ms-substatus"] with + | null -> "" + | subStatusCode -> subStatusCode + + rejection <- ValueSome (Rejected (response.StatusCode, subStatusCode, error)) + + return rejection |> ValueOption.defaultValue (Returned items) + } + + [] + member this.``Equality between different JSON types is false and only a missing value makes it undefined`` () : Task = task { + let! container = this.SeedAsync valuesOfEveryType + + let! outcome = + this.QueryAsync ( + container, + QueryDefinition + """SELECT VALUE {"id": c.id, "equalsOne": c.v = 1, "differsFromOne": c.v != 1, "equalsNull": c.v = null, "inOneOrA": c.v IN (1, "a")} FROM c ORDER BY c.id""" + ) + + outcome + |> QueryAssert.Returns + """[ + {"id": "a", "equalsOne": false, "differsFromOne": true, "equalsNull": false, "inOneOrA": true}, + {"id": "array", "equalsOne": false, "differsFromOne": true, "equalsNull": false, "inOneOrA": false}, + {"id": "empty", "equalsOne": false, "differsFromOne": true, "equalsNull": false, "inOneOrA": false}, + {"id": "false", "equalsOne": false, "differsFromOne": true, "equalsNull": false, "inOneOrA": false}, + {"id": "missing"}, + {"id": "null", "equalsOne": false, "differsFromOne": true, "equalsNull": true, "inOneOrA": false}, + {"id": "object", "equalsOne": false, "differsFromOne": true, "equalsNull": false, "inOneOrA": false}, + {"id": "one", "equalsOne": true, "differsFromOne": false, "equalsNull": false, "inOneOrA": true}, + {"id": "true", "equalsOne": false, "differsFromOne": true, "equalsNull": false, "inOneOrA": false}, + {"id": "zero", "equalsOne": false, "differsFromOne": true, "equalsNull": false, "inOneOrA": false} + ]""" + "=, != and IN should give false or true between different JSON types, null included, and undefined only for a missing value." + } + + [] + member this.``Ordering is undefined between different JSON types and between arrays or objects`` () : Task = task { + let! container = this.SeedAsync valuesOfEveryType + + let! outcome = + this.QueryAsync ( + container, + QueryDefinition + """SELECT VALUE {"id": c.id, "lessThanOne": c.v < 1, "atLeastOne": c.v >= 1, "lessThanItself": c.v < c.v} FROM c ORDER BY c.id""" + ) + + outcome + |> QueryAssert.Returns + """[ + {"id": "a", "lessThanItself": false}, + {"id": "array"}, + {"id": "empty", "lessThanItself": false}, + {"id": "false", "lessThanItself": false}, + {"id": "missing"}, + {"id": "null", "lessThanItself": false}, + {"id": "object"}, + {"id": "one", "lessThanOne": false, "atLeastOne": true, "lessThanItself": false}, + {"id": "true", "lessThanItself": false}, + {"id": "zero", "lessThanOne": true, "atLeastOne": false, "lessThanItself": false} + ]""" + "< and >= should be defined only between values of the same scalar type, null included, and undefined for arrays, objects and missing values." + } + + [] + member this.``A comparison with a parameter matches neither a missing nor a null value while its negation matches null`` + () + : Task + = task { + let! container = this.SeedAsync valuesOfEveryType + + let whereWithOne (predicate : string) = + QueryDefinition($"SELECT VALUE c.id FROM c WHERE {predicate} ORDER BY c.id").WithParameter("@value", 1) + + let! equal = this.QueryAsync (container, whereWithOne "c.v = @value") + equal + |> QueryAssert.Returns """["one"]""" "c.v = @value should match only the document whose value equals the parameter." + + let! notEqual = this.QueryAsync (container, whereWithOne "NOT (c.v = @value)") + + notEqual + |> QueryAssert.Returns + """["a", "array", "empty", "false", "null", "object", "true", "zero"]""" + "NOT (c.v = @value) should match every defined value but the parameter's, null included, and no missing value." + + let! different = this.QueryAsync (container, whereWithOne "c.v != @value") + + different + |> QueryAssert.Returns + """["a", "array", "empty", "false", "null", "object", "true", "zero"]""" + "c.v != @value should match exactly what NOT (c.v = @value) matches." + } + + [] + member this.``The coalesce operator replaces a missing value but keeps null`` () : Task = task { + let! container = this.SeedAsync valuesOfEveryType + + let! outcome = + this.QueryAsync ( + container, + QueryDefinition + """SELECT VALUE {"id": c.id, "coalesced": c.v ?? "default"} FROM c WHERE c.id IN ("missing", "null", "zero") ORDER BY c.id""" + ) + + outcome + |> QueryAssert.Returns + """[ + {"id": "missing", "coalesced": "default"}, + {"id": "null", "coalesced": null}, + {"id": "zero", "coalesced": 0} + ]""" + "?? should replace only a missing value; an explicit null and any other value should stay." + } + + [] + member this.``INTDIV and INTMOD truncate toward zero like F# and are undefined for a zero divisor or a fractional operand`` + () + : Task + = task { + let! container = this.SeedAsync [| """{"id": "item"}""" |] + + let! outcome = + this.QueryAsync ( + container, + QueryDefinition + """SELECT VALUE {"7 INTDIV 2": INTDIV(7, 2), "-7 INTDIV 2": INTDIV(-7, 2), "7 INTDIV -2": INTDIV(7, -2), "-7 INTMOD 2": INTMOD(-7, 2), "7 INTMOD -2": INTMOD(7, -2), "7 INTDIV 0": INTDIV(7, 0), "7 INTMOD 0": INTMOD(7, 0), "7.5 INTDIV 2": INTDIV(7.5, 2), "7 INTDIV 2.5": INTDIV(7, 2.5), "7.5 INTMOD 2": INTMOD(7.5, 2), "7 INTMOD 2.5": INTMOD(7, 2.5)} FROM c""" + ) + + // F# computes the expected quotients and remainders. Where F# throws DivideByZeroException, and for a fractional + // operand on either side of either function, the engine leaves the member undefined. + let expected = + $$"""[{"7 INTDIV 2": {{invariant (7 / 2)}}, "-7 INTDIV 2": {{invariant (-7 / 2)}}, "7 INTDIV -2": {{invariant (7 / -2)}}, "-7 INTMOD 2": {{invariant (-7 % 2)}}, "7 INTMOD -2": {{invariant (7 % -2)}}}]""" + + outcome + |> QueryAssert.Returns + expected + "INTDIV and INTMOD should agree with F# / and % on integers and be undefined for a zero divisor or a fractional operand." + } + + [] + member this.``A division by zero with an operator compares as infinity but fails the query when the result is returned`` + () + : Task + = task { + let! kind = Emulator.readKindAsync this.CancellationToken + let! container = this.SeedAsync [| """{"id": "item"}""" |] + + let! filtered = + this.QueryAsync (container, QueryDefinition "SELECT VALUE c.id FROM c WHERE 7 / 0 > 1000000") + filtered + |> QueryAssert.Returns """["item"]""" "7 / 0 should compare as positive infinity in a filter." + + let! projected = + this.QueryAsync (container, QueryDefinition """SELECT VALUE {"quotient": 7 / 0} FROM c""") + + // Both emulators fail with the same status and message, but only the Windows emulator sets a substatus + let expectedSubStatusCode = + match kind with + | Emulator.Kind.Windows -> "4001" + | Emulator.Kind.VNext -> "0" + + match projected with + | Returned items -> Assert.Fail $"A query that returns 7 / 0 should fail, but it returned {items.ToJsonString ()}." + | Rejected (statusCode, subStatusCode, error) -> + Assert.AreEqual (HttpStatusCode.BadRequest, statusCode, "A query that returns 7 / 0 should fail as a bad request.") + + Assert.AreEqual ( + expectedSubStatusCode, + subStatusCode, + $"A query that returns 7 / 0 should fail with substatus {expectedSubStatusCode} on the {kind} emulator." + ) + + Assert.Contains ( + "cannot be represented in JSON", + error, + StringComparison.Ordinal, + "A query that returns 7 / 0 should fail because JSON has no infinity." + ) + } + + [] + [] + [] + [] + [] + member this.``A parameter can be the ignore-case flag of a string function`` (functionName : string, text : string) : Task = + task { + let! container = + this.SeedAsync [| + """{"id": "lower", "s": "abc"}""" + """{"id": "upper", "s": "ABC"}""" + """{"id": "other", "s": "xyz"}""" + |] + + let withIgnoreCase (ignoreCase : bool) = + QueryDefinition($"SELECT VALUE c.id FROM c WHERE {functionName}(c.s, @text, @ignoreCase) ORDER BY c.id") + .WithParameter("@text", text) + .WithParameter("@ignoreCase", ignoreCase) + + let! ignoringCase = this.QueryAsync (container, withIgnoreCase true) + + ignoringCase + |> QueryAssert.Returns """["lower", "upper"]""" $"{functionName} should ignore case when the flag parameter is true." + + let! matchingCase = this.QueryAsync (container, withIgnoreCase false) + matchingCase + |> QueryAssert.Returns """["lower"]""" $"{functionName} should match case when the flag parameter is false." + } + + [] + member this.``A boolean parameter can be the whole filter`` () : Task = task { + let! container = this.SeedAsync [| """{"id": "first"}"""; """{"id": "second"}""" |] + + let whereMatchAll (matchAll : bool) = + QueryDefinition("SELECT VALUE c.id FROM c WHERE @matchAll ORDER BY c.id").WithParameter("@matchAll", matchAll) + + let! everything = this.QueryAsync (container, whereMatchAll true) + everything + |> QueryAssert.Returns """["first", "second"]""" "WHERE @matchAll should match every document when true." + + let! nothing = this.QueryAsync (container, whereMatchAll false) + nothing + |> QueryAssert.Returns "[]" "WHERE @matchAll should match no document when false." + } + + [] + member this.``STRINGEQUALS is undefined unless both operands are strings`` () : Task = task { + let! container = + this.SeedAsync [| + """{"id": "missing"}""" + """{"id": "null", "s": null}""" + """{"id": "number", "s": 5}""" + """{"id": "string", "s": "abc"}""" + |] + + let! outcome = + this.QueryAsync ( + container, + QueryDefinition + """SELECT VALUE {"id": c.id, "equals": STRINGEQUALS(c.s, "abc"), "equalsItself": STRINGEQUALS(c.s, c.s)} FROM c ORDER BY c.id""" + ) + + outcome + |> QueryAssert.Returns + """[ + {"id": "missing"}, + {"id": "null"}, + {"id": "number"}, + {"id": "string", "equals": true, "equalsItself": true} + ]""" + "STRINGEQUALS should be undefined for a missing value, null and a number, even compared with itself." + } + + [] + member this.``The ignore-case flag of STRINGEQUALS folds some letters unlike dotnet OrdinalIgnoreCase`` () : Task = task { + // Name, the two strings, and whether the Windows emulator, the vNext emulator and .NET OrdinalIgnoreCase call + // them equal ignoring case. The Windows emulator differs from .NET in the Greek final sigma alone; the vNext + // emulator also equates the dotted capital I, the Kelvin sign and the Angstrom sign with the letters they + // lowercase to. + let pairs = [| + struct ("ASCII", "abc", "ABC", true, true, true) + struct ("Cyrillic", "ё", "Ё", true, true, true) + struct ("Greek sigma", "σ", "Σ", true, true, true) + struct ("Greek final sigma", "ς", "Σ", false, false, true) + struct ("German sharp s", "straße", "STRASSE", false, false, false) + struct ("Turkish dotless i", "ı", "I", false, false, false) + struct ("Turkish dotted capital I", "i", "İ", false, true, false) + struct ("Kelvin sign", Char.ConvertFromUtf32 0x212A, "k", false, true, false) + struct ("Angstrom sign", Char.ConvertFromUtf32 0x212B, "å", false, true, false) + struct ("Long s", "ſ", "s", false, false, false) + |] + + for struct (name, left, right, _, _, dotnetEquals) in pairs do + Assert.AreEqual ( + dotnetEquals, + String.Equals (left, right, StringComparison.OrdinalIgnoreCase), + $"The table should state what .NET OrdinalIgnoreCase answers for {name}." + ) + + let! kind = Emulator.readKindAsync this.CancellationToken + let! container = this.SeedAsync [| """{"id": "item"}""" |] + + let selections = + pairs + |> Seq.mapi (fun index struct (name, _, _, _, _, _) -> $"\"{name}\": STRINGEQUALS(@left{index}, @right{index}, true)") + |> String.concat ", " + + let query = QueryDefinition $"SELECT VALUE {{{selections}}} FROM c" + + pairs + |> Array.iteri (fun index struct (_, left, right, _, _, _) -> + query.WithParameter($"@left{index}", left).WithParameter($"@right{index}", right) + |> ignore + ) + + let! outcome = this.QueryAsync (container, query) + + let answer = + outcome + |> QueryAssert.WantItems "STRINGEQUALS with the ignore-case flag should run." + |> Seq.exactlyOne + |> nonNull + + // Every pair the emulator folds otherwise than the table states, so that a failure names all of them at once + let differences = ResizeArray() + + for struct (name, _, _, windowsEquals, vNextEquals, _) in pairs do + let expected = + match kind with + | Emulator.Kind.Windows -> windowsEquals + | Emulator.Kind.VNext -> vNextEquals + + match answer[name] with + | null -> differences.Add $"{name}: undefined" + | equals when equals.GetValue() <> expected -> differences.Add $"{name}: {equals.GetValue()}" + | _ -> () + + Assert.IsEmpty ( + differences, + $"STRINGEQUALS with the ignore-case flag should fold case on the {kind} emulator as the table states." + ) + } + + [] + member this.``A parameter can index an object and an array`` () : Task = task { + let! container = this.SeedAsync [| """{"id": "item", "m": {"x": 1, "y": 2}, "arr": [10, 20, 30]}""" |] + + let! projected = + this.QueryAsync ( + container, + QueryDefinition("""SELECT VALUE {"byKey": c.m[@key], "byIndex": c.arr[@index]} FROM c""") + .WithParameter("@key", "y") + .WithParameter("@index", 1) + ) + + projected + |> QueryAssert.Returns + """[{"byKey": 2, "byIndex": 20}]""" + "A parameter should select a property by name and an element by index." + + let! filtered = + this.QueryAsync ( + container, + QueryDefinition("SELECT VALUE c.id FROM c WHERE c.m[@key] = 2").WithParameter("@key", "y") + ) + + filtered + |> QueryAssert.Returns """["item"]""" "A parameter used as an indexer should work in a filter too." + } + + [] + member this.``Object equality ignores the order of properties but array equality does not`` () : Task = task { + let! container = + this.SeedAsync [| """{"id": "object", "v": {"x": 1, "y": 2}}"""; """{"id": "array", "v": [3, 1, 2]}""" |] + + // The parameter goes to the engine as raw JSON, so its property order is the one written here + let whereEqualTo (json : string) = + QueryDefinition("SELECT VALUE c.id FROM c WHERE c.v = @value") + .WithParameterStream("@value", new MemoryStream (Encoding.UTF8.GetBytes json)) + + let! reordered = this.QueryAsync (container, whereEqualTo """{"y": 2, "x": 1}""") + reordered + |> QueryAssert.Returns """["object"]""" "An object should equal one with the same properties in another order." + + let! subset = this.QueryAsync (container, whereEqualTo """{"x": 1}""") + subset + |> QueryAssert.Returns "[]" "An object should not equal one with only some of its properties." + + let! sameOrder = this.QueryAsync (container, whereEqualTo "[3, 1, 2]") + sameOrder + |> QueryAssert.Returns """["array"]""" "An array should equal one with the same elements in the same order." + + let! otherOrder = this.QueryAsync (container, whereEqualTo "[1, 2, 3]") + otherOrder + |> QueryAssert.Returns "[]" "An array should not equal one with the same elements in another order." + + let! literal = + this.QueryAsync (container, QueryDefinition """SELECT VALUE c.id FROM c WHERE c.v = {"y": 2, "x": 1}""") + literal + |> QueryAssert.Returns """["object"]""" "An object literal should compare like an object parameter." + } + + [] + member this.``ObjectToArray can be searched through ARRAY_CONTAINS and through IN over a subquery`` () : Task = task { + let! container = + this.SeedAsync [| """{"id": "item", "m": {"x": 1, "y": 2}}"""; """{"id": "other", "m": {"x": 3}}""" |] + + let! pairs = + this.QueryAsync (container, QueryDefinition """SELECT VALUE ObjectToArray(c.m) FROM c WHERE c.id = "item" """) + + pairs + |> QueryAssert.Returns + """[[{"k": "x", "v": 1}, {"k": "y", "v": 2}]]""" + "ObjectToArray should turn each property into an object with its name as k and its value as v." + + let! containing = + this.QueryAsync ( + container, + QueryDefinition """SELECT VALUE c.id FROM c WHERE ARRAY_CONTAINS(ObjectToArray(c.m), {"v": 2}, true)""" + ) + + containing + |> QueryAssert.Returns """["item"]""" "ARRAY_CONTAINS with a partial match should find a property value in ObjectToArray." + + let! existing = + this.QueryAsync ( + container, + QueryDefinition + "SELECT VALUE c.id FROM c WHERE EXISTS(SELECT VALUE kv FROM kv IN (SELECT VALUE ObjectToArray(c.m)) WHERE kv.v = 2)" + ) + + existing + |> QueryAssert.Returns """["item"]""" "IN over a subquery that returns ObjectToArray should iterate its elements." + } + + [] + [] + [] + [] + [] + [] + [] + [] + member this.``A subquery rejects TOP, ORDER BY and OFFSET LIMIT`` (query : string, errorCode : string) : Task = task { + let! container = this.SeedAsync [| """{"id": "item", "arr": [3, 1, 2]}""" |] + let! outcome = this.QueryAsync (container, QueryDefinition query) + outcome + |> QueryAssert.Rejects errorCode "The engine should reject the clause inside the subquery." + } + + [] + [ 1", + """["b", "c"]""", + DisplayName = "a filter outside a FROM subquery")>] + [ 1", + "[2, 3]", + DisplayName = "a projection inside a FROM subquery")>] + [] + [] + [] + [ 1", + "[2, 3]", + DisplayName = "nested FROM subqueries")>] + [] + [] + [ 1) AS t", + "[3, 2]", + DisplayName = "a subquery as the source of a JOIN")>] + [ 1", + """["x"]""", + DisplayName = "a filter outside a GROUP BY subquery")>] + [] + [ 1) AS x", + "[5]", + DisplayName = "an aggregate over a filtered FROM subquery")>] + [ 1) AS x", + """["x", "y"]""", + DisplayName = "DISTINCT outside a FROM subquery")>] + [] + member this.``Subqueries without TOP, ORDER BY or OFFSET LIMIT run`` (query : string, expected : string) : Task = task { + let! container = this.SeedAsync subqueryDocuments + let! outcome = this.QueryAsync (container, QueryDefinition query) + + outcome + |> QueryAssert.ReturnsInAnyOrder expected "The subquery should run and return these items." + } + + [] + [] + [] + member this.``ORDER BY outside a FROM subquery sorts only by paths of the documents`` (query : string) : Task = task { + let! container = this.SeedAsync subqueryDocuments + let! outcome = this.QueryAsync (container, QueryDefinition query) + + outcome + |> QueryAssert.RejectsSaying + "ORDER BY item expression could not be mapped to a document path" + "ORDER BY should reject a value the subquery computes instead of reading it from the document." + } + + [] + [] + [] + [] + member this.``The source of IN must be a path or a subquery`` (query : string) : Task = task { + let! container = this.SeedAsync [| """{"id": "item", "m": {"x": 1}}""" |] + let! outcome = this.QueryAsync (container, QueryDefinition query) + outcome + |> QueryAssert.Rejects "SC1001" "The engine should reject the source of IN as a syntax error." + } + + [] + member this.``ORDER BY keeps documents without the value and orders JSON types from undefined to objects`` () : Task = task { + let! container = this.SeedAsync valuesOfEveryType + + let! ascending = this.QueryAsync (container, QueryDefinition "SELECT VALUE c.id FROM c ORDER BY c.v") + + ascending + |> QueryAssert.Returns + """["missing", "null", "false", "true", "zero", "one", "empty", "a", "array", "object"]""" + "ORDER BY ASC should return the document without the value first, then null, booleans, numbers, strings, arrays and objects." + + let! descending = + this.QueryAsync (container, QueryDefinition "SELECT VALUE c.id FROM c ORDER BY c.v DESC") + + descending + |> QueryAssert.Returns + """["object", "array", "a", "empty", "one", "zero", "true", "false", "null", "missing"]""" + "ORDER BY DESC should return the exact reverse of ORDER BY ASC." + } diff --git a/tests/Cosmos.Tests/TestCategories.fs b/tests/Cosmos.Tests/TestCategories.fs index 238cceb4..6735c277 100644 --- a/tests/Cosmos.Tests/TestCategories.fs +++ b/tests/Cosmos.Tests/TestCategories.fs @@ -49,6 +49,14 @@ type PatchTestCategoryAttribute () = inherit TestCategoryBaseAttribute () override _.TestCategories = [| "Patch" |] :> IList +/// +/// Categorizes , which record how the +/// emulator evaluates the query constructs that the planned translation of F# quotations relies on. +/// +type QuerySemanticsTestCategoryAttribute () = + inherit TestCategoryBaseAttribute () + override _.TestCategories = [| "QuerySemantics" |] :> IList + type ReadExtensionsTestCategoryAttribute () = inherit TestCategoryBaseAttribute () override _.TestCategories = [| "ReadExtensions" |] :> IList From 3e7165f3cd32ad469cf396443bfa404d1d2eae15 Mon Sep 17 00:00:00 2001 From: XperiAndri Date: Sat, 10 Oct 2026 09:48:08 +0200 Subject: [PATCH 2/3] test: record how ORDER BY over two properties uses a composite index A composite index keeps the documents that lack one of its values, so an ORDER BY over two properties returns them, sorted before null and numbers in each key, and the exact inverse of the index returns the exact reverse. The Windows emulator refuses directions that no composite index matches; the vNext emulator has no composite indexes and sorts without one. Co-Authored-By: Claude Opus 5.5 --- tests/Cosmos.Tests/QuerySemanticsTests.fs | 99 ++++++++++++++++++++++- 1 file changed, 95 insertions(+), 4 deletions(-) diff --git a/tests/Cosmos.Tests/QuerySemanticsTests.fs b/tests/Cosmos.Tests/QuerySemanticsTests.fs index a2d18565..5b6c0723 100644 --- a/tests/Cosmos.Tests/QuerySemanticsTests.fs +++ b/tests/Cosmos.Tests/QuerySemanticsTests.fs @@ -2,6 +2,7 @@ namespace FSharp.Azure.Cosmos.Tests.Integration open System open System.Collections.Immutable +open System.Collections.ObjectModel open System.Globalization open System.IO open System.Net @@ -89,7 +90,8 @@ module private QueryAssert = /// /// Records how the emulator evaluates the query constructs that the planned translation of F# quotations into Cosmos DB /// SQL relies on: comparisons with missing and values, the coalesce operator, integer division, -/// string functions with a case flag, parameters in unusual positions, object equality and subqueries. +/// string functions with a case flag, parameters in unusual positions, object equality, subqueries and +/// ORDER BY. /// /// /// @@ -138,15 +140,33 @@ type QuerySemanticsTests () = } |> ImmutableArray.CreateRange + // Documents that lack a, b or both, or hold null in a, for the ORDER BY tests over two properties + static let documentsForTwoSortKeys = + seq { + """{"id": "none"}""" + """{"id": "b1", "b": 1}""" + """{"id": "aNull", "a": null}""" + """{"id": "a1", "a": 1}""" + """{"id": "a1b1", "a": 1, "b": 1}""" + """{"id": "a2b0", "a": 2, "b": 0}""" + } + |> ImmutableArray.CreateRange + static let invariant (value : int) = value.ToString CultureInfo.InvariantCulture /// /// Creates the container of the running test and seeds , JSON objects to which the /// partition key property is added. /// - member private this.SeedAsync (documents : string seq) : Task = task { - let! container = - this.Application.GetOrCreateContainerAsync ("query-semantics", "/pk", this.CancellationToken) + member private this.SeedAsync (documents : string seq) : Task = + this.SeedAsync (ContainerProperties ("query-semantics", "/pk"), documents) + + /// + /// Creates the container that describe, which must be partitioned by + /// /pk, and seeds , JSON objects to which the partition key property is added. + /// + member private this.SeedAsync (containerProperties : ContainerProperties, documents : string seq) : Task = task { + let! container = this.Application.GetOrCreateContainerAsync (containerProperties, this.CancellationToken) for document in documents do let item = (nonNull (JsonNode.Parse document)).AsObject() @@ -165,6 +185,31 @@ type QuerySemanticsTests () = return container } + /// + /// Creates a container with the composite index (/a ASC, /b ASC) and seeds the documents of the + /// ORDER BY tests over two properties. + /// + /// + /// The vNext emulator accepts the composite index but does not build it, because it has no composite indexes yet. + /// + member private this.SeedWithCompositeIndexAsync () : Task = + let indexingPolicy = IndexingPolicy () + + // CompositeIndexes has no public setter, so the index joins the collection that the policy creates + indexingPolicy.CompositeIndexes.Add ( + Collection( + ResizeArray [| + CompositePath (Path = "/a", Order = CompositePathSortOrder.Ascending) + CompositePath (Path = "/b", Order = CompositePathSortOrder.Ascending) + |] + ) + ) + + this.SeedAsync ( + ContainerProperties ("query-semantics-composite", "/pk", IndexingPolicy = indexingPolicy), + documentsForTwoSortKeys + ) + /// /// Runs in the logical partition of the seeded documents and collects the items of every /// page, or the answer of the emulator when it refuses the query. @@ -740,3 +785,49 @@ type QuerySemanticsTests () = """["object", "array", "a", "empty", "one", "zero", "true", "false", "null", "missing"]""" "ORDER BY DESC should return the exact reverse of ORDER BY ASC." } + + [] + member this.``ORDER BY over two properties keeps documents without a value and sorts them first`` () : Task = task { + let! container = this.SeedWithCompositeIndexAsync () + + let! ascending = + this.QueryAsync (container, QueryDefinition "SELECT VALUE c.id FROM c ORDER BY c.a, c.b") + + ascending + |> QueryAssert.Returns + """["none", "b1", "aNull", "a1", "a1b1", "a2b0"]""" + "ORDER BY c.a, c.b should keep the documents without a or b and sort a missing value before null and numbers in each key." + + let! descending = + this.QueryAsync (container, QueryDefinition "SELECT VALUE c.id FROM c ORDER BY c.a DESC, c.b DESC") + + descending + |> QueryAssert.Returns + """["a2b0", "a1b1", "a1", "aNull", "b1", "none"]""" + "ORDER BY c.a DESC, c.b DESC, the inverse of the composite index, should return the exact reverse of ORDER BY c.a, c.b." + } + + [] + member this.``ORDER BY over two properties in directions that no composite index matches fails on the Windows emulator`` + () + : Task + = task { + let! kind = Emulator.readKindAsync this.CancellationToken + let! container = this.SeedWithCompositeIndexAsync () + + let! outcome = + this.QueryAsync (container, QueryDefinition "SELECT VALUE c.id FROM c ORDER BY c.a, c.b DESC") + + match kind with + | Emulator.Kind.Windows -> + outcome + |> QueryAssert.RejectsSaying + "does not have a corresponding composite index" + "The Windows emulator should refuse ORDER BY c.a, c.b DESC, which the composite index (/a ASC, /b ASC) cannot serve." + | Emulator.Kind.VNext -> + // The vNext emulator has no composite indexes, so it sorts without one + outcome + |> QueryAssert.Returns + """["b1", "none", "aNull", "a1b1", "a1", "a2b0"]""" + "The vNext emulator should sort by c.a ascending, then by c.b descending, without a composite index." + } From 1a6217aed473057ce9aa688e7924d6b9f1289321 Mon Sep 17 00:00:00 2001 From: XperiAndri Date: Sat, 10 Oct 2026 10:07:46 +0200 Subject: [PATCH 3/3] test: record how the emulators evaluate the spatial functions The spatial functions take GeoJSON as raw JSON parameters, the form the translator plans for captured geometry values. On the Windows emulator ST_DISTANCE and ST_AREA measure in metres on the WGS-84 ellipsoid, ST_INTERSECTS, ST_WITHIN and ST_ISVALID test points against a polygon, ST_ISVALIDDETAILED explains an invalid point, and ST_DISTANCE and ST_WITHIN filter stored points. The vNext emulator fails a spatial function in the projection with 500, "This query type isn't supported yet", and answers a spatial filter without documents, so the SDK throws while it reads the page. Co-Authored-By: Claude Opus 5.5 --- tests/Cosmos.Tests/QuerySemanticsTests.fs | 127 +++++++++++++++++++++- 1 file changed, 121 insertions(+), 6 deletions(-) diff --git a/tests/Cosmos.Tests/QuerySemanticsTests.fs b/tests/Cosmos.Tests/QuerySemanticsTests.fs index 5b6c0723..e2ea42c3 100644 --- a/tests/Cosmos.Tests/QuerySemanticsTests.fs +++ b/tests/Cosmos.Tests/QuerySemanticsTests.fs @@ -66,16 +66,23 @@ module private QueryAssert = CollectionAssert.AreEqual (itemTexts expectedItems, itemTexts (WantItems message outcome), message) /// - /// Fails the test with unless the emulator rejected the query as a bad request whose - /// error contains . + /// Fails the test with unless the emulator rejected the query with + /// and an error that contains . /// - let RejectsSaying (text : string) (message : string) (outcome : QueryOutcome) = + let RejectsWith (expectedStatusCode : HttpStatusCode) (text : string) (message : string) (outcome : QueryOutcome) = match outcome with | Returned items -> Assert.Fail $"{message} The query ran and returned {items.ToJsonString ()}." | Rejected (statusCode, _, error) -> - Assert.AreEqual (HttpStatusCode.BadRequest, statusCode, message) + Assert.AreEqual (expectedStatusCode, statusCode, message) Assert.Contains (text, error, StringComparison.Ordinal, message) + /// + /// Fails the test with unless the emulator rejected the query as a bad request whose + /// error contains . + /// + let RejectsSaying (text : string) (message : string) (outcome : QueryOutcome) = + RejectsWith HttpStatusCode.BadRequest text message outcome + /// /// Fails the test with unless the emulator rejected the query as a bad request whose /// error carries the syntax or semantic error code , such as SC2203. @@ -90,8 +97,8 @@ module private QueryAssert = /// /// Records how the emulator evaluates the query constructs that the planned translation of F# quotations into Cosmos DB /// SQL relies on: comparisons with missing and values, the coalesce operator, integer division, -/// string functions with a case flag, parameters in unusual positions, object equality, subqueries and -/// ORDER BY. +/// string functions with a case flag, parameters in unusual positions, object equality, subqueries, ORDER BY and +/// spatial functions. /// /// /// @@ -152,8 +159,28 @@ type QuerySemanticsTests () = } |> ImmutableArray.CreateRange + // GeoJSON for the spatial tests: points at the origin, one degree north of it and off the globe, and a square of one + // degree around the origin + static let geometries = + seq { + struct ("@origin", """{"type": "Point", "coordinates": [0.0, 0.0]}""") + struct ("@north", """{"type": "Point", "coordinates": [0.0, 1.0]}""") + struct ("@offTheGlobe", """{"type": "Point", "coordinates": [0.0, 100.0]}""") + struct ("@square", + """{"type": "Polygon", "coordinates": [[[-0.5, -0.5], [0.5, -0.5], [0.5, 0.5], [-0.5, 0.5], [-0.5, -0.5]]]}""") + } + |> ImmutableArray.CreateRange + static let invariant (value : int) = value.ToString CultureInfo.InvariantCulture + // Passes every geometry as a raw JSON parameter, the form planned for captured geometry values + static let withGeometries (query : QueryDefinition) = + for struct (name, json) in geometries do + query.WithParameterStream (name, new MemoryStream (Encoding.UTF8.GetBytes json)) + |> ignore + + query + /// /// Creates the container of the running test and seeds , JSON objects to which the /// partition key property is added. @@ -831,3 +858,91 @@ type QuerySemanticsTests () = """["b1", "none", "aNull", "a1b1", "a1", "a2b0"]""" "The vNext emulator should sort by c.a ascending, then by c.b descending, without a composite index." } + + [] + [] + [] + [] + [] + [] + [] + member this.``Spatial functions measure GeoJSON on the WGS-84 ellipsoid but fail on the vNext emulator`` + (expression : string, expected : string) + : Task + = task { + let! kind = Emulator.readKindAsync this.CancellationToken + let! container = this.SeedAsync [| """{"id": "item"}""" |] + + let! outcome = + this.QueryAsync ( + container, + QueryDefinition $"SELECT VALUE {expression} FROM c" + |> withGeometries + ) + + match kind with + | Emulator.Kind.Windows -> + outcome + |> QueryAssert.Returns + $"[{expected}]" + $"{expression} should measure in metres on the WGS-84 ellipsoid and test the points against the square." + | Emulator.Kind.VNext -> + outcome + |> QueryAssert.RejectsWith + HttpStatusCode.InternalServerError + "This query type isn't supported yet" + $"The vNext emulator should refuse {expression}, because it has no spatial functions yet." + } + + [] + member this.``Spatial functions filter stored GeoJSON points but the vNext emulator answers without documents`` () : Task = + task { + let! kind = Emulator.readKindAsync this.CancellationToken + + let! container = + this.SeedAsync [| + """{"id": "origin", "location": {"type": "Point", "coordinates": [0.0, 0.0]}}""" + """{"id": "north", "location": {"type": "Point", "coordinates": [0.0, 1.0]}}""" + """{"id": "far", "location": {"type": "Point", "coordinates": [10.0, 10.0]}}""" + |] + + let near = + QueryDefinition "SELECT VALUE c.id FROM c WHERE ST_DISTANCE(c.location, @origin) < 200000 ORDER BY c.id" + |> withGeometries + + let within = + QueryDefinition "SELECT VALUE c.id FROM c WHERE ST_WITHIN(c.location, @square) ORDER BY c.id" + |> withGeometries + + match kind with + | Emulator.Kind.Windows -> + let! nearOutcome = this.QueryAsync (container, near) + + nearOutcome + |> QueryAssert.Returns + """["north", "origin"]""" + "ST_DISTANCE should keep the stored points within 200 km of the origin." + + let! withinOutcome = this.QueryAsync (container, within) + + withinOutcome + |> QueryAssert.Returns """["origin"]""" "ST_WITHIN should keep only the stored point inside the square." + | Emulator.Kind.VNext -> + // Unlike a spatial function in the projection, which fails the request, a spatial filter gets an answer + // without documents, which the SDK cannot read + for query in [| near; within |] do + let! error = + Assert.ThrowsExactlyAsync( + Func(fun () -> this.QueryAsync (container, query) :> Task), + "The SDK should fail to read the answer of the vNext emulator to a spatial filter." + ) + + Assert.Contains ( + "QueryResponse did not have property: Documents", + error.Message, + StringComparison.Ordinal, + "The SDK should fail because the answer has no documents." + ) + }