Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
10 changes: 7 additions & 3 deletions src/SIL.Harmony.Sample/Changes/AddAntonymReferenceChange.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,22 @@

namespace SIL.Harmony.Sample.Changes;

public class AddAntonymReferenceChange(Guid entityId, Guid antonymId)
public class AddAntonymReferenceChange(Guid entityId, Guid antonymId, bool setObject = true)
: EditChange<Word>(entityId), ISelfNamedType<AddAntonymReferenceChange>
{
public Guid AntonymId { get; set; } = antonymId;
public bool SetObject { get; set; } = setObject;

public override async ValueTask ApplyChange(Word entity, IChangeContext context)
{
//if the word being referenced was deleted before this change was applied (could happen after a sync)
//then we don't want to apply the change
//if the change was already applied,
//then this reference is removed via Word.RemoveReference after the change which deletes the Antonym, see SnapshotWorker.MarkDeleted
if (!await context.IsObjectDeleted(AntonymId))
entity.AntonymId = AntonymId;
var antonym = await context.GetCurrent<Word>(AntonymId);
if (antonym is null or { DeletedAt: not null }) return;

entity.Antonym = SetObject ? antonym : null;
entity.AntonymId = AntonymId;
}
}
5 changes: 3 additions & 2 deletions src/SIL.Harmony.Sample/Changes/NewWordChange.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ public class NewWordChange(Guid entityId, string text, string? note = null, Guid

public override async ValueTask<Word> NewEntity(Commit commit, IChangeContext context)
{
var antonymShouldBeNull = AntonymId is null || (await context.IsObjectDeleted(AntonymId.Value));
return (new Word { Text = Text, Note = Note, Id = EntityId, AntonymId = antonymShouldBeNull ? null : AntonymId });
var antonym = AntonymId is null ? null : await context.GetCurrent<Word>(AntonymId.Value);
antonym = antonym is { DeletedAt: null } ? antonym : null;
return new Word { Text = Text, Note = Note, Id = EntityId, Antonym = antonym, AntonymId = antonym?.Id };
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}
6 changes: 5 additions & 1 deletion src/SIL.Harmony.Sample/CrdtSampleKernel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,10 @@ public static IServiceCollection AddCrdtDataSample(this IServiceCollection servi
builder.HasMany(w => w.Tags)
.WithMany()
.UsingEntity<WordTag>();
builder.HasOne((w) => w.Antonym)
.WithMany()
.HasForeignKey(w => w.AntonymId)
.OnDelete(DeleteBehavior.SetNull);
})
.Add<Definition>(builder =>
{
Expand All @@ -81,4 +85,4 @@ public static IServiceCollection AddCrdtDataSample(this IServiceCollection servi
});
return services;
}
}
}
12 changes: 9 additions & 3 deletions src/SIL.Harmony.Sample/Models/Word.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ public class Word : IObjectBase<Word>

public Guid Id { get; init; }
public DateTimeOffset? DeletedAt { get; set; }
public Word? Antonym { get; set; }
public Guid? AntonymId { get; set; }
public Guid? ImageResourceId { get; set; }
public List<Tag> Tags { get; set; } = new();
Expand All @@ -26,7 +27,11 @@ IEnumerable<Guid> Refs()

public void RemoveReference(Guid id, CommitBase commit)
{
if (AntonymId == id) AntonymId = null;
if (AntonymId == id)
{
AntonymId = null;
Antonym = null;
}
}

public IObjectBase Copy()
Expand All @@ -36,6 +41,7 @@ public IObjectBase Copy()
Id = Id,
Text = Text,
Note = Note,
Antonym = Antonym,
Comment thread
myieye marked this conversation as resolved.
AntonymId = AntonymId,
DeletedAt = DeletedAt,
ImageResourceId = ImageResourceId,
Expand All @@ -46,7 +52,7 @@ public IObjectBase Copy()
public override string ToString()
{
return
$"{nameof(Text)}: {Text}, {nameof(Id)}: {Id}, {nameof(Note)}: {Note}, {nameof(DeletedAt)}: {DeletedAt}, {nameof(AntonymId)}: {AntonymId}, {nameof(ImageResourceId)}: {ImageResourceId}" +
$"{nameof(Text)}: {Text}, {nameof(Id)}: {Id}, {nameof(Note)}: {Note}, {nameof(DeletedAt)}: {DeletedAt}, {nameof(Antonym)}: {Antonym}, {nameof(AntonymId)}: {AntonymId}, {nameof(ImageResourceId)}: {ImageResourceId}" +
$", {nameof(Tags)}: {string.Join(", ", Tags.Select(t => t.Text))}";
}
}
}
170 changes: 169 additions & 1 deletion src/SIL.Harmony.Tests/DataModelReferenceTests.cs
Comment thread
myieye marked this conversation as resolved.
Comment thread
myieye marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,174 @@ public override async Task InitializeAsync()
await WriteNextChange(SetWord(_word2Id, "entity2"));
}

