diff --git a/CLAUDE.md b/CLAUDE.md index 5063a38bea..5abe4c3755 100755 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -89,12 +89,37 @@ The framework must run on Lucee 5/6/7, Adobe CF 2018/2021/2023/2025, and BoxLang python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('totalPass','COMPILE FAIL'), d.get('RootCause',{}).get('snippet',''))" ``` +17. **A parameter named `default` loses its name — and its declared default value — if a type keyword precedes it, on every Adobe engine.** Adobe treats `default` as reserved in a parameter position, so `string default = ""` registers an argument named **`string`** and discards `default` entirely; the declared default value never materializes in the `arguments` scope. Dropping the type declaration fixes it — `default = ""` (untyped) parses correctly on Adobe, Lucee 6/7 and BoxLang alike. + + ```cfm + // WRONG — arguments scope gets a key named STRING; `default` never appears + public any function float(string columnNames, string default = "", boolean allowNull = "true") { + // RIGHT — arguments.default exists and carries "" + public any function float(string columnNames, default = "", boolean allowNull = "true") { + ``` + + Explicitly-passed values still arrive (as a separate lowercase `default` key alongside the bogus `STRING` one), which is what makes this so quiet: every call site that passes `default=` works, and only the *declared* default silently vanishes. `TableDefinition.uniqueidentifier()` shipped `string default = "newid()"` and emitted DDL with no `DEFAULT` clause on Adobe for as long as it has existed. All 24 `default` parameter declarations under `vendor/wheels/` were untyped uniformly in the #3302 burn-down; `cli/lucli/services/ArgSpec.cfc` still has typed ones but runs on the Lucee-only LuCLI runtime. + +18. **Adobe 2025's `FileWrite()` appends a trailing `0x0A` when handed a simple value.** `FileWrite(path, "hello world")` puts **12** bytes on disk, not 11. Lucee 6/7, BoxLang and Adobe 2023 write the string verbatim, so local Lucee green does not cover this. Harmless for generated source or JSON; fatal anywhere the read must round-trip what was written, which is why it corrupted every object stored through `wheels.storage.drivers.LocalDisk` (#3302). Decode to binary first — the binary overload has no line-ending behaviour on any engine: + + ```cfm + var payload = IsBinary(content) ? content : CharsetDecode(content, "utf-8"); + FileWrite(path, payload); + ``` + Verify Adobe CF fixes locally before pushing — don't iterate via CI: ```bash curl -s "http://localhost:62023/wheels/core/tests?db=mysql&format=json" | \ python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('totalPass',0),'pass',d.get('totalFail',0),'fail',d.get('totalError',0),'error')" ``` +**Adobe serves cached compiled classes — `?reload=true` does NOT pick up an edited `.cfc`.** `?reload=true` rebuilds the Wheels application scope, not Adobe's template cache, so a source change can keep producing the *old* result for many minutes. This reads exactly like a fix that did not work, and the natural response — reverting or piling on a second change — makes it worse. After editing framework source, `docker restart wheels-adobe2023-1` (or `-adobe2025-1`) before trusting any Adobe result. Lucee and BoxLang pick edits up from the bind mount immediately; only the Adobe legs need this. + +**Narrow the run with `directory=` — it turns a ~19-minute CI round-trip into ~5 seconds.** The core-test endpoint accepts a dotted TestBox scope, allowlisted to `wheels.tests.*` and `vendor..tests.*`. `bundles=` is silently ignored (#3352), so `directory=` is the only working filter. Point it at a *directory*, never a single spec file — a single-file scope discovers 0 bundles and reports green (#3083); check `bundlesDiscovered` in the payload. +```bash +curl -s "http://localhost:62025/wheels/core/tests?db=sqlite&directory=wheels.tests.specs.security&format=json&reload=true" +``` + Deep reference: [.ai/wheels/cross-engine-compatibility.md](.ai/wheels/cross-engine-compatibility.md). ## Anti-Patterns (Top 14) diff --git a/changelog.d/3302-adobe-typed-default-param.fixed.md b/changelog.d/3302-adobe-typed-default-param.fixed.md new file mode 100644 index 0000000000..b2c4d12c39 --- /dev/null +++ b/changelog.d/3302-adobe-typed-default-param.fixed.md @@ -0,0 +1 @@ +- Migrator column helpers no longer lose their declared `default` values on Adobe ColdFusion. A parameter declared as ` default` (e.g. `string default = "newid()"`) is parsed by Adobe as a parameter named `string`, silently discarding the `default` name and its declared value — so `t.uniqueidentifier()` emitted DDL with no `DEFAULT` clause and `t.float()` lost its `default=""` / `allowNull=true` outlier defaults. The type keyword has been dropped from every `default` parameter declaration in `Migration.cfc`, `TableDefinition.cfc`, `Abstract.cfc`, the MySQL/SQLite migrators and `DatabaseMigratorAdapterInterface.cfc` (#3302) diff --git a/changelog.d/3302-boxlang-evaluate-expression.fixed.md b/changelog.d/3302-boxlang-evaluate-expression.fixed.md new file mode 100644 index 0000000000..885fa68332 --- /dev/null +++ b/changelog.d/3302-boxlang-evaluate-expression.fixed.md @@ -0,0 +1 @@ +- `$evaluateExpression()` now evaluates built-in-function expressions through the BoxLang runtime on BoxLang. BoxLang ships no `Evaluate()` BIF, so every expression that fell through to the built-in branch returned `Error evaluating expression: Function [Evaluate] not found` instead of its result (#3302) diff --git a/changelog.d/3302-channel-cleanup-driver-row-bound.fixed.md b/changelog.d/3302-channel-cleanup-driver-row-bound.fixed.md new file mode 100644 index 0000000000..92af6d9cfb --- /dev/null +++ b/changelog.d/3302-channel-cleanup-driver-row-bound.fixed.md @@ -0,0 +1 @@ +- `Channel` database adapter `cleanup(maxRows=...)` no longer sets the driver-level `maxrows` query option when the row bound has already been pushed into dialect SQL. On BoxLang the option reaches the PostgreSQL driver as `setLargeMaxRows()`, which pgjdbc does not implement, so the bounded retention pass threw and reported zero rows deleted on PostgreSQL and CockroachDB — leaving expired `wheels_events` rows to accumulate (#3302) diff --git a/changelog.d/3302-crdb-nested-transaction-isolation.fixed.md b/changelog.d/3302-crdb-nested-transaction-isolation.fixed.md new file mode 100644 index 0000000000..dd14830d9d --- /dev/null +++ b/changelog.d/3302-crdb-nested-transaction-isolation.fixed.md @@ -0,0 +1 @@ +- `CockroachDBTransactionSpec` now declares an isolation level on the outer transaction that wraps `updateAll(transaction="rollback")`. Adobe ColdFusion rejects a nested `cftransaction` whose isolation level differs from its parent's, and the resulting exception escaped `invokeWithTransaction` before its `catch` could clear `request.wheels.transactions`, leaving the connection permanently marked as "transaction already open" — so every later model call in that request silently skipped its own transaction and `OuterTransactionSignalSpec`'s rollback assertion failed as a knock-on (#3302) diff --git a/changelog.d/3302-insert-column-list-parity.fixed.md b/changelog.d/3302-insert-column-list-parity.fixed.md new file mode 100644 index 0000000000..02d6ca5b69 --- /dev/null +++ b/changelog.d/3302-insert-column-list-parity.fixed.md @@ -0,0 +1 @@ +- `$parseInsertColumnList()` now uses one implementation on every engine instead of forking on a BoxLang check whose non-BoxLang branch dropped the comma delimiters when it ran on BoxLang. The unified regex form also preserves spaces inside quoted identifiers such as `[order date]`, which the previous `ReplaceList` form stripped (#3302) diff --git a/changelog.d/3302-localdisk-binary-write.fixed.md b/changelog.d/3302-localdisk-binary-write.fixed.md new file mode 100644 index 0000000000..5d40534d3a --- /dev/null +++ b/changelog.d/3302-localdisk-binary-write.fixed.md @@ -0,0 +1 @@ +- `LocalDisk.put()` now writes content as bytes rather than as a string, so `get()` round-trips exactly what was stored. Adobe ColdFusion 2025's `FileWrite()` appends a trailing line feed to simple values, which added a byte to every stored object and corrupted binary payloads (#3302) diff --git a/changelog.d/3302-public-component-helper-visibility.fixed.md b/changelog.d/3302-public-component-helper-visibility.fixed.md new file mode 100644 index 0000000000..97e1feb93f --- /dev/null +++ b/changelog.d/3302-public-component-helper-visibility.fixed.md @@ -0,0 +1 @@ +- Helper functions included into `wheels.Public` by `$init()` are now reachable on the component's `this` scope on every engine. The runtime include placed them in `variables` only, so external callers hit "has no function with name" on Lucee 6, Adobe 2023 and Adobe 2025 while the same call worked on Lucee 7 and BoxLang (#3302) diff --git a/changelog.d/3302-transaction-marker-reset.fixed.md b/changelog.d/3302-transaction-marker-reset.fixed.md new file mode 100644 index 0000000000..2068098bec --- /dev/null +++ b/changelog.d/3302-transaction-marker-reset.fixed.md @@ -0,0 +1 @@ +- `invokeWithTransaction()` now clears `request.wheels.transactions` when the `cftransaction` fails to open, not only when the wrapped method throws. A rejected isolation level, a nested-isolation mismatch, or a dead connection previously left the connection marked "transaction already open" for the rest of the request, so every later model call silently ran with no transaction at all (#3302) diff --git a/vendor/wheels/Public.cfc b/vendor/wheels/Public.cfc index 512d2be508..49a58f434e 100644 --- a/vendor/wheels/Public.cfc +++ b/vendor/wheels/Public.cfc @@ -5,6 +5,24 @@ component output="false" displayName="Internal GUI" extends="wheels.Global" { */ public struct function $init() { include "/wheels/public/helpers.cfm"; + + // The include above declares its UDFs into `variables` only — they never + // reach `this` on Lucee 6, Adobe 2023 or Adobe 2025 (Lucee 7 and BoxLang + // do promote them, which is why the split stayed invisible). Every helper + // in helpers.cfm is declared `public`, and the framework's own views reach + // them through `variables`, so the divergence only bites an external + // caller — `CreateObject("component", "wheels.Public").$init().$$findMatchingRoutes(…)` + // threw "has no function with name" on three of five engines (##3302). + // + // Same problem, same fix as the `/app/global/functions.cfm` include in + // `Global.cfc`'s pseudo-constructor. Call the raw scan rather than + // `$promoteIncludedGlobalsToThis()`: that wrapper memoizes its promote + // list per class in application scope, and the entry for `wheels.Public` + // is written by the pseudo-constructor *before* this include runs — so the + // memoized path would replay a stale, pre-include key list and promote + // nothing. This is the dev-only GUI component, not a request hot path. + $scanAndPromoteIncludedGlobals(); + return this; } diff --git a/vendor/wheels/Test.cfc b/vendor/wheels/Test.cfc index cb99cec653..06fe742273 100644 --- a/vendor/wheels/Test.cfc +++ b/vendor/wheels/Test.cfc @@ -759,7 +759,18 @@ component output="false" displayName="Test" extends="wheels.Global"{ if(arrayLen(local.args) == 2){ return invoke(variables, local.functionName[1], variables[local.args[2]]); } else { - // Use the Evaluate function to run Built-in functions + // Built-in functions. No portable call exists here: + // BoxLang has no Evaluate() BIF at all (verified absent + // on 1.11.0 — "The method Evaluate does not exist"), and + // getBoxRuntime() exists only on BoxLang. executeStatement() + // is the faithful equivalent — like Evaluate it takes the + // whole expression string, so neither branch has to + // re-parse the argument list. Function calls resolve at + // runtime, so the BoxLang-only name never has to compile + // on Lucee or Adobe (#3302). + if (StructKeyExists(server, "boxlang")) { + return getBoxRuntime().executeStatement(arguments.expression); + } return Evaluate(arguments.expression); } } diff --git a/vendor/wheels/channel/DatabaseAdapter.cfc b/vendor/wheels/channel/DatabaseAdapter.cfc index b8353bfdb2..4cf431bcf6 100644 --- a/vendor/wheels/channel/DatabaseAdapter.cfc +++ b/vendor/wheels/channel/DatabaseAdapter.cfc @@ -165,19 +165,33 @@ component { // indexed even when a large backlog has accumulated. The row bound is // pushed into dialect SQL (TOP / FETCH FIRST / LIMIT) so the database // does an index-assisted top-n read instead of materializing the whole - // expired backlog and truncating it client-side. The driver-level - // maxrows option stays on as belt-and-braces. + // expired backlog and truncating it client-side. + local.candidateSelect = "SELECT id FROM wheels_events WHERE createdAt < :cutoff ORDER BY createdAt ASC"; local.candidateSql = $applyRowBound( - sqlText = "SELECT id FROM wheels_events WHERE createdAt < :cutoff ORDER BY createdAt ASC", + sqlText = local.candidateSelect, dbType = $detectDatabaseType(), maxRows = arguments.maxRows ); + // The driver-level maxrows option is only used when the dialect + // rewrite applied nothing — $applyRowBound returns the statement + // unchanged exactly in that case, and there it is the only bound + // available. Everywhere else it is redundant, and redundant is not + // free: on BoxLang the option reaches PgPreparedStatement as + // setLargeMaxRows(), which pgjdbc has never implemented, so the whole + // pass threw and cleanup() reported 0 deleted on postgres and + // cockroachdb (#3302). JobWorker.$claimNext already bounds this way + // and carries a NOTE saying why; this path kept the option as + // belt-and-braces and reintroduced the failure the note warns about. + local.candidateOptions = {datasource: variables.$datasource}; + if (local.candidateSql == local.candidateSelect) { + local.candidateOptions.maxrows = Int(arguments.maxRows); + } local.candidates = queryExecute( local.candidateSql, { cutoff: {value: local.cutoff, cfsqltype: "cf_sql_timestamp"} }, - {datasource: variables.$datasource, maxrows: Int(arguments.maxRows)} + local.candidateOptions ); if (local.candidates.recordCount == 0) { return 0; @@ -315,8 +329,12 @@ component { /** * Detect the database type from the datasource via JDBC metadata. * Returns: "oracle", "postgresql", "h2", "mysql", "sqlserver", "sqlite", or "default". + * + * Public with $ prefix (internal naming convention), matching its caller + * $applyRowBound, so a spec can reproduce the dialect the bounded cleanup + * pass actually chose on the engine/database pair it is running against. */ - private string function $detectDatabaseType() { + public string function $detectDatabaseType() { try { cfdbinfo(type="version", datasource="#variables.$datasource#", name="local.info"); local.product = local.info.database_productname; @@ -342,8 +360,11 @@ component { * - mysql / postgresql / sqlite / h2: ... LIMIT n * - anything else (incl. "default" when $detectDatabaseType() falls back on a * cfdbinfo failure): statement UNCHANGED — appending LIMIT would be a syntax - * error on SQL Server/Oracle, and the caller keeps the driver-level maxrows - * option on the query, which still bounds the resultset on every engine. + * error on SQL Server/Oracle. Returning the statement unchanged is the signal + * the caller uses to fall back to the driver-level maxrows option, which is + * then the only bound on the read. That option is not portable (BoxLang routes + * it to a pgjdbc method that does not exist), so it is used only here, where + * the alternative is no bound at all. * * The bound is hardened with Int() so only a plain integer is ever interpolated * into the SQL string. A bound of zero or less returns the statement unchanged. diff --git a/vendor/wheels/databaseAdapters/Abstract.cfc b/vendor/wheels/databaseAdapters/Abstract.cfc index e3be1874bd..e722de73f0 100755 --- a/vendor/wheels/databaseAdapters/Abstract.cfc +++ b/vendor/wheels/databaseAdapters/Abstract.cfc @@ -113,7 +113,7 @@ component extends="wheels.migrator.Base"{ } // what's the purpose of this? - public boolean function optionsIncludeDefault(string type, string default = "", boolean allowNull = true) { + public boolean function optionsIncludeDefault(string type, default = "", boolean allowNull = true) { return true; } diff --git a/vendor/wheels/databaseAdapters/Base.cfc b/vendor/wheels/databaseAdapters/Base.cfc index 1f102c4fa4..255820bc80 100755 --- a/vendor/wheels/databaseAdapters/Base.cfc +++ b/vendor/wheels/databaseAdapters/Base.cfc @@ -164,15 +164,23 @@ component output=false extends="wheels.Global"{ local.columnList = ""; if (local.startPar > 1 && local.endPar > local.startPar) { local.rawColumns = Mid(arguments.sql, local.startPar, (local.endPar - local.startPar)); - if ($isBoxLangEngine()) { - // BoxLang's ReplaceList behaves differently — use regex to parse the column names. - local.columnList = REReplace(local.rawColumns, "\s*,\s*", ",", "all"); - local.columnList = REReplace(local.columnList, "[\r\n]", "", "all"); - local.columnList = Trim(local.columnList); - } else { - // Original Lucee / Adobe CF behavior. - local.columnList = ReplaceList(local.rawColumns, "#Chr(10)#,#Chr(13)#, ", ",,"); - } + // One implementation for every engine. This used to fork on + // $isBoxLangEngine(), with the ReplaceList form kept for Lucee/Adobe — + // but BoxLang's ReplaceList drops the comma delimiters themselves, so + // "id,name,age" came back as "idnameage" on any code path that reached + // that branch on BoxLang. BaseProbe hard-codes $isBoxLangEngine() to + // false, so the unit spec drove exactly that branch on the boxlang legs + // and failed on all five databases (#3302) while the sibling spec — which + // sets boxlangMode=true — passed. Collapsing the fork removes both the + // engine-dependent behaviour and the test-double trap. + // + // The regex form is also the more correct of the two: ReplaceList + // stripped every space, mangling quoted identifiers that legitimately + // contain one (e.g. `[order date]`), whereas \s*,\s* only collapses + // whitespace adjacent to the delimiters. + local.columnList = REReplace(local.rawColumns, "\s*,\s*", ",", "all"); + local.columnList = REReplace(local.columnList, "[\r\n]", "", "all"); + local.columnList = Trim(local.columnList); } // Strip identifier quotes from the column list for comparison. return $stripIdentifierQuotes(local.columnList); diff --git a/vendor/wheels/databaseAdapters/MySQL/MySQLMigrator.cfc b/vendor/wheels/databaseAdapters/MySQL/MySQLMigrator.cfc index 09cbccfdda..bc1c9166fc 100755 --- a/vendor/wheels/databaseAdapters/MySQL/MySQLMigrator.cfc +++ b/vendor/wheels/databaseAdapters/MySQL/MySQLMigrator.cfc @@ -78,7 +78,7 @@ component extends="wheels.databaseAdapters.Abstract" { * `vendor/wheels/tests/specs/migrator/addColumnOptionsSpec.cfc` — keep * this list and that spec aligned. See #2742. */ - public boolean function optionsIncludeDefault(string type, string default = "", boolean allowNull = true) { + public boolean function optionsIncludeDefault(string type, default = "", boolean allowNull = true) { if (ListFindNoCase("text,mediumtext,longtext,float", arguments.type)) { return false; } else { diff --git a/vendor/wheels/databaseAdapters/Oracle/OracleModel.cfc b/vendor/wheels/databaseAdapters/Oracle/OracleModel.cfc index 1d4e2edf35..61176c5776 100755 --- a/vendor/wheels/databaseAdapters/Oracle/OracleModel.cfc +++ b/vendor/wheels/databaseAdapters/Oracle/OracleModel.cfc @@ -279,17 +279,32 @@ component extends="wheels.databaseAdapters.Base" output=false { } /** - * Oracle bulk insert using `INSERT ALL INTO ... SELECT 1 FROM dual`. + * Oracle bulk insert using `INSERT INTO t (cols) SELECT ... FROM dual UNION ALL ...`. * - * The default Base adapter shape — `INSERT INTO t (cols) VALUES (?,?), (?,?), ...` - * (SQL standard table value constructor) — was rejected on Oracle 23 with - * `ORA: returning clause is not allowed with INSERT and Table Value Constructor`. - * The CFML engine's `cfquery` for INSERT statements implicitly sets - * `Statement.RETURN_GENERATED_KEYS`, which the Oracle JDBC driver translates into a - * RETURNING clause — and Oracle 23 does not permit RETURNING with multi-row VALUES. + * Two Oracle constraints shape this, and satisfying only the first is what the + * previous `INSERT ALL` form did. + * + * 1. The default Base adapter shape — `INSERT INTO t (cols) VALUES (?,?), (?,?)` + * (SQL standard table value constructor) — was rejected on Oracle 23 with + * `ORA: returning clause is not allowed with INSERT and Table Value + * Constructor`. The CFML engine's `cfquery` implicitly sets + * `Statement.RETURN_GENERATED_KEYS` on INSERTs, which the Oracle JDBC driver + * translates into a RETURNING clause, and Oracle 23 does not permit RETURNING + * with multi-row VALUES (#2745). + * + * 2. In a multitable insert (`INSERT ALL`), Oracle evaluates each row's default + * expressions ONCE PER ROW OF THE DRIVING QUERY and shares the result across + * every INTO clause. The driving query was `SELECT 1 FROM dual` — a single row + * — so every INTO received the SAME identity value, and any table with an + * identity or sequence-backed primary key got a duplicate-key violation on the + * second record: `ORA-00001 ... row with column values (ID:1) already exists`. + * insertAll() could never insert more than one row into such a table (#3302). + * + * `INSERT ... SELECT ... UNION ALL` satisfies both: it is not a table value + * constructor, and its driving query returns one row per record, so the identity + * default is evaluated per row. It is also the shape `$upsertSQL` below already + * uses for its MERGE source, including the alias-the-first-branch-only detail. * - * `INSERT ALL` is the Oracle-idiomatic multi-row insert form, doesn't trigger the - * RETURNING-clause expansion, and works on every Oracle version Wheels targets. * Uses parameterized values via `$buildBulkParam` — never interpolates user data * into SQL. */ @@ -312,11 +327,14 @@ component extends="wheels.databaseAdapters.Base" output=false { local.colList &= $quoteIdentifier(local.col); } - ArrayAppend(local.sql, "INSERT ALL"); + ArrayAppend(local.sql, "INSERT INTO #arguments.tableName# (#local.colList#) "); local.propCount = ArrayLen(arguments.validProperties); for (local.r = arguments.batchStart; local.r <= arguments.batchEnd; local.r++) { - ArrayAppend(local.sql, " INTO #arguments.tableName# (#local.colList#) VALUES ("); + if (local.r > arguments.batchStart) { + ArrayAppend(local.sql, " UNION ALL "); + } + ArrayAppend(local.sql, "SELECT "); for (local.p = 1; local.p <= local.propCount; local.p++) { if (local.p > 1) { ArrayAppend(local.sql, ", "); @@ -328,12 +346,15 @@ component extends="wheels.databaseAdapters.Base" output=false { propName = local.propName, propertyInfo = arguments.propertyInfo )); + // Only the first branch needs column aliases; the rest of the + // UNION ALL inherits them. Same rule as $upsertSQL's MERGE source. + if (local.r == arguments.batchStart) { + ArrayAppend(local.sql, " AS " & $quoteIdentifier(arguments.columns[local.p])); + } } - ArrayAppend(local.sql, ")"); + ArrayAppend(local.sql, " FROM dual"); } - ArrayAppend(local.sql, " SELECT 1 FROM dual"); - return local.sql; } diff --git a/vendor/wheels/databaseAdapters/SQLite/SQLiteMigrator.cfc b/vendor/wheels/databaseAdapters/SQLite/SQLiteMigrator.cfc index 5241e45662..c45f84ad50 100755 --- a/vendor/wheels/databaseAdapters/SQLite/SQLiteMigrator.cfc +++ b/vendor/wheels/databaseAdapters/SQLite/SQLiteMigrator.cfc @@ -76,7 +76,7 @@ component extends="wheels.databaseAdapters.Abstract" { /** * In SQLite, most types can have default values, except BLOB. */ - public boolean function optionsIncludeDefault(string type, string default = "", boolean allowNull = true) { + public boolean function optionsIncludeDefault(string type, default = "", boolean allowNull = true) { if (ListFindNoCase("blob", arguments.type)) { return false; } diff --git a/vendor/wheels/interfaces/database/DatabaseMigratorAdapterInterface.cfc b/vendor/wheels/interfaces/database/DatabaseMigratorAdapterInterface.cfc index 890a8d571d..0b6e64db93 100644 --- a/vendor/wheels/interfaces/database/DatabaseMigratorAdapterInterface.cfc +++ b/vendor/wheels/interfaces/database/DatabaseMigratorAdapterInterface.cfc @@ -54,7 +54,7 @@ interface { * @allowNull Whether NULL is allowed. * @return True if a DEFAULT clause should be added. */ - public boolean function optionsIncludeDefault(string type, string default, boolean allowNull); + public boolean function optionsIncludeDefault(string type, default, boolean allowNull); /** * Quote a value for use in DDL statements. diff --git a/vendor/wheels/migrator/Migration.cfc b/vendor/wheels/migrator/Migration.cfc index 6121f0ab85..c79be481e2 100755 --- a/vendor/wheels/migrator/Migration.cfc +++ b/vendor/wheels/migrator/Migration.cfc @@ -170,7 +170,7 @@ component extends="Base" { string columnNames, string afterColumn = "", string referenceName = "", - string default, + default, boolean allowNull, numeric limit, numeric precision, @@ -213,7 +213,7 @@ component extends="Base" { required string columnType, string afterColumn = "", string referenceName = "", - string default, + default, boolean allowNull, numeric limit, numeric precision, diff --git a/vendor/wheels/migrator/TableDefinition.cfc b/vendor/wheels/migrator/TableDefinition.cfc index b58f3fb56a..bce07c1b64 100644 --- a/vendor/wheels/migrator/TableDefinition.cfc +++ b/vendor/wheels/migrator/TableDefinition.cfc @@ -96,7 +96,7 @@ component extends="Base" { public any function column( required string columnName, required string columnType, - string default, + default, boolean allowNull, any limit, numeric precision, @@ -148,7 +148,7 @@ component extends="Base" { * [section: Migrator] * [category: Table Definition Functions] */ - public any function bigInteger(string columnNames, numeric limit, string default, boolean allowNull) { + public any function bigInteger(string columnNames, numeric limit, default, boolean allowNull) { return $addTypedColumns(columnType = "biginteger", args = arguments); } @@ -158,7 +158,7 @@ component extends="Base" { * [section: Migrator] * [category: Table Definition Functions] */ - public any function binary(string columnNames, string default, boolean allowNull) { + public any function binary(string columnNames, default, boolean allowNull) { return $addTypedColumns(columnType = "binary", args = arguments); } @@ -168,7 +168,7 @@ component extends="Base" { * [section: Migrator] * [category: Table Definition Functions] */ - public any function boolean(string columnNames, string default, boolean allowNull) { + public any function boolean(string columnNames, default, boolean allowNull) { return $addTypedColumns(columnType = "boolean", args = arguments); } @@ -178,7 +178,7 @@ component extends="Base" { * [section: Migrator] * [category: Table Definition Functions] */ - public any function date(string columnNames, string default, boolean allowNull) { + public any function date(string columnNames, default, boolean allowNull) { return $addTypedColumns(columnType = "date", args = arguments); } @@ -188,7 +188,7 @@ component extends="Base" { * [section: Migrator] * [category: Table Definition Functions] */ - public any function datetime(string columnNames, string default, boolean allowNull) { + public any function datetime(string columnNames, default, boolean allowNull) { return $addTypedColumns(columnType = "datetime", args = arguments); } @@ -198,7 +198,7 @@ component extends="Base" { * [section: Migrator] * [category: Table Definition Functions] */ - public any function decimal(string columnNames, string default, boolean allowNull, numeric precision, numeric scale) { + public any function decimal(string columnNames, default, boolean allowNull, numeric precision, numeric scale) { return $addTypedColumns(columnType = "decimal", args = arguments); } @@ -208,7 +208,7 @@ component extends="Base" { * [section: Migrator] * [category: Table Definition Functions] */ - public any function float(string columnNames, string default = "", boolean allowNull = "true") { + public any function float(string columnNames, default = "", boolean allowNull = "true") { // NOTE: the default=""/allowNull="true" parameter defaults are a // long-standing outlier among these helpers — preserved as-is for // backward compatibility (addColumnOptions renders default="" as @@ -222,7 +222,7 @@ component extends="Base" { * [section: Migrator] * [category: Table Definition Functions] */ - public any function integer(string columnNames, numeric limit, string default, boolean allowNull) { + public any function integer(string columnNames, numeric limit, default, boolean allowNull) { return $addTypedColumns(columnType = "integer", args = arguments); } @@ -232,7 +232,7 @@ component extends="Base" { * [section: Migrator] * [category: Table Definition Functions] */ - public any function string(string columnNames, any limit, string default, boolean allowNull) { + public any function string(string columnNames, any limit, default, boolean allowNull) { return $addTypedColumns(columnType = "string", args = arguments); } @@ -242,7 +242,7 @@ component extends="Base" { * [section: Migrator] * [category: Table Definition Functions] */ - public any function char(string columnNames, any limit, string default, boolean allowNull) { + public any function char(string columnNames, any limit, default, boolean allowNull) { return $addTypedColumns(columnType = "char", args = arguments); } @@ -259,7 +259,7 @@ component extends="Base" { * [section: Migrator] * [category: Table Definition Functions] */ - public any function text(string columnNames, string default, boolean allowNull, string size) { + public any function text(string columnNames, default, boolean allowNull, string size) { return $addTypedColumns(columnType = "text", args = arguments); } @@ -269,7 +269,7 @@ component extends="Base" { * [section: Migrator] * [category: Table Definition Functions] */ - public any function uniqueidentifier(string columnNames, string default = "newid()", boolean allowNull) { + public any function uniqueidentifier(string columnNames, default = "newid()", boolean allowNull) { // NOTE: the default="newid()" parameter default is MSSQL syntax — this // helper is only registered by the MicrosoftSQLServer adapter, so the // outlier default is preserved as-is. @@ -282,7 +282,7 @@ component extends="Base" { * [section: Migrator] * [category: Table Definition Functions] */ - public any function time(string columnNames, string default, boolean allowNull) { + public any function time(string columnNames, default, boolean allowNull) { return $addTypedColumns(columnType = "time", args = arguments); } @@ -292,7 +292,7 @@ component extends="Base" { * [section: Migrator] * [category: Table Definition Functions] */ - public any function timestamp(string columnNames, string default, boolean allowNull, string columnType = "datetime") { + public any function timestamp(string columnNames, default, boolean allowNull, string columnType = "datetime") { // columnType is caller-overridable here (defaults to "datetime") — // unlike the sibling helpers, which stamp a fixed type. return $addTypedColumns(columnType = arguments.columnType, args = arguments); @@ -338,7 +338,7 @@ component extends="Base" { public any function references( string referenceNames, string columnNames, - string default, + default, boolean allowNull = "false", boolean polymorphic = "false", boolean foreignKey = "true", diff --git a/vendor/wheels/model/transactions.cfc b/vendor/wheels/model/transactions.cfc index 84add8f5ad..d41488e8a1 100644 --- a/vendor/wheels/model/transactions.cfc +++ b/vendor/wheels/model/transactions.cfc @@ -55,17 +55,33 @@ component { switch (arguments.transaction) { case "commit": case "rollback": - transaction action="begin" isolation=arguments.isolation { - try { - local.rv = $invoke(method = arguments.method, componentReference = this, invokeArgs = local.methodArgs); - if (!IsBoolean(local.rv) || !local.rv || arguments.transaction eq "rollback") { + // The outer try/catch exists because the `transaction action="begin"` + // tag can throw before the inner one is ever entered — an unsupported + // isolation level, a nested-isolation mismatch on Adobe, a dead + // connection. The open marker is set above, so without this the + // marker stayed `true` for the rest of the request and every later + // invokeWithTransaction took the "alreadyopen" path and silently ran + // with no transaction at all. The whole core suite runs in one + // request, which is how a single throwing begin in + // CockroachDBTransactionSpec went on to fail OuterTransactionSignalSpec + // several bundles later (#3302). Resetting twice is harmless: the + // inner catch already clears the same flag before it rethrows. + try { + transaction action="begin" isolation=arguments.isolation { + try { + local.rv = $invoke(method = arguments.method, componentReference = this, invokeArgs = local.methodArgs); + if (!IsBoolean(local.rv) || !local.rv || arguments.transaction eq "rollback") { + transaction action="rollback"; + } + } catch (any e) { transaction action="rollback"; + request.wheels.transactions[local.connectionArgs] = false; + rethrow; } - } catch (any e) { - transaction action="rollback"; - request.wheels.transactions[local.connectionArgs] = false; - rethrow; } + } catch (any e) { + request.wheels.transactions[local.connectionArgs] = false; + rethrow; } break; case "false": diff --git a/vendor/wheels/storage/drivers/LocalDisk.cfc b/vendor/wheels/storage/drivers/LocalDisk.cfc index dfe1a3f6e7..c084c52143 100644 --- a/vendor/wheels/storage/drivers/LocalDisk.cfc +++ b/vendor/wheels/storage/drivers/LocalDisk.cfc @@ -32,7 +32,15 @@ component implements="wheels.interfaces.StorageDiskInterface" output="false" { public any function put(required string key, required any content, string contentType = "", string visibility = "") { local.path = $resolve(arguments.key); $ensureParentDir(local.path); - FileWrite(local.path, arguments.content); + // Write bytes, never a string. Adobe 2025's FileWrite() appends a + // trailing LF (0x0A) when handed a simple value — storing "hello world" + // put 12 bytes on disk, so `get()` no longer round-tripped what `put()` + // was given, and any binary payload came back corrupted by one byte. + // Lucee 6/7, BoxLang and Adobe 2023 write the string verbatim, so this + // only ever surfaced on the adobe2025 matrix legs (#3302). The binary + // overload has no line-ending behaviour on any engine. + local.payload = IsBinary(arguments.content) ? arguments.content : CharsetDecode(arguments.content, "utf-8"); + FileWrite(local.path, local.payload); return arguments.key; } diff --git a/vendor/wheels/tests/_assets/views/test/_groupRow.cfm b/vendor/wheels/tests/_assets/views/test/_grouprow.cfm similarity index 100% rename from vendor/wheels/tests/_assets/views/test/_groupRow.cfm rename to vendor/wheels/tests/_assets/views/test/_grouprow.cfm diff --git a/vendor/wheels/tests/specs/channel/DatabaseAdapterSpec.cfc b/vendor/wheels/tests/specs/channel/DatabaseAdapterSpec.cfc index bc2c23e75b..02c77c9cff 100644 --- a/vendor/wheels/tests/specs/channel/DatabaseAdapterSpec.cfc +++ b/vendor/wheels/tests/specs/channel/DatabaseAdapterSpec.cfc @@ -222,6 +222,89 @@ component extends="wheels.WheelsTest" { expect(remaining.recordCount).toBe(3); }); + it("runs the bounded-pass statements against the live database", function() { + // The $applyRowBound tests below only compare strings — nothing sends + // the rewritten SQL to a real database, and nothing exercises the + // list-parameter DELETE the bounded pass pairs it with. cleanup() + // catches every error and returns 0, so an engine/database pair that + // rejects either statement presents only as a wrong row count with no + // message: "Expected [2] but received [0]" on boxlang + postgres and + // cockroachdb, with the reason only in the wheels_channels log + // (#3302). Running both statements here without the catch makes the + // database's own error the thing the suite reports. + adapter.cleanup(); + + queryExecute( + "INSERT INTO wheels_events (id, channel, event, data, createdAt) + VALUES (:id, :channel, :event, :data, :createdAt)", + { + id: {value: "livebound-evt-1", cfsqltype: "cf_sql_varchar"}, + channel: {value: "test.livebound", cfsqltype: "cf_sql_varchar"}, + event: {value: "old", cfsqltype: "cf_sql_varchar"}, + data: {value: "expired", cfsqltype: "cf_sql_longvarchar"}, + createdAt: {value: DateAdd("h", -2, Now()), cfsqltype: "cf_sql_timestamp"} + }, + {datasource: application.wheels.dataSourceName} + ); + + var cutoff = DateAdd("n", -60, Now()); + var dialect = adapter.$detectDatabaseType(); + var candidateSql = adapter.$applyRowBound( + sqlText = "SELECT id FROM wheels_events WHERE createdAt < :cutoff ORDER BY createdAt ASC", + dbType = dialect, + maxRows = 1 + ); + + // Mirror cleanup()'s option handling exactly: the driver-level + // maxrows bound is used only when the dialect rewrite applied none. + // Setting it unconditionally is what threw on boxlang + pgjdbc. + var options = {datasource: application.wheels.dataSourceName}; + if (candidateSql == "SELECT id FROM wheels_events WHERE createdAt < :cutoff ORDER BY createdAt ASC") { + options.maxrows = 1; + } + var candidates = queryExecute( + candidateSql, + {cutoff: {value: cutoff, cfsqltype: "cf_sql_timestamp"}}, + options + ); + expect(candidates.recordCount).toBe( + 1, + "The dialect-bounded SELECT returned no rows on #dialect#. SQL was: #candidateSql#" + ); + + // Assert against the id the bounded SELECT actually returned rather + // than against the row inserted above: ORDER BY createdAt ASC takes + // the oldest expired row in the table, which need not be ours if a + // previous bundle left one behind. + var targetId = candidates.id[1]; + + queryExecute( + "DELETE FROM wheels_events WHERE createdAt < :cutoff AND id IN (:ids)", + { + cutoff: {value: cutoff, cfsqltype: "cf_sql_timestamp"}, + ids: {value: ValueList(candidates.id), cfsqltype: "cf_sql_varchar", list: true} + }, + {datasource: application.wheels.dataSourceName} + ); + + var survivor = queryExecute( + "SELECT id FROM wheels_events WHERE id = :id", + {id: {value: targetId, cfsqltype: "cf_sql_varchar"}}, + {datasource: application.wheels.dataSourceName} + ); + expect(survivor.recordCount).toBe( + 0, + "The list-parameter DELETE ran without error on #dialect# but did not " + & "remove the row the bounded SELECT had just identified." + ); + + queryExecute( + "DELETE FROM wheels_events WHERE channel = :channel", + {channel: {value: "test.livebound", cfsqltype: "cf_sql_varchar"}}, + {datasource: application.wheels.dataSourceName} + ); + }); + it("$applyRowBound rewrites the SELECT with TOP for sqlserver", function() { var bounded = adapter.$applyRowBound( sqlText = "SELECT id FROM wheels_events WHERE createdAt < :cutoff ORDER BY createdAt ASC", diff --git a/vendor/wheels/tests/specs/controller/miscellaneousSpec.cfc b/vendor/wheels/tests/specs/controller/miscellaneousSpec.cfc index 0b635091bf..b5e248fc25 100644 --- a/vendor/wheels/tests/specs/controller/miscellaneousSpec.cfc +++ b/vendor/wheels/tests/specs/controller/miscellaneousSpec.cfc @@ -494,7 +494,17 @@ component extends="wheels.WheelsTest" { fileContent = FileRead(filePath) FileDelete(filePath) - expect(fileContent).toInclude(textBody & Chr(13) & Chr(10) & Chr(13) & Chr(10) & HTMLBody) + // Assert the blank line, not the bytes that encode it. sendEmail + // writes CRLFCRLF, but BoxLang's cffile write normalizes CRLF to LF + // on the way to disk — a byte-level probe showed a 10-byte + // "AAA\r\n\r\nBBB" payload landing as 8 bytes — so a literal + // CRLFCRLF needle failed on all five boxlang legs while passing on + // Lucee and Adobe (#3302). The line-ending encoding of a debug + // artifact is the engine's business; the blank line is ours. + normalized = Replace(fileContent, Chr(13) & Chr(10), Chr(10), "all") + normalized = Replace(normalized, Chr(13), Chr(10), "all") + + expect(normalized).toInclude(textBody & Chr(10) & Chr(10) & HTMLBody) }) it("sends single template email when layout is an empty string", () => { diff --git a/vendor/wheels/tests/specs/database/AdapterIdentityTemplateSpec.cfc b/vendor/wheels/tests/specs/database/AdapterIdentityTemplateSpec.cfc index 5e09d4863b..1341fae9c8 100644 --- a/vendor/wheels/tests/specs/database/AdapterIdentityTemplateSpec.cfc +++ b/vendor/wheels/tests/specs/database/AdapterIdentityTemplateSpec.cfc @@ -18,11 +18,23 @@ component extends="wheels.WheelsTest" { expect(probe.$parseInsertColumnList("INSERT INTO users (id")).toBe(""); }); - it("parses via the regex branch on engines flagged BoxLang", () => { - var probe = CreateObject("component", "wheels.tests._assets.adapters.BaseProbe"); - probe.boxlangMode = true; + // $parseInsertColumnList used to fork on $isBoxLangEngine(). The + // result must now be identical no matter what that flag reports, + // so pin both settings to the same expectation — a reintroduced + // fork fails here rather than only on the boxlang matrix legs. + it("parses identically regardless of the BoxLang engine flag", () => { var insertSql = "INSERT INTO users ([id], ""name"",#Chr(10)#age) VALUES (1,'x',2)"; - expect(probe.$parseInsertColumnList(insertSql)).toBe("id,name,age"); + for (var flag in [false, true]) { + var probe = CreateObject("component", "wheels.tests._assets.adapters.BaseProbe"); + probe.boxlangMode = flag; + expect(probe.$parseInsertColumnList(insertSql)).toBe("id,name,age"); + } + }); + + it("preserves spaces inside a quoted identifier", () => { + var probe = CreateObject("component", "wheels.tests._assets.adapters.BaseProbe"); + var insertSql = "INSERT INTO orders ([order date], id) VALUES ('x',1)"; + expect(probe.$parseInsertColumnList(insertSql)).toBe("order date,id"); }); }); diff --git a/vendor/wheels/tests/specs/database/CockroachDBTransactionSpec.cfc b/vendor/wheels/tests/specs/database/CockroachDBTransactionSpec.cfc index 61694f3073..41b6f0d600 100644 --- a/vendor/wheels/tests/specs/database/CockroachDBTransactionSpec.cfc +++ b/vendor/wheels/tests/specs/database/CockroachDBTransactionSpec.cfc @@ -48,7 +48,16 @@ component extends="wheels.WheelsTest" { }); it("updateAll with rollback does not persist changes", () => { - transaction action="begin" { + // The isolation level must be declared here even though this + // outer transaction does not otherwise need one. updateAll's + // transaction="rollback" routes through invokeWithTransaction, + // which opens its own cftransaction with isolation="read_committed" + // — and Adobe rejects a nested cftransaction whose isolation + // level differs from its parent's ("Nested cftransaction tag + // should specify same isolation level as the parent"). Lucee and + // BoxLang do not enforce that, so an undeclared parent only fails + // on the two Adobe legs of the matrix (#3302). + transaction action="begin" isolation="read_committed" { g.model("tag").updateAll(name = "CRDBTemp", transaction = "rollback"); var changed = g.model("tag").findAll(where = "name = 'CRDBTemp'"); expect(changed.recordCount).toBe(0); diff --git a/vendor/wheels/tests/specs/database/TimestampRoundTripSpec.cfc b/vendor/wheels/tests/specs/database/TimestampRoundTripSpec.cfc new file mode 100644 index 0000000000..244601b912 --- /dev/null +++ b/vendor/wheels/tests/specs/database/TimestampRoundTripSpec.cfc @@ -0,0 +1,102 @@ +/** + * A `cf_sql_timestamp` written to a datetime column must read back in one of the + * two shapes the framework knows how to interpret (#3302). + * + * There is no engine-independent guarantee that it reads back as a date. SQLite + * has no real DATETIME type, and on Lucee 7 + sqlite-jdbc the value returns as + * raw epoch milliseconds — a probe here read `1785873308685` back from a + * `cf_sql_timestamp` write. `RateLimiter.$secondsSince()` already encodes that + * reality: `IsDate()` first, otherwise treat the value as epoch milliseconds + * against `GetTickCount()`. + * + * So the contract is a disjunction, and this spec asserts exactly it: the value + * is a CFML date, or it is a number of milliseconds close enough to now to be an + * epoch timestamp. Anything else lands in `$secondsSince`'s numeric branch as + * garbage, and every caller that branches on elapsed time silently misbehaves: + * + * - `RateLimiter` token buckets read as permanently empty or permanently full. + * - `Migrator`'s `applied_at` renders as nothing in `migrate info` / `doctor`. + * + * Both fail on adobe2023 + oracle — three `RateLimiterDatabaseSpec` legs and two + * `SchemaEnrichmentSpec` legs, all consistent with one shared cause. Asserting + * that cause directly beats chasing five symptoms, and the failure message + * prints the value, which none of the five do. + */ +component extends="wheels.WheelsTest" { + + function run() { + + describe("cf_sql_timestamp round-trip (##3302)", () => { + + it("reads back as a CFML date or as epoch milliseconds", () => { + var written = DateAdd("n", -37, Now()); + + queryExecute( + "DELETE FROM c_o_r_e_bulkitems WHERE code = :code", + {code: {value: "TS-ROUNDTRIP", cfsqltype: "cf_sql_varchar"}}, + {datasource: application.wheels.dataSourceName} + ); + queryExecute( + "INSERT INTO c_o_r_e_bulkitems (code, name, quantity, createdat) + VALUES (:code, :name, :quantity, :createdat)", + { + code: {value: "TS-ROUNDTRIP", cfsqltype: "cf_sql_varchar"}, + name: {value: "TimestampRoundTrip", cfsqltype: "cf_sql_varchar"}, + quantity: {value: 1, cfsqltype: "cf_sql_integer"}, + createdat: {value: written, cfsqltype: "cf_sql_timestamp"} + }, + {datasource: application.wheels.dataSourceName} + ); + + var row = queryExecute( + "SELECT createdat FROM c_o_r_e_bulkitems WHERE code = :code", + {code: {value: "TS-ROUNDTRIP", cfsqltype: "cf_sql_varchar"}}, + {datasource: application.wheels.dataSourceName} + ); + expect(row.recordCount).toBe(1); + + var readBack = row.createdat; + var shape = IsDate(readBack) ? "date" : (IsNumeric(readBack) ? "numeric" : "neither"); + + // Report the value, not just the verdict. "Expected [NO] to be true" + // is what the five downstream failures already say, and it names + // nothing at all. + expect(shape).notToBe( + "neither", + "A cf_sql_timestamp round-tripped as something $secondsSince() cannot " + & "read: wrote [" & DateTimeFormat(written, "yyyy-mm-dd HH:nn:ss") + & "], read back [" & readBack & "]. Every framework path that stores a " + & "timestamp and later measures elapsed time against it — RateLimiter's " + & "token bucket, the migrator's applied_at — is unreliable here." + ); + + // Whichever shape it is, it has to still mean the time that was + // written. Reproduce $secondsSince()'s own computation rather than + // reconstructing a date from the epoch value: both branches yield + // "seconds since the stored moment", which is timezone-free, so the + // comparison holds wherever the suite runs. + var elapsed = IsDate(readBack) + ? DateDiff("s", readBack, Now()) + : Int((GetTickCount() - readBack) / 1000); + var expected = DateDiff("s", written, Now()); + + expect(Abs(elapsed - expected)).toBeLT( + 120, + "The stored timestamp came back as a #shape# that does not resolve to " + & "the time written: wrote [" & DateTimeFormat(written, "yyyy-mm-dd HH:nn:ss") + & "], read back [" & readBack & "]. $secondsSince() would report " + & elapsed & "s elapsed where " & expected & "s is correct." + ); + + queryExecute( + "DELETE FROM c_o_r_e_bulkitems WHERE code = :code", + {code: {value: "TS-ROUNDTRIP", cfsqltype: "cf_sql_varchar"}}, + {datasource: application.wheels.dataSourceName} + ); + }); + + }); + + } + +} diff --git a/vendor/wheels/tests/specs/migrator/migratorSpec.cfc b/vendor/wheels/tests/specs/migrator/migratorSpec.cfc index 970cf18436..30e4fe4ae7 100644 --- a/vendor/wheels/tests/specs/migrator/migratorSpec.cfc +++ b/vendor/wheels/tests/specs/migrator/migratorSpec.cfc @@ -304,8 +304,15 @@ component extends="wheels.WheelsTest" { {}, { datasource = application.wheels.dataSourceName } ); + // DEFAULT before NOT NULL, which is the order the SQL standard + // specifies and the only one Oracle accepts — `NOT NULL DEFAULT 1` + // there is ORA-03076 "unexpected item DEFAULT in a column + // definition". MySQL/Postgres/SQL Server/SQLite/H2 take either + // order, so this form is portable and the reversed one is not + // (#3302). Migrator.cfc already emits the Oracle-safe order for + // the table it creates itself; this fixture hand-writes its own. queryExecute( - "CREATE TABLE c_o_r_e_migrator_versions (version VARCHAR(25), core_level INT NOT NULL DEFAULT 1)", + "CREATE TABLE c_o_r_e_migrator_versions (version VARCHAR(25), core_level INT DEFAULT 1 NOT NULL)", {}, { datasource = application.wheels.dataSourceName } ); diff --git a/vendor/wheels/tests/specs/model/TransactionMarkerResetSpec.cfc b/vendor/wheels/tests/specs/model/TransactionMarkerResetSpec.cfc new file mode 100644 index 0000000000..3280c3d309 --- /dev/null +++ b/vendor/wheels/tests/specs/model/TransactionMarkerResetSpec.cfc @@ -0,0 +1,69 @@ +/** + * A failure to OPEN a transaction must not leave the connection marked as + * "transaction already open" (#3302). + * + * `invokeWithTransaction()` sets `request.wheels.transactions[connectionArgs]` + * to true *before* it opens the `cftransaction`, and the tag itself sits + * outside the try/catch that resets the marker. So when the begin tag threw — + * an unsupported isolation level, Adobe's nested-isolation-mismatch rule, a + * dead connection — the marker stayed true and every subsequent + * `invokeWithTransaction` in the same request took the "alreadyopen" branch + * and ran with no transaction at all. Silent, and it does not recover until + * the request ends. + * + * That is what made one failing spec cascade in the compatibility matrix: the + * whole core suite runs inside a single request, so a throwing begin in + * `CockroachDBTransactionSpec` disabled model transaction handling for every + * bundle after it, and `OuterTransactionSignalSpec`'s rollback assertion + * failed several bundles later for reasons that had nothing to do with it. + * + * An invalid isolation level is the portable way to make the begin tag itself + * fail on every engine. + */ +component extends="wheels.WheelsTest" { + + function run() { + + describe("invokeWithTransaction marker reset (##3302)", () => { + + it("clears the open-transaction marker when the transaction fails to begin", () => { + var tag = application.wo.model("tag"); + var connectionArgs = tag.$hashedConnectionArgs(); + + if (!StructKeyExists(request, "wheels")) { + request.wheels = {}; + } + if (!StructKeyExists(request.wheels, "transactions")) { + request.wheels.transactions = {}; + } + request.wheels.transactions[connectionArgs] = false; + + // Struct, not a scalar, and accessed without the `local.` prefix: + // anything written through `local.` inside a catch is discarded on + // BoxLang (cross-engine invariant 11). + var state = {threw = false}; + try { + tag.invokeWithTransaction( + method = "count", + transaction = "commit", + isolation = "wheels_not_a_real_isolation_level" + ); + } catch (any e) { + state.threw = true; + } + + expect(state.threw).toBeTrue( + "An invalid isolation level should make the begin tag fail — if this is false the " + & "engine accepted the level and the spec needs a different way to fail the open." + ); + expect(request.wheels.transactions[connectionArgs]).toBeFalse( + "A transaction that never opened must leave the connection unmarked, otherwise every " + & "later model call in this request silently skips its own transaction." + ); + }); + + }); + + } + +} diff --git a/vendor/wheels/tests/specs/model/oracleBulkInsertSqlSpec.cfc b/vendor/wheels/tests/specs/model/oracleBulkInsertSqlSpec.cfc index 12ec5ffba5..3d146543fc 100644 --- a/vendor/wheels/tests/specs/model/oracleBulkInsertSqlSpec.cfc +++ b/vendor/wheels/tests/specs/model/oracleBulkInsertSqlSpec.cfc @@ -12,6 +12,16 @@ component extends="wheels.WheelsTest" { // "returning clause is not allowed with INSERT and Table Value Constructor" // caused by bulk INSERT emitting multi-row VALUES (?,?), (?,?) plus // JDBC RETURN_GENERATED_KEYS handling on Oracle 23. + // + // This spec used to assert `INSERT ALL` and "one INTO per record" — the + // shape that fixed #2745. That encoded the mechanism rather than the + // requirement, so it stayed green while the mechanism broke identity + // assignment: Oracle evaluates row defaults once per row of the driving + // query and shares them across every INTO, and `SELECT 1 FROM dual` is one + // row, so every record in a batch received the same generated key (#3302). + // The assertions below pin what actually has to hold — one statement, no + // table value constructor, one source row per record — not which Oracle + // construct delivers it. describe("OracleModel.$bulkInsertSQL", () => { @@ -28,7 +38,7 @@ component extends="wheels.WheelsTest" { ]; }); - it("emits INSERT ALL ... SELECT 1 FROM dual instead of multi-row VALUES", () => { + it("emits INSERT ... SELECT ... FROM dual instead of multi-row VALUES", () => { var sql = oracle.$bulkInsertSQL( tableName = """AUTHORS""", columns = ["firstName", "lastName"], @@ -51,16 +61,19 @@ component extends="wheels.WheelsTest" { // Oracle-idiomatic shape — avoids the table-value-constructor + // RETURNING incompatibility on Oracle 23. - expect(collapsed).toInclude("INSERT ALL"); - expect(collapsed).toInclude(" INTO ""AUTHORS"" "); - expect(collapsed).toInclude("SELECT 1 FROM dual"); + expect(collapsed).toInclude("INSERT INTO ""AUTHORS"""); + expect(collapsed).toInclude("FROM dual"); // And must NOT contain the multi-row VALUES tuple-list shape that // Oracle JDBC rejects when RETURN_GENERATED_KEYS is requested. expect(collapsed).notToMatch("VALUES \(.+\), ?\("); + + // Nor the multitable form, whose single driving row hands every + // record the same generated key. + expect(collapsed).notToInclude("INSERT ALL"); }); - it("emits one INTO clause per record in the batch", () => { + it("emits one source row per record in the batch", () => { var sql = oracle.$bulkInsertSQL( tableName = """AUTHORS""", columns = ["firstName", "lastName"], @@ -78,9 +91,17 @@ component extends="wheels.WheelsTest" { } } - // Three records → three INTO clauses. - var intoCount = ArrayLen(ReMatch("(?i)INTO\s+""AUTHORS""", text)); - expect(intoCount).toBe(3); + // Three records → three `FROM dual` source rows. This is the + // assertion that would have caught #3302: the old INSERT ALL form + // had three INTO clauses but only ONE driving row, and one driving + // row is one evaluation of the identity default for all three. + var rowCount = ArrayLen(ReMatch("(?i)FROM\s+dual", text)); + expect(rowCount).toBe(3); + + // Two of them joined by UNION ALL, one statement in total. + var unionCount = ArrayLen(ReMatch("(?i)UNION\s+ALL", text)); + expect(unionCount).toBe(2); + expect(ArrayLen(ReMatch("(?i)INSERT\s+INTO", text))).toBe(1); }); it("parameterizes values via $buildBulkParam structs (no inline interpolation)", () => { @@ -135,8 +156,9 @@ component extends="wheels.WheelsTest" { } var collapsed = ReReplace(text, "[[:space:]]+", " ", "all"); - expect(collapsed).toInclude("INSERT ALL"); - expect(collapsed).toInclude("SELECT 1 FROM dual"); + expect(collapsed).toInclude("INSERT INTO ""AUTHORS"""); + expect(collapsed).toInclude("FROM dual"); + expect(collapsed).notToInclude("UNION ALL"); }); }); diff --git a/vendor/wheels/tests/specs/view/ViewFileNamingGuardSpec.cfc b/vendor/wheels/tests/specs/view/ViewFileNamingGuardSpec.cfc new file mode 100644 index 0000000000..10da06cc55 --- /dev/null +++ b/vendor/wheels/tests/specs/view/ViewFileNamingGuardSpec.cfc @@ -0,0 +1,65 @@ +/** + * Structural cross-engine guard for the view-path casing trap. + * + * `$generateIncludeTemplatePath()` (vendor/wheels/controller/rendering.cfc) + * ends with `return LCase(local.rv);` — the whole resolved template path is + * folded to lowercase before the include. On a case-INsensitive filesystem + * (macOS dev machines) a camelCase view file still resolves, and Lucee and + * BoxLang resolve it even on Linux. Adobe ColdFusion on Linux does not: the + * lookup is literal, so `_groupRow.cfm` is simply not found once the path has + * been lowercased to `_grouprow.cfm`. + * + * That is exactly how `vendor/wheels/tests/_assets/views/test/_groupRow.cfm` + * (added with contentSpec's grouped-partial case) went unnoticed: green on + * every local run, green on lucee6/lucee7/boxlang in CI, and 11 failing legs + * across adobe2023 and adobe2025 — visible only in the compat matrix, which + * is `continue-on-error: true` and does not run on PRs (#3302). + * + * Lowercase view filenames are the framework's de facto convention: at the + * time this guard was written there was not a single camelCase `.cfm` under + * `app/views`, `vendor/wheels/public/views`, or the `wheels new` templates. + * This spec makes that convention enforceable on every engine, including the + * ones where the mismatch would otherwise resolve silently. + * + * Scope note: this guards the framework's OWN view trees. It does not (and + * cannot) stop an application from shipping a camelCase view — that remains a + * real limitation of the `LCase()` normalization, and is worth documenting for + * users rather than silently changing a long-standing path rule. + */ +component extends="wheels.WheelsTest" { + + function run() { + + describe("Cross-engine guard: view template filenames are lowercase", () => { + + it("no .cfm under the framework view trees has an uppercase filename", () => { + var roots = ["/wheels/public/views", "/wheels/tests/_assets/views"]; + var offenders = []; + + for (var root in roots) { + var absolute = ExpandPath(root); + if (!DirectoryExists(absolute)) { + continue; + } + var files = DirectoryList(absolute, true, "path", "*.cfm"); + for (var filePath in files) { + var fileName = ListLast(filePath, "/\"); + if (fileName != LCase(fileName)) { + ArrayAppend(offenders, root & " -> " & fileName); + } + } + } + + expect(ArrayLen(offenders)).toBe( + 0, + "View templates must be named in lowercase — $generateIncludeTemplatePath() lowercases the " + & "resolved path, so these files are unreachable on Adobe ColdFusion running on a " + & "case-sensitive filesystem: " & ArrayToList(offenders, ", ") + ); + }); + + }); + + } + +} diff --git a/vendor/wheels/tests/specs/view/contentSpec.cfc b/vendor/wheels/tests/specs/view/contentSpec.cfc index f7cc58d382..e13512c47d 100644 --- a/vendor/wheels/tests/specs/view/contentSpec.cfc +++ b/vendor/wheels/tests/specs/view/contentSpec.cfc @@ -98,7 +98,13 @@ component extends="wheels.WheelsTest" { [{dept: "a", name: "x"}, {dept: "a", name: "y"}, {dept: "b", name: "z"}] ) savecontent variable="result" { - WriteOutput(_controller.includePartial(partial = "groupRow", query = groupQuery, group = "dept")) + // Partial name stays lowercase on purpose: $generateIncludeTemplatePath + // LCase()s the whole resolved path, so a camelCase view filename is + // unreachable on a case-sensitive filesystem. Adobe enforces that; + // Lucee and BoxLang resolve the mismatch anyway, which is why the + // original `groupRow` / `_groupRow.cfm` pair only failed on the two + // Adobe legs of the matrix. Pinned by ViewFileNamingGuardSpec. + WriteOutput(_controller.includePartial(partial = "grouprow", query = groupQuery, group = "dept")) } expect(REReplace(result, "\s", "", "all")).toBe("a:2;b:1;") diff --git a/vendor/wheels/wheelstest/system/Assertion.cfc b/vendor/wheels/wheelstest/system/Assertion.cfc index 0b821ac5b9..bf7c8ffd7f 100755 --- a/vendor/wheels/wheelstest/system/Assertion.cfc +++ b/vendor/wheels/wheelstest/system/Assertion.cfc @@ -1286,6 +1286,17 @@ component { return true; } + // Two simple values that did not match above are unequal, and saying so here + // is what keeps them away from the `.equals()` fallback at the bottom of this + // function. That fallback is meant for objects; on BoxLang a simple value + // resolves `.equals()` to the DateTime member function and throws + // "Can't cast [2] to a DateTime", so an ordinary numeric mismatch was + // reported as an ERROR carrying a cast message instead of a FAILURE reading + // "Expected [2] but received [0]" (#3302). + if ( isSimpleValue( arguments.actual ) && isSimpleValue( arguments.expected ) ) { + return false; + } + // Queries if ( isQuery( arguments.actual ) && isQuery( arguments.expected ) ) { // Check number of records