Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 23 additions & 5 deletions src/SIL.Harmony.Tests/DataModelSimpleChanges.cs
Original file line number Diff line number Diff line change
Expand Up @@ -190,28 +190,46 @@ public async Task CanDeleteAnEntry()
}

[Fact]
public async Task CanModifyAnEntryAfterDelete()
public async Task ApplyChange_ModifiesADeletedEntry()
{
await WriteNextChange(SetWord(_entity1Id, "test-value"));
var deleteCommit = await WriteNextChange(new DeleteChange<Word>(_entity1Id));
await WriteNextChange(SetWord(_entity1Id, "after-delete"));
var newNoteChange = new SetWordNoteChange(_entity1Id, "note-after-delete");
newNoteChange.SupportsApplyChange().Should().BeTrue();
newNoteChange.SupportsNewEntity().Should().BeFalse(); // otherwise it will override the delete
await WriteNextChange(newNoteChange);
var snapshot = await DbContext.Snapshots.DefaultOrder().LastAsync();
var word = snapshot.Entity.Is<Word>();
word.Text.Should().Be("after-delete");
word.Text.Should().Be("test-value");
word.Note.Should().Be("note-after-delete");
word.DeletedAt.Should().Be(deleteCommit.DateTime);
}

[Fact]
public async Task NewEntity_UndeletesAnEntry()
{
await WriteNextChange(SetWord(_entity1Id, "test-value"));
await WriteNextChange(new DeleteChange<Word>(_entity1Id));
var recreateChange = SetWord(_entity1Id, "after-undo-delete");
recreateChange.SupportsNewEntity().Should().BeTrue();
await WriteNextChange(recreateChange);
var snapshot = await DbContext.Snapshots.DefaultOrder().LastAsync();
var word = snapshot.Entity.Is<Word>();
word.Text.Should().Be("after-undo-delete");
word.DeletedAt.Should().Be(null);
}

