Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
---
verified: 2026-10-17
pins:
- Microsoft.EntityFrameworkCore.Sqlite 10.0.12
- Microsoft.EntityFrameworkCore.Relational 10.0.12
source: the Query limitations section of SQLite Database Provider - Limitations (learn.microsoft.com, dotnet/EntityFramework.Docs providers/sqlite/limitations.md), which says "SQLite doesn't natively support the following data types. EF Core can read and write values of these types, and querying for equality (`where e.Property == value`) is also supported. Other operations, however, like comparison and ordering will require evaluation on the client." and lists DateTimeOffset, decimal, TimeSpan and ulong; measured with plain EF Core and its SQLite provider, where decimal is compared and ordered
turned-on: 12 rows of E02ValueTranslatesProbe (claim E02-value-translates-to-column), a value object over DateTimeOffset, TimeSpan or ulong under where-value-compare or order-by, where both sides refused the query (WS2 step 4, the EF family); E02's obligations declare those pairs inapplicable
test: test/CodoMetis.TypeKit.Facts/KB039.cs
---

# KB-039. EF Core's SQLite provider neither compares nor orders a `DateTimeOffset`, a `TimeSpan` or a `ulong`

Over a plain column of one of these types, with no TypeKit:

| Query | `DateTimeOffset`, `TimeSpan`, `ulong` | `decimal` |
|---|---|---|
| `Where(r => r.P >= v)` | `InvalidOperationException`: "The LINQ expression ... could not be translated" | translated, the rows at or above `v` |
| `OrderBy(r => r.P)` | `NotSupportedException`: "SQLite does not support expressions of type '...' in ORDER BY clauses" | translated, in order |
| `Where(r => r.P == v)` | translated | translated |

EF's page lists `decimal` beside the three. EF Core 10 translates its comparisons and orderings on SQLite, so the
table above, not the page's list, is what this repository relies on.

So on SQLite a query that compares or orders such a column fails for the plain type as it does for a value object
over it, before any value-object translation is asked anything: E02's comparison and ordering rows over these types
have nothing to check there, and its obligations declare them inapplicable. PostgreSQL translates both.
1 change: 1 addition & 0 deletions design/knowledge/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,3 +67,4 @@ test and later for the harness's `Baseline.Fact`.
| [KB-036](KB-036-a-char-key-gets-an-identity-postgresql-refuses.md) | A plain `char` key gets an identity column on Npgsql, which PostgreSQL refuses | PostgreSQL 17.11, Npgsql EF 10.0.3, EF Core 10.0.12 | `KB036`, with `TYPEKIT_PG` |
| [KB-037](KB-037-mvc-under-max-validation-depth-1-runs-an-unbound-action.md) | Under `MaxValidationDepth = 1`, MVC runs an action whose parameter it could not bind, with the parameter at its default | SDK 10.0.4xx | `KB037` |
| [KB-038](KB-038-a-byte-array-is-a-base64-string.md) | System.Text.Json writes a `byte[]` as one base64 string, and every other collection as a JSON array | SDK 10.0.4xx | `KB038` |
| [KB-039](KB-039-sqlite-neither-compares-nor-orders-datetimeoffset-timespan-or-ulong.md) | EF Core's SQLite provider neither compares nor orders a `DateTimeOffset`, a `TimeSpan` or a `ulong`, and does both for a `decimal` | EF Core 10.0.12, its SQLite provider 10.0.12 | `KB039` |
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
-->
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" />
</ItemGroup>

<!-- A fact's measured answers as data, beside its test (FactFixture, design/adr/0107), read from the test output. -->
Expand Down
106 changes: 106 additions & 0 deletions test/CodoMetis.TypeKit.Facts/KB039.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
using System.Linq.Expressions;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;

namespace CodoMetis.TypeKit.Facts;

