Skip to content

Commit

Permalink
CRDT sync handles parts of speech (#1203)
Browse files Browse the repository at this point in the history
Parts of speech now sync both ways in FW and CRDT sync. Someday we may
want to generalize this to include more types of CmPossibilityLists but
for now parts of speech are working.
  • Loading branch information
rmunn authored Nov 7, 2024
1 parent d2a0c24 commit 253403b
Show file tree
Hide file tree
Showing 11 changed files with 254 additions and 16 deletions.
56 changes: 49 additions & 7 deletions backend/FwLite/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs
Original file line number Diff line number Diff line change
Expand Up @@ -193,25 +193,56 @@ public IAsyncEnumerable<PartOfSpeech> GetPartsOfSpeech()
.AllInstances()
.OrderBy(p => p.Name.BestAnalysisAlternative.Text)
.ToAsyncEnumerable()
.Select(partOfSpeech => new PartOfSpeech
{
Id = partOfSpeech.Guid,
Name = FromLcmMultiString(partOfSpeech.Name)
});
.Select(FromLcmPartOfSpeech);
}

public Task CreatePartOfSpeech(PartOfSpeech partOfSpeech)
public Task<PartOfSpeech?> GetPartOfSpeech(Guid id)
{
return Task.FromResult(
PartOfSpeechRepository
.TryGetObject(id, out var partOfSpeech)
? FromLcmPartOfSpeech(partOfSpeech) : null);
}

public Task<PartOfSpeech> CreatePartOfSpeech(PartOfSpeech partOfSpeech)
{
IPartOfSpeech? lcmPartOfSpeech = null;
if (partOfSpeech.Id == default) partOfSpeech.Id = Guid.NewGuid();
UndoableUnitOfWorkHelper.DoUsingNewOrCurrentUOW("Create Part of Speech",
"Remove part of speech",
Cache.ServiceLocator.ActionHandler,
() =>
{
var lcmPartOfSpeech = Cache.ServiceLocator.GetInstance<IPartOfSpeechFactory>()
lcmPartOfSpeech = Cache.ServiceLocator.GetInstance<IPartOfSpeechFactory>()
.Create(partOfSpeech.Id, Cache.LangProject.PartsOfSpeechOA);
UpdateLcmMultiString(lcmPartOfSpeech.Name, partOfSpeech.Name);
});
return Task.FromResult(FromLcmPartOfSpeech(lcmPartOfSpeech ?? throw new InvalidOperationException("Part of speech was not created")));
}

public Task<PartOfSpeech> UpdatePartOfSpeech(Guid id, UpdateObjectInput<PartOfSpeech> update)
{
var lcmPartOfSpeech = PartOfSpeechRepository.GetObject(id);
UndoableUnitOfWorkHelper.DoUsingNewOrCurrentUOW("Update Part of Speech",
"Revert Part of Speech",
Cache.ServiceLocator.ActionHandler,
() =>
{
var updateProxy = new UpdatePartOfSpeechProxy(lcmPartOfSpeech, this);
update.Apply(updateProxy);
});
return Task.FromResult(FromLcmPartOfSpeech(lcmPartOfSpeech));
}

public Task DeletePartOfSpeech(Guid id)
{
UndoableUnitOfWorkHelper.DoUsingNewOrCurrentUOW("Delete Part of Speech",
"Revert delete",
Cache.ServiceLocator.ActionHandler,
() =>
{
PartOfSpeechRepository.GetObject(id).Delete();
});
return Task.CompletedTask;
}

Expand Down Expand Up @@ -290,6 +321,17 @@ public IAsyncEnumerable<VariantType> GetVariantTypes()
.ToAsyncEnumerable();
}

private PartOfSpeech FromLcmPartOfSpeech(IPartOfSpeech lcmPos)
{
return new PartOfSpeech
{
Id = lcmPos.Guid,
Name = FromLcmMultiString(lcmPos.Name),
// TODO: Abreviation = FromLcmMultiString(partOfSpeech.Abreviation),
Predefined = true, // NOTE: the !string.IsNullOrEmpty(lcmPos.CatalogSourceId) check doesn't work if the PoS originated in CRDT
};
}

