From aa70ebbe38eafb5d21dd4f20726a780310582958 Mon Sep 17 00:00:00 2001 From: Adrian Hall Date: Wed, 7 Aug 2024 19:59:03 -0700 Subject: [PATCH 1/6] feat: DatasyncSynchronizationContextBuilder WIP --- .../CommunityToolkit.Datasync.Client.csproj | 2 +- .../DatasyncSynchronizationContextBuilder.cs | 80 +++++++++++++++++ .../Offline/OfflineDbContext.cs | 86 +++++++++++++++++++ .../Service/ServiceErrorMessages.cs | 10 +++ 4 files changed, 177 insertions(+), 1 deletion(-) create mode 100644 src/CommunityToolkit.Datasync.Client/Offline/DatasyncSynchronizationContextBuilder.cs create mode 100644 src/CommunityToolkit.Datasync.Client/Offline/OfflineDbContext.cs diff --git a/src/CommunityToolkit.Datasync.Client/CommunityToolkit.Datasync.Client.csproj b/src/CommunityToolkit.Datasync.Client/CommunityToolkit.Datasync.Client.csproj index 93b7370..afc03d6 100644 --- a/src/CommunityToolkit.Datasync.Client/CommunityToolkit.Datasync.Client.csproj +++ b/src/CommunityToolkit.Datasync.Client/CommunityToolkit.Datasync.Client.csproj @@ -5,7 +5,7 @@ - + diff --git a/src/CommunityToolkit.Datasync.Client/Offline/DatasyncSynchronizationContextBuilder.cs b/src/CommunityToolkit.Datasync.Client/Offline/DatasyncSynchronizationContextBuilder.cs new file mode 100644 index 0000000..af8d929 --- /dev/null +++ b/src/CommunityToolkit.Datasync.Client/Offline/DatasyncSynchronizationContextBuilder.cs @@ -0,0 +1,80 @@ +// 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.Service; + +namespace CommunityToolkit.Datasync.Client; + +/// +/// The builder that is used to generate a map of the entities and other associated +/// information for the synchronization context. +/// +public class DatasyncSynchronizationContextBuilder +{ + private IHttpClientFactory? httpClientFactory; + private HttpClient? httpClient; + private Uri? endpoint; + + /// + /// true if a HttpClient generator has already been set. + /// + public bool HasHttpClientGenerator { get => this.httpClientFactory != null || this.httpClient != null || this.endpoint != null; } + + /// + /// Sets the HttpClient generator to be an . + /// + /// The to use for generating clients. + /// This object for chaining. + /// If the HttpClient generator is already set. + public DatasyncSynchronizationContextBuilder SetHttpClientFactory(IHttpClientFactory httpClientFactory) + { + ArgumentNullException.ThrowIfNull(httpClientFactory, nameof(httpClientFactory)); + ThrowIfGeneratorSet(); + this.httpClientFactory = httpClientFactory; + return this; + } + + /// + /// Sets the HttpClient generator to be a specific . + /// + /// The to use as the client. + /// This object for chaining. + /// If the HttpClient generator is already set. + public DatasyncSynchronizationContextBuilder SetHttpClient(HttpClient httpClient) + { + ArgumentNullException.ThrowIfNull(httpClient, nameof(httpClient)); + ThrowIfGeneratorSet(); + this.httpClient = httpClient; + return this; + } + + /// + /// Sets the HttpClient generator to produce HttpClients that connect to a specific endpoint. + /// + /// The datasync service endpoint. + /// This object for chaining. + /// If the HttpClient generator is already set. + /// If the endpoint is not a valid endpoint for datasync operations. + public DatasyncSynchronizationContextBuilder SetEndpoint(Uri endpoint) + { + ArgumentNullException.ThrowIfNull(endpoint, nameof(endpoint)); + ThrowIf.IsNotValidEndpoint(endpoint, nameof(endpoint)); + ThrowIfGeneratorSet(); + this.endpoint = endpoint; + return this; + } + + /// + /// Throws a if the HttpClient generator is already set. + /// + /// Thrown if the HttpClient generator is already set. + private void ThrowIfGeneratorSet() + { + if (HasHttpClientGenerator) + { + throw new DatasyncException(ServiceErrorMessages.HttpClientGeneratorAlreadySet); + + } + } +} diff --git a/src/CommunityToolkit.Datasync.Client/Offline/OfflineDbContext.cs b/src/CommunityToolkit.Datasync.Client/Offline/OfflineDbContext.cs new file mode 100644 index 0000000..6dbdde7 --- /dev/null +++ b/src/CommunityToolkit.Datasync.Client/Offline/OfflineDbContext.cs @@ -0,0 +1,86 @@ +// 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.Service; +using Microsoft.EntityFrameworkCore; + +namespace CommunityToolkit.Datasync.Client; + +/// +/// An instance represents a session with the database and can be used to query and save +/// instances of your entities just like a . It additionally supports synchronization handling to +/// a remote datasync service. +/// +public abstract class OfflineDbContext : DbContext +{ + /// + /// Initializes a new instance of the class. The + /// method will be called to configure the database (and other options) to be used for this context. + /// + protected OfflineDbContext() : base() + { + } + /// + /// Initializes a new instance of the class using the specified options. The + /// method will still be called to allow further configuration of the options. + /// + /// The options for this context. + protected OfflineDbContext(DbContextOptions contextOptions) : base(contextOptions) + { + } + + /// + /// Override this method to set defaults and configure conventions before they run. This method is invoked before + /// . + /// + /// The builder being used to set defaults and configure conventions that will be used to build the model for this context. + protected new virtual void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) + { + base.ConfigureConventions(configurationBuilder); + } + + /// + /// Override this method to configure the database (and other options) to be used for this context. This method is called for + /// each instance of the context that is created. The base implementation does nothing. + /// + /// + /// A builder used to create or modify options for this context. Databases (and other extensions) typically define extension + /// methods on this object that allow you to configure the context. + /// + protected new virtual void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + base.OnConfiguring(optionsBuilder); + } + + /// + /// Override this method to further configure the model that was discovered by convention from the entity types exposed in + /// properties on your derived context. The resulting model may be cached and re-used for subsequent + /// instances of your derived context. + /// + /// + /// The builder being used to construct the model for this context. Databases (and other extensions) typically define extension + /// methods on this object that allow you to configure aspects of the model that are specific to a given database. + /// + protected new virtual void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + } + + /// + /// Override this method to configure the settings on each entity that will be used to synchronize data to the service. + /// + /// + /// At a minimum, this method must be overridden to set the mechanism by which the used to + /// communicate with the remote datasync service will be generated. You can simply specify an endpoint, but it will + /// be more normal to set up a to generate clients for your service. + /// + /// The builder being used to construct the datasync synchronization context. + protected virtual void OnDatasyncInitializing(DatasyncSynchronizationContextBuilder contextBuilder) + { + if (!contextBuilder.HasHttpClientGenerator) + { + throw new DatasyncException(ServiceErrorMessages.NoHttpClientGenerator); + } + } +} diff --git a/src/CommunityToolkit.Datasync.Client/Service/ServiceErrorMessages.cs b/src/CommunityToolkit.Datasync.Client/Service/ServiceErrorMessages.cs index 51f10fd..7455f62 100644 --- a/src/CommunityToolkit.Datasync.Client/Service/ServiceErrorMessages.cs +++ b/src/CommunityToolkit.Datasync.Client/Service/ServiceErrorMessages.cs @@ -9,6 +9,11 @@ namespace CommunityToolkit.Datasync.Client.Service; /// internal static class ServiceErrorMessages { + /// + /// The HttpClient generator has already been set. + /// + internal static string HttpClientGeneratorAlreadySet = "The HttpClient generator has already been set."; + /// /// The content received from the service was invalid. /// @@ -19,6 +24,11 @@ internal static class ServiceErrorMessages /// internal static string InvalidVersion = "The version string cannot contain illegal characters."; + /// + /// When initializing the datasync service connector, no HttpClient generator was created. + /// + internal static string NoHttpClientGenerator = "No HttpClient generator was configured in the OnDatasyncInitializing() method of your OfflineDbContext."; + /// /// Server expected to send content, but returned a successful response without content. /// From e103405dbe4e8bb7c16e2755e13057d5c6cfbfed Mon Sep 17 00:00:00 2001 From: Adrian Hall Date: Thu, 8 Aug 2024 14:03:03 -0700 Subject: [PATCH 2/6] wip: tests for DatasyncSynchronizationContextBuilder --- .../DatasyncSynchronizationContextBuilder.cs | 12 ++++- ...syncSynchronizationContextBuilder_Tests.cs | 45 +++++++++++++++++++ 2 files changed, 56 insertions(+), 1 deletion(-) create mode 100644 tests/CommunityToolkit.Datasync.Client.Test/Offline/DatasyncSynchronizationContextBuilder_Tests.cs diff --git a/src/CommunityToolkit.Datasync.Client/Offline/DatasyncSynchronizationContextBuilder.cs b/src/CommunityToolkit.Datasync.Client/Offline/DatasyncSynchronizationContextBuilder.cs index af8d929..952dff3 100644 --- a/src/CommunityToolkit.Datasync.Client/Offline/DatasyncSynchronizationContextBuilder.cs +++ b/src/CommunityToolkit.Datasync.Client/Offline/DatasyncSynchronizationContextBuilder.cs @@ -12,8 +12,19 @@ namespace CommunityToolkit.Datasync.Client; /// public class DatasyncSynchronizationContextBuilder { + /// + /// When set, the to use for generation. + /// private IHttpClientFactory? httpClientFactory; + + /// + /// When set, the to use for all connections. + /// private HttpClient? httpClient; + + /// + /// When set, the mapping to the datasync service endpoint. + /// private Uri? endpoint; /// @@ -74,7 +85,6 @@ private void ThrowIfGeneratorSet() if (HasHttpClientGenerator) { throw new DatasyncException(ServiceErrorMessages.HttpClientGeneratorAlreadySet); - } } } diff --git a/tests/CommunityToolkit.Datasync.Client.Test/Offline/DatasyncSynchronizationContextBuilder_Tests.cs b/tests/CommunityToolkit.Datasync.Client.Test/Offline/DatasyncSynchronizationContextBuilder_Tests.cs new file mode 100644 index 0000000..d8fd1e4 --- /dev/null +++ b/tests/CommunityToolkit.Datasync.Client.Test/Offline/DatasyncSynchronizationContextBuilder_Tests.cs @@ -0,0 +1,45 @@ +// 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.Test.Offline; + +[ExcludeFromCodeCoverage] +public class DatasyncSynchronizationContextBuilder_Tests +{ + [Fact] + public void SetHttpClientFactory_Builds() + { + throw new NotImplementedException(); + } + + [Fact] + public void SetHttpClientFactory_Throws_Set() + { + throw new NotImplementedException(); + } + + [Fact] + public void SetHttpClient_Builds() + { + throw new NotImplementedException(); + } + + [Fact] + public void SetHttpClient_Throws_Set() + { + throw new NotImplementedException(); + } + + [Fact] + public void SetEndpoint_Builds() + { + throw new NotImplementedException(); + } + + [Fact] + public void SetEndpoint_Throws_Set() + { + throw new NotImplementedException(); + } +} From 615fc3c81aaa42ed372abd11ad4b268cf2222823 Mon Sep 17 00:00:00 2001 From: Adrian Hall Date: Fri, 9 Aug 2024 10:58:02 -0700 Subject: [PATCH 3/6] wip: SaveChanges / SaveChangesAsync / OperationsQueue --- .../Exceptions/DatasyncException.cs | 28 ++ .../Offline/DatasyncOperation.cs | 79 ++++ .../DatasyncSynchronizationContextBuilder.cs | 90 ---- .../Offline/DoNotSynchronizeAttribute.cs | 20 + .../Offline/OfflineDbContext.cs | 444 ++++++++++++++++-- ...syncSynchronizationContextBuilder_Tests.cs | 45 -- .../Offline/OfflineDbContext_Tests.cs | 137 ++++++ 7 files changed, 674 insertions(+), 169 deletions(-) create mode 100644 src/CommunityToolkit.Datasync.Client/Offline/DatasyncOperation.cs delete mode 100644 src/CommunityToolkit.Datasync.Client/Offline/DatasyncSynchronizationContextBuilder.cs create mode 100644 src/CommunityToolkit.Datasync.Client/Offline/DoNotSynchronizeAttribute.cs delete mode 100644 tests/CommunityToolkit.Datasync.Client.Test/Offline/DatasyncSynchronizationContextBuilder_Tests.cs create mode 100644 tests/CommunityToolkit.Datasync.Client.Test/Offline/OfflineDbContext_Tests.cs diff --git a/src/CommunityToolkit.Datasync.Client/Exceptions/DatasyncException.cs b/src/CommunityToolkit.Datasync.Client/Exceptions/DatasyncException.cs index 8e7b6d1..b1c0f82 100644 --- a/src/CommunityToolkit.Datasync.Client/Exceptions/DatasyncException.cs +++ b/src/CommunityToolkit.Datasync.Client/Exceptions/DatasyncException.cs @@ -26,4 +26,32 @@ public DatasyncException(string? message) : base(message) public DatasyncException(string? message, Exception? innerException) : base(message, innerException) { } + + /// + /// A helper method to throw the if the value is null. + /// + /// The value to check. + /// The message for this exception. + /// Thrown if is null. + internal static void ThrowIfNull(object? value, string? message) + { + if (value is null) + { + throw new DatasyncException(message); + } + } + + /// + /// A helper method to throw the if the value is null or empty. + /// + /// The value to check. + /// The message for this exception. + /// Thrown if is null or empty. + internal static void ThrowIfNullOrEmpty(string? value, string? message) + { + if (string.IsNullOrEmpty(value)) + { + throw new DatasyncException(message); + } + } } diff --git a/src/CommunityToolkit.Datasync.Client/Offline/DatasyncOperation.cs b/src/CommunityToolkit.Datasync.Client/Offline/DatasyncOperation.cs new file mode 100644 index 0000000..a9209cd --- /dev/null +++ b/src/CommunityToolkit.Datasync.Client/Offline/DatasyncOperation.cs @@ -0,0 +1,79 @@ +// 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.Offline; + +/// +/// The kind of operation represented by a entity. +/// +public enum OperationKind +{ + /// The operation represents an addition to the entity set. + Add, + /// The operation represents a deletion from the entity set. + Delete, + /// The operation represents a replacement of the entity within the entity set. + Replace +} + +/// +/// The current state of the pending operation. +/// +public enum OperationState +{ + /// The operation has not been completed yet. + Pending, + /// The operation has been completed successfully. + Completed, + /// The operation failed. + Failed +} + +/// +/// An entity representing a pending operation against an entity set. +/// +public record DatasyncOperation +{ + /// + /// A unique ID for the operation. + /// + public required string Id { get; set; } + + /// + /// The kind of operation that this entity represents. + /// + public required OperationKind Kind { get; set; } + + /// + /// The current state of the operation. + /// + public required OperationState State { get; set; } + + /// + /// The fully qualified name of the entity type. + /// + public required string EntityType { get; set; } + + /// + /// The globally unique ID of the entity. + /// + public required string ItemId { get; set; } + + /// + /// The JSON-encoded representation of the Item. + /// + public required string Item { get; set; } + + /// + /// The sequence number for the operation. This is incremented for each + /// new operation to a different entity. + /// + public required int Sequence { get; set; } + + /// + /// The version number for the operation. This is incremented as multiple + /// changes to the same entity are performed in between pushes. + /// + public required int Version { get; set; } +} diff --git a/src/CommunityToolkit.Datasync.Client/Offline/DatasyncSynchronizationContextBuilder.cs b/src/CommunityToolkit.Datasync.Client/Offline/DatasyncSynchronizationContextBuilder.cs deleted file mode 100644 index 952dff3..0000000 --- a/src/CommunityToolkit.Datasync.Client/Offline/DatasyncSynchronizationContextBuilder.cs +++ /dev/null @@ -1,90 +0,0 @@ -// 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.Service; - -namespace CommunityToolkit.Datasync.Client; - -/// -/// The builder that is used to generate a map of the entities and other associated -/// information for the synchronization context. -/// -public class DatasyncSynchronizationContextBuilder -{ - /// - /// When set, the to use for generation. - /// - private IHttpClientFactory? httpClientFactory; - - /// - /// When set, the to use for all connections. - /// - private HttpClient? httpClient; - - /// - /// When set, the mapping to the datasync service endpoint. - /// - private Uri? endpoint; - - /// - /// true if a HttpClient generator has already been set. - /// - public bool HasHttpClientGenerator { get => this.httpClientFactory != null || this.httpClient != null || this.endpoint != null; } - - /// - /// Sets the HttpClient generator to be an . - /// - /// The to use for generating clients. - /// This object for chaining. - /// If the HttpClient generator is already set. - public DatasyncSynchronizationContextBuilder SetHttpClientFactory(IHttpClientFactory httpClientFactory) - { - ArgumentNullException.ThrowIfNull(httpClientFactory, nameof(httpClientFactory)); - ThrowIfGeneratorSet(); - this.httpClientFactory = httpClientFactory; - return this; - } - - /// - /// Sets the HttpClient generator to be a specific . - /// - /// The to use as the client. - /// This object for chaining. - /// If the HttpClient generator is already set. - public DatasyncSynchronizationContextBuilder SetHttpClient(HttpClient httpClient) - { - ArgumentNullException.ThrowIfNull(httpClient, nameof(httpClient)); - ThrowIfGeneratorSet(); - this.httpClient = httpClient; - return this; - } - - /// - /// Sets the HttpClient generator to produce HttpClients that connect to a specific endpoint. - /// - /// The datasync service endpoint. - /// This object for chaining. - /// If the HttpClient generator is already set. - /// If the endpoint is not a valid endpoint for datasync operations. - public DatasyncSynchronizationContextBuilder SetEndpoint(Uri endpoint) - { - ArgumentNullException.ThrowIfNull(endpoint, nameof(endpoint)); - ThrowIf.IsNotValidEndpoint(endpoint, nameof(endpoint)); - ThrowIfGeneratorSet(); - this.endpoint = endpoint; - return this; - } - - /// - /// Throws a if the HttpClient generator is already set. - /// - /// Thrown if the HttpClient generator is already set. - private void ThrowIfGeneratorSet() - { - if (HasHttpClientGenerator) - { - throw new DatasyncException(ServiceErrorMessages.HttpClientGeneratorAlreadySet); - } - } -} diff --git a/src/CommunityToolkit.Datasync.Client/Offline/DoNotSynchronizeAttribute.cs b/src/CommunityToolkit.Datasync.Client/Offline/DoNotSynchronizeAttribute.cs new file mode 100644 index 0000000..5535ef9 --- /dev/null +++ b/src/CommunityToolkit.Datasync.Client/Offline/DoNotSynchronizeAttribute.cs @@ -0,0 +1,20 @@ +// 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.Offline; + +/// +/// An attribute for signifying that the DbSet should not be synchronized with +/// a remote datasync service. +/// +[AttributeUsage(AttributeTargets.Property)] +public class DoNotSynchronizeAttribute : Attribute +{ + /// + /// Creates a new instance. + /// + public DoNotSynchronizeAttribute() + { + } +} diff --git a/src/CommunityToolkit.Datasync.Client/Offline/OfflineDbContext.cs b/src/CommunityToolkit.Datasync.Client/Offline/OfflineDbContext.cs index 6dbdde7..1a3cb67 100644 --- a/src/CommunityToolkit.Datasync.Client/Offline/OfflineDbContext.cs +++ b/src/CommunityToolkit.Datasync.Client/Offline/OfflineDbContext.cs @@ -2,85 +2,461 @@ // 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.Service; +using CommunityToolkit.Datasync.Client.Serialization; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.ChangeTracking; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.EntityFrameworkCore.Infrastructure; +using System.Diagnostics; +using System.Reflection; -namespace CommunityToolkit.Datasync.Client; +namespace CommunityToolkit.Datasync.Client.Offline; /// -/// An instance represents a session with the database and can be used to query and save -/// instances of your entities just like a . It additionally supports synchronization handling to -/// a remote datasync service. +/// A instance represents a session with the database and can be used to query and save +/// instances of your entities. An additionally supports synchronization operations +/// with a remote datasync service. is a combination of the Unit Of Work and Repository patterns. /// +/// +/// +/// Entity Framework Core does not support multiple parallel operations being run on the same DbContext instance. This +/// includes both parallel execution of async queries and any explicit concurrent use from multiple threads. +/// Therefore, always await async calls immediately, or use separate DbContext instances for operations that execute +/// in parallel. See Avoiding DbContext threading issues for more information +/// and examples. +/// +/// +/// Typically you create a class that derives from DbContext and contains +/// properties for each entity in the model. If the properties have a public setter, +/// they are automatically initialized when the instance of the derived context is created. +/// +/// +/// Override the method to configure the database (and +/// other options) to be used for the context. Alternatively, if you would rather perform configuration externally +/// instead of inline in your context, you can use +/// (or ) to externally create an instance of +/// (or ) and pass it to a base constructor of . +/// +/// +/// The model is discovered by running a set of conventions over the entity classes found in the +/// properties on the derived context. To further configure the model that +/// is discovered by convention, you can override the method. +/// +/// +/// See DbContext lifetime, configuration, and initialization, +/// Querying data with EF Core, +/// Changing tracking, and +/// Saving data with EF Core for more information and examples. +/// +/// public abstract class OfflineDbContext : DbContext { /// - /// Initializes a new instance of the class. The - /// method will be called to configure the database (and other options) to be used for this context. + /// A checker for the disposed state of this context. /// + internal bool _disposedValue; + + /// + /// A mapping of all the entities that are allowed to be synchronized in this context. + /// + internal Dictionary DatasyncEntityMap { get; } = []; + + /// + /// The operations queue. This is used to store pending requests to the datasync service. + /// + [DoNotSynchronize] + public DbSet DatasyncOperationsQueue => Set(); + + /// + /// Initializes a new instance of the class. The + /// method will be called to + /// configure the database (and other options) to be used for this context. + /// + /// + /// See DbContext lifetime, configuration, and initialization + /// for more information and examples. + /// protected OfflineDbContext() : base() { } + /// - /// Initializes a new instance of the class using the specified options. The - /// method will still be called to allow further configuration of the options. + /// Initializes a new instance of the class using the specified options. + /// The method will still be called to allow further + /// configuration of the options. /// - /// The options for this context. - protected OfflineDbContext(DbContextOptions contextOptions) : base(contextOptions) + /// + /// See DbContext lifetime, configuration, and initialization and + /// Using DbContextOptions for more information and examples. + /// + /// The options for this context. + protected OfflineDbContext(DbContextOptions options) : base(options) { } /// - /// Override this method to set defaults and configure conventions before they run. This method is invoked before - /// . + /// Override this method to set defaults and configure conventions before they run. This method is invoked before + /// . /// - /// The builder being used to set defaults and configure conventions that will be used to build the model for this context. - protected new virtual void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) + /// + /// + /// If a model is explicitly set on the options for this context then this method will not be run. However, it will + /// still run when creating a compiled model. + /// + /// + /// See Pre-convention model building in EF Core for + /// more information and examples. + /// + /// + /// + /// The builder being used to set defaults and configure conventions that will be used to build the model for this context. + /// + protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) { base.ConfigureConventions(configurationBuilder); } /// - /// Override this method to configure the database (and other options) to be used for this context. This method is called for - /// each instance of the context that is created. The base implementation does nothing. + /// Override this method to configure the database (and other options) to be used for this context. + /// This method is called for each instance of the context that is created. + /// The base implementation does nothing. /// + /// + /// + /// In situations where an instance of may or may not have been passed + /// to the constructor, you can use to determine if + /// the options have already been set, and skip some or all of the logic in + /// . + /// + /// + /// See DbContext lifetime, configuration, and initialization + /// for more information and examples. + /// + /// /// - /// A builder used to create or modify options for this context. Databases (and other extensions) typically define extension - /// methods on this object that allow you to configure the context. + /// A builder used to create or modify options for this context. Databases (and other extensions) + /// typically define extension methods on this object that allow you to configure the context. /// - protected new virtual void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { base.OnConfiguring(optionsBuilder); } /// - /// Override this method to further configure the model that was discovered by convention from the entity types exposed in - /// properties on your derived context. The resulting model may be cached and re-used for subsequent - /// instances of your derived context. + /// Override this method to further configure the model that was discovered by convention from the entity types + /// exposed in properties on your derived context. The resulting model may be cached + /// and re-used for subsequent instances of your derived context. /// + /// + /// + /// If a model is explicitly set on the options for this context then this method will not be run. However, it + /// will still run when creating a compiled model. This may cause the entities + /// to not be configured properly. + /// + /// + /// See Modeling entity types and relationships for more + /// information and examples. + /// + /// /// - /// The builder being used to construct the model for this context. Databases (and other extensions) typically define extension - /// methods on this object that allow you to configure aspects of the model that are specific to a given database. + /// The builder being used to construct the model for this context. Databases (and other extensions) typically + /// define extension methods on this object that allow you to configure aspects of the model that are specific + /// to a given database. /// - protected new virtual void OnModelCreating(ModelBuilder modelBuilder) + protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); } /// - /// Override this method to configure the settings on each entity that will be used to synchronize data to the service. + /// Saves all changes made in this context to the database. + /// + /// + /// + /// This method will automatically call + /// to discover any changes to entity instances before saving to the underlying database. This can be disabled via + /// . + /// + /// + /// Entity Framework Core does not support multiple parallel operations being run on the same DbContext instance. This + /// includes both parallel execution of async queries and any explicit concurrent use from multiple threads. + /// Therefore, always await async calls immediately, or use separate DbContext instances for operations that execute + /// in parallel. See Avoiding DbContext threading issues for more information + /// and examples. + /// + /// + /// See Saving data in EF Core for more information and examples. + /// + /// + /// + /// The number of state entries written to the database. + /// + /// + /// An error is encountered while saving to the database. + /// + /// + /// A concurrency violation is encountered while saving to the database. + /// A concurrency violation occurs when an unexpected number of rows are affected during save. + /// This is usually because the data in the database has been modified since it was loaded into memory. + /// + public override int SaveChanges() + => SaveChanges(acceptAllChangesOnSuccess: true); + + /// + /// Saves all changes made in this context to the database. + /// + /// + /// + /// This method will automatically call + /// to discover any changes to entity instances before saving to the underlying database. This can be disabled via + /// . + /// + /// + /// Entity Framework Core does not support multiple parallel operations being run on the same DbContext instance. This + /// includes both parallel execution of async queries and any explicit concurrent use from multiple threads. + /// Therefore, always await async calls immediately, or use separate DbContext instances for operations that execute + /// in parallel. See Avoiding DbContext threading issues for more information + /// and examples. + /// + /// + /// See Saving data in EF Core for more information and examples. + /// + /// + /// + /// Indicates whether is called after the changes have been sent successfully to the database. + /// + /// + /// The number of state entries written to the database. + /// + /// + /// An error is encountered while saving to the database. + /// + /// + /// A concurrency violation is encountered while saving to the database. + /// A concurrency violation occurs when an unexpected number of rows are affected during save. + /// This is usually because the data in the database has been modified since it was loaded into memory. + /// + public override int SaveChanges(bool acceptAllChangesOnSuccess) + { + UpdateOperationsQueue(); + return base.SaveChanges(acceptAllChangesOnSuccess); + } + + /// + /// Saves all changes made in this context to the database. + /// + /// + /// + /// This method will automatically call + /// to discover any changes to entity instances before saving to the underlying database. This can be disabled via + /// . + /// + /// + /// Entity Framework Core does not support multiple parallel operations being run on the same DbContext instance. This + /// includes both parallel execution of async queries and any explicit concurrent use from multiple threads. + /// Therefore, always await async calls immediately, or use separate DbContext instances for operations that execute + /// in parallel. See Avoiding DbContext threading issues for more + /// information and examples. + /// + /// + /// See Saving data in EF Core for more information and examples. + /// + /// + /// A to observe while waiting for the task to complete. + /// + /// A task that represents the asynchronous save operation. The task result contains the + /// number of state entries written to the database. + /// + /// + /// An error is encountered while saving to the database. + /// + /// + /// A concurrency violation is encountered while saving to the database. + /// A concurrency violation occurs when an unexpected number of rows are affected during save. + /// This is usually because the data in the database has been modified since it was loaded into memory. + /// + /// If the is canceled. + public override Task SaveChangesAsync(CancellationToken cancellationToken = default) + => SaveChangesAsync(acceptAllChangesOnSuccess: true, cancellationToken); + + /// + /// Saves all changes made in this context to the database. + /// + /// + /// + /// This method will automatically call + /// to discover any changes to entity instances before saving to the underlying database. This can be disabled via + /// . + /// + /// + /// Entity Framework Core does not support multiple parallel operations being run on the same DbContext instance. This + /// includes both parallel execution of async queries and any explicit concurrent use from multiple threads. + /// Therefore, always await async calls immediately, or use separate DbContext instances for operations that execute + /// in parallel. See Avoiding DbContext threading issues for more + /// information and examples. + /// + /// + /// See Saving data in EF Core for more information and examples. + /// + /// + /// + /// Indicates whether is called after + /// the changes have been sent successfully to the database. + /// + /// A to observe while waiting for the task to complete. + /// + /// A task that represents the asynchronous save operation. The task result contains the + /// number of state entries written to the database. + /// + /// + /// An error is encountered while saving to the database. + /// + /// + /// A concurrency violation is encountered while saving to the database. + /// A concurrency violation occurs when an unexpected number of rows are affected during save. + /// This is usually because the data in the database has been modified since it was loaded into memory. + /// + /// If the is canceled. + public override Task SaveChangesAsync(bool acceptAllChangesOnSuccess, CancellationToken cancellationToken = default) + { + UpdateOperationsQueue(); + return base.SaveChangesAsync(acceptAllChangesOnSuccess, cancellationToken); + } + + /// + /// Initializes the value of the - this provides the mapping + /// of entity name to type, which is required for operating the operations queue. /// /// - /// At a minimum, this method must be overridden to set the mechanism by which the used to - /// communicate with the remote datasync service will be generated. You can simply specify an endpoint, but it will - /// be more normal to set up a to generate clients for your service. + /// An entity is "synchronization ready" if: + /// + /// * It is a property on this context + /// * The property is public and a . + /// * The property does not have a specified. + /// * The entity type is defined in the model. + /// * The entity type has an Id, UpdatedAt, and Version property (according to the ). /// - /// The builder being used to construct the datasync synchronization context. - protected virtual void OnDatasyncInitializing(DatasyncSynchronizationContextBuilder contextBuilder) + internal void InitializeDatasyncEntityMap() { - if (!contextBuilder.HasHttpClientGenerator) + Type[] modelEntities = Model.GetEntityTypes().Select(m => m.ClrType).ToArray(); + Type[] synchronizableEntities = GetType().GetProperties(BindingFlags.Public | BindingFlags.Instance) + .Where(IsSynchronizationEntity) + .Select(p => p.PropertyType.GetGenericArguments()[0]) + .Where(m => modelEntities.Contains(m)) + .ToArray(); + foreach (Type entityType in synchronizableEntities) { - throw new DatasyncException(ServiceErrorMessages.NoHttpClientGenerator); + DatasyncException.ThrowIfNullOrEmpty(entityType.FullName, $"Offline entity {entityType.Name} must be a valid reference type."); + EntityResolver.EntityPropertyInfo propInfo = EntityResolver.GetEntityPropertyInfo(entityType); + DatasyncException.ThrowIfNull(propInfo.UpdatedAtPropertyInfo, $"Offline entity {entityType.Name} does not have an UpdatedAt property."); + DatasyncException.ThrowIfNull(propInfo.VersionPropertyInfo, $"Offline entity {entityType.Name} does not have a Version property."); + DatasyncEntityMap.Add(entityType.FullName!, entityType); } } + + /// + /// Determines if the provided property is a synchronizable property. + /// + /// + /// An entity is "synchronization ready" if: + /// + /// * It is a property on this context + /// * The property is public and a . + /// * The property does not have a specified. + /// + /// The for the property to check. + /// true if the property is synchronizable; false otherwise. + internal bool IsSynchronizationEntity(PropertyInfo property) + { + if (property.PropertyType.IsGenericType && property.PropertyType.GetGenericTypeDefinition() == typeof(DbSet<>)) + { + if (property.GetCustomAttribute() == null) + { + return true; + } + } + + return false; + } + + /// + /// Iterates through each of the changes in the dataset prior to calling + /// or to add each change to the operations queue. + /// + internal void UpdateOperationsQueue() + { + CheckDisposed(); + InitializeDatasyncEntityMap(); + + if (ChangeTracker.AutoDetectChangesEnabled) + { + ChangeTracker.DetectChanges(); + } + + // Get the list of relevant changes from the change tracker: + List entitiesInScope = ChangeTracker.Entries() + .Where(e => e.State is EntityState.Added or EntityState.Modified or EntityState.Deleted) + .Where(e => DatasyncEntityMap.ContainsKey(GetNullScope(e.Entity.GetType().FullName))) + .ToList(); + + // Rest of the tracker here. + throw new NotImplementedException(); + } + + /// + /// a helper method for returning the empty string instead of null when there is a nullable string. + /// + /// The nullable string + /// The non-nullable string. + internal static string GetNullScope(string? nullableString) + => nullableString ?? string.Empty; + + #region IDisposable + /// + /// Ensure that the context has not been disposed. + /// + /// If the context has been disposed already. + [DebuggerStepThrough] + internal void CheckDisposed() + { + if (this._disposedValue) + { + throw new ObjectDisposedException(GetType().ShortDisplayName(), CoreStrings.ContextDisposed); + } + } + + /// + /// Releases the allocated resources for this context. + /// + /// + /// + /// See DbContext lifetime, configuration, and initialization + /// for more information and examples. + /// + protected virtual void Dispose(bool disposing) + { + if (!this._disposedValue) + { + if (disposing) + { + base.Dispose(); + } + + this._disposedValue = true; + } + } + + /// + /// Releases the allocated resources for this context. + /// + /// + /// See DbContext lifetime, configuration, and initialization + /// for more information and examples. + /// + public override void Dispose() + { + Dispose(disposing: true); + GC.SuppressFinalize(this); + } + #endregion } diff --git a/tests/CommunityToolkit.Datasync.Client.Test/Offline/DatasyncSynchronizationContextBuilder_Tests.cs b/tests/CommunityToolkit.Datasync.Client.Test/Offline/DatasyncSynchronizationContextBuilder_Tests.cs deleted file mode 100644 index d8fd1e4..0000000 --- a/tests/CommunityToolkit.Datasync.Client.Test/Offline/DatasyncSynchronizationContextBuilder_Tests.cs +++ /dev/null @@ -1,45 +0,0 @@ -// 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.Test.Offline; - -[ExcludeFromCodeCoverage] -public class DatasyncSynchronizationContextBuilder_Tests -{ - [Fact] - public void SetHttpClientFactory_Builds() - { - throw new NotImplementedException(); - } - - [Fact] - public void SetHttpClientFactory_Throws_Set() - { - throw new NotImplementedException(); - } - - [Fact] - public void SetHttpClient_Builds() - { - throw new NotImplementedException(); - } - - [Fact] - public void SetHttpClient_Throws_Set() - { - throw new NotImplementedException(); - } - - [Fact] - public void SetEndpoint_Builds() - { - throw new NotImplementedException(); - } - - [Fact] - public void SetEndpoint_Throws_Set() - { - throw new NotImplementedException(); - } -} diff --git a/tests/CommunityToolkit.Datasync.Client.Test/Offline/OfflineDbContext_Tests.cs b/tests/CommunityToolkit.Datasync.Client.Test/Offline/OfflineDbContext_Tests.cs new file mode 100644 index 0000000..7546ea9 --- /dev/null +++ b/tests/CommunityToolkit.Datasync.Client.Test/Offline/OfflineDbContext_Tests.cs @@ -0,0 +1,137 @@ +// 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. + +#pragma warning disable IDE0051 // Remove unused private members + +using CommunityToolkit.Datasync.Client.Offline; +using CommunityToolkit.Datasync.TestCommon.Databases; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; + +namespace CommunityToolkit.Datasync.Client.Test.Offline; + +[ExcludeFromCodeCoverage] +public class OfflineDbContext_Tests +{ + #region Helpers + /// + /// Creates a version of the TestDbContext backed by SQLite. + /// + /// + private static TestDbContext CreateContext() + { + SqliteConnection connection = new("Data Source=:memory:"); + connection.Open(); + DbContextOptionsBuilder optionsBuilder = new DbContextOptionsBuilder() + .UseSqlite(connection); + TestDbContext context = new(optionsBuilder.Options) { Connection = connection }; + return context; + } + #endregion + + [Fact] + public void InitializeDatasyncEntityMap__OptionsBuilder_Works() + { + Dictionary expected = new() + { + { typeof(ClientMovie).FullName, typeof(ClientMovie) }, + { typeof(Entity3).FullName, typeof(Entity3) } + }; + TestDbContext sut = CreateContext(); + sut.InitializeDatasyncEntityMap(); + sut.DatasyncEntityMap.Should().BeEquivalentTo(expected); + } + + [Fact] + public void InitializeDatasyncEntityMap__OnConfiguring_Works() + { + Dictionary expected = new() + { + { typeof(ClientMovie).FullName, typeof(ClientMovie) }, + { typeof(Entity3).FullName, typeof(Entity3) } + }; + TestDbContext sut = new(); + sut.InitializeDatasyncEntityMap(); + sut.DatasyncEntityMap.Should().BeEquivalentTo(expected); + } + + #region IDisposable + [Fact] + public void Dispose_Works() + { + TestDbContext sut = CreateContext(); + sut.Dispose(); + sut.Dispose(); + sut._disposedValue.Should().BeTrue(); + + Action act = () => sut.CheckDisposed(); + act.Should().Throw(); + } + + [Fact] + public void Dispose_bool_Works() + { + TestDbContext sut = CreateContext(); + sut.TestDispose(false); // Doesn't dispose the underlying thing + sut._disposedValue.Should().BeTrue(); + + Action act = () => sut.CheckDisposed(); + act.Should().Throw(); + } + #endregion + + /// + /// This db context has two synchronizable entities - Movies and KitchenSinks. + /// + public class TestDbContext : OfflineDbContext + { + public TestDbContext() : base() + { + } + + public TestDbContext(DbContextOptions options) : base(options) + { + } + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + if (!optionsBuilder.IsConfigured) + { + Connection = new("Data Source=:memory:"); + Connection.Open(); + optionsBuilder.UseSqlite(); + } + + base.OnConfiguring(optionsBuilder); + } + + internal SqliteConnection Connection { get; set; } + + public DbSet Movies => Set(); + + [DoNotSynchronize] + public DbSet EntitySet1 => Set(); + + private DbSet EntitySet2 => Set(); + public DbSet EntitySet3 => Set(); + internal DbSet EntitySet4 { get; set; } + public int EntitySet5 { get; set; } + public static int EntitySet6 { get; set; } + + internal void TestDispose(bool disposing) + { + if (disposing) + { + Connection.Dispose(); + } + + base.Dispose(disposing); + } + } + + public record Entity1(string Id, DateTimeOffset? UpdatedAt, string Version); + public record Entity2(string Id, DateTimeOffset? UpdatedAt, string Version); + public record Entity3(string Id, DateTimeOffset? UpdatedAt, string Version); + public record Entity4(string Id, DateTimeOffset? UpdatedAt, string Version); +} From c18dae65dfe3c9eda63b7a63615aba2dddd4c1c6 Mon Sep 17 00:00:00 2001 From: Adrian Hall Date: Fri, 9 Aug 2024 15:29:40 -0700 Subject: [PATCH 4/6] wip: Conversion to OperationsQueueManager for testability. --- .../Offline/DatasyncOperation.cs | 4 +- .../Offline/DatasyncQueueException.cs | 33 ++ .../Offline/OfflineDbContext.cs | 105 +----- .../Offline/OperationsQueueManager.cs | 319 ++++++++++++++++++ .../Serialization/EntityResolver.cs | 33 ++ .../ThrowIf.cs | 19 +- .../Offline/Helpers/BaseTest.cs | 26 ++ .../Offline/Helpers/TestDbContext.cs | 75 ++++ .../Offline/OfflineDbContext_Tests.cs | 108 +----- .../Offline/OperationsQueueManager_Tests.cs | 38 +++ .../Serialization/EntityResolver_Tests.cs | 83 +++++ 11 files changed, 629 insertions(+), 214 deletions(-) create mode 100644 src/CommunityToolkit.Datasync.Client/Offline/DatasyncQueueException.cs create mode 100644 src/CommunityToolkit.Datasync.Client/Offline/OperationsQueueManager.cs create mode 100644 tests/CommunityToolkit.Datasync.Client.Test/Offline/Helpers/BaseTest.cs create mode 100644 tests/CommunityToolkit.Datasync.Client.Test/Offline/Helpers/TestDbContext.cs create mode 100644 tests/CommunityToolkit.Datasync.Client.Test/Offline/OperationsQueueManager_Tests.cs diff --git a/src/CommunityToolkit.Datasync.Client/Offline/DatasyncOperation.cs b/src/CommunityToolkit.Datasync.Client/Offline/DatasyncOperation.cs index a9209cd..e946948 100644 --- a/src/CommunityToolkit.Datasync.Client/Offline/DatasyncOperation.cs +++ b/src/CommunityToolkit.Datasync.Client/Offline/DatasyncOperation.cs @@ -2,6 +2,8 @@ // 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.Metadata.Internal; + namespace CommunityToolkit.Datasync.Client.Offline; /// @@ -69,7 +71,7 @@ public record DatasyncOperation /// The sequence number for the operation. This is incremented for each /// new operation to a different entity. /// - public required int Sequence { get; set; } + public required long Sequence { get; set; } /// /// The version number for the operation. This is incremented as multiple diff --git a/src/CommunityToolkit.Datasync.Client/Offline/DatasyncQueueException.cs b/src/CommunityToolkit.Datasync.Client/Offline/DatasyncQueueException.cs new file mode 100644 index 0000000..0a8b5d6 --- /dev/null +++ b/src/CommunityToolkit.Datasync.Client/Offline/DatasyncQueueException.cs @@ -0,0 +1,33 @@ +// 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.Offline; + +/// +/// An error occurred when updating the Datasync Operations Queue. +/// +public class DatasyncQueueException : DatasyncException +{ + /// + /// Creates a new based on the original and updated operations. + /// + /// + /// + public DatasyncQueueException(DatasyncOperation originalOperation, DatasyncOperation updatedOperation) + : base("The operation could not be updated due to an invalid state change.") + { + OriginalOperation = originalOperation; + UpdatedOperation = updatedOperation; + } + + /// + /// The original operation definition. + /// + public DatasyncOperation OriginalOperation { get; } + + /// + /// The updated operation definition. + /// + public DatasyncOperation UpdatedOperation { get; } +} diff --git a/src/CommunityToolkit.Datasync.Client/Offline/OfflineDbContext.cs b/src/CommunityToolkit.Datasync.Client/Offline/OfflineDbContext.cs index 1a3cb67..c729a76 100644 --- a/src/CommunityToolkit.Datasync.Client/Offline/OfflineDbContext.cs +++ b/src/CommunityToolkit.Datasync.Client/Offline/OfflineDbContext.cs @@ -2,13 +2,11 @@ // 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.Serialization; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.ChangeTracking; using Microsoft.EntityFrameworkCore.Diagnostics; using Microsoft.EntityFrameworkCore.Infrastructure; using System.Diagnostics; -using System.Reflection; namespace CommunityToolkit.Datasync.Client.Offline; @@ -57,9 +55,9 @@ public abstract class OfflineDbContext : DbContext internal bool _disposedValue; /// - /// A mapping of all the entities that are allowed to be synchronized in this context. + /// The operations queue manager. /// - internal Dictionary DatasyncEntityMap { get; } = []; + internal OperationsQueueManager QueueManager { get; } /// /// The operations queue. This is used to store pending requests to the datasync service. @@ -78,6 +76,7 @@ public abstract class OfflineDbContext : DbContext /// protected OfflineDbContext() : base() { + QueueManager = new(this); } /// @@ -92,6 +91,7 @@ protected OfflineDbContext() : base() /// The options for this context. protected OfflineDbContext(DbContextOptions options) : base(options) { + QueueManager = new(this); } /// @@ -238,7 +238,7 @@ public override int SaveChanges() /// public override int SaveChanges(bool acceptAllChangesOnSuccess) { - UpdateOperationsQueue(); + QueueManager.UpdateOperationsQueue(); return base.SaveChanges(acceptAllChangesOnSuccess); } @@ -317,100 +317,12 @@ public override Task SaveChangesAsync(CancellationToken cancellationToken = /// This is usually because the data in the database has been modified since it was loaded into memory. /// /// If the is canceled. - public override Task SaveChangesAsync(bool acceptAllChangesOnSuccess, CancellationToken cancellationToken = default) + public override async Task SaveChangesAsync(bool acceptAllChangesOnSuccess, CancellationToken cancellationToken = default) { - UpdateOperationsQueue(); - return base.SaveChangesAsync(acceptAllChangesOnSuccess, cancellationToken); + await QueueManager.UpdateOperationsQueueAsync(cancellationToken).ConfigureAwait(false); + return await base.SaveChangesAsync(acceptAllChangesOnSuccess, cancellationToken).ConfigureAwait(false); } - /// - /// Initializes the value of the - this provides the mapping - /// of entity name to type, which is required for operating the operations queue. - /// - /// - /// An entity is "synchronization ready" if: - /// - /// * It is a property on this context - /// * The property is public and a . - /// * The property does not have a specified. - /// * The entity type is defined in the model. - /// * The entity type has an Id, UpdatedAt, and Version property (according to the ). - /// - internal void InitializeDatasyncEntityMap() - { - Type[] modelEntities = Model.GetEntityTypes().Select(m => m.ClrType).ToArray(); - Type[] synchronizableEntities = GetType().GetProperties(BindingFlags.Public | BindingFlags.Instance) - .Where(IsSynchronizationEntity) - .Select(p => p.PropertyType.GetGenericArguments()[0]) - .Where(m => modelEntities.Contains(m)) - .ToArray(); - foreach (Type entityType in synchronizableEntities) - { - DatasyncException.ThrowIfNullOrEmpty(entityType.FullName, $"Offline entity {entityType.Name} must be a valid reference type."); - EntityResolver.EntityPropertyInfo propInfo = EntityResolver.GetEntityPropertyInfo(entityType); - DatasyncException.ThrowIfNull(propInfo.UpdatedAtPropertyInfo, $"Offline entity {entityType.Name} does not have an UpdatedAt property."); - DatasyncException.ThrowIfNull(propInfo.VersionPropertyInfo, $"Offline entity {entityType.Name} does not have a Version property."); - DatasyncEntityMap.Add(entityType.FullName!, entityType); - } - } - - /// - /// Determines if the provided property is a synchronizable property. - /// - /// - /// An entity is "synchronization ready" if: - /// - /// * It is a property on this context - /// * The property is public and a . - /// * The property does not have a specified. - /// - /// The for the property to check. - /// true if the property is synchronizable; false otherwise. - internal bool IsSynchronizationEntity(PropertyInfo property) - { - if (property.PropertyType.IsGenericType && property.PropertyType.GetGenericTypeDefinition() == typeof(DbSet<>)) - { - if (property.GetCustomAttribute() == null) - { - return true; - } - } - - return false; - } - - /// - /// Iterates through each of the changes in the dataset prior to calling - /// or to add each change to the operations queue. - /// - internal void UpdateOperationsQueue() - { - CheckDisposed(); - InitializeDatasyncEntityMap(); - - if (ChangeTracker.AutoDetectChangesEnabled) - { - ChangeTracker.DetectChanges(); - } - - // Get the list of relevant changes from the change tracker: - List entitiesInScope = ChangeTracker.Entries() - .Where(e => e.State is EntityState.Added or EntityState.Modified or EntityState.Deleted) - .Where(e => DatasyncEntityMap.ContainsKey(GetNullScope(e.Entity.GetType().FullName))) - .ToList(); - - // Rest of the tracker here. - throw new NotImplementedException(); - } - - /// - /// a helper method for returning the empty string instead of null when there is a nullable string. - /// - /// The nullable string - /// The non-nullable string. - internal static string GetNullScope(string? nullableString) - => nullableString ?? string.Empty; - #region IDisposable /// /// Ensure that the context has not been disposed. @@ -439,6 +351,7 @@ protected virtual void Dispose(bool disposing) { if (disposing) { + QueueManager.Dispose(); base.Dispose(); } diff --git a/src/CommunityToolkit.Datasync.Client/Offline/OperationsQueueManager.cs b/src/CommunityToolkit.Datasync.Client/Offline/OperationsQueueManager.cs new file mode 100644 index 0000000..285a434 --- /dev/null +++ b/src/CommunityToolkit.Datasync.Client/Offline/OperationsQueueManager.cs @@ -0,0 +1,319 @@ +// 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.Serialization; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.ChangeTracking; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.EntityFrameworkCore.Infrastructure; +using System.Diagnostics; +using System.Reflection; +using System.Text.Json; + +namespace CommunityToolkit.Datasync.Client.Offline; + +/// +/// The manager for the datasync service operations queue. +/// +/// The database context. +internal class OperationsQueueManager(OfflineDbContext context) : IDisposable +{ + /// + /// A checker for the disposed state of this context. + /// + internal bool _disposedValue; + + /// + /// The internal entity map for mapping entities that have been determined to be valid synchronization targets. + /// + internal Dictionary DatasyncEntityMap { get; } = []; + + /// + /// The JSON Serializer Options to use in serializing and deserializing content. + /// + internal JsonSerializerOptions JsonSerializerOptions { get; } = DatasyncSerializer.JsonSerializerOptions; + + /// + /// Retrieves the existing operation that matches an operation for the provided entity. + /// + /// The entity being processed. + /// A to observe. + /// The operation entity or null if one does not exist. + /// Thrown if the entity ID of the provided entity is invalid. + internal async ValueTask GetExistingOperationAsync(object entity, CancellationToken cancellationToken = default) + { + Type entityType = entity.GetType(); + EntityMetadata metadata = EntityResolver.GetEntityMetadata(entity, entityType); + if (!EntityResolver.EntityIdIsValid(metadata.Id)) + { + throw new DatasyncException($"Entity ID for type {entityType.FullName} is invalid."); + } + + DatasyncOperation? existingOperation = await context.DatasyncOperationsQueue + .SingleOrDefaultAsync(x => x.EntityType == entityType.FullName && x.ItemId == metadata.Id, cancellationToken).ConfigureAwait(false); + return existingOperation; + } + + /// + /// Initializes the value of the - this provides the mapping + /// of entity name to type, which is required for operating the operations queue. + /// + /// + /// An entity is "synchronization ready" if: + /// + /// * It is a property on this context + /// * The property is public and a . + /// * The property does not have a specified. + /// * The entity type is defined in the model. + /// * The entity type has an Id, UpdatedAt, and Version property (according to the ). + /// + internal void InitializeDatasyncEntityMap() + { + if (DatasyncEntityMap.Count > 0) + { + // Fast return if the entity map has already been primed. + return; + } + + Type[] modelEntities = context.Model.GetEntityTypes().Select(m => m.ClrType).ToArray(); + Type[] synchronizableEntities = GetType().GetProperties(BindingFlags.Public | BindingFlags.Instance) + .Where(IsSynchronizationEntity) + .Select(p => p.PropertyType.GetGenericArguments()[0]) + .Where(m => modelEntities.Contains(m)) + .ToArray(); + foreach (Type entityType in synchronizableEntities) + { + DatasyncException.ThrowIfNullOrEmpty(entityType.FullName, $"Offline entity {entityType.Name} must be a valid reference type."); + EntityResolver.EntityPropertyInfo propInfo = EntityResolver.GetEntityPropertyInfo(entityType); + DatasyncException.ThrowIfNull(propInfo.UpdatedAtPropertyInfo, $"Offline entity {entityType.Name} does not have an UpdatedAt property."); + DatasyncException.ThrowIfNull(propInfo.VersionPropertyInfo, $"Offline entity {entityType.Name} does not have a Version property."); + DatasyncEntityMap.Add(entityType.FullName!, entityType); + } + } + + /// + /// Determines if the provided property is a synchronizable property. + /// + /// + /// An entity is "synchronization ready" if: + /// + /// * It is a property on this context + /// * The property is public and a . + /// * The property does not have a specified. + /// + /// The for the property to check. + /// true if the property is synchronizable; false otherwise. + internal bool IsSynchronizationEntity(PropertyInfo property) + { + if (property.PropertyType.IsGenericType && property.PropertyType.GetGenericTypeDefinition() == typeof(DbSet<>)) + { + if (property.GetCustomAttribute() == null) + { + return true; + } + } + + return false; + } + + /// + /// a helper method for returning the empty string instead of null when there is a nullable string. + /// + /// The nullable string + /// The non-nullable string. + internal static string NullAsEmpty(string? nullableString) + => nullableString ?? string.Empty; + + /// + /// Converts the EntityState to an OperationKind. + /// + /// The to convert. + /// The equivalent . + /// If the entity state is not valid as an operation. + internal static OperationKind ToOperationKind(EntityState entityState) + => entityState switch + { + EntityState.Deleted => OperationKind.Delete, + EntityState.Modified => OperationKind.Replace, + EntityState.Added => OperationKind.Add, + _ => throw new InvalidOperationException($"Invalid ChangeTracker EntryEntity state = {entityState}"), + }; + + /// + /// Updates an existing operation according to a ruleset for the new operation. + /// + /// The existing operation. + /// The updated operation. + internal void UpdateExistingOperation(DatasyncOperation existingOperation, DatasyncOperation updatedOperation) + { + // Add followed by Delete + if (existingOperation.Kind is OperationKind.Add && updatedOperation.Kind is OperationKind.Delete) + { + _ = context.DatasyncOperationsQueue.Remove(existingOperation); + return; + } + + // Add followed by Replace + if (existingOperation.Kind is OperationKind.Add && updatedOperation.Kind is OperationKind.Replace) + { + existingOperation.Item = updatedOperation.Item; + UpdateOperation(existingOperation); + return; + } + + // Delete followed by Add + if (existingOperation.Kind is OperationKind.Delete && updatedOperation.Kind is OperationKind.Add) + { + existingOperation.Kind = OperationKind.Replace; + existingOperation.Item = updatedOperation.Item; + UpdateOperation(existingOperation); + return; + } + + // Replace followed by Delete + if (existingOperation.Kind is OperationKind.Replace && updatedOperation.Kind is OperationKind.Delete) + { + existingOperation.Kind = OperationKind.Delete; + UpdateOperation(existingOperation); + return; + } + + // Replace followed by Replace + if (existingOperation.Kind is OperationKind.Replace && updatedOperation.Kind is OperationKind.Replace) + { + existingOperation.Item = updatedOperation.Item; + UpdateOperation(existingOperation); + return; + } + + throw new DatasyncQueueException(existingOperation, updatedOperation); + } + + /// + /// Updates the provided operation and stores in the context. + /// + /// The operation to update. + internal void UpdateOperation(DatasyncOperation operation) + { + operation.State = OperationState.Pending; + operation.Version++; + _ = context.DatasyncOperationsQueue.Update(operation); + } + + /// + /// Iterates through each of the changes in the dataset to add each change to the operations queue. + /// + /// + /// This method calls the async version in a thread pool. Prefer the async version to avoid deadlock issues. + /// + public void UpdateOperationsQueue() + => Task.Run(() => UpdateOperationsQueueAsync()).GetAwaiter().GetResult(); + + /// + /// Iterates through each of the changes in the dataset to add each change to the operations queue (asynchronously). + /// + /// A to observe. + /// A task that runs asynchronously. + public async Task UpdateOperationsQueueAsync(CancellationToken cancellationToken = default) + { + CheckDisposed(); + InitializeDatasyncEntityMap(); + + if (context.ChangeTracker.AutoDetectChangesEnabled) + { + context.ChangeTracker.DetectChanges(); + } + + // Get the list of relevant changes from the change tracker: + IEnumerable entitiesInScope = context.ChangeTracker.Entries() + .Where(e => e.State is EntityState.Added or EntityState.Modified or EntityState.Deleted) + .Where(e => DatasyncEntityMap.ContainsKey(NullAsEmpty(e.Entity.GetType().FullName))); + + // Get the current sequence ID. + long sequenceId = await context.DatasyncOperationsQueue.MaxAsync(x => x.Sequence, cancellationToken).ConfigureAwait(false); + + // Rest of the tracker here. + foreach (EntityEntry entry in entitiesInScope) + { + Type entityType = entry.Entity.GetType(); + EntityMetadata metadata = EntityResolver.GetEntityMetadata(entry.Entity, entityType); + if (!EntityResolver.EntityIdIsValid(metadata.Id)) + { + throw new DatasyncException($"Entity ID for type {entityType.FullName} is invalid."); + } + + DatasyncOperation operation = new() + { + Id = Guid.NewGuid().ToString("N"), + Kind = ToOperationKind(entry.State), + State = OperationState.Pending, + EntityType = NullAsEmpty(entityType.FullName), + ItemId = metadata.Id!, + Item = JsonSerializer.Serialize(entry.Entity, entityType, JsonSerializerOptions), + Sequence = sequenceId, + Version = 0 + }; + + DatasyncOperation? existingOperation = await GetExistingOperationAsync(entry.Entity, cancellationToken).ConfigureAwait(false); + if (existingOperation is null) + { + operation.Sequence = sequenceId++; + _ = await context.DatasyncOperationsQueue.AddAsync(operation, cancellationToken).ConfigureAwait(false); + } + else + { + UpdateExistingOperation(existingOperation, operation); + } + } + } + + #region IDisposable + /// + /// Ensure that the context has not been disposed. + /// + /// If the context has been disposed already. + [DebuggerStepThrough] + internal void CheckDisposed() + { + if (this._disposedValue) + { + throw new ObjectDisposedException(GetType().ShortDisplayName(), CoreStrings.ContextDisposed); + } + } + + /// + /// Releases the allocated resources for this context. + /// + /// + /// + /// See DbContext lifetime, configuration, and initialization + /// for more information and examples. + /// + protected virtual void Dispose(bool disposing) + { + if (!this._disposedValue) + { + if (disposing) + { + // Remove any managed content here. + } + + this._disposedValue = true; + } + } + + /// + /// Releases the allocated resources for this context. + /// + /// + /// See DbContext lifetime, configuration, and initialization + /// for more information and examples. + /// + public void Dispose() + { + Dispose(disposing: true); + GC.SuppressFinalize(this); + } + #endregion +} diff --git a/src/CommunityToolkit.Datasync.Client/Serialization/EntityResolver.cs b/src/CommunityToolkit.Datasync.Client/Serialization/EntityResolver.cs index 95eb3fb..f6d80e4 100644 --- a/src/CommunityToolkit.Datasync.Client/Serialization/EntityResolver.cs +++ b/src/CommunityToolkit.Datasync.Client/Serialization/EntityResolver.cs @@ -2,7 +2,10 @@ // The .NET Foundation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +#pragma warning disable SYSLIB1045 // Convert to 'GeneratedRegexAttribute'. + using System.Reflection; +using System.Text.RegularExpressions; namespace CommunityToolkit.Datasync.Client.Serialization; @@ -16,6 +19,27 @@ internal static class EntityResolver /// internal static Dictionary cache = []; + /// + /// The regular expression for an entity identity property. + /// + private static readonly Regex EntityIdentity = new("^[a-zA-Z0-9][a-zA-Z0-9_.|:-]{0,126}$", RegexOptions.Compiled); + + /// + /// Returns true if the provided value is a valid entity ID. + /// + /// The value to check. + /// If true, null is a valid value for the entity ID. + /// true if the entity ID is valid; false otherwise. + internal static bool EntityIdIsValid(string? value, bool allowNull = false) + { + if (value is null && allowNull) + { + return true; + } + + return value is not null && EntityIdentity.IsMatch(value); + } + /// /// Retrieves the EntityPropertyInfo for a specific type. /// @@ -42,6 +66,15 @@ internal static EntityPropertyInfo GetEntityPropertyInfo(Type type) internal static EntityMetadata GetEntityMetadata(TEntity entity) where TEntity : class => GetEntityPropertyInfo(typeof(TEntity)).GetEntityMetadata(entity); + /// + /// Retrieves the for the given entity. + /// + /// The entity. + /// The type of the entity. + /// The metadata for the entity. + internal static EntityMetadata GetEntityMetadata(object entity, Type entityType) + => GetEntityPropertyInfo(entityType).GetEntityMetadata(entity); + /// /// The class for handling a single type. /// diff --git a/src/CommunityToolkit.Datasync.Client/ThrowIf.cs b/src/CommunityToolkit.Datasync.Client/ThrowIf.cs index fc91119..26e4079 100644 --- a/src/CommunityToolkit.Datasync.Client/ThrowIf.cs +++ b/src/CommunityToolkit.Datasync.Client/ThrowIf.cs @@ -4,6 +4,7 @@ #pragma warning disable SYSLIB1045 // Convert to 'GeneratedRegexAttribute'. +using CommunityToolkit.Datasync.Client.Serialization; using System.Text.RegularExpressions; namespace CommunityToolkit.Datasync.Client; @@ -13,11 +14,6 @@ namespace CommunityToolkit.Datasync.Client; /// internal static class ThrowIf { - /// - /// The regular expression for an entity identity property. - /// - private static readonly Regex EntityIdentity = new("^[a-zA-Z0-9][a-zA-Z0-9_.|:-]{0,126}$", RegexOptions.Compiled); - /// /// An ETag is defined here: https://httpwg.org/specs/rfc9110.html#field.etag but becomes /// 0x21, 0x23-0x7E @@ -51,18 +47,7 @@ internal static void CountMismatch(IEnumerable values, int count, string p /// Thrown if the value is not valid. internal static void EntityIdIsInvalid(string? value, string paramName, string because = "Argument is invalid", bool allowNull = false) { - if (string.IsNullOrEmpty(value)) - { - if (!allowNull) - { - throw new ArgumentException(because, paramName); - } - - return; - } - - // value is non-null at this point. - if (!EntityIdentity.IsMatch(value)) + if (!EntityResolver.EntityIdIsValid(value, allowNull)) { throw new ArgumentException(because, paramName); } diff --git a/tests/CommunityToolkit.Datasync.Client.Test/Offline/Helpers/BaseTest.cs b/tests/CommunityToolkit.Datasync.Client.Test/Offline/Helpers/BaseTest.cs new file mode 100644 index 0000000..a371089 --- /dev/null +++ b/tests/CommunityToolkit.Datasync.Client.Test/Offline/Helpers/BaseTest.cs @@ -0,0 +1,26 @@ +// 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.Data.Sqlite; +using Microsoft.EntityFrameworkCore; + +namespace CommunityToolkit.Datasync.Client.Test.Offline.Helpers; + +[ExcludeFromCodeCoverage] +public abstract class BaseTest +{ + /// + /// Creates a version of the TestDbContext backed by SQLite. + /// + /// + protected static TestDbContext CreateContext() + { + SqliteConnection connection = new("Data Source=:memory:"); + connection.Open(); + DbContextOptionsBuilder optionsBuilder = new DbContextOptionsBuilder() + .UseSqlite(connection); + TestDbContext context = new(optionsBuilder.Options) { Connection = connection }; + return context; + } +} diff --git a/tests/CommunityToolkit.Datasync.Client.Test/Offline/Helpers/TestDbContext.cs b/tests/CommunityToolkit.Datasync.Client.Test/Offline/Helpers/TestDbContext.cs new file mode 100644 index 0000000..a22d365 --- /dev/null +++ b/tests/CommunityToolkit.Datasync.Client.Test/Offline/Helpers/TestDbContext.cs @@ -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. + +#pragma warning disable IDE0051 // Remove unused private members + +using CommunityToolkit.Datasync.Client.Offline; +using CommunityToolkit.Datasync.TestCommon.Databases; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; + +namespace CommunityToolkit.Datasync.Client.Test.Offline.Helpers; + +/// +/// This db context has two synchronizable entities - Movies and KitchenSinks. +/// +[ExcludeFromCodeCoverage] +public class TestDbContext : OfflineDbContext +{ + public TestDbContext() : base() + { + } + + public TestDbContext(DbContextOptions options) : base(options) + { + } + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + if (!optionsBuilder.IsConfigured) + { + Connection = new("Data Source=:memory:"); + Connection.Open(); + optionsBuilder.UseSqlite(); + } + + base.OnConfiguring(optionsBuilder); + } + + internal SqliteConnection Connection { get; set; } + + public DbSet Movies => Set(); + + [DoNotSynchronize] + public DbSet EntitySet1 => Set(); + + private DbSet EntitySet2 => Set(); + public DbSet EntitySet3 => Set(); + internal DbSet EntitySet4 { get; set; } + public int EntitySet5 { get; set; } + public static int EntitySet6 { get; set; } + + internal void TestDispose(bool disposing) + { + if (disposing) + { + Connection.Dispose(); + } + + base.Dispose(disposing); + } +} + +[ExcludeFromCodeCoverage] +public record Entity1(string Id, DateTimeOffset? UpdatedAt, string Version); + +[ExcludeFromCodeCoverage] +public record Entity2(string Id, DateTimeOffset? UpdatedAt, string Version); + +[ExcludeFromCodeCoverage] +public record Entity3(string Id, DateTimeOffset? UpdatedAt, string Version); + +[ExcludeFromCodeCoverage] +public record Entity4(string Id, DateTimeOffset? UpdatedAt, string Version); + diff --git a/tests/CommunityToolkit.Datasync.Client.Test/Offline/OfflineDbContext_Tests.cs b/tests/CommunityToolkit.Datasync.Client.Test/Offline/OfflineDbContext_Tests.cs index 7546ea9..79030ff 100644 --- a/tests/CommunityToolkit.Datasync.Client.Test/Offline/OfflineDbContext_Tests.cs +++ b/tests/CommunityToolkit.Datasync.Client.Test/Offline/OfflineDbContext_Tests.cs @@ -2,60 +2,14 @@ // The .NET Foundation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. -#pragma warning disable IDE0051 // Remove unused private members -using CommunityToolkit.Datasync.Client.Offline; -using CommunityToolkit.Datasync.TestCommon.Databases; -using Microsoft.Data.Sqlite; -using Microsoft.EntityFrameworkCore; +using CommunityToolkit.Datasync.Client.Test.Offline.Helpers; namespace CommunityToolkit.Datasync.Client.Test.Offline; [ExcludeFromCodeCoverage] -public class OfflineDbContext_Tests +public class OfflineDbContext_Tests : BaseTest { - #region Helpers - /// - /// Creates a version of the TestDbContext backed by SQLite. - /// - /// - private static TestDbContext CreateContext() - { - SqliteConnection connection = new("Data Source=:memory:"); - connection.Open(); - DbContextOptionsBuilder optionsBuilder = new DbContextOptionsBuilder() - .UseSqlite(connection); - TestDbContext context = new(optionsBuilder.Options) { Connection = connection }; - return context; - } - #endregion - - [Fact] - public void InitializeDatasyncEntityMap__OptionsBuilder_Works() - { - Dictionary expected = new() - { - { typeof(ClientMovie).FullName, typeof(ClientMovie) }, - { typeof(Entity3).FullName, typeof(Entity3) } - }; - TestDbContext sut = CreateContext(); - sut.InitializeDatasyncEntityMap(); - sut.DatasyncEntityMap.Should().BeEquivalentTo(expected); - } - - [Fact] - public void InitializeDatasyncEntityMap__OnConfiguring_Works() - { - Dictionary expected = new() - { - { typeof(ClientMovie).FullName, typeof(ClientMovie) }, - { typeof(Entity3).FullName, typeof(Entity3) } - }; - TestDbContext sut = new(); - sut.InitializeDatasyncEntityMap(); - sut.DatasyncEntityMap.Should().BeEquivalentTo(expected); - } - #region IDisposable [Fact] public void Dispose_Works() @@ -79,59 +33,13 @@ public void Dispose_bool_Works() Action act = () => sut.CheckDisposed(); act.Should().Throw(); } - #endregion - /// - /// This db context has two synchronizable entities - Movies and KitchenSinks. - /// - public class TestDbContext : OfflineDbContext + [Fact] + public void CheckDisposed_Works() { - public TestDbContext() : base() - { - } - - public TestDbContext(DbContextOptions options) : base(options) - { - } - - protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) - { - if (!optionsBuilder.IsConfigured) - { - Connection = new("Data Source=:memory:"); - Connection.Open(); - optionsBuilder.UseSqlite(); - } - - base.OnConfiguring(optionsBuilder); - } - - internal SqliteConnection Connection { get; set; } - - public DbSet Movies => Set(); - - [DoNotSynchronize] - public DbSet EntitySet1 => Set(); - - private DbSet EntitySet2 => Set(); - public DbSet EntitySet3 => Set(); - internal DbSet EntitySet4 { get; set; } - public int EntitySet5 { get; set; } - public static int EntitySet6 { get; set; } - - internal void TestDispose(bool disposing) - { - if (disposing) - { - Connection.Dispose(); - } - - base.Dispose(disposing); - } + TestDbContext sut = CreateContext(); + Action act = () => sut.CheckDisposed(); + act.Should().NotThrow(); } - - public record Entity1(string Id, DateTimeOffset? UpdatedAt, string Version); - public record Entity2(string Id, DateTimeOffset? UpdatedAt, string Version); - public record Entity3(string Id, DateTimeOffset? UpdatedAt, string Version); - public record Entity4(string Id, DateTimeOffset? UpdatedAt, string Version); + #endregion } diff --git a/tests/CommunityToolkit.Datasync.Client.Test/Offline/OperationsQueueManager_Tests.cs b/tests/CommunityToolkit.Datasync.Client.Test/Offline/OperationsQueueManager_Tests.cs new file mode 100644 index 0000000..f01207f --- /dev/null +++ b/tests/CommunityToolkit.Datasync.Client.Test/Offline/OperationsQueueManager_Tests.cs @@ -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 CommunityToolkit.Datasync.Client.Offline; +using CommunityToolkit.Datasync.Client.Test.Offline.Helpers; + +namespace CommunityToolkit.Datasync.Client.Test.Offline; + +[ExcludeFromCodeCoverage] +public class OperationsQueueManager_Tests : BaseTest +{ + #region IDisposable + [Fact] + public void Dispose_Works() + { + TestDbContext context = CreateContext(); + OperationsQueueManager sut = context.QueueManager; + + sut.Dispose(); + sut.Dispose(); + sut._disposedValue.Should().BeTrue(); + + Action act = () => sut.CheckDisposed(); + act.Should().Throw(); + } + + [Fact] + public void CheckDisposed_Works() + { + TestDbContext context = CreateContext(); + OperationsQueueManager sut = context.QueueManager; + + Action act = () => sut.CheckDisposed(); + act.Should().NotThrow(); + } + #endregion +} diff --git a/tests/CommunityToolkit.Datasync.Client.Test/Serialization/EntityResolver_Tests.cs b/tests/CommunityToolkit.Datasync.Client.Test/Serialization/EntityResolver_Tests.cs index d2d208a..515b900 100644 --- a/tests/CommunityToolkit.Datasync.Client.Test/Serialization/EntityResolver_Tests.cs +++ b/tests/CommunityToolkit.Datasync.Client.Test/Serialization/EntityResolver_Tests.cs @@ -4,6 +4,9 @@ #nullable enable +#pragma warning disable IDE0051 // Remove unused private members +#pragma warning disable IDE0052 // Remove unused private members + using CommunityToolkit.Datasync.Client.Serialization; namespace CommunityToolkit.Datasync.Client.Test.Serialization; @@ -11,6 +14,7 @@ namespace CommunityToolkit.Datasync.Client.Test.Serialization; [ExcludeFromCodeCoverage] public class EntityResolver_Tests { + #region GetEntityPropertyInfo(Type) [Theory] [InlineData(typeof(Resolver_PrivI))] [InlineData(typeof(Resolver_StaticI))] @@ -69,7 +73,9 @@ public void EntityResolver_CachesEntityPropInfo() EntityResolver.EntityPropertyInfo sut = EntityResolver.GetEntityPropertyInfo(typeof(Resolver_I_OU_OSV)); sut.Should().BeSameAs(propInfo); } + #endregion + #region GetEntityMetadata(T) [Fact] public void EntityResolver_OSV_GetEntityMetadata() { @@ -133,6 +139,83 @@ public void EntityResolver_OBV_GetEntityMetadata_Nulls() metadata.UpdatedAt.Should().BeNull(); metadata.Version.Should().BeNull(); } + #endregion + + #region GetEntityMetadata(object, Type) + [Fact] + public void EntityResolver_OSV_GetEntityMetadata2() + { + Resolver_I_OU_OSV entity = new() + { + Id = "1234", + UpdatedAt = DateTime.Parse("1977-05-04T10:37:45.867Z"), + Version = "1.0.0" + }; + + EntityMetadata metadata = EntityResolver.GetEntityMetadata(entity, entity.GetType()); + + metadata.Id.Should().Be(entity.Id); + metadata.UpdatedAt.Should().Be(entity.UpdatedAt); + metadata.Version.Should().Be(entity.Version); + } + + [Fact] + public void EntityResolver_OSV_GetEntityMetadata2_Nulls() + { + Resolver_I_OU_OSV entity = new() + { + Id = "1234" + }; + + EntityMetadata metadata = EntityResolver.GetEntityMetadata(entity, entity.GetType()); + + metadata.Id.Should().Be(entity.Id); + metadata.UpdatedAt.Should().BeNull(); + metadata.Version.Should().BeNull(); + } + + [Fact] + public void EntityResolver_OBV_GetEntityMetadata2() + { + Resolver_I_OU_OBV entity = new() + { + Id = "1234", + UpdatedAt = DateTime.Parse("1977-05-04T10:37:45.867Z"), + Version = Guid.NewGuid().ToByteArray() + }; + + EntityMetadata metadata = EntityResolver.GetEntityMetadata(entity, entity.GetType()); + + metadata.Id.Should().Be(entity.Id); + metadata.UpdatedAt.Should().Be(entity.UpdatedAt); + metadata.Version.Should().Be(Convert.ToBase64String(entity.Version)); + } + + [Fact] + public void EntityResolver_OBV_GetEntityMetadata2_Nulls() + { + Resolver_I_OU_OBV entity = new() + { + Id = "1234" + }; + + EntityMetadata metadata = EntityResolver.GetEntityMetadata(entity, entity.GetType()); + + metadata.Id.Should().Be(entity.Id); + metadata.UpdatedAt.Should().BeNull(); + metadata.Version.Should().BeNull(); + } + #endregion + + #region EntityIdIsValid(string, bool) + [Theory] + [InlineData(null, true, true)] + [InlineData(null, false, false)] + public void EntityIdIsValid_Works(string? sut, bool allowNull, bool expected) + { + EntityResolver.EntityIdIsValid(sut, allowNull).Should().Be(expected); + } + #endregion #region Bad Entity Types class Resolver_PrivI From 9965e7d7fcb4a9d3a8b4c64315dadfa3d910cc39 Mon Sep 17 00:00:00 2001 From: Adrian Hall Date: Mon, 12 Aug 2024 14:14:17 -0700 Subject: [PATCH 5/6] feat: operational queue manager. --- .../Offline/DatasyncOperation.cs | 2 +- .../Offline/OfflineDbContext.cs | 101 ++- .../Offline/OperationsQueueManager.cs | 18 +- .../Offline/Helpers/BaseTest.cs | 3 + .../Offline/OfflineDbContext_Tests.cs | 625 +++++++++++++++++- .../Offline/OperationsQueueManager_Tests.cs | 53 ++ 6 files changed, 790 insertions(+), 12 deletions(-) diff --git a/src/CommunityToolkit.Datasync.Client/Offline/DatasyncOperation.cs b/src/CommunityToolkit.Datasync.Client/Offline/DatasyncOperation.cs index e946948..6ad5eb0 100644 --- a/src/CommunityToolkit.Datasync.Client/Offline/DatasyncOperation.cs +++ b/src/CommunityToolkit.Datasync.Client/Offline/DatasyncOperation.cs @@ -35,7 +35,7 @@ public enum OperationState /// /// An entity representing a pending operation against an entity set. /// -public record DatasyncOperation +public class DatasyncOperation { /// /// A unique ID for the operation. diff --git a/src/CommunityToolkit.Datasync.Client/Offline/OfflineDbContext.cs b/src/CommunityToolkit.Datasync.Client/Offline/OfflineDbContext.cs index c729a76..7b71090 100644 --- a/src/CommunityToolkit.Datasync.Client/Offline/OfflineDbContext.cs +++ b/src/CommunityToolkit.Datasync.Client/Offline/OfflineDbContext.cs @@ -237,8 +237,52 @@ public override int SaveChanges() /// This is usually because the data in the database has been modified since it was loaded into memory. /// public override int SaveChanges(bool acceptAllChangesOnSuccess) + => SaveChanges(acceptAllChangesOnSuccess, true); + + /// + /// Saves all changes made in this context to the database. + /// + /// + /// + /// This method will automatically call + /// to discover any changes to entity instances before saving to the underlying database. This can be disabled via + /// . + /// + /// + /// Entity Framework Core does not support multiple parallel operations being run on the same DbContext instance. This + /// includes both parallel execution of async queries and any explicit concurrent use from multiple threads. + /// Therefore, always await async calls immediately, or use separate DbContext instances for operations that execute + /// in parallel. See Avoiding DbContext threading issues for more information + /// and examples. + /// + /// + /// See Saving data in EF Core for more information and examples. + /// + /// + /// + /// Indicates whether is called after the changes have been sent successfully to the database. + /// + /// + /// Indicates whether synchronization operations should be added to the operations queue. + /// + /// + /// The number of state entries written to the database. + /// + /// + /// An error is encountered while saving to the database. + /// + /// + /// A concurrency violation is encountered while saving to the database. + /// A concurrency violation occurs when an unexpected number of rows are affected during save. + /// This is usually because the data in the database has been modified since it was loaded into memory. + /// + public int SaveChanges(bool acceptAllChangesOnSuccess, bool addToQueue) { - QueueManager.UpdateOperationsQueue(); + if (addToQueue) + { + QueueManager.UpdateOperationsQueue(); + } + return base.SaveChanges(acceptAllChangesOnSuccess); } @@ -317,12 +361,63 @@ public override Task SaveChangesAsync(CancellationToken cancellationToken = /// This is usually because the data in the database has been modified since it was loaded into memory. /// /// If the is canceled. - public override async Task SaveChangesAsync(bool acceptAllChangesOnSuccess, CancellationToken cancellationToken = default) + public override Task SaveChangesAsync(bool acceptAllChangesOnSuccess, CancellationToken cancellationToken = default) + => SaveChangesAsync(acceptAllChangesOnSuccess, addToQueue: true, cancellationToken); + + /// + /// Saves all changes made in this context to the database. + /// + /// + /// + /// This method will automatically call + /// to discover any changes to entity instances before saving to the underlying database. This can be disabled via + /// . + /// + /// + /// Entity Framework Core does not support multiple parallel operations being run on the same DbContext instance. This + /// includes both parallel execution of async queries and any explicit concurrent use from multiple threads. + /// Therefore, always await async calls immediately, or use separate DbContext instances for operations that execute + /// in parallel. See Avoiding DbContext threading issues for more + /// information and examples. + /// + /// + /// See Saving data in EF Core for more information and examples. + /// + /// + /// + /// Indicates whether is called after + /// the changes have been sent successfully to the database. + /// + /// + /// Indicates whether synchronization operations should be added to the operations queue. + /// + /// A to observe while waiting for the task to complete. + /// + /// A task that represents the asynchronous save operation. The task result contains the + /// number of state entries written to the database. + /// + /// + /// An error is encountered while saving to the database. + /// + /// + /// A concurrency violation is encountered while saving to the database. + /// A concurrency violation occurs when an unexpected number of rows are affected during save. + /// This is usually because the data in the database has been modified since it was loaded into memory. + /// + /// If the is canceled. + public async Task SaveChangesAsync(bool acceptAllChangesOnSuccess, bool addToQueue, CancellationToken cancellationToken = default) { - await QueueManager.UpdateOperationsQueueAsync(cancellationToken).ConfigureAwait(false); + if (addToQueue) + { + await QueueManager.UpdateOperationsQueueAsync(cancellationToken).ConfigureAwait(false); + } + return await base.SaveChangesAsync(acceptAllChangesOnSuccess, cancellationToken).ConfigureAwait(false); } + #region SaveChanges without adding to queue + #endregion + #region IDisposable /// /// Ensure that the context has not been disposed. diff --git a/src/CommunityToolkit.Datasync.Client/Offline/OperationsQueueManager.cs b/src/CommunityToolkit.Datasync.Client/Offline/OperationsQueueManager.cs index 285a434..6812141 100644 --- a/src/CommunityToolkit.Datasync.Client/Offline/OperationsQueueManager.cs +++ b/src/CommunityToolkit.Datasync.Client/Offline/OperationsQueueManager.cs @@ -77,10 +77,9 @@ internal void InitializeDatasyncEntityMap() } Type[] modelEntities = context.Model.GetEntityTypes().Select(m => m.ClrType).ToArray(); - Type[] synchronizableEntities = GetType().GetProperties(BindingFlags.Public | BindingFlags.Instance) + Type[] synchronizableEntities = context.GetType().GetProperties(BindingFlags.Public | BindingFlags.Instance) .Where(IsSynchronizationEntity) .Select(p => p.PropertyType.GetGenericArguments()[0]) - .Where(m => modelEntities.Contains(m)) .ToArray(); foreach (Type entityType in synchronizableEntities) { @@ -226,12 +225,17 @@ public async Task UpdateOperationsQueueAsync(CancellationToken cancellationToken } // Get the list of relevant changes from the change tracker: - IEnumerable entitiesInScope = context.ChangeTracker.Entries() + List entitiesInScope = context.ChangeTracker.Entries() .Where(e => e.State is EntityState.Added or EntityState.Modified or EntityState.Deleted) - .Where(e => DatasyncEntityMap.ContainsKey(NullAsEmpty(e.Entity.GetType().FullName))); - - // Get the current sequence ID. - long sequenceId = await context.DatasyncOperationsQueue.MaxAsync(x => x.Sequence, cancellationToken).ConfigureAwait(false); + .Where(e => DatasyncEntityMap.ContainsKey(NullAsEmpty(e.Entity.GetType().FullName))) + .ToList(); + + // Get the current sequence ID. Note that ORDERBY/TOP is generally faster than aggregate functions in databases. + // The .FirstOrDefaultAsync() returns default(long) which is 0L. + long sequenceId = await context.DatasyncOperationsQueue + .OrderByDescending(x => x.Sequence) + .Select(x => x.Sequence) + .FirstOrDefaultAsync(cancellationToken); // Rest of the tracker here. foreach (EntityEntry entry in entitiesInScope) diff --git a/tests/CommunityToolkit.Datasync.Client.Test/Offline/Helpers/BaseTest.cs b/tests/CommunityToolkit.Datasync.Client.Test/Offline/Helpers/BaseTest.cs index a371089..8582b34 100644 --- a/tests/CommunityToolkit.Datasync.Client.Test/Offline/Helpers/BaseTest.cs +++ b/tests/CommunityToolkit.Datasync.Client.Test/Offline/Helpers/BaseTest.cs @@ -21,6 +21,9 @@ protected static TestDbContext CreateContext() DbContextOptionsBuilder optionsBuilder = new DbContextOptionsBuilder() .UseSqlite(connection); TestDbContext context = new(optionsBuilder.Options) { Connection = connection }; + + // Ensure the database is created. + context.Database.EnsureCreated(); return context; } } diff --git a/tests/CommunityToolkit.Datasync.Client.Test/Offline/OfflineDbContext_Tests.cs b/tests/CommunityToolkit.Datasync.Client.Test/Offline/OfflineDbContext_Tests.cs index 79030ff..1d5938d 100644 --- a/tests/CommunityToolkit.Datasync.Client.Test/Offline/OfflineDbContext_Tests.cs +++ b/tests/CommunityToolkit.Datasync.Client.Test/Offline/OfflineDbContext_Tests.cs @@ -2,14 +2,637 @@ // 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.Client.Serialization; using CommunityToolkit.Datasync.Client.Test.Offline.Helpers; +using CommunityToolkit.Datasync.TestCommon.Databases; +using System.Text.Json; +using TestData = CommunityToolkit.Datasync.TestCommon.TestData; namespace CommunityToolkit.Datasync.Client.Test.Offline; [ExcludeFromCodeCoverage] public class OfflineDbContext_Tests : BaseTest { + #region Ctor + [Fact] + public void Default_Ctor_CreateQueueManager() + { + TestDbContext context = new(); + context.QueueManager.Should().NotBeNull(); + } + #endregion + + #region SaveChanges + [Fact] + public void SaveChanges_Addition_AddsToQueue() + { + TestDbContext context = CreateContext(); + ClientMovie clientMovie = new() { Id = Guid.NewGuid().ToString("N") }; + TestData.Movies.BlackPanther.CopyTo(clientMovie); + string serializedEntity = JsonSerializer.Serialize(clientMovie, DatasyncSerializer.JsonSerializerOptions); + + context.Movies.Add(clientMovie); + context.SaveChanges(); + + context.Movies.Should().HaveCount(1); + context.DatasyncOperationsQueue.Should().HaveCount(1); + DatasyncOperation operation = context.DatasyncOperationsQueue.SingleOrDefault(); + operation.EntityType.Should().Be(typeof(ClientMovie).FullName); + operation.Id.Should().NotBeNullOrEmpty(); + operation.Item.Should().Be(serializedEntity); + operation.ItemId.Should().Be(clientMovie.Id); + operation.Kind.Should().Be(OperationKind.Add); + operation.State.Should().Be(OperationState.Pending); + operation.Sequence.Should().Be(0); + operation.Version.Should().Be(0); + } + + [Fact] + public void SaveChanges_TwoAdds_AddsToQueue() + { + TestDbContext context = CreateContext(); + ClientMovie firstMovie = new() { Id = Guid.NewGuid().ToString("N") }; + TestData.Movies.BlackPanther.CopyTo(firstMovie); + string firstMovieJson = JsonSerializer.Serialize(firstMovie, DatasyncSerializer.JsonSerializerOptions); + + ClientMovie secondMovie = new() { Id = Guid.NewGuid().ToString("N") }; + TestData.Movies.BlackPanther.CopyTo(secondMovie); + string secondMovieJson = JsonSerializer.Serialize(secondMovie, DatasyncSerializer.JsonSerializerOptions); + + context.Movies.Add(firstMovie); + context.Movies.Add(secondMovie); + context.SaveChanges(); + + context.Movies.Should().HaveCount(2); + context.DatasyncOperationsQueue.Should().HaveCount(2); + List operations = context.DatasyncOperationsQueue.ToList(); + + DatasyncOperation operation1 = operations.Single(x => x.ItemId == firstMovie.Id); + operation1.EntityType.Should().Be(typeof(ClientMovie).FullName); + operation1.Id.Should().NotBeNullOrEmpty(); + operation1.Item.Should().Be(firstMovieJson); + operation1.ItemId.Should().Be(firstMovie.Id); + operation1.Kind.Should().Be(OperationKind.Add); + operation1.State.Should().Be(OperationState.Pending); + operation1.Sequence.Should().Be(0); + operation1.Version.Should().Be(0); + + DatasyncOperation operation2 = operations.Single(x => x.ItemId == secondMovie.Id); + operation2.EntityType.Should().Be(typeof(ClientMovie).FullName); + operation2.Id.Should().NotBeNullOrEmpty(); + operation2.Item.Should().Be(secondMovieJson); + operation2.ItemId.Should().Be(secondMovie.Id); + operation2.Kind.Should().Be(OperationKind.Add); + operation2.State.Should().Be(OperationState.Pending); + operation2.Sequence.Should().Be(1); + operation2.Version.Should().Be(0); + } + + [Fact] + public void SaveChanges_InvalidId_Throws() + { + TestDbContext context = CreateContext(); + ClientMovie clientMovie = new() { Id = "###" }; + TestData.Movies.BlackPanther.CopyTo(clientMovie); + + Action act = () => + { + context.Movies.Add(clientMovie); + context.SaveChanges(); + }; + + act.Should().Throw(); + } + + [Fact] + public void SaveChanges_AddThenDelete_NoQueue() + { + TestDbContext context = CreateContext(); + ClientMovie clientMovie = new() { Id = Guid.NewGuid().ToString("N") }; + TestData.Movies.BlackPanther.CopyTo(clientMovie); + + context.Movies.Add(clientMovie); + context.SaveChanges(); + + context.Movies.Remove(clientMovie); + context.SaveChanges(); + + context.Movies.Should().HaveCount(0); + context.DatasyncOperationsQueue.Should().HaveCount(0); + } + + [Fact] + public void SaveChanges_AddThenReplace_AddsToQueue() + { + TestDbContext context = CreateContext(); + ClientMovie clientMovie = new() { Id = Guid.NewGuid().ToString("N") }; + TestData.Movies.BlackPanther.CopyTo(clientMovie); + + context.Movies.Add(clientMovie); + context.SaveChanges(); + + clientMovie.Title = "Foo"; + string serializedEntity = JsonSerializer.Serialize(clientMovie, DatasyncSerializer.JsonSerializerOptions); + context.Movies.Update(clientMovie); + context.SaveChanges(); + + context.Movies.Should().HaveCount(1); + context.DatasyncOperationsQueue.Should().HaveCount(1); + DatasyncOperation operation = context.DatasyncOperationsQueue.SingleOrDefault(); + operation.EntityType.Should().Be(typeof(ClientMovie).FullName); + operation.Id.Should().NotBeNullOrEmpty(); + operation.Item.Should().Be(serializedEntity); + operation.ItemId.Should().Be(clientMovie.Id); + operation.Kind.Should().Be(OperationKind.Add); + operation.State.Should().Be(OperationState.Pending); + operation.Sequence.Should().Be(0); + operation.Version.Should().Be(1); + } + + [Fact] + public void SaveChanges_Deletion_AddsToQueue() + { + TestDbContext context = CreateContext(); + ClientMovie clientMovie = new() { Id = Guid.NewGuid().ToString("N") }; + TestData.Movies.BlackPanther.CopyTo(clientMovie); + string serializedEntity = JsonSerializer.Serialize(clientMovie, DatasyncSerializer.JsonSerializerOptions); + context.Movies.Add(clientMovie); + context.SaveChanges(acceptAllChangesOnSuccess: true, addToQueue: false); + + context.Movies.Remove(clientMovie); + context.SaveChanges(); + + context.Movies.Should().HaveCount(0); + context.DatasyncOperationsQueue.Should().HaveCount(1); + DatasyncOperation operation = context.DatasyncOperationsQueue.SingleOrDefault(); + operation.EntityType.Should().Be(typeof(ClientMovie).FullName); + operation.Id.Should().NotBeNullOrEmpty(); + operation.Item.Should().Be(serializedEntity); + operation.ItemId.Should().Be(clientMovie.Id); + operation.Kind.Should().Be(OperationKind.Delete); + operation.State.Should().Be(OperationState.Pending); + operation.Sequence.Should().Be(0); + operation.Version.Should().Be(0); + } + + [Fact] + public void SaveChanges_DeleteThenAdd_AddsToQueue() + { + TestDbContext context = CreateContext(); + ClientMovie clientMovie = new() { Id = Guid.NewGuid().ToString("N") }; + TestData.Movies.BlackPanther.CopyTo(clientMovie); + string serializedEntity = JsonSerializer.Serialize(clientMovie, DatasyncSerializer.JsonSerializerOptions); + context.Movies.Add(clientMovie); + context.SaveChanges(acceptAllChangesOnSuccess: true, addToQueue: false); + + context.Movies.Remove(clientMovie); + context.SaveChanges(); + + context.Movies.Add(clientMovie); + context.SaveChanges(); + + context.Movies.Should().HaveCount(1); + context.DatasyncOperationsQueue.Should().HaveCount(1); + DatasyncOperation operation = context.DatasyncOperationsQueue.SingleOrDefault(); + operation.EntityType.Should().Be(typeof(ClientMovie).FullName); + operation.Id.Should().NotBeNullOrEmpty(); + operation.Item.Should().Be(serializedEntity); + operation.ItemId.Should().Be(clientMovie.Id); + operation.Kind.Should().Be(OperationKind.Replace); + operation.State.Should().Be(OperationState.Pending); + operation.Sequence.Should().Be(0); + operation.Version.Should().Be(1); + } + + [Fact] + public void SaveChanges_DeleteThenDelete_Throws() + { + TestDbContext context = CreateContext(); + ClientMovie clientMovie = new() { Id = Guid.NewGuid().ToString("N") }; + TestData.Movies.BlackPanther.CopyTo(clientMovie); + string serializedEntity = JsonSerializer.Serialize(clientMovie, DatasyncSerializer.JsonSerializerOptions); + context.Movies.Add(clientMovie); + + DatasyncOperation badOperation = new() + { + EntityType = typeof(ClientMovie).FullName, + Id = Guid.NewGuid().ToString("N"), + Item = serializedEntity, + ItemId = clientMovie.Id, + Kind = OperationKind.Delete, + State = OperationState.Pending, + Sequence = 1, + Version = 0 + }; + context.DatasyncOperationsQueue.Add(badOperation); + context.SaveChanges(acceptAllChangesOnSuccess: true, addToQueue: false); + + Action act = () => + { + context.Movies.Remove(clientMovie); + context.SaveChanges(); + }; + + DatasyncQueueException ex = act.Should().Throw().Subject.Single(); + ex.OriginalOperation?.Id.Should().Be(badOperation.Id); + ex.UpdatedOperation.Should().NotBe(badOperation).And.NotBeNull(); + } + + [Fact] + public void SaveChanges_Replacement_AddsToQueue() + { + TestDbContext context = CreateContext(); + ClientMovie clientMovie = new() { Id = Guid.NewGuid().ToString("N") }; + TestData.Movies.BlackPanther.CopyTo(clientMovie); + context.Movies.Add(clientMovie); + context.SaveChanges(acceptAllChangesOnSuccess: true, addToQueue: false); + + clientMovie.Title = "Replaced Title"; + string serializedEntity = JsonSerializer.Serialize(clientMovie, DatasyncSerializer.JsonSerializerOptions); + context.Movies.Update(clientMovie); + context.SaveChanges(); + + context.Movies.Should().HaveCount(1); + context.DatasyncOperationsQueue.Should().HaveCount(1); + DatasyncOperation operation = context.DatasyncOperationsQueue.SingleOrDefault(); + operation.EntityType.Should().Be(typeof(ClientMovie).FullName); + operation.Id.Should().NotBeNullOrEmpty(); + operation.Item.Should().Be(serializedEntity); + operation.ItemId.Should().Be(clientMovie.Id); + operation.Kind.Should().Be(OperationKind.Replace); + operation.State.Should().Be(OperationState.Pending); + operation.Sequence.Should().Be(0); + operation.Version.Should().Be(0); + } + + [Fact] + public void SaveChanges_ReplaceThenDelete_AddsToQueue() + { + TestDbContext context = CreateContext(); + ClientMovie clientMovie = new() { Id = Guid.NewGuid().ToString("N") }; + TestData.Movies.BlackPanther.CopyTo(clientMovie); + context.Movies.Add(clientMovie); + context.SaveChanges(acceptAllChangesOnSuccess: true, addToQueue: false); + + clientMovie.Title = "Replaced Title"; + string serializedEntity = JsonSerializer.Serialize(clientMovie, DatasyncSerializer.JsonSerializerOptions); + context.Movies.Update(clientMovie); + context.SaveChanges(); + + context.Movies.Remove(clientMovie); + context.SaveChanges(); + + context.Movies.Should().HaveCount(0); + context.DatasyncOperationsQueue.Should().HaveCount(1); + DatasyncOperation operation = context.DatasyncOperationsQueue.SingleOrDefault(); + operation.EntityType.Should().Be(typeof(ClientMovie).FullName); + operation.Id.Should().NotBeNullOrEmpty(); + operation.Item.Should().Be(serializedEntity); + operation.ItemId.Should().Be(clientMovie.Id); + operation.Kind.Should().Be(OperationKind.Delete); + operation.State.Should().Be(OperationState.Pending); + operation.Sequence.Should().Be(0); + operation.Version.Should().Be(1); + } + + [Fact] + public void SaveChanges_ReplaceThenReplace_AddsToQueue() + { + TestDbContext context = CreateContext(); + ClientMovie clientMovie = new() { Id = Guid.NewGuid().ToString("N") }; + TestData.Movies.BlackPanther.CopyTo(clientMovie); + context.Movies.Add(clientMovie); + context.SaveChanges(acceptAllChangesOnSuccess: true, addToQueue: false); + + clientMovie.Title = "Replaced Title"; + context.Movies.Update(clientMovie); + context.SaveChanges(); + + clientMovie.Title = "Foo"; + string serializedEntity = JsonSerializer.Serialize(clientMovie, DatasyncSerializer.JsonSerializerOptions); + context.Movies.Update(clientMovie); + context.SaveChanges(); + + context.Movies.Should().HaveCount(1); + context.DatasyncOperationsQueue.Should().HaveCount(1); + DatasyncOperation operation = context.DatasyncOperationsQueue.SingleOrDefault(); + operation.EntityType.Should().Be(typeof(ClientMovie).FullName); + operation.Id.Should().NotBeNullOrEmpty(); + operation.Item.Should().Be(serializedEntity); + operation.ItemId.Should().Be(clientMovie.Id); + operation.Kind.Should().Be(OperationKind.Replace); + operation.State.Should().Be(OperationState.Pending); + operation.Sequence.Should().Be(0); + operation.Version.Should().Be(1); + } + #endregion + + #region SaveChangesAsync + [Fact] + public async Task SaveChangesAsync_Addition_AddsToQueue() + { + TestDbContext context = CreateContext(); + ClientMovie clientMovie = new() { Id = Guid.NewGuid().ToString("N") }; + TestData.Movies.BlackPanther.CopyTo(clientMovie); + string serializedEntity = JsonSerializer.Serialize(clientMovie, DatasyncSerializer.JsonSerializerOptions); + + context.Movies.Add(clientMovie); + await context.SaveChangesAsync(); + + context.Movies.Should().HaveCount(1); + context.DatasyncOperationsQueue.Should().HaveCount(1); + DatasyncOperation operation = context.DatasyncOperationsQueue.SingleOrDefault(); + operation.EntityType.Should().Be(typeof(ClientMovie).FullName); + operation.Id.Should().NotBeNullOrEmpty(); + operation.Item.Should().Be(serializedEntity); + operation.ItemId.Should().Be(clientMovie.Id); + operation.Kind.Should().Be(OperationKind.Add); + operation.State.Should().Be(OperationState.Pending); + operation.Sequence.Should().Be(0); + operation.Version.Should().Be(0); + } + + [Fact] + public async Task SaveChangesAsync_TwoAdds_AddsToQueue() + { + TestDbContext context = CreateContext(); + ClientMovie firstMovie = new() { Id = Guid.NewGuid().ToString("N") }; + TestData.Movies.BlackPanther.CopyTo(firstMovie); + string firstMovieJson = JsonSerializer.Serialize(firstMovie, DatasyncSerializer.JsonSerializerOptions); + + ClientMovie secondMovie = new() { Id = Guid.NewGuid().ToString("N") }; + TestData.Movies.BlackPanther.CopyTo(secondMovie); + string secondMovieJson = JsonSerializer.Serialize(secondMovie, DatasyncSerializer.JsonSerializerOptions); + + context.Movies.Add(firstMovie); + context.Movies.Add(secondMovie); + await context.SaveChangesAsync(); + + context.Movies.Should().HaveCount(2); + context.DatasyncOperationsQueue.Should().HaveCount(2); + List operations = context.DatasyncOperationsQueue.ToList(); + + DatasyncOperation operation1 = operations.Single(x => x.ItemId == firstMovie.Id); + operation1.EntityType.Should().Be(typeof(ClientMovie).FullName); + operation1.Id.Should().NotBeNullOrEmpty(); + operation1.Item.Should().Be(firstMovieJson); + operation1.ItemId.Should().Be(firstMovie.Id); + operation1.Kind.Should().Be(OperationKind.Add); + operation1.State.Should().Be(OperationState.Pending); + operation1.Sequence.Should().Be(0); + operation1.Version.Should().Be(0); + + DatasyncOperation operation2 = operations.Single(x => x.ItemId == secondMovie.Id); + operation2.EntityType.Should().Be(typeof(ClientMovie).FullName); + operation2.Id.Should().NotBeNullOrEmpty(); + operation2.Item.Should().Be(secondMovieJson); + operation2.ItemId.Should().Be(secondMovie.Id); + operation2.Kind.Should().Be(OperationKind.Add); + operation2.State.Should().Be(OperationState.Pending); + operation2.Sequence.Should().Be(1); + operation2.Version.Should().Be(0); + } + + [Fact] + public async Task SaveChangesAsync_InvalidId_Throws() + { + TestDbContext context = CreateContext(); + ClientMovie clientMovie = new() { Id = "###" }; + TestData.Movies.BlackPanther.CopyTo(clientMovie); + + Func act = async () => + { + context.Movies.Add(clientMovie); + await context.SaveChangesAsync(); + }; + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task SaveChangesAsync_AddThenDelete_NoQueue() + { + TestDbContext context = CreateContext(); + ClientMovie clientMovie = new() { Id = Guid.NewGuid().ToString("N") }; + TestData.Movies.BlackPanther.CopyTo(clientMovie); + + context.Movies.Add(clientMovie); + await context.SaveChangesAsync(); + + context.Movies.Remove(clientMovie); + await context.SaveChangesAsync(); + + context.Movies.Should().HaveCount(0); + context.DatasyncOperationsQueue.Should().HaveCount(0); + } + + [Fact] + public async Task SaveChangesAsync_AddThenReplace_AddsToQueue() + { + TestDbContext context = CreateContext(); + ClientMovie clientMovie = new() { Id = Guid.NewGuid().ToString("N") }; + TestData.Movies.BlackPanther.CopyTo(clientMovie); + + context.Movies.Add(clientMovie); + await context.SaveChangesAsync(); + + clientMovie.Title = "Foo"; + string serializedEntity = JsonSerializer.Serialize(clientMovie, DatasyncSerializer.JsonSerializerOptions); + context.Movies.Update(clientMovie); + await context.SaveChangesAsync(); + + context.Movies.Should().HaveCount(1); + context.DatasyncOperationsQueue.Should().HaveCount(1); + DatasyncOperation operation = context.DatasyncOperationsQueue.SingleOrDefault(); + operation.EntityType.Should().Be(typeof(ClientMovie).FullName); + operation.Id.Should().NotBeNullOrEmpty(); + operation.Item.Should().Be(serializedEntity); + operation.ItemId.Should().Be(clientMovie.Id); + operation.Kind.Should().Be(OperationKind.Add); + operation.State.Should().Be(OperationState.Pending); + operation.Sequence.Should().Be(0); + operation.Version.Should().Be(1); + } + + [Fact] + public async Task SaveChangesAsync_Deletion_AddsToQueue() + { + TestDbContext context = CreateContext(); + ClientMovie clientMovie = new() { Id = Guid.NewGuid().ToString("N") }; + TestData.Movies.BlackPanther.CopyTo(clientMovie); + string serializedEntity = JsonSerializer.Serialize(clientMovie, DatasyncSerializer.JsonSerializerOptions); + context.Movies.Add(clientMovie); + await context.SaveChangesAsync(acceptAllChangesOnSuccess: true, addToQueue: false); + + context.Movies.Remove(clientMovie); + await context.SaveChangesAsync(); + + context.Movies.Should().HaveCount(0); + context.DatasyncOperationsQueue.Should().HaveCount(1); + DatasyncOperation operation = context.DatasyncOperationsQueue.SingleOrDefault(); + operation.EntityType.Should().Be(typeof(ClientMovie).FullName); + operation.Id.Should().NotBeNullOrEmpty(); + operation.Item.Should().Be(serializedEntity); + operation.ItemId.Should().Be(clientMovie.Id); + operation.Kind.Should().Be(OperationKind.Delete); + operation.State.Should().Be(OperationState.Pending); + operation.Sequence.Should().Be(0); + operation.Version.Should().Be(0); + } + + [Fact] + public async Task SaveChangesAsync_DeleteThenAdd_AddsToQueue() + { + TestDbContext context = CreateContext(); + ClientMovie clientMovie = new() { Id = Guid.NewGuid().ToString("N") }; + TestData.Movies.BlackPanther.CopyTo(clientMovie); + string serializedEntity = JsonSerializer.Serialize(clientMovie, DatasyncSerializer.JsonSerializerOptions); + context.Movies.Add(clientMovie); + await context.SaveChangesAsync(acceptAllChangesOnSuccess: true, addToQueue: false); + + context.Movies.Remove(clientMovie); + await context.SaveChangesAsync(); + + context.Movies.Add(clientMovie); + await context.SaveChangesAsync(); + + context.Movies.Should().HaveCount(1); + context.DatasyncOperationsQueue.Should().HaveCount(1); + DatasyncOperation operation = context.DatasyncOperationsQueue.SingleOrDefault(); + operation.EntityType.Should().Be(typeof(ClientMovie).FullName); + operation.Id.Should().NotBeNullOrEmpty(); + operation.Item.Should().Be(serializedEntity); + operation.ItemId.Should().Be(clientMovie.Id); + operation.Kind.Should().Be(OperationKind.Replace); + operation.State.Should().Be(OperationState.Pending); + operation.Sequence.Should().Be(0); + operation.Version.Should().Be(1); + } + + [Fact] + public async Task SaveChangesAsync_DeleteThenDelete_Throws() + { + TestDbContext context = CreateContext(); + ClientMovie clientMovie = new() { Id = Guid.NewGuid().ToString("N") }; + TestData.Movies.BlackPanther.CopyTo(clientMovie); + string serializedEntity = JsonSerializer.Serialize(clientMovie, DatasyncSerializer.JsonSerializerOptions); + context.Movies.Add(clientMovie); + + DatasyncOperation badOperation = new() + { + EntityType = typeof(ClientMovie).FullName, + Id = Guid.NewGuid().ToString("N"), + Item = serializedEntity, + ItemId = clientMovie.Id, + Kind = OperationKind.Delete, + State = OperationState.Pending, + Sequence = 1, + Version = 0 + }; + context.DatasyncOperationsQueue.Add(badOperation); + await context.SaveChangesAsync(acceptAllChangesOnSuccess: true, addToQueue: false); + + Func act = async () => + { + context.Movies.Remove(clientMovie); + await context.SaveChangesAsync(); + }; + + DatasyncQueueException ex = (await act.Should().ThrowAsync()).Subject.Single(); + ex.OriginalOperation?.Id.Should().Be(badOperation.Id); + ex.UpdatedOperation.Should().NotBe(badOperation).And.NotBeNull(); + } + + [Fact] + public async Task SaveChangesAsync_Replacement_AddsToQueue() + { + TestDbContext context = CreateContext(); + ClientMovie clientMovie = new() { Id = Guid.NewGuid().ToString("N") }; + TestData.Movies.BlackPanther.CopyTo(clientMovie); + context.Movies.Add(clientMovie); + await context.SaveChangesAsync(acceptAllChangesOnSuccess: true, addToQueue: false); + + clientMovie.Title = "Replaced Title"; + string serializedEntity = JsonSerializer.Serialize(clientMovie, DatasyncSerializer.JsonSerializerOptions); + context.Movies.Update(clientMovie); + await context.SaveChangesAsync(); + + context.Movies.Should().HaveCount(1); + context.DatasyncOperationsQueue.Should().HaveCount(1); + DatasyncOperation operation = context.DatasyncOperationsQueue.SingleOrDefault(); + operation.EntityType.Should().Be(typeof(ClientMovie).FullName); + operation.Id.Should().NotBeNullOrEmpty(); + operation.Item.Should().Be(serializedEntity); + operation.ItemId.Should().Be(clientMovie.Id); + operation.Kind.Should().Be(OperationKind.Replace); + operation.State.Should().Be(OperationState.Pending); + operation.Sequence.Should().Be(0); + operation.Version.Should().Be(0); + } + + [Fact] + public async Task SaveChangesAsync_ReplaceThenDelete_AddsToQueue() + { + TestDbContext context = CreateContext(); + ClientMovie clientMovie = new() { Id = Guid.NewGuid().ToString("N") }; + TestData.Movies.BlackPanther.CopyTo(clientMovie); + context.Movies.Add(clientMovie); + await context.SaveChangesAsync(acceptAllChangesOnSuccess: true, addToQueue: false); + + clientMovie.Title = "Replaced Title"; + string serializedEntity = JsonSerializer.Serialize(clientMovie, DatasyncSerializer.JsonSerializerOptions); + context.Movies.Update(clientMovie); + await context.SaveChangesAsync(); + + context.Movies.Remove(clientMovie); + await context.SaveChangesAsync(); + + context.Movies.Should().HaveCount(0); + context.DatasyncOperationsQueue.Should().HaveCount(1); + DatasyncOperation operation = context.DatasyncOperationsQueue.SingleOrDefault(); + operation.EntityType.Should().Be(typeof(ClientMovie).FullName); + operation.Id.Should().NotBeNullOrEmpty(); + operation.Item.Should().Be(serializedEntity); + operation.ItemId.Should().Be(clientMovie.Id); + operation.Kind.Should().Be(OperationKind.Delete); + operation.State.Should().Be(OperationState.Pending); + operation.Sequence.Should().Be(0); + operation.Version.Should().Be(1); + } + + [Fact] + public async Task SaveChangesAsync_ReplaceThenReplace_AddsToQueue() + { + TestDbContext context = CreateContext(); + ClientMovie clientMovie = new() { Id = Guid.NewGuid().ToString("N") }; + TestData.Movies.BlackPanther.CopyTo(clientMovie); + context.Movies.Add(clientMovie); + await context.SaveChangesAsync(acceptAllChangesOnSuccess: true, addToQueue: false); + + clientMovie.Title = "Replaced Title"; + context.Movies.Update(clientMovie); + await context.SaveChangesAsync(); + + clientMovie.Title = "Foo"; + string serializedEntity = JsonSerializer.Serialize(clientMovie, DatasyncSerializer.JsonSerializerOptions); + context.Movies.Update(clientMovie); + await context.SaveChangesAsync(); + + context.Movies.Should().HaveCount(1); + context.DatasyncOperationsQueue.Should().HaveCount(1); + DatasyncOperation operation = context.DatasyncOperationsQueue.SingleOrDefault(); + operation.EntityType.Should().Be(typeof(ClientMovie).FullName); + operation.Id.Should().NotBeNullOrEmpty(); + operation.Item.Should().Be(serializedEntity); + operation.ItemId.Should().Be(clientMovie.Id); + operation.Kind.Should().Be(OperationKind.Replace); + operation.State.Should().Be(OperationState.Pending); + operation.Sequence.Should().Be(0); + operation.Version.Should().Be(1); + } + #endregion + #region IDisposable [Fact] public void Dispose_Works() diff --git a/tests/CommunityToolkit.Datasync.Client.Test/Offline/OperationsQueueManager_Tests.cs b/tests/CommunityToolkit.Datasync.Client.Test/Offline/OperationsQueueManager_Tests.cs index f01207f..b3bf371 100644 --- a/tests/CommunityToolkit.Datasync.Client.Test/Offline/OperationsQueueManager_Tests.cs +++ b/tests/CommunityToolkit.Datasync.Client.Test/Offline/OperationsQueueManager_Tests.cs @@ -4,12 +4,65 @@ using CommunityToolkit.Datasync.Client.Offline; using CommunityToolkit.Datasync.Client.Test.Offline.Helpers; +using CommunityToolkit.Datasync.TestCommon.Databases; +using Microsoft.EntityFrameworkCore; namespace CommunityToolkit.Datasync.Client.Test.Offline; [ExcludeFromCodeCoverage] public class OperationsQueueManager_Tests : BaseTest { + #region GetExistingOperationAsync + [Fact] + public async Task GetExistingOperationAsync_InvalidId_Throws() + { + TestDbContext context = CreateContext(); + OperationsQueueManager manager = context.QueueManager; + ClientMovie movie = new() { Id = "###" }; + Func act = async () => _ = await manager.GetExistingOperationAsync(movie); + await act.Should().ThrowAsync(); + } + #endregion + + #region InitializeDatasyncEntityMap + [Fact] + public void InitializeDatasyncEntityMap_Works() + { + TestDbContext context = CreateContext(); + OperationsQueueManager sut = context.QueueManager; + sut.InitializeDatasyncEntityMap(); + + Dictionary expected = new() + { + { typeof(ClientMovie).FullName, typeof(ClientMovie) }, + { typeof(Entity3).FullName, typeof(Entity3) } + }; + + sut.DatasyncEntityMap.Should().NotBeNullOrEmpty().And.BeEquivalentTo(expected); + } + #endregion + + #region NullAsEmpty + [Theory] + [InlineData(null, "")] + [InlineData("", "")] + [InlineData("foo", "foo")] + public void NullAsEmpty_Works(string value, string expected) + { + OperationsQueueManager.NullAsEmpty(value).Should().Be(expected); + } + #endregion + + #region ToOperationKind + [Fact] + public void ToOperationKind_Invalid_Throws() + { + EntityState sut = EntityState.Detached; + Action act = () => _ = OperationsQueueManager.ToOperationKind(sut); + act.Should().Throw(); + } + #endregion + #region IDisposable [Fact] public void Dispose_Works() From 99e870045435587d8b5666d215a5200fea4546da Mon Sep 17 00:00:00 2001 From: Adrian Hall Date: Mon, 12 Aug 2024 14:30:10 -0700 Subject: [PATCH 6/6] fix: ClientTableData should be integer column for the UpdatedAt --- .../CommunityToolkit.Datasync.Client.csproj | 1 + .../Models/ClientTableData.cs | 7 +++++++ 2 files changed, 8 insertions(+) diff --git a/src/CommunityToolkit.Datasync.Client/CommunityToolkit.Datasync.Client.csproj b/src/CommunityToolkit.Datasync.Client/CommunityToolkit.Datasync.Client.csproj index afc03d6..8cd7619 100644 --- a/src/CommunityToolkit.Datasync.Client/CommunityToolkit.Datasync.Client.csproj +++ b/src/CommunityToolkit.Datasync.Client/CommunityToolkit.Datasync.Client.csproj @@ -10,6 +10,7 @@ + diff --git a/tests/CommunityToolkit.Datasync.TestCommon/Models/ClientTableData.cs b/tests/CommunityToolkit.Datasync.TestCommon/Models/ClientTableData.cs index 3ed6232..895cf51 100644 --- a/tests/CommunityToolkit.Datasync.TestCommon/Models/ClientTableData.cs +++ b/tests/CommunityToolkit.Datasync.TestCommon/Models/ClientTableData.cs @@ -3,6 +3,8 @@ // See the LICENSE file in the project root for more information. using CommunityToolkit.Datasync.Server; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; namespace CommunityToolkit.Datasync.TestCommon.Models; @@ -27,8 +29,13 @@ public ClientTableData(object source) } } + [Key] public string Id { get; set; } + + [Column(TypeName = "INTEGER")] public DateTimeOffset? UpdatedAt { get; set; } + public string Version { get; set; } + public bool Deleted { get; set; } }