Skip to content

feat(json): add a converter that stores a TimeSpan as a number of ticks - #67

Merged
xperiandri merged 1 commit into
mainfrom
feat/timespan-ticks-converter
Oct 18, 2026
Merged

xperiandri merged 1 commit into
mainfrom
feat/timespan-ticks-converter

Conversation

@xperiandri

@xperiandri xperiandri commented Oct 15, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

Cosmos DB compares text by character code. System.Text.Json writes a TimeSpan as text, for example "2.00:00:00". Thus a query with ORDER BY or > on a TimeSpan field returns the wrong result:

  • "2.00:00:00" sorts after "10.00:00:00".
  • "23:00:00" sorts after both.

System.Text.Json has no option for another format. JsonSerializerOptions has no setting for it, and FSharp.SystemTextJson has no converter for TimeSpan.

Change

  • Add TimeSpanTicksConverter (src/Cosmos/JsonConverters.fs). It writes the number of ticks as a JSON number. One tick is 100 nanoseconds. Cosmos DB sorts and compares a number in time order.
  • The converter reads the old text form too. Thus a container with old documents stays readable.
  • The converter handles Nullable<TimeSpan> and a TimeSpan as the key of a dictionary.
  • Add the how-to page How To store a duration and an entry in CHANGELOG.md.
  • Add Emulator.createClientWithJson to the test infrastructure. It creates a client with System.Text.Json options.

The converter is for System.Text.Json. The library has no other dependency for it, because net10.0 includes System.Text.Json.