private Entry FromLexEntry(ILexEntry entry)
{
return new Entry
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
using MiniLcm.Models;
using SIL.LCModel;

namespace FwDataMiniLcmBridge.Api.UpdateProxy;

public class UpdatePartOfSpeechProxy : PartOfSpeech
{
private readonly IPartOfSpeech _lcmPartOfSpeech;
private readonly FwDataMiniLcmApi _lexboxLcmApi;

public UpdatePartOfSpeechProxy(IPartOfSpeech lcmPartOfSpeech, FwDataMiniLcmApi lexboxLcmApi)
{
_lcmPartOfSpeech = lcmPartOfSpeech;
Id = lcmPartOfSpeech.Guid;
_lexboxLcmApi = lexboxLcmApi;
}

public override MultiString Name
{
get => new UpdateMultiStringProxy(_lcmPartOfSpeech.Name, _lexboxLcmApi);
set => throw new NotImplementedException();
}
}
76 changes: 76 additions & 0 deletions backend/FwLite/FwLiteProjectSync.Tests/SyncTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,82 @@ await crdtApi.CreateEntry(new Entry()
.For(e => e.ComplexForms).Exclude(c => c.Id));
}

[Fact]
public async Task PartsOfSpeechSyncBothWays()
{
var crdtApi = _fixture.CrdtApi;
var fwdataApi = _fixture.FwDataApi;
await _syncService.Sync(crdtApi, fwdataApi);

var noun = new PartOfSpeech()
{
Id = new Guid("a8e41fd3-e343-4c7c-aa05-01ea3dd5cfb5"),
Name = { { "en", "noun" } },
Predefined = true,
};
await fwdataApi.CreatePartOfSpeech(noun);

var verb = new PartOfSpeech()
{
Id = new Guid("86ff66f6-0774-407a-a0dc-3eeaf873daf7"),
Name = { { "en", "verb" } },
Predefined = true,
};
await crdtApi.CreatePartOfSpeech(verb);

await _syncService.Sync(crdtApi, fwdataApi);

var crdtPartsOfSpeech = await crdtApi.GetPartsOfSpeech().ToArrayAsync();
var fwdataPartsOfSpeech = await fwdataApi.GetPartsOfSpeech().ToArrayAsync();
crdtPartsOfSpeech.Should().ContainEquivalentOf(noun);
crdtPartsOfSpeech.Should().ContainEquivalentOf(verb);
fwdataPartsOfSpeech.Should().ContainEquivalentOf(noun);
fwdataPartsOfSpeech.Should().ContainEquivalentOf(verb);

crdtPartsOfSpeech.Should().BeEquivalentTo(fwdataPartsOfSpeech);
}

[Fact]
public async Task PartsOfSpeechSyncInEntries()
{
var crdtApi = _fixture.CrdtApi;
var fwdataApi = _fixture.FwDataApi;
await _syncService.Sync(crdtApi, fwdataApi);

var noun = new PartOfSpeech()
{
Id = new Guid("a8e41fd3-e343-4c7c-aa05-01ea3dd5cfb5"),
Name = { { "en", "noun" } },
Predefined = true,
};
await fwdataApi.CreatePartOfSpeech(noun);
// Note we do *not* call crdtApi.CreatePartOfSpeech(noun);

await fwdataApi.CreateEntry(new Entry()
{
LexemeForm = { { "en", "Pear" } },
Senses =
[
new Sense() { Gloss = { { "en", "Pear" } }, PartOfSpeechId = noun.Id }
]
});
await crdtApi.CreateEntry(new Entry()
{
LexemeForm = { { "en", "Banana" } },
Senses =
[
new Sense() { Gloss = { { "en", "Banana" } }, PartOfSpeechId = noun.Id }
]
});
await _syncService.Sync(crdtApi, fwdataApi);

var crdtEntries = await crdtApi.GetEntries().ToArrayAsync();
var fwdataEntries = await fwdataApi.GetEntries().ToArrayAsync();
crdtEntries.Should().BeEquivalentTo(fwdataEntries,
options => options.For(e => e.Components).Exclude(c => c.Id)
.For(e => e.ComplexForms).Exclude(c => c.Id));
}

