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/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!; + } + +} 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"]); + } +}