diff --git a/src/MongoDB.Driver/Core/IAsyncCursor.cs b/src/MongoDB.Driver/Core/IAsyncCursor.cs index 55a655f5b2a..cfd9963e3ba 100644 --- a/src/MongoDB.Driver/Core/IAsyncCursor.cs +++ b/src/MongoDB.Driver/Core/IAsyncCursor.cs @@ -360,6 +360,17 @@ public static class IAsyncCursorExtensions return new AsyncCursorEnumerableOneTimeAdapter(cursor, cancellationToken); } + /// + /// Wraps a cursor in an IAsyncEnumerable that can be enumerated one time. + /// + /// The type of the document. + /// The cursor. + /// An IAsyncEnumerable. + public static IAsyncEnumerable ToAsyncEnumerable(this IAsyncCursor cursor) + { + return new AsyncCursorEnumerableOneTimeAdapter(cursor, CancellationToken.None); + } + /// /// Returns a list containing all the documents returned by a cursor. /// diff --git a/src/MongoDB.Driver/Core/IAsyncCursorSource.cs b/src/MongoDB.Driver/Core/IAsyncCursorSource.cs index e6710c5ad49..25676130f32 100644 --- a/src/MongoDB.Driver/Core/IAsyncCursorSource.cs +++ b/src/MongoDB.Driver/Core/IAsyncCursorSource.cs @@ -336,6 +336,18 @@ public static class IAsyncCursorSourceExtensions return new AsyncCursorSourceEnumerableAdapter(source, cancellationToken); } + /// + /// Wraps a cursor source in an IAsyncEnumerable. Each time GetAsyncEnumerator is called a new enumerator is returned and a new cursor + /// is fetched from the cursor source on the first call to MoveNextAsync. + /// + /// The type of the document. + /// The source. + /// An IAsyncEnumerable. + public static IAsyncEnumerable ToAsyncEnumerable(this IAsyncCursorSource source) + { + return new AsyncCursorSourceEnumerableAdapter(source, CancellationToken.None); + } + /// /// Returns a list containing all the documents returned by the cursor returned by a cursor source. /// diff --git a/src/MongoDB.Driver/Core/Operations/AsyncCursorEnumerableOneTimeAdapter.cs b/src/MongoDB.Driver/Core/Operations/AsyncCursorEnumerableOneTimeAdapter.cs index 3303143eaf9..c5fe0cd8c25 100644 --- a/src/MongoDB.Driver/Core/Operations/AsyncCursorEnumerableOneTimeAdapter.cs +++ b/src/MongoDB.Driver/Core/Operations/AsyncCursorEnumerableOneTimeAdapter.cs @@ -21,7 +21,7 @@ namespace MongoDB.Driver.Core.Operations { - internal sealed class AsyncCursorEnumerableOneTimeAdapter : IEnumerable + internal sealed class AsyncCursorEnumerableOneTimeAdapter : IEnumerable, IAsyncEnumerable { private readonly CancellationToken _cancellationToken; private readonly IAsyncCursor _cursor; @@ -33,6 +33,16 @@ public AsyncCursorEnumerableOneTimeAdapter(IAsyncCursor cursor, Cance _cancellationToken = cancellationToken; } + public IAsyncEnumerator GetAsyncEnumerator(CancellationToken cancellationToken = default) + { + if (_hasBeenEnumerated) + { + throw new InvalidOperationException("An IAsyncCursor can only be enumerated once."); + } + _hasBeenEnumerated = true; + return new AsyncCursorEnumerator(_cursor, cancellationToken); + } + public IEnumerator GetEnumerator() { if (_hasBeenEnumerated) diff --git a/src/MongoDB.Driver/Core/Operations/AsyncCursorEnumerator.cs b/src/MongoDB.Driver/Core/Operations/AsyncCursorEnumerator.cs index 6778b38146a..744cb25b319 100644 --- a/src/MongoDB.Driver/Core/Operations/AsyncCursorEnumerator.cs +++ b/src/MongoDB.Driver/Core/Operations/AsyncCursorEnumerator.cs @@ -17,11 +17,12 @@ using System.Collections; using System.Collections.Generic; using System.Threading; +using System.Threading.Tasks; using MongoDB.Driver.Core.Misc; namespace MongoDB.Driver.Core.Operations { - internal class AsyncCursorEnumerator : IEnumerator + internal class AsyncCursorEnumerator : IEnumerator, IAsyncEnumerator { // private fields private IEnumerator _batchEnumerator; @@ -72,6 +73,15 @@ public void Dispose() } } + public ValueTask DisposeAsync() + { + // TODO: implement true async disposal (CSHARP-5630) + Dispose(); + + // TODO: convert to ValueTask.CompletedTask once we stop supporting older target frameworks + return default; // Equivalent to ValueTask.CompletedTask which is not available on older target frameworks. + } + public bool MoveNext() { ThrowIfDisposed(); @@ -82,24 +92,46 @@ public bool MoveNext() return true; } - while (true) + while (_cursor.MoveNext(_cancellationToken)) { - if (_cursor.MoveNext(_cancellationToken)) + _batchEnumerator?.Dispose(); + _batchEnumerator = _cursor.Current.GetEnumerator(); + if (_batchEnumerator.MoveNext()) { - _batchEnumerator?.Dispose(); - _batchEnumerator = _cursor.Current.GetEnumerator(); - if (_batchEnumerator.MoveNext()) - { - return true; - } + return true; } - else + } + + _batchEnumerator?.Dispose(); + _batchEnumerator = null; + _finished = true; + return false; + } + + public async ValueTask MoveNextAsync() + { + ThrowIfDisposed(); + _started = true; + + if (_batchEnumerator != null && _batchEnumerator.MoveNext()) + { + return true; + } + + while (await _cursor.MoveNextAsync(_cancellationToken).ConfigureAwait(false)) + { + _batchEnumerator?.Dispose(); + _batchEnumerator = _cursor.Current.GetEnumerator(); + if (_batchEnumerator.MoveNext()) { - _batchEnumerator = null; - _finished = true; - return false; + return true; } } + + _batchEnumerator?.Dispose(); + _batchEnumerator = null; + _finished = true; + return false; } public void Reset() diff --git a/src/MongoDB.Driver/Core/Operations/AsyncCursorSourceEnumerableAdapter.cs b/src/MongoDB.Driver/Core/Operations/AsyncCursorSourceEnumerableAdapter.cs index 9d175b1fde8..aa1aa6df500 100644 --- a/src/MongoDB.Driver/Core/Operations/AsyncCursorSourceEnumerableAdapter.cs +++ b/src/MongoDB.Driver/Core/Operations/AsyncCursorSourceEnumerableAdapter.cs @@ -13,7 +13,6 @@ * limitations under the License. */ -using System; using System.Collections; using System.Collections.Generic; using System.Threading; @@ -21,7 +20,7 @@ namespace MongoDB.Driver.Core.Operations { - internal class AsyncCursorSourceEnumerableAdapter : IEnumerable + internal class AsyncCursorSourceEnumerableAdapter : IEnumerable, IAsyncEnumerable { // private fields private readonly CancellationToken _cancellationToken; @@ -34,6 +33,11 @@ public AsyncCursorSourceEnumerableAdapter(IAsyncCursorSource source, _cancellationToken = cancellationToken; } + public IAsyncEnumerator GetAsyncEnumerator(CancellationToken cancellationToken = default) + { + return new AsyncCursorSourceEnumerator(_source, cancellationToken); + } + // public methods public IEnumerator GetEnumerator() { diff --git a/src/MongoDB.Driver/Core/Operations/AsyncCursorSourceEnumerator.cs b/src/MongoDB.Driver/Core/Operations/AsyncCursorSourceEnumerator.cs new file mode 100644 index 00000000000..18c5994a3b8 --- /dev/null +++ b/src/MongoDB.Driver/Core/Operations/AsyncCursorSourceEnumerator.cs @@ -0,0 +1,94 @@ +/* Copyright 2010-present MongoDB Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using MongoDB.Driver.Core.Misc; + +namespace MongoDB.Driver.Core.Operations +{ +#pragma warning disable CA1001 + internal class AsyncCursorSourceEnumerator : IAsyncEnumerator +#pragma warning restore CA1001 + { + private readonly CancellationToken _cancellationToken; + private AsyncCursorEnumerator _cursorEnumerator; + private readonly IAsyncCursorSource _cursorSource; + private bool _disposed; + + public AsyncCursorSourceEnumerator(IAsyncCursorSource cursorSource, CancellationToken cancellationToken) + { + _cursorSource = Ensure.IsNotNull(cursorSource, nameof(cursorSource)); + _cancellationToken = cancellationToken; + } + + public TDocument Current + { + get + { + if (_cursorEnumerator == null) + { + throw new InvalidOperationException("Enumeration has not started. Call MoveNextAsync."); + } + return _cursorEnumerator.Current; + } + } + + public async ValueTask DisposeAsync() + { + if (!_disposed) + { + _disposed = true; + + if (_cursorEnumerator != null) + { + await _cursorEnumerator.DisposeAsync().ConfigureAwait(false); + } + + GC.SuppressFinalize(this); + } + } + + public async ValueTask MoveNextAsync() + { + ThrowIfDisposed(); + + if (_cursorEnumerator == null) + { + var cursor = await _cursorSource.ToCursorAsync(_cancellationToken).ConfigureAwait(false); + _cursorEnumerator = new AsyncCursorEnumerator(cursor, _cancellationToken); + } + + return await _cursorEnumerator.MoveNextAsync().ConfigureAwait(false); + } + + public void Reset() + { + ThrowIfDisposed(); + throw new NotSupportedException(); + } + + // private methods + private void ThrowIfDisposed() + { + if (_disposed) + { + throw new ObjectDisposedException(GetType().Name); + } + } + } +} \ No newline at end of file diff --git a/src/MongoDB.Driver/Linq/MongoQueryable.cs b/src/MongoDB.Driver/Linq/MongoQueryable.cs index 70c385d4284..48cc283c65d 100644 --- a/src/MongoDB.Driver/Linq/MongoQueryable.cs +++ b/src/MongoDB.Driver/Linq/MongoQueryable.cs @@ -3385,6 +3385,18 @@ public static IQueryable Take(this IQueryable source, Expression.Constant(count))); } + /// + /// Returns an which can be enumerated asynchronously. + /// + /// The type of the elements of . + /// A sequence of values. + /// An IAsyncEnumerable for the query results. + public static IAsyncEnumerable ToAsyncEnumerable(this IQueryable source) + { + var cursorSource = GetCursorSource(source); + return cursorSource.ToAsyncEnumerable(); + } + /// /// Executes the LINQ query and returns a cursor to the results. /// diff --git a/tests/MongoDB.Driver.Tests/BulkWriteErrorTests.cs b/tests/MongoDB.Driver.Tests/BulkWriteErrorTests.cs index c51ee7cc5ec..1a90fd5d437 100644 --- a/tests/MongoDB.Driver.Tests/BulkWriteErrorTests.cs +++ b/tests/MongoDB.Driver.Tests/BulkWriteErrorTests.cs @@ -20,6 +20,7 @@ using System.Threading.Tasks; using FluentAssertions; using MongoDB.Bson; +using MongoDB.Driver.Core.Operations; using Xunit; namespace MongoDB.Driver.Tests @@ -34,7 +35,7 @@ public class BulkWriteErrorTests [InlineData(12582, ServerErrorCategory.DuplicateKey)] public void Should_translate_category_correctly(int code, ServerErrorCategory expectedCategory) { - var coreError = new Core.Operations.BulkWriteOperationError(0, code, "blah", new BsonDocument()); + var coreError = new BulkWriteOperationError(0, code, "blah", new BsonDocument()); var subject = BulkWriteError.FromCore(coreError); subject.Category.Should().Be(expectedCategory); diff --git a/tests/MongoDB.Driver.Tests/Core/IAsyncCursorExtensionsTests.cs b/tests/MongoDB.Driver.Tests/Core/IAsyncCursorExtensionsTests.cs index aff630021b5..e96e4c1a544 100644 --- a/tests/MongoDB.Driver.Tests/Core/IAsyncCursorExtensionsTests.cs +++ b/tests/MongoDB.Driver.Tests/Core/IAsyncCursorExtensionsTests.cs @@ -16,6 +16,8 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Threading; +using System.Threading.Tasks; using FluentAssertions; using MongoDB.Bson; using MongoDB.Bson.Serialization.Serializers; @@ -201,6 +203,55 @@ public void SingleOrDefault_should_throw_when_cursor_has_wrong_number_of_documen action.ShouldThrow(); } + [Fact] + public void ToAsyncEnumerable_result_should_only_be_enumerable_one_time() + { + var cursor = CreateCursor(2); + var enumerable = cursor.ToAsyncEnumerable(); + enumerable.GetAsyncEnumerator(); + + Action action = () => enumerable.GetAsyncEnumerator(); + + action.ShouldThrow(); + } + + [Fact] + public async Task ToAsyncEnumerable_should_respect_cancellation_token() + { + var source = CreateCursor(5); + using var cts = new CancellationTokenSource(); + + var count = 0; + await Assert.ThrowsAsync(async () => + { + await foreach (var doc in source.ToAsyncEnumerable().WithCancellation(cts.Token)) + { + count++; + if (count == 2) + cts.Cancel(); + } + }); + } + + [Fact] + public async Task ToAsyncEnumerable_should_return_expected_result() + { + var cursor = CreateCursor(2); + var expectedDocuments = new[] + { + new BsonDocument("_id", 0), + new BsonDocument("_id", 1) + }; + + var result = new List(); + await foreach (var doc in cursor.ToAsyncEnumerable()) + { + result.Add(doc); + } + + result.Should().Equal(expectedDocuments); + } + [Fact] public void ToEnumerable_result_should_only_be_enumerable_one_time() { diff --git a/tests/MongoDB.Driver.Tests/Core/IAsyncCursorSourceExtensionsTests.cs b/tests/MongoDB.Driver.Tests/Core/IAsyncCursorSourceExtensionsTests.cs index e2b8188ce43..dedd7cd59ae 100644 --- a/tests/MongoDB.Driver.Tests/Core/IAsyncCursorSourceExtensionsTests.cs +++ b/tests/MongoDB.Driver.Tests/Core/IAsyncCursorSourceExtensionsTests.cs @@ -203,6 +203,31 @@ public void SingleOrDefault_should_throw_when_cursor_has_wrong_number_of_documen action.ShouldThrow(); } + [Theory] + [ParameterAttributeData] + public async Task ToAsyncEnumerable_result_should_be_enumerable_multiple_times( + [Values(1, 2)] int times) + { + var source = CreateCursorSource(2); + var expectedDocuments = new[] + { + new BsonDocument("_id", 0), + new BsonDocument("_id", 1) + }; + + var result = new List(); + for (var i = 0; i < times; i++) + { + await foreach (var doc in source.ToAsyncEnumerable()) + { + result.Add(doc); + } + + result.Should().Equal(expectedDocuments); + result.Clear(); + } + } + [Theory] [ParameterAttributeData] public void ToEnumerable_result_should_be_enumerable_multiple_times( diff --git a/tests/MongoDB.Driver.Tests/Core/Operations/AsyncCursorEnumerableOneTimeAdapterTests.cs b/tests/MongoDB.Driver.Tests/Core/Operations/AsyncCursorEnumerableOneTimeAdapterTests.cs index c8438ee7c40..164a997e19e 100644 --- a/tests/MongoDB.Driver.Tests/Core/Operations/AsyncCursorEnumerableOneTimeAdapterTests.cs +++ b/tests/MongoDB.Driver.Tests/Core/Operations/AsyncCursorEnumerableOneTimeAdapterTests.cs @@ -15,6 +15,7 @@ using System; using System.Threading; +using System.Threading.Tasks; using FluentAssertions; using MongoDB.Bson; using Moq; @@ -32,6 +33,37 @@ public void constructor_should_throw_when_cursor_is_null() action.ShouldThrow().And.ParamName.Should().Be("cursor"); } + [Fact] + public async Task GetAsyncEnumerator_should_return_expected_result() + { + var mockCursor = new Mock>(); + mockCursor.SetupSequence(c => c.MoveNextAsync(CancellationToken.None)).ReturnsAsync(true).ReturnsAsync(false); + mockCursor.Setup(c => c.Current).Returns(new[] { new BsonDocument("_id", 0) }); + var subject = new AsyncCursorEnumerableOneTimeAdapter(mockCursor.Object, CancellationToken.None); + + var result = subject.GetAsyncEnumerator(); + + var result1 = await result.MoveNextAsync(); + result1.Should().BeTrue(); + + result.Current.Should().Be(new BsonDocument("_id", 0)); + + var result2 = await result.MoveNextAsync(); + result2.Should().BeFalse(); + } + + [Fact] + public void GetAsyncEnumerator_should_throw_when_called_more_than_once() + { + var mockCursor = new Mock>(); + var subject = new AsyncCursorEnumerableOneTimeAdapter(mockCursor.Object, CancellationToken.None); + subject.GetAsyncEnumerator(); + + Action action = () => subject.GetAsyncEnumerator(); + + action.ShouldThrow(); + } + [Fact] public void GetEnumerator_should_return_expected_result() { diff --git a/tests/MongoDB.Driver.Tests/Core/Operations/AsyncCursorEnumeratorTests.cs b/tests/MongoDB.Driver.Tests/Core/Operations/AsyncCursorEnumeratorTests.cs index e6cd9061a75..021a7946a28 100644 --- a/tests/MongoDB.Driver.Tests/Core/Operations/AsyncCursorEnumeratorTests.cs +++ b/tests/MongoDB.Driver.Tests/Core/Operations/AsyncCursorEnumeratorTests.cs @@ -16,11 +16,13 @@ using System; using System.Linq; using System.Threading; +using System.Threading.Tasks; using FluentAssertions; using MongoDB.Bson; using MongoDB.Bson.Serialization.Serializers; using MongoDB.Driver.Core.Bindings; using MongoDB.Driver.Core.WireProtocol.Messages.Encoders; +using MongoDB.TestHelpers.XunitExtensions; using Moq; using Xunit; @@ -105,29 +107,51 @@ public void Current_should_throw_when_subject_has_been_disposed() action.ShouldThrow(); } - [Fact] - public void Dispose_should_dispose_cursor() + [Theory] + [ParameterAttributeData] + public async Task Dispose_should_dispose_cursor( + [Values(false, true)] bool async) { var mockCursor = new Mock>(); var subject = new AsyncCursorEnumerator(mockCursor.Object, CancellationToken.None); - subject.Dispose(); + if (async) + { + await subject.DisposeAsync(); + } + else + { + subject.Dispose(); + } mockCursor.Verify(c => c.Dispose(), Times.Once); } - [Fact] - public void MoveNext_should_return_expected_result() + [Theory] + [ParameterAttributeData] + public async Task MoveNext_should_return_expected_result( + [Values(false, true)] bool async) { var subject = CreateSubject(2); - subject.MoveNext().Should().BeTrue(); - subject.MoveNext().Should().BeTrue(); - subject.MoveNext().Should().BeFalse(); + if (async) + { + (await subject.MoveNextAsync()).Should().BeTrue(); + (await subject.MoveNextAsync()).Should().BeTrue(); + (await subject.MoveNextAsync()).Should().BeFalse(); + } + else + { + subject.MoveNext().Should().BeTrue(); + subject.MoveNext().Should().BeTrue(); + subject.MoveNext().Should().BeFalse(); + } } - [Fact] - public void MoveNext_should_return_expected_result_when_there_are_two_batches() + [Theory] + [ParameterAttributeData] + public async Task MoveNext_should_return_expected_result_when_there_are_two_batches( + [Values(false, true)] bool async) { var mockCursor = new Mock>(); var firstBatch = new[] @@ -139,25 +163,61 @@ public void MoveNext_should_return_expected_result_when_there_are_two_batches() { new BsonDocument("_id", 2) }; - mockCursor.SetupSequence(c => c.MoveNext(CancellationToken.None)).Returns(true).Returns(true).Returns(false); + + if (async) + { + mockCursor.SetupSequence(c => c.MoveNextAsync(CancellationToken.None)) + .ReturnsAsync(true) + .ReturnsAsync(true) + .ReturnsAsync(false); + } + else + { + mockCursor.SetupSequence(c => c.MoveNext(CancellationToken.None)) + .Returns(true) + .Returns(true) + .Returns(false); + } + mockCursor.SetupSequence(c => c.Current).Returns(firstBatch).Returns(secondBatch); var subject = new AsyncCursorEnumerator(mockCursor.Object, CancellationToken.None); - subject.MoveNext().Should().BeTrue(); - subject.MoveNext().Should().BeTrue(); - subject.MoveNext().Should().BeTrue(); - subject.MoveNext().Should().BeFalse(); + if (async) + { + (await subject.MoveNextAsync()).Should().BeTrue(); + (await subject.MoveNextAsync()).Should().BeTrue(); + (await subject.MoveNextAsync()).Should().BeTrue(); + (await subject.MoveNextAsync()).Should().BeFalse(); + } + else + { + subject.MoveNext().Should().BeTrue(); + subject.MoveNext().Should().BeTrue(); + subject.MoveNext().Should().BeTrue(); + subject.MoveNext().Should().BeFalse(); + } } - [Fact] - public void MoveNext_should_throw_when_subject_has_been_disposed() + [Theory] + [ParameterAttributeData] + public async Task MoveNext_should_throw_when_subject_has_been_disposed( + [Values(false, true)] bool async) { var subject = CreateSubject(0); - subject.Dispose(); + if (async) + { + await subject.DisposeAsync(); - Action action = () => subject.MoveNext(); + Func action = async () => await subject.MoveNextAsync(); + action.ShouldThrow(); + } + else + { + subject.Dispose(); - action.ShouldThrow(); + Action action = () => subject.MoveNext(); + action.ShouldThrow(); + } } [Fact] diff --git a/tests/MongoDB.Driver.Tests/Core/Operations/AsyncCursorSourceEnumeratorTests.cs b/tests/MongoDB.Driver.Tests/Core/Operations/AsyncCursorSourceEnumeratorTests.cs new file mode 100644 index 00000000000..1170d8368c3 --- /dev/null +++ b/tests/MongoDB.Driver.Tests/Core/Operations/AsyncCursorSourceEnumeratorTests.cs @@ -0,0 +1,194 @@ +/* Copyright 2010-present MongoDB Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using MongoDB.Bson; +using MongoDB.Bson.Serialization.Serializers; +using MongoDB.Driver.Core.Bindings; +using MongoDB.Driver.Core.Operations; +using MongoDB.Driver.Core.WireProtocol.Messages.Encoders; +using Moq; +using Xunit; + +namespace MongoDB.Driver.Tests.Core.Operations +{ + public class AsyncCursorSourceEnumeratorTests + { + [Fact] + public void Constructor_should_throw_when_cursorSource_is_null() + { + var exception = Record.Exception(() => new AsyncCursorSourceEnumerator(null, CancellationToken.None)); + + exception.Should().BeOfType(); + } + + [Fact] + public void Current_should_throw_when_enumeration_has_not_started() + { + var source = CreateCursorSource(1); + var enumerator = new AsyncCursorSourceEnumerator(source, CancellationToken.None); + + var exception = Record.Exception(() => enumerator.Current); + + exception.Should().BeOfType() + .Which.Message.Should().Contain("Enumeration has not started"); + } + + [Fact] + public async Task Current_should_return_expected_document_after_MoveNextAsync() + { + var source = CreateCursorSource(1); + var enumerator = new AsyncCursorSourceEnumerator(source, CancellationToken.None); + var expectedDocument = new BsonDocument("_id", 0); + + await enumerator.MoveNextAsync(); + var result = enumerator.Current; + + result.Should().Be(expectedDocument); + } + + [Fact] + public async Task MoveNextAsync_should_execute_query_on_first_call() + { + var mockSource = new Mock>(); + var cursor = CreateCursor(1); + mockSource.Setup(s => s.ToCursorAsync(It.IsAny())) + .ReturnsAsync(cursor); + + var enumerator = new AsyncCursorSourceEnumerator(mockSource.Object, CancellationToken.None); + + // Query should not execute until first MoveNextAsync call + mockSource.Verify(s => s.ToCursorAsync(It.IsAny()), Times.Never); + + await enumerator.MoveNextAsync(); + + // Query should execute exactly once + mockSource.Verify(s => s.ToCursorAsync(It.IsAny()), Times.Once); + } + + [Fact] + public async Task MoveNextAsync_should_not_execute_query_on_subsequent_calls() + { + var mockSource = new Mock>(); + var cursor = CreateCursor(2); + mockSource.Setup(s => s.ToCursorAsync(It.IsAny())) + .ReturnsAsync(cursor); + + var enumerator = new AsyncCursorSourceEnumerator(mockSource.Object, CancellationToken.None); + + await enumerator.MoveNextAsync(); // First call + await enumerator.MoveNextAsync(); // Second call + + // Query should execute exactly once, not twice + mockSource.Verify(s => s.ToCursorAsync(It.IsAny()), Times.Once); + } + + [Fact] + public async Task MoveNextAsync_should_enumerate_all_documents() + { + var source = CreateCursorSource(3); + var enumerator = new AsyncCursorSourceEnumerator(source, CancellationToken.None); + var expectedDocuments = new[] + { + new BsonDocument("_id", 0), + new BsonDocument("_id", 1), + new BsonDocument("_id", 2) + }; + + var actualDocuments = new List(); + while (await enumerator.MoveNextAsync()) + { + actualDocuments.Add(enumerator.Current); + } + + actualDocuments.Should().Equal(expectedDocuments); + } + + [Fact] + public async Task MoveNextAsync_should_throw_when_disposed() + { + var source = CreateCursorSource(1); + var enumerator = new AsyncCursorSourceEnumerator(source, CancellationToken.None); + + await enumerator.DisposeAsync(); + + var exception = await Record.ExceptionAsync(async () => await enumerator.MoveNextAsync()); + + exception.Should().BeOfType(); + } + + [Fact] + public async Task MoveNextAsync_should_respect_cancellation_token() + { + var mockSource = new Mock>(); + var cts = new CancellationTokenSource(); + cts.Cancel(); // Cancel immediately + + mockSource.Setup(s => s.ToCursorAsync(It.IsAny())) + .ThrowsAsync(new OperationCanceledException()); + + var enumerator = new AsyncCursorSourceEnumerator(mockSource.Object, cts.Token); + + var exception = await Record.ExceptionAsync(async () => await enumerator.MoveNextAsync()); + + exception.Should().BeOfType(); + } + + [Fact] + public void Reset_should_throw_NotSupportedException() + { + var source = CreateCursorSource(1); + var enumerator = new AsyncCursorSourceEnumerator(source, CancellationToken.None); + + var exception = Record.Exception(() => enumerator.Reset()); + + exception.Should().BeOfType(); + } + + // Helper methods + private IAsyncCursor CreateCursor(int count) + { + var firstBatch = Enumerable.Range(0, count) + .Select(i => new BsonDocument("_id", i)) + .ToArray(); + + return new AsyncCursor( + channelSource: new Mock().Object, + collectionNamespace: new CollectionNamespace("test", "collection"), + comment: null, + firstBatch: firstBatch, + cursorId: 0, + batchSize: null, + limit: null, + serializer: BsonDocumentSerializer.Instance, + messageEncoderSettings: new MessageEncoderSettings(), + maxTime: null); + } + + private IAsyncCursorSource CreateCursorSource(int count) + { + var mockCursorSource = new Mock>(); + mockCursorSource.Setup(s => s.ToCursorAsync(It.IsAny())) + .ReturnsAsync(() => CreateCursor(count)); + + return mockCursorSource.Object; + } + } +} \ No newline at end of file diff --git a/tests/MongoDB.Driver.Tests/Linq/Linq3ImplementationWithLinq2Tests/MongoQueryableTests.cs b/tests/MongoDB.Driver.Tests/Linq/Linq3ImplementationWithLinq2Tests/MongoQueryableTests.cs index d68f43e1fe7..d5f527cc47b 100644 --- a/tests/MongoDB.Driver.Tests/Linq/Linq3ImplementationWithLinq2Tests/MongoQueryableTests.cs +++ b/tests/MongoDB.Driver.Tests/Linq/Linq3ImplementationWithLinq2Tests/MongoQueryableTests.cs @@ -19,7 +19,6 @@ using System.Threading.Tasks; using FluentAssertions; using MongoDB.Bson; -using MongoDB.Driver; using MongoDB.Driver.Core.Clusters; using MongoDB.Driver.Core.Misc; using MongoDB.Driver.Core.TestHelpers.XunitExtensions; @@ -78,6 +77,21 @@ public async Task AnyAsync_with_predicate() result.Should().BeTrue(); } + [Fact] + public async Task ToAsyncEnumerable() + { + var query = CreateQuery().Select(x => x.A); + var expectedResults = query.ToList(); + + var asyncResults = new List(); + await foreach (var item in query.ToAsyncEnumerable().ConfigureAwait(false)) + { + asyncResults.Add(item); + } + + asyncResults.Should().Equal(expectedResults); + } + [Fact] public void Average() {