Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<package>.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)
Expand Down
1 change: 1 addition & 0 deletions changelog.d/3302-adobe-typed-default-param.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Migrator column helpers no longer lose their declared `default` values on Adobe ColdFusion. A parameter declared as `<type> 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)
1 change: 1 addition & 0 deletions changelog.d/3302-boxlang-evaluate-expression.fixed.md
Original file line number Diff line number Diff line change
@@ -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)
1 change: 1 addition & 0 deletions changelog.d/3302-channel-cleanup-driver-row-bound.fixed.md
Original file line number Diff line number Diff line change
@@ -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)
Original file line number Diff line number Diff line change
@@ -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)
1 change: 1 addition & 0 deletions changelog.d/3302-insert-column-list-parity.fixed.md
Original file line number Diff line number Diff line change
@@ -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)
1 change: 1 addition & 0 deletions changelog.d/3302-localdisk-binary-write.fixed.md
Original file line number Diff line number Diff line change
@@ -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)
Original file line number Diff line number Diff line change
@@ -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)
1 change: 1 addition & 0 deletions changelog.d/3302-transaction-marker-reset.fixed.md
Original file line number Diff line number Diff line change
@@ -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)
18 changes: 18 additions & 0 deletions vendor/wheels/Public.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
13 changes: 12 additions & 1 deletion vendor/wheels/Test.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Expand Down
35 changes: 28 additions & 7 deletions vendor/wheels/channel/DatabaseAdapter.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion vendor/wheels/databaseAdapters/Abstract.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
26 changes: 17 additions & 9 deletions vendor/wheels/databaseAdapters/Base.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion vendor/wheels/databaseAdapters/MySQL/MySQLMigrator.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
49 changes: 35 additions & 14 deletions vendor/wheels/databaseAdapters/Oracle/OracleModel.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand All @@ -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, ", ");
Expand All @@ -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;
}

Expand Down
Loading
Loading