From a9041cd0140d6bdec60593fda80a3b617f11d8d8 Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Wed, 20 May 2026 17:37:24 +0200 Subject: [PATCH 01/21] =?UTF-8?q?Bump=20linq2db=20v5=E2=86=92v6=20and=20EF?= =?UTF-8?q?=20Core=209=E2=86=9210=20package=20versions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit linq2db.AspNet+linq2db.EntityFrameworkCore 9.0.0 (v5 API) → linq2db.Extensions 6.2.1 + linq2db.EntityFrameworkCore 10.3.0 (v6 API). EF Core 9.0.x → 10.0.7, Npgsql 9→10, NeinLinq 7.3→7.4, OpenIddict 7.1→7.5, Gridify 2.17→2.19. Robin's PR #2264 handled everything else in the .NET 10 bump; this PR covers only the linq2db/EF Core upgrade and its specific workarounds. Co-Authored-By: Claude Sonnet 4.6 --- backend/Directory.Packages.props | 35 +++++++++++++++++--------------- 1 file changed, 19 insertions(+), 16 deletions(-) diff --git a/backend/Directory.Packages.props b/backend/Directory.Packages.props index 8d18832745..c54d637395 100644 --- a/backend/Directory.Packages.props +++ b/backend/Directory.Packages.props @@ -12,11 +12,14 @@ + - - + + @@ -25,8 +28,8 @@ - - + + @@ -35,16 +38,16 @@ - - - + + + - + @@ -70,16 +73,16 @@ - - + + - - - + + + - - - + + + From 8d2b22aafbed7995ffaa164d6bb78bcbdd4249ed Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Wed, 20 May 2026 17:37:42 +0200 Subject: [PATCH 02/21] Fix linq2db v6 query regressions in LcmCrdt via shadow properties linq2db v6 changed how json_each expressions are translated: it now fires ExpressionMethodAttribute substitutions at materialization time as well as in queries, which breaks the IList jsonb columns (SemanticDomains, PublishIn). Shadow EF Core properties carry the raw JSON value into the linq2db query layer so json_each rewrites work without affecting entity materialization. See LINQ2DB-V6-NOTES.md for the full investigation and attempt history. Co-Authored-By: Claude Sonnet 4.6 --- .../Changes/CreateExampleSentenceChange.cs | 2 +- backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs | 1 + backend/FwLite/LcmCrdt/CrdtProjectsService.cs | 14 +- .../FwLite/LcmCrdt/CurrentProjectService.cs | 6 +- .../FwLite/LcmCrdt/Data/EntryQueryHelpers.cs | 4 +- .../FwLite/LcmCrdt/Data/MiniLcmRepository.cs | 1 + .../FwLite/LcmCrdt/EntryFilterMapProvider.cs | 12 +- .../FullTextSearch/EntrySearchService.cs | 18 +- backend/FwLite/LcmCrdt/HistoryService.cs | 3 +- backend/FwLite/LcmCrdt/Json.cs | 22 +- backend/FwLite/LcmCrdt/LINQ2DB-V6-NOTES.md | 308 ++++++++++++++++++ backend/FwLite/LcmCrdt/LcmCrdt.csproj | 3 +- backend/FwLite/LcmCrdt/LcmCrdtKernel.cs | 25 +- .../DbTranslationDeserializationTarget.cs | 2 +- .../FwLite/LcmCrdt/Objects/PreDefinedData.cs | 51 +-- 15 files changed, 387 insertions(+), 85 deletions(-) create mode 100644 backend/FwLite/LcmCrdt/LINQ2DB-V6-NOTES.md diff --git a/backend/FwLite/LcmCrdt/Changes/CreateExampleSentenceChange.cs b/backend/FwLite/LcmCrdt/Changes/CreateExampleSentenceChange.cs index 8d9dbebd73..9804ec65be 100644 --- a/backend/FwLite/LcmCrdt/Changes/CreateExampleSentenceChange.cs +++ b/backend/FwLite/LcmCrdt/Changes/CreateExampleSentenceChange.cs @@ -1,6 +1,6 @@ using System.ComponentModel; using System.Text.Json.Serialization; -using LinqToDB.Common; +using LinqToDB.Internal.Common; using SIL.Harmony; using SIL.Harmony.Changes; using SIL.Harmony.Core; diff --git a/backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs b/backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs index 5bd78128d0..00efaa550e 100644 --- a/backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs +++ b/backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs @@ -10,6 +10,7 @@ using LcmCrdt.MediaServer; using LcmCrdt.Objects; using LinqToDB; +using LinqToDB.Async; using LinqToDB.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; diff --git a/backend/FwLite/LcmCrdt/CrdtProjectsService.cs b/backend/FwLite/LcmCrdt/CrdtProjectsService.cs index f6ab9cf490..08c03b2455 100644 --- a/backend/FwLite/LcmCrdt/CrdtProjectsService.cs +++ b/backend/FwLite/LcmCrdt/CrdtProjectsService.cs @@ -169,9 +169,9 @@ public virtual async Task CreateProject(CreateProjectRequest reques // Morph types are predefined system data that must always exist — seed them // unconditionally so they're available before AfterCreate (e.g. import) runs. var dataModel = serviceScope.ServiceProvider.GetRequiredService(); - await PreDefinedData.AddPredefinedMorphTypes(dataModel, projectData); + await PreDefinedData.AddPredefinedMorphTypes(dataModel, projectData.ClientId); if (request.SeedNewProjectData) - await SeedSystemData(dataModel, projectData); + await SeedSystemData(dataModel, projectData.ClientId); await (request.AfterCreate?.Invoke(serviceScope.ServiceProvider, crdtProject) ?? Task.CompletedTask); } catch (Exception e) @@ -243,13 +243,13 @@ internal static async Task InitProjectDb(LcmCrdtDbContext db, ProjectData data) await db.SaveChangesAsync(); } - internal static async Task SeedSystemData(DataModel dataModel, ProjectData projectData) + internal static async Task SeedSystemData(DataModel dataModel, Guid clientId) { // Note: AddPredefinedMorphTypes is seeded unconditionally in CreateProject, not here. - await PreDefinedData.AddPredefinedComplexFormTypes(dataModel, projectData); - await PreDefinedData.AddPredefinedPartsOfSpeech(dataModel, projectData); - await PreDefinedData.AddPredefinedSemanticDomains(dataModel, projectData); - await PreDefinedData.AddPredefinedCustomViews(dataModel, projectData); + await PreDefinedData.AddPredefinedComplexFormTypes(dataModel, clientId); + await PreDefinedData.AddPredefinedPartsOfSpeech(dataModel, clientId); + await PreDefinedData.AddPredefinedSemanticDomains(dataModel, clientId); + await PreDefinedData.AddPredefinedCustomViews(dataModel, clientId); } [GeneratedRegex("^[a-zA-Z0-9][a-zA-Z0-9-_]+$")] diff --git a/backend/FwLite/LcmCrdt/CurrentProjectService.cs b/backend/FwLite/LcmCrdt/CurrentProjectService.cs index 36a886c5a5..90a57f8d70 100644 --- a/backend/FwLite/LcmCrdt/CurrentProjectService.cs +++ b/backend/FwLite/LcmCrdt/CurrentProjectService.cs @@ -111,11 +111,11 @@ async Task Execute() // Seed morph-types if missing (for existing projects created before morph-type support). // Must happen BEFORE FTS regeneration so headwords include morph-type tokens. // (querying Commits instead of MorphTypes, because the commit may not be projected yet) - var projectData = await dbContext.ProjectData.AsNoTracking().FirstAsync(); - if (!await dbContext.Set().AsNoTracking().AnyAsync(c => c.Id == PreDefinedData.MorphTypesSeedCommitId(projectData.Id))) + if (!await dbContext.Set().AsNoTracking().AnyAsync(c => c.Id == PreDefinedData.MorphTypesSeedCommitId)) { var dataModel = services.GetRequiredService(); - await PreDefinedData.AddPredefinedMorphTypes(dataModel, projectData); + var projectData = await dbContext.ProjectData.AsNoTracking().FirstAsync(); + await PreDefinedData.AddPredefinedMorphTypes(dataModel, projectData.ClientId); } if (EntrySearchServiceFactory is not null) diff --git a/backend/FwLite/LcmCrdt/Data/EntryQueryHelpers.cs b/backend/FwLite/LcmCrdt/Data/EntryQueryHelpers.cs index 37eb983a7e..69b7662ffa 100644 --- a/backend/FwLite/LcmCrdt/Data/EntryQueryHelpers.cs +++ b/backend/FwLite/LcmCrdt/Data/EntryQueryHelpers.cs @@ -47,11 +47,13 @@ public static bool SearchHeadwords(this Entry e, string? leading, string? traili private static Expression> SearchHeadwords() { + //Use Sql.Expr to spell the cross-scope path access explicitly: linq2db v6 + //emits `[kv].*` instead of `[kv].[key]` if we route this through Json.Value. return (e, leading, trailing, query) => Json.QueryValues(e.CitationForm).Any( v => SqlHelpers.ContainsIgnoreCaseAccents(v, query)) || Json.QueryEntries(e.LexemeForm).Any(kv => - string.IsNullOrEmpty((Json.Value(e.CitationForm, ms => ms[kv.Key]) ?? "").Trim()) && + string.IsNullOrEmpty((Sql.Expr($"{e.CitationForm}->>{kv.Key}") ?? "").Trim()) && SqlHelpers.ContainsIgnoreCaseAccents((leading ?? "") + (kv.Value ?? "").Trim() + (trailing ?? ""), query)); } diff --git a/backend/FwLite/LcmCrdt/Data/MiniLcmRepository.cs b/backend/FwLite/LcmCrdt/Data/MiniLcmRepository.cs index c1a8cf690a..8b3273b6da 100644 --- a/backend/FwLite/LcmCrdt/Data/MiniLcmRepository.cs +++ b/backend/FwLite/LcmCrdt/Data/MiniLcmRepository.cs @@ -4,6 +4,7 @@ using LcmCrdt.FullTextSearch; using LcmCrdt.Utils; using LinqToDB; +using LinqToDB.Async; using LinqToDB.EntityFrameworkCore; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; diff --git a/backend/FwLite/LcmCrdt/EntryFilterMapProvider.cs b/backend/FwLite/LcmCrdt/EntryFilterMapProvider.cs index 80b94417da..a5b3f5b62d 100644 --- a/backend/FwLite/LcmCrdt/EntryFilterMapProvider.cs +++ b/backend/FwLite/LcmCrdt/EntryFilterMapProvider.cs @@ -7,12 +7,10 @@ public class EntryFilterMapProvider : EntryFilterMapProvider { public override Expression> EntrySensesSemanticDomains => e => e.Senses.Select(s => s.SemanticDomains); public override Expression> EntrySensesSemanticDomainsCode => - //ideally we would use Json.Query(s.SemanticDomains) but Gridify doesn't support that, so we have to configure - //linq2db to rewrite this to that. - e => e.Senses.SelectMany(s => s.SemanticDomains).Select(sd => Json.Value(sd, sd => sd.Code)); + //SemanticDomainRows is the json_each rewrite target + e => e.Senses.SelectMany(s => s.SemanticDomainRows).Select(sd => Json.Value(sd, sd => sd.Code)); public override Func? EntrySensesSemanticDomainsConverter => - //linq2db treats Sense.SemanticDomains as a table, if we use "null" then it'll write the query we want - EntryFilter.NormalizeEmptyToNull; + EntryFilter.NormalizeEmptyToEmptyList; public override Expression> EntrySensesExampleSentences => e => e.Senses.Select(s => s.ExampleSentences); public override Expression> EntrySensesExampleSentencesSentence => (e, ws) => e.Senses.SelectMany(s => s.ExampleSentences).Select(example => Json.Value(example.Sentence, ms => ms[ws])!.GetPlainText()); @@ -32,6 +30,6 @@ public class EntryFilterMapProvider : EntryFilterMapProvider public override Func? EntryComplexFormTypesConverter => EntryFilter.NormalizeEmptyToEmptyList; public override Expression> EntryPublishIn => e => e.PublishIn; public override Expression> EntryPublishInId => - e => e.PublishIn.Select(p => Json.Value(p, p => p.Id.ToString())); - public override Func? EntryPublishInConverter => EntryFilter.NormalizeEmptyToNull; + e => e.PublishInRows.Select(p => Json.Value(p, p => p.Id.ToString())); + public override Func? EntryPublishInConverter => EntryFilter.NormalizeEmptyToEmptyList; } diff --git a/backend/FwLite/LcmCrdt/FullTextSearch/EntrySearchService.cs b/backend/FwLite/LcmCrdt/FullTextSearch/EntrySearchService.cs index 7a33e50f2c..b7bbc3ff83 100644 --- a/backend/FwLite/LcmCrdt/FullTextSearch/EntrySearchService.cs +++ b/backend/FwLite/LcmCrdt/FullTextSearch/EntrySearchService.cs @@ -1,6 +1,7 @@ using System.Text; using LcmCrdt.Data; using LinqToDB; +using LinqToDB.Async; using LinqToDB.Data; using LinqToDB.DataProvider.SQLite; using LinqToDB.EntityFrameworkCore; @@ -223,16 +224,12 @@ public async Task UpdateEntrySearchTable(Entry entry) private static async Task InsertOrUpdateEntrySearchRecord(EntrySearchRecord record, ITable table) { - await table.InsertOrUpdateAsync(() => new EntrySearchRecord() - { - Id = record.Id, - Headword = record.Headword, - LexemeForm = record.LexemeForm, - CitationForm = record.CitationForm, - Definition = record.Definition, - Gloss = record.Gloss, - }, - exiting => new EntrySearchRecord() + // Can't use table.InsertOrUpdateAsync here because EntrySearchRecord is a virtual table, + // and SQLite doesn't support UPSERT statements on virtual tables. Instead, we have to + // use the same DELETE+INSERT approach that Linq2DB 5 used to use (Linq2DB 6 changed this + // to a proper UPSERT, which is the correct approach most of the time... except here) + await table.DeleteAsync(e => e.Id == record.Id); + await table.InsertAsync(() => new EntrySearchRecord() { Id = record.Id, Headword = record.Headword, @@ -270,6 +267,7 @@ public static async Task UpdateEntrySearchTable(IEnumerable entries, foreach (var entrySearchRecord in searchRecords) { //can't use bulk copy here because that creates duplicate rows + //TODO: Replace this with a bulk delete followed by a bulk copy, it should be faster - 2026-05 RM await InsertOrUpdateEntrySearchRecord(entrySearchRecord, entrySearchRecordsTable); } diff --git a/backend/FwLite/LcmCrdt/HistoryService.cs b/backend/FwLite/LcmCrdt/HistoryService.cs index e730abf7eb..a7fc86a344 100644 --- a/backend/FwLite/LcmCrdt/HistoryService.cs +++ b/backend/FwLite/LcmCrdt/HistoryService.cs @@ -7,6 +7,7 @@ using LinqToDB.EntityFrameworkCore; using System.Text.RegularExpressions; using MiniLcm.Exceptions; +using LinqToDB.Async; namespace LcmCrdt; @@ -154,7 +155,7 @@ public async Task LoadChangeContext(Guid commitId, int changeInde } var affectedEntries = await GetAffectedEntryIds(change) - .SelectAwait(async entryId => await GetCurrentOrLatestEntry(entryId)) + .Select(async (Guid entryId, CancellationToken _) => await GetCurrentOrLatestEntry(entryId)) .ToArrayAsync(); return new ChangeContext(change, snapshot, affectedEntries); diff --git a/backend/FwLite/LcmCrdt/Json.cs b/backend/FwLite/LcmCrdt/Json.cs index 71dfe76294..1bc8a53aa2 100644 --- a/backend/FwLite/LcmCrdt/Json.cs +++ b/backend/FwLite/LcmCrdt/Json.cs @@ -3,7 +3,8 @@ using System.Text.Json.Serialization.Metadata; using LcmCrdt.Changes; using LinqToDB; -using LinqToDB.Common; +using LinqToDB.Internal.Common; +using LinqToDB.Internal.SqlQuery; using LinqToDB.Mapping; using LinqToDB.SqlQuery; using SIL.Harmony; @@ -14,7 +15,7 @@ public static class Json { sealed class JsonValuePathBuilder : Sql.IExtensionCallBuilder { - public void Build(Sql.ISqExtensionBuilder builder) + public void Build(Sql.ISqlExtensionBuilder builder) { var propExpression = builder.GetExpression(0); @@ -42,7 +43,7 @@ public void Build(Sql.ISqExtensionBuilder builder) parameters.Insert(0, propExpression); - var valueExpression = (ISqlExpression)new SqlExpression(typeof(string), + var valueExpression = (ISqlExpression)new SqlExpression(new DbDataType(typeof(string)), expressionStr, Precedence.Primary, parameters.ToArray()); @@ -51,7 +52,12 @@ public void Build(Sql.ISqExtensionBuilder builder) if (returnType != typeof(string) && returnType != typeof(RichString))//bypass rich string so it can be used with .GetPlainText() { - valueExpression = PseudoFunctions.MakeTryConvert(new SqlDataType(new DbDataType(returnType)), + //v6 dropped PseudoFunctions.MakeTryConvert; build the SqlFunction directly instead. + valueExpression = new SqlFunction( + new DbDataType(returnType), + PseudoFunctions.TRY_CONVERT, + canBeNull: true, + new SqlDataType(new DbDataType(returnType)), new SqlDataType(new DbDataType(typeof(string), DataType.Text)), valueExpression); } @@ -61,7 +67,7 @@ public void Build(Sql.ISqExtensionBuilder builder) private static void BuildParameterPath(Expression? pathBody, List parameters, - Sql.ISqExtensionBuilder builder) + Sql.ISqlExtensionBuilder builder) { while (pathBody is MemberExpression or MethodCallExpression or UnaryExpression) { @@ -86,6 +92,12 @@ private static void BuildParameterPath(Expression? pathBody, { pathBody = mce.Object ?? mce.Arguments[0]; } + else if (mce.Method.DeclaringType == typeof(Sql) && mce.Method.Name == "Alias") + { + //v6 wraps [ExpressionMethod] substitutions in Sql.Alias(real, "") for + //output-column aliasing; peel it so the path walker sees the underlying expr. + pathBody = mce.Arguments[0]; + } else { throw new InvalidOperationException($"Invalid property path for expression {mce}."); diff --git a/backend/FwLite/LcmCrdt/LINQ2DB-V6-NOTES.md b/backend/FwLite/LcmCrdt/LINQ2DB-V6-NOTES.md new file mode 100644 index 0000000000..0d4adf289b --- /dev/null +++ b/backend/FwLite/LcmCrdt/LINQ2DB-V6-NOTES.md @@ -0,0 +1,308 @@ +# Linq2Db v6 — shadow-property workaround in LcmCrdt + +Captured during the .NET 10 + Linq2Db 5.4 → 6.2.1 upgrade +(branch `wip/linq2db-v6-attempts`, PR +[#2264](https://github.com/sillsdev/languageforge-lexbox/pull/2264)). + +This document covers: + +1. The current working solution (**TL;DR** + **Files** sections). +2. Why v6 broke things (**Root cause**). +3. Everything we tried (**Attempt history**) — kept so the next person walking + into this doesn't repeat the dead ends. +4. What's still worth contributing upstream as community-benefit fixes + (**Upstream plan**) — lexbox no longer blocks on these. + +--- + +## TL;DR + +Two `IList` jsonb columns — `Sense.SemanticDomains` and `Entry.PublishIn` — +need a `json_each(...)` rewrite for queries (`Any`, `SelectMany`, etc.) but +*not* at entity materialization. v5 honored that split via +`ExpressionMethodAttribute(IsColumn = false)`. v6 ignores `IsColumn` and fires +the substitution at materialization too, which either casts wrong +(`EnumerableQuery` → `IList`) or invokes a `Sql.TableFunction` body +client-side. + +The fix here: + +- Add **non-column shadow properties** `Sense.SemanticDomainRows` and + `Entry.PublishInRows` in `MiniLcm/Models/`. They return the underlying list + in client context (so reflection-based deep equality and bulk-copy paths + don't trip) and have no column mapping. +- In `LcmCrdtKernel`, attach the `[ExpressionMethod]` rewrite to those shadow + properties via `FluentMappingBuilder.IsExpression(..., isColumn: false)`. + `IsExpression` also calls `IsNotColumn()`, so BulkCopy and insert paths + don't read them. +- Route Gridify filter projections through the shadow properties + (`EntryFilterMapProvider.EntryPublishInId`, + `EntrySensesSemanticDomainsCode`). +- The materialization expression doesn't reference the shadow properties, so + v6's `ExposeExpressionVisitor` never sees them — the substitution fires + only when LINQ translation hits `e.PublishInRows` in a query. + +Result: all of `LcmCrdt.Tests` passes, including the perf assertion and the +nine filter shapes that the earlier `.ToList()` workaround left red. + +--- + +## Root cause — what changed in v6 + +v6 rewrote the query parser. The +[migration wiki](https://github.com/linq2db/linq2db/wiki/Linq-To-DB-6) +makes one statement that explains both regressions: + +> *"For final query projection, linq2db doesn't try to translate it to SQL +> anymore and sets its value on the client during materialization."* + +Paired with a new "single-query preamble" strategy for eager loading +([`ExpressionBuilder.EagerLoad.cs`](https://github.com/linq2db/linq2db/blob/master/Source/LinqToDB/Internal/Linq/Builder/ExpressionBuilder.EagerLoad.cs)), +this means an `ExpressionMethodAttribute` registered on a property now fires +in two places where v5 only fired it in one: + +1. **Query translation** (where we *want* it) — `s.SemanticDomains` becomes + `Json.Query(Sql.Property<...>(s, "SemanticDomains"))`, which + `[Sql.TableFunction("json_each", argIndices: [0])]` then turns into a + `json_each(jsonb_column)` table subquery. +2. **Materialization** (where v5 didn't apply it) — after the row is read and + EF's value converter has deserialized the jsonb column to + `IList`, v6 *also* runs the substitution lambda + client-side over the deserialized list and assigns the result back into + the property. This is what causes the regressions. + +`IsColumn` on `ExpressionMethodAttribute` is documented to control exactly +this behavior (`IsColumn=false` should mean "not used during +materialization"). Verified locally that v6 ignores it: `TableBuilder.TableContext.MakeExpression` +now passes `fullEntity` through `Builder.ConvertExpressionTree`, which routes +through `ExposeExpressionVisitor` and expands every `[ExpressionMethod]` +regardless of `IsColumn`. + +### The two original regressions + +**Regression 1 — `LoadWith` materialization.** With the rewrite on the +property and returning `IQueryable` (natural shape — `json_each` is a +table), v6's materializer evaluates the substitution client-side and throws +`InvalidCastException : Unable to cast EnumerableQuery to IList` inside +`LinqToDB.Internal.Linq.QueryRunner.Mapper.ReMapOnException`. Originally +broke ~280 of 461 tests. + +**Regression 2 — translator can't see through `.ToList()`.** Forcing the +substitution to return `IList` via trailing `.ToList()` fixes +materialization but defeats SQL translation: filter chains like +`e.Senses.Any(s => s.SemanticDomains.Any(sd => sd.Code.Contains("Fruit")))` +expand to +`...json_each(s.SemanticDomains).Select(v => v.Value).ToList().Any(sd => ...)`, +and v6 gives up with `LinqToDBException : The LINQ expression could not be +converted to SQL`. + +--- + +## Attempt history + +| # | Approach | Eager load (`LoadWith`) | Gridify content filter | Gridify "missing"/null filter | +|---|---|---|---|---| +| A | Drop `[ExpressionMethod]`, write `Json.Query(...)` explicitly in `EntryFilterMapProvider` | ✓ works | ✗ Gridify can't parse `Json.Query(...)` — `InvalidOperationException` at `ParseMethodCallExpression` | ✗ same | +| B | Keep `[ExpressionMethod]`, expression returns `IQueryable` (no `.ToList()`) | ✗ `InvalidCastException` on every load (~280 fails) | ✓ would translate | n/a (load already broken) | +| C | Keep `[ExpressionMethod]`, expression returns `IList` via `.ToList()` | ✓ | ✗ translator can't see through `.ToList()` | ✗ same | +| D | C + `IsColumn = false` explicitly | same as C | same as C | same as C — v6 ignores it | +| E1 | Shadow **extension method** `e.PublishInAsRows()` with `[ExpressionMethod]` | ✓ | ✗ Gridify's `ParseMethodCallExpression` only handles `MemberExpression` / `Select` / `SelectMany` / `Where` as chain root; a method-call root throws | ✓ (combined with converter change — see below) | +| **F** | **Shadow *property*** `e.PublishInRows` with `IsExpression(..., isColumn:false)` *(current)* | ✓ | ✓ | ✓ | + +### Why the shadow approach works as a property but not a method + +Gridify's `LinqQueryBuilder.ParseMethodCallExpression` switches on the first +argument of the `Select(...)` projection. Only four shapes match: +`MemberExpression`, or a nested `Select` / `SelectMany` / `Where` +`MethodCallExpression`. A user-defined extension call like +`e.PublishInAsRows()` matches none and throws `InvalidOperationException`. A +property access (`e.PublishInRows`) is a `MemberExpression`, so it does +match. + +### Why v6's materializer doesn't trip on the shadow + +The shadow property is unmapped (`[NotMapped, JsonIgnore]`, plus +`IsNotColumn()` via `IsExpression`). v6's materialization expression is +shaped like `new Entry { Col1 = ..., Col2 = ... }` over the mapped columns +only. `ExposeExpressionVisitor` only expands `[ExpressionMethod]` when it +actually walks past that member in some expression tree. The shadow never +appears in the materialization tree, so its substitution never fires there. + +### Other untried alternatives (still escalation paths if F ever breaks) + +In increasing invasiveness: + +- Change `Sense.SemanticDomains` / `Entry.PublishIn` from `IList` to + `IQueryable` at the model level. Removes the type mismatch but is a wide + breaking change in `MiniLcm`. +- Drop the json column entirely and model these as real one-to-many EF + associations. No more `[Sql.TableFunction]` rewriting at all. Schema + + migration + sync-format break. +- Pin `linq2db` to 5.4.x. Avoids the regression at the cost of every fix in + the v6 line; conflicts with the rest of the .NET 10 upgrade. + +--- + +## Filter-converter changes + +Without the property-level rewrite, `e.PublishIn == null` no longer maps to +"NOT EXISTS json_each(...)" — it lowers to plain column `IS NULL`, which +matches nothing because empty lists are stored as `"[]"`, never SQL NULL. +The two affected converters +(`EntryPublishInConverter`, `EntrySensesSemanticDomainsConverter`) therefore +use `NormalizeEmptyToEmptyList` instead of `NormalizeEmptyToNull`, generating +`column = '[]'` — the same pattern `EntryComplexFormTypesConverter` already +used. + +--- + +## Two related v6 fixes that landed in `Json.cs` + +| Concern | Where | +|---|---| +| Peel `Sql.Alias(...)` wrap from `IExtensionCallBuilder` lambda arg bodies | `JsonValuePathBuilder.BuildParameterPath` | +| `PseudoFunctions.MakeTryConvert` was dropped in v6 — build the `SqlFunction` directly | `JsonValuePathBuilder.Build` | + +The Alias-peel exists because `ExposeExpressionVisitor` wraps every +`[ExpressionMethod]` substitution in `Sql.Alias(real_expr, "")` as a +column-alias hint, and that wrap leaks into user-written +`IExtensionCallBuilder` arg lambdas. `Json.Value(p, p => p.Id.ToString())` is +what trips it in our code; the same shape would affect any other linq2db +user with `[ExpressionMethod]` + custom extension builders. + +--- + +## Upstream plan (community-benefit, not lexbox-blocking) + +With the shadow-property approach in place, lexbox no longer depends on any +linq2db upstream fix. The two items below are still worth filing as +community contributions — other users will hit them — but they're off our +critical path. + +### PR A — Honor `IsColumn=false` at entity materialization + +The clean win. Restores the documented `[ExpressionMethod].IsColumn` +contract. + +- **Doc contract:** `IsColumn`'s XML doc reads: *"When applied to property + and set to true, Linq To DB will load data into property using expression + during entity materialization."* The default (`false`) should opt out of + materialization-time invocation. +- **Where it broke:** `TableBuilder.TableContext.MakeExpression` now passes + `fullEntity` through `Builder.ConvertExpressionTree`, which routes through + `ExposeExpressionVisitor` and expands every `[ExpressionMethod]` + regardless of `IsColumn`. +- **Proposed fix:** thread a `calculatedColumnsOnly` flag through one + materialization call site. `ExposeExpressionVisitor.ConvertExpressionMethodAttribute` + returns `null` when `_calculatedColumnsOnly && !attr.IsColumn`. Caller + falls back to bare member access. +- **Sample repro:** a `Foo` with `IList Bars` carrying a `JsonEach`-style + substitution and `IsColumn = false` — `db.GetTable().ToList()` throws + in v6 but works in v5. +- **Risk profile:** low. Anyone relying on v6's regression behavior would + have been broken on v5 too; they should set `IsColumn=true`. + +### PR C — Peel `Sql.Alias` from `IExtensionCallBuilder` lambda args + +Narrow, surface-area-only fix. No public contract change. + +- **Symptom:** `ExposeExpressionVisitor` wraps every `[ExpressionMethod]` + substitution in `Sql.Alias(real_expr, "")`. The wrap is + invisible to the SQL builder but observable inside user-written + `Sql.IExtensionCallBuilder` lambda-arg walkers as `Alias(real_expr, "ToString")`. +- **Proposed fix:** in `Sql.ExtensionAttribute.GetExtensionParam`, peel + `Sql.Alias(...)` from lambda-argument bodies before invoking the user + builder. Top-level (non-lambda) argument expressions are left alone. +- **Lexbox-side workaround already in `Json.cs`** — see the table above. +- **Risk profile:** low. The wrap was never part of the documented + extension-builder contract. + +### PR B — retired + +Previously envisioned as "suppress collection-typed `[ExpressionMethod]` +substitution inside `Equal`/`NotEqual` against null" so that +`e.PublishIn == null` would lower to column `IS NULL`. The shadow-property +approach makes this moot — the bare null check now lands on the column +naturally, no engine change needed. Don't open this PR. + +### Sequencing + +A → C, parallelizable. Cadence on `linq2db/linq2db` (sampled 2026-05-18): +~10 PRs/week merged, **6.3.0 shipped 2026-05-17**, **6.4.0 version bump +merged 2026-05-18**. Small bug-fixes with a linked issue land same-day to +3-day; medium fixes 1–2 weeks. Issue-first is convention; PR titles read +`Fix #NNNN: `. Maintainers worth tagging: `MaceWindu` (release +management + cross-provider review), `igor-tkachev` (original author), +`sdanyliv` (most active feature contributor). + +If we file A + C, realistic landing window is 1–2 weeks each, with a shot +at the 6.4.x line. + +--- + +## Files + +- `MiniLcm/Models/Entry.cs`, `MiniLcm/Models/Sense.cs` — shadow properties. +- `LcmCrdt/LcmCrdtKernel.cs` — `IsExpression` registration and the rewrite + factories (`SenseSemanticDomainRowsExpression`, + `EntryPublishInRowsExpression`). +- `LcmCrdt/Json.cs` — `Sql.Alias` peel; `TRY_CONVERT` direct construction. +- `LcmCrdt/EntryFilterMapProvider.cs` — projections routed through shadow + properties; converters use `NormalizeEmptyToEmptyList`. +- `LcmCrdt.Tests/MiniLcmTests/QueryEntryTests.cs:79` — perf-test assertion + margin (history of past adjustments in comments above the line). + +--- + +## Trade-offs + +- The shadow accessor is a soft lie in client context — anyone calling + `entry.PublishInRows` from non-query code gets the underlying list, not a + `json_each` table. Surface area is tiny (two properties, marked + `[NotMapped, JsonIgnore]`) and the comment on each points back here. +- The shape leaks a query-engine concern into `MiniLcm`'s shared domain + model. Acceptable cost given the alternative (waiting on PR A or shipping + the `.ToList()` workaround with 9 red tests). +- If PR A lands and we upgrade to a fixed linq2db release, the shadow + properties can be collapsed back into bare `[ExpressionMethod]` on the + underlying columns. Not load-bearing for lexbox. + +--- + +## Links + +- Linq To DB 6 migration guide: + <https://github.com/linq2db/linq2db/wiki/Linq-To-DB-6> +- Eager-load refactor in flight (targets 6.4.0) — may fix or change this: + <https://github.com/linq2db/linq2db/pull/5450> +- `ExpressionBuilder.EagerLoad.cs` (the file the stack trace points into): + <https://github.com/linq2db/linq2db/blob/master/Source/LinqToDB/Internal/Linq/Builder/ExpressionBuilder.EagerLoad.cs> +- Related (similar v6 `[ExpressionMethod]` regressions, all fixed — none + match our pair of symptoms but useful as precedent): + - <https://github.com/linq2db/linq2db/issues/4613> + - <https://github.com/linq2db/linq2db/issues/4977> + - <https://github.com/linq2db/linq2db/issues/5040> + - <https://github.com/linq2db/linq2db/issues/5254> +- Local upstream-side scratch (PR drafts, repro shapes for A and C): + `D:\code\linq2db\UPSTREAM-PLAN.md` (outside this repo). + +--- + +## Cctor patcher (Android-only) + +Separate from the shadow-property workaround above. `EFCoreMetadataReader+SqlTransparentExpression`'s `.cctor` in `linq2db.EntityFrameworkCore` 10.3.x looks up a `GetConstructor((ExceptExpression, RelationalTypeMapping))` signature that doesn't exist on the type (the only declared ctor takes `(ConstantExpression, RelationalTypeMapping?)`) — `GetConstructor` returns null, `_ctor = ...` throws `InvalidOperationException`, the cctor fails, and any later access to the type explodes with `TypeInitializationException`. + +Desktop is silent: the class is `beforefieldinit`, only `Quote()` reads the affected static fields, and our CRDT workload never calls `Quote()`. Mono/Android AOT eagerly initializes the type on the first CRDT save and detonates there. + +**Where the patcher lives:** `backend/FwLite/FwLiteMaui/build/Linq2DbCctorPatcher/` — a `net10.0` Mono.Cecil console tool. MSBuild targets in `FwLiteMaui.csproj` build it and run it against every staged copy of `linq2db.EntityFrameworkCore.dll` under `$(IntermediateOutputPath)`. The patcher rewrites the cctor body to `ret` and replaces `Quote()` with `throw new NotImplementedException()` so any surprise caller fails loudly instead of NRE'ing on the now-null `_ctor` field. Patched dlls get a sibling sentinel file (`<dll>.cctor-patched`) and the MSBuild target is `Inputs`/`Outputs`-incremental. + +**Why it's only here, not in `backend/build/`:** only `FwLiteMaui` targets `net10.0-android`. If `FwHeadless` or `FwLiteWeb` ever start targeting Android and reference `linq2db.EntityFrameworkCore`, lift the patcher into a shared tools dir and reference it from each consumer. + +**Kill-switch (delete the patcher entirely when):** + +1. `_VerifyLinq2DbEfCoreVersionPin` in `FwLiteMaui.csproj` fails because the version moved outside `10.3.x`. +2. Unskip the tests in `LcmCrdt.Tests/SqlTransparentExpressionCctorRepro.cs` and run them against the new version. +3. If they pass → delete `backend/FwLite/FwLiteMaui/build/Linq2DbCctorPatcher/` and the four targets in `FwLiteMaui.csproj` (`_VerifyLinq2DbEfCoreVersionPin`, `_BuildLinq2DbCctorPatcher`, `_CollectLinq2DbStagedAssemblies`, `_PatchLinq2DbSqlTransparentExpressionCctor`). Leave the now-passing repro tests as a permanent regression guard. +4. If they still fail → widen the version pin regex in `_VerifyLinq2DbEfCoreVersionPin`, re-`[Skip]` the tests with an updated reason. + +**Upstream issue:** TODO INSERT UPSTREAM ISSUE URL HERE (search both `_VerifyLinq2DbEfCoreVersionPin` in `FwLiteMaui.csproj` and `Program.cs` in the patcher project — keep the URL in lockstep). diff --git a/backend/FwLite/LcmCrdt/LcmCrdt.csproj b/backend/FwLite/LcmCrdt/LcmCrdt.csproj index 4f9c5b714f..f8cfb1db27 100644 --- a/backend/FwLite/LcmCrdt/LcmCrdt.csproj +++ b/backend/FwLite/LcmCrdt/LcmCrdt.csproj @@ -9,7 +9,7 @@ <PackageReference Include="Gridify.EntityFramework" /> <PackageReference Include="Humanizer.Core" /> <PackageReference Include="linq2db.EntityFrameworkCore" /> - <PackageReference Include="linq2db.AspNet" /> + <PackageReference Include="linq2db.Extensions" /> <PackageReference Include="Microsoft.EntityFrameworkCore.Design"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> @@ -19,7 +19,6 @@ <PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" /> <PackageReference Include="Refit" /> <PackageReference Include="Refit.HttpClientFactory" /> - <PackageReference Include="UUIDNext" /> </ItemGroup> <ItemGroup Condition="$([MSBuild]::IsOsPlatform('linux'))"> <PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" /> diff --git a/backend/FwLite/LcmCrdt/LcmCrdtKernel.cs b/backend/FwLite/LcmCrdt/LcmCrdtKernel.cs index 9d98c1ebbf..4b0b529cae 100644 --- a/backend/FwLite/LcmCrdt/LcmCrdtKernel.cs +++ b/backend/FwLite/LcmCrdt/LcmCrdtKernel.cs @@ -12,9 +12,9 @@ using LcmCrdt.Objects; using LcmCrdt.RemoteSync; using LinqToDB; -using LinqToDB.AspNet.Logging; using LinqToDB.Data; using LinqToDB.EntityFrameworkCore; +using LinqToDB.Extensions.Logging; using LinqToDB.Mapping; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; @@ -130,16 +130,18 @@ public static void ConfigureDbOptions(IServiceProvider provider, DbContextOption nameof(Commit.HybridDateTime) + "." + nameof(HybridDateTime.DateTime))) .HasAttribute<Commit>(new ColumnAttribute(nameof(HybridDateTime.Counter), nameof(Commit.HybridDateTime) + "." + nameof(HybridDateTime.Counter))) - //tells linq2db to rewrite Sense.SemanticDomains, into Json.Query(Sense.SemanticDomains) - .Entity<Sense>().Property(s => s.SemanticDomains).HasAttribute(new ExpressionMethodAttribute(SenseSemanticDomainsExpression())) - .Entity<Entry>().Property(e => e.PublishIn).HasAttribute(new ExpressionMethodAttribute(EntryPublishInExpression())) + //tells linq2db to rewrite Sense.SemanticDomainRows / Entry.PublishInRows into + //Json.Query(<underlying column>). The rewrite lives on the *Rows shadow accessors + //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<RichString>().Member(r => r.GetPlainText()).IsExpression(r => Json.GetPlainText(r)) .Entity<Guid>().Member(g => g.ToString()).IsExpression(g => Json.ToString(g)) .Build(); mappingSchema.SetConvertExpression((WritingSystemId id) => new DataParameter { Value = id.Code, DataType = DataType.Text }); optionsBuilder.AddMappingSchema(mappingSchema); - optionsBuilder.AddCustomOptions(options => options.UseSQLiteMicrosoft()); + optionsBuilder.AddCustomOptions(options => options.UseSQLite()); // Register read-relevant interceptors for LinqToDB var sqliteFunctionInterceptor = new CustomSqliteFunctionInterceptor(); @@ -161,19 +163,16 @@ public static void ConfigureDbOptions(IServiceProvider provider, DbContextOption builder.AddInterceptors(updateSearchTableInterceptor); } - private static Expression<Func<Sense, IQueryable<SemanticDomain>>> SenseSemanticDomainsExpression() + private static Expression<Func<Sense, IQueryable<SemanticDomain>>> SenseSemanticDomainRowsExpression() { - //using Sql.Property, otherwise if we used `s.SemanticDomains` again it would be recursively rewritten - return s => Json.Query(Sql.Property<IList<SemanticDomain>>(s, nameof(Sense.SemanticDomains))); + return s => Json.Query(s.SemanticDomains); } - private static Expression<Func<Entry, IQueryable<Publication>>> EntryPublishInExpression() + private static Expression<Func<Entry, IQueryable<Publication>>> EntryPublishInRowsExpression() { - //using Sql.Property, otherwise if we used `e.PublishIn` again it would be recursively rewritten - return e => Json.Query(Sql.Property<IList<Publication>>(e, nameof(Entry.PublishIn))); + return e => Json.Query(e.PublishIn); } - public static void ConfigureCrdt(CrdtConfig config) { config.EnableProjectedTables = true; @@ -263,7 +262,7 @@ public static void ConfigureCrdt(CrdtConfig config) list => JsonSerializer.Serialize(list, (JsonSerializerOptions?)null), json => JsonSerializer.Deserialize<ViewField[]>(json, (JsonSerializerOptions?)null) ?? Array.Empty<ViewField>()); - var writingSystemArrayConverter = new ValueConverter<ViewWritingSystem[]?, string?>( + var writingSystemArrayConverter = new Microsoft.EntityFrameworkCore.Storage.ValueConversion.ValueConverter<ViewWritingSystem[]?, string?>( list => JsonSerializer.Serialize(list, (JsonSerializerOptions?)null), json => json == null ? null : JsonSerializer.Deserialize<ViewWritingSystem[]>(json, (JsonSerializerOptions?)null)); builder.Property(v => v.Vernacular) diff --git a/backend/FwLite/LcmCrdt/Objects/DbTranslationDeserializationTarget.cs b/backend/FwLite/LcmCrdt/Objects/DbTranslationDeserializationTarget.cs index a249a5cd10..de5a947a27 100644 --- a/backend/FwLite/LcmCrdt/Objects/DbTranslationDeserializationTarget.cs +++ b/backend/FwLite/LcmCrdt/Objects/DbTranslationDeserializationTarget.cs @@ -1,6 +1,6 @@ using System.Text.Json; using System.Text.Json.Serialization; -using LinqToDB.Common; +using LinqToDB.Internal.Common; namespace LcmCrdt.Objects; diff --git a/backend/FwLite/LcmCrdt/Objects/PreDefinedData.cs b/backend/FwLite/LcmCrdt/Objects/PreDefinedData.cs index f319de8921..88f5849e6c 100644 --- a/backend/FwLite/LcmCrdt/Objects/PreDefinedData.cs +++ b/backend/FwLite/LcmCrdt/Objects/PreDefinedData.cs @@ -1,6 +1,5 @@ using LcmCrdt.Changes; using SIL.Harmony; -using UUIDNext; namespace LcmCrdt.Objects; @@ -16,38 +15,20 @@ public static class PreDefinedData public static readonly Guid AdjectivePartOfSpeechId = new("30d07580-5052-4d91-bc24-469b8b2d7df9"); public static readonly Guid AdverbPartOfSpeechId = new("46e4fe08-ffa0-4c8b-bf98-2c56f38904d9"); - // Seed commit-ids are derived per-project (UUIDv5 namespaced on projectId) so each project - // owns its own row in LexBox's CrdtCommits table — a shared constant id would collide on the - // primary key and the seed would get attributed to whichever project pushed first. - public static Guid ComplexFormTypesSeedCommitId(Guid projectId) => - Uuid.NewNameBased(projectId, "complex-form-types-seed"); - - public static Guid SemanticDomainsSeedCommitId(Guid projectId) => - Uuid.NewNameBased(projectId, "semantic-domains-seed"); - - public static Guid PartsOfSpeechSeedCommitId(Guid projectId) => - Uuid.NewNameBased(projectId, "parts-of-speech-seed"); - - public static Guid CustomViewsSeedCommitId(Guid projectId) => - Uuid.NewNameBased(projectId, "custom-views-seed"); - - public static Guid MorphTypesSeedCommitId(Guid projectId) => - Uuid.NewNameBased(projectId, "morph-types-seed"); - - internal static async Task AddPredefinedComplexFormTypes(DataModel dataModel, ProjectData projectData) + internal static async Task AddPredefinedComplexFormTypes(DataModel dataModel, Guid clientId) { - await dataModel.AddChanges(projectData.ClientId, + await dataModel.AddChanges(clientId, [ new CreateComplexFormType(CompoundComplexFormTypeId, new MultiString() { { "en", "Compound" } } ), new CreateComplexFormType(UnspecifiedComplexFormTypeId, new MultiString() { { "en", "Unspecified" } }) ], - ComplexFormTypesSeedCommitId(projectData.Id)); + new Guid("dc60d2a9-0cc2-48ed-803c-a238a14b6eae")); } - internal static async Task AddPredefinedSemanticDomains(DataModel dataModel, ProjectData projectData) + internal static async Task AddPredefinedSemanticDomains(DataModel dataModel, Guid clientId) { //todo load from xml instead of hardcoding and use real IDs - await dataModel.AddChanges(projectData.ClientId, + await dataModel.AddChanges(clientId, [ new CreateSemanticDomainChange(new Guid("63403699-07c1-43f3-a47c-069d6e4316e5"), new MultiString() { { "en", "Universe, Creation" } }, "1", true), new CreateSemanticDomainChange(new Guid("999581c4-1611-4acb-ae1b-5e6c1dfe6f0c"), new MultiString() { { "en", "Sky" } }, "1.1", true), @@ -57,25 +38,25 @@ await dataModel.AddChanges(projectData.ClientId, new CreateSemanticDomainChange(new Guid("46e4fe08-ffa0-4c8b-bf88-2c56f38904d5"), new MultiString() { { "en", "Head" } }, "2.1.1", false), new CreateSemanticDomainChange(new Guid("46e4fe08-ffa0-4c8b-bf88-2c56f38904d6"), new MultiString() { { "en", "Eye" } }, "2.1.1.1", false), ], - SemanticDomainsSeedCommitId(projectData.Id)); + new Guid("023faebb-711b-4d2f-a14f-a15621fc66bc")); } - public static async Task AddPredefinedPartsOfSpeech(DataModel dataModel, ProjectData projectData) + public static async Task AddPredefinedPartsOfSpeech(DataModel dataModel, Guid clientId) { //todo load from xml instead of hardcoding - await dataModel.AddChanges(projectData.ClientId, + await dataModel.AddChanges(clientId, [ new CreatePartOfSpeechChange(NounPartOfSpeechId, new MultiString() { { "en", "Noun" } }, true), new CreatePartOfSpeechChange(VerbPartOfSpeechId, new MultiString() { { "en", "Verb" } }, true), new CreatePartOfSpeechChange(AdjectivePartOfSpeechId, new MultiString() { { "en", "Adjective" } }, true), new CreatePartOfSpeechChange(AdverbPartOfSpeechId, new MultiString() { { "en", "Adverb" } }, true), ], - PartsOfSpeechSeedCommitId(projectData.Id)); + new Guid("023faebb-711b-4d2f-b34f-a15621fc66bb")); } - internal static async Task AddPredefinedCustomViews(DataModel dataModel, ProjectData projectData) + internal static async Task AddPredefinedCustomViews(DataModel dataModel, Guid clientId) { - await dataModel.AddChanges(projectData.ClientId, + await dataModel.AddChanges(clientId, [ new CreateCustomViewChange( new Guid("a1b2c3d4-e5f6-7890-abcd-ef1234567890"), @@ -101,13 +82,15 @@ await dataModel.AddChanges(projectData.ClientId, Analysis = [new ViewWritingSystem { WsId = "en" }] }) ], - CustomViewsSeedCommitId(projectData.Id)); + new Guid("b2c3d4e5-f6a7-8901-bcde-f12345678901")); } - internal static async Task AddPredefinedMorphTypes(DataModel dataModel, ProjectData projectData) + public static readonly Guid MorphTypesSeedCommitId = new("a7b2c3d4-e5f6-4a8b-9c0d-1e2f3a4b5c6d"); + + internal static async Task AddPredefinedMorphTypes(DataModel dataModel, Guid clientId) { - await dataModel.AddChanges(projectData.ClientId, + await dataModel.AddChanges(clientId, [.. CanonicalMorphTypes.All.Values.Select(mt => new CreateMorphTypeChange(mt))], - MorphTypesSeedCommitId(projectData.Id)); + MorphTypesSeedCommitId); } } From 584d021ca1954f4e64fd5dd6ff793253cd81f018 Mon Sep 17 00:00:00 2001 From: Tim Haasdyk <tim_haasdyk@sil.org> Date: Wed, 20 May 2026 17:37:55 +0200 Subject: [PATCH 03/21] Add Linq2DbCctorPatcher Android build tool linq2db v6's SqlTransparentExpression has a broken static constructor that crashes on Android at startup (AOT static-ctor ordering issue). A Cecil-based MSBuild build task patches the IL at build time to nop out the offending ctor. SqlTransparentExpressionCctorRepro.cs documents the crash-reproduction test. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --- backend/FwLite/FwLiteMaui/FwLiteMaui.csproj | 131 +++++++++++++++++- .../Linq2DbCctorPatcher.csproj | 16 +++ .../build/Linq2DbCctorPatcher/Program.cs | 129 +++++++++++++++++ .../SqlTransparentExpressionCctorRepro.cs | 58 ++++++++ 4 files changed, 328 insertions(+), 6 deletions(-) create mode 100644 backend/FwLite/FwLiteMaui/build/Linq2DbCctorPatcher/Linq2DbCctorPatcher.csproj create mode 100644 backend/FwLite/FwLiteMaui/build/Linq2DbCctorPatcher/Program.cs create mode 100644 backend/FwLite/LcmCrdt.Tests/SqlTransparentExpressionCctorRepro.cs diff --git a/backend/FwLite/FwLiteMaui/FwLiteMaui.csproj b/backend/FwLite/FwLiteMaui/FwLiteMaui.csproj index cfeae4b9ed..c82b53d4a8 100644 --- a/backend/FwLite/FwLiteMaui/FwLiteMaui.csproj +++ b/backend/FwLite/FwLiteMaui/FwLiteMaui.csproj @@ -40,12 +40,6 @@ <TargetPlatformMinVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'windows'">10.0.19041.0</TargetPlatformMinVersion> <SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'tizen'">6.5</SupportedOSPlatformVersion> </PropertyGroup> - <!-- Side-by-side "Dev" flavor: set -p:FwLiteFlavor=Dev to install alongside the prod app - (different package name + label so both can coexist on a device). --> - <PropertyGroup Condition="'$(FwLiteFlavor)' == 'Dev'"> - <ApplicationId>org.sil.FwLiteMaui.dev</ApplicationId> - <ApplicationTitle>FieldWorks Lite Dev</ApplicationTitle> - </PropertyGroup> <PropertyGroup Condition="'$(Configuration)' == 'Debug' "> <WindowsPackageType>None</WindowsPackageType> <PublishReadyToRun>false</PublishReadyToRun> @@ -60,6 +54,15 @@ <PropertyGroup> <TargetPlatform>$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)'))</TargetPlatform> </PropertyGroup> + <!-- Exclude the build-time tool's sources from FwLiteMaui's own compile. It's a + standalone Microsoft.NET.Sdk Exe project, not part of this project's code. --> + <ItemGroup> + <Compile Remove="build\**" /> + <Content Remove="build\**" /> + <None Remove="build\**" /> + <EmbeddedResource Remove="build\**" /> + <MauiAsset Remove="build\**" /> + </ItemGroup> <ItemGroup> <!-- App Icon --> <!-- background color is required for mac per: https://learn.microsoft.com/en-us/dotnet/maui/user-interface/images/app-icons?view=net-maui-8.0&tabs=windows#recolor-the-background --> @@ -100,4 +103,120 @@ --> <PackageReference Include="Mono.Unix" ExcludeAssets="all" /> </ItemGroup> + <!-- + Android workaround for linq2db.EntityFrameworkCore 10.3.x. + + SqlTransparentExpression's static ctor does a GetConstructor lookup for + (ExceptExpression, RelationalTypeMapping) which doesn't exist on the type + (the only declared ctor takes (ConstantExpression, RelationalTypeMapping?)). + The lookup returns null and the cctor throws InvalidOperationException. + Reproducible on plain net10.0 via RuntimeHelpers.RunClassConstructor — this + is a linq2db bug, not a missing-metadata-from-trimming issue. See + backend/FwLite/LcmCrdt.Tests/SqlTransparentExpressionCctorRepro.cs and + backend/FwLite/LcmCrdt/LINQ2DB-V6-NOTES.md (Cctor patcher section). + + Upstream issue: TODO INSERT UPSTREAM ISSUE URL HERE + + On desktop the class is beforefieldinit and its static fields are only read + from Quote(), which our CRDT workload never calls, so the bug is silent. + Android surfaces it the first time a CRDT save runs. + + An earlier attempt used ILLink.Substitutions.xml to stub the .cctor, but + Debug Android builds skip the linker entirely (PublishTrimmed=false), so + the substitution never applied. Even in Release publish, the substitution + site differs from where the dll ends up staged for packaging, so a + single-target hook is fragile. + + Instead, we Cecil-patch the assembly at build time, unconditionally, + via the small tool under build/Linq2DbCctorPatcher. + + Where the dll lives depends on configuration: + - Debug (PublishTrimmed=false): staged into $(MonoAndroidIntermediateAssemblyDir)<abi>\ + by _LinkAssembliesNoShrink, then bundled for fast-deploy. + - Release (PublishTrimmed=true) : output by ILLink into <rid>\linked\ and copied to + <rid>\linked\shrunk\ by _RemoveRegisterAttribute. _CollectAssembliesToCompress + then consumes the shrunk copies for the assembly store. + We must patch BOTH locations (and any other staged copies under $(IntermediateOutputPath)). + The target runs BeforeTargets on the consumers (_CollectAssembliesToCompress and + _BuildApkFastDev) so it fires regardless of trimmed/untrimmed and AAB/APK flows. + + KILL-SWITCH: + - The version-pin check below fails the build the moment somebody bumps + the package outside the verified-broken range. When that happens, either + widen the range (after re-verifying the bug still exists in the new + version) or delete this whole block + the build/Linq2DbCctorPatcher + project + unskip SqlTransparentExpressionCctorRepro.cs. + --> + + <!-- Version pin: hard-error if linq2db.EntityFrameworkCore is outside the patched range. + We don't want this patcher silently chugging along on a version we never tested, + nor do we want to carry it past an upstream fix. Range is the closed interval + [10.3.0, 10.3.999]; anything 10.4.x or 11.x is rejected. + + Source of truth is backend/Directory.Packages.props (central package management), + which we read directly: PackageReference items have no Version metadata under CPM, + and ResolvedPackageReference is only populated after restore. Reading the props + file is the most robust signal that works pre- and post-restore. --> + <Target Name="_VerifyLinq2DbEfCoreVersionPin" + Condition="'$(TargetPlatform)' == 'android'" + BeforeTargets="_BuildLinq2DbCctorPatcher"> + <PropertyGroup> + <!-- Pull "X.Y.Z" from a line like: <PackageVersion Include="linq2db.EntityFrameworkCore" Version="10.3.0" /> --> + <_Linq2DbEfCoreEffectiveVersion>$([System.Text.RegularExpressions.Regex]::Match($([System.IO.File]::ReadAllText('$(MSBuildThisFileDirectory)..\..\Directory.Packages.props')), 'linq2db\.EntityFrameworkCore[^>]*Version="([^"]+)"').Groups[1].Value)</_Linq2DbEfCoreEffectiveVersion> + </PropertyGroup> + <Error Condition="'$(_Linq2DbEfCoreEffectiveVersion)' == ''" + Text="Could not determine resolved version of linq2db.EntityFrameworkCore from backend/Directory.Packages.props. The cctor-patcher version pin can't verify itself. Investigate before proceeding." /> + <!-- Patched range: 10.3.x only. Anything else fails the build. --> + <Error Condition="'$(_Linq2DbEfCoreEffectiveVersion)' != '' And !$([System.Text.RegularExpressions.Regex]::IsMatch('$(_Linq2DbEfCoreEffectiveVersion)', '^10\.3\.[0-9]+(-.*)?$'))" + Text="linq2db.EntityFrameworkCore is at $(_Linq2DbEfCoreEffectiveVersion); the cctor patcher in build/Linq2DbCctorPatcher only verifies the 10.3.x IL shape. Re-verify the SqlTransparentExpression cctor bug still exists in $(_Linq2DbEfCoreEffectiveVersion) (unskip the repro test in LcmCrdt.Tests/SqlTransparentExpressionCctorRepro.cs and run it against the new version), then either: (a) widen this pin if still broken, or (b) DELETE build/Linq2DbCctorPatcher and the _BuildLinq2DbCctorPatcher / _PatchLinq2DbSqlTransparentExpressionCctor / _VerifyLinq2DbEfCoreVersionPin / _CollectLinq2DbStagedAssemblies targets if fixed. Upstream issue: TODO INSERT UPSTREAM ISSUE URL HERE. See backend/FwLite/LcmCrdt/LINQ2DB-V6-NOTES.md (Cctor patcher section)." /> + </Target> + <Target Name="_BuildLinq2DbCctorPatcher" + Condition="'$(TargetPlatform)' == 'android'" + DependsOnTargets="_VerifyLinq2DbEfCoreVersionPin" + BeforeTargets="_LinkAssembliesNoShrink;_AfterILLinkAdditionalSteps;_RemoveRegisterAttribute"> + <!-- Build the patcher tool as a side-channel net10.0 project. We use <MSBuild> rather + than a <ProjectReference> so the patcher's TargetFramework/RuntimeIdentifier isn't + entangled with FwLiteMaui's android-arm64 graph. RemoveProperties strips inherited + RID/TF so the inner build resolves cleanly as plain net10.0. --> + <MSBuild Projects="$(MSBuildThisFileDirectory)build\Linq2DbCctorPatcher\Linq2DbCctorPatcher.csproj" + Targets="Restore;Build" + Properties="Configuration=$(Configuration)" + RemoveProperties="TargetFramework;TargetFrameworks;RuntimeIdentifier;RuntimeIdentifiers;TargetPlatform;TargetPlatformIdentifier;TargetPlatformVersion;UseMaui;SingleProject;SelfContained;PublishReadyToRun;PublishSingleFile" /> + </Target> + <!-- Glob staged dlls before the patch target's Inputs/Outputs check evaluates. + Lives in a separate target so the ItemGroup is materialized by the time + _PatchLinq2Db...'s batching examines @(_Linq2DbStagedAssemblies). --> + <Target Name="_CollectLinq2DbStagedAssemblies" + Condition="'$(TargetPlatform)' == 'android'"> + <ItemGroup> + <!-- Glob every staged copy under obj\<Config>\<TF>\: Debug ships them via + android\assets\<abi>\, Release publishes through <rid>\linked\ (and linked\shrunk\). --> + <_Linq2DbStagedAssemblies Include="$(IntermediateOutputPath)**\linq2db.EntityFrameworkCore.dll" /> + </ItemGroup> + </Target> + <!-- + Incremental patching: per-dll sentinel files at <dll>.cctor-patched. + Inputs are the staged dlls; Outputs are %()-batched sentinels so MSBuild + skips already-patched files on subsequent builds. The patcher itself also + short-circuits via the same sentinel — if dotnet restore re-extracts the + package, the dll's mtime moves forward past the sentinel's and patching + re-runs. Belt-and-braces against any case where MSBuild's incremental + check disagrees with the file-system reality. + --> + <Target Name="_PatchLinq2DbSqlTransparentExpressionCctor" + Condition="'$(TargetPlatform)' == 'android'" + DependsOnTargets="_BuildLinq2DbCctorPatcher;_CollectLinq2DbStagedAssemblies" + AfterTargets="_LinkAssembliesNoShrink;_AfterILLinkAdditionalSteps;_RemoveRegisterAttribute" + BeforeTargets="_CollectAssembliesToCompress;_BuildApkFastDev" + Inputs="@(_Linq2DbStagedAssemblies)" + Outputs="@(_Linq2DbStagedAssemblies->'%(FullPath).cctor-patched')"> + <PropertyGroup> + <_Linq2DbPatcherDll>$(MSBuildThisFileDirectory)build\Linq2DbCctorPatcher\bin\$(Configuration)\net10.0\Linq2DbCctorPatcher.dll</_Linq2DbPatcherDll> + </PropertyGroup> + <Error Condition="!Exists('$(_Linq2DbPatcherDll)')" + Text="Linq2DbCctorPatcher.dll not found at $(_Linq2DbPatcherDll). _BuildLinq2DbCctorPatcher should have produced it." /> + <Message Importance="high" Text="Linq2db cctor patcher: @(_Linq2DbStagedAssemblies->Count()) staged linq2db.EntityFrameworkCore.dll copies under $(IntermediateOutputPath)" /> + <Exec Command="dotnet "$(_Linq2DbPatcherDll)" "%(_Linq2DbStagedAssemblies.FullPath)"" + Condition="'@(_Linq2DbStagedAssemblies)' != ''" /> + </Target> </Project> diff --git a/backend/FwLite/FwLiteMaui/build/Linq2DbCctorPatcher/Linq2DbCctorPatcher.csproj b/backend/FwLite/FwLiteMaui/build/Linq2DbCctorPatcher/Linq2DbCctorPatcher.csproj new file mode 100644 index 0000000000..1f9eecff17 --- /dev/null +++ b/backend/FwLite/FwLiteMaui/build/Linq2DbCctorPatcher/Linq2DbCctorPatcher.csproj @@ -0,0 +1,16 @@ +<Project Sdk="Microsoft.NET.Sdk"> + <PropertyGroup> + <OutputType>Exe</OutputType> + <TargetFramework>net10.0</TargetFramework> + <Nullable>enable</Nullable> + <ImplicitUsings>enable</ImplicitUsings> + <IsPackable>false</IsPackable> + <!-- Don't let this opt into shared central package management; we just pin Mono.Cecil locally. --> + <ManagePackageVersionsCentrally>false</ManagePackageVersionsCentrally> + <!-- This tool is invoked from MSBuild targets in FwLiteMaui.csproj; nothing else references it. --> + <IsPackable>false</IsPackable> + </PropertyGroup> + <ItemGroup> + <PackageReference Include="Mono.Cecil" Version="0.11.6" /> + </ItemGroup> +</Project> diff --git a/backend/FwLite/FwLiteMaui/build/Linq2DbCctorPatcher/Program.cs b/backend/FwLite/FwLiteMaui/build/Linq2DbCctorPatcher/Program.cs new file mode 100644 index 0000000000..73f2280820 --- /dev/null +++ b/backend/FwLite/FwLiteMaui/build/Linq2DbCctorPatcher/Program.cs @@ -0,0 +1,129 @@ +// Stubs the broken static constructor on +// LinqToDB.EntityFrameworkCore.EFCoreMetadataReader+SqlTransparentExpression +// inside linq2db.EntityFrameworkCore 10.3.x. +// +// The shipped .cctor does a GetConstructor lookup for (ExceptExpression, +// RelationalTypeMapping), which doesn't exist on the type — the only declared +// ctor takes (ConstantExpression, RelationalTypeMapping?). The lookup returns +// null and the .cctor throws InvalidOperationException. Reproducible on plain +// net10.0 with RuntimeHelpers.RunClassConstructor. +// +// Desktop CRDT never accesses the affected static fields (only Quote() does), +// so it's silent. Android (and any environment that eagerly initializes the +// type) hits TypeInitializationException on the first CRDT save. +// +// We can't fix this via ILLink.Substitutions.xml on Android Debug because +// PublishTrimmed is false and the linker pass is skipped. And in Release +// publish the staged dll location differs from the ILLink target site, so a +// single substitution hook is fragile. So we Cecil-patch unconditionally at +// build time, on every linq2db.EntityFrameworkCore.dll under the obj tree. +// +// Also removes Quote() so any unexpected caller fails loudly with +// NotImplementedException instead of NRE'ing on the (now-null) _ctor field. +// +// SCOPE: only FwLiteMaui targets net10.0-android today, so this patcher lives +// alongside it. If another csproj ever targets net10.0-android and references +// linq2db.EntityFrameworkCore, lift this into a shared backend/build/ tools +// directory and reference it from each consumer's targets. +// +// KILL-SWITCH: when upstream ships a fixed version (see the version pin +// in FwLiteMaui.csproj — search for _Linq2DbEfCorePatchedVersion), delete this +// project and the two _BuildLinq2DbCctorPatcher / _PatchLinq2Db... targets, +// and unskip backend/FwLite/LcmCrdt.Tests/SqlTransparentExpressionCctorRepro.cs. +using Mono.Cecil; +using Mono.Cecil.Cil; + +if (args.Length < 1) +{ + Console.Error.WriteLine("usage: Linq2DbCctorPatcher <path-to-linq2db.EntityFrameworkCore.dll>"); + return 1; +} + +var dllPath = args[0]; +if (!File.Exists(dllPath)) +{ + Console.Error.WriteLine($"File not found: {dllPath}"); + return 2; +} + +var markerPath = dllPath + ".cctor-patched"; +if (File.Exists(markerPath) && File.GetLastWriteTimeUtc(markerPath) >= File.GetLastWriteTimeUtc(dllPath)) +{ + Console.WriteLine($"Already patched: {dllPath}"); + return 0; +} + +// Structural guards: if upstream restructures any of these, the build must +// break loudly. We do NOT skip-on-mismatch — that would silently ship an +// unprotected dll. Bumping the package without re-checking this code is +// already gated by the MSBuild version pin in FwLiteMaui.csproj, but these +// guards are belt-and-braces for the case where someone widens the pin +// without auditing the IL shape. +static int Fail(string message) +{ + Console.Error.WriteLine("Linq2DbCctorPatcher: " + message); + Console.Error.WriteLine( + "linq2db.EntityFrameworkCore structure changed; patcher needs review. " + + "See backend/FwLite/LcmCrdt/LINQ2DB-V6-NOTES.md (Cctor patcher section)."); + return 3; +} + +using (var asm = AssemblyDefinition.ReadAssembly(dllPath, new ReaderParameters { ReadWrite = true })) +{ + var outer = asm.MainModule.GetType("LinqToDB.EntityFrameworkCore.EFCoreMetadataReader"); + if (outer is null) + return Fail("EFCoreMetadataReader type not found."); + + var nested = outer.NestedTypes.FirstOrDefault(t => t.Name == "SqlTransparentExpression"); + if (nested is null) + return Fail("SqlTransparentExpression nested type not found inside EFCoreMetadataReader."); + + var cctor = nested.Methods.FirstOrDefault(m => m.IsConstructor && m.IsStatic); + if (cctor is null || !cctor.HasBody) + return Fail("SqlTransparentExpression .cctor not found (or has no body)."); + + // Sanity-check the cctor shape: at least one stsfld targeting the _ctor field. + // If upstream renames _ctor or restructures the field init, we want to know. + var storesCtorField = cctor.Body.Instructions.Any(ins => + ins.OpCode == OpCodes.Stsfld + && ins.Operand is FieldReference fr + && fr.Name == "_ctor" + && fr.DeclaringType.FullName == nested.FullName); + if (!storesCtorField) + return Fail("SqlTransparentExpression .cctor no longer contains a stsfld for the _ctor field; IL shape changed."); + + var quote = nested.Methods.FirstOrDefault(m => m.Name == "Quote" && m.Parameters.Count == 0); + if (quote is null || !quote.HasBody) + return Fail("SqlTransparentExpression.Quote() not found (or has no body)."); + + { + var il = cctor.Body.GetILProcessor(); + cctor.Body.Instructions.Clear(); + cctor.Body.ExceptionHandlers.Clear(); + cctor.Body.Variables.Clear(); + il.Append(Instruction.Create(OpCodes.Ret)); + Console.WriteLine("Stubbed SqlTransparentExpression .cctor to no-op ret"); + } + + { + // Replace Quote() with `throw new NotImplementedException();` so anything that + // somehow reaches it fails loud rather than NRE'ing on the now-null _ctor field. + var nieCtor = asm.MainModule.ImportReference( + new MethodReference(".ctor", asm.MainModule.TypeSystem.Void, + asm.MainModule.ImportReference(typeof(NotImplementedException))) + { HasThis = true }); + var il = quote.Body.GetILProcessor(); + quote.Body.Instructions.Clear(); + quote.Body.ExceptionHandlers.Clear(); + quote.Body.Variables.Clear(); + il.Append(Instruction.Create(OpCodes.Newobj, nieCtor)); + il.Append(Instruction.Create(OpCodes.Throw)); + Console.WriteLine("Replaced SqlTransparentExpression.Quote() with throw NotImplementedException"); + } + + asm.Write(); +} + +File.WriteAllText(markerPath, DateTime.UtcNow.ToString("O")); +Console.WriteLine($"Patched {dllPath}"); +return 0; diff --git a/backend/FwLite/LcmCrdt.Tests/SqlTransparentExpressionCctorRepro.cs b/backend/FwLite/LcmCrdt.Tests/SqlTransparentExpressionCctorRepro.cs new file mode 100644 index 0000000000..c5c105d6b3 --- /dev/null +++ b/backend/FwLite/LcmCrdt.Tests/SqlTransparentExpressionCctorRepro.cs @@ -0,0 +1,58 @@ +using System; +using System.Runtime.CompilerServices; + +namespace LcmCrdt.Tests; + +// Empirical repro for the linq2db.EntityFrameworkCore 10.3.x bug where +// EFCoreMetadataReader+SqlTransparentExpression's static ctor throws because +// GetConstructor looks up an (ExceptExpression, RelationalTypeMapping) signature +// that doesn't exist on the type. See FwLiteMaui.csproj's cctor-patcher target +// and backend/FwLite/LcmCrdt/LINQ2DB-V6-NOTES.md (Cctor patcher section). +// +// IMPORTANT: These tests intentionally still FAIL on desktop. They probe the +// in-process linq2db.EntityFrameworkCore.dll loaded from NuGet — which is the +// shipping-broken assembly. The cctor patcher only rewrites the *Android-staged* +// copy in $(IntermediateOutputPath); the desktop test process loads the +// un-patched NuGet one. Failing here proves the upstream bug still exists; the +// Android binary check is what proves the patch is wired correctly. So these +// are marked Skip on .NET Core / desktop runs to keep the suite green, while +// the Cecil-disassembly check elsewhere is the load-bearing assertion. +// +// UNSKIP WHEN: the linq2db.EntityFrameworkCore version pin in +// FwLiteMaui.csproj (_VerifyLinq2DbEfCoreVersionPin) is bumped or removed. +// At that point either: +// - the bug is fixed upstream → these tests should pass without any patcher; +// unskip them, delete the patcher, and they become a permanent regression +// guard. +// - the bug still exists in the new version → run unskipped against the new +// version to confirm the same repro shape, then re-skip with an updated +// reason and widen the version pin. +public class SqlTransparentExpressionCctorRepro +{ + private const string SkipReason = + "Probes the unpatched NuGet linq2db.EntityFrameworkCore.dll loaded in the test " + + "process — repro only, not a regression test. The Android build's cctor patcher " + + "operates on the staged dll under obj/<Config>/net10.0-android/, not on the dll " + + "loaded here. Verify the Android patch by Cecil-inspecting that staged dll."; + + [Fact(Skip = SkipReason)] + public void Cctor_runs_without_throwing() + { + var t = Type.GetType( + "LinqToDB.EntityFrameworkCore.EFCoreMetadataReader+SqlTransparentExpression, linq2db.EntityFrameworkCore", + throwOnError: true)!; + var act = () => RuntimeHelpers.RunClassConstructor(t.TypeHandle); + act.Should().NotThrow(); + } + + [Fact(Skip = SkipReason)] + public void Cctor_is_stubbed_via_field_check() + { + var t = Type.GetType( + "LinqToDB.EntityFrameworkCore.EFCoreMetadataReader+SqlTransparentExpression, linq2db.EntityFrameworkCore", + throwOnError: true)!; + var f = t.GetField("_ctor", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static)!; + var act = () => f.GetValue(null); + act.Should().NotThrow(); + } +} From 525cfd7dee24853931055d75721f74297e8bc511 Mon Sep 17 00:00:00 2001 From: Tim Haasdyk <tim_haasdyk@sil.org> Date: Wed, 20 May 2026 17:38:12 +0200 Subject: [PATCH 04/21] EF Core 10 follow-ons: RichMultiString null-setter, model and test updates RichMultiString gains an explicit null-setter so the typed IDictionary<,> implementation satisfies EF Core 10's stricter property requirements. Entry and Sense get minor model adjustments. Snapshot/test expectations updated for EF Core 10 query output changes. FluentAssertions global config pinned to alpha.5 to avoid the 8.x O(N!) BeEquivalentTo regression in CI. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --- .../FwLite/LcmCrdt.Tests/MiniLcmApiFixture.cs | 2 +- .../MiniLcm.Tests/FluentAssertGlobalConfig.cs | 3 ++ .../MiniLcm.Tests/RichMultiStringTests.cs | 37 +++++++++++++++++++ backend/FwLite/MiniLcm/Models/Entry.cs | 12 ++++++ .../FwLite/MiniLcm/Models/RichMultiString.cs | 22 +++++------ backend/FwLite/MiniLcm/Models/Sense.cs | 6 +++ .../LexBoxApi/Services/CrdtCommitService.cs | 21 ++++------- backend/LexData/DataKernel.cs | 2 +- backend/LexData/LexData.csproj | 2 +- .../Services/CrdtCommitServiceTests.cs | 33 +++++++++-------- 10 files changed, 95 insertions(+), 45 deletions(-) diff --git a/backend/FwLite/LcmCrdt.Tests/MiniLcmApiFixture.cs b/backend/FwLite/LcmCrdt.Tests/MiniLcmApiFixture.cs index 3218031489..d7f2e0f984 100644 --- a/backend/FwLite/LcmCrdt.Tests/MiniLcmApiFixture.cs +++ b/backend/FwLite/LcmCrdt.Tests/MiniLcmApiFixture.cs @@ -77,7 +77,7 @@ await CrdtProjectsService.InitProjectDb(_crdtDbContext, projectData); await currentProjectService.RefreshProjectData(); // CreateProject would also seed morph types — so we need to do it manually here - await PreDefinedData.AddPredefinedMorphTypes(_services.ServiceProvider.GetRequiredService<DataModel>(), projectData); + await PreDefinedData.AddPredefinedMorphTypes(_services.ServiceProvider.GetRequiredService<DataModel>(), projectData.ClientId); if (_seedWs) { await Api.CreateWritingSystem(new WritingSystem() diff --git a/backend/FwLite/MiniLcm.Tests/FluentAssertGlobalConfig.cs b/backend/FwLite/MiniLcm.Tests/FluentAssertGlobalConfig.cs index 3cfb18764d..c8b247b304 100644 --- a/backend/FwLite/MiniLcm.Tests/FluentAssertGlobalConfig.cs +++ b/backend/FwLite/MiniLcm.Tests/FluentAssertGlobalConfig.cs @@ -16,6 +16,9 @@ public static void Initialize() .ComparingByMembers<RichSpan>() .Excluding(m => (m.DeclaringType == typeof(ComplexFormComponent) || m.DeclaringType == typeof(WritingSystem)) && (m.Name == nameof(ComplexFormComponent.Id) || m.Name == nameof(ComplexFormComponent.MaybeId))) + //Shadow query-rewrite targets — domain state lives on the underlying collection. + .Excluding(m => (m.DeclaringType == typeof(Entry) && m.Name == nameof(Entry.PublishInRows)) + || (m.DeclaringType == typeof(Sense) && m.Name == nameof(Sense.SemanticDomainRows))) ); } } diff --git a/backend/FwLite/MiniLcm.Tests/RichMultiStringTests.cs b/backend/FwLite/MiniLcm.Tests/RichMultiStringTests.cs index cfdbfeed0c..01e70eab2c 100644 --- a/backend/FwLite/MiniLcm.Tests/RichMultiStringTests.cs +++ b/backend/FwLite/MiniLcm.Tests/RichMultiStringTests.cs @@ -295,6 +295,43 @@ public void JsonPatchCanAddRichMultiString() ms["fr"].Should().BeEquivalentTo(new RichString("test", "fr")); } + //emulates older commits in the sync stream where a RichMultiString value was serialized as a plain string + [Fact] + public void JsonPatchCanAddRichMultiStringWhenValueIsString() + { + var ms = new RichMultiString() { { "en", new RichString("existing", "en") } }; + var patch = new JsonPatchDocument<RichMultiString>(); + patch.Operations.Add(new Operation<RichMultiString>("add", "/fr", null, "test")); + patch.ApplyTo(ms); + ms.Should().ContainKey("fr"); + ms["fr"].Should().BeEquivalentTo(new RichString("test", "fr")); + } + + //the original repro for "System.ArgumentException: unable to convert value String to RichString": + //a JsonPatchDocument<Entry> patches /Note/en with a raw string value. This routes through + //PocoAdapter.TryAdd -> DictionaryPropertyProxy, which used to hand RichMultiString.IDictionary.Add a raw string. + [Fact] + public void JsonPatchCanAddRichMultiStringPropertyOnEntityWhenValueIsString() + { + var entry = new Entry(); + var patch = new JsonPatchDocument<Entry>(); + patch.Operations.Add(new Operation<Entry>("add", "/Note/en", null, "test")); + patch.ApplyTo(entry); + entry.Note.Should().ContainKey("en"); + entry.Note["en"].Should().BeEquivalentTo(new RichString("test", "en")); + } + + [Fact] + public void JsonPatchCanAddRichMultiStringPropertyOnEntityWithRichStringValue() + { + var entry = new Entry(); + var patch = new JsonPatchDocument<Entry>(); + patch.Add(e => e.Note["en"], new RichString("test", "en")); + patch.ApplyTo(entry); + entry.Note.Should().ContainKey("en"); + entry.Note["en"].Should().BeEquivalentTo(new RichString("test", "en")); + } + [Fact] public void RichSpanEquality_TrueWhenMatching() { diff --git a/backend/FwLite/MiniLcm/Models/Entry.cs b/backend/FwLite/MiniLcm/Models/Entry.cs index 550080e628..a2427f27a7 100644 --- a/backend/FwLite/MiniLcm/Models/Entry.cs +++ b/backend/FwLite/MiniLcm/Models/Entry.cs @@ -1,3 +1,8 @@ +using System.ComponentModel; +using System.ComponentModel.DataAnnotations.Schema; +using System.Text.Json.Serialization; +using MiniLcm.Attributes; + namespace MiniLcm.Models; public record Entry : IObjectWithId<Entry> @@ -29,6 +34,13 @@ public record Entry : IObjectWithId<Entry> public virtual List<Publication> PublishIn { get; set; } = []; + //Server-side query rewrite target — LcmCrdt rewrites this to Json.Query(PublishIn) so + //filter projections (e.g. PublishInRows.Select(...).Any(...)) translate to json_each() SQL. + //Public only because LcmCrdt's filter map provider lives in a different assembly; treat as + //internal — don't read it from client code, use PublishIn. + [MiniLcmInternal, NotMapped, JsonIgnore, EditorBrowsable(EditorBrowsableState.Never)] + public IEnumerable<Publication> PublishInRows => PublishIn; + public const string UnknownHeadword = "(Unknown)"; public string Headword() diff --git a/backend/FwLite/MiniLcm/Models/RichMultiString.cs b/backend/FwLite/MiniLcm/Models/RichMultiString.cs index 777d0582b3..d8aefed427 100644 --- a/backend/FwLite/MiniLcm/Models/RichMultiString.cs +++ b/backend/FwLite/MiniLcm/Models/RichMultiString.cs @@ -38,10 +38,9 @@ public RichMultiString Copy() void IDictionary.Add(object key, object? value) { - var valStr = value as RichString ?? - throw new ArgumentException($"unable to convert value {value?.GetType().Name ?? "null"} to RichString", - nameof(value)); - Add(WritingSystemId.FromUnknown(key), valStr); + var richString = value as RichString ?? + throw new ArgumentException($"unable to convert value {value?.GetType().Name ?? "null"} to RichString", nameof(value)); + Add(WritingSystemId.FromUnknown(key), richString); } public void Add(WritingSystemId key, RichString value) @@ -70,14 +69,14 @@ public bool Contains(KeyValuePair<WritingSystemId, RichString> item) return dictionary.Contains(item); } - public void CopyTo(KeyValuePair<WritingSystemId, RichString>[] array, int arrayIndex) + public bool Remove(KeyValuePair<WritingSystemId, RichString> item) { - dictionary.CopyTo(array, arrayIndex); + return dictionary.Remove(item); } - public bool Remove(KeyValuePair<WritingSystemId, RichString> item) + public void CopyTo(KeyValuePair<WritingSystemId, RichString>[] array, int arrayIndex) { - return dictionary.Remove(item); + dictionary.CopyTo(array, arrayIndex); } public int Count => dictionary.Count; @@ -104,12 +103,11 @@ public RichString this[WritingSystemId key] get => dictionary.TryGetValue(key, out var value) ? value : new RichString([]); set { - // SystemTextJsonPatch's DictionaryTypedPropertyProxy casts to IDictionary<TKey, TValue?> - // and may pass null (e.g. when an empty-string RichString deserialized to null). Treat - // that as a remove, mirroring the explicit IDictionary.this[object] setter below. + // value will be null if an empty string was deserialized as a RichString (e.g. from a JsonPatch operation + // routed through DictionaryTypedPropertyProxy). Mirror the IDictionary.this[object] setter and remove the key. if (value is null) { - dictionary.Remove(key); + Remove(key); return; } value.EnsureWs(key); diff --git a/backend/FwLite/MiniLcm/Models/Sense.cs b/backend/FwLite/MiniLcm/Models/Sense.cs index 3c4016f13e..b4938dc867 100644 --- a/backend/FwLite/MiniLcm/Models/Sense.cs +++ b/backend/FwLite/MiniLcm/Models/Sense.cs @@ -1,3 +1,5 @@ +using System.ComponentModel; +using System.ComponentModel.DataAnnotations.Schema; using System.Text.Json; using System.Text.Json.Serialization; using MiniLcm.Attributes; @@ -18,6 +20,10 @@ public class Sense : IObjectWithId<Sense>, IOrderable public virtual PartOfSpeech? PartOfSpeech { get; set; } = null; public virtual Guid? PartOfSpeechId { get; set; } public virtual IList<SemanticDomain> SemanticDomains { get; set; } = []; + + //Server-side query rewrite target — see Entry.PublishInRows. + [MiniLcmInternal, NotMapped, JsonIgnore, EditorBrowsable(EditorBrowsableState.Never)] + public IEnumerable<SemanticDomain> SemanticDomainRows => SemanticDomains; public virtual List<ExampleSentence> ExampleSentences { get; set; } = []; public Guid[] GetReferences() diff --git a/backend/LexBoxApi/Services/CrdtCommitService.cs b/backend/LexBoxApi/Services/CrdtCommitService.cs index eed0dcc33e..d4ec51f8c9 100644 --- a/backend/LexBoxApi/Services/CrdtCommitService.cs +++ b/backend/LexBoxApi/Services/CrdtCommitService.cs @@ -1,6 +1,7 @@ using LexCore.Utils; using LexData; using LinqToDB; +using LinqToDB.Async; using LinqToDB.Data; using LinqToDB.EntityFrameworkCore; using LinqToDB.EntityFrameworkCore.Internal; @@ -15,26 +16,18 @@ public async Task AddCommits(Guid projectId, IAsyncEnumerable<ServerCommit> comm await using var transaction = await dbContext.Database.BeginTransactionAsync(token); var linqToDbContext = dbContext.CreateLinqToDBContext(); await using var tmpTable = await linqToDbContext.CreateTempTableAsync<ServerCommit>($"tmp_crdt_commit_import_{projectId}__{Guid.NewGuid()}", cancellationToken: token); - await tmpTable.BulkCopyAsync(new BulkCopyOptions{BulkCopyType = BulkCopyType.ProviderSpecific, MaxBatchSize = 10}, commits, token); + //Stamp ProjectId while streaming so the merge below can be a plain column-to-column copy. + //A projection lambda here would let linq2db v6 wrap our Sql.Expr<...>::jsonb cast in the + //EF value-converter (JsonSerializer.Serialize) and fail SQL translation. + var stampedCommits = commits.Select(c => { c.ProjectId = projectId; return c; }); + await tmpTable.BulkCopyAsync(new BulkCopyOptions{BulkCopyType = BulkCopyType.ProviderSpecific, MaxBatchSize = 10}, stampedCommits, token); var commitsTable = linqToDbContext.GetTable<ServerCommit>(); await commitsTable .Merge() .Using(tmpTable) .OnTargetKey() - .InsertWhenNotMatched(commit => new ServerCommit(commit.Id) - { - Id = commit.Id, - ClientId = commit.ClientId, - HybridDateTime = new HybridDateTime(commit.HybridDateTime.DateTime, commit.HybridDateTime.Counter) - { - DateTime = commit.HybridDateTime.DateTime, Counter = commit.HybridDateTime.Counter - }, - ProjectId = projectId, - Metadata = commit.Metadata, - //without this sql cast the value will be treated as text and fail to insert into the jsonb column - ChangeEntities = Sql.Expr<List<ChangeEntity<ServerJsonChange>>>($"{commit.ChangeEntities}::jsonb") - }) + .InsertWhenNotMatched() .MergeAsync(token); await transaction.CommitAsync(token); diff --git a/backend/LexData/DataKernel.cs b/backend/LexData/DataKernel.cs index 79f35275c8..c1aa1774a4 100644 --- a/backend/LexData/DataKernel.cs +++ b/backend/LexData/DataKernel.cs @@ -1,7 +1,7 @@ using LexData.Configuration; using LinqToDB; -using LinqToDB.AspNet.Logging; using LinqToDB.EntityFrameworkCore; +using LinqToDB.Extensions.Logging; using LinqToDB.Mapping; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; diff --git a/backend/LexData/LexData.csproj b/backend/LexData/LexData.csproj index 9c5454bf25..54e2e970ea 100644 --- a/backend/LexData/LexData.csproj +++ b/backend/LexData/LexData.csproj @@ -2,8 +2,8 @@ <ItemGroup> <PackageReference Include="AppAny.Quartz.EntityFrameworkCore.Migrations.PostgreSQL" /> <PackageReference Include="Humanizer.Core" /> - <PackageReference Include="linq2db.AspNet" /> <PackageReference Include="linq2db.EntityFrameworkCore" /> + <PackageReference Include="linq2db.Extensions" /> <PackageReference Include="Microsoft.EntityFrameworkCore" /> <PackageReference Include="Microsoft.EntityFrameworkCore.Design"> <PrivateAssets>all</PrivateAssets> diff --git a/backend/Testing/LexCore/Services/CrdtCommitServiceTests.cs b/backend/Testing/LexCore/Services/CrdtCommitServiceTests.cs index adcdad5bd6..cfd853c6c9 100644 --- a/backend/Testing/LexCore/Services/CrdtCommitServiceTests.cs +++ b/backend/Testing/LexCore/Services/CrdtCommitServiceTests.cs @@ -66,7 +66,6 @@ public async Task CanQueryOldCommits() { var projectId = await _lexBoxDbContext.Projects.Select(p => p.Id).FirstOrDefaultAsync(); var context = _lexBoxDbContext.CreateLinqToDBContext(); - var table = LinqToDB.DataExtensions.GetTable<ServerCommit>(context); var commitId = Guid.NewGuid(); var changeEntity = new ChangeEntity<ServerJsonChange> { @@ -87,21 +86,23 @@ public async Task CanQueryOldCommits() //the old format stored json in json, this is emulating that. changeEntityJson["Change"] = changeEntityJson["Change"]?.ToJsonString(); var jsonPayload = changeEntityJson.ToJsonString(); - var inlineSql = $"'[{jsonPayload}]'::jsonb"; - //insert a new server commit, manually specifying the value for ChangeEntities so it will match the old format. - await LinqToDB.LinqExtensions.InsertAsync(table, () => new ServerCommit(commitId) - { - Id = commitId, - ClientId = Guid.NewGuid(), - HybridDateTime = new HybridDateTime(DateTimeOffset.UtcNow, 0) - { - DateTime = DateTimeOffset.UtcNow, - Counter = 0 - }, - ProjectId = projectId, - Metadata = new CommitMetadata(), - ChangeEntities = LinqToDB.Sql.Expr<List<ChangeEntity<ServerJsonChange>>>(inlineSql) - }); + //Insert a synthetic old-format commit via raw SQL so we can put pre-serialized + //JSON in ChangeEntities. Linq2Db v6 unconditionally wraps any column assignment + //(including Sql.Expr) in the EF JSON value converter inside an InsertAsync + //projection lambda, so we can't use the typed API for this test case. + var inlinePayload = $"[{jsonPayload}]"; + await LinqToDB.Data.DataContextExtensions.ExecuteAsync( + context, + """ + INSERT INTO "CrdtCommits" + ("Id", "ClientId", "HybridDateTime_DateTime", "HybridDateTime_Counter", "ProjectId", "Metadata", "ChangeEntities") + VALUES (@id, @clientId, @dt, 0, @projectId, '{}'::jsonb, @payload::jsonb) + """, + new LinqToDB.Data.DataParameter("id", commitId, LinqToDB.DataType.Guid), + new LinqToDB.Data.DataParameter("clientId", Guid.NewGuid(), LinqToDB.DataType.Guid), + new LinqToDB.Data.DataParameter("dt", DateTimeOffset.UtcNow, LinqToDB.DataType.DateTimeOffset), + new LinqToDB.Data.DataParameter("projectId", projectId, LinqToDB.DataType.Guid), + new LinqToDB.Data.DataParameter("payload", inlinePayload, LinqToDB.DataType.NVarChar)); var commits = await _lexBoxDbContext.CrdtCommits(projectId).ToArrayAsync(); var actualCommit = commits.Should().ContainSingle(c => c.Id == commitId).Subject; actualCommit.ChangeEntities.Should().BeEquivalentTo([changeEntity], From c687e40e10275cb6142c6fc59fb80c62fd6257af Mon Sep 17 00:00:00 2001 From: Tim Haasdyk <tim_haasdyk@sil.org> Date: Wed, 20 May 2026 17:39:57 +0200 Subject: [PATCH 05/21] Bump harmony submodule to 96a75b26 (HasCommit TOCTOU fix) Advances from 50b0e2da (Robin's PR #2264 base) to 96a75b26 which adds the HasCommit TOCTOU fix: the commit-presence check now happens inside the lock in DataModel.Add, preventing a race where two concurrent callers could both pass the check and then collide on insert. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --- backend/harmony | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/harmony b/backend/harmony index 50b0e2daf0..96a75b26b5 160000 --- a/backend/harmony +++ b/backend/harmony @@ -1 +1 @@ -Subproject commit 50b0e2daf0392082eb6f552372737a6241ed8cd9 +Subproject commit 96a75b26b59bfcde23968c5e9c59674aef59242e From 06aefc5d678639aa433774feee1bcc639da2fba7 Mon Sep 17 00:00:00 2001 From: Tim Haasdyk <tim_haasdyk@sil.org> Date: Thu, 21 May 2026 09:31:53 +0200 Subject: [PATCH 06/21] Linq2DbCctorPatcher cleanups Extract ReplaceBodyWith helper, use Type.EmptyTypes ctor lookup for NotImplementedException, drop a duplicate <IsPackable>, fill in the upstream PR URL (linq2db/linq2db#5546). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- backend/FwLite/FwLiteMaui/FwLiteMaui.csproj | 4 +- .../Linq2DbCctorPatcher.csproj | 3 +- .../build/Linq2DbCctorPatcher/Program.cs | 47 ++++++++----------- backend/FwLite/LcmCrdt/LINQ2DB-V6-NOTES.md | 2 +- 4 files changed, 24 insertions(+), 32 deletions(-) diff --git a/backend/FwLite/FwLiteMaui/FwLiteMaui.csproj b/backend/FwLite/FwLiteMaui/FwLiteMaui.csproj index c82b53d4a8..a9ba4bf0f3 100644 --- a/backend/FwLite/FwLiteMaui/FwLiteMaui.csproj +++ b/backend/FwLite/FwLiteMaui/FwLiteMaui.csproj @@ -115,7 +115,7 @@ backend/FwLite/LcmCrdt.Tests/SqlTransparentExpressionCctorRepro.cs and backend/FwLite/LcmCrdt/LINQ2DB-V6-NOTES.md (Cctor patcher section). - Upstream issue: TODO INSERT UPSTREAM ISSUE URL HERE + Upstream fix: https://github.com/linq2db/linq2db/pull/5546 (approved, not yet released). On desktop the class is beforefieldinit and its static fields are only read from Quote(), which our CRDT workload never calls, so the bug is silent. @@ -168,7 +168,7 @@ Text="Could not determine resolved version of linq2db.EntityFrameworkCore from backend/Directory.Packages.props. The cctor-patcher version pin can't verify itself. Investigate before proceeding." /> <!-- Patched range: 10.3.x only. Anything else fails the build. --> <Error Condition="'$(_Linq2DbEfCoreEffectiveVersion)' != '' And !$([System.Text.RegularExpressions.Regex]::IsMatch('$(_Linq2DbEfCoreEffectiveVersion)', '^10\.3\.[0-9]+(-.*)?$'))" - Text="linq2db.EntityFrameworkCore is at $(_Linq2DbEfCoreEffectiveVersion); the cctor patcher in build/Linq2DbCctorPatcher only verifies the 10.3.x IL shape. Re-verify the SqlTransparentExpression cctor bug still exists in $(_Linq2DbEfCoreEffectiveVersion) (unskip the repro test in LcmCrdt.Tests/SqlTransparentExpressionCctorRepro.cs and run it against the new version), then either: (a) widen this pin if still broken, or (b) DELETE build/Linq2DbCctorPatcher and the _BuildLinq2DbCctorPatcher / _PatchLinq2DbSqlTransparentExpressionCctor / _VerifyLinq2DbEfCoreVersionPin / _CollectLinq2DbStagedAssemblies targets if fixed. Upstream issue: TODO INSERT UPSTREAM ISSUE URL HERE. See backend/FwLite/LcmCrdt/LINQ2DB-V6-NOTES.md (Cctor patcher section)." /> + Text="linq2db.EntityFrameworkCore is at $(_Linq2DbEfCoreEffectiveVersion); the cctor patcher in build/Linq2DbCctorPatcher only verifies the 10.3.x IL shape. Re-verify the SqlTransparentExpression cctor bug still exists in $(_Linq2DbEfCoreEffectiveVersion) (unskip the repro test in LcmCrdt.Tests/SqlTransparentExpressionCctorRepro.cs and run it against the new version), then either: (a) widen this pin if still broken, or (b) DELETE build/Linq2DbCctorPatcher and the _BuildLinq2DbCctorPatcher / _PatchLinq2DbSqlTransparentExpressionCctor / _VerifyLinq2DbEfCoreVersionPin / _CollectLinq2DbStagedAssemblies targets if fixed. Upstream fix: https://github.com/linq2db/linq2db/pull/5546. See backend/FwLite/LcmCrdt/LINQ2DB-V6-NOTES.md (Cctor patcher section)." /> </Target> <Target Name="_BuildLinq2DbCctorPatcher" Condition="'$(TargetPlatform)' == 'android'" diff --git a/backend/FwLite/FwLiteMaui/build/Linq2DbCctorPatcher/Linq2DbCctorPatcher.csproj b/backend/FwLite/FwLiteMaui/build/Linq2DbCctorPatcher/Linq2DbCctorPatcher.csproj index 1f9eecff17..97b7bb2f3d 100644 --- a/backend/FwLite/FwLiteMaui/build/Linq2DbCctorPatcher/Linq2DbCctorPatcher.csproj +++ b/backend/FwLite/FwLiteMaui/build/Linq2DbCctorPatcher/Linq2DbCctorPatcher.csproj @@ -4,11 +4,10 @@ <TargetFramework>net10.0</TargetFramework> <Nullable>enable</Nullable> <ImplicitUsings>enable</ImplicitUsings> + <!-- This tool is invoked from MSBuild targets in FwLiteMaui.csproj; nothing else references it. --> <IsPackable>false</IsPackable> <!-- Don't let this opt into shared central package management; we just pin Mono.Cecil locally. --> <ManagePackageVersionsCentrally>false</ManagePackageVersionsCentrally> - <!-- This tool is invoked from MSBuild targets in FwLiteMaui.csproj; nothing else references it. --> - <IsPackable>false</IsPackable> </PropertyGroup> <ItemGroup> <PackageReference Include="Mono.Cecil" Version="0.11.6" /> diff --git a/backend/FwLite/FwLiteMaui/build/Linq2DbCctorPatcher/Program.cs b/backend/FwLite/FwLiteMaui/build/Linq2DbCctorPatcher/Program.cs index 73f2280820..d7aba9a13b 100644 --- a/backend/FwLite/FwLiteMaui/build/Linq2DbCctorPatcher/Program.cs +++ b/backend/FwLite/FwLiteMaui/build/Linq2DbCctorPatcher/Program.cs @@ -21,10 +21,7 @@ // Also removes Quote() so any unexpected caller fails loudly with // NotImplementedException instead of NRE'ing on the (now-null) _ctor field. // -// SCOPE: only FwLiteMaui targets net10.0-android today, so this patcher lives -// alongside it. If another csproj ever targets net10.0-android and references -// linq2db.EntityFrameworkCore, lift this into a shared backend/build/ tools -// directory and reference it from each consumer's targets. +// Upstream fix: https://github.com/linq2db/linq2db/pull/5546 (approved, not yet released). // // KILL-SWITCH: when upstream ships a fixed version (see the version pin // in FwLiteMaui.csproj — search for _Linq2DbEfCorePatchedVersion), delete this @@ -96,30 +93,17 @@ static int Fail(string message) if (quote is null || !quote.HasBody) return Fail("SqlTransparentExpression.Quote() not found (or has no body)."); - { - var il = cctor.Body.GetILProcessor(); - cctor.Body.Instructions.Clear(); - cctor.Body.ExceptionHandlers.Clear(); - cctor.Body.Variables.Clear(); - il.Append(Instruction.Create(OpCodes.Ret)); - Console.WriteLine("Stubbed SqlTransparentExpression .cctor to no-op ret"); - } + ReplaceBodyWith(cctor, Instruction.Create(OpCodes.Ret)); + Console.WriteLine("Stubbed SqlTransparentExpression .cctor to no-op ret"); - { - // Replace Quote() with `throw new NotImplementedException();` so anything that - // somehow reaches it fails loud rather than NRE'ing on the now-null _ctor field. - var nieCtor = asm.MainModule.ImportReference( - new MethodReference(".ctor", asm.MainModule.TypeSystem.Void, - asm.MainModule.ImportReference(typeof(NotImplementedException))) - { HasThis = true }); - var il = quote.Body.GetILProcessor(); - quote.Body.Instructions.Clear(); - quote.Body.ExceptionHandlers.Clear(); - quote.Body.Variables.Clear(); - il.Append(Instruction.Create(OpCodes.Newobj, nieCtor)); - il.Append(Instruction.Create(OpCodes.Throw)); - Console.WriteLine("Replaced SqlTransparentExpression.Quote() with throw NotImplementedException"); - } + // Replace Quote() with `throw new NotImplementedException();` so anything that + // somehow reaches it fails loud rather than NRE'ing on the now-null _ctor field. + var nieCtor = asm.MainModule.ImportReference( + typeof(NotImplementedException).GetConstructor(Type.EmptyTypes)!); + ReplaceBodyWith(quote, + Instruction.Create(OpCodes.Newobj, nieCtor), + Instruction.Create(OpCodes.Throw)); + Console.WriteLine("Replaced SqlTransparentExpression.Quote() with throw NotImplementedException"); asm.Write(); } @@ -127,3 +111,12 @@ static int Fail(string message) File.WriteAllText(markerPath, DateTime.UtcNow.ToString("O")); Console.WriteLine($"Patched {dllPath}"); return 0; + +static void ReplaceBodyWith(MethodDefinition method, params Instruction[] instructions) +{ + method.Body.Instructions.Clear(); + method.Body.ExceptionHandlers.Clear(); + method.Body.Variables.Clear(); + var il = method.Body.GetILProcessor(); + foreach (var ins in instructions) il.Append(ins); +} diff --git a/backend/FwLite/LcmCrdt/LINQ2DB-V6-NOTES.md b/backend/FwLite/LcmCrdt/LINQ2DB-V6-NOTES.md index 0d4adf289b..d18986365f 100644 --- a/backend/FwLite/LcmCrdt/LINQ2DB-V6-NOTES.md +++ b/backend/FwLite/LcmCrdt/LINQ2DB-V6-NOTES.md @@ -305,4 +305,4 @@ Desktop is silent: the class is `beforefieldinit`, only `Quote()` reads the affe 3. If they pass → delete `backend/FwLite/FwLiteMaui/build/Linq2DbCctorPatcher/` and the four targets in `FwLiteMaui.csproj` (`_VerifyLinq2DbEfCoreVersionPin`, `_BuildLinq2DbCctorPatcher`, `_CollectLinq2DbStagedAssemblies`, `_PatchLinq2DbSqlTransparentExpressionCctor`). Leave the now-passing repro tests as a permanent regression guard. 4. If they still fail → widen the version pin regex in `_VerifyLinq2DbEfCoreVersionPin`, re-`[Skip]` the tests with an updated reason. -**Upstream issue:** TODO INSERT UPSTREAM ISSUE URL HERE (search both `_VerifyLinq2DbEfCoreVersionPin` in `FwLiteMaui.csproj` and `Program.cs` in the patcher project — keep the URL in lockstep). +**Upstream fix:** <https://github.com/linq2db/linq2db/pull/5546> (approved, not yet released). The URL is repeated in `_VerifyLinq2DbEfCoreVersionPin` in `FwLiteMaui.csproj` and in the patcher `Program.cs` — keep the three in lockstep. From b0e98052954a358947fe4ae8c9750411ab29245c Mon Sep 17 00:00:00 2001 From: Tim Haasdyk <tim_haasdyk@sil.org> Date: Thu, 21 May 2026 10:43:57 +0200 Subject: [PATCH 07/21] Restore predefined-data named-GUIDs work from #2278 The linq2db v6 + EF Core 10 work in 8d2b22aa and 525cfd7d accidentally reverted PR #2278 ('Use named GUIDs for predefined-data seed commits'). This restores PreDefinedData, CrdtProjectsService, CurrentProjectService, the MiniLcmApiFixture call site, and the UUIDNext PackageReference, while keeping the intended linq2db.AspNet -> linq2db.Extensions rename. --- .../FwLite/LcmCrdt.Tests/MiniLcmApiFixture.cs | 2 +- backend/FwLite/LcmCrdt/CrdtProjectsService.cs | 14 ++--- .../FwLite/LcmCrdt/CurrentProjectService.cs | 6 +-- backend/FwLite/LcmCrdt/LcmCrdt.csproj | 1 + .../FwLite/LcmCrdt/Objects/PreDefinedData.cs | 51 ++++++++++++------- 5 files changed, 46 insertions(+), 28 deletions(-) diff --git a/backend/FwLite/LcmCrdt.Tests/MiniLcmApiFixture.cs b/backend/FwLite/LcmCrdt.Tests/MiniLcmApiFixture.cs index d7f2e0f984..3218031489 100644 --- a/backend/FwLite/LcmCrdt.Tests/MiniLcmApiFixture.cs +++ b/backend/FwLite/LcmCrdt.Tests/MiniLcmApiFixture.cs @@ -77,7 +77,7 @@ await CrdtProjectsService.InitProjectDb(_crdtDbContext, projectData); await currentProjectService.RefreshProjectData(); // CreateProject would also seed morph types — so we need to do it manually here - await PreDefinedData.AddPredefinedMorphTypes(_services.ServiceProvider.GetRequiredService<DataModel>(), projectData.ClientId); + await PreDefinedData.AddPredefinedMorphTypes(_services.ServiceProvider.GetRequiredService<DataModel>(), projectData); if (_seedWs) { await Api.CreateWritingSystem(new WritingSystem() diff --git a/backend/FwLite/LcmCrdt/CrdtProjectsService.cs b/backend/FwLite/LcmCrdt/CrdtProjectsService.cs index 08c03b2455..f6ab9cf490 100644 --- a/backend/FwLite/LcmCrdt/CrdtProjectsService.cs +++ b/backend/FwLite/LcmCrdt/CrdtProjectsService.cs @@ -169,9 +169,9 @@ public virtual async Task<CrdtProject> CreateProject(CreateProjectRequest reques // Morph types are predefined system data that must always exist — seed them // unconditionally so they're available before AfterCreate (e.g. import) runs. var dataModel = serviceScope.ServiceProvider.GetRequiredService<DataModel>(); - await PreDefinedData.AddPredefinedMorphTypes(dataModel, projectData.ClientId); + await PreDefinedData.AddPredefinedMorphTypes(dataModel, projectData); if (request.SeedNewProjectData) - await SeedSystemData(dataModel, projectData.ClientId); + await SeedSystemData(dataModel, projectData); await (request.AfterCreate?.Invoke(serviceScope.ServiceProvider, crdtProject) ?? Task.CompletedTask); } catch (Exception e) @@ -243,13 +243,13 @@ internal static async Task InitProjectDb(LcmCrdtDbContext db, ProjectData data) await db.SaveChangesAsync(); } - internal static async Task SeedSystemData(DataModel dataModel, Guid clientId) + internal static async Task SeedSystemData(DataModel dataModel, ProjectData projectData) { // Note: AddPredefinedMorphTypes is seeded unconditionally in CreateProject, not here. - await PreDefinedData.AddPredefinedComplexFormTypes(dataModel, clientId); - await PreDefinedData.AddPredefinedPartsOfSpeech(dataModel, clientId); - await PreDefinedData.AddPredefinedSemanticDomains(dataModel, clientId); - await PreDefinedData.AddPredefinedCustomViews(dataModel, clientId); + await PreDefinedData.AddPredefinedComplexFormTypes(dataModel, projectData); + await PreDefinedData.AddPredefinedPartsOfSpeech(dataModel, projectData); + await PreDefinedData.AddPredefinedSemanticDomains(dataModel, projectData); + await PreDefinedData.AddPredefinedCustomViews(dataModel, projectData); } [GeneratedRegex("^[a-zA-Z0-9][a-zA-Z0-9-_]+$")] diff --git a/backend/FwLite/LcmCrdt/CurrentProjectService.cs b/backend/FwLite/LcmCrdt/CurrentProjectService.cs index 90a57f8d70..36a886c5a5 100644 --- a/backend/FwLite/LcmCrdt/CurrentProjectService.cs +++ b/backend/FwLite/LcmCrdt/CurrentProjectService.cs @@ -111,11 +111,11 @@ async Task Execute() // Seed morph-types if missing (for existing projects created before morph-type support). // Must happen BEFORE FTS regeneration so headwords include morph-type tokens. // (querying Commits instead of MorphTypes, because the commit may not be projected yet) - if (!await dbContext.Set<Commit>().AsNoTracking().AnyAsync(c => c.Id == PreDefinedData.MorphTypesSeedCommitId)) + var projectData = await dbContext.ProjectData.AsNoTracking().FirstAsync(); + if (!await dbContext.Set<Commit>().AsNoTracking().AnyAsync(c => c.Id == PreDefinedData.MorphTypesSeedCommitId(projectData.Id))) { var dataModel = services.GetRequiredService<DataModel>(); - var projectData = await dbContext.ProjectData.AsNoTracking().FirstAsync(); - await PreDefinedData.AddPredefinedMorphTypes(dataModel, projectData.ClientId); + await PreDefinedData.AddPredefinedMorphTypes(dataModel, projectData); } if (EntrySearchServiceFactory is not null) diff --git a/backend/FwLite/LcmCrdt/LcmCrdt.csproj b/backend/FwLite/LcmCrdt/LcmCrdt.csproj index f8cfb1db27..16463784b3 100644 --- a/backend/FwLite/LcmCrdt/LcmCrdt.csproj +++ b/backend/FwLite/LcmCrdt/LcmCrdt.csproj @@ -19,6 +19,7 @@ <PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" /> <PackageReference Include="Refit" /> <PackageReference Include="Refit.HttpClientFactory" /> + <PackageReference Include="UUIDNext" /> </ItemGroup> <ItemGroup Condition="$([MSBuild]::IsOsPlatform('linux'))"> <PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" /> diff --git a/backend/FwLite/LcmCrdt/Objects/PreDefinedData.cs b/backend/FwLite/LcmCrdt/Objects/PreDefinedData.cs index 88f5849e6c..f319de8921 100644 --- a/backend/FwLite/LcmCrdt/Objects/PreDefinedData.cs +++ b/backend/FwLite/LcmCrdt/Objects/PreDefinedData.cs @@ -1,5 +1,6 @@ using LcmCrdt.Changes; using SIL.Harmony; +using UUIDNext; namespace LcmCrdt.Objects; @@ -15,20 +16,38 @@ public static class PreDefinedData public static readonly Guid AdjectivePartOfSpeechId = new("30d07580-5052-4d91-bc24-469b8b2d7df9"); public static readonly Guid AdverbPartOfSpeechId = new("46e4fe08-ffa0-4c8b-bf98-2c56f38904d9"); - internal static async Task AddPredefinedComplexFormTypes(DataModel dataModel, Guid clientId) + // Seed commit-ids are derived per-project (UUIDv5 namespaced on projectId) so each project + // owns its own row in LexBox's CrdtCommits table — a shared constant id would collide on the + // primary key and the seed would get attributed to whichever project pushed first. + public static Guid ComplexFormTypesSeedCommitId(Guid projectId) => + Uuid.NewNameBased(projectId, "complex-form-types-seed"); + + public static Guid SemanticDomainsSeedCommitId(Guid projectId) => + Uuid.NewNameBased(projectId, "semantic-domains-seed"); + + public static Guid PartsOfSpeechSeedCommitId(Guid projectId) => + Uuid.NewNameBased(projectId, "parts-of-speech-seed"); + + public static Guid CustomViewsSeedCommitId(Guid projectId) => + Uuid.NewNameBased(projectId, "custom-views-seed"); + + public static Guid MorphTypesSeedCommitId(Guid projectId) => + Uuid.NewNameBased(projectId, "morph-types-seed"); + + internal static async Task AddPredefinedComplexFormTypes(DataModel dataModel, ProjectData projectData) { - await dataModel.AddChanges(clientId, + await dataModel.AddChanges(projectData.ClientId, [ new CreateComplexFormType(CompoundComplexFormTypeId, new MultiString() { { "en", "Compound" } } ), new CreateComplexFormType(UnspecifiedComplexFormTypeId, new MultiString() { { "en", "Unspecified" } }) ], - new Guid("dc60d2a9-0cc2-48ed-803c-a238a14b6eae")); + ComplexFormTypesSeedCommitId(projectData.Id)); } - internal static async Task AddPredefinedSemanticDomains(DataModel dataModel, Guid clientId) + internal static async Task AddPredefinedSemanticDomains(DataModel dataModel, ProjectData projectData) { //todo load from xml instead of hardcoding and use real IDs - await dataModel.AddChanges(clientId, + await dataModel.AddChanges(projectData.ClientId, [ new CreateSemanticDomainChange(new Guid("63403699-07c1-43f3-a47c-069d6e4316e5"), new MultiString() { { "en", "Universe, Creation" } }, "1", true), new CreateSemanticDomainChange(new Guid("999581c4-1611-4acb-ae1b-5e6c1dfe6f0c"), new MultiString() { { "en", "Sky" } }, "1.1", true), @@ -38,25 +57,25 @@ await dataModel.AddChanges(clientId, new CreateSemanticDomainChange(new Guid("46e4fe08-ffa0-4c8b-bf88-2c56f38904d5"), new MultiString() { { "en", "Head" } }, "2.1.1", false), new CreateSemanticDomainChange(new Guid("46e4fe08-ffa0-4c8b-bf88-2c56f38904d6"), new MultiString() { { "en", "Eye" } }, "2.1.1.1", false), ], - new Guid("023faebb-711b-4d2f-a14f-a15621fc66bc")); + SemanticDomainsSeedCommitId(projectData.Id)); } - public static async Task AddPredefinedPartsOfSpeech(DataModel dataModel, Guid clientId) + public static async Task AddPredefinedPartsOfSpeech(DataModel dataModel, ProjectData projectData) { //todo load from xml instead of hardcoding - await dataModel.AddChanges(clientId, + await dataModel.AddChanges(projectData.ClientId, [ new CreatePartOfSpeechChange(NounPartOfSpeechId, new MultiString() { { "en", "Noun" } }, true), new CreatePartOfSpeechChange(VerbPartOfSpeechId, new MultiString() { { "en", "Verb" } }, true), new CreatePartOfSpeechChange(AdjectivePartOfSpeechId, new MultiString() { { "en", "Adjective" } }, true), new CreatePartOfSpeechChange(AdverbPartOfSpeechId, new MultiString() { { "en", "Adverb" } }, true), ], - new Guid("023faebb-711b-4d2f-b34f-a15621fc66bb")); + PartsOfSpeechSeedCommitId(projectData.Id)); } - internal static async Task AddPredefinedCustomViews(DataModel dataModel, Guid clientId) + internal static async Task AddPredefinedCustomViews(DataModel dataModel, ProjectData projectData) { - await dataModel.AddChanges(clientId, + await dataModel.AddChanges(projectData.ClientId, [ new CreateCustomViewChange( new Guid("a1b2c3d4-e5f6-7890-abcd-ef1234567890"), @@ -82,15 +101,13 @@ await dataModel.AddChanges(clientId, Analysis = [new ViewWritingSystem { WsId = "en" }] }) ], - new Guid("b2c3d4e5-f6a7-8901-bcde-f12345678901")); + CustomViewsSeedCommitId(projectData.Id)); } - public static readonly Guid MorphTypesSeedCommitId = new("a7b2c3d4-e5f6-4a8b-9c0d-1e2f3a4b5c6d"); - - internal static async Task AddPredefinedMorphTypes(DataModel dataModel, Guid clientId) + internal static async Task AddPredefinedMorphTypes(DataModel dataModel, ProjectData projectData) { - await dataModel.AddChanges(clientId, + await dataModel.AddChanges(projectData.ClientId, [.. CanonicalMorphTypes.All.Values.Select(mt => new CreateMorphTypeChange(mt))], - MorphTypesSeedCommitId); + MorphTypesSeedCommitId(projectData.Id)); } } From 519c4be27b08a97c3362eb9ee5f1df9271f6a6bd Mon Sep 17 00:00:00 2001 From: Tim Haasdyk <tim_haasdyk@sil.org> Date: Thu, 21 May 2026 16:42:09 +0200 Subject: [PATCH 08/21] Update DbModel snapshot for EF Core 10 debug-string changes EF Core 10's Model.ToDebugString output drops empty 'DiscriminatorProperty:' annotations, emits explicit Required/Optional on navigations, and reports its own version. All cosmetic; no model change. --- ...elSnapshotTests.VerifyDbModel.verified.txt | 24 +++---------------- 1 file changed, 3 insertions(+), 21 deletions(-) diff --git a/backend/FwLite/LcmCrdt.Tests/DataModelSnapshotTests.VerifyDbModel.verified.txt b/backend/FwLite/LcmCrdt.Tests/DataModelSnapshotTests.VerifyDbModel.verified.txt index 8773cd85a0..c4255efd84 100644 --- a/backend/FwLite/LcmCrdt.Tests/DataModelSnapshotTests.VerifyDbModel.verified.txt +++ b/backend/FwLite/LcmCrdt.Tests/DataModelSnapshotTests.VerifyDbModel.verified.txt @@ -10,7 +10,6 @@ Keys: Id PK Annotations: - DiscriminatorProperty: Relational:FunctionName: Relational:Schema: Relational:SqlQuery: @@ -33,7 +32,6 @@ Keys: Id PK Annotations: - DiscriminatorProperty: Relational:FunctionName: Relational:Schema: Relational:SqlQuery: @@ -71,7 +69,6 @@ Annotations: Relational:Filter: ComponentSenseId IS NOT NULL Annotations: - DiscriminatorProperty: Relational:FunctionName: Relational:Schema: Relational:SqlQuery: @@ -93,7 +90,6 @@ Indexes: SnapshotId Unique Annotations: - DiscriminatorProperty: Relational:FunctionName: Relational:Schema: Relational:SqlQuery: @@ -129,7 +125,6 @@ Indexes: SnapshotId Unique Annotations: - DiscriminatorProperty: Relational:FunctionName: Relational:Schema: Relational:SqlQuery: @@ -171,7 +166,6 @@ Indexes: SnapshotId Unique Annotations: - DiscriminatorProperty: Relational:FunctionName: Relational:Schema: Relational:SqlQuery: @@ -203,7 +197,6 @@ SenseId SnapshotId Unique Annotations: - DiscriminatorProperty: Relational:FunctionName: Relational:Schema: Relational:SqlQuery: @@ -236,7 +229,6 @@ Kind Unique SnapshotId Unique Annotations: - DiscriminatorProperty: Relational:FunctionName: Relational:Schema: Relational:SqlQuery: @@ -259,7 +251,6 @@ Indexes: SnapshotId Unique Annotations: - DiscriminatorProperty: Relational:FunctionName: Relational:Schema: Relational:SqlQuery: @@ -281,7 +272,6 @@ Indexes: SnapshotId Unique Annotations: - DiscriminatorProperty: Relational:FunctionName: Relational:Schema: Relational:SqlQuery: @@ -305,7 +295,6 @@ Indexes: SnapshotId Unique Annotations: - DiscriminatorProperty: Relational:FunctionName: Relational:Schema: Relational:SqlQuery: @@ -331,7 +320,7 @@ SnapshotId (no field, Guid?) Shadow FK Index Navigations: ExampleSentences (List<ExampleSentence>) Collection ToDependent ExampleSentence - PartOfSpeech (PartOfSpeech) ToPrincipal PartOfSpeech + PartOfSpeech (PartOfSpeech) Optional ToPrincipal PartOfSpeech Keys: Id PK Foreign keys: @@ -343,7 +332,6 @@ PartOfSpeechId SnapshotId Unique Annotations: - DiscriminatorProperty: Relational:FunctionName: Relational:Schema: Relational:SqlQuery: @@ -372,7 +360,6 @@ SnapshotId Unique WsId, Type Unique Annotations: - DiscriminatorProperty: Relational:FunctionName: Relational:Schema: Relational:SqlQuery: @@ -404,7 +391,6 @@ Keys: Id PK Annotations: - DiscriminatorProperty: Relational:FunctionName: Relational:Schema: Relational:SqlQuery: @@ -424,7 +410,6 @@ Foreign keys: ChangeEntity<IChange> {'CommitId'} -> Commit {'Id'} Required Cascade ToDependent: ChangeEntities Annotations: - DiscriminatorProperty: Relational:FunctionName: Relational:Schema: Relational:SqlQuery: @@ -446,7 +431,7 @@ ElementType: Element type: Guid Required TypeName (string) Required Navigations: - Commit (Commit) ToPrincipal Commit Inverse: Snapshots + Commit (Commit) Required ToPrincipal Commit Inverse: Snapshots Keys: Id PK Foreign keys: @@ -455,7 +440,6 @@ EntityId CommitId, EntityId Unique Annotations: - DiscriminatorProperty: Relational:FunctionName: Relational:Schema: Relational:SqlQuery: @@ -469,7 +453,6 @@ Keys: Id PK Annotations: - DiscriminatorProperty: Relational:FunctionName: Relational:Schema: Relational:SqlQuery: @@ -489,7 +472,6 @@ Indexes: SnapshotId Unique Annotations: - DiscriminatorProperty: Relational:FunctionName: Relational:Schema: Relational:SqlQuery: @@ -497,4 +479,4 @@ Relational:ViewName: Relational:ViewSchema: Annotations: - ProductVersion: 9.0.16 \ No newline at end of file + ProductVersion: 10.0.7 \ No newline at end of file From 2283483ca46ba75203896d27b827e68ad542e93f Mon Sep 17 00:00:00 2001 From: Tim Haasdyk <tim_haasdyk@sil.org> Date: Thu, 21 May 2026 17:47:48 +0200 Subject: [PATCH 09/21] Clean up linq2db v6 comments and reduce diff noise - Drop a stale TODO and a "v6 dropped MakeTryConvert" comment that don't earn their keep - Tighten the Sql.Alias / ExposeExpressionVisitor comment to name the specific linq2db version it describes - Reword the RichMultiString JsonPatch repro test comment to focus on what it actually covers (Entry-routed patch via IDictionary.Add) - Swap RichMultiString.CopyTo / Remove back to their original order --- .../FwLite/LcmCrdt/FullTextSearch/EntrySearchService.cs | 1 - backend/FwLite/LcmCrdt/Json.cs | 6 +++--- backend/FwLite/MiniLcm.Tests/RichMultiStringTests.cs | 5 ++--- backend/FwLite/MiniLcm/Models/RichMultiString.cs | 8 ++++---- 4 files changed, 9 insertions(+), 11 deletions(-) diff --git a/backend/FwLite/LcmCrdt/FullTextSearch/EntrySearchService.cs b/backend/FwLite/LcmCrdt/FullTextSearch/EntrySearchService.cs index b7bbc3ff83..84bbe2eeda 100644 --- a/backend/FwLite/LcmCrdt/FullTextSearch/EntrySearchService.cs +++ b/backend/FwLite/LcmCrdt/FullTextSearch/EntrySearchService.cs @@ -267,7 +267,6 @@ public static async Task UpdateEntrySearchTable(IEnumerable<Entry> entries, foreach (var entrySearchRecord in searchRecords) { //can't use bulk copy here because that creates duplicate rows - //TODO: Replace this with a bulk delete followed by a bulk copy, it should be faster - 2026-05 RM await InsertOrUpdateEntrySearchRecord(entrySearchRecord, entrySearchRecordsTable); } diff --git a/backend/FwLite/LcmCrdt/Json.cs b/backend/FwLite/LcmCrdt/Json.cs index 1bc8a53aa2..cbb5da1c9e 100644 --- a/backend/FwLite/LcmCrdt/Json.cs +++ b/backend/FwLite/LcmCrdt/Json.cs @@ -52,7 +52,6 @@ public void Build(Sql.ISqlExtensionBuilder builder) if (returnType != typeof(string) && returnType != typeof(RichString))//bypass rich string so it can be used with .GetPlainText() { - //v6 dropped PseudoFunctions.MakeTryConvert; build the SqlFunction directly instead. valueExpression = new SqlFunction( new DbDataType(returnType), PseudoFunctions.TRY_CONVERT, @@ -94,8 +93,9 @@ private static void BuildParameterPath(Expression? pathBody, } else if (mce.Method.DeclaringType == typeof(Sql) && mce.Method.Name == "Alias") { - //v6 wraps [ExpressionMethod] substitutions in Sql.Alias(real, "<name>") for - //output-column aliasing; peel it so the path walker sees the underlying expr. + //linq2db 6.x's ExposeExpressionVisitor wraps every [ExpressionMethod] substitution in + //Sql.Alias(real, attr.Alias ?? member.Name) as a column-alias hint; peel it so the path + //walker sees the underlying expression. linq2db 5 did not have this wrap. pathBody = mce.Arguments[0]; } else diff --git a/backend/FwLite/MiniLcm.Tests/RichMultiStringTests.cs b/backend/FwLite/MiniLcm.Tests/RichMultiStringTests.cs index 01e70eab2c..59787a3739 100644 --- a/backend/FwLite/MiniLcm.Tests/RichMultiStringTests.cs +++ b/backend/FwLite/MiniLcm.Tests/RichMultiStringTests.cs @@ -307,9 +307,8 @@ public void JsonPatchCanAddRichMultiStringWhenValueIsString() ms["fr"].Should().BeEquivalentTo(new RichString("test", "fr")); } - //the original repro for "System.ArgumentException: unable to convert value String to RichString": - //a JsonPatchDocument<Entry> patches /Note/en with a raw string value. This routes through - //PocoAdapter.TryAdd -> DictionaryPropertyProxy, which used to hand RichMultiString.IDictionary.Add a raw string. + //same as above but patching via the parent Entry, so the patch goes through PocoAdapter / + //DictionaryPropertyProxy and hits RichMultiString's IDictionary.Add instead of the typed Add. [Fact] public void JsonPatchCanAddRichMultiStringPropertyOnEntityWhenValueIsString() { diff --git a/backend/FwLite/MiniLcm/Models/RichMultiString.cs b/backend/FwLite/MiniLcm/Models/RichMultiString.cs index d8aefed427..e5ba5453d5 100644 --- a/backend/FwLite/MiniLcm/Models/RichMultiString.cs +++ b/backend/FwLite/MiniLcm/Models/RichMultiString.cs @@ -69,14 +69,14 @@ public bool Contains(KeyValuePair<WritingSystemId, RichString> item) return dictionary.Contains(item); } - public bool Remove(KeyValuePair<WritingSystemId, RichString> item) + public void CopyTo(KeyValuePair<WritingSystemId, RichString>[] array, int arrayIndex) { - return dictionary.Remove(item); + dictionary.CopyTo(array, arrayIndex); } - public void CopyTo(KeyValuePair<WritingSystemId, RichString>[] array, int arrayIndex) + public bool Remove(KeyValuePair<WritingSystemId, RichString> item) { - dictionary.CopyTo(array, arrayIndex); + return dictionary.Remove(item); } public int Count => dictionary.Count; From 713bafcab9be779cd0dcb30d0f278a5cfc776503 Mon Sep 17 00:00:00 2001 From: Tim Haasdyk <tim_haasdyk@sil.org> Date: Thu, 21 May 2026 17:48:03 +0200 Subject: [PATCH 10/21] Bump Microsoft.* patch versions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 10.0.7 -> 10.0.8 across AspNetCore / EntityFrameworkCore / Extensions / System.* / OpenApi, and 10.5.0 -> 10.6.0 for the Extensions rolling packages (Caching.Hybrid, Http.Resilience, ServiceDiscovery, TimeProvider.Testing). DbModel snapshot follows EF Core's ProductVersion. Held back deliberately: - linq2db 6.2.1 / linq2db.EntityFrameworkCore 10.3.0 — upstream PR #5546 (SqlTransparentExpression cctor fix that would let us drop the IL patcher) targets linq2db 6.4.0 and is not yet merged. 10.4.0 ships 6.3.0 and still has the buggy reflection, so bumping buys nothing. - FluentAssertions 7.0.0-alpha.5 — pinned to dodge an 8.x BeEquivalentTo perf regression that pushes FwLite CI past its 60-min timeout. --- backend/Directory.Packages.props | 60 +++++++++---------- ...elSnapshotTests.VerifyDbModel.verified.txt | 2 +- 2 files changed, 31 insertions(+), 31 deletions(-) diff --git a/backend/Directory.Packages.props b/backend/Directory.Packages.props index c54d637395..95173db93f 100644 --- a/backend/Directory.Packages.props +++ b/backend/Directory.Packages.props @@ -32,34 +32,34 @@ <PackageVersion Include="linq2db.EntityFrameworkCore" Version="10.3.0" /> <PackageVersion Include="MailKit" Version="4.16.0" /> <PackageVersion Include="Meziantou.Extensions.Logging.Xunit" Version="2.0.0" /> - <PackageVersion Include="Microsoft.AspNetCore.Authentication.Google" Version="10.0.7" /> - <PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.7" /> - <PackageVersion Include="Microsoft.AspNetCore.Components.Web" Version="10.0.7" /> + <PackageVersion Include="Microsoft.AspNetCore.Authentication.Google" Version="10.0.8" /> + <PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.8" /> + <PackageVersion Include="Microsoft.AspNetCore.Components.Web" Version="10.0.8" /> <PackageVersion Include="Microsoft.AspNetCore.Components.WebView.Maui" Version="$(MauiVersion)" /> - <PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.7" /> - <PackageVersion Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.7" /> - <PackageVersion Include="Microsoft.EntityFrameworkCore" Version="10.0.7" /> - <PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.7" /> - <PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.7" /> - <PackageVersion Include="Microsoft.Extensions.Caching.Hybrid" Version="10.5.0" /> - <PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="10.0.7" /> - <PackageVersion Include="Microsoft.Extensions.Configuration" Version="10.0.7" /> - <PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.7" /> - <PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.7" /> - <PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.7" /> - <PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="10.0.7" /> - <PackageVersion Include="Microsoft.Extensions.FileProviders.Embedded" Version="10.0.7" /> - <PackageVersion Include="Microsoft.Extensions.Hosting" Version="10.0.7" /> - <PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.7" /> - <PackageVersion Include="Microsoft.Extensions.Http.Resilience" Version="10.5.0" /> - <PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.7" /> - <PackageVersion Include="Microsoft.Extensions.Logging.Console" Version="10.0.7" /> - <PackageVersion Include="Microsoft.Extensions.Logging.Debug" Version="10.0.7" /> - <PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.7" /> - <PackageVersion Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.7" /> - <PackageVersion Include="Microsoft.Extensions.Options.DataAnnotations" Version="10.0.7" /> - <PackageVersion Include="Microsoft.Extensions.ServiceDiscovery" Version="10.5.0" /> - <PackageVersion Include="Microsoft.Extensions.TimeProvider.Testing" Version="10.5.0" /> + <PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.8" /> + <PackageVersion Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.8" /> + <PackageVersion Include="Microsoft.EntityFrameworkCore" Version="10.0.8" /> + <PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.8" /> + <PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.8" /> + <PackageVersion Include="Microsoft.Extensions.Caching.Hybrid" Version="10.6.0" /> + <PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="10.0.8" /> + <PackageVersion Include="Microsoft.Extensions.Configuration" Version="10.0.8" /> + <PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.8" /> + <PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.8" /> + <PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.8" /> + <PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="10.0.8" /> + <PackageVersion Include="Microsoft.Extensions.FileProviders.Embedded" Version="10.0.8" /> + <PackageVersion Include="Microsoft.Extensions.Hosting" Version="10.0.8" /> + <PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.8" /> + <PackageVersion Include="Microsoft.Extensions.Http.Resilience" Version="10.6.0" /> + <PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.8" /> + <PackageVersion Include="Microsoft.Extensions.Logging.Console" Version="10.0.8" /> + <PackageVersion Include="Microsoft.Extensions.Logging.Debug" Version="10.0.8" /> + <PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.8" /> + <PackageVersion Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.8" /> + <PackageVersion Include="Microsoft.Extensions.Options.DataAnnotations" Version="10.0.8" /> + <PackageVersion Include="Microsoft.Extensions.ServiceDiscovery" Version="10.6.0" /> + <PackageVersion Include="Microsoft.Extensions.TimeProvider.Testing" Version="10.6.0" /> <PackageVersion Include="Microsoft.ICU.ICU4C.Runtime" Version="72.1.0.3" /> <PackageVersion Include="Microsoft.Identity.Client.Extensions.Msal" Version="4.84.0" /> <PackageVersion Include="Microsoft.IdentityModel.Protocols.OpenIdConnect" Version="8.18.0" /> @@ -122,9 +122,9 @@ <PackageVersion Include="System.IdentityModel.Tokens.Jwt" Version="8.18.0" /> <PackageVersion Include="System.Linq.Async" Version="7.0.1" /> <PackageVersion Include="System.Reactive" Version="6.1.0" /> - <PackageVersion Include="System.Security.Cryptography.Xml" Version="10.0.7" /> - <PackageVersion Include="System.Text.Encodings.Web" Version="10.0.7" /> - <PackageVersion Include="System.Text.Json" Version="10.0.7" /> + <PackageVersion Include="System.Security.Cryptography.Xml" Version="10.0.8" /> + <PackageVersion Include="System.Text.Encodings.Web" Version="10.0.8" /> + <PackageVersion Include="System.Text.Json" Version="10.0.8" /> <PackageVersion Include="SystemTextJson.JsonDiffPatch" Version="2.0.0" /> <PackageVersion Include="SystemTextJsonPatch" Version="5.0.0" /> <PackageVersion Include="tusdotnet" Version="2.11.1" /> diff --git a/backend/FwLite/LcmCrdt.Tests/DataModelSnapshotTests.VerifyDbModel.verified.txt b/backend/FwLite/LcmCrdt.Tests/DataModelSnapshotTests.VerifyDbModel.verified.txt index c4255efd84..82d3909af3 100644 --- a/backend/FwLite/LcmCrdt.Tests/DataModelSnapshotTests.VerifyDbModel.verified.txt +++ b/backend/FwLite/LcmCrdt.Tests/DataModelSnapshotTests.VerifyDbModel.verified.txt @@ -479,4 +479,4 @@ Relational:ViewName: Relational:ViewSchema: Annotations: - ProductVersion: 10.0.7 \ No newline at end of file + ProductVersion: 10.0.8 \ No newline at end of file From 1e9374dcd5322930901eda2361b6abb1858d722e Mon Sep 17 00:00:00 2001 From: Tim Haasdyk <tim_haasdyk@sil.org> Date: Fri, 22 May 2026 08:48:53 +0200 Subject: [PATCH 11/21] Bump linq2db 6.2.1 -> 6.3.0 and linq2db.EntityFrameworkCore 10.3.0 -> 10.4.0 linq2db.EntityFrameworkCore 10.4.0 ships linq2db 6.3.0 (released 2026-05-17). We already work around the SqlTransparentExpression cctor bug at MSBuild time on Android via build/Linq2DbCctorPatcher, and the bug's IL shape is byte-identical in 10.3.0 and 10.4.0, so the patcher applies unchanged. Widens the version pin in FwLiteMaui.csproj to 10.3.x | 10.4.x and updates the patcher's top-of-file comment. Upstream fix (PR #5546) targets linq2db 6.4.0 and isn't out yet; once it ships we can delete the patcher entirely. --- backend/Directory.Packages.props | 4 ++-- backend/FwLite/FwLiteMaui/FwLiteMaui.csproj | 6 +++--- .../FwLite/FwLiteMaui/build/Linq2DbCctorPatcher/Program.cs | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/backend/Directory.Packages.props b/backend/Directory.Packages.props index 95173db93f..611650c32d 100644 --- a/backend/Directory.Packages.props +++ b/backend/Directory.Packages.props @@ -28,8 +28,8 @@ <PackageVersion Include="HotChocolate.Types.OffsetPagination" Version="$(HotChocolateVersion)" /> <PackageVersion Include="Humanizer.Core" Version="3.0.10" /> <PackageVersion Include="icu.net" Version="3.0.1" /> - <PackageVersion Include="linq2db.Extensions" Version="6.2.1" /> - <PackageVersion Include="linq2db.EntityFrameworkCore" Version="10.3.0" /> + <PackageVersion Include="linq2db.Extensions" Version="6.3.0" /> + <PackageVersion Include="linq2db.EntityFrameworkCore" Version="10.4.0" /> <PackageVersion Include="MailKit" Version="4.16.0" /> <PackageVersion Include="Meziantou.Extensions.Logging.Xunit" Version="2.0.0" /> <PackageVersion Include="Microsoft.AspNetCore.Authentication.Google" Version="10.0.8" /> diff --git a/backend/FwLite/FwLiteMaui/FwLiteMaui.csproj b/backend/FwLite/FwLiteMaui/FwLiteMaui.csproj index a9ba4bf0f3..576164ea7e 100644 --- a/backend/FwLite/FwLiteMaui/FwLiteMaui.csproj +++ b/backend/FwLite/FwLiteMaui/FwLiteMaui.csproj @@ -166,9 +166,9 @@ </PropertyGroup> <Error Condition="'$(_Linq2DbEfCoreEffectiveVersion)' == ''" Text="Could not determine resolved version of linq2db.EntityFrameworkCore from backend/Directory.Packages.props. The cctor-patcher version pin can't verify itself. Investigate before proceeding." /> - <!-- Patched range: 10.3.x only. Anything else fails the build. --> - <Error Condition="'$(_Linq2DbEfCoreEffectiveVersion)' != '' And !$([System.Text.RegularExpressions.Regex]::IsMatch('$(_Linq2DbEfCoreEffectiveVersion)', '^10\.3\.[0-9]+(-.*)?$'))" - Text="linq2db.EntityFrameworkCore is at $(_Linq2DbEfCoreEffectiveVersion); the cctor patcher in build/Linq2DbCctorPatcher only verifies the 10.3.x IL shape. Re-verify the SqlTransparentExpression cctor bug still exists in $(_Linq2DbEfCoreEffectiveVersion) (unskip the repro test in LcmCrdt.Tests/SqlTransparentExpressionCctorRepro.cs and run it against the new version), then either: (a) widen this pin if still broken, or (b) DELETE build/Linq2DbCctorPatcher and the _BuildLinq2DbCctorPatcher / _PatchLinq2DbSqlTransparentExpressionCctor / _VerifyLinq2DbEfCoreVersionPin / _CollectLinq2DbStagedAssemblies targets if fixed. Upstream fix: https://github.com/linq2db/linq2db/pull/5546. See backend/FwLite/LcmCrdt/LINQ2DB-V6-NOTES.md (Cctor patcher section)." /> + <!-- Patched range: 10.3.x and 10.4.x. Anything else fails the build. --> + <Error Condition="'$(_Linq2DbEfCoreEffectiveVersion)' != '' And !$([System.Text.RegularExpressions.Regex]::IsMatch('$(_Linq2DbEfCoreEffectiveVersion)', '^10\.[34]\.[0-9]+(-.*)?$'))" + Text="linq2db.EntityFrameworkCore is at $(_Linq2DbEfCoreEffectiveVersion); the cctor patcher in build/Linq2DbCctorPatcher only verifies the 10.3.x / 10.4.x IL shape. Re-verify the SqlTransparentExpression cctor bug still exists in $(_Linq2DbEfCoreEffectiveVersion) (unskip the repro test in LcmCrdt.Tests/SqlTransparentExpressionCctorRepro.cs and run it against the new version), then either: (a) widen this pin if still broken, or (b) DELETE build/Linq2DbCctorPatcher and the _BuildLinq2DbCctorPatcher / _PatchLinq2DbSqlTransparentExpressionCctor / _VerifyLinq2DbEfCoreVersionPin / _CollectLinq2DbStagedAssemblies targets if fixed. Upstream fix: https://github.com/linq2db/linq2db/pull/5546. See backend/FwLite/LcmCrdt/LINQ2DB-V6-NOTES.md (Cctor patcher section)." /> </Target> <Target Name="_BuildLinq2DbCctorPatcher" Condition="'$(TargetPlatform)' == 'android'" diff --git a/backend/FwLite/FwLiteMaui/build/Linq2DbCctorPatcher/Program.cs b/backend/FwLite/FwLiteMaui/build/Linq2DbCctorPatcher/Program.cs index d7aba9a13b..1d09418d35 100644 --- a/backend/FwLite/FwLiteMaui/build/Linq2DbCctorPatcher/Program.cs +++ b/backend/FwLite/FwLiteMaui/build/Linq2DbCctorPatcher/Program.cs @@ -1,6 +1,6 @@ // Stubs the broken static constructor on // LinqToDB.EntityFrameworkCore.EFCoreMetadataReader+SqlTransparentExpression -// inside linq2db.EntityFrameworkCore 10.3.x. +// inside linq2db.EntityFrameworkCore 10.3.x / 10.4.x. // // The shipped .cctor does a GetConstructor lookup for (ExceptExpression, // RelationalTypeMapping), which doesn't exist on the type — the only declared From 38ba2497e0d29590ac318e6bdb2f67f73997e404 Mon Sep 17 00:00:00 2001 From: Tim Haasdyk <tim_haasdyk@sil.org> Date: Fri, 22 May 2026 10:11:35 +0200 Subject: [PATCH 12/21] Trim linq2db v6 noise: restore Dev flavor, slim cctor-patcher comments - Restore the Side-by-side FwLiteFlavor=Dev PropertyGroup that got dropped when files were rewritten earlier in this branch - Collapse the cctor-patcher commentary in FwLiteMaui.csproj and Linq2DbCctorPatcher/Program.cs to terse pointers to LINQ2DB-V6-NOTES.md (which already carries the full Cctor patcher section, including the kill-switch checklist) - Delete SqlTransparentExpressionCctorRepro.cs - both tests were Skip-ed, added no signal that the patcher's IL-shape guards don't already give us, and rotted the kill-switch dance. Update NOTES.md kill-switch step to do the manual RunClassConstructor probe instead - Fix EntrySearchService comment: linq2db v5 actually fell back to UPDATE+INSERT (not DELETE+INSERT) because the SQLite provider opted out of upsert. We pick DELETE+INSERT because it's cleaner on FTS5. Add a permalink to the v5 source - Drop the redundant "Public only because" line from Entry.PublishInRows - attributes already say [MiniLcmInternal] / [EditorBrowsable(Never)] --- backend/FwLite/FwLiteMaui/FwLiteMaui.csproj | 90 +++---------------- .../build/Linq2DbCctorPatcher/Program.cs | 32 +------ .../SqlTransparentExpressionCctorRepro.cs | 58 ------------ .../FullTextSearch/EntrySearchService.cs | 9 +- backend/FwLite/LcmCrdt/LINQ2DB-V6-NOTES.md | 10 +-- backend/FwLite/MiniLcm/Models/Entry.cs | 2 - 6 files changed, 27 insertions(+), 174 deletions(-) delete mode 100644 backend/FwLite/LcmCrdt.Tests/SqlTransparentExpressionCctorRepro.cs diff --git a/backend/FwLite/FwLiteMaui/FwLiteMaui.csproj b/backend/FwLite/FwLiteMaui/FwLiteMaui.csproj index 576164ea7e..d2dea186c6 100644 --- a/backend/FwLite/FwLiteMaui/FwLiteMaui.csproj +++ b/backend/FwLite/FwLiteMaui/FwLiteMaui.csproj @@ -40,6 +40,12 @@ <TargetPlatformMinVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'windows'">10.0.19041.0</TargetPlatformMinVersion> <SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'tizen'">6.5</SupportedOSPlatformVersion> </PropertyGroup> + <!-- Side-by-side "Dev" flavor: set -p:FwLiteFlavor=Dev to install alongside the prod app + (different package name + label so both can coexist on a device). --> + <PropertyGroup Condition="'$(FwLiteFlavor)' == 'Dev'"> + <ApplicationId>org.sil.FwLiteMaui.dev</ApplicationId> + <ApplicationTitle>FieldWorks Lite Dev</ApplicationTitle> + </PropertyGroup> <PropertyGroup Condition="'$(Configuration)' == 'Debug' "> <WindowsPackageType>None</WindowsPackageType> <PublishReadyToRun>false</PublishReadyToRun> @@ -103,106 +109,38 @@ --> <PackageReference Include="Mono.Unix" ExcludeAssets="all" /> </ItemGroup> - <!-- - Android workaround for linq2db.EntityFrameworkCore 10.3.x. - - SqlTransparentExpression's static ctor does a GetConstructor lookup for - (ExceptExpression, RelationalTypeMapping) which doesn't exist on the type - (the only declared ctor takes (ConstantExpression, RelationalTypeMapping?)). - The lookup returns null and the cctor throws InvalidOperationException. - Reproducible on plain net10.0 via RuntimeHelpers.RunClassConstructor — this - is a linq2db bug, not a missing-metadata-from-trimming issue. See - backend/FwLite/LcmCrdt.Tests/SqlTransparentExpressionCctorRepro.cs and - backend/FwLite/LcmCrdt/LINQ2DB-V6-NOTES.md (Cctor patcher section). - - Upstream fix: https://github.com/linq2db/linq2db/pull/5546 (approved, not yet released). - - On desktop the class is beforefieldinit and its static fields are only read - from Quote(), which our CRDT workload never calls, so the bug is silent. - Android surfaces it the first time a CRDT save runs. - - An earlier attempt used ILLink.Substitutions.xml to stub the .cctor, but - Debug Android builds skip the linker entirely (PublishTrimmed=false), so - the substitution never applied. Even in Release publish, the substitution - site differs from where the dll ends up staged for packaging, so a - single-target hook is fragile. - - Instead, we Cecil-patch the assembly at build time, unconditionally, - via the small tool under build/Linq2DbCctorPatcher. - - Where the dll lives depends on configuration: - - Debug (PublishTrimmed=false): staged into $(MonoAndroidIntermediateAssemblyDir)<abi>\ - by _LinkAssembliesNoShrink, then bundled for fast-deploy. - - Release (PublishTrimmed=true) : output by ILLink into <rid>\linked\ and copied to - <rid>\linked\shrunk\ by _RemoveRegisterAttribute. _CollectAssembliesToCompress - then consumes the shrunk copies for the assembly store. - We must patch BOTH locations (and any other staged copies under $(IntermediateOutputPath)). - The target runs BeforeTargets on the consumers (_CollectAssembliesToCompress and - _BuildApkFastDev) so it fires regardless of trimmed/untrimmed and AAB/APK flows. - - KILL-SWITCH: - - The version-pin check below fails the build the moment somebody bumps - the package outside the verified-broken range. When that happens, either - widen the range (after re-verifying the bug still exists in the new - version) or delete this whole block + the build/Linq2DbCctorPatcher - project + unskip SqlTransparentExpressionCctorRepro.cs. - --> - - <!-- Version pin: hard-error if linq2db.EntityFrameworkCore is outside the patched range. - We don't want this patcher silently chugging along on a version we never tested, - nor do we want to carry it past an upstream fix. Range is the closed interval - [10.3.0, 10.3.999]; anything 10.4.x or 11.x is rejected. - - Source of truth is backend/Directory.Packages.props (central package management), - which we read directly: PackageReference items have no Version metadata under CPM, - and ResolvedPackageReference is only populated after restore. Reading the props - file is the most robust signal that works pre- and post-restore. --> + <!-- Android workaround for linq2db.EntityFrameworkCore's broken SqlTransparentExpression + cctor. See backend/FwLite/LcmCrdt/LINQ2DB-V6-NOTES.md (Cctor patcher section) for the + full story, including the kill-switch checklist for when upstream PR #5546 ships. --> <Target Name="_VerifyLinq2DbEfCoreVersionPin" Condition="'$(TargetPlatform)' == 'android'" BeforeTargets="_BuildLinq2DbCctorPatcher"> <PropertyGroup> - <!-- Pull "X.Y.Z" from a line like: <PackageVersion Include="linq2db.EntityFrameworkCore" Version="10.3.0" /> --> <_Linq2DbEfCoreEffectiveVersion>$([System.Text.RegularExpressions.Regex]::Match($([System.IO.File]::ReadAllText('$(MSBuildThisFileDirectory)..\..\Directory.Packages.props')), 'linq2db\.EntityFrameworkCore[^>]*Version="([^"]+)"').Groups[1].Value)</_Linq2DbEfCoreEffectiveVersion> </PropertyGroup> <Error Condition="'$(_Linq2DbEfCoreEffectiveVersion)' == ''" - Text="Could not determine resolved version of linq2db.EntityFrameworkCore from backend/Directory.Packages.props. The cctor-patcher version pin can't verify itself. Investigate before proceeding." /> - <!-- Patched range: 10.3.x and 10.4.x. Anything else fails the build. --> + Text="Could not read linq2db.EntityFrameworkCore version from backend/Directory.Packages.props." /> <Error Condition="'$(_Linq2DbEfCoreEffectiveVersion)' != '' And !$([System.Text.RegularExpressions.Regex]::IsMatch('$(_Linq2DbEfCoreEffectiveVersion)', '^10\.[34]\.[0-9]+(-.*)?$'))" - Text="linq2db.EntityFrameworkCore is at $(_Linq2DbEfCoreEffectiveVersion); the cctor patcher in build/Linq2DbCctorPatcher only verifies the 10.3.x / 10.4.x IL shape. Re-verify the SqlTransparentExpression cctor bug still exists in $(_Linq2DbEfCoreEffectiveVersion) (unskip the repro test in LcmCrdt.Tests/SqlTransparentExpressionCctorRepro.cs and run it against the new version), then either: (a) widen this pin if still broken, or (b) DELETE build/Linq2DbCctorPatcher and the _BuildLinq2DbCctorPatcher / _PatchLinq2DbSqlTransparentExpressionCctor / _VerifyLinq2DbEfCoreVersionPin / _CollectLinq2DbStagedAssemblies targets if fixed. Upstream fix: https://github.com/linq2db/linq2db/pull/5546. See backend/FwLite/LcmCrdt/LINQ2DB-V6-NOTES.md (Cctor patcher section)." /> + Text="linq2db.EntityFrameworkCore is $(_Linq2DbEfCoreEffectiveVersion) but the cctor patcher only covers 10.3.x / 10.4.x. See backend/FwLite/LcmCrdt/LINQ2DB-V6-NOTES.md (Cctor patcher section) for the kill-switch / pin-widening checklist." /> </Target> <Target Name="_BuildLinq2DbCctorPatcher" Condition="'$(TargetPlatform)' == 'android'" DependsOnTargets="_VerifyLinq2DbEfCoreVersionPin" BeforeTargets="_LinkAssembliesNoShrink;_AfterILLinkAdditionalSteps;_RemoveRegisterAttribute"> - <!-- Build the patcher tool as a side-channel net10.0 project. We use <MSBuild> rather - than a <ProjectReference> so the patcher's TargetFramework/RuntimeIdentifier isn't - entangled with FwLiteMaui's android-arm64 graph. RemoveProperties strips inherited - RID/TF so the inner build resolves cleanly as plain net10.0. --> + <!-- <MSBuild> (not <ProjectReference>) so the tool's TF/RID isn't entangled with FwLiteMaui's android-arm64 graph. --> <MSBuild Projects="$(MSBuildThisFileDirectory)build\Linq2DbCctorPatcher\Linq2DbCctorPatcher.csproj" Targets="Restore;Build" Properties="Configuration=$(Configuration)" RemoveProperties="TargetFramework;TargetFrameworks;RuntimeIdentifier;RuntimeIdentifiers;TargetPlatform;TargetPlatformIdentifier;TargetPlatformVersion;UseMaui;SingleProject;SelfContained;PublishReadyToRun;PublishSingleFile" /> </Target> - <!-- Glob staged dlls before the patch target's Inputs/Outputs check evaluates. - Lives in a separate target so the ItemGroup is materialized by the time - _PatchLinq2Db...'s batching examines @(_Linq2DbStagedAssemblies). --> + <!-- Separate target so the ItemGroup is materialized by the time _PatchLinq2Db...'s Inputs/Outputs batching reads it. --> <Target Name="_CollectLinq2DbStagedAssemblies" Condition="'$(TargetPlatform)' == 'android'"> <ItemGroup> - <!-- Glob every staged copy under obj\<Config>\<TF>\: Debug ships them via - android\assets\<abi>\, Release publishes through <rid>\linked\ (and linked\shrunk\). --> <_Linq2DbStagedAssemblies Include="$(IntermediateOutputPath)**\linq2db.EntityFrameworkCore.dll" /> </ItemGroup> </Target> - <!-- - Incremental patching: per-dll sentinel files at <dll>.cctor-patched. - Inputs are the staged dlls; Outputs are %()-batched sentinels so MSBuild - skips already-patched files on subsequent builds. The patcher itself also - short-circuits via the same sentinel — if dotnet restore re-extracts the - package, the dll's mtime moves forward past the sentinel's and patching - re-runs. Belt-and-braces against any case where MSBuild's incremental - check disagrees with the file-system reality. - --> + <!-- Incremental: per-dll <dll>.cctor-patched sentinels gate re-runs. --> <Target Name="_PatchLinq2DbSqlTransparentExpressionCctor" Condition="'$(TargetPlatform)' == 'android'" DependsOnTargets="_BuildLinq2DbCctorPatcher;_CollectLinq2DbStagedAssemblies" diff --git a/backend/FwLite/FwLiteMaui/build/Linq2DbCctorPatcher/Program.cs b/backend/FwLite/FwLiteMaui/build/Linq2DbCctorPatcher/Program.cs index 1d09418d35..838f423428 100644 --- a/backend/FwLite/FwLiteMaui/build/Linq2DbCctorPatcher/Program.cs +++ b/backend/FwLite/FwLiteMaui/build/Linq2DbCctorPatcher/Program.cs @@ -1,32 +1,6 @@ -// Stubs the broken static constructor on -// LinqToDB.EntityFrameworkCore.EFCoreMetadataReader+SqlTransparentExpression -// inside linq2db.EntityFrameworkCore 10.3.x / 10.4.x. -// -// The shipped .cctor does a GetConstructor lookup for (ExceptExpression, -// RelationalTypeMapping), which doesn't exist on the type — the only declared -// ctor takes (ConstantExpression, RelationalTypeMapping?). The lookup returns -// null and the .cctor throws InvalidOperationException. Reproducible on plain -// net10.0 with RuntimeHelpers.RunClassConstructor. -// -// Desktop CRDT never accesses the affected static fields (only Quote() does), -// so it's silent. Android (and any environment that eagerly initializes the -// type) hits TypeInitializationException on the first CRDT save. -// -// We can't fix this via ILLink.Substitutions.xml on Android Debug because -// PublishTrimmed is false and the linker pass is skipped. And in Release -// publish the staged dll location differs from the ILLink target site, so a -// single substitution hook is fragile. So we Cecil-patch unconditionally at -// build time, on every linq2db.EntityFrameworkCore.dll under the obj tree. -// -// Also removes Quote() so any unexpected caller fails loudly with -// NotImplementedException instead of NRE'ing on the (now-null) _ctor field. -// -// Upstream fix: https://github.com/linq2db/linq2db/pull/5546 (approved, not yet released). -// -// KILL-SWITCH: when upstream ships a fixed version (see the version pin -// in FwLiteMaui.csproj — search for _Linq2DbEfCorePatchedVersion), delete this -// project and the two _BuildLinq2DbCctorPatcher / _PatchLinq2Db... targets, -// and unskip backend/FwLite/LcmCrdt.Tests/SqlTransparentExpressionCctorRepro.cs. +// Cecil-patches the broken cctor on LinqToDB.EntityFrameworkCore.EFCoreMetadataReader+SqlTransparentExpression +// (and replaces Quote() with a loud throw). See backend/FwLite/LcmCrdt/LINQ2DB-V6-NOTES.md (Cctor patcher section) +// for the background, kill-switch checklist, and upstream PR link. using Mono.Cecil; using Mono.Cecil.Cil; diff --git a/backend/FwLite/LcmCrdt.Tests/SqlTransparentExpressionCctorRepro.cs b/backend/FwLite/LcmCrdt.Tests/SqlTransparentExpressionCctorRepro.cs deleted file mode 100644 index c5c105d6b3..0000000000 --- a/backend/FwLite/LcmCrdt.Tests/SqlTransparentExpressionCctorRepro.cs +++ /dev/null @@ -1,58 +0,0 @@ -using System; -using System.Runtime.CompilerServices; - -namespace LcmCrdt.Tests; - -// Empirical repro for the linq2db.EntityFrameworkCore 10.3.x bug where -// EFCoreMetadataReader+SqlTransparentExpression's static ctor throws because -// GetConstructor looks up an (ExceptExpression, RelationalTypeMapping) signature -// that doesn't exist on the type. See FwLiteMaui.csproj's cctor-patcher target -// and backend/FwLite/LcmCrdt/LINQ2DB-V6-NOTES.md (Cctor patcher section). -// -// IMPORTANT: These tests intentionally still FAIL on desktop. They probe the -// in-process linq2db.EntityFrameworkCore.dll loaded from NuGet — which is the -// shipping-broken assembly. The cctor patcher only rewrites the *Android-staged* -// copy in $(IntermediateOutputPath); the desktop test process loads the -// un-patched NuGet one. Failing here proves the upstream bug still exists; the -// Android binary check is what proves the patch is wired correctly. So these -// are marked Skip on .NET Core / desktop runs to keep the suite green, while -// the Cecil-disassembly check elsewhere is the load-bearing assertion. -// -// UNSKIP WHEN: the linq2db.EntityFrameworkCore version pin in -// FwLiteMaui.csproj (_VerifyLinq2DbEfCoreVersionPin) is bumped or removed. -// At that point either: -// - the bug is fixed upstream → these tests should pass without any patcher; -// unskip them, delete the patcher, and they become a permanent regression -// guard. -// - the bug still exists in the new version → run unskipped against the new -// version to confirm the same repro shape, then re-skip with an updated -// reason and widen the version pin. -public class SqlTransparentExpressionCctorRepro -{ - private const string SkipReason = - "Probes the unpatched NuGet linq2db.EntityFrameworkCore.dll loaded in the test " + - "process — repro only, not a regression test. The Android build's cctor patcher " + - "operates on the staged dll under obj/<Config>/net10.0-android/, not on the dll " + - "loaded here. Verify the Android patch by Cecil-inspecting that staged dll."; - - [Fact(Skip = SkipReason)] - public void Cctor_runs_without_throwing() - { - var t = Type.GetType( - "LinqToDB.EntityFrameworkCore.EFCoreMetadataReader+SqlTransparentExpression, linq2db.EntityFrameworkCore", - throwOnError: true)!; - var act = () => RuntimeHelpers.RunClassConstructor(t.TypeHandle); - act.Should().NotThrow(); - } - - [Fact(Skip = SkipReason)] - public void Cctor_is_stubbed_via_field_check() - { - var t = Type.GetType( - "LinqToDB.EntityFrameworkCore.EFCoreMetadataReader+SqlTransparentExpression, linq2db.EntityFrameworkCore", - throwOnError: true)!; - var f = t.GetField("_ctor", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static)!; - var act = () => f.GetValue(null); - act.Should().NotThrow(); - } -} diff --git a/backend/FwLite/LcmCrdt/FullTextSearch/EntrySearchService.cs b/backend/FwLite/LcmCrdt/FullTextSearch/EntrySearchService.cs index 84bbe2eeda..d4a6febd1e 100644 --- a/backend/FwLite/LcmCrdt/FullTextSearch/EntrySearchService.cs +++ b/backend/FwLite/LcmCrdt/FullTextSearch/EntrySearchService.cs @@ -224,10 +224,11 @@ public async Task UpdateEntrySearchTable(Entry entry) private static async Task InsertOrUpdateEntrySearchRecord(EntrySearchRecord record, ITable<EntrySearchRecord> table) { - // Can't use table.InsertOrUpdateAsync here because EntrySearchRecord is a virtual table, - // and SQLite doesn't support UPSERT statements on virtual tables. Instead, we have to - // use the same DELETE+INSERT approach that Linq2DB 5 used to use (Linq2DB 6 changed this - // to a proper UPSERT, which is the correct approach most of the time... except here) + // EntrySearchRecord is an FTS5 virtual table; SQLite rejects UPSERT (ON CONFLICT) on + // virtual tables, so linq2db v6's InsertOrUpdateAsync — which emits ON CONFLICT — fails. + // v5 fell back to a two-statement UPDATE+INSERT because the provider opted out of upsert + // (https://github.com/linq2db/linq2db/blob/v5.4.1/Source/LinqToDB/Linq/QueryRunner.InsertOrReplace.cs#L251-L298); + // for FTS5 a clean DELETE+INSERT is simpler than emulating the UPDATE-first behavior. await table.DeleteAsync(e => e.Id == record.Id); await table.InsertAsync(() => new EntrySearchRecord() { diff --git a/backend/FwLite/LcmCrdt/LINQ2DB-V6-NOTES.md b/backend/FwLite/LcmCrdt/LINQ2DB-V6-NOTES.md index d18986365f..13bbe5781a 100644 --- a/backend/FwLite/LcmCrdt/LINQ2DB-V6-NOTES.md +++ b/backend/FwLite/LcmCrdt/LINQ2DB-V6-NOTES.md @@ -300,9 +300,9 @@ Desktop is silent: the class is `beforefieldinit`, only `Quote()` reads the affe **Kill-switch (delete the patcher entirely when):** -1. `_VerifyLinq2DbEfCoreVersionPin` in `FwLiteMaui.csproj` fails because the version moved outside `10.3.x`. -2. Unskip the tests in `LcmCrdt.Tests/SqlTransparentExpressionCctorRepro.cs` and run them against the new version. -3. If they pass → delete `backend/FwLite/FwLiteMaui/build/Linq2DbCctorPatcher/` and the four targets in `FwLiteMaui.csproj` (`_VerifyLinq2DbEfCoreVersionPin`, `_BuildLinq2DbCctorPatcher`, `_CollectLinq2DbStagedAssemblies`, `_PatchLinq2DbSqlTransparentExpressionCctor`). Leave the now-passing repro tests as a permanent regression guard. -4. If they still fail → widen the version pin regex in `_VerifyLinq2DbEfCoreVersionPin`, re-`[Skip]` the tests with an updated reason. +1. `_VerifyLinq2DbEfCoreVersionPin` in `FwLiteMaui.csproj` fails because the version moved outside `10.3.x` / `10.4.x`. +2. Manually verify the bug — `RuntimeHelpers.RunClassConstructor` on `LinqToDB.EntityFrameworkCore.EFCoreMetadataReader+SqlTransparentExpression` from a net10.0 test process loading the new dll. +3. If the cctor no longer throws → delete `backend/FwLite/FwLiteMaui/build/Linq2DbCctorPatcher/` and the four targets in `FwLiteMaui.csproj` (`_VerifyLinq2DbEfCoreVersionPin`, `_BuildLinq2DbCctorPatcher`, `_CollectLinq2DbStagedAssemblies`, `_PatchLinq2DbSqlTransparentExpressionCctor`). +4. If it still throws → widen the version pin regex in `_VerifyLinq2DbEfCoreVersionPin`. -**Upstream fix:** <https://github.com/linq2db/linq2db/pull/5546> (approved, not yet released). The URL is repeated in `_VerifyLinq2DbEfCoreVersionPin` in `FwLiteMaui.csproj` and in the patcher `Program.cs` — keep the three in lockstep. +**Upstream fix:** <https://github.com/linq2db/linq2db/pull/5546> (open, targets linq2db 6.4.0 — not yet released). diff --git a/backend/FwLite/MiniLcm/Models/Entry.cs b/backend/FwLite/MiniLcm/Models/Entry.cs index a2427f27a7..8f65638d89 100644 --- a/backend/FwLite/MiniLcm/Models/Entry.cs +++ b/backend/FwLite/MiniLcm/Models/Entry.cs @@ -36,8 +36,6 @@ public record Entry : IObjectWithId<Entry> //Server-side query rewrite target — LcmCrdt rewrites this to Json.Query(PublishIn) so //filter projections (e.g. PublishInRows.Select(...).Any(...)) translate to json_each() SQL. - //Public only because LcmCrdt's filter map provider lives in a different assembly; treat as - //internal — don't read it from client code, use PublishIn. [MiniLcmInternal, NotMapped, JsonIgnore, EditorBrowsable(EditorBrowsableState.Never)] public IEnumerable<Publication> PublishInRows => PublishIn; From 696b37b6aca012b46daf8483c24e81843f0f4662 Mon Sep 17 00:00:00 2001 From: Tim Haasdyk <tim_haasdyk@sil.org> Date: Fri, 22 May 2026 10:29:02 +0200 Subject: [PATCH 13/21] Add Json.At ergonomic helper, scope cctor-patcher excludes - New Json.At(MultiString, string) wraps the v6 cross-scope path-extract (replaces a one-off Sql.Expr<string?> at the SearchHeadwords call site). - Scope FwLiteMaui's build\** excludes to build\Linq2DbCctorPatcher\** and update the comment so it names the project. --- backend/FwLite/FwLiteMaui/FwLiteMaui.csproj | 13 ++++++------- backend/FwLite/LcmCrdt/Data/EntryQueryHelpers.cs | 4 +--- backend/FwLite/LcmCrdt/Json.cs | 5 +++++ 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/backend/FwLite/FwLiteMaui/FwLiteMaui.csproj b/backend/FwLite/FwLiteMaui/FwLiteMaui.csproj index d2dea186c6..ebd17854e5 100644 --- a/backend/FwLite/FwLiteMaui/FwLiteMaui.csproj +++ b/backend/FwLite/FwLiteMaui/FwLiteMaui.csproj @@ -60,14 +60,13 @@ <PropertyGroup> <TargetPlatform>$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)'))</TargetPlatform> </PropertyGroup> - <!-- Exclude the build-time tool's sources from FwLiteMaui's own compile. It's a - standalone Microsoft.NET.Sdk Exe project, not part of this project's code. --> + <!-- Linq2DbCctorPatcher (under build\) is its own net10.0 Exe project; keep it off this compile. --> <ItemGroup> - <Compile Remove="build\**" /> - <Content Remove="build\**" /> - <None Remove="build\**" /> - <EmbeddedResource Remove="build\**" /> - <MauiAsset Remove="build\**" /> + <Compile Remove="build\Linq2DbCctorPatcher\**" /> + <Content Remove="build\Linq2DbCctorPatcher\**" /> + <None Remove="build\Linq2DbCctorPatcher\**" /> + <EmbeddedResource Remove="build\Linq2DbCctorPatcher\**" /> + <MauiAsset Remove="build\Linq2DbCctorPatcher\**" /> </ItemGroup> <ItemGroup> <!-- App Icon --> diff --git a/backend/FwLite/LcmCrdt/Data/EntryQueryHelpers.cs b/backend/FwLite/LcmCrdt/Data/EntryQueryHelpers.cs index 69b7662ffa..f08eda6c3e 100644 --- a/backend/FwLite/LcmCrdt/Data/EntryQueryHelpers.cs +++ b/backend/FwLite/LcmCrdt/Data/EntryQueryHelpers.cs @@ -47,13 +47,11 @@ public static bool SearchHeadwords(this Entry e, string? leading, string? traili private static Expression<Func<Entry, string?, string?, string, bool>> SearchHeadwords() { - //Use Sql.Expr to spell the cross-scope path access explicitly: linq2db v6 - //emits `[kv].*` instead of `[kv].[key]` if we route this through Json.Value. return (e, leading, trailing, query) => Json.QueryValues(e.CitationForm).Any( v => SqlHelpers.ContainsIgnoreCaseAccents(v, query)) || Json.QueryEntries(e.LexemeForm).Any(kv => - string.IsNullOrEmpty((Sql.Expr<string?>($"{e.CitationForm}->>{kv.Key}") ?? "").Trim()) && + string.IsNullOrEmpty((Json.At(e.CitationForm, kv.Key) ?? "").Trim()) && SqlHelpers.ContainsIgnoreCaseAccents((leading ?? "") + (kv.Value ?? "").Trim() + (trailing ?? ""), query)); } diff --git a/backend/FwLite/LcmCrdt/Json.cs b/backend/FwLite/LcmCrdt/Json.cs index cbb5da1c9e..821bb69199 100644 --- a/backend/FwLite/LcmCrdt/Json.cs +++ b/backend/FwLite/LcmCrdt/Json.cs @@ -224,6 +224,11 @@ public static string ToString(Guid? guid) return guid?.ToString() ?? ""; } + //Json.Value's path walker can't handle a key captured from an outer json_each row; use At for that. + [Sql.Expression("{0}->>{1}", ServerSideOnly = true)] + public static string? At(MultiString container, string key) => + throw new NotImplementedException("server-side only"); + //maps to a row from json_each internal record JsonEach<T>( [property: Column("value")] T Value, From de1fafbb7847a18ff2d9b3ac2b2cca491f894008 Mon Sep 17 00:00:00 2001 From: Tim Haasdyk <tim_haasdyk@sil.org> Date: Fri, 22 May 2026 12:05:51 +0200 Subject: [PATCH 14/21] Pin SDK to 10.0.108 / latestPatch latestMinor on 10.0.100 was rolling forward to 10.0.200, where MAUI workload manifests aren't registered yet (they live in the 10.0.100 feature band; reinstalling them at 200 needs admin and Visual Studio hasn't done so yet). latestPatch keeps the repo on a feature band where workloads are known to be present. --- global.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/global.json b/global.json index 1e7fdfa95f..1747333448 100644 --- a/global.json +++ b/global.json @@ -1,6 +1,6 @@ { "sdk": { - "version": "10.0.100", - "rollForward": "latestMinor" + "version": "10.0.108", + "rollForward": "latestPatch" } } From cbc2d33bcf8a366d2429af5868d22cd308b77932 Mon Sep 17 00:00:00 2001 From: Tim Haasdyk <tim_haasdyk@sil.org> Date: Fri, 22 May 2026 12:26:34 +0200 Subject: [PATCH 15/21] Revert SDK pin to 10.0.100 / latestMinor MAUI workload manifests for 10.0.200 are now registered, so the tight latestPatch pin from de1fafbb isn't needed anymore. --- global.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/global.json b/global.json index 1747333448..1e7fdfa95f 100644 --- a/global.json +++ b/global.json @@ -1,6 +1,6 @@ { "sdk": { - "version": "10.0.108", - "rollForward": "latestPatch" + "version": "10.0.100", + "rollForward": "latestMinor" } } From c5dccbffc3515c16a2374ed8984486853c0e9c32 Mon Sep 17 00:00:00 2001 From: Tim Haasdyk <tim_haasdyk@sil.org> Date: Fri, 22 May 2026 13:19:07 +0200 Subject: [PATCH 16/21] Run cctor patcher before Android AOT compilation In Release builds the cctor patcher was scheduled AFTER _AndroidAotCompilation, so AOT compiled the unpatched IL into native .so files. Android's runtime prefers AOT'd native code over IL, so the patched dll in the final AAB was effectively shadowed and the cctor still threw on first CRDT save. Adding _AndroidAotCompilation to the patcher's BeforeTargets forces it to run between link and AOT. Verified end-to-end on a clean Release publish: patcher sentinel < AOT .so mtime, and the lz4-bundled dll, shrunk dll, and AOT inputs all show the no-op cctor (1 IL instruction: ret). Debug builds were unaffected (AOT typically off in Debug, _BuildApkFastDev covers the fast-deploy path). --- backend/FwLite/FwLiteMaui/FwLiteMaui.csproj | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/backend/FwLite/FwLiteMaui/FwLiteMaui.csproj b/backend/FwLite/FwLiteMaui/FwLiteMaui.csproj index ebd17854e5..5115e33b88 100644 --- a/backend/FwLite/FwLiteMaui/FwLiteMaui.csproj +++ b/backend/FwLite/FwLiteMaui/FwLiteMaui.csproj @@ -139,12 +139,16 @@ <_Linq2DbStagedAssemblies Include="$(IntermediateOutputPath)**\linq2db.EntityFrameworkCore.dll" /> </ItemGroup> </Target> - <!-- Incremental: per-dll <dll>.cctor-patched sentinels gate re-runs. --> + <!-- Incremental: per-dll <dll>.cctor-patched sentinels gate re-runs. + _AndroidAotCompilation must stay in BeforeTargets: in Release the AOT step compiles + IL to native .so files, and Android's runtime prefers the AOT'd native code over IL. + If the patcher runs after AOT, the patched dll in the AAB is shadowed by AOT'd + unpatched native code and the bug still ships. --> <Target Name="_PatchLinq2DbSqlTransparentExpressionCctor" Condition="'$(TargetPlatform)' == 'android'" DependsOnTargets="_BuildLinq2DbCctorPatcher;_CollectLinq2DbStagedAssemblies" AfterTargets="_LinkAssembliesNoShrink;_AfterILLinkAdditionalSteps;_RemoveRegisterAttribute" - BeforeTargets="_CollectAssembliesToCompress;_BuildApkFastDev" + BeforeTargets="_CollectAssembliesToCompress;_BuildApkFastDev;_AndroidAotCompilation" Inputs="@(_Linq2DbStagedAssemblies)" Outputs="@(_Linq2DbStagedAssemblies->'%(FullPath).cctor-patched')"> <PropertyGroup> From 7b3ffe9a833775bd474ae0c32476c388d44ba33f Mon Sep 17 00:00:00 2001 From: Tim Haasdyk <tim_haasdyk@sil.org> Date: Fri, 22 May 2026 14:23:58 +0200 Subject: [PATCH 17/21] Fix cctor patcher parallel-build race in Release multi-RID MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Android Release publishes 4 RIDs (arm/arm64/x86/x64) via parallel inner MSBuild invocations dispatched by _ResolveAssemblies. The patcher's _BuildLinq2DbCctorPatcher used to be hooked to inner-build link targets, which meant 4 simultaneous Restore;Build calls into the same patcher project — racing on obj/bin and erroring with MSB3030 (missing apphost.exe / patcher dll) or CS2012 (file locked). - Hook the patcher pre-build to BeforeTargets="_ResolveAssemblies" so it runs once in the outer Android build, before fan-out. - Switch the inner <MSBuild> call to <Exec dotnet build ...> so the patcher gets a clean isolated build process. - Inputs/Outputs guard makes the target a no-op when the dll is current and skips the inner-build hooks once the outer has produced it. - UseAppHost=false on the patcher csproj — we invoke it as `dotnet patcher.dll`, never via the native exe wrapper, so apphost generation is wasted work that was the first thing to race. Validated by clean Release build + adb install on a physical device: AOT .so mtime is after the patch sentinel, and exercising a CRDT save does not throw. --- backend/FwLite/FwLiteMaui/FwLiteMaui.csproj | 18 +++++++++++------- .../Linq2DbCctorPatcher.csproj | 3 +++ 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/backend/FwLite/FwLiteMaui/FwLiteMaui.csproj b/backend/FwLite/FwLiteMaui/FwLiteMaui.csproj index 5115e33b88..3176b03ad3 100644 --- a/backend/FwLite/FwLiteMaui/FwLiteMaui.csproj +++ b/backend/FwLite/FwLiteMaui/FwLiteMaui.csproj @@ -122,15 +122,19 @@ <Error Condition="'$(_Linq2DbEfCoreEffectiveVersion)' != '' And !$([System.Text.RegularExpressions.Regex]::IsMatch('$(_Linq2DbEfCoreEffectiveVersion)', '^10\.[34]\.[0-9]+(-.*)?$'))" Text="linq2db.EntityFrameworkCore is $(_Linq2DbEfCoreEffectiveVersion) but the cctor patcher only covers 10.3.x / 10.4.x. See backend/FwLite/LcmCrdt/LINQ2DB-V6-NOTES.md (Cctor patcher section) for the kill-switch / pin-widening checklist." /> </Target> + <!-- Build the patcher tool BEFORE the Android SDK dispatches per-RID inner builds. + _ResolveAssemblies is the outer-build target that fans out into parallel per-RID + MSBuild invocations; if we built the patcher inside any of those inner builds, + 4 parallel `dotnet build` processes would race on the same obj/bin. By running + once at the outer level, every inner build sees the patcher dll already in place. + The Inputs/Outputs guard makes the target a no-op on incremental rebuilds. --> <Target Name="_BuildLinq2DbCctorPatcher" - Condition="'$(TargetPlatform)' == 'android'" + Condition="'$(TargetPlatformIdentifier)' == 'android'" DependsOnTargets="_VerifyLinq2DbEfCoreVersionPin" - BeforeTargets="_LinkAssembliesNoShrink;_AfterILLinkAdditionalSteps;_RemoveRegisterAttribute"> - <!-- <MSBuild> (not <ProjectReference>) so the tool's TF/RID isn't entangled with FwLiteMaui's android-arm64 graph. --> - <MSBuild Projects="$(MSBuildThisFileDirectory)build\Linq2DbCctorPatcher\Linq2DbCctorPatcher.csproj" - Targets="Restore;Build" - Properties="Configuration=$(Configuration)" - RemoveProperties="TargetFramework;TargetFrameworks;RuntimeIdentifier;RuntimeIdentifiers;TargetPlatform;TargetPlatformIdentifier;TargetPlatformVersion;UseMaui;SingleProject;SelfContained;PublishReadyToRun;PublishSingleFile" /> + BeforeTargets="_ResolveAssemblies;_LinkAssembliesNoShrink;_AfterILLinkAdditionalSteps;_RemoveRegisterAttribute" + Inputs="build\Linq2DbCctorPatcher\Program.cs;build\Linq2DbCctorPatcher\Linq2DbCctorPatcher.csproj" + Outputs="build\Linq2DbCctorPatcher\bin\$(Configuration)\net10.0\Linq2DbCctorPatcher.dll"> + <Exec Command="dotnet build "$(MSBuildThisFileDirectory)build\Linq2DbCctorPatcher\Linq2DbCctorPatcher.csproj" --configuration $(Configuration) --nologo" /> </Target> <!-- Separate target so the ItemGroup is materialized by the time _PatchLinq2Db...'s Inputs/Outputs batching reads it. --> <Target Name="_CollectLinq2DbStagedAssemblies" diff --git a/backend/FwLite/FwLiteMaui/build/Linq2DbCctorPatcher/Linq2DbCctorPatcher.csproj b/backend/FwLite/FwLiteMaui/build/Linq2DbCctorPatcher/Linq2DbCctorPatcher.csproj index 97b7bb2f3d..7c1483ed72 100644 --- a/backend/FwLite/FwLiteMaui/build/Linq2DbCctorPatcher/Linq2DbCctorPatcher.csproj +++ b/backend/FwLite/FwLiteMaui/build/Linq2DbCctorPatcher/Linq2DbCctorPatcher.csproj @@ -6,6 +6,9 @@ <ImplicitUsings>enable</ImplicitUsings> <!-- This tool is invoked from MSBuild targets in FwLiteMaui.csproj; nothing else references it. --> <IsPackable>false</IsPackable> + <!-- No native apphost: invoked via `dotnet patcher.dll`. Also avoids a parallel-build + race on apphost.exe when multiple RID inner builds trigger the patcher concurrently. --> + <UseAppHost>false</UseAppHost> <!-- Don't let this opt into shared central package management; we just pin Mono.Cecil locally. --> <ManagePackageVersionsCentrally>false</ManagePackageVersionsCentrally> </PropertyGroup> From 0c126f10524106b06355b540ff829fdf9c698eb3 Mon Sep 17 00:00:00 2001 From: Tim Haasdyk <tim_haasdyk@sil.org> Date: Fri, 22 May 2026 14:27:52 +0200 Subject: [PATCH 18/21] Add task android-release-dev: Release "Dev" APK + adb install `task android-release-dev` builds a signed Release APK with FwLiteFlavor=Dev and pushes it to the connected USB device via `adb -d install -r`. Avoids -t:Run because the SDK's Run target invokes bundletool, which errors out when both a USB device and an emulator are visible to adb ("More than one device connected, please provide --device-id"). Building the universal APK and adb-installing it directly sidesteps that. --- Taskfile.yml | 5 +++++ backend/FwLite/Taskfile.yml | 15 +++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/Taskfile.yml b/Taskfile.yml index a51fcbf4f3..7fb944ce14 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -147,3 +147,8 @@ tasks: aliases: [android-install-emulator-dev] desc: Install the "Dev" flavor on a running x86_64 emulator without building deps: [fw-lite:install-maui-android-emulator-dev] + + fw-lite-android-release-dev: + aliases: [android-release-dev] + desc: Build a Release "Dev" APK and adb-install it on the connected USB device + deps: [fw-lite:install-maui-android-release-dev] diff --git a/backend/FwLite/Taskfile.yml b/backend/FwLite/Taskfile.yml index 3879bd15f1..e697c598f0 100644 --- a/backend/FwLite/Taskfile.yml +++ b/backend/FwLite/Taskfile.yml @@ -101,6 +101,21 @@ tasks: - task: install-maui-android vars: { FLAVOR: 'Dev', EXTRA_ARGS: '-p:RuntimeIdentifiers=android-x64 -p:RuntimeIdentifier=android-x64 -p:AdbTarget=-e' } + publish-maui-android-release: + deps: [ ui:build-viewer ] + dir: ./FwLiteMaui + # Produces a signed AAB/APK at bin/Release/net10.0-android/<appid>-Signed.{aab,apk}. + # No -t:Run — the SDK's Run uses bundletool, which errors out with multiple devices/emulators. + cmd: dotnet build -f net10.0-android -c Release -t:InstallAndroidDependencies -p:AcceptAndroidSdkLicenses=True -p:FwLiteFlavor={{.FLAVOR}} {{.CLI_ARGS}} + + install-maui-android-release-dev: + desc: Build a Release "Dev" APK and adb-install it on the connected USB device (uses `adb -d`) + dir: ./FwLiteMaui + cmds: + - task: publish-maui-android-release + vars: { FLAVOR: 'Dev' } + - adb -d install -r bin/Release/net10.0-android/org.sil.FwLiteMaui.dev-Signed.apk + build-mini-lcm-sdk: desc: Builds the sdk, a zip with the FwLiteWeb server with a project and config to run locally dir: ./FwLiteWeb From 64a16661c8718bcfe5ddce2567c3b2523f4d2617 Mon Sep 17 00:00:00 2001 From: Tim Haasdyk <tim_haasdyk@sil.org> Date: Fri, 22 May 2026 15:20:25 +0200 Subject: [PATCH 19/21] Move linq2db v6 design notes from markdown to issue #2291 LINQ2DB-V6-NOTES.md was a multi-page design note documenting why we have the shadow-property workaround and the cctor patcher, the attempt history, and the kill-switch checklist for both. Moved verbatim to issue #2291 with collapsible sections so the long-form content lives where it's actionable (a follow-up tracker) rather than as a committed markdown file. In-code references in FwLiteMaui.csproj and the patcher Program.cs updated to point at the issue. --- backend/FwLite/FwLiteMaui/FwLiteMaui.csproj | 4 +- .../build/Linq2DbCctorPatcher/Program.cs | 4 +- backend/FwLite/LcmCrdt/LINQ2DB-V6-NOTES.md | 308 ------------------ 3 files changed, 4 insertions(+), 312 deletions(-) delete mode 100644 backend/FwLite/LcmCrdt/LINQ2DB-V6-NOTES.md diff --git a/backend/FwLite/FwLiteMaui/FwLiteMaui.csproj b/backend/FwLite/FwLiteMaui/FwLiteMaui.csproj index 3176b03ad3..923a3053a8 100644 --- a/backend/FwLite/FwLiteMaui/FwLiteMaui.csproj +++ b/backend/FwLite/FwLiteMaui/FwLiteMaui.csproj @@ -109,7 +109,7 @@ <PackageReference Include="Mono.Unix" ExcludeAssets="all" /> </ItemGroup> <!-- Android workaround for linq2db.EntityFrameworkCore's broken SqlTransparentExpression - cctor. See backend/FwLite/LcmCrdt/LINQ2DB-V6-NOTES.md (Cctor patcher section) for the + cctor. See https://github.com/sillsdev/languageforge-lexbox/issues/2291 for the full story, including the kill-switch checklist for when upstream PR #5546 ships. --> <Target Name="_VerifyLinq2DbEfCoreVersionPin" Condition="'$(TargetPlatform)' == 'android'" @@ -120,7 +120,7 @@ <Error Condition="'$(_Linq2DbEfCoreEffectiveVersion)' == ''" Text="Could not read linq2db.EntityFrameworkCore version from backend/Directory.Packages.props." /> <Error Condition="'$(_Linq2DbEfCoreEffectiveVersion)' != '' And !$([System.Text.RegularExpressions.Regex]::IsMatch('$(_Linq2DbEfCoreEffectiveVersion)', '^10\.[34]\.[0-9]+(-.*)?$'))" - Text="linq2db.EntityFrameworkCore is $(_Linq2DbEfCoreEffectiveVersion) but the cctor patcher only covers 10.3.x / 10.4.x. See backend/FwLite/LcmCrdt/LINQ2DB-V6-NOTES.md (Cctor patcher section) for the kill-switch / pin-widening checklist." /> + Text="linq2db.EntityFrameworkCore is $(_Linq2DbEfCoreEffectiveVersion) but the cctor patcher only covers 10.3.x / 10.4.x. See https://github.com/sillsdev/languageforge-lexbox/issues/2291 for the kill-switch / pin-widening checklist." /> </Target> <!-- Build the patcher tool BEFORE the Android SDK dispatches per-RID inner builds. _ResolveAssemblies is the outer-build target that fans out into parallel per-RID diff --git a/backend/FwLite/FwLiteMaui/build/Linq2DbCctorPatcher/Program.cs b/backend/FwLite/FwLiteMaui/build/Linq2DbCctorPatcher/Program.cs index 838f423428..03e1b416e4 100644 --- a/backend/FwLite/FwLiteMaui/build/Linq2DbCctorPatcher/Program.cs +++ b/backend/FwLite/FwLiteMaui/build/Linq2DbCctorPatcher/Program.cs @@ -1,5 +1,5 @@ // Cecil-patches the broken cctor on LinqToDB.EntityFrameworkCore.EFCoreMetadataReader+SqlTransparentExpression -// (and replaces Quote() with a loud throw). See backend/FwLite/LcmCrdt/LINQ2DB-V6-NOTES.md (Cctor patcher section) +// (and replaces Quote() with a loud throw). See https://github.com/sillsdev/languageforge-lexbox/issues/2291 // for the background, kill-switch checklist, and upstream PR link. using Mono.Cecil; using Mono.Cecil.Cil; @@ -35,7 +35,7 @@ static int Fail(string message) Console.Error.WriteLine("Linq2DbCctorPatcher: " + message); Console.Error.WriteLine( "linq2db.EntityFrameworkCore structure changed; patcher needs review. " + - "See backend/FwLite/LcmCrdt/LINQ2DB-V6-NOTES.md (Cctor patcher section)."); + "See https://github.com/sillsdev/languageforge-lexbox/issues/2291."); return 3; } diff --git a/backend/FwLite/LcmCrdt/LINQ2DB-V6-NOTES.md b/backend/FwLite/LcmCrdt/LINQ2DB-V6-NOTES.md deleted file mode 100644 index 13bbe5781a..0000000000 --- a/backend/FwLite/LcmCrdt/LINQ2DB-V6-NOTES.md +++ /dev/null @@ -1,308 +0,0 @@ -# Linq2Db v6 — shadow-property workaround in LcmCrdt - -Captured during the .NET 10 + Linq2Db 5.4 → 6.2.1 upgrade -(branch `wip/linq2db-v6-attempts`, PR -[#2264](https://github.com/sillsdev/languageforge-lexbox/pull/2264)). - -This document covers: - -1. The current working solution (**TL;DR** + **Files** sections). -2. Why v6 broke things (**Root cause**). -3. Everything we tried (**Attempt history**) — kept so the next person walking - into this doesn't repeat the dead ends. -4. What's still worth contributing upstream as community-benefit fixes - (**Upstream plan**) — lexbox no longer blocks on these. - ---- - -## TL;DR - -Two `IList<T>` jsonb columns — `Sense.SemanticDomains` and `Entry.PublishIn` — -need a `json_each(...)` rewrite for queries (`Any`, `SelectMany`, etc.) but -*not* at entity materialization. v5 honored that split via -`ExpressionMethodAttribute(IsColumn = false)`. v6 ignores `IsColumn` and fires -the substitution at materialization too, which either casts wrong -(`EnumerableQuery<T>` → `IList<T>`) or invokes a `Sql.TableFunction` body -client-side. - -The fix here: - -- Add **non-column shadow properties** `Sense.SemanticDomainRows` and - `Entry.PublishInRows` in `MiniLcm/Models/`. They return the underlying list - in client context (so reflection-based deep equality and bulk-copy paths - don't trip) and have no column mapping. -- In `LcmCrdtKernel`, attach the `[ExpressionMethod]` rewrite to those shadow - properties via `FluentMappingBuilder.IsExpression(..., isColumn: false)`. - `IsExpression` also calls `IsNotColumn()`, so BulkCopy and insert paths - don't read them. -- Route Gridify filter projections through the shadow properties - (`EntryFilterMapProvider.EntryPublishInId`, - `EntrySensesSemanticDomainsCode`). -- The materialization expression doesn't reference the shadow properties, so - v6's `ExposeExpressionVisitor` never sees them — the substitution fires - only when LINQ translation hits `e.PublishInRows` in a query. - -Result: all of `LcmCrdt.Tests` passes, including the perf assertion and the -nine filter shapes that the earlier `.ToList()` workaround left red. - ---- - -## Root cause — what changed in v6 - -v6 rewrote the query parser. The -[migration wiki](https://github.com/linq2db/linq2db/wiki/Linq-To-DB-6) -makes one statement that explains both regressions: - -> *"For final query projection, linq2db doesn't try to translate it to SQL -> anymore and sets its value on the client during materialization."* - -Paired with a new "single-query preamble" strategy for eager loading -([`ExpressionBuilder.EagerLoad.cs`](https://github.com/linq2db/linq2db/blob/master/Source/LinqToDB/Internal/Linq/Builder/ExpressionBuilder.EagerLoad.cs)), -this means an `ExpressionMethodAttribute` registered on a property now fires -in two places where v5 only fired it in one: - -1. **Query translation** (where we *want* it) — `s.SemanticDomains` becomes - `Json.Query(Sql.Property<...>(s, "SemanticDomains"))`, which - `[Sql.TableFunction("json_each", argIndices: [0])]` then turns into a - `json_each(jsonb_column)` table subquery. -2. **Materialization** (where v5 didn't apply it) — after the row is read and - EF's value converter has deserialized the jsonb column to - `IList<SemanticDomain>`, v6 *also* runs the substitution lambda - client-side over the deserialized list and assigns the result back into - the property. This is what causes the regressions. - -`IsColumn` on `ExpressionMethodAttribute` is documented to control exactly -this behavior (`IsColumn=false` should mean "not used during -materialization"). Verified locally that v6 ignores it: `TableBuilder.TableContext.MakeExpression` -now passes `fullEntity` through `Builder.ConvertExpressionTree`, which routes -through `ExposeExpressionVisitor` and expands every `[ExpressionMethod]` -regardless of `IsColumn`. - -### The two original regressions - -**Regression 1 — `LoadWith` materialization.** With the rewrite on the -property and returning `IQueryable<T>` (natural shape — `json_each` is a -table), v6's materializer evaluates the substitution client-side and throws -`InvalidCastException : Unable to cast EnumerableQuery<T> to IList<T>` inside -`LinqToDB.Internal.Linq.QueryRunner.Mapper.ReMapOnException`. Originally -broke ~280 of 461 tests. - -**Regression 2 — translator can't see through `.ToList()`.** Forcing the -substitution to return `IList<T>` via trailing `.ToList()` fixes -materialization but defeats SQL translation: filter chains like -`e.Senses.Any(s => s.SemanticDomains.Any(sd => sd.Code.Contains("Fruit")))` -expand to -`...json_each(s.SemanticDomains).Select(v => v.Value).ToList().Any(sd => ...)`, -and v6 gives up with `LinqToDBException : The LINQ expression could not be -converted to SQL`. - ---- - -## Attempt history - -| # | Approach | Eager load (`LoadWith`) | Gridify content filter | Gridify "missing"/null filter | -|---|---|---|---|---| -| A | Drop `[ExpressionMethod]`, write `Json.Query(...)` explicitly in `EntryFilterMapProvider` | ✓ works | ✗ Gridify can't parse `Json.Query(...)` — `InvalidOperationException` at `ParseMethodCallExpression` | ✗ same | -| B | Keep `[ExpressionMethod]`, expression returns `IQueryable<T>` (no `.ToList()`) | ✗ `InvalidCastException` on every load (~280 fails) | ✓ would translate | n/a (load already broken) | -| C | Keep `[ExpressionMethod]`, expression returns `IList<T>` via `.ToList()` | ✓ | ✗ translator can't see through `.ToList()` | ✗ same | -| D | C + `IsColumn = false` explicitly | same as C | same as C | same as C — v6 ignores it | -| E1 | Shadow **extension method** `e.PublishInAsRows()` with `[ExpressionMethod]` | ✓ | ✗ Gridify's `ParseMethodCallExpression` only handles `MemberExpression` / `Select` / `SelectMany` / `Where` as chain root; a method-call root throws | ✓ (combined with converter change — see below) | -| **F** | **Shadow *property*** `e.PublishInRows` with `IsExpression(..., isColumn:false)` *(current)* | ✓ | ✓ | ✓ | - -### Why the shadow approach works as a property but not a method - -Gridify's `LinqQueryBuilder.ParseMethodCallExpression` switches on the first -argument of the `Select(...)` projection. Only four shapes match: -`MemberExpression`, or a nested `Select` / `SelectMany` / `Where` -`MethodCallExpression`. A user-defined extension call like -`e.PublishInAsRows()` matches none and throws `InvalidOperationException`. A -property access (`e.PublishInRows`) is a `MemberExpression`, so it does -match. - -### Why v6's materializer doesn't trip on the shadow - -The shadow property is unmapped (`[NotMapped, JsonIgnore]`, plus -`IsNotColumn()` via `IsExpression`). v6's materialization expression is -shaped like `new Entry { Col1 = ..., Col2 = ... }` over the mapped columns -only. `ExposeExpressionVisitor` only expands `[ExpressionMethod]` when it -actually walks past that member in some expression tree. The shadow never -appears in the materialization tree, so its substitution never fires there. - -### Other untried alternatives (still escalation paths if F ever breaks) - -In increasing invasiveness: - -- Change `Sense.SemanticDomains` / `Entry.PublishIn` from `IList<T>` to - `IQueryable<T>` at the model level. Removes the type mismatch but is a wide - breaking change in `MiniLcm`. -- Drop the json column entirely and model these as real one-to-many EF - associations. No more `[Sql.TableFunction]` rewriting at all. Schema + - migration + sync-format break. -- Pin `linq2db` to 5.4.x. Avoids the regression at the cost of every fix in - the v6 line; conflicts with the rest of the .NET 10 upgrade. - ---- - -## Filter-converter changes - -Without the property-level rewrite, `e.PublishIn == null` no longer maps to -"NOT EXISTS json_each(...)" — it lowers to plain column `IS NULL`, which -matches nothing because empty lists are stored as `"[]"`, never SQL NULL. -The two affected converters -(`EntryPublishInConverter`, `EntrySensesSemanticDomainsConverter`) therefore -use `NormalizeEmptyToEmptyList` instead of `NormalizeEmptyToNull`, generating -`column = '[]'` — the same pattern `EntryComplexFormTypesConverter` already -used. - ---- - -## Two related v6 fixes that landed in `Json.cs` - -| Concern | Where | -|---|---| -| Peel `Sql.Alias(...)` wrap from `IExtensionCallBuilder` lambda arg bodies | `JsonValuePathBuilder.BuildParameterPath` | -| `PseudoFunctions.MakeTryConvert` was dropped in v6 — build the `SqlFunction` directly | `JsonValuePathBuilder.Build` | - -The Alias-peel exists because `ExposeExpressionVisitor` wraps every -`[ExpressionMethod]` substitution in `Sql.Alias(real_expr, "<member>")` as a -column-alias hint, and that wrap leaks into user-written -`IExtensionCallBuilder` arg lambdas. `Json.Value(p, p => p.Id.ToString())` is -what trips it in our code; the same shape would affect any other linq2db -user with `[ExpressionMethod]` + custom extension builders. - ---- - -## Upstream plan (community-benefit, not lexbox-blocking) - -With the shadow-property approach in place, lexbox no longer depends on any -linq2db upstream fix. The two items below are still worth filing as -community contributions — other users will hit them — but they're off our -critical path. - -### PR A — Honor `IsColumn=false` at entity materialization - -The clean win. Restores the documented `[ExpressionMethod].IsColumn` -contract. - -- **Doc contract:** `IsColumn`'s XML doc reads: *"When applied to property - and set to true, Linq To DB will load data into property using expression - during entity materialization."* The default (`false`) should opt out of - materialization-time invocation. -- **Where it broke:** `TableBuilder.TableContext.MakeExpression` now passes - `fullEntity` through `Builder.ConvertExpressionTree`, which routes through - `ExposeExpressionVisitor` and expands every `[ExpressionMethod]` - regardless of `IsColumn`. -- **Proposed fix:** thread a `calculatedColumnsOnly` flag through one - materialization call site. `ExposeExpressionVisitor.ConvertExpressionMethodAttribute` - returns `null` when `_calculatedColumnsOnly && !attr.IsColumn`. Caller - falls back to bare member access. -- **Sample repro:** a `Foo` with `IList<Bar> Bars` carrying a `JsonEach`-style - substitution and `IsColumn = false` — `db.GetTable<Foo>().ToList()` throws - in v6 but works in v5. -- **Risk profile:** low. Anyone relying on v6's regression behavior would - have been broken on v5 too; they should set `IsColumn=true`. - -### PR C — Peel `Sql.Alias` from `IExtensionCallBuilder` lambda args - -Narrow, surface-area-only fix. No public contract change. - -- **Symptom:** `ExposeExpressionVisitor` wraps every `[ExpressionMethod]` - substitution in `Sql.Alias(real_expr, "<member-name>")`. The wrap is - invisible to the SQL builder but observable inside user-written - `Sql.IExtensionCallBuilder` lambda-arg walkers as `Alias(real_expr, "ToString")`. -- **Proposed fix:** in `Sql.ExtensionAttribute.GetExtensionParam`, peel - `Sql.Alias(...)` from lambda-argument bodies before invoking the user - builder. Top-level (non-lambda) argument expressions are left alone. -- **Lexbox-side workaround already in `Json.cs`** — see the table above. -- **Risk profile:** low. The wrap was never part of the documented - extension-builder contract. - -### PR B — retired - -Previously envisioned as "suppress collection-typed `[ExpressionMethod]` -substitution inside `Equal`/`NotEqual` against null" so that -`e.PublishIn == null` would lower to column `IS NULL`. The shadow-property -approach makes this moot — the bare null check now lands on the column -naturally, no engine change needed. Don't open this PR. - -### Sequencing - -A → C, parallelizable. Cadence on `linq2db/linq2db` (sampled 2026-05-18): -~10 PRs/week merged, **6.3.0 shipped 2026-05-17**, **6.4.0 version bump -merged 2026-05-18**. Small bug-fixes with a linked issue land same-day to -3-day; medium fixes 1–2 weeks. Issue-first is convention; PR titles read -`Fix #NNNN: <title>`. Maintainers worth tagging: `MaceWindu` (release -management + cross-provider review), `igor-tkachev` (original author), -`sdanyliv` (most active feature contributor). - -If we file A + C, realistic landing window is 1–2 weeks each, with a shot -at the 6.4.x line. - ---- - -## Files - -- `MiniLcm/Models/Entry.cs`, `MiniLcm/Models/Sense.cs` — shadow properties. -- `LcmCrdt/LcmCrdtKernel.cs` — `IsExpression` registration and the rewrite - factories (`SenseSemanticDomainRowsExpression`, - `EntryPublishInRowsExpression`). -- `LcmCrdt/Json.cs` — `Sql.Alias` peel; `TRY_CONVERT` direct construction. -- `LcmCrdt/EntryFilterMapProvider.cs` — projections routed through shadow - properties; converters use `NormalizeEmptyToEmptyList`. -- `LcmCrdt.Tests/MiniLcmTests/QueryEntryTests.cs:79` — perf-test assertion - margin (history of past adjustments in comments above the line). - ---- - -## Trade-offs - -- The shadow accessor is a soft lie in client context — anyone calling - `entry.PublishInRows` from non-query code gets the underlying list, not a - `json_each` table. Surface area is tiny (two properties, marked - `[NotMapped, JsonIgnore]`) and the comment on each points back here. -- The shape leaks a query-engine concern into `MiniLcm`'s shared domain - model. Acceptable cost given the alternative (waiting on PR A or shipping - the `.ToList()` workaround with 9 red tests). -- If PR A lands and we upgrade to a fixed linq2db release, the shadow - properties can be collapsed back into bare `[ExpressionMethod]` on the - underlying columns. Not load-bearing for lexbox. - ---- - -## Links - -- Linq To DB 6 migration guide: - <https://github.com/linq2db/linq2db/wiki/Linq-To-DB-6> -- Eager-load refactor in flight (targets 6.4.0) — may fix or change this: - <https://github.com/linq2db/linq2db/pull/5450> -- `ExpressionBuilder.EagerLoad.cs` (the file the stack trace points into): - <https://github.com/linq2db/linq2db/blob/master/Source/LinqToDB/Internal/Linq/Builder/ExpressionBuilder.EagerLoad.cs> -- Related (similar v6 `[ExpressionMethod]` regressions, all fixed — none - match our pair of symptoms but useful as precedent): - - <https://github.com/linq2db/linq2db/issues/4613> - - <https://github.com/linq2db/linq2db/issues/4977> - - <https://github.com/linq2db/linq2db/issues/5040> - - <https://github.com/linq2db/linq2db/issues/5254> -- Local upstream-side scratch (PR drafts, repro shapes for A and C): - `D:\code\linq2db\UPSTREAM-PLAN.md` (outside this repo). - ---- - -## Cctor patcher (Android-only) - -Separate from the shadow-property workaround above. `EFCoreMetadataReader+SqlTransparentExpression`'s `.cctor` in `linq2db.EntityFrameworkCore` 10.3.x looks up a `GetConstructor((ExceptExpression, RelationalTypeMapping))` signature that doesn't exist on the type (the only declared ctor takes `(ConstantExpression, RelationalTypeMapping?)`) — `GetConstructor` returns null, `_ctor = ...` throws `InvalidOperationException`, the cctor fails, and any later access to the type explodes with `TypeInitializationException`. - -Desktop is silent: the class is `beforefieldinit`, only `Quote()` reads the affected static fields, and our CRDT workload never calls `Quote()`. Mono/Android AOT eagerly initializes the type on the first CRDT save and detonates there. - -**Where the patcher lives:** `backend/FwLite/FwLiteMaui/build/Linq2DbCctorPatcher/` — a `net10.0` Mono.Cecil console tool. MSBuild targets in `FwLiteMaui.csproj` build it and run it against every staged copy of `linq2db.EntityFrameworkCore.dll` under `$(IntermediateOutputPath)`. The patcher rewrites the cctor body to `ret` and replaces `Quote()` with `throw new NotImplementedException()` so any surprise caller fails loudly instead of NRE'ing on the now-null `_ctor` field. Patched dlls get a sibling sentinel file (`<dll>.cctor-patched`) and the MSBuild target is `Inputs`/`Outputs`-incremental. - -**Why it's only here, not in `backend/build/`:** only `FwLiteMaui` targets `net10.0-android`. If `FwHeadless` or `FwLiteWeb` ever start targeting Android and reference `linq2db.EntityFrameworkCore`, lift the patcher into a shared tools dir and reference it from each consumer. - -**Kill-switch (delete the patcher entirely when):** - -1. `_VerifyLinq2DbEfCoreVersionPin` in `FwLiteMaui.csproj` fails because the version moved outside `10.3.x` / `10.4.x`. -2. Manually verify the bug — `RuntimeHelpers.RunClassConstructor` on `LinqToDB.EntityFrameworkCore.EFCoreMetadataReader+SqlTransparentExpression` from a net10.0 test process loading the new dll. -3. If the cctor no longer throws → delete `backend/FwLite/FwLiteMaui/build/Linq2DbCctorPatcher/` and the four targets in `FwLiteMaui.csproj` (`_VerifyLinq2DbEfCoreVersionPin`, `_BuildLinq2DbCctorPatcher`, `_CollectLinq2DbStagedAssemblies`, `_PatchLinq2DbSqlTransparentExpressionCctor`). -4. If it still throws → widen the version pin regex in `_VerifyLinq2DbEfCoreVersionPin`. - -**Upstream fix:** <https://github.com/linq2db/linq2db/pull/5546> (open, targets linq2db 6.4.0 — not yet released). From afc4df61b9359709ee19d6371020b1c02be4106f Mon Sep 17 00:00:00 2001 From: Tim Haasdyk <tim_haasdyk@sil.org> Date: Fri, 22 May 2026 16:01:21 +0200 Subject: [PATCH 20/21] Drop LinqToDB.Internal.Common dependency for IsNullOrEmpty() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three call sites used linq2db's internal IsNullOrEmpty() extension on RichMultiString. Replaced with `is null or { Count: 0 }` / `is { Count: > 0 }` patterns at the call sites — same semantics, no internal-namespace dependency. The remaining LinqToDB.Internal.SqlQuery import in Json.cs is harder to eliminate (custom SqlFunction/PseudoFunctions construction has no public-API equivalent); see #2291 for the long-term plan. Verified by running TranslationDeserializationTests + TranslationChangeDeserializationTests (10 tests, all pass) which exercise all three call sites: DbTranslationDeserializationTarget's JSON converter, CreateExampleSentenceChange.NewEntity's legacy-Translation fallback, and the Json.ExampleSentenceTranslationModifier setter via the harmony snapshot round-trip test. --- backend/FwLite/LcmCrdt/Changes/CreateExampleSentenceChange.cs | 3 +-- backend/FwLite/LcmCrdt/Json.cs | 3 +-- .../LcmCrdt/Objects/DbTranslationDeserializationTarget.cs | 3 +-- 3 files changed, 3 insertions(+), 6 deletions(-) diff --git a/backend/FwLite/LcmCrdt/Changes/CreateExampleSentenceChange.cs b/backend/FwLite/LcmCrdt/Changes/CreateExampleSentenceChange.cs index 9804ec65be..e82b7ef49f 100644 --- a/backend/FwLite/LcmCrdt/Changes/CreateExampleSentenceChange.cs +++ b/backend/FwLite/LcmCrdt/Changes/CreateExampleSentenceChange.cs @@ -1,6 +1,5 @@ using System.ComponentModel; using System.Text.Json.Serialization; -using LinqToDB.Internal.Common; using SIL.Harmony; using SIL.Harmony.Changes; using SIL.Harmony.Core; @@ -39,7 +38,7 @@ private CreateExampleSentenceChange(Guid entityId, Guid senseId) : base(entityId public override async ValueTask<ExampleSentence> NewEntity(Commit commit, IChangeContext context) { #pragma warning disable CS0618 // Type or member is obsolete - var translations = Translations ?? (!Translation.IsNullOrEmpty() + var translations = Translations ?? (Translation is { Count: > 0 } ? [MiniLcm.Models.Translation.FromMultiString(Translation)] : []); #pragma warning restore CS0618 // Type or member is obsolete diff --git a/backend/FwLite/LcmCrdt/Json.cs b/backend/FwLite/LcmCrdt/Json.cs index 821bb69199..9f1d0ad4ef 100644 --- a/backend/FwLite/LcmCrdt/Json.cs +++ b/backend/FwLite/LcmCrdt/Json.cs @@ -3,7 +3,6 @@ using System.Text.Json.Serialization.Metadata; using LcmCrdt.Changes; using LinqToDB; -using LinqToDB.Internal.Common; using LinqToDB.Internal.SqlQuery; using LinqToDB.Mapping; using LinqToDB.SqlQuery; @@ -256,7 +255,7 @@ internal static void ExampleSentenceTranslationModifier(JsonTypeInfo typeInfo) var exampleSentence = (ExampleSentence)obj; if (exampleSentence.Translations.Any()) throw new InvalidOperationException("Cannot set translations when they already exist."); var richString = (RichMultiString?)value; - if (richString.IsNullOrEmpty()) return; + if (richString is null or { Count: 0 }) return; #pragma warning disable CS0618 // Type or member is obsolete exampleSentence.Translations = [Translation.FromMultiString(richString)]; #pragma warning restore CS0618 // Type or member is obsolete diff --git a/backend/FwLite/LcmCrdt/Objects/DbTranslationDeserializationTarget.cs b/backend/FwLite/LcmCrdt/Objects/DbTranslationDeserializationTarget.cs index de5a947a27..7651c3b0e9 100644 --- a/backend/FwLite/LcmCrdt/Objects/DbTranslationDeserializationTarget.cs +++ b/backend/FwLite/LcmCrdt/Objects/DbTranslationDeserializationTarget.cs @@ -1,6 +1,5 @@ using System.Text.Json; using System.Text.Json.Serialization; -using LinqToDB.Internal.Common; namespace LcmCrdt.Objects; @@ -21,7 +20,7 @@ private class DbTranslationDeserializationTargetConverter: JsonConverter<DbTrans if (reader.TokenType == JsonTokenType.StartObject) { var translation = JsonSerializer.Deserialize<RichMultiString>(ref reader, options); - if (translation.IsNullOrEmpty()) return null; + if (translation is null or { Count: 0 }) return null; return new DbTranslationDeserializationTarget(translation); } return null; From e15f368d7216479048abbf8c8dfb2781b5e67fc2 Mon Sep 17 00:00:00 2001 From: Tim Haasdyk <tim_haasdyk@sil.org> Date: Tue, 26 May 2026 15:15:07 +0200 Subject: [PATCH 21/21] Tiny Refactor --- backend/FwLite/LcmCrdt/EntryFilterMapProvider.cs | 1 + backend/FwLite/LcmCrdt/Json.cs | 5 +++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/backend/FwLite/LcmCrdt/EntryFilterMapProvider.cs b/backend/FwLite/LcmCrdt/EntryFilterMapProvider.cs index a5b3f5b62d..1caeb2bf0d 100644 --- a/backend/FwLite/LcmCrdt/EntryFilterMapProvider.cs +++ b/backend/FwLite/LcmCrdt/EntryFilterMapProvider.cs @@ -30,6 +30,7 @@ public class EntryFilterMapProvider : EntryFilterMapProvider<Entry> public override Func<string, object>? EntryComplexFormTypesConverter => EntryFilter.NormalizeEmptyToEmptyList<ComplexFormType>; public override Expression<Func<Entry, object?>> EntryPublishIn => e => e.PublishIn; public override Expression<Func<Entry, object?>> EntryPublishInId => + //PublishInRows is the json_each rewrite target e => e.PublishInRows.Select(p => Json.Value(p, p => p.Id.ToString())); public override Func<string, object>? EntryPublishInConverter => EntryFilter.NormalizeEmptyToEmptyList<Publication>; } diff --git a/backend/FwLite/LcmCrdt/Json.cs b/backend/FwLite/LcmCrdt/Json.cs index 9f1d0ad4ef..0ba71452ee 100644 --- a/backend/FwLite/LcmCrdt/Json.cs +++ b/backend/FwLite/LcmCrdt/Json.cs @@ -1,7 +1,6 @@ using System.Linq.Expressions; using System.Reflection; using System.Text.Json.Serialization.Metadata; -using LcmCrdt.Changes; using LinqToDB; using LinqToDB.Internal.SqlQuery; using LinqToDB.Mapping; @@ -225,8 +224,10 @@ public static string ToString(Guid? guid) //Json.Value's path walker can't handle a key captured from an outer json_each row; use At for that. [Sql.Expression("{0}->>{1}", ServerSideOnly = true)] - public static string? At(MultiString container, string key) => + public static string? At(MultiString value, string key) + { throw new NotImplementedException("server-side only"); + } //maps to a row from json_each internal record JsonEach<T>(