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/4326-sqlserver-datetime-equality.changed.md b/changelog.d/4326-sqlserver-datetime-equality.changed.md new file mode 100644 index 0000000000..f9b893a091 --- /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. 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/changelog.d/4327-sqlserver-time-conditions.fixed.md b/changelog.d/4327-sqlserver-time-conditions.fixed.md new file mode 100644 index 0000000000..f25e0d2346 --- /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. 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 c5e7a7507e..2dcc0f3b79 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,136 @@ 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 (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)) { + 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(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): @@ -115,8 +242,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 ( @@ -139,16 +268,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 @@ -323,7 +473,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/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/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 new file mode 100644 index 0000000000..7ce9f39e7e --- /dev/null +++ b/vendor/wheels/tests/specs/model/sqlServerTemporalConditionSpec.cfc @@ -0,0 +1,200 @@ +/** + * 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("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"); + }); + + }); + + 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")); + }); + + // 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."); + } + 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")); + }); + + // 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: 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 b289c76cfd..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 @@ -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. 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` 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.