diff --git a/backend/FwLite/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs b/backend/FwLite/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs index f7e359044b..af02630ffc 100644 --- a/backend/FwLite/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs +++ b/backend/FwLite/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs @@ -956,8 +956,9 @@ public IAsyncEnumerable SearchEntries(string query, QueryOptions? options return Task.FromResult(FromLexEntry(EntriesRepository.GetObject(id))); } - public async Task CreateEntry(Entry entry) + public async Task CreateEntry(Entry entry, CreateEntryOptions? options = null) { + options ??= CreateEntryOptions.Everything; entry.Id = entry.Id == default ? Guid.NewGuid() : entry.Id; try { @@ -983,15 +984,18 @@ public async Task CreateEntry(Entry entry) AddComplexFormType(lexEntry, complexFormType.Id); } - foreach (var component in entry.Components) + if (options.IncludeComplexFormsAndComponents) { - AddComplexFormComponent(lexEntry, component); - } - - foreach (var complexForm in entry.ComplexForms) - { - var complexLexEntry = EntriesRepository.GetObject(complexForm.ComplexFormEntryId); - AddComplexFormComponent(complexLexEntry, complexForm); + foreach (var component in entry.Components) + { + AddComplexFormComponent(lexEntry, component); + } + + foreach (var complexForm in entry.ComplexForms) + { + var complexLexEntry = EntriesRepository.GetObject(complexForm.ComplexFormEntryId); + AddComplexFormComponent(complexLexEntry, complexForm); + } } // Subtract entry.Publications from Publications to get the publications that the entry should not be published in var doNotPublishIn = Publications.PossibilitiesOS.Where(p => entry.PublishIn.All(ep => ep.Id != p.Guid)); @@ -1287,7 +1291,7 @@ await Cache.DoUsingNewOrCurrentUOW("Update Entry", "Revert entry", async () => { - await EntrySync.Sync(before, after, api ?? this); + await EntrySync.SyncFull(before, after, api ?? this); }); return await GetEntry(after.Id) ?? throw new NullReferenceException("unable to find entry with id " + after.Id); } diff --git a/backend/FwLite/FwLiteProjectSync.Tests/EntrySyncTests.cs b/backend/FwLite/FwLiteProjectSync.Tests/EntrySyncTests.cs index 72a9e35099..81ee5e4993 100644 --- a/backend/FwLite/FwLiteProjectSync.Tests/EntrySyncTests.cs +++ b/backend/FwLite/FwLiteProjectSync.Tests/EntrySyncTests.cs @@ -4,7 +4,6 @@ using MiniLcm.SyncHelpers; using MiniLcm.Tests.AutoFakerHelpers; using Soenneker.Utils.AutoBogus; -using Soenneker.Utils.AutoBogus.Config; namespace FwLiteProjectSync.Tests; @@ -29,7 +28,7 @@ public async Task CanSyncRandomEntries() ..after.Senses ])]; - await EntrySync.Sync(createdEntry, after, Api); + await EntrySync.SyncFull(createdEntry, after, Api); var actual = await Api.GetEntry(after.Id); actual.Should().NotBeNull(); actual.Should().BeEquivalentTo(after, options => options @@ -92,7 +91,7 @@ public async Task CanChangeComplexFormViaSync_Components() complexFormAfter.Components[0].ComponentEntryId = component2.Id; complexFormAfter.Components[0].ComponentHeadword = component2.Headword(); - await EntrySync.Sync(complexForm, complexFormAfter, Api); + await EntrySync.SyncFull(complexForm, complexFormAfter, Api); var actual = await Api.GetEntry(complexFormAfter.Id); actual.Should().NotBeNull(); @@ -125,7 +124,7 @@ public async Task CanChangeComplexFormViaSync_ComplexForms() componentAter.ComplexForms[0].ComplexFormEntryId = complexForm2.Id; componentAter.ComplexForms[0].ComplexFormHeadword = complexForm2.Headword(); - await EntrySync.Sync(component, componentAter, Api); + await EntrySync.SyncFull(component, componentAter, Api); var actual = await Api.GetEntry(componentAter.Id); actual.Should().NotBeNull(); @@ -140,7 +139,7 @@ public async Task CanChangeComplexFormTypeViaSync() var entry = await Api.CreateEntry(new() { LexemeForm = { { "en", "complexForm1" } } }); var after = entry.Copy(); after.ComplexFormTypes = [complexFormType]; - await EntrySync.Sync(entry, after, Api); + await EntrySync.SyncFull(entry, after, Api); var actual = await Api.GetEntry(after.Id); actual.Should().NotBeNull(); @@ -172,14 +171,14 @@ public async Task CanInsertComplexFormComponentViaSync(bool componentThenComplex // this results in 2 crdt changes: // (1) add complex-form (i.e. implicitly add component) // (2) move component to the right place - await EntrySync.Sync([newComponentBefore, complexFormBefore], [newComponentAfter, complexFormAfter], Api); + await EntrySync.SyncFull([newComponentBefore, complexFormBefore], [newComponentAfter, complexFormAfter], Api); } else { // this results in 1 crdt change: // the component is added in the right place // (adding the complex-form becomes a no-op, because it already exists and a BetweenPosition is not specified) - await EntrySync.Sync([complexFormBefore, newComponentBefore], [complexFormAfter, newComponentAfter], Api); + await EntrySync.SyncFull([complexFormBefore, newComponentBefore], [complexFormAfter, newComponentAfter], Api); } // assert @@ -210,7 +209,7 @@ public async Task CanSyncNewEntryReferencedByExistingEntry() newEntry.Components.Add(newComplexFormComponent); // act - await EntrySync.Sync([existingEntryBefore], [existingEntryAfter, newEntry], Api); + await EntrySync.SyncFull([existingEntryBefore], [existingEntryAfter, newEntry], Api); // assert var actualExistingEntry = await Api.GetEntry(existingEntryAfter.Id); @@ -224,4 +223,202 @@ public async Task CanSyncNewEntryReferencedByExistingEntry() .For(e => e.Components).Exclude(c => c.Id) .For(e => e.Components).Exclude(c => c.Order)); } + + [Fact] + public async Task CanSyncNewComplexFormComponentReferencingNewSense() + { + // arrange + // - before + var complexFormEntryBefore = await Api.CreateEntry(new() { LexemeForm = { { "en", "complex-form" } } }); + var componentEntryBefore = await Api.CreateEntry(new() { LexemeForm = { { "en", "component" } } }); + + // - after + var complexFormEntryAfter = complexFormEntryBefore.Copy(); + var componentEntryAfter = componentEntryBefore.Copy(); + var senseId = Guid.NewGuid(); + componentEntryAfter.Senses = [new Sense() { Id = senseId, EntryId = componentEntryAfter.Id }]; + + var component = ComplexFormComponent.FromEntries(complexFormEntryAfter, componentEntryAfter, senseId); + complexFormEntryAfter.Components.Add(component); + componentEntryAfter.ComplexForms.Add(component); + + // act + await EntrySync.SyncFull( + // note: the entry with the added sense is at the end of the list + [complexFormEntryBefore, componentEntryBefore], + [complexFormEntryAfter, componentEntryAfter], + Api); + + // assert + var actualComplexFormEntry = await Api.GetEntry(complexFormEntryAfter.Id); + actualComplexFormEntry.Should().BeEquivalentTo(complexFormEntryAfter, + options => SyncTests.SyncExclusions(options) + .Excluding(e => e.ComplexFormTypes) // LibLcm automatically creates a complex form type. Should we? + .WithStrictOrdering()); + + var actualComponentEntry = await Api.GetEntry(componentEntryAfter.Id); + actualComponentEntry.Should().BeEquivalentTo(componentEntryAfter, + options => SyncTests.SyncExclusions(options).WithStrictOrdering()); + } + + [Fact] + public async Task SyncWithoutComplexFormsAndComponents_CorrectlySyncsUpdatedEntries() + { + // Arrange + // - before + var componentBefore = await Api.CreateEntry(new() { LexemeForm = { { "en", "component" } } }); + + // - after + var componentAfter = componentBefore.Copy(); + componentAfter.LexemeForm["en"] = "component updated"; + var complexForm = new Entry() + { + Id = Guid.NewGuid(), + LexemeForm = { { "en", "complex form" } } + }; + var complexFormComponent = ComplexFormComponent.FromEntries(complexForm, componentAfter); + componentAfter.ComplexForms.Add(complexFormComponent); + complexForm.Components.Add(complexFormComponent); + + // act + var (changes, added) = await EntrySync.SyncWithoutComplexFormsAndComponents([componentBefore], [componentAfter, complexForm], Api); + added.Should().HaveCount(1); + var addedComplexForm = added.First(); + + // assert + var actualComponent = await Api.GetEntry(componentAfter.Id); + actualComponent.Should().BeEquivalentTo(componentAfter, + options => options.Excluding(e => e.ComplexForms)); + actualComponent.ComplexForms.Should().BeEmpty(); + + var actualComplexForm = await Api.GetEntry(complexForm.Id); + addedComplexForm.Should().BeEquivalentTo(actualComplexForm); + actualComplexForm.Should().BeEquivalentTo(complexForm, + options => options.Excluding(e => e.Components)); + actualComplexForm.Components.Should().BeEmpty(); + } + + [Fact] + public async Task SyncWithoutComplexFormsAndComponents_CorrectlySyncsAddedEntries() + { + // Arrange + // - after + var component = new Entry() + { + Id = Guid.NewGuid(), + LexemeForm = { { "en", "component" } } + }; + var complexForm = new Entry() + { + Id = Guid.NewGuid(), + LexemeForm = { { "en", "complex form" } } + }; + var complexFormComponent = ComplexFormComponent.FromEntries(complexForm, component); + component.ComplexForms.Add(complexFormComponent); + complexForm.Components.Add(complexFormComponent); + + // act + var (_, added) = await EntrySync.SyncWithoutComplexFormsAndComponents([], [component, complexForm], Api); + added.Should().HaveCount(2); + var addedComponent = added.ElementAt(0); + var addedComplexForm = added.ElementAt(1); + + // assert + var actualComponent = await Api.GetEntry(component.Id); + addedComponent.Should().BeEquivalentTo(actualComponent); + actualComponent.Should().BeEquivalentTo(component, + options => options.Excluding(e => e.ComplexForms)); + actualComponent.ComplexForms.Should().BeEmpty(); + + var actualComplexForm = await Api.GetEntry(complexForm.Id); + addedComplexForm.Should().BeEquivalentTo(actualComplexForm); + actualComplexForm.Should().BeEquivalentTo(complexForm, + options => options.Excluding(e => e.Components)); + actualComplexForm.Components.Should().BeEmpty(); + } + + [Fact] + public async Task SyncComplexFormsAndComponents_CorrectlySyncsUpdatedEntries() + { + // Arrange + // - before + var componentBefore = await Api.CreateEntry(new() { LexemeForm = { { "en", "component" } } }); + var complexFormBefore = await Api.CreateEntry(new() { LexemeForm = { { "en", "complex form" } } }); + + // - after + var componentAfter = componentBefore.Copy(); + componentAfter.LexemeForm["en"] = "component updated"; + var complexFormAfter = complexFormBefore.Copy(); + complexFormAfter.LexemeForm["en"] = "complex form updated"; + var complexFormComponent = ComplexFormComponent.FromEntries(complexFormAfter, componentAfter); + componentAfter.ComplexForms.Add(complexFormComponent); + complexFormAfter.Components.Add(complexFormComponent); + + // act + await EntrySync.SyncComplexFormsAndComponents([componentBefore, complexFormBefore], [componentAfter, complexFormAfter], Api); + + // assert + var actualComponent = await Api.GetEntry(componentAfter.Id); + actualComponent.Should().NotBeNull(); + + // complex forms were synced + actualComponent.ComplexForms.Should().NotBeEmpty(); + actualComponent.ComplexForms.Should().BeEquivalentTo(componentAfter.ComplexForms, options + => options.Excluding(c => c.Id) + .Excluding(c => c.Order) + // The lexeme-form/headword wasn't synced so it doesn't match the "after" version + .Excluding(c => c.ComplexFormHeadword) + .Excluding(c => c.ComponentHeadword)); + + var actualComplexForm = await Api.GetEntry(complexFormAfter.Id); + actualComplexForm.Should().NotBeNull(); + // components were synced + actualComplexForm.Components.Should().NotBeEmpty(); + actualComplexForm.Components.Should().BeEquivalentTo(complexFormAfter.Components, options + => options.Excluding(c => c.Id) + .Excluding(c => c.Order) + // The lexeme-form/headword wasn't synced so it doesn't match the "after" version + .Excluding(c => c.ComplexFormHeadword) + .Excluding(c => c.ComponentHeadword)); + + // Lexeme form was not synced + actualComponent.LexemeForm.Should().BeEquivalentTo(componentBefore.LexemeForm); + actualComponent.LexemeForm.Should().NotBeEquivalentTo(componentAfter.LexemeForm); + actualComplexForm.LexemeForm.Should().BeEquivalentTo(complexFormBefore.LexemeForm); + actualComplexForm.LexemeForm.Should().NotBeEquivalentTo(complexFormAfter.LexemeForm); + } + + [Fact] + public async Task SyncComplexFormsAndComponents_ThrowsExceptionIfEntryNotInBefore() + { + // Arrange + var component = new Entry() { Id = Guid.NewGuid() }; + var complexForm = new Entry() { Id = Guid.NewGuid() }; + var complexFormComponent = ComplexFormComponent.FromEntries(complexForm, component); + component.ComplexForms.Add(complexFormComponent); + complexForm.Components.Add(complexFormComponent); + + // Act + var act = () => EntrySync.SyncComplexFormsAndComponents([], [component, complexForm], Api); + + // Assert + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task SyncComplexFormsAndComponents_ThrowsExceptionIfEntryNotInAfter() + { + // Arrange + var component = new Entry() { Id = Guid.NewGuid() }; + var complexForm = new Entry() { Id = Guid.NewGuid() }; + var complexFormComponent = ComplexFormComponent.FromEntries(complexForm, component); + component.ComplexForms.Add(complexFormComponent); + complexForm.Components.Add(complexFormComponent); + + // Act + var act = () => EntrySync.SyncComplexFormsAndComponents([component, complexForm], [], Api); + + // Assert + await act.Should().ThrowAsync(); + } } diff --git a/backend/FwLite/FwLiteProjectSync.Tests/Import/ResumableTests.cs b/backend/FwLite/FwLiteProjectSync.Tests/Import/ResumableTests.cs index 541797d28c..1a97ac32f3 100644 --- a/backend/FwLite/FwLiteProjectSync.Tests/Import/ResumableTests.cs +++ b/backend/FwLite/FwLiteProjectSync.Tests/Import/ResumableTests.cs @@ -158,10 +158,10 @@ Task IMiniLcmWriteApi.CreatePartOfSpeech(PartOfSpeech partOfSpeech return _api.CreatePartOfSpeech(partOfSpeech); } - Task IMiniLcmWriteApi.CreateEntry(Entry entry) + Task IMiniLcmWriteApi.CreateEntry(Entry entry, CreateEntryOptions? options) { ResumableTests.MaybeThrowRandom(random, 0.2); - return _api.CreateEntry(entry); + return _api.CreateEntry(entry, options); } async Task IMiniLcmWriteApi.BulkCreateEntries(IAsyncEnumerable entries) diff --git a/backend/FwLite/FwLiteProjectSync/CrdtFwdataProjectSyncService.cs b/backend/FwLite/FwLiteProjectSync/CrdtFwdataProjectSyncService.cs index f8de177712..935420980f 100644 --- a/backend/FwLite/FwLiteProjectSync/CrdtFwdataProjectSyncService.cs +++ b/backend/FwLite/FwLiteProjectSync/CrdtFwdataProjectSyncService.cs @@ -89,10 +89,10 @@ private async Task Sync(IMiniLcmApi crdtApi, IMiniLcmApi fwdataApi, fwdataChanges += await ComplexFormTypeSync.Sync(currentFwDataComplexFormTypes, await crdtApi.GetComplexFormTypes().ToArrayAsync(), fwdataApi); var currentFwDataEntries = await fwdataApi.GetAllEntries().ToArrayAsync(); - crdtChanges += await EntrySync.Sync(projectSnapshot.Entries, currentFwDataEntries, crdtApi); + crdtChanges += await EntrySync.SyncFull(projectSnapshot.Entries, currentFwDataEntries, crdtApi); LogDryRun(crdtApi, "crdt"); - fwdataChanges += await EntrySync.Sync(currentFwDataEntries, await crdtApi.GetAllEntries().ToArrayAsync(), fwdataApi); + fwdataChanges += await EntrySync.SyncFull(currentFwDataEntries, await crdtApi.GetAllEntries().ToArrayAsync(), fwdataApi); LogDryRun(fwdataApi, "fwdata"); //todo push crdt changes to lexbox diff --git a/backend/FwLite/FwLiteProjectSync/DryRunMiniLcmApi.cs b/backend/FwLite/FwLiteProjectSync/DryRunMiniLcmApi.cs index 13ecf6b773..df2474da53 100644 --- a/backend/FwLite/FwLiteProjectSync/DryRunMiniLcmApi.cs +++ b/backend/FwLite/FwLiteProjectSync/DryRunMiniLcmApi.cs @@ -151,10 +151,15 @@ public Task DeleteMorphTypeData(Guid id) return Task.CompletedTask; } - public Task CreateEntry(Entry entry) - { - DryRunRecords.Add(new DryRunRecord(nameof(CreateEntry), $"Create entry {entry.Headword()}")); - return Task.FromResult(entry); + public Task CreateEntry(Entry entry, CreateEntryOptions? options) + { + options ??= new CreateEntryOptions(); + DryRunRecords.Add(new DryRunRecord(nameof(CreateEntry), $"Create entry {entry.Headword()} ({options})")); + // Only return what would have been persisted + if (options.IncludeComplexFormsAndComponents) + return Task.FromResult(entry); + else + return Task.FromResult(entry with { Components = [], ComplexForms = [] }); } public Task UpdateEntry(Guid id, UpdateObjectInput update) diff --git a/backend/FwLite/FwLiteProjectSync/Import/ResumableImportApi.cs b/backend/FwLite/FwLiteProjectSync/Import/ResumableImportApi.cs index 59a2c396e5..ef31b7ba40 100644 --- a/backend/FwLite/FwLiteProjectSync/Import/ResumableImportApi.cs +++ b/backend/FwLite/FwLiteProjectSync/Import/ResumableImportApi.cs @@ -45,9 +45,9 @@ private async ValueTask> EnsureCached(string typeName // ********** Overrides go here ********** - async Task IMiniLcmWriteApi.CreateEntry(Entry entry) + async Task IMiniLcmWriteApi.CreateEntry(Entry entry, CreateEntryOptions? options) { - return await HasCreated(entry, _api.GetAllEntries(), () => _api.CreateEntry(entry)); + return await HasCreated(entry, _api.GetAllEntries(), () => _api.CreateEntry(entry, options)); } async Task IMiniLcmWriteApi.CreatePartOfSpeech(PartOfSpeech partOfSpeech) diff --git a/backend/FwLite/FwLiteShared/Services/MiniLcmApiNotifyWrapper.cs b/backend/FwLite/FwLiteShared/Services/MiniLcmApiNotifyWrapper.cs index 9e52509387..9f5c01198f 100644 --- a/backend/FwLite/FwLiteShared/Services/MiniLcmApiNotifyWrapper.cs +++ b/backend/FwLite/FwLiteShared/Services/MiniLcmApiNotifyWrapper.cs @@ -80,10 +80,10 @@ public void NotifyEntryDeleted(Guid entryId) // ********** Overrides go here ********** - async Task IMiniLcmWriteApi.CreateEntry(Entry entry) + async Task IMiniLcmWriteApi.CreateEntry(Entry entry, CreateEntryOptions? options) { await using var _ = BeginTrackingChanges(); - var result = await _api.CreateEntry(entry); + var result = await _api.CreateEntry(entry, options); NotifyEntryChanged(result); return result; } diff --git a/backend/FwLite/LcmCrdt.Tests/Changes/UseChangesTests.cs b/backend/FwLite/LcmCrdt.Tests/Changes/UseChangesTests.cs index 828103e9f2..d6d5553512 100644 --- a/backend/FwLite/LcmCrdt.Tests/Changes/UseChangesTests.cs +++ b/backend/FwLite/LcmCrdt.Tests/Changes/UseChangesTests.cs @@ -75,7 +75,7 @@ public async Task CanSyncAllChangesWithDuplicates() await fixture.DataModel.AddChange(Guid.NewGuid(), duplicateChange); var allEntries = await fixture.Api.GetEntries().ToArrayAsync(); - var result = await EntrySync.Sync(allEntries, allEntries, fixture.Api); + var result = await EntrySync.SyncFull(allEntries, allEntries, fixture.Api); result.Should().Be(0); committedChanges.Add(change); diff --git a/backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs b/backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs index 2105c317f7..c3414fa4ef 100644 --- a/backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs +++ b/backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs @@ -482,8 +482,9 @@ private IEnumerable CreateEntryChanges(Entry entry, } } - public async Task CreateEntry(Entry entry) + public async Task CreateEntry(Entry entry, CreateEntryOptions? options = null) { + options ??= CreateEntryOptions.Everything; await using var repo = await repoFactory.CreateRepoAsync(); await AddChanges([ new CreateEntryChange(entry), @@ -495,8 +496,12 @@ ..await entry.Senses.ToAsyncEnumerable() }) .ToArrayAsync(), ..await ToPublications(entry.PublishIn).ToArrayAsync(), - ..await ToComplexFormComponents(entry.Components).ToArrayAsync(), - ..await ToComplexFormComponents(entry.ComplexForms).ToArrayAsync(), + ..options.IncludeComplexFormsAndComponents ? + await ToComplexFormComponents(entry.Components).ToArrayAsync() : + Enumerable.Empty(), + ..options.IncludeComplexFormsAndComponents ? + await ToComplexFormComponents(entry.ComplexForms).ToArrayAsync() : + Enumerable.Empty(), ..await ToComplexFormTypes(entry.ComplexFormTypes).ToArrayAsync() ]); return await repo.GetEntry(entry.Id) ?? throw new NullReferenceException(); @@ -599,7 +604,7 @@ public async Task UpdateEntry(Guid id, public async Task UpdateEntry(Entry before, Entry after, IMiniLcmApi? api = null) { - await EntrySync.Sync(before, after, api ?? this); + await EntrySync.SyncFull(before, after, api ?? this); var updatedEntry = await GetEntry(after.Id) ?? throw new NullReferenceException("unable to find entry with id " + after.Id); return updatedEntry; } diff --git a/backend/FwLite/MiniLcm.Tests/DiffCollectionTests.cs b/backend/FwLite/MiniLcm.Tests/DiffCollectionTests.cs index c6c263a099..bff2618d45 100644 --- a/backend/FwLite/MiniLcm.Tests/DiffCollectionTests.cs +++ b/backend/FwLite/MiniLcm.Tests/DiffCollectionTests.cs @@ -1,6 +1,5 @@ using System.Runtime.CompilerServices; using MiniLcm.SyncHelpers; -using Moq; namespace MiniLcm.Tests; @@ -205,7 +204,7 @@ public async Task Diff_CallsAddForNewRecords() { var entry = new Entry(Guid.NewGuid(), "test"); await DiffCollection.Diff([], [entry], _fakeApi); - _fakeApi.VerifyCalls(new FakeDiffApi.MethodCall(entry, nameof(FakeDiffApi.Add))); + _fakeApi.VerifyCalls(new FakeDiffApi.MethodCall(entry, nameof(FakeDiffApi.AddAndGet))); } [Fact] @@ -225,60 +224,21 @@ public async Task Diff_CallsReplaceForMatchingRecords() _fakeApi.VerifyCalls(new FakeDiffApi.MethodCall((entry, updated), nameof(FakeDiffApi.Replace))); } - [Fact] - public async Task Diff_AddThenUpdate_CallsAddForNewRecords() - { - var entry = new Entry(Guid.NewGuid(), "test"); - await DiffCollection.DiffAddThenUpdate([], [entry], _fakeApi); - _fakeApi.VerifyCalls( - new FakeDiffApi.MethodCall(entry, nameof(FakeDiffApi.AddAndGet)), - new FakeDiffApi.MethodCall((entry, entry), nameof(FakeDiffApi.Replace)) - ); - } - - [Fact] - public async Task DiffAddThenUpdate_CallsRemoveForMissingRecords() - { - var entry = new Entry(Guid.NewGuid(), "test"); - await DiffCollection.DiffAddThenUpdate([entry], [], _fakeApi); - _fakeApi.VerifyCalls(new FakeDiffApi.MethodCall(entry, nameof(FakeDiffApi.Remove))); - } - - [Fact] - public async Task DiffAddThenUpdate_CallsReplaceForMatchingRecords() - { - var entry = new Entry(Guid.NewGuid(), "test"); - var updated = entry with { Word = "new" }; - await DiffCollection.DiffAddThenUpdate([entry], [updated], _fakeApi); - _fakeApi.VerifyCalls(new FakeDiffApi.MethodCall((entry, updated), nameof(FakeDiffApi.Replace))); - } - - [Fact] - public async Task DiffAddThenUpdate_AddAlwaysBeforeReplace() - { - var newEntry = new Entry(Guid.NewGuid(), "new"); - var oldEntry = new Entry(Guid.NewGuid(), "test"); - var updated = oldEntry with { Word = "new" }; - await DiffCollection.DiffAddThenUpdate([oldEntry], [updated, newEntry], _fakeApi); - //this order is required because the new entry must be created before the updated entry is modified. - //the updated entry might reference the newEntry and so must be updated after the new entry is created. - //the order that the replace calls are made is unimportant. - _fakeApi.VerifyCalls( - new FakeDiffApi.MethodCall(newEntry, nameof(FakeDiffApi.AddAndGet)), - new FakeDiffApi.MethodCall((oldEntry, updated), nameof(FakeDiffApi.Replace)), - new FakeDiffApi.MethodCall((newEntry, newEntry), nameof(FakeDiffApi.Replace)) - ); - } - private class FakeDiffApi: CollectionDiffApi { public record MethodCall(object Args, [CallerMemberName] string Name = ""); public List Calls { get; set; } = []; - public override Task Add(Entry value) + public override Task<(int Changes, Entry Added)> AddAndGet(Entry value) { Calls.Add(new(value)); - return Task.FromResult(1); + return Task.FromResult((1, value)); + } + + public override Task Add(Entry value) + { + throw new InvalidOperationException( + $"{nameof(Add)} should never be called, because {nameof(AddAndGet)} is implemented"); } public override Task Remove(Entry value) @@ -293,12 +253,6 @@ public override Task Replace(Entry before, Entry after) return Task.FromResult(1); } - public override Task<(int, Entry)> AddAndGet(Entry value) - { - Calls.Add(new(value)); - return Task.FromResult((1, value)); - } - public override Guid GetId(Entry value) { return value.Id; diff --git a/backend/FwLite/MiniLcm/CreateEntryOptions.cs b/backend/FwLite/MiniLcm/CreateEntryOptions.cs new file mode 100644 index 0000000000..2e90e0becc --- /dev/null +++ b/backend/FwLite/MiniLcm/CreateEntryOptions.cs @@ -0,0 +1,13 @@ +namespace MiniLcm; + +public record CreateEntryOptions( + /// + /// Can be excluded for the purpose of deferring referencing entities that might not exist yet. + /// + bool IncludeComplexFormsAndComponents = true +) +{ + public static readonly CreateEntryOptions Everything = new(); + public static readonly CreateEntryOptions WithoutComplexFormsAndComponents + = new(IncludeComplexFormsAndComponents: false); +} diff --git a/backend/FwLite/MiniLcm/IMiniLcmWriteApi.cs b/backend/FwLite/MiniLcm/IMiniLcmWriteApi.cs index bdc1497876..cbf3c2ba85 100644 --- a/backend/FwLite/MiniLcm/IMiniLcmWriteApi.cs +++ b/backend/FwLite/MiniLcm/IMiniLcmWriteApi.cs @@ -52,7 +52,7 @@ Task UpdateWritingSystem(WritingSystemId id, #endregion #region Entry - Task CreateEntry(Entry entry); + Task CreateEntry(Entry entry, CreateEntryOptions? options = null); Task UpdateEntry(Guid id, UpdateObjectInput update); Task UpdateEntry(Entry before, Entry after, IMiniLcmApi? api = null); diff --git a/backend/FwLite/MiniLcm/Models/Entry.cs b/backend/FwLite/MiniLcm/Models/Entry.cs index 00b2f303e7..b3bf75fe00 100644 --- a/backend/FwLite/MiniLcm/Models/Entry.cs +++ b/backend/FwLite/MiniLcm/Models/Entry.cs @@ -76,11 +76,6 @@ public Guid[] GetReferences() public void RemoveReference(Guid id, DateTimeOffset time) { } - - public Entry WithoutEntryRefs() - { - return this with { Components = [], ComplexForms = [] }; - } } public class Variants diff --git a/backend/FwLite/MiniLcm/SyncHelpers/DiffCollection.cs b/backend/FwLite/MiniLcm/SyncHelpers/DiffCollection.cs index 2138a039c9..c305136dea 100644 --- a/backend/FwLite/MiniLcm/SyncHelpers/DiffCollection.cs +++ b/backend/FwLite/MiniLcm/SyncHelpers/DiffCollection.cs @@ -9,12 +9,16 @@ namespace MiniLcm.SyncHelpers; public abstract class CollectionDiffApi where TId : notnull { - public abstract Task Add(T value); - public virtual async Task<(int, T)> AddAndGet(T value) + public virtual async Task<(int Changes, T Added)> AddAndGet(T value) { var changes = await Add(value); return (changes, value); } + // Can be implemented instead of AddAndGet for simpler DX + public virtual Task Add(T value) + { + throw new NotImplementedException(); + } public abstract Task Remove(T value); public abstract Task Replace(T before, T after); public abstract TId GetId(T value); @@ -28,6 +32,24 @@ public override Guid GetId(T value) } } +public class ObjectWithIdCollectionReplaceDiffApi(Func> ReplaceFunc) : ObjectWithIdCollectionDiffApi where T : IObjectWithId +{ + public override Task<(int, T)> AddAndGet(T value) + { + throw new InvalidOperationException($"{nameof(AddAndGet)} should never be called"); + } + + public override Task Remove(T value) + { + throw new InvalidOperationException($"{nameof(Remove)} should never be called"); + } + + public override async Task Replace(T before, T after) + { + return await ReplaceFunc(before, after); + } +} + public interface IOrderableCollectionDiffApi where T : IOrderable { Task Add(T value, BetweenPosition between); @@ -43,73 +65,44 @@ Guid GetId(T value) public static class DiffCollection { - /// - /// Diffs a list, for new items calls add, it will then call update for the item returned from the add, using that as the before item for the replace call - /// - public static async Task DiffAddThenUpdate( + public static async Task<(int Changes, ICollection Added)> DiffAndGetAdded( IList before, IList after, CollectionDiffApi diffApi) where TId : notnull { var changes = 0; - - var beforeEntriesDict = before.ToDictionary(diffApi.GetId); - - var postAddUpdates = new List<(T created, T after)>(after.Count); - foreach (var afterEntry in after) + var afterEntriesDict = after.ToDictionary(diffApi.GetId); + foreach (var beforeEntry in before) { - if (beforeEntriesDict.Remove(diffApi.GetId(afterEntry), out var beforeEntry)) + if (afterEntriesDict.TryGetValue(diffApi.GetId(beforeEntry), out var afterEntry)) { - postAddUpdates.Add((beforeEntry, afterEntry)); // defer updating existing entry + changes += await diffApi.Replace(beforeEntry, afterEntry); } else { - var (change, created) = await diffApi.AddAndGet(afterEntry); // create new entry - changes += change; - postAddUpdates.Add((created, afterEntry)); // defer updating new entry + changes += await diffApi.Remove(beforeEntry); } - } - foreach ((var createdItem, var afterItem) in postAddUpdates) - { - //todo this may do a lot more work than it needs to, eg sense will be created during add, but they will be checked again here when we know they didn't change - changes += await diffApi.Replace(createdItem, afterItem); + afterEntriesDict.Remove(diffApi.GetId(beforeEntry)); } - foreach (var beforeEntry in beforeEntriesDict.Values) + foreach (var (id, value) in afterEntriesDict) { - changes += await diffApi.Remove(beforeEntry); + var (addChanges, added) = await diffApi.AddAndGet(value); + changes += addChanges; + afterEntriesDict[id] = added; } - return changes; + return (changes, afterEntriesDict.Values); } + public static async Task Diff( IList before, IList after, CollectionDiffApi diffApi) where TId : notnull { - var changes = 0; - var afterEntriesDict = after.ToDictionary(diffApi.GetId); - foreach (var beforeEntry in before) - { - if (afterEntriesDict.TryGetValue(diffApi.GetId(beforeEntry), out var afterEntry)) - { - changes += await diffApi.Replace(beforeEntry, afterEntry); - } - else - { - changes += await diffApi.Remove(beforeEntry); - } - - afterEntriesDict.Remove(diffApi.GetId(beforeEntry)); - } - - foreach (var value in afterEntriesDict.Values) - { - changes += await diffApi.Add(value); - } - + var (changes, _) = await DiffAndGetAdded(before, after, diffApi); return changes; } diff --git a/backend/FwLite/MiniLcm/SyncHelpers/EntrySync.cs b/backend/FwLite/MiniLcm/SyncHelpers/EntrySync.cs index dc67303d6f..59c67c2302 100644 --- a/backend/FwLite/MiniLcm/SyncHelpers/EntrySync.cs +++ b/backend/FwLite/MiniLcm/SyncHelpers/EntrySync.cs @@ -7,23 +7,50 @@ namespace MiniLcm.SyncHelpers; public static class EntrySync { - public static async Task Sync(Entry[] beforeEntries, + public static async Task SyncFull(Entry[] beforeEntries, Entry[] afterEntries, IMiniLcmApi api) { - return await DiffCollection.DiffAddThenUpdate(beforeEntries, afterEntries, new EntriesDiffApi(api)); + var (changes, addedEntries) = await SyncWithoutComplexFormsAndComponents(beforeEntries, afterEntries, api); + var updatedBeforeEntries = beforeEntries.Where(before => afterEntries.Any(after => after.Id == before.Id)); + changes += await SyncComplexFormsAndComponents([.. updatedBeforeEntries, .. addedEntries], afterEntries, api); + return changes; } - public static async Task Sync(Entry beforeEntry, Entry afterEntry, IMiniLcmApi api) + public static async Task<(int Changes, ICollection Added)> SyncWithoutComplexFormsAndComponents(Entry[] beforeEntries, + Entry[] afterEntries, + IMiniLcmApi api) + { + return await DiffCollection.DiffAndGetAdded(beforeEntries, afterEntries, new EntriesDiffApi(api)); + } + + /// + /// Syncs only the complex forms and components of the before and after entries. + /// When the before and after entries do not match. + /// + public static async Task SyncComplexFormsAndComponents(Entry[] beforeEntries, + Entry[] afterEntries, + IMiniLcmApi api) + { + return await DiffCollection.Diff(beforeEntries, afterEntries, + new ObjectWithIdCollectionReplaceDiffApi( + (before, after) => SyncComplexFormsAndComponents(before, after, api))); + } + + public static async Task SyncFull(Entry beforeEntry, Entry afterEntry, IMiniLcmApi api) + { + var changes = await SyncWithoutComplexFormsAndComponents(beforeEntry, afterEntry, api); + changes += await SyncComplexFormsAndComponents(beforeEntry, afterEntry, api); + return changes; + } + + public static async Task SyncWithoutComplexFormsAndComponents(Entry beforeEntry, Entry afterEntry, IMiniLcmApi api) { try { var updateObjectInput = EntryDiffToUpdate(beforeEntry, afterEntry); if (updateObjectInput is not null) await api.UpdateEntry(afterEntry.Id, updateObjectInput); var changes = await SensesSync(afterEntry.Id, beforeEntry.Senses, afterEntry.Senses, api); - - changes += await SyncComplexFormComponents(afterEntry, beforeEntry.Components, afterEntry.Components, api); - changes += await SyncComplexForms(beforeEntry.ComplexForms, afterEntry.ComplexForms, api); changes += await Sync(afterEntry.Id, beforeEntry.ComplexFormTypes, afterEntry.ComplexFormTypes, api); changes += await SyncPublications(afterEntry.Id, beforeEntry.PublishIn, afterEntry.PublishIn, api); return changes + (updateObjectInput is null ? 0 : 1); @@ -34,6 +61,21 @@ public static async Task Sync(Entry beforeEntry, Entry afterEntry, IMiniLcm } } + public static async Task SyncComplexFormsAndComponents(Entry beforeEntry, Entry afterEntry, IMiniLcmApi api) + { + try + { + var changes = 0; + changes += await SyncComplexFormComponents(afterEntry, beforeEntry.Components, afterEntry.Components, api); + changes += await SyncComplexForms(beforeEntry.ComplexForms, afterEntry.ComplexForms, api); + return changes; + } + catch (Exception e) + { + throw new SyncObjectException($"Failed to sync complex forms and components of entry {afterEntry}", e); + } + } + private static async Task SyncPublications(Guid entryId, IList beforePublications, IList afterPublications, @@ -99,17 +141,8 @@ private class EntriesDiffApi(IMiniLcmApi api) : ObjectWithIdCollectionDiffApi AddAndGet(Entry afterEntry) { - //create each entry without components. - //After each entry is created, then replace will be called to create those components - var entryWithoutEntryRefs = afterEntry.WithoutEntryRefs(); - var changes = await Add(entryWithoutEntryRefs); - return (changes, entryWithoutEntryRefs); - } - - public override async Task Add(Entry afterEntry) - { - await api.CreateEntry(afterEntry); - return 1; + var addedEntry = await api.CreateEntry(afterEntry, CreateEntryOptions.WithoutComplexFormsAndComponents); + return (1, addedEntry); } public override async Task Remove(Entry entry) @@ -120,7 +153,7 @@ public override async Task Remove(Entry entry) public override Task Replace(Entry before, Entry after) { - return Sync(before, after, api); + return SyncWithoutComplexFormsAndComponents(before, after, api); } }