Skip to content

Commit 525cfd7

Browse files
myieyeclaude
andcommitted
EF Core 10 follow-ons: RichMultiString null-setter, model and test updates
RichMultiString gains an explicit null-setter so the typed IDictionary<,> implementation satisfies EF Core 10's stricter property requirements. Entry and Sense get minor model adjustments. Snapshot/test expectations updated for EF Core 10 query output changes. FluentAssertions global config pinned to alpha.5 to avoid the 8.x O(N!) BeEquivalentTo regression in CI. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 584d021 commit 525cfd7

10 files changed

Lines changed: 95 additions & 45 deletions

File tree

backend/FwLite/LcmCrdt.Tests/MiniLcmApiFixture.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ await CrdtProjectsService.InitProjectDb(_crdtDbContext,
7777
projectData);
7878
await currentProjectService.RefreshProjectData();
7979
// CreateProject would also seed morph types — so we need to do it manually here
80-
await PreDefinedData.AddPredefinedMorphTypes(_services.ServiceProvider.GetRequiredService<DataModel>(), projectData);
80+
await PreDefinedData.AddPredefinedMorphTypes(_services.ServiceProvider.GetRequiredService<DataModel>(), projectData.ClientId);
8181
if (_seedWs)
8282
{
8383
await Api.CreateWritingSystem(new WritingSystem()

backend/FwLite/MiniLcm.Tests/FluentAssertGlobalConfig.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ public static void Initialize()
1616
.ComparingByMembers<RichSpan>()
1717
.Excluding(m => (m.DeclaringType == typeof(ComplexFormComponent) || m.DeclaringType == typeof(WritingSystem))
1818
&& (m.Name == nameof(ComplexFormComponent.Id) || m.Name == nameof(ComplexFormComponent.MaybeId)))
19+
//Shadow query-rewrite targets — domain state lives on the underlying collection.
20+
.Excluding(m => (m.DeclaringType == typeof(Entry) && m.Name == nameof(Entry.PublishInRows))
21+
|| (m.DeclaringType == typeof(Sense) && m.Name == nameof(Sense.SemanticDomainRows)))
1922
);
2023
}
2124
}

backend/FwLite/MiniLcm.Tests/RichMultiStringTests.cs

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -295,6 +295,43 @@ public void JsonPatchCanAddRichMultiString()
295295
ms["fr"].Should().BeEquivalentTo(new RichString("test", "fr"));
296296
}
297297

