Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions backend/FwLite/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs
Original file line number Diff line number Diff line change
Expand Up @@ -731,9 +731,9 @@ private ComplexFormComponent ToEntryReference(ILexEntry component, ILexEntry com
return new ComplexFormComponent
{
ComponentEntryId = component.Guid,
ComponentHeadword = component.LexEntryHeadwordOrUnknown(applyMorphTokens: false), // match CRDT for now
ComponentHeadword = component.LexEntryHeadwordOrUnknown(),
ComplexFormEntryId = complexEntry.Guid,
ComplexFormHeadword = complexEntry.LexEntryHeadwordOrUnknown(applyMorphTokens: false), // match CRDT for now
ComplexFormHeadword = complexEntry.LexEntryHeadwordOrUnknown(),
Order = Order(component, complexEntry)
};
}
Expand All @@ -744,9 +744,9 @@ private ComplexFormComponent ToSenseReference(ILexSense componentSense, ILexEntr
{
ComponentEntryId = componentSense.Entry.Guid,
ComponentSenseId = componentSense.Guid,
ComponentHeadword = componentSense.Entry.LexEntryHeadwordOrUnknown(applyMorphTokens: false), // match CRDT for now
ComponentHeadword = componentSense.Entry.LexEntryHeadwordOrUnknown(),
ComplexFormEntryId = complexEntry.Guid,
ComplexFormHeadword = complexEntry.LexEntryHeadwordOrUnknown(applyMorphTokens: false), // match CRDT for now
ComplexFormHeadword = complexEntry.LexEntryHeadwordOrUnknown(),
Order = Order(componentSense, complexEntry)
};
}
Expand Down
13 changes: 6 additions & 7 deletions backend/FwLite/FwLiteProjectSync.Tests/EntrySyncTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -361,13 +361,12 @@ public async Task CanSyncRandomEntries(ApiType? roundTripApiType)
.For(e => e.Components).Exclude(c => c.Order)
.For(e => e.ComplexForms).Exclude(c => c.Order)
.For(e => e.Senses).For(s => s.ExampleSentences).Exclude(e => e.Order);
if (currentApiType == ApiType.Crdt)
{
// does not yet update Headwords 😕
options = options
.For(e => e.Components).Exclude(c => c.ComplexFormHeadword)
.For(e => e.ComplexForms).Exclude(c => c.ComponentHeadword);
}
// ComplexFormHeadword/ComponentHeadword are derived live from the referenced entry on read,
// so they don't round-trip the randomly-generated values here; their behaviour is asserted in
// ComplexFormComponentTestsBase (see ComplexFormComponentHeadwords_UpdateWhenReferencedEntriesChange).
options = options
.For(e => e.Components).Exclude(c => c.ComplexFormHeadword)
.For(e => e.ComplexForms).Exclude(c => c.ComponentHeadword);
if (currentApiType == ApiType.FwData)
{
// does not support changing MorphType yet (see UpdateEntryProxy.MorphType)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103375,7 +103375,7 @@
"ComplexFormHeadword": "ubaba",
"ComponentEntryId": "f1c350fe-ebc2-41f0-9e5d-4e06bb47ccce",
"ComponentSenseId": null,
"ComponentHeadword": "u"
"ComponentHeadword": "u-"
}
],
"ComplexFormTypes": [],
Expand Down Expand Up @@ -103791,7 +103791,7 @@
"ComplexFormHeadword": "ubaba",
"ComponentEntryId": "f1c350fe-ebc2-41f0-9e5d-4e06bb47ccce",
"ComponentSenseId": null,
"ComponentHeadword": "u"
"ComponentHeadword": "u-"
}
],
"ComplexForms": [],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@
"Order": 1,
"DeletedAt": null,
"ComplexFormEntryId": "75050583-70d7-4605-a6e6-872b8f36c482",
"ComplexFormHeadword": "Orange",
"ComplexFormHeadword": null,
"ComponentEntryId": "00edefeb-173e-45cd-836c-eea2ba514429",
"ComponentSenseId": null,
"ComponentHeadword": "de"
Expand Down Expand Up @@ -238,7 +238,7 @@
"Order": 1,
"DeletedAt": null,
"ComplexFormEntryId": "75050583-70d7-4605-a6e6-872b8f36c482",
"ComplexFormHeadword": "Orange",
"ComplexFormHeadword": null,
"ComponentEntryId": "00edefeb-173e-45cd-836c-eea2ba514429",
"ComponentSenseId": null,
"ComponentHeadword": "de"
Expand Down
110 changes: 110 additions & 0 deletions backend/FwLite/LcmCrdt.Tests/Data/QueryHelperTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
using LcmCrdt.Data;
using LinqToDB;
using LinqToDB.EntityFrameworkCore;

