Skip to content

Commit ff3c40f

Browse files
bpamiriclaude
andcommitted
Quote SQL identifiers across all database adapters to prevent reserved word conflicts
Add adapter-specific identifier quoting ($quoteIdentifier) for all 6 database adapters: - MySQL: backticks (`name`) - SQL Server: square brackets ([name]) - PostgreSQL, Oracle, SQLite: double-quotes ("name") - H2: no-op (case-sensitive with quoted identifiers) Quote table names in FROM, JOIN, DELETE, UPDATE, INSERT clauses and column names in WHERE, SET, ORDER BY, INSERT column lists, and JOIN ON conditions. SELECT clause only quotes table name prefixes (not column names) to maintain compatibility with SQL Server's triple-subquery pagination. Add $stripIdentifierQuotes helper for comparing rendered SQL containing mixed quoting styles (used in MSSQL pagination and $identitySelect across all adapters). Fix read.cfc empty-result regex to handle quoted table prefixes in column lists. Update test expectations to be adapter-aware using qi() helper that delegates to the current adapter's quoting character. Closes #1856 Co-Authored-By: Claude Opus 4.6 <[email protected]>
1 parent 262d552 commit ff3c40f

File tree

14 files changed

+163
-56
lines changed

14 files changed

+163
-56
lines changed

core/src/wheels/databaseAdapters/Base.cfc

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,8 @@ component output=false extends="wheels.Global"{
161161
"#Chr(10)#,#Chr(13)#, ",
162162
",,"
163163
);
164+
// Strip identifier quotes from column list for comparison
165+
local.columnList = $stripIdentifierQuotes(local.columnList);
164166
if (!ListFindNoCase(local.columnList, ListFirst(arguments.primaryKey))) {
165167
local.rv = {};
166168
query = $query(sql = "SELECT LAST_INSERT_ID() AS lastId", argumentCollection = arguments.queryAttributes);
@@ -186,6 +188,23 @@ component output=false extends="wheels.Global"{
186188
return " DEFAULT VALUES";
187189
}
188190

191+
/**
192+
* Quote a database identifier (table or column name) using the adapter's quoting character.
193+
* Base implementation is a no-op; individual adapters override with their specific quoting.
194+
* This prevents reserved word conflicts across all supported databases.
195+
*/
196+
public string function $quoteIdentifier(required string name) {
197+
return arguments.name;
198+
}
199+
200+
/**
201+
* Strip all identifier quote characters from a string.
202+
* Used when parsing rendered SQL to compare column names without quoting artifacts.
203+
*/
204+
public string function $stripIdentifierQuotes(required string str) {
205+
return ReReplace(arguments.str, '`|\[|\]|"', "", "all");
206+
}
207+
189208
/**
190209
* Set a default for the table alias string (e.g. "users AS users2").
191210
* Individual database adapters will override when necessary.

core/src/wheels/databaseAdapters/MicrosoftSQLServer/MicrosoftSQLServerModel.cfc

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,10 @@ component extends="wheels.databaseAdapters.Base" output=false {
140140
local.iEnd = ListLen(local.thirdOrder);
141141
for (local.i = 1; local.i <= local.iEnd; local.i++) {
142142
local.item = ReReplace(ReReplace(ListGetAt(local.thirdOrder, local.i), " ASC\b", ""), " DESC\b", "");
143-
if (!ListFindNoCase(local.thirdSelect, local.item)) {
143+
// Strip identifier quotes for comparison since SELECT may have different quoting than ORDER BY
144+
local.itemStripped = $stripIdentifierQuotes(local.item);
145+
local.thirdSelectStripped = $stripIdentifierQuotes(local.thirdSelect);
146+
if (!ListFindNoCase(local.thirdSelectStripped, local.itemStripped) && !ListFindNoCase(local.thirdSelect, local.item)) {
144147
// The test "order_clause_with_paginated_include_and_ambiguous_columns" passes in a complex order (CASE WHEN registration IN ('foo') THEN 0 ELSE 1 END DESC).
145148
// This gets moved up to the SELECT clause to support pagination.
146149
// However, we need to add "AS" to it otherwise we get a "No column name was specified" error.
@@ -233,9 +236,11 @@ component extends="wheels.databaseAdapters.Base" output=false {
233236
"#Chr(10)#,#Chr(13)#, ",
234237
",,"
235238
);
239+
// Strip identifier quotes from column list for comparison
240+
local.columnList = $stripIdentifierQuotes(local.columnList);
236241
if (!ListFindNoCase(local.columnList, ListFirst(arguments.primaryKey))) {
237242
local.rv = {};
238-
243+
239244
// Use @@IDENTITY instead of SCOPE_IDENTITY() for BoxLang compatibility
240245
// SCOPE_IDENTITY() returns empty values in BoxLang with SQL Server
241246
query = $query(sql = "SELECT @@IDENTITY AS lastId", argumentCollection = arguments.queryAttributes);
@@ -258,5 +263,12 @@ component extends="wheels.databaseAdapters.Base" output=false {
258263
return "NEWID()";
259264
}
260265

266+
/**
267+
* Override Base adapter's function.
268+
* SQL Server uses square brackets to quote identifiers.
269+
*/
270+
public string function $quoteIdentifier(required string name) {
271+
return "[#arguments.name#]";
272+
}
261273

262274
}