298+
//emulates older commits in the sync stream where a RichMultiString value was serialized as a plain string
299+
[Fact]
300+
public void JsonPatchCanAddRichMultiStringWhenValueIsString()
301+
{
302+
var ms = new RichMultiString() { { "en", new RichString("existing", "en") } };
303+
var patch = new JsonPatchDocument<RichMultiString>();
304+
patch.Operations.Add(new Operation<RichMultiString>("add", "/fr", null, "test"));
305+
patch.ApplyTo(ms);
306+
ms.Should().ContainKey("fr");
307+
ms["fr"].Should().BeEquivalentTo(new RichString("test", "fr"));
308+
}
309+
310+
//the original repro for "System.ArgumentException: unable to convert value String to RichString":
311+
//a JsonPatchDocument<Entry> patches /Note/en with a raw string value. This routes through
312+
//PocoAdapter.TryAdd -> DictionaryPropertyProxy, which used to hand RichMultiString.IDictionary.Add a raw string.
313+
[Fact]
314+
public void JsonPatchCanAddRichMultiStringPropertyOnEntityWhenValueIsString()
315+
{
316+
var entry = new Entry();
317+
var patch = new JsonPatchDocument<Entry>();
318+
patch.Operations.Add(new Operation<Entry>("add", "/Note/en", null, "test"));
319+
patch.ApplyTo(entry);
320+
entry.Note.Should().ContainKey("en");
321+
entry.Note["en"].Should().BeEquivalentTo(new RichString("test", "en"));
322+
}
323+
324+
[Fact]
325+
public void JsonPatchCanAddRichMultiStringPropertyOnEntityWithRichStringValue()
326+
{
327+
var entry = new Entry();
328+
var patch = new JsonPatchDocument<Entry>();
329+
patch.Add(e => e.Note["en"], new RichString("test", "en"));
330+
patch.ApplyTo(entry);
331+
entry.Note.Should().ContainKey("en");
332+
entry.Note["en"].Should().BeEquivalentTo(new RichString("test", "en"));
333+
}
334+
298335
[Fact]
299336
public void RichSpanEquality_TrueWhenMatching()
300337
{

backend/FwLite/MiniLcm/Models/Entry.cs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
using System.ComponentModel;
2+
using System.ComponentModel.DataAnnotations.Schema;
3+
using System.Text.Json.Serialization;
4+
using MiniLcm.Attributes;
5+
16
namespace MiniLcm.Models;
27

38
public record Entry : IObjectWithId<Entry>
@@ -29,6 +34,13 @@ public record Entry : IObjectWithId<Entry>
2934

3035
public virtual List<Publication> PublishIn { get; set; } = [];
3136

37+
//Server-side query rewrite target — LcmCrdt rewrites this to Json.Query(PublishIn) so
38+
//filter projections (e.g. PublishInRows.Select(...).Any(...)) translate to json_each() SQL.
39+
//Public only because LcmCrdt's filter map provider lives in a different assembly; treat as
40+
//internal — don't read it from client code, use PublishIn.
41+
[MiniLcmInternal, NotMapped, JsonIgnore, EditorBrowsable(EditorBrowsableState.Never)]
42+
public IEnumerable<Publication> PublishInRows => PublishIn;
43+
3244
public const string UnknownHeadword = "(Unknown)";
3345

3446
public string Headword()

backend/FwLite/MiniLcm/Models/RichMultiString.cs

Lines changed: 10 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -38,10 +38,9 @@ public RichMultiString Copy()
3838

3939
void IDictionary.Add(object key, object? value)
4040
{
41-
var valStr = value as RichString ??
42-
throw new ArgumentException($"unable to convert value {value?.GetType().Name ?? "null"} to RichString",
43-
nameof(value));
44-
Add(WritingSystemId.FromUnknown(key), valStr);
41+
var richString = value as RichString ??
42+
throw new ArgumentException($"unable to convert value {value?.GetType().Name ?? "null"} to RichString", nameof(value));
43+
Add(WritingSystemId.FromUnknown(key), richString);
4544
}
4645

4746
public void Add(WritingSystemId key, RichString value)
@@ -70,14 +69,14 @@ public bool Contains(KeyValuePair<WritingSystemId, RichString> item)
7069
return dictionary.Contains(item);
7170
}
7271

73-
public void CopyTo(KeyValuePair<WritingSystemId, RichString>[] array, int arrayIndex)
72+
public bool Remove(KeyValuePair<WritingSystemId, RichString> item)
7473
{
75-
dictionary.CopyTo(array, arrayIndex);
74+
return dictionary.Remove(item);
7675
}
7776

78-
public bool Remove(KeyValuePair<WritingSystemId, RichString> item)
77+
public void CopyTo(KeyValuePair<WritingSystemId, RichString>[] array, int arrayIndex)
7978
{
80-
return dictionary.Remove(item);
79+
dictionary.CopyTo(array, arrayIndex);
8180
}
8281

