Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ All historical references to "CFWheels" in this changelog have been preserved fo

### Fixed

- `Model.insertAll()` and `Migrator.renameSystemTables()` now work on Oracle. (1) Bulk insert: the default `INSERT INTO ... VALUES (...), (...), ...` form failed on Oracle with `returning clause is not allowed with INSERT and Table Value Constructor` (multi-row) and `no statement parsed` (single-row) because `cfquery result="..."` makes Oracle's JDBC driver internally rewrite INSERTs with a RETURNING clause to surface generated keys — and that rewrite rejects multi-row VALUES. SQL building has moved into the adapter layer behind a new `$bulkInsertSQL()` hook (mirroring `$upsertSQL()`); the Base implementation emits the original single multi-row INSERT, and `OracleModel.$bulkInsertSQL()` returns one single-row INSERT per record so the JDBC driver's auto-RETURNING transform always sees a plain `INSERT INTO ... VALUES (...)` it can handle. `bulk.cfc::insertAll()` now loops over the returned SQL array, so non-Oracle adapters keep the single-statement fast path unchanged. (2) Migrator rename: `renameSystemTables()` wrapped DDL in `transaction action="begin" { ... commit }`, but Oracle implicitly commits DDL and closes the JDBC statement — so the subsequent `transaction action="commit"` raised `ORA: Closed statement`. The transaction wrapper is now skipped on Oracle (the existing code comment already acknowledged it was a no-op there); other engines (Postgres, SQLite via SAVEPOINT, MySQL on InnoDB) keep their atomic-rollback behavior. BoxLang × Oracle was the only engine currently exercising these paths because Lucee 6/7 still skip Oracle on the soft-fail track from #2663 (#2745)
- `CockroachDBModel` now overrides `$supportsAdvisoryLocks()` to return `false`, so the four `lockingSpec` `withAdvisoryLock` tests skip cleanly on CockroachDB instead of erroring with `CockroachDB does not support advisory locks.`. The PR that introduced the capability flag (#2670) claimed CockroachDB in its CHANGELOG entry but never added the override — CockroachDB inherits from `PostgreSQLModel`, which reports `true`, so the spec's `beforeEach` skip-guard never fired and the four specs proceeded to call `$acquireAdvisoryLock`, which the adapter throws from by design. Compat-matrix legs `lucee6/cockroachdb`, `lucee7/cockroachdb`, and `boxlang/cockroachdb` now report 4 skips where they previously reported 4 errors. No spec changes needed — the capability-flag layer added in #2670 already does the right thing once the flag is correct (#2743)
- `wheels.middleware.Cors` now short-circuits unmatched `OPTIONS` preflight requests at the dispatch layer, preserving the legacy `set(allowCorsRequests=true)` contract under the new middleware pipeline. Previously, `$findMatchingRoute()` ran before middleware, so a preflight against a path that only declared `POST` (or any non-`OPTIONS` verb) 404'd with `Wheels.RouteNotFound` before the CORS middleware's preflight branch could fire — leaving the middleware strictly less capable than the 3.x global setting it was meant to replace and breaking cross-origin `POST`/`PUT`/`PATCH`/`DELETE` from configured browsers. `Dispatch.$request()` now checks for an `OPTIONS` verb plus a `wheels.middleware.Cors` instance in the global pipeline and, if both are present, runs the pipeline against a no-op core handler before route matching. Dispatch behavior for `OPTIONS` without CORS middleware (still 404s) and for non-`OPTIONS` verbs (still routed normally) is unchanged (#2703)
- `paginationNav()` `showFirst` / `showLast` / `showPrevious` / `showNext` args now accept the tri-state strings `"auto"` / `"always"` / `"never"` (with backwards-compatible boolean coercion: `true` → `"always"`, `false` → `"never"`) and default to `"auto"`. Under `"auto"` the first/last anchors only render when the visible page-number window does not already reach the boundary — restoring the legacy 3.x `paginationLinks(alwaysShowAnchors=false)` semantics that a like-for-like swap to `paginationNav()` previously lost. Under `"auto"` the previous/next anchors always delegate to `previousPageLink()` / `nextPageLink()`, which render a disabled `<span class="disabled">` at the boundary by default — preserving the legacy `showPrevious=true` / `showNext=true` boundary indicator unless callers opt out with `"never"`. Adds a `windowSize` arg on `paginationNav()` so the auto-mode predicates stay coherent with `pageNumberLinks()`'s window (now passed explicitly to `pageNumberLinks()` instead of leaking through the anchor sub-helpers). Invalid strings throw `Wheels.InvalidArgument` at the call site
Expand Down
25 changes: 17 additions & 8 deletions vendor/wheels/Migrator.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -714,16 +714,25 @@ component output="false" extends="wheels.Global"{
// transaction is a no-op on Oracle (auto-commits) and MSSQL has
// adapter-specific behavior, but on the engines that DO honor it
// (Postgres, SQLite via SAVEPOINT, MySQL on InnoDB) we get atomicity.
// On Oracle the auto-commit closes the JDBC statement, so a subsequent
// `transaction action="commit"` reports "Closed statement". Run the
// DDL bare on Oracle — there is no rollback to forfeit.
try {
transaction action="begin" {
try {
for (var sql in rv.sql) {
$query(datasource = dsn, sql = sql);
if (FindNoCase("Oracle", dbType)) {
for (var sql in rv.sql) {
$query(datasource = dsn, sql = sql);
}
} else {
transaction action="begin" {
try {
for (var sql in rv.sql) {
$query(datasource = dsn, sql = sql);
}
transaction action="commit";
} catch (any e) {
transaction action="rollback";
rethrow;
}
transaction action="commit";
} catch (any e) {
transaction action="rollback";
rethrow;
}
}

Expand Down
62 changes: 61 additions & 1 deletion vendor/wheels/databaseAdapters/Base.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -766,9 +766,69 @@ component output=false extends="wheels.Global"{
);
}

/**
* Generates bulk INSERT SQL for a batch of records as an array of SQL arrays,
* each compatible with `$querySetup()`. The default emits a single multi-row
* `INSERT INTO ... VALUES (...), (...), ...` statement. Adapters whose drivers
* cannot run multi-row VALUES against generated-keys retrieval (Oracle) should
* override to return one entry per row.
*
* @tableName The quoted table name.
* @columns Array of column names to insert.
* @validProperties Array of model property names corresponding to `columns`.
* @records Array of record structs.
* @batchStart Starting index in the records array.
* @batchEnd Ending index in the records array.
* @propertyInfo Struct of model property metadata.
*/
public array function $bulkInsertSQL(
required string tableName,
required array columns,
required array validProperties,
required array records,
required numeric batchStart,
required numeric batchEnd,
required struct propertyInfo
) {
local.sql = [];

local.colList = "";
for (local.col in arguments.columns) {
if (Len(local.colList)) {
local.colList &= ", ";
}
local.colList &= $quoteIdentifier(local.col);
}

ArrayAppend(local.sql, "INSERT INTO #arguments.tableName# (#local.colList#) VALUES ");

local.propCount = ArrayLen(arguments.validProperties);
for (local.r = arguments.batchStart; local.r <= arguments.batchEnd; local.r++) {
if (local.r > arguments.batchStart) {
ArrayAppend(local.sql, ", ");
}
ArrayAppend(local.sql, "(");
for (local.p = 1; local.p <= local.propCount; local.p++) {
if (local.p > 1) {
ArrayAppend(local.sql, ", ");
}
local.propName = arguments.validProperties[local.p];
local.val = StructKeyExists(arguments.records[local.r], local.propName) ? arguments.records[local.r][local.propName] : "";
ArrayAppend(local.sql, $buildBulkParam(
value = local.val,
propName = local.propName,
propertyInfo = arguments.propertyInfo
));
}
ArrayAppend(local.sql, ")");
}

return [local.sql];
}

/**
* Builds parameter struct for a single value in a bulk operation.
* Used by adapter upsert implementations.
* Used by adapter bulk insert and upsert implementations.
*/
public struct function $buildBulkParam(
required string value,
Expand Down
45 changes: 45 additions & 0 deletions vendor/wheels/databaseAdapters/Oracle/OracleModel.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,51 @@ component extends="wheels.databaseAdapters.Base" output=false {
return """#UCase(arguments.name)#""";
}

/**
* Oracle does not support multi-row `INSERT INTO ... VALUES (...), (...)` together with
* the JDBC driver's auto-RETURNING (which `cfquery result="..."` triggers for generated
* keys). Emit one single-row INSERT per record so each statement is a plain
* `INSERT INTO ... VALUES (...)` that Oracle's JDBC driver handles cleanly.
*/
public array function $bulkInsertSQL(
required string tableName,
required array columns,
required array validProperties,
required array records,
required numeric batchStart,
required numeric batchEnd,
required struct propertyInfo
) {
local.batches = [];

local.colList = "";
for (local.col in arguments.columns) {
if (Len(local.colList)) local.colList &= ", ";
local.colList &= $quoteIdentifier(local.col);
}
local.prefix = "INSERT INTO #arguments.tableName# (#local.colList#) VALUES (";

local.propCount = ArrayLen(arguments.validProperties);
for (local.r = arguments.batchStart; local.r <= arguments.batchEnd; local.r++) {
local.sql = [];
ArrayAppend(local.sql, local.prefix);
for (local.p = 1; local.p <= local.propCount; local.p++) {
if (local.p > 1) ArrayAppend(local.sql, ", ");
local.propName = arguments.validProperties[local.p];
local.val = StructKeyExists(arguments.records[local.r], local.propName) ? arguments.records[local.r][local.propName] : "";
ArrayAppend(local.sql, $buildBulkParam(
value = local.val,
propName = local.propName,
propertyInfo = arguments.propertyInfo
));
}
ArrayAppend(local.sql, ")");
ArrayAppend(local.batches, local.sql);
}

return local.batches;
}

/**
* Oracle upsert using MERGE with USING (SELECT ... FROM dual UNION ALL ...) source.
* Uses parameterized values via $buildBulkParam — never interpolates user data into SQL.
Expand Down
67 changes: 14 additions & 53 deletions vendor/wheels/model/bulk.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -42,18 +42,26 @@ component {
for (local.batchStart = 1; local.batchStart <= local.totalRecords; local.batchStart += local.batchSize) {
local.batchEnd = Min(local.batchStart + local.batchSize - 1, local.totalRecords);

local.sql = $buildBulkInsertSQL(
// Adapter returns an array of SQL arrays — one entry per query to
// execute. Most adapters emit a single multi-row INSERT; Oracle
// returns one entry per record because it does not support
// multi-row VALUES with the JDBC driver's auto-RETURNING.
local.sqlBatches = variables.wheels.class.adapter.$bulkInsertSQL(
tableName = $quotedTableName(),
columns = local.mapped.columns,
validProperties = local.mapped.validProperties,
records = arguments.records,
batchStart = local.batchStart,
batchEnd = local.batchEnd
batchEnd = local.batchEnd,
propertyInfo = variables.wheels.class.properties
);

variables.wheels.class.adapter.$querySetup(
parameterize = arguments.parameterize,
sql = local.sql
);
for (local.bSql in local.sqlBatches) {
variables.wheels.class.adapter.$querySetup(
parameterize = arguments.parameterize,
sql = local.bSql
);
}

local.totalInserted += (local.batchEnd - local.batchStart + 1);
}
Expand Down Expand Up @@ -196,53 +204,6 @@ component {
return {columns: local.columns, validProperties: local.validProperties};
}

/**
* Builds the SQL array for a multi-row INSERT statement.
* Returns an array compatible with the adapter's `$querySetup()`.
*/
public array function $buildBulkInsertSQL(
required array columns,
required array validProperties,
required array records,
required numeric batchStart,
required numeric batchEnd
) {
local.sql = [];

local.colList = "";
for (local.col in arguments.columns) {
if (Len(local.colList)) {
local.colList &= ", ";
}
local.colList &= $quoteColumn(local.col);
}

ArrayAppend(local.sql, "INSERT INTO #$quotedTableName()# (#local.colList#) VALUES ");

local.propCount = ArrayLen(arguments.validProperties);
for (local.r = arguments.batchStart; local.r <= arguments.batchEnd; local.r++) {
if (local.r > arguments.batchStart) {
ArrayAppend(local.sql, ", ");
}
ArrayAppend(local.sql, "(");
for (local.p = 1; local.p <= local.propCount; local.p++) {
if (local.p > 1) {
ArrayAppend(local.sql, ", ");
}
local.propName = arguments.validProperties[local.p];
local.val = StructKeyExists(arguments.records[local.r], local.propName) ? arguments.records[local.r][local.propName] : "";
ArrayAppend(local.sql, variables.wheels.class.adapter.$buildBulkParam(
value = local.val,
propName = local.propName,
propertyInfo = variables.wheels.class.properties
));
}
ArrayAppend(local.sql, ")");
}

return local.sql;
}

/**
* Adds `createdAt` and `updatedAt` timestamps to bulk record arrays when the model
* is configured for automatic timestamping.
Expand Down
Loading