core/src/wheels/databaseAdapters/MySQL/MySQLModel.cfc

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,5 +115,12 @@ component extends="wheels.databaseAdapters.Base" output=false {
115115
return "() VALUES()";
116116
}
117117

118+
/**
119+
* Override Base adapter's function.
120+
* MySQL uses backticks to quote identifiers.
121+
*/
122+
public string function $quoteIdentifier(required string name) {
123+
return "`#arguments.name#`";
124+
}
118125

119126
}

core/src/wheels/databaseAdapters/Oracle/OracleModel.cfc

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,8 @@ component extends="wheels.databaseAdapters.Base" output=false {
106106
"#Chr(10)#,#Chr(13)#, ",
107107
",,"
108108
);
109+
// Strip identifier quotes from column list for comparison
110+
local.columnList = $stripIdentifierQuotes(local.columnList);
109111
if (!ListFindNoCase(local.columnList, ListFirst(arguments.primaryKey))) {
110112
local.rv = {};
111113
local.tbl = SpanExcluding(Right(local.sql, Len(local.sql) - 12), " ");
@@ -141,5 +143,12 @@ component extends="wheels.databaseAdapters.Base" output=false {
141143
return arguments.table & " " & arguments.alias;
142144
}
143145

146+
/**
147+
* Override Base adapter's function.
148+
* Oracle uses double-quotes to quote identifiers.
149+
*/
150+
public string function $quoteIdentifier(required string name) {
151+
return """#arguments.name#""";
152+
}
144153

145154
}

core/src/wheels/databaseAdapters/PostgreSQL/PostgreSQLModel.cfc

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,10 +151,15 @@ component extends="wheels.databaseAdapters.Base" output=false {
151151
}
152152
}
153153

154+
// Strip identifier quotes from column list for comparison
155+
local.columnList = $stripIdentifierQuotes(local.columnList);
156+
154157
// Lucee/ACF doesn't support PostgreSQL natively when it comes to returning the primary key value of the last inserted record so we have to do it manually by using the sequence.
155158
if (!ListFindNoCase(local.columnList, ListFirst(arguments.primaryKey))) {
156159
local.rv = {};
157160
local.tbl = SpanExcluding(Right(local.sql, Len(local.sql) - 12), " ");
161+
// Strip identifier quotes that may have been added by $quoteIdentifier
162+
local.tbl = ReReplace(local.tbl, '^"|"$', "", "all");
158163
query = $query(
159164
sql = "SELECT currval(pg_get_serial_sequence('#local.tbl#', '#arguments.primaryKey#')) AS lastId",
160165
argumentCollection = arguments.queryAttributes
@@ -172,5 +177,12 @@ component extends="wheels.databaseAdapters.Base" output=false {
172177
return "random()";
173178
}
174179

180+
/**
181+
* Override Base adapter's function.
182+
* PostgreSQL uses double-quotes to quote identifiers (ANSI SQL standard).
183+
*/
184+
public string function $quoteIdentifier(required string name) {
185+
return """#arguments.name#""";
186+
}
175187

176188
}

core/src/wheels/databaseAdapters/SQLite/SQLiteModel.cfc

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,8 @@ component extends="wheels.databaseAdapters.Base" output=false {
105105
);
106106
}
107107