/// <summary>
/// design/knowledge/KB-039: EF Core's SQLite provider translates no comparison and no <c>ORDER BY</c> of a
/// <see cref="DateTimeOffset"/>, a <see cref="TimeSpan"/> or a <see cref="ulong"/> column: <c>&gt;=</c> throws "could not be
/// translated", and an ordering throws <see cref="NotSupportedException"/>. Equality is translated. A <see cref="decimal"/>,
/// which EF's page lists beside them, is compared and ordered.
/// </summary>
public sealed class KB039
{
public sealed class Row<T>
{
public int Id { get; set; }

public T P { get; set; } = default!;
}

private sealed class Db<T>(DbContextOptions options) : DbContext(options)
{
protected override void OnModelCreating(ModelBuilder modelBuilder) => modelBuilder.Entity<Row<T>>().ToTable("t");
}

private sealed class PerType : IModelCacheKeyFactory
{
public object Create(DbContext context, bool designTime) => (context.GetType(), designTime);
}

private static async Task<(SqliteConnection Connection, Db<T> Db)> Open<T>(T low, T high)
{
var connection = new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync(TestContext.Current.CancellationToken);
var db = new Db<T>(new DbContextOptionsBuilder().UseSqlite(connection).ReplaceService<IModelCacheKeyFactory, PerType>().Options);
await db.Database.EnsureCreatedAsync(TestContext.Current.CancellationToken);
db.AddRange(new Row<T> { P = high }, new Row<T> { P = low });
await db.SaveChangesAsync(TestContext.Current.CancellationToken);
db.ChangeTracker.Clear();
return (connection, db);
}

public static TheoryData<string> Refused => ["DateTimeOffset", "TimeSpan", "ulong"];

[Theory, MemberData(nameof(Refused))]
public Task A_comparison_is_not_translated(string type) => type switch
{
"DateTimeOffset" => NotTranslated(DateTimeOffset.UnixEpoch, DateTimeOffset.UnixEpoch.AddDays(1), r => r.P >= DateTimeOffset.UnixEpoch.AddDays(1)),
"TimeSpan" => NotTranslated(TimeSpan.FromHours(1), TimeSpan.FromHours(2), r => r.P >= TimeSpan.FromHours(2)),
_ => NotTranslated(1UL, 2UL, r => r.P >= 2UL),
};

[Theory, MemberData(nameof(Refused))]
public Task An_ordering_is_not_supported(string type) => type switch
{
"DateTimeOffset" => NotOrdered(DateTimeOffset.UnixEpoch, DateTimeOffset.UnixEpoch.AddDays(1), "DateTimeOffset"),
"TimeSpan" => NotOrdered(TimeSpan.FromHours(1), TimeSpan.FromHours(2), "TimeSpan"),
_ => NotOrdered(1UL, 2UL, "ulong"),
};

[Theory, MemberData(nameof(Refused))]
public Task Equality_is_translated(string type) => type switch
{
"DateTimeOffset" => Equal(DateTimeOffset.UnixEpoch, DateTimeOffset.UnixEpoch.AddDays(1), r => r.P == DateTimeOffset.UnixEpoch),
"TimeSpan" => Equal(TimeSpan.FromHours(1), TimeSpan.FromHours(2), r => r.P == TimeSpan.FromHours(1)),
_ => Equal(1UL, 2UL, r => r.P == 1UL),
};

[Fact]
public async Task A_decimal_is_compared_and_ordered()
{
var (connection, db) = await Open(1.5m, 2.5m);
await using var _ = connection;
await using var __ = db;
(await db.Set<Row<decimal>>().Where(r => r.P >= 2m).Select(r => r.P).ToListAsync(TestContext.Current.CancellationToken)).ShouldBe([2.5m]);
(await db.Set<Row<decimal>>().OrderBy(r => r.P).Select(r => r.P).ToListAsync(TestContext.Current.CancellationToken)).ShouldBe([1.5m, 2.5m]);
}

private static async Task NotTranslated<T>(T low, T high, Expression<Func<Row<T>, bool>> atLeastHigh)
{
var (connection, db) = await Open(low, high);
await using var _ = connection;
await using var __ = db;
var refused = await Should.ThrowAsync<InvalidOperationException>(() => db.Set<Row<T>>().Where(atLeastHigh).ToListAsync(TestContext.Current.CancellationToken));
refused.Message.ShouldContain("could not be translated");
}

private static async Task NotOrdered<T>(T low, T high, string name)
{
var (connection, db) = await Open(low, high);
await using var _ = connection;
await using var __ = db;
var refused = await Should.ThrowAsync<NotSupportedException>(() => db.Set<Row<T>>().OrderBy(r => r.P).ToListAsync(TestContext.Current.CancellationToken));
refused.Message.ShouldContain($"SQLite does not support expressions of type '{name}' in ORDER BY clauses");
}

private static async Task Equal<T>(T low, T high, Expression<Func<Row<T>, bool>> isLow)
{
var (connection, db) = await Open(low, high);
await using var _ = connection;
await using var __ = db;
(await db.Set<Row<T>>().Where(isLow).Select(r => r.P).ToListAsync(TestContext.Current.CancellationToken)).ShouldBe([low]);
}
}
117 changes: 113 additions & 4 deletions tools/probe/corpus/claims.json
Original file line number Diff line number Diff line change
Expand Up @@ -12598,7 +12598,23 @@
"ef.accessor": [
"Value"
]
}
},
"inapplicable": [
{
"axes": {
"shape.wrapped": [
"DateTimeOffset",
"TimeSpan",
"ulong"
],
"ef.query": [
"where-value-compare",
"order-by"
]
},
"cite": "fact:KB-039"
}
]
},
{
"id": "get-value",
Expand All @@ -12607,7 +12623,23 @@
"ef.accessor": [
"GetValue"
]
}
},
"inapplicable": [
{
"axes": {
"shape.wrapped": [
"DateTimeOffset",
"TimeSpan",
"ulong"
],
"ef.query": [
"where-value-compare",
"order-by"
]
},
"cite": "fact:KB-039"
}
]
},
{
"id": "value-or-null",
Expand All @@ -12616,11 +12648,43 @@
"ef.accessor": [
"ValueOrNull"
]
}
},
"inapplicable": [
{
"axes": {
"shape.wrapped": [
"DateTimeOffset",
"TimeSpan",
"ulong"
],
"ef.query": [
"where-value-compare",
"order-by"
]
},
"cite": "fact:KB-039"
}
]
},
{
"id": "column-not-cast",
"sentence": "A table's column is re-typed, not cast, so an index on it still serves the query."
"sentence": "A table's column is re-typed, not cast, so an index on it still serves the query.",
"inapplicable": [
{
"axes": {
"shape.wrapped": [
"DateTimeOffset",
"TimeSpan",
"ulong"
],
"ef.query": [
"where-value-compare",
"order-by"
]
},
"cite": "fact:KB-039"
}
]
}
],
"not-promised": [
Expand Down Expand Up @@ -12805,6 +12869,33 @@
"default"
]
}
},
{
"id": "not-on-efs-list",
"sentence": "That's EF's own list: `int`, `long`, `short`, `sbyte`, `uint`, `ulong` and `ushort`, and not `byte`.",
"axes": {
"shape.wrapped": [
"string"
],
"ef.key-config": [
"default"
]
}
},
{
"id": "own-configuration-wins",
"sentence": "A configuration you write yourself always wins over these defaults.",
"axes": {
"shape.wrapped": [
"char",
"Guid",
"string"
],
"ef.key-config": [
"ValueGeneratedOnAdd",
"ValueGeneratedNever"
]
}
}
],
"not-promised": [
Expand Down Expand Up @@ -13349,6 +13440,15 @@
"value-object"
]
}
},
{
"id": "underlying-type-does-not-reach",
"sentence": "So `configurationBuilder.Properties<decimal>().HavePrecision(18, 2)` reaches `decimal` properties, and not an `Amount` that wraps one.",
"axes": {
"ef.convention-type": [
"underlying"
]
}
}
],
"not-promised": [
Expand Down Expand Up @@ -13389,6 +13489,15 @@
"shape.kind": "db.Orders.Where(o => o.Tags.Contains(tag));"
},
"obligations": [
{
"id": "contains",
"sentence": "db.Orders.Where(o => o.Tags.Contains(tag));",
"axes": {
"ef.query": [
"contains"
]
}
},
{
"id": "element-value",
"sentence": "db.Orders.Where(o => o.Tags.Any(t => t.Value == \"x\"));",
Expand Down
25 changes: 25 additions & 0 deletions tools/probe/corpus/harness/EfHarness.cs
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,31 @@ public static string FirstDiff(string a, string b)
}
}

