Skip to content

Commit eb51454

Browse files
authored
(#258) Added [JsonIgnore] support for local data preservation. (#363)
* (#258) Added [JsonIgnore] support for local data preservation.
1 parent 97556e5 commit eb51454

File tree

7 files changed

+196
-13
lines changed

7 files changed

+196
-13
lines changed

Directory.Packages.props

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,12 +44,12 @@
4444
<PackageVersion Include="TestContainers.MsSql" Version="4.4.0" />
4545
<PackageVersion Include="TestContainers.MySql" Version="4.4.0" />
4646
<PackageVersion Include="TestContainers.PostgreSql" Version="4.4.0" />
47+
<PackageVersion Include="xRetry" Version="1.9.0" />
4748
<PackageVersion Include="xunit" Version="2.9.3" />
4849
<PackageVersion Include="xunit.runner.visualstudio" Version="3.0.2" />
4950
<PackageVersion Include="coverlet.collector" Version="6.0.4" />
5051
<PackageVersion Include="coverlet.msbuild" Version="6.0.4" />
5152
<PackageVersion Include="Ulid" Version="1.3.4" />
52-
5353
<!-- Do not change XUnit.Combinatorial to v2 (xUnit v2 compatibility) -->
5454
<PackageVersion Include="XUnit.Combinatorial" Version="1.6.24" />
5555
<PackageVersion Include="XUnit.SkippableFact" Version="1.5.23" />

src/CommunityToolkit.Datasync.Client/Offline/Operations/PullOperationManager.cs

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,12 @@
88
using CommunityToolkit.Datasync.Client.Query.OData;
99
using CommunityToolkit.Datasync.Client.Serialization;
1010
using CommunityToolkit.Datasync.Client.Threading;
11+
using Microsoft.EntityFrameworkCore.ChangeTracking;
12+
using Microsoft.EntityFrameworkCore.Metadata;
1113
using System.Diagnostics.CodeAnalysis;
1214
using System.Reflection;
1315
using System.Text.Json;
16+
using System.Text.Json.Serialization;
1417

1518
namespace CommunityToolkit.Datasync.Client.Offline.Operations;
1619

@@ -42,6 +45,7 @@ internal class PullOperationManager(OfflineDbContext context, IEnumerable<Type>
4245
/// <param name="pullOptions">The pull options to use.</param>
4346
/// <param name="cancellationToken">A <see cref="CancellationToken"/> to observe.</param>
4447
/// <returns>The results of the pull operation.</returns>
48+
[SuppressMessage("Style", "IDE0305:Simplify collection initialization", Justification = "Readability")]
4549
public async Task<PullResult> ExecuteAsync(IEnumerable<PullRequest> requests, PullOptions pullOptions, CancellationToken cancellationToken = default)
4650
{
4751
ArgumentValidationException.ThrowIfNotValid(pullOptions, nameof(pullOptions));
@@ -67,7 +71,25 @@ public async Task<PullResult> ExecuteAsync(IEnumerable<PullRequest> requests, Pu
6771
}
6872
else if (originalEntity is not null && !metadata.Deleted)
6973
{
70-
context.Entry(originalEntity).CurrentValues.SetValues(item);
74+
// Gather properties marked with [JsonIgnore]
75+
HashSet<string> ignoredProps = pullResponse.EntityType
76+
.GetProperties(BindingFlags.Public | BindingFlags.Instance)
77+
.Where(p => p.IsDefined(typeof(JsonIgnoreAttribute), inherit: true))
78+
.Select(p => p.Name)
79+
.ToHashSet();
80+
81+
EntityEntry originalEntry = context.Entry(originalEntity);
82+
EntityEntry newEntry = context.Entry(item);
83+
84+
// Only copy properties that are not marked with [JsonIgnore]
85+
foreach (IProperty property in originalEntry.Metadata.GetProperties())
86+
{
87+
if (!ignoredProps.Contains(property.Name))
88+
{
89+
originalEntry.Property(property.Name).CurrentValue = newEntry.Property(property.Name).CurrentValue;
90+
}
91+
}
92+
7193
result.IncrementReplacements();
7294
}
7395

@@ -161,12 +183,11 @@ internal async Task<Page<object>> GetPageAsync(HttpClient client, Uri requestUri
161183
object? result = await JsonSerializer.DeserializeAsync(response.ContentStream, pageType, context.JsonSerializerOptions, cancellationToken).ConfigureAwait(false)
162184
?? throw new DatasyncPullException("JSON result is null") { ServiceResponse = response };
163185

164-
Page<object> page = new Page<object>()
186+
return new Page<object>()
165187
{
166188
Items = (IEnumerable<object>)itemsPropInfo.GetValue(result)!,
167189
NextLink = (string?)nextLinkPropInfo.GetValue(result)
168190
};
169-
return page;
170191
}
171192
catch (JsonException ex)
172193
{

src/CommunityToolkit.Datasync.Client/Offline/OperationsQueue/OperationsQueueManager.cs

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,11 @@
88
using CommunityToolkit.Datasync.Client.Threading;
99
using Microsoft.EntityFrameworkCore;
1010
using Microsoft.EntityFrameworkCore.ChangeTracking;
11+
using Microsoft.EntityFrameworkCore.Metadata;
1112
using System.Diagnostics.CodeAnalysis;
1213
using System.Reflection;
1314
using System.Text.Json;
15+
using System.Text.Json.Serialization;
1416

1517
namespace CommunityToolkit.Datasync.Client.Offline.OperationsQueue;
1618

@@ -379,6 +381,7 @@ internal async Task<PushResult> PushAsync(IEnumerable<Type> entityTypes, PushOpt
379381
/// </summary>
380382
/// <param name="oldValue">The old value.</param>
381383
/// <param name="newValue">The new value.</param>
384+
[SuppressMessage("Style", "IDE0305:Simplify collection initialization", Justification = "Readability")]
382385
internal void ReplaceDatabaseValue(object? oldValue, object? newValue)
383386
{
384387
if (oldValue is null || newValue is null)
@@ -388,8 +391,21 @@ internal void ReplaceDatabaseValue(object? oldValue, object? newValue)
388391

389392
lock (this.pushlock)
390393
{
391-
EntityEntry tracker = this._context.Entry(oldValue);
392-
tracker.CurrentValues.SetValues(newValue);
394+
EntityEntry oldEntry = this._context.Entry(oldValue);
395+
EntityEntry newEntry = this._context.Entry(newValue);
396+
397+
HashSet<string> ignoredProps = oldValue.GetType().GetProperties(BindingFlags.Public | BindingFlags.Instance)
398+
.Where(p => p.IsDefined(typeof(JsonIgnoreAttribute), inherit: true))
399+
.Select(p => p.Name)
400+
.ToHashSet();
401+
402+
foreach (IProperty property in oldEntry.Metadata.GetProperties())
403+
{
404+
if (!ignoredProps.Contains(property.Name))
405+
{
406+
oldEntry.Property(property.Name).CurrentValue = newEntry.Property(property.Name).CurrentValue;
407+
}
408+
}
393409
}
394410
}
395411

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
// See the LICENSE file in the project root for more information.
4+
5+
using CommunityToolkit.Datasync.Server;
6+
using CommunityToolkit.Datasync.TestCommon.Models;
7+
using System.Text.Json.Serialization;
8+
9+
namespace CommunityToolkit.Datasync.Client.Test.Helpers;
10+
11+
/// <summary>
12+
/// This is a copy of the ClientMovie class, but with additional properties
13+
/// that are not synchronized to the server.
14+
/// </summary>
15+
[ExcludeFromCodeCoverage]
16+
public class ClientMovieWithLocalData : ClientTableData, IMovie, IEquatable<IMovie>
17+
{
18+
public ClientMovieWithLocalData() { }
19+
20+
public ClientMovieWithLocalData(object source)
21+
{
22+
if (source is ITableData metadata)
23+
{
24+
Id = metadata.Id;
25+
Deleted = metadata.Deleted;
26+
UpdatedAt = metadata.UpdatedAt;
27+
Version = Convert.ToBase64String(metadata.Version);
28+
}
29+
30+
if (source is IMovie movie)
31+
{
32+
BestPictureWinner = movie.BestPictureWinner;
33+
Duration = movie.Duration;
34+
Rating = movie.Rating;
35+
ReleaseDate = movie.ReleaseDate;
36+
Title = movie.Title;
37+
Year = movie.Year;
38+
}
39+
40+
if (source is ClientMovieWithLocalData localData)
41+
{
42+
UserRating = localData.UserRating;
43+
}
44+
}
45+
46+
/// <summary>
47+
/// A client-only value This value is not synchronized to the server.
48+
/// </summary>
49+
[JsonIgnore]
50+
public int UserRating { get; set; } = 0;
51+
52+
/// <summary>
53+
/// True if the movie won the oscar for Best Picture
54+
/// </summary>
55+
public bool BestPictureWinner { get; set; }
56+
57+
/// <summary>
58+
/// The running time of the movie
59+
/// </summary>
60+
public int Duration { get; set; }
61+
62+
/// <summary>
63+
/// The MPAA rating for the movie, if available.
64+
/// </summary>
65+
public MovieRating Rating { get; set; } = MovieRating.Unrated;
66+
67+
/// <summary>
68+
/// The release date of the movie.
69+
/// </summary>
70+
public DateOnly ReleaseDate { get; set; }
71+
72+
/// <summary>
73+
/// The title of the movie.
74+
/// </summary>
75+
public string Title { get; set; } = string.Empty;
76+
77+
/// <summary>
78+
/// The year that the movie was released.
79+
/// </summary>
80+
public int Year { get; set; }
81+
82+
/// <summary>
83+
/// Determines if this movie has the same content as another movie.
84+
/// </summary>
85+
/// <param name="other">The other movie</param>
86+
/// <returns>true if the content is the same</returns>
87+
public bool Equals(IMovie other)
88+
=> other != null
89+
&& other.BestPictureWinner == BestPictureWinner
90+
&& other.Duration == Duration
91+
&& other.Rating == Rating
92+
&& other.ReleaseDate == ReleaseDate
93+
&& other.Title == Title
94+
&& other.Year == Year;
95+
}

tests/CommunityToolkit.Datasync.Client.Test/Helpers/IntegrationDbContext.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ public class IntegrationDbContext(DbContextOptions<IntegrationDbContext> options
1616

1717
public DbSet<ByteVersionMovie> ByteMovies => Set<ByteVersionMovie>();
1818

19+
public DbSet<ClientMovieWithLocalData> MoviesWithLocalData => Set<ClientMovieWithLocalData>();
20+
1921
public ServiceApplicationFactory Factory { get; set; }
2022

2123
public SqliteConnection Connection { get; set; }
@@ -36,6 +38,12 @@ protected override void OnDatasyncInitialization(DatasyncOfflineOptionsBuilder o
3638
cfg.ClientName = "movies";
3739
cfg.Endpoint = new Uri($"/{Factory.MovieEndpoint}", UriKind.Relative);
3840
});
41+
42+
optionsBuilder.Entity<ClientMovieWithLocalData>(cfg =>
43+
{
44+
cfg.ClientName = "movies";
45+
cfg.Endpoint = new Uri($"/{Factory.MovieEndpoint}", UriKind.Relative);
46+
});
3947
}
4048

4149
protected override void Dispose(bool disposing)

tests/CommunityToolkit.Datasync.Client.Test/Offline/Integration_Pull_Tests.cs

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,4 +120,46 @@ await this.context.PullAsync(builder =>
120120
.And.HaveEquivalentMetadataTo(serviceMovie);
121121
}
122122
}
123+
124+
[Fact]
125+
public async Task PullAsync_WithLocalData_Works()
126+
{
127+
const string testId = "id-010";
128+
129+
await this.context.MoviesWithLocalData.PullAsync();
130+
131+
ClientMovieWithLocalData t1 = await this.context.MoviesWithLocalData.FindAsync([testId]);
132+
133+
// Update the local data part and push it back to the server.
134+
t1.UserRating = 5;
135+
this.context.Update(t1);
136+
await this.context.SaveChangesAsync();
137+
await this.context.MoviesWithLocalData.PushAsync();
138+
139+
// Reload the local data from the server and check that the local data is still there
140+
await this.context.Entry(t1).ReloadAsync();
141+
t1.UserRating.Should().Be(5);
142+
143+
// Pull again and check that the local data is still there.
144+
await this.context.MoviesWithLocalData.PullAsync();
145+
ClientMovieWithLocalData t2 = await this.context.MoviesWithLocalData.FindAsync([testId]);
146+
t2.UserRating.Should().Be(5);
147+
148+
// Do another change (this time, server side) and push again
149+
t2.Title = "New Title";
150+
this.context.Update(t2);
151+
await this.context.SaveChangesAsync();
152+
await this.context.MoviesWithLocalData.PushAsync();
153+
154+
// Reload the local data from the server and check that the local data is still there
155+
await this.context.Entry(t2).ReloadAsync();
156+
t2.UserRating.Should().Be(5);
157+
t2.Title.Should().Be("New Title");
158+
159+
// Pull again and check that the local data is still there.
160+
await this.context.MoviesWithLocalData.PullAsync();
161+
ClientMovieWithLocalData t3 = await this.context.MoviesWithLocalData.FindAsync([testId]);
162+
t3.UserRating.Should().Be(5);
163+
t3.Title.Should().Be("New Title");
164+
}
123165
}
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,4 @@
11
<Project Sdk="Microsoft.NET.Sdk">
2-
<ItemGroup>
3-
<PackageReference Include="Ulid" />
4-
</ItemGroup>
5-
<ItemGroup>
6-
<ProjectReference Include="..\..\src\CommunityToolkit.Datasync.Server.EntityFrameworkCore\CommunityToolkit.Datasync.Server.EntityFrameworkCore.csproj" />
7-
<ProjectReference Include="..\CommunityToolkit.Datasync.TestCommon\CommunityToolkit.Datasync.TestCommon.csproj" />
8-
</ItemGroup>
92
<ItemGroup>
103
<PackageReference Include="coverlet.collector">
114
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
@@ -19,5 +12,13 @@
1912
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
2013
<PrivateAssets>all</PrivateAssets>
2114
</PackageReference>
15+
<PackageReference Include="Ulid" />
16+
<PackageReference Include="xRetry" />
17+
</ItemGroup>
18+
19+
<ItemGroup>
20+
<ProjectReference Include="..\..\src\CommunityToolkit.Datasync.Server.EntityFrameworkCore\CommunityToolkit.Datasync.Server.EntityFrameworkCore.csproj" />
21+
<ProjectReference Include="..\CommunityToolkit.Datasync.TestCommon\CommunityToolkit.Datasync.TestCommon.csproj" />
2222
</ItemGroup>
23+
2324
</Project>

0 commit comments

Comments
 (0)