[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task AddReferenceWorks(bool includeObjectInSnapshot)
{
// act
await WriteNextChange(new AddAntonymReferenceChange(_word1Id, _word2Id, setObject: includeObjectInSnapshot));

// assert - snapshot
var entryWithRef = await DataModel.GetLatest<Word>(_word1Id);
entryWithRef.Should().NotBeNull();
if (includeObjectInSnapshot)
{
entryWithRef.Antonym.Should().NotBeNull();
entryWithRef.Antonym.Text.Should().Be("entity2");
}
entryWithRef.AntonymId.Should().Be(_word2Id);

// assert - projected entity
var entityWord = await DataModel.QueryLatest<Word>(w => w.Include(w => w.Antonym))
.Where(w => w.Id == _word1Id).SingleOrDefaultAsync();
entityWord.Should().NotBeNull();
entityWord.Antonym.Should().NotBeNull();
entityWord.Antonym.Text.Should().Be("entity2");
entityWord.AntonymId.Should().Be(_word2Id);
Comment thread
myieye marked this conversation as resolved.
}
Comment thread
myieye marked this conversation as resolved.

[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task AddEntityAndReferenceInSameCommitWorks(bool includeObjectInSnapshot)
{
// arrange
var word3Id = Guid.NewGuid();

// act
await WriteNextChange(
[
new NewWordChange(word3Id, "entity3"),
new AddAntonymReferenceChange(word3Id, _word1Id, setObject: includeObjectInSnapshot),
]);

// assert - snapshot
var word = await DataModel.GetLatest<Word>(word3Id);
word.Should().NotBeNull();
word.Text.Should().Be("entity3");
word.AntonymId.Should().Be(_word1Id);
if (includeObjectInSnapshot)
{
word.Antonym.Should().NotBeNull();
word.Antonym.Text.Should().Be("entity1");
}

// assert - projected entity
var entityWord = await DataModel.QueryLatest<Word>(w => w.Include(w => w.Antonym))
.Where(w => w.Id == word3Id).SingleOrDefaultAsync();
entityWord.Should().NotBeNull();
entityWord.Text.Should().Be("entity3");
entityWord.AntonymId.Should().Be(_word1Id);
entityWord.Antonym.Should().NotBeNull();
entityWord.Antonym.Text.Should().Be("entity1");
}

[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task AddEntityAndReverseReferenceInSameCommitWorks(bool includeObjectInSnapshot)
{
// arrange
var word3Id = Guid.NewGuid();

// act
await WriteNextChange(
[
new NewWordChange(word3Id, "entity3"),
new AddAntonymReferenceChange(_word1Id, word3Id, setObject: includeObjectInSnapshot),
]);

// assert - snapshot
var word = await DataModel.GetLatest<Word>(_word1Id);
word.Should().NotBeNull();
word.Text.Should().Be("entity1");
word.AntonymId.Should().Be(word3Id);
if (includeObjectInSnapshot)
{
word.Antonym.Should().NotBeNull();
word.Antonym.Text.Should().Be("entity3");
}

// assert - projected entity
var entityWord = await DataModel.QueryLatest<Word>(w => w.Include(w => w.Antonym))
.Where(w => w.Id == _word1Id).SingleOrDefaultAsync();
entityWord.Should().NotBeNull();
entityWord.Text.Should().Be("entity1");
entityWord.AntonymId.Should().Be(word3Id);
entityWord.Antonym.Should().NotBeNull();
entityWord.Antonym.Text.Should().Be("entity3");
}

[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task AddEntityAndReferenceInSameSyncWorks(bool includeObjectInSnapshot)
{
// arrange
var word3Id = Guid.NewGuid();

// act
await AddCommitsViaSync([
await WriteNextChange(new NewWordChange(word3Id, "entity3"), add: false),
await WriteNextChange(new AddAntonymReferenceChange(word3Id, _word1Id, setObject: includeObjectInSnapshot), add: false),
]);

// assert - snapshot
var word = await DataModel.GetLatest<Word>(word3Id);
word.Should().NotBeNull();
word.Text.Should().Be("entity3");
word.AntonymId.Should().Be(_word1Id);
if (includeObjectInSnapshot)
{
word.Antonym.Should().NotBeNull();
word.Antonym.Text.Should().Be("entity1");
}

// assert - projected entity
var entityWord = await DataModel.QueryLatest<Word>(w => w.Include(w => w.Antonym))
.Where(w => w.Id == word3Id).SingleOrDefaultAsync();
entityWord.Should().NotBeNull();
entityWord.Text.Should().Be("entity3");
entityWord.AntonymId.Should().Be(_word1Id);
entityWord.Antonym.Should().NotBeNull();
entityWord.Antonym.Text.Should().Be("entity1");
}

[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task AddEntityAndReverseReferenceInSameSyncWorks(bool includeObjectInSnapshot)
{
// arrange
var word3Id = Guid.NewGuid();

// act
await AddCommitsViaSync([
await WriteNextChange(new NewWordChange(word3Id, "entity3"), add: false),
await WriteNextChange(new AddAntonymReferenceChange(_word1Id, word3Id, setObject: includeObjectInSnapshot), add: false),
]);

// assert - snapshot
var word = await DataModel.GetLatest<Word>(_word1Id);
word.Should().NotBeNull();
word.Text.Should().Be("entity1");
word.AntonymId.Should().Be(word3Id);
if (includeObjectInSnapshot)
{
word.Antonym.Should().NotBeNull();
word.Antonym.Text.Should().Be("entity3");
}

// assert - projected entity
var entityWord = await DataModel.QueryLatest<Word>(w => w.Include(w => w.Antonym))
.Where(w => w.Id == _word1Id).SingleOrDefaultAsync();
entityWord.Should().NotBeNull();
entityWord.Text.Should().Be("entity1");
entityWord.AntonymId.Should().Be(word3Id);
entityWord.Antonym.Should().NotBeNull();
entityWord.Antonym.Text.Should().Be("entity3");
}

[Fact]
public async Task DeleteAfterTheFactRewritesReferences()
Expand Down Expand Up @@ -171,4 +339,4 @@ public async Task CanUpdateTagWithTheSameNameOutOfOrder()
await WriteNextChange(SetTag(renameTagId, tagText));
DataModel.QueryLatest<Tag>().ToBlockingEnumerable().Where(t => t.Text == tagText).Should().ContainSingle();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -189,19 +189,23 @@
EntityType: Word
Properties:
Id (Guid) Required PK AfterSave:Throw ValueGenerated.OnAdd
AntonymId (Guid?)
AntonymId (Guid?) FK Index
DeletedAt (DateTimeOffset?)
ImageResourceId (Guid?)
Note (string)
SnapshotId (no field, Guid?) Shadow FK Index
Text (string) Required
Navigations:
Antonym (Word) ToPrincipal Word
Skip navigations:
Tags (List<Tag>) CollectionTag Inverse: Word
Keys:
Id PK
Foreign keys:
Word {'AntonymId'} -> Word {'Id'} SetNull ToPrincipal: Antonym
Word {'SnapshotId'} -> ObjectSnapshot {'Id'} Unique SetNull
Indexes:
AntonymId
SnapshotId Unique
Annotations:
DiscriminatorProperty:
Expand Down
7 changes: 5 additions & 2 deletions src/SIL.Harmony/Db/CrdtRepository.cs
Original file line number Diff line number Diff line change
Expand Up @@ -362,8 +362,11 @@ private async ValueTask ProjectSnapshot(ObjectSnapshot objectSnapshot)
//if we don't make a copy first then the entity will be tracked by the context and be modified
//by future changes in the same session
var entity = objectSnapshot.Entity.Copy().DbObject;
_dbContext.Add(entity)
.Property(ObjectSnapshot.ShadowRefName).CurrentValue = objectSnapshot.Id;

var entry = _dbContext.Entry(entity);
// only mark this single entry as added, rather than the whole graph (this matches the update behaviour below)
entry.State = EntityState.Added;
entry.Property(ObjectSnapshot.ShadowRefName).CurrentValue = objectSnapshot.Id;
Comment thread
myieye marked this conversation as resolved.
Outdated
}
else if (objectSnapshot.EntityIsDeleted) // delete
{
Expand Down
Loading