Skip to content

Commit 399406c

Browse files
authored
Make NewEntry override deleted (#54)
* Make NewEntity override deleted * Save changes even if no snapshots were created * Restore save-changes in loop * Fix projecting deleted and undeleted snapshots of same entity * Test no-op NewEntity * Add note explaining redundant try/catch * Fix insufficient commit ordering * Add debug assert for what is now an unexpected code path. * Add comments/docs
1 parent a9600a0 commit 399406c

File tree

6 files changed

+319
-25
lines changed

6 files changed

+319
-25
lines changed

src/SIL.Harmony.Tests/DataModelSimpleChanges.cs

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -190,28 +190,46 @@ public async Task CanDeleteAnEntry()
190190
}
191191

192192
[Fact]
193-
public async Task CanModifyAnEntryAfterDelete()
193+
public async Task ApplyChange_ModifiesADeletedEntry()
194194
{
195195
await WriteNextChange(SetWord(_entity1Id, "test-value"));
196196
var deleteCommit = await WriteNextChange(new DeleteChange<Word>(_entity1Id));
197-
await WriteNextChange(SetWord(_entity1Id, "after-delete"));
197+
var newNoteChange = new SetWordNoteChange(_entity1Id, "note-after-delete");
198+
newNoteChange.SupportsApplyChange().Should().BeTrue();
199+
newNoteChange.SupportsNewEntity().Should().BeFalse(); // otherwise it will override the delete
200+
await WriteNextChange(newNoteChange);
198201
var snapshot = await DbContext.Snapshots.DefaultOrder().LastAsync();
199202
var word = snapshot.Entity.Is<Word>();
200-
word.Text.Should().Be("after-delete");
203+
word.Text.Should().Be("test-value");
204+
word.Note.Should().Be("note-after-delete");
201205
word.DeletedAt.Should().Be(deleteCommit.DateTime);
202206
}
203207

