Skip to content

Commit c0d2484

Browse files
committed
Fixed BCP datetime decode truncation (0.3–0.9s value error). Mimicked datetime SqlClient rounding. Nested-loop join optimized as hash join, protected against regression with new observability mechanics visible from internal tests.
1 parent 7ceff21 commit c0d2484

16 files changed

Lines changed: 624 additions & 49 deletions

File tree

CLAUDE.md

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -103,9 +103,7 @@ Six scopes, one home each. **Add new state to whichever class matches its true s
103103
The `*.Tests` and `*.Tests.EFCore` suites are the authoritative behavior contract. Notes below cover only probe-confirmed quirks, deviations from SQL Server, and non-obvious implementation rules. Per-feature deep-dives live under `docs/claude/` (see [Feature reference](#feature-reference) for the trigger-phrased index); short cross-cutting sections stay inline below.
104104

105105
### JOINs / APPLY
106-
INNER / bare JOIN / LEFT [OUTER] / RIGHT [OUTER] / FULL [OUTER] / CROSS / CROSS APPLY / OUTER APPLY. Multi-table chains compose left-to-right. ON-predicate UNKNOWN excludes. APPLY = lateral form (right side re-executed per outer row, no ON clause). Comma-separated FROM (ANSI-89 syntax — `FROM a, b WHERE a.id = b.id`) parses as a sequence of explicit-join chains spliced with `JoinKind.Cross` joins; each comma starts a fresh chain via the same `ParseExplicitJoinChain` helper the JOIN-keyword loop calls, so any explicit JOINs *within* a chain bind before the cross-splice.
107-
108-
`JoinDriver` is a fold over `joins[]`: leftmost rowset → wrap with each join's operator → final enumerator (`Selection.Execution.Joins.cs`). INNER / CROSS / LEFT / CROSS APPLY / OUTER APPLY stream one upstream tuple at a time; RIGHT / FULL materialize `sources[level].Rows` into a list and track a `matched[]` bitmap across the entire upstream iteration so unmatched right rows can be emitted (with all prior slots NULL-filled) after upstream is exhausted. **RIGHT / FULL with a derived-table right side** materialize the lateral plan once via the enclosing-scope `outerResolver` (not the joined-tuple resolver), so non-correlated and outer-correlated derived tables work; lateral correlation to the left side is rejected because the derived-table parse doesn't wire the left-source snapshot resolver — left-side references raise Msg 207 ("Invalid column name") at runtime when `Reference.Run` hits the null outer resolver. Real SQL Server raises Msg 4104 at bind time for the same shape; different code, same end state. EF Core 10's LINQ `LeftJoin` / `RightJoin` operators translate to LEFT / RIGHT JOIN respectively and route through this pipeline; .NET 10 LINQ doesn't expose a `FullJoin` operator, so FULL OUTER JOIN is reachable only via raw SQL.
106+
INNER / bare JOIN / LEFT [OUTER] / RIGHT [OUTER] / FULL [OUTER] / CROSS / CROSS APPLY / OUTER APPLY, multi-table chains (compose left-to-right), and ANSI-89 comma-FROM. ON-predicate UNKNOWN excludes. Equi-joins (`a.col = b.col`) take an O(L+R) hash path; non-equi / CROSS / APPLY / lateral-right fall back to streaming nested loops. EF Core's LINQ `LeftJoin` / `RightJoin` route through this pipeline (no LINQ `FullJoin`, so FULL is raw-SQL-only). See [`docs/claude/joins.md`](docs/claude/joins.md).
109107

110108
### Subqueries
111109
`EXISTS` / `NOT EXISTS` (multi-column inner allowed); `expr [NOT] IN (SELECT ...)` (single inner column, Msg 116); scalar `(SELECT col FROM ...)` (single column, single-row Msg 512 per outer row, empty → typed NULL); `expr <op> {ANY|SOME|ALL} (SELECT col FROM ...)` quantified comparison with all six operators (`=` `<>` `<` `<=` `>` `>=`) plus T-SQL synonyms `!=` `!<` `!>`, predicate-only (SELECT-list usage raises Msg 102 at the operator, matching real SQL Server's grammar restriction); SOME aliases ANY. Empty inner: ALL vacuously true, ANY vacuously false (both independent of LHS NULL); non-empty inner with NULL on either side of any per-row compare taints to UNKNOWN per three-valued logic. All forms work correlated and non-correlated, arbitrary nesting depth. `UNION` / `UNION ALL` / `INTERSECT` / `EXCEPT` are legal inside every subquery context — derived tables in FROM, EXISTS, IN, ANY/ALL inners, scalar `(SELECT ...)`, CTE bodies — because subquery parsers route through `Selection.Parse` → `ParseQueryExpression`, which already drives the set-op chain. EF Core 7+'s TPC inheritance emit shape (UNION ALL of selects from each concrete table wrapped in a derived table) ships end-to-end through this path.
@@ -139,7 +137,7 @@ Session-scoped T-SQL cursors: `DECLARE <name> [INSENSITIVE][SCROLL] CURSOR [LOCA
139137
Comparison (Msg 402), ORDER BY/DISTINCT (Msg 306), and aggregates (Msg 8117 from MAX/MIN) all enforced.
140138

141139
### `SimulatedDbDataReader`
142-
Full `DbDataReader` contract. Typed accessors read `SqlValue` directly via the cursor's indexer and unwrap via `As*` (no boxing); NULL on a typed accessor → `SqlNullValueException` matching SqlClient. `GetDateTime` covers Date/DateTime/SmallDateTime/DateTime2 (Date surfaces at midnight, `Kind=Unspecified`); `GetDecimal` covers Decimal/Numeric/Money/SmallMoney; `GetFieldValue<T>` short-circuits EF's `DateOnly`-over-`Date` and `TimeOnly`-over-`Time`. `GetOrdinal(name)` is two-pass linear scan (case-sensitive then case-insensitive — SqlClient's documented precedence). `HasRows` is a sticky bit. `GetChar(int)` always raises `InvalidCastException` (matches SqlClient).
140+
Full `DbDataReader` contract. Typed accessors read `SqlValue` directly via the cursor's indexer and unwrap via `As*` (no boxing); NULL on a typed accessor → `SqlNullValueException` matching SqlClient. `GetDateTime` covers Date/DateTime/SmallDateTime/DateTime2 (Date surfaces at midnight, `Kind=Unspecified`). A **`datetime` value rounds to whole milliseconds at the ADO.NET boundary** (`DateTimeSqlType.RoundToClientMilliseconds`, applied in `GetDateTime` and `SqlValue.ToObject` — the latter also covers `GetValue` / `GetFieldValue` / output-parameter writeback) to match SqlClient's `.000`/`.003`/`.007` materialization; the value stays at full 1/300-second resolution for engine-internal comparison / arithmetic / re-encode, so only the client-facing surface rounds. `GetDecimal` covers Decimal/Numeric/Money/SmallMoney; `GetFieldValue<T>` short-circuits EF's `DateOnly`-over-`Date` and `TimeOnly`-over-`Time`. `GetOrdinal(name)` is two-pass linear scan (case-sensitive then case-insensitive — SqlClient's documented precedence). `HasRows` is a sticky bit. `GetChar(int)` always raises `InvalidCastException` (matches SqlClient).
143141

144142
### Feature reference
145143

@@ -149,6 +147,7 @@ Per-feature deep-dives live under `docs/claude/`. Each entry below is a trigger:
149147
- **`SqlType.Promote` / `PromoteForArithmetic` / decimal precision-scale / int↔string promotion**[`arithmetic.md`](docs/claude/arithmetic.md).
150148
- **`Cast` / coercion error paths** (CAST/CONVERT narrow targets, TRY_CAST/TRY_CONVERT swallow set, PARSE/TRY_PARSE culture-aware parsing) → [`casting.md`](docs/claude/casting.md).
151149
- **`Selection`, aggregates, window functions, set ops, CASE, OFFSET/FETCH**[`query.md`](docs/claude/query.md).
150+
- **JOIN / APPLY execution** (`JoinDriver`, equi-join hash fast path + `SqlValueKey` keying, nested-loop fallback, RIGHT/FULL materialization + derived-table-right, comma-FROM splicing, `JoinDiagnostics` strategy guard) → [`joins.md`](docs/claude/joins.md).
152151
- **UPDATE / DELETE / INSERT…SELECT / SELECT…INTO / rowversion (incl. `@@DBTS` / `MIN_ACTIVE_ROWVERSION`) / identity helpers (`@@IDENTITY` / `SCOPE_IDENTITY` / `IDENT_CURRENT` / `IDENT_INCR` / `IDENT_SEED`) / `@@ROWCOUNT` / `ROWCOUNT_BIG` / OUTPUT / MERGE**[`dml.md`](docs/claude/dml.md).
153152
- **Variables, control flow (IF/WHILE/BREAK/CONTINUE/RETURN), TRY/CATCH+THROW+ERROR_*, `@@ERROR` / `@@TRANCOUNT` / `XACT_STATE`, PRINT, WAITFOR**[`control-flow.md`](docs/claude/control-flow.md).
154153
- **Cursors (`DECLARE … CURSOR` / `OPEN` / `FETCH` / `CLOSE` / `DEALLOCATE`, STATIC / KEYSET / DYNAMIC sensitivity, scroll fetches, `@@FETCH_STATUS` / `@@CURSOR_ROWS` / `CURSOR_STATUS`, `WHERE CURRENT OF`)**[`cursors.md`](docs/claude/cursors.md).

SqlServerSimulator.Tests.EFCore/EFCoreDateTime.cs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,9 @@ public void Where_FiltersByDateTimeOffsetEquality_CrossOffset()
161161
[TestMethod]
162162
public void Insert_LegacyDateTime_RoundTripsAtTickGranularity()
163163
{
164-
// Legacy datetime stores 1/300-second ticks; .997 input → tick 299 = 9_966_666 100-ns ticks past second.
164+
// Legacy datetime stores 1/300-second ticks (.997 input → tick 299, the
165+
// raw .9966666); SqlClient rounds that to whole ms on read, so EF surfaces
166+
// .997 — a lossless round-trip for a millisecond-precision input.
165167
using var context = new TestDbContext(TestDbContext.CreateEventsSimulation());
166168

167169
var started = new DateTime(2026, 5, 4, 13, 45, 30, 997);
@@ -174,7 +176,7 @@ public void Insert_LegacyDateTime_RoundTripsAtTickGranularity()
174176
Assert.AreEqual(started.Hour, read.Value.Hour);
175177
Assert.AreEqual(started.Minute, read.Value.Minute);
176178
Assert.AreEqual(started.Second, read.Value.Second);
177-
Assert.AreEqual(9_966_666, read.Value.Ticks % TimeSpan.TicksPerSecond);
179+
Assert.AreEqual(9_970_000, read.Value.Ticks % TimeSpan.TicksPerSecond);
178180
}
179181

180182
[TestMethod]
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
using SqlServerSimulator.Parser;
2+
using static Microsoft.VisualStudio.TestTools.UnitTesting.Assert;
3+
4+
namespace SqlServerSimulator;
5+
6+
/// <summary>
7+
/// Perf-regression guard for the join executor's strategy choice
8+
/// (<c>Selection.Execution.Joins.cs</c>): an equi-join <c>a.col = b.col</c> must
9+
/// take the O(L+R) hash path, while a non-equi / CROSS predicate must fall back
10+
/// to the nested loop. The correctness suite passes under either strategy, so a
11+
/// silent revert to the O(L×R) loop would otherwise go unnoticed until a real
12+
/// workload hung. Reads the opt-in <see cref="JoinDiagnostics"/> trace (recorded
13+
/// at the single dispatch point) rather than timing, so the guard is exact and
14+
/// non-flaky.
15+
/// </summary>
16+
[TestClass]
17+
public sealed class JoinStrategyTests
18+
{
19+
private static List<string> CaptureStrategies(string joinQuery)
20+
{
21+
var sim = new Simulation();
22+
var connection = sim.CreateDbConnection();
23+
connection.Open();
24+
using (var setup = connection.CreateCommand())
25+
{
26+
setup.CommandText = """
27+
create table a (id int, name varchar(20));
28+
create table b (id int, a_id int);
29+
insert a values (1, 'one'), (2, 'two');
30+
insert b values (10, 1), (11, 2)
31+
""";
32+
_ = setup.ExecuteNonQuery();
33+
}
34+
35+
JoinDiagnostics.Sink = [];
36+
try
37+
{
38+
using var command = connection.CreateCommand();
39+
command.CommandText = joinQuery;
40+
using var reader = command.ExecuteReader();
41+
while (reader.Read()) { }
42+
return JoinDiagnostics.Sink;
43+
}
44+
finally
45+
{
46+
JoinDiagnostics.Sink = null;
47+
}
48+
}
49+
50+
[TestMethod]
51+
public void EquiJoin_TakesHashPath()
52+
=> Contains("Inner:HashMatch(keys=1,residual=0)",
53+
CaptureStrategies("select a.id from a join b on a.id = b.a_id"));
54+
55+
[TestMethod]
56+
public void EquiJoinWithExtraPredicate_HashesKeyKeepsRestAsResidual()
57+
=> Contains("Inner:HashMatch(keys=1,residual=1)",
58+
CaptureStrategies("select a.id from a join b on a.id = b.a_id and b.id > 10"));
59+
60+
[TestMethod]
61+
public void LeftJoin_TakesHashPath()
62+
=> Contains("Left:HashMatch(keys=1,residual=0)",
63+
CaptureStrategies("select a.id from a left join b on a.id = b.a_id"));
64+
65+
[TestMethod]
66+
public void NonEquiJoin_FallsBackToNestedLoops()
67+
=> Contains("Inner:NestedLoops",
68+
CaptureStrategies("select a.id from a join b on a.id <> b.a_id"));
69+
70+
[TestMethod]
71+
public void CrossJoin_UsesNestedLoops()
72+
=> Contains("Cross:NestedLoops",
73+
CaptureStrategies("select a.id from a cross join b"));
74+
}

SqlServerSimulator.Tests/Bacpac/BacpacBuilder.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -800,7 +800,8 @@ private static void WriteDateTime(Span<byte> buf, DateTime dt)
800800
var epoch = new DateTime(1900, 1, 1, 0, 0, 0, DateTimeKind.Unspecified);
801801
var diff = dt - epoch;
802802
var days = diff.Days;
803-
var ticks300 = (uint)((diff.Ticks - (TimeSpan.TicksPerDay * days)) / (TimeSpan.TicksPerSecond / 300L));
803+
var timeTicks = diff.Ticks - (TimeSpan.TicksPerDay * days);
804+
var ticks300 = (uint)(((timeTicks * 300) + (TimeSpan.TicksPerSecond / 2)) / TimeSpan.TicksPerSecond);
804805
BinaryPrimitives.WriteInt32LittleEndian(buf[..4], days);
805806
BinaryPrimitives.WriteUInt32LittleEndian(buf[4..8], ticks300);
806807
}

SqlServerSimulator.Tests/Bacpac/BacpacLoaderTests.cs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,24 @@ public void BcpDataRoundTrips_AcrossCommonTypes()
308308
IsFalse(reader.Read());
309309
}
310310

311+
[TestMethod]
312+
public void DateTimeColumn_LateDaySubSecond_RoundTripsThroughBcp()
313+
{
314+
// datetime decode converts 1/300-second ticks back to .NET ticks. Doing
315+
// the divide before the multiply (TicksPerSecond / 300 = 33333, truncating
316+
// the real 33333.333) under-counts every tick — an error that compounds
317+
// with the tick-count-since-midnight, reaching ~0.4-0.9s late in the day.
318+
// 23:47:16.030 sits on the 1/300 grid (30ms = 9 ticks) so it must survive
319+
// exactly; the old truncation shifted it back to ~23:47:15.6.
320+
var stamp = new DateTime(2024, 11, 8, 23, 47, 16, 30, DateTimeKind.Unspecified);
321+
using var bacpac = BacpacBuilder.Create()
322+
.Table("dbo", "Stamps", t => t.Column("When", "datetime").Row(stamp))
323+
.Build();
324+
var sim = new Simulation();
325+
sim.ImportBacpac(bacpac, out _);
326+
AreEqual(stamp, sim.ExecuteScalar("SELECT [When] FROM Stamps"));
327+
}
328+
311329
[TestMethod]
312330
public void DecimalColumn_RoundTripsThroughBcp_AcrossPrecisionBuckets()
313331
{

SqlServerSimulator.Tests/CurrentTimeFunctionTests.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,8 @@ private static DateTime RoundToLegacyTick(DateTime utcDt)
208208
timeUnits = 0;
209209
}
210210
var roundedTicks = timeUnits * TimeSpan.TicksPerSecond / 300;
211-
return new DateTime(1900, 1, 1).AddDays(dayCount).AddTicks(roundedTicks);
211+
// SqlClient rounds the stored 1/300 tick to whole milliseconds on retrieval.
212+
var clientTicks = (roundedTicks + (TimeSpan.TicksPerMillisecond / 2)) / TimeSpan.TicksPerMillisecond * TimeSpan.TicksPerMillisecond;
213+
return new DateTime(1900, 1, 1).AddDays(dayCount).AddTicks(clientTicks);
212214
}
213215
}

SqlServerSimulator.Tests/JoinTests.cs

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -504,4 +504,107 @@ public void CommaFrom_LeadingComma_RaisesSyntaxError()
504504
create table a (id int);
505505
select * from , a
506506
""", 102);
507+
508+
// --- Equi-join hash path edge cases (Selection.Execution.Joins.cs). ---
509+
// The `a.col = b.col` fast path replaces the nested loop; these pin the
510+
// semantics that differ from a naive hash: NULL keys, type promotion,
511+
// composite keys, residual non-equi conjuncts, and collation folding.
512+
513+
private static Simulation SeededNullKeys()
514+
{
515+
var sim = new Simulation();
516+
_ = sim.ExecuteNonQuery("""
517+
create table l (id int, k int);
518+
create table r (k int, v int);
519+
insert l values (1, 10), (2, null);
520+
insert r values (10, 100), (null, 999)
521+
""");
522+
return sim;
523+
}
524+
525+
/// <summary>
526+
/// l.id=2 (NULL k) and r's NULL-k row must not pair — NULL = NULL is UNKNOWN.
527+
/// </summary>
528+
[TestMethod]
529+
public void HashEquiJoin_NullKey_ExcludedFromInnerMatch()
530+
=> AreEqual(1, SeededNullKeys().ExecuteScalar("select count(*) from l join r on l.k = r.k"));
531+
532+
[TestMethod]
533+
public void HashEquiJoin_NullKey_LeftJoinEmitsNullFilledRight()
534+
{
535+
var sim = SeededNullKeys();
536+
// Both left rows survive; the NULL-key one is NULL-filled, not matched to r's NULL row.
537+
AreEqual(2, sim.ExecuteScalar("select count(*) from l left join r on l.k = r.k"));
538+
AreEqual(1, sim.ExecuteScalar("select count(*) from l left join r on l.k = r.k where r.v is null"));
539+
}
540+
541+
/// <summary>
542+
/// r's NULL-key row never matches, so RIGHT JOIN emits it with left NULL-filled (2 rows total).
543+
/// </summary>
544+
[TestMethod]
545+
public void HashEquiJoin_NullKey_RightJoinEmitsUnmatchedRightRow()
546+
=> AreEqual(2, SeededNullKeys().ExecuteScalar("select count(*) from l right join r on l.k = r.k"));
547+
548+
/// <summary>
549+
/// bigint = int must hash under the promoted common type, not by raw SqlValue.Type.
550+
/// </summary>
551+
[TestMethod]
552+
public void HashEquiJoin_CrossTypeKey_PromotesAndMatches()
553+
=> AreEqual(1L, new Simulation().ExecuteScalar("""
554+
create table l (k bigint);
555+
create table r (k int);
556+
insert l values (5);
557+
insert r values (5);
558+
select count_big(*) from l join r on l.k = r.k
559+
"""));
560+
561+
[TestMethod]
562+
public void HashEquiJoin_CompositeKey_AllColumnsMustMatch()
563+
=> AreEqual(1, new Simulation().ExecuteScalar("""
564+
create table l (a int, b int);
565+
create table r (a int, b int);
566+
insert l values (1, 2), (1, 3);
567+
insert r values (1, 2), (9, 9);
568+
select count(*) from l join r on l.a = r.a and l.b = r.b
569+
"""));
570+
571+
/// <summary>
572+
/// The `r.v > 150` conjunct isn't an equi-key; it's re-checked per probed candidate.
573+
/// </summary>
574+
[TestMethod]
575+
public void HashEquiJoin_ResidualNonEquiConjunct_FiltersCandidates()
576+
=> AreEqual(1, new Simulation().ExecuteScalar("""
577+
create table l (k int);
578+
create table r (k int, v int);
579+
insert l values (10);
580+
insert r values (10, 100), (10, 200);
581+
select count(*) from l join r on l.k = r.k and r.v > 150
582+
"""));
583+
584+
/// <summary>
585+
/// Default SQL_Latin1_General_CP1_CI_AS is case-insensitive — the hash key
586+
/// must fold case (GetHashCode agreeing with collation-aware Equals).
587+
/// </summary>
588+
[TestMethod]
589+
public void HashEquiJoin_StringKey_FoldsCaseUnderDefaultCollation()
590+
=> AreEqual(1, new Simulation().ExecuteScalar("""
591+
create table l (name varchar(20));
592+
create table r (name varchar(20));
593+
insert l values ('One');
594+
insert r values ('one');
595+
select count(*) from l join r on l.name = r.name
596+
"""));
597+
598+
/// <summary>
599+
/// 1 match + 1 unmatched-left + 1 unmatched-right = 3 rows.
600+
/// </summary>
601+
[TestMethod]
602+
public void HashEquiJoin_FullJoin_EmitsUnmatchedFromBothSides()
603+
=> AreEqual(3, new Simulation().ExecuteScalar("""
604+
create table l (k int);
605+
create table r (k int);
606+
insert l values (1), (2);
607+
insert r values (1), (3);
608+
select count(*) from l full outer join r on l.k = r.k
609+
"""));
507610
}

0 commit comments

Comments
 (0)