From 8d081b4dc9e2d4142aa6f401661a343e477ce91b Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Wed, 20 Aug 2025 20:15:26 +0200 Subject: [PATCH 1/8] Ensure senses are created before adding complex-form-components --- .../FwLiteProjectSync.Tests/EntrySyncTests.cs | 37 +++++++++++++++++++ .../MiniLcm.Tests/DiffCollectionTests.cs | 27 +++++++++----- backend/FwLite/MiniLcm/Models/Entry.cs | 5 +++ .../MiniLcm/SyncHelpers/DiffCollection.cs | 17 +++++++-- .../FwLite/MiniLcm/SyncHelpers/EntrySync.cs | 16 +++++++- 5 files changed, 87 insertions(+), 15 deletions(-) diff --git a/backend/FwLite/FwLiteProjectSync.Tests/EntrySyncTests.cs b/backend/FwLite/FwLiteProjectSync.Tests/EntrySyncTests.cs index 72a9e35099..f445c3e689 100644 --- a/backend/FwLite/FwLiteProjectSync.Tests/EntrySyncTests.cs +++ b/backend/FwLite/FwLiteProjectSync.Tests/EntrySyncTests.cs @@ -224,4 +224,41 @@ 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.Sync( + // 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()); + } } diff --git a/backend/FwLite/MiniLcm.Tests/DiffCollectionTests.cs b/backend/FwLite/MiniLcm.Tests/DiffCollectionTests.cs index c6c263a099..91e68cea16 100644 --- a/backend/FwLite/MiniLcm.Tests/DiffCollectionTests.cs +++ b/backend/FwLite/MiniLcm.Tests/DiffCollectionTests.cs @@ -231,7 +231,7 @@ 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, nameof(FakeDiffApi.AddWithoutReferencesAndGet)), new FakeDiffApi.MethodCall((entry, entry), nameof(FakeDiffApi.Replace)) ); } @@ -250,22 +250,25 @@ 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))); + _fakeApi.VerifyCalls( + new FakeDiffApi.MethodCall((entry, updated), nameof(FakeDiffApi.ReplaceWithoutReferencesAndGet)), + new FakeDiffApi.MethodCall((updated, updated), nameof(FakeDiffApi.Replace))); } [Fact] - public async Task DiffAddThenUpdate_AddAlwaysBeforeReplace() + public async Task DiffAddThenUpdate_WithoutReferencesAlwaysFirst() { 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. + //this order is required because new entries (and/or new senses etc.) must be created first + //updated entries might reference the newEntry (or a new sense) and so must be updated after the new entry is created. + //the order of the "simple" Replace calls is unimportant. _fakeApi.VerifyCalls( - new FakeDiffApi.MethodCall(newEntry, nameof(FakeDiffApi.AddAndGet)), - new FakeDiffApi.MethodCall((oldEntry, updated), nameof(FakeDiffApi.Replace)), + new FakeDiffApi.MethodCall((oldEntry, updated), nameof(FakeDiffApi.ReplaceWithoutReferencesAndGet)), + new FakeDiffApi.MethodCall(newEntry, nameof(FakeDiffApi.AddWithoutReferencesAndGet)), + new FakeDiffApi.MethodCall((updated, updated), nameof(FakeDiffApi.Replace)), new FakeDiffApi.MethodCall((newEntry, newEntry), nameof(FakeDiffApi.Replace)) ); } @@ -293,12 +296,18 @@ public override Task Replace(Entry before, Entry after) return Task.FromResult(1); } - public override Task<(int, Entry)> AddAndGet(Entry value) + public override Task<(int, Entry)> AddWithoutReferencesAndGet(Entry value) { Calls.Add(new(value)); return Task.FromResult((1, value)); } + public override Task<(int, Entry)> ReplaceWithoutReferencesAndGet(Entry before, Entry after) + { + Calls.Add(new((before, after))); + return Task.FromResult((1, after)); + } + public override Guid GetId(Entry value) { return value.Id; diff --git a/backend/FwLite/MiniLcm/Models/Entry.cs b/backend/FwLite/MiniLcm/Models/Entry.cs index 00b2f303e7..e1fd21b2cc 100644 --- a/backend/FwLite/MiniLcm/Models/Entry.cs +++ b/backend/FwLite/MiniLcm/Models/Entry.cs @@ -81,6 +81,11 @@ public Entry WithoutEntryRefs() { return this with { Components = [], ComplexForms = [] }; } + + public Entry WithEntryRefsFrom(Entry from) + { + return this with { Components = from.Components, ComplexForms = from.ComplexForms }; + } } public class Variants diff --git a/backend/FwLite/MiniLcm/SyncHelpers/DiffCollection.cs b/backend/FwLite/MiniLcm/SyncHelpers/DiffCollection.cs index 2138a039c9..816d068724 100644 --- a/backend/FwLite/MiniLcm/SyncHelpers/DiffCollection.cs +++ b/backend/FwLite/MiniLcm/SyncHelpers/DiffCollection.cs @@ -10,11 +10,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, T)> AddWithoutReferencesAndGet(T value) { var changes = await Add(value); return (changes, value); } + public virtual async Task<(int, T)> ReplaceWithoutReferencesAndGet(T before, T after) + { + var changes = await Replace(before, after); + return (changes, after); + } public abstract Task Remove(T value); public abstract Task Replace(T before, T after); public abstract TId GetId(T value); @@ -60,13 +65,17 @@ public static async Task DiffAddThenUpdate( { if (beforeEntriesDict.Remove(diffApi.GetId(afterEntry), out var beforeEntry)) { - postAddUpdates.Add((beforeEntry, afterEntry)); // defer updating existing entry + // ensure all children that might be referenced are created + var (changed, replacedEntry) = await diffApi.ReplaceWithoutReferencesAndGet(beforeEntry, afterEntry); + changes += changed; + postAddUpdates.Add((replacedEntry, afterEntry)); // defer full update } else { - var (change, created) = await diffApi.AddAndGet(afterEntry); // create new entry + // create new entry with children that might be referenced + var (change, created) = await diffApi.AddWithoutReferencesAndGet(afterEntry); changes += change; - postAddUpdates.Add((created, afterEntry)); // defer updating new entry + postAddUpdates.Add((created, afterEntry)); // defer full update } } diff --git a/backend/FwLite/MiniLcm/SyncHelpers/EntrySync.cs b/backend/FwLite/MiniLcm/SyncHelpers/EntrySync.cs index dc67303d6f..2e0646d0e0 100644 --- a/backend/FwLite/MiniLcm/SyncHelpers/EntrySync.cs +++ b/backend/FwLite/MiniLcm/SyncHelpers/EntrySync.cs @@ -97,15 +97,27 @@ private static async Task SensesSync(Guid entryId, private class EntriesDiffApi(IMiniLcmApi api) : ObjectWithIdCollectionDiffApi { - public override async Task<(int, Entry)> AddAndGet(Entry afterEntry) + public override async Task<(int, Entry)> AddWithoutReferencesAndGet(Entry afterEntry) { - //create each entry without components. + //create each entry (and its hierarchy: senses, example sentence etc.) in isolation (e.g. 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<(int, Entry)> ReplaceWithoutReferencesAndGet(Entry beforeEntry, Entry afterEntry) + { + //same as AddAndGet, but for already existing entries, because they + //might have new entities (e.g. senses) in their hierarchy that other entries reference + var beforeEntryWithoutEntryRefs = beforeEntry.WithoutEntryRefs(); + var afterEntryWithoutEntryRefs = afterEntry.WithoutEntryRefs(); + var changes = await Sync(beforeEntryWithoutEntryRefs, afterEntryWithoutEntryRefs, api); + //We've synced everything except the refs + var updatedBeforeEntry = afterEntry.WithEntryRefsFrom(beforeEntry); + return (changes, updatedBeforeEntry); + } + public override async Task Add(Entry afterEntry) { await api.CreateEntry(afterEntry); From 5bda78ba2a6a03bf7e4718c274c676a8341b735b Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Thu, 21 Aug 2025 10:04:34 +0200 Subject: [PATCH 2/8] Add CreateEntryOptions to CreateEntry --- .../Api/FwDataMiniLcmApi.cs | 22 +++++++++++-------- .../Import/ResumableTests.cs | 4 ++-- .../FwLiteProjectSync/DryRunMiniLcmApi.cs | 4 ++-- .../Import/ResumableImportApi.cs | 4 ++-- .../Services/MiniLcmApiNotifyWrapper.cs | 4 ++-- backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs | 11 +++++++--- backend/FwLite/MiniLcm/CreateEntryOptions.cs | 13 +++++++++++ backend/FwLite/MiniLcm/IMiniLcmWriteApi.cs | 2 +- 8 files changed, 43 insertions(+), 21 deletions(-) create mode 100644 backend/FwLite/MiniLcm/CreateEntryOptions.cs diff --git a/backend/FwLite/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs b/backend/FwLite/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs index f7e359044b..d6a2316ef5 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) { + 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)); 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/DryRunMiniLcmApi.cs b/backend/FwLite/FwLiteProjectSync/DryRunMiniLcmApi.cs index 13ecf6b773..d62bf6443c 100644 --- a/backend/FwLite/FwLiteProjectSync/DryRunMiniLcmApi.cs +++ b/backend/FwLite/FwLiteProjectSync/DryRunMiniLcmApi.cs @@ -151,9 +151,9 @@ public Task DeleteMorphTypeData(Guid id) return Task.CompletedTask; } - public Task CreateEntry(Entry entry) + public Task CreateEntry(Entry entry, CreateEntryOptions? options) { - DryRunRecords.Add(new DryRunRecord(nameof(CreateEntry), $"Create entry {entry.Headword()}")); + DryRunRecords.Add(new DryRunRecord(nameof(CreateEntry), $"Create entry {entry.Headword()} ({options})")); return Task.FromResult(entry); } 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/CrdtMiniLcmApi.cs b/backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs index 2105c317f7..d379ba433d 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) { + 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(); 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); From e72db88500b09c78f5bd8dafbce939e5f4baa2a3 Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Thu, 21 Aug 2025 10:29:10 +0200 Subject: [PATCH 3/8] Sync entries without complex-forms and components first --- .../Api/FwDataMiniLcmApi.cs | 2 +- .../FwLiteProjectSync.Tests/EntrySyncTests.cs | 16 ++-- .../CrdtFwdataProjectSyncService.cs | 4 +- .../LcmCrdt.Tests/Changes/UseChangesTests.cs | 2 +- backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs | 2 +- .../MiniLcm.Tests/DiffCollectionTests.cs | 61 --------------- .../MiniLcm/SyncHelpers/DiffCollection.cs | 75 +++++-------------- .../FwLite/MiniLcm/SyncHelpers/EntrySync.cs | 74 +++++++++++------- 8 files changed, 78 insertions(+), 158 deletions(-) diff --git a/backend/FwLite/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs b/backend/FwLite/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs index d6a2316ef5..243c34bcab 100644 --- a/backend/FwLite/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs +++ b/backend/FwLite/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs @@ -1291,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 f445c3e689..9a6a25d2bf 100644 --- a/backend/FwLite/FwLiteProjectSync.Tests/EntrySyncTests.cs +++ b/backend/FwLite/FwLiteProjectSync.Tests/EntrySyncTests.cs @@ -29,7 +29,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 +92,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 +125,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 +140,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 +172,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 +210,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); @@ -244,7 +244,7 @@ public async Task CanSyncNewComplexFormComponentReferencingNewSense() componentEntryAfter.ComplexForms.Add(component); // act - await EntrySync.Sync( + await EntrySync.SyncFull( // note: the entry with the added sense is at the end of the list [complexFormEntryBefore, componentEntryBefore], [complexFormEntryAfter, componentEntryAfter], 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/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 d379ba433d..98e7ccdf01 100644 --- a/backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs +++ b/backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs @@ -604,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 91e68cea16..9a18285e1b 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; @@ -225,54 +224,6 @@ 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.AddWithoutReferencesAndGet)), - 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.ReplaceWithoutReferencesAndGet)), - new FakeDiffApi.MethodCall((updated, updated), nameof(FakeDiffApi.Replace))); - } - - [Fact] - public async Task DiffAddThenUpdate_WithoutReferencesAlwaysFirst() - { - 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 new entries (and/or new senses etc.) must be created first - //updated entries might reference the newEntry (or a new sense) and so must be updated after the new entry is created. - //the order of the "simple" Replace calls is unimportant. - _fakeApi.VerifyCalls( - new FakeDiffApi.MethodCall((oldEntry, updated), nameof(FakeDiffApi.ReplaceWithoutReferencesAndGet)), - new FakeDiffApi.MethodCall(newEntry, nameof(FakeDiffApi.AddWithoutReferencesAndGet)), - new FakeDiffApi.MethodCall((updated, updated), nameof(FakeDiffApi.Replace)), - new FakeDiffApi.MethodCall((newEntry, newEntry), nameof(FakeDiffApi.Replace)) - ); - } - private class FakeDiffApi: CollectionDiffApi { public record MethodCall(object Args, [CallerMemberName] string Name = ""); @@ -296,18 +247,6 @@ public override Task Replace(Entry before, Entry after) return Task.FromResult(1); } - public override Task<(int, Entry)> AddWithoutReferencesAndGet(Entry value) - { - Calls.Add(new(value)); - return Task.FromResult((1, value)); - } - - public override Task<(int, Entry)> ReplaceWithoutReferencesAndGet(Entry before, Entry after) - { - Calls.Add(new((before, after))); - return Task.FromResult((1, after)); - } - public override Guid GetId(Entry value) { return value.Id; diff --git a/backend/FwLite/MiniLcm/SyncHelpers/DiffCollection.cs b/backend/FwLite/MiniLcm/SyncHelpers/DiffCollection.cs index 816d068724..567665c619 100644 --- a/backend/FwLite/MiniLcm/SyncHelpers/DiffCollection.cs +++ b/backend/FwLite/MiniLcm/SyncHelpers/DiffCollection.cs @@ -10,16 +10,6 @@ namespace MiniLcm.SyncHelpers; public abstract class CollectionDiffApi where TId : notnull { public abstract Task Add(T value); - public virtual async Task<(int, T)> AddWithoutReferencesAndGet(T value) - { - var changes = await Add(value); - return (changes, value); - } - public virtual async Task<(int, T)> ReplaceWithoutReferencesAndGet(T before, T after) - { - var changes = await Replace(before, after); - return (changes, after); - } public abstract Task Remove(T value); public abstract Task Replace(T before, T after); public abstract TId GetId(T value); @@ -33,6 +23,26 @@ public override Guid GetId(T value) } } +public class ObjectWithIdCollectionReplaceOnlyDiffApi(Func> ReplaceFunc) : ObjectWithIdCollectionDiffApi where T : IObjectWithId +{ + public override Task Add(T value) + { + // no op + return Task.FromResult(0); + } + + public override Task Remove(T value) + { + // no op + return Task.FromResult(0); + } + + 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); @@ -48,51 +58,6 @@ 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( - 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) - { - if (beforeEntriesDict.Remove(diffApi.GetId(afterEntry), out var beforeEntry)) - { - // ensure all children that might be referenced are created - var (changed, replacedEntry) = await diffApi.ReplaceWithoutReferencesAndGet(beforeEntry, afterEntry); - changes += changed; - postAddUpdates.Add((replacedEntry, afterEntry)); // defer full update - } - else - { - // create new entry with children that might be referenced - var (change, created) = await diffApi.AddWithoutReferencesAndGet(afterEntry); - changes += change; - postAddUpdates.Add((created, afterEntry)); // defer full update - } - } - - 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); - } - - foreach (var beforeEntry in beforeEntriesDict.Values) - { - changes += await diffApi.Remove(beforeEntry); - } - - return changes; - } - public static async Task Diff( IList before, IList after, diff --git a/backend/FwLite/MiniLcm/SyncHelpers/EntrySync.cs b/backend/FwLite/MiniLcm/SyncHelpers/EntrySync.cs index 2e0646d0e0..19e4bff280 100644 --- a/backend/FwLite/MiniLcm/SyncHelpers/EntrySync.cs +++ b/backend/FwLite/MiniLcm/SyncHelpers/EntrySync.cs @@ -7,23 +7,45 @@ 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 = await SyncWithoutComplexFormsAndComponents(beforeEntries, afterEntries, api); + changes += await SyncComplexFormsAndComponents(beforeEntries, afterEntries, api); + return changes; } - public static async Task Sync(Entry beforeEntry, Entry afterEntry, IMiniLcmApi api) + public static async Task SyncWithoutComplexFormsAndComponents(Entry[] beforeEntries, + Entry[] afterEntries, + IMiniLcmApi api) + { + return await DiffCollection.Diff(beforeEntries, afterEntries, new EntriesDiffApi(api)); + } + + public static async Task SyncComplexFormsAndComponents(Entry[] beforeEntries, + Entry[] afterEntries, + IMiniLcmApi api) + { + return await DiffCollection.Diff(beforeEntries, afterEntries, + new ObjectWithIdCollectionReplaceOnlyDiffApi( + (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 +56,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, @@ -97,30 +134,9 @@ private static async Task SensesSync(Guid entryId, private class EntriesDiffApi(IMiniLcmApi api) : ObjectWithIdCollectionDiffApi { - public override async Task<(int, Entry)> AddWithoutReferencesAndGet(Entry afterEntry) - { - //create each entry (and its hierarchy: senses, example sentence etc.) in isolation (e.g. 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<(int, Entry)> ReplaceWithoutReferencesAndGet(Entry beforeEntry, Entry afterEntry) - { - //same as AddAndGet, but for already existing entries, because they - //might have new entities (e.g. senses) in their hierarchy that other entries reference - var beforeEntryWithoutEntryRefs = beforeEntry.WithoutEntryRefs(); - var afterEntryWithoutEntryRefs = afterEntry.WithoutEntryRefs(); - var changes = await Sync(beforeEntryWithoutEntryRefs, afterEntryWithoutEntryRefs, api); - //We've synced everything except the refs - var updatedBeforeEntry = afterEntry.WithEntryRefsFrom(beforeEntry); - return (changes, updatedBeforeEntry); - } - public override async Task Add(Entry afterEntry) { - await api.CreateEntry(afterEntry); + await api.CreateEntry(afterEntry, CreateEntryOptions.WithoutComplexFormsAndComponents); return 1; } @@ -132,7 +148,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); } } From 2e35e9ea362c3b8b60396f62e4ac71b7b645d782 Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Thu, 21 Aug 2025 10:37:34 +0200 Subject: [PATCH 4/8] Fix compile error --- backend/FwLite/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs | 2 +- backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/FwLite/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs b/backend/FwLite/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs index 243c34bcab..af02630ffc 100644 --- a/backend/FwLite/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs +++ b/backend/FwLite/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs @@ -956,7 +956,7 @@ public IAsyncEnumerable SearchEntries(string query, QueryOptions? options return Task.FromResult(FromLexEntry(EntriesRepository.GetObject(id))); } - public async Task CreateEntry(Entry entry, CreateEntryOptions? options) + public async Task CreateEntry(Entry entry, CreateEntryOptions? options = null) { options ??= CreateEntryOptions.Everything; entry.Id = entry.Id == default ? Guid.NewGuid() : entry.Id; diff --git a/backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs b/backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs index 98e7ccdf01..c3414fa4ef 100644 --- a/backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs +++ b/backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs @@ -482,7 +482,7 @@ private IEnumerable CreateEntryChanges(Entry entry, } } - public async Task CreateEntry(Entry entry, CreateEntryOptions? options) + public async Task CreateEntry(Entry entry, CreateEntryOptions? options = null) { options ??= CreateEntryOptions.Everything; await using var repo = await repoFactory.CreateRepoAsync(); From 4e86e16b51b3d12f8cee07948c982d7f0f03c128 Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Thu, 21 Aug 2025 13:38:49 +0200 Subject: [PATCH 5/8] Fix: added entries' complex-forms and component are not synced --- .../FwLiteProjectSync.Tests/EntrySyncTests.cs | 95 ++++++++++++++++++- backend/FwLite/MiniLcm/Models/Entry.cs | 12 +-- .../MiniLcm/SyncHelpers/DiffCollection.cs | 7 +- .../FwLite/MiniLcm/SyncHelpers/EntrySync.cs | 17 +++- 4 files changed, 112 insertions(+), 19 deletions(-) diff --git a/backend/FwLite/FwLiteProjectSync.Tests/EntrySyncTests.cs b/backend/FwLite/FwLiteProjectSync.Tests/EntrySyncTests.cs index 9a6a25d2bf..4b177f8f5f 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; @@ -261,4 +260,98 @@ await EntrySync.SyncFull( actualComponentEntry.Should().BeEquivalentTo(componentEntryAfter, options => SyncTests.SyncExclusions(options).WithStrictOrdering()); } + + [Fact] + public async Task SyncsAddedEntriesInTwoPhases() + { + // 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 - first phase + await EntrySync.SyncWithoutComplexFormsAndComponents([], [component, complexForm], Api); + + // assert - first phase + var actualComponent = await Api.GetEntry(component.Id); + actualComponent.Should().BeEquivalentTo(component, + options => options.Excluding(e => e.ComplexForms)); + actualComponent.ComplexForms.Should().BeEmpty(); + + var actualComplexForm = await Api.GetEntry(complexForm.Id); + actualComplexForm.Should().BeEquivalentTo(complexForm, + options => options.Excluding(e => e.Components)); + actualComplexForm.Components.Should().BeEmpty(); + + // act - second phase + await EntrySync.SyncComplexFormsAndComponentsOfExistingEntries([], [component, complexForm], Api); + + // assert - second phase + actualComponent = await Api.GetEntry(component.Id); + actualComponent.Should().BeEquivalentTo(component, + options => SyncTests.SyncExclusions(options).WithStrictOrdering()); + + actualComplexForm = await Api.GetEntry(complexForm.Id); + actualComplexForm.Should().BeEquivalentTo(complexForm, + options => SyncTests.SyncExclusions(options) + .Excluding(e => e.ComplexFormTypes) // LibLcm automatically creates a complex form type. Should we? + .WithStrictOrdering()); + } + + [Fact] + public async Task SyncsUpdatedEntriesInTwoPhases() + { + // 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 - first phase + await EntrySync.SyncWithoutComplexFormsAndComponents([componentBefore], [componentAfter, complexForm], Api); + + // assert - first phase + 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); + actualComplexForm.Should().BeEquivalentTo(complexForm, + options => options.Excluding(e => e.Components)); + actualComplexForm.Components.Should().BeEmpty(); + + // act - second phase + await EntrySync.SyncComplexFormsAndComponentsOfExistingEntries([], [componentAfter, complexForm], Api); + + // assert - second phase + actualComponent = await Api.GetEntry(componentAfter.Id); + actualComponent.Should().BeEquivalentTo(componentAfter, + options => SyncTests.SyncExclusions(options).WithStrictOrdering()); + + actualComplexForm = await Api.GetEntry(complexForm.Id); + actualComplexForm.Should().BeEquivalentTo(complexForm, + options => SyncTests.SyncExclusions(options).WithStrictOrdering()); + } } diff --git a/backend/FwLite/MiniLcm/Models/Entry.cs b/backend/FwLite/MiniLcm/Models/Entry.cs index e1fd21b2cc..a3f5c0fad0 100644 --- a/backend/FwLite/MiniLcm/Models/Entry.cs +++ b/backend/FwLite/MiniLcm/Models/Entry.cs @@ -2,6 +2,8 @@ namespace MiniLcm.Models; public record Entry : IObjectWithId { + public static readonly Entry Empty = new(); + public Guid Id { get; set; } public DateTimeOffset? DeletedAt { get; set; } @@ -76,16 +78,6 @@ public Guid[] GetReferences() public void RemoveReference(Guid id, DateTimeOffset time) { } - - public Entry WithoutEntryRefs() - { - return this with { Components = [], ComplexForms = [] }; - } - - public Entry WithEntryRefsFrom(Entry from) - { - return this with { Components = from.Components, ComplexForms = from.ComplexForms }; - } } public class Variants diff --git a/backend/FwLite/MiniLcm/SyncHelpers/DiffCollection.cs b/backend/FwLite/MiniLcm/SyncHelpers/DiffCollection.cs index 567665c619..608e108302 100644 --- a/backend/FwLite/MiniLcm/SyncHelpers/DiffCollection.cs +++ b/backend/FwLite/MiniLcm/SyncHelpers/DiffCollection.cs @@ -23,12 +23,11 @@ public override Guid GetId(T value) } } -public class ObjectWithIdCollectionReplaceOnlyDiffApi(Func> ReplaceFunc) : ObjectWithIdCollectionDiffApi where T : IObjectWithId +public class ObjectWithIdCollectionReplaceDiffApi(Func> ReplaceFunc) : ObjectWithIdCollectionDiffApi where T : IObjectWithId { - public override Task Add(T value) + public override async Task Add(T value) { - // no op - return Task.FromResult(0); + return await ReplaceFunc(default, value); } public override Task Remove(T value) diff --git a/backend/FwLite/MiniLcm/SyncHelpers/EntrySync.cs b/backend/FwLite/MiniLcm/SyncHelpers/EntrySync.cs index 19e4bff280..aa53a5e9bc 100644 --- a/backend/FwLite/MiniLcm/SyncHelpers/EntrySync.cs +++ b/backend/FwLite/MiniLcm/SyncHelpers/EntrySync.cs @@ -12,7 +12,7 @@ public static async Task SyncFull(Entry[] beforeEntries, IMiniLcmApi api) { var changes = await SyncWithoutComplexFormsAndComponents(beforeEntries, afterEntries, api); - changes += await SyncComplexFormsAndComponents(beforeEntries, afterEntries, api); + changes += await SyncComplexFormsAndComponentsOfExistingEntries(beforeEntries, afterEntries, api); return changes; } @@ -23,12 +23,16 @@ public static async Task SyncWithoutComplexFormsAndComponents(Entry[] befor return await DiffCollection.Diff(beforeEntries, afterEntries, new EntriesDiffApi(api)); } - public static async Task SyncComplexFormsAndComponents(Entry[] beforeEntries, + /// + /// Syncs only the complex forms and components of the before and after entries. + /// Any entries that are NOT in before are assumed to have already been created and have NO complex forms or components. + /// + public static async Task SyncComplexFormsAndComponentsOfExistingEntries(Entry[] beforeEntries, Entry[] afterEntries, IMiniLcmApi api) { return await DiffCollection.Diff(beforeEntries, afterEntries, - new ObjectWithIdCollectionReplaceOnlyDiffApi( + new ObjectWithIdCollectionReplaceDiffApi( (before, after) => SyncComplexFormsAndComponents(before, after, api))); } @@ -56,8 +60,13 @@ public static async Task SyncWithoutComplexFormsAndComponents(Entry beforeE } } - public static async Task SyncComplexFormsAndComponents(Entry beforeEntry, Entry afterEntry, IMiniLcmApi api) + /// + /// Syncs only the complex forms and components of the before and after entries. + /// If before is null it is assumed to have already been created and have NO complex forms or components. + /// + public static async Task SyncComplexFormsAndComponents(Entry? beforeEntry, Entry afterEntry, IMiniLcmApi api) { + beforeEntry ??= Entry.Empty; try { var changes = 0; From 6d9b3d11f1f82bd21fbd1c80cf9cd4f8f91c6bc4 Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Thu, 21 Aug 2025 13:51:11 +0200 Subject: [PATCH 6/8] Fix test --- backend/FwLite/FwLiteProjectSync.Tests/EntrySyncTests.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/backend/FwLite/FwLiteProjectSync.Tests/EntrySyncTests.cs b/backend/FwLite/FwLiteProjectSync.Tests/EntrySyncTests.cs index 4b177f8f5f..dddf9d20be 100644 --- a/backend/FwLite/FwLiteProjectSync.Tests/EntrySyncTests.cs +++ b/backend/FwLite/FwLiteProjectSync.Tests/EntrySyncTests.cs @@ -352,6 +352,8 @@ public async Task SyncsUpdatedEntriesInTwoPhases() actualComplexForm = await Api.GetEntry(complexForm.Id); actualComplexForm.Should().BeEquivalentTo(complexForm, - options => SyncTests.SyncExclusions(options).WithStrictOrdering()); + options => SyncTests.SyncExclusions(options) + .Excluding(e => e.ComplexFormTypes) // LibLcm automatically creates a complex form type. Should we? + .WithStrictOrdering()); } } From 1bb9712279b50b237e435053486db5211810b307 Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Fri, 22 Aug 2025 13:50:59 +0200 Subject: [PATCH 7/8] Refactor entry sync to retrieve created entries --- .../FwLiteProjectSync.Tests/EntrySyncTests.cs | 159 ++++++++++++------ .../MiniLcm.Tests/DiffCollectionTests.cs | 12 +- backend/FwLite/MiniLcm/Models/Entry.cs | 2 - .../MiniLcm/SyncHelpers/DiffCollection.cs | 38 ++++- .../FwLite/MiniLcm/SyncHelpers/EntrySync.cs | 26 ++- 5 files changed, 161 insertions(+), 76 deletions(-) diff --git a/backend/FwLite/FwLiteProjectSync.Tests/EntrySyncTests.cs b/backend/FwLite/FwLiteProjectSync.Tests/EntrySyncTests.cs index dddf9d20be..81ee5e4993 100644 --- a/backend/FwLite/FwLiteProjectSync.Tests/EntrySyncTests.cs +++ b/backend/FwLite/FwLiteProjectSync.Tests/EntrySyncTests.cs @@ -262,7 +262,44 @@ await EntrySync.SyncFull( } [Fact] - public async Task SyncsAddedEntriesInTwoPhases() + 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 @@ -280,80 +317,108 @@ public async Task SyncsAddedEntriesInTwoPhases() component.ComplexForms.Add(complexFormComponent); complexForm.Components.Add(complexFormComponent); - // act - first phase - await EntrySync.SyncWithoutComplexFormsAndComponents([], [component, complexForm], Api); + // act + var (_, added) = await EntrySync.SyncWithoutComplexFormsAndComponents([], [component, complexForm], Api); + added.Should().HaveCount(2); + var addedComponent = added.ElementAt(0); + var addedComplexForm = added.ElementAt(1); - // assert - first phase + // 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(); - - // act - second phase - await EntrySync.SyncComplexFormsAndComponentsOfExistingEntries([], [component, complexForm], Api); - - // assert - second phase - actualComponent = await Api.GetEntry(component.Id); - actualComponent.Should().BeEquivalentTo(component, - options => SyncTests.SyncExclusions(options).WithStrictOrdering()); - - actualComplexForm = await Api.GetEntry(complexForm.Id); - actualComplexForm.Should().BeEquivalentTo(complexForm, - options => SyncTests.SyncExclusions(options) - .Excluding(e => e.ComplexFormTypes) // LibLcm automatically creates a complex form type. Should we? - .WithStrictOrdering()); } [Fact] - public async Task SyncsUpdatedEntriesInTwoPhases() + 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 complexForm = new Entry() - { - Id = Guid.NewGuid(), - LexemeForm = { { "en", "complex form" } } - }; - var complexFormComponent = ComplexFormComponent.FromEntries(complexForm, componentAfter); + var complexFormAfter = complexFormBefore.Copy(); + complexFormAfter.LexemeForm["en"] = "complex form updated"; + var complexFormComponent = ComplexFormComponent.FromEntries(complexFormAfter, componentAfter); componentAfter.ComplexForms.Add(complexFormComponent); - complexForm.Components.Add(complexFormComponent); + complexFormAfter.Components.Add(complexFormComponent); - // act - first phase - await EntrySync.SyncWithoutComplexFormsAndComponents([componentBefore], [componentAfter, complexForm], Api); + // act + await EntrySync.SyncComplexFormsAndComponents([componentBefore, complexFormBefore], [componentAfter, complexFormAfter], Api); - // assert - first phase + // assert var actualComponent = await Api.GetEntry(componentAfter.Id); - actualComponent.Should().BeEquivalentTo(componentAfter, - options => options.Excluding(e => e.ComplexForms)); - actualComponent.ComplexForms.Should().BeEmpty(); + 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); + } - var actualComplexForm = await Api.GetEntry(complexForm.Id); - actualComplexForm.Should().BeEquivalentTo(complexForm, - options => options.Excluding(e => e.Components)); - actualComplexForm.Components.Should().BeEmpty(); + [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 - second phase - await EntrySync.SyncComplexFormsAndComponentsOfExistingEntries([], [componentAfter, complexForm], Api); + // Act + var act = () => EntrySync.SyncComplexFormsAndComponents([], [component, complexForm], Api); - // assert - second phase - actualComponent = await Api.GetEntry(componentAfter.Id); - actualComponent.Should().BeEquivalentTo(componentAfter, - options => SyncTests.SyncExclusions(options).WithStrictOrdering()); + // Assert + await act.Should().ThrowAsync(); + } - actualComplexForm = await Api.GetEntry(complexForm.Id); - actualComplexForm.Should().BeEquivalentTo(complexForm, - options => SyncTests.SyncExclusions(options) - .Excluding(e => e.ComplexFormTypes) // LibLcm automatically creates a complex form type. Should we? - .WithStrictOrdering()); + [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/MiniLcm.Tests/DiffCollectionTests.cs b/backend/FwLite/MiniLcm.Tests/DiffCollectionTests.cs index 9a18285e1b..bff2618d45 100644 --- a/backend/FwLite/MiniLcm.Tests/DiffCollectionTests.cs +++ b/backend/FwLite/MiniLcm.Tests/DiffCollectionTests.cs @@ -204,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] @@ -229,10 +229,16 @@ 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) diff --git a/backend/FwLite/MiniLcm/Models/Entry.cs b/backend/FwLite/MiniLcm/Models/Entry.cs index a3f5c0fad0..b3bf75fe00 100644 --- a/backend/FwLite/MiniLcm/Models/Entry.cs +++ b/backend/FwLite/MiniLcm/Models/Entry.cs @@ -2,8 +2,6 @@ namespace MiniLcm.Models; public record Entry : IObjectWithId { - public static readonly Entry Empty = new(); - public Guid Id { get; set; } public DateTimeOffset? DeletedAt { get; set; } diff --git a/backend/FwLite/MiniLcm/SyncHelpers/DiffCollection.cs b/backend/FwLite/MiniLcm/SyncHelpers/DiffCollection.cs index 608e108302..c305136dea 100644 --- a/backend/FwLite/MiniLcm/SyncHelpers/DiffCollection.cs +++ b/backend/FwLite/MiniLcm/SyncHelpers/DiffCollection.cs @@ -9,7 +9,16 @@ namespace MiniLcm.SyncHelpers; public abstract class CollectionDiffApi where TId : notnull { - public abstract Task Add(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); @@ -23,17 +32,16 @@ public override Guid GetId(T value) } } -public class ObjectWithIdCollectionReplaceDiffApi(Func> ReplaceFunc) : ObjectWithIdCollectionDiffApi where T : IObjectWithId +public class ObjectWithIdCollectionReplaceDiffApi(Func> ReplaceFunc) : ObjectWithIdCollectionDiffApi where T : IObjectWithId { - public override async Task Add(T value) + public override Task<(int, T)> AddAndGet(T value) { - return await ReplaceFunc(default, value); + throw new InvalidOperationException($"{nameof(AddAndGet)} should never be called"); } public override Task Remove(T value) { - // no op - return Task.FromResult(0); + throw new InvalidOperationException($"{nameof(Remove)} should never be called"); } public override async Task Replace(T before, T after) @@ -57,7 +65,7 @@ Guid GetId(T value) public static class DiffCollection { - public static async Task Diff( + public static async Task<(int Changes, ICollection Added)> DiffAndGetAdded( IList before, IList after, CollectionDiffApi diffApi) where TId : notnull @@ -78,11 +86,23 @@ public static async Task Diff( afterEntriesDict.Remove(diffApi.GetId(beforeEntry)); } - foreach (var value in afterEntriesDict.Values) + foreach (var (id, value) in afterEntriesDict) { - changes += await diffApi.Add(value); + var (addChanges, added) = await diffApi.AddAndGet(value); + changes += addChanges; + afterEntriesDict[id] = added; } + return (changes, afterEntriesDict.Values); + } + + + public static async Task Diff( + IList before, + IList after, + CollectionDiffApi diffApi) where TId : notnull + { + 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 aa53a5e9bc..59c67c2302 100644 --- a/backend/FwLite/MiniLcm/SyncHelpers/EntrySync.cs +++ b/backend/FwLite/MiniLcm/SyncHelpers/EntrySync.cs @@ -11,23 +11,24 @@ public static async Task SyncFull(Entry[] beforeEntries, Entry[] afterEntries, IMiniLcmApi api) { - var changes = await SyncWithoutComplexFormsAndComponents(beforeEntries, afterEntries, api); - changes += await SyncComplexFormsAndComponentsOfExistingEntries(beforeEntries, afterEntries, 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 SyncWithoutComplexFormsAndComponents(Entry[] beforeEntries, + public static async Task<(int Changes, ICollection Added)> SyncWithoutComplexFormsAndComponents(Entry[] beforeEntries, Entry[] afterEntries, IMiniLcmApi api) { - return await DiffCollection.Diff(beforeEntries, afterEntries, new EntriesDiffApi(api)); + return await DiffCollection.DiffAndGetAdded(beforeEntries, afterEntries, new EntriesDiffApi(api)); } /// /// Syncs only the complex forms and components of the before and after entries. - /// Any entries that are NOT in before are assumed to have already been created and have NO complex forms or components. + /// When the before and after entries do not match. /// - public static async Task SyncComplexFormsAndComponentsOfExistingEntries(Entry[] beforeEntries, + public static async Task SyncComplexFormsAndComponents(Entry[] beforeEntries, Entry[] afterEntries, IMiniLcmApi api) { @@ -60,13 +61,8 @@ public static async Task SyncWithoutComplexFormsAndComponents(Entry beforeE } } - /// - /// Syncs only the complex forms and components of the before and after entries. - /// If before is null it is assumed to have already been created and have NO complex forms or components. - /// - public static async Task SyncComplexFormsAndComponents(Entry? beforeEntry, Entry afterEntry, IMiniLcmApi api) + public static async Task SyncComplexFormsAndComponents(Entry beforeEntry, Entry afterEntry, IMiniLcmApi api) { - beforeEntry ??= Entry.Empty; try { var changes = 0; @@ -143,10 +139,10 @@ private static async Task SensesSync(Guid entryId, private class EntriesDiffApi(IMiniLcmApi api) : ObjectWithIdCollectionDiffApi { - public override async Task Add(Entry afterEntry) + public override async Task<(int, Entry)> AddAndGet(Entry afterEntry) { - await api.CreateEntry(afterEntry, CreateEntryOptions.WithoutComplexFormsAndComponents); - return 1; + var addedEntry = await api.CreateEntry(afterEntry, CreateEntryOptions.WithoutComplexFormsAndComponents); + return (1, addedEntry); } public override async Task Remove(Entry entry) From fa7ac01cc2e144f9c065b3ad515d2c850a395164 Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Fri, 22 Aug 2025 14:09:13 +0200 Subject: [PATCH 8/8] Make dry-run api respect CreateEntryOptions --- backend/FwLite/FwLiteProjectSync/DryRunMiniLcmApi.cs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/backend/FwLite/FwLiteProjectSync/DryRunMiniLcmApi.cs b/backend/FwLite/FwLiteProjectSync/DryRunMiniLcmApi.cs index d62bf6443c..df2474da53 100644 --- a/backend/FwLite/FwLiteProjectSync/DryRunMiniLcmApi.cs +++ b/backend/FwLite/FwLiteProjectSync/DryRunMiniLcmApi.cs @@ -153,8 +153,13 @@ public Task DeleteMorphTypeData(Guid id) public Task CreateEntry(Entry entry, CreateEntryOptions? options) { + options ??= new CreateEntryOptions(); DryRunRecords.Add(new DryRunRecord(nameof(CreateEntry), $"Create entry {entry.Headword()} ({options})")); - return Task.FromResult(entry); + // 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)