namespace LcmCrdt.Tests.Data;

public class QueryHelperTests: IAsyncLifetime
{
private readonly MiniLcmApiFixture _fixture = new();
private IMiniLcmApi Api => _fixture.Api;

public Task InitializeAsync()
{
return _fixture.InitializeAsync();
}
public Task DisposeAsync()
{
return _fixture.DisposeAsync();
}

[Fact]
public async Task QueryEntryMorphType()
{
var pluralEntry = await Api.CreateEntry(new Entry() { LexemeForm = { { "en", "s" } }, MorphType = MorphTypeKind.Suffix, });
var morphType = await _fixture.DbContext.Entries.Where(e => e.Id == pluralEntry.Id)
.Select(e => e.QueryMorphType())
.SingleAsyncLinqToDB();
morphType.Should().NotBeNull();
morphType.Kind.Should().Be(MorphTypeKind.Suffix);
}

[Fact]
public async Task QueryEntryHeadword()
{
var pluralEntry = await Api.CreateEntry(new Entry() { LexemeForm = { { "en", "s" } }, MorphType = MorphTypeKind.Suffix, });
(await _fixture.DbContext.Entries.Where(e => e.Id == pluralEntry.Id).Select(e => e.QueryHeadwordWithTokens(EntryQueryHelpers.DefaultWritingSystem(WritingSystemType.Vernacular)))
.SingleAsyncLinqToDB())
.Should().Be("-s");

var appleEntry = await Api.CreateEntry(new Entry() { LexemeForm = { { "en", "apple" } }, MorphType = MorphTypeKind.Stem });
(await _fixture.DbContext.Entries.Where(e => e.Id == appleEntry.Id)
.Select(e => e.QueryHeadwordWithTokens(EntryQueryHelpers.DefaultWritingSystem(WritingSystemType.Vernacular)))
.SingleAsyncLinqToDB())
.Should().Be("apple");
}

[Fact]
public async Task QueryComplexFormComponentEntry()
{
var appleEntry = await Api.CreateEntry(new Entry() { LexemeForm = { { "en", "apple" } }, });
var pluralEntry = await Api.CreateEntry(new Entry()
{
LexemeForm = { { "en", "s" } }, MorphType = MorphTypeKind.Suffix,
});
var applesEntry = await Api.CreateEntry(new Entry() { LexemeForm = { { "en", "apples" } }, });
await Api.CreateComplexFormComponent(ComplexFormComponent.FromEntries(applesEntry, appleEntry));
var pluralComponent = await Api.CreateComplexFormComponent(ComplexFormComponent.FromEntries(applesEntry, pluralEntry));
var componentEntry = await _fixture.DbContext.ComplexFormComponents
.Where(p => p.Id == pluralComponent.Id)
.Select(p => EntryQueryHelpers.QueryComponentEntry(p))
.SingleAsyncLinqToDB();

componentEntry.Should().BeEquivalentTo(pluralEntry);

var complexEntry = await _fixture.DbContext.ComplexFormComponents
.Where(p => p.Id == pluralComponent.Id)
.Select(p => EntryQueryHelpers.QueryComplexFormEntry(p))
.SingleAsyncLinqToDB();

complexEntry.Should().BeEquivalentTo(applesEntry);
}

[Fact]
public async Task QueryComponentHeadword()
{
var appleEntry =
await Api.CreateEntry(new Entry() { LexemeForm = { { "en", "apple" } }, MorphType = MorphTypeKind.Stem });
var pluralEntry = await Api.CreateEntry(new Entry()
{
LexemeForm = { { "en", "s" } }, MorphType = MorphTypeKind.Suffix,
});
var complexEntry = await Api.CreateEntry(new Entry()
{
LexemeForm = { { "en", "apples" } },
MorphType = MorphTypeKind.Stem
});

var appleComponent = await Api.CreateComplexFormComponent(ComplexFormComponent.FromEntries(complexEntry, appleEntry));
var appleComponentFromDb = await _fixture.DbContext.ComplexFormComponents.SingleAsyncLinqToDB(p => p.Id == appleComponent.Id);
appleComponentFromDb.ComponentHeadword.Should().Be("apple");
appleComponentFromDb.ComplexFormHeadword.Should().Be("apples");

var pluralComponent = await Api.CreateComplexFormComponent(ComplexFormComponent.FromEntries(complexEntry, pluralEntry));
var pluralComponentFromDb = await _fixture.DbContext.ComplexFormComponents.SingleAsyncLinqToDB(p => p.Id == pluralComponent.Id);
pluralComponentFromDb.ComponentHeadword.Should().Be("-s");
pluralComponentFromDb.ComplexFormHeadword.Should().Be("apples");

var apples = await _fixture.DbContext.Entries
.LoadWith(e => e.ComplexForms)
.LoadWith(e => e.Components)
.SingleOrDefaultAsyncLinqToDB(e => e.Id == complexEntry.Id);

apples.Should().NotBeNull();
apples.Components.Should().NotBeNull();
apples.Components.Should().HaveCount(2);

apples.Components[0].ComponentHeadword.Should().Be("apple");
apples.Components[1].ComponentHeadword.Should().Be("-s");
}
}
64 changes: 59 additions & 5 deletions backend/FwLite/LcmCrdt/Data/EntryQueryHelpers.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System.Linq.Expressions;
using LinqToDB;
using LinqToDB.DataProvider.SQLite;

