Skip to content

fix(model): compare SQL Server date and time conditions in the column's type - #4333

Merged
bpamiri merged 6 commits into
developfrom
peter/sqlserver-temporal-conditions
Oct 5, 2026
Merged

bpamiri merged 6 commits into
developfrom
peter/sqlserver-temporal-conditions

Conversation

@bpamiri

@bpamiri bpamiri commented Oct 5, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Fixes #4326. Fixes #4327.

On SQL Server, a condition compares in the parameter's type, and that type depends on the engine:

The fix: compare in the column's own type. The SQL Server adapter's $performQuery() hook rewrites a condition parameter into CAST(? AS <type>), the same mechanism #4190 uses for wide decimals. The WHERE parameter already carries the column's native dataType. One line in model/sql.cfm now passes its operator along too.

Column type Operators Becomes
DATETIME / SMALLDATETIME =, <> / !=, IN, NOT IN CAST(? AS DATETIME) / SMALLDATETIME: the value rounds the way the stored value did
DATETIME / SMALLDATETIME ranges (<, <=, >, >=, BETWEEN) unchanged; the exact comparison is correct, and rounding a bound could move a row across it
TIME any comparison the value bound as text (HH:mm:ss.fffffff), CAST(? AS TIME(7)), which holds any TIME(n) exactly
DATETIME2 / DATE / DATETIMEOFFSET — unchanged; they already matched

