diff --git a/changelog.d/4318-sqlserver-long-date-in-lists.added.md b/changelog.d/4318-sqlserver-long-date-in-lists.added.md new file mode 100644 index 0000000000..87270a2f5a --- /dev/null +++ b/changelog.d/4318-sqlserver-long-date-in-lists.added.md @@ -0,0 +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) diff --git a/vendor/wheels/databaseAdapters/MicrosoftSQLServer/MicrosoftSQLServerModel.cfc b/vendor/wheels/databaseAdapters/MicrosoftSQLServer/MicrosoftSQLServerModel.cfc index 83a80ddec5..bcc130fe88 100755 --- a/vendor/wheels/databaseAdapters/MicrosoftSQLServer/MicrosoftSQLServerModel.cfc +++ b/vendor/wheels/databaseAdapters/MicrosoftSQLServer/MicrosoftSQLServerModel.cfc @@ -40,7 +40,8 @@ component extends="wheels.databaseAdapters.Base" output=false { * rewrites IN lists, largest first until it fits, to one parameter each (#4103): * `(SELECT CAST(value AS ) FROM STRING_SPLIT(?, NCHAR(31)))`. A statement that fits keeps * its SQL. STRING_SPLIT needs compatibility level 130; below it, and for lists of other types - * (dates and times among them), the statement is left for $assertBoundParameterCount() to refuse. + * (times among them), the statement is left for $assertBoundParameterCount() to refuse. Date and + * timestamp lists are split only when the driver's bind type is known (#4318). */ public void function $splitLargeInLists(required struct args) { local.limit = $inListSplitLimit(); @@ -50,13 +51,14 @@ component extends="wheels.databaseAdapters.Base" output=false { if (!$supportsStringSplit(arguments.args.dataSource)) { return; } - local.convert = $inListsToSplit(sql = arguments.args.sql, limit = local.limit); + local.dateCasts = $stringSplitDateCasts(arguments.args.dataSource); + local.convert = $inListsToSplit(sql = arguments.args.sql, limit = local.limit, dateCasts = local.dateCasts); local.rv = []; local.iEnd = ArrayLen(arguments.args.sql); for (local.i = 1; local.i <= local.iEnd; local.i++) { // Adobe CF passes arrays by value, so the parts are returned and appended here. if (StructKeyExists(local.convert, local.i)) { - local.parts = $stringSplitParts(arguments.args.sql[local.i]); + local.parts = $stringSplitParts(part = arguments.args.sql[local.i], dateCasts = local.dateCasts); } else { local.parts = [arguments.args.sql[local.i]]; } @@ -69,13 +71,14 @@ component extends="wheels.databaseAdapters.Base" output=false { /** * Internal function. The positions of the IN lists to split, largest first, until the statement - * binds no more than `limit` parameters (as a struct keyed by position). + * binds no more than `limit` parameters (as a struct keyed by position). `dateCasts` is + * $stringSplitDateCasts()'s result; without it, date and timestamp lists aren't split. */ - public struct function $inListsToSplit(required array sql, required numeric limit) { + public struct function $inListsToSplit(required array sql, required numeric limit, struct dateCasts = {}) { local.sizes = {}; local.iEnd = ArrayLen(arguments.sql); for (local.i = 1; local.i <= local.iEnd; local.i++) { - local.split = $stringSplitList(arguments.sql[local.i]); + local.split = $stringSplitList(part = arguments.sql[local.i], dateCasts = arguments.dateCasts); if (!StructIsEmpty(local.split) && ArrayLen(local.split.values) > 1) { local.sizes[local.i] = ArrayLen(local.split.values); } @@ -99,8 +102,8 @@ component extends="wheels.databaseAdapters.Base" output=false { /** * Internal function. The SQL parts for an IN list bound as one STRING_SPLIT parameter. */ - public array function $stringSplitParts(required struct part) { - local.split = $stringSplitList(arguments.part); + public array function $stringSplitParts(required struct part, struct dateCasts = {}) { + local.split = $stringSplitList(part = arguments.part, dateCasts = arguments.dateCasts); return [ "(SELECT " & local.split.expression & " FROM STRING_SPLIT(", {type = "cf_sql_nvarchar", value = ArrayToList(local.split.values, Chr(31))}, @@ -111,11 +114,11 @@ 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. An empty - * struct for any other list, including dates and times, or one with a value that doesn't - * validate. + * 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. */ - public struct function $stringSplitList(required any part) { + public struct function $stringSplitList(required any part, struct dateCasts = {}) { if ( !IsStruct(arguments.part) || !StructKeyExists(arguments.part, "list") @@ -136,9 +139,101 @@ component extends="wheels.databaseAdapters.Base" output=false { if (ListFindNoCase("cf_sql_varchar,cf_sql_char", arguments.part.type)) { return $stringSplitStrings(local.values); } + if (StructKeyExists(arguments.dateCasts, arguments.part.type)) { + return $stringSplitDates( + values = local.values, + sqlType = arguments.dateCasts[arguments.part.type], + dateOnly = CompareNoCase(arguments.part.type, "cf_sql_date") == 0 + ); + } return {}; } + /** + * 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 + * form $isoDateTimeValue() accepts. + */ + public struct function $stringSplitDates(required array values, required string sqlType, required boolean dateOnly) { + local.rv = []; + for (local.value in arguments.values) { + local.iso = $isoDateTimeValue(value = local.value, dateOnly = arguments.dateOnly); + if (!Len(local.iso)) { + return {}; + } + ArrayAppend(local.rv, local.iso); + } + return {expression = "CAST(value AS #arguments.sqlType#)", values = local.rv}; + } + + /** + * Internal function. `value` as `yyyy-mm-ddTHH:nn:ss.lll` (or `yyyy-mm-dd` when `dateOnly`), or "" + * when it isn't a valid date written as `yyyy-mm-dd` with an optional ` HH:nn`, `:ss` and + * `.lll`, or CFML's `{ts '...'}` / `{d '...'}` form of one. Other forms (locale dates, time + * zones, other fractions) are left to the one-parameter-per-value path, whose parsing they need. + */ + public string function $isoDateTimeValue(required string value, required boolean dateOnly) { + local.text = Trim(arguments.value); + local.wrapped = ReFind("^\{(ts|d) '([^']*)'\}$", local.text, 1, true); + if (local.wrapped.pos[1]) { + local.text = Mid(local.text, local.wrapped.pos[3], local.wrapped.len[3]); + } + local.pattern = arguments.dateOnly ? "^([0-9]{4})-([0-9]{2})-([0-9]{2})$" : "^([0-9]{4})-([0-9]{2})-([0-9]{2})([ T]([0-9]{2}):([0-9]{2})(:([0-9]{2})(\.([0-9]{3}))?)?)?$"; + local.parts = $regexGroups(pattern = local.pattern, text = local.text, count = 10); + if (!ArrayLen(local.parts)) { + return ""; + } + local.date = Left(local.text, 10); + if (!$isCalendarDate(local.date)) { + return ""; + } + if (arguments.dateOnly) { + return local.date; + } + local.hour = Len(local.parts[5]) ? local.parts[5] : "00"; + local.minute = Len(local.parts[6]) ? local.parts[6] : "00"; + local.second = Len(local.parts[8]) ? local.parts[8] : "00"; + local.fraction = Len(local.parts[10]) ? local.parts[10] : "000"; + if (Val(local.hour) > 23 || Val(local.minute) > 59 || Val(local.second) > 59) { + return ""; + } + return local.date & "T" & local.hour & ":" & local.minute & ":" & local.second & "." & local.fraction; + } + + /** + * Internal function. The text of groups 1 to `count` when `pattern` matches `text` ("" for a + * group that took no part), or an empty array when it doesn't match. Engines differ in how + * they report a group that took no part (0 or -1, or a shorter array), so any of those is "". + */ + public array function $regexGroups(required string pattern, required string text, required numeric count) { + local.match = ReFind(arguments.pattern, arguments.text, 1, true); + if (local.match.pos[1] < 1) { + return []; + } + local.rv = []; + for (local.i = 2; local.i <= arguments.count + 1; local.i++) { + if (local.i <= ArrayLen(local.match.pos) && local.match.pos[local.i] > 0 && local.match.len[local.i] > 0) { + ArrayAppend(local.rv, Mid(arguments.text, local.match.pos[local.i], local.match.len[local.i])); + } else { + ArrayAppend(local.rv, ""); + } + } + return local.rv; + } + + /** + * Internal function. True when `yyyy-mm-dd` names a real calendar day. + */ + public boolean function $isCalendarDate(required string ymd) { + local.year = Val(ListGetAt(arguments.ymd, 1, "-")); + local.month = Val(ListGetAt(arguments.ymd, 2, "-")); + local.day = Val(ListGetAt(arguments.ymd, 3, "-")); + if (local.year < 1 || local.month < 1 || local.month > 12 || local.day < 1) { + return false; + } + return local.day <= DaysInMonth(CreateDate(local.year, local.month, 1)); + } + /** * Internal function. The SQL Server type an integer IN list is cast to, or "" for other types. */ @@ -228,7 +323,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. Any other list (dates, times, 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. 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."; } /** @@ -268,6 +363,69 @@ component extends="wheels.databaseAdapters.Base" output=false { } } + /** + * Internal function. The CAST each date or timestamp IN list is split with, keyed by cf_sql type: + * the type the datasource's driver binds that cf_sql type as, so a split list matches exactly + * the rows a list bound one parameter per value matches (#4318). Read once per datasource and + * kept in the application's Wheels settings, like the compatibility level. + */ + public struct function $stringSplitDateCasts(required string dataSource) { + local.appKey = $appKey(); + local.dataSource = $effectiveDataSource(arguments.dataSource); + if (!StructKeyExists(application[local.appKey], "sqlServerDateBindTypes")) { + application[local.appKey].sqlServerDateBindTypes = {}; + } + local.bindTypes = application[local.appKey].sqlServerDateBindTypes; + if (!StructKeyExists(local.bindTypes, local.dataSource)) { + local.bindTypes[local.dataSource] = $readDateBindTypes(local.dataSource); + } + return $dateCastsFor(local.bindTypes[local.dataSource]); + } + + /** + * Internal function. Casts for the bind types $readDateBindTypes() found. Only the bind types the + * split form is proven to mirror get one: a timestamp bound as datetime2(7) and a date bound as + * date. Any other driver behaviour leaves those lists to Wheels.TooManyParameters. + */ + public struct function $dateCastsFor(required struct bindTypes) { + local.rv = {}; + if (StructKeyExists(arguments.bindTypes, "cf_sql_timestamp") && Compare(arguments.bindTypes.cf_sql_timestamp, "datetime2/7") == 0) { + local.rv.cf_sql_timestamp = "DATETIME2(7)"; + } + if (StructKeyExists(arguments.bindTypes, "cf_sql_date") && Compare(arguments.bindTypes.cf_sql_date, "date/0") == 0) { + local.rv.cf_sql_date = "DATE"; + } + return local.rv; + } + + /** + * Internal function. The SQL Server base type and scale (`datetime2/7`) the driver binds a + * cf_sql_timestamp and a cf_sql_date parameter as, or an empty struct when it can't be read. + */ + public struct function $readDateBindTypes(required string dataSource) { + local.options = {datasource = arguments.dataSource}; + if (Len(variables.username)) { + local.options.username = variables.username; + } + if (Len(variables.password)) { + local.options.password = variables.password; + } + local.property = "CAST(SQL_VARIANT_PROPERTY(CAST(? AS SQL_VARIANT), '%s') AS VARCHAR(30))"; + local.sql = "SELECT " & Replace(local.property, "%s", "BaseType") & " AS tsType, " & Replace(local.property, "%s", "Scale") & " AS tsScale, " + & Replace(local.property, "%s", "BaseType") & " AS dType, " & Replace(local.property, "%s", "Scale") & " AS dScale"; + local.ts = {value = "2026-01-02 10:00:00", cfsqltype = "cf_sql_timestamp"}; + local.d = {value = "2026-01-02", cfsqltype = "cf_sql_date"}; + try { + local.query = QueryExecute(local.sql, [local.ts, local.ts, local.d, local.d], local.options); + return { + cf_sql_timestamp = LCase(local.query.tsType) & "/" & local.query.tsScale, + cf_sql_date = LCase(local.query.dType) & "/" & local.query.dScale + }; + } catch (any e) { + return {}; + } + } + /** * SQL Server accepts at most 2100 parameters in one request, and the JDBC drivers use some of them * themselves: mssql-jdbc (Lucee, BoxLang) runs 2098 bound values and fails at 2099, Adobe's diff --git a/vendor/wheels/tests/_assets/models/DtSplit.cfc b/vendor/wheels/tests/_assets/models/DtSplit.cfc new file mode 100644 index 0000000000..7d8f2bd096 --- /dev/null +++ b/vendor/wheels/tests/_assets/models/DtSplit.cfc @@ -0,0 +1,11 @@ +component extends="Model" { + + /* + * Backs sqlServerDateStringSplitSpec: one column of each SQL Server date and time type. The + * spec creates and drops the table, on SQL Server only. + */ + function config() { + table("c_o_r_e_dtsplits"); + } + +} diff --git a/vendor/wheels/tests/specs/model/sqlServerDateStringSplitSpec.cfc b/vendor/wheels/tests/specs/model/sqlServerDateStringSplitSpec.cfc new file mode 100644 index 0000000000..d7b313b573 --- /dev/null +++ b/vendor/wheels/tests/specs/model/sqlServerDateStringSplitSpec.cfc @@ -0,0 +1,284 @@ +/** + * Date and timestamp IN lists past SQL Server's parameter limit (#4318). A long list binds as one + * STRING_SPLIT parameter cast to the type the driver binds each value as (datetime2(7) for a + * timestamp, date for a date), so it matches exactly the rows the same list matches bound one + * parameter per value. Time lists, and dates written in other forms, keep Wheels.TooManyParameters. + */ +component extends="wheels.WheelsTest" { + + function beforeAll() { + variables.g = application.wo; + variables.isSqlServer = variables.g.get("adapterName") == "MicrosoftSQLServerModel"; + variables.mssql = CreateObject("component", "wheels.databaseAdapters.MicrosoftSQLServer.MicrosoftSQLServerModel"); + // Stored values: whole and fractional seconds, the last ticks of a day, midnight, and + // the seconds either side of SMALLDATETIME's rounding point. + variables.stored = [ + "2026-01-02T10:00:00.000", + "2026-01-02T10:00:00.003", + "2026-01-02T10:00:00.123", + "2026-01-02T10:00:00.999", + "2026-01-02T23:59:59.997", + "2026-01-02T23:59:59.999", + "2026-01-03T00:00:00.000", + "2026-01-02T10:00:29.000", + "2026-01-02T10:00:30.000" + ]; + variables.columns = "d,dt,sdt,dt2,dt23,dto"; + if (variables.isSqlServer) { + var ds = variables.g.get("dataSourceName"); + QueryExecute("IF OBJECT_ID('c_o_r_e_dtsplits') IS NOT NULL DROP TABLE c_o_r_e_dtsplits", [], {datasource = ds}); + QueryExecute( + "CREATE TABLE c_o_r_e_dtsplits (id INT IDENTITY PRIMARY KEY, d DATE, t TIME(7), dt DATETIME, sdt SMALLDATETIME, dt2 DATETIME2(7), dt23 DATETIME2(3), dto DATETIMEOFFSET(7))", + [], + {datasource = ds} + ); + for (var v in variables.stored) { + var c = "CAST('#v#' AS DATETIME2(7))"; + QueryExecute( + "INSERT INTO c_o_r_e_dtsplits (d, t, dt, sdt, dt2, dt23, dto) VALUES (#c#, #c#, #c#, #c#, #c#, #c#, #c#)", + [], + {datasource = ds} + ); + } + // a row of NULLs, which neither IN nor NOT IN matches + QueryExecute("INSERT INTO c_o_r_e_dtsplits (d) VALUES (NULL)", [], {datasource = ds}); + StructDelete(application.wheels.models, "DtSplit"); + } + } + + function afterAll() { + if (variables.isSqlServer) { + adapterOf().$setInListSplitLimit(0); + QueryExecute("DROP TABLE c_o_r_e_dtsplits", [], {datasource = variables.g.get("dataSourceName")}); + } + StructDelete(application.wheels.models, "DtSplit"); + } + + function adapterOf() { + return variables.g.model("dtSplit").$classData().adapter; + } + + function listPart(required string type, required string value) { + return {type = arguments.type, value = arguments.value, list = true}; + } + + // The ids a call returns, as a sorted list, or its error type. + function idsOf(required any callback) { + var target = arguments.callback; + try { + var q = target(); + return ListSort(ValueList(q.id), "numeric"); + } catch (any e) { + return "ERROR " & e.type; + } + } + + // Every stored value written the way an app would pass it for `column`, plus values that match + // nothing, so each list has more than the forced split limit. + function listFor(required string column) { + var rv = []; + for (var v in variables.stored) { + if (arguments.column == "d") { + ArrayAppend(rv, Left(v, 10)); + } else { + ArrayAppend(rv, Replace(v, "T", " ")); + } + } + ArrayAppend(rv, arguments.column == "d" ? "1999-12-31" : "1999-12-31 08:15:00"); + ArrayAppend(rv, arguments.column == "d" ? "2026-01-04" : "2026-01-02 10:00"); + return rv; + } + + // `list` padded past SQL Server's parameter limit with values that match no row, so the + // statement only runs if the list is split. + function padded(required array list, required string column) { + var rv = Duplicate(arguments.list); + var base = CreateDateTime(1900, 1, 1, 0, 0, 0); + for (var i = 1; i <= 2150; i++) { + if (arguments.column == "d") { + ArrayAppend(rv, DateFormat(DateAdd("d", i, base), "yyyy-mm-dd")); + } else { + var stamp = DateAdd("n", i, base); + ArrayAppend(rv, DateFormat(stamp, "yyyy-mm-dd") & " " & TimeFormat(stamp, "HH:mm:ss")); + } + } + return rv; + } + + // The same queries, one parameter per value (the default) and split (forced with a low limit). + function bothWays(required any callback) { + var target = arguments.callback; + adapterOf().$setInListSplitLimit(0); + var normal = idsOf(target); + adapterOf().$setInListSplitLimit(1); + try { + var split = idsOf(target); + } finally { + adapterOf().$setInListSplitLimit(0); + } + return {normal = normal, split = split}; + } + + function run() { + + describe("The STRING_SPLIT form of a date or timestamp IN list", () => { + + it("normalises each value to an unambiguous ISO form", () => { + expect(variables.mssql.$isoDateTimeValue(value = "2026-01-02 10:00:00.123", dateOnly = false)).toBe("2026-01-02T10:00:00.123"); + expect(variables.mssql.$isoDateTimeValue(value = " 2026-01-02T10:00 ", dateOnly = false)).toBe("2026-01-02T10:00:00.000"); + expect(variables.mssql.$isoDateTimeValue(value = "2026-01-02", dateOnly = false)).toBe("2026-01-02T00:00:00.000"); + expect(variables.mssql.$isoDateTimeValue(value = "{ts '2026-01-02 23:59:59'}", dateOnly = false)).toBe("2026-01-02T23:59:59.000"); + expect(variables.mssql.$isoDateTimeValue(value = "2026-01-02", dateOnly = true)).toBe("2026-01-02"); + expect(variables.mssql.$isoDateTimeValue(value = "{d '2026-01-02'}", dateOnly = true)).toBe("2026-01-02"); + }); + + it("leaves other forms to the one-parameter-per-value path", () => { + var refused = [ + "01/02/2026", + "2026-01-02T10:00:00Z", + "2026-01-02 10:00:00+02:00", + "2026-01-02 10:00:00.1", + "2026-02-30", + "2026-01-02 24:00:00", + "2026-1-2", + "January 2, 2026", + "" + ]; + for (var v in refused) { + expect(variables.mssql.$isoDateTimeValue(value = v, dateOnly = false)).toBe("", "accepted [#v#]"); + } + expect(variables.mssql.$isoDateTimeValue(value = "2026-01-02 10:00:00", dateOnly = true)).toBe(""); + }); + + it("casts to the type the driver binds, only for the bind types it mirrors", () => { + var casts = variables.mssql.$dateCastsFor({cf_sql_timestamp = "datetime2/7", cf_sql_date = "date/0"}); + expect(casts.cf_sql_timestamp).toBe("DATETIME2(7)"); + expect(casts.cf_sql_date).toBe("DATE"); + expect(StructIsEmpty(variables.mssql.$dateCastsFor({cf_sql_timestamp = "datetime/3", cf_sql_date = "datetime/3"}))).toBeTrue(); + expect(StructIsEmpty(variables.mssql.$dateCastsFor({}))).toBeTrue(); + var split = variables.mssql.$stringSplitList(part = listPart("cf_sql_timestamp", "'2026-01-02 10:00:00.123','2026-01-03'"), dateCasts = casts); + expect(split.expression).toBe("CAST(value AS DATETIME2(7))"); + expect(split.values).toBe(["2026-01-02T10:00:00.123", "2026-01-03T00:00:00.000"]); + expect(variables.mssql.$stringSplitList(part = listPart("CF_SQL_DATE", "2026-01-02"), dateCasts = casts).expression).toBe("CAST(value AS DATE)"); + }); + + it("isn't used for times, without a known bind type, or when any value is in another form", () => { + var casts = {cf_sql_timestamp = "DATETIME2(7)", cf_sql_date = "DATE"}; + expect(StructIsEmpty(variables.mssql.$stringSplitList(part = listPart("cf_sql_time", "10:00:00,11:00:00"), dateCasts = casts))).toBeTrue(); + expect(StructIsEmpty(variables.mssql.$stringSplitList(part = listPart("cf_sql_timestamp", "2026-01-02,2026-01-03")))).toBeTrue(); + expect(StructIsEmpty(variables.mssql.$stringSplitList(part = listPart("cf_sql_timestamp", "2026-01-02,01/03/2026"), dateCasts = casts))).toBeTrue(); + }); + + it("reads the driver's bind types once per datasource", () => { + if (!variables.isSqlServer) { + skip("SQL Server only."); + } + var ds = variables.g.get("dataSourceName"); + if (StructKeyExists(application.wheels, "sqlServerDateBindTypes")) { + StructDelete(application.wheels.sqlServerDateBindTypes, ds); + } + var casts = adapterOf().$stringSplitDateCasts(ds); + // every engine on the CI matrix binds these types; the split form mirrors them + expect(application.wheels.sqlServerDateBindTypes[ds].cf_sql_timestamp).toBe("datetime2/7"); + expect(application.wheels.sqlServerDateBindTypes[ds].cf_sql_date).toBe("date/0"); + expect(casts.cf_sql_timestamp).toBe("DATETIME2(7)"); + application.wheels.sqlServerDateBindTypes[ds] = {cf_sql_timestamp = "datetime/3"}; + expect(StructIsEmpty(adapterOf().$stringSplitDateCasts(ds))).toBeTrue(); + StructDelete(application.wheels.sqlServerDateBindTypes, ds); + expect(adapterOf().$stringSplitDateCasts(ds).cf_sql_date).toBe("DATE"); + }); + + }); + + describe("Date and timestamp IN lists past SQL Server's parameter limit", () => { + + afterEach(() => { + if (variables.isSqlServer) { + adapterOf().$setInListSplitLimit(0); + } + }); + + // The same list must match the same rows whether bound one parameter per value or + // split, for every date and timestamp column type, including where the one-parameter + // path misses values the column can't hold exactly (DATETIME, SMALLDATETIME). + it("returns the same rows split as bound one parameter per value, for each column type", () => { + if (!variables.isSqlServer) { + skip("SQL Server's parameter limit."); + } + for (var col in ListToArray(variables.columns)) { + var values = listFor(col); + var longList = padded(values, col); + // whereIn / whereNotIn: the short list runs one parameter per value; the same + // list padded past the limit can only run split + var inShort = idsOf(() => variables.g.model("dtSplit").whereIn(col, values).get(select = "id", returnAs = "query")); + var inLong = idsOf(() => variables.g.model("dtSplit").whereIn(col, longList).get(select = "id", returnAs = "query")); + var notInShort = idsOf(() => variables.g.model("dtSplit").whereNotIn(col, values).get(select = "id", returnAs = "query")); + var notInLong = idsOf(() => variables.g.model("dtSplit").whereNotIn(col, longList).get(select = "id", returnAs = "query")); + // a where-string IN list, split by a low limit + var quoted = "'" & ArrayToList(values, "','") & "'"; + var whereString = bothWays(() => variables.g.model("dtSplit").findAll(select = "id", where = "#col# IN (#quoted#)", returnAs = "query")); + expect(inShort).notToInclude("ERROR", "whereIn on #col#"); + expect(inLong).toBe(inShort, "whereIn on #col#"); + expect(notInLong).toBe(notInShort, "whereNotIn on #col#"); + expect(whereString.split).toBe(whereString.normal, "where IN on #col#"); + } + }); + + it("matches date objects the same way split", () => { + if (!variables.isSqlServer) { + skip("SQL Server's parameter limit."); + } + var dates = [CreateDateTime(2026, 1, 2, 10, 0, 0), CreateDateTime(2026, 1, 3, 0, 0, 0), CreateDateTime(1999, 12, 31, 8, 0, 0)]; + var longList = Duplicate(dates); + for (var i = 1; i <= 2150; i++) { + ArrayAppend(longList, DateAdd("n", i, CreateDateTime(1900, 1, 1, 0, 0, 0))); + } + var shortIds = idsOf(() => variables.g.model("dtSplit").whereIn("dt2", dates).get(select = "id", returnAs = "query")); + expect(ListLen(shortIds)).toBe(2); + expect(idsOf(() => variables.g.model("dtSplit").whereIn("dt2", longList).get(select = "id", returnAs = "query"))).toBe(shortIds); + }); + + it("runs a timestamp list far past the limit", () => { + if (!variables.isSqlServer) { + skip("SQL Server's parameter limit."); + } + var values = []; + for (var i = 1; i <= 2500; i++) { + ArrayAppend(values, DateFormat(DateAdd("d", i, CreateDate(2000, 1, 1)), "yyyy-mm-dd") & " 10:00:00"); + } + ArrayAppend(values, "2026-01-02 10:00:00.123"); + expect(variables.g.model("dtSplit").whereIn("dt2", values).count()).toBe(1); + 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", () => { + if (!variables.isSqlServer) { + skip("SQL Server's parameter limit."); + } + var times = []; + var localDates = []; + for (var i = 1; i <= 2200; i++) { + ArrayAppend(times, TimeFormat(DateAdd("s", i, CreateDateTime(2026, 1, 2, 0, 0, 0)), "HH:mm:ss")); + ArrayAppend(localDates, DateFormat(DateAdd("d", i, CreateDate(2000, 1, 1)), "mm/dd/yyyy")); + } + var timeError = ""; + try { + variables.g.model("dtSplit").whereIn("t", times).count(); + } catch (any e) { + timeError = e.type; + } + var dateError = ""; + try { + variables.g.model("dtSplit").whereIn("dt2", localDates).count(); + } catch (any e) { + dateError = e.type; + } + expect(timeError).toBe("Wheels.TooManyParameters"); + expect(dateError).toBe("Wheels.TooManyParameters"); + }); + + }); + + } + +} diff --git a/vendor/wheels/tests/specs/model/sqlServerStringSplitSpec.cfc b/vendor/wheels/tests/specs/model/sqlServerStringSplitSpec.cfc index bba5294a2d..19cc9f7892 100644 --- a/vendor/wheels/tests/specs/model/sqlServerStringSplitSpec.cfc +++ b/vendor/wheels/tests/specs/model/sqlServerStringSplitSpec.cfc @@ -182,7 +182,8 @@ component extends="wheels.WheelsTest" { expect(variables.g.model("author").whereNotIn("id", paddedIds(5000)).count()).toBe(0); }); - it("keeps Wheels.TooManyParameters for a date list past the limit", () => { + // Date and timestamp lists are covered in detail by sqlServerDateStringSplitSpec (#4318). + it("runs an ISO date list past the limit", () => { if (!variables.isSqlServer) { skip("SQL Server's parameter limit."); } @@ -191,8 +192,7 @@ component extends="wheels.WheelsTest" { ArrayAppend(dates, DateFormat(DateAdd("d", i, CreateDate(2000, 1, 1)), "yyyy-mm-dd")); } var thrown = errorOf(() => variables.g.model("post").whereIn("createdAt", dates).count()); - expect(thrown.type).toBe("Wheels.TooManyParameters"); - expect(thrown.extendedInfo).toInclude("Any other list (dates, times"); + expect(thrown.type).toBe(""); }); it("keeps Wheels.TooManyParameters for a float list past the limit, and says which lists it combines", () => { 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 13e8f6758c..87cb9ae004 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,7 +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`. - - Any other long list (dates, times, 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). 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. 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: