diff --git a/src/Microsoft.OData.Client/DataServiceActionQuerySingleOfT.cs b/src/Microsoft.OData.Client/DataServiceActionQuerySingleOfT.cs index 2be1b743f6..578e7021c7 100644 --- a/src/Microsoft.OData.Client/DataServiceActionQuerySingleOfT.cs +++ b/src/Microsoft.OData.Client/DataServiceActionQuerySingleOfT.cs @@ -78,9 +78,12 @@ public Task GetValueAsync() /// Asynchronously sends the request so that this call does not block processing while waiting for the results from the service. /// A task represents the result of the operation. /// The token to monitor for cancellation requests. - public Task GetValueAsync(CancellationToken cancellationToken) + public async Task GetValueAsync(CancellationToken cancellationToken) { - return this.context.FromAsync(this.BeginGetValue, this.EndGetValue, cancellationToken); + IEnumerable result = await context.ExecuteAsync(this.RequestUri, XmlConstants.HttpMethodPost, true, cancellationToken, parameters) + .ConfigureAwait(false); + + return ClientTypeUtil.CanAssignNull(typeof(T)) ? result.SingleOrDefault() : result.Single(); } /// Called to complete the . diff --git a/src/Microsoft.OData.Client/DataServiceContext.cs b/src/Microsoft.OData.Client/DataServiceContext.cs index 211ee07636..67c6e3b615 100644 --- a/src/Microsoft.OData.Client/DataServiceContext.cs +++ b/src/Microsoft.OData.Client/DataServiceContext.cs @@ -1121,9 +1121,12 @@ public virtual Task LoadPropertyAsync(object entity, str /// The entity that contains the property to load. /// The name of the property on the specified entity to load. /// The token to monitor for cancellation requests. - public virtual Task LoadPropertyAsync(object entity, string propertyName, CancellationToken cancellationToken) + public virtual async Task LoadPropertyAsync(object entity, string propertyName, CancellationToken cancellationToken) { - return this.FromAsync(this.BeginLoadProperty, this.EndLoadProperty, entity, propertyName, cancellationToken); + LoadPropertyResult result = this.CreateLoadPropertyRequest(entity, propertyName, callback: null, state: null, requestUri: null, continuation: null); + await result.ExecuteQueryAsync(cancellationToken).ConfigureAwait(false); + + return result.LoadProperty(); } /// Asynchronously loads a page of related entities from the data service by using the supplied next link URI. @@ -1156,9 +1159,12 @@ public virtual Task LoadPropertyAsync(object entity, str /// The name of the property on the specified entity to load. /// The URI used to load the next results page. /// The token to monitor for cancellation requests. - public virtual Task LoadPropertyAsync(object entity, string propertyName, Uri nextLinkUri, CancellationToken cancellationToken) + public virtual async Task LoadPropertyAsync(object entity, string propertyName, Uri nextLinkUri, CancellationToken cancellationToken) { - return this.FromAsync(this.BeginLoadProperty, this.EndLoadProperty, entity, propertyName, nextLinkUri, cancellationToken); + LoadPropertyResult result = this.CreateLoadPropertyRequest(entity, propertyName, callback: null, state: null, nextLinkUri, continuation: null); + await result.ExecuteQueryAsync(cancellationToken).ConfigureAwait(false); + + return result.LoadProperty(); } /// Asynchronously loads the next page of related entities from the data service by using the supplied query continuation object. @@ -1192,9 +1198,13 @@ public virtual Task LoadPropertyAsync(object entity, str /// The name of the property on the specified entity to load. /// A object that represents the next page of related entity data to return from the data service. /// The token to monitor for cancellation requests. - public virtual Task LoadPropertyAsync(object entity, string propertyName, DataServiceQueryContinuation continuation, CancellationToken cancellationToken) + public virtual async Task LoadPropertyAsync(object entity, string propertyName, DataServiceQueryContinuation continuation, CancellationToken cancellationToken) { - return this.FromAsync(this.BeginLoadProperty, this.EndLoadProperty, entity, propertyName, continuation, cancellationToken); + Util.CheckArgumentNull(continuation, "continuation"); + LoadPropertyResult result = this.CreateLoadPropertyRequest(entity, propertyName, callback: null, state: null, requestUri: null, continuation); + await result.ExecuteQueryAsync(cancellationToken).ConfigureAwait(false); + + return result.LoadProperty(); } /// Called to complete the operation. @@ -1403,9 +1413,11 @@ public virtual Task GetReadStreamAsync(object entity, /// The token to monitor for cancellation requests. /// Any of the parameters supplied to the method is null. /// The is not tracked by this .-or-The is in the state.-or-The is not a Media Link Entry and does not have a related binary data stream. - public virtual Task GetReadStreamAsync(object entity, DataServiceRequestArgs args, CancellationToken cancellationToken) + public virtual async Task GetReadStreamAsync(object entity, DataServiceRequestArgs args, CancellationToken cancellationToken) { - return this.FromAsync(this.BeginGetReadStream, this.EndGetReadStream, entity, args, cancellationToken); + GetReadStreamResult result = this.CreateGetReadStreamResult(entity, args, callback: null, state: null, name: null); + + return await result.ExecuteAsync(cancellationToken).ConfigureAwait(false); } /// Asynchronously gets a named binary data stream that belongs to the specified entity, by using the specified message headers. @@ -1440,9 +1452,14 @@ public virtual Task GetReadStreamAsync(object entity, /// The name of the binary stream to request. /// Instance of the class that contains settings for the HTTP request message. /// The token to monitor for cancellation requests. - public virtual Task GetReadStreamAsync(object entity, string name, DataServiceRequestArgs args, CancellationToken cancellationToken) + public virtual async Task GetReadStreamAsync(object entity, string name, DataServiceRequestArgs args, CancellationToken cancellationToken) { - return this.FromAsync(this.BeginGetReadStream, this.EndGetReadStream, entity, name, args, cancellationToken); + Util.CheckArgumentNullAndEmpty(name, "name"); + + this.EnsureMinimumProtocolVersionV3(); + GetReadStreamResult result = this.CreateGetReadStreamResult(entity, args, callback: null, state: null, name); + + return await result.ExecuteAsync(cancellationToken).ConfigureAwait(false); } /// Called to complete the asynchronous operation of retrieving a binary data stream. @@ -1699,14 +1716,19 @@ public virtual Task ExecuteBatchAsync(SaveChangesOptions op /// A member of the enumeration for how the client can save the pending set of changes. /// The token to monitor for cancellation requests. /// The array of query requests to include in the batch request. - public virtual Task ExecuteBatchAsync(SaveChangesOptions options, CancellationToken cancellationToken, params DataServiceRequest[] queries) + public virtual async Task ExecuteBatchAsync(SaveChangesOptions options, CancellationToken cancellationToken, params DataServiceRequest[] queries) { + Util.CheckArgumentNotEmpty(queries, "queries"); + if (!Util.IsBatch(options)) { throw new InvalidOperationException(); } - return this.FromAsync((callback, state) => this.BeginExecuteBatch(callback, state, options, queries), this.EndExecuteBatch, cancellationToken); + BatchSaveResult result = new BatchSaveResult(this, "ExecuteBatch", queries, options, null, null); + await result.BatchRequestAsync(cancellationToken).ConfigureAwait(false); + + return result.EndRequest(); } /// Called to complete the . @@ -2133,9 +2155,31 @@ public virtual Task SaveChangesAsync(CancellationToken canc /// A task that represents a object that indicates the result of the batch operation. /// A member of the enumeration for how the client can save the pending set of changes. /// The token to monitor for cancellation requests. - public virtual Task SaveChangesAsync(SaveChangesOptions options, CancellationToken cancellationToken) + public virtual async Task SaveChangesAsync(SaveChangesOptions options, CancellationToken cancellationToken) { - return FromAsync(this.BeginSaveChanges, this.EndSaveChanges, options, cancellationToken); + DataServiceResponse errors = null; + this.ValidateSaveChangesOptions(options); + + BaseSaveResult result = BaseSaveResult.CreateSaveResult(this, Util.SaveChangesMethodName, null, options, null, null); + if (result.IsBatchRequest) + { + await ((BatchSaveResult)result).BatchRequestAsync(cancellationToken).ConfigureAwait(false); + } + else + { + await ((SaveResult)result).CreateNextChangeAsync(cancellationToken).ConfigureAwait(false); + } + + errors = result.EndRequest(); + + Debug.Assert(errors != null, "null errors"); + + if (this.ChangesSaved != null) + { + this.ChangesSaved(this, new SaveChangesEventArgs(errors)); + } + + return errors; } /// Called to complete the operation. @@ -2229,9 +2273,17 @@ public virtual Task BulkUpdateAsync(params T[] objects) /// The type of top-level objects to be deep updated. /// The top-level objects of the type to be deep updated. /// A task representing the that holds the result of a bulk operation. - public virtual Task BulkUpdateAsync(CancellationToken cancellationToken, params T[] objects) + public virtual async Task BulkUpdateAsync(CancellationToken cancellationToken, params T[] objects) { - return FromAsync((objectsArg, callback, state) => BeginBulkUpdate(callback, state, objectsArg), EndBulkUpdate, objects, cancellationToken); + if (objects == null || objects.Length == 0) + { + throw Error.Argument(SRResources.Util_EmptyArray, nameof(objects)); + } + + BulkUpdateSaveResult result = new BulkUpdateSaveResult(this, Util.BulkUpdateMethodName, SaveChangesOptions.BulkUpdate, null, null); + await result.BulkUpdateRequestAsync(cancellationToken, objects).ConfigureAwait(false); + + return result.EndRequest(); } /// Asynchronously submits top-level objects to be deep-updated to the data service. @@ -2299,9 +2351,17 @@ public virtual Task DeepInsertAsync(T resource) /// The type of top-level object to be deep inserted. /// The top-level object of the type to be deep inserted. /// A task representing the that holds the result of the deep insert operation. - public virtual Task DeepInsertAsync(T resource, CancellationToken cancellationToken) + public virtual async Task DeepInsertAsync(T resource, CancellationToken cancellationToken) { - return FromAsync((objectsArg, callback, state) => BeginDeepInsert(callback, state, objectsArg), EndDeepInsert, resource, cancellationToken); + if (resource == null) + { + throw Error.ArgumentNull(nameof(resource)); + } + + DeepInsertSaveResult result = new DeepInsertSaveResult(this, Util.DeepInsertMethodName, SaveChangesOptions.DeepInsert, callback: null, state: null); + await result.DeepInsertRequestAsync(resource, cancellationToken).ConfigureAwait(false); + + return result.EndRequest(); } /// Asynchronously submits top-level objects to be deep inserted to the data service. @@ -3140,14 +3200,18 @@ internal Task LoadPropertyAllPagesAsync(object entity, s /// The name of the property of the specified entity to load. /// The token to monitor for cancellation requests. /// An object representing an asynchronous operation resulting in an instance of that contains the results of the last page request. - [SuppressMessage("Reliability", "CA2008:Do not create tasks without passing a TaskScheduler", Justification = "")] - internal Task LoadPropertyAllPagesAsync(object entity, string propertyName, CancellationToken cancellationToken) + internal async Task LoadPropertyAllPagesAsync(object entity, string propertyName, CancellationToken cancellationToken) { - var currentTask = this.FromAsync(this.BeginLoadProperty, this.EndLoadProperty, entity, propertyName, cancellationToken); + QueryOperationResponse response = await LoadPropertyAsync(entity, propertyName, cancellationToken).ConfigureAwait(false); - return currentTask.ContinueWith( - t => ContinuePageAsync(t.Result, entity, propertyName, cancellationToken), - cancellationToken).Unwrap(); + DataServiceQueryContinuation continuation = response.GetContinuation(); + while (continuation != null) + { + response = await LoadPropertyAsync(entity, propertyName, continuation, cancellationToken).ConfigureAwait(false); + continuation = response.GetContinuation(); + } + + return response; } /// @@ -3633,40 +3697,6 @@ private QueryOperationResponse ContinuePage(QueryOperationResponse response, obj return response; } - [SuppressMessage("Reliability", "CA2008:Do not create tasks without passing a TaskScheduler", Justification = "")] - private Task ContinuePageAsync(QueryOperationResponse response, object entity, string propertyName, CancellationToken cancellationToken) - { - var continuation = response.GetContinuation(); - if (continuation != null) - { - IAsyncResult beginLoadPropertyResult = this.BeginLoadProperty(entity, propertyName, continuation, null, null); - - // Dispose the cancellation registration once the request completes so it is removed - // from the (potentially long-lived) token source, otherwise the captured async result - // is kept alive for every page, leaking memory (issue #3583). - CancellationTokenRegistration registration = cancellationToken.Register(() => this.CancelRequest(beginLoadPropertyResult)); - var currentTask = Task.Factory.FromAsync(beginLoadPropertyResult, this.EndLoadProperty); - - // Schedule the disposal/continuation with CancellationToken.None so it always runs - // even when the token is already canceled; otherwise the continuation could be - // skipped and the registration would never be disposed (and stay rooted in a - // long-lived token source). - return currentTask.ContinueWith( - t => - { - registration.Dispose(); - return this.ContinuePageAsync(t.Result, entity, propertyName, cancellationToken); - }, - CancellationToken.None, - TaskContinuationOptions.ExecuteSynchronously, - TaskScheduler.Default).Unwrap(); - } - - var taskSource = new TaskCompletionSource(); - taskSource.SetResult(response); - return taskSource.Task; - } - /// validate and trim leading and trailing forward slashes /// diff --git a/test/EndToEndTests/Tests/Client/Microsoft.OData.Client.E2E.Tests/CancellationTokenTests/Tests/CancellationTokenTests.cs b/test/EndToEndTests/Tests/Client/Microsoft.OData.Client.E2E.Tests/CancellationTokenTests/Tests/CancellationTokenTests.cs index 3df4255480..1bb43542bd 100644 --- a/test/EndToEndTests/Tests/Client/Microsoft.OData.Client.E2E.Tests/CancellationTokenTests/Tests/CancellationTokenTests.cs +++ b/test/EndToEndTests/Tests/Client/Microsoft.OData.Client.E2E.Tests/CancellationTokenTests/Tests/CancellationTokenTests.cs @@ -76,8 +76,8 @@ public async Task SaveChangesAsyncCancellationTokenTest() Task response() => _context.SaveChangesAsync(source.Token); source.Cancel(); - var exception = await Assert.ThrowsAsync(response); - Assert.Equal("The operation was canceled.", exception.Message); + var exception = await Assert.ThrowsAsync(response); + Assert.Equal("A task was canceled.", exception.Message); // SaveChangesAsync with SaveChangesOptions var c2 = new Customer { CustomerId = 22, Name = "customerTwo" }; @@ -87,8 +87,8 @@ public async Task SaveChangesAsyncCancellationTokenTest() Task response2() => _context.SaveChangesAsync(SaveChangesOptions.BatchWithIndependentOperations, source.Token); source.Cancel(); - var exception2 = await Assert.ThrowsAsync(response2); - Assert.Equal("The operation was canceled.", exception2.Message); + var exception2 = await Assert.ThrowsAsync(response2); + Assert.Equal("A task was canceled.", exception2.Message); } #endregion @@ -208,8 +208,8 @@ public async Task LoadPropertyAsyncCancellationTokenTest() Task response() => _context.LoadPropertyAsync(c1, "Orders", source.Token); source.Cancel(); - var exception = await Assert.ThrowsAsync(response); - Assert.Equal("The operation was canceled.", exception.Message); + var exception = await Assert.ThrowsAsync(response); + Assert.Equal("A task was canceled.", exception.Message); //Get Entity by DataServiceQuery.ExecuteAsync var query = _context.Customers.Expand(c => c.Orders).Where(c => c.CustomerId == 11) as DataServiceQuery; @@ -222,13 +222,13 @@ public async Task LoadPropertyAsyncCancellationTokenTest() var continuation = resp.GetContinuation(customer.Orders); Task response2() => _context.LoadPropertyAsync(customer, "Orders", continuation, source.Token); source.Cancel(); - var exception2 = await Assert.ThrowsAsync(response2); - Assert.Equal("The operation was canceled.", exception2.Message); + var exception2 = await Assert.ThrowsAsync(response2); + Assert.Equal("A task was canceled.", exception2.Message); Task response3() => _context.LoadPropertyAsync(customer, "Orders", continuation.NextLinkUri, source.Token); source.Cancel(); - var exception3 = await Assert.ThrowsAsync(response3); - Assert.Equal("The operation was canceled.", exception3.Message); + var exception3 = await Assert.ThrowsAsync(response3); + Assert.Equal("A task was canceled.", exception3.Message); } #endregion @@ -291,8 +291,8 @@ Task response() => _context.ExecuteBatchAsync( }); source.Cancel(); - var exception = await Assert.ThrowsAnyAsync(response); - Assert.Equal("The operation was canceled.", exception.Message); + var exception = await Assert.ThrowsAsync(response); + Assert.Equal("A task was canceled.", exception.Message); } #endregion diff --git a/test/FunctionalTests/Tests/DataServices/UnitTests/Client.TDD.Tests/Tests/BulkUpdateE2ETests.cs b/test/FunctionalTests/Tests/DataServices/UnitTests/Client.TDD.Tests/Tests/BulkUpdateE2ETests.cs index 554c20b970..96366c132d 100644 --- a/test/FunctionalTests/Tests/DataServices/UnitTests/Client.TDD.Tests/Tests/BulkUpdateE2ETests.cs +++ b/test/FunctionalTests/Tests/DataServices/UnitTests/Client.TDD.Tests/Tests/BulkUpdateE2ETests.cs @@ -1012,6 +1012,12 @@ public override IODataResponseMessage GetResponse() }); } + public override Task GetResponseAsync(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + return Task.FromResult(GetResponse()); + } + public override IAsyncResult BeginGetResponse(AsyncCallback callback, object state) { return GetCompletedTask(callback, state); diff --git a/test/FunctionalTests/Tests/DataServices/UnitTests/Client.TDD.Tests/Tests/DeepInsertE2ETests.cs b/test/FunctionalTests/Tests/DataServices/UnitTests/Client.TDD.Tests/Tests/DeepInsertE2ETests.cs index 9cdc837e7b..d91480e1cc 100644 --- a/test/FunctionalTests/Tests/DataServices/UnitTests/Client.TDD.Tests/Tests/DeepInsertE2ETests.cs +++ b/test/FunctionalTests/Tests/DataServices/UnitTests/Client.TDD.Tests/Tests/DeepInsertE2ETests.cs @@ -985,6 +985,12 @@ public override IODataResponseMessage GetResponse() }); } + public override Task GetResponseAsync(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + return Task.FromResult(GetResponse()); + } + public override IAsyncResult BeginGetResponse(AsyncCallback callback, object state) { return GetCompletedTask(callback, state); diff --git a/test/UnitTests/Microsoft.OData.Client.Tests/DataServiceActionQuerySingleTests.cs b/test/UnitTests/Microsoft.OData.Client.Tests/DataServiceActionQuerySingleTests.cs index 63f55c9156..f3ab53260e 100644 --- a/test/UnitTests/Microsoft.OData.Client.Tests/DataServiceActionQuerySingleTests.cs +++ b/test/UnitTests/Microsoft.OData.Client.Tests/DataServiceActionQuerySingleTests.cs @@ -308,18 +308,13 @@ public void EndGetValue_ThrowsInvalidOperationException_WhenMultipleResults() } [Fact] - public async Task GetValueAsync_UsesBeginEndPattern_ReturnsSingleValue() + public async Task GetValueAsync_UsesNativeAsyncPath_ReturnsSingleValue() { // Arrange - var asyncResult = new TestAsyncResult(); var context = new TestDataServiceContext(new Uri("http://service/")) { - BeginExecuteFunc = (uri, callback, state, method, single, parameters) => - { - callback?.Invoke(asyncResult); - return asyncResult; - }, - EndExecuteFunc = (ar) => new[] { 567 } + ExecuteAsyncFunc = (uri, method, single, cancellationToken, parameters) => + Task.FromResult>(new[] { 567 }) }; var query = new DataServiceActionQuerySingle( @@ -334,18 +329,13 @@ public async Task GetValueAsync_UsesBeginEndPattern_ReturnsSingleValue() } [Fact] - public async Task GetValueAsync_UsesBeginEndPattern_ThrowsInvalidOperationException_WhenNoResultsForNonNullableType() + public async Task GetValueAsync_UsesNativeAsyncPath_ThrowsInvalidOperationException_WhenNoResultsForNonNullableType() { // Arrange - var asyncResult = new TestAsyncResult(); var context = new TestDataServiceContext(new Uri("http://service/")) { - BeginExecuteFunc = (uri, callback, state, method, single, parameters) => - { - callback?.Invoke(asyncResult); - return asyncResult; - }, - EndExecuteFunc = (ar) => Enumerable.Empty() + ExecuteAsyncFunc = (uri, method, single, cancellationToken, parameters) => + Task.FromResult(Enumerable.Empty()) }; var query = new DataServiceActionQuerySingle( @@ -357,18 +347,13 @@ public async Task GetValueAsync_UsesBeginEndPattern_ThrowsInvalidOperationExcept } [Fact] - public async Task GetValueAsync_UsesBeginEndPattern_Throws_WhenMultipleResults() + public async Task GetValueAsync_UsesNativeAsyncPath_Throws_WhenMultipleResults() { // Arrange - var asyncResult = new TestAsyncResult(); var context = new TestDataServiceContext(new Uri("http://service/")) { - BeginExecuteFunc = (uri, callback, state, method, single, parameters) => - { - callback?.Invoke(asyncResult); - return asyncResult; - }, - EndExecuteFunc = (ar) => new[] { 1, 2 } + ExecuteAsyncFunc = (uri, method, single, cancellationToken, parameters) => + Task.FromResult>(new[] { 1, 2 }) }; var query = new DataServiceActionQuerySingle( @@ -379,24 +364,41 @@ public async Task GetValueAsync_UsesBeginEndPattern_Throws_WhenMultipleResults() await Assert.ThrowsAsync(() => query.GetValueAsync()); } + [Fact] + public async Task GetValueAsync_UsesNativeAsyncPath_ReturnsNullForNullableType() + { + // Arrange + var context = new TestDataServiceContext(new Uri("http://service/")) + { + ExecuteAsyncFunc = (uri, method, single, cancellationToken, parameters) => + Task.FromResult(Enumerable.Empty()) + }; + var query = new DataServiceActionQuerySingle(context, "http://service/Action"); + + // Act + int? result = await query.GetValueAsync(); + + // Assert + Assert.Null(result); + } + [Fact] public async Task GetValueAsync_WithCancellationToken_ReturnsExpectedResult() { // Arrange - var asyncResult = new TestAsyncResult(); + using var cancellationTokenSource = new CancellationTokenSource(); var context = new TestDataServiceContext(new Uri("http://service/")) { - BeginExecuteFunc = (uri, callback, state, method, single, parameters) => + ExecuteAsyncFunc = (uri, method, single, cancellationToken, parameters) => { - callback?.Invoke(asyncResult); - return asyncResult; - }, - EndExecuteFunc = (ar) => new[] { 888 } + Assert.Equal(cancellationTokenSource.Token, cancellationToken); + return Task.FromResult>(new[] { 888 }); + } }; var query = new DataServiceActionQuerySingle(context, "http://service/Action"); // Act - var result = await query.GetValueAsync(CancellationToken.None); + var result = await query.GetValueAsync(cancellationTokenSource.Token); // Assert Assert.Equal(888, result); @@ -480,6 +482,7 @@ public void EndGetValue_ThrowsIfAsyncResultIsNull() private class TestDataServiceContext : DataServiceContext { public Func> ExecuteFunc { get; set; } + public Func>> ExecuteAsyncFunc { get; set; } public Func BeginExecuteFunc { get; set; } public Func> EndExecuteFunc { get; set; } @@ -501,6 +504,23 @@ public override IAsyncResult BeginExecute(Uri requestUri, AsyncCallbac throw new NotImplementedException(); } + public override async Task> ExecuteAsync(Uri requestUri, string httpMethod, bool singleResult, CancellationToken cancellationToken, params OperationParameter[] operationParameters) + { + if (typeof(TElement) == typeof(T) && ExecuteAsyncFunc != null) + { + IEnumerable result = await ExecuteAsyncFunc( + requestUri, + httpMethod, + singleResult, + cancellationToken, + operationParameters.Cast().ToArray()); + + return (IEnumerable)result; + } + + throw new NotImplementedException(); + } + public override IEnumerable EndExecute(IAsyncResult asyncResult) { if (typeof(TElement) == typeof(T) && EndExecuteFunc != null) diff --git a/test/UnitTests/Microsoft.OData.Client.Tests/Serialization/AsyncWasmCompatibilityTests.cs b/test/UnitTests/Microsoft.OData.Client.Tests/Serialization/AsyncWasmCompatibilityTests.cs index 2dc96532fd..840c33efdc 100644 --- a/test/UnitTests/Microsoft.OData.Client.Tests/Serialization/AsyncWasmCompatibilityTests.cs +++ b/test/UnitTests/Microsoft.OData.Client.Tests/Serialization/AsyncWasmCompatibilityTests.cs @@ -21,8 +21,7 @@ namespace Microsoft.OData.Client.Tests.Serialization { /// /// Tests for the async-native WASM compatibility path that eliminates Task.Wait() blocking. - /// These tests verify that GetResponseAsync, ExecuteAsync, GetAllPagesAsync, and - /// EnumerateAllPagesAsync work correctly through the new async pipeline. + /// These tests verify that query, paging, stream, and save operations use the native async pipeline. /// public class AsyncWasmCompatibilityTests { @@ -37,11 +36,29 @@ public class AsyncWasmCompatibilityTests + + + + + + + + + + + + + + + + + + @@ -371,6 +388,225 @@ public async Task GetValueAsync_ReturnsEntity_WithAsyncPath() #endregion + #region Remaining Async API Tests + + [Fact] + public async Task SaveChangesAsync_UpdatesEntity_WithoutUsingApm() + { + var context = CreateContext(); + SetupRequestPipeline(context, string.Empty, 204, null); + var product = new Product { Id = 1, Name = "Updated" }; + context.AttachTo("Products", product); + context.UpdateObject(product); + bool changesSaved = false; + context.ChangesSaved += (_, _) => changesSaved = true; + + DataServiceResponse response = await context.SaveChangesAsync(); + + ChangeOperationResponse operationResponse = Assert.IsType(Assert.Single(response)); + Assert.Equal(204, operationResponse.StatusCode); + Assert.True(changesSaved); + } + + [Fact] + public async Task SaveChangesAsync_BatchWithPreCanceledToken_DoesNotUseApm() + { + var context = CreateContext(); + SetupRequestPipeline(context, string.Empty); + context.AddObject("Products", new Product { Id = 3, Name = "New" }); + using var cancellationTokenSource = new CancellationTokenSource(); + cancellationTokenSource.Cancel(); + + await Assert.ThrowsAnyAsync( + () => context.SaveChangesAsync(SaveChangesOptions.BatchWithSingleChangeset, cancellationTokenSource.Token)); + } + + [Fact] + public async Task ExecuteBatchAsync_WithPreCanceledToken_DoesNotUseApm() + { + var context = CreateContext(); + SetupRequestPipeline(context, string.Empty); + using var cancellationTokenSource = new CancellationTokenSource(); + cancellationTokenSource.Cancel(); + + await Assert.ThrowsAnyAsync( + () => context.ExecuteBatchAsync(cancellationTokenSource.Token, context.Products)); + } + + [Fact] + public async Task ExecuteBatchAsync_WithEmptyQueries_Throws() + { + var context = CreateContext(); + + await Assert.ThrowsAsync( + () => context.ExecuteBatchAsync(Array.Empty())); + } + + [Fact] + public async Task LoadPropertyAsync_LoadsNavigationProperty_WithoutUsingApm() + { + const string categoryResponse = @"{ + ""@odata.context"": ""http://localhost:9090/$metadata#Categories/$entity"", + ""Id"": 7, + ""Name"": ""Hardware"" +}"; + var context = CreateContext(); + SetupRequestPipeline(context, categoryResponse); + var product = new Product { Id = 1, Name = "Widget" }; + context.AttachTo("Products", product); + + QueryOperationResponse response = await context.LoadPropertyAsync(product, nameof(Product.Category)); + + Assert.NotNull(response); + Assert.NotNull(product.Category); + Assert.Equal(7, product.Category.Id); + } + + [Fact] + public async Task LoadPropertyAsync_WithNextLink_LoadsCollection_WithoutUsingApm() + { + const string categoriesResponse = @"{ + ""@odata.context"": ""http://localhost:9090/$metadata#Categories"", + ""value"": [ + { ""Id"": 7, ""Name"": ""Hardware"" } + ] +}"; + var context = CreateContext(); + SetupRequestPipeline(context, categoriesResponse); + var product = new Product { Id = 1, Name = "Widget", Categories = new List() }; + context.AttachTo("Products", product); + + QueryOperationResponse response = await context.LoadPropertyAsync( + product, + nameof(Product.Categories), + new Uri($"{ServiceRoot}/Products(1)/Categories?$skip=1")); + + Assert.NotNull(response); + Assert.Single(product.Categories); + } + + [Fact] + public async Task LoadPropertyAllPagesAsync_LoadsContinuations_WithoutUsingApm() + { + const string firstPageResponse = @"{ + ""@odata.context"": ""http://localhost:9090/$metadata#Categories"", + ""value"": [ + { ""Id"": 7, ""Name"": ""Hardware"" } + ], + ""@odata.nextLink"": ""http://localhost:9090/Products(1)/Categories?$skip=1"" +}"; + const string secondPageResponse = @"{ + ""@odata.context"": ""http://localhost:9090/$metadata#Categories"", + ""value"": [ + { ""Id"": 8, ""Name"": ""Software"" } + ] +}"; + int requestCount = 0; + var context = CreateContext(); + context.Configurations.RequestPipeline.OnMessageCreating = args => + new AsyncTestRequestMessage(args, ++requestCount == 1 ? firstPageResponse : secondPageResponse); + var product = new Product { Id = 1, Name = "Widget", Categories = new List() }; + context.AttachTo("Products", product); + + QueryOperationResponse response = await context.LoadPropertyAllPagesAsync( + product, + nameof(Product.Categories), + CancellationToken.None); + + Assert.NotNull(response); + Assert.Equal(2, product.Categories.Count); + Assert.Equal(2, requestCount); + } + + [Fact] + public async Task LoadPropertyAsync_WithNullContinuation_Throws() + { + var context = CreateContext(); + var product = new Product { Id = 1, Name = "Widget", Categories = new List() }; + context.AttachTo("Products", product); + + await Assert.ThrowsAsync( + () => context.LoadPropertyAsync( + product, + nameof(Product.Categories), + continuation: null, + CancellationToken.None)); + } + + [Fact] + public async Task GetReadStreamAsync_ReturnsStream_WithoutUsingApm() + { + byte[] payload = Encoding.UTF8.GetBytes("stream content"); + var context = CreateContext(); + context.Configurations.RequestPipeline.OnMessageCreating = args => + new AsyncTestRequestMessage(args, payload, 200, "application/octet-stream"); + var document = new Document { Id = 1 }; + context.AttachTo("Documents", document); + context.GetEntityDescriptor(document).ReadStreamUri = new Uri($"{ServiceRoot}/Documents(1)/$value"); + + using DataServiceStreamResponse response = await context.GetReadStreamAsync( + document, + new DataServiceRequestArgs()); + using var reader = new StreamReader(response.Stream); + + Assert.Equal("application/octet-stream", response.ContentType); + Assert.Equal("stream content", await reader.ReadToEndAsync()); + } + + [Fact] + public async Task GetReadStreamAsync_WithNamedStream_ReturnsStream_WithoutUsingApm() + { + byte[] payload = Encoding.UTF8.GetBytes("photo content"); + var context = CreateContext(); + context.Configurations.RequestPipeline.OnMessageCreating = args => + new AsyncTestRequestMessage(args, payload, 200, "image/png"); + var product = new Product { Id = 1, Name = "Widget" }; + context.AttachTo("Products", product); + context.GetEntityDescriptor(product).AddStreamInfoIfNotPresent(nameof(Product.Photo)).SelfLink = + new Uri($"{ServiceRoot}/Products(1)/Photo"); + + using DataServiceStreamResponse response = await context.GetReadStreamAsync( + product, + nameof(Product.Photo), + new DataServiceRequestArgs(), + CancellationToken.None); + using var reader = new StreamReader(response.Stream); + + Assert.Equal("image/png", response.ContentType); + Assert.Equal("photo content", await reader.ReadToEndAsync()); + } + + [Fact] + public async Task BulkUpdateAsync_WithPreCanceledToken_DoesNotUseApm() + { + var context = CreateContext(); + SetupRequestPipeline(context, string.Empty); + var product = new Product { Id = 1, Name = "Updated" }; + context.AttachTo("Products", product); + context.UpdateObject(product); + using var cancellationTokenSource = new CancellationTokenSource(); + cancellationTokenSource.Cancel(); + + await Assert.ThrowsAnyAsync( + () => context.BulkUpdateAsync(cancellationTokenSource.Token, product)); + } + + [Fact] + public async Task DeepInsertAsync_WithPreCanceledToken_DoesNotUseApm() + { + var context = CreateContext(); + SetupRequestPipeline(context, string.Empty); + var product = new Product { Id = 3, Name = "New" }; + context.AddObject("Products", product); + using var cancellationTokenSource = new CancellationTokenSource(); + cancellationTokenSource.Cancel(); + + await Assert.ThrowsAnyAsync( + () => context.DeepInsertAsync(product, cancellationTokenSource.Token)); + } + + #endregion + #region Helper Methods private TestContainer CreateContext() @@ -384,6 +620,12 @@ private void SetupRequestPipeline(DataServiceContext context, string response) new AsyncTestRequestMessage(args, response); } + private void SetupRequestPipeline(DataServiceContext context, string response, int statusCode, string contentType) + { + context.Configurations.RequestPipeline.OnMessageCreating = (args) => + new AsyncTestRequestMessage(args, response, statusCode, contentType); + } + #endregion #region Test Types @@ -393,6 +635,23 @@ public class Product { public int Id { get; set; } public string Name { get; set; } + public DataServiceStreamLink Photo { get; set; } + public Category Category { get; set; } + public List Categories { get; set; } + } + + [Key("Id")] + public class Category + { + public int Id { get; set; } + public string Name { get; set; } + } + + [Key("Id")] + [HasStream] + public class Document + { + public int Id { get; set; } } private class TestContainer : DataServiceContext @@ -425,44 +684,61 @@ public override void CancelRequest(IAsyncResult asyncResult) /// private class AsyncTestRequestMessage : HttpClientRequestMessage { - private readonly string _response; + private readonly byte[] _response; + private readonly int _statusCode; + private readonly string _contentType; public AsyncTestRequestMessage(DataServiceClientRequestMessageArgs args, string response) + : this(args, Encoding.UTF8.GetBytes(response), 200, "application/json;charset=utf-8") + { + } + + public AsyncTestRequestMessage(DataServiceClientRequestMessageArgs args, string response, int statusCode, string contentType = "application/json;charset=utf-8") + : this(args, Encoding.UTF8.GetBytes(response), statusCode, contentType) + { + } + + public AsyncTestRequestMessage(DataServiceClientRequestMessageArgs args, byte[] response, int statusCode, string contentType) : base(args) { _response = response; + _statusCode = statusCode; + _contentType = contentType; } public override IODataResponseMessage GetResponse() { - return CreateMockResponse(); + throw new NotSupportedException("Synchronous response APIs are unavailable on WebAssembly."); } public override Task GetResponseAsync(CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); - return Task.FromResult(GetResponse()); + return Task.FromResult(CreateMockResponse()); } public override IAsyncResult BeginGetResponse(AsyncCallback callback, object state) { - var tcs = new TaskCompletionSource(state); - tcs.TrySetResult(true); - callback(tcs.Task); - return tcs.Task; + throw new NotSupportedException("APM response APIs are unavailable on WebAssembly."); } public override IODataResponseMessage EndGetResponse(IAsyncResult asyncResult) { - return GetResponse(); + throw new NotSupportedException("APM response APIs are unavailable on WebAssembly."); } private IODataResponseMessage CreateMockResponse() { + var headers = new Dictionary(); + if (_contentType != null) + { + headers.Add("Content-Type", _contentType); + } + return new HttpWebResponseMessage( - new Dictionary { { "Content-Type", "application/json;charset=utf-8" } }, - 200, - () => new MemoryStream(Encoding.UTF8.GetBytes(_response)), + headers, + _statusCode, + () => new MemoryStream(_response), null); } }