From d80695e53dcbee220500fae25e77cc30584cc6aa Mon Sep 17 00:00:00 2001 From: Peter Amiri Date: Tue, 14 Apr 2026 18:54:41 -0700 Subject: [PATCH 1/2] feat(model): add advisory locks and SELECT FOR UPDATE Adds database-level advisory locking and pessimistic row locking to close a gap vs Rails/Laravel/Django. Advisory locks coordinate exclusive access to shared resources without locking rows/tables. FOR UPDATE enables pessimistic row locking within transactions via the QueryBuilder. New files: - vendor/wheels/model/locking.cfc: withAdvisoryLock() mixin - vendor/wheels/tests/specs/model/lockingSpec.cfc: BDD tests Modified: - QueryBuilder.cfc: forUpdate() chain method - read.cfc: $forUpdate argument for findAll() - Base.cfc: stub methods with sensible defaults - All 7 database adapters: per-DB implementations Co-Authored-By: Claude Opus 4.6 (1M context) --- vendor/wheels/databaseAdapters/Base.cfc | 38 +++++++ .../CockroachDB/CockroachDBModel.cfc | 23 ++++ vendor/wheels/databaseAdapters/H2/H2Model.cfc | 22 ++++ .../MicrosoftSQLServerModel.cfc | 34 ++++++ .../databaseAdapters/MySQL/MySQLModel.cfc | 33 ++++++ .../databaseAdapters/Oracle/OracleModel.cfc | 22 ++++ .../PostgreSQL/PostgreSQLModel.cfc | 26 +++++ .../databaseAdapters/SQLite/SQLiteModel.cfc | 24 ++++ vendor/wheels/model/locking.cfc | 52 +++++++++ vendor/wheels/model/query/QueryBuilder.cfc | 22 +++- vendor/wheels/model/read.cfc | 9 +- .../wheels/tests/specs/model/lockingSpec.cfc | 104 ++++++++++++++++++ 12 files changed, 407 insertions(+), 2 deletions(-) create mode 100644 vendor/wheels/model/locking.cfc create mode 100644 vendor/wheels/tests/specs/model/lockingSpec.cfc diff --git a/vendor/wheels/databaseAdapters/Base.cfc b/vendor/wheels/databaseAdapters/Base.cfc index 7bd184b02..1c04f3eb3 100755 --- a/vendor/wheels/databaseAdapters/Base.cfc +++ b/vendor/wheels/databaseAdapters/Base.cfc @@ -485,6 +485,44 @@ component output=false extends="wheels.Global"{ return local.rv; } + /** + * Acquire a database advisory lock with the given name. + * Advisory locks are application-level locks that don't lock rows or tables. + * Individual database adapters override this with their specific implementation. + * + * @name A unique name identifying the lock. + * @timeout Maximum seconds to wait for the lock. + */ + public void function $acquireAdvisoryLock(required string name, numeric timeout = 10) { + Throw( + type = "Wheels.AdvisoryLockNotSupported", + message = "Advisory locks are not supported for this database adapter.", + extendedInfo = "The #GetMetaData(this).name# adapter does not implement advisory locking. Use a database that supports advisory locks (PostgreSQL, MySQL, or SQL Server) or implement application-level locking." + ); + } + + /** + * Release a previously acquired advisory lock. + * Individual database adapters override this with their specific implementation. + * + * @name The name of the lock to release. + */ + public void function $releaseAdvisoryLock(required string name) { + Throw( + type = "Wheels.AdvisoryLockNotSupported", + message = "Advisory locks are not supported for this database adapter.", + extendedInfo = "The #GetMetaData(this).name# adapter does not implement advisory locking." + ); + } + + /** + * Returns the SQL clause for pessimistic row locking (e.g., "FOR UPDATE"). + * Individual database adapters override this when the default is not appropriate. + */ + public string function $forUpdateClause() { + return "FOR UPDATE"; + } + /** * Remove the maxRows argument and add a limit argument instead. * The args argument is the original arguments passed in by reference so we just modify it without passing it back. diff --git a/vendor/wheels/databaseAdapters/CockroachDB/CockroachDBModel.cfc b/vendor/wheels/databaseAdapters/CockroachDB/CockroachDBModel.cfc index e1c35fd37..e1bbc55de 100644 --- a/vendor/wheels/databaseAdapters/CockroachDB/CockroachDBModel.cfc +++ b/vendor/wheels/databaseAdapters/CockroachDB/CockroachDBModel.cfc @@ -38,6 +38,29 @@ component extends="wheels.databaseAdapters.PostgreSQL.PostgreSQLModel" output=fa return local.rv; } + /** + * CockroachDB does not support advisory locks. + * Use forUpdate() for row-level locking instead. + */ + public void function $acquireAdvisoryLock(required string name, numeric timeout = 10) { + Throw( + type = "Wheels.AdvisoryLockNotSupported", + message = "CockroachDB does not support advisory locks.", + extendedInfo = "Use forUpdate() for row-level locking instead. CockroachDB supports SELECT ... FOR UPDATE for pessimistic locking within transactions." + ); + } + + /** + * CockroachDB does not support advisory locks. + */ + public void function $releaseAdvisoryLock(required string name) { + Throw( + type = "Wheels.AdvisoryLockNotSupported", + message = "CockroachDB does not support advisory locks.", + extendedInfo = "Use forUpdate() for row-level locking instead." + ); + } + /** * Override query setup to append RETURNING clause to INSERTs. * CockroachDB does not support pg_get_serial_sequence()/currval(), diff --git a/vendor/wheels/databaseAdapters/H2/H2Model.cfc b/vendor/wheels/databaseAdapters/H2/H2Model.cfc index cd987e5b1..55f9b9c2d 100755 --- a/vendor/wheels/databaseAdapters/H2/H2Model.cfc +++ b/vendor/wheels/databaseAdapters/H2/H2Model.cfc @@ -127,6 +127,28 @@ component extends="wheels.databaseAdapters.Base" output=false { return $performQuery(argumentCollection = arguments); } + /** + * H2 does not support advisory locks. + */ + public void function $acquireAdvisoryLock(required string name, numeric timeout = 10) { + Throw( + type = "Wheels.AdvisoryLockNotSupported", + message = "H2 does not support advisory locks.", + extendedInfo = "Advisory locks are not available in H2. Consider using a different database for features that require advisory locking." + ); + } + + /** + * H2 does not support advisory locks. + */ + public void function $releaseAdvisoryLock(required string name) { + Throw( + type = "Wheels.AdvisoryLockNotSupported", + message = "H2 does not support advisory locks.", + extendedInfo = "Advisory locks are not available in H2." + ); + } + /** * Override Base adapter's function. * When using H2, cfdbinfo incorrectly returns information_schema tables. diff --git a/vendor/wheels/databaseAdapters/MicrosoftSQLServer/MicrosoftSQLServerModel.cfc b/vendor/wheels/databaseAdapters/MicrosoftSQLServer/MicrosoftSQLServerModel.cfc index 750c15bc6..d97446002 100755 --- a/vendor/wheels/databaseAdapters/MicrosoftSQLServer/MicrosoftSQLServerModel.cfc +++ b/vendor/wheels/databaseAdapters/MicrosoftSQLServer/MicrosoftSQLServerModel.cfc @@ -211,6 +211,40 @@ component extends="wheels.databaseAdapters.Base" output=false { return $performQuery(argumentCollection = arguments); } + /** + * Acquire a SQL Server application lock using sp_getapplock. + * The lock is scoped to the current session. + */ + public void function $acquireAdvisoryLock(required string name, numeric timeout = 10) { + $query( + sql = "EXEC sp_getapplock @Resource='#arguments.name#', @LockMode='Exclusive', @LockTimeout=#arguments.timeout * 1000#", + dataSource = variables.dataSource, + username = variables.username, + password = variables.password + ); + } + + /** + * Release a SQL Server application lock. + */ + public void function $releaseAdvisoryLock(required string name) { + $query( + sql = "EXEC sp_releaseapplock @Resource='#arguments.name#'", + dataSource = variables.dataSource, + username = variables.username, + password = variables.password + ); + } + + /** + * SQL Server uses table hints (WITH (UPDLOCK)) instead of trailing FOR UPDATE. + * Table hints require modifying the FROM clause which is too complex for initial implementation. + * Returns empty string to no-op. + */ + public string function $forUpdateClause() { + return ""; + } + /** * Override Base adapter's function. */ diff --git a/vendor/wheels/databaseAdapters/MySQL/MySQLModel.cfc b/vendor/wheels/databaseAdapters/MySQL/MySQLModel.cfc index 3d6fdfbc8..57808aac3 100755 --- a/vendor/wheels/databaseAdapters/MySQL/MySQLModel.cfc +++ b/vendor/wheels/databaseAdapters/MySQL/MySQLModel.cfc @@ -108,6 +108,39 @@ component extends="wheels.databaseAdapters.Base" output=false { return $performQuery(argumentCollection = arguments); } + /** + * Acquire a MySQL advisory lock using GET_LOCK. + * Returns after the lock is acquired or the timeout expires. + * Throws if the lock could not be acquired within the timeout. + */ + public void function $acquireAdvisoryLock(required string name, numeric timeout = 10) { + local.result = $query( + sql = "SELECT GET_LOCK('#arguments.name#', #arguments.timeout#) AS lockResult", + dataSource = variables.dataSource, + username = variables.username, + password = variables.password + ); + if (!IsQuery(local.result) || local.result.lockResult != 1) { + Throw( + type = "Wheels.AdvisoryLockTimeout", + message = "Could not acquire advisory lock '#arguments.name#' within #arguments.timeout# seconds.", + extendedInfo = "The MySQL GET_LOCK function returned a non-1 result, indicating the lock could not be acquired." + ); + } + } + + /** + * Release a MySQL advisory lock. + */ + public void function $releaseAdvisoryLock(required string name) { + $query( + sql = "SELECT RELEASE_LOCK('#arguments.name#')", + dataSource = variables.dataSource, + username = variables.username, + password = variables.password + ); + } + /** * Override Base adapter's function. */ diff --git a/vendor/wheels/databaseAdapters/Oracle/OracleModel.cfc b/vendor/wheels/databaseAdapters/Oracle/OracleModel.cfc index 0ad78c10c..42a6e4e6c 100755 --- a/vendor/wheels/databaseAdapters/Oracle/OracleModel.cfc +++ b/vendor/wheels/databaseAdapters/Oracle/OracleModel.cfc @@ -64,6 +64,28 @@ component extends="wheels.databaseAdapters.Base" output=false { return local.rv; } + /** + * Oracle advisory locks require DBMS_LOCK package setup which is not available by default. + */ + public void function $acquireAdvisoryLock(required string name, numeric timeout = 10) { + Throw( + type = "Wheels.AdvisoryLockNotSupported", + message = "Oracle advisory locks require DBMS_LOCK package setup.", + extendedInfo = "Oracle supports advisory locks via the DBMS_LOCK package, but this requires DBA-level setup and is not supported by Wheels out of the box. Use forUpdate() for row-level locking instead." + ); + } + + /** + * Oracle advisory locks require DBMS_LOCK package setup. + */ + public void function $releaseAdvisoryLock(required string name) { + Throw( + type = "Wheels.AdvisoryLockNotSupported", + message = "Oracle advisory locks require DBMS_LOCK package setup.", + extendedInfo = "Oracle supports advisory locks via the DBMS_LOCK package, but this requires DBA-level setup and is not supported by Wheels out of the box." + ); + } + /** * Call functions to make adapter specific changes to arguments before executing query. */ diff --git a/vendor/wheels/databaseAdapters/PostgreSQL/PostgreSQLModel.cfc b/vendor/wheels/databaseAdapters/PostgreSQL/PostgreSQLModel.cfc index 6c11b1853..9ffc098ae 100755 --- a/vendor/wheels/databaseAdapters/PostgreSQL/PostgreSQLModel.cfc +++ b/vendor/wheels/databaseAdapters/PostgreSQL/PostgreSQLModel.cfc @@ -178,6 +178,32 @@ component extends="wheels.databaseAdapters.Base" output=false { return "random()"; } + /** + * Acquire a PostgreSQL advisory lock using pg_advisory_lock. + * This is a session-level lock that blocks until acquired. + * The lock name is hashed to an integer using hashtext(). + */ + public void function $acquireAdvisoryLock(required string name, numeric timeout = 10) { + $query( + sql = "SELECT pg_advisory_lock(hashtext('#arguments.name#'))", + dataSource = variables.dataSource, + username = variables.username, + password = variables.password + ); + } + + /** + * Release a PostgreSQL advisory lock. + */ + public void function $releaseAdvisoryLock(required string name) { + $query( + sql = "SELECT pg_advisory_unlock(hashtext('#arguments.name#'))", + dataSource = variables.dataSource, + username = variables.username, + password = variables.password + ); + } + /** * Override Base adapter's function. * PostgreSQL uses double-quotes to quote identifiers (ANSI SQL standard). diff --git a/vendor/wheels/databaseAdapters/SQLite/SQLiteModel.cfc b/vendor/wheels/databaseAdapters/SQLite/SQLiteModel.cfc index ee04ddc09..1b44629ef 100755 --- a/vendor/wheels/databaseAdapters/SQLite/SQLiteModel.cfc +++ b/vendor/wheels/databaseAdapters/SQLite/SQLiteModel.cfc @@ -122,6 +122,30 @@ component extends="wheels.databaseAdapters.Base" output=false { } + /** + * SQLite uses file-level locking and does not support advisory locks. + * This is a no-op to allow code that uses advisory locks to run without errors on SQLite. + */ + public void function $acquireAdvisoryLock(required string name, numeric timeout = 10) { + // No-op: SQLite has file-level locking only. + // Advisory locks are not meaningful for SQLite. + } + + /** + * No-op release for SQLite. + */ + public void function $releaseAdvisoryLock(required string name) { + // No-op + } + + /** + * SQLite does not support SELECT ... FOR UPDATE. + * Returns empty string to no-op. + */ + public string function $forUpdateClause() { + return ""; + } + /** * Default VALUES syntax (same as MySQL). */ diff --git a/vendor/wheels/model/locking.cfc b/vendor/wheels/model/locking.cfc new file mode 100644 index 000000000..e6059b570 --- /dev/null +++ b/vendor/wheels/model/locking.cfc @@ -0,0 +1,52 @@ +/** + * Provides advisory lock and pessimistic row locking support for Wheels models. + * + * Advisory locks are database-level, application-coordinated locks that don't lock any rows or tables. + * They are useful for coordinating exclusive access to shared resources (e.g., preventing duplicate + * background job processing, serializing access to external APIs). + * + * Pessimistic row locking (SELECT ... FOR UPDATE) is handled via the QueryBuilder's `forUpdate()` method. + */ +component { + + /** + * Executes a callback while holding a database advisory lock. + * The lock is automatically released when the callback completes, even if an exception is thrown. + * + * Advisory locks are database-level locks that don't lock rows or tables. They are useful for + * coordinating exclusive access to shared resources across application instances. + * + * Support varies by database: + * - PostgreSQL: Full support via pg_advisory_lock/pg_advisory_unlock + * - MySQL: Full support via GET_LOCK/RELEASE_LOCK + * - SQL Server: Full support via sp_getapplock/sp_releaseapplock + * - SQLite: No-op (file-level locking only) + * - CockroachDB: Not supported (throws error, use forUpdate() instead) + * - H2: Not supported (throws error) + * - Oracle: Not supported by default (requires DBMS_LOCK package setup) + * + * [section: Model Class] + * [category: Locking Functions] + * + * @name A unique name for the lock. Different callers using the same name will contend for the same lock. + * @timeout Maximum number of seconds to wait when acquiring the lock (supported by MySQL and SQL Server). + * @callback A function or closure to execute while holding the lock. Its return value is returned by this method. + */ + public any function withAdvisoryLock( + required string name, + numeric timeout = 10, + required any callback + ) { + local.adapter = variables.wheels.class.adapter; + local.adapter.$acquireAdvisoryLock(name = arguments.name, timeout = arguments.timeout); + try { + local.result = arguments.callback(); + } finally { + local.adapter.$releaseAdvisoryLock(name = arguments.name); + } + if (StructKeyExists(local, "result")) { + return local.result; + } + } + +} diff --git a/vendor/wheels/model/query/QueryBuilder.cfc b/vendor/wheels/model/query/QueryBuilder.cfc index ac7b454a2..633051a2f 100644 --- a/vendor/wheels/model/query/QueryBuilder.cfc +++ b/vendor/wheels/model/query/QueryBuilder.cfc @@ -32,6 +32,7 @@ component output="false" { variables.offsetValue = 0; variables.distinctValue = false; variables.groupClause = ""; + variables.forUpdateValue = false; return this; } @@ -211,6 +212,20 @@ component output="false" { return this; } + /** + * Add a FOR UPDATE clause to the query for pessimistic row locking. + * The locked rows will be held until the current transaction commits or rolls back. + * Must be used within a transaction to be effective. + * + * Support varies by database: + * - PostgreSQL, MySQL, CockroachDB, H2, Oracle: Appends FOR UPDATE + * - SQL Server, SQLite: No-op (MSSQL uses table hints, SQLite has file-level locking) + */ + public any function forUpdate() { + variables.forUpdateValue = true; + return this; + } + /** * Build the accumulated arguments into a struct suitable for finder methods. */ @@ -281,6 +296,11 @@ component output="false" { local.args.maxRows = variables.limitValue; } + // Add FOR UPDATE flag if set + if (variables.forUpdateValue) { + local.args.$forUpdate = true; + } + // Merge in any extra arguments passed to the terminal method StructAppend(local.args, arguments.extraArgs, false); @@ -390,7 +410,7 @@ component output="false" { Throw( type = "Wheels.MethodNotFound", message = "The method `#arguments.missingMethodName#` was not found on the query builder for `#variables.modelReference.$classData().modelName#`.", - extendedInfo = "Available methods: where, orWhere, whereNull, whereNotNull, whereBetween, whereIn, whereNotIn, orderBy, limit, offset, select, include, group, distinct, get, first, findAll, findOne, count, exists, updateAll, deleteAll, findEach, findInBatches." + extendedInfo = "Available methods: where, orWhere, whereNull, whereNotNull, whereBetween, whereIn, whereNotIn, orderBy, limit, offset, select, include, group, distinct, forUpdate, get, first, findAll, findOne, count, exists, updateAll, deleteAll, findEach, findInBatches." ); } diff --git a/vendor/wheels/model/read.cfc b/vendor/wheels/model/read.cfc index cee79ad13..787427986 100644 --- a/vendor/wheels/model/read.cfc +++ b/vendor/wheels/model/read.cfc @@ -49,7 +49,8 @@ component { struct useIndex = {}, string dataSource = variables.wheels.class.dataSource, numeric $limit = "0", - numeric $offset = "0" + numeric $offset = "0", + boolean $forUpdate = "false" ) { $args(name = "findAll", args = arguments); $setDebugName(name = "findAll", args = arguments); @@ -269,6 +270,12 @@ component { if (Len(local.orderBy)) { ArrayAppend(local.sql, local.orderBy); } + if (arguments.$forUpdate) { + local.forUpdateClause = variables.wheels.class.adapter.$forUpdateClause(); + if (Len(local.forUpdateClause)) { + ArrayAppend(local.sql, local.forUpdateClause); + } + } local.lockName = "findAllSQLLock" & application.applicationName; local.executeArgs = {key = local.queryShellKey, value = local.sql, category = "sql"}; $simpleLock( diff --git a/vendor/wheels/tests/specs/model/lockingSpec.cfc b/vendor/wheels/tests/specs/model/lockingSpec.cfc new file mode 100644 index 000000000..d11e84038 --- /dev/null +++ b/vendor/wheels/tests/specs/model/lockingSpec.cfc @@ -0,0 +1,104 @@ +component extends="wheels.WheelsTest" { + + function run() { + + g = application.wo + + describe("Tests that withAdvisoryLock", () => { + + it("executes callback and returns result", () => { + local.result = g.model("author").withAdvisoryLock(name="test_lock_1", callback=function() { + return 42; + }); + expect(local.result).toBe(42); + }) + + it("releases lock even when callback throws an exception", () => { + local.exceptionThrown = false; + try { + g.model("author").withAdvisoryLock(name="test_lock_2", callback=function() { + Throw(type="TestException", message="deliberate error"); + }); + } catch (TestException e) { + local.exceptionThrown = true; + } + expect(local.exceptionThrown).toBeTrue(); + + // Verify the lock was released by successfully acquiring it again + local.result = g.model("author").withAdvisoryLock(name="test_lock_2", callback=function() { + return "reacquired"; + }); + expect(local.result).toBe("reacquired"); + }) + + it("accepts a custom timeout argument", () => { + local.result = g.model("author").withAdvisoryLock(name="test_lock_timeout", timeout=5, callback=function() { + return "locked"; + }); + expect(local.result).toBe("locked"); + }) + + }) + + describe("Tests that forUpdate on QueryBuilder", () => { + + it("sets the forUpdate flag in built finder args", () => { + local.builder = g.model("author").where("firstName", "Per").forUpdate(); + local.args = local.builder.$buildFinderArgs(); + expect(local.args).toHaveKey("$forUpdate"); + expect(local.args.$forUpdate).toBeTrue(); + }) + + it("does not set forUpdate flag when not called", () => { + local.builder = g.model("author").where("firstName", "Per"); + local.args = local.builder.$buildFinderArgs(); + expect(local.args).notToHaveKey("$forUpdate"); + }) + + it("works in a QueryBuilder chain with other methods", () => { + local.builder = g.model("author") + .where("firstName", "Per") + .orderBy("lastName") + .forUpdate() + .limit(1); + local.args = local.builder.$buildFinderArgs(); + expect(local.args).toHaveKey("$forUpdate"); + expect(local.args.$forUpdate).toBeTrue(); + expect(local.args).toHaveKey("where"); + expect(local.args).toHaveKey("order"); + expect(local.args).toHaveKey("maxRows"); + }) + + it("executes a findAll with forUpdate without error", () => { + // On SQLite this is a no-op (empty FOR UPDATE clause), but should not throw + local.result = g.model("author").where("firstName", "Per").forUpdate().get(); + expect(local.result.recordCount).toBeGTE(0); + }) + + it("executes findAll with dollar forUpdate argument without error", () => { + // Direct findAll with $forUpdate argument + local.result = g.model("author").findAll(where="firstName = 'Per'", $forUpdate=true); + expect(local.result.recordCount).toBeGTE(0); + }) + + }) + + describe("Tests that adapter forUpdateClause", () => { + + it("returns correct clause for the current adapter", () => { + local.adapter = g.model("author").$classData().adapter; + local.adapterName = g.model("author").get("adapterName"); + local.clause = local.adapter.$forUpdateClause(); + + if (local.adapterName == "SQLiteModel" || local.adapterName == "MicrosoftSQLServerModel") { + expect(local.clause).toBe(""); + } else { + expect(local.clause).toBe("FOR UPDATE"); + } + }) + + }) + + } + +} From 5e38c261897c302fd2fa847e8ae6e47db19caecb Mon Sep 17 00:00:00 2001 From: Peter Amiri Date: Tue, 14 Apr 2026 22:57:42 -0700 Subject: [PATCH 2/2] fix(model): parameterize advisory lock names to prevent SQL injection PostgreSQL, MySQL, and SQL Server adapters were interpolating the lock name directly into SQL strings, creating an injection vector when the name comes from a non-trusted source. Switch to queryExecute with bind parameters. Adds regression test using a name with single quotes ("O'Brien's lock") that would have broken the old SQL. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../MicrosoftSQLServerModel.cfc | 18 ++++++++---------- .../databaseAdapters/MySQL/MySQLModel.cfc | 18 ++++++++---------- .../PostgreSQL/PostgreSQLModel.cfc | 18 ++++++++---------- .../wheels/tests/specs/model/lockingSpec.cfc | 10 ++++++++++ 4 files changed, 34 insertions(+), 30 deletions(-) diff --git a/vendor/wheels/databaseAdapters/MicrosoftSQLServer/MicrosoftSQLServerModel.cfc b/vendor/wheels/databaseAdapters/MicrosoftSQLServer/MicrosoftSQLServerModel.cfc index d97446002..50b7d678a 100755 --- a/vendor/wheels/databaseAdapters/MicrosoftSQLServer/MicrosoftSQLServerModel.cfc +++ b/vendor/wheels/databaseAdapters/MicrosoftSQLServer/MicrosoftSQLServerModel.cfc @@ -216,11 +216,10 @@ component extends="wheels.databaseAdapters.Base" output=false { * The lock is scoped to the current session. */ public void function $acquireAdvisoryLock(required string name, numeric timeout = 10) { - $query( - sql = "EXEC sp_getapplock @Resource='#arguments.name#', @LockMode='Exclusive', @LockTimeout=#arguments.timeout * 1000#", - dataSource = variables.dataSource, - username = variables.username, - password = variables.password + queryExecute( + "EXEC sp_getapplock @Resource = ?, @LockMode = 'Exclusive', @LockTimeout = ?", + [arguments.name, arguments.timeout * 1000], + {datasource: variables.dataSource, username: variables.username, password: variables.password} ); } @@ -228,11 +227,10 @@ component extends="wheels.databaseAdapters.Base" output=false { * Release a SQL Server application lock. */ public void function $releaseAdvisoryLock(required string name) { - $query( - sql = "EXEC sp_releaseapplock @Resource='#arguments.name#'", - dataSource = variables.dataSource, - username = variables.username, - password = variables.password + queryExecute( + "EXEC sp_releaseapplock @Resource = ?", + [arguments.name], + {datasource: variables.dataSource, username: variables.username, password: variables.password} ); } diff --git a/vendor/wheels/databaseAdapters/MySQL/MySQLModel.cfc b/vendor/wheels/databaseAdapters/MySQL/MySQLModel.cfc index 57808aac3..611c63307 100755 --- a/vendor/wheels/databaseAdapters/MySQL/MySQLModel.cfc +++ b/vendor/wheels/databaseAdapters/MySQL/MySQLModel.cfc @@ -114,11 +114,10 @@ component extends="wheels.databaseAdapters.Base" output=false { * Throws if the lock could not be acquired within the timeout. */ public void function $acquireAdvisoryLock(required string name, numeric timeout = 10) { - local.result = $query( - sql = "SELECT GET_LOCK('#arguments.name#', #arguments.timeout#) AS lockResult", - dataSource = variables.dataSource, - username = variables.username, - password = variables.password + local.result = queryExecute( + "SELECT GET_LOCK(?, ?) AS lockResult", + [arguments.name, arguments.timeout], + {datasource: variables.dataSource, username: variables.username, password: variables.password} ); if (!IsQuery(local.result) || local.result.lockResult != 1) { Throw( @@ -133,11 +132,10 @@ component extends="wheels.databaseAdapters.Base" output=false { * Release a MySQL advisory lock. */ public void function $releaseAdvisoryLock(required string name) { - $query( - sql = "SELECT RELEASE_LOCK('#arguments.name#')", - dataSource = variables.dataSource, - username = variables.username, - password = variables.password + queryExecute( + "SELECT RELEASE_LOCK(?)", + [arguments.name], + {datasource: variables.dataSource, username: variables.username, password: variables.password} ); } diff --git a/vendor/wheels/databaseAdapters/PostgreSQL/PostgreSQLModel.cfc b/vendor/wheels/databaseAdapters/PostgreSQL/PostgreSQLModel.cfc index 9ffc098ae..f3c0788a5 100755 --- a/vendor/wheels/databaseAdapters/PostgreSQL/PostgreSQLModel.cfc +++ b/vendor/wheels/databaseAdapters/PostgreSQL/PostgreSQLModel.cfc @@ -184,11 +184,10 @@ component extends="wheels.databaseAdapters.Base" output=false { * The lock name is hashed to an integer using hashtext(). */ public void function $acquireAdvisoryLock(required string name, numeric timeout = 10) { - $query( - sql = "SELECT pg_advisory_lock(hashtext('#arguments.name#'))", - dataSource = variables.dataSource, - username = variables.username, - password = variables.password + queryExecute( + "SELECT pg_advisory_lock(hashtext(?))", + [arguments.name], + {datasource: variables.dataSource, username: variables.username, password: variables.password} ); } @@ -196,11 +195,10 @@ component extends="wheels.databaseAdapters.Base" output=false { * Release a PostgreSQL advisory lock. */ public void function $releaseAdvisoryLock(required string name) { - $query( - sql = "SELECT pg_advisory_unlock(hashtext('#arguments.name#'))", - dataSource = variables.dataSource, - username = variables.username, - password = variables.password + queryExecute( + "SELECT pg_advisory_unlock(hashtext(?))", + [arguments.name], + {datasource: variables.dataSource, username: variables.username, password: variables.password} ); } diff --git a/vendor/wheels/tests/specs/model/lockingSpec.cfc b/vendor/wheels/tests/specs/model/lockingSpec.cfc index d11e84038..8b678db6f 100644 --- a/vendor/wheels/tests/specs/model/lockingSpec.cfc +++ b/vendor/wheels/tests/specs/model/lockingSpec.cfc @@ -38,6 +38,16 @@ component extends="wheels.WheelsTest" { expect(local.result).toBe("locked"); }) + it("safely handles lock names with single quotes", () => { + // Regression guard: verify lock names are parameterized, not interpolated. + // On SQLite this is a no-op, but the call must not throw a SQL syntax error + // regardless of adapter. With proper parameterization, "O'Brien" is fine. + local.result = g.model("author").withAdvisoryLock(name="O'Brien's lock", callback=function() { + return "safe"; + }); + expect(local.result).toBe("safe"); + }) + }) describe("Tests that forUpdate on QueryBuilder", () => {