Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
20 changes: 20 additions & 0 deletions .ai/wheels/cross-engine-compatibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,26 @@ if (isPostgresFamily) {

See `vendor/wheels/tests/specs/migrator/addColumnOptionsSpec.cfc` and `migrationSpec.cfc` for working examples of this branching idiom.

### MySQL — TEXT and FLOAT DEFAULT suppression

`MySQLMigrator.optionsIncludeDefault` returns `false` for `text`, `mediumtext`, `longtext`, and `float` columns. The inherited `Abstract.addColumnOptions` short-circuits the entire `DEFAULT` clause when `optionsIncludeDefault` returns false — meaning a non-empty `default="long body"` on a `text` column is silently dropped in the emitted DDL on MySQL. Rationale: pre-8.0.13 MySQL rejects `DEFAULT` on `TEXT`/`BLOB` columns outright.

When writing migrator spec assertions that involve TEXT-family columns with non-empty defaults, add an `isMySQLFamily` carve-out alongside the `isPostgresFamily` one:

```cfm
var name = adapter.adapterName();
var isPostgresFamily = (name == "PostgreSQL" || name == "CockroachDB");
var isMySQLFamily = (name == "MySQL");

if (isMySQLFamily) {
// DEFAULT clause is suppressed entirely for text/float on MySQL
expect(sql).notToInclude("DEFAULT");
} else {
expect(sql).toInclude("DEFAULT");
expect(sql).toInclude("'long body'");
}
```

### CockroachDB (Soft-Fail in CI)

CockroachDB is in CI but marked as soft-fail — test failures are logged as warnings, not build failures. Controlled by `SOFT_FAIL_DBS` in `.github/workflows/tests.yml`.
Expand Down
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/#2669
- `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
29 changes: 26 additions & 3 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 @@ -86,10 +99,20 @@ component extends="wheels.WheelsTest" {
expect(sql).toInclude("'hello'");
});

it("text with a real default (non-empty) still emits DEFAULT", () => {
it("text with a real default (non-empty): DEFAULT clause is adapter-dependent", () => {
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