From e8b8229b542e72efc38a9bda43308d18e7b80dbb Mon Sep 17 00:00:00 2001 From: XperiAndri Date: Thu, 15 Oct 2026 01:50:50 +0100 Subject: [PATCH] feat(json): add a converter that stores a TimeSpan as a number of ticks Cosmos DB compares text by character code. System.Text.Json writes a TimeSpan as text. Thus "2.00:00:00" sorts after "10.00:00:00", and a query with ORDER BY returns the wrong order. System.Text.Json has no option for another format. Add TimeSpanTicksConverter. It writes the number of ticks. A number sorts and compares in time order. The converter reads the old text form too, so a container with old documents stays readable. It also handles Nullable and the key of a dictionary. Add 27 unit tests and 4 tests for the emulator. The emulator tests show the sort order, a filter with a TimeSpan parameter, and the read of documents in the old text form. A scenario writes the same durations through each serializer into a partition of its own. Add Emulator.createClientWithJson to the test infrastructure for a client with System.Text.Json options. Add the how-to page "How To store a duration" and a CHANGELOG entry. Co-Authored-By: Claude Sonnet 5.5 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 3 + docsSrc/How_Tos/Store_Durations.md | 87 +++++++++ src/Cosmos/FSharp.Azure.Cosmos.fsproj | 1 + src/Cosmos/JsonConverters.fs | 100 ++++++++++ tests/Cosmos.Tests.Infrastructure/Emulator.fs | 27 ++- .../FSharp.Azure.Cosmos.Tests.fsproj | 3 + .../Scenarios/DurationsScenario.fs | 112 +++++++++++ tests/Cosmos.Tests/TestCategories.fs | 8 + .../TimeSpanTicksConverterIntegrationTests.fs | 128 +++++++++++++ .../TimeSpanTicksConverterTests.fs | 174 ++++++++++++++++++ 10 files changed, 634 insertions(+), 9 deletions(-) create mode 100644 docsSrc/How_Tos/Store_Durations.md create mode 100644 src/Cosmos/JsonConverters.fs create mode 100644 tests/Cosmos.Tests/Scenarios/DurationsScenario.fs create mode 100644 tests/Cosmos.Tests/TimeSpanTicksConverterIntegrationTests.fs create mode 100644 tests/Cosmos.Tests/TimeSpanTicksConverterTests.fs diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c2c4e5c..6fe536af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +* `TimeSpanTicksConverter`, a `System.Text.Json` converter that writes a `TimeSpan` as a JSON number of ticks. Cosmos DB sorts and compares a number in time order, but not the text that `System.Text.Json` writes (`"2.00:00:00"` sorts after `"10.00:00:00"`). The converter also reads that text, so old documents stay readable. See [How To store a duration](https://fsprojects.github.io/FSharp.Azure.Cosmos/How_Tos/Store_Durations.html) + ### Changed * BREAKING: the package targets `net10.0` only and no longer supports `net8.0`, which makes this a major release * BREAKING: `Microsoft.Azure.Cosmos` updated from 3.60.0 to 3.62.0, which becomes the minimum version for consumers. Its release notes list a breaking change of their own: thin client mode is enabled by default and does not support resource-token authentication (opt out with `AZURE_COSMOS_THIN_CLIENT_ENABLED=false`) diff --git a/docsSrc/How_Tos/Store_Durations.md b/docsSrc/How_Tos/Store_Durations.md new file mode 100644 index 00000000..276f478e --- /dev/null +++ b/docsSrc/How_Tos/Store_Durations.md @@ -0,0 +1,87 @@ +--- +title: How To store a duration +category: How To Guides +categoryindex: 2 +index: 4 +--- + +# How To store a duration so that a query sorts it + +Cosmos DB has no type for a duration. System.Text.Json writes a `TimeSpan` as text, for example `"2.00:00:00"`. +Cosmos DB compares text by character code. Thus `"2.00:00:00"` sorts after `"10.00:00:00"`, and `"23:00:00"` sorts +after both. A query with `ORDER BY` or with `>` on such a field gives the wrong result. + +`TimeSpanTicksConverter` writes the number of ticks instead. One tick is 100 nanoseconds. Cosmos DB sorts and +compares a number in time order. + +## Use the converter + +Add the converter to the options of System.Text.Json. Then give the options to the client. The camel case naming +policy writes the field `Id` as `id`, which Cosmos DB requires: + +``` F# +open System.Text.Json +open Microsoft.Azure.Cosmos +open FSharp.Azure.Cosmos + +let serializerOptions = JsonSerializerOptions (PropertyNamingPolicy = JsonNamingPolicy.CamelCase) +serializerOptions.Converters.Add (TimeSpanTicksConverter ()) + +let client = + new CosmosClient ( + endpoint, + key, + CosmosClientOptions (UseSystemTextJsonSerializerWithOptions = serializerOptions) + ) +``` + +Each `TimeSpan` that the client writes is now a number. This record is stored as the JSON below it: + +``` F# +type Job = { Id : string; PartitionKey : string; Duration : System.TimeSpan } + +let job = { Id = "job-1"; PartitionKey = "jobs"; Duration = System.TimeSpan.FromDays 2.0 } +``` + +``` JSON +{ "id": "job-1", "partitionKey": "jobs", "duration": 1728000000000 } +``` + +The client writes a parameter with the same converter. Thus a query can compare the field with a `TimeSpan`: + +``` F# +let longJobsAsync (container : Container) (minimum : System.TimeSpan) = task { + let query = + QueryDefinition("SELECT VALUE c FROM c WHERE c.duration >= @minimum ORDER BY c.duration") + .WithParameter ("@minimum", minimum) + + use iterator = container.GetItemQueryIterator query + let jobs = ResizeArray () + + while iterator.HasMoreResults do + let! page = iterator.ReadNextAsync () + jobs.AddRange page + + return jobs.ToArray () +} +``` + +The converter also handles `Nullable` and a `TimeSpan` as the key of a dictionary. + +## Read the documents that you wrote before + +The converter reads the text that System.Text.Json writes, for example `"1.02:00:00"`. Thus a container with old +documents stays readable. An old document keeps its text until you write it again. Cosmos DB sorts a number before a +text. Thus a query with `ORDER BY` returns the new documents in time order, then the old documents in the order of +their text. With `DESC` the two groups change places. + +To move the old documents, read each one and write it again with a client that uses the converter. + +## Know the limits + +- The converter works with System.Text.Json only. Set it in `UseSystemTextJsonSerializerWithOptions`. +- Cosmos DB defines a JSON number as a 64-bit floating-point number. Such a number holds each whole number up to 2^53 + exactly, which is about 28 years of ticks. A longer `TimeSpan` can lose digits in a client that reads the number as + a floating-point number. +- The number has no unit in the document. A query written by hand must use ticks. Write + `c.duration >= 36000000000` for one hour, or use a `TimeSpan` parameter as above. diff --git a/src/Cosmos/FSharp.Azure.Cosmos.fsproj b/src/Cosmos/FSharp.Azure.Cosmos.fsproj index d018942d..0c380fef 100644 --- a/src/Cosmos/FSharp.Azure.Cosmos.fsproj +++ b/src/Cosmos/FSharp.Azure.Cosmos.fsproj @@ -26,6 +26,7 @@ + diff --git a/src/Cosmos/JsonConverters.fs b/src/Cosmos/JsonConverters.fs new file mode 100644 index 00000000..42d97410 --- /dev/null +++ b/src/Cosmos/JsonConverters.fs @@ -0,0 +1,100 @@ +namespace FSharp.Azure.Cosmos + +open System +open System.Globalization +open System.Text.Json +open System.Text.Json.Serialization + +/// +/// Writes a as a JSON number, its number of ticks, and reads the same number. +/// +/// +/// +/// The converter of System.Text.Json writes a as text, for example "2.00:00:00". +/// Cosmos DB compares text by character code. Thus "2.00:00:00" sorts after "10.00:00:00". +/// A number sorts and compares in time order. Use this converter for each that a query +/// compares or sorts. +/// +/// +/// One tick is 100 nanoseconds. Cosmos DB defines a JSON number as a 64-bit floating-point number. Such a number holds +/// each whole number up to 2^53 exactly, which is about 28 years of ticks. A longer can lose +/// digits in a client that reads the number as a floating-point number. +/// +/// +/// The converter also reads the text that System.Text.Json writes, so a container that holds old documents stays +/// readable. An old document keeps its text, and the text does not sort in time order, until you write the document +/// again. +/// +/// +/// To use the converter, add it to . Then set the options in +/// . The converter +/// also handles values and the keys of a dictionary. +/// +/// +[] +type TimeSpanTicksConverter () = + inherit JsonConverter () + + // A text that holds a whole number is a number of ticks. This is the form of a dictionary key. + // Any other text must have the form of System.Text.Json: [-][d.]hh:mm:ss[.fffffff]. It always has a colon. + // The parser of the "c" format reads a text without a colon as a number of days, so the check keeps such a text out. + static let tryParseText (text : string) : TimeSpan voption = + match Int64.TryParse (text, NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture) with + | true, ticks -> ValueSome (TimeSpan.FromTicks ticks) + | false, _ when text.Contains (':', StringComparison.Ordinal) -> + match TimeSpan.TryParseExact (text, "c", CultureInfo.InvariantCulture) with + | true, value -> ValueSome value + | false, _ -> ValueNone + | false, _ -> ValueNone + + static let parseText (text : string) : TimeSpan = + match tryParseText text with + | ValueSome value -> value + | ValueNone -> + raise ( + JsonException + $"The text '{text}' is not a number of ticks and not a TimeSpan in the form [-][d.]hh:mm:ss[.fffffff]." + ) + + /// + /// Reads a from a whole number of ticks. Reads the text form of System.Text.Json as well. + /// + /// + /// The token is not a number or a text, the number is not a whole number that fits in 64 bits, or the text has an + /// unknown form. + /// + override _.Read (reader : byref, typeToConvert : Type, options : JsonSerializerOptions) : TimeSpan = + match reader.TokenType with + | JsonTokenType.Number -> + match reader.TryGetInt64 () with + | true, ticks -> TimeSpan.FromTicks ticks + | false, _ -> raise (JsonException "A TimeSpan must be a whole number of ticks that fits in 64 bits.") + | JsonTokenType.String -> + match reader.GetString () with + | null -> raise (JsonException "A TimeSpan must not be null.") + | text -> parseText text + | token -> raise (JsonException $"A TimeSpan must be a number or a text, but the JSON token is {token}.") + + /// + /// Writes the number of ticks of as a JSON number. + /// + override _.Write (writer : Utf8JsonWriter, value : TimeSpan, options : JsonSerializerOptions) : unit = + writer.WriteNumberValue value.Ticks + + /// + /// Reads the key of a dictionary: the number of ticks as text. Reads the text form of System.Text.Json as well. + /// + /// The key has an unknown form. + override _.ReadAsPropertyName + (reader : byref, typeToConvert : Type, options : JsonSerializerOptions) + : TimeSpan + = + match reader.GetString () with + | null -> raise (JsonException "A TimeSpan key must not be null.") + | text -> parseText text + + /// + /// Writes the key of a dictionary: the number of ticks of as text. + /// + override _.WriteAsPropertyName (writer : Utf8JsonWriter, value : TimeSpan, options : JsonSerializerOptions) : unit = + writer.WritePropertyName (value.Ticks.ToString CultureInfo.InvariantCulture) diff --git a/tests/Cosmos.Tests.Infrastructure/Emulator.fs b/tests/Cosmos.Tests.Infrastructure/Emulator.fs index b73d667f..cb697d5f 100644 --- a/tests/Cosmos.Tests.Infrastructure/Emulator.fs +++ b/tests/Cosmos.Tests.Infrastructure/Emulator.fs @@ -10,6 +10,7 @@ open System open System.Globalization open System.Net.Http open System.Net.Security +open System.Text.Json open System.Threading open System.Threading.Tasks @@ -172,20 +173,28 @@ let private createHttpMessageHandler () = ) ) +// The shared settings are in a static member, not in a function, because F# sets the named properties of the object +// that a method returns. Thus each client adds its own settings in a class initializer. +type private ClientOptions = + static member Create () = + CosmosClientOptions ( + ConnectionMode = ConnectionMode.Gateway, + HttpClientFactory = Func(fun () -> new HttpClient (createHttpMessageHandler (), true)) + ) + /// /// Creates a for the emulator in mode, the only mode /// the Linux vNext emulator serves, whose accepts the emulator's self-signed certificate from /// local hosts only. /// -let createClient () : CosmosClient = - new CosmosClient ( - endpoint, - key, - CosmosClientOptions ( - ConnectionMode = ConnectionMode.Gateway, - HttpClientFactory = Func(fun () -> new HttpClient (createHttpMessageHandler (), true)) - ) - ) +let createClient () : CosmosClient = new CosmosClient (endpoint, key, ClientOptions.Create ()) + +/// +/// Creates a like , whose serializer is System.Text.Json with +/// . The client of a test that checks how a converter stores a type. +/// +let createClientWithJson (serializerOptions : JsonSerializerOptions) : CosmosClient = + new CosmosClient (endpoint, key, ClientOptions.Create (UseSystemTextJsonSerializerWithOptions = serializerOptions)) // How long the sweep waits for an answer before it concludes that no emulator runs. Without this check, a run of // the unit tests alone on a machine without an emulator would wait for the SDK to give up (seconds per request). diff --git a/tests/Cosmos.Tests/FSharp.Azure.Cosmos.Tests.fsproj b/tests/Cosmos.Tests/FSharp.Azure.Cosmos.Tests.fsproj index 1fb272aa..716a0d8d 100644 --- a/tests/Cosmos.Tests/FSharp.Azure.Cosmos.Tests.fsproj +++ b/tests/Cosmos.Tests/FSharp.Azure.Cosmos.Tests.fsproj @@ -24,6 +24,7 @@ + @@ -32,6 +33,7 @@ + @@ -41,6 +43,7 @@ + diff --git a/tests/Cosmos.Tests/Scenarios/DurationsScenario.fs b/tests/Cosmos.Tests/Scenarios/DurationsScenario.fs new file mode 100644 index 00000000..d52021c9 --- /dev/null +++ b/tests/Cosmos.Tests/Scenarios/DurationsScenario.fs @@ -0,0 +1,112 @@ +namespace FSharp.Azure.Cosmos.Tests.Integration.DurationsScenario + +open System +open System.Collections.Immutable +open System.Text.Json +open System.Threading +open System.Threading.Tasks + +open Microsoft.VisualStudio.TestTools.UnitTesting + +open FSharp.Azure.Cosmos +open FSharp.Azure.Cosmos.Tests +open FSharp.Azure.Cosmos.Tests.Integration + +// The record is public because System.Text.Json does not write the properties of an internal record. Only the files +// that open this namespace see its labels. Thus, the record type inference of the other test files does not use them. +/// A document that holds one duration. +[] +type DurationItem = { Id : string; PartitionKey : string; Duration : TimeSpan } + +/// The serializer options of the clients that write and read the durations. +[] +module SerializerOptions = + + /// + /// The options with the default converter of , which writes text. The queries use camel case + /// names, and Cosmos DB requires the name id. + /// + let text = JsonSerializerOptions (PropertyNamingPolicy = JsonNamingPolicy.CamelCase) + + /// + /// The text options with , which writes a number of ticks. + /// + let ticks = + let options = JsonSerializerOptions text + options.Converters.Add (TimeSpanTicksConverter ()) + options + +/// The values that the tests of this scenario read. +[] +type TestData = { + /// The durations of each partition, in the order of their creation. They differ in the length of their text and in + /// their sign. Thus, no accident puts the text in time order. + Durations : ImmutableArray + /// + /// The partition key of the documents that wrote. + /// + TicksPartition : string + /// The partition key of the documents that the default converter wrote, as old documents hold them. + TextPartition : string +} + +/// +/// Writes the same durations two times into the container of : through +/// , which writes ticks, into , and through the +/// default converter, which writes text, into . +/// +type TestApplicationFactory (testContext : TestContext) = + inherit OperationTestApplicationFactory (testContext) + + let durations = + seq { + TimeSpan.FromDays 2.0 + TimeSpan.FromDays 10.0 + TimeSpan.FromHours 23.0 + TimeSpan.FromHours 1.0 + TimeSpan.FromDays 1.0 + TimeSpan.FromMinutes (-5.0) + } + |> ImmutableArray.CreateRange + + let ticksPartition = "ticks" + let textPartition = "text" + + let documentsIn (partition : string) = + durations + |> Seq.mapi (fun index duration -> { Id = $"{partition}-{index}"; PartitionKey = partition; Duration = duration }) + |> ImmutableArray.CreateRange + + let ticksDocuments = documentsIn ticksPartition + let textDocuments = documentsIn textPartition + + /// The values that the tests read. + member val TestData : TestData = + { + Durations = durations + TicksPartition = ticksPartition + TextPartition = textPartition + } + + // A client of its own writes the documents, because its serializer decides how Cosmos DB stores a TimeSpan + member private this.CreateAsync (options, documents : ImmutableArray, cancellationToken) : Task = task { + use client = Emulator.createClientWithJson options + let container = this.GetContainerOf client + + for document in documents do + let! response = + container.ExecuteAsync ( + create { + item document + partitionKey document.PartitionKey + }, + cancellationToken + ) + + CosmosAssert.IsOk (response.Result, $"Seed create should succeed for the document '{document.Id}'.") + } + + override this.SeedDataAsync (cancellationToken : CancellationToken) : Task = task { + do! this.CreateAsync (SerializerOptions.ticks, ticksDocuments, cancellationToken) + do! this.CreateAsync (SerializerOptions.text, textDocuments, cancellationToken) + } diff --git a/tests/Cosmos.Tests/TestCategories.fs b/tests/Cosmos.Tests/TestCategories.fs index dbff6580..bf6a7b56 100644 --- a/tests/Cosmos.Tests/TestCategories.fs +++ b/tests/Cosmos.Tests/TestCategories.fs @@ -36,6 +36,10 @@ type IterationExtensionsTestCategoryAttribute () = inherit TestCategoryBaseAttribute () override _.TestCategories = [| "Iteration extensions" |] :> IList +type JsonConverterTestCategoryAttribute () = + inherit TestCategoryBaseAttribute () + override _.TestCategories = [| "JSON converter" |] :> IList + type PatchTestCategoryAttribute () = inherit TestCategoryBaseAttribute () override _.TestCategories = [| "Patch" |] :> IList @@ -71,6 +75,10 @@ type TestInfrastructureTestCategoryAttribute () = inherit TestCategoryBaseAttribute () override _.TestCategories = [| "Test infrastructure" |] :> IList +type TimeSpanTestCategoryAttribute () = + inherit TestCategoryBaseAttribute () + override _.TestCategories = [| "TimeSpan" |] :> IList + type UpsertTestCategoryAttribute () = inherit TestCategoryBaseAttribute () override _.TestCategories = [| "Upsert" |] :> IList diff --git a/tests/Cosmos.Tests/TimeSpanTicksConverterIntegrationTests.fs b/tests/Cosmos.Tests/TimeSpanTicksConverterIntegrationTests.fs new file mode 100644 index 00000000..ee97808d --- /dev/null +++ b/tests/Cosmos.Tests/TimeSpanTicksConverterIntegrationTests.fs @@ -0,0 +1,128 @@ +namespace FSharp.Azure.Cosmos.Tests.Integration + +open System +open System.Text.Json +open System.Threading.Tasks + +open Microsoft.Azure.Cosmos +open Microsoft.VisualStudio.TestTools.UnitTesting + +open FSharp.Azure.Cosmos.Tests +open FSharp.Azure.Cosmos.Tests.Integration.DurationsScenario + +/// +/// Checks on the Cosmos DB Emulator that Cosmos DB sorts and compares a in time order when +/// writes it. Cosmos DB does not do this for the text of the default converter. +/// +[] +type TimeSpanTicksConverterIntegrationTests () = + inherit OperationTestBase () + + override _.CreateApplication context = DurationsScenario.TestApplicationFactory (context) + + member private this.QueryAsync<'Item> (container : Container, partition : string, query : QueryDefinition) = task { + let options = QueryRequestOptions (PartitionKey = Nullable (PartitionKey partition)) + use iterator = container.GetItemQueryIterator<'Item>(query, requestOptions = options) + let results = ResizeArray<'Item>() + + while iterator.HasMoreResults do + let! page = iterator.ReadNextAsync this.CancellationToken + results.AddRange page + + return results.ToArray () + } + + static member private OrderedByDuration (partition : string) = + QueryDefinition ("SELECT VALUE c.duration FROM c WHERE c.partitionKey = @partitionKey ORDER BY c.duration") + |> _.WithParameter("@partitionKey", partition) + + [] + member this.``ORDER BY returns the durations in time order when the converter stores ticks`` () : Task = task { + let { Durations = durations; TicksPartition = partition } = this.Application.TestData + use client = Emulator.createClientWithJson SerializerOptions.ticks + let container = this.Application.GetContainerOf client + + let! results = + this.QueryAsync(container, partition, TimeSpanTicksConverterIntegrationTests.OrderedByDuration partition) + + CollectionAssert.AreEqual ( + durations |> Seq.sort |> Seq.toArray, + results, + "ORDER BY should return the durations in time order." + ) + } + + [] + member this.``A TimeSpan parameter filters the durations by time when the converter stores ticks`` () : Task = task { + let { Durations = durations; TicksPartition = partition } = this.Application.TestData + use client = Emulator.createClientWithJson SerializerOptions.ticks + let container = this.Application.GetContainerOf client + + let minimum = TimeSpan.FromHours 23.0 + + let query = + QueryDefinition ( + "SELECT VALUE c.duration FROM c WHERE c.partitionKey = @partitionKey AND c.duration >= @minimum ORDER BY c.duration" + ) + |> _.WithParameter("@partitionKey", partition) + |> _.WithParameter("@minimum", minimum) + + let! results = this.QueryAsync(container, partition, query) + + let expected = + durations + |> Seq.filter ((<=) minimum) + |> Seq.sort + |> Seq.toArray + Assert.HasCount (4, expected, "The test data should have four durations of 23 hours or more.") + CollectionAssert.AreEqual (expected, results, "The filter should keep the durations of 23 hours or more.") + } + + [] + member this.``Documents that hold the text of the default converter are read with the converter`` () : Task = task { + let { Durations = durations; TextPartition = partition } = this.Application.TestData + use client = Emulator.createClientWithJson SerializerOptions.ticks + let container = this.Application.GetContainerOf client + + let query = + QueryDefinition ("SELECT * FROM c WHERE c.partitionKey = @partitionKey") + |> _.WithParameter("@partitionKey", partition) + + let! documents = this.QueryAsync(container, partition, query) + + CollectionAssert.AreEquivalent ( + durations |> Seq.toArray, + documents |> Array.map _.Duration, + "The converter should read the text form of the old documents." + ) + } + + [] + member this.``ORDER BY returns the durations in the order of their text when the default converter stores text`` () : Task = + task { + let { Durations = durations; TextPartition = partition } = this.Application.TestData + use client = Emulator.createClientWithJson SerializerOptions.text + let container = this.Application.GetContainerOf client + + let! results = + this.QueryAsync( + container, + partition, + TimeSpanTicksConverterIntegrationTests.OrderedByDuration partition + ) + + let text (duration : TimeSpan) = JsonSerializer.Serialize (duration, SerializerOptions.text) + + let expected = + durations + |> Seq.sortWith (fun first second -> String.CompareOrdinal (text first, text second)) + |> Seq.toArray + + CollectionAssert.AreEqual (expected, results, "ORDER BY should return the durations in the order of their text.") + + CollectionAssert.AreNotEqual ( + durations |> Seq.sort |> Seq.toArray, + results, + "The order of the text should differ from the time order, which is why the converter exists." + ) + } diff --git a/tests/Cosmos.Tests/TimeSpanTicksConverterTests.fs b/tests/Cosmos.Tests/TimeSpanTicksConverterTests.fs new file mode 100644 index 00000000..bc2575b3 --- /dev/null +++ b/tests/Cosmos.Tests/TimeSpanTicksConverterTests.fs @@ -0,0 +1,174 @@ +namespace FSharp.Azure.Cosmos.Tests + +open System +open System.Collections.Generic +open System.Text.Json +open FSharp.Azure.Cosmos +open Microsoft.VisualStudio.TestTools.UnitTesting + +// The record is in a module of its own. Thus, the record type inference of the other test files does not see its +// labels. The module is public because System.Text.Json does not write the properties of an internal record. +module TimeSpanTicksConverterTestData = + + [] + type DurationDocument = { Id : string; Duration : TimeSpan } + +[] +type TimeSpanTicksConverterTests () = + + // A Cosmos DB document has camel case names, and Cosmos DB requires the name id + static let options = + let options = JsonSerializerOptions (PropertyNamingPolicy = JsonNamingPolicy.CamelCase) + options.Converters.Add (TimeSpanTicksConverter ()) + options + + static let defaultText (value : TimeSpan) = JsonSerializer.Serialize (value, JsonSerializerOptions ()) + + // The generic overload of Deserialize returns 'T | null. For a value type, the compiler cannot express that and + // warns (FS3265). Thus, the two helpers use the overload with a Type, which returns obj | null. + static let readTimeSpan (json : string) : TimeSpan = + JsonSerializer.Deserialize (json, typeof, options) + |> nonNull + |> unbox + + static let readNullableTimeSpan (json : string) : Nullable = + match JsonSerializer.Deserialize (json, typeof>, options) with + | null -> Nullable () + | value -> Nullable (unbox value) + + [] + [] + [] + [] + [] + [] + [] + [] + member _.``Write stores the ticks as a number and Read returns the same TimeSpan`` (ticks : int64, expectedJson : string) = + let value = TimeSpan.FromTicks ticks + + let json = JsonSerializer.Serialize (value, options) + Assert.AreEqual (expectedJson, json, "Write should store the ticks as a JSON number.") + + Assert.AreEqual (value, readTimeSpan json, "Read should return the TimeSpan that Write stored.") + + [] + [] + [] + [] + [] + member _.``Read accepts the text form of System.Text.Json and the ticks as text`` (json : string, expectedTicks : int64) = + Assert.AreEqual (TimeSpan.FromTicks expectedTicks, readTimeSpan json, "Read should return the TimeSpan of the text.") + + [] + member _.``Read accepts what the default converter writes`` () = + let values = [| + TimeSpan.Zero + TimeSpan.FromHours 26.0 + TimeSpan.FromDays (-10.0) + TimeSpan.FromTicks 1L + TimeSpan.FromMilliseconds 1500.0 + TimeSpan.MinValue + TimeSpan.MaxValue + |] + + for value in values do + let text = defaultText value + Assert.AreEqual (value, readTimeSpan text, $"Read should return {text} as {value}.") + + [] + [] + [] + [] + [] + [] + [] + [] + [] + [] + [] + [] + member _.``Read throws JsonException for a JSON value that is not a TimeSpan`` (json : string) = + Assert.ThrowsExactly( + (fun () -> readTimeSpan json |> ignore), + $"Read should throw JsonException for {json}." + ) + |> ignore + + [] + member _.``Nullable TimeSpan stores null and the ticks`` () = + Assert.AreEqual ( + "null", + JsonSerializer.Serialize (Nullable(), options), + "An empty Nullable should be stored as null." + ) + + Assert.AreEqual ( + "36000000000", + JsonSerializer.Serialize (Nullable (TimeSpan.FromHours 1.0), options), + "A Nullable that has a value should be stored as the ticks." + ) + + Assert.IsFalse ((readNullableTimeSpan "null").HasValue, "null should be read as an empty Nullable.") + + Assert.AreEqual ( + Nullable (TimeSpan.FromHours 1.0), + readNullableTimeSpan "36000000000", + "A number should be read as a Nullable that has a value." + ) + + [] + member _.``A TimeSpan is stored as the key of a dictionary and read back`` () = + let key = TimeSpan.FromHours 1.0 + let dictionary = Dictionary() + dictionary[key] <- "one hour" + + let json = JsonSerializer.Serialize (dictionary, options) + Assert.AreEqual ("""{"36000000000":"one hour"}""", json, "The key should be the ticks as text.") + + let readDictionary = + JsonSerializer.Deserialize>(json, options) + |> nonNull + + Assert.AreEqual ("one hour", readDictionary[key], "The key should be read back as the same TimeSpan.") + + let legacy = + JsonSerializer.Deserialize>("""{"01:00:00":"one hour"}""", options) + |> nonNull + + Assert.AreEqual ("one hour", legacy[key], "A key in the text form of System.Text.Json should be read too.") + + [] + member _.``A document that has a TimeSpan is stored with the ticks and read back`` () = + let document : TimeSpanTicksConverterTestData.DurationDocument = { Id = "document"; Duration = TimeSpan.FromDays 2.0 } + + let json = JsonSerializer.Serialize (document, options) + Assert.AreEqual ("""{"id":"document","duration":1728000000000}""", json, "The duration should be a number.") + + let readDocument = + JsonSerializer.Deserialize(json, options) + |> nonNull + + Assert.AreEqual (document, readDocument, "The document should be read back unchanged.") + + [] + member _.``The text of the default converter does not sort in time order`` () = + let durations = [| + TimeSpan.FromDays 2.0 + TimeSpan.FromDays 10.0 + TimeSpan.FromHours 23.0 + TimeSpan.FromHours 1.0 + TimeSpan.FromDays 1.0 + |] + + let byTime = durations |> Array.sort + + let byDefaultText = + durations + |> Array.sortWith (fun first second -> String.CompareOrdinal (defaultText first, defaultText second)) + + CollectionAssert.AreNotEqual ( + byTime, + byDefaultText, + "The text of the default converter should not sort in time order, which is why the converter exists." + )