Risk. The value is now compared at the column's precision. So an = or IN condition with a fractional value can now match the row that holds it, and a <> or NOT IN condition now leaves that row out, as SQL Server does with a literal.

  • Example: createdAt <> '2026-01-02 10:00:00.001' used to return a row stored as 10:00:00.000 and now doesn't, because .001 rounds to .000 in a DATETIME. A spec pins this case.
  • (Corrected after review: an earlier version of this paragraph said no working query changes, which isn't true for <> / NOT IN.)
  • Whole-second values cast exactly.
  • Ranges and other column types are untouched.
  • Raw queryExecute and parameterize=false are unaffected; SQL Server already converts an inline literal to the column's type.
  • One edge follows SQL Server's own storage: 23:59:59.999 rounds to the next midnight on DATETIME, so it equals a stored 00:00:00.000.

Docs

  • changelog.d/4326-sqlserver-datetime-equality.changed.md.
  • changelog.d/4327-sqlserver-time-conditions.fixed.md.
  • A 4.1 to 4.2 upgrade-guide section, "SQL Server DATETIME conditions match fractional values".

Tests

model/sqlServerTemporalConditionSpec uses a SQL Server table with DATETIME, SMALLDATETIME, DATETIME2(7), DATETIME2(3) and TIME(7) columns. The rows hold the #4318 probe values: whole seconds, fractions DATETIME can't hold, a value that rounds to the next day, and half-minute boundaries. The checks:

  • = through the model path matches exactly what col = CAST(? AS <column type>) matches, for every column and value;
  • fractional DATETIME values match, including a dynamic finder;
  • IN / NOT IN lists match value by value;
  • ranges on DATETIME stay exactly as they were (compared against today's DATETIME2(7) result);
  • <> and NOT IN with .001 leave out the row stored as .000: one row fewer than the old DATETIME2(7) comparison, and the same as the column-type comparison;
  • TIME columns compare exactly, fractions included;
  • a TIME list past the parameter limit runs and matches the same rows;
  • unit checks for the operator gating, the type map and the time text.

RED on develop (only the spec and its model asset added), Lucee 7 × SQL Server: 7 checks fail or error.

  • Fractional DATETIME = and IN match 0 rows.
  • The TIME check errors with The data types time and datetime are incompatible in the equal to operator.
  • The three helper checks error because the helpers don't exist yet.
  • The range check already passes, as it should: ranges must not change.

GREEN at the final head, after merging develop (with #4318 and #4332):

Engine Database internal model
Lucee 7 SQL Server 114 1440
Lucee 7 SQLite 114 1390
BoxLang SQL Server — 1440

All 0 fail / 0 error. Before that merge, these also passed with 0 fail / 0 error:

  • Adobe 2025 × SQL Server (model 1430);
  • Lucee 7 × MySQL and PostgreSQL (model), which cover the new operator key on the other adapters.

Adobe 2023 × SQL Server was not run locally. The local Adobe 2023 engine never becomes healthy (The Runtime service is not available), as during #4103, so no request reaches Wheels; the compat matrix covers that leg.

The guides build passes.

🤖 Generated with Claude Code

bpamiri and others added 4 commits October 5, 2026 01:20
…'s type

Every engine binds cf_sql_timestamp as DATETIME2(7), and SQL Server then
converts a DATETIME or SMALLDATETIME column exactly, so a value with a
fraction of a second never equalled what the column stored in 1/300-second
(or one-minute) steps. timestamps() creates DATETIME columns on SQL Server,
so createdAt = <a saved value> could match nothing. cf_sql_time bound as
DATETIME on Lucee (an error against a TIME column), not at all on BoxLang,
and without its fraction on Adobe.

A condition param now carries its operator, and the SQL Server adapter casts:
- =, <>, IN and NOT IN on a DATETIME / SMALLDATETIME column to that type, so
  the value rounds the way the stored value did. Ranges keep the exact
  comparison, and DATETIME2 / DATE columns are unchanged;
- any comparison on a TIME column, bound as text, to TIME(7).
An IN list becomes one CAST per value; the parameter count is unchanged.
$temporalComparisonType() names the type for each column.

Fixes #4326
Fixes #4327

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>
… type

A long IN list on a DATETIME or SMALLDATETIME column now splits with the
column's own CAST, as the same list does when it fits, so the two paths match
the same rows (sqlServerDateStringSplitSpec). A long TIME list splits too, cast
to TIME(7) like a time condition; it used to keep Wheels.TooManyParameters.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>
@bpamiri bpamiri added this to the v4.2.0 milestone Oct 5, 2026
@bpamiri

bpamiri commented Oct 5, 2026

Copy link
Copy Markdown
Collaborator Author

Review @ 2895e57: source looks good, one MEDIUM on the docs claim. Final verdict after matrix 37293563373 and PR CI

Mechanism

  • Hook order. $performQuery() runs $splitLargeInLists(), then $castTemporalParams(), then $castWideDecimalParams().

    • A list that was split is already a plain nvarchar part, with no dataType / operator, so it isn't cast twice.
    • An unsplit IN list becomes (CAST(? AS DATETIME), CAST(? AS DATETIME), …), so the parameter count is unchanged. The list key is deleted per value, so Base doesn't add a second set of parentheses: col IN (CAST(…), …).
  • The new operator key (model/sql.cfm :1587) never reaches cfqueryparam. Base.$queryParams() builds a new struct from type, value and null only, so other adapters ignore it.

  • One helper decides the cast for both paths. $temporalComparisonType() drives both $isTemporalCastParam() (the normal path) and $stringSplitList() (the split path):

    • DATETIME / SMALLDATETIME are cast on = <> != IN NOT IN only;
    • TIME is cast on every comparison;
    • DATETIME2, DATE and DATETIMEOFFSET are untouched.

    That's the shared helper I asked for on feat(model): run long date and timestamp IN lists on SQL Server through STRING_SPLIT #4329.

orch1's checks

  1. Split equals unsplit. sqlServerDateStringSplitSpec's differential, over d, dt, sdt, dt2, dt23 and dto × whereIn / whereNotIn / where-string IN, is unchanged and now also compares the DATETIME and SMALLDATETIME columns in their own type on both paths. Long TIME lists are covered by the new spec's "a TIME list past the parameter limit runs and matches the same rows". I'll confirm both on the matrix.
  2. Do existing results change? Ranges, whole-second values and DATETIME2 / DATE columns are unchanged: whole seconds cast exactly, and the range check compares against today's DATETIME2(7) result. But see the MEDIUM: <> and NOT IN with a fractional value do change.
  3. The 4326 / 4327 fragments and the guide section are accurate apart from the sentence below.
  4. The edits to the 4318 fragment and guide note are accurate.
    • They now say a long list compares "in the column's own type on a DATETIME, SMALLDATETIME or TIME column, otherwise as datetime2(7) / date", and that time lists split.
    • That matches $stringSplitList(), which uses columnCast for cf_sql_timestamp and calls $stringSplitTimes() for TIME.
    • The spec's time case now expects the list to run, and the Wheels.TooManyParameters advice drops "times".

Finding

  • MEDIUM (docs: the claim is wrong for <> / NOT IN). The fragment and upgrade guide say: "This only lets rows match that could never match before; no working query changes." That holds for = / IN, which can only gain rows. For <> / NOT IN it doesn't.
    • Take a row stored as 10:00:00.000 and the condition createdAt <> '2026-01-02 10:00:00.001'.
    • Before: the column converts exactly to DATETIME2, .000 <> .001 is true, and the row is returned.
    • Now: CAST('…001' AS DATETIME) rounds to .000, <> is false, and the row is left out.
    • NOT IN behaves the same way per value. So a working <> / NOT IN query whose value has a fraction can return fewer rows.
    • The new behaviour is right: it's what SQL Server itself does with an inline literal. But the docs should say so, and orch1's check (2) asks exactly this.
    • Suggested wording: "An = or IN condition with such a value can now match the row that holds it, and a <> or NOT IN condition now leaves that row out, as SQL Server does with a literal. Whole-second values, ranges and DATETIME2 / DATE columns compare as before."
    • The same correction belongs in the PR body's Risk paragraph.

INFOs

  • $timeText() passes text that isn't a time through unchanged, so CAST(? AS TIME(7)) reports it as a conversion error. Previously the engine's bind failed instead. Either way it errors.
  • The 23:59:59.999 → next-midnight edge on DATETIME is SQL Server's own storage rounding. It's documented in the PR body; worth one clause in the guide if you like.

On the matrix I'll read sqlServerTemporalConditionSpec, sqlServerDateStringSplitSpec and sqlServerStringSplitSpec on all five SQL Server legs, reading adobe2023 closely, plus the MySQL and PostgreSQL legs for the new operator key, and the PR CI tally.

…e out rows

Comparing at the column's precision lets = and IN match rows they never
matched, and makes <> and NOT IN leave out a row whose stored value the
rounded value equals: createdAt <> '...10:00:00.001' no longer returns a row
stored as 10:00:00.000. The fragment and upgrade guide now say so, and a spec
pins the <> / NOT IN case.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>
@github-actions

github-actions Bot commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

Wheels Test Results

222 279 tests   217 180 ✅  1h 23m 8s ⏱️
 17 216 suites    5 099 💤
     32 files          0 ❌

Results for commit e5ae6be.

♻️ This comment has been updated with latest results.

@bpamiri

bpamiri commented Oct 5, 2026

Copy link
Copy Markdown
Collaborator Author

Matrix 37294384312 @ dd7c565: not approvable yet (one MUST-FIX, RustCFML)

The delta itself is good. The fragment and guide now state the <> / NOT IN consequence (with the .001 → .000 example and the 23:59:59.999 edge). The new spec, "leaves out with <> and NOT IN a row whose stored value the cast value equals", pins it.

The 32 engine × database legs are all green.

  • 0 fail and 0 error everywhere.
  • On all five SQL Server legs, including adobe2023 × sqlserver, which I read closely:
    • sqlServerTemporalConditionSpec 10/10
    • sqlServerDateStringSplitSpec 9/9
    • sqlServerStringSplitSpec 12/12
  • MySQL and PostgreSQL are unaffected by the new operator key: the only skip changes there are the new SQL Server-only cases.

MUST-FIX: the RustCFML lane fails, which reddens the matrix gate and the PR's "RustCFML (Linux)" check.

  • The failing case is sqlServerTemporalConditionSpec › "writes time values as SQL Server TIME text": $timeText(CreateDateTime(2026, 1, 2, 10, 0, 0)) returned 10:00:00, expected 10:00:00.000.
  • Cause. On RustCFML a date object passes IsSimpleValue(), and its string form is 2026-01-02 10:00:00. So $timeText() takes the string branch, where the ^\d{4}-\d{2}-\d{2}[ T](…)$ pattern matches. It returns the time text without milliseconds and never reaches the IsDate() branch.
  • On Lucee, Adobe and BoxLang the string form is {ts '…'}, which doesn't match the pattern, so they fall through to TimeFormat(…) & "." & Millisecond(…).
  • This is more than a cosmetic spec mismatch. A date object carrying milliseconds would lose them on RustCFML, so a TIME condition could compare 10:00:00 instead of 10:00:00.123.
  • Suggested fix: handle real date objects before the string parsing. For example, when !IsSimpleValue(value) || IsInstanceOf(value, "java.util.Date") (or the engine-neutral check you already use elsewhere for date objects), go straight to the TimeFormat / Millisecond branch. Keep the regex branches for genuine strings.
  • RustCFML's own pinned suite should then show writes time values as SQL Server TIME text passing.

PR CI: 25 pass, 1 fail (RustCFML (Linux), the same cause), 3 skipping.

I'll re-read the RustCFML lane, the matrix gate and the SQL Server legs on the next head.

…tCFML

RustCFML treats a date object as a simple value whose text drops the
milliseconds, so $timeText() read it as a date-time string and lost them.
A date-time string is now cut to its time only when it carries a fraction;
any other date goes through TimeFormat() and Millisecond().

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>
@bpamiri

bpamiri commented Oct 5, 2026

Copy link
Copy Markdown
Collaborator Author

Final: APPROVE @ e5ae6be (matrix 37298981849, all 32 legs read, plus the RustCFML lane and the gate; PR CI read)

Delta since dd7c565. The MUST-FIX is fixed.

  • $timeText() now cuts a date-time string's time only when the string carries a fraction (^\d{4}-\d{2}-\d{2}[ T]\d{1,2}:\d{2}:\d{2}\.\d{1,7}$).
  • Every other date, including a date object that RustCFML treats as a simple value, goes through TimeFormat(…, "HH:mm:ss") & "." & Millisecond(…). So milliseconds are no longer dropped on that path, and "noon" still passes through unchanged.
  • The RustCFML guard is narrow. It wraps only the one assertion that a date's 250 ms survive, because RustCFML dates carry no milliseconds at all (measured). The other $timeText checks run on every engine, including the date-object case and the new "2026-01-02 10:00:00" → "10:00:00.000". So a regression on Lucee, Adobe or BoxLang would still fail.

Matrix

  • 32/32 legs have 0 fail, 0 error. The rustcfml job and the Compatibility Matrix Gate both succeed.
  • On all five SQL Server legs, including adobe2023 × sqlserver, all with 0 skipped:
    • sqlServerTemporalConditionSpec 10/10, including the <> / NOT IN case
    • sqlServerDateStringSplitSpec 9/9
    • sqlServerStringSplitSpec 12/12
  • Every other leg: the SQL Server-only cases skip as designed (3/10, 4/9 and 5/12).
  • Skips are identical to the previous head's matrix on adobe2023-sqlserver and lucee7-mysql.

PR CI: 26 pass (including RustCFML (Linux)), 3 skipping, 0 fail, 0 pending.

No open findings.

@bpamiri
bpamiri merged commit e88227a into develop Oct 5, 2026
67 checks passed
@bpamiri
bpamiri deleted the peter/sqlserver-temporal-conditions branch October 5, 2026 11:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

1 participant