108+
// Strip identifier quotes from column list for comparison
109+
local.columnList = $stripIdentifierQuotes(local.columnList);
108110
// If the primary key column wasn't part of the INSERT, we fetch last inserted ID
109111
if (!ListFindNoCase(local.columnList, ListFirst(arguments.primaryKey))) {
110112
local.rv = {};
@@ -126,4 +128,12 @@ component extends="wheels.databaseAdapters.Base" output=false {
126128
return " DEFAULT VALUES";
127129
}
128130

131+
/**
132+
* Override Base adapter's function.
133+
* SQLite uses double-quotes to quote identifiers (ANSI SQL standard).
134+
*/
135+
public string function $quoteIdentifier(required string name) {
136+
return """#arguments.name#""";
137+
}
138+
129139
}

core/src/wheels/model/calculations.cfc

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -235,7 +235,7 @@ component {
235235
if (StructKeyExists(variables.wheels.class.propertyStruct, local.item)) {
236236
local.properties = ListAppend(
237237
local.properties,
238-
tableName() & "." & variables.wheels.class.properties[local.item].column
238+
$quotedTableName() & "." & $quoteColumn(variables.wheels.class.properties[local.item].column)
239239
);
240240
} else if (StructKeyExists(variables.wheels.class.calculatedProperties, local.item)) {
241241
local.properties = ListAppend(local.properties, variables.wheels.class.calculatedProperties[local.item].sql);

core/src/wheels/model/create.cfc

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -301,7 +301,7 @@ component {
301301
)
302302
)
303303
) {
304-
ArrayAppend(local.sql, variables.wheels.class.properties[local.key].column);
304+
ArrayAppend(local.sql, $quoteColumn(variables.wheels.class.properties[local.key].column));
305305
ArrayAppend(local.sql, ",");
306306
ArrayAppend(local.sql2, $buildQueryParamValues(local.key));
307307
ArrayAppend(local.sql2, ",");
@@ -310,7 +310,7 @@ component {
310310

311311
if (ArrayLen(local.sql)) {
312312
// Create wrapping SQL code and merge the second array that holds the values with the first one.
313-
ArrayPrepend(local.sql, "INSERT INTO #tableName()# (");
313+
ArrayPrepend(local.sql, "INSERT INTO #$quotedTableName()# (");
314314
ArrayPrepend(local.sql2, " VALUES (");
315315
ArrayDeleteAt(local.sql, ArrayLen(local.sql));
316316
ArrayDeleteAt(local.sql2, ArrayLen(local.sql2));
@@ -332,7 +332,7 @@ component {
332332
local.pks = primaryKey(0);
333333
ArrayAppend(
334334
local.sql,
335-
"INSERT INTO #tableName()#" & variables.wheels.class.adapter.$defaultValues($primaryKey = local.pks)
335+
"INSERT INTO #$quotedTableName()#" & variables.wheels.class.adapter.$defaultValues($primaryKey = local.pks)
336336
);
337337
}
338338

core/src/wheels/model/miscellaneous.cfc

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,22 @@ component {
185185
}
186186
}
187187

188+
/**
189+
* Returns the table name quoted with the adapter's identifier quoting character.
190+
* Used internally when building SQL to prevent reserved word conflicts.
191+
*/
192+
public string function $quotedTableName() {
193+
return variables.wheels.class.adapter.$quoteIdentifier(tableName());
194+
}
195+
196+
/**
197+
* Quotes a column name using the adapter's identifier quoting character.
198+
* Used internally when building SQL to prevent reserved word conflicts.
199+
*/
200+
public string function $quoteColumn(required string column) {
201+
return variables.wheels.class.adapter.$quoteIdentifier(arguments.column);
202+
}
203+
188204
/**
189205
* Returns the table name prefix set for the table.
190206
*

core/src/wheels/model/read.cfc

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -213,7 +213,7 @@ component {
213213
list = arguments.select,
214214
returnAs = arguments.returnAs
215215
);
216-
local.columns = ReReplace(local.columns, "\w*?\.([\w\s]*?)(,|$)", "\1\2", "all");
216+
local.columns = ReReplace(local.columns, "[`""\[\]\w]*?\.([\w\s]*?)(,|$)", "\1\2", "all");
217217
local.columns = ReReplace(local.columns, "\(.*?\)\sAS\s([\w\s]*?)(,|$)", "\1\2", "all");
218218
local.columns = ReReplace(local.columns, "\w*?\sAS\s([\w\s]*?)(,|$)", "\1\2", "all");
219219
local.rv = QueryNew(local.columns);

0 commit comments

Comments
 (0)