Skip to content

Commit

Permalink
moved changes history tracking from the UndoRedoDomainModel to the se…
Browse files Browse the repository at this point in the history
…parate class
  • Loading branch information
AlexNav73 committed Apr 16, 2024
1 parent 59e24f9 commit c7679a2
Show file tree
Hide file tree
Showing 10 changed files with 344 additions and 284 deletions.
8 changes: 4 additions & 4 deletions build/Build.cs
Original file line number Diff line number Diff line change
Expand Up @@ -133,28 +133,28 @@ internal partial class Build : NukeBuild
DotNetPack(s => s
.SetProject(Solution.CoreCraft)
.Apply(PackSettingsBase)
.SetVersion(MakePreviewIfNeeded("0.6.0", "0.6.10"))
.SetVersion(MakePreviewIfNeeded("0.6.0", "0.6.11"))
.SetDescription("A core library to build cross-platform and highly customizable domain models")
.AddPackageTags("Model", "Domain"));
DotNetPack(s => s
.SetProject(Solution.CoreCraft_Generators)
.Apply(PackSettingsBase)
.SetVersion(MakePreviewIfNeeded("0.6.0", "0.6.10"))
.SetVersion(MakePreviewIfNeeded("0.6.0", "0.6.11"))
.SetDescription("Roslyn Source Generators for generating domain models using 'CoreCraft' library")
.AddPackageTags("Model", "Domain", "SourceGenerator", "Generator"));
DotNetPack(s => s
.SetProject(Solution.CoreCraft_Storage_Sqlite)
.Apply(PackSettingsBase)
.SetVersion(MakePreviewIfNeeded("0.6.0", "0.6.10"))
.SetVersion(MakePreviewIfNeeded("0.6.0", "0.6.11"))
.SetDescription("SQLite storage implementation for 'CoreCraft' library")
.AddPackageTags("Model", "Domain", "SQLite"));
DotNetPack(s => s
.SetProject(Solution.CoreCraft_Storage_Json)
.Apply(PackSettingsBase)
.SetVersion(MakePreviewIfNeeded("0.3.0", "0.3.10"))
.SetVersion(MakePreviewIfNeeded("0.3.0", "0.3.11"))
.SetDescription("Json storage implementation for 'CoreCraft' library")
.AddPackageTags("Model", "Domain", "Json"));
Expand Down
1 change: 1 addition & 0 deletions examples/ConsoleDemoApp/ConsoleDemoApp.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@

<ItemGroup>
<ProjectReference Include="..\..\src\CoreCraft.Generators\CoreCraft.Generators.csproj" OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
<ProjectReference Include="..\..\src\CoreCraft.Storage.Json\CoreCraft.Storage.Json.csproj" />
<ProjectReference Include="..\..\src\CoreCraft.Storage.Sqlite\CoreCraft.Storage.Sqlite.csproj" />
<ProjectReference Include="..\..\src\CoreCraft\CoreCraft.csproj" />
</ItemGroup>
Expand Down
43 changes: 33 additions & 10 deletions examples/ConsoleDemoApp/Program.cs
Original file line number Diff line number Diff line change
@@ -1,28 +1,32 @@
using ConsoleDemoApp.Model;
using ConsoleDemoApp.Model.Entities;
using CoreCraft;
using CoreCraft.Subscription;
using CoreCraft.Scheduling;
using CoreCraft.Storage.Json;
using CoreCraft.Storage.Sqlite;
using CoreCraft.Storage.Sqlite.Migrations;
using ConsoleDemoApp.Model.Entities;
using CoreCraft.Subscription;

namespace ConsoleDemoApp;