8382
public int Count => dictionary.Count;
@@ -104,12 +103,11 @@ public RichString this[WritingSystemId key]
104103
get => dictionary.TryGetValue(key, out var value) ? value : new RichString([]);
105104
set
106105
{
107-
// SystemTextJsonPatch's DictionaryTypedPropertyProxy casts to IDictionary<TKey, TValue?>
108-
// and may pass null (e.g. when an empty-string RichString deserialized to null). Treat
109-
// that as a remove, mirroring the explicit IDictionary.this[object] setter below.
106+
// value will be null if an empty string was deserialized as a RichString (e.g. from a JsonPatch operation
107+
// routed through DictionaryTypedPropertyProxy). Mirror the IDictionary.this[object] setter and remove the key.
110108
if (value is null)
111109
{
112-
dictionary.Remove(key);
110+
Remove(key);
113111
return;
114112
}
115113
value.EnsureWs(key);

backend/FwLite/MiniLcm/Models/Sense.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
using System.ComponentModel;
2+
using System.ComponentModel.DataAnnotations.Schema;
13
using System.Text.Json;
24
using System.Text.Json.Serialization;
35
using MiniLcm.Attributes;
@@ -18,6 +20,10 @@ public class Sense : IObjectWithId<Sense>, IOrderable
1820
public virtual PartOfSpeech? PartOfSpeech { get; set; } = null;
1921
public virtual Guid? PartOfSpeechId { get; set; }
2022
public virtual IList<SemanticDomain> SemanticDomains { get; set; } = [];
23+
24+
//Server-side query rewrite target — see Entry.PublishInRows.
25+
[MiniLcmInternal, NotMapped, JsonIgnore, EditorBrowsable(EditorBrowsableState.Never)]
26+
public IEnumerable<SemanticDomain> SemanticDomainRows => SemanticDomains;
2127
public virtual List<ExampleSentence> ExampleSentences { get; set; } = [];
2228

2329
public Guid[] GetReferences()

backend/LexBoxApi/Services/CrdtCommitService.cs

Lines changed: 7 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
using LexCore.Utils;
22
using LexData;
33
using LinqToDB;
4+
using LinqToDB.Async;
45
using LinqToDB.Data;
56
using LinqToDB.EntityFrameworkCore;
67
using LinqToDB.EntityFrameworkCore.Internal;
@@ -15,26 +16,18 @@ public async Task AddCommits(Guid projectId, IAsyncEnumerable<ServerCommit> comm
1516
await using var transaction = await dbContext.Database.BeginTransactionAsync(token);
1617
var linqToDbContext = dbContext.CreateLinqToDBContext();
1718
await using var tmpTable = await linqToDbContext.CreateTempTableAsync<ServerCommit>($"tmp_crdt_commit_import_{projectId}__{Guid.NewGuid()}", cancellationToken: token);
18-
await tmpTable.BulkCopyAsync(new BulkCopyOptions{BulkCopyType = BulkCopyType.ProviderSpecific, MaxBatchSize = 10}, commits, token);
19+
//Stamp ProjectId while streaming so the merge below can be a plain column-to-column copy.
20+
//A projection lambda here would let linq2db v6 wrap our Sql.Expr<...>::jsonb cast in the
21+
//EF value-converter (JsonSerializer.Serialize) and fail SQL translation.
22+
var stampedCommits = commits.Select(c => { c.ProjectId = projectId; return c; });
23+
await tmpTable.BulkCopyAsync(new BulkCopyOptions{BulkCopyType = BulkCopyType.ProviderSpecific, MaxBatchSize = 10}, stampedCommits, token);
1924

2025
var commitsTable = linqToDbContext.GetTable<ServerCommit>();
2126
await commitsTable
2227
.Merge()
2328
.Using(tmpTable)
2429
.OnTargetKey()
25-
.InsertWhenNotMatched(commit => new ServerCommit(commit.Id)
26-
{
27-
Id = commit.Id,
28-
ClientId = commit.ClientId,
29-
HybridDateTime = new HybridDateTime(commit.HybridDateTime.DateTime, commit.HybridDateTime.Counter)
30-
{
31-
DateTime = commit.HybridDateTime.DateTime, Counter = commit.HybridDateTime.Counter
32-
},
33-
ProjectId = projectId,
34-
Metadata = commit.Metadata,
35-
//without this sql cast the value will be treated as text and fail to insert into the jsonb column
36-
ChangeEntities = Sql.Expr<List<ChangeEntity<ServerJsonChange>>>($"{commit.ChangeEntities}::jsonb")
37-
})
30+
.InsertWhenNotMatched()
3831
.MergeAsync(token);
3932

4033
await transaction.CommitAsync(token);

backend/LexData/DataKernel.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
using LexData.Configuration;
22
using LinqToDB;
3-
using LinqToDB.AspNet.Logging;
43
using LinqToDB.EntityFrameworkCore;
4+
using LinqToDB.Extensions.Logging;
55
using LinqToDB.Mapping;
66
using Microsoft.EntityFrameworkCore;
77
using Microsoft.Extensions.DependencyInjection;

backend/LexData/LexData.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@
22
<ItemGroup>
33
<PackageReference Include="AppAny.Quartz.EntityFrameworkCore.Migrations.PostgreSQL" />
44
<PackageReference Include="Humanizer.Core" />
5-
<PackageReference Include="linq2db.AspNet" />
65
<PackageReference Include="linq2db.EntityFrameworkCore" />
6+
<PackageReference Include="linq2db.Extensions" />
77
<PackageReference Include="Microsoft.EntityFrameworkCore" />
88
<PackageReference Include="Microsoft.EntityFrameworkCore.Design">
99
<PrivateAssets>all</PrivateAssets>

backend/Testing/LexCore/Services/CrdtCommitServiceTests.cs

Lines changed: 17 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,6 @@ public async Task CanQueryOldCommits()
6666
{
6767
var projectId = await _lexBoxDbContext.Projects.Select(p => p.Id).FirstOrDefaultAsync();
6868
var context = _lexBoxDbContext.CreateLinqToDBContext();
69-
var table = LinqToDB.DataExtensions.GetTable<ServerCommit>(context);
7069
var commitId = Guid.NewGuid();
7170
var changeEntity = new ChangeEntity<ServerJsonChange>
7271
{
@@ -87,21 +86,23 @@ public async Task CanQueryOldCommits()
8786
//the old format stored json in json, this is emulating that.
8887
changeEntityJson["Change"] = changeEntityJson["Change"]?.ToJsonString();
8988
var jsonPayload = changeEntityJson.ToJsonString();
90-
var inlineSql = $"'[{jsonPayload}]'::jsonb";
91-
//insert a new server commit, manually specifying the value for ChangeEntities so it will match the old format.
92-
await LinqToDB.LinqExtensions.InsertAsync(table, () => new ServerCommit(commitId)
93-
{
94-
Id = commitId,
95-
ClientId = Guid.NewGuid(),
96-
HybridDateTime = new HybridDateTime(DateTimeOffset.UtcNow, 0)
97-
{
98-
DateTime = DateTimeOffset.UtcNow,
99-
Counter = 0
100-
},
101-
ProjectId = projectId,
102-
Metadata = new CommitMetadata(),
103-
ChangeEntities = LinqToDB.Sql.Expr<List<ChangeEntity<ServerJsonChange>>>(inlineSql)
104-
});
89+
//Insert a synthetic old-format commit via raw SQL so we can put pre-serialized
90+
//JSON in ChangeEntities. Linq2Db v6 unconditionally wraps any column assignment
91+
//(including Sql.Expr) in the EF JSON value converter inside an InsertAsync
92+
//projection lambda, so we can't use the typed API for this test case.
93+
var inlinePayload = $"[{jsonPayload}]";
94+
await LinqToDB.Data.DataContextExtensions.ExecuteAsync(
95+
context,
96+
"""
97+
INSERT INTO "CrdtCommits"
98+
("Id", "ClientId", "HybridDateTime_DateTime", "HybridDateTime_Counter", "ProjectId", "Metadata", "ChangeEntities")
99+
VALUES (@id, @clientId, @dt, 0, @projectId, '{}'::jsonb, @payload::jsonb)
100+
""",
101+
new LinqToDB.Data.DataParameter("id", commitId, LinqToDB.DataType.Guid),
102+
new LinqToDB.Data.DataParameter("clientId", Guid.NewGuid(), LinqToDB.DataType.Guid),
103+
new LinqToDB.Data.DataParameter("dt", DateTimeOffset.UtcNow, LinqToDB.DataType.DateTimeOffset),
104+
new LinqToDB.Data.DataParameter("projectId", projectId, LinqToDB.DataType.Guid),
105+
new LinqToDB.Data.DataParameter("payload", inlinePayload, LinqToDB.DataType.NVarChar));
105106
var commits = await _lexBoxDbContext.CrdtCommits(projectId).ToArrayAsync();
106107
var actualCommit = commits.Should().ContainSingle(c => c.Id == commitId).Subject;
107108
actualCommit.ChangeEntities.Should().BeEquivalentTo([changeEntity],

0 commit comments

Comments
 (0)