Skip to content

Commit

Permalink
Added offline queue management (#23)
Browse files Browse the repository at this point in the history
* Offline DbContext and operations queue injection code.

* (#20) Tests for updating queue when the DbContext is changed.
  • Loading branch information
adrianhall authored May 17, 2024
1 parent a2f582c commit 93946b5
Show file tree
Hide file tree
Showing 14 changed files with 1,529 additions and 8 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,12 @@

<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Http" Version="8.0.0" />
<PackageReference Include="Microsoft.Azure.Core.Spatial" Version="1.1.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.4" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.4" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\CommunityToolkit.Datasync.Server.Abstractions\CommunityToolkit.Datasync.Server.Abstractions.csproj" />
</ItemGroup>

<ItemGroup>
<Reference Include="Microsoft.Azure.Core.Spatial">
<HintPath>..\..\..\..\..\..\..\Nuget\microsoft.azure.core.spatial\1.1.0\lib\netstandard2.0\Microsoft.Azure.Core.Spatial.dll</HintPath>
</Reference>
</ItemGroup>
</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
// 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 System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;

namespace CommunityToolkit.Datasync.Client.Offline;

/// <summary>
/// To support incremental sync, every synchronization event is recorded in the <see cref="OfflineDbContext"/>
/// together with the last time that the synchronization happened. This information is used to determine what
/// parameters to add to the query to get only the changes since the last sync.
/// </summary>
[Table("__datasync_tableinfo")]
public sealed class DatasyncTableInformation
{
/// <summary>
/// The unique ID for the query. This is generated from the table name
/// and query when not provided.
/// </summary>
[Key]
public string QueryId { get; set; } = string.Empty;

/// <summary>
/// The number of ticks when the last synchronization happened (or 0L if never synchronized).
/// </summary>
[Required]
public long LastSynchronization { get; set; } = 0L;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
// 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 Microsoft.EntityFrameworkCore.ChangeTracking;
using System.Text.Json;

namespace CommunityToolkit.Datasync.Client.Offline;

/// <summary>
/// Definition of the operations queue manager. This is the set of methods used to
/// maintain the queue of operations that need to be synchronized with the remote service.
/// </summary>
internal interface IOperationsQueueManager
{
/// <summary>
/// The <see cref="JsonSerializerOptions"/> to use for serializing and deserializing data.
/// </summary>
JsonSerializerOptions JsonSerializerOptions { get; }

/// <summary>
/// Creates a new Create/Insert/Add operation for the given state change.
/// </summary>
/// <param name="entry">The entry being processed.</param>
void AddCreateOperation(EntityEntry entry);

/// <summary>
/// Creates a new Delete operation for the given state change.
/// </summary>
/// <param name="entry">The entry being processed.</param>
void AddDeleteOperation(EntityEntry entry);

/// <summary>
/// Creates a new Modify/Update operation for the given state change.
/// </summary>
/// <param name="entry">The entry being processed.</param>
void AddUpdateOperation(EntityEntry entry);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// 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.

namespace CommunityToolkit.Datasync.Client;

/// <summary>
/// The base class for entities that are stored in an offline database.
/// </summary>
public abstract class OfflineClientEntity
{
/// <summary>
/// The globally unique ID for the entity.
/// </summary>
public string Id { get; set; }

/// <summary>
/// The date/time that the entity was last updated on the server.
/// </summary>
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.MinValue;

/// <summary>
/// The version of the entity on the server.
/// </summary>
public string Version { get; set; } = string.Empty;

/// <summary>
/// If <c>true</c>, the entity is deleted on the server.
/// </summary>
public bool Deleted { get; set; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
// 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 System.Diagnostics.CodeAnalysis;

namespace CommunityToolkit.Datasync.Client;

/// <summary>
/// A base exception class for all the exceptions thrown by the offline dataset processing.
/// </summary>
[ExcludeFromCodeCoverage(Justification = "Standard exception class")]
public class OfflineDatasetException : ApplicationException
{
/// <inheritdoc />
public OfflineDatasetException() : base()
{
}

/// <inheritdoc />
public OfflineDatasetException(string message) : base(message)
{
}

/// <inheritdoc />
public OfflineDatasetException(string message, Exception innerException) : base(message, innerException)
{
}
}
151 changes: 151 additions & 0 deletions src/CommunityToolkit.Datasync.Client/Offline/OfflineDbContext.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
// 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.Client.Offline;
using CommunityToolkit.Datasync.Server;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.ChangeTracking;
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;

namespace CommunityToolkit.Datasync.Client;

/// <summary>
/// The <see cref="OfflineDbContext"/> class is a base class for a <see cref="DbContext"/> that is
/// used to store offline data that is later synchronized to a remote datasync service.
/// </summary>
public abstract class OfflineDbContext : DbContext
{
/// <summary>
/// Initializes a new instance of the <see cref="OfflineDbContext"/> class. The <see cref="OnConfiguring(DbContextOptionsBuilder)"/>
/// method will be called to configure the database (and other options) to be used for this context.
/// </summary>
[ExcludeFromCodeCoverage(Justification = "Standard entry point that is part of DbContext")]
protected OfflineDbContext() : base()
{
}

/// <summary>
/// Initializes a new instance of the <see cref="OfflineDbContext"/> class. The <see cref="OnConfiguring(DbContextOptionsBuilder)"/>
/// method will still be called to configure the database to be used for this context.
/// </summary>
/// <param name="options">The options to use in configuring the <see cref="OfflineDbContext"/>.</param>
protected OfflineDbContext(DbContextOptions options) : base(options)
{
}

/// <summary>
/// The list of entities that are awaiting synchronization.
/// </summary>
public DbSet<OfflineQueueEntity> DatasyncOperationsQueue => Set<OfflineQueueEntity>();

/// <summary>
/// The metadata storage for each table synchronization set.
/// </summary>
public DbSet<DatasyncTableInformation> DatasyncTableMetadata => Set<DatasyncTableInformation>();

/// <summary>
/// The <see cref="JsonSerializerOptions"/> to use for serializing and deserializing entities.
/// </summary>
public JsonSerializerOptions JsonSerializerOptions { get; set; }

/// <summary>
/// Used to manage the operations queue internally.
/// </summary>
internal IOperationsQueueManager OperationsQueueManager { get; set; }

/// <inheritdoc />
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
base.OnConfiguring(optionsBuilder);
JsonSerializerOptions ??= new DatasyncServiceOptions().JsonSerializerOptions;
OperationsQueueManager ??= new OperationsQueueManager(this);
}

/// <inheritdoc />
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<OfflineQueueEntity>(entity =>
{
entity.HasKey(e => e.Id);
entity.HasIndex(e => e.EntityName);
entity.HasIndex(e => e.EntityId);
});

modelBuilder.Entity<DatasyncTableInformation>(entity =>
{
entity.HasKey(e => e.QueryId);
entity.Property(e => e.LastSynchronization).IsRequired().HasDefaultValue(0L);
});

base.OnModelCreating(modelBuilder);
}

/// <inheritdoc />
public virtual new int SaveChanges() => SaveChanges(true);

/// <inheritdoc />
public virtual new int SaveChanges(bool acceptAllChangesOnSuccess)
{
StoreChangesInOperationsQueue();
return base.SaveChanges(acceptAllChangesOnSuccess);
}

/// <inheritdoc />
public virtual new Task<int> SaveChangesAsync(CancellationToken cancellationToken = default) => SaveChangesAsync(true, cancellationToken);

/// <inheritdoc />
public virtual new Task<int> SaveChangesAsync(bool acceptAllChangesOnSuccess, CancellationToken cancellationToken = default)
{
StoreChangesInOperationsQueue();
return base.SaveChangesAsync(acceptAllChangesOnSuccess, cancellationToken);
}

/// <summary>
/// When <see cref="SaveChanges(bool)"/> or the async equivalent is called, this method looks through the changes
/// to see if any should be pushed to the service. An <see cref="OfflineQueueEntity"/> is created for each change.
/// </summary>
/// <remarks>
/// Every offline-capable entity must be derived from OfflineClientData and have a <see cref="DbSet{TEntity}"/> that
/// is using the entity.
/// </remarks>
internal void StoreChangesInOperationsQueue()
{
ChangeTracker.DetectChanges();
List<EntityEntry> changedEntities = ChangeTracker.Entries().Where(t => t.State is EntityState.Added or EntityState.Deleted or EntityState.Modified).ToList();
foreach (EntityEntry entry in changedEntities)
{
StoreChangeInOperationsQueue(entry);
}
}

/// <summary>
/// When <see cref="SaveChanges(bool)"/> or the async equivalent is called, this method is used to add the entry via
/// the operations queue manager. An <see cref="OfflineQueueEntity"/> is created for each change.
/// </summary>
/// <remarks>
/// Every offline-capable entity must be derived from OfflineClientData and have a <see cref="DbSet{TEntity}"/> that
/// is using the entity.
/// </remarks>
internal void StoreChangeInOperationsQueue(EntityEntry entry)
{
if (entry.Entity is OfflineClientEntity)
{
switch (entry.State)
{
case EntityState.Added:
OperationsQueueManager.AddCreateOperation(entry);
break;
case EntityState.Deleted:
OperationsQueueManager.AddDeleteOperation(entry);
break;
case EntityState.Modified:
OperationsQueueManager.AddUpdateOperation(entry);
break;
default:
throw new InvalidOperationException("Unknown entity state");
}
}
}
}
75 changes: 75 additions & 0 deletions src/CommunityToolkit.Datasync.Client/Offline/OfflineQueueEntity.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
// 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 Microsoft.EntityFrameworkCore;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;

namespace CommunityToolkit.Datasync.Client.Offline;

/// <summary>
/// The potential change types that can be recorded in the <see cref="OfflineQueueEntity"/>.
/// </summary>
public enum EntityChangeType
{
/// <summary>
/// The change type is unknown (and hence skipped - this is always an error).
/// </summary>
Unknown,

/// <summary>
/// The entity is being added.
/// </summary>
Add,

/// <summary>
/// The entity is to be deleted.
/// </summary>
Delete,

/// <summary>
/// The entity is being updated.
/// </summary>
Update
}

/// <summary>
/// When an entity is modified while offline, the changes are stored in the <see cref="OfflineDbContext"/>
/// in an operations queue. This entity is used to record what changes are pending push to the server.
/// </summary>
[Table("__datasync_opsqueue")]
[Index(nameof(EntityName))]
public sealed class OfflineQueueEntity
{
/// <summary>
/// The globally unique ID for the change.
/// </summary>
[Key]
public string Id { get; set; } = Guid.NewGuid().ToString("N");

/// <summary>
/// The type of the change (Add, Delete, or Update).
/// </summary>
public EntityChangeType ChangeType { get; set; } = EntityChangeType.Unknown;

/// <summary>
/// The name of the entity or table that is being modified.
/// </summary>
public string EntityName { get; set; } = string.Empty;

/// <summary>
/// The ID of the entity that is being modified.
/// </summary>
public string EntityId { get; set; } = string.Empty;

/// <summary>
/// The replacement version of the entity that is being modified.
/// </summary>
public string ReplacementJsonEntityData { get; set; } = string.Empty;

/// <summary>
/// The original version of the entity that is being modified.
/// </summary>
public string OriginalJsonEntityEntity { get; set; } = string.Empty;
}
Loading

0 comments on commit 93946b5

Please sign in to comment.