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
2 changes: 2 additions & 0 deletions changelog.d/database-adapters-hardener-s2-s16.changed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
- Database adapters no longer coerce the bound string `"null"` after `IS` / `IS NOT` to SQL NULL, drop MySQL TEXT/float `DEFAULT`, leave boolean `yes`/`no` unquoted, advertise fake SQLite advisory locks, default unknown foreign-key actions to `CASCADE`, emit asymmetric empty-string defaults, map unknown column types silently, or fall back to Oracle `MAX(ROWID)` / SQL Server `@@IDENTITY`
- The unquoted SQL keyword `NULL` and a CFML/Java null still bind as SQL NULL (so an absent uniqueness-scope property does not send the string `[NULL]` to an integer `cfqueryparam`)
3 changes: 3 additions & 0 deletions changelog.d/database-adapters-hardener-s9-s17-s18.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
- `addColumnOptions` quotes `AFTER` through `quoteColumnName` so a hostile column name cannot be interpolated raw
- Microsoft SQL Server `quoteTableName` quotes `schema.table` as `[schema].[table]` instead of mixing MySQL backticks inside brackets
- Oracle `createTable` scopes `col` and `fk` so the composite-key and foreign-key loops resolve on Adobe CF
70 changes: 46 additions & 24 deletions vendor/wheels/databaseAdapters/Abstract.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ component extends="wheels.migrator.Base"{
public string function addColumnOptions(required string sql, struct options = "#StructNew()#") {
if (StructKeyExists(arguments.options, 'type') && arguments.options.type != 'primaryKey') {
if (StructKeyExists(arguments.options, 'default') && optionsIncludeDefault(argumentCollection = arguments.options)) {
$rejectEmptyStringDefault(arguments.options);
if (
arguments.options.default eq "NULL"
|| (
Expand All @@ -82,18 +83,6 @@ component extends="wheels.migrator.Base"{
arguments.sql = arguments.sql & " DEFAULT NULL";
} else if (arguments.options.type == 'boolean') {
arguments.sql = arguments.sql & " DEFAULT #IIf(arguments.options.default, 1, 0)#";
} else if (
arguments.options.default eq ""
&& ListFindNoCase("string,text,char", arguments.options.type)
) {
// Symmetric handling for all string-like types: an empty
// `default=""` means "no default clause" (not `DEFAULT ''`).
// Without this, `t.string("a", default="")` and
// `t.text("b", default="")` produced asymmetric DDL and
// the presence-check skip in validatesPresenceOf fired
// inconsistently between equivalent column types. See
// fresh-VM journal F17.
arguments.sql = arguments.sql;
} else {
arguments.sql = arguments.sql & " DEFAULT #quote(value = arguments.options.default, options = arguments.options)#";
}
Expand All @@ -107,7 +96,7 @@ component extends="wheels.migrator.Base"{
}
}
if (StructKeyExists(arguments.options, "afterColumn") And Len(Trim(arguments.options.afterColumn)) GT 0) {
arguments.sql = arguments.sql & " AFTER #arguments.options.afterColumn#";
arguments.sql = arguments.sql & " AFTER " & quoteColumnName(arguments.options.afterColumn);
}
return arguments.sql;
}
Expand All @@ -117,6 +106,27 @@ component extends="wheels.migrator.Base"{
return true;
}

/**
* Fail-loud contract for `default=""` on string-like columns. Abstract
* used to omit the DEFAULT clause; PostgreSQL used to emit `DEFAULT ''`.
* Both now throw `Wheels.InvalidDefault` so the adapters cannot silently
* diverge.
*/
public void function $rejectEmptyStringDefault(required struct options) {
if (
StructKeyExists(arguments.options, "default")
&& arguments.options.default eq ""
&& StructKeyExists(arguments.options, "type")
&& ListFindNoCase("string,text,char", arguments.options.type)
) {
Throw(
type = "Wheels.InvalidDefault",
message = "An empty string default is not allowed for #arguments.options.type# columns.",
extendedInfo = "Omit the default, pass a non-empty value, or use default='NULL'. Abstract used to omit the DEFAULT clause and PostgreSQL used to emit DEFAULT ''."
);
}
}

/**
* quote value if required
*/
Expand Down Expand Up @@ -292,22 +302,34 @@ component extends="wheels.migrator.Base"{
local.sql = "CONSTRAINT #quoteTableName(arguments.name)# FOREIGN KEY (#quoteColumnName(arguments.column)#) REFERENCES #quoteTableName(arguments.referenceTable)#(#quoteColumnName(arguments.referenceColumn)#)";
for (local.item in ListToArray("onUpdate,onDelete")) {
if (Len(arguments[local.item])) {
switch (arguments[local.item]) {
case "none":
local.sql = local.sql & " " & UCase(humanize(local.item)) & " NO ACTION";
break;
case "null":
local.sql = local.sql & " " & UCase(humanize(local.item)) & " SET NULL";
break;
default:
local.sql = local.sql & " " & UCase(humanize(local.item)) & " CASCADE";
break;
}
local.sql = local.sql & $referentialActionSQL(item = local.item, action = arguments[local.item]);
}
}
return local.sql;
}

/**
* Maps a known onUpdate/onDelete value. Unknown values throw instead of
* silently becoming CASCADE.
*/
public string function $referentialActionSQL(required string item, required string action) {
switch (arguments.action) {
case "none":
return " " & UCase(humanize(arguments.item)) & " NO ACTION";
case "null":
return " " & UCase(humanize(arguments.item)) & " SET NULL";
case "cascade":
case "true":
return " " & UCase(humanize(arguments.item)) & " CASCADE";
default:
Throw(
type = "Wheels.InvalidReferentialAction",
message = "The referential action `#arguments.action#` is not supported.",
extendedInfo = "Use none, null, cascade, or true. Unknown onUpdate/onDelete values used to silently become CASCADE."
);
}
}

/**
* generates sql to add database index on a table column
*/
Expand Down
67 changes: 49 additions & 18 deletions vendor/wheels/databaseAdapters/Base.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -20,25 +20,27 @@ component output=false extends="wheels.Global"{
// Build query
cfquery(attributeCollection = args.queryAttributes) {
local.pos = 1;
local.prev = "";

for (; pos <= sqlLen; pos++) {
local.part = sqlArray[pos];

if (isStruct(part)) {
local.qp = $queryParams(part);

// Handle NULL for "IS NULL" or "IS NOT NULL"
if (
!isBinary(part.value) &&
part.value == "null" &&
pos > 1 &&
( right(prev, 2) == "IS" || right(prev, 6) == "IS NOT" )
) {
writeOutput("NULL");
}
// Handle parameter lists "(?,?,?)"
else if (structKeyExists(qp, "list")) {
// The literal string "null" after IS / IS NOT stays a bound
// parameter. Do not coerce that string to SQL NULL. A missing
// value (cfqueryparam null=true, or a CFML/Java null) is a
// different thing and must still bind as SQL NULL.
if (structKeyExists(qp, "null") && qp.null) {
if (args.parameterize) {
if (!structKeyExists(qp, "value") || IsNull(qp.value) || !Len(ToString(qp.value))) {
qp.value = "";
}
cfqueryParam(attributeCollection = qp);
} else {
writeOutput("NULL");
}
} else if (structKeyExists(qp, "list")) {
writeOutput("(");
if (args.parameterize) {
cfqueryParam(attributeCollection = qp);
Expand Down Expand Up @@ -66,7 +68,6 @@ component output=false extends="wheels.Global"{
}

writeOutput(newLine);
prev = part;
}

// LIMIT / OFFSET logic
Expand Down Expand Up @@ -536,7 +537,9 @@ component output=false extends="wheels.Global"{
* Internal function.
*/
public struct function $queryParams(required struct settings) {
if (!StructKeyExists(arguments.settings, "value")) {
local.hasValue = StructKeyExists(arguments.settings, "value");
local.valueIsNull = local.hasValue && IsNull(arguments.settings.value);
if (!local.hasValue && !(StructKeyExists(arguments.settings, "null") && arguments.settings.null)) {
Throw(
type = "Wheels.QueryParamValue",
message = "The value for `cfqueryparam` cannot be determined for property `#arguments.settings.property#`.<br>This usually happens due to a syntax error in the WHERE clause (e.g., using unquoted strings or invalid values).",
Expand All @@ -545,9 +548,17 @@ component output=false extends="wheels.Global"{
}
local.rv = {};
local.rv.cfsqltype = arguments.settings.type;
local.rv.value = arguments.settings.value;
if (StructKeyExists(arguments.settings, "null")) {
local.rv.null = arguments.settings.null;
if (local.valueIsNull || (StructKeyExists(arguments.settings, "null") && arguments.settings.null)) {
// CFML/Java null and an explicit SQL-NULL flag bind as SQL NULL.
// Do not pass the strings "null" / "[NULL]" as the typed value —
// integer cfqueryparam cannot cast them.
local.rv.null = true;
local.rv.value = "";
} else {
local.rv.value = arguments.settings.value;
if (StructKeyExists(arguments.settings, "null")) {
local.rv.null = arguments.settings.null;
}
}
if (StructKeyExists(arguments.settings, "scale") && arguments.settings.scale > 0) {
local.rv.scale = arguments.settings.scale;
Expand Down Expand Up @@ -593,7 +604,11 @@ component output=false extends="wheels.Global"{
if (!StructKeyExists(arguments, "type")) {
arguments.type = $getValidationType(arguments.sqlType);
}
if (!ListFindNoCase("integer,float,boolean", arguments.type) || !Len(arguments.str)) {
if (
!ListFindNoCase("integer,float,boolean", arguments.type)
|| !Len(arguments.str)
|| (arguments.type == "boolean" && ListFindNoCase("yes,no", arguments.str))
) {
local.rv = "'#Replace(arguments.str, "'", "''", "all")#'";
} else {
$validateValueShape(arguments.str, arguments.type);
Expand Down Expand Up @@ -632,6 +647,22 @@ component output=false extends="wheels.Global"{
}
}

public void function $throwUnknownColumnType(required string typeName) {
Throw(
type = "Wheels.UnknownColumnType",
message = "The column type `#arguments.typeName#` is not mapped to a CFML SQL type.",
extendedInfo = "Add a case for `#arguments.typeName#` to `$getType()` on this database adapter."
);
}

public void function $throwIdentityNotFound() {
Throw(
type = "Wheels.IdentityNotFound",
message = "Could not retrieve the generated identity for this INSERT.",
extendedInfo = "The driver-supplied key and the sequence / SCOPE_IDENTITY path both missed. Last-resort MAX(ROWID) and @@IDENTITY have been removed."
);
}

public void function $throwInvalidValue(required string str, required string expectedType) {
Throw(
type = "Wheels.InvalidValue",
Expand Down
3 changes: 3 additions & 0 deletions vendor/wheels/databaseAdapters/H2/H2Model.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,9 @@ component extends="wheels.databaseAdapters.Base" output=false {
case "json":
local.rv = "cf_sql_longvarchar";
break;
default:
$throwUnknownColumnType(arguments.type);
break;
}
return local.rv;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ component extends="wheels.databaseAdapters.Abstract" {
* Surrounds table names with square brackets
*/
public string function quoteTableName(required string name) {
return "[#Replace(objectCase(arguments.name), ".", "`.`", "ALL")#]";
return "[#Replace(objectCase(arguments.name), ".", "].[", "ALL")#]";
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,9 @@ component extends="wheels.databaseAdapters.Base" output=false {
case "cursor":
local.rv = "cf_sql_refcursor";
break;
default:
$throwUnknownColumnType(arguments.type);
break;
}
return local.rv;
}
Expand Down Expand Up @@ -312,19 +315,7 @@ component extends="wheels.databaseAdapters.Base" output=false {
return arguments.returningIdentity.lastId[1];
}

// Absolute last resort — only reached when the multi-statement batch did not
// surface a usable resultset on this engine/driver combo. @@IDENTITY is
// session-scoped and can return a trigger-generated identity from another
// table, but keeping it means a same-batch miss degrades to the pre-fix
// behavior instead of losing the key entirely.
local.query = $query(sql = "SELECT @@IDENTITY AS lastId", argumentCollection = arguments.queryAttributes);

// Fallback to SCOPE_IDENTITY() if @@IDENTITY returned nothing (other CFML engines).
if (!Len(local.query.lastId)) {
local.query = $query(sql = "SELECT SCOPE_IDENTITY() AS lastId", argumentCollection = arguments.queryAttributes);
}

return local.query.lastId;
$throwIdentityNotFound();
}

/**
Expand Down
18 changes: 4 additions & 14 deletions vendor/wheels/databaseAdapters/MySQL/MySQLMigrator.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -68,22 +68,12 @@ component extends="wheels.databaseAdapters.Abstract" {

/**
* 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.
* Always true — TEXT/float keep their DEFAULT the same way Abstract does.
* Pre-8.0.13 MySQL rejected DEFAULT on TEXT/BLOB; current Wheels targets
* servers that accept it rather than silently dropping the clause.
*/
public boolean function optionsIncludeDefault(string type, default = "", boolean allowNull = true) {
if (ListFindNoCase("text,mediumtext,longtext,float", arguments.type)) {
return false;
} else {
return true;
}
return true;
}

/**
Expand Down
3 changes: 3 additions & 0 deletions vendor/wheels/databaseAdapters/MySQL/MySQLModel.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,9 @@ component extends="wheels.databaseAdapters.Base" output=false {
case "longtext":
local.rv = "cf_sql_longvarchar";
break;
default:
$throwUnknownColumnType(arguments.type);
break;
}
return local.rv;
}
Expand Down
6 changes: 3 additions & 3 deletions vendor/wheels/databaseAdapters/Oracle/OracleMigrator.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -52,13 +52,13 @@ component extends="wheels.databaseAdapters.Abstract" {
} else {
// Add all primary key columns normally
for (local.col in arguments.primaryKeys) {
arrayAppend(local.lines, col.toSQL());
arrayAppend(local.lines, local.col.toSQL());
}
}

// 2. Add normal columns
for (local.col in arguments.columns) {
arrayAppend(local.lines, col.toSQL());
arrayAppend(local.lines, local.col.toSQL());
}

// 3. Add composite primary key constraint if needed
Expand All @@ -68,7 +68,7 @@ component extends="wheels.databaseAdapters.Abstract" {

// 4. Add foreign keys
for (local.fk in arguments.foreignKeys) {
arrayAppend(local.lines, fk.toForeignKeySQL());
arrayAppend(local.lines, local.fk.toForeignKeySQL());
}

// 5. Join all lines and wrap in CREATE TABLE
Expand Down
Loading