Skip to content

feat(model): run IN lists past SQL Server's parameter limit as one STRING_SPLIT parameter - #4321

Merged
bpamiri merged 3 commits into
developfrom
peter/mssql-in-list-string-split
Oct 5, 2026
Merged

bpamiri merged 3 commits into
developfrom
peter/mssql-in-list-string-split

Conversation

@bpamiri

@bpamiri bpamiri commented Oct 5, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Closes #4103.

SQL Server binds at most 2,100 parameters per statement, and Wheels binds every IN-list value as its own parameter. Since #3906, a query over the adapter limit (2097) is refused with Wheels.TooManyParameters. This PR lets such a query run on SQL Server 2016 and later, with every value still bound.

What changes

  • Only over the limit. A statement that fits keeps its exact SQL and plan. Over the limit, the largest IN lists are converted, one at a time, until the statement fits:

    col IN (SELECT CAST(value AS <type>) FROM STRING_SPLIT(?, NCHAR(31)))

    Each converted list is bound as one parameter.

  • Gated on compatibility level 130+, STRING_SPLIT's requirement.

    • The level is read once per datasource with SELECT compatibility_level FROM sys.databases WHERE name = DB_NAME() and kept in the application's Wheels settings, so a reload re-reads it.
    • Below level 130, Wheels.TooManyParameters stays.
  • CAST, not TRY_CAST. Integer and decimal values are validated in CFML first, then CAST.

    • TRY_CAST('' AS INT) is 0 on SQL Server, which would silently turn an empty value into id 0.
    • Because validated values never become NULL, NOT IN keeps exactly the meaning of a literal list.
  • Types

    List type Converted to
    tinyint / smallint / integer / bigint the matching integer type
    decimal / numeric DECIMAL(38, s), exact (as in fix(model): bind decimals beyond 15 significant digits exactly #4190)
    strings compared as they are
    uniqueidentifier UNIQUEIDENTIFIER, after a format check
  • Not covered. Every other list keeps Wheels.TooManyParameters. That means dates and times (follow-up feat(model): bind long date and time IN lists on SQL Server through STRING_SPLIT #4318), float, real, bit, text and binary values, and numbers written another way, such as +5, .5, 1E5 or more than 38 digits.

  • Delimiter. NCHAR(31). A value containing it is refused with a clear error, as the WHERE masker does for its own markers.

  • Clearer error. On SQL Server, Wheels.TooManyParameters's details now name the lists that run this way (integers, plain decimals up to 38 digits, strings, uniqueidentifiers). They also say that any other list, or a database below level 130, still needs batching.

    • The base advice moved into $tooManyParametersAdvice(), which the SQL Server adapter extends.
  • Hook. The SQL Server $performQuery() override from fix(model): bind decimals beyond 15 significant digits exactly #4190. It runs before the wide-decimal cast, so a long, wide decimal list goes through the split path, which is exact.

Docs

Tests

model/sqlServerStringSplitSpec checks:

  • each type is cast as listed above;
  • dates, values that don't validate, and decimals beyond 38 digits aren't split;
  • a value containing the delimiter is refused;
  • the largest lists are split first, and only until the statement fits;
  • a statement that fits keeps its SQL;
  • the same rows come back split and bound one parameter per value (differential, IN and NOT IN);
  • exact decimal matching;
  • a list far past the limit runs;
  • a date list and a float list past the limit keep Wheels.TooManyParameters, with the new advice naming the lists that are combined;
  • compatibility level 120 keeps Wheels.TooManyParameters, with the new advice;
  • the level is read once per datasource and re-read once cleared.

The 5,000-key whereIn / whereNotIn checks in whereLargeIntegerListSpec and inListLimitsSpec now expect SQL Server to run the list instead of refusing it.

Local results, at the current develop base

Run Pass Fail Error
Lucee 7 × SQLite: internal 114 0 0
Lucee 7 × SQLite: model 1371 0 0
Lucee 7 × SQL Server: internal 114 0 0
Lucee 7 × SQL Server: model 1386 0 0
BoxLang × SQL Server: model 1384 0 0
Adobe 2025 × SQL Server: model 1381 0 0

Adobe 2023 × SQL Server was not run locally. The local Adobe 2023 engine never became healthy (The Runtime service is not available), so no request reached Wheels; the compat matrix covers that leg. The guides site build passes.

🤖 Generated with Claude Code

bpamiri and others added 2 commits October 4, 2026 23:05
…ING_SPLIT

SQL Server binds at most 2100 parameters per statement and Wheels binds
each IN list value as its own parameter, so a statement past the adapter
limit of 2097 was refused with Wheels.TooManyParameters (#3906).

On compatibility level 130+ (STRING_SPLIT), such a statement now binds its
largest IN lists, until it fits, as one parameter each:
IN (SELECT CAST(value AS <type>) FROM STRING_SPLIT(?, NCHAR(31))).
Integer and decimal values are validated first and CAST (never TRY_CAST,
which turns an empty value into 0), so NOT IN keeps its semantics; decimals
cast to DECIMAL(38, s) exactly. String lists are compared as they are.
Statements within the limit keep their SQL. Date and time lists, and
databases below level 130, keep Wheels.TooManyParameters. The level is
read once per datasource and kept in application.wheels, so a reload
re-reads it.

Fixes #4103

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

Wheels.TooManyParameters on SQL Server now says that long integer, decimal,
string and uniqueidentifier lists run as one STRING_SPLIT parameter on
compatibility level 130+, and that date and time lists (#4318) or an older
level still need batching. The query builder guide covers long IN lists on
Oracle and SQL Server.

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

bpamiri commented Oct 5, 2026

Copy link
Copy Markdown
Collaborator Author

Review @ e662b45: source looks good, one LOW. Final verdict after matrix 37270864773

SQL shape

  • Base.$performQuery wraps a param that has a list key in its own parentheses (Base.cfc:43). The SQL text before it is col IN / col NOT IN.
  • The rewrite replaces the list part with three parts: "(SELECT <expr> FROM STRING_SPLIT(", a plain cf_sql_nvarchar param (no list key, so no extra parentheses) and ", NCHAR(31)))".
  • The result is col IN (SELECT CAST(value AS INT) FROM STRING_SPLIT(?, NCHAR(31))), a real subquery and not the IN ((…)) row-constructor trap.
  • A statement that fits keeps its SQL, because the early return happens when $boundParameterCount() <= limit.

NOT IN stays exact

  • Split values are never NULL, because a NULL is written inline and not put in a list, so NOT IN (subquery) has no NULL-in-set pitfall.
  • Integers and decimals are validated by regex and then CAST, not TRY_CAST, so '' can never become 0.
  • Decimals are cast to DECIMAL(38, s) with s = the widest fraction, and are refused past 38 digits.
  • The spec drives whereIn / whereNotIn / hand-written IN and NOT IN against the one-parameter-per-value result on live SQL Server, including decimal scale-0 and scale-10 columns.

Empty strings and nulls

  • Values come from ListToArray($queryParams(part).value, Chr(7)). That is the same empty-element handling as the normal list bind (Base.cfc ~845, ListToArray(param.value, param.separator)), so a split list and an unsplit list drop exactly the same empties.
  • NULLs never reach a list.
  • A single-value list isn't split (ArrayLen > 1).

Compatibility-level read

  • $supportsStringSplit() caches per effective datasource ($effectiveDataSource, so tenants included) in the application's Wheels settings, and is re-read on reload.
  • $readCompatibilityLevel() returns 0 on any error or no row. It uses VIEW on sys.databases WHERE name = DB_NAME(), which public can see by default. So the read fails safe to the existing Wheels.TooManyParameters error.
  • INFO: a transient failure caches 0 until the next reload. That's still safe, just sticky.

Injection

  • Values travel only inside the one bound nvarchar parameter.
  • Integers and decimals must match ^-?[0-9]+(\.[0-9]+)?$ after Trim.
  • A string holding the Chr(31) delimiter is refused with Wheels.QueryParamValue rather than being split wrongly.
  • The CAST type comes from a fixed map or a computed integer scale, never from user input.
  • uniqueidentifier maps to cf_sql_char, so it takes the string path and SQL Server converts each value implicitly, just as with the per-value bind.

Findings

  • LOW (accuracy of the advice and docs): $tooManyParametersAdvice() and the query-builder guide say a long "integer, decimal, string or uniqueidentifier" list runs, and that only "a date or time list, or a database below level 130, still needs batching". Per the SQL Server type map and $stringSplitList(), these also still fail:

    • float / real (cf_sql_float / cf_sql_real), bit (cf_sql_bit), text / ntext / xml (cf_sql_longvarchar) and binary lists;
    • and any integer or decimal list with a value that doesn't validate (+5, .5, 1E5, or a decimal wider than 38 digits).

    Suggested wording: "…a list of any other type (date, time, float, bit, …), or with a value Wheels can't validate as a plain number, still needs batching". This isn't a correctness bug, because those cases keep the clear error.

  • INFO: a varchar column compared against the nvarchar STRING_SPLIT output converts the column side implicitly, so an index seek becomes a scan. That only applies past ~2,100 values, where the alternative was an error. Worth one clause in the guide if you like.

On the matrix I'll read sqlServerStringSplitSpec, inListLimitsSpec and whereLargeIntegerListSpec on all six SQL Server legs, reading adobe2023 × sqlserver closely, and check that they skip correctly elsewhere.

Only lists of integers, plain decimals, strings and uniqueidentifiers run as
one STRING_SPLIT parameter. The advice, guide note and fragment now say so,
and that any other list (dates, times, float, real, bit, text or binary
values, or numbers such as +5, .5 or 1E5) still needs batching. A spec pins
the message for a float list.

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

github-actions Bot commented Oct 5, 2026

Copy link
Copy Markdown
Contributor

Wheels Test Results

219 303 tests   214 888 ✅  1h 15m 2s ⏱️
 16 928 suites    4 415 💤
     32 files          0 ❌

Results for commit 0cd3310.

@bpamiri

bpamiri commented Oct 5, 2026

Copy link
Copy Markdown
Collaborator Author

Final: APPROVE @ 0cd3310 (matrix 37271475480, all 32 legs read)

Delta since e662b45. My LOW is fixed.

  • The advice, the guide note and the fragment now name the combined types: integers, plain decimals up to 38 digits, strings and uniqueidentifiers.
  • They say any other list still needs batching: dates, times, float, real, bit, text or binary values, or numbers written as +5, .5 or 1E5.
  • A new spec binds 2,200 floats against posts.averagerating (floatColumnType) and pins that message.

Matrix

  • 32/32 legs have 0 fail, 0 error.
  • All five SQL Server legs (lucee6, lucee7, adobe2023, adobe2025, boxlang): sqlServerStringSplitSpec passes 12/12 with 0 skipped. It's read case by case on adobe2023 × sqlserver, which wasn't run locally. Passing there:
    • the same rows split as bound one per value;
    • decimals matching exactly when split;
    • a list far past the limit (5,000 ids for IN and NOT IN);
    • Wheels.TooManyParameters kept for a date list, a float list and a database below level 130;
    • the compatibility level read once per datasource and again after it is cleared;
    • the unit cases: CAST forms, refusing values that don't validate, the delimiter guard, splitting the largest list first, and keeping the SQL of a statement that fits.
  • The two edited specs, on every SQL Server leg:
    • inListLimitsSpec has 6 passed and 3 skipped. The skips are the Oracle grouping cases, as on develop. "runs the adapter's limit and one more on SQL Server" and "counts a scalar parameter alongside a list … and still runs" pass.
    • whereLargeIntegerListSpec passes 5/5.
    • These edits strengthen the coverage: SQL Server used to stop at "expect Wheels.TooManyParameters", and it now runs the same row-count assertions as every other database. The refusal coverage moved to the cases above.
  • Every other leg: the SQL Server-only cases skip as "SQL Server's parameter limit", leaving 5/12 (the unit cases). Compared with a develop-based run (37269045190), the only skip changes are those new SQL Server-only cases and the two renamed inListLimitsSpec cases. The adobe2023 and lucee7 SQL Server legs have no skip changes at all.

No open findings. INFOs from the source review stand: a failed compatibility read caches 0 until reload, which is safe; and a varchar column against nvarchar split values turns an index seek into a scan, past the old error threshold only.

@bpamiri
bpamiri merged commit 7a89e85 into develop Oct 5, 2026
70 checks passed
@bpamiri
bpamiri deleted the peter/mssql-in-list-string-split branch October 5, 2026 06:44
bpamiri added a commit that referenced this pull request Oct 5, 2026
The SQL Server adapter reads two facts once per datasource and keeps them
in application.wheels: the database's compatibility level (STRING_SPLIT,
#4321) and the types the driver binds dates as (#4329). A failed read was
kept too, so one transient error disabled STRING_SPLIT, or date-list
splitting, until the next reload.

A failed read is no longer kept. Its time is recorded, the answer is "not
supported" until $probeRetrySeconds() (60) have passed, and then the probe
runs again; a good read is kept as before. The back-off stops a broken
probe from running before every long statement.

Signed-off-by: Peter Amiri <peter@alurium.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(model): bind IN lists past SQL Server's 2100-parameter limit as one STRING_SPLIT parameter

1 participant