[Fact]
public async Task ChangesToSnapshotsAreNotSaved()
{
await WriteNextChange(SetWord(_entity1Id, "test-value"));
var word = await DataModel.GetLatest<Word>(_entity1Id);
word!.Text.Should().Be("test-value");
word.Note.Should().BeNull();

//change made outside the model, should not be saved when writing the next change
word.Note = "a note";

var commit = await WriteNextChange(SetWord(_entity1Id, "after-change"));
var objectSnapshot = commit.Snapshots.Should().ContainSingle().Subject;
objectSnapshot.Entity.Is<Word>().Text.Should().Be("after-change");
Expand Down
215 changes: 215 additions & 0 deletions src/SIL.Harmony.Tests/DeleteAndCreateTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
using Microsoft.EntityFrameworkCore;
using SIL.Harmony.Changes;
using SIL.Harmony.Sample.Changes;
using SIL.Harmony.Sample.Models;

namespace SIL.Harmony.Tests;

public class DeleteAndCreateTests : DataModelTestBase
{
[Fact]
public async Task DeleteAndUndeleteInSameCommitWorks()
{
var wordId = Guid.NewGuid();

await WriteNextChange(new NewWordChange(wordId, "original"));

await WriteNextChange(
[
new DeleteChange<Word>(wordId),
new NewWordChange(wordId, "Undeleted"),
]);

var word = await DataModel.GetLatest<Word>(wordId);
word.Should().NotBeNull();
word.DeletedAt.Should().BeNull();
word.Text.Should().Be("Undeleted");

var entityWord = await DataModel.QueryLatest<Word>().Where(w => w.Id == wordId).SingleOrDefaultAsync();
entityWord.Should().NotBeNull();
entityWord.DeletedAt.Should().BeNull();
entityWord.Text.Should().Be("Undeleted");
}

[Fact]
public async Task DeleteAndUndeleteInSameSyncWorks()
{
var wordId = Guid.NewGuid();

await WriteNextChange(new NewWordChange(wordId, "original"));

await AddCommitsViaSync([
await WriteNextChange(new DeleteChange<Word>(wordId), add: false),
await WriteNextChange(new NewWordChange(wordId, "Undeleted"), add: false),
]);

var word = await DataModel.GetLatest<Word>(wordId);
word.Should().NotBeNull();
word.DeletedAt.Should().BeNull();
word.Text.Should().Be("Undeleted");

var entityWord = await DataModel.QueryLatest<Word>().Where(w => w.Id == wordId).SingleOrDefaultAsync();
entityWord.Should().NotBeNull();
entityWord.DeletedAt.Should().BeNull();
entityWord.Text.Should().Be("Undeleted");
}

[Fact]
public async Task UpdateAndUndeleteInSameCommitWorks()
{
var wordId = Guid.NewGuid();

await WriteNextChange(new NewWordChange(wordId, "original"));
await WriteNextChange(new DeleteChange<Word>(wordId));

await WriteNextChange([
new SetWordNoteChange(wordId, "overridden-note"),
new NewWordChange(wordId, "Undeleted"),
]);

var word = await DataModel.GetLatest<Word>(wordId);
word.Should().NotBeNull();
word.DeletedAt.Should().BeNull();
word.Text.Should().Be("Undeleted");

var entityWord = await DataModel.QueryLatest<Word>().Where(w => w.Id == wordId).SingleOrDefaultAsync();
entityWord.Should().NotBeNull();
entityWord.DeletedAt.Should().BeNull();
entityWord.Text.Should().Be("Undeleted");
}

[Fact]
public async Task UpdateAndUndeleteInSameSyncWorks()
{
var wordId = Guid.NewGuid();

await WriteNextChange(new NewWordChange(wordId, "original"));
await WriteNextChange(new DeleteChange<Word>(wordId));

await AddCommitsViaSync([
await WriteNextChange(new SetWordNoteChange(wordId, "overridden-note"), add: false),
await WriteNextChange(new NewWordChange(wordId, "Undeleted"), add: false),
]);

var word = await DataModel.GetLatest<Word>(wordId);
word.Should().NotBeNull();
word.DeletedAt.Should().BeNull();
word.Text.Should().Be("Undeleted");

var entityWord = await DataModel.QueryLatest<Word>().Where(w => w.Id == wordId).SingleOrDefaultAsync();
entityWord.Should().NotBeNull();
entityWord.DeletedAt.Should().BeNull();
entityWord.Text.Should().Be("Undeleted");
}

[Fact]
public async Task CreateDeleteAndUndeleteInSameCommitWorks()
{
var wordId = Guid.NewGuid();

await WriteNextChange(
[
new NewWordChange(wordId, "original"),
new DeleteChange<Word>(wordId),
new NewWordChange(wordId, "Undeleted"),
]);

var word = await DataModel.GetLatest<Word>(wordId);
word.Should().NotBeNull();
word.DeletedAt.Should().BeNull();
word.Text.Should().Be("Undeleted");

var entityWord = await DataModel.QueryLatest<Word>().Where(w => w.Id == wordId).SingleOrDefaultAsync();
entityWord.Should().NotBeNull();
entityWord.DeletedAt.Should().BeNull();
entityWord.Text.Should().Be("Undeleted");
}

[Fact]
public async Task CreateDeleteAndUndeleteInSameSyncWorks()
{
var wordId = Guid.NewGuid();

await AddCommitsViaSync([
await WriteNextChange(new NewWordChange(wordId, "original"), add: false),
await WriteNextChange(new DeleteChange<Word>(wordId), add: false),
await WriteNextChange(new NewWordChange(wordId, "Undeleted"), add: false),
]);

var word = await DataModel.GetLatest<Word>(wordId);
word.Should().NotBeNull();
word.DeletedAt.Should().BeNull();
word.Text.Should().Be("Undeleted");

var entityWord = await DataModel.QueryLatest<Word>().Where(w => w.Id == wordId).SingleOrDefaultAsync();
entityWord.Should().NotBeNull();
entityWord.DeletedAt.Should().BeNull();
entityWord.Text.Should().Be("Undeleted");
}

[Fact]
public async Task CreateAndDeleteInSameCommitWorks()
{
var wordId = Guid.NewGuid();

await WriteNextChange(
[
new NewWordChange(wordId, "original"),
new DeleteChange<Word>(wordId),
]);

var word = await DataModel.GetLatest<Word>(wordId);
word.Should().NotBeNull();
word.DeletedAt.Should().NotBeNull();
word.Text.Should().Be("original");

var entityWord = await DataModel.QueryLatest<Word>().Where(w => w.Id == wordId).SingleOrDefaultAsync();
entityWord.Should().BeNull();
}

[Fact]
public async Task CreateAndDeleteInSameSyncWorks()
{
var wordId = Guid.NewGuid();

await AddCommitsViaSync([
await WriteNextChange(new NewWordChange(wordId, "original"), add: false),
await WriteNextChange(new DeleteChange<Word>(wordId), add: false),
]);

var word = await DataModel.GetLatest<Word>(wordId);
word.Should().NotBeNull();
word.DeletedAt.Should().NotBeNull();
word.Text.Should().Be("original");

var entityWord = await DataModel.QueryLatest<Word>().Where(w => w.Id == wordId).SingleOrDefaultAsync();
entityWord.Should().BeNull();
}

[Fact]
public async Task NewEntityOnExistingEntityIsNoOp()
{
var wordId = Guid.NewGuid();

await WriteNextChange(new NewWordChange(wordId, "original"));
var snapshotsBefore = await DbContext.Snapshots.Where(s => s.EntityId == wordId).ToArrayAsync();

await WriteNextChange(
[
new NewWordChange(wordId, "Undeleted"),
]);

var word = await DataModel.GetLatest<Word>(wordId);
word.Should().NotBeNull();
word.DeletedAt.Should().BeNull();
word.Text.Should().Be("original");

var entityWord = await DataModel.QueryLatest<Word>().Where(w => w.Id == wordId).SingleOrDefaultAsync();
entityWord.Should().NotBeNull();
entityWord.DeletedAt.Should().BeNull();
entityWord.Text.Should().Be("original");

var snapshotsAfter = await DbContext.Snapshots.Where(s => s.EntityId == wordId).ToArrayAsync();
snapshotsAfter.Select(s => s.Id).Should().BeEquivalentTo(snapshotsBefore.Select(s => s.Id));
}
}
8 changes: 5 additions & 3 deletions src/SIL.Harmony.Tests/SnapshotTests.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
using SIL.Harmony.Sample.Models;
using SIL.Harmony.Sample.Changes;
using SIL.Harmony.Changes;
using Microsoft.EntityFrameworkCore;

namespace SIL.Harmony.Tests;
Expand All @@ -19,7 +21,7 @@ public async Task MultipleChangesPreservesRootSnapshot()
{
var entityId = Guid.NewGuid();
var commits = new List<Commit>();
for (int i = 0; i < 4; i++)
for (var i = 0; i < 4; i++)
{
commits.Add(await WriteChange(_localClientId,
new DateTimeOffset(2000, 1, 1, 0, 0, 0, TimeSpan.Zero).AddHours(i),
Expand All @@ -39,7 +41,7 @@ public async Task MultipleChangesPreservesSomeIntermediateSnapshots()
{
var entityId = Guid.NewGuid();
var commits = new List<Commit>();
for (int i = 0; i < 6; i++)
for (var i = 0; i < 6; i++)
{
commits.Add(await WriteChange(_localClientId,
new DateTimeOffset(2000, 1, 1, 0, 0, 0, TimeSpan.Zero).AddHours(i),
Expand Down Expand Up @@ -135,7 +137,7 @@ await WriteNextChange(
var tagCreation = await WriteNextChange(TagWord(wordId, tagId));
await WriteChangeBefore(tagCreation, TagWord(wordId, tagId));

var word = await DataModel.QueryLatest<Word>(q=> q.Include(w => w.Tags)
var word = await DataModel.QueryLatest<Word>(q => q.Include(w => w.Tags)
.Where(w => w.Id == wordId)).FirstOrDefaultAsync();
word.Should().NotBeNull();
word.Tags.Should().BeEquivalentTo([new Tag { Id = tagId, Text = "tag-1" }]);
Expand Down
28 changes: 27 additions & 1 deletion src/SIL.Harmony/Changes/Change.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Diagnostics;
using System.Text.Json.Serialization;

namespace SIL.Harmony.Changes;
Expand All @@ -16,6 +17,17 @@ public interface IChange

ValueTask ApplyChange(IObjectBase entity, IChangeContext context);
ValueTask<IObjectBase> NewEntity(Commit commit, IChangeContext context);
/// <summary>
/// Indicates whether this change supports applying changes to an existing entity (whether deleted or not).
/// Essentially just avoids creating redundant snapshots for change objects that won't apply changes.
/// (e.g. redundant change objects intended only for NewEntity)
/// </summary>
bool SupportsApplyChange();
/// <summary>
/// Indicates whether this change supports creating entities (both creating brand new entities as well as recreating deleted entities).
/// Primarily for differentiating between updating vs recreating deleted entities.
/// </summary>
bool SupportsNewEntity();
}

/// <summary>
Expand Down Expand Up @@ -44,8 +56,12 @@ async ValueTask<IObjectBase> IChange.NewEntity(Commit commit, IChangeContext con

public async ValueTask ApplyChange(IObjectBase entity, IChangeContext context)
{
if (this is CreateChange<T>)
if (!SupportsApplyChange())
{
Debug.Fail("ApplyChange called on a Change that does not support it");
return; // skip attempting to apply changes on CreateChange as it does not support apply changes
}

if (entity.DbObject is T entityT)
{
await ApplyChange(entityT, context);
Expand All @@ -56,6 +72,16 @@ public async ValueTask ApplyChange(IObjectBase entity, IChangeContext context)
}
}

public virtual bool SupportsApplyChange()
{
return this is not CreateChange<T>;
}

public virtual bool SupportsNewEntity()
{
return this is not EditChange<T>;
}

[JsonIgnore]
public Type EntityType => typeof(T);
}
Loading