diff --git a/src/SIL.Harmony.Linq2db/Linq2dbKernel.cs b/src/SIL.Harmony.Linq2db/Linq2dbKernel.cs index a4d9f8f..c6a9d31 100644 --- a/src/SIL.Harmony.Linq2db/Linq2dbKernel.cs +++ b/src/SIL.Harmony.Linq2db/Linq2dbKernel.cs @@ -1,4 +1,5 @@ -using SIL.Harmony.Core; +using System.Globalization; +using SIL.Harmony.Core; using LinqToDB.EntityFrameworkCore; using LinqToDB.Extensions.Logging; using LinqToDB.Mapping; @@ -33,6 +34,23 @@ public static DbContextOptionsBuilder UseLinqToDbCrdt(this DbContextOptionsBuild dt => new DateTimeOffset(dt.Ticks, TimeSpan.Zero)) .Build(); + //bind timestamp parameters as the exact TEXT Microsoft.Data.Sqlite writes for DateTime columns + //("yyyy-MM-dd HH:mm:ss.FFFFFFF"): linq2db's own rendering truncates to milliseconds while + //SQLite's strftime rounds, so comparisons against stored values can misclassify rows + //(e.g. WhereAfter matching the target commit itself). Even with identical text, linq2db wraps + //SQLite timestamp comparisons in strftime('...%f'), which is millisecond-grained — use EF for + //comparisons that must distinguish same-millisecond commits. + mappingSchema.SetConvertExpression((DateTime dt) => new LinqToDB.Data.DataParameter + { + Value = dt.ToString("yyyy-MM-dd HH:mm:ss.FFFFFFF", CultureInfo.InvariantCulture), + DataType = LinqToDB.DataType.Text + }); + mappingSchema.SetConvertExpression((DateTimeOffset dto) => new LinqToDB.Data.DataParameter + { + Value = dto.UtcDateTime.ToString("yyyy-MM-dd HH:mm:ss.FFFFFFF", CultureInfo.InvariantCulture), + DataType = LinqToDB.DataType.Text + }); + var loggerFactory = provider.GetService(); if (loggerFactory is not null) optionsBuilder.AddCustomOptions(dataOptions => dataOptions.UseLoggerFactory(loggerFactory)); diff --git a/src/SIL.Harmony.Tests/DbContextTests.cs b/src/SIL.Harmony.Tests/DbContextTests.cs index 3b7677d..2cbee52 100644 --- a/src/SIL.Harmony.Tests/DbContextTests.cs +++ b/src/SIL.Harmony.Tests/DbContextTests.cs @@ -68,6 +68,98 @@ await DbContext.Set().ToLinqToDBTable().AsValueInsertable() [InlineData(TimeSpan.TicksPerMicrosecond)] [InlineData(1)] public async Task CanFilterCommitsByDateTime(double scale) + { + var baseDateTime = await SeedCommitsAtTickScale(scale); + var commits = await DbContext.Commits + .Where(c => c.HybridDateTime.DateTime > baseDateTime.Add(new TimeSpan((long)(25 * scale)))) + .OrderBy(c => c.HybridDateTime.DateTime) + .ToArrayAsyncEF(TestContext.Current.CancellationToken); + commits.Should().HaveCount(24); + } + + //no sub-millisecond scales here: linq2db wraps SQLite timestamp comparisons in + //strftime('%Y-%m-%d %H:%M:%f', ...), which normalizes both sides to milliseconds + [Theory] + [InlineData(TimeSpan.TicksPerHour)] + [InlineData(TimeSpan.TicksPerMinute)] + [InlineData(TimeSpan.TicksPerSecond)] + [InlineData(TimeSpan.TicksPerMillisecond)] + public async Task CanFilterCommitsByDateTimeViaLinq2db(double scale) + { + var baseDateTime = await SeedCommitsAtTickScale(scale); + var cutoff = baseDateTime.Add(new TimeSpan((long)(25 * scale))); + var commits = await DbContext.Commits + .ToLinqToDB() + .Where(c => c.HybridDateTime.DateTime > cutoff) + .OrderBy(c => c.HybridDateTime.DateTime) + .ToArrayAsyncLinqToDB(TestContext.Current.CancellationToken); + commits.Should().HaveCount(24); + } + + //commits sharing a millisecond compare equal under linq2db (see above), so WhereAfter must + //never see the target itself as "after": the parameter has to render byte-identical to the + //stored text or strftime can normalize the two differently and delete the target commit + [Fact] + public async Task WhereAfterViaLinq2dbExcludesTheTargetCommit() + { + //sub-millisecond part must be >= 0.5ms: SQLite's strftime rounds to the nearest millisecond + //while a truncating parameter rendering lands one millisecond lower, which is the disagreement + var baseDateTime = new DateTimeOffset(2000, 1, 1, 1, 11, 11, TimeSpan.Zero).Add(TimeSpan.FromTicks(6000)); + for (int i = 0; i < 50; i++) + { + DbContext.Add(new Commit + { + ClientId = Guid.NewGuid(), + HybridDateTime = new HybridDateTime(baseDateTime.Add(TimeSpan.FromTicks(i)), 0) + }); + } + + await DbContext.SaveChangesAsync(TestContext.Current.CancellationToken); + var commits = await DbContext.Commits.ToArrayAsyncEF(TestContext.Current.CancellationToken); + foreach (var target in commits) + { + var after = await DbContext.Commits + .WhereAfter(target) + .ToLinqToDB() + .Select(c => c.Id) + .ToArrayAsyncLinqToDB(TestContext.Current.CancellationToken); + after.Should().NotContain(target.Id, + $"WhereAfter via linq2db should never include the target commit itself (target at {target.HybridDateTime.DateTime:O})"); + } + } + + //characterizes the limitation documented in Linq2dbKernel rather than a desired behavior: + //if the linq2db side starts seeing the later commit, its SQLite translation has changed — + //revisit whether timestamp comparisons still need to stay in EF + [Fact] + public async Task Linq2dbCannotOrderCommitsWithinTheSameMillisecond() + { + var baseDateTime = new DateTimeOffset(2000, 1, 1, 1, 11, 11, TimeSpan.Zero); + var earlier = new Commit(Guid.NewGuid()) + { + ClientId = Guid.NewGuid(), + HybridDateTime = new HybridDateTime(baseDateTime.Add(TimeSpan.FromTicks(100)), 0) + }; + var later = new Commit(Guid.NewGuid()) + { + ClientId = Guid.NewGuid(), + HybridDateTime = new HybridDateTime(baseDateTime.Add(TimeSpan.FromTicks(200)), 0) + }; + DbContext.AddRange(earlier, later); + await DbContext.SaveChangesAsync(TestContext.Current.CancellationToken); + + var linq2dbCount = await DbContext.Commits.ToLinqToDB() + .Where(c => c.HybridDateTime.DateTime > earlier.HybridDateTime.DateTime) + .CountAsyncLinqToDB(TestContext.Current.CancellationToken); + linq2dbCount.Should().Be(0, "linq2db compares SQLite timestamps at millisecond precision (strftime '%f')"); + + var efCount = await DbContext.Commits + .Where(c => c.HybridDateTime.DateTime > earlier.HybridDateTime.DateTime) + .CountAsyncEF(TestContext.Current.CancellationToken); + efCount.Should().Be(1, "EF compares the stored text at full precision"); + } + + private async Task SeedCommitsAtTickScale(double scale) { var baseDateTime = new DateTimeOffset(2000, 1, 1, 1, 11, 11, TimeSpan.Zero); for (int i = 0; i < 50; i++) @@ -81,10 +173,6 @@ public async Task CanFilterCommitsByDateTime(double scale) } await DbContext.SaveChangesAsync(TestContext.Current.CancellationToken); - var commits = await DbContext.Commits - .Where(c => c.HybridDateTime.DateTime > baseDateTime.Add(new TimeSpan((long)(25 * scale)))) - .OrderBy(c => c.HybridDateTime.DateTime) - .ToArrayAsyncEF(TestContext.Current.CancellationToken); - commits.Should().HaveCount(24); + return baseDateTime; } }