diff --git a/backend/FwLite/FwDataMiniLcmBridge.Tests/MiniLcmTests/ComplexFormComponentTests.cs b/backend/FwLite/FwDataMiniLcmBridge.Tests/MiniLcmTests/ComplexFormComponentTests.cs new file mode 100644 index 000000000..96f978ca2 --- /dev/null +++ b/backend/FwLite/FwDataMiniLcmBridge.Tests/MiniLcmTests/ComplexFormComponentTests.cs @@ -0,0 +1,12 @@ +using FwDataMiniLcmBridge.Tests.Fixtures; + +namespace FwDataMiniLcmBridge.Tests.MiniLcmTests; + +[Collection(ProjectLoaderFixture.Name)] +public class ComplexFormComponentTests(ProjectLoaderFixture fixture): ComplexFormComponentTestsBase +{ + protected override Task NewApi() + { + return Task.FromResult(fixture.NewProjectApi("complex-form-component-test", "en", "en")); + } +} diff --git a/backend/FwLite/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs b/backend/FwLite/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs index ccc7cff57..d9766fcda 100644 --- a/backend/FwLite/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs +++ b/backend/FwLite/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs @@ -2,9 +2,11 @@ using System.Reflection; using System.Text; using FwDataMiniLcmBridge.Api.UpdateProxy; +using FwDataMiniLcmBridge.LcmUtils; using Microsoft.Extensions.Logging; using MiniLcm; using MiniLcm.Models; +using MiniLcm.SyncHelpers; using SIL.LCModel; using SIL.LCModel.Core.KernelInterfaces; using SIL.LCModel.Core.Text; @@ -151,7 +153,7 @@ internal void CompleteExemplars(WritingSystems writingSystems) public Task CreateWritingSystem(WritingSystemType type, WritingSystem writingSystem) { CoreWritingSystemDefinition? ws = null; - UndoableUnitOfWorkHelper.Do("Create Writing System", + UndoableUnitOfWorkHelper.DoUsingNewOrCurrentUOW("Create Writing System", "Remove writing system", Cache.ServiceLocator.ActionHandler, () => @@ -201,7 +203,7 @@ public IAsyncEnumerable GetPartsOfSpeech() public Task CreatePartOfSpeech(PartOfSpeech partOfSpeech) { if (partOfSpeech.Id == default) partOfSpeech.Id = Guid.NewGuid(); - UndoableUnitOfWorkHelper.Do("Create Part of Speech", + UndoableUnitOfWorkHelper.DoUsingNewOrCurrentUOW("Create Part of Speech", "Remove part of speech", Cache.ServiceLocator.ActionHandler, () => @@ -231,7 +233,7 @@ public IAsyncEnumerable GetSemanticDomains() public Task CreateSemanticDomain(SemanticDomain semanticDomain) { if (semanticDomain.Id == Guid.Empty) semanticDomain.Id = Guid.NewGuid(); - UndoableUnitOfWorkHelper.Do("Create Semantic Domain", + UndoableUnitOfWorkHelper.DoUsingNewOrCurrentUOW("Create Semantic Domain", "Remove semantic domain", Cache.ActionHandlerAccessor, () => @@ -266,7 +268,7 @@ private ComplexFormType ToComplexFormType(ICmPossibility t) public Task CreateComplexFormType(ComplexFormType complexFormType) { if (complexFormType.Id != default) throw new InvalidOperationException("Complex form type id must be empty"); - UndoableUnitOfWorkHelper.Do("Create complex form type", + UndoableUnitOfWorkHelper.DoUsingNewOrCurrentUOW("Create complex form type", "Remove complex form type", Cache.ActionHandlerAccessor, () => @@ -299,7 +301,7 @@ private Entry FromLexEntry(ILexEntry entry) LiteralMeaning = FromLcmMultiString(entry.LiteralMeaning), Senses = entry.AllSenses.Select(FromLexSense).ToList(), ComplexFormTypes = ToComplexFormTypes(entry), - Components = ToComplexFormComponents(entry), + Components = ToComplexFormComponents(entry).ToList(), ComplexForms = [ ..entry.ComplexFormEntries.Select(complexEntry => ToEntryReference(entry, complexEntry)), ..entry.AllSenses.SelectMany(sense => sense.ComplexFormEntries.Select(complexEntry => ToSenseReference(sense, complexEntry))) @@ -314,7 +316,7 @@ private IList ToComplexFormTypes(ILexEntry entry) .Select(ToComplexFormType) .ToList() ?? []; } - private IList ToComplexFormComponents(ILexEntry entry) + private IEnumerable ToComplexFormComponents(ILexEntry entry) { return entry.ComplexFormEntryRefs.SingleOrDefault() ?.ComponentLexemesRS @@ -323,8 +325,7 @@ private IList ToComplexFormComponents(ILexEntry entry) ILexEntry component => ToEntryReference(component, entry), ILexSense s => ToSenseReference(s, entry), _ => throw new NotSupportedException($"object type {o.ClassName} not supported") - }) - .ToList() ?? []; + }) ?? []; } private Variants? ToVariants(ILexEntry entry) @@ -481,7 +482,7 @@ public IAsyncEnumerable SearchEntries(string query, QueryOptions? options public async Task CreateEntry(Entry entry) { entry.Id = entry.Id == default ? Guid.NewGuid() : entry.Id; - UndoableUnitOfWorkHelper.Do("Create Entry", + UndoableUnitOfWorkHelper.DoUsingNewOrCurrentUOW("Create Entry", "Remove entry", Cache.ServiceLocator.ActionHandler, () => @@ -518,6 +519,57 @@ public async Task CreateEntry(Entry entry) return await GetEntry(entry.Id) ?? throw new InvalidOperationException("Entry was not created"); } + public Task CreateComplexFormComponent(ComplexFormComponent complexFormComponent) + { + UndoableUnitOfWorkHelper.DoUsingNewOrCurrentUOW("Create Complex Form Component", + "Remove Complex Form Component", + Cache.ServiceLocator.ActionHandler, + () => + { + var lexEntry = EntriesRepository.GetObject(complexFormComponent.ComplexFormEntryId); + AddComplexFormComponent(lexEntry, complexFormComponent); + }); + return Task.FromResult(ToComplexFormComponents(EntriesRepository.GetObject(complexFormComponent.ComplexFormEntryId)) + .Single(c => c.ComponentEntryId == complexFormComponent.ComponentEntryId && c.ComponentSenseId == complexFormComponent.ComponentSenseId)); + } + + public Task DeleteComplexFormComponent(ComplexFormComponent complexFormComponent) + { + UndoableUnitOfWorkHelper.DoUsingNewOrCurrentUOW("Delete Complex Form Component", + "Add Complex Form Component", + Cache.ServiceLocator.ActionHandler, + () => + { + var lexEntry = EntriesRepository.GetObject(complexFormComponent.ComplexFormEntryId); + RemoveComplexFormComponent(lexEntry, complexFormComponent); + }); + return Task.CompletedTask; + } + + public Task AddComplexFormType(Guid entryId, Guid complexFormTypeId) + { + UndoableUnitOfWorkHelper.DoUsingNewOrCurrentUOW("Add Complex Form Type", + "Remove Complex Form Type", + Cache.ServiceLocator.ActionHandler, + () => + { + AddComplexFormType(EntriesRepository.GetObject(entryId), complexFormTypeId); + }); + return Task.CompletedTask; + } + + public Task RemoveComplexFormType(Guid entryId, Guid complexFormTypeId) + { + UndoableUnitOfWorkHelper.DoUsingNewOrCurrentUOW("Remove Complex Form Type", + "Add Complex Form Type", + Cache.ServiceLocator.ActionHandler, + () => + { + RemoveComplexFormType(EntriesRepository.GetObject(entryId), complexFormTypeId); + }); + return Task.CompletedTask; + } + /// /// must be called as part of an lcm action /// @@ -531,9 +583,20 @@ internal void AddComplexFormComponent(ILexEntry lexEntry, ComplexFormComponent c internal void RemoveComplexFormComponent(ILexEntry lexEntry, ComplexFormComponent component) { - ICmObject lexComponent = component.ComponentSenseId is not null - ? SenseRepository.GetObject(component.ComponentSenseId.Value) - : EntriesRepository.GetObject(component.ComponentEntryId); + ICmObject lexComponent; + if (component.ComponentSenseId is not null) + { + //sense has been deleted, so this complex form has been deleted already + if (!SenseRepository.TryGetObject(component.ComponentSenseId.Value, out var sense)) return; + lexComponent = sense; + } + else + { + //entry has been deleted, so this complex form has been deleted already + if (!EntriesRepository.TryGetObject(component.ComponentEntryId, out var entry)) return; + lexComponent = entry; + } + var entryRef = lexEntry.ComplexFormEntryRefs.Single(); if (!entryRef.ComponentLexemesRS.Remove(lexComponent)) { @@ -588,7 +651,7 @@ private void UpdateLcmMultiString(ITsMultiString multiString, MultiString newMul public Task UpdateEntry(Guid id, UpdateObjectInput update) { var lexEntry = EntriesRepository.GetObject(id); - UndoableUnitOfWorkHelper.Do("Update Entry", + UndoableUnitOfWorkHelper.DoUsingNewOrCurrentUOW("Update Entry", "Revert entry", Cache.ServiceLocator.ActionHandler, () => @@ -601,7 +664,7 @@ public Task UpdateEntry(Guid id, UpdateObjectInput update) public Task DeleteEntry(Guid id) { - UndoableUnitOfWorkHelper.Do("Delete Entry", + UndoableUnitOfWorkHelper.DoUsingNewOrCurrentUOW("Delete Entry", "Revert delete", Cache.ServiceLocator.ActionHandler, () => @@ -653,7 +716,7 @@ public Task CreateSense(Guid entryId, Sense sense) if (sense.Id == default) sense.Id = Guid.NewGuid(); if (!EntriesRepository.TryGetObject(entryId, out var lexEntry)) throw new InvalidOperationException("Entry not found"); - UndoableUnitOfWorkHelper.Do("Create Sense", + UndoableUnitOfWorkHelper.DoUsingNewOrCurrentUOW("Create Sense", "Remove sense", Cache.ServiceLocator.ActionHandler, () => CreateSense(lexEntry, sense)); @@ -664,7 +727,7 @@ public Task UpdateSense(Guid entryId, Guid senseId, UpdateObjectInput @@ -675,11 +738,37 @@ public Task UpdateSense(Guid entryId, Guid senseId, UpdateObjectInput + { + var lexSense = SenseRepository.GetObject(senseId); + lexSense.SemanticDomainsRC.Add(GetLcmSemanticDomain(semanticDomain.Id)); + }); + return Task.CompletedTask; + } + + public Task RemoveSemanticDomainFromSense(Guid senseId, Guid semanticDomainId) + { + UndoableUnitOfWorkHelper.DoUsingNewOrCurrentUOW("Remove Semantic Domain from Sense", + "Add Semantic Domain to Sense", + Cache.ServiceLocator.ActionHandler, + () => + { + var lexSense = SenseRepository.GetObject(senseId); + lexSense.SemanticDomainsRC.Remove(lexSense.SemanticDomainsRC.First(sd => sd.Guid == semanticDomainId)); + }); + return Task.CompletedTask; + } + public Task DeleteSense(Guid entryId, Guid senseId) { var lexSense = SenseRepository.GetObject(senseId); if (lexSense.Entry.Guid != entryId) throw new InvalidOperationException("Sense does not belong to entry"); - UndoableUnitOfWorkHelper.Do("Delete Sense", + UndoableUnitOfWorkHelper.DoUsingNewOrCurrentUOW("Delete Sense", "Revert delete", Cache.ServiceLocator.ActionHandler, () => lexSense.Delete()); @@ -702,7 +791,7 @@ public Task CreateExampleSentence(Guid entryId, Guid senseId, E if (exampleSentence.Id == default) exampleSentence.Id = Guid.NewGuid(); if (!SenseRepository.TryGetObject(senseId, out var lexSense)) throw new InvalidOperationException("Sense not found"); - UndoableUnitOfWorkHelper.Do("Create Example Sentence", + UndoableUnitOfWorkHelper.DoUsingNewOrCurrentUOW("Create Example Sentence", "Remove example sentence", Cache.ServiceLocator.ActionHandler, () => CreateExampleSentence(lexSense, exampleSentence)); @@ -716,7 +805,7 @@ public Task UpdateExampleSentence(Guid entryId, { var lexExampleSentence = ExampleSentenceRepository.GetObject(exampleSentenceId); ValidateOwnership(lexExampleSentence, entryId, senseId); - UndoableUnitOfWorkHelper.Do("Update Example Sentence", + UndoableUnitOfWorkHelper.DoUsingNewOrCurrentUOW("Update Example Sentence", "Revert example sentence", Cache.ServiceLocator.ActionHandler, () => @@ -731,7 +820,7 @@ public Task DeleteExampleSentence(Guid entryId, Guid senseId, Guid exampleSenten { var lexExampleSentence = ExampleSentenceRepository.GetObject(exampleSentenceId); ValidateOwnership(lexExampleSentence, entryId, senseId); - UndoableUnitOfWorkHelper.Do("Delete Example Sentence", + UndoableUnitOfWorkHelper.DoUsingNewOrCurrentUOW("Delete Example Sentence", "Revert delete", Cache.ServiceLocator.ActionHandler, () => lexExampleSentence.Delete()); diff --git a/backend/FwLite/FwDataMiniLcmBridge/Api/UpdateProxy/UpdateComplexFormTypeProxy.cs b/backend/FwLite/FwDataMiniLcmBridge/Api/UpdateProxy/UpdateComplexFormTypeProxy.cs index 3dad6ad23..b3a6eeeb0 100644 --- a/backend/FwLite/FwDataMiniLcmBridge/Api/UpdateProxy/UpdateComplexFormTypeProxy.cs +++ b/backend/FwLite/FwDataMiniLcmBridge/Api/UpdateProxy/UpdateComplexFormTypeProxy.cs @@ -4,7 +4,7 @@ namespace FwDataMiniLcmBridge.Api.UpdateProxy; -public class UpdateComplexFormTypeProxy : ComplexFormType +public record UpdateComplexFormTypeProxy : ComplexFormType { private readonly ILexEntryType _lexEntryType; private readonly ILexEntry _lcmEntry; diff --git a/backend/FwLite/FwDataMiniLcmBridge/Api/UpdateProxy/UpdateSenseProxy.cs b/backend/FwLite/FwDataMiniLcmBridge/Api/UpdateProxy/UpdateSenseProxy.cs index d09b2d8b3..ea4703301 100644 --- a/backend/FwLite/FwDataMiniLcmBridge/Api/UpdateProxy/UpdateSenseProxy.cs +++ b/backend/FwLite/FwDataMiniLcmBridge/Api/UpdateProxy/UpdateSenseProxy.cs @@ -64,14 +64,13 @@ public override IList SemanticDomains new UpdateListProxy( semanticDomain => { - if (semanticDomain.Id != default && lexboxLcmApi.GetLcmSemanticDomain(semanticDomain.Id) is { } lcmSemanticDomain) - sense.SemanticDomainsRC.Add(lcmSemanticDomain); +#pragma warning disable VSTHRD002 + lexboxLcmApi.AddSemanticDomainToSense(sense.Guid, semanticDomain).Wait(); }, semanticDomain => { - if (semanticDomain.Id != default) - sense.SemanticDomainsRC.Remove( - sense.SemanticDomainsRC.First(sd => sd.Guid == semanticDomain.Id)); + lexboxLcmApi.RemoveSemanticDomainFromSense(sense.Guid, semanticDomain.Id).Wait(); +#pragma warning restore VSTHRD002 }, i => new UpdateProxySemanticDomain(sense.SemanticDomainsRC, sense.SemanticDomainsRC.ElementAt(i).Guid, diff --git a/backend/FwLite/FwDataMiniLcmBridge/LcmUtils/ActionHandlerHelpers.cs b/backend/FwLite/FwDataMiniLcmBridge/LcmUtils/ActionHandlerHelpers.cs new file mode 100644 index 000000000..70ef7f830 --- /dev/null +++ b/backend/FwLite/FwDataMiniLcmBridge/LcmUtils/ActionHandlerHelpers.cs @@ -0,0 +1,26 @@ +using SIL.LCModel; +using SIL.LCModel.Core.KernelInterfaces; +using SIL.LCModel.Infrastructure; + +namespace FwDataMiniLcmBridge.LcmUtils; + +public static class ActionHandlerHelpers +{ + public static async ValueTask DoUsingNewOrCurrentUOW( + this LcmCache cache, + string description, + string revertDescription, + Func action) + { + var actionHandler = cache.ServiceLocator.ActionHandler; + if (actionHandler.CurrentDepth > 0) + { + await action(); + return; + } + + using var undoHelper = new UndoableUnitOfWorkHelper(actionHandler, description, revertDescription); + await action(); + undoHelper.RollBack = false; // task ran successfully, don't roll back. + } +} diff --git a/backend/FwLite/FwLiteProjectSync.Tests/Fixtures/SyncFixture.cs b/backend/FwLite/FwLiteProjectSync.Tests/Fixtures/SyncFixture.cs index 944de1834..2d3a08e55 100644 --- a/backend/FwLite/FwLiteProjectSync.Tests/Fixtures/SyncFixture.cs +++ b/backend/FwLite/FwLiteProjectSync.Tests/Fixtures/SyncFixture.cs @@ -39,7 +39,7 @@ private SyncFixture(string projectName) _services = crdtServices.CreateAsyncScope(); } - public SyncFixture(): this("sena-3") + public SyncFixture(): this("sena-3_" + Guid.NewGuid().ToString("N")) { } diff --git a/backend/FwLite/FwLiteProjectSync.Tests/MultiStringDiffTests.cs b/backend/FwLite/FwLiteProjectSync.Tests/MultiStringDiffTests.cs index 07b0d7910..58f1298f8 100644 --- a/backend/FwLite/FwLiteProjectSync.Tests/MultiStringDiffTests.cs +++ b/backend/FwLite/FwLiteProjectSync.Tests/MultiStringDiffTests.cs @@ -1,4 +1,5 @@ using MiniLcm.Models; +using MiniLcm.SyncHelpers; using Spart.Parsers; using SystemTextJsonPatch.Operations; @@ -11,19 +12,19 @@ private record Placeholder(); [Fact] public void DiffEmptyDoesNothing() { - var previous = new MultiString(); - var current = new MultiString(); - var result = CrdtFwdataProjectSyncService.GetMultiStringDiff("test", previous, current); + var before = new MultiString(); + var after = new MultiString(); + var result = MultiStringDiff.GetMultiStringDiff("test", before, after); result.Should().BeEmpty(); } [Fact] public void DiffOneToEmptyAddsOne() { - var previous = new MultiString(); - var current = new MultiString(); - current.Values.Add("en", "hello"); - var result = CrdtFwdataProjectSyncService.GetMultiStringDiff("test", previous, current); + var before = new MultiString(); + var after = new MultiString(); + after.Values.Add("en", "hello"); + var result = MultiStringDiff.GetMultiStringDiff("test", before, after); result.Should().BeEquivalentTo([ new Operation("add", "/test/en", null, "hello") ]); @@ -32,11 +33,11 @@ public void DiffOneToEmptyAddsOne() [Fact] public void DiffOneToOneReplacesOne() { - var previous = new MultiString(); - previous.Values.Add("en", "hello"); - var current = new MultiString(); - current.Values.Add("en", "world"); - var result = CrdtFwdataProjectSyncService.GetMultiStringDiff("test", previous, current); + var before = new MultiString(); + before.Values.Add("en", "hello"); + var after = new MultiString(); + after.Values.Add("en", "world"); + var result = MultiStringDiff.GetMultiStringDiff("test", before, after); result.Should().BeEquivalentTo([ new Operation("replace", "/test/en", null, "world") ]); @@ -45,10 +46,10 @@ public void DiffOneToOneReplacesOne() [Fact] public void DiffNoneToOneRemovesOne() { - var previous = new MultiString(); - previous.Values.Add("en", "hello"); - var current = new MultiString(); - var result = CrdtFwdataProjectSyncService.GetMultiStringDiff("test", previous, current); + var before = new MultiString(); + before.Values.Add("en", "hello"); + var after = new MultiString(); + var result = MultiStringDiff.GetMultiStringDiff("test", before, after); result.Should().BeEquivalentTo([ new Operation("remove", "/test/en", null) ]); @@ -57,26 +58,26 @@ public void DiffNoneToOneRemovesOne() [Fact] public void DiffMatchingDoesNothing() { - var previous = new MultiString(); - previous.Values.Add("en", "hello"); - var current = new MultiString(); - current.Values.Add("en", new string(['h', 'e', 'l', 'l', 'o'])); + var before = new MultiString(); + before.Values.Add("en", "hello"); + var after = new MultiString(); + after.Values.Add("en", new string(['h', 'e', 'l', 'l', 'o'])); //ensure the strings are not the same instance - ReferenceEquals(previous["en"], current["en"]).Should().BeFalse(); - var result = CrdtFwdataProjectSyncService.GetMultiStringDiff("test", previous, current); + ReferenceEquals(before["en"], after["en"]).Should().BeFalse(); + var result = MultiStringDiff.GetMultiStringDiff("test", before, after); result.Should().BeEmpty(); } [Fact] public void DiffWithMultipleAddsRemovesEach() { - var previous = new MultiString(); - previous.Values.Add("en", "hello"); - previous.Values.Add("es", "hola"); - var current = new MultiString(); - current.Values.Add("en", "world"); - current.Values.Add("fr", "monde"); - var result = CrdtFwdataProjectSyncService.GetMultiStringDiff("test", previous, current); + var before = new MultiString(); + before.Values.Add("en", "hello"); + before.Values.Add("es", "hola"); + var after = new MultiString(); + after.Values.Add("en", "world"); + after.Values.Add("fr", "monde"); + var result = MultiStringDiff.GetMultiStringDiff("test", before, after); result.Should().BeEquivalentTo([ new Operation("replace", "/test/en", null, "world"), new Operation("add", "/test/fr", null, "monde"), diff --git a/backend/FwLite/FwLiteProjectSync.Tests/SyncTests.cs b/backend/FwLite/FwLiteProjectSync.Tests/SyncTests.cs index 7bb3a08da..cc11173c7 100644 --- a/backend/FwLite/FwLiteProjectSync.Tests/SyncTests.cs +++ b/backend/FwLite/FwLiteProjectSync.Tests/SyncTests.cs @@ -60,7 +60,7 @@ public async Task DisposeAsync() { await _fixture.FwDataApi.DeleteEntry(entry.Id); } - await foreach (var entry in _fixture.CrdtApi.GetEntries()) + foreach (var entry in await _fixture.CrdtApi.GetEntries().ToArrayAsync()) { await _fixture.CrdtApi.DeleteEntry(entry.Id); } @@ -146,7 +146,7 @@ public async Task UpdatingAnEntryInEachProjectSyncsAcrossBoth() await fwdataApi.CreateWritingSystem(WritingSystemType.Vernacular, new WritingSystem() { Id = Guid.NewGuid(), Type = WritingSystemType.Vernacular, WsId = new WritingSystemId("fr"), Name = "French", Abbreviation = "fr", Font = "Arial" }); await _syncService.Sync(crdtApi, fwdataApi); - await crdtApi.UpdateEntry(_testEntry.Id, new UpdateObjectInput().Set(entry => entry.LexemeForm["es"],"Manzana")); + await crdtApi.UpdateEntry(_testEntry.Id, new UpdateObjectInput().Set(entry => entry.LexemeForm["es"], "Manzana")); await fwdataApi.UpdateEntry(_testEntry.Id, new UpdateObjectInput().Set(entry => entry.LexemeForm["fr"], "Pomme")); var results = await _syncService.Sync(crdtApi, fwdataApi); @@ -164,6 +164,43 @@ public async Task UpdatingAnEntryInEachProjectSyncsAcrossBoth() .For(e => e.ComplexForms).Exclude(c => c.ComponentHeadword)); } + [Fact] + public async Task CanSyncAnyEntryWithDeletedComplexForm() + { + var crdtApi = _fixture.CrdtApi; + var fwdataApi = _fixture.FwDataApi; + await _syncService.Sync(crdtApi, fwdataApi); + await crdtApi.DeleteEntry(_testEntry.Id); + var newEntryId = Guid.NewGuid(); + await fwdataApi.CreateEntry(new Entry() + { + Id = newEntryId, + LexemeForm = { { "en", "pineapple" } }, + Senses = + [ + new Sense + { + Gloss = { { "en", "fruit" } }, + Definition = { { "en", "a citris fruit" } }, + } + ], + Components = + [ + new ComplexFormComponent() + { + ComponentEntryId = _testEntry.Id, + ComponentHeadword = "apple", + ComplexFormEntryId = newEntryId, + ComplexFormHeadword = "pineapple" + } + ] + }); + + //sync may fail because it will try to create a complex form for an entry which was deleted + await _syncService.Sync(crdtApi, fwdataApi); + + } + [Fact] public async Task AddingASenseToAnEntryInEachProjectSyncsAcrossBoth() { diff --git a/backend/FwLite/FwLiteProjectSync.Tests/UpdateDiffTests.cs b/backend/FwLite/FwLiteProjectSync.Tests/UpdateDiffTests.cs index be158c8e7..a9f13f49c 100644 --- a/backend/FwLite/FwLiteProjectSync.Tests/UpdateDiffTests.cs +++ b/backend/FwLite/FwLiteProjectSync.Tests/UpdateDiffTests.cs @@ -1,5 +1,6 @@ using FwLiteProjectSync.Tests.Fixtures; using MiniLcm.Models; +using MiniLcm.SyncHelpers; using Soenneker.Utils.AutoBogus; using Soenneker.Utils.AutoBogus.Config; @@ -15,12 +16,12 @@ public class UpdateDiffTests [Fact] public void EntryDiffShouldUpdateAllFields() { - var previous = new Entry(); - var current = _autoFaker.Generate(); - var entryDiffToUpdate = CrdtFwdataProjectSyncService.EntryDiffToUpdate(previous, current); + var before = new Entry(); + var after = _autoFaker.Generate(); + var entryDiffToUpdate = EntrySync.EntryDiffToUpdate(before, after); ArgumentNullException.ThrowIfNull(entryDiffToUpdate); - entryDiffToUpdate.Apply(previous); - previous.Should().BeEquivalentTo(current, options => options.Excluding(x => x.Id) + entryDiffToUpdate.Apply(before); + before.Should().BeEquivalentTo(after, options => options.Excluding(x => x.Id) .Excluding(x => x.DeletedAt).Excluding(x => x.Senses) .Excluding(x => x.Components) .Excluding(x => x.ComplexForms) @@ -30,22 +31,22 @@ public void EntryDiffShouldUpdateAllFields() [Fact] public async Task SenseDiffShouldUpdateAllFields() { - var previous = new Sense(); - var current = _autoFaker.Generate(); - var senseDiffToUpdate = await CrdtFwdataProjectSyncService.SenseDiffToUpdate(previous, current); + var before = new Sense(); + var after = _autoFaker.Generate(); + var senseDiffToUpdate = await SenseSync.SenseDiffToUpdate(before, after); ArgumentNullException.ThrowIfNull(senseDiffToUpdate); - senseDiffToUpdate.Apply(previous); - previous.Should().BeEquivalentTo(current, options => options.Excluding(x => x.Id).Excluding(x => x.EntryId).Excluding(x => x.DeletedAt).Excluding(x => x.ExampleSentences)); + senseDiffToUpdate.Apply(before); + before.Should().BeEquivalentTo(after, options => options.Excluding(x => x.Id).Excluding(x => x.EntryId).Excluding(x => x.DeletedAt).Excluding(x => x.ExampleSentences)); } [Fact] public void ExampleSentenceDiffShouldUpdateAllFields() { - var previous = new ExampleSentence(); - var current = _autoFaker.Generate(); - var exampleSentenceDiffToUpdate = CrdtFwdataProjectSyncService.ExampleDiffToUpdate(previous, current); + var before = new ExampleSentence(); + var after = _autoFaker.Generate(); + var exampleSentenceDiffToUpdate = ExampleSentenceSync.DiffToUpdate(before, after); ArgumentNullException.ThrowIfNull(exampleSentenceDiffToUpdate); - exampleSentenceDiffToUpdate.Apply(previous); - previous.Should().BeEquivalentTo(current, options => options.Excluding(x => x.Id).Excluding(x => x.SenseId).Excluding(x => x.DeletedAt)); + exampleSentenceDiffToUpdate.Apply(before); + before.Should().BeEquivalentTo(after, options => options.Excluding(x => x.Id).Excluding(x => x.SenseId).Excluding(x => x.DeletedAt)); } } diff --git a/backend/FwLite/FwLiteProjectSync/CrdtFwdataProjectSyncService.cs b/backend/FwLite/FwLiteProjectSync/CrdtFwdataProjectSyncService.cs index 5b18b044b..a85833c68 100644 --- a/backend/FwLite/FwLiteProjectSync/CrdtFwdataProjectSyncService.cs +++ b/backend/FwLite/FwLiteProjectSync/CrdtFwdataProjectSyncService.cs @@ -5,6 +5,7 @@ using Microsoft.Extensions.Options; using MiniLcm; using MiniLcm.Models; +using MiniLcm.SyncHelpers; using SystemTextJsonPatch; using SystemTextJsonPatch.Operations; @@ -50,10 +51,10 @@ private async Task Sync(IMiniLcmApi crdtApi, IMiniLcmApi fwdataApi, //todo sync complex form types, parts of speech, semantic domains, writing systems var currentFwDataEntries = await fwdataApi.GetEntries().ToArrayAsync(); - var crdtChanges = await EntrySync(currentFwDataEntries, projectSnapshot.Entries, crdtApi); + var crdtChanges = await EntrySync.Sync(currentFwDataEntries, projectSnapshot.Entries, crdtApi); LogDryRun(crdtApi, "crdt"); - var fwdataChanges = await EntrySync(await crdtApi.GetEntries().ToArrayAsync(), currentFwDataEntries, fwdataApi); + var fwdataChanges = await EntrySync.Sync(await crdtApi.GetEntries().ToArrayAsync(), currentFwDataEntries, fwdataApi); LogDryRun(fwdataApi, "fwdata"); //todo push crdt changes to lexbox @@ -91,222 +92,4 @@ private async Task SaveProjectSnapshot(string projectName, string? projectPath, await JsonSerializer.SerializeAsync(file, projectSnapshot); } - private async Task EntrySync(Entry[] currentEntries, - Entry[] previousEntries, - IMiniLcmApi api) - { - return await DiffCollection(api, - previousEntries, - currentEntries, - async (api, currentEntry) => - { - await api.CreateEntry(currentEntry); - return 1; - }, - async (api, previousEntry) => - { - await api.DeleteEntry(previousEntry.Id); - return 1; - }, - async (api, previousEntry, currentEntry) => - { - var updateObjectInput = EntryDiffToUpdate(previousEntry, currentEntry); - if (updateObjectInput is not null) await api.UpdateEntry(currentEntry.Id, updateObjectInput); - var changes = await SenseSync(currentEntry.Id, currentEntry.Senses, previousEntry.Senses, api); - return changes + (updateObjectInput is null ? 0 : 1); - }); - } - - private async Task SenseSync(Guid entryId, - IList currentSenses, - IList previousSenses, - IMiniLcmApi api) - { - return await DiffCollection(api, - previousSenses, - currentSenses, - async (api, currentSense) => - { - await api.CreateSense(entryId, currentSense); - return 1; - }, - async (api, previousSense) => - { - await api.DeleteSense(entryId, previousSense.Id); - return 1; - }, - async (api, previousSense, currentSense) => - { - var updateObjectInput = await SenseDiffToUpdate(previousSense, currentSense); - if (updateObjectInput is not null) await api.UpdateSense(entryId, previousSense.Id, updateObjectInput); - var changes = await ExampleSentenceSync(entryId, - previousSense.Id, - currentSense.ExampleSentences, - previousSense.ExampleSentences, - api); - return changes + (updateObjectInput is null ? 0 : 1); - }); - } - - private async Task ExampleSentenceSync(Guid entryId, - Guid senseId, - IList currentExampleSentences, - IList previousExampleSentences, - IMiniLcmApi api) - { - return await DiffCollection(api, - previousExampleSentences, - currentExampleSentences, - async (api, currentExampleSentence) => - { - await api.CreateExampleSentence(entryId, senseId, currentExampleSentence); - return 1; - }, - async (api, previousExampleSentence) => - { - await api.DeleteExampleSentence(entryId, senseId, previousExampleSentence.Id); - return 1; - }, - async (api, previousExampleSentence, currentExampleSentence) => - { - var updateObjectInput = ExampleDiffToUpdate(previousExampleSentence, currentExampleSentence); - if (updateObjectInput is null) return 0; - await api.UpdateExampleSentence(entryId, senseId, previousExampleSentence.Id, updateObjectInput); - return 1; - }); - } - - public static UpdateObjectInput? EntryDiffToUpdate(Entry previousEntry, Entry currentEntry) - { - JsonPatchDocument patchDocument = new(); - patchDocument.Operations.AddRange(GetMultiStringDiff(nameof(Entry.LexemeForm), - previousEntry.LexemeForm, - currentEntry.LexemeForm)); - patchDocument.Operations.AddRange(GetMultiStringDiff(nameof(Entry.CitationForm), - previousEntry.CitationForm, - currentEntry.CitationForm)); - patchDocument.Operations.AddRange(GetMultiStringDiff(nameof(Entry.Note), - previousEntry.Note, - currentEntry.Note)); - patchDocument.Operations.AddRange(GetMultiStringDiff(nameof(Entry.LiteralMeaning), - previousEntry.LiteralMeaning, - currentEntry.LiteralMeaning)); - if (patchDocument.Operations.Count == 0) return null; - return new UpdateObjectInput(patchDocument); - } - - public static async Task?> SenseDiffToUpdate(Sense previousSense, Sense currentSense) - { - JsonPatchDocument patchDocument = new(); - patchDocument.Operations.AddRange(GetMultiStringDiff(nameof(Sense.Gloss), - previousSense.Gloss, - currentSense.Gloss)); - patchDocument.Operations.AddRange(GetMultiStringDiff(nameof(Sense.Definition), - previousSense.Definition, - currentSense.Definition)); - if (previousSense.PartOfSpeech != currentSense.PartOfSpeech) - { - patchDocument.Replace(sense => sense.PartOfSpeech, currentSense.PartOfSpeech); - } - - if (previousSense.PartOfSpeechId != currentSense.PartOfSpeechId) - { - patchDocument.Replace(sense => sense.PartOfSpeechId, currentSense.PartOfSpeechId); - } - - await DiffCollection(null!, - previousSense.SemanticDomains, - currentSense.SemanticDomains, - (_, domain) => - { - patchDocument.Add(sense => sense.SemanticDomains, domain); - return Task.FromResult(1); - }, - (_, previousDomain) => - { - patchDocument.Remove(sense => sense.SemanticDomains, previousSense.SemanticDomains.IndexOf(previousDomain)); - return Task.FromResult(1); - }, - (_, previousDomain, currentDomain) => - { - //do nothing, semantic domains are not editable here - return Task.FromResult(0); - }); - if (patchDocument.Operations.Count == 0) return null; - return new UpdateObjectInput(patchDocument); - } - - public static UpdateObjectInput? ExampleDiffToUpdate(ExampleSentence previousExampleSentence, - ExampleSentence currentExampleSentence) - { - JsonPatchDocument patchDocument = new(); - patchDocument.Operations.AddRange(GetMultiStringDiff(nameof(ExampleSentence.Sentence), - previousExampleSentence.Sentence, - currentExampleSentence.Sentence)); - patchDocument.Operations.AddRange(GetMultiStringDiff(nameof(ExampleSentence.Translation), - previousExampleSentence.Translation, - currentExampleSentence.Translation)); - if (previousExampleSentence.Reference != currentExampleSentence.Reference) - { - patchDocument.Replace(exampleSentence => exampleSentence.Reference, currentExampleSentence.Reference); - } - - if (patchDocument.Operations.Count == 0) return null; - return new UpdateObjectInput(patchDocument); - } - - public static IEnumerable> GetMultiStringDiff(string path, MultiString previous, MultiString current) - where T : class - { - var currentKeys = current.Values.Keys.ToHashSet(); - foreach (var (key, previousValue) in previous.Values) - { - if (current.Values.TryGetValue(key, out var currentValue)) - { - if (!previousValue.Equals(currentValue)) - yield return new Operation("replace", $"/{path}/{key}", null, currentValue); - } - else - { - yield return new Operation("remove", $"/{path}/{key}", null); - } - - currentKeys.Remove(key); - } - - foreach (var key in currentKeys) - { - yield return new Operation("add", $"/{path}/{key}", null, current.Values[key]); - } - } - - private static async Task DiffCollection( - IMiniLcmApi api, - IList previous, - IList current, - Func> add, - Func> remove, - Func> replace) where T : IObjectWithId - { - var changes = 0; - var currentEntriesDict = current.ToDictionary(entry => entry.Id); - foreach (var previousEntry in previous) - { - if (currentEntriesDict.TryGetValue(previousEntry.Id, out var currentEntry)) - { - changes += await replace(api, previousEntry, currentEntry); - } - else - { - changes += await remove(api, previousEntry); - } - - currentEntriesDict.Remove(previousEntry.Id); - } - foreach (var value in currentEntriesDict.Values) - { - changes += await add(api, value); - } - return changes; - } } diff --git a/backend/FwLite/FwLiteProjectSync/DryRunMiniLcmApi.cs b/backend/FwLite/FwLiteProjectSync/DryRunMiniLcmApi.cs index e30ba7cdf..129cc709e 100644 --- a/backend/FwLite/FwLiteProjectSync/DryRunMiniLcmApi.cs +++ b/backend/FwLite/FwLiteProjectSync/DryRunMiniLcmApi.cs @@ -102,6 +102,12 @@ public Task DeleteEntry(Guid id) return Task.CompletedTask; } + public async Task RemoveComplexFormType(Guid entryId, Guid complexFormTypeId) + { + DryRunRecords.Add(new DryRunRecord(nameof(RemoveComplexFormType), $"Remove complex form type {complexFormTypeId}, from entry {entryId}")); + await Task.CompletedTask; + } + public Task CreateSense(Guid entryId, Sense sense) { DryRunRecords.Add(new DryRunRecord(nameof(CreateSense), $"Create sense {sense.Gloss}")); @@ -124,6 +130,18 @@ public Task DeleteSense(Guid entryId, Guid senseId) return Task.CompletedTask; } + public Task AddSemanticDomainToSense(Guid senseId, SemanticDomain semanticDomain) + { + DryRunRecords.Add(new DryRunRecord(nameof(AddSemanticDomainToSense), $"Add semantic domain {semanticDomain.Name}")); + return Task.CompletedTask; + } + + public Task RemoveSemanticDomainFromSense(Guid senseId, Guid semanticDomainId) + { + DryRunRecords.Add(new DryRunRecord(nameof(RemoveSemanticDomainFromSense), $"Remove semantic domain {semanticDomainId}")); + return Task.CompletedTask; + } + public Task CreateExampleSentence(Guid entryId, Guid senseId, ExampleSentence exampleSentence) { DryRunRecords.Add(new DryRunRecord(nameof(CreateExampleSentence), $"Create example sentence {exampleSentence.Sentence}")); @@ -149,4 +167,22 @@ public Task DeleteExampleSentence(Guid entryId, Guid senseId, Guid exampleSenten DryRunRecords.Add(new DryRunRecord(nameof(DeleteExampleSentence), $"Delete example sentence {exampleSentenceId}")); return Task.CompletedTask; } + + public Task CreateComplexFormComponent(ComplexFormComponent complexFormComponent) + { + DryRunRecords.Add(new DryRunRecord(nameof(CreateComplexFormComponent), $"Create complex form component complex entry: {complexFormComponent.ComplexFormHeadword}, component entry: {complexFormComponent.ComponentHeadword}")); + return Task.FromResult(complexFormComponent); + } + + public Task DeleteComplexFormComponent(ComplexFormComponent complexFormComponent) + { + DryRunRecords.Add(new DryRunRecord(nameof(DeleteComplexFormComponent), $"Delete complex form component complex entry: {complexFormComponent.ComplexFormHeadword}, component entry: {complexFormComponent.ComponentHeadword}")); + return Task.CompletedTask; + } + + public async Task AddComplexFormType(Guid entryId, Guid complexFormTypeId) + { + DryRunRecords.Add(new DryRunRecord(nameof(AddComplexFormType), $"Add complex form type {complexFormTypeId}, to entry {entryId}")); + await Task.CompletedTask; + } } diff --git a/backend/FwLite/LcmCrdt.Tests/DataModelSnapshotTests.VerifyChangeModels.verified.txt b/backend/FwLite/LcmCrdt.Tests/DataModelSnapshotTests.VerifyChangeModels.verified.txt index b20d42518..4622a2e58 100644 --- a/backend/FwLite/LcmCrdt.Tests/DataModelSnapshotTests.VerifyChangeModels.verified.txt +++ b/backend/FwLite/LcmCrdt.Tests/DataModelSnapshotTests.VerifyChangeModels.verified.txt @@ -108,6 +108,10 @@ DerivedType: RemoveComplexFormTypeChange, TypeDiscriminator: RemoveComplexFormTypeChange }, + { + DerivedType: SetComplexFormComponentChange, + TypeDiscriminator: SetComplexFormComponentChange + }, { DerivedType: CreateComplexFormType, TypeDiscriminator: CreateComplexFormType diff --git a/backend/FwLite/LcmCrdt.Tests/MiniLcmTests/ComplexFormComponentTests.cs b/backend/FwLite/LcmCrdt.Tests/MiniLcmTests/ComplexFormComponentTests.cs new file mode 100644 index 000000000..486146ae1 --- /dev/null +++ b/backend/FwLite/LcmCrdt.Tests/MiniLcmTests/ComplexFormComponentTests.cs @@ -0,0 +1,19 @@ +namespace LcmCrdt.Tests.MiniLcmTests; + +public class ComplexFormComponentTests : ComplexFormComponentTestsBase +{ + private readonly MiniLcmApiFixture _fixture = new(); + + protected override async Task NewApi() + { + await _fixture.InitializeAsync(); + var api = _fixture.Api; + return api; + } + + public override async Task DisposeAsync() + { + await base.DisposeAsync(); + await _fixture.DisposeAsync(); + } +} diff --git a/backend/FwLite/LcmCrdt/Changes/JsonPatchChange.cs b/backend/FwLite/LcmCrdt/Changes/JsonPatchChange.cs index bba12f5c9..e0068168f 100644 --- a/backend/FwLite/LcmCrdt/Changes/JsonPatchChange.cs +++ b/backend/FwLite/LcmCrdt/Changes/JsonPatchChange.cs @@ -45,9 +45,9 @@ public static void ValidatePatchDocument(IJsonPatchDocument patchDocument) { foreach (var operation in patchDocument.GetOperations()) { - if (operation.OperationType == OperationType.Remove) + if (operation.OperationType == OperationType.Remove && char.IsDigit(operation.Path?[^1] ?? default)) { - throw new NotSupportedException("remove at index not supported"); + throw new NotSupportedException("remove at index not supported, op " + JsonSerializer.Serialize(operation)); } // we want to make sure that the path is not an index, as a shortcut we just check the first character is not a digit, because it's invalid for fields to start with a digit. diff --git a/backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs b/backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs index 0bc0c8ead..ed0ccddef 100644 --- a/backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs +++ b/backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs @@ -7,10 +7,13 @@ using LcmCrdt.Objects; using LinqToDB; using LinqToDB.EntityFrameworkCore; +using MiniLcm.Exceptions; +using MiniLcm.SyncHelpers; +using SIL.Harmony.Db; namespace LcmCrdt; -public class CrdtMiniLcmApi(DataModel dataModel, CurrentProjectService projectService) : IMiniLcmApi +public class CrdtMiniLcmApi(DataModel dataModel, CurrentProjectService projectService, LcmCrdtDbContext dbContext) : IMiniLcmApi { private Guid ClientId { get; } = projectService.ProjectData.ClientId; public ProjectData ProjectData => projectService.ProjectData; @@ -106,6 +109,28 @@ public async Task CreateComplexFormType(MiniLcm.Models.ComplexF return await ComplexFormTypes.SingleAsync(c => c.Id == complexFormType.Id); } + public async Task CreateComplexFormComponent(ComplexFormComponent complexFormComponent) + { + var addEntryComponentChange = new AddEntryComponentChange(complexFormComponent); + await dataModel.AddChange(ClientId, addEntryComponentChange); + return (await ComplexFormComponents.SingleOrDefaultAsync(c => c.Id == addEntryComponentChange.EntityId)) ?? throw NotFoundException.ForType(); + } + + public async Task DeleteComplexFormComponent(ComplexFormComponent complexFormComponent) + { + await dataModel.AddChange(ClientId, new DeleteChange(complexFormComponent.Id)); + } + + public async Task AddComplexFormType(Guid entryId, Guid complexFormTypeId) + { + await dataModel.AddChange(ClientId, new AddComplexFormTypeChange(entryId, await ComplexFormTypes.SingleAsync(ct => ct.Id == complexFormTypeId))); + } + + public async Task RemoveComplexFormType(Guid entryId, Guid complexFormTypeId) + { + await dataModel.AddChange(ClientId, new RemoveComplexFormTypeChange(entryId, complexFormTypeId)); + } + public IAsyncEnumerable GetEntries(QueryOptions? options = null) { return GetEntriesAsyncEnum(predicate: null, options); @@ -118,18 +143,14 @@ public IAsyncEnumerable SearchEntries(string? query, QueryOptions? option return GetEntriesAsyncEnum(Filtering.SearchFilter(query), options); } - private async IAsyncEnumerable GetEntriesAsyncEnum( + private IAsyncEnumerable GetEntriesAsyncEnum( Expression>? predicate = null, QueryOptions? options = null) { - var entries = await GetEntries(predicate, options); - foreach (var entry in entries) - { - yield return entry; - } + return GetEntries(predicate, options); } - private async Task GetEntries( + private async IAsyncEnumerable GetEntries( Expression>? predicate = null, QueryOptions? options = null) { @@ -157,15 +178,16 @@ private async Task GetEntries( .ThenBy(e => e.Id) .Skip(options.Offset) .Take(options.Count); - var entries = await queryable - .ToArrayAsyncLinqToDB(); - - return entries; + var entries = queryable.AsAsyncEnumerable(); + await foreach (var entry in entries) + { + yield return entry; + } } public async Task GetEntry(Guid id) { - var entry = await Entries + var entry = await Entries.AsTracking(false) .LoadWith(e => e.Senses) .ThenLoad(s => s.ExampleSentences) .LoadWith(e => e.ComplexForms) @@ -237,13 +259,69 @@ await dataModel.AddChanges(ClientId, ..await entry.Senses.ToAsyncEnumerable() .SelectMany(s => CreateSenseChanges(entry.Id, s)) .ToArrayAsync(), - ..entry.Components.Select(c => new AddEntryComponentChange(c)), - ..entry.ComplexForms.Select(c => new AddEntryComponentChange(c)), - ..entry.ComplexFormTypes.Select(c => new AddComplexFormTypeChange(entry.Id, c)) + ..await ToComplexFormComponents(entry.Components).ToArrayAsync(), + ..await ToComplexFormComponents(entry.ComplexForms).ToArrayAsync(), + ..await ToComplexFormTypes(entry.ComplexFormTypes).ToArrayAsync() ]); return await GetEntry(entry.Id) ?? throw new NullReferenceException(); + + async IAsyncEnumerable ToComplexFormComponents(IList complexFormComponents) + { + foreach (var complexFormComponent in complexFormComponents) + { + if (complexFormComponent.ComponentEntryId == default) complexFormComponent.ComponentEntryId = entry.Id; + if (complexFormComponent.ComplexFormEntryId == default) complexFormComponent.ComplexFormEntryId = entry.Id; + if (complexFormComponent.ComponentEntryId == complexFormComponent.ComplexFormEntryId) + { + throw new InvalidOperationException($"Complex form component {complexFormComponent} has the same component id as its complex form"); + } + //these tests break under sync when the entry was deleted in a CRDT but that's not yet been synced to FW + //todo enable these tests when the api is not syncing but being called normally + // if (complexFormComponent.ComponentEntryId != entry.Id && + // await IsEntryDeleted(complexFormComponent.ComponentEntryId)) + // { + // throw new InvalidOperationException($"Complex form component {complexFormComponent} references deleted entry {complexFormComponent.ComponentEntryId} as its component"); + // } + // if (complexFormComponent.ComplexFormEntryId != entry.Id && + // await IsEntryDeleted(complexFormComponent.ComplexFormEntryId)) + // { + // throw new InvalidOperationException($"Complex form component {complexFormComponent} references deleted entry {complexFormComponent.ComplexFormEntryId} as its complex form"); + // } + + // if (complexFormComponent.ComponentSenseId != null && + // !await Senses.AnyAsyncEF(s => s.Id == complexFormComponent.ComponentSenseId.Value)) + // { + // throw new InvalidOperationException($"Complex form component {complexFormComponent} references deleted sense {complexFormComponent.ComponentSenseId} as its component"); + // } + yield return new AddEntryComponentChange(complexFormComponent); + } + } + + async IAsyncEnumerable ToComplexFormTypes(IList complexFormTypes) + { + foreach (var complexFormType in complexFormTypes) + { + if (complexFormType.Id == default) + { + throw new InvalidOperationException("Complex form type must have an id"); + } + + if (!await ComplexFormTypes.AnyAsyncEF(t => t.Id == complexFormType.Id)) + { + throw new InvalidOperationException($"Complex form type {complexFormType} does not exist"); + } + yield return new AddComplexFormTypeChange(entry.Id, complexFormType); + } + } + } + + private async ValueTask IsEntryDeleted(Guid id) + { + return !await Entries.AnyAsyncEF(e => e.Id == id); } + + public async Task UpdateEntry(Guid id, UpdateObjectInput update) { @@ -251,7 +329,7 @@ public async Task UpdateEntry(Guid id, if (entry is null) throw new NullReferenceException($"unable to find entry with id {id}"); await dataModel.AddChanges(ClientId, [..entry.ToChanges(update.Patch)]); - return await GetEntry(id) ?? throw new NullReferenceException(); + return await GetEntry(id) ?? throw new NullReferenceException("unable to find entry with id " + id); } public async Task DeleteEntry(Guid id) @@ -300,6 +378,16 @@ public async Task DeleteSense(Guid entryId, Guid senseId) await dataModel.AddChange(ClientId, new DeleteChange(senseId)); } + public async Task AddSemanticDomainToSense(Guid senseId, SemanticDomain semanticDomain) + { + await dataModel.AddChange(ClientId, new AddSemanticDomainChange(semanticDomain, senseId)); + } + + public async Task RemoveSemanticDomainFromSense(Guid senseId, Guid semanticDomainId) + { + await dataModel.AddChange(ClientId, new RemoveSemanticDomainChange(semanticDomainId, senseId)); + } + public async Task CreateExampleSentence(Guid entryId, Guid senseId, ExampleSentence exampleSentence) diff --git a/backend/FwLite/LcmCrdt/LcmCrdtKernel.cs b/backend/FwLite/LcmCrdt/LcmCrdtKernel.cs index 93ea9587d..7e4df57c1 100644 --- a/backend/FwLite/LcmCrdt/LcmCrdtKernel.cs +++ b/backend/FwLite/LcmCrdt/LcmCrdtKernel.cs @@ -14,6 +14,7 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using SIL.Harmony.Db; namespace LcmCrdt; @@ -61,6 +62,7 @@ private static void ConfigureDbOptions(IServiceProvider provider, DbContextOptio }); } + public static void ConfigureCrdt(CrdtConfig config) { config.EnableProjectedTables = true; @@ -68,7 +70,6 @@ public static void ConfigureCrdt(CrdtConfig config) .CustomAdapter() .Add(builder => { - builder.Ignore(e => e.Senses); builder.HasMany(e => e.Components) .WithOne() .HasPrincipalKey(entry => entry.Id) @@ -149,6 +150,7 @@ public static void ConfigureCrdt(CrdtConfig config) .Add() .Add() .Add() + .Add() .Add(); } diff --git a/backend/FwLite/LcmCrdt/Objects/PreDefinedData.cs b/backend/FwLite/LcmCrdt/Objects/PreDefinedData.cs index b69837bd0..a9bc4984a 100644 --- a/backend/FwLite/LcmCrdt/Objects/PreDefinedData.cs +++ b/backend/FwLite/LcmCrdt/Objects/PreDefinedData.cs @@ -5,6 +5,16 @@ namespace LcmCrdt.Objects; public static class PreDefinedData { + internal static async Task PredefinedComplexFormTypes(DataModel dataModel, Guid clientId) + { + await dataModel.AddChanges(clientId, + [ + new CreateComplexFormType(new Guid("c36f55ed-d1ea-4069-90b3-3f35ff696273"), new MultiString() { { "en", "Compound" } } ), + new CreateComplexFormType(new Guid("eeb78fce-6009-4932-aaa6-85faeb180c69"), new MultiString() { { "en", "Unspecified" } }) + ], + new Guid("dc60d2a9-0cc2-48ed-803c-a238a14b6eae")); + } + internal static async Task PredefinedSemanticDomains(DataModel dataModel, Guid clientId) { //todo load from xml instead of hardcoding and use real IDs diff --git a/backend/FwLite/LcmCrdt/ProjectsService.cs b/backend/FwLite/LcmCrdt/ProjectsService.cs index 4a9950e8b..a9cbeb43f 100644 --- a/backend/FwLite/LcmCrdt/ProjectsService.cs +++ b/backend/FwLite/LcmCrdt/ProjectsService.cs @@ -81,6 +81,7 @@ internal static async Task InitProjectDb(LcmCrdtDbContext db, ProjectData data) internal static async Task SeedSystemData(DataModel dataModel, Guid clientId) { + await PreDefinedData.PredefinedComplexFormTypes(dataModel, clientId); await PreDefinedData.PredefinedPartsOfSpeech(dataModel, clientId); await PreDefinedData.PredefinedSemanticDomains(dataModel, clientId); } diff --git a/backend/FwLite/MiniLcm.Tests/ComplexFormComponentTestsBase.cs b/backend/FwLite/MiniLcm.Tests/ComplexFormComponentTestsBase.cs new file mode 100644 index 000000000..80dac29d2 --- /dev/null +++ b/backend/FwLite/MiniLcm.Tests/ComplexFormComponentTestsBase.cs @@ -0,0 +1,77 @@ +using MiniLcm.Models; + +namespace MiniLcm.Tests; + +public abstract class ComplexFormComponentTestsBase : MiniLcmTestBase +{ + private readonly Guid _complexFormEntryId = Guid.NewGuid(); + private readonly Guid _componentEntryId = Guid.NewGuid(); + private readonly Guid _componentSenseId1 = Guid.NewGuid(); + private readonly Guid _componentSenseId2 = Guid.NewGuid(); + private Entry _complexFormEntry = null!; + private Entry _componentEntry = null!; + + public override async Task InitializeAsync() + { + await base.InitializeAsync(); + _complexFormEntry = await Api.CreateEntry(new() + { + Id = _complexFormEntryId, + LexemeForm = { { "en", "complex form" } } + }); + _componentEntry = await Api.CreateEntry(new() + { + Id = _componentEntryId, + LexemeForm = { { "en", "component" } }, + Senses = + [ + new Sense + { + Id = _componentSenseId1, + Gloss = { { "en", "component sense 1" } } + }, + new Sense + { + Id = _componentSenseId2, + Gloss = { { "en", "component sense 2" } } + } + ] + }); + } + + [Fact] + public async Task CreateComplexFormComponent_Works() + { + var component = await Api.CreateComplexFormComponent(ComplexFormComponent.FromEntries(_complexFormEntry, _componentEntry)); + component.ComplexFormEntryId.Should().Be(_complexFormEntryId); + component.ComponentEntryId.Should().Be(_componentEntryId); + component.ComponentSenseId.Should().BeNull(); + component.ComplexFormHeadword.Should().Be("complex form"); + component.ComponentHeadword.Should().Be("component"); + } + + [Fact] + public async Task CreateComplexFormComponent_WorksWithSense() + { + var component = await Api.CreateComplexFormComponent(ComplexFormComponent.FromEntries(_complexFormEntry, _componentEntry, _componentSenseId1)); + component.ComplexFormEntryId.Should().Be(_complexFormEntryId); + component.ComponentEntryId.Should().Be(_componentEntryId); + component.ComponentSenseId.Should().Be(_componentSenseId1); + component.ComplexFormHeadword.Should().Be("complex form"); + component.ComponentHeadword.Should().Be("component"); + } + + [Fact] + public async Task CreateComplexFormComponent_CanCreateMultipleComponentSenses() + { + var component1 = await Api.CreateComplexFormComponent(ComplexFormComponent.FromEntries(_complexFormEntry, _componentEntry, _componentSenseId1)); + component1.ComplexFormEntryId.Should().Be(_complexFormEntryId); + component1.ComponentEntryId.Should().Be(_componentEntryId); + component1.ComponentSenseId.Should().Be(_componentSenseId1); + + var component2 = await Api.CreateComplexFormComponent(ComplexFormComponent.FromEntries(_complexFormEntry, _componentEntry, _componentSenseId2)); + component2.ComplexFormEntryId.Should().Be(_complexFormEntryId); + component2.ComponentEntryId.Should().Be(_componentEntryId); + component2.ComponentSenseId.Should().Be(_componentSenseId2); + } +} diff --git a/backend/FwLite/MiniLcm/Exceptions/NotFoundException.cs b/backend/FwLite/MiniLcm/Exceptions/NotFoundException.cs new file mode 100644 index 000000000..a8d4eb85e --- /dev/null +++ b/backend/FwLite/MiniLcm/Exceptions/NotFoundException.cs @@ -0,0 +1,16 @@ +using System.Diagnostics.CodeAnalysis; + +namespace MiniLcm.Exceptions; + +public class NotFoundException(string message, string type) : Exception(message) +{ + public static void ThrowIfNull([AllowNull, NotNull] T arg) + { + if (arg is null) + throw ForType(); + } + + public static NotFoundException ForType() => new($"{typeof(T).Name} not found", typeof(T).Name); + + public string Type { get; set; } = type; +} diff --git a/backend/FwLite/MiniLcm/IMiniLcmWriteApi.cs b/backend/FwLite/MiniLcm/IMiniLcmWriteApi.cs index f7ebc2209..608e3eda0 100644 --- a/backend/FwLite/MiniLcm/IMiniLcmWriteApi.cs +++ b/backend/FwLite/MiniLcm/IMiniLcmWriteApi.cs @@ -12,23 +12,38 @@ Task UpdateWritingSystem(WritingSystemId id, WritingSystemType type, UpdateObjectInput update); + Task CreatePartOfSpeech(PartOfSpeech partOfSpeech); Task CreateSemanticDomain(SemanticDomain semanticDomain); Task CreateComplexFormType(ComplexFormType complexFormType); + + #region Entry Task CreateEntry(Entry entry); Task UpdateEntry(Guid id, UpdateObjectInput update); Task DeleteEntry(Guid id); + Task CreateComplexFormComponent(ComplexFormComponent complexFormComponent); + Task DeleteComplexFormComponent(ComplexFormComponent complexFormComponent); + Task AddComplexFormType(Guid entryId, Guid complexFormTypeId); + Task RemoveComplexFormType(Guid entryId, Guid complexFormTypeId); + #endregion + + #region Sense Task CreateSense(Guid entryId, Sense sense); Task UpdateSense(Guid entryId, Guid senseId, UpdateObjectInput update); Task DeleteSense(Guid entryId, Guid senseId); - Task CreateExampleSentence(Guid entryId, Guid senseId, ExampleSentence exampleSentence); + Task AddSemanticDomainToSense(Guid senseId, SemanticDomain semanticDomain); + Task RemoveSemanticDomainFromSense(Guid senseId, Guid semanticDomainId); + #endregion + #region ExampleSentence + Task CreateExampleSentence(Guid entryId, Guid senseId, ExampleSentence exampleSentence); Task UpdateExampleSentence(Guid entryId, Guid senseId, Guid exampleSentenceId, UpdateObjectInput update); Task DeleteExampleSentence(Guid entryId, Guid senseId, Guid exampleSentenceId); + #endregion } /// diff --git a/backend/FwLite/MiniLcm/InMemoryApi.cs b/backend/FwLite/MiniLcm/InMemoryApi.cs index afebd814d..2e29f9ec6 100644 --- a/backend/FwLite/MiniLcm/InMemoryApi.cs +++ b/backend/FwLite/MiniLcm/InMemoryApi.cs @@ -160,6 +160,26 @@ public Task CreateComplexFormType(ComplexFormType complexFormTy throw new NotImplementedException(); } + public Task CreateComplexFormComponent(ComplexFormComponent complexFormComponent) + { + throw new NotImplementedException(); + } + + public Task DeleteComplexFormComponent(ComplexFormComponent complexFormComponent) + { + throw new NotImplementedException(); + } + + public Task AddComplexFormType(Guid entryId, Guid complexFormTypeId) + { + throw new NotImplementedException(); + } + + public Task RemoveComplexFormType(Guid entryId, Guid complexFormTypeId) + { + throw new NotImplementedException(); + } + private readonly string[] _exemplars = Enumerable.Range('a', 'z').Select(c => ((char)c).ToString()).ToArray(); public Task CreateEntry(Entry entry) @@ -278,6 +298,16 @@ public Task UpdateSense(Guid entryId, Guid senseId, UpdateObjectInput Diff( + IMiniLcmApi api, + IList before, + IList after, + Func identity, + Func> add, + Func> remove, + Func> replace) where TId : notnull + { + var changes = 0; + var afterEntriesDict = after.ToDictionary(identity); + foreach (var beforeEntry in before) + { + if (afterEntriesDict.TryGetValue(identity(beforeEntry), out var afterEntry)) + { + changes += await replace(api, beforeEntry, afterEntry); + } + else + { + changes += await remove(api, beforeEntry); + } + + afterEntriesDict.Remove(identity(beforeEntry)); + } + + foreach (var value in afterEntriesDict.Values) + { + changes += await add(api, value); + } + + return changes; + } + public static async Task Diff( + IMiniLcmApi api, + IList before, + IList after, + Func> add, + Func> remove, + Func> replace) where T : IObjectWithId + { + return await Diff(api, before, after, t => t.Id, add, remove, replace); + } + + public static async Task Diff( + IMiniLcmApi api, + IList before, + IList after, + Func add, + Func remove, + Func> replace) where T : IObjectWithId + { + return await Diff(api, + before, + after, + async (api, entry) => + { + await add(api, entry); + return 1; + }, + async (api, entry) => + { + await remove(api, entry); + return 1; + }, + async (api, beforeEntry, afterEntry) => await replace(api, beforeEntry, afterEntry)); + } +} diff --git a/backend/FwLite/MiniLcm/SyncHelpers/EntrySync.cs b/backend/FwLite/MiniLcm/SyncHelpers/EntrySync.cs new file mode 100644 index 000000000..50fe78892 --- /dev/null +++ b/backend/FwLite/MiniLcm/SyncHelpers/EntrySync.cs @@ -0,0 +1,131 @@ +using MiniLcm.Exceptions; +using MiniLcm.Models; +using SystemTextJsonPatch; + +namespace MiniLcm.SyncHelpers; + +public static class EntrySync +{ + public static async Task Sync(Entry[] afterEntries, + Entry[] beforeEntries, + IMiniLcmApi api) + { + Func> add = static async (api, afterEntry) => + { + await api.CreateEntry(afterEntry); + return 1; + }; + Func> remove = static async (api, beforeEntry) => + { + await api.DeleteEntry(beforeEntry.Id); + return 1; + }; + Func> replace = static async (api, beforeEntry, afterEntry) => await Sync(afterEntry, beforeEntry, api); + return await DiffCollection.Diff(api, beforeEntries, afterEntries, add, remove, replace); + } + + public static async Task Sync(Entry afterEntry, Entry beforeEntry, IMiniLcmApi api) + { + var updateObjectInput = EntryDiffToUpdate(beforeEntry, afterEntry); + if (updateObjectInput is not null) await api.UpdateEntry(afterEntry.Id, updateObjectInput); + var changes = await SensesSync(afterEntry.Id, afterEntry.Senses, beforeEntry.Senses, api); + + changes += await Sync(afterEntry.Components, beforeEntry.Components, api); + changes += await Sync(afterEntry.ComplexForms, beforeEntry.ComplexForms, api); + changes += await Sync(afterEntry.Id, afterEntry.ComplexFormTypes, beforeEntry.ComplexFormTypes, api); + return changes + (updateObjectInput is null ? 0 : 1); + } + + private static async Task Sync(Guid entryId, + IList afterComplexFormTypes, + IList beforeComplexFormTypes, + IMiniLcmApi api) + { + return await DiffCollection.Diff(api, + beforeComplexFormTypes, + afterComplexFormTypes, + complexFormType => complexFormType.Id, + async (api, afterComplexFormType) => + { + await api.AddComplexFormType(entryId, afterComplexFormType.Id); + return 1; + }, + async (api, beforeComplexFormType) => + { + await api.RemoveComplexFormType(entryId, beforeComplexFormType.Id); + return 1; + }, + //do nothing, complex form types are not editable, ignore any changes to them here + static (api, beforeComplexFormType, afterComplexFormType) => Task.FromResult(0)); + } + + private static async Task Sync(IList afterComponents, IList beforeComponents, IMiniLcmApi api) + { + return await DiffCollection.Diff(api, + beforeComponents, + afterComponents, + //we can't use the ID as there's none defined by Fw so it won't work as a sync key + component => (component.ComplexFormEntryId, component.ComponentEntryId, component.ComponentSenseId), + static async (api, afterComponent) => + { + //change id, since we're not using the id as the key for this collection + //the id may be the same, which is not what we want here + afterComponent.Id = Guid.NewGuid(); + try + { + await api.CreateComplexFormComponent(afterComponent); + } + catch (NotFoundException) + { + //this can happen if the entry was deleted, so we can just ignore it + } + return 1; + }, + static async (api, beforeComponent) => + { + await api.DeleteComplexFormComponent(beforeComponent); + return 1; + }, + static (api, beforeComponent, afterComponent) => + { + if (beforeComponent.ComplexFormEntryId == afterComponent.ComplexFormEntryId && + beforeComponent.ComponentEntryId == afterComponent.ComponentEntryId && + beforeComponent.ComponentSenseId == afterComponent.ComponentSenseId) + { + return Task.FromResult(0); + } + throw new InvalidOperationException($"changing complex form components is not supported, they should just be deleted and recreated"); + } + ); + } + + private static async Task SensesSync(Guid entryId, + IList afterSenses, + IList beforeSenses, + IMiniLcmApi api) + { + Func> add = async (api, afterSense) => + { + await api.CreateSense(entryId, afterSense); + return 1; + }; + Func> remove = async (api, beforeSense) => + { + await api.DeleteSense(entryId, beforeSense.Id); + return 1; + }; + Func> replace = async (api, beforeSense, afterSense) => await SenseSync.Sync(entryId, afterSense, beforeSense, api); + return await DiffCollection.Diff(api, beforeSenses, afterSenses, add, remove, replace); + } + + public static UpdateObjectInput? EntryDiffToUpdate(Entry beforeEntry, Entry afterEntry) + { + JsonPatchDocument patchDocument = new(); + patchDocument.Operations.AddRange(MultiStringDiff.GetMultiStringDiff(nameof(Entry.LexemeForm), beforeEntry.LexemeForm, afterEntry.LexemeForm)); + patchDocument.Operations.AddRange(MultiStringDiff.GetMultiStringDiff(nameof(Entry.CitationForm), beforeEntry.CitationForm, afterEntry.CitationForm)); + patchDocument.Operations.AddRange(MultiStringDiff.GetMultiStringDiff(nameof(Entry.Note), beforeEntry.Note, afterEntry.Note)); + patchDocument.Operations.AddRange(MultiStringDiff.GetMultiStringDiff(nameof(Entry.LiteralMeaning), beforeEntry.LiteralMeaning, afterEntry.LiteralMeaning)); + if (patchDocument.Operations.Count == 0) return null; + return new UpdateObjectInput(patchDocument); + } +} diff --git a/backend/FwLite/MiniLcm/SyncHelpers/ExampleSentenceSync.cs b/backend/FwLite/MiniLcm/SyncHelpers/ExampleSentenceSync.cs new file mode 100644 index 000000000..5652e77de --- /dev/null +++ b/backend/FwLite/MiniLcm/SyncHelpers/ExampleSentenceSync.cs @@ -0,0 +1,60 @@ +using MiniLcm.Models; +using SystemTextJsonPatch; + +namespace MiniLcm.SyncHelpers; + +public static class ExampleSentenceSync +{ + public static async Task Sync(Guid entryId, + Guid senseId, + IList afterExampleSentences, + IList beforeExampleSentences, + IMiniLcmApi api) + { + Func> add = async (api, afterExampleSentence) => + { + await api.CreateExampleSentence(entryId, senseId, afterExampleSentence); + return 1; + }; + Func> remove = async (api, beforeExampleSentence) => + { + await api.DeleteExampleSentence(entryId, senseId, beforeExampleSentence.Id); + return 1; + }; + Func> replace = + async (api, beforeExampleSentence, afterExampleSentence) => + { + var updateObjectInput = DiffToUpdate(beforeExampleSentence, afterExampleSentence); + if (updateObjectInput is null) return 0; + await api.UpdateExampleSentence(entryId, senseId, beforeExampleSentence.Id, updateObjectInput); + return 1; + }; + return await DiffCollection.Diff(api, + beforeExampleSentences, + afterExampleSentences, + add, + remove, + replace); + } + + public static UpdateObjectInput? DiffToUpdate(ExampleSentence beforeExampleSentence, + ExampleSentence afterExampleSentence) + { + JsonPatchDocument patchDocument = new(); + patchDocument.Operations.AddRange(MultiStringDiff.GetMultiStringDiff( + nameof(ExampleSentence.Sentence), + beforeExampleSentence.Sentence, + afterExampleSentence.Sentence)); + patchDocument.Operations.AddRange(MultiStringDiff.GetMultiStringDiff( + nameof(ExampleSentence.Translation), + beforeExampleSentence.Translation, + afterExampleSentence.Translation)); + if (beforeExampleSentence.Reference != afterExampleSentence.Reference) + { + patchDocument.Replace(exampleSentence => exampleSentence.Reference, afterExampleSentence.Reference); + } + + if (patchDocument.Operations.Count == 0) return null; + return new UpdateObjectInput(patchDocument); + } +} diff --git a/backend/FwLite/MiniLcm/SyncHelpers/MultiStringDiff.cs b/backend/FwLite/MiniLcm/SyncHelpers/MultiStringDiff.cs new file mode 100644 index 000000000..7a24c58fd --- /dev/null +++ b/backend/FwLite/MiniLcm/SyncHelpers/MultiStringDiff.cs @@ -0,0 +1,33 @@ +using MiniLcm.Models; +using SystemTextJsonPatch.Operations; + +namespace MiniLcm.SyncHelpers; + +public static class MultiStringDiff +{ + public static IEnumerable> GetMultiStringDiff(string path, + MultiString before, + MultiString after) where T : class + { + var afterKeys = after.Values.Keys.ToHashSet(); + foreach (var (key, beforeValue) in before.Values) + { + if (after.Values.TryGetValue(key, out var afterValue)) + { + if (!beforeValue.Equals(afterValue)) + yield return new Operation("replace", $"/{path}/{key}", null, afterValue); + } + else + { + yield return new Operation("remove", $"/{path}/{key}", null); + } + + afterKeys.Remove(key); + } + + foreach (var key in afterKeys) + { + yield return new Operation("add", $"/{path}/{key}", null, after.Values[key]); + } + } +} diff --git a/backend/FwLite/MiniLcm/SyncHelpers/SenseSync.cs b/backend/FwLite/MiniLcm/SyncHelpers/SenseSync.cs new file mode 100644 index 000000000..1b3b7c437 --- /dev/null +++ b/backend/FwLite/MiniLcm/SyncHelpers/SenseSync.cs @@ -0,0 +1,63 @@ +using MiniLcm.Models; +using SystemTextJsonPatch; + +namespace MiniLcm.SyncHelpers; + +public static class SenseSync +{ + public static async Task Sync(Guid entryId, + Sense afterSense, + Sense beforeSense, + IMiniLcmApi api) + { + var updateObjectInput = await SenseDiffToUpdate(beforeSense, afterSense); + if (updateObjectInput is not null) await api.UpdateSense(entryId, beforeSense.Id, updateObjectInput); + var changes = await ExampleSentenceSync.Sync(entryId, + beforeSense.Id, + afterSense.ExampleSentences, + beforeSense.ExampleSentences, + api); + return changes + (updateObjectInput is null ? 0 : 1); + } + + public static async Task?> SenseDiffToUpdate(Sense beforeSense, Sense afterSense) + { + JsonPatchDocument patchDocument = new(); + patchDocument.Operations.AddRange( + MultiStringDiff.GetMultiStringDiff(nameof(Sense.Gloss), beforeSense.Gloss, afterSense.Gloss)); + patchDocument.Operations.AddRange(MultiStringDiff.GetMultiStringDiff(nameof(Sense.Definition), + beforeSense.Definition, + afterSense.Definition)); + if (beforeSense.PartOfSpeech != afterSense.PartOfSpeech) + { + patchDocument.Replace(sense => sense.PartOfSpeech, afterSense.PartOfSpeech); + } + + if (beforeSense.PartOfSpeechId != afterSense.PartOfSpeechId) + { + patchDocument.Replace(sense => sense.PartOfSpeechId, afterSense.PartOfSpeechId); + } + + await DiffCollection.Diff(null!, + beforeSense.SemanticDomains, + afterSense.SemanticDomains, + (_, domain) => + { + patchDocument.Add(sense => sense.SemanticDomains, domain); + return Task.FromResult(1); + }, + (_, beforeDomain) => + { + patchDocument.Remove(sense => sense.SemanticDomains, + beforeSense.SemanticDomains.IndexOf(beforeDomain)); + return Task.FromResult(1); + }, + (_, beforeDomain, afterDomain) => + { + //do nothing, semantic domains are not editable here + return Task.FromResult(0); + }); + if (patchDocument.Operations.Count == 0) return null; + return new UpdateObjectInput(patchDocument); + } +}