namespace LcmCrdt.Data;

Expand All @@ -18,6 +19,59 @@ public static string Headword(this Entry e, WritingSystemId ws)
? (Json.Value(e.LexemeForm, ms => ms[ws]) ?? "").Trim()
: (Json.Value(e.CitationForm, ms => ms[ws]) ?? "").Trim();

/// <summary>
/// https://sqlite.org/lang_corefunc.html#concat_ws
/// like string.join, but if the separator is null, it returns null
/// </summary>
[Sql.Function("concat_ws", ServerSideOnly = true, ArgIndices = [1, 2, 3])]
public static string? ConcatWs(this ISQLiteExtensions? ext, string? sep, string? val1, string? val2)
{
if (sep is null) return null;
return string.Join(sep, val1, val2);
}

/// <summary>
/// https://sqlite.org/lang_corefunc.html#nullif
/// returns the value, unless it's equal to other, then it returns null
/// </summary>
[Sql.Function("nullif", ServerSideOnly = true, ArgIndices = [1, 2])]
public static T? NullIf<T>(this ISQLiteExtensions? ext, T? value, T? other) =>
(value is null && other is null) || value?.Equals(other) == true ? default : value;

public static MorphType? QueryMorphType(this Entry e) => throw new NotSupportedException();
public static Entry? QueryComponentEntry(ComplexFormComponent c) => throw new NotSupportedException();
public static Sense? QueryComponentSense(ComplexFormComponent c) => throw new NotSupportedException();
public static Entry? QueryComplexFormEntry(ComplexFormComponent c) => throw new NotSupportedException();

[Sql.Expression("""
(select WsId
from WritingSystem
where Type = {0}
order by "Order", Id
limit 1)
""",
ServerSideOnly = true)]
public static WritingSystemId DefaultWritingSystem(WritingSystemType type) => throw new NotSupportedException();

[ExpressionMethod(nameof(QueryHeadwordWithTokensExpression))]
public static string QueryHeadwordWithTokens(this Entry e, WritingSystemId ws) => throw new NotSupportedException();

private static Expression<Func<Entry, WritingSystemId, string>> QueryHeadwordWithTokensExpression() =>
(e, ws) => e.HeadwordWithTokens(ws, e.QueryMorphType());

