Skip to content

Commit da3e31a

Browse files
vkuttypclaude
andcommitted
release: 2.5.4 — coerce Date↔DateTimeOffset for ORDER/WHERE compare
TryCompare only handled same-kind pairs for Date vs Date and DTO vs DTO; mixed kinds fell through to the "Cannot order X against Y" throw. Mailserver's SmtpQueue.ClaimPending hit this every RelayWorker tick on marivil post-cutover — @now bound as SqlValueKind.Date (DateTime .UtcNow), next_retry column is DATETIMEOFFSET, comparison crashes the worker loop. Fix: coerce both sides to DateTimeOffset on the mixed branch. Force Utc when a DateTime's Kind is Unspecified — the mailserver schema and GETUTCDATE default both store UTC, so that's the safe assumption (matches TypeCoercer.CoerceDateTimeOffset which already accepts Date as DTO for INSERT). 3 new tests in Phase15CompareDtoTests cover the mixed comparison both directions plus NULL semantics. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent e3f9c49 commit da3e31a

3 files changed

Lines changed: 125 additions & 1 deletion

File tree

Directory.Build.props

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,6 @@
77
<Authors>vkuttyp</Authors>
88
<PackageLicenseExpression>MIT</PackageLicenseExpression>
99
<RepositoryUrl>https://github.com/vkuttyp/CosmoSQLClient-Dotnet</RepositoryUrl>
10-
<Version>2.5.3</Version>
10+
<Version>2.5.4</Version>
1111
</PropertyGroup>
1212
</Project>

