diff --git a/JMayer.Data/Database/DataLayer/IUserEditableDataLayer.cs b/JMayer.Data/Database/DataLayer/IUserEditableDataLayer.cs index 467b363..47b4cac 100644 --- a/JMayer.Data/Database/DataLayer/IUserEditableDataLayer.cs +++ b/JMayer.Data/Database/DataLayer/IUserEditableDataLayer.cs @@ -10,6 +10,41 @@ namespace JMayer.Data.Database.DataLayer; /// A UserEditableDataObject which represents the user editable data in the collection/table. public interface IUserEditableDataLayer : IStandardCRUDDataLayer where T : UserEditableDataObject { + /// + /// The property gets/sets if milliseconds, microseconds and nanoseconds are ignored when comparing the + /// UserEditableDataObject.LastEditedOn property for old data object detection. + /// + /// + /// With client/server communication, its possible the milliseconds, microseconds and nanoseconds are stipped + /// from the DateTime when data is sent to the server. Because of this, the data layer would always detect old + /// data and throw a DataObjectUpdateConflictException so this property was introduced when the comparison needs + /// to be less precise. + ///
+ ///
+ /// The default functionality is strong precision but when you need less precision then in the constructor of your + /// child class, set this property to true. + ///
+ ///
+ /// This property is ignored if the IsOldDataObjectDetectionEnabled property is set to false. + ///
+ bool IsLessPreciseTimestampComparisonEnabled { get; init; } + + /// + /// The property gets/sets if the data layer checks if the data object being updated is considered old. + /// + /// + /// When enabled, the data layer will compare the data object passed into UpdateAsync() with the data + /// object already stored in the collection/table. The LastEditedOn property will be compared and if the + /// timestamps are the same then no one else has edited the data object and the update will occur. If the + /// timestamps are not the same, another user has edited the data object and the data layer throws a + /// DataObjectUpdateConflictException. + ///
+ ///
+ /// The default functionality is to check for data submission conflicts between users but when you don't want this + /// then in the constructor of your child class, set this property to false. + ///
+ bool IsOldDataObjectDetectionEnabled { get; init; } + /// /// The method returns all the data objects for the collection/table as a list view based on a where predicate with an order. /// diff --git a/JMayer.Data/Database/DataLayer/MemoryStorage/UserEditableDataLayer.cs b/JMayer.Data/Database/DataLayer/MemoryStorage/UserEditableDataLayer.cs index aaac505..793fdbe 100644 --- a/JMayer.Data/Database/DataLayer/MemoryStorage/UserEditableDataLayer.cs +++ b/JMayer.Data/Database/DataLayer/MemoryStorage/UserEditableDataLayer.cs @@ -16,23 +16,52 @@ namespace JMayer.Data.Database.DataLayer.MemoryStorage; /// Also, the underlying data storage is a List so this shouldn't be used with very large datasets. /// /// -/// UpdateAsync() has conflict detection. Because the UserEditableDataObject has a LastEditedOn property, -/// update can now determine if the data object passed in is older than the record in memory. When this occurs, -/// an exception is thrown with the idea the user will need to refresh the data to have the latest and then -/// try editing it again. +/// By default, old data detection is enabled. UpdateAsync() will use the LastEditedOn property to determine +/// if the data object passed in is older than the data object in memory. When a conflict is detected, an exception is thrown +/// with the idea the user will need to refresh the data to have the latest and then try editing it again. /// /// public class UserEditableDataLayer : StandardCRUDDataLayer, IUserEditableDataLayer where T : UserEditableDataObject, new() { + /// + public bool IsLessPreciseTimestampComparisonEnabled { get; init; } + + /// + public bool IsOldDataObjectDetectionEnabled { get; init; } = true; + /// /// The method returns if there's no update conflict. /// /// The data object in the database. /// The data object updated by the user. /// True means the data object can be updated; false means there's a conflict. - private static bool AllowToUpdate(T databaseDataObject, T userDataObject) + private bool AllowToUpdate(T databaseDataObject, T userDataObject) { - return databaseDataObject.LastEditedOn == userDataObject.LastEditedOn; + if (IsLessPreciseTimestampComparisonEnabled) + { + //If both are not null, recreate the LastEditedOn but without the millseconds, microseconds & nanoseconds and then, compare to two. + if (databaseDataObject.LastEditedOn is not null && userDataObject.LastEditedOn is not null) + { + DateTime databaseDataObjectLastEditedOn = new(databaseDataObject.LastEditedOn.Value.Year, databaseDataObject.LastEditedOn.Value.Month, databaseDataObject.LastEditedOn.Value.Day, databaseDataObject.LastEditedOn.Value.Hour, databaseDataObject.LastEditedOn.Value.Minute, databaseDataObject.LastEditedOn.Value.Second); + DateTime userDataObjectLastEditedOn = new(userDataObject.LastEditedOn.Value.Year, userDataObject.LastEditedOn.Value.Month, userDataObject.LastEditedOn.Value.Day, userDataObject.LastEditedOn.Value.Hour, userDataObject.LastEditedOn.Value.Minute, userDataObject.LastEditedOn.Value.Second); + + return databaseDataObjectLastEditedOn == userDataObjectLastEditedOn; + } + //If both null then no edits have occurred so allow the update. + else if (databaseDataObject.LastEditedOn is null && userDataObject.LastEditedOn is null) + { + return true; + } + //If one is null and the other not null then edits have occurred so do not allow an update. + else + { + return false; + } + } + else + { + return databaseDataObject.LastEditedOn == userDataObject.LastEditedOn; + } } /// @@ -112,14 +141,10 @@ public async override Task> UpdateAsync(List dataObjects, Cancellatio //Confirm the ID exists and the data object isn't old because this wants all or none updated. foreach (T dataObject in dataObjects) { - T? databaseDataObject = DataStorage.FirstOrDefault(obj => obj.Integer64ID == dataObject.Integer64ID); - - if (databaseDataObject == null) - { - throw new IDNotFoundException(dataObject.Integer64ID.ToString()); - } + T? databaseDataObject = DataStorage.FirstOrDefault(obj => obj.Integer64ID == dataObject.Integer64ID) + ?? throw new IDNotFoundException(dataObject.Integer64ID.ToString()); - if (!AllowToUpdate(databaseDataObject, dataObject)) + if (IsOldDataObjectDetectionEnabled && AllowToUpdate(databaseDataObject, dataObject) is false) { throw new DataObjectUpdateConflictException($"Failed to update because the data object was updated by {databaseDataObject.LastEditedBy ?? databaseDataObject.LastEditedByID ?? string.Empty} on {databaseDataObject.LastEditedOn}."); } @@ -137,7 +162,7 @@ public async override Task> UpdateAsync(List dataObjects, Cancellatio } } - OnUpdated(new UpdatedEventArgs([.. returnDataObjects])); + OnUpdated(new UpdatedEventArgs([..returnDataObjects])); return await Task.FromResult(returnDataObjects); } @@ -149,7 +174,7 @@ public async override Task> ValidateAsync(T dataObject, C if (await ExistAsync(obj => obj.Integer64ID != dataObject.Integer64ID && obj.Name == dataObject.Name, cancellationToken) == true) { - validationResults.Add(new ValidationResult($"The {dataObject.Name} name already exists in the data store.", new List() { nameof(dataObject.Name) })); + validationResults.Add(new ValidationResult($"The {dataObject.Name} name already exists in the data store.", [nameof(dataObject.Name)])); } return validationResults; diff --git a/JMayer.Data/JMayer.Data.csproj b/JMayer.Data/JMayer.Data.csproj index 27cbb04..b89b7a0 100644 --- a/JMayer.Data/JMayer.Data.csproj +++ b/JMayer.Data/JMayer.Data.csproj @@ -4,7 +4,7 @@ net8.0 enable enable - 1.2.2 + 1.3.0 jmayer913 jmayer913 Package contains generic data access via HTTP or a Database. diff --git a/TestProject/Test/Database/UserEditableDataLayerUnitTest.cs b/TestProject/Test/Database/UserEditableDataLayerUnitTest.cs index e9cf4f6..c7b6e37 100644 --- a/TestProject/Test/Database/UserEditableDataLayerUnitTest.cs +++ b/TestProject/Test/Database/UserEditableDataLayerUnitTest.cs @@ -254,6 +254,61 @@ public async Task VerifyGetPageListView() [Fact] public async Task VerifyGetPageListViewThrowsArgumentNullException() => await Assert.ThrowsAsync(() => new SimpleUserEditableDataLayer().GetPageListViewAsync(null)); + /// + /// The method verifies when IsOldDataObjectDetectionEnabled is false no exception is thrown. + /// + /// A Task for the async. + /// + /// This is relying on when IsOldDataObjectDetectionEnabled is true and there's a conflict, an exception + /// is thrown. Xunit fails the test when an unhandle exception occurs so this passes when no exception is thrown. + /// + [Fact] + public async Task VerifyUpdateDisableOldDataObjectDetectionThrowsNoException() + { + SimpleUserEditableDataLayer dataLayer = new() + { + IsOldDataObjectDetectionEnabled = false, + }; + SimpleUserEditableDataObject originalDataObject = await dataLayer.CreateAsync(new SimpleUserEditableDataObject() { Name = DefaultName }); + + _ = await dataLayer.UpdateAsync(new SimpleUserEditableDataObject(originalDataObject) + { + Value = DefaultValue, + }); + + originalDataObject.Value = DefaultValue + 1; + _ = await dataLayer.UpdateAsync(originalDataObject); + } + + /// + /// The method verifies when IsLessPreciseTimestampComparisonEnabled is true and LastEditedOn only has a time difference in the milliseconds or below no exception is thrown. + /// + /// A Task for the async. + /// + /// This is relying on when a conflict is detected, an exception is thrown. Xunit fails the test + /// when an unhandle exception occurs so this passes when no exception is thrown. + /// + [Fact] + public async Task VerifyUpdateEnableLessPreciseTimestampComparisonThrowsNoException() + { +#warning For the VerifyUpdateEnableLessPreciseTimestampComparisonThrowsNoException() test, I set LastEditedOn property to DateTime.Now without milliseconds. Create doesn't clear the LastEditedOn property but maybe it should because the object has just been added to the memory storage and the LastEditedOn property should be null. + + SimpleUserEditableDataLayer dataLayer = new() + { + IsLessPreciseTimestampComparisonEnabled = true, + }; + SimpleUserEditableDataObject originalDataObject = await dataLayer.CreateAsync(new SimpleUserEditableDataObject() { Name = DefaultName, LastEditedOn = new DateTime(DateTime.Now.Year, DateTime.Now.Month, DateTime.Now.Day, DateTime.Now.Hour, DateTime.Now.Minute, DateTime.Now.Second) }); + + originalDataObject.Value = DefaultValue + 1; + + if (originalDataObject.LastEditedOn is not null) + { + originalDataObject.LastEditedOn = originalDataObject.LastEditedOn.Value.AddMilliseconds(1); + } + + _ = await dataLayer.UpdateAsync(originalDataObject); + } + /// /// The method verifies the UserEditableMemoryDataLayer.Updated event fires when UserEditableMemoryDataLayer.UpdateAsync() is called. /// @@ -367,6 +422,8 @@ await Assert.ThrowsAsync(async Task () => }); } + + /// /// The method verifies UserEditableMemoryDataLayer.UpdateAsync() updates a list of data objects in the data store. ///