[ExpressionMethod(nameof(HeadwordFromMorphTypeExpression))]
public static string HeadwordWithTokens(this Entry e, WritingSystemId ws, MorphType? morphType)
{
var citation = e.CitationForm[ws]?.Trim();
if (!string.IsNullOrEmpty(citation)) return citation;
var lexeme = e.LexemeForm[ws]?.Trim();
if (string.IsNullOrEmpty(lexeme)) return string.Empty;
return ((morphType?.Prefix ?? "") + lexeme + (morphType?.Postfix ?? "")).Trim();
}

private static Expression<Func<Entry, WritingSystemId, MorphType?, string>> HeadwordFromMorphTypeExpression() =>
(e, ws, morphType) => e.HeadwordWithTokens(ws, morphType!.Prefix, morphType!.Postfix);

[ExpressionMethod(nameof(HeadwordWithTokensExpression))]
public static string HeadwordWithTokens(this Entry e, WritingSystemId ws, string? leading, string? trailing)
{
Expand All @@ -30,11 +84,11 @@ public static string HeadwordWithTokens(this Entry e, WritingSystemId ws, string

private static Expression<Func<Entry, WritingSystemId, string?, string?, string?>> HeadwordWithTokensExpression() =>
(e, ws, leading, trailing) =>
string.IsNullOrEmpty((Json.Value(e.CitationForm, ms => ms[ws]) ?? "").Trim())
? string.IsNullOrEmpty((Json.Value(e.LexemeForm, ms => ms[ws]) ?? "").Trim())
? ""
: ((leading ?? "") + (Json.Value(e.LexemeForm, ms => ms[ws]) ?? "").Trim() + (trailing ?? "")).Trim()
: Json.Value(e.CitationForm, ms => ms[ws])!.Trim();
Sql.Ext.SQLite().NullIf(Json.Value(e.CitationForm, ms => ms[ws])!.Trim(), "")
//hack using concat_ws which if the first parameter is null it will return null, if not it will join leading and trailing with the separator
//there's a bug in the version of sqlite that we're using https://sqlite.org/forum/forumpost/52503ac21d
//so instead of leading ?? "" we must use leading ?? " " and trim off the trailing space, same for trailing
?? Sql.Ext.SQLite().ConcatWs(Sql.Ext.SQLite().NullIf(Json.Value(e.LexemeForm, ms => ms[ws])!.Trim(), ""), leading ?? " ", trailing ?? " ")!.Trim();
Comment thread
hahn-kev marked this conversation as resolved.

[ExpressionMethod(nameof(SearchHeadwords))]
public static bool SearchHeadwords(this Entry e, string? leading, string? trailing, string query)
Expand Down
6 changes: 6 additions & 0 deletions backend/FwLite/LcmCrdt/LcmCrdtKernel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,12 @@ public static void ConfigureDbOptions(IServiceProvider provider, DbContextOption
//rather than the real IList<T> columns; see Entry.PublishInRows for why.
.Entity<Sense>().Property(s => s.SemanticDomainRows).IsExpression(SenseSemanticDomainRowsExpression(), isColumn: false)
.Entity<Entry>().Property(e => e.PublishInRows).IsExpression(EntryPublishInRowsExpression(), isColumn: false)
.Entity<Entry>().Association(e => e.QueryMorphType(), e => e.MorphType, m => m!.Kind)
.Entity<ComplexFormComponent>().Association(c => EntryQueryHelpers.QueryComponentEntry(c), c => c.ComponentEntryId, e => e!.Id)
.Entity<ComplexFormComponent>().Association(c => EntryQueryHelpers.QueryComponentSense(c), c => c.ComponentSenseId, s => s!.Id)
.Entity<ComplexFormComponent>().Association(c => EntryQueryHelpers.QueryComplexFormEntry(c), c => c.ComplexFormEntryId, e => e!.Id)
.Entity<ComplexFormComponent>().Property(c => c.ComponentHeadword).IsExpression(c => EntryQueryHelpers.QueryComponentEntry(c)!.QueryHeadwordWithTokens(EntryQueryHelpers.DefaultWritingSystem(WritingSystemType.Vernacular)), isColumn: true, alias: "componentHeadword")
.Entity<ComplexFormComponent>().Property(c => c.ComplexFormHeadword).IsExpression(c => EntryQueryHelpers.QueryComplexFormEntry(c)!.QueryHeadwordWithTokens(EntryQueryHelpers.DefaultWritingSystem(WritingSystemType.Vernacular)), isColumn: true, alias: "complexFormHeadword")
.Entity<RichString>().Member(r => r.GetPlainText()).IsExpression(r => Json.GetPlainText(r))
.Entity<Guid>().Member(g => g.ToString()).IsExpression(g => Json.ToString(g))
.Build();
Expand Down
31 changes: 31 additions & 0 deletions backend/FwLite/MiniLcm.Tests/ComplexFormComponentTestsBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,37 @@ public async Task CreateComplexFormComponent_Works()
component.ComponentHeadword.Should().Be("component");
}

[Fact]
public async Task ComplexFormComponentHeadwords_UpdateWhenReferencedEntriesChange()
{
// a suffix component so the headword carries a morph token ("-s"), not just the bare form
var suffix = await Api.CreateEntry(new()
{
Id = Guid.NewGuid(),
LexemeForm = { { "en", "s" } },
MorphType = MorphTypeKind.Suffix,
});
await Api.CreateComplexFormComponent(ComplexFormComponent.FromEntries(_complexFormEntry, suffix));

var component = (await Api.GetEntry(_complexFormEntryId))!.Components.Should().ContainSingle().Subject;
component.ComponentHeadword.Should().Be("-s");
component.ComplexFormHeadword.Should().Be("complex form");

var beforeSuffix = suffix.Copy();
suffix.LexemeForm["en"] = "es";
await Api.UpdateEntry(beforeSuffix, suffix);

var complexFormEntry = (await Api.GetEntry(_complexFormEntryId))!;
var beforeComplexForm = complexFormEntry.Copy();
complexFormEntry.LexemeForm["en"] = "renamed complex form";
await Api.UpdateEntry(beforeComplexForm, complexFormEntry);

// headwords are recomputed from the referenced entries on read (with morph tokens), not stored on the component
var updated = (await Api.GetEntry(_complexFormEntryId))!.Components.Should().ContainSingle().Subject;
updated.ComponentHeadword.Should().Be("-es");
updated.ComplexFormHeadword.Should().Be("renamed complex form");
}

[Fact]
public async Task RemoveComplexFormComponent_Works()
{
Expand Down
24 changes: 23 additions & 1 deletion backend/FwLite/MiniLcm.Tests/QueryEntryTestsBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ namespace MiniLcm.Tests;

public abstract class QueryEntryTestsBase : MiniLcmTestBase
{
private readonly Guid appleId = Guid.NewGuid();
protected readonly Guid appleId = Guid.NewGuid();
private readonly string Apple = "Apple";
private readonly string Peach = "Peach";
private readonly string Banana = "Banana";
Expand Down Expand Up @@ -118,6 +118,28 @@ public async Task Get_ExistingEntry_ReturnsEntry()
entry.LexemeForm["en"].Should().Be(Apple);
}

[Fact]
public async Task Get_EntryWithComponents_ComponentHeadwordIncludesMorphToken()
{
var pluralEntry = await Api.CreateEntry(new Entry()
{
LexemeForm = { { "en", "s" } }, MorphType = MorphTypeKind.Suffix,
});
var appleEntry = await Api.GetEntry(appleId);
appleEntry.Should().NotBeNull();
var complexForm = await Api.CreateEntry(new Entry() { LexemeForm = { { "en", "apples" } }, });
await Api.CreateComplexFormComponent(ComplexFormComponent.FromEntries(complexForm, appleEntry));
await Api.CreateComplexFormComponent(ComplexFormComponent.FromEntries(complexForm, pluralEntry));
var result = await Api.GetEntry(complexForm.Id);
result.Should().NotBeNull();
result.Components.Should().HaveCount(2);

result.Components[0].ComponentHeadword.Should().Be("Apple");
result.Components[0].ComplexFormHeadword.Should().Be("apples");
result.Components[1].ComponentHeadword.Should().Be("-s");
result.Components[1].ComplexFormHeadword.Should().Be("apples");
}

[Fact]
public async Task CanFilterToMissingSenses()
{
Expand Down
Loading