[Fact]
public async Task UpdatingAnEntryInEachProjectSyncsAcrossBoth()
{
Expand Down
12 changes: 8 additions & 4 deletions backend/FwLite/FwLiteProjectSync/CrdtFwdataProjectSyncService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ public async Task<SyncResult> Sync(IMiniLcmApi crdtApi, FwDataMiniLcmApi fwdataA
if (!dryRun)
{
await SaveProjectSnapshot(fwdataApi.Project.Name, fwdataApi.Project.ProjectsPath,
new ProjectSnapshot(await fwdataApi.GetEntries().ToArrayAsync()));
new ProjectSnapshot(await fwdataApi.GetEntries().ToArrayAsync(), await fwdataApi.GetPartsOfSpeech().ToArrayAsync()));
}
return result;
}
Expand All @@ -50,11 +50,15 @@ private async Task<SyncResult> Sync(IMiniLcmApi crdtApi, IMiniLcmApi fwdataApi,

//todo sync complex form types, parts of speech, semantic domains, writing systems

var currentFwDataPartsOfSpeech = await fwdataApi.GetPartsOfSpeech().ToArrayAsync();
var crdtChanges = await PartOfSpeechSync.Sync(currentFwDataPartsOfSpeech, projectSnapshot.PartsOfSpeech, crdtApi);
var fwdataChanges = await PartOfSpeechSync.Sync(await crdtApi.GetPartsOfSpeech().ToArrayAsync(), currentFwDataPartsOfSpeech, fwdataApi);

var currentFwDataEntries = await fwdataApi.GetEntries().ToArrayAsync();
var crdtChanges = await EntrySync.Sync(currentFwDataEntries, projectSnapshot.Entries, crdtApi);
crdtChanges += await EntrySync.Sync(currentFwDataEntries, projectSnapshot.Entries, crdtApi);
LogDryRun(crdtApi, "crdt");

var fwdataChanges = await EntrySync.Sync(await crdtApi.GetEntries().ToArrayAsync(), currentFwDataEntries, fwdataApi);
fwdataChanges += await EntrySync.Sync(await crdtApi.GetEntries().ToArrayAsync(), currentFwDataEntries, fwdataApi);
LogDryRun(fwdataApi, "fwdata");

//todo push crdt changes to lexbox
Expand All @@ -73,7 +77,7 @@ private void LogDryRun(IMiniLcmApi api, string type)
logger.LogInformation($"Dry run {type} changes: {dryRunApi.DryRunRecords.Count}");
}

public record ProjectSnapshot(Entry[] Entries);
public record ProjectSnapshot(Entry[] Entries, PartOfSpeech[] PartsOfSpeech);

private async Task<ProjectSnapshot?> GetProjectSnapshot(string projectName, string? projectPath)
{
Expand Down
18 changes: 17 additions & 1 deletion backend/FwLite/FwLiteProjectSync/DryRunMiniLcmApi.cs
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,25 @@ public IAsyncEnumerable<PartOfSpeech> GetPartsOfSpeech()
return api.GetPartsOfSpeech();
}

public Task CreatePartOfSpeech(PartOfSpeech partOfSpeech)
public Task<PartOfSpeech?> GetPartOfSpeech(Guid id)
{
return api.GetPartOfSpeech(id);
}

public Task<PartOfSpeech> CreatePartOfSpeech(PartOfSpeech partOfSpeech)
{
DryRunRecords.Add(new DryRunRecord(nameof(CreatePartOfSpeech), $"Create part of speech {partOfSpeech.Name}"));
return Task.FromResult(partOfSpeech); // Since this is a dry run, api.GetPartOfSpeech would return null
}
public Task<PartOfSpeech> UpdatePartOfSpeech(Guid id, UpdateObjectInput<PartOfSpeech> update)
{
DryRunRecords.Add(new DryRunRecord(nameof(UpdatePartOfSpeech), $"Update part of speech {id}"));
return GetPartOfSpeech(id)!;
}

public Task DeletePartOfSpeech(Guid id)
{
DryRunRecords.Add(new DryRunRecord(nameof(DeletePartOfSpeech), $"Delete part of speech {id}"));
return Task.CompletedTask;
}

Expand Down
24 changes: 22 additions & 2 deletions backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs
Original file line number Diff line number Diff line change
Expand Up @@ -77,9 +77,29 @@ public IAsyncEnumerable<PartOfSpeech> GetPartsOfSpeech()
return PartsOfSpeech.AsAsyncEnumerable();
}

public async Task CreatePartOfSpeech(PartOfSpeech partOfSpeech)
public Task<PartOfSpeech?> GetPartOfSpeech(Guid id)
{
await dataModel.AddChange(ClientId, new CreatePartOfSpeechChange(partOfSpeech.Id, partOfSpeech.Name, false));
return dataModel.GetLatest<PartOfSpeech>(id);
}

public async Task<PartOfSpeech> CreatePartOfSpeech(PartOfSpeech partOfSpeech)
{
await dataModel.AddChange(ClientId, new CreatePartOfSpeechChange(partOfSpeech.Id, partOfSpeech.Name, partOfSpeech.Predefined));
return await GetPartOfSpeech(partOfSpeech.Id) ?? throw new NullReferenceException();
}

public async Task<PartOfSpeech> UpdatePartOfSpeech(Guid id, UpdateObjectInput<PartOfSpeech> update)
{
var pos = await GetPartOfSpeech(id);
if (pos is null) throw new NullReferenceException($"unable to find part of speech with id {id}");

await dataModel.AddChanges(ClientId, [..pos.ToChanges(update.Patch)]);
return await GetPartOfSpeech(id) ?? throw new NullReferenceException();
}

public async Task DeletePartOfSpeech(Guid id)
{
await dataModel.AddChange(ClientId, new DeleteChange<PartOfSpeech>(id));
}

public IAsyncEnumerable<MiniLcm.Models.SemanticDomain> GetSemanticDomains()
Expand Down
6 changes: 6 additions & 0 deletions backend/FwLite/LcmCrdt/Objects/JsonPatchChangeExtractor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -148,4 +148,10 @@ IChange RewriteComplexFormComponents(IList<ComplexFormComponent> components, Com
if (patch.Operations.Count > 0)
yield return new JsonPatchChange<Entry>(entry.Id, patch);
}

public static IEnumerable<IChange> ToChanges(this PartOfSpeech pos, JsonPatchDocument<PartOfSpeech> patch)
{
if (patch.Operations.Count > 0)
yield return new JsonPatchChange<PartOfSpeech>(pos.Id, patch);
}
}
1 change: 1 addition & 0 deletions backend/FwLite/MiniLcm/IMiniLcmReadApi.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ public interface IMiniLcmReadApi
IAsyncEnumerable<Entry> GetEntries(QueryOptions? options = null);
IAsyncEnumerable<Entry> SearchEntries(string query, QueryOptions? options = null);
Task<Entry?> GetEntry(Guid id);
Task<PartOfSpeech?> GetPartOfSpeech(Guid id);
}

public record QueryOptions(
Expand Down
4 changes: 3 additions & 1 deletion backend/FwLite/MiniLcm/IMiniLcmWriteApi.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@ Task<WritingSystem> UpdateWritingSystem(WritingSystemId id,
UpdateObjectInput<WritingSystem> update);


Task CreatePartOfSpeech(PartOfSpeech partOfSpeech);
Task<PartOfSpeech> CreatePartOfSpeech(PartOfSpeech partOfSpeech);
Task<PartOfSpeech> UpdatePartOfSpeech(Guid id, UpdateObjectInput<PartOfSpeech> update);
Task DeletePartOfSpeech(Guid id);
Task CreateSemanticDomain(SemanticDomain semanticDomain);
Task<ComplexFormType> CreateComplexFormType(ComplexFormType complexFormType);

Expand Down
3 changes: 2 additions & 1 deletion backend/FwLite/MiniLcm/Models/PartOfSpeech.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
public class PartOfSpeech : IObjectWithId
{
public Guid Id { get; set; }
public MultiString Name { get; set; } = new();
public virtual MultiString Name { get; set; } = new();
// TODO: Probably need Abbreviation in order to match LCM data model

public DateTimeOffset? DeletedAt { get; set; }
public bool Predefined { get; set; }
Expand Down
47 changes: 47 additions & 0 deletions backend/FwLite/MiniLcm/SyncHelpers/PartOfSpeechSync.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
using MiniLcm;
using MiniLcm.Models;
using MiniLcm.SyncHelpers;
using SystemTextJsonPatch;

public static class PartOfSpeechSync
{
public static async Task<int> Sync(PartOfSpeech[] currentPartsOfSpeech,
PartOfSpeech[] previousPartsOfSpeech,
IMiniLcmApi api)
{
return await DiffCollection.Diff(api,
previousPartsOfSpeech,
currentPartsOfSpeech,
pos => pos.Id,
async (api, currentPos) =>
{
await api.CreatePartOfSpeech(currentPos);
return 1;
},
async (api, previousPos) =>
{
await api.DeletePartOfSpeech(previousPos.Id);
return 1;
},
async (api, previousPos, currentPos) =>
{
var updateObjectInput = PartOfSpeechDiffToUpdate(previousPos, currentPos);
if (updateObjectInput is not null) await api.UpdatePartOfSpeech(currentPos.Id, updateObjectInput);
return updateObjectInput is null ? 0 : 1;
});
}

public static UpdateObjectInput<PartOfSpeech>? PartOfSpeechDiffToUpdate(PartOfSpeech previousPartOfSpeech, PartOfSpeech currentPartOfSpeech)
{
JsonPatchDocument<PartOfSpeech> patchDocument = new();
patchDocument.Operations.AddRange(MultiStringDiff.GetMultiStringDiff<PartOfSpeech>(nameof(PartOfSpeech.Name),
previousPartOfSpeech.Name,
currentPartOfSpeech.Name));
// TODO: Once we add abbreviations to MiniLcm's PartOfSpeech objects, then:
// patchDocument.Operations.AddRange(GetMultiStringDiff<PartOfSpeech>(nameof(PartOfSpeech.Abbreviation),
// previousPartOfSpeech.Abbreviation,
// currentPartOfSpeech.Abbreviation));
if (patchDocument.Operations.Count == 0) return null;
return new UpdateObjectInput<PartOfSpeech>(patchDocument);
}
}

0 comments on commit 253403b

Please sign in to comment.