From b74e678fc246f0ad740ac6cf1aca4c98f3fa5579 Mon Sep 17 00:00:00 2001 From: Peter Amiri Date: Mon, 5 Oct 2026 01:20:38 -0700 Subject: [PATCH 1/4] fix(model): compare SQL Server date and time conditions in the column'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 = 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 Signed-off-by: Peter Amiri --- ...326-sqlserver-datetime-equality.changed.md | 1 + .../4327-sqlserver-time-conditions.fixed.md | 1 + .../MicrosoftSQLServerModel.cfc | 130 +++++++++++++- vendor/wheels/model/sql.cfm | 1 + .../wheels/tests/_assets/models/Temporal.cfc | 7 + .../model/sqlServerTemporalConditionSpec.cfc | 168 ++++++++++++++++++ .../docs/v4-2-0/upgrading/4x-1-to-4x-2.mdx | 4 + 7 files changed, 311 insertions(+), 1 deletion(-) create mode 100644 changelog.d/4326-sqlserver-datetime-equality.changed.md create mode 100644 changelog.d/4327-sqlserver-time-conditions.fixed.md create mode 100644 vendor/wheels/tests/_assets/models/Temporal.cfc create mode 100644 vendor/wheels/tests/specs/model/sqlServerTemporalConditionSpec.cfc diff --git a/changelog.d/4326-sqlserver-datetime-equality.changed.md b/changelog.d/4326-sqlserver-datetime-equality.changed.md new file mode 100644 index 0000000000..a3f4248dc1 --- /dev/null +++ b/changelog.d/4326-sqlserver-datetime-equality.changed.md @@ -0,0 +1 @@ +- On SQL Server, an `=`, `<>`, `IN` or `NOT IN` condition on a `DATETIME` or `SMALLDATETIME` column now compares in the column's own type. A value with a fraction of a second, such as `createdAt = '2026-01-02 10:00:00.123'` on a `timestamps()` column, matched no row, because every engine sends the value as `DATETIME2(7)` while the column stores 1/300-second (or one-minute) steps. Such a condition now matches the row that holds the value. This only lets rows match that could never match before; no working query changes. Whole-second values, ranges (`<`, `>=`, `BETWEEN`) and `DATETIME2` / `DATE` columns compare as before (#4326) diff --git a/changelog.d/4327-sqlserver-time-conditions.fixed.md b/changelog.d/4327-sqlserver-time-conditions.fixed.md new file mode 100644 index 0000000000..477e7e5ec3 --- /dev/null +++ b/changelog.d/4327-sqlserver-time-conditions.fixed.md @@ -0,0 +1 @@ +- On SQL Server, a condition on a `TIME` column works on every engine. It errored on Lucee ("The data types time and datetime are incompatible") and BoxLang, and on Adobe ColdFusion it dropped the value's fraction of a second, so only whole-second values matched (#4327) diff --git a/vendor/wheels/databaseAdapters/MicrosoftSQLServer/MicrosoftSQLServerModel.cfc b/vendor/wheels/databaseAdapters/MicrosoftSQLServer/MicrosoftSQLServerModel.cfc index 83a80ddec5..9a51f040e7 100755 --- a/vendor/wheels/databaseAdapters/MicrosoftSQLServer/MicrosoftSQLServerModel.cfc +++ b/vendor/wheels/databaseAdapters/MicrosoftSQLServer/MicrosoftSQLServerModel.cfc @@ -18,7 +18,8 @@ component extends="wheels.databaseAdapters.Base" output=false { /** * Internal function. Splits IN lists that would exceed the parameter limit into one parameter - * each (#4103), then casts high-precision decimal params exactly (#4172), before running the query. + * each (#4103), casts date and time conditions to their column's type (#4326, #4327), then casts + * high-precision decimal params exactly (#4172), before running the query. */ public struct function $performQuery( required array sql, @@ -31,10 +32,137 @@ component extends="wheels.databaseAdapters.Base" output=false { boolean $captureResult = true ) { $splitLargeInLists(args = arguments); + $castTemporalParams(args = arguments); $castWideDecimalParams(args = arguments); return super.$performQuery(argumentCollection = arguments); } + /** + * Internal function. The SQL Server type a condition on a column of `dataType` compares in, or + * "" to keep the bind as it is (#4326, #4327). 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 never equals what the column stored in 1/300-second (or one-minute) + * steps; compared as DATETIME / SMALLDATETIME, the value rounds the way the stored value did. + * cf_sql_time binds as DATETIME on Lucee, not at all on BoxLang and without its fraction on + * Adobe, so a TIME column compares as TIME(7), which holds any TIME(n) value exactly. + */ + public string function $temporalComparisonType(required string dataType) { + switch (LCase(arguments.dataType)) { + case "datetime": + return "DATETIME"; + case "smalldatetime": + return "SMALLDATETIME"; + case "time": + return "TIME(7)"; + } + return ""; + } + + /** + * Internal function. Rewrites each date or time condition param that $isTemporalCastParam() + * selects into `CAST(? AS )`, an IN list into one CAST per value (#4326, #4327). + * The number of bound parameters is unchanged. + */ + public void function $castTemporalParams(required struct args) { + if (!arguments.args.parameterize) { + return; + } + local.rv = []; + for (local.part in arguments.args.sql) { + // Adobe CF passes arrays by value, so the parts are returned and appended here. + if ($isTemporalCastParam(local.part)) { + local.parts = $castTemporalParts(local.part); + } else { + local.parts = [local.part]; + } + for (local.item in local.parts) { + ArrayAppend(local.rv, local.item); + } + } + arguments.args.sql = local.rv; + } + + /** + * Internal function. True for a WHERE condition param on a DATETIME or SMALLDATETIME column + * compared with =, <>, !=, IN or NOT IN, or on a TIME column with any comparison. A range on a + * DATETIME column keeps the exact comparison: rounding its bound could move a row across it. + */ + public boolean function $isTemporalCastParam(required any part) { + if ( + !IsStruct(arguments.part) + || !StructKeyExists(arguments.part, "dataType") + || !StructKeyExists(arguments.part, "operator") + || !StructKeyExists(arguments.part, "type") + || !StructKeyExists(arguments.part, "value") + || !ListFindNoCase("cf_sql_timestamp,cf_sql_time,cf_sql_date", arguments.part.type) + || (StructKeyExists(arguments.part, "null") && arguments.part.null) + ) { + return false; + } + local.castType = $temporalComparisonType(arguments.part.dataType); + if (!Len(local.castType)) { + return false; + } + local.operator = UCase(ReReplace(Trim(arguments.part.operator), "\s+", " ", "all")); + if (local.castType == "TIME(7)") { + return ListFind("=,<>,!=,<,<=,>,>=,IN,NOT IN", local.operator) > 0; + } + return ListFind("=,<>,!=,IN,NOT IN", local.operator) > 0; + } + + /** + * Internal function. The SQL parts for a date or time condition param as `CAST(? AS )`, + * or for an IN list as `(CAST(...), CAST(...))` with one param per value. A TIME value binds as + * text (HH:mm:ss.fffffff), which every engine sends as it is. + */ + public array function $castTemporalParts(required struct part) { + local.castType = $temporalComparisonType(arguments.part.dataType); + // $queryParams() unmasks an IN list's values and joins them with Chr(7). + local.qp = $queryParams(arguments.part); + local.isList = StructKeyExists(local.qp, "list") && local.qp.list; + local.values = local.isList ? ListToArray(local.qp.value, Chr(7)) : [local.qp.value]; + local.rv = []; + local.iEnd = ArrayLen(local.values); + for (local.i = 1; local.i <= local.iEnd; local.i++) { + local.param = StructCopy(arguments.part); + StructDelete(local.param, "list"); + local.param.value = local.values[local.i]; + if (local.castType == "TIME(7)") { + local.param.value = $timeText(local.values[local.i]); + local.param.type = "cf_sql_varchar"; + } + ArrayAppend(local.rv, (local.i == 1 ? (local.isList ? "(" : "") : ", ") & "CAST("); + ArrayAppend(local.rv, local.param); + ArrayAppend(local.rv, " AS #local.castType#)" & (local.isList && local.i == local.iEnd ? ")" : "")); + } + return local.rv; + } + + /** + * Internal function. A time value as SQL Server TIME text: `HH:mm[:ss[.fffffff]]` as written, the + * time of a `yyyy-mm-dd HH:mm:ss[.f]` string, or a CFML date's time to the millisecond. Any other + * text is returned unchanged, so the CAST reports it. + */ + public string function $timeText(required any value) { + if (IsSimpleValue(arguments.value)) { + local.text = Trim(arguments.value); + if (ReFind("^\d{1,2}:\d{2}(:\d{2}(\.\d{1,7})?)?$", local.text)) { + return local.text; + } + local.match = ReFind("^\d{4}-\d{2}-\d{2}[ T](\d{1,2}:\d{2}(:\d{2}(\.\d{1,7})?)?)$", local.text, 1, true); + if (local.match.pos[1]) { + return Mid(local.text, local.match.pos[2], local.match.len[2]); + } + if (!IsDate(local.text)) { + return local.text; + } + } + if (IsDate(arguments.value)) { + return TimeFormat(arguments.value, "HH:mm:ss") & "." & NumberFormat(Millisecond(arguments.value), "000"); + } + return arguments.value; + } + /** * Internal function. When a statement would bind more parameters than SQL Server accepts, * rewrites IN lists, largest first until it fits, to one parameter each (#4103): diff --git a/vendor/wheels/model/sql.cfm b/vendor/wheels/model/sql.cfm index 6b60dad51c..7312e4e603 100644 --- a/vendor/wheels/model/sql.cfm +++ b/vendor/wheels/model/sql.cfm @@ -1584,6 +1584,7 @@ dataType = local.params[local.i].dataType, scale = local.params[local.i].scale, list = local.params[local.i].list, + operator = local.params[local.i].operator, property = local.column }; ArrayAppend(local.rv, local.param); diff --git a/vendor/wheels/tests/_assets/models/Temporal.cfc b/vendor/wheels/tests/_assets/models/Temporal.cfc new file mode 100644 index 0000000000..b1e5fcd4cf --- /dev/null +++ b/vendor/wheels/tests/_assets/models/Temporal.cfc @@ -0,0 +1,7 @@ +component extends="Model" { + + function config() { + table("c_o_r_e_temporals"); + } + +} diff --git a/vendor/wheels/tests/specs/model/sqlServerTemporalConditionSpec.cfc b/vendor/wheels/tests/specs/model/sqlServerTemporalConditionSpec.cfc new file mode 100644 index 0000000000..c3d5b8335a --- /dev/null +++ b/vendor/wheels/tests/specs/model/sqlServerTemporalConditionSpec.cfc @@ -0,0 +1,168 @@ +/** + * Date and time conditions on SQL Server compare in the column's own type (#4326, #4327). Every + * engine binds cf_sql_timestamp as DATETIME2(7), so a value with a fraction never equalled what a + * DATETIME or SMALLDATETIME column stored, and cf_sql_time errored against a TIME column on Lucee + * and BoxLang and lost its fraction on Adobe. The model path must now match exactly the rows a + * comparison in the column's type matches, and leave ranges and DATETIME2 columns as they were. + */ +component extends="wheels.WheelsTest" { + + function beforeAll() { + variables.g = application.wo; + variables.ds = variables.g.get("dataSourceName"); + variables.isSqlServer = variables.g.get("adapterName") == "MicrosoftSQLServerModel"; + // dev1-impl's #4318 probe values: whole seconds, fractions DATETIME can't hold, a value that + // rounds to the next day, and half-minute boundaries SMALLDATETIME rounds differently. + variables.values = [ + "2026-01-02 10:00:00.000", + "2026-01-02 10:00:00.003", + "2026-01-02 10:00:00.123", + "2026-01-02 10:00:00.999", + "2026-01-02 23:59:59.997", + "2026-01-02 23:59:59.999", + "2026-01-03 00:00:00.000", + "2026-01-02 10:00:29.000", + "2026-01-02 10:00:30.000" + ]; + variables.columnTypes = {dt = "DATETIME", sdt = "SMALLDATETIME", dt2 = "DATETIME2(7)", dt23 = "DATETIME2(3)"}; + if (!variables.isSqlServer) { + return; + } + QueryExecute("IF OBJECT_ID('c_o_r_e_temporals') IS NOT NULL DROP TABLE c_o_r_e_temporals", [], {datasource = variables.ds}); + QueryExecute( + "CREATE TABLE c_o_r_e_temporals (id INT IDENTITY PRIMARY KEY, dt DATETIME, sdt SMALLDATETIME, dt2 DATETIME2(7), dt23 DATETIME2(3), t TIME(7))", + [], + {datasource = variables.ds} + ); + for (var v in variables.values) { + QueryExecute( + "INSERT INTO c_o_r_e_temporals (dt, sdt, dt2, dt23, t) VALUES (CAST(? AS DATETIME2(7)), CAST(? AS DATETIME2(7)), CAST(? AS DATETIME2(7)), CAST(? AS DATETIME2(7)), CAST(? AS TIME(7)))", + [v, v, v, v, ListLast(v, " ")], + {datasource = variables.ds} + ); + } + } + + function afterAll() { + if (variables.isSqlServer) { + QueryExecute("IF OBJECT_ID('c_o_r_e_temporals') IS NOT NULL DROP TABLE c_o_r_e_temporals", [], {datasource = variables.ds}); + } + StructDelete(application.wheels.models, "Temporal"); + } + + // Rows a comparison in `castType` matches: the ground truth for the model path. + function truthCount(required string column, required string operator, required string castType, required string value) { + return QueryExecute( + "SELECT COUNT(*) AS n FROM c_o_r_e_temporals WHERE #arguments.column# #arguments.operator# CAST(? AS #arguments.castType#)", + [{value = arguments.value, cfsqltype = "cf_sql_varchar"}], + {datasource = variables.ds} + ).n; + } + + function run() { + + describe("$temporalComparisonType()", () => { + + it("compares DATETIME, SMALLDATETIME and TIME columns in their own type and leaves the rest", () => { + var adapter = CreateObject("component", "wheels.databaseAdapters.MicrosoftSQLServer.MicrosoftSQLServerModel"); + expect(adapter.$temporalComparisonType("datetime")).toBe("DATETIME"); + expect(adapter.$temporalComparisonType("SMALLDATETIME")).toBe("SMALLDATETIME"); + expect(adapter.$temporalComparisonType("time")).toBe("TIME(7)"); + for (var type in ["datetime2", "date", "datetimeoffset", "varchar", "int"]) { + expect(adapter.$temporalComparisonType(type)).toBe("", type); + } + }); + + it("casts equality and IN on DATETIME, any comparison on TIME, and no range on DATETIME", () => { + var adapter = CreateObject("component", "wheels.databaseAdapters.MicrosoftSQLServer.MicrosoftSQLServerModel"); + for (var op in ["=", "<>", "!=", "IN", "NOT IN", "not in"]) { + expect(adapter.$isTemporalCastParam({type = "cf_sql_timestamp", dataType = "datetime", value = "2026-01-02 10:00:00.123", operator = op})).toBeTrue(op); + } + for (var op in ["<", "<=", ">", ">=", "LIKE"]) { + expect(adapter.$isTemporalCastParam({type = "cf_sql_timestamp", dataType = "datetime", value = "2026-01-02 10:00:00.123", operator = op})).toBeFalse(op); + } + var t = {type = "cf_sql_time", dataType = "time", value = "10:00:00.123", operator = ">="}; + expect(adapter.$isTemporalCastParam(t)).toBeTrue(); + var dt2 = {type = "cf_sql_timestamp", dataType = "datetime2", value = "2026-01-02 10:00:00.123", operator = "="}; + expect(adapter.$isTemporalCastParam(dt2)).toBeFalse(); + expect(adapter.$isTemporalCastParam({type = "cf_sql_timestamp", dataType = "datetime", value = "", null = true, operator = "="})).toBeFalse(); + }); + + it("writes time values as SQL Server TIME text", () => { + var adapter = CreateObject("component", "wheels.databaseAdapters.MicrosoftSQLServer.MicrosoftSQLServerModel"); + expect(adapter.$timeText("10:00:00.123")).toBe("10:00:00.123"); + expect(adapter.$timeText("9:05")).toBe("9:05"); + expect(adapter.$timeText("2026-01-02 10:00:00.1234567")).toBe("10:00:00.1234567"); + expect(adapter.$timeText(CreateDateTime(2026, 1, 2, 10, 0, 0))).toBe("10:00:00.000"); + expect(adapter.$timeText("noon")).toBe("noon"); + }); + + }); + + describe("A condition on a SQL Server date or time column", () => { + + it("matches with = exactly the rows a comparison in the column's type matches", () => { + if (!variables.isSqlServer) { + skip("SQL Server's DATETIME, SMALLDATETIME and TIME columns."); + } + for (var column in variables.columnTypes) { + for (var v in variables.values) { + var got = variables.g.model("temporal").count(where = "#column# = '#v#'"); + expect(got).toBe(truthCount(column, "=", variables.columnTypes[column], v), column & " = " & v); + } + } + }); + + it("matches fractional values that DATETIME and SMALLDATETIME round", () => { + if (!variables.isSqlServer) { + skip("SQL Server's DATETIME and SMALLDATETIME columns."); + } + expect(variables.g.model("temporal").count(where = "dt = '2026-01-02 10:00:00.123'")).toBe(1); + expect(variables.g.model("temporal").count(where = "dt = '2026-01-02 10:00:00.003'")).toBe(1); + // .999 rounds to the next midnight, which two rows hold once stored as DATETIME. + expect(variables.g.model("temporal").count(where = "dt = '2026-01-02 23:59:59.999'")).toBe(2); + expect(variables.g.model("temporal").findOneByDt("2026-01-02 10:00:00.123")).toBeWheelsModel(); + }); + + it("matches IN and NOT IN lists value by value in the column's type", () => { + if (!variables.isSqlServer) { + skip("SQL Server's DATETIME columns."); + } + var list = ["2026-01-02 10:00:00.003", "2026-01-02 10:00:00.123", "2026-01-02 10:00:00.999"]; + var expected = 0; + for (var v in list) { + expected += truthCount("dt", "=", "DATETIME", v); + } + expect(variables.g.model("temporal").whereIn("dt", list).count()).toBe(expected); + expect(variables.g.model("temporal").whereNotIn("dt", list).count()).toBe(ArrayLen(variables.values) - expected); + expect(variables.g.model("temporal").whereIn("sdt", ["2026-01-02 10:00:29.000"]).count()).toBe(truthCount("sdt", "=", "SMALLDATETIME", "2026-01-02 10:00:29.000")); + }); + + it("keeps ranges on DATETIME exact, as before", () => { + if (!variables.isSqlServer) { + skip("SQL Server's DATETIME columns."); + } + for (var op in [">=", "<", ">", "<="]) { + for (var v in ["2026-01-02 10:00:00.998", "2026-01-02 10:00:00.124", "2026-01-02 23:59:59.998"]) { + var got = variables.g.model("temporal").count(where = "dt #op# '#v#'"); + expect(got).toBe(truthCount("dt", op, "DATETIME2(7)", v), "dt " & op & " " & v); + } + } + }); + + it("compares TIME columns exactly on every engine, fractions included", () => { + if (!variables.isSqlServer) { + skip("SQL Server's TIME columns."); + } + for (var v in ["10:00:00.000", "10:00:00.123", "10:00:00.999", "23:59:59.997", "10:00:29"]) { + expect(variables.g.model("temporal").count(where = "t = '#v#'")).toBe(truthCount("t", "=", "TIME(7)", v), "t = " & v); + } + expect(variables.g.model("temporal").whereIn("t", ["10:00:00.123", "10:00:00.999"]).count()).toBe(2); + expect(variables.g.model("temporal").count(where = "t >= '23:00:00'")).toBe(truthCount("t", ">=", "TIME(7)", "23:00:00")); + }); + + }); + + } + +} diff --git a/web/sites/guides/src/content/docs/v4-2-0/upgrading/4x-1-to-4x-2.mdx b/web/sites/guides/src/content/docs/v4-2-0/upgrading/4x-1-to-4x-2.mdx index 172b3c0fc0..dad12386f3 100644 --- a/web/sites/guides/src/content/docs/v4-2-0/upgrading/4x-1-to-4x-2.mdx +++ b/web/sites/guides/src/content/docs/v4-2-0/upgrading/4x-1-to-4x-2.mdx @@ -352,6 +352,10 @@ In a migration, `default=""` on a column that isn't string-like (`bigInteger`, ` A migration that uses a column type the database adapter doesn't map, such as `t.column(columnName="payload", columnType="jsonb")`, now fails with `Wheels.Migrator.UnsupportedColumnType`, naming the type and the adapter ([#4095](https://github.com/wheels-dev/wheels/issues/4095)). Earlier releases emitted the column with no type. On most databases that was already a DDL error. On SQLite it created a typeless column whose model failed to load. If an existing SQLite migration relies on that, change the column to a supported type (for example `text` for JSON) before running it on a fresh database. The error's details list the supported types. Every type the `t.*()` column helpers emit is supported on every adapter. +### SQL Server `DATETIME` conditions match fractional values + +On SQL Server, an `=`, `<>`, `IN` or `NOT IN` condition on a `DATETIME` or `SMALLDATETIME` column now compares in the column's own type ([#4326](https://github.com/wheels-dev/wheels/issues/4326)). A value with a fraction of a second, such as `createdAt = '2026-01-02 10:00:00.123'` on a `timestamps()` column, used to match no row, because the value was sent as `DATETIME2(7)` while the column stores 1/300-second (or one-minute) steps. It now matches the row that holds it. This only lets rows match that could never match before; no working query changes. Whole-second values, ranges (`<`, `>=`, `BETWEEN`) and `DATETIME2` / `DATE` columns compare as before. Conditions on a `TIME` column, which errored on Lucee and BoxLang, now work on every engine ([#4327](https://github.com/wheels-dev/wheels/issues/4327)). + ### `withAdvisoryLock()` can throw `Wheels.AdvisoryLockReleaseFailed` On MySQL, PostgreSQL and SQL Server, `withAdvisoryLock()` now checks that the lock was released ([#4197](https://github.com/wheels-dev/wheels/issues/4197)). These locks belong to the pooled database session that took them, and on Lucee and BoxLang a busy pool can run the release on a different session, which frees nothing. Wheels retries the release for up to 5 seconds and then throws `Wheels.AdvisoryLockReleaseFailed`, naming the lock, if our session still holds it. The callback has already finished when this is thrown. If the callback itself threw, the failed release is logged instead and the callback's error is what you get. A lock left held stays held until that pooled connection closes, so other callers wait or time out on it. Before 4.2 this went unnoticed. The case is rare, but a scheduled job that relies on the lock should catch the exception and alert. Callers in the same application now also wait for each other, within `timeout`, before taking the database lock. To hold one connection for the whole call, which removes this case, pass `transaction = true` to `withAdvisoryLock()` ([#4198](https://github.com/wheels-dev/wheels/issues/4198)): it pins the lock, the callback, and the release to one connection inside a transaction (PostgreSQL, MySQL, and SQL Server; other databases throw). The callback then runs inside a transaction, so prefer it for short critical sections. On MySQL and SQL Server, `transaction = true` inside a transaction that is already open throws `Wheels.AdvisoryLockNotSupported`, because their lock would be released before the outer transaction commits: take the lock outside the transaction. PostgreSQL joins the open transaction and holds the lock until it ends. From 3bff452a86aa1a5714e3f6029ae6c6753c0b2434 Mon Sep 17 00:00:00 2001 From: Peter Amiri Date: Mon, 5 Oct 2026 02:51:57 -0700 Subject: [PATCH 2/4] fix(model): split long SQL Server date and time lists in the column's 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 Signed-off-by: Peter Amiri --- ...4318-sqlserver-long-date-in-lists.added.md | 2 +- .../4327-sqlserver-time-conditions.fixed.md | 2 +- .../MicrosoftSQLServerModel.cfc | 31 ++++++++++++++++--- .../model/sqlServerDateStringSplitSpec.cfc | 5 +-- .../model/sqlServerTemporalConditionSpec.cfc | 12 +++++++ .../basics/query-builder-and-scopes.mdx | 4 +-- 6 files changed, 46 insertions(+), 10 deletions(-) diff --git a/changelog.d/4318-sqlserver-long-date-in-lists.added.md b/changelog.d/4318-sqlserver-long-date-in-lists.added.md index 87270a2f5a..bbaa329681 100644 --- a/changelog.d/4318-sqlserver-long-date-in-lists.added.md +++ b/changelog.d/4318-sqlserver-long-date-in-lists.added.md @@ -1 +1 @@ -- On SQL Server 2016 and later, a statement whose date or timestamp IN lists push it past the parameter limit now runs: each long list binds as one `STRING_SPLIT` parameter, its values converted to the type the database driver binds a date or timestamp as (`datetime2(7)` / `date`), so it matches exactly the rows the same list matches when it fits. Values must be written as `yyyy-mm-dd` with an optional time, or be CFML dates; time lists and dates in other forms still throw `Wheels.TooManyParameters` (#4318) +- On SQL Server 2016 and later, a statement whose date or timestamp IN lists push it past the parameter limit now runs: each long list binds as one `STRING_SPLIT` parameter, its values compared the way the same list is when it fits (in the column's own type on a `DATETIME`, `SMALLDATETIME` or `TIME` column, otherwise as `datetime2(7)` / `date`), so it matches exactly the same rows. Values must be written as `yyyy-mm-dd` with an optional time, `HH:mm` with optional seconds for a time, or be CFML dates; dates in other forms still throw `Wheels.TooManyParameters` (#4318) diff --git a/changelog.d/4327-sqlserver-time-conditions.fixed.md b/changelog.d/4327-sqlserver-time-conditions.fixed.md index 477e7e5ec3..f25e0d2346 100644 --- a/changelog.d/4327-sqlserver-time-conditions.fixed.md +++ b/changelog.d/4327-sqlserver-time-conditions.fixed.md @@ -1 +1 @@ -- On SQL Server, a condition on a `TIME` column works on every engine. It errored on Lucee ("The data types time and datetime are incompatible") and BoxLang, and on Adobe ColdFusion it dropped the value's fraction of a second, so only whole-second values matched (#4327) +- On SQL Server, a condition on a `TIME` column works on every engine. It errored on Lucee ("The data types time and datetime are incompatible") and BoxLang, and on Adobe ColdFusion it dropped the value's fraction of a second, so only whole-second values matched. A long time list past SQL Server's parameter limit now runs too, split like a long date list (#4327) diff --git a/vendor/wheels/databaseAdapters/MicrosoftSQLServer/MicrosoftSQLServerModel.cfc b/vendor/wheels/databaseAdapters/MicrosoftSQLServer/MicrosoftSQLServerModel.cfc index 4fe980fad5..53e6dd1f0c 100755 --- a/vendor/wheels/databaseAdapters/MicrosoftSQLServer/MicrosoftSQLServerModel.cfc +++ b/vendor/wheels/databaseAdapters/MicrosoftSQLServer/MicrosoftSQLServerModel.cfc @@ -243,8 +243,10 @@ component extends="wheels.databaseAdapters.Base" output=false { * Internal function. For an IN list STRING_SPLIT can carry, its values and the expression that * turns each split value back into the list's type: integers and decimals are validated here * and CAST (never TRY_CAST, which turns '' into 0), strings are compared as they are. Dates and - * timestamps are cast to the type the driver binds them as, given in `dateCasts` (#4318). An - * empty struct for any other list, including times, or one with a value that doesn't validate. + * timestamps are cast to the type the driver binds them as, given in `dateCasts` (#4318), except + * on a DATETIME / SMALLDATETIME column, which compares in its own type as the normal path does, + * and times are cast to TIME(7) ($temporalComparisonType(), #4326, #4327). An empty struct for + * any other list, or one with a value that doesn't validate. */ public struct function $stringSplitList(required any part, struct dateCasts = {}) { if ( @@ -267,16 +269,37 @@ component extends="wheels.databaseAdapters.Base" output=false { if (ListFindNoCase("cf_sql_varchar,cf_sql_char", arguments.part.type)) { return $stringSplitStrings(local.values); } + // The split compares in the same type the normal path does (#4326, #4327). + local.columnCast = StructKeyExists(arguments.part, "dataType") ? $temporalComparisonType(arguments.part.dataType) : ""; + if (CompareNoCase(arguments.part.type, "cf_sql_time") == 0 && local.columnCast == "TIME(7)") { + return $stringSplitTimes(local.values); + } if (StructKeyExists(arguments.dateCasts, arguments.part.type)) { return $stringSplitDates( values = local.values, - sqlType = arguments.dateCasts[arguments.part.type], + sqlType = (Len(local.columnCast) && CompareNoCase(arguments.part.type, "cf_sql_timestamp") == 0) ? local.columnCast : arguments.dateCasts[arguments.part.type], dateOnly = CompareNoCase(arguments.part.type, "cf_sql_date") == 0 ); } return {}; } + /** + * Internal function. Time values as TIME text ($timeText()), cast to TIME(7) like a time + * condition on the normal path; empty when a value isn't `HH:mm[:ss[.fffffff]]` once converted. + */ + public struct function $stringSplitTimes(required array values) { + local.rv = []; + for (local.value in arguments.values) { + local.text = $timeText(local.value); + if (!ReFind("^[0-9]{1,2}:[0-9]{2}(:[0-9]{2}(\.[0-9]{1,7})?)?$", local.text)) { + return {}; + } + ArrayAppend(local.rv, local.text); + } + return {expression = "CAST(value AS TIME(7))", values = local.rv}; + } + /** * Internal function. Date or timestamp values written in a form SQL Server reads the same way * under every language and DATEFORMAT setting, cast to `sqlType`; empty when a value isn't in a @@ -451,7 +474,7 @@ component extends="wheels.databaseAdapters.Base" output=false { */ public string function $tooManyParametersAdvice(required numeric limit) { return super.$tooManyParametersAdvice(limit = arguments.limit) - & " On SQL Server 2016 and later (database compatibility level 130 or higher), Wheels runs a long list of integers, plain decimals (up to 38 digits), strings or uniqueidentifiers as one parameter, and also a list of dates or timestamps written as yyyy-mm-dd with an optional HH:nn, :ss and .lll. Any other list (times, dates written another way, float, real, bit, text or binary values, or numbers written another way, such as +5, .5 or 1E5), or a database below level 130, still needs batching."; + & " On SQL Server 2016 and later (database compatibility level 130 or higher), Wheels runs a long list of integers, plain decimals (up to 38 digits), strings or uniqueidentifiers as one parameter, and also a list of dates or timestamps written as yyyy-mm-dd with an optional HH:nn, :ss and .lll, or of times written as HH:mm with an optional :ss and fraction. Any other list (dates written another way, float, real, bit, text or binary values, or numbers written another way, such as +5, .5 or 1E5), or a database below level 130, still needs batching."; } /** diff --git a/vendor/wheels/tests/specs/model/sqlServerDateStringSplitSpec.cfc b/vendor/wheels/tests/specs/model/sqlServerDateStringSplitSpec.cfc index d7b313b573..655abfdb47 100644 --- a/vendor/wheels/tests/specs/model/sqlServerDateStringSplitSpec.cfc +++ b/vendor/wheels/tests/specs/model/sqlServerDateStringSplitSpec.cfc @@ -251,7 +251,8 @@ component extends="wheels.WheelsTest" { expect(variables.g.model("dtSplit").whereNotIn("dt2", values).count()).toBe(ArrayLen(variables.stored) - 1); }); - it("keeps Wheels.TooManyParameters for a time list, or dates in another form, past the limit", () => { + // A time list splits as TIME(7) since #4327; dates in a locale form still have no split form. + it("runs a time list past the limit, and keeps Wheels.TooManyParameters for dates in another form", () => { if (!variables.isSqlServer) { skip("SQL Server's parameter limit."); } @@ -273,7 +274,7 @@ component extends="wheels.WheelsTest" { } catch (any e) { dateError = e.type; } - expect(timeError).toBe("Wheels.TooManyParameters"); + expect(timeError).toBe(""); expect(dateError).toBe("Wheels.TooManyParameters"); }); diff --git a/vendor/wheels/tests/specs/model/sqlServerTemporalConditionSpec.cfc b/vendor/wheels/tests/specs/model/sqlServerTemporalConditionSpec.cfc index c3d5b8335a..5078fa31a4 100644 --- a/vendor/wheels/tests/specs/model/sqlServerTemporalConditionSpec.cfc +++ b/vendor/wheels/tests/specs/model/sqlServerTemporalConditionSpec.cfc @@ -161,6 +161,18 @@ component extends="wheels.WheelsTest" { expect(variables.g.model("temporal").count(where = "t >= '23:00:00'")).toBe(truthCount("t", ">=", "TIME(7)", "23:00:00")); }); + // Past the parameter limit the list is split (#4318), and the split compares as TIME(7) too. + it("runs a TIME list past the parameter limit, matching the same rows", () => { + if (!variables.isSqlServer) { + skip("SQL Server's TIME columns."); + } + var times = ["10:00:00.123", "10:00:00.999"]; + for (var i = 1; i <= 2200; i++) { + ArrayAppend(times, NumberFormat(Int(i / 3600), "00") & ":" & NumberFormat(Int((i mod 3600) / 60), "00") & ":" & NumberFormat(i mod 60, "00")); + } + expect(variables.g.model("temporal").whereIn("t", times).count()).toBe(2); + }); + }); } diff --git a/web/sites/guides/src/content/docs/v4-2-0/basics/query-builder-and-scopes.mdx b/web/sites/guides/src/content/docs/v4-2-0/basics/query-builder-and-scopes.mdx index 87cb9ae004..8720f6aed2 100644 --- a/web/sites/guides/src/content/docs/v4-2-0/basics/query-builder-and-scopes.mdx +++ b/web/sites/guides/src/content/docs/v4-2-0/basics/query-builder-and-scopes.mdx @@ -71,8 +71,8 @@ The two-argument form of `where` means equality. The three-argument form takes a - **Oracle** 19c and earlier accept at most 1000 values per list. Wheels splits a longer list into groups for you. - **SQL Server** accepts about 2,100 parameters per statement. - On SQL Server 2016 and later (database compatibility level 130 or higher), a statement over the limit runs its largest lists of integers, plain decimals (up to 38 digits), strings and uniqueidentifiers as one parameter each, expanded on the server with `STRING_SPLIT`. - - Date and timestamp lists run the same way when every value is written as `yyyy-mm-dd`, optionally followed by `HH:mm`, `:ss` and `.SSS` (or is a CFML date). Each value is converted to the type the database driver binds it as, so a long list matches exactly the rows a short one would. - - Any other long list (times, dates written another way, float, real, bit, text or binary values, or numbers written as `+5`, `.5` or `1E5`), or a database below level 130, fails before it runs with `Wheels.TooManyParameters`. Query those values in batches, or use a join or subquery instead of a long list. + - Date and timestamp lists run the same way when every value is written as `yyyy-mm-dd`, optionally followed by `HH:mm`, `:ss` and `.SSS` (or is a CFML date). So do time lists written as `HH:mm`, optionally followed by `:ss` and a fraction (or CFML times). Each value is compared the way a short list compares it: in the column's own type on a `DATETIME`, `SMALLDATETIME` or `TIME` column, otherwise as the database driver binds it. A long list therefore matches exactly the rows a short one would. + - Any other long list (dates written another way, float, real, bit, text or binary values, or numbers written as `+5`, `.5` or `1E5`), or a database below level 130, fails before it runs with `Wheels.TooManyParameters`. Query those values in batches, or use a join or subquery instead of a long list. Every builder method can start the chain directly on the model class — you don't have to lead with `where()`. Starting with `select()` reads naturally when you only need a few columns: From dd7c5653c63abc039994cb1ff92446e1237dd282 Mon Sep 17 00:00:00 2001 From: Peter Amiri Date: Mon, 5 Oct 2026 03:05:19 -0700 Subject: [PATCH 3/4] docs(model): say that <> and NOT IN on a SQL Server DATETIME can leave 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 Signed-off-by: Peter Amiri --- .../4326-sqlserver-datetime-equality.changed.md | 2 +- .../specs/model/sqlServerTemporalConditionSpec.cfc | 14 ++++++++++++++ .../content/docs/v4-2-0/upgrading/4x-1-to-4x-2.mdx | 2 +- 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/changelog.d/4326-sqlserver-datetime-equality.changed.md b/changelog.d/4326-sqlserver-datetime-equality.changed.md index a3f4248dc1..f9b893a091 100644 --- a/changelog.d/4326-sqlserver-datetime-equality.changed.md +++ b/changelog.d/4326-sqlserver-datetime-equality.changed.md @@ -1 +1 @@ -- On SQL Server, an `=`, `<>`, `IN` or `NOT IN` condition on a `DATETIME` or `SMALLDATETIME` column now compares in the column's own type. A value with a fraction of a second, such as `createdAt = '2026-01-02 10:00:00.123'` on a `timestamps()` column, matched no row, because every engine sends the value as `DATETIME2(7)` while the column stores 1/300-second (or one-minute) steps. Such a condition now matches the row that holds the value. This only lets rows match that could never match before; no working query changes. Whole-second values, ranges (`<`, `>=`, `BETWEEN`) and `DATETIME2` / `DATE` columns compare as before (#4326) +- On SQL Server, an `=`, `<>`, `IN` or `NOT IN` condition on a `DATETIME` or `SMALLDATETIME` column now compares in the column's own type. A value with a fraction of a second, such as `createdAt = '2026-01-02 10:00:00.123'` on a `timestamps()` column, matched no row, because every engine sends the value as `DATETIME2(7)` while the column stores 1/300-second (or one-minute) steps. 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: `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`. Whole-second values, ranges (`<`, `>=`, `BETWEEN`) and `DATETIME2` / `DATE` columns compare as before (#4326) diff --git a/vendor/wheels/tests/specs/model/sqlServerTemporalConditionSpec.cfc b/vendor/wheels/tests/specs/model/sqlServerTemporalConditionSpec.cfc index 5078fa31a4..4216b47f47 100644 --- a/vendor/wheels/tests/specs/model/sqlServerTemporalConditionSpec.cfc +++ b/vendor/wheels/tests/specs/model/sqlServerTemporalConditionSpec.cfc @@ -138,6 +138,20 @@ component extends="wheels.WheelsTest" { expect(variables.g.model("temporal").whereIn("sdt", ["2026-01-02 10:00:29.000"]).count()).toBe(truthCount("sdt", "=", "SMALLDATETIME", "2026-01-02 10:00:29.000")); }); + // The value is compared at the column's precision, so <> and NOT IN can now leave out a row + // they used to return: .001 rounds to .000 in a DATETIME, as SQL Server does with a literal. + it("leaves out with <> and NOT IN a row whose stored value the cast value equals", () => { + if (!variables.isSqlServer) { + skip("SQL Server's DATETIME columns."); + } + var v = "2026-01-02 10:00:00.001"; + var before = truthCount("dt", "<>", "DATETIME2(7)", v); + var got = variables.g.model("temporal").count(where = "dt <> '#v#'"); + expect(got).toBe(truthCount("dt", "<>", "DATETIME", v)); + expect(got).toBe(before - 1, "the row stored as 10:00:00.000 is left out"); + expect(variables.g.model("temporal").whereNotIn("dt", [v]).count()).toBe(before - 1); + }); + it("keeps ranges on DATETIME exact, as before", () => { if (!variables.isSqlServer) { skip("SQL Server's DATETIME columns."); diff --git a/web/sites/guides/src/content/docs/v4-2-0/upgrading/4x-1-to-4x-2.mdx b/web/sites/guides/src/content/docs/v4-2-0/upgrading/4x-1-to-4x-2.mdx index 9a4bc8ba53..b18b694cc1 100644 --- a/web/sites/guides/src/content/docs/v4-2-0/upgrading/4x-1-to-4x-2.mdx +++ b/web/sites/guides/src/content/docs/v4-2-0/upgrading/4x-1-to-4x-2.mdx @@ -354,7 +354,7 @@ A migration that uses a column type the database adapter doesn't map, such as `t ### SQL Server `DATETIME` conditions match fractional values -On SQL Server, an `=`, `<>`, `IN` or `NOT IN` condition on a `DATETIME` or `SMALLDATETIME` column now compares in the column's own type ([#4326](https://github.com/wheels-dev/wheels/issues/4326)). A value with a fraction of a second, such as `createdAt = '2026-01-02 10:00:00.123'` on a `timestamps()` column, used to match no row, because the value was sent as `DATETIME2(7)` while the column stores 1/300-second (or one-minute) steps. It now matches the row that holds it. This only lets rows match that could never match before; no working query changes. Whole-second values, ranges (`<`, `>=`, `BETWEEN`) and `DATETIME2` / `DATE` columns compare as before. Conditions on a `TIME` column, which errored on Lucee and BoxLang, now work on every engine ([#4327](https://github.com/wheels-dev/wheels/issues/4327)). +On SQL Server, an `=`, `<>`, `IN` or `NOT IN` condition on a `DATETIME` or `SMALLDATETIME` column now compares in the column's own type ([#4326](https://github.com/wheels-dev/wheels/issues/4326)). A value with a fraction of a second, such as `createdAt = '2026-01-02 10:00:00.123'` on a `timestamps()` column, used to match no row, because the value was sent as `DATETIME2(7)` while the column stores 1/300-second (or one-minute) steps. 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: `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` (and `23:59:59.999` rounds to the next midnight). Whole-second values, ranges (`<`, `>=`, `BETWEEN`) and `DATETIME2` / `DATE` columns compare as before. Conditions on a `TIME` column, which errored on Lucee and BoxLang, now work on every engine ([#4327](https://github.com/wheels-dev/wheels/issues/4327)). ### `withAdvisoryLock()` can throw `Wheels.AdvisoryLockReleaseFailed` From e5ae6be154a040ffed31d0077101880c24fc76a4 Mon Sep 17 00:00:00 2001 From: Peter Amiri Date: Mon, 5 Oct 2026 03:48:36 -0700 Subject: [PATCH 4/4] fix(model): keep a date's milliseconds in SQL Server TIME text on RustCFML 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 Signed-off-by: Peter Amiri --- .../MicrosoftSQLServer/MicrosoftSQLServerModel.cfc | 11 +++++------ .../specs/model/sqlServerTemporalConditionSpec.cfc | 6 ++++++ 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/vendor/wheels/databaseAdapters/MicrosoftSQLServer/MicrosoftSQLServerModel.cfc b/vendor/wheels/databaseAdapters/MicrosoftSQLServer/MicrosoftSQLServerModel.cfc index cb57e9be42..2dcc0f3b79 100755 --- a/vendor/wheels/databaseAdapters/MicrosoftSQLServer/MicrosoftSQLServerModel.cfc +++ b/vendor/wheels/databaseAdapters/MicrosoftSQLServer/MicrosoftSQLServerModel.cfc @@ -140,8 +140,10 @@ component extends="wheels.databaseAdapters.Base" output=false { /** * Internal function. A time value as SQL Server TIME text: `HH:mm[:ss[.fffffff]]` as written, the - * time of a `yyyy-mm-dd HH:mm:ss[.f]` string, or a CFML date's time to the millisecond. Any other - * text is returned unchanged, so the CAST reports it. + * time of a `yyyy-mm-dd HH:mm:ss.f` string (up to 7 fraction digits), or any other date's time to + * the millisecond. A date-time without a fraction goes through Millisecond(): RustCFML treats a + * date object as a simple value whose text drops the milliseconds. Any other text is returned + * unchanged, so the CAST reports it. */ public string function $timeText(required any value) { if (IsSimpleValue(arguments.value)) { @@ -149,13 +151,10 @@ component extends="wheels.databaseAdapters.Base" output=false { if (ReFind("^\d{1,2}:\d{2}(:\d{2}(\.\d{1,7})?)?$", local.text)) { return local.text; } - local.match = ReFind("^\d{4}-\d{2}-\d{2}[ T](\d{1,2}:\d{2}(:\d{2}(\.\d{1,7})?)?)$", local.text, 1, true); + local.match = ReFind("^\d{4}-\d{2}-\d{2}[ T](\d{1,2}:\d{2}:\d{2}\.\d{1,7})$", local.text, 1, true); if (local.match.pos[1]) { return Mid(local.text, local.match.pos[2], local.match.len[2]); } - if (!IsDate(local.text)) { - return local.text; - } } if (IsDate(arguments.value)) { return TimeFormat(arguments.value, "HH:mm:ss") & "." & NumberFormat(Millisecond(arguments.value), "000"); diff --git a/vendor/wheels/tests/specs/model/sqlServerTemporalConditionSpec.cfc b/vendor/wheels/tests/specs/model/sqlServerTemporalConditionSpec.cfc index 4216b47f47..7ce9f39e7e 100644 --- a/vendor/wheels/tests/specs/model/sqlServerTemporalConditionSpec.cfc +++ b/vendor/wheels/tests/specs/model/sqlServerTemporalConditionSpec.cfc @@ -94,6 +94,12 @@ component extends="wheels.WheelsTest" { expect(adapter.$timeText("9:05")).toBe("9:05"); expect(adapter.$timeText("2026-01-02 10:00:00.1234567")).toBe("10:00:00.1234567"); expect(adapter.$timeText(CreateDateTime(2026, 1, 2, 10, 0, 0))).toBe("10:00:00.000"); + expect(adapter.$timeText("2026-01-02 10:00:00")).toBe("10:00:00.000"); + // A date's milliseconds survive. RustCFML's date values carry no milliseconds at all + // (DateAdd("l"), ParseDateTime and Millisecond() all give 0), so that engine is left out. + if (!variables.g.$engineAdapter().isRustCFML()) { + expect(adapter.$timeText(DateAdd("l", 250, CreateDateTime(2026, 1, 2, 10, 0, 0)))).toBe("10:00:00.250"); + } expect(adapter.$timeText("noon")).toBe("noon"); });