From 6a51808b2058ceb0340709aad840f458d44dfa66 Mon Sep 17 00:00:00 2001 From: Kevin Hahn Date: Wed, 7 Oct 2026 14:34:51 +0700 Subject: [PATCH] Split entry API out of CrdtMiniLcmApi Move the entry methods, including CreateEntry and BulkCreateEntries, into CrdtEntryApi. CrdtMiniLcmApi is now a pure facade that forwards every IMiniLcmApi member to a sub-service. BulkCreateBatchSize moves with BulkCreateEntries, so BulkCreateEntriesTest sets it on the scoped CrdtEntryApi instead of on the facade. Co-Authored-By: Claude Opus 5.5 --- .../LcmCrdt.Tests/BulkCreateEntriesTest.cs | 7 +- backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs | 264 +--------------- backend/FwLite/LcmCrdt/LcmCrdtKernel.cs | 1 + .../FwLite/LcmCrdt/MiniLcmImp/CrdtEntryApi.cs | 297 ++++++++++++++++++ 4 files changed, 318 insertions(+), 251 deletions(-) create mode 100644 backend/FwLite/LcmCrdt/MiniLcmImp/CrdtEntryApi.cs diff --git a/backend/FwLite/LcmCrdt.Tests/BulkCreateEntriesTest.cs b/backend/FwLite/LcmCrdt.Tests/BulkCreateEntriesTest.cs index ea00569692..401332c701 100644 --- a/backend/FwLite/LcmCrdt.Tests/BulkCreateEntriesTest.cs +++ b/backend/FwLite/LcmCrdt.Tests/BulkCreateEntriesTest.cs @@ -1,4 +1,5 @@ using System.Diagnostics; +using LcmCrdt.MiniLcmImp; using MiniLcm.Tests.AutoFakerHelpers; using Soenneker.Utils.AutoBogus; using Xunit.Abstractions; @@ -123,7 +124,7 @@ public async Task BulkCreateEntriesPerformance() public async Task BulkCreateEntries_PersistsAllEntries_WhenBelowBatchThreshold() { // Batch size well above entry count so only the tail flush (changeList.Count > 0) fires. - _fixture.Api.BulkCreateBatchSize = 1000; + _fixture.GetService().BulkCreateBatchSize = 1000; var entryCount = 5; var entries = await SeedData(entryCount, _fixture.Api).ToListAsync(); @@ -136,7 +137,7 @@ public async Task BulkCreateEntries_PersistsAllEntries_WhenBelowBatchThreshold() public async Task BulkCreateEntries_PersistsAllEntries_AcrossMultipleBatchFlushes() { // Tiny batch size forces multiple mid-loop flushes plus the tail flush. - _fixture.Api.BulkCreateBatchSize = 3; + _fixture.GetService().BulkCreateBatchSize = 3; var entryCount = 10; var entries = await SeedData(entryCount, _fixture.Api).ToListAsync(); @@ -148,7 +149,7 @@ public async Task BulkCreateEntries_PersistsAllEntries_AcrossMultipleBatchFlushe [Fact] public async Task BulkCreateEntries_RoundTripsAllEntriesById() { - _fixture.Api.BulkCreateBatchSize = 3; + _fixture.GetService().BulkCreateBatchSize = 3; var entries = await SeedData(10, _fixture.Api).ToListAsync(); await _fixture.Api.BulkCreateEntries(entries.ToAsyncEnumerable()); diff --git a/backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs b/backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs index 9d0d96a50f..5fc4853bca 100644 --- a/backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs +++ b/backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs @@ -1,25 +1,11 @@ -using SIL.Harmony.Changes; -using LcmCrdt.Changes; -using LcmCrdt.Changes.Entries; -using LcmCrdt.Data; -using LcmCrdt.FullTextSearch; -using LcmCrdt.Harmony; using LcmCrdt.MiniLcmImp; -using LcmCrdt.Objects; -using LinqToDB.Async; -using LinqToDB.EntityFrameworkCore; -using Microsoft.Extensions.Logging; -using MiniLcm.Exceptions; using MiniLcm.SyncHelpers; using MiniLcm.Media; namespace LcmCrdt; public class CrdtMiniLcmApi( - HarmonyChangeWriter harmonyChangeWriter, CurrentProjectService projectService, - MiniLcmRepositoryFactory repoFactory, - ILogger logger, CrdtWritingSystemApi writingSystemApi, CrdtSemanticDomainsApi semanticDomainsApi, CrdtPublicationApi publicationApi, @@ -27,20 +13,17 @@ public class CrdtMiniLcmApi( CrdtMorphTypeApi morphTypeApi, CrdtPartsOfSpeechApi partsOfSpeechApi, CrdtComplexFormTypesApi complexFormTypesApi, + CrdtEntryApi entryApi, CrdtSenseApi senseApi, CrdtExampleSentenceApi exampleSentenceApi, CrdtPictureApi pictureApi, CrdtMediaApi mediaApi, CrdtCustomViewApi customViewApi, - CrdtCommentApi commentApi, - EntrySearchService? entrySearchService = null) : IMiniLcmApi + CrdtCommentApi commentApi) : IMiniLcmApi { public ProjectData ProjectData => projectService.ProjectData; public CrdtProject Project => projectService.Project; - // Flush threshold (in accumulated IChange records) for BulkCreateEntries; internal so tests can force multi-batch behavior. - internal int BulkCreateBatchSize { get; set; } = 1000; - #region WritingSystemApi public Task GetWritingSystems() { @@ -325,278 +308,63 @@ public async Task UpdateMorphType(MorphType before, MorphType after, } #endregion + #region EntryApi public async Task CountEntries(string? query = null, FilterQueryOptions? options = null) { - await using var repo = await repoFactory.CreateRepoAsync(); - return await repo.CountEntries(query, options); + return await entryApi.CountEntries(query, options); } public IAsyncEnumerable GetEntries(QueryOptions? options = null) { - return SearchEntries(null, options); + return entryApi.GetEntries(options); } - public async IAsyncEnumerable SearchEntries(string? query, QueryOptions? options = null) + public IAsyncEnumerable SearchEntries(string? query, QueryOptions? options = null) { - await using var repo = await repoFactory.CreateRepoAsync(); - await foreach (var entry in repo.GetEntries(query, options)) - { - yield return entry; - } + return entryApi.SearchEntries(query, options); } public async Task GetEntry(Guid id) { - await using var repo = await repoFactory.CreateRepoAsync(); - return await repo.GetEntry(id); + return await entryApi.GetEntry(id); } public async Task GetEntryIndex(Guid entryId, string? query = null, IndexQueryOptions? options = null) { - await using var repo = await repoFactory.CreateRepoAsync(); - return await repo.GetEntryIndex(entryId, query, options); + return await entryApi.GetEntryIndex(entryId, query, options); } public async Task BulkCreateEntries(IAsyncEnumerable entries) { - await using var repo = await repoFactory.CreateRepoAsync(); - var semanticDomains = await repo.SemanticDomains.ToDictionaryAsync(sd => sd.Id, sd => sd); - //we're using this change list to ensure that we partially commit in case of an error - //this lets us attempt an import again skipping the entries that were already imported - var changeList = new List(1300); - var createdEntryIds = await repo.Entries.Select(e => e.Id).ToAsyncEnumerable().ToHashSetAsync(); - int entryCount = 0; - await foreach (var entry in entries) - { - entryCount++; - changeList.AddRange(CreateEntryChanges(entry, semanticDomains, createdEntryIds)); - createdEntryIds.Add(entry.Id); - if (changeList.Count > BulkCreateBatchSize) - { - await harmonyChangeWriter.AddChanges(changeList); - changeList.Clear(); - logger.LogInformation("Added {Count} entries so far", entryCount); - } - } - if (changeList.Count > 0) - { - await harmonyChangeWriter.AddChanges(changeList); - changeList.Clear(); - } - - await (entrySearchService?.RegenerateEntrySearchTable() ?? Task.CompletedTask); - - logger.LogInformation("Added {Count} entries", entryCount); - } - - private IEnumerable CreateEntryChanges(Entry entry, - Dictionary semanticDomains, - HashSet createdEntryIds) - { - yield return new CreateEntryChange(entry); - - var componentOrder = 1; - foreach (var component in entry.Components) - { - //only add components if the component entry was created already, otherwise it will be added when the component entry is created - if (!createdEntryIds.Contains(component.ComponentEntryId)) continue; - if (component.Order == 0) component.Order = componentOrder++; - yield return new AddEntryComponentChange(component); - } - foreach (var complexForm in entry.ComplexForms) - { - //only add complex forms if the complex form entry was created already, otherwise it will be added when the complex form entry is created - if (!createdEntryIds.Contains(complexForm.ComplexFormEntryId)) continue; - yield return new AddEntryComponentChange(complexForm); - } - foreach (var addComplexFormTypeChange in entry.ComplexFormTypes.Select(c => new AddComplexFormTypeChange(entry.Id, c))) - { - yield return addComplexFormTypeChange; - } - foreach (var addPublicationChange in entry.PublishIn.Select(c => new AddPublicationChange(entry.Id, c))) - { - yield return addPublicationChange; - } - var senseOrder = 1; - foreach (var sense in entry.Senses) - { - sense.SemanticDomains = sense.SemanticDomains - .Select(sd => semanticDomains.TryGetValue(sd.Id, out var selectedSd) ? selectedSd : null) - .OfType() - .ToList(); - sense.Order = senseOrder++; - yield return new CreateSenseChange(sense, entry.Id); - var exampleOrder = 1; - foreach (var exampleSentence in sense.ExampleSentences) - { - exampleSentence.Order = exampleOrder++; - yield return new CreateExampleSentenceChange(exampleSentence, sense.Id); - } - } + await entryApi.BulkCreateEntries(entries); } public async Task CreateEntry(Entry entry, CreateEntryOptions? options = null) { - options ??= CreateEntryOptions.WithMainPublication; - await using var repo = await repoFactory.CreateRepoAsync(); - - // This is our primitive logic for now: - // If 0, we assume the caller did not specify a number, so we're responsible - // for keeping homograph numbers accurate. - // That's it. - // There are other scenarios that we're NOT handling correctly for now: - // Deletes, headword changes, non 0's for inserted entries coming from fwdata etc. - // These will be automatically corrected after 2 fw-headless syncs. - IChange? homographPromotionChange = null; - if (entry.HomographNumber == 0) - { - var resolution = await HomographResolver.ResolveForNewEntry(entry, repo); - entry.HomographNumber = resolution.NewEntryNumber; - if (resolution.Promotion is { } promotion) - { - var patchDoc = new SystemTextJsonPatch.JsonPatchDocument(); - patchDoc.Replace(e => e.HomographNumber, promotion.NewNumber); - homographPromotionChange = patchDoc.ToChanges(promotion.EntryId).Single(); - } - } - - if (options.AutoAddMainPublication) - { - var mainPublication = await repo.GetMainPublication(); - if (mainPublication is not null && entry.PublishIn.All(pub => pub.Id != mainPublication.Id)) - { - entry.PublishIn.Add(mainPublication); - } - } - await harmonyChangeWriter.AddChanges((IEnumerable)[ - new CreateEntryChange(entry), - ..homographPromotionChange is null ? [] : new[] { homographPromotionChange }, - ..await entry.Senses.ToAsyncEnumerable() - .SelectMany((s, i) => - { - s.Order = i + 1; - return CrdtSenseApi.CreateSenseChanges(entry.Id, s, repo.SemanticDomains); - }) - .ToArrayAsync(), - ..await ToPublications(entry.PublishIn).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 NotFoundException.ForType(entry.Id); - - async IAsyncEnumerable ToComplexFormComponents(IList complexFormComponents) - { - var currOrder = 1; - foreach (var complexFormComponent in complexFormComponents) - { - if (complexFormComponent.ComponentEntryId == default) complexFormComponent.ComponentEntryId = entry.Id; - if (complexFormComponent.ComplexFormEntryId == default) complexFormComponent.ComplexFormEntryId = entry.Id; - if (complexFormComponent.ComponentEntryId == complexFormComponent.ComplexFormEntryId) - { - throw new InvalidOperationException($"Complex form component {complexFormComponent} has the same component id as its complex form"); - } - //these tests break under sync when the entry was deleted in a CRDT but that's not yet been synced to FW - //todo enable these tests when the api is not syncing but being called normally - // if (complexFormComponent.ComponentEntryId != entry.Id && - // await IsEntryDeleted(complexFormComponent.ComponentEntryId)) - // { - // throw new InvalidOperationException($"Complex form component {complexFormComponent} references deleted entry {complexFormComponent.ComponentEntryId} as its component"); - // } - // if (complexFormComponent.ComplexFormEntryId != entry.Id && - // await IsEntryDeleted(complexFormComponent.ComplexFormEntryId)) - // { - // throw new InvalidOperationException($"Complex form component {complexFormComponent} references deleted entry {complexFormComponent.ComplexFormEntryId} as its complex form"); - // } - - // if (complexFormComponent.ComponentSenseId != null && - // !await Senses.AnyAsyncEF(s => s.Id == complexFormComponent.ComponentSenseId.Value)) - // { - // throw new InvalidOperationException($"Complex form component {complexFormComponent} references deleted sense {complexFormComponent.ComponentSenseId} as its component"); - // } - if (complexFormComponent.ComplexFormEntryId == entry.Id) - { - // the entry is the complex-form and picks what order its components are in - complexFormComponent.Order = currOrder++; - yield return new AddEntryComponentChange(complexFormComponent); - } - else - { - // the entry is a component, so we let its complex-form pick the order - yield return await repo.CreateComplexFormComponentChange(complexFormComponent); - } - } - } - - async IAsyncEnumerable ToComplexFormTypes(IList complexFormTypes) - { - foreach (var complexFormType in complexFormTypes) - { - if (complexFormType.Id == default) - { - throw new InvalidOperationException("Complex form type must have an id"); - } - - if (!await repo.ComplexFormTypes.AnyAsyncEF(t => t.Id == complexFormType.Id)) - { - throw new InvalidOperationException($"Complex form type {complexFormType} does not exist"); - } - yield return new AddComplexFormTypeChange(entry.Id, complexFormType); - } - } - - async IAsyncEnumerable ToPublications(IList publications) - { - foreach (var publication in publications) - { - if (publication.Id == default) - { - throw new InvalidOperationException("Publication must have an id"); - } - - if (!await repo.Publications.AnyAsyncEF(t => t.Id == publication.Id)) - { - throw new InvalidOperationException($"Publication {publication} does not exist"); - } - yield return new AddPublicationChange(entry.Id, publication); - } - } - } - - private async ValueTask IsEntryDeleted(Guid id) - { - await using var repo = await repoFactory.CreateRepoAsync(); - return !await repo.Entries.AnyAsyncEF(e => e.Id == id); + return await entryApi.CreateEntry(entry, options); } public async Task SubmitUpdateEntry(Guid id, UpdateObjectInput update) { - await harmonyChangeWriter.AddChanges(update.Patch.ToChanges(id)); + await entryApi.SubmitUpdateEntry(id, update); } public async Task UpdateEntry(Guid id, UpdateObjectInput update) { - await SubmitUpdateEntry(id, update); - await using var repo = await repoFactory.CreateRepoAsync(); - return await repo.GetEntry(id) ?? throw NotFoundException.ForType(id); + return await entryApi.UpdateEntry(id, update); } public async Task UpdateEntry(Entry before, Entry after, IMiniLcmApi? api = null) { - await EntrySync.SyncFull(before, after, api ?? this); - var updatedEntry = await GetEntry(after.Id) ?? throw NotFoundException.ForType(after.Id); - return updatedEntry; + return await entryApi.UpdateEntry(before, after, api ?? this); } public async Task DeleteEntry(Guid id) { - await harmonyChangeWriter.AddChange(new DeleteChange(id)); + await entryApi.DeleteEntry(id); } + #endregion #region SenseApi public async Task GetSense(Guid senseId) diff --git a/backend/FwLite/LcmCrdt/LcmCrdtKernel.cs b/backend/FwLite/LcmCrdt/LcmCrdtKernel.cs index 793fddc373..b49deab4bb 100644 --- a/backend/FwLite/LcmCrdt/LcmCrdtKernel.cs +++ b/backend/FwLite/LcmCrdt/LcmCrdtKernel.cs @@ -90,6 +90,7 @@ public static IServiceCollection AddLcmCrdtClientCore(this IServiceCollection se services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/backend/FwLite/LcmCrdt/MiniLcmImp/CrdtEntryApi.cs b/backend/FwLite/LcmCrdt/MiniLcmImp/CrdtEntryApi.cs new file mode 100644 index 0000000000..411d03f9e3 --- /dev/null +++ b/backend/FwLite/LcmCrdt/MiniLcmImp/CrdtEntryApi.cs @@ -0,0 +1,297 @@ +using SIL.Harmony.Changes; +using LcmCrdt.Changes; +using LcmCrdt.Changes.Entries; +using LcmCrdt.Data; +using LcmCrdt.FullTextSearch; +using LcmCrdt.Harmony; +using LcmCrdt.Objects; +using LinqToDB.Async; +using LinqToDB.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using MiniLcm.Exceptions; +using MiniLcm.SyncHelpers; + +namespace LcmCrdt.MiniLcmImp; + +public class CrdtEntryApi( + MiniLcmRepositoryFactory repoFactory, + HarmonyChangeWriter harmonyChangeWriter, + ILogger logger, + EntrySearchService? entrySearchService = null) +{ + // Flush threshold (in accumulated IChange records) for BulkCreateEntries; internal so tests can force multi-batch behavior. + internal int BulkCreateBatchSize { get; set; } = 1000; + + public async Task CountEntries(string? query = null, FilterQueryOptions? options = null) + { + await using var repo = await repoFactory.CreateRepoAsync(); + return await repo.CountEntries(query, options); + } + + public IAsyncEnumerable GetEntries(QueryOptions? options = null) + { + return SearchEntries(null, options); + } + + public async IAsyncEnumerable SearchEntries(string? query, QueryOptions? options = null) + { + await using var repo = await repoFactory.CreateRepoAsync(); + await foreach (var entry in repo.GetEntries(query, options)) + { + yield return entry; + } + } + + public async Task GetEntry(Guid id) + { + await using var repo = await repoFactory.CreateRepoAsync(); + return await repo.GetEntry(id); + } + + public async Task GetEntryIndex(Guid entryId, string? query = null, IndexQueryOptions? options = null) + { + await using var repo = await repoFactory.CreateRepoAsync(); + return await repo.GetEntryIndex(entryId, query, options); + } + + public async Task BulkCreateEntries(IAsyncEnumerable entries) + { + await using var repo = await repoFactory.CreateRepoAsync(); + var semanticDomains = await repo.SemanticDomains.ToDictionaryAsync(sd => sd.Id, sd => sd); + //we're using this change list to ensure that we partially commit in case of an error + //this lets us attempt an import again skipping the entries that were already imported + var changeList = new List(1300); + var createdEntryIds = await repo.Entries.Select(e => e.Id).ToAsyncEnumerable().ToHashSetAsync(); + int entryCount = 0; + await foreach (var entry in entries) + { + entryCount++; + changeList.AddRange(CreateEntryChanges(entry, semanticDomains, createdEntryIds)); + createdEntryIds.Add(entry.Id); + if (changeList.Count > BulkCreateBatchSize) + { + await harmonyChangeWriter.AddChanges(changeList); + changeList.Clear(); + logger.LogInformation("Added {Count} entries so far", entryCount); + } + } + if (changeList.Count > 0) + { + await harmonyChangeWriter.AddChanges(changeList); + changeList.Clear(); + } + + await (entrySearchService?.RegenerateEntrySearchTable() ?? Task.CompletedTask); + + logger.LogInformation("Added {Count} entries", entryCount); + } + + private IEnumerable CreateEntryChanges(Entry entry, + Dictionary semanticDomains, + HashSet createdEntryIds) + { + yield return new CreateEntryChange(entry); + + var componentOrder = 1; + foreach (var component in entry.Components) + { + //only add components if the component entry was created already, otherwise it will be added when the component entry is created + if (!createdEntryIds.Contains(component.ComponentEntryId)) continue; + if (component.Order == 0) component.Order = componentOrder++; + yield return new AddEntryComponentChange(component); + } + foreach (var complexForm in entry.ComplexForms) + { + //only add complex forms if the complex form entry was created already, otherwise it will be added when the complex form entry is created + if (!createdEntryIds.Contains(complexForm.ComplexFormEntryId)) continue; + yield return new AddEntryComponentChange(complexForm); + } + foreach (var addComplexFormTypeChange in entry.ComplexFormTypes.Select(c => new AddComplexFormTypeChange(entry.Id, c))) + { + yield return addComplexFormTypeChange; + } + foreach (var addPublicationChange in entry.PublishIn.Select(c => new AddPublicationChange(entry.Id, c))) + { + yield return addPublicationChange; + } + var senseOrder = 1; + foreach (var sense in entry.Senses) + { + sense.SemanticDomains = sense.SemanticDomains + .Select(sd => semanticDomains.TryGetValue(sd.Id, out var selectedSd) ? selectedSd : null) + .OfType() + .ToList(); + sense.Order = senseOrder++; + yield return new CreateSenseChange(sense, entry.Id); + var exampleOrder = 1; + foreach (var exampleSentence in sense.ExampleSentences) + { + exampleSentence.Order = exampleOrder++; + yield return new CreateExampleSentenceChange(exampleSentence, sense.Id); + } + } + } + + public async Task CreateEntry(Entry entry, CreateEntryOptions? options = null) + { + options ??= CreateEntryOptions.WithMainPublication; + await using var repo = await repoFactory.CreateRepoAsync(); + + // This is our primitive logic for now: + // If 0, we assume the caller did not specify a number, so we're responsible + // for keeping homograph numbers accurate. + // That's it. + // There are other scenarios that we're NOT handling correctly for now: + // Deletes, headword changes, non 0's for inserted entries coming from fwdata etc. + // These will be automatically corrected after 2 fw-headless syncs. + IChange? homographPromotionChange = null; + if (entry.HomographNumber == 0) + { + var resolution = await HomographResolver.ResolveForNewEntry(entry, repo); + entry.HomographNumber = resolution.NewEntryNumber; + if (resolution.Promotion is { } promotion) + { + var patchDoc = new SystemTextJsonPatch.JsonPatchDocument(); + patchDoc.Replace(e => e.HomographNumber, promotion.NewNumber); + homographPromotionChange = patchDoc.ToChanges(promotion.EntryId).Single(); + } + } + + if (options.AutoAddMainPublication) + { + var mainPublication = await repo.GetMainPublication(); + if (mainPublication is not null && entry.PublishIn.All(pub => pub.Id != mainPublication.Id)) + { + entry.PublishIn.Add(mainPublication); + } + } + await harmonyChangeWriter.AddChanges((IEnumerable)[ + new CreateEntryChange(entry), + ..homographPromotionChange is null ? [] : new[] { homographPromotionChange }, + ..await entry.Senses.ToAsyncEnumerable() + .SelectMany((s, i) => + { + s.Order = i + 1; + return CrdtSenseApi.CreateSenseChanges(entry.Id, s, repo.SemanticDomains); + }) + .ToArrayAsync(), + ..await ToPublications(entry.PublishIn).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 NotFoundException.ForType(entry.Id); + + async IAsyncEnumerable ToComplexFormComponents(IList complexFormComponents) + { + var currOrder = 1; + foreach (var complexFormComponent in complexFormComponents) + { + if (complexFormComponent.ComponentEntryId == default) complexFormComponent.ComponentEntryId = entry.Id; + if (complexFormComponent.ComplexFormEntryId == default) complexFormComponent.ComplexFormEntryId = entry.Id; + if (complexFormComponent.ComponentEntryId == complexFormComponent.ComplexFormEntryId) + { + throw new InvalidOperationException($"Complex form component {complexFormComponent} has the same component id as its complex form"); + } + //these tests break under sync when the entry was deleted in a CRDT but that's not yet been synced to FW + //todo enable these tests when the api is not syncing but being called normally + // if (complexFormComponent.ComponentEntryId != entry.Id && + // await IsEntryDeleted(complexFormComponent.ComponentEntryId)) + // { + // throw new InvalidOperationException($"Complex form component {complexFormComponent} references deleted entry {complexFormComponent.ComponentEntryId} as its component"); + // } + // if (complexFormComponent.ComplexFormEntryId != entry.Id && + // await IsEntryDeleted(complexFormComponent.ComplexFormEntryId)) + // { + // throw new InvalidOperationException($"Complex form component {complexFormComponent} references deleted entry {complexFormComponent.ComplexFormEntryId} as its complex form"); + // } + + // if (complexFormComponent.ComponentSenseId != null && + // !await Senses.AnyAsyncEF(s => s.Id == complexFormComponent.ComponentSenseId.Value)) + // { + // throw new InvalidOperationException($"Complex form component {complexFormComponent} references deleted sense {complexFormComponent.ComponentSenseId} as its component"); + // } + if (complexFormComponent.ComplexFormEntryId == entry.Id) + { + // the entry is the complex-form and picks what order its components are in + complexFormComponent.Order = currOrder++; + yield return new AddEntryComponentChange(complexFormComponent); + } + else + { + // the entry is a component, so we let its complex-form pick the order + yield return await repo.CreateComplexFormComponentChange(complexFormComponent); + } + } + } + + async IAsyncEnumerable ToComplexFormTypes(IList complexFormTypes) + { + foreach (var complexFormType in complexFormTypes) + { + if (complexFormType.Id == default) + { + throw new InvalidOperationException("Complex form type must have an id"); + } + + if (!await repo.ComplexFormTypes.AnyAsyncEF(t => t.Id == complexFormType.Id)) + { + throw new InvalidOperationException($"Complex form type {complexFormType} does not exist"); + } + yield return new AddComplexFormTypeChange(entry.Id, complexFormType); + } + } + + async IAsyncEnumerable ToPublications(IList publications) + { + foreach (var publication in publications) + { + if (publication.Id == default) + { + throw new InvalidOperationException("Publication must have an id"); + } + + if (!await repo.Publications.AnyAsyncEF(t => t.Id == publication.Id)) + { + throw new InvalidOperationException($"Publication {publication} does not exist"); + } + yield return new AddPublicationChange(entry.Id, publication); + } + } + } + + private async ValueTask IsEntryDeleted(Guid id) + { + await using var repo = await repoFactory.CreateRepoAsync(); + return !await repo.Entries.AnyAsyncEF(e => e.Id == id); + } + + public async Task SubmitUpdateEntry(Guid id, UpdateObjectInput update) + { + await harmonyChangeWriter.AddChanges(update.Patch.ToChanges(id)); + } + + public async Task UpdateEntry(Guid id, + UpdateObjectInput update) + { + await SubmitUpdateEntry(id, update); + await using var repo = await repoFactory.CreateRepoAsync(); + return await repo.GetEntry(id) ?? throw NotFoundException.ForType(id); + } + + public async Task UpdateEntry(Entry before, Entry after, IMiniLcmApi api) + { + await EntrySync.SyncFull(before, after, api); + var updatedEntry = await GetEntry(after.Id) ?? throw NotFoundException.ForType(after.Id); + return updatedEntry; + } + + public async Task DeleteEntry(Guid id) + { + await harmonyChangeWriter.AddChange(new DeleteChange(id)); + } +}