From 41b49ab2ae776f3551a8dec2dedd615dea9b0357 Mon Sep 17 00:00:00 2001 From: Adrian Hall Date: Fri, 23 May 2025 11:11:00 -0700 Subject: [PATCH 1/2] (#258) Added [JsonIgnore] support for local data preservation. --- Directory.Packages.props | 2 +- .../Operations/PullOperationManager.cs | 27 +++++- .../OperationsQueue/OperationsQueueManager.cs | 20 +++- .../Helpers/ClientMovieWithLocalData.cs | 95 +++++++++++++++++++ .../Helpers/IntegrationDbContext.cs | 8 ++ .../Offline/Integration_Pull_Tests.cs | 42 ++++++++ ...ync.Server.EntityFrameworkCore.Test.csproj | 15 +-- 7 files changed, 196 insertions(+), 13 deletions(-) create mode 100644 tests/CommunityToolkit.Datasync.Client.Test/Helpers/ClientMovieWithLocalData.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 955d848e..2d469575 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -44,12 +44,12 @@ + - diff --git a/src/CommunityToolkit.Datasync.Client/Offline/Operations/PullOperationManager.cs b/src/CommunityToolkit.Datasync.Client/Offline/Operations/PullOperationManager.cs index 3e09fa24..87549699 100644 --- a/src/CommunityToolkit.Datasync.Client/Offline/Operations/PullOperationManager.cs +++ b/src/CommunityToolkit.Datasync.Client/Offline/Operations/PullOperationManager.cs @@ -8,9 +8,12 @@ using CommunityToolkit.Datasync.Client.Query.OData; using CommunityToolkit.Datasync.Client.Serialization; using CommunityToolkit.Datasync.Client.Threading; +using Microsoft.EntityFrameworkCore.ChangeTracking; +using Microsoft.EntityFrameworkCore.Metadata; using System.Diagnostics.CodeAnalysis; using System.Reflection; using System.Text.Json; +using System.Text.Json.Serialization; namespace CommunityToolkit.Datasync.Client.Offline.Operations; @@ -42,6 +45,7 @@ internal class PullOperationManager(OfflineDbContext context, IEnumerable /// The pull options to use. /// A to observe. /// The results of the pull operation. + [SuppressMessage("Style", "IDE0305:Simplify collection initialization", Justification = "Readability")] public async Task ExecuteAsync(IEnumerable requests, PullOptions pullOptions, CancellationToken cancellationToken = default) { ArgumentValidationException.ThrowIfNotValid(pullOptions, nameof(pullOptions)); @@ -67,7 +71,25 @@ public async Task ExecuteAsync(IEnumerable requests, Pu } else if (originalEntity is not null && !metadata.Deleted) { - context.Entry(originalEntity).CurrentValues.SetValues(item); + // Gather properties marked with [JsonIgnore] + HashSet ignoredProps = pullResponse.EntityType + .GetProperties(BindingFlags.Public | BindingFlags.Instance) + .Where(p => p.IsDefined(typeof(JsonIgnoreAttribute), inherit: true)) + .Select(p => p.Name) + .ToHashSet(); + + EntityEntry originalEntry = context.Entry(originalEntity); + EntityEntry newEntry = context.Entry(item); + + // Only copy properties that are not marked with [JsonIgnore] + foreach (IProperty property in originalEntry.Metadata.GetProperties()) + { + if (!ignoredProps.Contains(property.Name)) + { + originalEntry.Property(property.Name).CurrentValue = newEntry.Property(property.Name).CurrentValue; + } + } + result.IncrementReplacements(); } @@ -161,12 +183,11 @@ internal async Task> GetPageAsync(HttpClient client, Uri requestUri object? result = await JsonSerializer.DeserializeAsync(response.ContentStream, pageType, context.JsonSerializerOptions, cancellationToken).ConfigureAwait(false) ?? throw new DatasyncPullException("JSON result is null") { ServiceResponse = response }; - Page page = new Page() + return new Page() { Items = (IEnumerable)itemsPropInfo.GetValue(result)!, NextLink = (string?)nextLinkPropInfo.GetValue(result) }; - return page; } catch (JsonException ex) { diff --git a/src/CommunityToolkit.Datasync.Client/Offline/OperationsQueue/OperationsQueueManager.cs b/src/CommunityToolkit.Datasync.Client/Offline/OperationsQueue/OperationsQueueManager.cs index 3bab55b3..d00c8ac6 100644 --- a/src/CommunityToolkit.Datasync.Client/Offline/OperationsQueue/OperationsQueueManager.cs +++ b/src/CommunityToolkit.Datasync.Client/Offline/OperationsQueue/OperationsQueueManager.cs @@ -8,9 +8,11 @@ using CommunityToolkit.Datasync.Client.Threading; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.ChangeTracking; +using Microsoft.EntityFrameworkCore.Metadata; using System.Diagnostics.CodeAnalysis; using System.Reflection; using System.Text.Json; +using System.Text.Json.Serialization; namespace CommunityToolkit.Datasync.Client.Offline.OperationsQueue; @@ -379,6 +381,7 @@ internal async Task PushAsync(IEnumerable entityTypes, PushOpt /// /// The old value. /// The new value. + [SuppressMessage("Style", "IDE0305:Simplify collection initialization", Justification = "Readability")] internal void ReplaceDatabaseValue(object? oldValue, object? newValue) { if (oldValue is null || newValue is null) @@ -388,8 +391,21 @@ internal void ReplaceDatabaseValue(object? oldValue, object? newValue) lock (this.pushlock) { - EntityEntry tracker = this._context.Entry(oldValue); - tracker.CurrentValues.SetValues(newValue); + EntityEntry oldEntry = this._context.Entry(oldValue); + EntityEntry newEntry = this._context.Entry(newValue); + + HashSet ignoredProps = oldValue.GetType().GetProperties(BindingFlags.Public | BindingFlags.Instance) + .Where(p => p.IsDefined(typeof(JsonIgnoreAttribute), inherit: true)) + .Select(p => p.Name) + .ToHashSet(); + + foreach (IProperty property in oldEntry.Metadata.GetProperties()) + { + if (!ignoredProps.Contains(property.Name)) + { + oldEntry.Property(property.Name).CurrentValue = newEntry.Property(property.Name).CurrentValue; + } + } } } diff --git a/tests/CommunityToolkit.Datasync.Client.Test/Helpers/ClientMovieWithLocalData.cs b/tests/CommunityToolkit.Datasync.Client.Test/Helpers/ClientMovieWithLocalData.cs new file mode 100644 index 00000000..27a9a57a --- /dev/null +++ b/tests/CommunityToolkit.Datasync.Client.Test/Helpers/ClientMovieWithLocalData.cs @@ -0,0 +1,95 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using CommunityToolkit.Datasync.Server; +using CommunityToolkit.Datasync.TestCommon.Models; +using System.Text.Json.Serialization; + +namespace CommunityToolkit.Datasync.Client.Test.Helpers; + +/// +/// This is a copy of the ClientMovie class, but with additional properties +/// that are not synchronized to the server. +/// +[ExcludeFromCodeCoverage] +public class ClientMovieWithLocalData : ClientTableData, IMovie, IEquatable +{ + public ClientMovieWithLocalData() { } + + public ClientMovieWithLocalData(object source) + { + if (source is ITableData metadata) + { + Id = metadata.Id; + Deleted = metadata.Deleted; + UpdatedAt = metadata.UpdatedAt; + Version = Convert.ToBase64String(metadata.Version); + } + + if (source is IMovie movie) + { + BestPictureWinner = movie.BestPictureWinner; + Duration = movie.Duration; + Rating = movie.Rating; + ReleaseDate = movie.ReleaseDate; + Title = movie.Title; + Year = movie.Year; + } + + if (source is ClientMovieWithLocalData localData) + { + UserRating = localData.UserRating; + } + } + + /// + /// A client-only value This value is not synchronized to the server. + /// + [JsonIgnore] + public int UserRating { get; set; } = 0; + + /// + /// True if the movie won the oscar for Best Picture + /// + public bool BestPictureWinner { get; set; } + + /// + /// The running time of the movie + /// + public int Duration { get; set; } + + /// + /// The MPAA rating for the movie, if available. + /// + public MovieRating Rating { get; set; } = MovieRating.Unrated; + + /// + /// The release date of the movie. + /// + public DateOnly ReleaseDate { get; set; } + + /// + /// The title of the movie. + /// + public string Title { get; set; } = string.Empty; + + /// + /// The year that the movie was released. + /// + public int Year { get; set; } + + /// + /// Determines if this movie has the same content as another movie. + /// + /// The other movie + /// true if the content is the same + public bool Equals(IMovie other) + => other != null + && other.BestPictureWinner == BestPictureWinner + && other.Duration == Duration + && other.Rating == Rating + && other.ReleaseDate == ReleaseDate + && other.Title == Title + && other.Year == Year; +} diff --git a/tests/CommunityToolkit.Datasync.Client.Test/Helpers/IntegrationDbContext.cs b/tests/CommunityToolkit.Datasync.Client.Test/Helpers/IntegrationDbContext.cs index b4ad1049..386dd8ee 100644 --- a/tests/CommunityToolkit.Datasync.Client.Test/Helpers/IntegrationDbContext.cs +++ b/tests/CommunityToolkit.Datasync.Client.Test/Helpers/IntegrationDbContext.cs @@ -16,6 +16,8 @@ public class IntegrationDbContext(DbContextOptions options public DbSet ByteMovies => Set(); + public DbSet MoviesWithLocalData => Set(); + public ServiceApplicationFactory Factory { get; set; } public SqliteConnection Connection { get; set; } @@ -36,6 +38,12 @@ protected override void OnDatasyncInitialization(DatasyncOfflineOptionsBuilder o cfg.ClientName = "movies"; cfg.Endpoint = new Uri($"/{Factory.MovieEndpoint}", UriKind.Relative); }); + + optionsBuilder.Entity(cfg => + { + cfg.ClientName = "movies"; + cfg.Endpoint = new Uri($"/{Factory.MovieEndpoint}", UriKind.Relative); + }); } protected override void Dispose(bool disposing) diff --git a/tests/CommunityToolkit.Datasync.Client.Test/Offline/Integration_Pull_Tests.cs b/tests/CommunityToolkit.Datasync.Client.Test/Offline/Integration_Pull_Tests.cs index 1d5fce4d..bcbdcb25 100644 --- a/tests/CommunityToolkit.Datasync.Client.Test/Offline/Integration_Pull_Tests.cs +++ b/tests/CommunityToolkit.Datasync.Client.Test/Offline/Integration_Pull_Tests.cs @@ -120,4 +120,46 @@ await this.context.PullAsync(builder => .And.HaveEquivalentMetadataTo(serviceMovie); } } + + [Fact] + public async Task PullAsync_WithLocalData_Works() + { + const string testId = "id-010"; + + await this.context.MoviesWithLocalData.PullAsync(); + + ClientMovieWithLocalData t1 = await this.context.MoviesWithLocalData.FindAsync([testId]); + + // Update the local data part and push it back to the server. + t1.UserRating = 5; + this.context.Update(t1); + await this.context.SaveChangesAsync(); + await this.context.MoviesWithLocalData.PushAsync(); + + // Reload the local data from the server and check that the local data is still there + await this.context.Entry(t1).ReloadAsync(); + t1.UserRating.Should().Be(5); + + // Pull again and check that the local data is still there. + await this.context.MoviesWithLocalData.PullAsync(); + ClientMovieWithLocalData t2 = await this.context.MoviesWithLocalData.FindAsync([testId]); + t2.UserRating.Should().Be(5); + + // Do another change (this time, server side) and push again + t2.Title = "New Title"; + this.context.Update(t2); + await this.context.SaveChangesAsync(); + await this.context.MoviesWithLocalData.PushAsync(); + + // Reload the local data from the server and check that the local data is still there + await this.context.Entry(t1).ReloadAsync(); + t2.UserRating.Should().Be(5); + t2.Title.Should().Be("New Title"); + + // Pull again and check that the local data is still there. + await this.context.MoviesWithLocalData.PullAsync(); + ClientMovieWithLocalData t3 = await this.context.MoviesWithLocalData.FindAsync([testId]); + t3.UserRating.Should().Be(5); + t3.Title.Should().Be("New Title"); + } } diff --git a/tests/CommunityToolkit.Datasync.Server.EntityFrameworkCore.Test/CommunityToolkit.Datasync.Server.EntityFrameworkCore.Test.csproj b/tests/CommunityToolkit.Datasync.Server.EntityFrameworkCore.Test/CommunityToolkit.Datasync.Server.EntityFrameworkCore.Test.csproj index 965b7729..604b9420 100644 --- a/tests/CommunityToolkit.Datasync.Server.EntityFrameworkCore.Test/CommunityToolkit.Datasync.Server.EntityFrameworkCore.Test.csproj +++ b/tests/CommunityToolkit.Datasync.Server.EntityFrameworkCore.Test/CommunityToolkit.Datasync.Server.EntityFrameworkCore.Test.csproj @@ -1,11 +1,4 @@ - - - - - - - runtime; build; native; contentfiles; analyzers; buildtransitive @@ -19,5 +12,13 @@ runtime; build; native; contentfiles; analyzers; buildtransitive all + + + + + + + + From 0979a23e637b1432e069e8c603016caf88937bcb Mon Sep 17 00:00:00 2001 From: Adrian Hall Date: Fri, 23 May 2025 11:25:23 -0700 Subject: [PATCH 2/2] (#258) Corrected reload logic in test. No change to core code. --- .../Offline/Integration_Pull_Tests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/CommunityToolkit.Datasync.Client.Test/Offline/Integration_Pull_Tests.cs b/tests/CommunityToolkit.Datasync.Client.Test/Offline/Integration_Pull_Tests.cs index bcbdcb25..da4544c1 100644 --- a/tests/CommunityToolkit.Datasync.Client.Test/Offline/Integration_Pull_Tests.cs +++ b/tests/CommunityToolkit.Datasync.Client.Test/Offline/Integration_Pull_Tests.cs @@ -152,7 +152,7 @@ public async Task PullAsync_WithLocalData_Works() await this.context.MoviesWithLocalData.PushAsync(); // Reload the local data from the server and check that the local data is still there - await this.context.Entry(t1).ReloadAsync(); + await this.context.Entry(t2).ReloadAsync(); t2.UserRating.Should().Be(5); t2.Title.Should().Be("New Title");