From 28a22c3fef4976aa05cabc9b63263947dc4f17cf Mon Sep 17 00:00:00 2001 From: McNultyyy Date: Wed, 20 May 2026 14:47:27 +0100 Subject: [PATCH 1/2] fix: return documents in insertion order for queries without ORDER BY Queries without ORDER BY now return documents in insertion order, matching real Cosmos DB behavior. Previously documents were returned in hash-map order due to ConcurrentDictionary enumeration. Added insertion-order tracking list to InMemoryContainer that maintains document position across create, replace, upsert, and delete operations. GetAllItemsForPartition() now iterates this ordered list instead of the dictionary directly. Fixes #72 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 5 + .../InMemoryContainer.cs | 102 +++++++- src/Directory.Build.props | 2 +- .../FakeCosmosHandlerInsertionOrderTests.cs | 245 ++++++++++++++++++ 4 files changed, 345 insertions(+), 9 deletions(-) create mode 100644 tests/CosmosDB.InMemoryEmulator.Tests.Integration/FakeCosmosHandlerInsertionOrderTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 6da297f..cbd462a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,11 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [4.0.18] - 2026-05-15 + +### Fixed +- Queries without `ORDER BY` now return documents in insertion order, matching real Cosmos DB behavior (Issue #72). Previously documents were returned in hash-map order due to `ConcurrentDictionary` enumeration. Added insertion-order tracking to `InMemoryContainer` that maintains document position across create, replace, upsert, and delete operations. + ## [4.0.17] - 2026-05-14 ### Fixed diff --git a/src/CosmosDB.InMemoryEmulator/InMemoryContainer.cs b/src/CosmosDB.InMemoryEmulator/InMemoryContainer.cs index 07447d3..a3158f3 100644 --- a/src/CosmosDB.InMemoryEmulator/InMemoryContainer.cs +++ b/src/CosmosDB.InMemoryEmulator/InMemoryContainer.cs @@ -74,6 +74,11 @@ internal class InMemoryContainer : Container, IContainerTestSetup private readonly ConcurrentDictionary<(string Id, string PartitionKey), string> _items = new(); private readonly ConcurrentDictionary<(string Id, string PartitionKey), string> _etags = new(); private readonly ConcurrentDictionary<(string Id, string PartitionKey), DateTimeOffset> _timestamps = new(); + // Ref: Observed behavior on Windows Cosmos DB Emulator — documents return in + // insertion order when no ORDER BY is applied. Maintains creation-time ordering + // so GetAllItemsForPartition can enumerate deterministically. + private readonly List<(string Id, string PartitionKey)> _insertionOrder = new(); + private readonly object _insertionOrderLock = new(); private readonly List<(DateTimeOffset Timestamp, string Id, string PartitionKey, string Json, bool IsDelete)> _changeFeed = new(); private readonly object _changeFeedLock = new(); private long _changeFeedLsnCounter; @@ -357,6 +362,7 @@ public void ClearItems() _items.Clear(); _etags.Clear(); _timestamps.Clear(); + lock (_insertionOrderLock) { _insertionOrder.Clear(); } lock (_changeFeedLock) { _changeFeed.Clear(); } } @@ -402,9 +408,11 @@ public void LoadPersistedState() /// public string ExportState() { - var items = _items - .Where(kvp => !IsExpired(kvp.Key)) - .Select(kvp => JsonParseHelpers.ParseJson(kvp.Value)).ToList(); + List<(string Id, string PartitionKey)> orderedKeys; + lock (_insertionOrderLock) { orderedKeys = _insertionOrder.ToList(); } + var items = orderedKeys + .Where(key => _items.ContainsKey(key) && !IsExpired(key)) + .Select(key => JsonParseHelpers.ParseJson(_items[key])).ToList(); var state = new JObject { ["items"] = new JArray(items) }; return state.ToString(Formatting.Indented); } @@ -437,6 +445,7 @@ public void ImportState(string json) _etags[key] = importEtag; _timestamps[key] = DateTimeOffset.UtcNow; _items[key] = EnrichWithSystemProperties(itemJson, importEtag, _timestamps[key]); + lock (_insertionOrderLock) { _insertionOrder.Add(key); } } } @@ -489,6 +498,25 @@ public void RestoreToPointInTime(DateTimeOffset pointInTime) _etags.Clear(); _timestamps.Clear(); _itemLocks.Clear(); + lock (_insertionOrderLock) { _insertionOrder.Clear(); } + + // Rebuild insertion order from the change feed replay sequence. + // Track which keys were first created (not deleted) to preserve original insertion order. + var insertionOrderKeys = new List<(string Id, string PartitionKey)>(); + foreach (var entry in feedSnapshot) + { + if (entry.IsDelete && entry.Json.Contains("\"_ttlEviction\":true", StringComparison.Ordinal)) + continue; + var entryKey = (entry.Id, entry.PartitionKey); + if (entry.IsDelete) + { + insertionOrderKeys.Remove(entryKey); + } + else if (!insertionOrderKeys.Contains(entryKey)) + { + insertionOrderKeys.Add(entryKey); + } + } foreach (var kvp in lastPerKey) { @@ -500,6 +528,15 @@ public void RestoreToPointInTime(DateTimeOffset pointInTime) _etags[key] = etag; _timestamps[key] = pointInTime; } + + lock (_insertionOrderLock) + { + foreach (var key in insertionOrderKeys) + { + if (_items.ContainsKey(key)) + _insertionOrder.Add(key); + } + } } // ─── IndexingPolicy ─────────────────────────────────────────────────────── @@ -738,6 +775,7 @@ public override async Task> CreateItemAsync( } TrackBatchWrite(key); + lock (_insertionOrderLock) { _insertionOrder.Add(key); } var etag = GenerateETag(); _etags[key] = etag; _timestamps[key] = DateTimeOffset.UtcNow; @@ -773,6 +811,7 @@ public override async Task> CreateItemAsync( _items.TryRemove(key, out _); _etags.TryRemove(key, out _); _timestamps.TryRemove(key, out _); + lock (_insertionOrderLock) { _insertionOrder.Remove(key); } throw; } } @@ -867,6 +906,7 @@ public override async Task> UpsertItemAsync( } TrackBatchWrite(key); + if (!existed) { lock (_insertionOrderLock) { _insertionOrder.Add(key); } } try { @@ -893,6 +933,7 @@ public override async Task> UpsertItemAsync( _items.TryRemove(key, out _); _etags.TryRemove(key, out _); _timestamps.TryRemove(key, out _); + lock (_insertionOrderLock) { _insertionOrder.Remove(key); } } throw; } @@ -1045,6 +1086,7 @@ public override async Task> DeleteItemAsync( _items.TryRemove(key, out _); _etags.TryRemove(key, out _); _timestamps.TryRemove(key, out _); + lock (_insertionOrderLock) { _insertionOrder.Remove(key); } TrackBatchWrite(key); try @@ -1057,6 +1099,7 @@ public override async Task> DeleteItemAsync( _items[key] = existingJson; if (previousEtag is not null) _etags[key] = previousEtag; if (previousTimestamp.HasValue) _timestamps[key] = previousTimestamp.Value; + lock (_insertionOrderLock) { _insertionOrder.Add(key); } throw; } @@ -1236,6 +1279,7 @@ public override async Task CreateItemStreamAsync( } TrackBatchWrite(key); + lock (_insertionOrderLock) { _insertionOrder.Add(key); } var etag = GenerateETag(); _etags[key] = etag; _timestamps[key] = DateTimeOffset.UtcNow; @@ -1252,6 +1296,7 @@ public override async Task CreateItemStreamAsync( _items.TryRemove(key, out _); _etags.TryRemove(key, out _); _timestamps.TryRemove(key, out _); + lock (_insertionOrderLock) { _insertionOrder.Remove(key); } throw; } @@ -1360,6 +1405,7 @@ public override async Task UpsertItemStreamAsync( } TrackBatchWrite(key); + if (!existed) { lock (_insertionOrderLock) { _insertionOrder.Add(key); } } try { ExecutePostTriggers(requestOptions, JsonParseHelpers.ParseJson(enrichedJson), "Upsert"); @@ -1378,6 +1424,7 @@ public override async Task UpsertItemStreamAsync( _items.TryRemove(key, out _); _etags.TryRemove(key, out _); _timestamps.TryRemove(key, out _); + lock (_insertionOrderLock) { _insertionOrder.Remove(key); } } throw; } @@ -1525,6 +1572,7 @@ public override async Task DeleteItemStreamAsync( _items.TryRemove(key, out _); _etags.TryRemove(key, out _); _timestamps.TryRemove(key, out _); + lock (_insertionOrderLock) { _insertionOrder.Remove(key); } TrackBatchWrite(key); try @@ -1537,6 +1585,7 @@ public override async Task DeleteItemStreamAsync( _items[key] = existingJson; if (previousEtag is not null) _etags[key] = previousEtag; if (previousTimestamp.HasValue) _timestamps[key] = previousTimestamp.Value; + lock (_insertionOrderLock) { _insertionOrder.Add(key); } throw; } @@ -2364,6 +2413,7 @@ public override Task DeleteContainerAsync( _items.Clear(); _etags.Clear(); _timestamps.Clear(); + lock (_insertionOrderLock) { _insertionOrder.Clear(); } lock (_changeFeedLock) { _changeFeed.Clear(); } _storedProcedures.Clear(); _userDefinedFunctions.Clear(); @@ -2386,6 +2436,7 @@ public override Task DeleteContainerStreamAsync( _items.Clear(); _etags.Clear(); _timestamps.Clear(); + lock (_insertionOrderLock) { _insertionOrder.Clear(); } lock (_changeFeedLock) { _changeFeed.Clear(); } _storedProcedures.Clear(); _userDefinedFunctions.Clear(); @@ -2449,6 +2500,7 @@ public override Task DeleteAllItemsByPartitionKeyStreamAsync( _items.TryRemove(key, out _); _etags.TryRemove(key, out _); _timestamps.TryRemove(key, out _); + lock (_insertionOrderLock) { _insertionOrder.Remove(key); } RecordDeleteTombstone(key.Id, pk, partitionKey); } return Task.FromResult(CreateResponseMessage(HttpStatusCode.OK)); @@ -3408,6 +3460,7 @@ private void EvictIfExpired((string Id, string PartitionKey) key) _items.TryRemove(key, out _); _etags.TryRemove(key, out _); _timestamps.TryRemove(key, out _); + lock (_insertionOrderLock) { _insertionOrder.Remove(key); } // Record a delete tombstone in the change feed so consumers see TTL evictions RecordDeleteTombstone(key.Id, key.PartitionKey, isTtlEviction: true); @@ -3467,6 +3520,27 @@ internal void RestoreSnapshot( _items.TryRemove(key, out _); _etags.TryRemove(key, out _); _timestamps.TryRemove(key, out _); + lock (_insertionOrderLock) { _insertionOrder.Remove(key); } + } + } + + // Restore insertion order for keys that were newly added during the batch + // but didn't exist in the snapshot — they need to be removed from insertion order. + // Keys that existed in the snapshot should already be in insertion order. + lock (_insertionOrderLock) + { + foreach (var key in touchedKeys) + { + if (!itemsSnapshot.ContainsKey(key)) + { + // Already removed above + } + else if (!_insertionOrder.Contains(key)) + { + // Item existed in snapshot but is missing from insertion order + // (shouldn't normally happen, but be safe) + _insertionOrder.Add(key); + } } } @@ -4061,8 +4135,16 @@ private static void EnrichStoredProcedureSystemProperties(StoredProcedurePropert // Private helpers — Query execution pipeline // ═══════════════════════════════════════════════════════════════════════════ + // Ref: Observed behavior on the Windows Cosmos DB Emulator (priority 6): + // Documents returned by queries without ORDER BY are in insertion order. private IEnumerable GetAllItemsForPartition(QueryRequestOptions requestOptions) { + List<(string Id, string PartitionKey)> orderedKeys; + lock (_insertionOrderLock) + { + orderedKeys = _insertionOrder.ToList(); + } + if (requestOptions?.PartitionKey is not null && requestOptions.PartitionKey != PartitionKey.None) { @@ -4076,15 +4158,19 @@ private IEnumerable GetAllItemsForPartition(QueryRequestOptions requestO if (queryComponents > 0 && queryComponents < PartitionKeyPaths.Count) { var prefix = pk + "|"; - return _items - .Where(kvp => (kvp.Key.PartitionKey?.StartsWith(prefix, StringComparison.Ordinal) ?? false) && !IsExpired(kvp.Key)) - .Select(kvp => kvp.Value); + return orderedKeys + .Where(key => (key.PartitionKey?.StartsWith(prefix, StringComparison.Ordinal) ?? false) && !IsExpired(key) && _items.ContainsKey(key)) + .Select(key => _items[key]); } } - return _items.Where(kvp => kvp.Key.PartitionKey == pk && !IsExpired(kvp.Key)).Select(kvp => kvp.Value); + return orderedKeys + .Where(key => key.PartitionKey == pk && !IsExpired(key) && _items.ContainsKey(key)) + .Select(key => _items[key]); } - return _items.Where(kvp => !IsExpired(kvp.Key)).Select(kvp => kvp.Value); + return orderedKeys + .Where(key => !IsExpired(key) && _items.ContainsKey(key)) + .Select(key => _items[key]); } private static int CountPartitionKeyComponents(PartitionKey partitionKey) diff --git a/src/Directory.Build.props b/src/Directory.Build.props index fdf6b25..711e662 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -5,7 +5,7 @@ true - 4.0.17 + 4.0.18 lemonlion Copyright (c) 2026 lemonlion MIT diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Integration/FakeCosmosHandlerInsertionOrderTests.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Integration/FakeCosmosHandlerInsertionOrderTests.cs new file mode 100644 index 0000000..e931af5 --- /dev/null +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Integration/FakeCosmosHandlerInsertionOrderTests.cs @@ -0,0 +1,245 @@ +using AwesomeAssertions; +using Microsoft.Azure.Cosmos; +using Newtonsoft.Json; +using Xunit; + +namespace CosmosDB.InMemoryEmulator.Tests; + +/// +/// Tests that queries without an ORDER BY clause return documents in insertion order, +/// matching the observed behavior of real Cosmos DB and the Windows Cosmos Emulator. +/// +[Collection(IntegrationCollection.Name)] +public class FakeCosmosHandlerInsertionOrderTests(EmulatorSession session) : IAsyncLifetime +{ + private readonly ITestContainerFixture _fixture = TestFixtureFactory.Create(session); + private Container _container = null!; + + public async ValueTask InitializeAsync() + { + _container = await _fixture.CreateContainerAsync("test-insertion-order", "/partitionKey"); + } + + public async ValueTask DisposeAsync() + { + await _fixture.DisposeAsync(); + } + + private async Task> DrainQuery(string sql, string? partitionKey = null) + { + var options = partitionKey is not null + ? new QueryRequestOptions { PartitionKey = new PartitionKey(partitionKey) } + : null; + var iterator = _container.GetItemQueryIterator(sql, requestOptions: options); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + return results; + } + + private async Task> DrainQuery(QueryDefinition queryDef, string? partitionKey = null) + { + var options = partitionKey is not null + ? new QueryRequestOptions { PartitionKey = new PartitionKey(partitionKey) } + : null; + var iterator = _container.GetItemQueryIterator(queryDef, requestOptions: options); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + return results; + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Insertion order — queries without ORDER BY + // ═══════════════════════════════════════════════════════════════════════════ + + /// + /// Verifies that documents queried within a single partition are returned in + /// insertion order when no ORDER BY clause is specified. + /// Ref: Observed behavior on Windows Cosmos DB Emulator — documents return + /// in the order they were created when no ORDER BY is applied. + /// + [Fact] + public async Task Query_WithoutOrderBy_ReturnsSinglePartitionDocsInInsertionOrder() + { + // Arrange — insert 10 documents sequentially + var insertedIds = new List(); + for (var i = 1; i <= 10; i++) + { + var id = $"order-{i:D4}"; + insertedIds.Add(id); + await _container.CreateItemAsync( + new TestDocument { Id = id, PartitionKey = "pk-order", Name = $"Doc {i}", Value = i }, + new PartitionKey("pk-order")); + } + + // Act — query without ORDER BY + var results = await DrainQuery( + "SELECT * FROM c WHERE c.partitionKey = 'pk-order'", + "pk-order"); + + // Assert — returned IDs match insertion order + var returnedIds = results.Select(r => r.Id).ToList(); + returnedIds.Should().Equal(insertedIds); + } + + /// + /// Verifies that a cross-partition query without ORDER BY returns documents + /// in insertion order across all partitions. + /// + [Fact] + public async Task Query_WithoutOrderBy_ReturnsCrossPartitionDocsInInsertionOrder() + { + // Arrange — insert documents across two partitions, interleaved + var insertedIds = new List(); + for (var i = 1; i <= 8; i++) + { + var pk = i % 2 == 0 ? "pk-even" : "pk-odd"; + var id = $"cross-{i:D4}"; + insertedIds.Add(id); + await _container.CreateItemAsync( + new TestDocument { Id = id, PartitionKey = pk, Name = $"Doc {i}", Value = i }, + new PartitionKey(pk)); + } + + // Act — cross-partition query without ORDER BY + var results = await DrainQuery("SELECT * FROM c"); + + // Assert — returned IDs match insertion order + var returnedIds = results.Select(r => r.Id).ToList(); + returnedIds.Should().Equal(insertedIds); + } + + /// + /// Verifies that replacing a document does not change its position in the + /// insertion order (replace preserves original creation position). + /// + [Fact] + public async Task Query_AfterReplace_PreservesInsertionOrder() + { + // Arrange — insert 5 documents + var insertedIds = new List(); + for (var i = 1; i <= 5; i++) + { + var id = $"replace-{i:D4}"; + insertedIds.Add(id); + await _container.CreateItemAsync( + new TestDocument { Id = id, PartitionKey = "pk-replace", Name = $"Doc {i}", Value = i }, + new PartitionKey("pk-replace")); + } + + // Replace the 2nd document + await _container.ReplaceItemAsync( + new TestDocument { Id = "replace-0002", PartitionKey = "pk-replace", Name = "Updated", Value = 99 }, + "replace-0002", + new PartitionKey("pk-replace")); + + // Act + var results = await DrainQuery( + "SELECT * FROM c WHERE c.partitionKey = 'pk-replace'", + "pk-replace"); + + // Assert — order unchanged + var returnedIds = results.Select(r => r.Id).ToList(); + returnedIds.Should().Equal(insertedIds); + } + + /// + /// Verifies that deleting a document and re-creating it places the new + /// document at the end (new insertion position). + /// + [Fact] + public async Task Query_AfterDeleteAndRecreate_NewDocAppearsAtEnd() + { + // Arrange — insert 5 documents + for (var i = 1; i <= 5; i++) + { + await _container.CreateItemAsync( + new TestDocument { Id = $"delrec-{i:D4}", PartitionKey = "pk-delrec", Name = $"Doc {i}", Value = i }, + new PartitionKey("pk-delrec")); + } + + // Delete the 2nd document + await _container.DeleteItemAsync("delrec-0002", new PartitionKey("pk-delrec")); + + // Re-create with same ID + await _container.CreateItemAsync( + new TestDocument { Id = "delrec-0002", PartitionKey = "pk-delrec", Name = "Recreated", Value = 200 }, + new PartitionKey("pk-delrec")); + + // Act + var results = await DrainQuery( + "SELECT * FROM c WHERE c.partitionKey = 'pk-delrec'", + "pk-delrec"); + + // Assert — recreated doc is now at the end + var returnedIds = results.Select(r => r.Id).ToList(); + returnedIds.Should().Equal(["delrec-0001", "delrec-0003", "delrec-0004", "delrec-0005", "delrec-0002"]); + } + + /// + /// Verifies that upsert of a new document places it at the end of + /// the insertion order (same as create). + /// + [Fact] + public async Task Query_UpsertNewDoc_AppearsAtEnd() + { + // Arrange — insert 3 documents + for (var i = 1; i <= 3; i++) + { + await _container.CreateItemAsync( + new TestDocument { Id = $"upsert-{i:D4}", PartitionKey = "pk-upsert", Name = $"Doc {i}", Value = i }, + new PartitionKey("pk-upsert")); + } + + // Upsert a new document + await _container.UpsertItemAsync( + new TestDocument { Id = "upsert-0004", PartitionKey = "pk-upsert", Name = "New", Value = 4 }, + new PartitionKey("pk-upsert")); + + // Act + var results = await DrainQuery( + "SELECT * FROM c WHERE c.partitionKey = 'pk-upsert'", + "pk-upsert"); + + // Assert — upserted new doc at end + var returnedIds = results.Select(r => r.Id).ToList(); + returnedIds.Should().Equal(["upsert-0001", "upsert-0002", "upsert-0003", "upsert-0004"]); + } + + /// + /// Verifies that upsert of an existing document preserves its original + /// insertion position (same as replace). + /// + [Fact] + public async Task Query_UpsertExistingDoc_PreservesInsertionOrder() + { + // Arrange — insert 3 documents + for (var i = 1; i <= 3; i++) + { + await _container.CreateItemAsync( + new TestDocument { Id = $"upsert-ex-{i:D4}", PartitionKey = "pk-upsert-ex", Name = $"Doc {i}", Value = i }, + new PartitionKey("pk-upsert-ex")); + } + + // Upsert an existing document (update) + await _container.UpsertItemAsync( + new TestDocument { Id = "upsert-ex-0002", PartitionKey = "pk-upsert-ex", Name = "Updated", Value = 99 }, + new PartitionKey("pk-upsert-ex")); + + // Act + var results = await DrainQuery( + "SELECT * FROM c WHERE c.partitionKey = 'pk-upsert-ex'", + "pk-upsert-ex"); + + // Assert — order preserved + var returnedIds = results.Select(r => r.Id).ToList(); + returnedIds.Should().Equal(["upsert-ex-0001", "upsert-ex-0002", "upsert-ex-0003"]); + } +} From fbd7997b1c71211693473248bd3c2ad7aede4909 Mon Sep 17 00:00:00 2001 From: McNultyyy Date: Wed, 20 May 2026 14:56:45 +0100 Subject: [PATCH 2/2] Add hardening integration tests for insertion order behavior Comprehensive edge case tests covering: - Large batch (55 docs) insertion order - Multi-partition interleaved insertion order - Repeated replaces preserving position - Mixed operations sequence (create/delete/replace/upsert) - SELECT projections maintaining order - WHERE filter preserving relative order - TOP and OFFSET/LIMIT pagination order - Empty container and single document edge cases - Delete all + recreate ordering - Upsert-only creates ordering - DISTINCT VALUE preserving first occurrence order - COUNT and SUM aggregates not crashing - Cross-partition vs single-partition order - Rapid sequential creates order - ORDER BY overriding insertion order - Range filter preserving relative order - Multiple non-consecutive deletes preserving remaining order Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...smosHandlerInsertionOrderHardeningTests.cs | 718 ++++++++++++++++++ 1 file changed, 718 insertions(+) create mode 100644 tests/CosmosDB.InMemoryEmulator.Tests.Integration/FakeCosmosHandlerInsertionOrderHardeningTests.cs diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Integration/FakeCosmosHandlerInsertionOrderHardeningTests.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Integration/FakeCosmosHandlerInsertionOrderHardeningTests.cs new file mode 100644 index 0000000..042f90e --- /dev/null +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Integration/FakeCosmosHandlerInsertionOrderHardeningTests.cs @@ -0,0 +1,718 @@ +using AwesomeAssertions; +using Microsoft.Azure.Cosmos; +using Newtonsoft.Json; +using Xunit; + +namespace CosmosDB.InMemoryEmulator.Tests; + +/// +/// Hardening tests for insertion order behavior in queries without ORDER BY. +/// Covers edge cases: large batches, multi-partition interleaving, repeated replaces, +/// mixed operation sequences, projections, filters, pagination, and more. +/// Ref: Observed behavior on Windows Cosmos DB Emulator — documents return +/// in the order they were created when no ORDER BY is applied. +/// +[Collection(IntegrationCollection.Name)] +public class FakeCosmosHandlerInsertionOrderHardeningTests(EmulatorSession session) : IAsyncLifetime +{ + private readonly ITestContainerFixture _fixture = TestFixtureFactory.Create(session); + private Container _container = null!; + + public async ValueTask InitializeAsync() + { + _container = await _fixture.CreateContainerAsync("test-insertion-order-hardening", "/partitionKey"); + } + + public async ValueTask DisposeAsync() + { + await _fixture.DisposeAsync(); + } + + private async Task> DrainQuery(string sql, string? partitionKey = null) + { + var options = partitionKey is not null + ? new QueryRequestOptions { PartitionKey = new PartitionKey(partitionKey) } + : null; + var iterator = _container.GetItemQueryIterator(sql, requestOptions: options); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + return results; + } + + private async Task> DrainQuery(QueryDefinition queryDef, string? partitionKey = null) + { + var options = partitionKey is not null + ? new QueryRequestOptions { PartitionKey = new PartitionKey(partitionKey) } + : null; + var iterator = _container.GetItemQueryIterator(queryDef, requestOptions: options); + var results = new List(); + while (iterator.HasMoreResults) + { + var page = await iterator.ReadNextAsync(); + results.AddRange(page); + } + return results; + } + + // ═══════════════════════════════════════════════════════════════════════════ + // 1. Large batch insertion order + // ═══════════════════════════════════════════════════════════════════════════ + + /// + /// Inserting 50+ documents and querying without ORDER BY must return them + /// in exact insertion order. + /// + [Fact] + public async Task Query_LargeBatch_ReturnsDocsInInsertionOrder() + { + const string pk = "pk-large-batch"; + var insertedIds = new List(); + + for (var i = 1; i <= 55; i++) + { + var id = $"large-{i:D4}"; + insertedIds.Add(id); + await _container.CreateItemAsync( + new TestDocument { Id = id, PartitionKey = pk, Name = $"Doc {i}", Value = i }, + new PartitionKey(pk)); + } + + var results = await DrainQuery( + $"SELECT * FROM c WHERE c.partitionKey = '{pk}'", pk); + + results.Select(r => r.Id).Should().Equal(insertedIds); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // 2. Multiple partitions interleaved + // ═══════════════════════════════════════════════════════════════════════════ + + /// + /// Documents inserted across 3+ partitions in an interleaved pattern must + /// appear in global insertion order when queried cross-partition. + /// + [Fact] + public async Task Query_MultiplePKsInterleaved_ReturnsCrossPartitionInsertionOrder() + { + var partitions = new[] { "pk-interleave-a", "pk-interleave-b", "pk-interleave-c" }; + var insertedIds = new List(); + + for (var i = 1; i <= 15; i++) + { + var pk = partitions[(i - 1) % 3]; + var id = $"interleave-{i:D4}"; + insertedIds.Add(id); + await _container.CreateItemAsync( + new TestDocument { Id = id, PartitionKey = pk, Name = $"Doc {i}", Value = i }, + new PartitionKey(pk)); + } + + var results = await DrainQuery("SELECT * FROM c WHERE STARTSWITH(c.partitionKey, 'pk-interleave-')"); + + results.Select(r => r.Id).Should().Equal(insertedIds); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // 3. Replace multiple times + // ═══════════════════════════════════════════════════════════════════════════ + + /// + /// Replacing the same document 5+ times must never change its position in + /// the insertion order. + /// + [Fact] + public async Task Query_AfterMultipleReplacesOnSameDoc_PositionNeverChanges() + { + const string pk = "pk-multi-replace"; + var insertedIds = new List(); + + for (var i = 1; i <= 5; i++) + { + var id = $"mr-{i:D4}"; + insertedIds.Add(id); + await _container.CreateItemAsync( + new TestDocument { Id = id, PartitionKey = pk, Name = $"Doc {i}", Value = i }, + new PartitionKey(pk)); + } + + // Replace the 3rd document 6 times with different content each time + for (var r = 1; r <= 6; r++) + { + await _container.ReplaceItemAsync( + new TestDocument { Id = "mr-0003", PartitionKey = pk, Name = $"Replaced-{r}", Value = 100 + r }, + "mr-0003", + new PartitionKey(pk)); + } + + var results = await DrainQuery( + $"SELECT * FROM c WHERE c.partitionKey = '{pk}'", pk); + + results.Select(r => r.Id).Should().Equal(insertedIds); + // Verify the content was actually updated + results.Single(r => r.Id == "mr-0003").Name.Should().Be("Replaced-6"); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // 4. Mixed operations sequence + // ═══════════════════════════════════════════════════════════════════════════ + + /// + /// Complex mixed operation sequence: + /// create A, B, C, D, E → delete C → replace A → create F → upsert B (existing) → upsert G (new) + /// Expected order: A, B, D, E, F, G + /// + [Fact] + public async Task Query_MixedOperationSequence_ReturnsExpectedInsertionOrder() + { + const string pk = "pk-mixed-ops"; + + // Create A, B, C, D, E + await _container.CreateItemAsync( + new TestDocument { Id = "mixed-A", PartitionKey = pk, Name = "A", Value = 1 }, + new PartitionKey(pk)); + await _container.CreateItemAsync( + new TestDocument { Id = "mixed-B", PartitionKey = pk, Name = "B", Value = 2 }, + new PartitionKey(pk)); + await _container.CreateItemAsync( + new TestDocument { Id = "mixed-C", PartitionKey = pk, Name = "C", Value = 3 }, + new PartitionKey(pk)); + await _container.CreateItemAsync( + new TestDocument { Id = "mixed-D", PartitionKey = pk, Name = "D", Value = 4 }, + new PartitionKey(pk)); + await _container.CreateItemAsync( + new TestDocument { Id = "mixed-E", PartitionKey = pk, Name = "E", Value = 5 }, + new PartitionKey(pk)); + + // Delete C + await _container.DeleteItemAsync("mixed-C", new PartitionKey(pk)); + + // Replace A (should not change position) + await _container.ReplaceItemAsync( + new TestDocument { Id = "mixed-A", PartitionKey = pk, Name = "A-replaced", Value = 100 }, + "mixed-A", + new PartitionKey(pk)); + + // Create F (should appear at end) + await _container.CreateItemAsync( + new TestDocument { Id = "mixed-F", PartitionKey = pk, Name = "F", Value = 6 }, + new PartitionKey(pk)); + + // Upsert B (existing — should not change position) + await _container.UpsertItemAsync( + new TestDocument { Id = "mixed-B", PartitionKey = pk, Name = "B-upserted", Value = 200 }, + new PartitionKey(pk)); + + // Upsert G (new — should appear at end) + await _container.UpsertItemAsync( + new TestDocument { Id = "mixed-G", PartitionKey = pk, Name = "G", Value = 7 }, + new PartitionKey(pk)); + + var results = await DrainQuery( + $"SELECT * FROM c WHERE c.partitionKey = '{pk}'", pk); + + results.Select(r => r.Id).Should().Equal( + ["mixed-A", "mixed-B", "mixed-D", "mixed-E", "mixed-F", "mixed-G"]); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // 5. Query with SELECT specific fields (projection) + // ═══════════════════════════════════════════════════════════════════════════ + + /// + /// Projecting specific fields (SELECT c.id, c.name) must still maintain + /// insertion order. + /// + [Fact] + public async Task Query_WithProjection_MaintainsInsertionOrder() + { + const string pk = "pk-projection"; + var insertedIds = new List(); + + for (var i = 1; i <= 8; i++) + { + var id = $"proj-{i:D4}"; + insertedIds.Add(id); + await _container.CreateItemAsync( + new TestDocument { Id = id, PartitionKey = pk, Name = $"Name-{i}", Value = i }, + new PartitionKey(pk)); + } + + var results = await DrainQuery( + $"SELECT c.id, c.name FROM c WHERE c.partitionKey = '{pk}'", pk); + + results.Select(r => r.Id).Should().Equal(insertedIds); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // 6. Query with WHERE filter + // ═══════════════════════════════════════════════════════════════════════════ + + /// + /// Filtering with a WHERE clause must preserve relative insertion order of + /// the matched documents. + /// + [Fact] + public async Task Query_WithWhereFilter_MaintainsRelativeInsertionOrder() + { + const string pk = "pk-where-filter"; + + // Insert with alternating isActive values: true, false, true, false, true... + for (var i = 1; i <= 10; i++) + { + await _container.CreateItemAsync( + new TestDocument + { + Id = $"filter-{i:D4}", + PartitionKey = pk, + Name = $"Doc {i}", + Value = i, + IsActive = i % 2 != 0 + }, + new PartitionKey(pk)); + } + + // Query only active documents + var results = await DrainQuery( + $"SELECT * FROM c WHERE c.partitionKey = '{pk}' AND c.isActive = true", pk); + + // Expect odd-numbered docs only, in original order + results.Select(r => r.Id).Should().Equal( + ["filter-0001", "filter-0003", "filter-0005", "filter-0007", "filter-0009"]); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // 7. Query with TOP/OFFSET pagination + // ═══════════════════════════════════════════════════════════════════════════ + + /// + /// Using TOP N returns the first N documents in insertion order. + /// + [Fact] + public async Task Query_WithTop_ReturnsFirstNInInsertionOrder() + { + const string pk = "pk-top"; + var insertedIds = new List(); + + for (var i = 1; i <= 20; i++) + { + var id = $"top-{i:D4}"; + insertedIds.Add(id); + await _container.CreateItemAsync( + new TestDocument { Id = id, PartitionKey = pk, Name = $"Doc {i}", Value = i }, + new PartitionKey(pk)); + } + + var results = await DrainQuery( + $"SELECT TOP 5 * FROM c WHERE c.partitionKey = '{pk}'", pk); + + results.Select(r => r.Id).Should().Equal(insertedIds.Take(5)); + } + + /// + /// Using OFFSET/LIMIT returns the correct page of documents in insertion order. + /// + [Fact] + public async Task Query_WithOffsetLimit_ReturnsCorrectPageInInsertionOrder() + { + const string pk = "pk-offset"; + var insertedIds = new List(); + + for (var i = 1; i <= 20; i++) + { + var id = $"offset-{i:D4}"; + insertedIds.Add(id); + await _container.CreateItemAsync( + new TestDocument { Id = id, PartitionKey = pk, Name = $"Doc {i}", Value = i }, + new PartitionKey(pk)); + } + + // Get page 2 (items 6-10) + var results = await DrainQuery( + $"SELECT * FROM c WHERE c.partitionKey = '{pk}' OFFSET 5 LIMIT 5", pk); + + results.Select(r => r.Id).Should().Equal(insertedIds.Skip(5).Take(5)); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // 8. Empty container query + // ═══════════════════════════════════════════════════════════════════════════ + + /// + /// Querying an empty container must return an empty result without errors. + /// + [Fact] + public async Task Query_EmptyContainer_ReturnsEmptyResults() + { + var results = await DrainQuery( + "SELECT * FROM c WHERE c.partitionKey = 'pk-nonexistent'", "pk-nonexistent"); + + results.Should().BeEmpty(); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // 9. Single document + // ═══════════════════════════════════════════════════════════════════════════ + + /// + /// A container with a single document must return just that document. + /// + [Fact] + public async Task Query_SingleDocument_ReturnsThatDocument() + { + const string pk = "pk-single-doc"; + + await _container.CreateItemAsync( + new TestDocument { Id = "only-one", PartitionKey = pk, Name = "Solo", Value = 42 }, + new PartitionKey(pk)); + + var results = await DrainQuery( + $"SELECT * FROM c WHERE c.partitionKey = '{pk}'", pk); + + results.Should().HaveCount(1); + results[0].Id.Should().Be("only-one"); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // 10. Delete all then recreate + // ═══════════════════════════════════════════════════════════════════════════ + + /// + /// After deleting all documents and inserting new ones, the new documents + /// should appear in their own insertion order with no ghost entries. + /// + [Fact] + public async Task Query_DeleteAllThenRecreate_ShowsOnlyNewDocsInOrder() + { + const string pk = "pk-del-all-recreate"; + + // Insert A, B, C + await _container.CreateItemAsync( + new TestDocument { Id = "orig-A", PartitionKey = pk, Name = "A", Value = 1 }, + new PartitionKey(pk)); + await _container.CreateItemAsync( + new TestDocument { Id = "orig-B", PartitionKey = pk, Name = "B", Value = 2 }, + new PartitionKey(pk)); + await _container.CreateItemAsync( + new TestDocument { Id = "orig-C", PartitionKey = pk, Name = "C", Value = 3 }, + new PartitionKey(pk)); + + // Delete all + await _container.DeleteItemAsync("orig-A", new PartitionKey(pk)); + await _container.DeleteItemAsync("orig-B", new PartitionKey(pk)); + await _container.DeleteItemAsync("orig-C", new PartitionKey(pk)); + + // Insert D, E + await _container.CreateItemAsync( + new TestDocument { Id = "new-D", PartitionKey = pk, Name = "D", Value = 4 }, + new PartitionKey(pk)); + await _container.CreateItemAsync( + new TestDocument { Id = "new-E", PartitionKey = pk, Name = "E", Value = 5 }, + new PartitionKey(pk)); + + var results = await DrainQuery( + $"SELECT * FROM c WHERE c.partitionKey = '{pk}'", pk); + + results.Select(r => r.Id).Should().Equal(["new-D", "new-E"]); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // 11. Upsert-only to create multiple documents + // ═══════════════════════════════════════════════════════════════════════════ + + /// + /// Using only UpsertItemAsync to create new documents must produce results + /// in the order the upserts were called. + /// + [Fact] + public async Task Query_UpsertOnlyCreates_ReturnsInUpsertCallOrder() + { + const string pk = "pk-upsert-create"; + var insertedIds = new List(); + + for (var i = 1; i <= 10; i++) + { + var id = $"upsert-new-{i:D4}"; + insertedIds.Add(id); + await _container.UpsertItemAsync( + new TestDocument { Id = id, PartitionKey = pk, Name = $"Doc {i}", Value = i }, + new PartitionKey(pk)); + } + + var results = await DrainQuery( + $"SELECT * FROM c WHERE c.partitionKey = '{pk}'", pk); + + results.Select(r => r.Id).Should().Equal(insertedIds); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // 12. Query with DISTINCT + // ═══════════════════════════════════════════════════════════════════════════ + + /// + /// DISTINCT on a field with duplicates must preserve the insertion order of + /// the first occurrence of each distinct value. + /// + [Fact] + public async Task Query_WithDistinctValue_MaintainsFirstOccurrenceInsertionOrder() + { + const string pk = "pk-distinct"; + + // Insert documents with duplicate Value fields + // Values: 10, 20, 10, 30, 20, 40 + var docs = new[] + { + ("dist-01", 10), ("dist-02", 20), ("dist-03", 10), + ("dist-04", 30), ("dist-05", 20), ("dist-06", 40) + }; + foreach (var (id, value) in docs) + { + await _container.CreateItemAsync( + new TestDocument { Id = id, PartitionKey = pk, Name = $"Doc-{value}", Value = value }, + new PartitionKey(pk)); + } + + var results = await DrainQuery( + $"SELECT DISTINCT VALUE c[\"value\"] FROM c WHERE c.partitionKey = '{pk}'", pk); + + // First occurrences in insertion order: 10, 20, 30, 40 + results.Should().Equal([10, 20, 30, 40]); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // 13. Aggregate functions don't crash + // ═══════════════════════════════════════════════════════════════════════════ + + /// + /// COUNT aggregate should return the correct count without crashing. + /// + [Fact] + public async Task Query_CountAggregate_ReturnsCorrectCount() + { + const string pk = "pk-agg-count"; + + for (var i = 1; i <= 7; i++) + { + await _container.CreateItemAsync( + new TestDocument { Id = $"agg-{i:D4}", PartitionKey = pk, Name = $"Doc {i}", Value = i }, + new PartitionKey(pk)); + } + + var results = await DrainQuery( + $"SELECT VALUE COUNT(1) FROM c WHERE c.partitionKey = '{pk}'", pk); + + results.Should().HaveCount(1); + results[0].Should().Be(7); + } + + /// + /// SUM aggregate should return the correct sum without crashing. + /// + [Fact] + public async Task Query_SumAggregate_ReturnsCorrectSum() + { + const string pk = "pk-agg-sum"; + + for (var i = 1; i <= 5; i++) + { + await _container.CreateItemAsync( + new TestDocument { Id = $"sum-{i:D4}", PartitionKey = pk, Name = $"Doc {i}", Value = i * 10 }, + new PartitionKey(pk)); + } + + // SUM of 10+20+30+40+50 = 150 + var results = await DrainQuery( + $"SELECT VALUE SUM(c[\"value\"]) FROM c WHERE c.partitionKey = '{pk}'", pk); + + results.Should().HaveCount(1); + results[0].Should().Be(150); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // 14. Cross-partition: with vs without partition key filter + // ═══════════════════════════════════════════════════════════════════════════ + + /// + /// When querying within a single partition, insertion order within that + /// partition is preserved regardless of documents in other partitions. + /// + [Fact] + public async Task Query_SinglePartitionFilter_PreservesOrderWithinPartition() + { + var pkTarget = "pk-xpart-target"; + var pkOther = "pk-xpart-other"; + + // Interleave: target-1, other-1, target-2, other-2, target-3 + await _container.CreateItemAsync( + new TestDocument { Id = "xpart-t1", PartitionKey = pkTarget, Name = "T1", Value = 1 }, + new PartitionKey(pkTarget)); + await _container.CreateItemAsync( + new TestDocument { Id = "xpart-o1", PartitionKey = pkOther, Name = "O1", Value = 2 }, + new PartitionKey(pkOther)); + await _container.CreateItemAsync( + new TestDocument { Id = "xpart-t2", PartitionKey = pkTarget, Name = "T2", Value = 3 }, + new PartitionKey(pkTarget)); + await _container.CreateItemAsync( + new TestDocument { Id = "xpart-o2", PartitionKey = pkOther, Name = "O2", Value = 4 }, + new PartitionKey(pkOther)); + await _container.CreateItemAsync( + new TestDocument { Id = "xpart-t3", PartitionKey = pkTarget, Name = "T3", Value = 5 }, + new PartitionKey(pkTarget)); + + // Query with partition key filter — should give target docs in their insertion order + var filteredResults = await DrainQuery( + $"SELECT * FROM c WHERE c.partitionKey = '{pkTarget}'", pkTarget); + filteredResults.Select(r => r.Id).Should().Equal(["xpart-t1", "xpart-t2", "xpart-t3"]); + + // Cross-partition query — should give all docs in global insertion order + var allResults = await DrainQuery( + "SELECT * FROM c WHERE STARTSWITH(c.partitionKey, 'pk-xpart-')"); + allResults.Select(r => r.Id).Should().Equal( + ["xpart-t1", "xpart-o1", "xpart-t2", "xpart-o2", "xpart-t3"]); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // 15. Rapid sequential creates + // ═══════════════════════════════════════════════════════════════════════════ + + /// + /// Rapidly creating 20 documents sequentially (each awaited) must produce + /// results in the exact call order. + /// + [Fact] + public async Task Query_RapidSequentialCreates_MaintainsCallOrder() + { + const string pk = "pk-rapid"; + var insertedIds = new List(); + + for (var i = 1; i <= 20; i++) + { + var id = $"rapid-{i:D4}"; + insertedIds.Add(id); + await _container.CreateItemAsync( + new TestDocument { Id = id, PartitionKey = pk, Name = $"Rapid {i}", Value = i }, + new PartitionKey(pk)); + } + + var results = await DrainQuery( + $"SELECT * FROM c WHERE c.partitionKey = '{pk}'", pk); + + results.Select(r => r.Id).Should().Equal(insertedIds); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Additional hardening: ORDER BY overrides insertion order + // ═══════════════════════════════════════════════════════════════════════════ + + /// + /// A query with ORDER BY must return documents sorted by the specified field, + /// overriding the natural insertion order. + /// + [Fact] + public async Task Query_WithOrderBy_OverridesInsertionOrder() + { + const string pk = "pk-orderby"; + + // Insert in non-sorted order: values 50, 10, 40, 20, 30 + var values = new[] { 50, 10, 40, 20, 30 }; + for (var i = 0; i < values.Length; i++) + { + await _container.CreateItemAsync( + new TestDocument + { + Id = $"orderby-{i + 1:D4}", + PartitionKey = pk, + Name = $"Doc {values[i]}", + Value = values[i] + }, + new PartitionKey(pk)); + } + + var results = await DrainQuery( + $"SELECT * FROM c WHERE c.partitionKey = '{pk}' ORDER BY c[\"value\"] ASC", pk); + + results.Select(r => r.Value).Should().Equal([10, 20, 30, 40, 50]); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Additional hardening: WHERE + VALUE filter preserves relative order + // ═══════════════════════════════════════════════════════════════════════════ + + /// + /// A range filter (WHERE c.value > X) must preserve relative insertion order + /// of the matching documents. + /// + [Fact] + public async Task Query_WithRangeFilter_MaintainsRelativeInsertionOrder() + { + const string pk = "pk-range-filter"; + + // Insert values: 5, 15, 3, 25, 8, 30, 2 + var values = new[] { 5, 15, 3, 25, 8, 30, 2 }; + var expectedIdsAbove10 = new List(); + + for (var i = 0; i < values.Length; i++) + { + var id = $"range-{i + 1:D4}"; + if (values[i] > 10) expectedIdsAbove10.Add(id); + await _container.CreateItemAsync( + new TestDocument { Id = id, PartitionKey = pk, Name = $"Doc-{values[i]}", Value = values[i] }, + new PartitionKey(pk)); + } + + var results = await DrainQuery( + $"SELECT * FROM c WHERE c.partitionKey = '{pk}' AND c[\"value\"] > 10", pk); + + // Expected: range-0002 (15), range-0004 (25), range-0006 (30) — in insertion order + results.Select(r => r.Id).Should().Equal(expectedIdsAbove10); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Additional hardening: Delete from middle, verify remaining order + // ═══════════════════════════════════════════════════════════════════════════ + + /// + /// Deleting multiple non-consecutive documents must not disturb the relative + /// order of the remaining documents. + /// + [Fact] + public async Task Query_AfterMultipleNonConsecutiveDeletes_PreservesRemainingOrder() + { + const string pk = "pk-multi-del"; + + for (var i = 1; i <= 10; i++) + { + await _container.CreateItemAsync( + new TestDocument { Id = $"mdel-{i:D4}", PartitionKey = pk, Name = $"Doc {i}", Value = i }, + new PartitionKey(pk)); + } + + // Delete positions 2, 5, 8 (non-consecutive) + await _container.DeleteItemAsync("mdel-0002", new PartitionKey(pk)); + await _container.DeleteItemAsync("mdel-0005", new PartitionKey(pk)); + await _container.DeleteItemAsync("mdel-0008", new PartitionKey(pk)); + + var results = await DrainQuery( + $"SELECT * FROM c WHERE c.partitionKey = '{pk}'", pk); + + results.Select(r => r.Id).Should().Equal( + ["mdel-0001", "mdel-0003", "mdel-0004", "mdel-0006", "mdel-0007", "mdel-0009", "mdel-0010"]); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Projection helper models + // ═══════════════════════════════════════════════════════════════════════════ + + private class ProjectedDocument + { + [JsonProperty("id")] + public string Id { get; set; } = default!; + + [JsonProperty("name")] + public string Name { get; set; } = default!; + } + +}