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/.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 }} diff --git a/backend/Directory.Packages.props b/backend/Directory.Packages.props index bf493b9a70..c54d637395 100644 --- a/backend/Directory.Packages.props +++ b/backend/Directory.Packages.props @@ -3,139 +3,142 @@ true true $(NoWarn);NU1507 - 9.0.50 - 15.1.10 + 10.0.60 + 15.1.16 - - - - - + + + + + + - - - - + + + + - + - - - - - - - + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + - - + + - + - - - - - + + + + + - - + + - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + - + - + - - - - - - + + + + + + - + - + - - - - + + + + - - - + + + - - - - + + + + - + 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/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, 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 f94563f33e..410b57269d 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; @@ -8,15 +8,15 @@ 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() + operation.Parameters?.Add(new OpenApiParameter() { Name = CrdtMiniLcmApiHub.ProjectRouteKey, 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 d1cf29049a..33d4f830b3 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; @@ -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() + 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 a1ac74af5d..77660e2452 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; @@ -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() + 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/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..988fde5049 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; @@ -31,9 +31,9 @@ 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() + operation.Parameters?.Add(new OpenApiParameter() { Name = "projectType", In = ParameterLocation.Path, @@ -42,19 +42,19 @@ 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" + Type = JsonSchemaType.String }, }); - operation.Parameters.Add(new() + operation.Parameters?.Add(new OpenApiParameter() { Name = "projectCode", 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 cf5abe3268..6a8d186fc3 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; @@ -13,15 +13,15 @@ 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() + operation.Parameters?.Add(new OpenApiParameter() { Name = CrdtMiniLcmApiHub.ProjectRouteKey, In = ParameterLocation.Path, Required = true }); - return operation; + return Task.CompletedTask; }); group.MapGet("/entries", (IMiniLcmApi api) => 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/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/EntryQueryHelpers.cs b/backend/FwLite/LcmCrdt/Data/EntryQueryHelpers.cs index 37eb983a7e..69b7662ffa 100644 --- a/backend/FwLite/LcmCrdt/Data/EntryQueryHelpers.cs +++ b/backend/FwLite/LcmCrdt/Data/EntryQueryHelpers.cs @@ -47,11 +47,13 @@ public static bool SearchHeadwords(this Entry e, string? leading, string? traili private static Expression> SearchHeadwords() { + //Use Sql.Expr to spell the cross-scope path access explicitly: linq2db v6 + //emits `[kv].*` instead of `[kv].[key]` if we route this through Json.Value. return (e, leading, trailing, query) => Json.QueryValues(e.CitationForm).Any( v => SqlHelpers.ContainsIgnoreCaseAccents(v, query)) || Json.QueryEntries(e.LexemeForm).Any(kv => - string.IsNullOrEmpty((Json.Value(e.CitationForm, ms => ms[kv.Key]) ?? "").Trim()) && + string.IsNullOrEmpty((Sql.Expr($"{e.CitationForm}->>{kv.Key}") ?? "").Trim()) && SqlHelpers.ContainsIgnoreCaseAccents((leading ?? "") + (kv.Value ?? "").Trim() + (trailing ?? ""), query)); } diff --git a/backend/FwLite/LcmCrdt/Data/MiniLcmRepository.cs b/backend/FwLite/LcmCrdt/Data/MiniLcmRepository.cs index c1a8cf690a..8b3273b6da 100644 --- a/backend/FwLite/LcmCrdt/Data/MiniLcmRepository.cs +++ b/backend/FwLite/LcmCrdt/Data/MiniLcmRepository.cs @@ -4,6 +4,7 @@ using LcmCrdt.FullTextSearch; using LcmCrdt.Utils; using LinqToDB; +using LinqToDB.Async; using LinqToDB.EntityFrameworkCore; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; diff --git a/backend/FwLite/LcmCrdt/EntryFilterMapProvider.cs b/backend/FwLite/LcmCrdt/EntryFilterMapProvider.cs index 80b94417da..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 => - //ideally we would use Json.Query(s.SemanticDomains) but Gridify doesn't support that, so we have to configure - //linq2db to rewrite this to that. - e => e.Senses.SelectMany(s => s.SemanticDomains).Select(sd => Json.Value(sd, sd => sd.Code)); + //SemanticDomainRows is the json_each rewrite target — 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/FullTextSearch/EntrySearchService.cs b/backend/FwLite/LcmCrdt/FullTextSearch/EntrySearchService.cs index 7a33e50f2c..b7bbc3ff83 100644 --- a/backend/FwLite/LcmCrdt/FullTextSearch/EntrySearchService.cs +++ b/backend/FwLite/LcmCrdt/FullTextSearch/EntrySearchService.cs @@ -1,6 +1,7 @@ using System.Text; using LcmCrdt.Data; using LinqToDB; +using LinqToDB.Async; using LinqToDB.Data; using LinqToDB.DataProvider.SQLite; using LinqToDB.EntityFrameworkCore; @@ -223,16 +224,12 @@ public async Task UpdateEntrySearchTable(Entry entry) private static async Task InsertOrUpdateEntrySearchRecord(EntrySearchRecord record, ITable table) { - await table.InsertOrUpdateAsync(() => new EntrySearchRecord() - { - Id = record.Id, - Headword = record.Headword, - LexemeForm = record.LexemeForm, - CitationForm = record.CitationForm, - Definition = record.Definition, - Gloss = record.Gloss, - }, - exiting => new EntrySearchRecord() + // Can't use table.InsertOrUpdateAsync here because EntrySearchRecord is a virtual table, + // and SQLite doesn't support UPSERT statements on virtual tables. Instead, we have to + // use the same DELETE+INSERT approach that Linq2DB 5 used to use (Linq2DB 6 changed this + // to a proper UPSERT, which is the correct approach most of the time... except here) + await table.DeleteAsync(e => e.Id == record.Id); + await table.InsertAsync(() => new EntrySearchRecord() { Id = record.Id, Headword = record.Headword, @@ -270,6 +267,7 @@ public static async Task UpdateEntrySearchTable(IEnumerable entries, foreach (var entrySearchRecord in searchRecords) { //can't use bulk copy here because that creates duplicate rows + //TODO: Replace this with a bulk delete followed by a bulk copy, it should be faster - 2026-05 RM await InsertOrUpdateEntrySearchRecord(entrySearchRecord, entrySearchRecordsTable); } diff --git a/backend/FwLite/LcmCrdt/HistoryService.cs b/backend/FwLite/LcmCrdt/HistoryService.cs index e730abf7eb..a7fc86a344 100644 --- a/backend/FwLite/LcmCrdt/HistoryService.cs +++ b/backend/FwLite/LcmCrdt/HistoryService.cs @@ -7,6 +7,7 @@ using LinqToDB.EntityFrameworkCore; using System.Text.RegularExpressions; using MiniLcm.Exceptions; +using LinqToDB.Async; namespace LcmCrdt; @@ -154,7 +155,7 @@ public async Task LoadChangeContext(Guid commitId, int changeInde } var affectedEntries = await GetAffectedEntryIds(change) - .SelectAwait(async entryId => await GetCurrentOrLatestEntry(entryId)) + .Select(async (Guid entryId, CancellationToken _) => await GetCurrentOrLatestEntry(entryId)) .ToArrayAsync(); return new ChangeContext(change, snapshot, affectedEntries); diff --git a/backend/FwLite/LcmCrdt/Json.cs b/backend/FwLite/LcmCrdt/Json.cs index 71dfe76294..bb7190eb8a 100644 --- a/backend/FwLite/LcmCrdt/Json.cs +++ b/backend/FwLite/LcmCrdt/Json.cs @@ -3,7 +3,8 @@ using System.Text.Json.Serialization.Metadata; using LcmCrdt.Changes; using LinqToDB; -using LinqToDB.Common; +using LinqToDB.Internal.Common; +using LinqToDB.Internal.SqlQuery; using LinqToDB.Mapping; using LinqToDB.SqlQuery; using SIL.Harmony; @@ -14,7 +15,7 @@ public static class Json { sealed class JsonValuePathBuilder : Sql.IExtensionCallBuilder { - public void Build(Sql.ISqExtensionBuilder builder) + public void Build(Sql.ISqlExtensionBuilder builder) { var propExpression = builder.GetExpression(0); @@ -42,7 +43,7 @@ public void Build(Sql.ISqExtensionBuilder builder) parameters.Insert(0, propExpression); - var valueExpression = (ISqlExpression)new SqlExpression(typeof(string), + var valueExpression = (ISqlExpression)new SqlExpression(new DbDataType(typeof(string)), expressionStr, Precedence.Primary, parameters.ToArray()); @@ -51,7 +52,12 @@ public void Build(Sql.ISqExtensionBuilder builder) if (returnType != typeof(string) && returnType != typeof(RichString))//bypass rich string so it can be used with .GetPlainText() { - valueExpression = PseudoFunctions.MakeTryConvert(new SqlDataType(new DbDataType(returnType)), + //v6 dropped PseudoFunctions.MakeTryConvert; build the SqlFunction directly instead. + valueExpression = new SqlFunction( + new DbDataType(returnType), + PseudoFunctions.TRY_CONVERT, + canBeNull: true, + new SqlDataType(new DbDataType(returnType)), new SqlDataType(new DbDataType(typeof(string), DataType.Text)), valueExpression); } @@ -61,7 +67,7 @@ public void Build(Sql.ISqExtensionBuilder builder) private static void BuildParameterPath(Expression? pathBody, List parameters, - Sql.ISqExtensionBuilder builder) + Sql.ISqlExtensionBuilder builder) { while (pathBody is MemberExpression or MethodCallExpression or UnaryExpression) { @@ -86,6 +92,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}."); diff --git a/backend/FwLite/LcmCrdt/LINQ2DB-V6-NOTES.md b/backend/FwLite/LcmCrdt/LINQ2DB-V6-NOTES.md new file mode 100644 index 0000000000..cbcdfb2537 --- /dev/null +++ b/backend/FwLite/LcmCrdt/LINQ2DB-V6-NOTES.md @@ -0,0 +1,287 @@ +# Linq2Db v6 — shadow-property workaround in LcmCrdt + +Captured during the .NET 10 + Linq2Db 5.4 → 6.2.1 upgrade +(branch `wip/linq2db-v6-attempts`, PR +[#2264](https://github.com/sillsdev/languageforge-lexbox/pull/2264)). + +This document covers: + +1. The current working solution (**TL;DR** + **Files** sections). +2. Why v6 broke things (**Root cause**). +3. Everything we tried (**Attempt history**) — kept so the next person walking + into this doesn't repeat the dead ends. +4. What's still worth contributing upstream as community-benefit fixes + (**Upstream plan**) — lexbox no longer blocks on these. + +--- + +## TL;DR + +Two `IList` jsonb columns — `Sense.SemanticDomains` and `Entry.PublishIn` — +need a `json_each(...)` rewrite for queries (`Any`, `SelectMany`, etc.) but +*not* at entity materialization. v5 honored that split via +`ExpressionMethodAttribute(IsColumn = false)`. v6 ignores `IsColumn` and fires +the substitution at materialization too, which either casts wrong +(`EnumerableQuery` → `IList`) or invokes a `Sql.TableFunction` body +client-side. + +The fix here: + +- Add **non-column shadow properties** `Sense.SemanticDomainRows` and + `Entry.PublishInRows` in `MiniLcm/Models/`. They return the underlying list + in client context (so reflection-based deep equality and bulk-copy paths + don't trip) and have no column mapping. +- In `LcmCrdtKernel`, attach the `[ExpressionMethod]` rewrite to those shadow + properties via `FluentMappingBuilder.IsExpression(..., isColumn: false)`. + `IsExpression` also calls `IsNotColumn()`, so BulkCopy and insert paths + don't read them. +- Route Gridify filter projections through the shadow properties + (`EntryFilterMapProvider.EntryPublishInId`, + `EntrySensesSemanticDomainsCode`). +- The materialization expression doesn't reference the shadow properties, so + v6's `ExposeExpressionVisitor` never sees them — the substitution fires + only when LINQ translation hits `e.PublishInRows` in a query. + +Result: all of `LcmCrdt.Tests` passes, including the perf assertion and the +nine filter shapes that the earlier `.ToList()` workaround left red. + +--- + +## Root cause — what changed in v6 + +v6 rewrote the query parser. The +[migration wiki](https://github.com/linq2db/linq2db/wiki/Linq-To-DB-6) +makes one statement that explains both regressions: + +> *"For final query projection, linq2db doesn't try to translate it to SQL +> anymore and sets its value on the client during materialization."* + +Paired with a new "single-query preamble" strategy for eager loading +([`ExpressionBuilder.EagerLoad.cs`](https://github.com/linq2db/linq2db/blob/master/Source/LinqToDB/Internal/Linq/Builder/ExpressionBuilder.EagerLoad.cs)), +this means an `ExpressionMethodAttribute` registered on a property now fires +in two places where v5 only fired it in one: + +1. **Query translation** (where we *want* it) — `s.SemanticDomains` becomes + `Json.Query(Sql.Property<...>(s, "SemanticDomains"))`, which + `[Sql.TableFunction("json_each", argIndices: [0])]` then turns into a + `json_each(jsonb_column)` table subquery. +2. **Materialization** (where v5 didn't apply it) — after the row is read and + EF's value converter has deserialized the jsonb column to + `IList`, v6 *also* runs the substitution lambda + client-side over the deserialized list and assigns the result back into + the property. This is what causes the regressions. + +`IsColumn` on `ExpressionMethodAttribute` is documented to control exactly +this behavior (`IsColumn=false` should mean "not used during +materialization"). Verified locally that v6 ignores it: `TableBuilder.TableContext.MakeExpression` +now passes `fullEntity` through `Builder.ConvertExpressionTree`, which routes +through `ExposeExpressionVisitor` and expands every `[ExpressionMethod]` +regardless of `IsColumn`. + +### The two original regressions + +**Regression 1 — `LoadWith` materialization.** With the rewrite on the +property and returning `IQueryable` (natural shape — `json_each` is a +table), v6's materializer evaluates the substitution client-side and throws +`InvalidCastException : Unable to cast EnumerableQuery to IList` inside +`LinqToDB.Internal.Linq.QueryRunner.Mapper.ReMapOnException`. Originally +broke ~280 of 461 tests. + +**Regression 2 — translator can't see through `.ToList()`.** Forcing the +substitution to return `IList` via trailing `.ToList()` fixes +materialization but defeats SQL translation: filter chains like +`e.Senses.Any(s => s.SemanticDomains.Any(sd => sd.Code.Contains("Fruit")))` +expand to +`...json_each(s.SemanticDomains).Select(v => v.Value).ToList().Any(sd => ...)`, +and v6 gives up with `LinqToDBException : The LINQ expression could not be +converted to SQL`. + +--- + +## Attempt history + +| # | Approach | Eager load (`LoadWith`) | Gridify content filter | Gridify "missing"/null filter | +|---|---|---|---|---| +| A | Drop `[ExpressionMethod]`, write `Json.Query(...)` explicitly in `EntryFilterMapProvider` | ✓ works | ✗ Gridify can't parse `Json.Query(...)` — `InvalidOperationException` at `ParseMethodCallExpression` | ✗ same | +| B | Keep `[ExpressionMethod]`, expression returns `IQueryable` (no `.ToList()`) | ✗ `InvalidCastException` on every load (~280 fails) | ✓ would translate | n/a (load already broken) | +| C | Keep `[ExpressionMethod]`, expression returns `IList` via `.ToList()` | ✓ | ✗ translator can't see through `.ToList()` | ✗ same | +| D | C + `IsColumn = false` explicitly | same as C | same as C | same as C — v6 ignores it | +| E1 | Shadow **extension method** `e.PublishInAsRows()` with `[ExpressionMethod]` | ✓ | ✗ Gridify's `ParseMethodCallExpression` only handles `MemberExpression` / `Select` / `SelectMany` / `Where` as chain root; a method-call root throws | ✓ (combined with converter change — see below) | +| **F** | **Shadow *property*** `e.PublishInRows` with `IsExpression(..., isColumn:false)` *(current)* | ✓ | ✓ | ✓ | + +### Why the shadow approach works as a property but not a method + +Gridify's `LinqQueryBuilder.ParseMethodCallExpression` switches on the first +argument of the `Select(...)` projection. Only four shapes match: +`MemberExpression`, or a nested `Select` / `SelectMany` / `Where` +`MethodCallExpression`. A user-defined extension call like +`e.PublishInAsRows()` matches none and throws `InvalidOperationException`. A +property access (`e.PublishInRows`) is a `MemberExpression`, so it does +match. + +### Why v6's materializer doesn't trip on the shadow + +The shadow property is unmapped (`[NotMapped, JsonIgnore]`, plus +`IsNotColumn()` via `IsExpression`). v6's materialization expression is +shaped like `new Entry { Col1 = ..., Col2 = ... }` over the mapped columns +only. `ExposeExpressionVisitor` only expands `[ExpressionMethod]` when it +actually walks past that member in some expression tree. The shadow never +appears in the materialization tree, so its substitution never fires there. + +### Other untried alternatives (still escalation paths if F ever breaks) + +In increasing invasiveness: + +- Change `Sense.SemanticDomains` / `Entry.PublishIn` from `IList` to + `IQueryable` at the model level. Removes the type mismatch but is a wide + breaking change in `MiniLcm`. +- Drop the json column entirely and model these as real one-to-many EF + associations. No more `[Sql.TableFunction]` rewriting at all. Schema + + migration + sync-format break. +- Pin `linq2db` to 5.4.x. Avoids the regression at the cost of every fix in + the v6 line; conflicts with the rest of the .NET 10 upgrade. + +--- + +## Filter-converter changes + +Without the property-level rewrite, `e.PublishIn == null` no longer maps to +"NOT EXISTS json_each(...)" — it lowers to plain column `IS NULL`, which +matches nothing because empty lists are stored as `"[]"`, never SQL NULL. +The two affected converters +(`EntryPublishInConverter`, `EntrySensesSemanticDomainsConverter`) therefore +use `NormalizeEmptyToEmptyList` instead of `NormalizeEmptyToNull`, generating +`column = '[]'` — the same pattern `EntryComplexFormTypesConverter` already +used. + +--- + +## Two related v6 fixes that landed in `Json.cs` + +| Concern | Where | +|---|---| +| Peel `Sql.Alias(...)` wrap from `IExtensionCallBuilder` lambda arg bodies | `JsonValuePathBuilder.BuildParameterPath` | +| `PseudoFunctions.MakeTryConvert` was dropped in v6 — build the `SqlFunction` directly | `JsonValuePathBuilder.Build` | + +The Alias-peel exists because `ExposeExpressionVisitor` wraps every +`[ExpressionMethod]` substitution in `Sql.Alias(real_expr, "")` as a +column-alias hint, and that wrap leaks into user-written +`IExtensionCallBuilder` arg lambdas. `Json.Value(p, p => p.Id.ToString())` is +what trips it in our code; the same shape would affect any other linq2db +user with `[ExpressionMethod]` + custom extension builders. + +--- + +## Upstream plan (community-benefit, not lexbox-blocking) + +With the shadow-property approach in place, lexbox no longer depends on any +linq2db upstream fix. The two items below are still worth filing as +community contributions — other users will hit them — but they're off our +critical path. + +### PR A — Honor `IsColumn=false` at entity materialization + +The clean win. Restores the documented `[ExpressionMethod].IsColumn` +contract. + +- **Doc contract:** `IsColumn`'s XML doc reads: *"When applied to property + and set to true, Linq To DB will load data into property using expression + during entity materialization."* The default (`false`) should opt out of + materialization-time invocation. +- **Where it broke:** `TableBuilder.TableContext.MakeExpression` now passes + `fullEntity` through `Builder.ConvertExpressionTree`, which routes through + `ExposeExpressionVisitor` and expands every `[ExpressionMethod]` + regardless of `IsColumn`. +- **Proposed fix:** thread a `calculatedColumnsOnly` flag through one + materialization call site. `ExposeExpressionVisitor.ConvertExpressionMethodAttribute` + returns `null` when `_calculatedColumnsOnly && !attr.IsColumn`. Caller + falls back to bare member access. +- **Sample repro:** a `Foo` with `IList Bars` carrying a `JsonEach`-style + substitution and `IsColumn = false` — `db.GetTable().ToList()` throws + in v6 but works in v5. +- **Risk profile:** low. Anyone relying on v6's regression behavior would + have been broken on v5 too; they should set `IsColumn=true`. + +### PR C — Peel `Sql.Alias` from `IExtensionCallBuilder` lambda args + +Narrow, surface-area-only fix. No public contract change. + +- **Symptom:** `ExposeExpressionVisitor` wraps every `[ExpressionMethod]` + substitution in `Sql.Alias(real_expr, "")`. The wrap is + invisible to the SQL builder but observable inside user-written + `Sql.IExtensionCallBuilder` lambda-arg walkers as `Alias(real_expr, "ToString")`. +- **Proposed fix:** in `Sql.ExtensionAttribute.GetExtensionParam`, peel + `Sql.Alias(...)` from lambda-argument bodies before invoking the user + builder. Top-level (non-lambda) argument expressions are left alone. +- **Lexbox-side workaround already in `Json.cs`** — see the table above. +- **Risk profile:** low. The wrap was never part of the documented + extension-builder contract. + +### PR B — retired + +Previously envisioned as "suppress collection-typed `[ExpressionMethod]` +substitution inside `Equal`/`NotEqual` against null" so that +`e.PublishIn == null` would lower to column `IS NULL`. The shadow-property +approach makes this moot — the bare null check now lands on the column +naturally, no engine change needed. Don't open this PR. + +### Sequencing + +A → C, parallelizable. Cadence on `linq2db/linq2db` (sampled 2026-05-18): +~10 PRs/week merged, **6.3.0 shipped 2026-05-17**, **6.4.0 version bump +merged 2026-05-18**. Small bug-fixes with a linked issue land same-day to +3-day; medium fixes 1–2 weeks. Issue-first is convention; PR titles read +`Fix #NNNN: `. Maintainers worth tagging: `MaceWindu` (release +management + cross-provider review), `igor-tkachev` (original author), +`sdanyliv` (most active feature contributor). + +If we file A + C, realistic landing window is 1–2 weeks each, with a shot +at the 6.4.x line. + +--- + +## Files + +- `MiniLcm/Models/Entry.cs`, `MiniLcm/Models/Sense.cs` — shadow properties. +- `LcmCrdt/LcmCrdtKernel.cs` — `IsExpression` registration and the rewrite + factories (`SenseSemanticDomainRowsExpression`, + `EntryPublishInRowsExpression`). +- `LcmCrdt/Json.cs` — `Sql.Alias` peel; `TRY_CONVERT` direct construction. +- `LcmCrdt/EntryFilterMapProvider.cs` — projections routed through shadow + properties; converters use `NormalizeEmptyToEmptyList`. +- `LcmCrdt.Tests/MiniLcmTests/QueryEntryTests.cs:79` — perf-test assertion + margin (history of past adjustments in comments above the line). + +--- + +## Trade-offs + +- The shadow accessor is a soft lie in client context — anyone calling + `entry.PublishInRows` from non-query code gets the underlying list, not a + `json_each` table. Surface area is tiny (two properties, marked + `[NotMapped, JsonIgnore]`) and the comment on each points back here. +- The shape leaks a query-engine concern into `MiniLcm`'s shared domain + model. Acceptable cost given the alternative (waiting on PR A or shipping + the `.ToList()` workaround with 9 red tests). +- If PR A lands and we upgrade to a fixed linq2db release, the shadow + properties can be collapsed back into bare `[ExpressionMethod]` on the + underlying columns. Not load-bearing for lexbox. + +--- + +## Links + +- Linq To DB 6 migration guide: + <https://github.com/linq2db/linq2db/wiki/Linq-To-DB-6> +- Eager-load refactor in flight (targets 6.4.0) — may fix or change this: + <https://github.com/linq2db/linq2db/pull/5450> +- `ExpressionBuilder.EagerLoad.cs` (the file the stack trace points into): + <https://github.com/linq2db/linq2db/blob/master/Source/LinqToDB/Internal/Linq/Builder/ExpressionBuilder.EagerLoad.cs> +- Related (similar v6 `[ExpressionMethod]` regressions, all fixed — none + match our pair of symptoms but useful as precedent): + - <https://github.com/linq2db/linq2db/issues/4613> + - <https://github.com/linq2db/linq2db/issues/4977> + - <https://github.com/linq2db/linq2db/issues/5040> + - <https://github.com/linq2db/linq2db/issues/5254> +- Local upstream-side scratch (PR drafts, repro shapes for A and C): + `D:\code\linq2db\UPSTREAM-PLAN.md` (outside this repo). 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 @@ <ItemGroup> <PackageReference Include="Gridify.EntityFramework" /> <PackageReference Include="Humanizer.Core" /> - <PackageReference Include="linq2db.AspNet" /> <PackageReference Include="linq2db.EntityFrameworkCore" /> + <PackageReference Include="linq2db.Extensions" /> <PackageReference Include="Microsoft.EntityFrameworkCore.Design"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> diff --git a/backend/FwLite/LcmCrdt/LcmCrdtKernel.cs b/backend/FwLite/LcmCrdt/LcmCrdtKernel.cs index 9d98c1ebbf..1ab81cf990 100644 --- a/backend/FwLite/LcmCrdt/LcmCrdtKernel.cs +++ b/backend/FwLite/LcmCrdt/LcmCrdtKernel.cs @@ -12,9 +12,9 @@ using LcmCrdt.Objects; using LcmCrdt.RemoteSync; using LinqToDB; -using LinqToDB.AspNet.Logging; using LinqToDB.Data; using LinqToDB.EntityFrameworkCore; +using LinqToDB.Extensions.Logging; using LinqToDB.Mapping; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; @@ -130,16 +130,19 @@ public static void ConfigureDbOptions(IServiceProvider provider, DbContextOption nameof(Commit.HybridDateTime) + "." + nameof(HybridDateTime.DateTime))) .HasAttribute<Commit>(new ColumnAttribute(nameof(HybridDateTime.Counter), nameof(Commit.HybridDateTime) + "." + nameof(HybridDateTime.Counter))) - //tells linq2db to rewrite Sense.SemanticDomains, into Json.Query(Sense.SemanticDomains) - .Entity<Sense>().Property(s => s.SemanticDomains).HasAttribute(new ExpressionMethodAttribute(SenseSemanticDomainsExpression())) - .Entity<Entry>().Property(e => e.PublishIn).HasAttribute(new ExpressionMethodAttribute(EntryPublishInExpression())) + //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(); mappingSchema.SetConvertExpression((WritingSystemId id) => new DataParameter { Value = id.Code, DataType = DataType.Text }); optionsBuilder.AddMappingSchema(mappingSchema); - optionsBuilder.AddCustomOptions(options => options.UseSQLiteMicrosoft()); + optionsBuilder.AddCustomOptions(options => options.UseSQLite()); // Register read-relevant interceptors for LinqToDB var sqliteFunctionInterceptor = new CustomSqliteFunctionInterceptor(); @@ -161,18 +164,13 @@ public static void ConfigureDbOptions(IServiceProvider provider, DbContextOption builder.AddInterceptors(updateSearchTableInterceptor); } - private static Expression<Func<Sense, IQueryable<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))); - } - - private static Expression<Func<Entry, IQueryable<Publication>>> EntryPublishInExpression() - { - //using Sql.Property, otherwise if we used `e.PublishIn` again it would be recursively rewritten - return e => Json.Query(Sql.Property<IList<Publication>>(e, nameof(Entry.PublishIn))); - } + //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, IEnumerable<Publication>>> EntryPublishInRowsExpression() + => e => Json.Query(Sql.Property<IList<Publication>>(e, nameof(Entry.PublishIn))); public static void ConfigureCrdt(CrdtConfig config) { @@ -263,7 +261,7 @@ public static void ConfigureCrdt(CrdtConfig config) list => JsonSerializer.Serialize(list, (JsonSerializerOptions?)null), json => JsonSerializer.Deserialize<ViewField[]>(json, (JsonSerializerOptions?)null) ?? Array.Empty<ViewField>()); - var writingSystemArrayConverter = new ValueConverter<ViewWritingSystem[]?, string?>( + var writingSystemArrayConverter = new Microsoft.EntityFrameworkCore.Storage.ValueConversion.ValueConverter<ViewWritingSystem[]?, string?>( list => JsonSerializer.Serialize(list, (JsonSerializerOptions?)null), json => json == null ? null : JsonSerializer.Deserialize<ViewWritingSystem[]>(json, (JsonSerializerOptions?)null)); builder.Property(v => v.Vernacular) diff --git a/backend/FwLite/LcmCrdt/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<Dictionary<string, object>>("HybridDateTime", "SIL.Harmony.Commit.HybridDateTime#HybridDateTime", b1 => + b.ComplexProperty(typeof(Dictionary<string, object>), "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 => diff --git a/backend/FwLite/LcmCrdt/Objects/DbTranslationDeserializationTarget.cs b/backend/FwLite/LcmCrdt/Objects/DbTranslationDeserializationTarget.cs index a249a5cd10..de5a947a27 100644 --- a/backend/FwLite/LcmCrdt/Objects/DbTranslationDeserializationTarget.cs +++ b/backend/FwLite/LcmCrdt/Objects/DbTranslationDeserializationTarget.cs @@ -1,6 +1,6 @@ using System.Text.Json; using System.Text.Json.Serialization; -using LinqToDB.Common; +using LinqToDB.Internal.Common; namespace LcmCrdt.Objects; diff --git a/backend/FwLite/MiniLcm.Tests/FluentAssertGlobalConfig.cs b/backend/FwLite/MiniLcm.Tests/FluentAssertGlobalConfig.cs index 3cfb18764d..26d04eb4d2 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; @@ -16,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))) ); } } 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.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] 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/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 @@ <PackageReference Include="Gridify" /> <PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" /> <PackageReference Include="SIL.WritingSystems" /> - <PackageReference Include="System.Text.Json" /> <PackageReference Include="SystemTextJson.JsonDiffPatch" /> <PackageReference Include="SystemTextJsonPatch" /> <PackageReference Include="System.Linq.Async" /> diff --git a/backend/FwLite/MiniLcm/Models/Entry.cs b/backend/FwLite/MiniLcm/Models/Entry.cs index 550080e628..1871ea4dad 100644 --- a/backend/FwLite/MiniLcm/Models/Entry.cs +++ b/backend/FwLite/MiniLcm/Models/Entry.cs @@ -1,3 +1,8 @@ +using System.ComponentModel; +using System.ComponentModel.DataAnnotations.Schema; +using System.Text.Json.Serialization; +using MiniLcm.Attributes; + namespace MiniLcm.Models; public record Entry : IObjectWithId<Entry> @@ -29,6 +34,13 @@ public record Entry : IObjectWithId<Entry> public virtual List<Publication> PublishIn { get; set; } = []; + //Server-side query rewrite target — LcmCrdt lowers this to json_each(PublishIn) at query time. + //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)"; public string Headword() 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<RichString>() + ?? 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); } diff --git a/backend/FwLite/MiniLcm/Models/Sense.cs b/backend/FwLite/MiniLcm/Models/Sense.cs index 3c4016f13e..b4938dc867 100644 --- a/backend/FwLite/MiniLcm/Models/Sense.cs +++ b/backend/FwLite/MiniLcm/Models/Sense.cs @@ -1,3 +1,5 @@ +using System.ComponentModel; +using System.ComponentModel.DataAnnotations.Schema; using System.Text.Json; using System.Text.Json.Serialization; using MiniLcm.Attributes; @@ -18,6 +20,10 @@ public class Sense : IObjectWithId<Sense>, IOrderable public virtual PartOfSpeech? PartOfSpeech { get; set; } = null; public virtual Guid? PartOfSpeechId { get; set; } public virtual IList<SemanticDomain> SemanticDomains { get; set; } = []; + + //Server-side query rewrite target — see Entry.PublishInRows. + [MiniLcmInternal, NotMapped, JsonIgnore, EditorBrowsable(EditorBrowsableState.Never)] + public IEnumerable<SemanticDomain> SemanticDomainRows => SemanticDomains; public virtual List<ExampleSentence> ExampleSentences { get; set; } = []; public Guid[] GetReferences() diff --git a/backend/LexBoxApi/Auth/Attributes/LexboxAuthAttribute.cs b/backend/LexBoxApi/Auth/Attributes/LexboxAuthAttribute.cs index edf8c7ef19..f5cf679b32 100644 --- a/backend/LexBoxApi/Auth/Attributes/LexboxAuthAttribute.cs +++ b/backend/LexBoxApi/Auth/Attributes/LexboxAuthAttribute.cs @@ -28,8 +28,8 @@ public string Policy protected override void TryConfigure(IDescriptorContext context, IDescriptor descriptor, - ICustomAttributeProvider element) + ICustomAttributeProvider? element) { - ApplyAttribute(context, descriptor, element, new AuthorizeAttribute(Policy)); + if (element is not null) ApplyAttribute(context, descriptor, element, new AuthorizeAttribute(Policy)); } } 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<string>() + new OpenApiSecuritySchemeReference("Bearer"), + [] } }); }); 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<Dictionary<string, bool>> LoadBatch(IsLanguageForgePro IReadOnlyList<string> 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<LfProject>.Filter.In(p => p.ProjectCode, list)) + .Project(p => p.ProjectCode) + .ToListAsync(cancellationToken: token) + ).ToHashSet(); return list.ToDictionary(pc => pc, pc => actualProjects.Contains(pc)); } 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 @@ <Project Sdk="Microsoft.NET.Sdk.Web"> <PropertyGroup> <DockerDefaultTargetOS>Linux</DockerDefaultTargetOS> - <IncludeOpenAPIAnalyzers>true</IncludeOpenAPIAnalyzers> <UserSecretsId>7392cddf-9b3b-441c-9316-203bb5c4a6bc</UserSecretsId> <GarbageCollectionAdaptationMode>1</GarbageCollectionAdaptationMode> </PropertyGroup> @@ -50,7 +49,6 @@ <PackageReference Include="Quartz.Serialization.SystemTextJson" /> <PackageReference Include="Swashbuckle.AspNetCore" /> <PackageReference Include="System.Linq.Async" /> - <PackageReference Include="System.Text.Encodings.Web" /> <PackageReference Include="tusdotnet" /> <PackageReference Include="zxcvbn-core" /> </ItemGroup> diff --git a/backend/LexBoxApi/Program.cs b/backend/LexBoxApi/Program.cs index 9898ef873d..dafe074a2f 100644 --- a/backend/LexBoxApi/Program.cs +++ b/backend/LexBoxApi/Program.cs @@ -21,9 +21,8 @@ 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; 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<TusService>().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/LexBoxApi/Services/CrdtCommitService.cs b/backend/LexBoxApi/Services/CrdtCommitService.cs index eed0dcc33e..d4ec51f8c9 100644 --- a/backend/LexBoxApi/Services/CrdtCommitService.cs +++ b/backend/LexBoxApi/Services/CrdtCommitService.cs @@ -1,6 +1,7 @@ using LexCore.Utils; using LexData; using LinqToDB; +using LinqToDB.Async; using LinqToDB.Data; using LinqToDB.EntityFrameworkCore; using LinqToDB.EntityFrameworkCore.Internal; @@ -15,26 +16,18 @@ public async Task AddCommits(Guid projectId, IAsyncEnumerable<ServerCommit> comm await using var transaction = await dbContext.Database.BeginTransactionAsync(token); var linqToDbContext = dbContext.CreateLinqToDBContext(); await using var tmpTable = await linqToDbContext.CreateTempTableAsync<ServerCommit>($"tmp_crdt_commit_import_{projectId}__{Guid.NewGuid()}", cancellationToken: token); - await tmpTable.BulkCopyAsync(new BulkCopyOptions{BulkCopyType = BulkCopyType.ProviderSpecific, MaxBatchSize = 10}, commits, token); + //Stamp ProjectId while streaming so the merge below can be a plain column-to-column copy. + //A projection lambda here would let linq2db v6 wrap our Sql.Expr<...>::jsonb cast in the + //EF value-converter (JsonSerializer.Serialize) and fail SQL translation. + var stampedCommits = commits.Select(c => { c.ProjectId = projectId; return c; }); + await tmpTable.BulkCopyAsync(new BulkCopyOptions{BulkCopyType = BulkCopyType.ProviderSpecific, MaxBatchSize = 10}, stampedCommits, token); var commitsTable = linqToDbContext.GetTable<ServerCommit>(); await commitsTable .Merge() .Using(tmpTable) .OnTargetKey() - .InsertWhenNotMatched(commit => new ServerCommit(commit.Id) - { - Id = commit.Id, - ClientId = commit.ClientId, - HybridDateTime = new HybridDateTime(commit.HybridDateTime.DateTime, commit.HybridDateTime.Counter) - { - DateTime = commit.HybridDateTime.DateTime, Counter = commit.HybridDateTime.Counter - }, - ProjectId = projectId, - Metadata = commit.Metadata, - //without this sql cast the value will be treated as text and fail to insert into the jsonb column - ChangeEntities = Sql.Expr<List<ChangeEntity<ServerJsonChange>>>($"{commit.ChangeEntities}::jsonb") - }) + .InsertWhenNotMatched() .MergeAsync(token); await transaction.CommitAsync(token); diff --git a/backend/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<User> 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", 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/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) }; } diff --git a/backend/LexData/LexData.csproj b/backend/LexData/LexData.csproj index 9c5454bf25..54e2e970ea 100644 --- a/backend/LexData/LexData.csproj +++ b/backend/LexData/LexData.csproj @@ -2,8 +2,8 @@ <ItemGroup> <PackageReference Include="AppAny.Quartz.EntityFrameworkCore.Migrations.PostgreSQL" /> <PackageReference Include="Humanizer.Core" /> - <PackageReference Include="linq2db.AspNet" /> <PackageReference Include="linq2db.EntityFrameworkCore" /> + <PackageReference Include="linq2db.Extensions" /> <PackageReference Include="Microsoft.EntityFrameworkCore" /> <PackageReference Include="Microsoft.EntityFrameworkCore.Design"> <PrivateAssets>all</PrivateAssets> diff --git a/backend/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 @@ +// <auto-generated /> +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 + { + /// <inheritdoc /> + 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<string>("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property<string>("TriggerName") + .HasColumnType("text") + .HasColumnName("trigger_name"); + + b.Property<string>("TriggerGroup") + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.Property<byte[]>("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<string>("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property<string>("CalendarName") + .HasColumnType("text") + .HasColumnName("calendar_name"); + + b.Property<byte[]>("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<string>("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property<string>("TriggerName") + .HasColumnType("text") + .HasColumnName("trigger_name"); + + b.Property<string>("TriggerGroup") + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.Property<string>("CronExpression") + .IsRequired() + .HasColumnType("text") + .HasColumnName("cron_expression"); + + b.Property<string>("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<string>("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property<string>("EntryId") + .HasColumnType("text") + .HasColumnName("entry_id"); + + b.Property<long>("FiredTime") + .HasColumnType("bigint") + .HasColumnName("fired_time"); + + b.Property<string>("InstanceName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("instance_name"); + + b.Property<bool>("IsNonConcurrent") + .HasColumnType("bool") + .HasColumnName("is_nonconcurrent"); + + b.Property<string>("JobGroup") + .HasColumnType("text") + .HasColumnName("job_group"); + + b.Property<string>("JobName") + .HasColumnType("text") + .HasColumnName("job_name"); + + b.Property<int>("Priority") + .HasColumnType("integer") + .HasColumnName("priority"); + + b.Property<bool?>("RequestsRecovery") + .HasColumnType("bool") + .HasColumnName("requests_recovery"); + + b.Property<long>("ScheduledTime") + .HasColumnType("bigint") + .HasColumnName("sched_time"); + + b.Property<string>("State") + .IsRequired() + .HasColumnType("text") + .HasColumnName("state"); + + b.Property<string>("TriggerGroup") + .IsRequired() + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.Property<string>("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<string>("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property<string>("JobName") + .HasColumnType("text") + .HasColumnName("job_name"); + + b.Property<string>("JobGroup") + .HasColumnType("text") + .HasColumnName("job_group"); + + b.Property<string>("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property<bool>("IsDurable") + .HasColumnType("bool") + .HasColumnName("is_durable"); + + b.Property<bool>("IsNonConcurrent") + .HasColumnType("bool") + .HasColumnName("is_nonconcurrent"); + + b.Property<bool>("IsUpdateData") + .HasColumnType("bool") + .HasColumnName("is_update_data"); + + b.Property<string>("JobClassName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("job_class_name"); + + b.Property<byte[]>("JobData") + .HasColumnType("bytea") + .HasColumnName("job_data"); + + b.Property<bool>("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<string>("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property<string>("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<string>("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property<string>("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<string>("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property<string>("InstanceName") + .HasColumnType("text") + .HasColumnName("instance_name"); + + b.Property<long>("CheckInInterval") + .HasColumnType("bigint") + .HasColumnName("checkin_interval"); + + b.Property<long>("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<string>("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property<string>("TriggerName") + .HasColumnType("text") + .HasColumnName("trigger_name"); + + b.Property<string>("TriggerGroup") + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.Property<bool?>("BooleanProperty1") + .HasColumnType("bool") + .HasColumnName("bool_prop_1"); + + b.Property<bool?>("BooleanProperty2") + .HasColumnType("bool") + .HasColumnName("bool_prop_2"); + + b.Property<decimal?>("DecimalProperty1") + .HasColumnType("numeric") + .HasColumnName("dec_prop_1"); + + b.Property<decimal?>("DecimalProperty2") + .HasColumnType("numeric") + .HasColumnName("dec_prop_2"); + + b.Property<int?>("IntegerProperty1") + .HasColumnType("integer") + .HasColumnName("int_prop_1"); + + b.Property<int?>("IntegerProperty2") + .HasColumnType("integer") + .HasColumnName("int_prop_2"); + + b.Property<long?>("LongProperty1") + .HasColumnType("bigint") + .HasColumnName("long_prop_1"); + + b.Property<long?>("LongProperty2") + .HasColumnType("bigint") + .HasColumnName("long_prop_2"); + + b.Property<string>("StringProperty1") + .HasColumnType("text") + .HasColumnName("str_prop_1"); + + b.Property<string>("StringProperty2") + .HasColumnType("text") + .HasColumnName("str_prop_2"); + + b.Property<string>("StringProperty3") + .HasColumnType("text") + .HasColumnName("str_prop_3"); + + b.Property<string>("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<string>("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property<string>("TriggerName") + .HasColumnType("text") + .HasColumnName("trigger_name"); + + b.Property<string>("TriggerGroup") + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.Property<long>("RepeatCount") + .HasColumnType("bigint") + .HasColumnName("repeat_count"); + + b.Property<long>("RepeatInterval") + .HasColumnType("bigint") + .HasColumnName("repeat_interval"); + + b.Property<long>("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<string>("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property<string>("TriggerName") + .HasColumnType("text") + .HasColumnName("trigger_name"); + + b.Property<string>("TriggerGroup") + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.Property<string>("CalendarName") + .HasColumnType("text") + .HasColumnName("calendar_name"); + + b.Property<string>("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property<long?>("EndTime") + .HasColumnType("bigint") + .HasColumnName("end_time"); + + b.Property<byte[]>("JobData") + .HasColumnType("bytea") + .HasColumnName("job_data"); + + b.Property<string>("JobGroup") + .IsRequired() + .HasColumnType("text") + .HasColumnName("job_group"); + + b.Property<string>("JobName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("job_name"); + + b.Property<short?>("MisfireInstruction") + .HasColumnType("smallint") + .HasColumnName("misfire_instr"); + + b.Property<long?>("MisfireOriginalFireTime") + .HasColumnType("bigint") + .HasColumnName("misfire_orig_fire_time"); + + b.Property<long?>("NextFireTime") + .HasColumnType("bigint") + .HasColumnName("next_fire_time"); + + b.Property<long?>("PreviousFireTime") + .HasColumnType("bigint") + .HasColumnName("prev_fire_time"); + + b.Property<int?>("Priority") + .HasColumnType("integer") + .HasColumnName("priority"); + + b.Property<long>("StartTime") + .HasColumnType("bigint") + .HasColumnName("start_time"); + + b.Property<string>("TriggerState") + .IsRequired() + .HasColumnType("text") + .HasColumnName("trigger_state"); + + b.Property<string>("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<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property<string>("Code") + .IsRequired() + .HasColumnType("text"); + + b.Property<DateTimeOffset>("CreatedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property<string>("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property<bool?>("IsConfidential") + .HasColumnType("boolean"); + + b.Property<string>("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property<Guid?>("OrgId") + .HasColumnType("uuid"); + + b.Property<Guid?>("ProjectManagerId") + .HasColumnType("uuid"); + + b.Property<int>("RetentionPolicy") + .HasColumnType("integer"); + + b.Property<int>("Type") + .HasColumnType("integer"); + + b.Property<DateTimeOffset>("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<Guid>("ProjectId") + .HasColumnType("uuid"); + + b.Property<int?>("FlexModelVersion") + .HasColumnType("integer"); + + b.Property<Guid?>("LangProjectId") + .HasColumnType("uuid"); + + b.Property<int?>("LexEntryCount") + .HasColumnType("integer"); + + b.HasKey("ProjectId"); + + b.ToTable("FlexProjectMetadata"); + }); + + modelBuilder.Entity("LexCore.Entities.MediaFile", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property<DateTimeOffset>("CreatedDate") + .HasColumnType("timestamp with time zone"); + + b.Property<string>("Filename") + .IsRequired() + .HasColumnType("text"); + + b.Property<string>("Metadata") + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValueSql("'{}'"); + + b.Property<Guid>("ProjectId") + .HasColumnType("uuid"); + + b.Property<DateTimeOffset>("UpdatedDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ProjectId"); + + b.ToTable("Files"); + }); + + modelBuilder.Entity("LexCore.Entities.OrgMember", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property<DateTimeOffset>("CreatedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property<Guid>("OrgId") + .HasColumnType("uuid"); + + b.Property<int>("Role") + .HasColumnType("integer"); + + b.Property<DateTimeOffset>("UpdatedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property<Guid>("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<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property<DateTimeOffset>("CreatedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property<Guid>("OrgId") + .HasColumnType("uuid"); + + b.Property<Guid>("ProjectId") + .HasColumnType("uuid"); + + b.Property<DateTimeOffset>("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<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property<DateTimeOffset>("CreatedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property<string>("Name") + .IsRequired() + .HasColumnType("text") + .UseCollation("case_insensitive"); + + b.Property<DateTimeOffset>("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<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property<string>("Code") + .IsRequired() + .HasColumnType("text"); + + b.Property<DateTimeOffset>("CreatedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property<DateTimeOffset?>("DeletedDate") + .HasColumnType("timestamp with time zone"); + + b.Property<string>("Description") + .HasColumnType("text"); + + b.Property<bool?>("IsConfidential") + .HasColumnType("boolean"); + + b.Property<DateTimeOffset?>("LastCommit") + .HasColumnType("timestamp with time zone"); + + b.Property<DateTimeOffset?>("MigratedDate") + .HasColumnType("timestamp with time zone"); + + b.Property<string>("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property<Guid?>("ParentId") + .HasColumnType("uuid"); + + b.Property<int>("ProjectOrigin") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(1); + + b.Property<int?>("RepoSizeInKb") + .HasColumnType("integer"); + + b.Property<int>("ResetStatus") + .HasColumnType("integer"); + + b.Property<int>("RetentionPolicy") + .HasColumnType("integer"); + + b.Property<int>("Type") + .HasColumnType("integer"); + + b.Property<DateTimeOffset>("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<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property<DateTimeOffset>("CreatedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property<Guid>("ProjectId") + .HasColumnType("uuid"); + + b.Property<int>("Role") + .HasColumnType("integer"); + + b.Property<DateTimeOffset>("UpdatedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property<Guid>("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<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property<bool>("CanCreateProjects") + .HasColumnType("boolean"); + + b.Property<Guid?>("CreatedById") + .HasColumnType("uuid"); + + b.Property<DateTimeOffset>("CreatedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property<string>("Email") + .HasColumnType("text") + .UseCollation("case_insensitive"); + + b.Property<bool>("EmailVerified") + .HasColumnType("boolean"); + + b.Property<List<string>>("FeatureFlags") + .HasColumnType("text[]"); + + b.Property<string>("GoogleId") + .HasColumnType("text"); + + b.Property<bool>("IsAdmin") + .HasColumnType("boolean"); + + b.Property<DateTimeOffset>("LastActive") + .HasColumnType("timestamp with time zone"); + + b.Property<string>("LocalizationCode") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("en"); + + b.Property<bool>("Locked") + .HasColumnType("boolean"); + + b.Property<string>("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property<string>("PasswordHash") + .IsRequired() + .HasColumnType("text"); + + b.Property<int?>("PasswordStrength") + .HasColumnType("integer"); + + b.Property<string>("Salt") + .IsRequired() + .HasColumnType("text"); + + b.Property<DateTimeOffset>("UpdatedDate") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property<string>("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<string>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property<string>("ApplicationType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property<string>("ClientId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property<string>("ClientSecret") + .HasColumnType("text"); + + b.Property<string>("ClientType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property<string>("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property<string>("ConsentType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property<string>("DisplayName") + .HasColumnType("text"); + + b.Property<string>("DisplayNames") + .HasColumnType("text"); + + b.Property<string>("JsonWebKeySet") + .HasColumnType("text"); + + b.Property<string>("Permissions") + .HasColumnType("text"); + + b.Property<string>("PostLogoutRedirectUris") + .HasColumnType("text"); + + b.Property<string>("Properties") + .HasColumnType("text"); + + b.Property<string>("RedirectUris") + .HasColumnType("text"); + + b.Property<string>("Requirements") + .HasColumnType("text"); + + b.Property<string>("Settings") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ClientId") + .IsUnique(); + + b.ToTable("OpenIddictApplications", (string)null); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b => + { + b.Property<string>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property<string>("ApplicationId") + .HasColumnType("text"); + + b.Property<string>("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property<DateTime?>("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property<string>("Properties") + .HasColumnType("text"); + + b.Property<string>("Scopes") + .HasColumnType("text"); + + b.Property<string>("Status") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property<string>("Subject") + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property<string>("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<string>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property<string>("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property<string>("Description") + .HasColumnType("text"); + + b.Property<string>("Descriptions") + .HasColumnType("text"); + + b.Property<string>("DisplayName") + .HasColumnType("text"); + + b.Property<string>("DisplayNames") + .HasColumnType("text"); + + b.Property<string>("Name") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property<string>("Properties") + .HasColumnType("text"); + + b.Property<string>("Resources") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("OpenIddictScopes", (string)null); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreToken", b => + { + b.Property<string>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property<string>("ApplicationId") + .HasColumnType("text"); + + b.Property<string>("AuthorizationId") + .HasColumnType("text"); + + b.Property<string>("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property<DateTime?>("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property<DateTime?>("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property<string>("Payload") + .HasColumnType("text"); + + b.Property<string>("Properties") + .HasColumnType("text"); + + b.Property<DateTime?>("RedemptionDate") + .HasColumnType("timestamp with time zone"); + + b.Property<string>("ReferenceId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property<string>("Status") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property<string>("Subject") + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property<string>("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<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property<string>("ChangeEntities") + .HasColumnType("jsonb"); + + b.Property<Guid>("ClientId") + .HasColumnType("uuid"); + + b.Property<string>("Metadata") + .IsRequired() + .HasColumnType("text"); + + b.Property<Guid>("ProjectId") + .HasColumnType("uuid"); + + b.ComplexProperty(typeof(Dictionary<string, object>), "HybridDateTime", "SIL.Harmony.Core.ServerCommit.HybridDateTime#HybridDateTime", b1 => + { + b1.IsRequired(); + + b1.Property<long>("Counter") + .HasColumnType("bigint"); + + b1.Property<DateTimeOffset>("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<Guid>("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<Guid>("ProjectWritingSystemsFlexProjectMetadataProjectId"); + + b2.Property<int>("__synthesizedOrdinal") + .ValueGeneratedOnAdd(); + + b2.Property<bool>("IsActive"); + + b2.Property<bool>("IsDefault"); + + b2.Property<string>("Tag") + .IsRequired(); + + b2.HasKey("ProjectWritingSystemsFlexProjectMetadataProjectId", "__synthesizedOrdinal"); + + b2.ToTable("FlexProjectMetadata"); + + b2.WithOwner() + .HasForeignKey("ProjectWritingSystemsFlexProjectMetadataProjectId"); + }); + + b1.OwnsMany("LexCore.Entities.FLExWsId", "VernacularWss", b2 => + { + b2.Property<Guid>("ProjectWritingSystemsFlexProjectMetadataProjectId"); + + b2.Property<int>("__synthesizedOrdinal") + .ValueGeneratedOnAdd(); + + b2.Property<bool>("IsActive"); + + b2.Property<bool>("IsDefault"); + + b2.Property<string>("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 +{ + /// <inheritdoc /> + public partial class QuartzDatabaseUpdates : Migration + { + /// <inheritdoc /> + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn<long>( + name: "misfire_orig_fire_time", + schema: "quartz", + table: "qrtz_triggers", + type: "bigint", + nullable: true); + + migrationBuilder.AlterColumn<string>( + name: "Type", + table: "OpenIddictTokens", + type: "character varying(150)", + maxLength: 150, + nullable: true, + oldClrType: typeof(string), + oldType: "character varying(50)", + oldMaxLength: 50, + oldNullable: true); + } + + /// <inheritdoc /> + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "misfire_orig_fire_time", + schema: "quartz", + table: "qrtz_triggers"); + + migrationBuilder.AlterColumn<string>( + 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<long?>("MisfireOriginalFireTime") + .HasColumnType("bigint") + .HasColumnName("misfire_orig_fire_time"); + b.Property<long?>("NextFireTime") .HasColumnType("bigint") .HasColumnName("next_fire_time"); @@ -1045,8 +1049,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("character varying(400)"); b.Property<string>("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<Guid>("ProjectId") .HasColumnType("uuid"); - b.ComplexProperty<Dictionary<string, object>>("HybridDateTime", "SIL.Harmony.Core.ServerCommit.HybridDateTime#HybridDateTime", b1 => + b.ComplexProperty(typeof(Dictionary<string, object>), "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<Guid>("FlexProjectMetadataProjectId") - .HasColumnType("uuid"); + b1.Property<Guid>("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<Guid>("ProjectWritingSystemsFlexProjectMetadataProjectId") - .HasColumnType("uuid"); + b2.Property<Guid>("ProjectWritingSystemsFlexProjectMetadataProjectId"); b2.Property<int>("__synthesizedOrdinal") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); + .ValueGeneratedOnAdd(); - b2.Property<bool>("IsActive") - .HasColumnType("boolean"); + b2.Property<bool>("IsActive"); - b2.Property<bool>("IsDefault") - .HasColumnType("boolean"); + b2.Property<bool>("IsDefault"); b2.Property<string>("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<Guid>("ProjectWritingSystemsFlexProjectMetadataProjectId") - .HasColumnType("uuid"); + b2.Property<Guid>("ProjectWritingSystemsFlexProjectMetadataProjectId"); b2.Property<int>("__synthesizedOrdinal") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); + .ValueGeneratedOnAdd(); - b2.Property<bool>("IsActive") - .HasColumnType("boolean"); + b2.Property<bool>("IsActive"); - b2.Property<bool>("IsDefault") - .HasColumnType("boolean"); + b2.Property<bool>("IsDefault"); b2.Property<string>("Tag") - .IsRequired() - .HasColumnType("text"); + .IsRequired(); b2.HasKey("ProjectWritingSystemsFlexProjectMetadataProjectId", "__synthesizedOrdinal"); 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 @@ <Project Sdk="Microsoft.NET.Sdk"> <ItemGroup> <FrameworkReference Include="Microsoft.AspNetCore.App" /> - <PackageReference Include="Microsoft.Extensions.Configuration.Binder" /> - <PackageReference Include="Microsoft.Extensions.Options" /> <PackageReference Include="MongoDB.Analyzer" /> <PackageReference Include="MongoDB.Driver" /> <PackageReference Include="MongoDB.Driver.Core.Extensions.DiagnosticSources" /> 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 @@ </PropertyGroup> <ItemGroup> <PackageReference Include="Microsoft.AspNetCore.OpenApi" /> - <PackageReference Include="Microsoft.Extensions.Options" /> <PackageReference Include="Swashbuckle.AspNetCore" /> <PackageReference Include="System.IdentityModel.Tokens.Jwt" /> <PackageReference Include="Yarp.ReverseProxy" /> 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; diff --git a/backend/Testing/LexCore/Services/CrdtCommitServiceTests.cs b/backend/Testing/LexCore/Services/CrdtCommitServiceTests.cs index adcdad5bd6..cfd853c6c9 100644 --- a/backend/Testing/LexCore/Services/CrdtCommitServiceTests.cs +++ b/backend/Testing/LexCore/Services/CrdtCommitServiceTests.cs @@ -66,7 +66,6 @@ public async Task CanQueryOldCommits() { var projectId = await _lexBoxDbContext.Projects.Select(p => p.Id).FirstOrDefaultAsync(); var context = _lexBoxDbContext.CreateLinqToDBContext(); - var table = LinqToDB.DataExtensions.GetTable<ServerCommit>(context); var commitId = Guid.NewGuid(); var changeEntity = new ChangeEntity<ServerJsonChange> { @@ -87,21 +86,23 @@ public async Task CanQueryOldCommits() //the old format stored json in json, this is emulating that. changeEntityJson["Change"] = changeEntityJson["Change"]?.ToJsonString(); var jsonPayload = changeEntityJson.ToJsonString(); - var inlineSql = $"'[{jsonPayload}]'::jsonb"; - //insert a new server commit, manually specifying the value for ChangeEntities so it will match the old format. - await LinqToDB.LinqExtensions.InsertAsync(table, () => new ServerCommit(commitId) - { - Id = commitId, - ClientId = Guid.NewGuid(), - HybridDateTime = new HybridDateTime(DateTimeOffset.UtcNow, 0) - { - DateTime = DateTimeOffset.UtcNow, - Counter = 0 - }, - ProjectId = projectId, - Metadata = new CommitMetadata(), - ChangeEntities = LinqToDB.Sql.Expr<List<ChangeEntity<ServerJsonChange>>>(inlineSql) - }); + //Insert a synthetic old-format commit via raw SQL so we can put pre-serialized + //JSON in ChangeEntities. Linq2Db v6 unconditionally wraps any column assignment + //(including Sql.Expr) in the EF JSON value converter inside an InsertAsync + //projection lambda, so we can't use the typed API for this test case. + var inlinePayload = $"[{jsonPayload}]"; + await LinqToDB.Data.DataContextExtensions.ExecuteAsync( + context, + """ + INSERT INTO "CrdtCommits" + ("Id", "ClientId", "HybridDateTime_DateTime", "HybridDateTime_Counter", "ProjectId", "Metadata", "ChangeEntities") + VALUES (@id, @clientId, @dt, 0, @projectId, '{}'::jsonb, @payload::jsonb) + """, + new LinqToDB.Data.DataParameter("id", commitId, LinqToDB.DataType.Guid), + new LinqToDB.Data.DataParameter("clientId", Guid.NewGuid(), LinqToDB.DataType.Guid), + new LinqToDB.Data.DataParameter("dt", DateTimeOffset.UtcNow, LinqToDB.DataType.DateTimeOffset), + new LinqToDB.Data.DataParameter("projectId", projectId, LinqToDB.DataType.Guid), + new LinqToDB.Data.DataParameter("payload", inlinePayload, LinqToDB.DataType.NVarChar)); var commits = await _lexBoxDbContext.CrdtCommits(projectId).ToArrayAsync(); var actualCommit = commits.Should().ContainSingle(c => c.Id == commitId).Subject; actualCommit.ChangeEntities.Should().BeEquivalentTo([changeEntity], 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