Skip to content

Commit 93946b5

Browse files
authored
Added offline queue management (#23)
* Offline DbContext and operations queue injection code. * (#20) Tests for updating queue when the DbContext is changed.
1 parent a2f582c commit 93946b5

14 files changed

+1529
-8
lines changed

src/CommunityToolkit.Datasync.Client/CommunityToolkit.Datasync.Client.csproj

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,15 +15,12 @@
1515

1616
<ItemGroup>
1717
<PackageReference Include="Microsoft.Extensions.Http" Version="8.0.0" />
18+
<PackageReference Include="Microsoft.Azure.Core.Spatial" Version="1.1.0" />
19+
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.4" />
20+
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.4" />
1821
</ItemGroup>
1922

2023
<ItemGroup>
2124
<ProjectReference Include="..\CommunityToolkit.Datasync.Server.Abstractions\CommunityToolkit.Datasync.Server.Abstractions.csproj" />
2225
</ItemGroup>
23-
24-
<ItemGroup>
25-
<Reference Include="Microsoft.Azure.Core.Spatial">
26-
<HintPath>..\..\..\..\..\..\..\Nuget\microsoft.azure.core.spatial\1.1.0\lib\netstandard2.0\Microsoft.Azure.Core.Spatial.dll</HintPath>
27-
</Reference>
28-
</ItemGroup>
2926
</Project>
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
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 System.ComponentModel.DataAnnotations;
6+
using System.ComponentModel.DataAnnotations.Schema;
7+
8+
namespace CommunityToolkit.Datasync.Client.Offline;
9+
10+
/// <summary>
11+
/// To support incremental sync, every synchronization event is recorded in the <see cref="OfflineDbContext"/>
12+
/// together with the last time that the synchronization happened. This information is used to determine what
13+
/// parameters to add to the query to get only the changes since the last sync.
14+
/// </summary>
15+
[Table("__datasync_tableinfo")]
16+
public sealed class DatasyncTableInformation
17+
{
18+
/// <summary>
19+
/// The unique ID for the query. This is generated from the table name
20+
/// and query when not provided.
21+
/// </summary>
22+
[Key]
23+
public string QueryId { get; set; } = string.Empty;
24+
25+
/// <summary>
26+
/// The number of ticks when the last synchronization happened (or 0L if never synchronized).
27+
/// </summary>
28+
[Required]
29+
public long LastSynchronization { get; set; } = 0L;
30+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
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 Microsoft.EntityFrameworkCore.ChangeTracking;
6+
using System.Text.Json;
7+
8+
namespace CommunityToolkit.Datasync.Client.Offline;
9+
10+
/// <summary>
11+
/// Definition of the operations queue manager. This is the set of methods used to
12+
/// maintain the queue of operations that need to be synchronized with the remote service.
13+
/// </summary>
14+
internal interface IOperationsQueueManager
15+
{
16+
/// <summary>
17+
/// The <see cref="JsonSerializerOptions"/> to use for serializing and deserializing data.
18+
/// </summary>
19+
JsonSerializerOptions JsonSerializerOptions { get; }
20+
21+
/// <summary>
22+
/// Creates a new Create/Insert/Add operation for the given state change.
23+
/// </summary>
24+
/// <param name="entry">The entry being processed.</param>
25+
void AddCreateOperation(EntityEntry entry);
26+
27+
/// <summary>
28+
/// Creates a new Delete operation for the given state change.
29+
/// </summary>
30+
/// <param name="entry">The entry being processed.</param>
31+
void AddDeleteOperation(EntityEntry entry);
32+
33+
/// <summary>
34+
/// Creates a new Modify/Update operation for the given state change.
35+
/// </summary>
36+
/// <param name="entry">The entry being processed.</param>
37+
void AddUpdateOperation(EntityEntry entry);
38+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
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+
namespace CommunityToolkit.Datasync.Client;
6+
7+
/// <summary>
8+
/// The base class for entities that are stored in an offline database.
9+
/// </summary>
10+
public abstract class OfflineClientEntity
11+
{
12+
/// <summary>
13+
/// The globally unique ID for the entity.
14+
/// </summary>
15+
public string Id { get; set; }
16+
17+
/// <summary>
18+
/// The date/time that the entity was last updated on the server.
19+
/// </summary>
20+
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.MinValue;
21+
22+
/// <summary>
23+
/// The version of the entity on the server.
24+
/// </summary>
25+
public string Version { get; set; } = string.Empty;
26+
27+
/// <summary>
28+
/// If <c>true</c>, the entity is deleted on the server.
29+
/// </summary>
30+
public bool Deleted { get; set; }
31+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
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 System.Diagnostics.CodeAnalysis;
6+
7+
namespace CommunityToolkit.Datasync.Client;
8+
9+
/// <summary>
10+
/// A base exception class for all the exceptions thrown by the offline dataset processing.
11+
/// </summary>
12+
[ExcludeFromCodeCoverage(Justification = "Standard exception class")]
13+
public class OfflineDatasetException : ApplicationException
14+
{
15+
/// <inheritdoc />
16+
public OfflineDatasetException() : base()
17+
{
18+
}
19+
20+
/// <inheritdoc />
21+
public OfflineDatasetException(string message) : base(message)
22+
{
23+
}
24+
25+
/// <inheritdoc />
26+
public OfflineDatasetException(string message, Exception innerException) : base(message, innerException)
27+
{
28+
}
29+
}
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
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.Client.Offline;
6+
using CommunityToolkit.Datasync.Server;
7+
using Microsoft.EntityFrameworkCore;
8+
using Microsoft.EntityFrameworkCore.ChangeTracking;
9+
using System.Diagnostics.CodeAnalysis;
10+
using System.Text.Json;
11+
12+
namespace CommunityToolkit.Datasync.Client;
13+
14+
/// <summary>
15+
/// The <see cref="OfflineDbContext"/> class is a base class for a <see cref="DbContext"/> that is
16+
/// used to store offline data that is later synchronized to a remote datasync service.
17+
/// </summary>
18+
public abstract class OfflineDbContext : DbContext
19+
{
20+
/// <summary>
21+
/// Initializes a new instance of the <see cref="OfflineDbContext"/> class. The <see cref="OnConfiguring(DbContextOptionsBuilder)"/>
22+
/// method will be called to configure the database (and other options) to be used for this context.
23+
/// </summary>
24+
[ExcludeFromCodeCoverage(Justification = "Standard entry point that is part of DbContext")]
25+
protected OfflineDbContext() : base()
26+
{
27+
}
28+
29+
/// <summary>
30+
/// Initializes a new instance of the <see cref="OfflineDbContext"/> class. The <see cref="OnConfiguring(DbContextOptionsBuilder)"/>
31+
/// method will still be called to configure the database to be used for this context.
32+
/// </summary>
33+
/// <param name="options">The options to use in configuring the <see cref="OfflineDbContext"/>.</param>
34+
protected OfflineDbContext(DbContextOptions options) : base(options)
35+
{
36+
}
37+
38+
/// <summary>
39+
/// The list of entities that are awaiting synchronization.
40+
/// </summary>
41+
public DbSet<OfflineQueueEntity> DatasyncOperationsQueue => Set<OfflineQueueEntity>();
42+
43+
/// <summary>
44+
/// The metadata storage for each table synchronization set.
45+
/// </summary>
46+
public DbSet<DatasyncTableInformation> DatasyncTableMetadata => Set<DatasyncTableInformation>();
47+
48+
/// <summary>
49+
/// The <see cref="JsonSerializerOptions"/> to use for serializing and deserializing entities.
50+
/// </summary>
51+
public JsonSerializerOptions JsonSerializerOptions { get; set; }
52+
53+
/// <summary>
54+
/// Used to manage the operations queue internally.
55+
/// </summary>
56+
internal IOperationsQueueManager OperationsQueueManager { get; set; }
57+
58+
/// <inheritdoc />
59+
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
60+
{
61+
base.OnConfiguring(optionsBuilder);
62+
JsonSerializerOptions ??= new DatasyncServiceOptions().JsonSerializerOptions;
63+
OperationsQueueManager ??= new OperationsQueueManager(this);
64+
}
65+
66+
/// <inheritdoc />
67+
protected override void OnModelCreating(ModelBuilder modelBuilder)
68+
{
69+
modelBuilder.Entity<OfflineQueueEntity>(entity =>
70+
{
71+
entity.HasKey(e => e.Id);
72+
entity.HasIndex(e => e.EntityName);
73+
entity.HasIndex(e => e.EntityId);
74+
});
75+
76+
modelBuilder.Entity<DatasyncTableInformation>(entity =>
77+
{
78+
entity.HasKey(e => e.QueryId);
79+
entity.Property(e => e.LastSynchronization).IsRequired().HasDefaultValue(0L);
80+
});
81+
82+
base.OnModelCreating(modelBuilder);
83+
}
84+
85+
/// <inheritdoc />
86+
public virtual new int SaveChanges() => SaveChanges(true);
87+
88+
/// <inheritdoc />
89+
public virtual new int SaveChanges(bool acceptAllChangesOnSuccess)
90+
{
91+
StoreChangesInOperationsQueue();
92+
return base.SaveChanges(acceptAllChangesOnSuccess);
93+
}
94+
95+
/// <inheritdoc />
96+
public virtual new Task<int> SaveChangesAsync(CancellationToken cancellationToken = default) => SaveChangesAsync(true, cancellationToken);
97+
98+
/// <inheritdoc />
99+
public virtual new Task<int> SaveChangesAsync(bool acceptAllChangesOnSuccess, CancellationToken cancellationToken = default)
100+
{
101+
StoreChangesInOperationsQueue();
102+
return base.SaveChangesAsync(acceptAllChangesOnSuccess, cancellationToken);
103+
}
104+
105+
/// <summary>
106+
/// When <see cref="SaveChanges(bool)"/> or the async equivalent is called, this method looks through the changes
107+
/// to see if any should be pushed to the service. An <see cref="OfflineQueueEntity"/> is created for each change.
108+
/// </summary>
109+
/// <remarks>
110+
/// Every offline-capable entity must be derived from OfflineClientData and have a <see cref="DbSet{TEntity}"/> that
111+
/// is using the entity.
112+
/// </remarks>
113+
internal void StoreChangesInOperationsQueue()
114+
{
115+
ChangeTracker.DetectChanges();
116+
List<EntityEntry> changedEntities = ChangeTracker.Entries().Where(t => t.State is EntityState.Added or EntityState.Deleted or EntityState.Modified).ToList();
117+
foreach (EntityEntry entry in changedEntities)
118+
{
119+
StoreChangeInOperationsQueue(entry);
120+
}
121+
}
122+
123+
/// <summary>
124+
/// When <see cref="SaveChanges(bool)"/> or the async equivalent is called, this method is used to add the entry via
125+
/// the operations queue manager. An <see cref="OfflineQueueEntity"/> is created for each change.
126+
/// </summary>
127+
/// <remarks>
128+
/// Every offline-capable entity must be derived from OfflineClientData and have a <see cref="DbSet{TEntity}"/> that
129+
/// is using the entity.
130+
/// </remarks>
131+
internal void StoreChangeInOperationsQueue(EntityEntry entry)
132+
{
133+
if (entry.Entity is OfflineClientEntity)
134+
{
135+
switch (entry.State)
136+
{
137+
case EntityState.Added:
138+
OperationsQueueManager.AddCreateOperation(entry);
139+
break;
140+
case EntityState.Deleted:
141+
OperationsQueueManager.AddDeleteOperation(entry);
142+
break;
143+
case EntityState.Modified:
144+
OperationsQueueManager.AddUpdateOperation(entry);
145+
break;
146+
default:
147+
throw new InvalidOperationException("Unknown entity state");
148+
}
149+
}
150+
}
151+
}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
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 Microsoft.EntityFrameworkCore;
6+
using System.ComponentModel.DataAnnotations;
7+
using System.ComponentModel.DataAnnotations.Schema;
8+
9+
namespace CommunityToolkit.Datasync.Client.Offline;
10+
11+
/// <summary>
12+
/// The potential change types that can be recorded in the <see cref="OfflineQueueEntity"/>.
13+
/// </summary>
14+
public enum EntityChangeType
15+
{
16+
/// <summary>
17+
/// The change type is unknown (and hence skipped - this is always an error).
18+
/// </summary>
19+
Unknown,
20+
21+
/// <summary>
22+
/// The entity is being added.
23+
/// </summary>
24+
Add,
25+
26+
/// <summary>
27+
/// The entity is to be deleted.
28+
/// </summary>
29+
Delete,
30+
31+
/// <summary>
32+
/// The entity is being updated.
33+
/// </summary>
34+
Update
35+
}
36+
37+
/// <summary>
38+
/// When an entity is modified while offline, the changes are stored in the <see cref="OfflineDbContext"/>
39+
/// in an operations queue. This entity is used to record what changes are pending push to the server.
40+
/// </summary>
41+
[Table("__datasync_opsqueue")]
42+
[Index(nameof(EntityName))]
43+
public sealed class OfflineQueueEntity
44+
{
45+
/// <summary>
46+
/// The globally unique ID for the change.
47+
/// </summary>
48+
[Key]
49+
public string Id { get; set; } = Guid.NewGuid().ToString("N");
50+
51+
/// <summary>
52+
/// The type of the change (Add, Delete, or Update).
53+
/// </summary>
54+
public EntityChangeType ChangeType { get; set; } = EntityChangeType.Unknown;
55+
56+
/// <summary>
57+
/// The name of the entity or table that is being modified.
58+
/// </summary>
59+
public string EntityName { get; set; } = string.Empty;
60+
61+
/// <summary>
62+
/// The ID of the entity that is being modified.
63+
/// </summary>
64+
public string EntityId { get; set; } = string.Empty;
65+
66+
/// <summary>
67+
/// The replacement version of the entity that is being modified.
68+
/// </summary>
69+
public string ReplacementJsonEntityData { get; set; } = string.Empty;
70+
71+
/// <summary>
72+
/// The original version of the entity that is being modified.
73+
/// </summary>
74+
public string OriginalJsonEntityEntity { get; set; } = string.Empty;
75+
}

0 commit comments

Comments
 (0)