Tests

  • 27 unit tests: the written number, a round trip of the smallest and the largest TimeSpan, the old text form (also the text that the default converter writes), values that must fail, Nullable<TimeSpan>, dictionary keys, and a record.
  • 4 tests on the emulator. DurationsScenario writes the same six durations two times, through each serializer into a partition of its own, and the tests read them. As the rule for scenarios says, the test class refers to DurationsScenario.TestApplicationFactory through the namespace and opens the namespace for the other types of the scenario. The scenario inherits from OperationTestApplicationFactory (test: let the fixture create the containers of a scenario #71), so the fixture creates the container:
    • ORDER BY returns the durations in time order with the converter.
    • A filter with a TimeSpan parameter keeps the right durations. The client writes the parameter with the converter.
    • The documents with the old text form are read with the converter.
    • Negative control: with the default converter, ORDER BY returns the order of the text. This is why the converter exists.

On the Windows emulator (Microsoft.Azure.Cosmos 3.62.0), the 31 TimeSpan tests and the 14 tests of the categories "Read", "Read many" and "Iteration extensions" pass. Before the scenario, the whole suite passed (233 tests). The Release and Debug builds add no warning, and the strict documentation build passes.

Notes for the reviewer

  • I chose ticks, not seconds or milliseconds. Ticks are exact for each TimeSpan. The unit is not in the document, so a query written by hand must use ticks. The how-to page says so.
  • Cosmos DB defines a JSON number as a 64-bit floating-point number, which holds each whole number up to 2^53 exactly. This is about 28 years of ticks. On the emulator, an integer above 2^53 stayed exact.
  • DateTime and DateTimeOffset have a related problem: System.Text.Json drops the trailing zeros of the fraction, so ...:00Z sorts after ...:00.5Z. This PR does not change them. A converter for them fits in the same file.
  • The query translator of ADR 0001 can recognize this converter and treat the field as a number.

🤖 Generated with Claude Code

Copilot AI balanced review requested due to automatic review settings October 15, 2026 00:51

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approval recommended

No blocking defects were identified; remaining feedback concerns minor coding conventions.

0 open findings

What changed in this PR

Adds an opt-in System.Text.Json converter that stores durations as numeric ticks for Cosmos DB sorting and filtering, while preserving reads of legacy text values.

Changes:

  • Adds tick serialization, including nullable values and dictionary keys.
  • Adds unit and emulator coverage with configurable JSON clients.
  • Documents setup, migration, and precision limits.
File Description
tests/​Cosmos.Tests/​TimeSpanTicksConverterTests.fs Tests serialization and supported inputs.
tests/​Cosmos.Tests/​TimeSpanTicksConverterIntegrationTests.fs Tests sorting, filtering, and legacy reads.
tests/​Cosmos.Tests/​TestCategories.fs Adds converter test categories.
tests/​Cosmos.Tests/​FSharp.Azure.Cosmos.Tests.fsproj Includes both test files.
tests/​Cosmos.Tests.Infrastructure/​Emulator.fs Adds configurable JSON clients.
src/​Cosmos/​JsonConverters.fs Implements the ticks converter.
src/​Cosmos/​FSharp.Azure.Cosmos.fsproj Includes the converter.
docsSrc/​How_Tos/​Store_Durations.md Explains usage and limitations.
CHANGELOG.md Announces the converter.

🧠 Review effort: Balanced


💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread tests/Cosmos.Tests.Infrastructure/Emulator.fs Outdated
Comment thread tests/Cosmos.Tests/TestCategories.fs
Comment thread tests/Cosmos.Tests/TimeSpanTicksConverterIntegrationTests.fs Outdated
Comment thread tests/Cosmos.Tests/TimeSpanTicksConverterTests.fs Outdated
Comment thread tests/Cosmos.Tests/TimeSpanTicksConverterTests.fs Outdated
Comment thread CHANGELOG.md Outdated
@xperiandri
xperiandri force-pushed the feat/timespan-ticks-converter branch 2 times, most recently from b346cf0 to 76d94c7 Compare October 17, 2026 22:31
@xperiandri xperiandri changed the title feat(json): add a converter that stores a TimeSpan as a number of ticks feat(json): add a converter that stores a TimeSpan as a number of ticks Oct 17, 2026

@xperiandri xperiandri left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you always seed the same data why don't you derive from OperationTestBase<> with scenario?

@xperiandri

Copy link
Copy Markdown
Collaborator Author

About the review comment "derive from OperationTestBase<> with scenario": done in 67082c4 (folded into the single commit).

  • tests/Cosmos.Tests/Scenarios/DurationsScenario.fs has the shape of the Ecierge scenarios: a [<Struct>] TestData record, the data created in the constructor of TestApplicationFactory, and SeedDataAsync that only writes the data.
  • The scenario writes the same six durations two times: through TimeSpanTicksConverter into the partition ticks, and through the default converter into the partition text.
  • TimeSpanTicksConverterIntegrationTests derives from OperationTestBase<TestApplicationFactory>, and each test reads its values with let { … } = this.Application.TestData.
  • The test of the old text form now reads all six text documents with the converter, not one document.

The 31 TimeSpan tests (27 unit, 4 emulator) pass on the Windows emulator.

Comment thread tests/Cosmos.Tests/TimeSpanTicksConverterIntegrationTests.fs Outdated
@xperiandri
xperiandri force-pushed the feat/timespan-ticks-converter branch from 67082c4 to 883b40e Compare October 18, 2026 00:48
@xperiandri
xperiandri force-pushed the feat/timespan-ticks-converter branch 2 times, most recently from af6b72f to b268450 Compare October 18, 2026 01:17
Comment thread tests/Cosmos.Tests/TestCategories.fs Outdated
Comment thread tests/Cosmos.Tests/TestCategories.fs Outdated
@xperiandri
xperiandri force-pushed the feat/timespan-ticks-converter branch 3 times, most recently from 3806e97 to 048e40f Compare October 18, 2026 14:28
Comment thread tests/Cosmos.Tests/Scenarios/DurationsScenario.fs Outdated
Comment thread tests/Cosmos.Tests/Scenarios/DurationsScenario.fs Outdated
Comment thread tests/Cosmos.Tests/TimeSpanTicksConverterIntegrationTests.fs Outdated
@xperiandri
xperiandri force-pushed the feat/timespan-ticks-converter branch from 048e40f to 249478c Compare October 18, 2026 14:49
xperiandri added a commit that referenced this pull request Oct 18, 2026
A scenario created its container in SeedDataAsync. The scenarios also
repeated the identifier and the partition key path of the container of
OperationTestBase. The review of #67 asked why the fixture does not make
sure that all containers exist.

Add the virtual member Containers to DatabaseTestApplicationFactory. A
scenario overrides it to declare its containers. InitializeAsync creates
them before SeedDataAsync runs. By default, ContainerCount is the number
of declared containers, and at least one. Thus a test that creates its
containers itself works as before.

Add OperationTestApplicationFactory, the base class of the scenarios of
the operation tests. It declares the container of OperationTestBase and
gives it to a scenario as Container. SingleItemScenario and
MultipleItemsScenario use it, and OperationTestBase uses its constants.
Update the test-data-seeding skill and its examples.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@xperiandri
xperiandri force-pushed the feat/timespan-ticks-converter branch from 249478c to 63ab72d Compare October 18, 2026 15:04
xperiandri added a commit that referenced this pull request Oct 18, 2026
A scenario created its container in SeedDataAsync. The scenarios also
repeated the identifier and the partition key path of the container of
OperationTestBase. The review of #67 asked why the fixture does not make
sure that all containers exist.

Add the virtual member Containers to DatabaseTestApplicationFactory. A
scenario overrides it to declare its containers. InitializeAsync creates
them before SeedDataAsync runs. By default, ContainerCount is the number
of declared containers, and at least one. Thus a test that creates its
containers itself works as before.

Containers is a ContainerSet, a set of container definitions with the
identifier as key. It throws when a scenario declares a container twice.
CreateContainerIfNotExistsAsync would hide the second declaration. It
returns the container that exists already and ignores all of its other
settings, except the partition key path.

Add OperationTestApplicationFactory, the base class of the scenarios of
the operation tests. It declares the container of OperationTestBase and
gives it to a scenario as Container. SingleItemScenario and
MultipleItemsScenario use it, and OperationTestBase uses its constants.
Update the test-data-seeding skill and its examples.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@xperiandri
xperiandri force-pushed the feat/timespan-ticks-converter branch from 63ab72d to 90b91d7 Compare October 18, 2026 15:43
xperiandri added a commit that referenced this pull request Oct 18, 2026
A scenario created its container in SeedDataAsync. The scenarios also
repeated the identifier and the partition key path of the container of
OperationTestBase. The review of #67 asked why the fixture does not make
sure that all containers exist.

Add the virtual member Containers to DatabaseTestApplicationFactory. A
scenario overrides it to declare its containers. InitializeAsync creates
them before SeedDataAsync runs. By default, ContainerCount is the number
of declared containers, and at least one. Thus a test that creates its
containers itself works as before.

Containers is a ContainerSet, a set of container definitions with the
identifier as key. It throws when a scenario declares a container twice.
CreateContainerIfNotExistsAsync would hide the second declaration. It
returns the container that exists already and ignores all of its other
settings, except the partition key path.

Add OperationTestApplicationFactory, the base class of the scenarios of
the operation tests. It declares the container of OperationTestBase and
gives it to a scenario as Container. SingleItemScenario and
MultipleItemsScenario use it, and OperationTestBase uses its constants.
Update the test-data-seeding skill and its examples.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@xperiandri
xperiandri force-pushed the feat/timespan-ticks-converter branch from 90b91d7 to 20267bb Compare October 18, 2026 16:53
@xperiandri
xperiandri force-pushed the feat/timespan-ticks-converter branch from 20267bb to 9672633 Compare October 18, 2026 17:09
Comment thread tests/Cosmos.Tests/Scenarios/DurationsScenario.fs Outdated
Comment thread tests/Cosmos.Tests/TimeSpanTicksConverterIntegrationTests.fs Outdated
Comment thread tests/Cosmos.Tests/TimeSpanTicksConverterIntegrationTests.fs Outdated
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<TimeSpan> 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 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@xperiandri
xperiandri force-pushed the feat/timespan-ticks-converter branch from 9672633 to e8b8229 Compare October 18, 2026 17:18
@xperiandri
xperiandri merged commit 2c90aa0 into main Oct 18, 2026
7 checks passed
@xperiandri
xperiandri deleted the feat/timespan-ticks-converter branch October 18, 2026 17:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants