Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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

- `addColumnOptionsSpec` now branches on `adapter.adapterName() == "MySQL"` for the `text` + non-empty default assertion, matching the existing `isPostgresFamily` carve-out. MySQL's `MySQLMigrator.optionsIncludeDefault` returns false for `text` / `mediumtext` / `longtext` / `float`, so the Abstract `addColumnOptions` short-circuits the entire DEFAULT clause for those types — emitting `NULL` rather than `DEFAULT '<value>'`. The spec previously asserted `toInclude("DEFAULT")` unconditionally and failed on every MySQL leg of the compat matrix (lucee6/mysql, lucee7/mysql, boxlang/mysql). The MySQL adapter's `optionsIncludeDefault` doc-comment now also explains the legacy pre-8.0.13 TEXT/BLOB constraint that motivates the suppression and references the spec contract. Follow-up to #2661
- `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
- `QueryBuilder.whereIn()` / `whereNotIn()` with an empty array no longer emit malformed SQL (`property IN ()`). Previously, passing an empty list or array to either method produced syntactically invalid SQL that surfaced as a generic JDBC syntax error from the database, with no pointer back to the call site that built the empty collection. `whereIn(prop, [])` now sets an `$alwaysEmpty` flag on the builder so every terminal method (`count`, `findAll`, `findOne`, `first`, `exists`, `updateAll`, `deleteAll`, `findEach`, `findInBatches`) short-circuits to the appropriate zero-row sentinel before going through the finder. `whereNotIn(prop, [])` is a no-op (exclude-none = match-all), so the chain proceeds normally. Matches the user-facing behaviour every mature ORM converged on (Rails, Sequel, Django, Laravel Eloquent: empty `IN` matches no rows, empty `NOT IN` matches every row). The flag-based design avoids a runtime trap from Wheels' WHERE-clause parser (`vendor/wheels/model/sql.cfc` runs a property-extraction regex over every clause it sees — a raw `1 = 0` literal would be parsed as property `1` and trip `Wheels.ColumnNotFound`). Fourteen new specs in `vendor/wheels/tests/specs/model/queryBuilderSpec.cfc` cover empty-array, empty-list, composition with other clauses, the `whereNotIn` mirrors, every patched terminal (`findAll`, `first` / `findOne`, `exists`, `count`, `updateAll`, `deleteAll`, `findEach`, `findInBatches`), and the documented `select()` / `include()` silent-ignore caveat on the short-circuit path. Both copies of the query-builder guide were updated to document the short-circuit in the methods table (#2736)
Expand Down
11 changes: 10 additions & 1 deletion vendor/wheels/databaseAdapters/MySQL/MySQLMigrator.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,16 @@ component extends="wheels.databaseAdapters.Abstract" {
}

/**
* MySQL text fields can't have default
* Whether `addColumnOptions` should emit a DEFAULT clause for the column.
* Returns false for TEXT-family and FLOAT — the inherited Abstract
* `addColumnOptions` short-circuits the entire DEFAULT clause when this
* returns false, so a non-empty `default="long body"` is silently
* suppressed on MySQL. Rationale: pre-8.0.13 MySQL rejects DEFAULT on
* TEXT/BLOB columns outright, and the framework targets the broadest
* supported MySQL surface rather than emitting DDL that fails on older
* servers. The cross-engine contract this implies is asserted in
* `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) {
if (ListFindNoCase("text,mediumtext,longtext,float", arguments.type)) {
Expand Down
27 changes: 25 additions & 2 deletions vendor/wheels/tests/specs/migrator/addColumnOptionsSpec.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,14 @@
* surface differences are part of those adapters' contract — this spec
* documents them rather than asserting them away. See #2661 for the cross-
* adapter triage that motivated this adapter-aware shape.
*
* MySQL is also a documented divergence: `MySQLMigrator.optionsIncludeDefault`
* returns false for `text` / `mediumtext` / `longtext` / `float`, so the
* Abstract `addColumnOptions` short-circuits the entire DEFAULT clause for
* those types on MySQL — a real, non-empty `default="long body"` is silently
* suppressed in the emitted DDL. The legacy MySQL constraint that motivated
* this (pre-8.0.13 TEXT/BLOB columns reject DEFAULT) is documented on the
* adapter; this spec asserts the resulting cross-engine contract. See #2742.
*/
component extends="wheels.WheelsTest" {

Expand All @@ -37,6 +45,11 @@ component extends="wheels.WheelsTest" {
// for cross-adapter branching.
var name = variables.adapter.adapterName();
variables.isPostgresFamily = (name == "PostgreSQL" || name == "CockroachDB");
// MySQL suppresses the entire DEFAULT clause for TEXT-family and FLOAT
// columns via optionsIncludeDefault, so any text-with-real-default
// assertion must carve out MySQL the same way isPostgresFamily does for
// the empty-default cases.
variables.isMySQLFamily = (name == "MySQL");
}

private string function buildOptions(string type, string default = "", boolean allowNull = true) {
Expand Down Expand Up @@ -88,8 +101,18 @@ component extends="wheels.WheelsTest" {

it("text with a real default (non-empty) still emits DEFAULT", () => {
var sql = buildOptions(type = "text", default = "long body");
expect(sql).toInclude("DEFAULT");
expect(sql).toInclude("'long body'");
if (variables.isMySQLFamily) {
// MySQL's optionsIncludeDefault returns false for TEXT, so the
// Abstract addColumnOptions short-circuits the DEFAULT clause
// entirely. The user's `default="long body"` is silently
// suppressed in the emitted DDL — surprising but intentional,
// rooted in the pre-8.0.13 MySQL constraint that TEXT/BLOB
// columns cannot carry a DEFAULT.
expect(sql).notToInclude("DEFAULT");
} else {
expect(sql).toInclude("DEFAULT");
expect(sql).toInclude("'long body'");
}
});

it("integer with default='' becomes DEFAULT NULL across adapters", () => {
Expand Down
Loading