diff --git a/src/SIL.Harmony.Sample/Changes/NewWordChange.cs b/src/SIL.Harmony.Sample/Changes/NewWordChange.cs index 80f1641..2cf7c48 100644 --- a/src/SIL.Harmony.Sample/Changes/NewWordChange.cs +++ b/src/SIL.Harmony.Sample/Changes/NewWordChange.cs @@ -12,7 +12,8 @@ public class NewWordChange(Guid entityId, string text, string? note = null, Guid public override async ValueTask 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(AntonymId.Value); + antonym = antonym is { DeletedAt: null } ? antonym : null; + return new Word { Text = Text, Note = Note, Id = EntityId, Antonym = antonym, AntonymId = antonym?.Id }; } } diff --git a/src/SIL.Harmony.Sample/Changes/AddAntonymReferenceChange.cs b/src/SIL.Harmony.Sample/Changes/SetAntonymReferenceChange.cs similarity index 59% rename from src/SIL.Harmony.Sample/Changes/AddAntonymReferenceChange.cs rename to src/SIL.Harmony.Sample/Changes/SetAntonymReferenceChange.cs index 400c063..4e02ed1 100644 --- a/src/SIL.Harmony.Sample/Changes/AddAntonymReferenceChange.cs +++ b/src/SIL.Harmony.Sample/Changes/SetAntonymReferenceChange.cs @@ -4,10 +4,11 @@ namespace SIL.Harmony.Sample.Changes; -public class AddAntonymReferenceChange(Guid entityId, Guid antonymId) - : EditChange(entityId), ISelfNamedType +public class SetAntonymReferenceChange(Guid entityId, Guid antonymId, bool setObject = true) + : EditChange(entityId), ISelfNamedType { public Guid AntonymId { get; set; } = antonymId; + public bool SetObject { get; set; } = setObject; public override async ValueTask ApplyChange(Word entity, IChangeContext context) { @@ -15,7 +16,10 @@ public override async ValueTask ApplyChange(Word entity, IChangeContext context) //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(AntonymId); + if (antonym is null or { DeletedAt: not null }) return; + + entity.Antonym = SetObject ? antonym : null; + entity.AntonymId = AntonymId; } } diff --git a/src/SIL.Harmony.Sample/CrdtSampleKernel.cs b/src/SIL.Harmony.Sample/CrdtSampleKernel.cs index a430f68..761af81 100644 --- a/src/SIL.Harmony.Sample/CrdtSampleKernel.cs +++ b/src/SIL.Harmony.Sample/CrdtSampleKernel.cs @@ -43,7 +43,7 @@ public static IServiceCollection AddCrdtDataSample(this IServiceCollection servi .Add() .Add() .Add() - .Add() + .Add() .Add() .Add>() .Add() @@ -60,6 +60,10 @@ public static IServiceCollection AddCrdtDataSample(this IServiceCollection servi builder.HasMany(w => w.Tags) .WithMany() .UsingEntity(); + builder.HasOne((w) => w.Antonym) + .WithMany() + .HasForeignKey(w => w.AntonymId) + .OnDelete(DeleteBehavior.SetNull); }) .Add(builder => { @@ -81,4 +85,4 @@ public static IServiceCollection AddCrdtDataSample(this IServiceCollection servi }); return services; } -} \ No newline at end of file +} diff --git a/src/SIL.Harmony.Sample/Models/Word.cs b/src/SIL.Harmony.Sample/Models/Word.cs index 955c24c..5a8fe05 100644 --- a/src/SIL.Harmony.Sample/Models/Word.cs +++ b/src/SIL.Harmony.Sample/Models/Word.cs @@ -9,6 +9,7 @@ public class Word : IObjectBase 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 Tags { get; set; } = new(); @@ -26,7 +27,11 @@ IEnumerable Refs() public void RemoveReference(Guid id, CommitBase commit) { - if (AntonymId == id) AntonymId = null; + if (AntonymId == id) + { + AntonymId = null; + Antonym = null; + } } public IObjectBase Copy() @@ -36,6 +41,7 @@ public IObjectBase Copy() Id = Id, Text = Text, Note = Note, + Antonym = Antonym, AntonymId = AntonymId, DeletedAt = DeletedAt, ImageResourceId = ImageResourceId, @@ -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))}"; } -} \ No newline at end of file +} diff --git a/src/SIL.Harmony.Tests/DataModelReferenceTests.cs b/src/SIL.Harmony.Tests/DataModelReferenceTests.cs index 5072362..cad2545 100644 --- a/src/SIL.Harmony.Tests/DataModelReferenceTests.cs +++ b/src/SIL.Harmony.Tests/DataModelReferenceTests.cs @@ -17,11 +17,276 @@ 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 SetAntonymReferenceChange(_word1Id, _word2Id, setObject: includeObjectInSnapshot)); + + // assert - snapshot + var word = await DataModel.GetLatest(_word1Id); + word.Should().NotBeNull(); + word.AntonymId.Should().Be(_word2Id); + if (includeObjectInSnapshot) + { + word.Antonym.Should().NotBeNull(); + word.Antonym.Text.Should().Be("entity2"); + } + else + { + word.Antonym.Should().BeNull(); + } + + // assert - projected entity + var entityWord = await DataModel.QueryLatest(w => w.Include(w => w.Antonym)) + .Where(w => w.Id == _word1Id).SingleOrDefaultAsync(); + entityWord.Should().NotBeNull(); + entityWord.AntonymId.Should().Be(_word2Id); + entityWord.Antonym.Should().NotBeNull(); + entityWord.Antonym.Text.Should().Be("entity2"); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task UpdateReferenceTwiceInSameCommitWorks(bool includeObjectInSnapshot) + { + // arrange + var word3Id = Guid.NewGuid(); + await WriteNextChange(new NewWordChange(word3Id, "entity3")); + + // act + await WriteNextChange( + [ + new SetAntonymReferenceChange(word3Id, _word1Id, setObject: includeObjectInSnapshot), + new SetAntonymReferenceChange(word3Id, _word2Id, setObject: includeObjectInSnapshot), + ]); + + // assert - snapshot + var word = await DataModel.GetLatest(word3Id); + word.Should().NotBeNull(); + word.AntonymId.Should().Be(_word2Id); + if (includeObjectInSnapshot) + { + word.Antonym.Should().NotBeNull(); + word.Antonym.Text.Should().Be("entity2"); + } + else + { + word.Antonym.Should().BeNull(); + } + + // assert - projected entity + var entityWord = await DataModel.QueryLatest(w => w.Include(w => w.Antonym)) + .Where(w => w.Id == word3Id).SingleOrDefaultAsync(); + entityWord.Should().NotBeNull(); + entityWord.AntonymId.Should().Be(_word2Id); + entityWord.Antonym.Should().NotBeNull(); + entityWord.Antonym.Text.Should().Be("entity2"); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task UpdateReferenceTwiceInSameSyncWorks(bool includeObjectInSnapshot) + { + // arrange + var word3Id = Guid.NewGuid(); + await WriteNextChange(new NewWordChange(word3Id, "entity3")); + + // act + await AddCommitsViaSync([ + await WriteNextChange(new SetAntonymReferenceChange(word3Id, _word1Id, setObject: includeObjectInSnapshot), add: false), + await WriteNextChange(new SetAntonymReferenceChange(word3Id, _word2Id, setObject: includeObjectInSnapshot), add: false), + ]); + + // assert - snapshot + var word = await DataModel.GetLatest(word3Id); + word.Should().NotBeNull(); + word.AntonymId.Should().Be(_word2Id); + if (includeObjectInSnapshot) + { + word.Antonym.Should().NotBeNull(); + word.Antonym.Text.Should().Be("entity2"); + } + else + { + word.Antonym.Should().BeNull(); + } + + // assert - projected entity + var entityWord = await DataModel.QueryLatest(w => w.Include(w => w.Antonym)) + .Where(w => w.Id == word3Id).SingleOrDefaultAsync(); + entityWord.Should().NotBeNull(); + entityWord.AntonymId.Should().Be(_word2Id); + entityWord.Antonym.Should().NotBeNull(); + entityWord.Antonym.Text.Should().Be("entity2"); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task AddEntityAndReferenceInSameCommitWorks(bool includeObjectInSnapshot) + { + // arrange + var word3Id = Guid.NewGuid(); + + // act + await WriteNextChange( + [ + new NewWordChange(word3Id, "entity3"), + new SetAntonymReferenceChange(word3Id, _word1Id, setObject: includeObjectInSnapshot), + ]); + + // assert - snapshot + var word = await DataModel.GetLatest(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"); + } + else + { + word.Antonym.Should().BeNull(); + } + + // assert - projected entity + var entityWord = await DataModel.QueryLatest(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 SetAntonymReferenceChange(_word1Id, word3Id, setObject: includeObjectInSnapshot), + ]); + + // assert - snapshot + var word = await DataModel.GetLatest(_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"); + } + else + { + word.Antonym.Should().BeNull(); + } + + // assert - projected entity + var entityWord = await DataModel.QueryLatest(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 SetAntonymReferenceChange(word3Id, _word1Id, setObject: includeObjectInSnapshot), add: false), + ]); + + // assert - snapshot + var word = await DataModel.GetLatest(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"); + } + else + { + word.Antonym.Should().BeNull(); + } + + // assert - projected entity + var entityWord = await DataModel.QueryLatest(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 SetAntonymReferenceChange(_word1Id, word3Id, setObject: includeObjectInSnapshot), add: false), + ]); + + // assert - snapshot + var word = await DataModel.GetLatest(_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"); + } + else + { + word.Antonym.Should().BeNull(); + } + + // assert - projected entity + var entityWord = await DataModel.QueryLatest(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() { - var addRef = await WriteNextChange(new AddAntonymReferenceChange(_word1Id, _word2Id)); + var addRef = await WriteNextChange(new SetAntonymReferenceChange(_word1Id, _word2Id)); var entryWithRef = await DataModel.GetLatest(_word1Id); entryWithRef!.AntonymId.Should().Be(_word2Id); @@ -33,7 +298,7 @@ public async Task DeleteAfterTheFactRewritesReferences() [Fact] public async Task DeleteRemovesAllReferences() { - await WriteNextChange(new AddAntonymReferenceChange(_word1Id, _word2Id)); + await WriteNextChange(new SetAntonymReferenceChange(_word1Id, _word2Id)); var entryWithRef = await DataModel.GetLatest(_word1Id); entryWithRef!.AntonymId.Should().Be(_word2Id); @@ -45,7 +310,7 @@ public async Task DeleteRemovesAllReferences() [Fact] public async Task SnapshotsDontGetMutatedByADelete() { - var refAdd = await WriteNextChange(new AddAntonymReferenceChange(_word1Id, _word2Id)); + var refAdd = await WriteNextChange(new SetAntonymReferenceChange(_word1Id, _word2Id)); await WriteNextChange(new DeleteChange(_word2Id)); var word = await DataModel.GetAtCommit(refAdd.Id, _word1Id); word.Should().NotBeNull(); @@ -57,11 +322,11 @@ public async Task DeleteRetroactivelyRemovesRefs() { var entityId3 = Guid.NewGuid(); await WriteNextChange(SetWord(entityId3, "entity3")); - await WriteNextChange(new AddAntonymReferenceChange(_word1Id, _word2Id)); + await WriteNextChange(new SetAntonymReferenceChange(_word1Id, _word2Id)); var delete = await WriteNextChange(new DeleteChange(_word2Id)); //a ref was synced in the past, it happened before the delete, the reference should be retroactively removed - await WriteChangeBefore(delete, new AddAntonymReferenceChange(entityId3, _word2Id)); + await WriteChangeBefore(delete, new SetAntonymReferenceChange(entityId3, _word2Id)); var entry = await DataModel.GetLatest(entityId3); entry!.AntonymId.Should().BeNull(); } @@ -171,4 +436,4 @@ public async Task CanUpdateTagWithTheSameNameOutOfOrder() await WriteNextChange(SetTag(renameTagId, tagText)); DataModel.QueryLatest().ToBlockingEnumerable().Where(t => t.Text == tagText).Should().ContainSingle(); } -} \ No newline at end of file +} diff --git a/src/SIL.Harmony.Tests/DbContextTests.VerifyModel.verified.txt b/src/SIL.Harmony.Tests/DbContextTests.VerifyModel.verified.txt index 7ad2c2d..79ccc2f 100644 --- a/src/SIL.Harmony.Tests/DbContextTests.VerifyModel.verified.txt +++ b/src/SIL.Harmony.Tests/DbContextTests.VerifyModel.verified.txt @@ -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) 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: diff --git a/src/SIL.Harmony/Db/CrdtRepository.cs b/src/SIL.Harmony/Db/CrdtRepository.cs index 98d1210..1fcae0e 100644 --- a/src/SIL.Harmony/Db/CrdtRepository.cs +++ b/src/SIL.Harmony/Db/CrdtRepository.cs @@ -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 newEntry = _dbContext.Entry(entity); + // only mark this single entry as added, rather than the whole graph (this matches the update behaviour below) + newEntry.State = EntityState.Added; + newEntry.Property(ObjectSnapshot.ShadowRefName).CurrentValue = objectSnapshot.Id; } else if (objectSnapshot.EntityIsDeleted) // delete {