From a71f713b1edffde918e4a11acc9bde17bcf7d29d Mon Sep 17 00:00:00 2001 From: Adrian Hall Date: Mon, 26 Aug 2024 13:20:43 -0700 Subject: [PATCH] (#38) PullAsync complete (#73) --- .../Offline/Extensions.cs | 61 +++++ .../Offline/OfflineDbContext.cs | 2 +- .../Operations/PullOperationManager.cs | 1 - .../Offline/PullRequestBuilder.cs | 29 +- .../Offline/OfflineDbContext_Tests.cs | 256 +++++++++++++++++- 5 files changed, 334 insertions(+), 15 deletions(-) diff --git a/src/CommunityToolkit.Datasync.Client/Offline/Extensions.cs b/src/CommunityToolkit.Datasync.Client/Offline/Extensions.cs index 0226529..cd8fdd5 100644 --- a/src/CommunityToolkit.Datasync.Client/Offline/Extensions.cs +++ b/src/CommunityToolkit.Datasync.Client/Offline/Extensions.cs @@ -13,6 +13,67 @@ namespace CommunityToolkit.Datasync.Client.Offline; /// public static class Extensions { + /// + /// Pulls the changes from the remote service for the specified dataset + /// + /// The dataset to pull from the remote service. + /// A to observe. + /// The results of the pull operation. + public static Task PullAsync(this DbSet dataset, CancellationToken cancellationToken = default) where TEntity : class + => dataset.PullAsync(new PullOptions(), cancellationToken); + + /// + /// Pulls the changes from the remote service for the specified dataset + /// + /// The dataset to pull from the remote service. + /// The options to use on this pull request. + /// A to observe. + /// The results of the pull operation. + public static Task PullAsync(this DbSet dataset, PullOptions options, CancellationToken cancellationToken = default) where TEntity : class + { + DbContext context = dataset.GetService().Context; + if (context is OfflineDbContext offlineContext) + { + return offlineContext.PullAsync([typeof(TEntity)], options, cancellationToken); + } + else + { + throw new DatasyncException($"Provided dataset is not a part of an {nameof(OfflineDbContext)}"); + } + } + + /// + /// Pulls the changes from the remote service for all synchronizable entities. + /// + /// The offline database context to use. + /// A to observe. + /// The results of the pull operation. + [ExcludeFromCodeCoverage] + public static Task PullAsync(this OfflineDbContext context, CancellationToken cancellationToken = default) + => context.PullAsync(context.QueueManager.GetSynchronizableEntityTypes(), new PullOptions(), cancellationToken); + + /// + /// Pulls the changes from the remote service for all synchronizable entities. + /// + /// The offline database context to use. + /// The options to use on this pull request. + /// A to observe. + /// The results of the pull operation. + [ExcludeFromCodeCoverage] + public static Task PullAsync(this OfflineDbContext context, PullOptions options, CancellationToken cancellationToken = default) + => context.PullAsync(context.QueueManager.GetSynchronizableEntityTypes(), options, cancellationToken); + + /// + /// Pulls the changes from the remote service for the specified synchronizable entities. + /// + /// The offline database context to use. + /// The list of entity types to pull. + /// A to observe. + /// The results of the pull operation. + [ExcludeFromCodeCoverage] + public static Task PullAsync(this OfflineDbContext context, IEnumerable entityTypes, CancellationToken cancellationToken = default) + => context.PullAsync(entityTypes, new PullOptions(), cancellationToken); + /// /// Pushes the pending operations against the remote service for the provided dataset /// diff --git a/src/CommunityToolkit.Datasync.Client/Offline/OfflineDbContext.cs b/src/CommunityToolkit.Datasync.Client/Offline/OfflineDbContext.cs index 7b5db85..4a87242 100644 --- a/src/CommunityToolkit.Datasync.Client/Offline/OfflineDbContext.cs +++ b/src/CommunityToolkit.Datasync.Client/Offline/OfflineDbContext.cs @@ -250,7 +250,7 @@ public Task PullAsync(IEnumerable entityTypes, PullOptions pul return new PullRequest() { EntityType = type, - QueryId = PullRequestBuilder.GetQueryIdFromQuery(type, entityOptions.QueryDescription), + QueryId = PullRequestBuilder.GetQueryIdFromQuery(string.Empty, type, entityOptions.QueryDescription), HttpClient = entityOptions.HttpClient, Endpoint = entityOptions.Endpoint, QueryDescription = entityOptions.QueryDescription diff --git a/src/CommunityToolkit.Datasync.Client/Offline/Operations/PullOperationManager.cs b/src/CommunityToolkit.Datasync.Client/Offline/Operations/PullOperationManager.cs index 362a522..36f55ec 100644 --- a/src/CommunityToolkit.Datasync.Client/Offline/Operations/PullOperationManager.cs +++ b/src/CommunityToolkit.Datasync.Client/Offline/Operations/PullOperationManager.cs @@ -116,7 +116,6 @@ public async Task ExecuteAsync(IEnumerable requests, Pu foreach (PullRequest request in requests) { - request.QueryId = !string.IsNullOrEmpty(request.QueryId) ? request.QueryId : PullRequestBuilder.GetQueryIdFromQuery(request.EntityType, request.QueryDescription); DateTimeOffset lastSynchronization = await context.DeltaTokenStore.GetDeltaTokenAsync(request.QueryId, cancellationToken).ConfigureAwait(false); PrepareQueryDescription(request.QueryDescription, lastSynchronization); serviceRequestQueue.Enqueue(request); diff --git a/src/CommunityToolkit.Datasync.Client/Offline/PullRequestBuilder.cs b/src/CommunityToolkit.Datasync.Client/Offline/PullRequestBuilder.cs index 5682182..89c9660 100644 --- a/src/CommunityToolkit.Datasync.Client/Offline/PullRequestBuilder.cs +++ b/src/CommunityToolkit.Datasync.Client/Offline/PullRequestBuilder.cs @@ -40,6 +40,18 @@ public PullRequestBuilder SetParallelOperations(int parallelOperations) return this; } + /// + /// Determines whether to save the delta-token after every service request, or just after all the requests + /// are completed. + /// + /// If true, save after every request. + /// The builder for chaining + public PullRequestBuilder SaveAfterEveryServiceRequest(bool enabled) + { + this.pullOptions.SaveAfterEveryServiceRequest = enabled; + return this; + } + /// /// Adds a pull request for the default query as setup in the /// method of your database context. @@ -60,7 +72,7 @@ public PullRequestBuilder AddPullRequest() where TEntity : class EntityType = typeof(TEntity), HttpClient = options.HttpClient, QueryDescription = options.QueryDescription, - QueryId = GetQueryIdFromQuery(typeof(TEntity), options.QueryDescription) + QueryId = GetQueryIdFromQuery(string.Empty, typeof(TEntity), options.QueryDescription) }; this.requests[request.QueryId] = request; return this; @@ -96,7 +108,7 @@ public PullRequestBuilder AddPullRequest(Action> c EntityType = request.EntityType, HttpClient = request.HttpClient, QueryDescription = queryDescription, - QueryId = string.IsNullOrEmpty(request.QueryId) ? GetQueryIdFromQuery(request.EntityType, queryDescription) : request.QueryId + QueryId = GetQueryIdFromQuery(request.QueryId, request.EntityType, queryDescription) }; this.requests[storedRequest.QueryId] = storedRequest; return this; @@ -106,10 +118,11 @@ public PullRequestBuilder AddPullRequest(Action> c /// Obtain a query ID from the query and entity type. This is used when the developer does /// not specify a query ID. /// + /// The specified query ID /// The entity type. /// The query, /// - internal static string GetQueryIdFromQuery(Type entityType, QueryDescription query) + internal static string GetQueryIdFromQuery(string queryId, Type entityType, QueryDescription query) { string odataQuery = query.ToODataQueryString(); if (string.IsNullOrEmpty(odataQuery)) @@ -117,9 +130,13 @@ internal static string GetQueryIdFromQuery(Type entityType, QueryDescription que return entityType.FullName!; } - byte[] bytes = Encoding.UTF8.GetBytes(odataQuery); - byte[] hashBytes = MD5.HashData(bytes); - string queryId = BitConverter.ToString(hashBytes).Replace("-", string.Empty).ToLower(); + if (string.IsNullOrEmpty(queryId)) + { + byte[] bytes = Encoding.UTF8.GetBytes(odataQuery); + byte[] hashBytes = MD5.HashData(bytes); + queryId = BitConverter.ToString(hashBytes).Replace("-", string.Empty).ToLower(); + } + return $"q-{entityType.FullName!}-{queryId}"; } diff --git a/tests/CommunityToolkit.Datasync.Client.Test/Offline/OfflineDbContext_Tests.cs b/tests/CommunityToolkit.Datasync.Client.Test/Offline/OfflineDbContext_Tests.cs index bdc991f..0a4cc58 100644 --- a/tests/CommunityToolkit.Datasync.Client.Test/Offline/OfflineDbContext_Tests.cs +++ b/tests/CommunityToolkit.Datasync.Client.Test/Offline/OfflineDbContext_Tests.cs @@ -42,7 +42,7 @@ public void Base_Ctor_CreatesInternalApi() #region PullAsync [Fact] - public async Task ExecuteAsync_Works_InitialSync() + public async Task PullAsync_List_Works_InitialSync() { Page page1 = CreatePage(5, 20, "$skip=5"); Page page2 = CreatePage(5, 20, "$skip=10"); @@ -71,10 +71,228 @@ public async Task ExecuteAsync_Works_InitialSync() this.context.Handler.Requests[1].RequestUri.ToString().Should().Be("https://test.zumo.net/tables/movies/?$skip=5"); this.context.Handler.Requests[2].RequestUri.ToString().Should().Be("https://test.zumo.net/tables/movies/?$skip=10"); this.context.Handler.Requests[3].RequestUri.ToString().Should().Be("https://test.zumo.net/tables/movies/?$skip=15"); + + DatasyncDeltaToken token = this.context.DatasyncDeltaTokens.Find(["CommunityToolkit.Datasync.TestCommon.Databases.ClientMovie"]); + token.Should().NotBeNull(); + token.Value.Should().BeGreaterThan(0); + } + + [Fact] + public async Task PullAsync_DbSet_Works_InitialSync() + { + Page page1 = CreatePage(5, 20, "$skip=5"); + Page page2 = CreatePage(5, 20, "$skip=10"); + Page page3 = CreatePage(5, 20, "$skip=15"); + Page page4 = CreatePage(5, 20); + + this.context.Handler.AddResponse(HttpStatusCode.OK, page1); + this.context.Handler.AddResponse(HttpStatusCode.OK, page2); + this.context.Handler.AddResponse(HttpStatusCode.OK, page3); + this.context.Handler.AddResponse(HttpStatusCode.OK, page4); + + PullResult pullResult = await this.context.Movies.PullAsync(); + + pullResult.IsSuccessful.Should().BeTrue(); + pullResult.Additions.Should().Be(20); + pullResult.Deletions.Should().Be(0); + pullResult.Replacements.Should().Be(0); + + List expected = page1.Items.Concat(page2.Items).Concat(page3.Items).Concat(page4.Items).ToList(); + List actual = await this.context.Movies.ToListAsync(); + + actual.Should().BeEquivalentTo(expected); + + this.context.Handler.Requests.Should().HaveCount(4); + this.context.Handler.Requests[0].RequestUri.ToString().Should().Be("https://test.zumo.net/tables/movies/?$orderby=updatedAt&$count=true&__includedeleted=true"); + this.context.Handler.Requests[1].RequestUri.ToString().Should().Be("https://test.zumo.net/tables/movies/?$skip=5"); + this.context.Handler.Requests[2].RequestUri.ToString().Should().Be("https://test.zumo.net/tables/movies/?$skip=10"); + this.context.Handler.Requests[3].RequestUri.ToString().Should().Be("https://test.zumo.net/tables/movies/?$skip=15"); + + DatasyncDeltaToken token = this.context.DatasyncDeltaTokens.Find(["CommunityToolkit.Datasync.TestCommon.Databases.ClientMovie"]); + token.Should().NotBeNull(); + token.Value.Should().BeGreaterThan(0); + } + + [Fact] + public async Task PullAsync_Configurator_Works_InitialSync_Ver1() + { + Page page1 = CreatePage(5, 20, "$skip=5"); + Page page2 = CreatePage(5, 20, "$skip=10"); + Page page3 = CreatePage(5, 20, "$skip=15"); + Page page4 = CreatePage(5, 20); + + this.context.Handler.AddResponse(HttpStatusCode.OK, page1); + this.context.Handler.AddResponse(HttpStatusCode.OK, page2); + this.context.Handler.AddResponse(HttpStatusCode.OK, page3); + this.context.Handler.AddResponse(HttpStatusCode.OK, page4); + + PullResult pullResult = await this.context.PullAsync(cfg => + { + cfg.SetParallelOperations(1); + cfg.AddPullRequest(options => + { + options.QueryId = "abc"; + options.Query.Where(x => x.Title.StartsWith("abc")); + }); + }); + + pullResult.IsSuccessful.Should().BeTrue(); + pullResult.Additions.Should().Be(20); + pullResult.Deletions.Should().Be(0); + pullResult.Replacements.Should().Be(0); + + List expected = page1.Items.Concat(page2.Items).Concat(page3.Items).Concat(page4.Items).ToList(); + List actual = await this.context.Movies.ToListAsync(); + + actual.Should().BeEquivalentTo(expected); + + this.context.Handler.Requests.Should().HaveCount(4); + this.context.Handler.Requests[0].RequestUri.ToString().Should().Be("https://test.zumo.net/tables/movies/?$filter=startswith%28title%2C%27abc%27%29&$orderby=updatedAt&$count=true&__includedeleted=true"); + this.context.Handler.Requests[1].RequestUri.ToString().Should().Be("https://test.zumo.net/tables/movies/?$skip=5"); + this.context.Handler.Requests[2].RequestUri.ToString().Should().Be("https://test.zumo.net/tables/movies/?$skip=10"); + this.context.Handler.Requests[3].RequestUri.ToString().Should().Be("https://test.zumo.net/tables/movies/?$skip=15"); + + DatasyncDeltaToken token = this.context.DatasyncDeltaTokens.Find(["q-CommunityToolkit.Datasync.TestCommon.Databases.ClientMovie-abc"]); + token.Should().NotBeNull(); + token.Value.Should().BeGreaterThan(0); } [Fact] - public async Task ExecuteAsync_Works_FollowonSync() + public async Task PullAsync_Configurator_Works_InitialSync_Ver2() + { + Page page1 = CreatePage(5, 20, "$skip=5"); + Page page2 = CreatePage(5, 20, "$skip=10"); + Page page3 = CreatePage(5, 20, "$skip=15"); + Page page4 = CreatePage(5, 20); + + this.context.Handler.AddResponse(HttpStatusCode.OK, page1); + this.context.Handler.AddResponse(HttpStatusCode.OK, page2); + this.context.Handler.AddResponse(HttpStatusCode.OK, page3); + this.context.Handler.AddResponse(HttpStatusCode.OK, page4); + + PullResult pullResult = await this.context.PullAsync(cfg => + { + cfg.SetParallelOperations(1); + cfg.AddPullRequest(options => + { + options.QueryId = string.Empty; + options.Query.Where(x => x.Title.StartsWith("abc")); + }); + }); + + pullResult.IsSuccessful.Should().BeTrue(); + pullResult.Additions.Should().Be(20); + pullResult.Deletions.Should().Be(0); + pullResult.Replacements.Should().Be(0); + + List expected = page1.Items.Concat(page2.Items).Concat(page3.Items).Concat(page4.Items).ToList(); + List actual = await this.context.Movies.ToListAsync(); + + actual.Should().BeEquivalentTo(expected); + + this.context.Handler.Requests.Should().HaveCount(4); + this.context.Handler.Requests[0].RequestUri.ToString().Should().Be("https://test.zumo.net/tables/movies/?$filter=startswith%28title%2C%27abc%27%29&$orderby=updatedAt&$count=true&__includedeleted=true"); + this.context.Handler.Requests[1].RequestUri.ToString().Should().Be("https://test.zumo.net/tables/movies/?$skip=5"); + this.context.Handler.Requests[2].RequestUri.ToString().Should().Be("https://test.zumo.net/tables/movies/?$skip=10"); + this.context.Handler.Requests[3].RequestUri.ToString().Should().Be("https://test.zumo.net/tables/movies/?$skip=15"); + + DatasyncDeltaToken token = this.context.DatasyncDeltaTokens.Find(["q-CommunityToolkit.Datasync.TestCommon.Databases.ClientMovie-a87ec01f71a5577199797b433e3bcc6b"]); + token.Should().NotBeNull(); + token.Value.Should().BeGreaterThan(0); + } + + [Fact] + public async Task PullAsync_Configurator_Works_InitialSync_Ver3() + { + Page page1 = CreatePage(5, 20, "$skip=5"); + Page page2 = CreatePage(5, 20, "$skip=10"); + Page page3 = CreatePage(5, 20, "$skip=15"); + Page page4 = CreatePage(5, 20); + + // Adjust the page 4 items to include a null updatedAt and an updatedAt in the past + List page4Items = page4.Items.ToList(); + page4Items[0].UpdatedAt = null; + page4Items[1].UpdatedAt = DateTimeOffset.Parse("1975-01-01T12:34:56.789Z"); + page4.Items = page4Items; + + this.context.Handler.AddResponse(HttpStatusCode.OK, page1); + this.context.Handler.AddResponse(HttpStatusCode.OK, page2); + this.context.Handler.AddResponse(HttpStatusCode.OK, page3); + this.context.Handler.AddResponse(HttpStatusCode.OK, page4); + + PullResult pullResult = await this.context.PullAsync(cfg => + { + cfg.SetParallelOperations(1); + cfg.SaveAfterEveryServiceRequest(false); + cfg.AddPullRequest(options => + { + options.QueryId = string.Empty; + options.Query.Where(x => x.Title.StartsWith("abc")); + }); + }); + + pullResult.IsSuccessful.Should().BeTrue(); + pullResult.Additions.Should().Be(20); + pullResult.Deletions.Should().Be(0); + pullResult.Replacements.Should().Be(0); + + List expected = page1.Items.Concat(page2.Items).Concat(page3.Items).Concat(page4.Items).ToList(); + List actual = await this.context.Movies.ToListAsync(); + + actual.Should().BeEquivalentTo(expected); + + this.context.Handler.Requests.Should().HaveCount(4); + this.context.Handler.Requests[0].RequestUri.ToString().Should().Be("https://test.zumo.net/tables/movies/?$filter=startswith%28title%2C%27abc%27%29&$orderby=updatedAt&$count=true&__includedeleted=true"); + this.context.Handler.Requests[1].RequestUri.ToString().Should().Be("https://test.zumo.net/tables/movies/?$skip=5"); + this.context.Handler.Requests[2].RequestUri.ToString().Should().Be("https://test.zumo.net/tables/movies/?$skip=10"); + this.context.Handler.Requests[3].RequestUri.ToString().Should().Be("https://test.zumo.net/tables/movies/?$skip=15"); + + DatasyncDeltaToken token = this.context.DatasyncDeltaTokens.Find(["q-CommunityToolkit.Datasync.TestCommon.Databases.ClientMovie-a87ec01f71a5577199797b433e3bcc6b"]); + token.Should().NotBeNull(); + token.Value.Should().BeGreaterThan(0); + } + + [Fact] + public async Task PullAsync_Configurator_Works_InitialSync_Ver4() + { + Page page1 = CreatePage(5, 20, "$skip=5"); + Page page2 = CreatePage(5, 20, "$skip=10"); + Page page3 = CreatePage(5, 20, "$skip=15"); + Page page4 = CreatePage(5, 20); + + this.context.Handler.AddResponse(HttpStatusCode.OK, page1); + this.context.Handler.AddResponse(HttpStatusCode.OK, page2); + this.context.Handler.AddResponse(HttpStatusCode.OK, page3); + this.context.Handler.AddResponse(HttpStatusCode.OK, page4); + + PullResult pullResult = await this.context.PullAsync(cfg => + { + cfg.AddPullRequest(); + }); + + pullResult.IsSuccessful.Should().BeTrue(); + pullResult.Additions.Should().Be(20); + pullResult.Deletions.Should().Be(0); + pullResult.Replacements.Should().Be(0); + + List expected = page1.Items.Concat(page2.Items).Concat(page3.Items).Concat(page4.Items).ToList(); + List actual = await this.context.Movies.ToListAsync(); + + actual.Should().BeEquivalentTo(expected); + + this.context.Handler.Requests.Should().HaveCount(4); + this.context.Handler.Requests[0].RequestUri.ToString().Should().Be("https://test.zumo.net/tables/movies/?$orderby=updatedAt&$count=true&__includedeleted=true"); + this.context.Handler.Requests[1].RequestUri.ToString().Should().Be("https://test.zumo.net/tables/movies/?$skip=5"); + this.context.Handler.Requests[2].RequestUri.ToString().Should().Be("https://test.zumo.net/tables/movies/?$skip=10"); + this.context.Handler.Requests[3].RequestUri.ToString().Should().Be("https://test.zumo.net/tables/movies/?$skip=15"); + + DatasyncDeltaToken token = this.context.DatasyncDeltaTokens.Find(["CommunityToolkit.Datasync.TestCommon.Databases.ClientMovie"]); + token.Should().NotBeNull(); + token.Value.Should().BeGreaterThan(0); + } + + [Fact] + public async Task PullAsync_List_Works_FollowonSync() { Page page1 = CreatePage(5, 20, "$skip=5"); Page page2 = CreatePage(5, 20, "$skip=10"); @@ -110,7 +328,7 @@ public async Task ExecuteAsync_Works_FollowonSync() } [Fact] - public async Task ExecuteAsync_Works_DoesntAddDeletions() + public async Task PullAsync_List_Works_DoesntAddDeletions() { Page page1 = CreatePage(5, 20, "$skip=5"); Page page2 = CreatePage(5, 20, "$skip=10"); @@ -151,7 +369,7 @@ public async Task ExecuteAsync_Works_DoesntAddDeletions() } [Fact] - public async Task ExecuteAsync_Works_DeletionsAndReplacements() + public async Task PullAsync_List_Works_DeletionsAndReplacements() { Page page1 = CreatePage(5, 20, "$skip=5"); Page page2 = CreatePage(5, 20, "$skip=10"); @@ -199,7 +417,7 @@ public async Task ExecuteAsync_Works_DeletionsAndReplacements() } [Fact] - public async Task ExecuteAsync_FailedRequest() + public async Task PullAsync_List_FailedRequest() { this.context.Handler.AddResponse(HttpStatusCode.BadRequest); @@ -212,7 +430,7 @@ public async Task ExecuteAsync_FailedRequest() } [Fact] - public async Task ExecuteAsync_NoRequests() + public async Task PullAsync_List_NoRequests() { PullResult pullResult = await this.context.PullAsync([], new PullOptions()); @@ -221,7 +439,7 @@ public async Task ExecuteAsync_NoRequests() } [Fact] - public async Task ExecuteAsync_PendingRequests() + public async Task PullAsync_List_PendingRequests() { Page page1 = CreatePage(5, 20, "$skip=5"); List page1Items = page1.Items.ToList(); @@ -239,6 +457,20 @@ public async Task ExecuteAsync_PendingRequests() Func act = async () => _ = await this.context.PullAsync([typeof(ClientMovie)], new PullOptions()); await act.Should().ThrowAsync(); } + + [Fact] + public async Task PullAsync_Configurator_InvalidType_Ver1() + { + Func act = async () => _ = await this.context.PullAsync(cfg => cfg.AddPullRequest()); + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task PullAsync_Configurator_InvalidType_Ver2() + { + Func act = async () => _ = await this.context.PullAsync(cfg => cfg.AddPullRequest(opt => opt.QueryId = "x")); + await act.Should().ThrowAsync(); + } #endregion #region PushAsync @@ -1181,6 +1413,16 @@ public void CheckDisposed_Works() } #endregion + #region DbSet.PullAsync + [Fact] + public async Task DbSet_PullAsync_Throws_OnNonOfflineDbContext() + { + NotOfflineDbContext context = NotOfflineDbContext.CreateContext(); + Func act = async () => await context.Movies.PullAsync(); + await act.Should().ThrowAsync(); + } + #endregion + #region DbSet.PushAsync [Fact] public async Task DbSet_PushAsync_Throws_OnNonOfflineDbContext()