class Program
{
private const string Path = "test.db";
private const string History = "history.json";

public static async Task Main()
{
if (File.Exists(Path))
{
File.Delete(Path);
}
if (File.Exists(History))
{
File.Delete(History);
}

var storage = new SqliteStorage(
Path,
Array.Empty<IMigration>(),
Console.WriteLine);
var model = new DomainModel(new[] { new ExampleModelShard() });
var storage = new SqliteStorage(Path, [], Console.WriteLine);
var historyStorage = new JsonStorage(History, new() { Formatting = Newtonsoft.Json.Formatting.Indented });
var model = new UndoRedoDomainModel(new[] { new ExampleModelShard() }, new SyncScheduler());

using (model.For<IExampleChangesFrame>().Subscribe(OnExampleShardChanged))
{
Expand Down Expand Up @@ -63,13 +67,32 @@ await model.Run<IMutableExampleModelShard>((shard, _) =>

Console.WriteLine("======================== Saving ========================");
await model.Save(storage);
await model.History.Save(historyStorage);

model = new DomainModel(new[] { new ExampleModelShard() });
model = new UndoRedoDomainModel(new[] { new ExampleModelShard() }, new SyncScheduler());
using (model.For<IExampleChangesFrame>().Subscribe(OnExampleShardChanged))
{
Console.WriteLine("======================== Loading ========================");

await model.Load(storage);
await model.History.Load(historyStorage);
await model.Load(storage, force: true);

Console.WriteLine("======================== Adding new change ========================");

await model.Run<IMutableExampleModelShard>((shard, _) =>
{
shard.FirstCollection.Add(new() { StringProperty = "test", IntegerProperty = 42 });
});
await model.History.Save(historyStorage);
await model.History.Load(historyStorage);

Console.WriteLine("======================== Undo after load ========================");

var historySize = model.History.UndoStack.Count;
for (int i = 0; i < historySize; i++)
{
await model.History.Undo();
}
}
}

Expand Down
1 change: 1 addition & 0 deletions examples/ConsoleDemoApp/SecondEntityProperties.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ public enum SecondEntityEnum : int

partial record SecondEntityProperties
{
[Newtonsoft.Json.JsonIgnore]
public SecondEntityEnum EnumProperty
{
get => (SecondEntityEnum)IntProperty;
Expand Down
8 changes: 4 additions & 4 deletions examples/WpfDemoApp/ViewModels/MainWindowViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ public MainWindowViewModel(UndoRedoDomainModel model, Func<string, IStorage> sto
.With(x => x.Items)
.Bind(OnNext);

_model.Changed += OnModelChanged; // unsubscribe
_model.History.Changed += OnModelChanged; // unsubscribe
}

public ObservableCollection<ItemViewModel> Items { get; }
Expand Down Expand Up @@ -78,13 +78,13 @@ await _model.Run<IMutableToDoModelShard>(
[RelayCommand]
private async Task Undo()
{
await _model.Undo();
await _model.History.Undo();
}

[RelayCommand]
private async Task Redo()
{
await _model.Redo();
await _model.History.Redo();
}

[RelayCommand]
Expand Down Expand Up @@ -113,7 +113,7 @@ private void OnModelChanged(object? sender, EventArgs e)
{
Logs.Clear();

foreach (var change in _model.UndoStack)
foreach (var change in _model.History.UndoStack)
{
if (change.TryGetFrame<IToDoChangesFrame>(out var frame) && frame.HasChanges())
{
Expand Down
217 changes: 217 additions & 0 deletions src/CoreCraft/ChangesHistory.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
using CoreCraft.ChangesTracking;
using CoreCraft.Exceptions;
using CoreCraft.Persistence;
using CoreCraft.Persistence.History;

namespace CoreCraft;

/// <summary>
/// Tracks changes history for a model and provides undo/redo functionality.
/// </summary>
public class ChangesHistory
{
private readonly DomainModel _model;

private readonly Stack<IModelChanges> _undoStack;
private readonly Stack<IModelChanges> _redoStack;

/// <summary>
/// Ctor
/// </summary>
public ChangesHistory(DomainModel model)
{
_model = model;

_undoStack = new Stack<IModelChanges>();
_redoStack = new Stack<IModelChanges>();
}

/// <summary>
/// Undo stack of model changes
/// </summary>
public IReadOnlyCollection<IModelChanges> UndoStack => _undoStack;

/// <summary>
/// Redo stack of model changes
/// </summary>
public IReadOnlyCollection<IModelChanges> RedoStack => _redoStack;

/// <summary>
/// Raised when model has changed
/// </summary>
public event EventHandler? Changed;

/// <summary>
/// Saves changes happened since the last save operation
/// </summary>
/// <param name="token">Cancellation token</param>
/// <param name="storage">A storage where a model will be saved</param>
/// <param name="historyStorage">A storage where a model changes history will be saved</param>
public async Task Update(IStorage storage, IHistoryStorage? historyStorage = null, CancellationToken token = default)
{
try
{
var changes = _undoStack.Reverse().ToList();
if (changes.Count > 0)
{
var merged = MergeChanges(changes);
// merge operation can combine actions line Add and Remove, which will cause
// resulting IModelChanges object contain no changes
if (merged.HasChanges())
{
await _model.Scheduler.RunParallel(() =>
{
storage.Update(merged);
historyStorage?.Save(changes);
}, token);

// TODO(#8): saving operation executes in thread pool
// and launched by 'async void' methods. If two
// sequential savings happened, clearing of the stacks
// can cause data race (just after first save we will clear stack
// with new changes, made after first save started).
Clear();
}
}
}
catch (Exception ex)
{
throw new ModelSaveException("Model update has failed", ex);
}
}

/// <summary>
/// Saves the model's undo/redo history to the provided storage.
/// </summary>
/// <param name="storage">A storage to write</param>
/// <param name="token">Cancellation token</param>
/// <exception cref="ModelSaveException">Throws when an error occurred while saving model changes history</exception>
public async Task Save(IHistoryStorage storage, CancellationToken token = default)
{
try
{
var changes = _undoStack.Reverse().ToList();
if (changes.Count > 0)
{
await _model.Scheduler.RunParallel(() => storage.Save(changes), token);

// TODO(#8): saving operation executes in thread pool
// and launched by 'async void' methods. If two
// sequential savings happened, clearing of the stacks
// can cause data race (just after first save we will clear stack
// with new changes, made after first save started).
Clear();
}
}
catch (Exception ex)
{
throw new ModelSaveException("Model changes saving has failed", ex);
}
}

/// <summary>
/// Loads the model's undo/redo history from the provided storage.
/// </summary>
/// <param name="storage">A storage to write</param>
/// <param name="token">Cancellation token</param>
public async Task Load(IHistoryStorage storage, CancellationToken token = default)
{
if (_undoStack.Count > 0 || _redoStack.Count > 0)
{
return;
}

var changes = await LoadHistory(storage, token);

foreach (var change in changes)
{
_undoStack.Push(change);
}
}

/// <summary>
/// Clears the model's undo/redo history
/// </summary>
public void Clear()
{
if (_undoStack.Count > 0 || _redoStack.Count > 0)
{
_undoStack.Clear();
_redoStack.Clear();

Changed?.Invoke(this, EventArgs.Empty);
}
}

/// <summary>
/// Undo latest changes
/// </summary>
public async Task Undo()
{
if (_undoStack.Count > 0)
{
var changes = _undoStack.Pop();
_redoStack.Push(changes);
await _model.Apply(changes.Invert());

Changed?.Invoke(this, EventArgs.Empty);
}
}

/// <summary>
/// Redo changes which were undone previously
/// </summary>
public async Task Redo()
{
if (_redoStack.Count > 0)
{
var changes = _redoStack.Pop();
_undoStack.Push(changes);
await _model.Apply(changes);

Changed?.Invoke(this, EventArgs.Empty);
}
}

/// <summary>
/// History has changes
/// </summary>
/// <returns>True - if history has some changes in it</returns>
public bool HasChanges()
{
return _undoStack.Count > 0;
}

/// <summary>
/// Pushes the provided model changes to the undo stack
/// </summary>
/// <param name="change"></param>
public void Push(IModelChanges change)
{
_undoStack.Push(change);
_redoStack.Clear();

Changed?.Invoke(this, EventArgs.Empty);
}

private Task<IEnumerable<IModelChanges>> LoadHistory(
IHistoryStorage storage,
CancellationToken token = default)
{
// TODO: Write explanation why we can use UnsafeModelShards here
var shards = _model.UnsafeModelShards;

return _model.Scheduler.Enqueue(() => storage.Load(shards), token);
}

private static IModelChanges MergeChanges(IReadOnlyList<IModelChanges> changes)
{
var merged = (IMutableModelChanges)changes[0];
for (var i = 1; i < changes.Count; i++)
{
merged = merged.Merge(changes[i]);
}

return merged;
}
}
Loading

0 comments on commit c7679a2

Please sign in to comment.