208+
[Fact]
209+
public async Task NewEntity_UndeletesAnEntry()
210+
{
211+
await WriteNextChange(SetWord(_entity1Id, "test-value"));
212+
await WriteNextChange(new DeleteChange<Word>(_entity1Id));
213+
var recreateChange = SetWord(_entity1Id, "after-undo-delete");
214+
recreateChange.SupportsNewEntity().Should().BeTrue();
215+
await WriteNextChange(recreateChange);
216+
var snapshot = await DbContext.Snapshots.DefaultOrder().LastAsync();
217+
var word = snapshot.Entity.Is<Word>();
218+
word.Text.Should().Be("after-undo-delete");
219+
word.DeletedAt.Should().Be(null);
220+
}
221+
204222
[Fact]
205223
public async Task ChangesToSnapshotsAreNotSaved()
206224
{
207225
await WriteNextChange(SetWord(_entity1Id, "test-value"));
208226
var word = await DataModel.GetLatest<Word>(_entity1Id);
209227
word!.Text.Should().Be("test-value");
210228
word.Note.Should().BeNull();
211-
229+
212230
//change made outside the model, should not be saved when writing the next change
213231
word.Note = "a note";
214-
232+
215233
var commit = await WriteNextChange(SetWord(_entity1Id, "after-change"));
216234
var objectSnapshot = commit.Snapshots.Should().ContainSingle().Subject;
217235
objectSnapshot.Entity.Is<Word>().Text.Should().Be("after-change");
Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
using Microsoft.EntityFrameworkCore;
2+
using SIL.Harmony.Changes;
3+
using SIL.Harmony.Sample.Changes;
4+
using SIL.Harmony.Sample.Models;
5+
6+
namespace SIL.Harmony.Tests;
7+
8+
public class DeleteAndCreateTests : DataModelTestBase
9+
{
10+
[Fact]
11+
public async Task DeleteAndUndeleteInSameCommitWorks()
12+
{
13+
var wordId = Guid.NewGuid();
14+
15+
await WriteNextChange(new NewWordChange(wordId, "original"));
16+
17+
await WriteNextChange(
18+
[
19+
new DeleteChange<Word>(wordId),
20+
new NewWordChange(wordId, "Undeleted"),
21+
]);
22+
23+
var word = await DataModel.GetLatest<Word>(wordId);
24+
word.Should().NotBeNull();
25+
word.DeletedAt.Should().BeNull();
26+
word.Text.Should().Be("Undeleted");
27+
28+
var entityWord = await DataModel.QueryLatest<Word>().Where(w => w.Id == wordId).SingleOrDefaultAsync();
29+
entityWord.Should().NotBeNull();
30+
entityWord.DeletedAt.Should().BeNull();
31+
entityWord.Text.Should().Be("Undeleted");
32+
}
33+
34+
[Fact]
35+
public async Task DeleteAndUndeleteInSameSyncWorks()
36+
{
37+
var wordId = Guid.NewGuid();
38+
39+
await WriteNextChange(new NewWordChange(wordId, "original"));
40+
41+
await AddCommitsViaSync([
42+
await WriteNextChange(new DeleteChange<Word>(wordId), add: false),
43+
await WriteNextChange(new NewWordChange(wordId, "Undeleted"), add: false),
44+
]);
45+
46+
var word = await DataModel.GetLatest<Word>(wordId);
47+
word.Should().NotBeNull();
48+
word.DeletedAt.Should().BeNull();
49+
word.Text.Should().Be("Undeleted");
50+
51+
var entityWord = await DataModel.QueryLatest<Word>().Where(w => w.Id == wordId).SingleOrDefaultAsync();
52+
entityWord.Should().NotBeNull();
53+
entityWord.DeletedAt.Should().BeNull();
54+
entityWord.Text.Should().Be("Undeleted");
55+
}
56+
57+
[Fact]
58+
public async Task UpdateAndUndeleteInSameCommitWorks()
59+
{
60+
var wordId = Guid.NewGuid();
61+
62+
await WriteNextChange(new NewWordChange(wordId, "original"));
63+
await WriteNextChange(new DeleteChange<Word>(wordId));
64+
65+
await WriteNextChange([
66+
new SetWordNoteChange(wordId, "overridden-note"),
67+
new NewWordChange(wordId, "Undeleted"),
68+
]);
69+
70+
var word = await DataModel.GetLatest<Word>(wordId);
71+
word.Should().NotBeNull();
72+
word.DeletedAt.Should().BeNull();
73+
word.Text.Should().Be("Undeleted");
74+
75+
var entityWord = await DataModel.QueryLatest<Word>().Where(w => w.Id == wordId).SingleOrDefaultAsync();
76+
entityWord.Should().NotBeNull();
77+
entityWord.DeletedAt.Should().BeNull();
78+
entityWord.Text.Should().Be("Undeleted");
79+
}
80+
81+
[Fact]
82+
public async Task UpdateAndUndeleteInSameSyncWorks()
83+
{
84+
var wordId = Guid.NewGuid();
85+
86+
await WriteNextChange(new NewWordChange(wordId, "original"));
87+
await WriteNextChange(new DeleteChange<Word>(wordId));
88+
89+
await AddCommitsViaSync([
90+
await WriteNextChange(new SetWordNoteChange(wordId, "overridden-note"), add: false),
91+
await WriteNextChange(new NewWordChange(wordId, "Undeleted"), add: false),
92+
]);
93+
94+
var word = await DataModel.GetLatest<Word>(wordId);
95+
word.Should().NotBeNull();
96+
word.DeletedAt.Should().BeNull();
97+
word.Text.Should().Be("Undeleted");
98+
99+
var entityWord = await DataModel.QueryLatest<Word>().Where(w => w.Id == wordId).SingleOrDefaultAsync();
100+
entityWord.Should().NotBeNull();
101+
entityWord.DeletedAt.Should().BeNull();
102+
entityWord.Text.Should().Be("Undeleted");
103+
}
104+
105+
[Fact]
106+
public async Task CreateDeleteAndUndeleteInSameCommitWorks()
107+
{
108+
var wordId = Guid.NewGuid();
109+
110+
await WriteNextChange(
111+
[
112+
new NewWordChange(wordId, "original"),
113+
new DeleteChange<Word>(wordId),
114+
new NewWordChange(wordId, "Undeleted"),
115+
]);
116+
117+
var word = await DataModel.GetLatest<Word>(wordId);
118+
word.Should().NotBeNull();
119+
word.DeletedAt.Should().BeNull();
120+
word.Text.Should().Be("Undeleted");
121+
122+
var entityWord = await DataModel.QueryLatest<Word>().Where(w => w.Id == wordId).SingleOrDefaultAsync();
123+
entityWord.Should().NotBeNull();
124+
entityWord.DeletedAt.Should().BeNull();
125+
entityWord.Text.Should().Be("Undeleted");
126+
}
127+
128+
[Fact]
129+
public async Task CreateDeleteAndUndeleteInSameSyncWorks()
130+
{
131+
var wordId = Guid.NewGuid();
132+
133+
await AddCommitsViaSync([
134+
await WriteNextChange(new NewWordChange(wordId, "original"), add: false),
135+
await WriteNextChange(new DeleteChange<Word>(wordId), add: false),
136+
await WriteNextChange(new NewWordChange(wordId, "Undeleted"), add: false),
137+
]);
138+
139+
var word = await DataModel.GetLatest<Word>(wordId);
140+
word.Should().NotBeNull();
141+
word.DeletedAt.Should().BeNull();
142+
word.Text.Should().Be("Undeleted");
143+
144+
var entityWord = await DataModel.QueryLatest<Word>().Where(w => w.Id == wordId).SingleOrDefaultAsync();
145+
entityWord.Should().NotBeNull();
146+
entityWord.DeletedAt.Should().BeNull();
147+
entityWord.Text.Should().Be("Undeleted");
148+
}
149+
150+
[Fact]
151+
public async Task CreateAndDeleteInSameCommitWorks()
152+
{
153+
var wordId = Guid.NewGuid();
154+
155+
await WriteNextChange(
156+
[
157+
new NewWordChange(wordId, "original"),
158+
new DeleteChange<Word>(wordId),
159+
]);
160+
161+
var word = await DataModel.GetLatest<Word>(wordId);
162+
word.Should().NotBeNull();
163+
word.DeletedAt.Should().NotBeNull();
164+
word.Text.Should().Be("original");
165+
166+
var entityWord = await DataModel.QueryLatest<Word>().Where(w => w.Id == wordId).SingleOrDefaultAsync();
167+
entityWord.Should().BeNull();
168+
}
169+
170+
[Fact]
171+
public async Task CreateAndDeleteInSameSyncWorks()
172+
{
173+
var wordId = Guid.NewGuid();
174+
175+
await AddCommitsViaSync([
176+
await WriteNextChange(new NewWordChange(wordId, "original"), add: false),
177+
await WriteNextChange(new DeleteChange<Word>(wordId), add: false),
178+
]);
179+
180+
var word = await DataModel.GetLatest<Word>(wordId);
181+
word.Should().NotBeNull();
182+
word.DeletedAt.Should().NotBeNull();
183+
word.Text.Should().Be("original");
184+
185+
var entityWord = await DataModel.QueryLatest<Word>().Where(w => w.Id == wordId).SingleOrDefaultAsync();
186+
entityWord.Should().BeNull();
187+
}
188+
189+
[Fact]
190+
public async Task NewEntityOnExistingEntityIsNoOp()
191+
{
192+
var wordId = Guid.NewGuid();
193+
194+
await WriteNextChange(new NewWordChange(wordId, "original"));
195+
var snapshotsBefore = await DbContext.Snapshots.Where(s => s.EntityId == wordId).ToArrayAsync();
196+
197+
await WriteNextChange(
198+
[
199+
new NewWordChange(wordId, "Undeleted"),
200+
]);
201+
202+
var word = await DataModel.GetLatest<Word>(wordId);
203+
word.Should().NotBeNull();
204+
word.DeletedAt.Should().BeNull();
205+
word.Text.Should().Be("original");
206+
207+
var entityWord = await DataModel.QueryLatest<Word>().Where(w => w.Id == wordId).SingleOrDefaultAsync();
208+
entityWord.Should().NotBeNull();
209+
entityWord.DeletedAt.Should().BeNull();
210+
entityWord.Text.Should().Be("original");
211+
212+
var snapshotsAfter = await DbContext.Snapshots.Where(s => s.EntityId == wordId).ToArrayAsync();
213+
snapshotsAfter.Select(s => s.Id).Should().BeEquivalentTo(snapshotsBefore.Select(s => s.Id));
214+
}
215+
}

src/SIL.Harmony.Tests/SnapshotTests.cs

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
using SIL.Harmony.Sample.Models;
2+
using SIL.Harmony.Sample.Changes;
3+
using SIL.Harmony.Changes;
24
using Microsoft.EntityFrameworkCore;
35

46
namespace SIL.Harmony.Tests;
@@ -19,7 +21,7 @@ public async Task MultipleChangesPreservesRootSnapshot()
1921
{
2022
var entityId = Guid.NewGuid();
2123
var commits = new List<Commit>();
22-
for (int i = 0; i < 4; i++)
24+
for (var i = 0; i < 4; i++)
2325
{
2426
commits.Add(await WriteChange(_localClientId,
2527
new DateTimeOffset(2000, 1, 1, 0, 0, 0, TimeSpan.Zero).AddHours(i),
@@ -39,7 +41,7 @@ public async Task MultipleChangesPreservesSomeIntermediateSnapshots()
3941
{
4042
var entityId = Guid.NewGuid();
4143
var commits = new List<Commit>();
42-
for (int i = 0; i < 6; i++)
44+
for (var i = 0; i < 6; i++)
4345
{
4446
commits.Add(await WriteChange(_localClientId,
4547
new DateTimeOffset(2000, 1, 1, 0, 0, 0, TimeSpan.Zero).AddHours(i),
@@ -135,7 +137,7 @@ await WriteNextChange(
135137
var tagCreation = await WriteNextChange(TagWord(wordId, tagId));
136138
await WriteChangeBefore(tagCreation, TagWord(wordId, tagId));
137139

138-
var word = await DataModel.QueryLatest<Word>(q=> q.Include(w => w.Tags)
140+
var word = await DataModel.QueryLatest<Word>(q => q.Include(w => w.Tags)
139141
.Where(w => w.Id == wordId)).FirstOrDefaultAsync();
140142
word.Should().NotBeNull();
141143
word.Tags.Should().BeEquivalentTo([new Tag { Id = tagId, Text = "tag-1" }]);

src/SIL.Harmony/Changes/Change.cs

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using System.Diagnostics;
12
using System.Text.Json.Serialization;
23

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

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

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

4557
public async ValueTask ApplyChange(IObjectBase entity, IChangeContext context)
4658
{
47-
if (this is CreateChange<T>)
59+
if (!SupportsApplyChange())
60+
{
61+
Debug.Fail("ApplyChange called on a Change that does not support it");
4862
return; // skip attempting to apply changes on CreateChange as it does not support apply changes
63+
}
64+
4965
if (entity.DbObject is T entityT)
5066
{
5167
await ApplyChange(entityT, context);
@@ -56,6 +72,16 @@ public async ValueTask ApplyChange(IObjectBase entity, IChangeContext context)
5672
}
5773
}
5874

75+
public virtual bool SupportsApplyChange()
76+
{
77+
return this is not CreateChange<T>;
78+
}
79+
80+
public virtual bool SupportsNewEntity()
81+
{
82+
return this is not EditChange<T>;
83+
}
84+
5985
[JsonIgnore]
6086
public Type EntityType => typeof(T);
6187
}

0 commit comments

Comments
 (0)