Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
c327a5d
Initial plan
Copilot May 23, 2026
d921eca
Add Cosmos emulator integration test infrastructure and scenario plan
Copilot May 23, 2026
527ea36
Refine Cosmos test infrastructure and planned scenario list
Copilot May 23, 2026
f98ebe5
Remove DB name normalization and format integration files
Copilot May 23, 2026
67c9905
Use TestContext-based database identifier for integration tests
Copilot May 24, 2026
44baaef
Harden test data hash conversion for database identifier
Copilot May 24, 2026
e177988
Add integration tests for create/read/upsert/replace/patch/delete/rea…
Copilot May 24, 2026
7d70871
Address integration test review feedback in patch scenario
Copilot May 24, 2026
660a0cc
Add CosmosAssert helper and simplify integration test assertions
Copilot May 24, 2026
771f88d
Refine CosmosAssert failure handling consistency
Copilot May 24, 2026
f9d3251
Plan split operation tests into separate files with AndRead coverage
Copilot May 24, 2026
6d5d59f
Split operation integration tests into per-operation files with AndRe…
Copilot May 24, 2026
4a47672
Adjust shared operation test fixture visibility
Copilot May 24, 2026
0bf340c
Remove class-scope literal from operation test infrastructure
Copilot May 24, 2026
03795eb
Rename test methods add read extension coverage and builder tests
Copilot May 24, 2026
31d54c0
Add Assert extensions and align test namespaces
Copilot May 24, 2026
d63fbff
Fix Assert extension signatures and keep test helper usage
Copilot May 24, 2026
60e97b6
fixup! Split operation integration tests into per-operation files wit…
xperiandri May 24, 2026
e1305d3
Delete IntegrationTestPlan file per PR feedback
Copilot May 24, 2026
5d79634
Apply suggestions from code review
xperiandri May 24, 2026
f62634d
fixup! ci(cosmos): use separate common action to check Azure Cosmos E…
xperiandri May 24, 2026
cd491da
Fix failing Cosmos emulator tests
Copilot May 24, 2026
d53c998
Handle undefined deleted marker in IsNotDeletedAsync
Copilot May 24, 2026
829a053
Validate deleted field name in IsNotDeletedAsync query
Copilot May 24, 2026
a06c43a
Harden IsNotDeletedAsync field-name validation
Copilot May 24, 2026
5db799c
Address review feedback for nullArg, test categories, and scenario se…
Copilot May 24, 2026
842d5ed
Apply validation feedback ordering in IntegrationTestBase
Copilot May 24, 2026
bde1f0d
Simplify async exception test delegate in read extensions tests
Copilot May 24, 2026
7badc2f
fix `ReadExtensionsIntegrationTests` name
xperiandri May 24, 2026
69776da
Refine IsNotDeletedAsync docs and validation coverage
Copilot May 24, 2026
1a73340
Address follow-up review notes for IsNotDeletedAsync
Copilot May 24, 2026
b9c6eae
Tidy read extensions test variable naming
Copilot May 24, 2026
985d3f9
Expand deleted-field validation coverage in read extension tests
Copilot May 24, 2026
e19cf82
Changes before error encountered
Copilot Aug 28, 2026
73a869f
Fix deleted-marker semantics, extract CosmosName validation, fix Asse…
xperiandri Sep 13, 2026
ed0eb69
Expand builder and negative-path test coverage (plan part B)
xperiandri Sep 14, 2026
8561e28
test: probe push_files permission scope (no-op, will be reverted)
xperiandri Sep 14, 2026
21d30c3
Fix NotModified mapping and Debug TaskSeq failure; address review com…
xperiandri Sep 14, 2026
3ba5caa
Report conditional-read test as inconclusive when the endpoint ignore…
xperiandri Sep 14, 2026
e9c9c9d
devcontainer: start the Cosmos Emulator from initializeCommand; move …
xperiandri Sep 14, 2026
8a9d0b5
ci(windows): fix Cosmos Emulator readiness check and don't fail the b…
xperiandri Sep 14, 2026
3b65026
Implement AsAsyncEnumerable without taskSeq; revert always-on Optimize
xperiandri Sep 14, 2026
f7fea72
Link the upstream emulator issue from the inconclusive conditional-re…
xperiandri Sep 14, 2026
f37ff46
devcontainer: run the emulator initializeCommand with pwsh instead of…
xperiandri Sep 14, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 16 additions & 3 deletions .devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,19 @@
"forwardPorts": [
0
],
// Share the host's network namespace so the container can reach the Cosmos DB Emulator
// that runs as a sibling container on the host, bound to the host's 127.0.0.1:8081.
"runArgs": [
"--network=host"
],
// Runs on the host before the container is created. On GitHub Actions it starts the
// Cosmos DB Emulator and waits until it is ready; elsewhere it does nothing.
"initializeCommand": [
"pwsh",
"-NoProfile",
"-File",
"${localWorkspaceFolder}/.devcontainer/initialize-cosmos-emulator.ps1"
],
"features": {
// https://github.com/devcontainers/features/blob/main/src/common-utils/README.md
"ghcr.io/devcontainers/features/common-utils:2": {
Expand All @@ -19,8 +32,8 @@
},
// https://github.com/devcontainers/features/blob/main/src/github-cli/README.md
"ghcr.io/devcontainers/features/github-cli:1": {},
// https://github.com/devcontainers-contrib/features/blob/main/src/starship/README.md
"ghcr.io/devcontainers-contrib/features/starship:1": {},
// https://github.com/devcontainers-extra/features/blob/main/src/starship/README.md
"ghcr.io/devcontainers-extra/features/starship:1": {},
// https://github.com/devcontainers/features/blob/main/src/dotnet/README.md
"ghcr.io/devcontainers/features/dotnet:2": {
"version": "10.0",
Expand All @@ -30,7 +43,7 @@
"overrideFeatureInstallOrder": [
"ghcr.io/devcontainers/features/common-utils",
"ghcr.io/devcontainers/features/github-cli",
"ghcr.io/devcontainers-contrib/features/starship",
"ghcr.io/devcontainers-extra/features/starship",
"ghcr.io/devcontainers/features/dotnet"
],
"customizations": {
Expand Down
52 changes: 52 additions & 0 deletions .devcontainer/initialize-cosmos-emulator.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
#!/usr/bin/env pwsh
# Host-side devcontainer initializeCommand.
#
# On GitHub Actions, start the Cosmos DB Emulator container on the runner and wait until it is ready,
# so the dev container (which shares the host network) can run the integration tests against
# 127.0.0.1:8081. Local developers manage their own emulator, so outside GitHub Actions this is a no-op.
$ErrorActionPreference = 'Stop'
Set-StrictMode -Version Latest

if ($env:GITHUB_ACTIONS -ne 'true') {
exit 0
}

$containerName = 'cosmosdb'

# The dev container CLI can run initializeCommand more than once, so only create the container once.
$existing = docker ps --all --filter "name=^$containerName$" --format '{{.Names}}'
if ($existing -contains $containerName) {
Write-Host 'Cosmos DB Emulator container already exists.'
docker start $containerName | Out-Null
}
else {
# Same settings as .github/scripts/linux/start-cosmos-emulator.sh, which the main Linux CI job uses.
docker run -d --name $containerName `
-p 8081:8081 -p 8080:8080 -p 1234:1234 `
-e PROTOCOL=https `
mcr.microsoft.com/cosmosdb/linux/azure-cosmos-emulator:vnext-preview | Out-Null
}

if ($LASTEXITCODE -ne 0) {
throw "docker exited with code $LASTEXITCODE."
}

$maxAttempts = 120
for ($attempt = 1; $attempt -le $maxAttempts; $attempt++) {
try {
$response = Invoke-WebRequest -Uri 'http://127.0.0.1:8080/ready' -SkipHttpErrorCheck -TimeoutSec 5
if ($response.StatusCode -eq 200) {
Write-Host 'Cosmos DB Emulator is ready.'
exit 0
}
}
catch {
# The readiness endpoint is not listening yet.
}

Write-Host "Cosmos DB Emulator is not ready yet (attempt $attempt/$maxAttempts)."
Start-Sleep -Seconds 5
}

docker logs --tail 50 $containerName
throw 'Cosmos DB Emulator failed to become ready in time.'
11 changes: 8 additions & 3 deletions .github/scripts/windows/start-cosmos-emulator.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,15 @@ Start-Process -FilePath $emulatorPath -ArgumentList '/NoUI /NoExplorer /AllowNet
$maxAttempts = 60
for ($attempt = 1; $attempt -le $maxAttempts; $attempt++) {
try {
$response = Invoke-WebRequest -Uri 'https://127.0.0.1:8081/' -SkipCertificateCheck -Method Get -TimeoutSec 5
# An unauthenticated request to the emulator root returns 401 once it is up.
# PowerShell 7 throws for 4xx responses unless -SkipHttpErrorCheck is set.
$response = Invoke-WebRequest -Uri 'https://127.0.0.1:8081/' -SkipCertificateCheck -SkipHttpErrorCheck -Method Get -TimeoutSec 5
if ($response.StatusCode -in 200, 401) {
Write-Host 'Cosmos DB Emulator is ready on Windows.'
Write-Host "Cosmos DB Emulator is ready on Windows (status: $($response.StatusCode))."
exit 0
}

Write-Host "Cosmos DB Emulator returned status $($response.StatusCode) (attempt $attempt/$maxAttempts)."
}
catch {
Write-Host "Cosmos DB Emulator is not ready yet (attempt $attempt/$maxAttempts)."
Expand All @@ -17,4 +21,5 @@ for ($attempt = 1; $attempt -le $maxAttempts; $attempt++) {
Start-Sleep -Seconds 5
}

throw 'Cosmos DB Emulator failed to become ready on Windows.'
# Warn instead of failing: the Windows job runs the build-only target, so it does not need the emulator.
Write-Warning 'Cosmos DB Emulator failed to become ready on Windows. Continuing because Windows job runs build-only target.'
93 changes: 84 additions & 9 deletions src/Cosmos/Cosmos.fs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,48 @@ open System.Threading.Tasks
open FSharp.Control
open Microsoft.Azure.Cosmos

/// <summary>
/// Helpers for validating Cosmos DB item field names used in dynamically constructed queries.
/// </summary>
module CosmosName =

let private isAsciiLetter c = ('a' <= c && c <= 'z') || ('A' <= c && c <= 'Z')
let private isAsciiDigit c = '0' <= c && c <= '9'

/// <summary>
/// Validates that <paramref name="fieldName"/> is a syntactically valid Cosmos DB item field name:
/// non-null, non-empty, starting with a letter or underscore, and containing only letters, digits,
/// or underscores.
/// </summary>
/// <param name="paramName">Name of the caller's parameter to report in a thrown exception.</param>
/// <param name="fieldName">Field name to validate.</param>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="fieldName"/> is <c>null</c>.</exception>
/// <exception cref="ArgumentException">
/// Thrown when <paramref name="fieldName"/> does not start with a letter or underscore,
/// or contains characters other than letters, digits, or underscores.
/// </exception>
[<CompiledName "ValidateField">]
let validateField (paramName : string) (fieldName : string) =
if obj.ReferenceEquals (fieldName, null) then
nullArg paramName

let isValidFieldName =
if String.IsNullOrWhiteSpace fieldName then
false
else
let firstCharacter = fieldName[0]
let hasValidStart = firstCharacter = '_' || isAsciiLetter firstCharacter
let hasValidBody =
fieldName
|> Seq.forall (fun c -> c = '_' || isAsciiLetter c || isAsciiDigit c)

hasValidStart && hasValidBody

if not isValidFieldName then
invalidArg
paramName
"Field name must start with a letter or underscore and contain only letters, digits, or underscores."

module internal RequestOptions =

let internal createOrUpdate setter requestOptions =
Expand Down Expand Up @@ -70,13 +112,22 @@ module Operations =

type ItemRequestOptions with

/// <summary>
/// Adds a pre-trigger to request options.
/// </summary>
/// <param name="trigger">Trigger name.</param>
member options.AddPreTrigger (trigger : string) =
options.PreTriggers <- [|
if not <| isNull options.PreTriggers then
yield! options.PreTriggers
yield trigger
|]

/// <summary>
/// Adds pre-triggers to request options.
/// </summary>
/// <param name="triggers">Trigger names.</param>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="triggers"/> is <c>null</c>.</exception>
member options.AddPreTriggers (triggers : string seq) =
if obj.ReferenceEquals (triggers, null) then
raise (ArgumentNullException (nameof triggers))
Expand All @@ -93,10 +144,19 @@ module Operations =
yield trigger
|]

/// <summary>
/// Adds post-triggers to request options.
/// </summary>
/// <param name="triggers">Trigger names.</param>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="triggers"/> is <c>null</c>.</exception>
member options.AddPostTriggers (triggers : string seq) =
if obj.ReferenceEquals (triggers, null) then
raise (ArgumentNullException (nameof triggers))
options.PostTriggers <- [| yield! options.PostTriggers; yield! triggers |]
options.PostTriggers <- [|
if not <| isNull options.PostTriggers then
yield! options.PostTriggers
yield! triggers
|]

let internal countQuery = QueryDefinition ("SELECT VALUE COUNT(1) FROM c")
let internal existsQuery = QueryDefinition ("SELECT VALUE COUNT(1) FROM item WHERE item.id = @Id")
Expand Down Expand Up @@ -209,27 +269,42 @@ module Operations =
container.ExistsAsync (id, QueryRequestOptions (PartitionKey = partitionKey), cancellationToken)

/// <summary>
/// Checks if an item with specified Id exists in the container partition with specified key.
/// Checks whether an item with the specified Id exists and is not marked as deleted.
/// <para>
/// The item is treated as not deleted when the <paramref name="deletedFieldName"/> field is absent,
/// <c>null</c>, or <c>false</c>. Any other value, such as <c>true</c> or a deletion timestamp,
/// marks the item as deleted.
/// </para>
/// </summary>
/// <param name="deletedFieldName">Name of the item field that marks the item as deleted.</param>
/// <param name="id">Item Id</param>
/// <param name="partitionKey">Partition key</param>
/// <param name="requestOptions">Query request options, for example to scope the query to a partition key.</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns><c>true</c> when the item exists and is not marked as deleted; otherwise <c>false</c>.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="deletedFieldName"/> is <c>null</c>.</exception>
/// <exception cref="ArgumentException">
/// Thrown when <paramref name="deletedFieldName"/> does not start with a letter or underscore,
/// or contains characters other than letters, digits, or underscores.
/// </exception>
member container.IsNotDeletedAsync
Comment thread
xperiandri marked this conversation as resolved.
deletedFieldName
(id : string, [<Optional>] requiestOptions : QueryRequestOptions, [<Optional>] cancellationToken : CancellationToken)
(deletedFieldName : string)
(id : string, [<Optional>] requestOptions : QueryRequestOptions, [<Optional>] cancellationToken : CancellationToken)
=
CosmosName.validateField (nameof deletedFieldName) deletedFieldName

task {
let query =
QueryDefinition(
$"SELECT VALUE COUNT(1) \
FROM item \
WHERE item.id = @Id AND IS_NULL(item.{deletedFieldName})"
$"""SELECT VALUE COUNT(1)
FROM item
WHERE item.id = @Id
AND (NOT IS_DEFINED(item.{deletedFieldName}) OR IS_NULL(item.{deletedFieldName}) OR item.{deletedFieldName} = false)"""
)
.WithParameter ("@Id", id)
let! count =
container.GetItemQueryIterator<int> (
query,
requestOptions = getRequestOptionsWithMaxItemCount1 requiestOptions
requestOptions = getRequestOptionsWithMaxItemCount1 requestOptions
)
|> CancellableTaskSeq.ofFeedIterator cancellationToken
|> TaskSeq.tryHead
Expand Down
67 changes: 57 additions & 10 deletions src/Cosmos/IterationExtensions.fs
Original file line number Diff line number Diff line change
@@ -1,10 +1,63 @@
namespace Microsoft.Azure.Cosmos
namespace Microsoft.Azure.Cosmos

open System.Collections.Generic
open System.Runtime.CompilerServices
open System.Runtime.InteropServices
open System.Threading
open System.Threading.Tasks
open Microsoft.Azure.Cosmos
open FSharp.Control

/// Enumerates the items of every page of a <see cref="FeedIterator{T}" />.
/// <remarks>
/// Implemented by hand rather than with a <c>taskSeq { }</c> computation expression: <c>taskSeq</c> has no
/// dynamic implementation, so it throws <c>NotImplementedException</c> whenever the compiler does not turn it
/// into a static state machine, which is the case for assemblies built without optimizations (Debug).
/// </remarks>
[<Sealed>]
type internal FeedIteratorAsyncEnumerator<'T> (iterator : FeedIterator<'T>, cancellationToken : CancellationToken) =

let mutable page : IEnumerator<'T> voption = ValueNone
let mutable current = Unchecked.defaultof<'T>

let disposePage () =
page |> ValueOption.iter _.Dispose()
page <- ValueNone

interface IAsyncEnumerator<'T> with

member _.Current = current

member _.MoveNextAsync () =
let moveNext = task {
let mutable found = false
let mutable exhausted = false

while not (found || exhausted) do
match page with
| ValueSome items when items.MoveNext () ->
cancellationToken.ThrowIfCancellationRequested ()
current <- items.Current
found <- true
| _ when iterator.HasMoreResults ->
disposePage ()
let! response = iterator.ReadNextAsync cancellationToken
page <- ValueSome (response.GetEnumerator ())
| _ -> exhausted <- true

return found
}

ValueTask<bool> (moveNext)

member _.DisposeAsync () =
disposePage ()
ValueTask.CompletedTask

[<Sealed>]
type internal FeedIteratorAsyncEnumerable<'T> (iterator : FeedIterator<'T>, cancellationToken : CancellationToken) =

interface IAsyncEnumerable<'T> with
member _.GetAsyncEnumerator (_ : CancellationToken) = new FeedIteratorAsyncEnumerator<'T> (iterator, cancellationToken)

[<AutoOpen>]
module FeedIteratorExtensions =
Expand All @@ -13,14 +66,8 @@ module FeedIteratorExtensions =
type FeedIterator<'T> with

/// Converts the iterator to an async sequence of items.
member iterator.AsAsyncEnumerable<'T> ([<Optional; EnumeratorCancellation>] cancellationToken : CancellationToken) = taskSeq {
while iterator.HasMoreResults do
let! page = iterator.ReadNextAsync (cancellationToken)

for item in page do
cancellationToken.ThrowIfCancellationRequested ()
yield item
}
member iterator.AsAsyncEnumerable<'T> ([<Optional; EnumeratorCancellation>] cancellationToken : CancellationToken) =
FeedIteratorAsyncEnumerable (iterator, cancellationToken) :> IAsyncEnumerable<_>

open System.Linq
open Microsoft.Azure.Cosmos
Expand Down
33 changes: 20 additions & 13 deletions src/Cosmos/Read.fs
Original file line number Diff line number Diff line change
Expand Up @@ -145,19 +145,26 @@ type Microsoft.Azure.Cosmos.Container with
/// </summary>
/// <param name="operation">Read operation</param>
/// <param name="cancellationToken">Cancellation token</param>
member container.ExecuteAsync<'T> (operation : ReadOperation<'T>, [<Optional>] cancellationToken : CancellationToken) =
let successFn result : ReadResult<'T> =
if Object.Equals (result, Unchecked.defaultof<'T>) then
ReadResult.NotModified
else
ReadResult.Ok result

container.ExecuteAsync<'T, ReadResult<'T>> (
operation,
successFn,
toReadResult ReadResult.IncompatibleConsistencyLevel ReadResult.NotFound,
cancellationToken
)
member container.ExecuteAsync<'T>
(operation : ReadOperation<'T>, [<Optional>] cancellationToken : CancellationToken)
: Task<CosmosResponse<ReadResult<'T>>>
=
task {
try
let! response = container.PlainExecuteAsync (operation, cancellationToken)

// A matching If-None-Match can come back as a successful 304 response...
if response.StatusCode = HttpStatusCode.NotModified then
return CosmosResponse.fromItemResponse (fun _ -> ReadResult.NotModified) response
else
return CosmosResponse.fromItemResponse ReadResult.Ok response
with
// ...or, depending on the SDK transport and emulator, as a thrown 304 CosmosException.
| CosmosException ex when ex.StatusCode = HttpStatusCode.NotModified ->
return CosmosResponse.fromException (fun _ -> ReadResult.NotModified) ex
| HandleException ex ->
return CosmosResponse.fromException (toReadResult ReadResult.IncompatibleConsistencyLevel ReadResult.NotFound) ex
}

/// <summary>
/// Executes a read operation and returns <see cref="CosmosResponse{FSharpValueOption{T}}"/>.
Expand Down
Loading
Loading