/// <summary>
/// How a check of a two-sided EF row ends where a side threw: the value object's context under <c>UseTypeKit()</c> and the
/// wrapped twin's under plain EF ran the same step, named by <c>what</c>. Neither threw: false, compare on. The value object
/// alone: its violation. Both, with exceptions of one type: nothing to compare, inconclusive, since no fact or sentence says why
/// the framework refuses it. Both, otherwise: a violation, since the value object did not fail as its twin does. The twin alone:
/// inconclusive, no twin to compare with.
/// </summary>
public static class EfSides
{
public static bool Threw(Check c, Exception? vo, Exception? plain, string what, out string? failure)
{
failure = null;
if (vo is null && plain is null) return false;
if (vo is not null && plain is not null)
failure = vo.GetType() == plain.GetType()
? c.Inconclusive($"both sides refuse {what} alike: {vo.GetType().Name}: {Short(vo.Message)}")
: $"{what}: the value object's side threw {vo.GetType().Name}: {Short(vo.Message)}; the wrapped type's {plain.GetType().Name}: {Short(plain.Message)}";
else if (plain is not null) failure = c.Inconclusive($"the wrapped type's side refuses {what}, so there is no twin to compare with: {plain.GetType().Name}: {Short(plain.Message)}");
else failure = $"{what} with the value object threw {vo!.GetType().Name}: {Short(vo.Message)}; the wrapped type's did not";
return true;
}

static string Short(string s) { s = s.Split('\n')[0]; return s.Length > 300 ? s[..300] : s; }
}

public static class Q
{
public static IQueryable Call(string name, Type[] typeArgs, IQueryable src, params Expression[] args)
Expand Down
Loading
Loading