From b0b3a4f119518517365f9515196bff645c22e3af Mon Sep 17 00:00:00 2001 From: Robin Munn Date: Tue, 12 May 2026 11:25:59 +0700 Subject: [PATCH 01/36] Bump pretty much ALL packages for .NET 10 This does not compile, due to several breaking changes (mostly various functions being moved to different namespaces). We may back out some of these version bumps later to minimize said breaking changes. --- backend/Directory.Packages.props | 196 +++++++++++++++---------------- 1 file changed, 98 insertions(+), 98 deletions(-) diff --git a/backend/Directory.Packages.props b/backend/Directory.Packages.props index bf493b9a70..9c3fe79e71 100644 --- a/backend/Directory.Packages.props +++ b/backend/Directory.Packages.props @@ -3,139 +3,139 @@ true true $(NoWarn);NU1507 - 9.0.50 - 15.1.10 + 10.0.60 + 15.1.16 - - - - - - - - - - + + + + + + + + + + - + - - - - - - - + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + - - + + - + - - - - - + + + + + - - + + - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + - + - + - - - + + + - - + + - - - + + + - - - - + + + + - - - + + + - - - - + + + + - + From 846f23b73ae5c0271a252f57ab02bed59bd0cb49 Mon Sep 17 00:00:00 2001 From: Robin Munn Date: Tue, 12 May 2026 12:47:33 +0700 Subject: [PATCH 02/36] Include version bump that dotnet missed `dotnet package upgrade` upgraded most SQLitePCLRaw packages to version 3.0.3, but missed one component that would have caused inconsistencies. --- backend/Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/Directory.Packages.props b/backend/Directory.Packages.props index 9c3fe79e71..1f3b424ecf 100644 --- a/backend/Directory.Packages.props +++ b/backend/Directory.Packages.props @@ -110,7 +110,7 @@ - + From 4f23cd2c24ae01f5aefd8e76ceb80cec7ed537d3 Mon Sep 17 00:00:00 2001 From: Robin Munn Date: Wed, 13 May 2026 14:12:19 +0700 Subject: [PATCH 03/36] Go back to CommandLine pkg before breaking changes Too many breaking changes in System.CommandLine version 3, and not worth solving at this time. Going back to version 2 for now. --- backend/Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/Directory.Packages.props b/backend/Directory.Packages.props index 1f3b424ecf..24e15aa0f3 100644 --- a/backend/Directory.Packages.props +++ b/backend/Directory.Packages.props @@ -115,7 +115,7 @@ - + From 81edc09dd46f300aa7edcdcf1177927ddf463593 Mon Sep 17 00:00:00 2001 From: Robin Munn Date: Wed, 13 May 2026 14:13:17 +0700 Subject: [PATCH 04/36] Deal with OpenApi breaking changes --- backend/FwLite/FwLiteWeb/Routes/ActivityRoutes.cs | 2 +- .../FwLite/FwLiteWeb/Routes/FwIntegrationRoutes.cs | 2 +- backend/FwLite/FwLiteWeb/Routes/HistoryRoutes.cs | 2 +- backend/FwLite/FwLiteWeb/Routes/ImportRoutes.cs | 2 +- backend/FwLite/FwLiteWeb/Routes/MiniLcmRoutes.cs | 12 ++++++------ backend/FwLite/FwLiteWeb/Routes/TestRoutes.cs | 2 +- backend/LexBoxApi/Auth/AuthKernel.cs | 13 ++++--------- 7 files changed, 15 insertions(+), 20 deletions(-) diff --git a/backend/FwLite/FwLiteWeb/Routes/ActivityRoutes.cs b/backend/FwLite/FwLiteWeb/Routes/ActivityRoutes.cs index f94563f33e..dbb5db5021 100644 --- a/backend/FwLite/FwLiteWeb/Routes/ActivityRoutes.cs +++ b/backend/FwLite/FwLiteWeb/Routes/ActivityRoutes.cs @@ -1,6 +1,6 @@ using LcmCrdt; using FwLiteWeb.Hubs; -using Microsoft.OpenApi.Models; +using Microsoft.OpenApi; namespace FwLiteWeb.Routes; diff --git a/backend/FwLite/FwLiteWeb/Routes/FwIntegrationRoutes.cs b/backend/FwLite/FwLiteWeb/Routes/FwIntegrationRoutes.cs index d1cf29049a..a0ba34e91d 100644 --- a/backend/FwLite/FwLiteWeb/Routes/FwIntegrationRoutes.cs +++ b/backend/FwLite/FwLiteWeb/Routes/FwIntegrationRoutes.cs @@ -3,7 +3,7 @@ using FwLiteWeb.Hubs; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.SignalR; -using Microsoft.OpenApi.Models; +using Microsoft.OpenApi; namespace FwLiteWeb.Routes; diff --git a/backend/FwLite/FwLiteWeb/Routes/HistoryRoutes.cs b/backend/FwLite/FwLiteWeb/Routes/HistoryRoutes.cs index a1ac74af5d..2656c40a63 100644 --- a/backend/FwLite/FwLiteWeb/Routes/HistoryRoutes.cs +++ b/backend/FwLite/FwLiteWeb/Routes/HistoryRoutes.cs @@ -7,7 +7,7 @@ using LinqToDB; using LinqToDB.EntityFrameworkCore; using FwLiteWeb.Hubs; -using Microsoft.OpenApi.Models; +using Microsoft.OpenApi; using MiniLcm.Models; namespace FwLiteWeb.Routes; diff --git a/backend/FwLite/FwLiteWeb/Routes/ImportRoutes.cs b/backend/FwLite/FwLiteWeb/Routes/ImportRoutes.cs index 10ad36a0ea..eb1939c69a 100644 --- a/backend/FwLite/FwLiteWeb/Routes/ImportRoutes.cs +++ b/backend/FwLite/FwLiteWeb/Routes/ImportRoutes.cs @@ -1,7 +1,7 @@ using FwLiteShared.Projects; using SIL.Harmony.Db; using FwLiteWeb.Services; - using Microsoft.OpenApi.Models; + using Microsoft.OpenApi; using MiniLcm; namespace FwLiteWeb.Routes; diff --git a/backend/FwLite/FwLiteWeb/Routes/MiniLcmRoutes.cs b/backend/FwLite/FwLiteWeb/Routes/MiniLcmRoutes.cs index 8e84c19d4f..e62ea32eaa 100644 --- a/backend/FwLite/FwLiteWeb/Routes/MiniLcmRoutes.cs +++ b/backend/FwLite/FwLiteWeb/Routes/MiniLcmRoutes.cs @@ -1,13 +1,13 @@ using System.ComponentModel; using System.Diagnostics.CodeAnalysis; using Microsoft.AspNetCore.Mvc; -using Microsoft.OpenApi.Any; -using Microsoft.OpenApi.Models; +using Microsoft.OpenApi; using MiniLcm; using MiniLcm.Filtering; using MiniLcm.Models; using MiniLcm.Project; using MiniLcm.Validators; +using System.Text.Json.Nodes; namespace FwLiteWeb.Routes; @@ -33,7 +33,7 @@ public static IEndpointConventionBuilder MapMiniLcmRoutes(this IEndpointRouteBui var api = app.MapGroup(prefix + "/{projectType}/{projectCode}") .WithOpenApi(operation => { - operation.Parameters.Add(new() + operation.Parameters?.Add(new() { Name = "projectType", In = ParameterLocation.Path, @@ -42,13 +42,13 @@ public static IEndpointConventionBuilder MapMiniLcmRoutes(this IEndpointRouteBui { Enum = [ - new OpenApiString(ProjectDataFormat.FwData.ToString()), - new OpenApiString(ProjectDataFormat.Harmony.ToString()) + JsonValue.Create(ProjectDataFormat.FwData.ToString()), + JsonValue.Create(ProjectDataFormat.Harmony.ToString()) ], Type = "string" }, }); - operation.Parameters.Add(new() + operation.Parameters?.Add(new() { Name = "projectCode", In = ParameterLocation.Path, diff --git a/backend/FwLite/FwLiteWeb/Routes/TestRoutes.cs b/backend/FwLite/FwLiteWeb/Routes/TestRoutes.cs index cf5abe3268..c01943d5a2 100644 --- a/backend/FwLite/FwLiteWeb/Routes/TestRoutes.cs +++ b/backend/FwLite/FwLiteWeb/Routes/TestRoutes.cs @@ -3,7 +3,7 @@ using LcmCrdt; using FwLiteWeb.Hubs; using FwLiteWeb.Services; -using Microsoft.OpenApi.Models; +using Microsoft.OpenApi; using MiniLcm; using MiniLcm.Models; diff --git a/backend/LexBoxApi/Auth/AuthKernel.cs b/backend/LexBoxApi/Auth/AuthKernel.cs index d7955a907d..d26ca94c46 100644 --- a/backend/LexBoxApi/Auth/AuthKernel.cs +++ b/backend/LexBoxApi/Auth/AuthKernel.cs @@ -16,7 +16,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.Extensions.Options; using Microsoft.IdentityModel.Logging; -using Microsoft.OpenApi.Models; +using Microsoft.OpenApi; using OpenIddict.Core; using OpenIddict.Server; using OpenIddict.Server.AspNetCore; @@ -242,16 +242,11 @@ public static void AddLexBoxAuth(IServiceCollection services, Type = SecuritySchemeType.ApiKey, Scheme = "Bearer" }); - options.AddSecurityRequirement(new OpenApiSecurityRequirement + options.AddSecurityRequirement(_ => new OpenApiSecurityRequirement { { - new OpenApiSecurityScheme - { - Name = "Bearer", - In = ParameterLocation.Header, - Reference = new OpenApiReference { Id = "Bearer", Type = ReferenceType.SecurityScheme } - }, - new List() + new OpenApiSecuritySchemeReference("Bearer"), + [] } }); }); From 4e248ce087c1af3f9db31313aa9f47dbe664ce2f Mon Sep 17 00:00:00 2001 From: Robin Munn Date: Wed, 13 May 2026 14:16:36 +0700 Subject: [PATCH 05/36] Deal with Linq2Db and EF Core breaking changes --- .../Changes/CreateExampleSentenceChange.cs | 2 +- backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs | 1 + .../FwLite/LcmCrdt/Data/MiniLcmRepository.cs | 1 + .../FullTextSearch/EntrySearchService.cs | 1 + backend/FwLite/LcmCrdt/HistoryService.cs | 1 + backend/FwLite/LcmCrdt/Json.cs | 19 ++++++++++++++----- backend/FwLite/LcmCrdt/LcmCrdtKernel.cs | 4 ++-- .../DbTranslationDeserializationTarget.cs | 2 +- backend/LexData/DbError.cs | 4 ++-- 9 files changed, 24 insertions(+), 11 deletions(-) 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/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/FullTextSearch/EntrySearchService.cs b/backend/FwLite/LcmCrdt/FullTextSearch/EntrySearchService.cs index 7a33e50f2c..c3e6945118 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; diff --git a/backend/FwLite/LcmCrdt/HistoryService.cs b/backend/FwLite/LcmCrdt/HistoryService.cs index e730abf7eb..3144aa11d1 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; diff --git a/backend/FwLite/LcmCrdt/Json.cs b/backend/FwLite/LcmCrdt/Json.cs index 71dfe76294..6bac5dfb18 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,15 @@ 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)), + // MakeTryConvert was removed in Linq2Db 6, don't know why. + // valueExpression = PseudoFunctions.MakeTryConvert(new SqlDataType(new DbDataType(returnType)), + // new SqlDataType(new DbDataType(typeof(string), DataType.Text)), + // valueExpression); + 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 +70,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) { diff --git a/backend/FwLite/LcmCrdt/LcmCrdtKernel.cs b/backend/FwLite/LcmCrdt/LcmCrdtKernel.cs index 9d98c1ebbf..6530af68b2 100644 --- a/backend/FwLite/LcmCrdt/LcmCrdtKernel.cs +++ b/backend/FwLite/LcmCrdt/LcmCrdtKernel.cs @@ -139,7 +139,7 @@ public static void ConfigureDbOptions(IServiceProvider provider, DbContextOption 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(); @@ -263,7 +263,7 @@ public static void ConfigureCrdt(CrdtConfig config) list => JsonSerializer.Serialize(list, (JsonSerializerOptions?)null), json => JsonSerializer.Deserialize(json, (JsonSerializerOptions?)null) ?? Array.Empty()); - var writingSystemArrayConverter = new ValueConverter( + var writingSystemArrayConverter = new Microsoft.EntityFrameworkCore.Storage.ValueConversion.ValueConverter( list => JsonSerializer.Serialize(list, (JsonSerializerOptions?)null), json => json == null ? null : JsonSerializer.Deserialize(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/LexData/DbError.cs b/backend/LexData/DbError.cs index a211410d39..825ebbef56 100644 --- a/backend/LexData/DbError.cs +++ b/backend/LexData/DbError.cs @@ -22,8 +22,8 @@ public static DbError CreateErrorFrom(PostgresException exception) { return exception switch { - { SqlState: PostgresErrorCodes.UniqueViolation, ConstraintName: "IX_Projects_Code" } => new DbError($"{exception.TableName.Humanize().Singularize(false)} already exists", DbErrorCode.DuplicateProjectCode), - { SqlState: PostgresErrorCodes.UniqueViolation } => new DbError($"{exception.TableName.Humanize().Singularize(false)} already exists", DbErrorCode.Duplicate), + { SqlState: PostgresErrorCodes.UniqueViolation, ConstraintName: "IX_Projects_Code" } => new DbError($"{exception.TableName?.Humanize().Singularize(false)} already exists", DbErrorCode.DuplicateProjectCode), + { SqlState: PostgresErrorCodes.UniqueViolation } => new DbError($"{exception.TableName?.Humanize().Singularize(false)} already exists", DbErrorCode.Duplicate), _ => new DbError(exception.Message) }; } From 43d07772b24d22080759953ff576a23fd0c93729 Mon Sep 17 00:00:00 2001 From: Robin Munn Date: Wed, 13 May 2026 14:17:04 +0700 Subject: [PATCH 06/36] Deal with FluentAssertion breaking changes --- backend/FwLite/MiniLcm.Tests/FluentAssertGlobalConfig.cs | 4 +++- .../Validators/WritingSystemUpdateValidatorTests.cs | 2 +- backend/LexBoxApi/Auth/Attributes/LexboxAuthAttribute.cs | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/backend/FwLite/MiniLcm.Tests/FluentAssertGlobalConfig.cs b/backend/FwLite/MiniLcm.Tests/FluentAssertGlobalConfig.cs index 3cfb18764d..2680797f50 100644 --- a/backend/FwLite/MiniLcm.Tests/FluentAssertGlobalConfig.cs +++ b/backend/FwLite/MiniLcm.Tests/FluentAssertGlobalConfig.cs @@ -1,3 +1,5 @@ +using FluentAssertions; +using FluentAssertions.Equivalency; using FluentAssertions.Extensibility; using MiniLcm.Tests; @@ -9,7 +11,7 @@ public static class FluentAssertGlobalConfig { public static void Initialize() { - AssertionOptions.AssertEquivalencyUsing(options => options + AssertionConfiguration.Current.Equivalency.Modify(options => options //by default, assertion will use the overriden equality function //however that will result in very poor error messages, so we override it .ComparingByMembers() diff --git a/backend/FwLite/MiniLcm.Tests/Validators/WritingSystemUpdateValidatorTests.cs b/backend/FwLite/MiniLcm.Tests/Validators/WritingSystemUpdateValidatorTests.cs index 854769f09b..94bb714c8e 100644 --- a/backend/FwLite/MiniLcm.Tests/Validators/WritingSystemUpdateValidatorTests.cs +++ b/backend/FwLite/MiniLcm.Tests/Validators/WritingSystemUpdateValidatorTests.cs @@ -40,6 +40,6 @@ public void Fails_WhenTryingToUpdateType() public void Fails_WhenThereAreNoChanges() { var update = NewUpdate(); - _validator.TestValidate(update).ShouldHaveAnyValidationError(); + _validator.TestValidate(update).ShouldHaveValidationErrors(); } } diff --git a/backend/LexBoxApi/Auth/Attributes/LexboxAuthAttribute.cs b/backend/LexBoxApi/Auth/Attributes/LexboxAuthAttribute.cs index edf8c7ef19..5fe6c89e19 100644 --- a/backend/LexBoxApi/Auth/Attributes/LexboxAuthAttribute.cs +++ b/backend/LexBoxApi/Auth/Attributes/LexboxAuthAttribute.cs @@ -28,7 +28,7 @@ public string Policy protected override void TryConfigure(IDescriptorContext context, IDescriptor descriptor, - ICustomAttributeProvider element) + ICustomAttributeProvider? element) { ApplyAttribute(context, descriptor, element, new AuthorizeAttribute(Policy)); } From 7a3b7719235ed38e518d0bfc1997405dd47f3e3f Mon Sep 17 00:00:00 2001 From: Robin Munn Date: Wed, 13 May 2026 15:00:50 +0700 Subject: [PATCH 07/36] SetDbStatementForText = true is now default SetDbStatementForText option has been removed, so we need to remove the line setting it. It's the default behavior now, so that's the only change needed. --- backend/FwHeadless/Program.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/backend/FwHeadless/Program.cs b/backend/FwHeadless/Program.cs index c17c1878ff..79e1cb3ae1 100644 --- a/backend/FwHeadless/Program.cs +++ b/backend/FwHeadless/Program.cs @@ -45,7 +45,6 @@ { //never emit traces for sqlite as there's way too much noise and it'll crash servers and overrun honeycomb c.Filter = (provider, command) => provider is not "Microsoft.EntityFrameworkCore.Sqlite"; - c.SetDbStatementForText = true; }) .AddSource(FwHeadlessActivitySource.ActivitySourceName, FwLiteProjectSyncActivitySource.ActivitySourceName, From c00ea7d1ec77c0bedceddd0435a21cffd6a7ed19 Mon Sep 17 00:00:00 2001 From: Robin Munn Date: Wed, 13 May 2026 15:15:22 +0700 Subject: [PATCH 08/36] Use version 10 of DataAnnotatedModelValidations Version 11 of DataAnnotatedModelValidations uses HotChocolate 16, which has some breaking changes we're trying to avoid. We need to stick to version 10 as long as we're on HotChocolate 15. --- backend/Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/Directory.Packages.props b/backend/Directory.Packages.props index 24e15aa0f3..6a402b3dad 100644 --- a/backend/Directory.Packages.props +++ b/backend/Directory.Packages.props @@ -11,7 +11,7 @@ - + From 09e1f917fbe73b81d11376ac9264cabcdaa04af0 Mon Sep 17 00:00:00 2001 From: Robin Munn Date: Wed, 13 May 2026 15:28:09 +0700 Subject: [PATCH 09/36] Fix more OpenApi breaking changes --- backend/FwLite/FwLiteWeb/Routes/ActivityRoutes.cs | 2 +- backend/FwLite/FwLiteWeb/Routes/FwIntegrationRoutes.cs | 2 +- backend/FwLite/FwLiteWeb/Routes/HistoryRoutes.cs | 2 +- backend/FwLite/FwLiteWeb/Routes/MiniLcmRoutes.cs | 6 +++--- backend/FwLite/FwLiteWeb/Routes/TestRoutes.cs | 2 +- backend/LexBoxApi/Program.cs | 2 +- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/backend/FwLite/FwLiteWeb/Routes/ActivityRoutes.cs b/backend/FwLite/FwLiteWeb/Routes/ActivityRoutes.cs index dbb5db5021..3956acb1ea 100644 --- a/backend/FwLite/FwLiteWeb/Routes/ActivityRoutes.cs +++ b/backend/FwLite/FwLiteWeb/Routes/ActivityRoutes.cs @@ -10,7 +10,7 @@ public static IEndpointConventionBuilder MapActivities(this WebApplication app) { var group = app.MapGroup("/api/activity/{project}").WithOpenApi(operation => { - operation.Parameters.Add(new OpenApiParameter() + operation.Parameters?.Add(new OpenApiParameter() { Name = CrdtMiniLcmApiHub.ProjectRouteKey, In = ParameterLocation.Path, diff --git a/backend/FwLite/FwLiteWeb/Routes/FwIntegrationRoutes.cs b/backend/FwLite/FwLiteWeb/Routes/FwIntegrationRoutes.cs index a0ba34e91d..6b3147c8cf 100644 --- a/backend/FwLite/FwLiteWeb/Routes/FwIntegrationRoutes.cs +++ b/backend/FwLite/FwLiteWeb/Routes/FwIntegrationRoutes.cs @@ -14,7 +14,7 @@ public static IEndpointConventionBuilder MapFwIntegrationRoutes(this WebApplicat var group = app.MapGroup($"/api/fw/{{{FwDataMiniLcmHub.ProjectRouteKey}}}").WithOpenApi( operation => { - operation.Parameters.Add(new OpenApiParameter() + operation.Parameters?.Add(new OpenApiParameter() { Name = FwDataMiniLcmHub.ProjectRouteKey, In = ParameterLocation.Path, Required = true }); diff --git a/backend/FwLite/FwLiteWeb/Routes/HistoryRoutes.cs b/backend/FwLite/FwLiteWeb/Routes/HistoryRoutes.cs index 2656c40a63..5762ebf4a4 100644 --- a/backend/FwLite/FwLiteWeb/Routes/HistoryRoutes.cs +++ b/backend/FwLite/FwLiteWeb/Routes/HistoryRoutes.cs @@ -18,7 +18,7 @@ public static IEndpointConventionBuilder MapHistoryRoutes(this WebApplication ap { var group = app.MapGroup("/api/history/{project}").WithOpenApi(operation => { - operation.Parameters.Add(new OpenApiParameter() + operation.Parameters?.Add(new OpenApiParameter() { Name = CrdtMiniLcmApiHub.ProjectRouteKey, In = ParameterLocation.Path, Required = true }); diff --git a/backend/FwLite/FwLiteWeb/Routes/MiniLcmRoutes.cs b/backend/FwLite/FwLiteWeb/Routes/MiniLcmRoutes.cs index e62ea32eaa..9f6c161656 100644 --- a/backend/FwLite/FwLiteWeb/Routes/MiniLcmRoutes.cs +++ b/backend/FwLite/FwLiteWeb/Routes/MiniLcmRoutes.cs @@ -33,7 +33,7 @@ public static IEndpointConventionBuilder MapMiniLcmRoutes(this IEndpointRouteBui var api = app.MapGroup(prefix + "/{projectType}/{projectCode}") .WithOpenApi(operation => { - operation.Parameters?.Add(new() + operation.Parameters?.Add(new OpenApiParameter() { Name = "projectType", In = ParameterLocation.Path, @@ -45,10 +45,10 @@ public static IEndpointConventionBuilder MapMiniLcmRoutes(this IEndpointRouteBui JsonValue.Create(ProjectDataFormat.FwData.ToString()), JsonValue.Create(ProjectDataFormat.Harmony.ToString()) ], - Type = "string" + Type = JsonSchemaType.String }, }); - operation.Parameters?.Add(new() + operation.Parameters?.Add(new OpenApiParameter() { Name = "projectCode", In = ParameterLocation.Path, diff --git a/backend/FwLite/FwLiteWeb/Routes/TestRoutes.cs b/backend/FwLite/FwLiteWeb/Routes/TestRoutes.cs index c01943d5a2..adba101135 100644 --- a/backend/FwLite/FwLiteWeb/Routes/TestRoutes.cs +++ b/backend/FwLite/FwLiteWeb/Routes/TestRoutes.cs @@ -15,7 +15,7 @@ public static IEndpointConventionBuilder MapTest(this WebApplication app) { var group = app.MapGroup("/api/test/{project}").WithOpenApi(operation => { - operation.Parameters.Add(new OpenApiParameter() + operation.Parameters?.Add(new OpenApiParameter() { Name = CrdtMiniLcmApiHub.ProjectRouteKey, In = ParameterLocation.Path, diff --git a/backend/LexBoxApi/Program.cs b/backend/LexBoxApi/Program.cs index 9898ef873d..23e9202e91 100644 --- a/backend/LexBoxApi/Program.cs +++ b/backend/LexBoxApi/Program.cs @@ -21,7 +21,7 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.ModelBinding.Metadata; using Microsoft.Extensions.Options; -using Microsoft.OpenApi.Models; +using Microsoft.OpenApi; using tusdotnet; using IPNetwork = Microsoft.AspNetCore.HttpOverrides.IPNetwork; From 2068937e412b2ac2b5124d6470d53c908835d774 Mon Sep 17 00:00:00 2001 From: Robin Munn Date: Wed, 13 May 2026 15:31:34 +0700 Subject: [PATCH 10/36] Fix another possible-null-reference error --- backend/LexBoxApi/Auth/Attributes/LexboxAuthAttribute.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/LexBoxApi/Auth/Attributes/LexboxAuthAttribute.cs b/backend/LexBoxApi/Auth/Attributes/LexboxAuthAttribute.cs index 5fe6c89e19..f5cf679b32 100644 --- a/backend/LexBoxApi/Auth/Attributes/LexboxAuthAttribute.cs +++ b/backend/LexBoxApi/Auth/Attributes/LexboxAuthAttribute.cs @@ -30,6 +30,6 @@ protected override void TryConfigure(IDescriptorContext context, IDescriptor descriptor, ICustomAttributeProvider? element) { - ApplyAttribute(context, descriptor, element, new AuthorizeAttribute(Policy)); + if (element is not null) ApplyAttribute(context, descriptor, element, new AuthorizeAttribute(Policy)); } } From ad92d312291c4b48ca62bc72c4c659761ae82015 Mon Sep 17 00:00:00 2001 From: Robin Munn Date: Wed, 13 May 2026 15:34:52 +0700 Subject: [PATCH 11/36] Fix another Linq2DB breaking change --- backend/LexBoxApi/Services/CrdtCommitService.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/LexBoxApi/Services/CrdtCommitService.cs b/backend/LexBoxApi/Services/CrdtCommitService.cs index eed0dcc33e..2beedaf995 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; From 2651878397ea4732d1f37cf99826b05a7a53ec69 Mon Sep 17 00:00:00 2001 From: Robin Munn Date: Wed, 13 May 2026 15:35:12 +0700 Subject: [PATCH 12/36] Fix another possible null reference --- backend/LexBoxApi/Services/EmailService.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/LexBoxApi/Services/EmailService.cs b/backend/LexBoxApi/Services/EmailService.cs index ae603c65c7..faf392668a 100644 --- a/backend/LexBoxApi/Services/EmailService.cs +++ b/backend/LexBoxApi/Services/EmailService.cs @@ -51,7 +51,7 @@ public async Task SendNewAdminEmail(IAsyncEnumerable admins, LexAuthUser l var email = new MimeMessage(); await foreach (var admin in admins) { - email.Bcc.Add(new MailboxAddress(admin.Name, admin.Email)); + if (admin.Email is not null) email.Bcc.Add(new MailboxAddress(admin.Name, admin.Email)); } var emailTemplate = new NewAdminEmail( "Admin", From 774ce9a352df0d6f00cd4b7465f8f71e48bc744b Mon Sep 17 00:00:00 2001 From: Robin Munn Date: Wed, 13 May 2026 16:01:02 +0700 Subject: [PATCH 13/36] Fix MongoDB breaking change New versions of MongoDB driver can't handle chaining .Select and .Where on a IMongoQueryable, and it ends up as a System.Linq.IQueryable after the method chain. Which means MongoExtensions.ToAsyncEnumerable can no longer handle it. Replaced the LINQ query with a manually-constructed Mongo query that does the same thing. (Except that since Mongo doesn't provide .ToHashSetAsync, we have to convert it to a list first to get the data back, then convert the list to a HashSet locally to get O(1) lookup). --- .../CustomTypes/IsLanguageForgeProjectDataLoader.cs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/backend/LexBoxApi/GraphQL/CustomTypes/IsLanguageForgeProjectDataLoader.cs b/backend/LexBoxApi/GraphQL/CustomTypes/IsLanguageForgeProjectDataLoader.cs index 59b9c04697..3350687e0f 100644 --- a/backend/LexBoxApi/GraphQL/CustomTypes/IsLanguageForgeProjectDataLoader.cs +++ b/backend/LexBoxApi/GraphQL/CustomTypes/IsLanguageForgeProjectDataLoader.cs @@ -1,5 +1,6 @@ using LexCore.ServiceInterfaces; using LfClassicData; +using LfClassicData.Entities; using MongoDB.Driver; using MongoDB.Driver.Linq; using Polly; @@ -58,12 +59,11 @@ private static async Task> LoadBatch(IsLanguageForgePro IReadOnlyList list, CancellationToken token) { - var actualProjects = await MongoExtensions.ToAsyncEnumerable(loader._systemDbContext.Projects.AsQueryable() - .Select(p => p.ProjectCode) -#pragma warning disable MALinq2001 - .Where(projectCode => list.Contains(projectCode))) -#pragma warning restore MALinq2001 - .ToHashSetAsync(cancellationToken: token); + var actualProjects = (await loader._systemDbContext.Projects + .Find(Builders.Filter.In(p => p.ProjectCode, list)) + .Project(p => p.ProjectCode) + .ToListAsync(cancellationToken: token) + ).ToHashSet(); return list.ToDictionary(pc => pc, pc => actualProjects.Contains(pc)); } From fe67b6a7fd1bb0749dde38a1f6d9350647f52907 Mon Sep 17 00:00:00 2001 From: Robin Munn Date: Wed, 13 May 2026 16:27:44 +0700 Subject: [PATCH 14/36] Adjust to two more breaking changes --- backend/FwLite/LcmCrdt.Tests/EntityCopyMethodTests.cs | 2 +- backend/Testing/Fixtures/IntegrationFixture.cs | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/backend/FwLite/LcmCrdt.Tests/EntityCopyMethodTests.cs b/backend/FwLite/LcmCrdt.Tests/EntityCopyMethodTests.cs index 0548cb0c98..df0d9ab076 100644 --- a/backend/FwLite/LcmCrdt.Tests/EntityCopyMethodTests.cs +++ b/backend/FwLite/LcmCrdt.Tests/EntityCopyMethodTests.cs @@ -91,7 +91,7 @@ public EquivalencyResult Handle(Comparands comparands, assertionChain .ForCondition(!ReferenceEquals(comparands.Subject, comparands.Expectation)) .BecauseOf(context.Reason) - .FailWith("Subject and Expectation for {0} should not reference the same instance in memory.", context.CurrentNode.Description); + .FailWith("Subject for {0} and Expectation for {1} should not reference the same instance in memory.", context.CurrentNode.Subject.Description, context.CurrentNode.Expectation.Description); return EquivalencyResult.ContinueWithNext; } diff --git a/backend/Testing/Fixtures/IntegrationFixture.cs b/backend/Testing/Fixtures/IntegrationFixture.cs index 77a1f282ab..472ae7a26a 100644 --- a/backend/Testing/Fixtures/IntegrationFixture.cs +++ b/backend/Testing/Fixtures/IntegrationFixture.cs @@ -6,6 +6,7 @@ using Testing.ApiTests; using Testing.Services; using static Testing.Services.Constants; +using Squidex.Assets.TusClient; namespace Testing.Fixtures; From 187b95fc41182db0ab2291d81c06ebb31bfb4ebb Mon Sep 17 00:00:00 2001 From: Robin Munn Date: Wed, 13 May 2026 16:30:36 +0700 Subject: [PATCH 15/36] Fix two FluentAssertions breaking changes On JsonObjects, Should().HaveProperty() is the correct call now, rather than Should().ContainKey(). --- backend/Testing/SyncReverseProxy/LegacyProjectApiTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/Testing/SyncReverseProxy/LegacyProjectApiTests.cs b/backend/Testing/SyncReverseProxy/LegacyProjectApiTests.cs index ab48a04d83..fdce5cd6c5 100644 --- a/backend/Testing/SyncReverseProxy/LegacyProjectApiTests.cs +++ b/backend/Testing/SyncReverseProxy/LegacyProjectApiTests.cs @@ -102,7 +102,7 @@ public async Task TestInvalidPassword() content.ValueKind.Should().Be(JsonValueKind.Object); var responseObject = JsonObject.Create(content); responseObject.Should().NotBeNull(); - responseObject.Should().ContainKey("error"); + responseObject.Should().HaveProperty("error"); responseObject["error"]!.GetValue().Should().Be("Bad password"); } @@ -118,7 +118,7 @@ public async Task TestInvalidUser() content.ValueKind.Should().Be(JsonValueKind.Object); var responseObject = JsonObject.Create(content); responseObject.Should().NotBeNull(); - responseObject.Should().ContainKey("error"); + responseObject.Should().HaveProperty("error"); responseObject["error"]!.GetValue().Should().Be("Unknown user"); } From 63c1b116215ce0bd9ed69d6a9a4b0888f93db6ff Mon Sep 17 00:00:00 2001 From: Robin Munn Date: Wed, 13 May 2026 16:51:25 +0700 Subject: [PATCH 16/36] Fix Linq2Db breaking changes in logging --- backend/Directory.Packages.props | 2 +- backend/FwLite/LcmCrdt/LcmCrdt.csproj | 2 +- backend/FwLite/LcmCrdt/LcmCrdtKernel.cs | 2 +- backend/LexData/DataKernel.cs | 2 +- backend/LexData/LexData.csproj | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/backend/Directory.Packages.props b/backend/Directory.Packages.props index 6a402b3dad..2e044012d9 100644 --- a/backend/Directory.Packages.props +++ b/backend/Directory.Packages.props @@ -25,7 +25,7 @@ - + diff --git a/backend/FwLite/LcmCrdt/LcmCrdt.csproj b/backend/FwLite/LcmCrdt/LcmCrdt.csproj index df5353a90a..f8cfb1db27 100644 --- a/backend/FwLite/LcmCrdt/LcmCrdt.csproj +++ b/backend/FwLite/LcmCrdt/LcmCrdt.csproj @@ -8,8 +8,8 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/backend/FwLite/LcmCrdt/LcmCrdtKernel.cs b/backend/FwLite/LcmCrdt/LcmCrdtKernel.cs index 6530af68b2..f84f1d5297 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; 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 @@ - + all From 48f22228b4457b07a2eb2af8073e6c1dccbdc11c Mon Sep 17 00:00:00 2001 From: Robin Munn Date: Thu, 14 May 2026 08:46:21 +0700 Subject: [PATCH 17/36] Pull in Harmony build for .NET 10 --- backend/harmony | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/harmony b/backend/harmony index f16f71b99f..fab27544d3 160000 --- a/backend/harmony +++ b/backend/harmony @@ -1 +1 @@ -Subproject commit f16f71b99fed2a4b0fb524858f6dff341c9c4db5 +Subproject commit fab27544d3934624554ad9b1364e0467f2694855 From 25eba58db6412b062dcf5dbc1d4e216fc09301f5 Mon Sep 17 00:00:00 2001 From: Robin Munn Date: Thu, 14 May 2026 10:42:22 +0700 Subject: [PATCH 18/36] Update EF tools to 10.0.7 to match NuGet pkg MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Otherwise EF tells us a migration is needed, when in fact the DB schema didn't change — but the version of the EF tools is kept in the model snapshot, and that's what's causing "migration needed" test failures. --- .config/dotnet-tools.json | 2 +- .../LcmCrdtDbContextModelSnapshot.cs | 30 +++++++++---------- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index d724e90247..9755770cd2 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -3,7 +3,7 @@ "isRoot": true, "tools": { "dotnet-ef": { - "version": "9.0.6", + "version": "10.0.7", "commands": [ "dotnet-ef" ], diff --git a/backend/FwLite/LcmCrdt/Migrations/LcmCrdtDbContextModelSnapshot.cs b/backend/FwLite/LcmCrdt/Migrations/LcmCrdtDbContextModelSnapshot.cs index 5cddad6fec..dd0f0974a9 100644 --- a/backend/FwLite/LcmCrdt/Migrations/LcmCrdtDbContextModelSnapshot.cs +++ b/backend/FwLite/LcmCrdt/Migrations/LcmCrdtDbContextModelSnapshot.cs @@ -16,7 +16,7 @@ partial class LcmCrdtDbContextModelSnapshot : ModelSnapshot protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 - modelBuilder.HasAnnotation("ProductVersion", "9.0.6"); + modelBuilder.HasAnnotation("ProductVersion", "10.0.7"); modelBuilder.Entity("LcmCrdt.FullTextSearch.EntrySearchRecord", b => { @@ -89,7 +89,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("Id"); - b.ToTable("ProjectData"); + b.ToTable("ProjectData", (string)null); }); modelBuilder.Entity("MiniLcm.Models.ComplexFormComponent", b => @@ -164,7 +164,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("SnapshotId") .IsUnique(); - b.ToTable("ComplexFormType"); + b.ToTable("ComplexFormType", (string)null); }); modelBuilder.Entity("MiniLcm.Models.CustomView", b => @@ -209,7 +209,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("SnapshotId") .IsUnique(); - b.ToTable("CustomView"); + b.ToTable("CustomView", (string)null); }); modelBuilder.Entity("MiniLcm.Models.Entry", b => @@ -256,7 +256,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("SnapshotId") .IsUnique(); - b.ToTable("Entry"); + b.ToTable("Entry", (string)null); }); modelBuilder.Entity("MiniLcm.Models.ExampleSentence", b => @@ -295,7 +295,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("SnapshotId") .IsUnique(); - b.ToTable("ExampleSentence"); + b.ToTable("ExampleSentence", (string)null); }); modelBuilder.Entity("MiniLcm.Models.MorphType", b => @@ -342,7 +342,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("SnapshotId") .IsUnique(); - b.ToTable("MorphType"); + b.ToTable("MorphType", (string)null); }); modelBuilder.Entity("MiniLcm.Models.PartOfSpeech", b => @@ -369,7 +369,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("SnapshotId") .IsUnique(); - b.ToTable("PartOfSpeech"); + b.ToTable("PartOfSpeech", (string)null); }); modelBuilder.Entity("MiniLcm.Models.Publication", b => @@ -393,7 +393,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("SnapshotId") .IsUnique(); - b.ToTable("Publication"); + b.ToTable("Publication", (string)null); }); modelBuilder.Entity("MiniLcm.Models.SemanticDomain", b => @@ -424,7 +424,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("SnapshotId") .IsUnique(); - b.ToTable("SemanticDomain"); + b.ToTable("SemanticDomain", (string)null); }); modelBuilder.Entity("MiniLcm.Models.Sense", b => @@ -469,7 +469,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("SnapshotId") .IsUnique(); - b.ToTable("Sense"); + b.ToTable("Sense", (string)null); }); modelBuilder.Entity("MiniLcm.Models.WritingSystem", b => @@ -518,7 +518,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("WsId", "Type") .IsUnique(); - b.ToTable("WritingSystem"); + b.ToTable("WritingSystem", (string)null); }); modelBuilder.Entity("SIL.Harmony.Commit", b => @@ -542,7 +542,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired() .HasColumnType("TEXT"); - b.ComplexProperty>("HybridDateTime", "SIL.Harmony.Commit.HybridDateTime#HybridDateTime", b1 => + b.ComplexProperty(typeof(Dictionary), "HybridDateTime", "SIL.Harmony.Commit.HybridDateTime#HybridDateTime", b1 => { b1.IsRequired(); @@ -631,7 +631,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("Id"); - b.ToTable("LocalResource"); + b.ToTable("LocalResource", (string)null); }); modelBuilder.Entity("SIL.Harmony.Resource.RemoteResource", b => @@ -654,7 +654,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("SnapshotId") .IsUnique(); - b.ToTable("RemoteResource"); + b.ToTable("RemoteResource", (string)null); }); modelBuilder.Entity("MiniLcm.Models.ComplexFormComponent", b => From cd557deae60e9179c4d46371a85f631b34140d75 Mon Sep 17 00:00:00 2001 From: Robin Munn Date: Thu, 14 May 2026 10:43:55 +0700 Subject: [PATCH 19/36] One more FluentValidation breaking change This one is in the MorphType tests that were just merged into develop. --- .../MiniLcm.Tests/Validators/MorphTypeUpdateValidatorTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/FwLite/MiniLcm.Tests/Validators/MorphTypeUpdateValidatorTests.cs b/backend/FwLite/MiniLcm.Tests/Validators/MorphTypeUpdateValidatorTests.cs index d07a4004a2..c0c42b7fe6 100644 --- a/backend/FwLite/MiniLcm.Tests/Validators/MorphTypeUpdateValidatorTests.cs +++ b/backend/FwLite/MiniLcm.Tests/Validators/MorphTypeUpdateValidatorTests.cs @@ -41,7 +41,7 @@ public void Fails_WhenTryingToUpdateDeletedAt() public void Fails_WhenThereAreNoChanges() { var update = NewUpdate(); - _validator.TestValidate(update).ShouldHaveAnyValidationError(); + _validator.TestValidate(update).ShouldHaveValidationErrors(); } [Fact] From e9c843635a6b927f32fd176e36ff44e8d4d5e393 Mon Sep 17 00:00:00 2001 From: Robin Munn Date: Thu, 14 May 2026 10:52:27 +0700 Subject: [PATCH 20/36] Add LexBoxDbContext migration for Quartz changes The version of Quartz.AspNetCore that we migrated to has some minor DB schema changes, which need a migration. --- ...14035126_QuartzDatabaseUpdates.Designer.cs | 1414 +++++++++++++++++ .../20260514035126_QuartzDatabaseUpdates.cs | 52 + .../LexBoxDbContextModelSnapshot.cs | 49 +- 3 files changed, 1488 insertions(+), 27 deletions(-) create mode 100644 backend/LexData/Migrations/20260514035126_QuartzDatabaseUpdates.Designer.cs create mode 100644 backend/LexData/Migrations/20260514035126_QuartzDatabaseUpdates.cs diff --git a/backend/LexData/Migrations/20260514035126_QuartzDatabaseUpdates.Designer.cs b/backend/LexData/Migrations/20260514035126_QuartzDatabaseUpdates.Designer.cs new file mode 100644 index 0000000000..b6723691e9 --- /dev/null +++ b/backend/LexData/Migrations/20260514035126_QuartzDatabaseUpdates.Designer.cs @@ -0,0 +1,1414 @@ +// +using System; +using System.Collections.Generic; +using LexData; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace LexData.Migrations +{ + [DbContext(typeof(LexBoxDbContext))] + [Migration("20260514035126_QuartzDatabaseUpdates")] + partial class QuartzDatabaseUpdates + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("Npgsql:CollationDefinition:case_insensitive", "und-u-ks-level2,und-u-ks-level2,icu,False") + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzBlobTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("text") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.Property("BlobData") + .HasColumnType("bytea") + .HasColumnName("blob_data"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup"); + + b.ToTable("qrtz_blob_triggers", "quartz"); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzCalendar", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("CalendarName") + .HasColumnType("text") + .HasColumnName("calendar_name"); + + b.Property("Calendar") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("calendar"); + + b.HasKey("SchedulerName", "CalendarName"); + + b.ToTable("qrtz_calendars", "quartz"); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzCronTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("text") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.Property("CronExpression") + .IsRequired() + .HasColumnType("text") + .HasColumnName("cron_expression"); + + b.Property("TimeZoneId") + .HasColumnType("text") + .HasColumnName("time_zone_id"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup"); + + b.ToTable("qrtz_cron_triggers", "quartz"); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzFiredTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("EntryId") + .HasColumnType("text") + .HasColumnName("entry_id"); + + b.Property("FiredTime") + .HasColumnType("bigint") + .HasColumnName("fired_time"); + + b.Property("InstanceName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("instance_name"); + + b.Property("IsNonConcurrent") + .HasColumnType("bool") + .HasColumnName("is_nonconcurrent"); + + b.Property("JobGroup") + .HasColumnType("text") + .HasColumnName("job_group"); + + b.Property("JobName") + .HasColumnType("text") + .HasColumnName("job_name"); + + b.Property("Priority") + .HasColumnType("integer") + .HasColumnName("priority"); + + b.Property("RequestsRecovery") + .HasColumnType("bool") + .HasColumnName("requests_recovery"); + + b.Property("ScheduledTime") + .HasColumnType("bigint") + .HasColumnName("sched_time"); + + b.Property("State") + .IsRequired() + .HasColumnType("text") + .HasColumnName("state"); + + b.Property("TriggerGroup") + .IsRequired() + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.Property("TriggerName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("trigger_name"); + + b.HasKey("SchedulerName", "EntryId"); + + b.HasIndex("InstanceName") + .HasDatabaseName("idx_qrtz_ft_trig_inst_name"); + + b.HasIndex("JobGroup") + .HasDatabaseName("idx_qrtz_ft_job_group"); + + b.HasIndex("JobName") + .HasDatabaseName("idx_qrtz_ft_job_name"); + + b.HasIndex("RequestsRecovery") + .HasDatabaseName("idx_qrtz_ft_job_req_recovery"); + + b.HasIndex("TriggerGroup") + .HasDatabaseName("idx_qrtz_ft_trig_group"); + + b.HasIndex("TriggerName") + .HasDatabaseName("idx_qrtz_ft_trig_name"); + + b.HasIndex("SchedulerName", "TriggerName", "TriggerGroup") + .HasDatabaseName("idx_qrtz_ft_trig_nm_gp"); + + b.ToTable("qrtz_fired_triggers", "quartz"); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzJobDetail", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("JobName") + .HasColumnType("text") + .HasColumnName("job_name"); + + b.Property("JobGroup") + .HasColumnType("text") + .HasColumnName("job_group"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("IsDurable") + .HasColumnType("bool") + .HasColumnName("is_durable"); + + b.Property("IsNonConcurrent") + .HasColumnType("bool") + .HasColumnName("is_nonconcurrent"); + + b.Property("IsUpdateData") + .HasColumnType("bool") + .HasColumnName("is_update_data"); + + b.Property("JobClassName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("job_class_name"); + + b.Property("JobData") + .HasColumnType("bytea") + .HasColumnName("job_data"); + + b.Property("RequestsRecovery") + .HasColumnType("bool") + .HasColumnName("requests_recovery"); + + b.HasKey("SchedulerName", "JobName", "JobGroup"); + + b.HasIndex("RequestsRecovery") + .HasDatabaseName("idx_qrtz_j_req_recovery"); + + b.ToTable("qrtz_job_details", "quartz"); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzLock", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("LockName") + .HasColumnType("text") + .HasColumnName("lock_name"); + + b.HasKey("SchedulerName", "LockName"); + + b.ToTable("qrtz_locks", "quartz"); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzPausedTriggerGroup", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("TriggerGroup") + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.HasKey("SchedulerName", "TriggerGroup"); + + b.ToTable("qrtz_paused_trigger_grps", "quartz"); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzSchedulerState", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("InstanceName") + .HasColumnType("text") + .HasColumnName("instance_name"); + + b.Property("CheckInInterval") + .HasColumnType("bigint") + .HasColumnName("checkin_interval"); + + b.Property("LastCheckInTime") + .HasColumnType("bigint") + .HasColumnName("last_checkin_time"); + + b.HasKey("SchedulerName", "InstanceName"); + + b.ToTable("qrtz_scheduler_state", "quartz"); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzSimplePropertyTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("text") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.Property("BooleanProperty1") + .HasColumnType("bool") + .HasColumnName("bool_prop_1"); + + b.Property("BooleanProperty2") + .HasColumnType("bool") + .HasColumnName("bool_prop_2"); + + b.Property("DecimalProperty1") + .HasColumnType("numeric") + .HasColumnName("dec_prop_1"); + + b.Property("DecimalProperty2") + .HasColumnType("numeric") + .HasColumnName("dec_prop_2"); + + b.Property("IntegerProperty1") + .HasColumnType("integer") + .HasColumnName("int_prop_1"); + + b.Property("IntegerProperty2") + .HasColumnType("integer") + .HasColumnName("int_prop_2"); + + b.Property("LongProperty1") + .HasColumnType("bigint") + .HasColumnName("long_prop_1"); + + b.Property("LongProperty2") + .HasColumnType("bigint") + .HasColumnName("long_prop_2"); + + b.Property("StringProperty1") + .HasColumnType("text") + .HasColumnName("str_prop_1"); + + b.Property("StringProperty2") + .HasColumnType("text") + .HasColumnName("str_prop_2"); + + b.Property("StringProperty3") + .HasColumnType("text") + .HasColumnName("str_prop_3"); + + b.Property("TimeZoneId") + .HasColumnType("text") + .HasColumnName("time_zone_id"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup"); + + b.ToTable("qrtz_simprop_triggers", "quartz"); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzSimpleTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("text") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.Property("RepeatCount") + .HasColumnType("bigint") + .HasColumnName("repeat_count"); + + b.Property("RepeatInterval") + .HasColumnType("bigint") + .HasColumnName("repeat_interval"); + + b.Property("TimesTriggered") + .HasColumnType("bigint") + .HasColumnName("times_triggered"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup"); + + b.ToTable("qrtz_simple_triggers", "quartz"); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("text") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.Property("CalendarName") + .HasColumnType("text") + .HasColumnName("calendar_name"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("EndTime") + .HasColumnType("bigint") + .HasColumnName("end_time"); + + b.Property("JobData") + .HasColumnType("bytea") + .HasColumnName("job_data"); + + b.Property("JobGroup") + .IsRequired() + .HasColumnType("text") + .HasColumnName("job_group"); + + b.Property("JobName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("job_name"); + + b.Property("MisfireInstruction") + .HasColumnType("smallint") + .HasColumnName("misfire_instr"); + + b.Property("MisfireOriginalFireTime") + .HasColumnType("bigint") + .HasColumnName("misfire_orig_fire_time"); + + b.Property("NextFireTime") + .HasColumnType("bigint") + .HasColumnName("next_fire_time"); + + b.Property("PreviousFireTime") + .HasColumnType("bigint") + .HasColumnName("prev_fire_time"); + + b.Property("Priority") + .HasColumnType("integer") + .HasColumnName("priority"); + + b.Property("StartTime") + .HasColumnType("bigint") + .HasColumnName("start_time"); + + b.Property("TriggerState") + .IsRequired() + .HasColumnType("text") + .HasColumnName("trigger_state"); + + b.Property("TriggerType") + .IsRequired() + .HasColumnType("text") + .HasColumnName("trigger_type"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup"); + + b.HasIndex("NextFireTime") + .HasDatabaseName("idx_qrtz_t_next_fire_time"); + + b.HasIndex("TriggerState") + .HasDatabaseName("idx_qrtz_t_state"); + + b.HasIndex("NextFireTime", "TriggerState") + .HasDatabaseName("idx_qrtz_t_nft_st"); + + b.HasIndex("SchedulerName", "JobName", "JobGroup"); + + b.ToTable("qrtz_triggers", "quartz"); + }); + + modelBuilder.Entity("LexCore.Entities.DraftProject", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Code") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("IsConfidential") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("OrgId") + .HasColumnType("uuid"); + + b.Property("ProjectManagerId") + .HasColumnType("uuid"); + + b.Property("RetentionPolicy") + .HasColumnType("integer"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("UpdatedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("ProjectManagerId"); + + b.ToTable("DraftProjects"); + }); + + modelBuilder.Entity("LexCore.Entities.FlexProjectMetadata", b => + { + b.Property("ProjectId") + .HasColumnType("uuid"); + + b.Property("FlexModelVersion") + .HasColumnType("integer"); + + b.Property("LangProjectId") + .HasColumnType("uuid"); + + b.Property("LexEntryCount") + .HasColumnType("integer"); + + b.HasKey("ProjectId"); + + b.ToTable("FlexProjectMetadata"); + }); + + modelBuilder.Entity("LexCore.Entities.MediaFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Filename") + .IsRequired() + .HasColumnType("text"); + + b.Property("Metadata") + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValueSql("'{}'"); + + b.Property("ProjectId") + .HasColumnType("uuid"); + + b.Property("UpdatedDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ProjectId"); + + b.ToTable("Files"); + }); + + modelBuilder.Entity("LexCore.Entities.OrgMember", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("OrgId") + .HasColumnType("uuid"); + + b.Property("Role") + .HasColumnType("integer"); + + b.Property("UpdatedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("OrgId"); + + b.HasIndex("UserId", "OrgId") + .IsUnique(); + + b.ToTable("OrgMembers", (string)null); + }); + + modelBuilder.Entity("LexCore.Entities.OrgProjects", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("OrgId") + .HasColumnType("uuid"); + + b.Property("ProjectId") + .HasColumnType("uuid"); + + b.Property("UpdatedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.HasKey("Id"); + + b.HasIndex("ProjectId"); + + b.HasIndex("OrgId", "ProjectId") + .IsUnique(); + + b.ToTable("OrgProjects"); + }); + + modelBuilder.Entity("LexCore.Entities.Organization", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .UseCollation("case_insensitive"); + + b.Property("UpdatedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Orgs", (string)null); + }); + + modelBuilder.Entity("LexCore.Entities.Project", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Code") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("DeletedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("IsConfidential") + .HasColumnType("boolean"); + + b.Property("LastCommit") + .HasColumnType("timestamp with time zone"); + + b.Property("MigratedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("ParentId") + .HasColumnType("uuid"); + + b.Property("ProjectOrigin") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(1); + + b.Property("RepoSizeInKb") + .HasColumnType("integer"); + + b.Property("ResetStatus") + .HasColumnType("integer"); + + b.Property("RetentionPolicy") + .HasColumnType("integer"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("UpdatedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("ParentId"); + + b.ToTable("Projects"); + }); + + modelBuilder.Entity("LexCore.Entities.ProjectUsers", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("ProjectId") + .HasColumnType("uuid"); + + b.Property("Role") + .HasColumnType("integer"); + + b.Property("UpdatedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ProjectId"); + + b.HasIndex("UserId", "ProjectId") + .IsUnique(); + + b.ToTable("ProjectUsers"); + }); + + modelBuilder.Entity("LexCore.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CanCreateProjects") + .HasColumnType("boolean"); + + b.Property("CreatedById") + .HasColumnType("uuid"); + + b.Property("CreatedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("Email") + .HasColumnType("text") + .UseCollation("case_insensitive"); + + b.Property("EmailVerified") + .HasColumnType("boolean"); + + b.Property>("FeatureFlags") + .HasColumnType("text[]"); + + b.Property("GoogleId") + .HasColumnType("text"); + + b.Property("IsAdmin") + .HasColumnType("boolean"); + + b.Property("LastActive") + .HasColumnType("timestamp with time zone"); + + b.Property("LocalizationCode") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("en"); + + b.Property("Locked") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("text"); + + b.Property("PasswordStrength") + .HasColumnType("integer"); + + b.Property("Salt") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("Username") + .HasColumnType("text") + .UseCollation("case_insensitive"); + + b.HasKey("Id"); + + b.HasIndex("CreatedById"); + + b.HasIndex("Email") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("ApplicationType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ClientId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ClientSecret") + .HasColumnType("text"); + + b.Property("ClientType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ConsentType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("DisplayName") + .HasColumnType("text"); + + b.Property("DisplayNames") + .HasColumnType("text"); + + b.Property("JsonWebKeySet") + .HasColumnType("text"); + + b.Property("Permissions") + .HasColumnType("text"); + + b.Property("PostLogoutRedirectUris") + .HasColumnType("text"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("RedirectUris") + .HasColumnType("text"); + + b.Property("Requirements") + .HasColumnType("text"); + + b.Property("Settings") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ClientId") + .IsUnique(); + + b.ToTable("OpenIddictApplications", (string)null); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("ApplicationId") + .HasColumnType("text"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("Scopes") + .HasColumnType("text"); + + b.Property("Status") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Subject") + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("Type") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationId", "Status", "Subject", "Type"); + + b.ToTable("OpenIddictAuthorizations", (string)null); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreScope", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Descriptions") + .HasColumnType("text"); + + b.Property("DisplayName") + .HasColumnType("text"); + + b.Property("DisplayNames") + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("Resources") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("OpenIddictScopes", (string)null); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("ApplicationId") + .HasColumnType("text"); + + b.Property("AuthorizationId") + .HasColumnType("text"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Payload") + .HasColumnType("text"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("RedemptionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ReferenceId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Status") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Subject") + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("Type") + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.HasKey("Id"); + + b.HasIndex("AuthorizationId"); + + b.HasIndex("ReferenceId") + .IsUnique(); + + b.HasIndex("ApplicationId", "Status", "Subject", "Type"); + + b.ToTable("OpenIddictTokens", (string)null); + }); + + modelBuilder.Entity("SIL.Harmony.Core.ServerCommit", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ChangeEntities") + .HasColumnType("jsonb"); + + b.Property("ClientId") + .HasColumnType("uuid"); + + b.Property("Metadata") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProjectId") + .HasColumnType("uuid"); + + b.ComplexProperty(typeof(Dictionary), "HybridDateTime", "SIL.Harmony.Core.ServerCommit.HybridDateTime#HybridDateTime", b1 => + { + b1.IsRequired(); + + b1.Property("Counter") + .HasColumnType("bigint"); + + b1.Property("DateTime") + .HasColumnType("timestamp with time zone"); + }); + + b.HasKey("Id"); + + b.HasIndex("ProjectId"); + + b.ToTable("CrdtCommits", (string)null); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzBlobTrigger", b => + { + b.HasOne("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzTrigger", "Trigger") + .WithMany("BlobTriggers") + .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzCronTrigger", b => + { + b.HasOne("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzTrigger", "Trigger") + .WithMany("CronTriggers") + .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzSimplePropertyTrigger", b => + { + b.HasOne("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzTrigger", "Trigger") + .WithMany("SimplePropertyTriggers") + .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzSimpleTrigger", b => + { + b.HasOne("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzTrigger", "Trigger") + .WithMany("SimpleTriggers") + .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzTrigger", b => + { + b.HasOne("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzJobDetail", "JobDetail") + .WithMany("Triggers") + .HasForeignKey("SchedulerName", "JobName", "JobGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("JobDetail"); + }); + + modelBuilder.Entity("LexCore.Entities.DraftProject", b => + { + b.HasOne("LexCore.Entities.User", "ProjectManager") + .WithMany() + .HasForeignKey("ProjectManagerId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("ProjectManager"); + }); + + modelBuilder.Entity("LexCore.Entities.FlexProjectMetadata", b => + { + b.HasOne("LexCore.Entities.Project", null) + .WithOne("FlexProjectMetadata") + .HasForeignKey("LexCore.Entities.FlexProjectMetadata", "ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.OwnsOne("LexCore.Entities.ProjectWritingSystems", "WritingSystems", b1 => + { + b1.Property("FlexProjectMetadataProjectId"); + + b1.HasKey("FlexProjectMetadataProjectId"); + + b1.ToTable("FlexProjectMetadata"); + + b1 + .ToJson("WritingSystems") + .HasColumnType("jsonb"); + + b1.WithOwner() + .HasForeignKey("FlexProjectMetadataProjectId"); + + b1.OwnsMany("LexCore.Entities.FLExWsId", "AnalysisWss", b2 => + { + b2.Property("ProjectWritingSystemsFlexProjectMetadataProjectId"); + + b2.Property("__synthesizedOrdinal") + .ValueGeneratedOnAdd(); + + b2.Property("IsActive"); + + b2.Property("IsDefault"); + + b2.Property("Tag") + .IsRequired(); + + b2.HasKey("ProjectWritingSystemsFlexProjectMetadataProjectId", "__synthesizedOrdinal"); + + b2.ToTable("FlexProjectMetadata"); + + b2.WithOwner() + .HasForeignKey("ProjectWritingSystemsFlexProjectMetadataProjectId"); + }); + + b1.OwnsMany("LexCore.Entities.FLExWsId", "VernacularWss", b2 => + { + b2.Property("ProjectWritingSystemsFlexProjectMetadataProjectId"); + + b2.Property("__synthesizedOrdinal") + .ValueGeneratedOnAdd(); + + b2.Property("IsActive"); + + b2.Property("IsDefault"); + + b2.Property("Tag") + .IsRequired(); + + b2.HasKey("ProjectWritingSystemsFlexProjectMetadataProjectId", "__synthesizedOrdinal"); + + b2.ToTable("FlexProjectMetadata"); + + b2.WithOwner() + .HasForeignKey("ProjectWritingSystemsFlexProjectMetadataProjectId"); + }); + + b1.Navigation("AnalysisWss"); + + b1.Navigation("VernacularWss"); + }); + + b.Navigation("WritingSystems"); + }); + + modelBuilder.Entity("LexCore.Entities.MediaFile", b => + { + b.HasOne("LexCore.Entities.Project", null) + .WithMany() + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("LexCore.Entities.OrgMember", b => + { + b.HasOne("LexCore.Entities.Organization", "Organization") + .WithMany("Members") + .HasForeignKey("OrgId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("LexCore.Entities.User", "User") + .WithMany("Organizations") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("LexCore.Entities.OrgProjects", b => + { + b.HasOne("LexCore.Entities.Organization", "Org") + .WithMany() + .HasForeignKey("OrgId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("LexCore.Entities.Project", "Project") + .WithMany() + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Org"); + + b.Navigation("Project"); + }); + + modelBuilder.Entity("LexCore.Entities.Project", b => + { + b.HasOne("LexCore.Entities.Project", null) + .WithMany() + .HasForeignKey("ParentId"); + }); + + modelBuilder.Entity("LexCore.Entities.ProjectUsers", b => + { + b.HasOne("LexCore.Entities.Project", "Project") + .WithMany("Users") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("LexCore.Entities.User", "User") + .WithMany("Projects") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Project"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("LexCore.Entities.User", b => + { + b.HasOne("LexCore.Entities.User", "CreatedBy") + .WithMany("UsersICreated") + .HasForeignKey("CreatedById") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("CreatedBy"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b => + { + b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", "Application") + .WithMany("Authorizations") + .HasForeignKey("ApplicationId"); + + b.Navigation("Application"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreToken", b => + { + b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", "Application") + .WithMany("Tokens") + .HasForeignKey("ApplicationId"); + + b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", "Authorization") + .WithMany("Tokens") + .HasForeignKey("AuthorizationId"); + + b.Navigation("Application"); + + b.Navigation("Authorization"); + }); + + modelBuilder.Entity("SIL.Harmony.Core.ServerCommit", b => + { + b.HasOne("LexCore.Entities.Project", null) + .WithMany() + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzJobDetail", b => + { + b.Navigation("Triggers"); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzTrigger", b => + { + b.Navigation("BlobTriggers"); + + b.Navigation("CronTriggers"); + + b.Navigation("SimplePropertyTriggers"); + + b.Navigation("SimpleTriggers"); + }); + + modelBuilder.Entity("LexCore.Entities.Organization", b => + { + b.Navigation("Members"); + }); + + modelBuilder.Entity("LexCore.Entities.Project", b => + { + b.Navigation("FlexProjectMetadata"); + + b.Navigation("Users"); + }); + + modelBuilder.Entity("LexCore.Entities.User", b => + { + b.Navigation("Organizations"); + + b.Navigation("Projects"); + + b.Navigation("UsersICreated"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", b => + { + b.Navigation("Authorizations"); + + b.Navigation("Tokens"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b => + { + b.Navigation("Tokens"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/LexData/Migrations/20260514035126_QuartzDatabaseUpdates.cs b/backend/LexData/Migrations/20260514035126_QuartzDatabaseUpdates.cs new file mode 100644 index 0000000000..51da9bdc54 --- /dev/null +++ b/backend/LexData/Migrations/20260514035126_QuartzDatabaseUpdates.cs @@ -0,0 +1,52 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace LexData.Migrations +{ + /// + public partial class QuartzDatabaseUpdates : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "misfire_orig_fire_time", + schema: "quartz", + table: "qrtz_triggers", + type: "bigint", + nullable: true); + + migrationBuilder.AlterColumn( + name: "Type", + table: "OpenIddictTokens", + type: "character varying(150)", + maxLength: 150, + nullable: true, + oldClrType: typeof(string), + oldType: "character varying(50)", + oldMaxLength: 50, + oldNullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "misfire_orig_fire_time", + schema: "quartz", + table: "qrtz_triggers"); + + migrationBuilder.AlterColumn( + name: "Type", + table: "OpenIddictTokens", + type: "character varying(50)", + maxLength: 50, + nullable: true, + oldClrType: typeof(string), + oldType: "character varying(150)", + oldMaxLength: 150, + oldNullable: true); + } + } +} diff --git a/backend/LexData/Migrations/LexBoxDbContextModelSnapshot.cs b/backend/LexData/Migrations/LexBoxDbContextModelSnapshot.cs index cb1e6cf783..f1cea83163 100644 --- a/backend/LexData/Migrations/LexBoxDbContextModelSnapshot.cs +++ b/backend/LexData/Migrations/LexBoxDbContextModelSnapshot.cs @@ -19,7 +19,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) #pragma warning disable 612, 618 modelBuilder .HasAnnotation("Npgsql:CollationDefinition:case_insensitive", "und-u-ks-level2,und-u-ks-level2,icu,False") - .HasAnnotation("ProductVersion", "9.0.6") + .HasAnnotation("ProductVersion", "10.0.7") .HasAnnotation("Relational:MaxIdentifierLength", 63); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); @@ -425,6 +425,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("smallint") .HasColumnName("misfire_instr"); + b.Property("MisfireOriginalFireTime") + .HasColumnType("bigint") + .HasColumnName("misfire_orig_fire_time"); + b.Property("NextFireTime") .HasColumnType("bigint") .HasColumnName("next_fire_time"); @@ -1045,8 +1049,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("character varying(400)"); b.Property("Type") - .HasMaxLength(50) - .HasColumnType("character varying(50)"); + .HasMaxLength(150) + .HasColumnType("character varying(150)"); b.HasKey("Id"); @@ -1079,7 +1083,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("ProjectId") .HasColumnType("uuid"); - b.ComplexProperty>("HybridDateTime", "SIL.Harmony.Core.ServerCommit.HybridDateTime#HybridDateTime", b1 => + b.ComplexProperty(typeof(Dictionary), "HybridDateTime", "SIL.Harmony.Core.ServerCommit.HybridDateTime#HybridDateTime", b1 => { b1.IsRequired(); @@ -1172,36 +1176,32 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.OwnsOne("LexCore.Entities.ProjectWritingSystems", "WritingSystems", b1 => { - b1.Property("FlexProjectMetadataProjectId") - .HasColumnType("uuid"); + b1.Property("FlexProjectMetadataProjectId"); b1.HasKey("FlexProjectMetadataProjectId"); b1.ToTable("FlexProjectMetadata"); - b1.ToJson("WritingSystems"); + b1 + .ToJson("WritingSystems") + .HasColumnType("jsonb"); b1.WithOwner() .HasForeignKey("FlexProjectMetadataProjectId"); b1.OwnsMany("LexCore.Entities.FLExWsId", "AnalysisWss", b2 => { - b2.Property("ProjectWritingSystemsFlexProjectMetadataProjectId") - .HasColumnType("uuid"); + b2.Property("ProjectWritingSystemsFlexProjectMetadataProjectId"); b2.Property("__synthesizedOrdinal") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); + .ValueGeneratedOnAdd(); - b2.Property("IsActive") - .HasColumnType("boolean"); + b2.Property("IsActive"); - b2.Property("IsDefault") - .HasColumnType("boolean"); + b2.Property("IsDefault"); b2.Property("Tag") - .IsRequired() - .HasColumnType("text"); + .IsRequired(); b2.HasKey("ProjectWritingSystemsFlexProjectMetadataProjectId", "__synthesizedOrdinal"); @@ -1213,22 +1213,17 @@ protected override void BuildModel(ModelBuilder modelBuilder) b1.OwnsMany("LexCore.Entities.FLExWsId", "VernacularWss", b2 => { - b2.Property("ProjectWritingSystemsFlexProjectMetadataProjectId") - .HasColumnType("uuid"); + b2.Property("ProjectWritingSystemsFlexProjectMetadataProjectId"); b2.Property("__synthesizedOrdinal") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); + .ValueGeneratedOnAdd(); - b2.Property("IsActive") - .HasColumnType("boolean"); + b2.Property("IsActive"); - b2.Property("IsDefault") - .HasColumnType("boolean"); + b2.Property("IsDefault"); b2.Property("Tag") - .IsRequired() - .HasColumnType("text"); + .IsRequired(); b2.HasKey("ProjectWritingSystemsFlexProjectMetadataProjectId", "__synthesizedOrdinal"); From e22ee0f84b52f8b3b99d4cbf52897bcb9bb74931 Mon Sep 17 00:00:00 2001 From: Robin Munn Date: Thu, 14 May 2026 12:05:51 +0700 Subject: [PATCH 21/36] Fix "UPSERT not implemented for virtual table" Workaround isn't ideal in all cases, we'll want to come back and revisit this once all tests are passing. --- .../FullTextSearch/EntrySearchService.cs | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/backend/FwLite/LcmCrdt/FullTextSearch/EntrySearchService.cs b/backend/FwLite/LcmCrdt/FullTextSearch/EntrySearchService.cs index c3e6945118..b7bbc3ff83 100644 --- a/backend/FwLite/LcmCrdt/FullTextSearch/EntrySearchService.cs +++ b/backend/FwLite/LcmCrdt/FullTextSearch/EntrySearchService.cs @@ -224,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, @@ -271,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); } From d849ff20019e928802179db3be14f5af22b3a850 Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Fri, 15 May 2026 11:17:38 +0200 Subject: [PATCH 22/36] Apply more .NET 10 / Linq2Db / OpenAPI breaking-change fixes - WithOpenApi(...) -> AddOpenApiOperationTransformer(...) on route groups - ForwardedHeadersOptions.KnownNetworks -> KnownIPNetworks (drop the legacy Microsoft.AspNetCore.HttpOverrides.IPNetwork alias) - SelectAwait / ToDictionaryAwaitAsync -> Select / ToDictionaryAsync with the (T, CancellationToken) overload now in System.Linq.AsyncEnumerable - Drop package refs that are now part of the .NET 10 BCL / Microsoft.AspNetCore.App framework reference --- backend/FwHeadless/FwHeadless.csproj | 1 - backend/FwHeadless/Routes/MediaFileRoutes.cs | 2 +- backend/FwHeadless/Routes/MergeRoutes.cs | 2 +- backend/FwLite/FwLiteShared/Auth/AuthService.cs | 2 +- backend/FwLite/FwLiteWeb/Routes/ActivityRoutes.cs | 4 ++-- backend/FwLite/FwLiteWeb/Routes/AuthRoutes.cs | 2 +- backend/FwLite/FwLiteWeb/Routes/FwIntegrationRoutes.cs | 6 +++--- backend/FwLite/FwLiteWeb/Routes/HistoryRoutes.cs | 4 ++-- backend/FwLite/FwLiteWeb/Routes/MiniLcmRoutes.cs | 4 ++-- backend/FwLite/FwLiteWeb/Routes/ProjectRoutes.cs | 2 +- backend/FwLite/FwLiteWeb/Routes/TestRoutes.cs | 4 ++-- backend/FwLite/LcmCrdt/HistoryService.cs | 2 +- backend/FwLite/MiniLcm.Tests/UpdateEntryTestsBase.cs | 2 +- backend/FwLite/MiniLcm/MiniLcm.csproj | 1 - backend/LexBoxApi/LexBoxApi.csproj | 2 -- backend/LexBoxApi/Program.cs | 5 ++--- backend/LexBoxApi/Proxies/FileUploadProxy.cs | 2 +- backend/LexData/SeedingData.cs | 2 +- backend/LfClassicData/LfClassicData.csproj | 2 -- backend/SyncReverseProxy/SyncReverseProxy.csproj | 1 - 20 files changed, 22 insertions(+), 30 deletions(-) diff --git a/backend/FwHeadless/FwHeadless.csproj b/backend/FwHeadless/FwHeadless.csproj index fe6acf46ad..cda3f10c9e 100644 --- a/backend/FwHeadless/FwHeadless.csproj +++ b/backend/FwHeadless/FwHeadless.csproj @@ -11,7 +11,6 @@ - diff --git a/backend/FwHeadless/Routes/MediaFileRoutes.cs b/backend/FwHeadless/Routes/MediaFileRoutes.cs index 5029e40a68..05928ddb57 100644 --- a/backend/FwHeadless/Routes/MediaFileRoutes.cs +++ b/backend/FwHeadless/Routes/MediaFileRoutes.cs @@ -7,7 +7,7 @@ public static class MediaFileRoutes public const string RootRoute = "/api/media"; public static IEndpointConventionBuilder MapMediaFileRoutes(this WebApplication app) { - var group = app.MapGroup(RootRoute).WithOpenApi(); + var group = app.MapGroup(RootRoute); group.MapGet("/list/{projectId:guid}", MediaFileController.ListFiles); group.MapGet("/metadata/{fileId:guid}", MediaFileMetadataController.GetFileMetadata); group.MapGet("/{fileId:guid}", MediaFileController.GetFile); diff --git a/backend/FwHeadless/Routes/MergeRoutes.cs b/backend/FwHeadless/Routes/MergeRoutes.cs index d1bd08e84a..126c5451e4 100644 --- a/backend/FwHeadless/Routes/MergeRoutes.cs +++ b/backend/FwHeadless/Routes/MergeRoutes.cs @@ -18,7 +18,7 @@ public static class MergeRoutes { public static IEndpointConventionBuilder MapMergeRoutes(this WebApplication app) { - var group = app.MapGroup("/api/merge").WithOpenApi(); + var group = app.MapGroup("/api/merge"); group.MapPost("/execute", ExecuteMergeRequest); group.MapPost("/sync-harmony", SyncHarmonyProject); diff --git a/backend/FwLite/FwLiteShared/Auth/AuthService.cs b/backend/FwLite/FwLiteShared/Auth/AuthService.cs index efff36f68f..3a25af68f3 100644 --- a/backend/FwLite/FwLiteShared/Auth/AuthService.cs +++ b/backend/FwLite/FwLiteShared/Auth/AuthService.cs @@ -10,7 +10,7 @@ public class AuthService(LexboxProjectService lexboxProjectService, OAuthClientF [JSInvokable] public async Task Servers() { - return await lexboxProjectService.Servers().ToAsyncEnumerable().SelectAwait(async s => + return await lexboxProjectService.Servers().ToAsyncEnumerable().Select(async (LexboxServer s, CancellationToken _) => { var currentName = await clientFactory.GetClient(s).GetCurrentName(); return new ServerStatus(s.DisplayName, diff --git a/backend/FwLite/FwLiteWeb/Routes/ActivityRoutes.cs b/backend/FwLite/FwLiteWeb/Routes/ActivityRoutes.cs index 3956acb1ea..410b57269d 100644 --- a/backend/FwLite/FwLiteWeb/Routes/ActivityRoutes.cs +++ b/backend/FwLite/FwLiteWeb/Routes/ActivityRoutes.cs @@ -8,7 +8,7 @@ public static class ActivityRoutes { public static IEndpointConventionBuilder MapActivities(this WebApplication app) { - var group = app.MapGroup("/api/activity/{project}").WithOpenApi(operation => + var group = app.MapGroup("/api/activity/{project}").AddOpenApiOperationTransformer((operation, _, _) => { operation.Parameters?.Add(new OpenApiParameter() { @@ -16,7 +16,7 @@ public static IEndpointConventionBuilder MapActivities(this WebApplication app) In = ParameterLocation.Path, Required = true }); - return operation; + return Task.CompletedTask; }); group.MapGet("/", (HistoryService historyService, int skip, int take) => historyService.ProjectActivity(skip, take)); return group; diff --git a/backend/FwLite/FwLiteWeb/Routes/AuthRoutes.cs b/backend/FwLite/FwLiteWeb/Routes/AuthRoutes.cs index 55bc0711c4..a9617c55d4 100644 --- a/backend/FwLite/FwLiteWeb/Routes/AuthRoutes.cs +++ b/backend/FwLite/FwLiteWeb/Routes/AuthRoutes.cs @@ -12,7 +12,7 @@ public static class AuthRoutes public record ServerStatus(string DisplayName, bool LoggedIn, string? LoggedInAs, string? Authority); public static IEndpointConventionBuilder MapAuthRoutes(this WebApplication app) { - var group = app.MapGroup("/api/auth").WithOpenApi(); + var group = app.MapGroup("/api/auth"); group.MapGet("/servers", (AuthService authService) => authService.Servers()); group.MapGet("/login/{authority}", async (AuthService authService, string authority, IOptions options, [FromHeader] string referer) => diff --git a/backend/FwLite/FwLiteWeb/Routes/FwIntegrationRoutes.cs b/backend/FwLite/FwLiteWeb/Routes/FwIntegrationRoutes.cs index 6b3147c8cf..33d4f830b3 100644 --- a/backend/FwLite/FwLiteWeb/Routes/FwIntegrationRoutes.cs +++ b/backend/FwLite/FwLiteWeb/Routes/FwIntegrationRoutes.cs @@ -11,14 +11,14 @@ public static class FwIntegrationRoutes { public static IEndpointConventionBuilder MapFwIntegrationRoutes(this WebApplication app) { - var group = app.MapGroup($"/api/fw/{{{FwDataMiniLcmHub.ProjectRouteKey}}}").WithOpenApi( - operation => + var group = app.MapGroup($"/api/fw/{{{FwDataMiniLcmHub.ProjectRouteKey}}}").AddOpenApiOperationTransformer( + (operation, _, _) => { operation.Parameters?.Add(new OpenApiParameter() { Name = FwDataMiniLcmHub.ProjectRouteKey, In = ParameterLocation.Path, Required = true }); - return operation; + return Task.CompletedTask; }); group.MapGet("/link/entry/{id}", async ([FromServices] FwDataProjectContext context, diff --git a/backend/FwLite/FwLiteWeb/Routes/HistoryRoutes.cs b/backend/FwLite/FwLiteWeb/Routes/HistoryRoutes.cs index 5762ebf4a4..77660e2452 100644 --- a/backend/FwLite/FwLiteWeb/Routes/HistoryRoutes.cs +++ b/backend/FwLite/FwLiteWeb/Routes/HistoryRoutes.cs @@ -16,13 +16,13 @@ public static class HistoryRoutes { public static IEndpointConventionBuilder MapHistoryRoutes(this WebApplication app) { - var group = app.MapGroup("/api/history/{project}").WithOpenApi(operation => + var group = app.MapGroup("/api/history/{project}").AddOpenApiOperationTransformer((operation, _, _) => { operation.Parameters?.Add(new OpenApiParameter() { Name = CrdtMiniLcmApiHub.ProjectRouteKey, In = ParameterLocation.Path, Required = true }); - return operation; + return Task.CompletedTask; }); group.MapGet("/snapshot/{snapshotId:guid}", async (Guid snapshotId, HistoryService historyService) => await historyService.GetSnapshot(snapshotId)); diff --git a/backend/FwLite/FwLiteWeb/Routes/MiniLcmRoutes.cs b/backend/FwLite/FwLiteWeb/Routes/MiniLcmRoutes.cs index 9f6c161656..988fde5049 100644 --- a/backend/FwLite/FwLiteWeb/Routes/MiniLcmRoutes.cs +++ b/backend/FwLite/FwLiteWeb/Routes/MiniLcmRoutes.cs @@ -31,7 +31,7 @@ internal IMiniLcmApi MiniLcmApi public static IEndpointConventionBuilder MapMiniLcmRoutes(this IEndpointRouteBuilder app, [StringSyntax("route")] string prefix) { var api = app.MapGroup(prefix + "/{projectType}/{projectCode}") - .WithOpenApi(operation => + .AddOpenApiOperationTransformer((operation, _, _) => { operation.Parameters?.Add(new OpenApiParameter() { @@ -54,7 +54,7 @@ public static IEndpointConventionBuilder MapMiniLcmRoutes(this IEndpointRouteBui In = ParameterLocation.Path, Required = true }); - return operation; + return Task.CompletedTask; }) .AddEndpointFilter(async (context, next) => { diff --git a/backend/FwLite/FwLiteWeb/Routes/ProjectRoutes.cs b/backend/FwLite/FwLiteWeb/Routes/ProjectRoutes.cs index 0795351782..77c3ce0ff0 100644 --- a/backend/FwLite/FwLiteWeb/Routes/ProjectRoutes.cs +++ b/backend/FwLite/FwLiteWeb/Routes/ProjectRoutes.cs @@ -18,7 +18,7 @@ public static class ProjectRoutes { public static IEndpointConventionBuilder MapProjectRoutes(this WebApplication app) { - var group = app.MapGroup("/api").WithOpenApi(); + var group = app.MapGroup("/api"); group.MapGet("/remoteProjects", async (CombinedProjectsService combinedProjectsService) => { diff --git a/backend/FwLite/FwLiteWeb/Routes/TestRoutes.cs b/backend/FwLite/FwLiteWeb/Routes/TestRoutes.cs index adba101135..6a8d186fc3 100644 --- a/backend/FwLite/FwLiteWeb/Routes/TestRoutes.cs +++ b/backend/FwLite/FwLiteWeb/Routes/TestRoutes.cs @@ -13,7 +13,7 @@ public static class TestRoutes { public static IEndpointConventionBuilder MapTest(this WebApplication app) { - var group = app.MapGroup("/api/test/{project}").WithOpenApi(operation => + var group = app.MapGroup("/api/test/{project}").AddOpenApiOperationTransformer((operation, _, _) => { operation.Parameters?.Add(new OpenApiParameter() { @@ -21,7 +21,7 @@ public static IEndpointConventionBuilder MapTest(this WebApplication app) In = ParameterLocation.Path, Required = true }); - return operation; + return Task.CompletedTask; }); group.MapGet("/entries", (IMiniLcmApi api) => diff --git a/backend/FwLite/LcmCrdt/HistoryService.cs b/backend/FwLite/LcmCrdt/HistoryService.cs index 3144aa11d1..a7fc86a344 100644 --- a/backend/FwLite/LcmCrdt/HistoryService.cs +++ b/backend/FwLite/LcmCrdt/HistoryService.cs @@ -155,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/MiniLcm.Tests/UpdateEntryTestsBase.cs b/backend/FwLite/MiniLcm.Tests/UpdateEntryTestsBase.cs index de2f12728f..830bea2023 100644 --- a/backend/FwLite/MiniLcm.Tests/UpdateEntryTestsBase.cs +++ b/backend/FwLite/MiniLcm.Tests/UpdateEntryTestsBase.cs @@ -270,7 +270,7 @@ public async Task UpdateEntry_CanReorderComponents(string before, string after, var entryId = Guid.NewGuid(); var componentHeadwordsToIds = before.Split(',').Concat(after.Split(',')).Distinct() .ToDictionary(i => i, _ => Guid.NewGuid()); - var componentHeadwordsToEntryIds = componentHeadwordsToIds.Keys.ToAsyncEnumerable().SelectAwait(async @char => + var componentHeadwordsToEntryIds = componentHeadwordsToIds.Keys.ToAsyncEnumerable().Select(async (string @char, CancellationToken _) => { var componentEntry = await Api.CreateEntry(new() { diff --git a/backend/FwLite/MiniLcm/MiniLcm.csproj b/backend/FwLite/MiniLcm/MiniLcm.csproj index d8b55b7497..216e3df2b4 100644 --- a/backend/FwLite/MiniLcm/MiniLcm.csproj +++ b/backend/FwLite/MiniLcm/MiniLcm.csproj @@ -9,7 +9,6 @@ - diff --git a/backend/LexBoxApi/LexBoxApi.csproj b/backend/LexBoxApi/LexBoxApi.csproj index e30e232194..f290067aa0 100644 --- a/backend/LexBoxApi/LexBoxApi.csproj +++ b/backend/LexBoxApi/LexBoxApi.csproj @@ -1,7 +1,6 @@ Linux - true 7392cddf-9b3b-441c-9316-203bb5c4a6bc 1 @@ -50,7 +49,6 @@ - diff --git a/backend/LexBoxApi/Program.cs b/backend/LexBoxApi/Program.cs index 23e9202e91..dafe074a2f 100644 --- a/backend/LexBoxApi/Program.cs +++ b/backend/LexBoxApi/Program.cs @@ -23,7 +23,6 @@ using Microsoft.Extensions.Options; using Microsoft.OpenApi; using tusdotnet; -using IPNetwork = Microsoft.AspNetCore.HttpOverrides.IPNetwork; if (MigrationKernel.IsMigrationRequest(args)) { @@ -140,7 +139,7 @@ foreach (var knownNetwork in configuration.GetSection("ForwardedHeadersOptions:KnownNetworks").GetChildren()) { - options.KnownNetworks.Add(IPNetwork.Parse(knownNetwork.Value!)); + options.KnownIPNetworks.Add(IPNetwork.Parse(knownNetwork.Value!)); } }); @@ -186,7 +185,7 @@ app.MapQuartzUI("/api/quartz").RequireAuthorization(new AdminRequiredAttribute()); app.MapControllers(); -app.MapLfClassicApi().WithOpenApi().WithGroupName(LexBoxKernel.OpenApiPublicDocumentName) +app.MapLfClassicApi().WithGroupName(LexBoxKernel.OpenApiPublicDocumentName) .RequireAuthorization(policyBuilder => policyBuilder.RequireAuthenticatedUser().AddRequirements(new UserHasAccessToProjectRequirement())); app.MapTus("/api/tus-test", async context => await context.RequestServices.GetRequiredService().GetTestConfig(context)) diff --git a/backend/LexBoxApi/Proxies/FileUploadProxy.cs b/backend/LexBoxApi/Proxies/FileUploadProxy.cs index fbfd691dbf..bc5839961f 100644 --- a/backend/LexBoxApi/Proxies/FileUploadProxy.cs +++ b/backend/LexBoxApi/Proxies/FileUploadProxy.cs @@ -36,7 +36,7 @@ public static IEndpointConventionBuilder MapFileUploadProxy(this IEndpointRouteB Policy = UserCanDownloadMediaFilesPolicy }; - var group = app.MapGroup("/api/media").WithOpenApi(); + var group = app.MapGroup("/api/media"); //media upload/download group.Map("/list/{projectId:guid}/{**catch-all}", diff --git a/backend/LexData/SeedingData.cs b/backend/LexData/SeedingData.cs index 5070e39cab..9661544dbc 100644 --- a/backend/LexData/SeedingData.cs +++ b/backend/LexData/SeedingData.cs @@ -252,7 +252,7 @@ private async Task SeedUserData(CancellationToken cancellationToken = default) public async Task SeedOAuth(CancellationToken token = default) { var dbApps = await applicationManager.ListAsync(cancellationToken: token) - .ToDictionaryAwaitAsync(async app => await applicationManager.GetClientIdAsync(app, token) ?? throw new InvalidOperationException("ClientId is null"), token); + .ToDictionaryAsync(async (object app, CancellationToken ct) => await applicationManager.GetClientIdAsync(app, ct) ?? throw new InvalidOperationException("ClientId is null"), cancellationToken: token); var seedApps = OAuthApps; foreach (var clientId in dbApps.Keys.Union(seedApps.Keys)) { diff --git a/backend/LfClassicData/LfClassicData.csproj b/backend/LfClassicData/LfClassicData.csproj index a14554c78a..0562ff82ce 100644 --- a/backend/LfClassicData/LfClassicData.csproj +++ b/backend/LfClassicData/LfClassicData.csproj @@ -1,8 +1,6 @@ - - diff --git a/backend/SyncReverseProxy/SyncReverseProxy.csproj b/backend/SyncReverseProxy/SyncReverseProxy.csproj index dfdd8e0f6e..c89499ff50 100644 --- a/backend/SyncReverseProxy/SyncReverseProxy.csproj +++ b/backend/SyncReverseProxy/SyncReverseProxy.csproj @@ -5,7 +5,6 @@ - From a7347d8671278adfa962243bc654d0f674216c9f Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Fri, 15 May 2026 11:18:15 +0200 Subject: [PATCH 23/36] Work around Linq2Db v6 regressions in CRDT and Merge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Linq2Db v6 changed two behaviors that broke us: 1) ExpressionMethodAttribute on a collection property is now applied during entity materialization for LoadWith(...) eager loads, not only at filter/query translation. That blew up every entry load with "only supported server side" from Json.QueryInternal because the [Sql.TableFunction("json_each")] rewrite needs a FROM-clause context. Dropping the FluentMapping registrations on Sense.SemanticDomains and Entry.PublishIn fixes loads. The Gridify filter map provider now wraps content-walking projections in Json.Query(...) explicitly. 2) Linq2Db v6 + linq2db.EntityFrameworkCore 10.3 now wraps the EF value-converter (JsonSerializer.Serialize) around any expression in a Merge.InsertWhenNotMatched(...) projection lambda, including the raw Sql.Expr<...> we used to cast to jsonb. Switching to the parameterless InsertWhenNotMatched() avoids the projection entirely and does a plain column-to-column copy from the temp table (which is already jsonb thanks to the EF column type). ProjectId is stamped on each commit during the bulk-copy stream. Also: EntryQueryHelpers.SearchHeadwords uses Sql.Expr directly for the cross-scope path access — v6 emits `[kv].*` instead of `[kv].[key]` when Json.Value's path builder tries to convert `kv.Key` from a captured outer-scope json_each row. --- .../FwLite/LcmCrdt/Data/EntryQueryHelpers.cs | 4 +++- .../FwLite/LcmCrdt/EntryFilterMapProvider.cs | 9 ++++----- backend/FwLite/LcmCrdt/LcmCrdtKernel.cs | 15 -------------- .../LexBoxApi/Services/CrdtCommitService.cs | 20 ++++++------------- 4 files changed, 13 insertions(+), 35 deletions(-) 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/EntryFilterMapProvider.cs b/backend/FwLite/LcmCrdt/EntryFilterMapProvider.cs index 80b94417da..7b202d079d 100644 --- a/backend/FwLite/LcmCrdt/EntryFilterMapProvider.cs +++ b/backend/FwLite/LcmCrdt/EntryFilterMapProvider.cs @@ -5,13 +5,12 @@ namespace LcmCrdt; public class EntryFilterMapProvider : EntryFilterMapProvider { + //bare property here so Gridify's null-check filter compiles to a column-level IS NULL; + //the SelectMany-based code filter needs Json.Query to invoke json_each. 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)); + e => e.Senses.SelectMany(s => Json.Query(s.SemanticDomains)).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; public override Expression> EntrySensesExampleSentences => e => e.Senses.Select(s => s.ExampleSentences); public override Expression> EntrySensesExampleSentencesSentence => @@ -32,6 +31,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())); + e => Json.Query(e.PublishIn).Select(p => Json.Value(p, p => p.Id.ToString())); public override Func? EntryPublishInConverter => EntryFilter.NormalizeEmptyToNull; } diff --git a/backend/FwLite/LcmCrdt/LcmCrdtKernel.cs b/backend/FwLite/LcmCrdt/LcmCrdtKernel.cs index f84f1d5297..c880dc5316 100644 --- a/backend/FwLite/LcmCrdt/LcmCrdtKernel.cs +++ b/backend/FwLite/LcmCrdt/LcmCrdtKernel.cs @@ -130,9 +130,6 @@ public static void ConfigureDbOptions(IServiceProvider provider, DbContextOption nameof(Commit.HybridDateTime) + "." + nameof(HybridDateTime.DateTime))) .HasAttribute(new ColumnAttribute(nameof(HybridDateTime.Counter), nameof(Commit.HybridDateTime) + "." + nameof(HybridDateTime.Counter))) - //tells linq2db to rewrite Sense.SemanticDomains, into Json.Query(Sense.SemanticDomains) - .Entity().Property(s => s.SemanticDomains).HasAttribute(new ExpressionMethodAttribute(SenseSemanticDomainsExpression())) - .Entity().Property(e => e.PublishIn).HasAttribute(new ExpressionMethodAttribute(EntryPublishInExpression())) .Entity().Member(r => r.GetPlainText()).IsExpression(r => Json.GetPlainText(r)) .Entity().Member(g => g.ToString()).IsExpression(g => Json.ToString(g)) .Build(); @@ -161,18 +158,6 @@ public static void ConfigureDbOptions(IServiceProvider provider, DbContextOption builder.AddInterceptors(updateSearchTableInterceptor); } - private static Expression>> SenseSemanticDomainsExpression() - { - //using Sql.Property, otherwise if we used `s.SemanticDomains` again it would be recursively rewritten - return s => Json.Query(Sql.Property>(s, nameof(Sense.SemanticDomains))); - } - - private static Expression>> EntryPublishInExpression() - { - //using Sql.Property, otherwise if we used `e.PublishIn` again it would be recursively rewritten - return e => Json.Query(Sql.Property>(e, nameof(Entry.PublishIn))); - } - public static void ConfigureCrdt(CrdtConfig config) { diff --git a/backend/LexBoxApi/Services/CrdtCommitService.cs b/backend/LexBoxApi/Services/CrdtCommitService.cs index 2beedaf995..d4ec51f8c9 100644 --- a/backend/LexBoxApi/Services/CrdtCommitService.cs +++ b/backend/LexBoxApi/Services/CrdtCommitService.cs @@ -16,26 +16,18 @@ public async Task AddCommits(Guid projectId, IAsyncEnumerable comm await using var transaction = await dbContext.Database.BeginTransactionAsync(token); var linqToDbContext = dbContext.CreateLinqToDBContext(); await using var tmpTable = await linqToDbContext.CreateTempTableAsync($"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(); 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>>($"{commit.ChangeEntities}::jsonb") - }) + .InsertWhenNotMatched() .MergeAsync(token); await transaction.CommitAsync(token); From 65aa70a9b33ea3f65c9cd099f165948bcece4428 Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Fri, 15 May 2026 11:23:45 +0200 Subject: [PATCH 24/36] Fix small CRDT test failures left over from .NET 10 bumps - RichMultiString.IDictionary.Add now deserializes JsonElement (which SystemTextJsonPatch v5 hands us via PocoAdapter) instead of throwing - Update VerifyDbModel snapshot for EF Core 10's removed DiscriminatorProperty lines and dropped Optional/Required annotations on nav properties --- ...elSnapshotTests.VerifyDbModel.verified.txt | 24 +++---------------- .../FwLite/MiniLcm/Models/RichMultiString.cs | 11 ++++++--- 2 files changed, 11 insertions(+), 24 deletions(-) diff --git a/backend/FwLite/LcmCrdt.Tests/DataModelSnapshotTests.VerifyDbModel.verified.txt b/backend/FwLite/LcmCrdt.Tests/DataModelSnapshotTests.VerifyDbModel.verified.txt index 87c97de674..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) 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 {'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.6 \ No newline at end of file + ProductVersion: 10.0.7 \ No newline at end of file diff --git a/backend/FwLite/MiniLcm/Models/RichMultiString.cs b/backend/FwLite/MiniLcm/Models/RichMultiString.cs index eeac03a25b..9cd98cf29f 100644 --- a/backend/FwLite/MiniLcm/Models/RichMultiString.cs +++ b/backend/FwLite/MiniLcm/Models/RichMultiString.cs @@ -38,9 +38,14 @@ 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)); + //SystemTextJsonPatch v5 hands us the raw JsonElement; deserialize so PocoAdapter callers work. + var valStr = value switch + { + RichString rs => rs, + JsonElement je => je.Deserialize() + ?? throw new ArgumentException("unable to deserialize JsonElement to RichString", nameof(value)), + _ => throw new ArgumentException($"unable to convert value {value?.GetType().Name ?? "null"} to RichString", nameof(value)) + }; Add(WritingSystemId.FromUnknown(key), valStr); } From 8726123caac345730b6ccd25d23e7b7793ba2238 Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Fri, 15 May 2026 11:29:49 +0200 Subject: [PATCH 25/36] Wrap empty CommitMetadata in Sql.Expr in old-commit query test Linq2Db v6 can't translate `new CommitMetadata()` inside an InsertAsync projection lambda (the EF JSON value converter requires a runtime serialization that Linq2Db can't synthesize). Use the same Sql.Expr trick we already use for ChangeEntities to give it a raw `'{}'::jsonb`. --- backend/Testing/LexCore/Services/CrdtCommitServiceTests.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/backend/Testing/LexCore/Services/CrdtCommitServiceTests.cs b/backend/Testing/LexCore/Services/CrdtCommitServiceTests.cs index adcdad5bd6..c739c80632 100644 --- a/backend/Testing/LexCore/Services/CrdtCommitServiceTests.cs +++ b/backend/Testing/LexCore/Services/CrdtCommitServiceTests.cs @@ -99,7 +99,9 @@ public async Task CanQueryOldCommits() Counter = 0 }, ProjectId = projectId, - Metadata = new CommitMetadata(), + //Linq2Db v6 + EFCore 10 can't translate `new CommitMetadata()` (empty record + value converter) + //inside an InsertAsync projection lambda — wrap it as raw SQL like ChangeEntities below. + Metadata = LinqToDB.Sql.Expr("'{}'::jsonb"), ChangeEntities = LinqToDB.Sql.Expr>>(inlineSql) }); var commits = await _lexBoxDbContext.CrdtCommits(projectId).ToArrayAsync(); From 558ef6f3b5545ace695ea620078badc41c5388f1 Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Fri, 15 May 2026 11:36:07 +0200 Subject: [PATCH 26/36] Use raw SQL ExecuteAsync for old-format commit insert in test Linq2Db v6 unconditionally wraps any column assignment in an InsertAsync projection lambda (even raw Sql.Expr) with the EF JSON value converter for the affected columns, so the Sql.Expr workaround used by the previous commit still fails. This test specifically inserts pre-serialized JSON to simulate the old commit format, so route around Linq2Db entirely with a parameterized raw INSERT via DataContextExtensions.ExecuteAsync. --- .../Services/CrdtCommitServiceTests.cs | 35 +++++++++---------- 1 file changed, 17 insertions(+), 18 deletions(-) diff --git a/backend/Testing/LexCore/Services/CrdtCommitServiceTests.cs b/backend/Testing/LexCore/Services/CrdtCommitServiceTests.cs index c739c80632..f82d0dbbff 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(context); var commitId = Guid.NewGuid(); var changeEntity = new ChangeEntity { @@ -87,23 +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, - //Linq2Db v6 + EFCore 10 can't translate `new CommitMetadata()` (empty record + value converter) - //inside an InsertAsync projection lambda — wrap it as raw SQL like ChangeEntities below. - Metadata = LinqToDB.Sql.Expr("'{}'::jsonb"), - ChangeEntities = LinqToDB.Sql.Expr>>(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", "DateTime", "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 810d6cdfb6ad0d453e35eefc98d42b002306e288 Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Fri, 15 May 2026 11:45:26 +0200 Subject: [PATCH 27/36] Use correct EF column names for HybridDateTime in raw SQL insert The actual PG columns are HybridDateTime_DateTime and HybridDateTime_Counter (from EF ComplexProperty mapping); Linq2Db's fluent column-alias mapping flattens these for typed queries but the raw SQL has to use the physical names. --- backend/Testing/LexCore/Services/CrdtCommitServiceTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/Testing/LexCore/Services/CrdtCommitServiceTests.cs b/backend/Testing/LexCore/Services/CrdtCommitServiceTests.cs index f82d0dbbff..cfd853c6c9 100644 --- a/backend/Testing/LexCore/Services/CrdtCommitServiceTests.cs +++ b/backend/Testing/LexCore/Services/CrdtCommitServiceTests.cs @@ -95,7 +95,7 @@ await LinqToDB.Data.DataContextExtensions.ExecuteAsync( context, """ INSERT INTO "CrdtCommits" - ("Id", "ClientId", "DateTime", "Counter", "ProjectId", "Metadata", "ChangeEntities") + ("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), From 2ac278fa5692d6b7019d3d7af37f43d5d8a8d7c6 Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Fri, 15 May 2026 12:39:15 +0200 Subject: [PATCH 28/36] Restore ExpressionMethodAttribute on jsonb collection props, .ToList()'d MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removing the registration entirely broke Gridify-based filters that walk into the collection content (CanFilterSemanticDomainCodeContains, CanFilterByPublicationId, etc.) because Gridify only knows how to walk bare-property projections. Bring the registration back so the filter projections can stay bare (e.Senses.SelectMany(s => s.SemanticDomains)...). Two changes to make v6 happy: 1) Json.QueryInternal no longer throws — v6's eager-load preamble may inline the rewrite client-side after materialization, so the bodies return a real iterator over the deserialized list / multistring. 2) The rewrite expression returns IList (via .ToList()) so v6's materializer can assign the result back into the IList property without an InvalidCastException. Trade-off: filter-only tests that compare the collection projection to null (Senses.SemanticDomains=, PublishIn=, etc.) still fail because linq2db v6 can't translate `(IList)json_each(...).ToList() == null` to SQL. Same handful of tests that were failing before this commit; the broader BasicApi/CRDT load path is fixed in exchange. --- .../FwLite/LcmCrdt/EntryFilterMapProvider.cs | 9 +++++---- backend/FwLite/LcmCrdt/Json.cs | 10 ++++++++-- backend/FwLite/LcmCrdt/LcmCrdtKernel.cs | 19 +++++++++++++++++++ 3 files changed, 32 insertions(+), 6 deletions(-) diff --git a/backend/FwLite/LcmCrdt/EntryFilterMapProvider.cs b/backend/FwLite/LcmCrdt/EntryFilterMapProvider.cs index 7b202d079d..64ebfc1ce1 100644 --- a/backend/FwLite/LcmCrdt/EntryFilterMapProvider.cs +++ b/backend/FwLite/LcmCrdt/EntryFilterMapProvider.cs @@ -5,12 +5,13 @@ namespace LcmCrdt; public class EntryFilterMapProvider : EntryFilterMapProvider { - //bare property here so Gridify's null-check filter compiles to a column-level IS NULL; - //the SelectMany-based code filter needs Json.Query to invoke json_each. public override Expression> EntrySensesSemanticDomains => e => e.Senses.Select(s => s.SemanticDomains); public override Expression> EntrySensesSemanticDomainsCode => - e => e.Senses.SelectMany(s => Json.Query(s.SemanticDomains)).Select(sd => Json.Value(sd, sd => sd.Code)); + //linq2db rewrites `s.SemanticDomains` to a json_each query at query-translation time + //(see LcmCrdtKernel's ExpressionMethodAttribute registration). + e => e.Senses.SelectMany(s => s.SemanticDomains).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; public override Expression> EntrySensesExampleSentences => e => e.Senses.Select(s => s.ExampleSentences); public override Expression> EntrySensesExampleSentencesSentence => @@ -31,6 +32,6 @@ public class EntryFilterMapProvider : EntryFilterMapProvider public override Func? EntryComplexFormTypesConverter => EntryFilter.NormalizeEmptyToEmptyList; public override Expression> EntryPublishIn => e => e.PublishIn; public override Expression> EntryPublishInId => - e => Json.Query(e.PublishIn).Select(p => Json.Value(p, p => p.Id.ToString())); + e => e.PublishIn.Select(p => Json.Value(p, p => p.Id.ToString())); public override Func? EntryPublishInConverter => EntryFilter.NormalizeEmptyToNull; } diff --git a/backend/FwLite/LcmCrdt/Json.cs b/backend/FwLite/LcmCrdt/Json.cs index 6bac5dfb18..5b54c044a4 100644 --- a/backend/FwLite/LcmCrdt/Json.cs +++ b/backend/FwLite/LcmCrdt/Json.cs @@ -197,16 +197,22 @@ private static Expression, Expression>, IQu //these 2 methods tell linq2db to treat the given property as a table where each row looks like a JsonEach //however we don't really care about any other columns and probably want to just use the value, that's what the QueryExpression does above + //Linq2Db v6's eager-load preamble may apply the [ExpressionMethod]-driven rewrite client-side after + //materialization, so the bodies have to actually work — not throw — when invoked over a real list. [Sql.TableFunction("json_each", argIndices: [0])] private static IQueryable> QueryInternal(this IEnumerable value) { - throw new NotImplementedException("only supported server side"); + return value is null + ? Enumerable.Empty>().AsQueryable() + : value.Select((v, i) => new JsonEach(v, i.ToString(), "", i, "", "")).AsQueryable(); } [Sql.TableFunction("json_each", argIndices: [0])] private static IQueryable> QueryInternal(this MultiString value) { - throw new NotImplementedException("only supported server side"); + return value is null + ? Enumerable.Empty>().AsQueryable() + : value.Values.Select((kv, i) => new JsonEach(kv.Value, kv.Key.Code, "", i, "", "")).AsQueryable(); } [Sql.Expression("(select group_concat(s.value->>'Text', '') from json_each({0}->>'Spans') as s)", PreferServerSide = true)] diff --git a/backend/FwLite/LcmCrdt/LcmCrdtKernel.cs b/backend/FwLite/LcmCrdt/LcmCrdtKernel.cs index c880dc5316..90d49b6bd9 100644 --- a/backend/FwLite/LcmCrdt/LcmCrdtKernel.cs +++ b/backend/FwLite/LcmCrdt/LcmCrdtKernel.cs @@ -130,6 +130,13 @@ public static void ConfigureDbOptions(IServiceProvider provider, DbContextOption nameof(Commit.HybridDateTime) + "." + nameof(HybridDateTime.DateTime))) .HasAttribute(new ColumnAttribute(nameof(HybridDateTime.Counter), nameof(Commit.HybridDateTime) + "." + nameof(HybridDateTime.Counter))) + //Tells linq2db to rewrite Sense.SemanticDomains / Entry.PublishIn as Json.Query(...) + //in queries. Gridify can then parse the projection lambdas in EntryFilterMapProvider + //using bare property access, and linq2db expands to json_each() at translation time. + //Json.QueryInternal has a working client-side body (not throw) so v6's eager-load + //preamble — which may invoke the rewrite client-side — doesn't blow up. + .Entity().Property(s => s.SemanticDomains).HasAttribute(new ExpressionMethodAttribute(SenseSemanticDomainsExpression())) + .Entity().Property(e => e.PublishIn).HasAttribute(new ExpressionMethodAttribute(EntryPublishInExpression())) .Entity().Member(r => r.GetPlainText()).IsExpression(r => Json.GetPlainText(r)) .Entity().Member(g => g.ToString()).IsExpression(g => Json.ToString(g)) .Build(); @@ -158,6 +165,18 @@ public static void ConfigureDbOptions(IServiceProvider provider, DbContextOption builder.AddInterceptors(updateSearchTableInterceptor); } + private static Expression>> SenseSemanticDomainsExpression() + { + //using Sql.Property, otherwise if we used `s.SemanticDomains` again it would be recursively rewritten. + //ToList() lets v6's eager-load preamble assign the result back into the IList property — + //the property type and expression return type have to match or v6 fails the cast at materialization. + return s => Json.Query(Sql.Property>(s, nameof(Sense.SemanticDomains))).ToList(); + } + + private static Expression>> EntryPublishInExpression() + { + return e => Json.Query(Sql.Property>(e, nameof(Entry.PublishIn))).ToList(); + } public static void ConfigureCrdt(CrdtConfig config) { From c346afbee89aea60d412f8885c05dcb6b3f1bab8 Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Fri, 15 May 2026 13:23:21 +0200 Subject: [PATCH 29/36] Bump FW Lite test job timeout 40 -> 60 minutes The .NET 10 / Linq2Db v6 fixes turned many fast-failing tests into slow-passing tests (they now actually run to completion). On Windows CI the LcmCrdt + FwLiteProjectSync test step needs more than 40 min. --- .github/workflows/fw-lite.yaml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/fw-lite.yaml b/.github/workflows/fw-lite.yaml index 1d6c2bbdef..ee0fe58067 100644 --- a/.github/workflows/fw-lite.yaml +++ b/.github/workflows/fw-lite.yaml @@ -27,7 +27,10 @@ env: jobs: build-and-test: name: Build FW Lite and run tests - timeout-minutes: 40 + #bumped from 40 -> 60: with the .NET 10 / Linq2Db v6 upgrade many tests that + #used to fast-fail now run to completion (passing), so total test step time + #went up. Hits Windows-CI variance; 60 leaves some headroom. + timeout-minutes: 60 runs-on: windows-latest outputs: version: ${{ steps.setVersion.outputs.VERSION }} From 809039d3e726c43574a8ee785cdfb5bd1e0cebfa Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Fri, 15 May 2026 14:09:16 +0200 Subject: [PATCH 30/36] Document Linq2Db v6 regression workarounds in LcmCrdt Adds LINQ2DB-V6-NOTES.md next to the affected code with: the two v6 behavior changes that triggered the issues, the matrix of workarounds we tried, the trade-off the current code picks, links to the migration wiki / related issues / in-flight PR, and a clear revert checklist for when upstream lands a fix. Drops TODO pointers to that doc in the two source locations holding the workaround (Json.QueryInternal bodies and the SemanticDomains / PublishIn ExpressionMethodAttribute expressions in LcmCrdtKernel). --- backend/FwLite/LcmCrdt/Json.cs | 4 +- backend/FwLite/LcmCrdt/LINQ2DB-V6-NOTES.md | 172 +++++++++++++++++++++ backend/FwLite/LcmCrdt/LcmCrdtKernel.cs | 8 +- 3 files changed, 180 insertions(+), 4 deletions(-) create mode 100644 backend/FwLite/LcmCrdt/LINQ2DB-V6-NOTES.md diff --git a/backend/FwLite/LcmCrdt/Json.cs b/backend/FwLite/LcmCrdt/Json.cs index 5b54c044a4..c40b4e5ffa 100644 --- a/backend/FwLite/LcmCrdt/Json.cs +++ b/backend/FwLite/LcmCrdt/Json.cs @@ -197,8 +197,10 @@ private static Expression, Expression>, IQu //these 2 methods tell linq2db to treat the given property as a table where each row looks like a JsonEach //however we don't really care about any other columns and probably want to just use the value, that's what the QueryExpression does above - //Linq2Db v6's eager-load preamble may apply the [ExpressionMethod]-driven rewrite client-side after + //Linq2Db v6's eager-load preamble applies the [ExpressionMethod]-driven rewrite client-side after //materialization, so the bodies have to actually work — not throw — when invoked over a real list. + //TODO when upstream fixes the v6 regression: restore `throw new NotImplementedException(...)` in both + //bodies. See LINQ2DB-V6-NOTES.md (sibling of this file). [Sql.TableFunction("json_each", argIndices: [0])] private static IQueryable> QueryInternal(this IEnumerable value) { diff --git a/backend/FwLite/LcmCrdt/LINQ2DB-V6-NOTES.md b/backend/FwLite/LcmCrdt/LINQ2DB-V6-NOTES.md new file mode 100644 index 0000000000..40133c7be5 --- /dev/null +++ b/backend/FwLite/LcmCrdt/LINQ2DB-V6-NOTES.md @@ -0,0 +1,172 @@ +# Linq2Db v6 regressions and workarounds in LcmCrdt + +Captured during the .NET 10 + Linq2Db 5.4 → 6.2.1 upgrade +(branch `chore/update-nuget-pkgs-dotnet-10`, PR +[#2264](https://github.com/sillsdev/languageforge-lexbox/pull/2264)). + +This file documents the two issues we worked around, the trade-off the +current code makes, and the actions to take when upstream lands a fix. + +--- + +## TL;DR + +| Symptom | Status | Action when fixed upstream | +|---|---|---| +| 9 specific Gridify filter tests fail with `LinqToDBException : The LINQ expression could not be converted to SQL` | Worked around; tests remain red | Drop the `.ToList()` calls in `LcmCrdtKernel.cs:SenseSemanticDomainsExpression` / `EntryPublishInExpression`; revert return type to `IQueryable`; restore `throw new NotImplementedException(...)` in both `Json.QueryInternal` bodies | +| `QueryPerformanceTesting` assertion `< 0.5 µs/entry` now sees ~0.7–0.8 µs/entry | Test margin too tight; not a code bug | After the workaround revert above, recheck. If still tight on the GHA Windows runner, raise the assertion margin (see history of `Tim H:` comments in `QueryEntryTests.cs:79`) | + +--- + +## What changed in Linq2Db v6 that caused this + +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 that an `ExpressionMethodAttribute` registered on a property +now fires in two distinct 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 for our case. + +## Why the two regressions appear + +### Regression 1 — `LoadWith` materialization + +`Sense.SemanticDomains : IList` has the rewrite +registered. v6's preamble materializer evaluates the substitution +client-side and tries to assign the result back to the property. If +the substitution returns `IQueryable` (which is the +natural type — `json_each` is a table), v6 throws +`InvalidCastException : Unable to cast EnumerableQuery to IList` +inside `LinqToDB.Internal.Linq.QueryRunner.Mapper.ReMapOnException`. + +Originally this broke every code path that loaded a Sense — ~280 of 461 +`LcmCrdt.Tests` failures came from this one issue. + +### Regression 2 — translator can't see through `.ToList()` + +The fix for regression 1 is to make the substitution return `IList`, +which means tacking `.ToList()` on the end. That works for +materialization (`List : IList`). But then the *translator* sees +chains like + +```csharp +e.Senses.Any(s => s.SemanticDomains.Any(sd => sd.Code.Contains("Fruit"))) +``` + +expand to + +```csharp +e.Senses.Any(s => json_each(s.SemanticDomains).Select(v => v.Value).ToList().Any(sd => sd.Code.Contains("Fruit"))) +``` + +and gives up with `LinqToDBException : The LINQ expression could not be +converted to SQL`. The `.ToList()` between the table function and the +`Any(...)` defeats the table-form recognition. + +The current code chooses to keep regression 1 silenced (so 99% of the +suite passes) and accept that the nine filter tests below stay red. + +## The 9 still-failing filter tests + +All in `LcmCrdt.Tests.MiniLcmTests.QueryEntryTests`: + +- `CanFilterByPublicationId` +- `CanFilterByPublicationId_AndSearch` +- `CanFilterSemanticDomainCodeContains` +- `CanFilterToMissingSemanticDomains` +- `CanFilterToMissingSemanticDomainsWithEmptyArray` +- `CanFilterToMissingSemanticDomains_AndSearch` +- `CanFilterToMissingPublishIn` +- `CanFilterToMissingPublishInWithEmptyArray` +- `CanFilterToMissingPublishIn_AndSearch` + +They throw at SQL-translation time. **They do not silently fall back to +client-side evaluation** — the query never runs at all. So this is not +a correctness or perf cliff in production traffic; it's a capability +loss for these specific filter shapes until upstream fixes it. + +## The performance regression (`QueryPerformanceTesting`) + +Two tests assert `< 0.5 µs/entry` after a warmup phase +(`QueryEntryTests.cs:79`). On this branch the per-entry cost is +~0.7–0.8 µs. + +Cause: v6's client-side rewrite runs `Json.QueryInternal(list).Select(v +=> v.Value).ToList()` for every loaded `Sense` row, even when nothing +in the query consumes `SemanticDomains` as a table. For a 50K–100K +entry test this is an extra allocation + iteration per row. + +**What would make this faster again:** the same upstream fix that +closes Regression 1. If the materializer stops invoking our +substitution after deserialization (i.e. honors `IsColumn=false` again), +the per-row overhead disappears and we revert to v5-level performance. +Until then the test margin is the better lever — the in-source history +in `QueryEntryTests.cs` already shows Tim cycling through 1.0 → 1.3 → +0.5 as the GHA runners changed. Bumping back to ~1.0 µs/entry would +match Kevin's original baseline plus headroom. + +## What we tried + +| Attempt | Eager load (`LoadWith`) | Gridify content filter | Gridify "missing"/null filter | +|---|---|---|---| +| A. Drop `ExpressionMethodAttribute`, write `Json.Query(...)` explicitly in `EntryFilterMapProvider` | ✓ works | ✗ Gridify can't parse `Json.Query(...)` — `InvalidOperationException` at `ParseMethodCallExpression` | ✗ same | +| B. Keep `ExpressionMethodAttribute`, expression returns `IQueryable` (no `.ToList()`) | ✗ `InvalidCastException` on every load (~280 fails) | ✓ would translate | n/a (load already broken) | +| **C. Keep `ExpressionMethodAttribute`, expression returns `IList` via `.ToList()`** *(current)* | ✓ | ✗ translator can't see through `.ToList()` | ✗ same | +| D. C + `IsColumn = false` explicitly | same as C | same as C | same as C — v6 ignores it | + +Untried, 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. + +## Links + +- Linq To DB 6 migration guide: + +- Related (similar v6 `ExpressionMethod` regressions, all fixed — none + match our pair of symptoms): + - + - + - + - +- Eager-load refactor in flight (targets 6.4.0) — might fix or change + this: + +- The file the stack trace points into: + + +## Affected source + +- `backend/FwLite/LcmCrdt/Json.cs` — `QueryInternal` bodies must return + a working iterator (not throw) because v6 invokes them client-side. +- `backend/FwLite/LcmCrdt/LcmCrdtKernel.cs` — the two + `Sense.SemanticDomains` / `Entry.PublishIn` `ExpressionMethodAttribute` + registrations and their substitution expressions + (`SenseSemanticDomainsExpression` / `EntryPublishInExpression`). +- `backend/FwLite/LcmCrdt.Tests/MiniLcmTests/QueryEntryTests.cs:79` — + perf-test assertion margin (history of past adjustments in comments + just above the line). diff --git a/backend/FwLite/LcmCrdt/LcmCrdtKernel.cs b/backend/FwLite/LcmCrdt/LcmCrdtKernel.cs index 90d49b6bd9..2be2970fe4 100644 --- a/backend/FwLite/LcmCrdt/LcmCrdtKernel.cs +++ b/backend/FwLite/LcmCrdt/LcmCrdtKernel.cs @@ -165,11 +165,13 @@ public static void ConfigureDbOptions(IServiceProvider provider, DbContextOption builder.AddInterceptors(updateSearchTableInterceptor); } + //TODO when upstream fixes the v6 regression: drop .ToList() from both expressions below and revert + //the return type to IQueryable<...>. See LINQ2DB-V6-NOTES.md for context — the ToList() is what + //blocks SQL translation of `.Any(...) == null` filter forms but keeps eager-load materialization + //working in v6. private static Expression>> SenseSemanticDomainsExpression() { - //using Sql.Property, otherwise if we used `s.SemanticDomains` again it would be recursively rewritten. - //ToList() lets v6's eager-load preamble assign the result back into the IList property — - //the property type and expression return type have to match or v6 fails the cast at materialization. + //using Sql.Property, otherwise if we used `s.SemanticDomains` again it would be recursively rewritten return s => Json.Query(Sql.Property>(s, nameof(Sense.SemanticDomains))).ToList(); } From 620e4dcd653ed9c35acabc4f8331f13b182baddb Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Mon, 18 May 2026 17:23:14 +0200 Subject: [PATCH 31/36] Use shadow properties for json_each rewrite in v6 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the .ToList()-on-property workaround with non-column shadow properties (Entry.PublishInRows / Sense.SemanticDomainRows) carrying the [ExpressionMethod] rewrite. v6's materializer doesn't walk unmapped properties, so the substitution no longer fires client-side at entity load — eager-load Regression 1 stays fixed without the .ToList() that defeated SQL translation of Any/null filters (Regression 2). The 9 filter tests that were red on the .ToList() branch now pass, and QueryPerformanceTesting is back under its v5-era 0.5 µs/entry threshold. See LINQ2DB-V6-NOTES.md for full root-cause + attempt history. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../FwLite/LcmCrdt/EntryFilterMapProvider.cs | 13 +- backend/FwLite/LcmCrdt/Json.cs | 22 +- backend/FwLite/LcmCrdt/LINQ2DB-V6-NOTES.md | 369 ++++++++++++------ backend/FwLite/LcmCrdt/LcmCrdtKernel.cs | 32 +- backend/FwLite/MiniLcm/Models/Entry.cs | 8 + backend/FwLite/MiniLcm/Models/Sense.cs | 5 + 6 files changed, 281 insertions(+), 168 deletions(-) diff --git a/backend/FwLite/LcmCrdt/EntryFilterMapProvider.cs b/backend/FwLite/LcmCrdt/EntryFilterMapProvider.cs index 64ebfc1ce1..a4e4905666 100644 --- a/backend/FwLite/LcmCrdt/EntryFilterMapProvider.cs +++ b/backend/FwLite/LcmCrdt/EntryFilterMapProvider.cs @@ -7,12 +7,11 @@ public class EntryFilterMapProvider : EntryFilterMapProvider { public override Expression> EntrySensesSemanticDomains => e => e.Senses.Select(s => s.SemanticDomains); public override Expression> EntrySensesSemanticDomainsCode => - //linq2db rewrites `s.SemanticDomains` to a json_each query at query-translation time - //(see LcmCrdtKernel's ExpressionMethodAttribute registration). - e => e.Senses.SelectMany(s => s.SemanticDomains).Select(sd => Json.Value(sd, sd => sd.Code)); + //SemanticDomainRows is the json_each rewrite target — see LcmCrdtKernel + LINQ2DB-V6-NOTES.md. + 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; + //Empty lists are stored as "[]", never SQL NULL — compare against [] like ComplexFormTypes does. + 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 +31,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/Json.cs b/backend/FwLite/LcmCrdt/Json.cs index c40b4e5ffa..bb7190eb8a 100644 --- a/backend/FwLite/LcmCrdt/Json.cs +++ b/backend/FwLite/LcmCrdt/Json.cs @@ -52,10 +52,7 @@ public void Build(Sql.ISqlExtensionBuilder builder) if (returnType != typeof(string) && returnType != typeof(RichString))//bypass rich string so it can be used with .GetPlainText() { - // MakeTryConvert was removed in Linq2Db 6, don't know why. - // valueExpression = PseudoFunctions.MakeTryConvert(new SqlDataType(new DbDataType(returnType)), - // new SqlDataType(new DbDataType(typeof(string), DataType.Text)), - // valueExpression); + //v6 dropped PseudoFunctions.MakeTryConvert; build the SqlFunction directly instead. valueExpression = new SqlFunction( new DbDataType(returnType), PseudoFunctions.TRY_CONVERT, @@ -95,6 +92,11 @@ 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, ""); peel it. + pathBody = mce.Arguments[0]; + } else { throw new InvalidOperationException($"Invalid property path for expression {mce}."); @@ -197,24 +199,16 @@ private static Expression, Expression>, IQu //these 2 methods tell linq2db to treat the given property as a table where each row looks like a JsonEach //however we don't really care about any other columns and probably want to just use the value, that's what the QueryExpression does above - //Linq2Db v6's eager-load preamble applies the [ExpressionMethod]-driven rewrite client-side after - //materialization, so the bodies have to actually work — not throw — when invoked over a real list. - //TODO when upstream fixes the v6 regression: restore `throw new NotImplementedException(...)` in both - //bodies. See LINQ2DB-V6-NOTES.md (sibling of this file). [Sql.TableFunction("json_each", argIndices: [0])] private static IQueryable> QueryInternal(this IEnumerable value) { - return value is null - ? Enumerable.Empty>().AsQueryable() - : value.Select((v, i) => new JsonEach(v, i.ToString(), "", i, "", "")).AsQueryable(); + throw new NotImplementedException("only supported server side"); } [Sql.TableFunction("json_each", argIndices: [0])] private static IQueryable> QueryInternal(this MultiString value) { - return value is null - ? Enumerable.Empty>().AsQueryable() - : value.Values.Select((kv, i) => new JsonEach(kv.Value, kv.Key.Code, "", i, "", "")).AsQueryable(); + throw new NotImplementedException("only supported server side"); } [Sql.Expression("(select group_concat(s.value->>'Text', '') from json_each({0}->>'Spans') as s)", PreferServerSide = true)] diff --git a/backend/FwLite/LcmCrdt/LINQ2DB-V6-NOTES.md b/backend/FwLite/LcmCrdt/LINQ2DB-V6-NOTES.md index 40133c7be5..cbcdfb2537 100644 --- a/backend/FwLite/LcmCrdt/LINQ2DB-V6-NOTES.md +++ b/backend/FwLite/LcmCrdt/LINQ2DB-V6-NOTES.md @@ -1,172 +1,287 @@ -# Linq2Db v6 regressions and workarounds in LcmCrdt +# Linq2Db v6 — shadow-property workaround in LcmCrdt Captured during the .NET 10 + Linq2Db 5.4 → 6.2.1 upgrade -(branch `chore/update-nuget-pkgs-dotnet-10`, PR +(branch `wip/linq2db-v6-attempts`, PR [#2264](https://github.com/sillsdev/languageforge-lexbox/pull/2264)). -This file documents the two issues we worked around, the trade-off the -current code makes, and the actions to take when upstream lands a fix. +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 -| Symptom | Status | Action when fixed upstream | -|---|---|---| -| 9 specific Gridify filter tests fail with `LinqToDBException : The LINQ expression could not be converted to SQL` | Worked around; tests remain red | Drop the `.ToList()` calls in `LcmCrdtKernel.cs:SenseSemanticDomainsExpression` / `EntryPublishInExpression`; revert return type to `IQueryable`; restore `throw new NotImplementedException(...)` in both `Json.QueryInternal` bodies | -| `QueryPerformanceTesting` assertion `< 0.5 µs/entry` now sees ~0.7–0.8 µs/entry | Test margin too tight; not a code bug | After the workaround revert above, recheck. If still tight on the GHA Windows runner, raise the assertion margin (see history of `Tim H:` comments in `QueryEntryTests.cs:79`) | +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. --- -## What changed in Linq2Db v6 that caused this +## 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."* +> *"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 that an `ExpressionMethodAttribute` registered on a property -now fires in two distinct places where v5 only fired it in one: +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 +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 +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. + 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`. -`IsColumn` on `ExpressionMethodAttribute` is documented to control -exactly this behavior (`IsColumn=false` should mean "not used during -materialization"). Verified locally that v6 ignores it for our case. +--- -## Why the two regressions appear +## Attempt history -### Regression 1 — `LoadWith` materialization +| # | 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)* | ✓ | ✓ | ✓ | -`Sense.SemanticDomains : IList` has the rewrite -registered. v6's preamble materializer evaluates the substitution -client-side and tries to assign the result back to the property. If -the substitution returns `IQueryable` (which is the -natural type — `json_each` is a table), v6 throws -`InvalidCastException : Unable to cast EnumerableQuery to IList` -inside `LinqToDB.Internal.Linq.QueryRunner.Mapper.ReMapOnException`. +### Why the shadow approach works as a property but not a method -Originally this broke every code path that loaded a Sense — ~280 of 461 -`LcmCrdt.Tests` failures came from this one issue. +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. -### Regression 2 — translator can't see through `.ToList()` +### Why v6's materializer doesn't trip on the shadow -The fix for regression 1 is to make the substitution return `IList`, -which means tacking `.ToList()` on the end. That works for -materialization (`List : IList`). But then the *translator* sees -chains like +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. -```csharp -e.Senses.Any(s => s.SemanticDomains.Any(sd => sd.Code.Contains("Fruit"))) -``` +### Other untried alternatives (still escalation paths if F ever breaks) -expand to +In increasing invasiveness: -```csharp -e.Senses.Any(s => json_each(s.SemanticDomains).Select(v => v.Value).ToList().Any(sd => sd.Code.Contains("Fruit"))) -``` - -and gives up with `LinqToDBException : The LINQ expression could not be -converted to SQL`. The `.ToList()` between the table function and the -`Any(...)` defeats the table-form recognition. - -The current code chooses to keep regression 1 silenced (so 99% of the -suite passes) and accept that the nine filter tests below stay red. - -## The 9 still-failing filter tests - -All in `LcmCrdt.Tests.MiniLcmTests.QueryEntryTests`: - -- `CanFilterByPublicationId` -- `CanFilterByPublicationId_AndSearch` -- `CanFilterSemanticDomainCodeContains` -- `CanFilterToMissingSemanticDomains` -- `CanFilterToMissingSemanticDomainsWithEmptyArray` -- `CanFilterToMissingSemanticDomains_AndSearch` -- `CanFilterToMissingPublishIn` -- `CanFilterToMissingPublishInWithEmptyArray` -- `CanFilterToMissingPublishIn_AndSearch` - -They throw at SQL-translation time. **They do not silently fall back to -client-side evaluation** — the query never runs at all. So this is not -a correctness or perf cliff in production traffic; it's a capability -loss for these specific filter shapes until upstream fixes it. - -## The performance regression (`QueryPerformanceTesting`) - -Two tests assert `< 0.5 µs/entry` after a warmup phase -(`QueryEntryTests.cs:79`). On this branch the per-entry cost is -~0.7–0.8 µs. - -Cause: v6's client-side rewrite runs `Json.QueryInternal(list).Select(v -=> v.Value).ToList()` for every loaded `Sense` row, even when nothing -in the query consumes `SemanticDomains` as a table. For a 50K–100K -entry test this is an extra allocation + iteration per row. - -**What would make this faster again:** the same upstream fix that -closes Regression 1. If the materializer stops invoking our -substitution after deserialization (i.e. honors `IsColumn=false` again), -the per-row overhead disappears and we revert to v5-level performance. -Until then the test margin is the better lever — the in-source history -in `QueryEntryTests.cs` already shows Tim cycling through 1.0 → 1.3 → -0.5 as the GHA runners changed. Bumping back to ~1.0 µs/entry would -match Kevin's original baseline plus headroom. - -## What we tried - -| Attempt | Eager load (`LoadWith`) | Gridify content filter | Gridify "missing"/null filter | -|---|---|---|---| -| A. Drop `ExpressionMethodAttribute`, write `Json.Query(...)` explicitly in `EntryFilterMapProvider` | ✓ works | ✗ Gridify can't parse `Json.Query(...)` — `InvalidOperationException` at `ParseMethodCallExpression` | ✗ same | -| B. Keep `ExpressionMethodAttribute`, expression returns `IQueryable` (no `.ToList()`) | ✗ `InvalidCastException` on every load (~280 fails) | ✓ would translate | n/a (load already broken) | -| **C. Keep `ExpressionMethodAttribute`, expression returns `IList` via `.ToList()`** *(current)* | ✓ | ✗ translator can't see through `.ToList()` | ✗ same | -| D. C + `IsColumn = false` explicitly | same as C | same as C | same as C — v6 ignores it | - -Untried, 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`. + `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. + 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> -- Related (similar v6 `ExpressionMethod` regressions, all fixed — none - match our pair of symptoms): +- 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> -- Eager-load refactor in flight (targets 6.4.0) — might fix or change - this: - <https://github.com/linq2db/linq2db/pull/5450> -- The file the stack trace points into: - <https://github.com/linq2db/linq2db/blob/master/Source/LinqToDB/Internal/Linq/Builder/ExpressionBuilder.EagerLoad.cs> - -## Affected source - -- `backend/FwLite/LcmCrdt/Json.cs` — `QueryInternal` bodies must return - a working iterator (not throw) because v6 invokes them client-side. -- `backend/FwLite/LcmCrdt/LcmCrdtKernel.cs` — the two - `Sense.SemanticDomains` / `Entry.PublishIn` `ExpressionMethodAttribute` - registrations and their substitution expressions - (`SenseSemanticDomainsExpression` / `EntryPublishInExpression`). -- `backend/FwLite/LcmCrdt.Tests/MiniLcmTests/QueryEntryTests.cs:79` — - perf-test assertion margin (history of past adjustments in comments - just above the line). +- Local upstream-side scratch (PR drafts, repro shapes for A and C): + `D:\code\linq2db\UPSTREAM-PLAN.md` (outside this repo). diff --git a/backend/FwLite/LcmCrdt/LcmCrdtKernel.cs b/backend/FwLite/LcmCrdt/LcmCrdtKernel.cs index 2be2970fe4..1ab81cf990 100644 --- a/backend/FwLite/LcmCrdt/LcmCrdtKernel.cs +++ b/backend/FwLite/LcmCrdt/LcmCrdtKernel.cs @@ -130,13 +130,12 @@ 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 / Entry.PublishIn as Json.Query(...) - //in queries. Gridify can then parse the projection lambdas in EntryFilterMapProvider - //using bare property access, and linq2db expands to json_each() at translation time. - //Json.QueryInternal has a working client-side body (not throw) so v6's eager-load - //preamble — which may invoke the rewrite client-side — doesn't blow up. - .Entity<Sense>().Property(s => s.SemanticDomains).HasAttribute(new ExpressionMethodAttribute(SenseSemanticDomainsExpression())) - .Entity<Entry>().Property(e => e.PublishIn).HasAttribute(new ExpressionMethodAttribute(EntryPublishInExpression())) + //Lower the shadow properties to json_each form. The rewrite lives on these + //unmapped accessors instead of on the underlying IList<T> columns because v6's + //materializer expands [ExpressionMethod] on mapped properties regardless of + //IsColumn=false (see LINQ2DB-V6-NOTES.md). + .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(); @@ -165,20 +164,13 @@ public static void ConfigureDbOptions(IServiceProvider provider, DbContextOption builder.AddInterceptors(updateSearchTableInterceptor); } - //TODO when upstream fixes the v6 regression: drop .ToList() from both expressions below and revert - //the return type to IQueryable<...>. See LINQ2DB-V6-NOTES.md for context — the ToList() is what - //blocks SQL translation of `.Any(...) == null` filter forms but keeps eager-load materialization - //working in v6. - private static Expression<Func<Sense, IList<SemanticDomain>>> SenseSemanticDomainsExpression() - { - //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))).ToList(); - } + //Sql.Property (not bare s.SemanticDomains) so the substitution doesn't recurse if the + //underlying column ever picks up an [ExpressionMethod] of its own. + private static Expression<Func<Sense, IEnumerable<SemanticDomain>>> SenseSemanticDomainRowsExpression() + => s => Json.Query(Sql.Property<IList<SemanticDomain>>(s, nameof(Sense.SemanticDomains))); - private static Expression<Func<Entry, IList<Publication>>> EntryPublishInExpression() - { - return e => Json.Query(Sql.Property<IList<Publication>>(e, nameof(Entry.PublishIn))).ToList(); - } + private static Expression<Func<Entry, IEnumerable<Publication>>> EntryPublishInRowsExpression() + => e => Json.Query(Sql.Property<IList<Publication>>(e, nameof(Entry.PublishIn))); public static void ConfigureCrdt(CrdtConfig config) { diff --git a/backend/FwLite/MiniLcm/Models/Entry.cs b/backend/FwLite/MiniLcm/Models/Entry.cs index 550080e628..4b0077f1c5 100644 --- a/backend/FwLite/MiniLcm/Models/Entry.cs +++ b/backend/FwLite/MiniLcm/Models/Entry.cs @@ -1,3 +1,6 @@ +using System.ComponentModel.DataAnnotations.Schema; +using System.Text.Json.Serialization; + namespace MiniLcm.Models; public record Entry : IObjectWithId<Entry> @@ -29,6 +32,11 @@ public record Entry : IObjectWithId<Entry> public virtual List<Publication> PublishIn { get; set; } = []; + //Server-side query rewrite target — LcmCrdt lowers this to json_each(PublishIn) at query time. + //Don't read from client code; use PublishIn. See LINQ2DB-V6-NOTES.md. + [NotMapped, JsonIgnore] + public IEnumerable<Publication> PublishInRows => PublishIn; + public const string UnknownHeadword = "(Unknown)"; public string Headword() diff --git a/backend/FwLite/MiniLcm/Models/Sense.cs b/backend/FwLite/MiniLcm/Models/Sense.cs index 3c4016f13e..3a212cb83d 100644 --- a/backend/FwLite/MiniLcm/Models/Sense.cs +++ b/backend/FwLite/MiniLcm/Models/Sense.cs @@ -1,3 +1,4 @@ +using System.ComponentModel.DataAnnotations.Schema; using System.Text.Json; using System.Text.Json.Serialization; using MiniLcm.Attributes; @@ -18,6 +19,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. + [NotMapped, JsonIgnore] + public IEnumerable<SemanticDomain> SemanticDomainRows => SemanticDomains; public virtual List<ExampleSentence> ExampleSentences { get; set; } = []; public Guid[] GetReferences() From 3b38e5847dd61266752e35c3e4a1cdc84ed48182 Mon Sep 17 00:00:00 2001 From: Tim Haasdyk <tim_haasdyk@sil.org> Date: Mon, 18 May 2026 17:23:25 +0200 Subject: [PATCH 32/36] Bump FluentAssertions 8.9.0 -> 8.10.0 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- backend/Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/Directory.Packages.props b/backend/Directory.Packages.props index 2e044012d9..cf6bc867a4 100644 --- a/backend/Directory.Packages.props +++ b/backend/Directory.Packages.props @@ -12,7 +12,7 @@ <PackageVersion Include="coverlet.collector" Version="10.0.0" /> <PackageVersion Include="CrystalQuartz.AspNetCore" Version="7.3.0" /> <PackageVersion Include="DataAnnotatedModelValidations" Version="10.0.0" /> - <PackageVersion Include="FluentAssertions" Version="8.9.0" /> + <PackageVersion Include="FluentAssertions" Version="8.10.0" /> <PackageVersion Include="FluentValidation" Version="12.1.1" /> <PackageVersion Include="GitHubActionsTestLogger" Version="3.0.4" /> <PackageVersion Include="Gridify" Version="2.19.1" /> From 2d47dac538e3a93b39c4883c73231a6297d687ca Mon Sep 17 00:00:00 2001 From: Tim Haasdyk <tim_haasdyk@sil.org> Date: Tue, 19 May 2026 09:32:37 +0200 Subject: [PATCH 33/36] Exclude shadow rewrite properties from FA equivalency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PublishInRows / SemanticDomainRows aren't domain state — they're server-side query-rewrite targets that happen to return the underlying collection in client context. BeEquivalentTo was walking them and failing in shapes that exclude the real collection (UpdateDiffTests, random-entry round-trip). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- backend/FwLite/MiniLcm.Tests/FluentAssertGlobalConfig.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/backend/FwLite/MiniLcm.Tests/FluentAssertGlobalConfig.cs b/backend/FwLite/MiniLcm.Tests/FluentAssertGlobalConfig.cs index 2680797f50..9945ed2dbd 100644 --- a/backend/FwLite/MiniLcm.Tests/FluentAssertGlobalConfig.cs +++ b/backend/FwLite/MiniLcm.Tests/FluentAssertGlobalConfig.cs @@ -18,6 +18,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))) ); } } From 443c0561dfb387f1f2783952e52a72d7f35c67de Mon Sep 17 00:00:00 2001 From: Tim Haasdyk <tim_haasdyk@sil.org> Date: Tue, 19 May 2026 09:46:30 +0200 Subject: [PATCH 34/36] Hide PublishInRows / SemanticDomainRows shadow properties MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The properties have to stay public because LcmCrdt's filter map provider is in another assembly, but they're query-rewrite plumbing — not domain state. Mark them as: [MiniLcmInternal] — strips from MiniLcm's external JSON resolver [JsonIgnore] — and from any default System.Text.Json path [NotMapped] — and from EF column mapping [EditorBrowsable.Never] — and from IntelliSense Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- backend/FwLite/MiniLcm/Models/Entry.cs | 8 ++++++-- backend/FwLite/MiniLcm/Models/Sense.cs | 3 ++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/backend/FwLite/MiniLcm/Models/Entry.cs b/backend/FwLite/MiniLcm/Models/Entry.cs index 4b0077f1c5..1871ea4dad 100644 --- a/backend/FwLite/MiniLcm/Models/Entry.cs +++ b/backend/FwLite/MiniLcm/Models/Entry.cs @@ -1,5 +1,7 @@ +using System.ComponentModel; using System.ComponentModel.DataAnnotations.Schema; using System.Text.Json.Serialization; +using MiniLcm.Attributes; namespace MiniLcm.Models; @@ -33,8 +35,10 @@ public record Entry : IObjectWithId<Entry> public virtual List<Publication> PublishIn { get; set; } = []; //Server-side query rewrite target — LcmCrdt lowers this to json_each(PublishIn) at query time. - //Don't read from client code; use PublishIn. See LINQ2DB-V6-NOTES.md. - [NotMapped, JsonIgnore] + //Public only because the LcmCrdt filter map provider lives in a different assembly; treat as + //internal. Hidden from IntelliSense, JSON, EF column mapping, and FA equivalency. + //See LINQ2DB-V6-NOTES.md. + [MiniLcmInternal, NotMapped, JsonIgnore, EditorBrowsable(EditorBrowsableState.Never)] public IEnumerable<Publication> PublishInRows => PublishIn; public const string UnknownHeadword = "(Unknown)"; diff --git a/backend/FwLite/MiniLcm/Models/Sense.cs b/backend/FwLite/MiniLcm/Models/Sense.cs index 3a212cb83d..b4938dc867 100644 --- a/backend/FwLite/MiniLcm/Models/Sense.cs +++ b/backend/FwLite/MiniLcm/Models/Sense.cs @@ -1,3 +1,4 @@ +using System.ComponentModel; using System.ComponentModel.DataAnnotations.Schema; using System.Text.Json; using System.Text.Json.Serialization; @@ -21,7 +22,7 @@ public class Sense : IObjectWithId<Sense>, IOrderable public virtual IList<SemanticDomain> SemanticDomains { get; set; } = []; //Server-side query rewrite target — see Entry.PublishInRows. - [NotMapped, JsonIgnore] + [MiniLcmInternal, NotMapped, JsonIgnore, EditorBrowsable(EditorBrowsableState.Never)] public IEnumerable<SemanticDomain> SemanticDomainRows => SemanticDomains; public virtual List<ExampleSentence> ExampleSentences { get; set; } = []; From 73d26c41c7f006121fb069dd59472ca74b5a28c2 Mon Sep 17 00:00:00 2001 From: Tim Haasdyk <tim_haasdyk@sil.org> Date: Tue, 19 May 2026 09:46:41 +0200 Subject: [PATCH 35/36] Pin FluentAssertions to 7.0.0-alpha.5 8.x has a serious BeEquivalentTo perf regression that pushes FwLite CI past its 60-minute timeout. Revert the 8.x-only API call in EntityCopyMethodTests (Subject/Expectation.Description -> Description) and AssertionConfiguration.Current -> AssertionOptions.AssertEquivalencyUsing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- backend/Directory.Packages.props | 5 ++++- backend/FwLite/LcmCrdt.Tests/EntityCopyMethodTests.cs | 2 +- backend/FwLite/MiniLcm.Tests/FluentAssertGlobalConfig.cs | 2 +- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/backend/Directory.Packages.props b/backend/Directory.Packages.props index cf6bc867a4..c54d637395 100644 --- a/backend/Directory.Packages.props +++ b/backend/Directory.Packages.props @@ -12,7 +12,10 @@ <PackageVersion Include="coverlet.collector" Version="10.0.0" /> <PackageVersion Include="CrystalQuartz.AspNetCore" Version="7.3.0" /> <PackageVersion Include="DataAnnotatedModelValidations" Version="10.0.0" /> - <PackageVersion Include="FluentAssertions" Version="8.10.0" /> + <!-- Pinned: FluentAssertions 8.x has a serious BeEquivalentTo perf regression + that pushes FwLite CI past its 60-minute timeout. 7.0.0-alpha.5 is the + last known-good build for our suite. --> + <PackageVersion Include="FluentAssertions" Version="7.0.0-alpha.5" /> <PackageVersion Include="FluentValidation" Version="12.1.1" /> <PackageVersion Include="GitHubActionsTestLogger" Version="3.0.4" /> <PackageVersion Include="Gridify" Version="2.19.1" /> diff --git a/backend/FwLite/LcmCrdt.Tests/EntityCopyMethodTests.cs b/backend/FwLite/LcmCrdt.Tests/EntityCopyMethodTests.cs index df0d9ab076..0548cb0c98 100644 --- a/backend/FwLite/LcmCrdt.Tests/EntityCopyMethodTests.cs +++ b/backend/FwLite/LcmCrdt.Tests/EntityCopyMethodTests.cs @@ -91,7 +91,7 @@ public EquivalencyResult Handle(Comparands comparands, assertionChain .ForCondition(!ReferenceEquals(comparands.Subject, comparands.Expectation)) .BecauseOf(context.Reason) - .FailWith("Subject for {0} and Expectation for {1} should not reference the same instance in memory.", context.CurrentNode.Subject.Description, context.CurrentNode.Expectation.Description); + .FailWith("Subject and Expectation for {0} should not reference the same instance in memory.", context.CurrentNode.Description); return EquivalencyResult.ContinueWithNext; } diff --git a/backend/FwLite/MiniLcm.Tests/FluentAssertGlobalConfig.cs b/backend/FwLite/MiniLcm.Tests/FluentAssertGlobalConfig.cs index 9945ed2dbd..26d04eb4d2 100644 --- a/backend/FwLite/MiniLcm.Tests/FluentAssertGlobalConfig.cs +++ b/backend/FwLite/MiniLcm.Tests/FluentAssertGlobalConfig.cs @@ -11,7 +11,7 @@ public static class FluentAssertGlobalConfig { public static void Initialize() { - AssertionConfiguration.Current.Equivalency.Modify(options => options + AssertionOptions.AssertEquivalencyUsing(options => options //by default, assertion will use the overriden equality function //however that will result in very poor error messages, so we override it .ComparingByMembers<RichString>() From d21394f6c6f4ab001358c508fe27b127668c03f4 Mon Sep 17 00:00:00 2001 From: Tim Haasdyk <tim_haasdyk@sil.org> Date: Tue, 19 May 2026 10:09:19 +0200 Subject: [PATCH 36/36] Revert HaveProperty -> ContainKey for FA 7.x pin HaveProperty on dictionary assertions was added in FA 8.x; with the pin to 7.0.0-alpha.5 we have to keep using ContainKey. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- backend/Testing/SyncReverseProxy/LegacyProjectApiTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/Testing/SyncReverseProxy/LegacyProjectApiTests.cs b/backend/Testing/SyncReverseProxy/LegacyProjectApiTests.cs index fdce5cd6c5..ab48a04d83 100644 --- a/backend/Testing/SyncReverseProxy/LegacyProjectApiTests.cs +++ b/backend/Testing/SyncReverseProxy/LegacyProjectApiTests.cs @@ -102,7 +102,7 @@ public async Task TestInvalidPassword() content.ValueKind.Should().Be(JsonValueKind.Object); var responseObject = JsonObject.Create(content); responseObject.Should().NotBeNull(); - responseObject.Should().HaveProperty("error"); + responseObject.Should().ContainKey("error"); responseObject["error"]!.GetValue<string>().Should().Be("Bad password"); } @@ -118,7 +118,7 @@ public async Task TestInvalidUser() content.ValueKind.Should().Be(JsonValueKind.Object); var responseObject = JsonObject.Create(content); responseObject.Should().NotBeNull(); - responseObject.Should().HaveProperty("error"); + responseObject.Should().ContainKey("error"); responseObject["error"]!.GetValue<string>().Should().Be("Unknown user"); }