src/CosmoSQLClient.CosmoKv/Execution/ExpressionEvaluator.cs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -355,6 +355,26 @@ public static bool TryCompare(SqlValue l, SqlValue r, out int cmp)
355355
cmp = l.DateTimeOffsetValue.CompareTo(r.DateTimeOffsetValue);
356356
return true;
357357
}
358+
// Mixed Date / DateTimeOffset — coerce to DateTimeOffset on both sides.
359+
// The implicit conversion mirrors TypeCoercer.CoerceDateTimeOffset's
360+
// accept-Date-as-DateTimeOffset rule used at INSERT/UPDATE time, so
361+
// a WHERE/ORDER BY against a DateTimeOffset column with a plain
362+
// DateTime parameter (or vice versa) compares as expected instead
363+
// of throwing "Cannot order Date against DateTimeOffset" — caught
364+
// by the mailserver's SmtpQueue claim-pending path where @now is
365+
// bound as SqlValueKind.Date but next_retry is DATETIMEOFFSET.
366+
if ((l.Kind == SqlValueKind.Date && r.Kind == SqlValueKind.DateTimeOffset) ||
367+
(l.Kind == SqlValueKind.DateTimeOffset && r.Kind == SqlValueKind.Date))
368+
{
369+
var lDto = l.Kind == SqlValueKind.DateTimeOffset
370+
? l.DateTimeOffsetValue
371+
: new DateTimeOffset(DateTime.SpecifyKind(l.DateValue, DateTimeKind.Utc));
372+
var rDto = r.Kind == SqlValueKind.DateTimeOffset
373+
? r.DateTimeOffsetValue
374+
: new DateTimeOffset(DateTime.SpecifyKind(r.DateValue, DateTimeKind.Utc));
375+
cmp = lDto.CompareTo(rDto);
376+
return true;
377+
}
358378
// Guid — byte-wise.
359379
if (l.Kind == SqlValueKind.Uuid && r.Kind == SqlValueKind.Uuid)
360380
{
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
using CosmoSQLClient.Core;
2+
using CosmoSQLClient.CosmoKv;
3+
4+
namespace CosmoSQLClient.CosmoKv.Tests;
5+
6+
/// <summary>
7+
/// v2.5.4 — mixed Date / DateTimeOffset comparisons. Pre-fix the executor's
8+
/// TryCompare only matched same-kind pairs (Date↔Date, DTO↔DTO) and threw
9+
/// "Cannot order Date against DateTimeOffset" on the mixed case. The mail
10+
/// server's SmtpQueue claim-pending path hit this on every RelayWorker
11+
/// iteration: column next_retry is DATETIMEOFFSET, but @now was bound as
12+
/// SqlValueKind.Date (DateTime.UtcNow). Now coerced via DateTimeOffset on
13+
/// both sides at compare time.
14+
/// </summary>
15+
public class Phase15DateDtoCompareTests : IAsyncLifetime
16+
{
17+
private readonly string _dir = Path.Combine(
18+
Path.GetTempPath(),
19+
"cosmosql-cosmokv-p15-" + Guid.NewGuid().ToString("N"));
20+
private CosmoKvConnection? _conn;
21+
22+
public async Task InitializeAsync()
23+
{
24+
_conn = await CosmoKvConnection.OpenAsync(
25+
new CosmoKvConfiguration { DataSource = _dir });
26+
await _conn.ExecuteAsync("""
27+
CREATE TABLE Q (
28+
Id BIGINT IDENTITY PRIMARY KEY,
29+
NextRetry DATETIMEOFFSET NULL,
30+
Status NVARCHAR(32) NOT NULL DEFAULT 'pending')
31+
""");
32+
}
33+
34+
public async Task DisposeAsync()
35+
{
36+
if (_conn is not null) await _conn.DisposeAsync();
37+
try { if (Directory.Exists(_dir)) Directory.Delete(_dir, recursive: true); } catch { }
38+
}
39+
40+
[Fact]
41+
public async Task DateTimeParam_ComparedToDateTimeOffsetColumn_Works()
42+
{
43+
// Seed three rows with explicit DTO retry times.
44+
var t0 = DateTimeOffset.Parse("2026-01-01T00:00:00Z");
45+
await _conn!.ExecuteAsync(
46+
"INSERT INTO Q (NextRetry) VALUES (@a), (@b), (@c)",
47+
new[]
48+
{
49+
SqlParameter.Named("@a", SqlValue.From(t0)),
50+
SqlParameter.Named("@b", SqlValue.From(t0.AddHours(1))),
51+
SqlParameter.Named("@c", SqlValue.From(t0.AddHours(2))),
52+
});
53+
54+
// Compare DTO column against DateTime (SqlValueKind.Date) param.
55+
// Pre-fix: throws "Cannot order Date against DateTimeOffset".
56+
var threshold = t0.AddHours(1).UtcDateTime; // plain DateTime
57+
var rows = await _conn.QueryAsync(
58+
"SELECT Id FROM Q WHERE NextRetry <= @now ORDER BY Id",
59+
new[] { SqlParameter.Named("@now", SqlValue.From(threshold)) });
60+
61+
Assert.Equal(2, rows.Count);
62+
Assert.Equal(1L, rows[0]["Id"].AsInt());
63+
Assert.Equal(2L, rows[1]["Id"].AsInt());
64+
}
65+
66+
[Fact]
67+
public async Task DateTimeColumn_ComparedToDateTimeOffsetParam_Works()
68+
{
69+
// Reverse direction: column is Date (DATETIME2), parameter is DTO.
70+
await _conn!.ExecuteAsync("""
71+
CREATE TABLE Events (
72+
Id BIGINT IDENTITY PRIMARY KEY,
73+
Ts DATETIME2 NOT NULL)
74+
""");
75+
await _conn.ExecuteAsync(
76+
"INSERT INTO Events (Ts) VALUES (@a), (@b)",
77+
new[]
78+
{
79+
SqlParameter.Named("@a", SqlValue.From(new DateTime(2026, 1, 1, 10, 0, 0, DateTimeKind.Utc))),
80+
SqlParameter.Named("@b", SqlValue.From(new DateTime(2026, 1, 1, 12, 0, 0, DateTimeKind.Utc))),
81+
});
82+
83+
var threshold = DateTimeOffset.Parse("2026-01-01T11:00:00Z");
84+
var rows = await _conn.QueryAsync(
85+
"SELECT Id FROM Events WHERE Ts < @t",
86+
new[] { SqlParameter.Named("@t", SqlValue.From(threshold)) });
87+
88+
Assert.Single(rows);
89+
Assert.Equal(1L, rows[0]["Id"].AsInt());
90+
}
91+
92+
[Fact]
93+
public async Task NullDateTimeOffset_StillReturnsNullOnCompare()
94+
{
95+
// SQL semantics: NULL comparisons return NULL (treated as false in
96+
// a WHERE clause). The Date/DTO coercion path must not break this.
97+
await _conn!.ExecuteAsync("INSERT INTO Q (NextRetry) VALUES (NULL)");
98+
var now = DateTime.UtcNow;
99+
var rows = await _conn.QueryAsync(
100+
"SELECT Id FROM Q WHERE NextRetry <= @now",
101+
new[] { SqlParameter.Named("@now", SqlValue.From(now)) });
102+
Assert.Empty(rows);
103+
}
104+
}

0 commit comments

Comments
 (0)