diff --git a/.ai/wheels/channels/channels.md b/.ai/wheels/channels/channels.md index 952bd9bd94..5760ecf060 100644 --- a/.ai/wheels/channels/channels.md +++ b/.ai/wheels/channels/channels.md @@ -138,8 +138,12 @@ adapter.publish(channel = "orders", event = "created", data = SerializeJSON(orde var events = adapter.poll(channel = "orders", since = DateAdd("n", -5, Now())); var events = adapter.poll(channel = "orders", lastEventId = "evt-123"); -// Manual cleanup (automatic cleanup runs every 5 minutes) +// Manual cleanup. A throttled sweep also runs on the publish path: at most +// once every 5 minutes (every 15s while a backlog drains), bounded to 1000 +// oldest expired rows per pass. Busy multi-server deployments should run a +// scheduled full sweep instead of relying on it. adapter.cleanup(olderThanMinutes = 30); +adapter.cleanup(olderThanMinutes = 60, maxRows = 10000); // bounded pass; returns rows deleted ``` #### Database Table: `wheels_events` @@ -158,10 +162,10 @@ Indexes: `(channel, createdAt)`, `(createdAt)`. ## JavaScript Client -Include `wheels-sse.js` (located at `/wheels/assets/js/wheels-sse.js`) for a zero-dependency EventSource client with auto-reconnect. +`wheels-sse.js` is a zero-dependency EventSource client with auto-reconnect. It is NOT served at any URL by the framework — copy it from `vendor/wheels/public/assets/js/wheels-sse.js` into your app (e.g. `public/javascripts/wheels-sse.js`) and include it with `javaScriptIncludeTag("wheels-sse")` or a plain script tag. ```html - + + - +#ReReplace(local.wdbHtml, "(?m)>\s+<", "><", "all")# diff --git a/vendor/wheels/helpers/DatabaseShellHelper.cfc b/vendor/wheels/helpers/DatabaseShellHelper.cfc deleted file mode 100644 index ca240cfc9e..0000000000 --- a/vendor/wheels/helpers/DatabaseShellHelper.cfc +++ /dev/null @@ -1,250 +0,0 @@ -component { - - /** - * Get database shell command for different database types - */ - public struct function getShellCommand(required string databaseType, required struct connectionInfo) { - local.result = { - success = false, - command = "", - message = "", - requiresPassword = true - }; - - switch(arguments.databaseType) { - case "MySQL": - case "MariaDB": - local.result.command = "mysql"; - if (structKeyExists(arguments.connectionInfo, "host")) { - local.result.command &= " -h " & arguments.connectionInfo.host; - } - if (structKeyExists(arguments.connectionInfo, "port")) { - local.result.command &= " -P " & arguments.connectionInfo.port; - } - if (structKeyExists(arguments.connectionInfo, "username")) { - local.result.command &= " -u " & arguments.connectionInfo.username; - } - if (structKeyExists(arguments.connectionInfo, "database")) { - local.result.command &= " " & arguments.connectionInfo.database; - } - local.result.command &= " -p"; - local.result.success = true; - break; - - case "PostgreSQL": - local.result.command = "psql"; - if (structKeyExists(arguments.connectionInfo, "host")) { - local.result.command &= " -h " & arguments.connectionInfo.host; - } - if (structKeyExists(arguments.connectionInfo, "port")) { - local.result.command &= " -p " & arguments.connectionInfo.port; - } - if (structKeyExists(arguments.connectionInfo, "username")) { - local.result.command &= " -U " & arguments.connectionInfo.username; - } - if (structKeyExists(arguments.connectionInfo, "database")) { - local.result.command &= " -d " & arguments.connectionInfo.database; - } - local.result.success = true; - break; - - case "SQLServer": - case "MSSQL": - local.result.command = "sqlcmd"; - if (structKeyExists(arguments.connectionInfo, "host")) { - local.server = arguments.connectionInfo.host; - if (structKeyExists(arguments.connectionInfo, "port")) { - local.server &= "," & arguments.connectionInfo.port; - } - local.result.command &= " -S " & local.server; - } - if (structKeyExists(arguments.connectionInfo, "username")) { - local.result.command &= " -U " & arguments.connectionInfo.username; - } - if (structKeyExists(arguments.connectionInfo, "database")) { - local.result.command &= " -d " & arguments.connectionInfo.database; - } - local.result.success = true; - break; - - case "H2": - // For H2, we can provide both shell and web options - local.result.command = "java -cp /path/to/h2*.jar org.h2.tools.Shell"; - local.result.webConsole = "http://localhost:8082/"; // Default H2 console port - local.result.message = "H2 can be accessed via web console or command line shell"; - local.result.requiresPassword = false; - local.result.success = true; - break; - - default: - local.result.message = "Unsupported database type: " & arguments.databaseType; - } - - return local.result; - } - - /** - * Get dump command for different database types - */ - public struct function getDumpCommand(required string databaseType, required struct connectionInfo, string outputFile = "backup.sql") { - local.result = { - success = false, - command = "", - message = "" - }; - - switch(arguments.databaseType) { - case "MySQL": - case "MariaDB": - local.result.command = "mysqldump"; - if (structKeyExists(arguments.connectionInfo, "host")) { - local.result.command &= " -h " & arguments.connectionInfo.host; - } - if (structKeyExists(arguments.connectionInfo, "port")) { - local.result.command &= " -P " & arguments.connectionInfo.port; - } - if (structKeyExists(arguments.connectionInfo, "username")) { - local.result.command &= " -u " & arguments.connectionInfo.username; - } - local.result.command &= " -p"; - if (structKeyExists(arguments.connectionInfo, "database")) { - local.result.command &= " " & arguments.connectionInfo.database; - } - local.result.command &= " > " & arguments.outputFile; - local.result.success = true; - break; - - case "PostgreSQL": - local.result.command = "pg_dump"; - if (structKeyExists(arguments.connectionInfo, "host")) { - local.result.command &= " -h " & arguments.connectionInfo.host; - } - if (structKeyExists(arguments.connectionInfo, "port")) { - local.result.command &= " -p " & arguments.connectionInfo.port; - } - if (structKeyExists(arguments.connectionInfo, "username")) { - local.result.command &= " -U " & arguments.connectionInfo.username; - } - if (structKeyExists(arguments.connectionInfo, "database")) { - local.result.command &= " " & arguments.connectionInfo.database; - } - local.result.command &= " > " & arguments.outputFile; - local.result.success = true; - break; - - case "SQLServer": - case "MSSQL": - local.result.command = "sqlcmd"; - if (structKeyExists(arguments.connectionInfo, "host")) { - local.server = arguments.connectionInfo.host; - if (structKeyExists(arguments.connectionInfo, "port")) { - local.server &= "," & arguments.connectionInfo.port; - } - local.result.command &= " -S " & local.server; - } - if (structKeyExists(arguments.connectionInfo, "username")) { - local.result.command &= " -U " & arguments.connectionInfo.username; - } - if (structKeyExists(arguments.connectionInfo, "database")) { - local.result.command &= " -d " & arguments.connectionInfo.database; - } - local.result.command &= " -Q ""BACKUP DATABASE [" & arguments.connectionInfo.database & "] TO DISK='" & arguments.outputFile & "'"""; - local.result.success = true; - break; - - case "H2": - local.result.command = "SCRIPT TO '" & arguments.outputFile & "'"; - local.result.message = "Execute this command in H2 console or use the dbDump CLI command"; - local.result.success = true; - break; - - default: - local.result.message = "Unsupported database type: " & arguments.databaseType; - } - - return local.result; - } - - /** - * Get restore command for different database types - */ - public struct function getRestoreCommand(required string databaseType, required struct connectionInfo, string inputFile = "backup.sql") { - local.result = { - success = false, - command = "", - message = "" - }; - - switch(arguments.databaseType) { - case "MySQL": - case "MariaDB": - local.result.command = "mysql"; - if (structKeyExists(arguments.connectionInfo, "host")) { - local.result.command &= " -h " & arguments.connectionInfo.host; - } - if (structKeyExists(arguments.connectionInfo, "port")) { - local.result.command &= " -P " & arguments.connectionInfo.port; - } - if (structKeyExists(arguments.connectionInfo, "username")) { - local.result.command &= " -u " & arguments.connectionInfo.username; - } - local.result.command &= " -p"; - if (structKeyExists(arguments.connectionInfo, "database")) { - local.result.command &= " " & arguments.connectionInfo.database; - } - local.result.command &= " < " & arguments.inputFile; - local.result.success = true; - break; - - case "PostgreSQL": - local.result.command = "psql"; - if (structKeyExists(arguments.connectionInfo, "host")) { - local.result.command &= " -h " & arguments.connectionInfo.host; - } - if (structKeyExists(arguments.connectionInfo, "port")) { - local.result.command &= " -p " & arguments.connectionInfo.port; - } - if (structKeyExists(arguments.connectionInfo, "username")) { - local.result.command &= " -U " & arguments.connectionInfo.username; - } - if (structKeyExists(arguments.connectionInfo, "database")) { - local.result.command &= " -d " & arguments.connectionInfo.database; - } - local.result.command &= " < " & arguments.inputFile; - local.result.success = true; - break; - - case "SQLServer": - case "MSSQL": - local.result.command = "sqlcmd"; - if (structKeyExists(arguments.connectionInfo, "host")) { - local.server = arguments.connectionInfo.host; - if (structKeyExists(arguments.connectionInfo, "port")) { - local.server &= "," & arguments.connectionInfo.port; - } - local.result.command &= " -S " & local.server; - } - if (structKeyExists(arguments.connectionInfo, "username")) { - local.result.command &= " -U " & arguments.connectionInfo.username; - } - if (structKeyExists(arguments.connectionInfo, "database")) { - local.result.command &= " -d " & arguments.connectionInfo.database; - } - local.result.command &= " -i " & arguments.inputFile; - local.result.success = true; - break; - - case "H2": - local.result.command = "RUNSCRIPT FROM '" & arguments.inputFile & "'"; - local.result.message = "Execute this command in H2 console"; - local.result.success = true; - break; - - default: - local.result.message = "Unsupported database type: " & arguments.databaseType; - } - - return local.result; - } - -} \ No newline at end of file diff --git a/vendor/wheels/mapper/mapping.cfc b/vendor/wheels/mapper/mapping.cfc index a6d7f1a11a..6ed407427e 100644 --- a/vendor/wheels/mapper/mapping.cfc +++ b/vendor/wheels/mapper/mapping.cfc @@ -24,6 +24,17 @@ component { * [category: Routing] */ public struct function end() { + // Guard against unbalanced end() calls: once the scope stack is empty there + // is no open block left to close, so fail with a clear DSL error instead of + // a raw array-index engine error. + if (ArrayIsEmpty(variables.scopeStack)) { + Throw( + type = "Wheels.InvalidRoute", + message = "Unbalanced `end()` call in the route configuration.", + detail = "Each `end()` closes one block opened by `mapper()`, `scope()`, `namespace()`, `package()`, `group()`, `resource()`, or `resources()`. This `end()` has no matching open block, so remove the extra `end()`." + ); + } + local.formatPattern = ""; if (StructKeyExists(variables.scopeStack[1], "mapFormat") && variables.scopeStack[1].mapFormat) { diff --git a/vendor/wheels/mapper/matching.cfc b/vendor/wheels/mapper/matching.cfc index 5032c329b4..5df815c523 100644 --- a/vendor/wheels/mapper/matching.cfc +++ b/vendor/wheels/mapper/matching.cfc @@ -140,7 +140,7 @@ component { } /** - * Create a route that matches the root of its current context. This mapper can be used for the application's web root (or home page), or it can generate a route for the root of a namespace or other path scoping mapper. + * Create a route that matches the root of its current context. This mapper can be used for the application's web root (or home page), or it can generate a route for the root of a namespace or other path scoping mapper. The route only responds to the `GET` verb unless you explicitly pass a `method` (or `methods`) argument. * * [section: Configuration] * [category: Routing] @@ -166,15 +166,19 @@ component { local.pattern = "/"; } + // Restrict the root route to GET unless the caller explicitly passed method/methods. + // Without this, a route registered with no methods key matches every HTTP verb. + if (!StructKeyExists(arguments, "method") && !StructKeyExists(arguments, "methods")) { + arguments.method = "get"; + } + // If arguments.to is not passed in, we check for the existence of app/views/home/index.cfm if found we set that as the root // else we set wheels##wheels as the root. if (!structKeyExists(arguments, "to")) { if (fileExists(application.AppDir & "views/home/index.cfm")) { arguments.to = "home##index"; - arguments.method = "get"; } else { arguments.to = "wheels##wheels"; - arguments.method = "get"; } } @@ -188,7 +192,8 @@ component { * [section: Configuration] * [category: Routing] * - * @method List of HTTP methods (verbs) to generate the wildcard routes for. We strongly recommend leaving the default value of `get` and using other routing mappers if you need to `POST` to a URL endpoint. For better readability, you can also pass this argument as `methods` if you're listing multiple methods. + * @method List of HTTP methods (verbs) to generate the wildcard routes for. We strongly recommend leaving the default value of `get` and using other routing mappers if you need to `POST` to a URL endpoint. Pass an empty string to generate the wildcard routes for all verbs (`get`, `post`, `put`, `patch`, and `delete`). + * @methods Alias for `method`, provided for better readability when listing multiple methods. Takes precedence over `method` when both are passed. * @action Default action to specify if the value for the `[action]` placeholder is not provided. * @mapKey Whether or not to enable a `[key]` matcher, enabling a `[controller]/[action]/[key]` pattern. * @mapFormat Whether or not to add an optional `.[format]` pattern to the end of the generated routes. This is useful for providing formats via URL like `json`, `xml`, `pdf`, etc. @@ -197,12 +202,18 @@ component { string method = "get", string action = "index", boolean mapKey = false, - boolean mapFormat = false + boolean mapFormat = false, + string methods ) { - if (StructKeyExists(arguments, "methods") && Len(arguments.methods)) { - local.methods = arguments.methods; - } else if (Len(arguments.method)) { - local.methods = ListToArray(arguments.method); + // Accept either `method` or `methods` (mirroring $match's aliasing), with `methods` + // taking precedence. An empty string generates the wildcard routes for all verbs. + if (StructKeyExists(arguments, "methods")) { + local.methodList = arguments.methods; + } else { + local.methodList = arguments.method; + } + if (Len(local.methodList)) { + local.methods = ListToArray(local.methodList); } else { local.methods = ["get", "post", "put", "patch", "delete"]; } @@ -339,6 +350,16 @@ component { } } + // On BoxLang an unpassed optional `pattern` argument surfaces as a + // present-but-null key (Lucee/Adobe treat it as absent). Strip it so the + // name->pattern derivation below runs identically on every engine — deriving + // `hyphenize(name)` for a named route, and letting the "pattern or name + // required" guard fire when neither is supplied — instead of a present-null + // key skipping derivation and leaving a null/empty pattern on BoxLang. + if (StructKeyExists(arguments, "pattern") && IsNull(arguments.pattern)) { + StructDelete(arguments, "pattern"); + } + // Pull route name from arguments if it exists. local.name = ""; if (StructKeyExists(arguments, "name")) { @@ -538,9 +559,30 @@ component { * @values A comma-delimited list of allowed values (e.g., `"active,inactive,pending"`). */ public struct function whereIn(required string variableName, required string values) { - local.pattern = Replace(arguments.values, ",", "|", "all"); - local.pattern = Replace(local.pattern, " ", "", "all"); - return $applyConstraintToLastRoute(arguments.variableName, "(?:#local.pattern#)"); + // The values are literal strings, not regex source: trim each item and escape regex + // metacharacters so values like "readme.txt" match exactly instead of widening the + // constraint (an unescaped "." would match any character). The escaping is done with + // a character scan rather than ReReplace because backslash handling in regex + // replacement strings differs across CFML engines (Lucee 7 turns a `\\` replacement + // into two literal backslashes, producing a constraint that never matches). + local.metaChars = "\^$.|?*+()[]{}"; + local.escaped = []; + for (local.item in ListToArray(arguments.values)) { + local.item = Trim(local.item); + if (Len(local.item)) { + local.escapedItem = ""; + local.itemLength = Len(local.item); + for (local.i = 1; local.i <= local.itemLength; local.i++) { + local.char = Mid(local.item, local.i, 1); + if (Find(local.char, local.metaChars)) { + local.escapedItem &= "\"; + } + local.escapedItem &= local.char; + } + ArrayAppend(local.escaped, local.escapedItem); + } + } + return $applyConstraintToLastRoute(arguments.variableName, "(?:#ArrayToList(local.escaped, "|")#)"); } /** @@ -562,10 +604,8 @@ component { * Supports comma-delimited variable names to constrain multiple variables at once. */ public struct function $applyConstraintToLastRoute(required string variableName, required string pattern) { - // Use this.getRoutes() to access routes via the Mapper's own method, - // which returns variables.routes from the Mapper's scope. This avoids - // both application-scope access issues on Adobe CF and potential mixin - // scope issues where `variables` may not reference the Mapper's state. + // getRoutes() returns the Mapper's variables.routes array by reference, so the + // constraint updates below apply to the live route registrations. local.routes = this.getRoutes(); local.routeCount = ArrayLen(local.routes); if (local.routeCount == 0) { @@ -581,8 +621,8 @@ component { local.lastRoute = local.routes[local.routeCount]; local.lastRouteName = StructKeyExists(local.lastRoute, "name") ? local.lastRoute.name : ""; - local.variables = ListToArray(arguments.variableName); - for (local.varName in local.variables) { + local.variableNames = ListToArray(arguments.variableName); + for (local.varName in local.variableNames) { local.varName = Trim(local.varName); // Walk backward through routes to find all variants of the same named route. @@ -597,13 +637,18 @@ component { // Only update if this variable exists in the route's pattern. if (Find("[#local.varName#]", local.route.pattern)) { - if (!StructKeyExists(local.route, "constraints")) { - local.route.constraints = {}; - } - local.route.constraints[local.varName] = arguments.pattern; + local.newConstraints = StructKeyExists(local.route, "constraints") ? StructCopy(local.route.constraints) : {}; + local.newConstraints[local.varName] = arguments.pattern; + + // Recompile the regex with the new constraint and validate it before + // touching the route, so an invalid constraint pattern fails here at + // draw time with Wheels.InvalidRegex instead of throwing a raw + // PatternSyntaxException on every request that scans this route. + local.newRegex = $patternToRegex(local.route.pattern, local.newConstraints); + $compileRegex(regex = local.newRegex, pattern = local.route.pattern, name = local.routeName); - // Recompile the regex with the new constraint. - local.route.regex = $patternToRegex(local.route.pattern, local.route.constraints); + local.route.constraints = local.newConstraints; + local.route.regex = local.newRegex; // Write modified route back to the local array reference. local.routes[local.i] = local.route; @@ -619,11 +664,7 @@ component { // Sync routes back to application scope. On Lucee/BoxLang, routes // are pass-by-reference so this is redundant but harmless. On Adobe CF, // this ensures the application-scoped copy reflects the modifications. - local.appKey = "wheels"; - if (StructKeyExists(application, "$wheels")) { - local.appKey = "$wheels"; - } - application[local.appKey].routes = local.routes; + application[$appKey()].routes = local.routes; return this; } diff --git a/vendor/wheels/mapper/resources.cfc b/vendor/wheels/mapper/resources.cfc index fb2eb01dcc..0efccb9b9a 100644 --- a/vendor/wheels/mapper/resources.cfc +++ b/vendor/wheels/mapper/resources.cfc @@ -100,18 +100,27 @@ component { local.args.collectionPath = arguments.path; // Consider only / except REST routes for resources. - // Allow arguments.only to override local.args.only. + // Allow arguments.only to override local.args.actions. + // Normalize (trim + lowercase) each item so values like "Index" or " show" still match. if (StructKeyExists(arguments, "only")) { - local.args.actions = LCase(arguments.only); + local.args.actions = $normalizeRestActions(arguments.only); } - // Remove unwanted routes from local.args.only. + // Remove unwanted routes from local.args.actions. + // Filter with array operations on normalized values so case or whitespace + // differences (e.g. except="Delete" or except="new, delete") cannot leave + // an excluded (potentially destructive) route registered. if (StructKeyExists(arguments, "except") && ListLen(arguments.except) > 0) { - local.except = ListToArray(arguments.except); - local.iEnd = ArrayLen(local.except); + local.except = ListToArray($normalizeRestActions(arguments.except)); + local.actions = ListToArray(local.args.actions); + local.remaining = []; + local.iEnd = ArrayLen(local.actions); for (local.i = 1; local.i <= local.iEnd; local.i++) { - local.args.actions = ReReplace(local.args.actions, "\b#local.except[local.i]#\b(,?|$)", ""); + if (!ArrayFindNoCase(local.except, local.actions[local.i])) { + ArrayAppend(local.remaining, local.actions[local.i]); + } } + local.args.actions = ArrayToList(local.remaining); } // If controller name was passed, use it. @@ -248,4 +257,36 @@ component { public struct function collection() { return scope(path = variables.scopeStack[1].collectionPath, $call = "collection"); } + + /** + * Internal function. + * Normalizes a comma-delimited list of REST action names used by the `only` / + * `except` arguments: trims and lowercases each item and drops empty entries so + * filtering matches reliably. When `showErrorInformation` is enabled (i.e. in + * development), unknown action names throw instead of being silently ignored. + */ + public string function $normalizeRestActions(required string actions) { + local.validActions = "index,new,create,show,edit,update,delete"; + local.items = ListToArray(arguments.actions); + local.rv = []; + local.iEnd = ArrayLen(local.items); + for (local.i = 1; local.i <= local.iEnd; local.i++) { + local.item = LCase(Trim(local.items[local.i])); + if (!Len(local.item)) { + continue; + } + if (!ListFindNoCase(local.validActions, local.item)) { + if ($get("showErrorInformation")) { + Throw( + type = "Wheels.InvalidResource", + message = "`#local.item#` is not a valid REST action name for the `only` / `except` arguments.", + detail = "Valid action names are: #local.validActions#." + ); + } + continue; + } + ArrayAppend(local.rv, local.item); + } + return ArrayToList(local.rv); + } } diff --git a/vendor/wheels/mapper/scoping.cfc b/vendor/wheels/mapper/scoping.cfc index 9d4b4de172..68379da5a0 100644 --- a/vendor/wheels/mapper/scoping.cfc +++ b/vendor/wheels/mapper/scoping.cfc @@ -13,6 +13,7 @@ component { * @shallowPath Shallow path prefix. * @shallowName Shallow name prefix. * @constraints Variable patterns to use for matching. + * @callback A callback function to define nested routes within this scope. If provided, the scope is automatically closed when the callback completes. */ public struct function scope( string name, @@ -25,8 +26,19 @@ component { struct constraints, any middleware, any binding, + any callback, string $call = "scope" ) { + // Consume `callback` before it can land on the scope stack. Mirrors group(): + // without this, the extra named argument was silently dropped — the callback + // never ran (its routes 404'd) and nothing closed the scope, so every route + // declared after inherited this scope's path and middleware. namespace(), + // package(), and controller() forward here, so they inherit this support too. + if (StructKeyExists(arguments, "callback")) { + local.callback = arguments.callback; + StructDelete(arguments, "callback"); + } + // Set shallow path and prefix if not in a resource. if (!ListFindNoCase("resource,resources", variables.scopeStack[1].$call)) { if (!StructKeyExists(arguments, "shallowPath") && StructKeyExists(arguments, "path")) { @@ -90,6 +102,15 @@ component { } ArrayPrepend(variables.scopeStack, arguments); + // If a callback was provided, execute it to declare the nested routes and + // auto-close the scope — keeping namespace()/package()/controller() consistent + // with group()/resources(). Use IsCustomFunction (as group() does) so the same + // closure shape works across Lucee, Adobe, and BoxLang. + if (StructKeyExists(local, "callback") && IsCustomFunction(local.callback)) { + local.callback(this); + end(); + } + return this; } @@ -108,7 +129,9 @@ component { string package = arguments.name, string path = hyphenize(arguments.name) ) { - return scope(name = arguments.name, package = arguments.package, path = arguments.path, $call = "namespace"); + // Forward all arguments so scope() options like middleware and binding are + // not silently dropped. + return scope(argumentCollection = arguments, $call = "namespace"); } /** @@ -121,7 +144,9 @@ component { * @package Subfolder (package) to reference for controllers. This defaults to the value provided for `name`. */ public struct function package(required string name, string package = arguments.name) { - return scope(name = arguments.name, package = arguments.package, $call = "package"); + // Forward all arguments so scope() options like middleware and binding are + // not silently dropped. + return scope(argumentCollection = arguments, $call = "package"); } /** @@ -165,20 +190,16 @@ component { struct constraints, any callback ) { + // Forward every passed argument so scope() options like middleware and + // binding are not silently dropped. Only `callback` is consumed by group() + // itself. (api() and version() delegate here, so they inherit this too.) local.args = {}; - local.args.$call = "group"; - - if (StructKeyExists(arguments, "name")) { - local.args.name = arguments.name; - } - - if (StructKeyExists(arguments, "path")) { - local.args.path = arguments.path; - } - - if (StructKeyExists(arguments, "constraints")) { - local.args.constraints = arguments.constraints; + for (local.key in arguments) { + if (local.key != "callback" && StructKeyExists(arguments, local.key) && !IsNull(arguments[local.key])) { + local.args[local.key] = arguments[local.key]; + } } + local.args.$call = "group"; scope(argumentCollection = local.args); diff --git a/vendor/wheels/middleware/AuthMiddleware.cfc b/vendor/wheels/middleware/AuthMiddleware.cfc index 74188d14bf..4f0b8623ac 100644 --- a/vendor/wheels/middleware/AuthMiddleware.cfc +++ b/vendor/wheels/middleware/AuthMiddleware.cfc @@ -33,6 +33,9 @@ component implements="wheels.middleware.MiddlewareInterface" output="false" { * application.$wheels.authenticator or application.wheels.authenticator at request time. * @strategies Comma-delimited list or array of strategy names to restrict authentication to. * If empty, all registered strategies are tried. Useful for per-route strategy selection. + * When set, the authenticator must expose authenticateWith(request, strategies) + * (wheels.auth.Authenticator does; previously the restricted path required the + * equally non-interface getStrategy()). * @onFailure Optional callback invoked on authentication failure. Receives (request, authResult) * and must return a response string. If not provided, returns a JSON error with the * appropriate HTTP status code. @@ -69,9 +72,12 @@ component implements="wheels.middleware.MiddlewareInterface" output="false" { public string function handle(required struct request, required any next) { local.auth = $resolveAuthenticator(); - // Authenticate — restricted to specific strategies or all + // Authenticate — restricted to specific strategies or all. Strategy + // filtering is delegated to the Authenticator so the restricted path + // shares its diagnostics (zero registered strategies, unknown strategy + // names) and AuthResult construction. if (ArrayLen(variables.strategies)) { - local.result = $authenticateWithStrategies(local.auth, arguments.request); + local.result = local.auth.authenticateWith(request = arguments.request, strategies = variables.strategies); } else { local.result = local.auth.authenticate(arguments.request); } @@ -128,53 +134,6 @@ component implements="wheels.middleware.MiddlewareInterface" output="false" { ); } - /** - * Authenticate using only the specified strategies from the registry. - * Strategies are tried in the order listed in the strategies array. - */ - private struct function $authenticateWithStrategies(required any auth, required struct request) { - local.lastError = ""; - local.lastStatusCode = 401; - - for (local.strategyName in variables.strategies) { - if (!arguments.auth.hasStrategy(local.strategyName)) { - continue; - } - - local.strategy = arguments.auth.getStrategy(local.strategyName); - - if (!local.strategy.supports(arguments.request)) { - continue; - } - - local.result = local.strategy.authenticate(arguments.request); - - if (local.result.success) { - // Stamp strategy name if not already set - if (!Len(local.result.strategy)) { - local.result.strategy = local.strategyName; - } - return local.result; - } - - local.lastError = local.result.error; - local.lastStatusCode = local.result.statusCode; - } - - // All specified strategies failed or were not found - if (!Len(local.lastError)) { - local.lastError = "No authentication strategy supports this request"; - } - - return { - success = false, - principal = {}, - strategy = "", - error = local.lastError, - statusCode = local.lastStatusCode - }; - } - /** * Produce the default JSON error response and set the HTTP status code. * Only sets cfheader when running inside a real HTTP dispatch (not during tests). diff --git a/vendor/wheels/middleware/Cors.cfc b/vendor/wheels/middleware/Cors.cfc index 223783326e..a90f56cab5 100644 --- a/vendor/wheels/middleware/Cors.cfc +++ b/vendor/wheels/middleware/Cors.cfc @@ -10,7 +10,7 @@ component implements="wheels.middleware.MiddlewareInterface" output="false" { /** * Creates the CORS middleware with configurable options. * - * @allowOrigins Comma-delimited list of allowed origins, or "*" for any origin. Defaults to "" (no origins allowed). You must explicitly configure allowed origins for CORS to function. + * @allowOrigins Comma-delimited list of allowed origins, or "*" for any origin. Whitespace around entries is ignored. Defaults to "" (no origins allowed). You must explicitly configure allowed origins for CORS to function. * @allowMethods Comma-delimited list of allowed HTTP methods. * @allowHeaders Comma-delimited list of allowed request headers. * @allowCredentials Whether to allow credentials (cookies, auth headers). @@ -27,7 +27,7 @@ component implements="wheels.middleware.MiddlewareInterface" output="false" { // Access-Control-Allow-Credentials: true. Browsers silently // reject the response, which often leads developers to weaken // security further. Fail fast with a clear message instead. - if (arguments.allowOrigins == "*" && arguments.allowCredentials) { + if (Trim(arguments.allowOrigins) == "*" && arguments.allowCredentials) { Throw( type = "Wheels.Cors.InvalidConfiguration", message = "CORS misconfiguration: allowOrigins=""*"" cannot be combined with allowCredentials=true. " @@ -36,7 +36,18 @@ component implements="wheels.middleware.MiddlewareInterface" output="false" { ); } - variables.allowOrigins = arguments.allowOrigins; + // Normalize allowOrigins once: split the comma-delimited list and trim + // each entry so configs like "https://a.com, https://b.com" match every + // origin. Without this, entries after a space keep their leading + // whitespace inside the list element and silently never match. + local.normalizedOrigins = []; + for (local.entry in ListToArray(arguments.allowOrigins)) { + if (Len(Trim(local.entry))) { + ArrayAppend(local.normalizedOrigins, Trim(local.entry)); + } + } + + variables.allowOrigins = ArrayToList(local.normalizedOrigins); variables.allowMethods = arguments.allowMethods; variables.allowHeaders = arguments.allowHeaders; variables.allowCredentials = arguments.allowCredentials; diff --git a/vendor/wheels/middleware/MiddlewareOrderResolver.cfc b/vendor/wheels/middleware/MiddlewareOrderResolver.cfc index 9460a0e70d..bfcb4c2f0f 100644 --- a/vendor/wheels/middleware/MiddlewareOrderResolver.cfc +++ b/vendor/wheels/middleware/MiddlewareOrderResolver.cfc @@ -25,16 +25,14 @@ component output="false" { return arguments.entries; } - // 1. Normalize: assign name and priority to each entry. + // 1. Normalize: assign a unique name and priority to each entry + // (duplicate names are warned about and suffixed). local.named = $normalizeEntries(arguments.entries); - // 2. Detect duplicate names and warn. - $warnDuplicateNames(local.named); - - // 3. Build adjacency list and in-degree map from before/after constraints. + // 2. Build adjacency list and in-degree map from before/after constraints. local.graph = $buildGraph(local.named); - // 4. Topological sort with priority tiebreaker (Kahn's algorithm). + // 3. Topological sort with priority tiebreaker (Kahn's algorithm). local.sorted = $topologicalSort(local.named, local.graph); return local.sorted; @@ -42,9 +40,16 @@ component output="false" { /** * Assign a resolved name and numeric priority to each entry. + * Duplicate names are warned about and suffixed so every graph node stays + * unique: duplicates would otherwise collapse into a single node, making + * the topological sort mis-diagnose the unvisited remainder as a circular + * dependency and fall back to priority-only ordering, discarding all + * before/after constraints. Constraints that reference a duplicated name + * apply to the first registration only. */ private array function $normalizeEntries(required array entries) { local.result = []; + local.seen = {}; for (local.entry in arguments.entries) { local.opts = StructKeyExists(local.entry, "options") ? local.entry.options : {}; @@ -59,6 +64,28 @@ component output="false" { local.resolvedName = "middleware_#CreateUUID()#"; } + // Registrant shown in duplicate-name warnings (pluginName may be absent). + local.registrant = StructKeyExists(local.entry, "pluginName") && Len(Trim(local.entry.pluginName)) ? local.entry.pluginName : local.resolvedName; + + // Duplicate name: warn and rename this entry to a unique node name. + local.dupKey = LCase(local.resolvedName); + if (StructKeyExists(local.seen, local.dupKey)) { + WriteLog( + type = "warning", + text = "Wheels middleware ordering: duplicate name '#local.resolvedName#' — " + & "registered by '#local.seen[local.dupKey]#' and '#local.registrant#'. " + & "Both entries are kept, but before/after constraints referencing " + & "'#local.resolvedName#' apply to the first registration only. " + & "Use unique names to avoid ambiguous ordering." + ); + local.suffix = 2; + while (StructKeyExists(local.seen, LCase(local.resolvedName & "__dup" & local.suffix))) { + local.suffix++; + } + local.resolvedName = local.resolvedName & "__dup" & local.suffix; + } + local.seen[LCase(local.resolvedName)] = local.registrant; + // Priority: numeric option or default 10. local.priority = 10; if (StructKeyExists(local.opts, "priority") && IsNumeric(local.opts.priority)) { @@ -97,25 +124,6 @@ component output="false" { return []; } - /** - * Log warnings for duplicate middleware names. - */ - private void function $warnDuplicateNames(required array named) { - local.seen = {}; - for (local.item in arguments.named) { - local.key = LCase(local.item.name); - if (StructKeyExists(local.seen, local.key)) { - WriteLog( - type = "warning", - text = "Wheels middleware ordering: duplicate name '#local.item.name#' — " - & "registered by plugin '#local.seen[local.key]#' and '#local.item.entry.pluginName#'. " - & "Use unique names to avoid ambiguous ordering." - ); - } - local.seen[local.key] = StructKeyExists(local.item.entry, "pluginName") ? local.item.entry.pluginName : local.item.name; - } - } - /** * Build directed graph edges from before/after constraints. * Returns struct with: adjacency (name -> array of names it must come before) diff --git a/vendor/wheels/middleware/RateLimiter.cfc b/vendor/wheels/middleware/RateLimiter.cfc index 4375f1b5d3..ffe98f6ea3 100644 --- a/vendor/wheels/middleware/RateLimiter.cfc +++ b/vendor/wheels/middleware/RateLimiter.cfc @@ -90,22 +90,6 @@ component implements="wheels.middleware.MiddlewareInterface" output="false" { ); } - // A non-positive window is nonsensical and divides by zero in the fixedWindow / tokenBucket math. - if (arguments.windowSeconds <= 0) { - throw( - type = "Wheels.RateLimiter.InvalidConfiguration", - message = "Invalid rate limiter windowSeconds: #arguments.windowSeconds#. Must be a positive number of seconds." - ); - } - - // maxRequests = 0 is a legitimate kill-switch (block everything); negative values are not. - if (arguments.maxRequests < 0) { - throw( - type = "Wheels.RateLimiter.InvalidConfiguration", - message = "Invalid rate limiter maxRequests: #arguments.maxRequests#. Must be zero or greater." - ); - } - variables.maxRequests = arguments.maxRequests; variables.windowSeconds = arguments.windowSeconds; variables.strategy = arguments.strategy; @@ -127,10 +111,25 @@ component implements="wheels.middleware.MiddlewareInterface" output="false" { // Throttle cleanup interval in seconds. variables.cleanupThrottleSeconds = 10; variables.lastCleanup = 0; + // Bounded cleanup scan (#2971): at most cleanupMaxScanKeys keys are + // examined per cleanup pass, starting at a rotating cursor so + // successive passes cover the whole store. + variables.cleanupCursor = 0; + variables.cleanupMaxScanKeys = 1000; // Track whether DB table has been verified. variables.tableVerified = false; + // Datasource for database storage is resolved lazily (see $queryOptions()) because + // middleware is typically constructed in config/settings.cfm, before + // application.wheels.dataSourceName is guaranteed to exist. + variables.datasourceResolved = false; + variables.resolvedDatasource = ""; + + // Throttle markers for database housekeeping (epoch seconds via GetTickCount() / 1000). + variables.lastDbPurge = 0; + variables.lastTableAttempt = 0; + return this; } @@ -145,8 +144,11 @@ component implements="wheels.middleware.MiddlewareInterface" output="false" { if (variables.storage == "memory") { $maybeCleanup(local.now); // Emergency eviction if store is at capacity and this is a new key. + // Routed through the maintenance lock: concurrent requests racing + // an unguarded $evictOldest() each evicted their own 25% headroom, + // purging far more entries than intended (#2971). if (variables.store.size() >= variables.maxStoreSize && !variables.store.containsKey(local.clientKey)) { - $evictOldest(local.now); + $lockedEvictOldest(local.now, local.clientKey); } } @@ -276,7 +278,12 @@ component implements="wheels.middleware.MiddlewareInterface" output="false" { local.resetAt = (local.windowId + 1) * variables.windowSeconds; if (variables.storage == "database") { - return $dbIncrement(arguments.clientKey, local.storeKey, local.resetAt); + // Counter rows get a "c:" namespace prefix so the UNIQUE(store_key) + // schema can hold counter/bucket/event/anchor rows side by side without + // cross-strategy key collisions. The in-memory store key stays + // unprefixed — it is opaque there and the memory cleanup parsers + // (ListLast(key, ":")) are untouched. + return $dbIncrement(arguments.clientKey, "c:" & local.storeKey, local.resetAt); } // In-memory with per-key locking. @@ -444,7 +451,10 @@ component implements="wheels.middleware.MiddlewareInterface" output="false" { } try { - cflock(name = "wheels-ratelimit-cleanup", type = "exclusive", timeout = 1) { + // Shared maintenance lock: periodic cleanup and emergency eviction + // ($lockedEvictOldest) serialize on the same name so only one + // thread mutates the store's bookkeeping at a time (#2971). + cflock(name = "wheels-ratelimit-maintenance", type = "exclusive", timeout = 1) { // Double-check after acquiring lock. if ((arguments.now - variables.lastCleanup) < variables.cleanupThrottleSeconds) { return; @@ -454,8 +464,16 @@ component implements="wheels.middleware.MiddlewareInterface" output="false" { local.currentWindowId = Int(arguments.now / variables.windowSeconds); local.keysToRemove = []; local.keys = variables.store.keySet().toArray(); + local.keyCount = ArrayLen(local.keys); + + // Bounded scan (#2971): examine at most cleanupMaxScanKeys keys + // per pass, starting at a rotating cursor so successive passes + // cover the whole store — the unlucky request that triggers + // cleanup no longer pays a full-store scan at high cardinality. + local.scanLimit = Min(local.keyCount, variables.cleanupMaxScanKeys); - for (local.key in local.keys) { + for (local.offset = 0; local.offset < local.scanLimit; local.offset++) { + local.key = local.keys[((variables.cleanupCursor + local.offset) % local.keyCount) + 1]; local.value = ""; if (variables.store.containsKey(local.key)) { local.value = variables.store.get(local.key); @@ -492,6 +510,10 @@ component implements="wheels.middleware.MiddlewareInterface" output="false" { } } + variables.cleanupCursor = local.keyCount > 0 + ? (variables.cleanupCursor + local.scanLimit) % local.keyCount + : 0; + for (local.key in local.keysToRemove) { variables.store.remove(local.key); } @@ -506,6 +528,39 @@ component implements="wheels.middleware.MiddlewareInterface" output="false" { } } + /** + * Internal probe. Public ONLY so specs can assert the store-bound + * invariant ($storeSize() <= maxStoreSize) without reaching into the + * variables scope. Returns 0 for non-memory storage. + */ + public numeric function $storeSize() { + return variables.storage == "memory" ? variables.store.size() : 0; + } + + /** + * Emergency eviction entry point for handle(): re-checks capacity under + * the shared maintenance lock before evicting, so concurrent requests + * that all saw a full store don't each purge their own 25% headroom + * (the pre-#2971 unguarded path). A lock timeout skips eviction — the + * ConcurrentHashMap stays consistent and the next request retries. + */ + private void function $lockedEvictOldest(required numeric now, required string clientKey) { + try { + cflock(name = "wheels-ratelimit-maintenance", type = "exclusive", timeout = 1) { + // Double-check: a concurrent evictor may have created headroom + // (or another thread already stored this key) while we waited. + if ( + variables.store.size() >= variables.maxStoreSize + && !variables.store.containsKey(arguments.clientKey) + ) { + $evictOldest(arguments.now); + } + } + } catch (any e) { + // Lock timeout — skip; the store remains internally consistent. + } + } + /** * Evict entries from the in-memory store when it exceeds maxStoreSize. * First removes fully expired entries, then evicts the oldest 25% to create headroom. @@ -560,60 +615,96 @@ component implements="wheels.middleware.MiddlewareInterface" output="false" { return; } - // Second pass: sort remaining entries by age and evict oldest. + // Second pass: approximated oldest-first eviction via bounded + // random sampling (Redis-style). The previous implementation built + // an entries array for the ENTIRE remaining store and ran a full + // closure-comparator sort on the request thread — O(n log n) at + // exactly the moment the store is at its largest. Sampling caps + // the per-eviction work at a small constant while still strongly + // preferring idle entries (#2971). local.remainingToEvict = local.toEvict - local.expiredCount; local.keys = variables.store.keySet().toArray(); - local.entries = []; - for (local.key in local.keys) { - local.age = 0; - if (variables.store.containsKey(local.key)) { - local.value = variables.store.get(local.key); - - if (variables.strategy == "fixedWindow" && Find(":", local.key)) { - local.windowId = Val(ListLast(local.key, ":")); - local.age = local.currentWindowId - local.windowId; - } else if (variables.strategy == "slidingWindow" && IsArray(local.value) && ArrayLen(local.value) > 0) { - local.age = arguments.now - local.value[1]; - } else if (variables.strategy == "tokenBucket" && IsStruct(local.value) && StructKeyExists(local.value, "lastRefill")) { - local.age = arguments.now - local.value.lastRefill; - } - } - ArrayAppend(local.entries, {key: local.key, age: local.age}); + local.keyCount = ArrayLen(local.keys); + if (local.keyCount == 0) { + return; } - - // Sort by age descending (oldest first). - ArraySort(local.entries, function(a, b) { - return (b.age < a.age) ? -1 : ((b.age > a.age) ? 1 : 0); - }); - - // Evict the oldest entries. + local.sampleSize = 8; local.evicted = 0; - for (local.entry in local.entries) { - if (local.evicted >= local.remainingToEvict) { - break; + // Attempt bound: repeated samples can land on already-evicted keys, + // so cap total iterations to keep the worst case bounded too. + local.maxAttempts = local.remainingToEvict * 4; + local.attempts = 0; + while (local.evicted < local.remainingToEvict && local.attempts < local.maxAttempts) { + local.attempts++; + local.bestKey = ""; + local.bestAge = -1; + for (local.s = 1; local.s <= local.sampleSize; local.s++) { + local.candidate = local.keys[RandRange(1, local.keyCount)]; + if (!variables.store.containsKey(local.candidate)) { + continue; + } + local.age = $entryAge(local.candidate, arguments.now, local.currentWindowId); + if (local.age > local.bestAge) { + local.bestAge = local.age; + local.bestKey = local.candidate; + } + } + if (Len(local.bestKey)) { + variables.store.remove(local.bestKey); + local.evicted++; } - variables.store.remove(local.entry.key); - local.evicted++; } } catch (any e) { // Best-effort eviction — don't let errors propagate. } } + /** + * Age score for an in-memory store entry (higher = older / more idle). + * Entries whose age cannot be determined score 0 (youngest), so they are + * evicted last — same semantics the pre-sampling sort used. + */ + private numeric function $entryAge(required string storeKey, required numeric now, required numeric currentWindowId) { + local.age = 0; + local.value = variables.store.get(arguments.storeKey); + if (IsNull(local.value)) { + return local.age; + } + if (variables.strategy == "fixedWindow" && Find(":", arguments.storeKey)) { + local.windowId = Val(ListLast(arguments.storeKey, ":")); + local.age = arguments.currentWindowId - local.windowId; + } else if (variables.strategy == "slidingWindow" && IsArray(local.value) && ArrayLen(local.value) > 0) { + local.age = arguments.now - local.value[1]; + } else if (variables.strategy == "tokenBucket" && IsStruct(local.value) && StructKeyExists(local.value, "lastRefill")) { + local.age = arguments.now - local.value.lastRefill; + } + return local.age; + } + // --------------------------------------------------------------------------- // Database Storage // --------------------------------------------------------------------------- /** * Database-backed fixed window increment. + * On engines with a native atomic upsert (MySQL/MariaDB, PostgreSQL, SQLite) the + * counter row is created-or-incremented in a single statement keyed on the + * UNIQUE(store_key) index. Everywhere else (SQL Server, Oracle, H2, unrecognized + * engines) an UPDATE-first algorithm runs: increment the existing counter row, + * INSERT when no row exists yet, and treat an insert failure as having lost the + * first-insert race — the UNIQUE index turns that race into a caught constraint + * violation, after which a single re-read returns the concurrent row. */ private struct function $dbIncrement(required string clientKey, required string storeKey, required numeric resetAt) { - $ensureTable(); + if (!$ensureTable()) { + local.err = $handleError("table unavailable", arguments.clientKey); + return {allowed: local.err.allowed, remaining: local.err.remaining, resetAt: arguments.resetAt}; + } // Kill-switch: maxRequests = 0 blocks every request. Short-circuit before the // INSERT path, which would otherwise allow the first request per window through // because local.allowed is initialised to true and the counter > maxRequests - // check (line below) only fires from the UPDATE branch on subsequent requests. + // check (line below) only fires once a counter row exists. if (variables.maxRequests == 0) { return {allowed: false, remaining: 0, resetAt: arguments.resetAt}; } @@ -622,164 +713,652 @@ component implements="wheels.middleware.MiddlewareInterface" output="false" { local.remaining = variables.maxRequests; try { - // Try to insert a new row. - QueryExecute( - "INSERT INTO wheels_rate_limits (store_key, counter, expires_at) VALUES (:storeKey, 1, :expiresAt)", - {storeKey: {value: arguments.storeKey, cfsqltype: "cf_sql_varchar"}, expiresAt: {value: DateAdd("s", variables.windowSeconds, Now()), cfsqltype: "cf_sql_timestamp"}} - ); - local.remaining = variables.maxRequests - 1; - } catch (any e) { - // Row exists — update the counter. - try { - local.qUpdate = QueryExecute( - "UPDATE wheels_rate_limits SET counter = counter + 1 WHERE store_key = :storeKey", - {storeKey: {value: arguments.storeKey, cfsqltype: "cf_sql_varchar"}} - ); - // Read current count. - local.qCount = QueryExecute( - "SELECT counter FROM wheels_rate_limits WHERE store_key = :storeKey", - {storeKey: {value: arguments.storeKey, cfsqltype: "cf_sql_varchar"}} - ); - if (local.qCount.recordCount && local.qCount.counter > variables.maxRequests) { - local.allowed = false; - local.remaining = 0; - } else if (local.qCount.recordCount) { - local.remaining = variables.maxRequests - local.qCount.counter; + $dbPurgeExpired(); + + if (ListFindNoCase("mysql,postgresql,sqlite", $detectDatabaseType())) { + local.count = $dbAtomicIncrement(arguments.storeKey, arguments.clientKey); + } else { + local.count = $dbUpdateAndCount(arguments.storeKey); + if (local.count == -1) { + // No counter row for this window yet — create it. + if ($dbTryInsert(arguments.storeKey, arguments.clientKey)) { + local.count = 1; + } else { + // Lost the first-insert race to a concurrent request — re-read once. + local.count = $dbUpdateAndCount(arguments.storeKey); + } } - } catch (any e2) { - local.err = $handleError("DB error", arguments.clientKey); - local.allowed = local.err.allowed; - local.remaining = local.err.remaining; } + if (local.count == -1) { + // Still no row — surface as a DB error via the catch below. + throw( + type = "Wheels.RateLimiter.StoreUnavailable", + message = "The wheels_rate_limits counter row could not be created or read." + ); + } + + if (local.count > variables.maxRequests) { + local.allowed = false; + local.remaining = 0; + } else { + local.remaining = variables.maxRequests - local.count; + } + } catch (any e) { + local.err = $handleError("DB error", arguments.clientKey); + local.allowed = local.err.allowed; + local.remaining = local.err.remaining; } return {allowed: local.allowed, remaining: local.remaining, resetAt: arguments.resetAt}; } /** - * Database-backed sliding window check. + * Atomically create-or-increment the counter row via the engine's native upsert. + * Only called for engines whose dialect is known: MySQL/MariaDB (ON DUPLICATE KEY + * UPDATE) and PostgreSQL/SQLite (ON CONFLICT ... DO UPDATE). Both forms require the + * UNIQUE index on store_key — without it MySQL's upsert silently always-inserts, + * which is why $ensureTable() refuses to leave a table without that index behind. + * The upsert-then-select pair is not atomic as a pair, but an interleaved request + * can only make the re-read count HIGHER (stricter enforcement), never lower. */ - private struct function $dbSlidingWindow(required string clientKey, required numeric now, required numeric windowStart, required numeric resetAt) { - $ensureTable(); + private numeric function $dbAtomicIncrement(required string storeKey, required string clientKey) { + if ($detectDatabaseType() == "mysql") { + local.sql = "INSERT INTO wheels_rate_limits (store_key, client_key, row_type, counter, expires_at) VALUES (:storeKey, :clientKey, 'counter', 1, :expiresAt) ON DUPLICATE KEY UPDATE counter = counter + 1"; + } else { + // PostgreSQL and SQLite share the ON CONFLICT syntax; both accept the + // table-name-qualified reference to the existing row's counter. + local.sql = "INSERT INTO wheels_rate_limits (store_key, client_key, row_type, counter, expires_at) VALUES (:storeKey, :clientKey, 'counter', 1, :expiresAt) ON CONFLICT (store_key) DO UPDATE SET counter = wheels_rate_limits.counter + 1"; + } + QueryExecute( + local.sql, + { + storeKey: {value: arguments.storeKey, cfsqltype: "cf_sql_varchar"}, + clientKey: {value: arguments.clientKey, cfsqltype: "cf_sql_varchar"}, + expiresAt: {value: DateAdd("s", variables.windowSeconds, Now()), cfsqltype: "cf_sql_timestamp"} + }, + $queryOptions() + ); + local.qCount = QueryExecute( + "SELECT counter FROM wheels_rate_limits WHERE store_key = :storeKey", + {storeKey: {value: arguments.storeKey, cfsqltype: "cf_sql_varchar"}}, + $queryOptions() + ); + if (!local.qCount.recordCount || !IsNumeric(local.qCount.counter)) { + return -1; + } + return local.qCount.counter; + } - local.allowed = true; - local.remaining = variables.maxRequests; - local.expiresAt = DateAdd("s", variables.windowSeconds, Now()); + /** + * Increment the counter for a store key and return the resulting count. + * Returns -1 when no counter row exists yet (MAX() over zero rows returns a single + * row with NULL, so IsNumeric — not recordCount — is the reliable "no row" signal). + */ + private numeric function $dbUpdateAndCount(required string storeKey) { + QueryExecute( + "UPDATE wheels_rate_limits SET counter = counter + 1 WHERE store_key = :storeKey", + {storeKey: {value: arguments.storeKey, cfsqltype: "cf_sql_varchar"}}, + $queryOptions() + ); + local.qCount = QueryExecute( + "SELECT MAX(counter) AS counter FROM wheels_rate_limits WHERE store_key = :storeKey", + {storeKey: {value: arguments.storeKey, cfsqltype: "cf_sql_varchar"}}, + $queryOptions() + ); + if (!IsNumeric(local.qCount.counter)) { + return -1; + } + return local.qCount.counter; + } + /** + * Insert the first counter row for a store key. + * Returns false when the insert fails (losing the first-insert race against a + * concurrent request trips the UNIQUE store_key index) so the caller can re-read + * instead — the constraint-backed insert-retry idiom for engines without a + * portable native upsert. + */ + private boolean function $dbTryInsert(required string storeKey, required string clientKey) { try { - // Clean expired entries for this client. QueryExecute( - "DELETE FROM wheels_rate_limits WHERE store_key = :clientKey AND expires_at < :now", - {clientKey: {value: arguments.clientKey, cfsqltype: "cf_sql_varchar"}, now: {value: Now(), cfsqltype: "cf_sql_timestamp"}} + "INSERT INTO wheels_rate_limits (store_key, client_key, row_type, counter, expires_at) VALUES (:storeKey, :clientKey, 'counter', 1, :expiresAt)", + { + storeKey: {value: arguments.storeKey, cfsqltype: "cf_sql_varchar"}, + clientKey: {value: arguments.clientKey, cfsqltype: "cf_sql_varchar"}, + expiresAt: {value: DateAdd("s", variables.windowSeconds, Now()), cfsqltype: "cf_sql_timestamp"} + }, + $queryOptions() ); + return true; + } catch (any e) { + return false; + } + } - // Count current entries. - local.qCount = QueryExecute( - "SELECT COUNT(*) AS cnt FROM wheels_rate_limits WHERE store_key = :clientKey", - {clientKey: {value: arguments.clientKey, cfsqltype: "cf_sql_varchar"}} - ); + /** + * Select a row by store key with a cross-node row lock where the engine supports + * one: SELECT ... FOR UPDATE on PostgreSQL/MySQL/Oracle/H2, an UPDLOCK/ROWLOCK + * table hint on SQL Server. SQLite and unrecognized engines get a plain SELECT — + * there the serialization guarantee comes from the caller's in-process cflock + * only (sufficient on a single node, not a multi-node guarantee). + * Must be called inside a transaction for the lock to be held. + */ + private query function $dbRowLockSelect(required string storeKey) { + local.dbType = $detectDatabaseType(); + if (local.dbType == "sqlserver") { + local.sql = "SELECT counter, expires_at FROM wheels_rate_limits WITH (UPDLOCK, ROWLOCK) WHERE store_key = :storeKey"; + } else if (ListFindNoCase("postgresql,mysql,oracle,h2", local.dbType)) { + local.sql = "SELECT counter, expires_at FROM wheels_rate_limits WHERE store_key = :storeKey FOR UPDATE"; + } else { + local.sql = "SELECT counter, expires_at FROM wheels_rate_limits WHERE store_key = :storeKey"; + } + return QueryExecute( + local.sql, + {storeKey: {value: arguments.storeKey, cfsqltype: "cf_sql_varchar"}}, + $queryOptions() + ); + } - if (local.qCount.cnt >= variables.maxRequests) { - local.allowed = false; - local.remaining = 0; - } else { - // Insert a new timestamp entry. + /** + * Insert a wheels_rate_limits row if no row with the same store key exists yet, + * without ever surfacing the duplicate-key violation to the enclosing + * transaction. Uses the engine's insert-if-absent form where one exists + * (MySQL/MariaDB, PostgreSQL, SQLite, SQL Server); elsewhere a plain INSERT + * with the violation swallowed. The conditional forms are load-bearing on + * PostgreSQL and SQL Server, not style: on PostgreSQL ANY raised statement + * error — even one caught in CFML — aborts the open transaction + * (SQLSTATE 25P02), dooming every follow-up statement including the re-read + * that recovers from losing a first-insert race. On SQL Server with + * `SET XACT_ABORT ON` (a non-default but common enterprise/DBA setting), a + * UNIQUE constraint violation dooms the transaction in the same way, so + * the subsequent $dbRowLockSelect would throw error 3930 against a doomed + * transaction and the outer catch would fall back to fail-open. The SQL + * Server branch therefore uses MERGE INTO ... WITH (HOLDLOCK) ... WHEN NOT + * MATCHED THEN INSERT, which serializes concurrent insert-if-absent attempts + * via a key-range lock and never raises a duplicate-key error in normal + * operation. The remaining try/catch branch covers Oracle, H2, and + * unrecognized engines, which all survive a failed statement with the + * transaction intact under their default settings. + */ + private void function $dbEnsureRow( + required string storeKey, + required string clientKey, + required string rowType, + required numeric counter, + required date expiresAt + ) { + local.params = { + storeKey: {value: arguments.storeKey, cfsqltype: "cf_sql_varchar"}, + clientKey: {value: arguments.clientKey, cfsqltype: "cf_sql_varchar"}, + rowType: {value: arguments.rowType, cfsqltype: "cf_sql_varchar"}, + counter: {value: arguments.counter, cfsqltype: "cf_sql_integer"}, + expiresAt: {value: arguments.expiresAt, cfsqltype: "cf_sql_timestamp"} + }; + local.dbType = $detectDatabaseType(); + if (local.dbType == "mysql") { + QueryExecute( + "INSERT INTO wheels_rate_limits (store_key, client_key, row_type, counter, expires_at) VALUES (:storeKey, :clientKey, :rowType, :counter, :expiresAt) ON DUPLICATE KEY UPDATE counter = counter", + local.params, + $queryOptions() + ); + } else if (ListFindNoCase("postgresql,sqlite", local.dbType)) { + QueryExecute( + "INSERT INTO wheels_rate_limits (store_key, client_key, row_type, counter, expires_at) VALUES (:storeKey, :clientKey, :rowType, :counter, :expiresAt) ON CONFLICT (store_key) DO NOTHING", + local.params, + $queryOptions() + ); + } else if (local.dbType == "sqlserver") { + QueryExecute( + "MERGE INTO wheels_rate_limits WITH (HOLDLOCK) AS target USING (SELECT :storeKey AS store_key) AS source ON target.store_key = source.store_key WHEN NOT MATCHED THEN INSERT (store_key, client_key, row_type, counter, expires_at) VALUES (:storeKey, :clientKey, :rowType, :counter, :expiresAt);", + local.params, + $queryOptions() + ); + } else { + try { QueryExecute( - "INSERT INTO wheels_rate_limits (store_key, counter, expires_at) VALUES (:clientKey, 1, :expiresAt)", - {clientKey: {value: arguments.clientKey, cfsqltype: "cf_sql_varchar"}, expiresAt: {value: local.expiresAt, cfsqltype: "cf_sql_timestamp"}} + "INSERT INTO wheels_rate_limits (store_key, client_key, row_type, counter, expires_at) VALUES (:storeKey, :clientKey, :rowType, :counter, :expiresAt)", + local.params, + $queryOptions() ); - local.remaining = variables.maxRequests - local.qCount.cnt - 1; + } catch (any e) { + // Duplicate key — a concurrent request created the row first. Fine. } - } catch (any e) { - local.err = $handleError("DB error", arguments.clientKey); - local.allowed = local.err.allowed; - local.remaining = local.err.remaining; } + } - return {allowed: local.allowed, remaining: local.remaining, resetAt: arguments.resetAt}; + /** + * Ensure the per-client anchor row ("a:" prefix) exists for the sliding window + * strategy. The anchor is the single lockable row that serializes the + * delete-count-insert sequence across nodes via $dbRowLockSelect(). + */ + private void function $dbEnsureAnchor(required string clientKey) { + $dbEnsureRow( + storeKey = "a:" & arguments.clientKey, + clientKey = arguments.clientKey, + rowType = "anchor", + counter = 0, + expiresAt = DateAdd("s", variables.windowSeconds, Now()) + ); + } + + /** + * Database-backed sliding window check. + * One "event" row is inserted per allowed request (store_key carries a synthetic + * UUID suffix so the UNIQUE store_key index holds), and the whole + * delete-count-insert read-modify-write sequence runs inside a transaction while + * holding both an in-process cflock and a cross-node row lock on the client's + * "anchor" row (see $dbRowLockSelect for which engines get a real SQL lock). + * Results route through the local.outcome struct: catch-block writes to bare + * local.X don't persist on BoxLang, struct-field writes do. + */ + private struct function $dbSlidingWindow(required string clientKey, required numeric now, required numeric windowStart, required numeric resetAt) { + if (!$ensureTable()) { + local.err = $handleError("table unavailable", arguments.clientKey); + return {allowed: local.err.allowed, remaining: local.err.remaining, resetAt: arguments.resetAt}; + } + + local.outcome = {allowed: true, remaining: variables.maxRequests}; + local.expiresAt = DateAdd("s", variables.windowSeconds, Now()); + + // Global purge stays outside the lock and transaction (it never throws). + $dbPurgeExpired(); + + try { + cflock(name = "wheels-ratelimit-db-#arguments.clientKey#", type = "exclusive", timeout = 1) { + transaction action="begin" { + try { + // Ensure and lock the per-client anchor row — this serializes + // concurrent checks for the same client across nodes. + $dbEnsureAnchor(arguments.clientKey); + $dbRowLockSelect("a:" & arguments.clientKey); + + // Clean expired event rows for this client. + QueryExecute( + "DELETE FROM wheels_rate_limits WHERE client_key = :clientKey AND row_type = 'event' AND expires_at < :now", + {clientKey: {value: arguments.clientKey, cfsqltype: "cf_sql_varchar"}, now: {value: Now(), cfsqltype: "cf_sql_timestamp"}}, + $queryOptions() + ); + + // Count current event rows. + local.qCount = QueryExecute( + "SELECT COUNT(*) AS cnt FROM wheels_rate_limits WHERE client_key = :clientKey AND row_type = 'event'", + {clientKey: {value: arguments.clientKey, cfsqltype: "cf_sql_varchar"}}, + $queryOptions() + ); + + if (local.qCount.cnt >= variables.maxRequests) { + local.outcome.allowed = false; + local.outcome.remaining = 0; + } else { + // Record this request as an event row. + QueryExecute( + "INSERT INTO wheels_rate_limits (store_key, client_key, row_type, counter, expires_at) VALUES (:storeKey, :clientKey, 'event', 1, :expiresAt)", + { + storeKey: {value: "e:" & arguments.clientKey & ":" & CreateUUID(), cfsqltype: "cf_sql_varchar"}, + clientKey: {value: arguments.clientKey, cfsqltype: "cf_sql_varchar"}, + expiresAt: {value: local.expiresAt, cfsqltype: "cf_sql_timestamp"} + }, + $queryOptions() + ); + local.outcome.remaining = variables.maxRequests - local.qCount.cnt - 1; + } + transaction action="commit"; + } catch (any dbError) { + transaction action="rollback"; + local.err = $handleError("DB error", arguments.clientKey); + local.outcome.allowed = local.err.allowed; + local.outcome.remaining = local.err.remaining; + } + } + } + } catch (any lockError) { + local.err = $handleError("lock timeout", arguments.clientKey); + local.outcome.allowed = local.err.allowed; + local.outcome.remaining = local.err.remaining; + } + + return {allowed: local.outcome.allowed, remaining: local.outcome.remaining, resetAt: arguments.resetAt}; } /** * Database-backed token bucket check. + * The bucket row ("b:" prefix) is read under a transaction with a cross-node row + * lock plus an in-process cflock, so the refill-and-consume read-modify-write is + * serialized per client (see $dbRowLockSelect for engine lock support). A cold + * bucket is created FULL via the never-raising insert-if-absent helper + * ($dbEnsureRow) and then read back under the row lock, so the creator and a + * node that lost the first-insert race take the same path: lock whatever row + * exists now, refill, consume. The conditional insert is what keeps the + * recovery reachable on PostgreSQL — a raised duplicate-key violation there + * aborts the open transaction (SQLSTATE 25P02) even when CFML catches it, and + * the re-read would throw "current transaction is aborted" instead of locking + * the winning row. Results route through the local.outcome struct: catch-block + * writes to bare local.X don't persist on BoxLang, struct-field writes do. */ private struct function $dbTokenBucket(required string clientKey, required numeric now, required numeric refillRate, required numeric resetAt) { - $ensureTable(); + if (!$ensureTable()) { + local.err = $handleError("table unavailable", arguments.clientKey); + return {allowed: local.err.allowed, remaining: local.err.remaining, resetAt: arguments.resetAt}; + } - local.allowed = true; - local.remaining = variables.maxRequests; + local.outcome = {allowed: true, remaining: variables.maxRequests}; + local.bucketKey = "b:" & arguments.clientKey; + + // Global purge stays outside the lock and transaction (it never throws). + $dbPurgeExpired(); try { - local.qBucket = QueryExecute( - "SELECT counter, expires_at FROM wheels_rate_limits WHERE store_key = :clientKey", - {clientKey: {value: arguments.clientKey, cfsqltype: "cf_sql_varchar"}} - ); + cflock(name = "wheels-ratelimit-db-#arguments.clientKey#", type = "exclusive", timeout = 1) { + transaction action="begin" { + try { + local.qBucket = $dbRowLockSelect(local.bucketKey); + if (!local.qBucket.recordCount) { + // First request — create the bucket FULL (counter holds the + // tokens, expires_at carries the last-refill time) via the + // insert-if-absent helper, then lock whatever row exists + // afterwards: ours, or a concurrent node's that won the + // first-insert race. Creating full instead of pre-consumed + // lets the refill-and-consume below treat both cases + // identically — a fresh bucket reads as elapsed 0 -> + // maxRequests tokens and consumes one, the same end state + // the old insert-at-maxRequests-minus-one produced. + $dbEnsureRow( + storeKey = local.bucketKey, + clientKey = arguments.clientKey, + rowType = "bucket", + counter = variables.maxRequests, + expiresAt = Now() + ); + local.qBucket = $dbRowLockSelect(local.bucketKey); + if (!local.qBucket.recordCount) { + throw( + type = "Wheels.RateLimiter.StoreUnavailable", + message = "The wheels_rate_limits bucket row could not be created or read." + ); + } + } - if (local.qBucket.recordCount) { - // Calculate token refill. - local.lastRefill = local.qBucket.expires_at; - local.elapsed = DateDiff("s", local.lastRefill, Now()); - local.currentTokens = Min(variables.maxRequests, local.qBucket.counter + (local.elapsed * arguments.refillRate)); + // Calculate token refill on the locked row. + local.elapsed = $secondsSince(local.qBucket.expires_at); + local.currentTokens = Min(variables.maxRequests, local.qBucket.counter + (local.elapsed * arguments.refillRate)); - if (local.currentTokens < 1) { - local.allowed = false; - local.remaining = 0; - } else { - local.currentTokens -= 1; - local.remaining = Int(local.currentTokens); - QueryExecute( - "UPDATE wheels_rate_limits SET counter = :tokens, expires_at = :now WHERE store_key = :clientKey", - { - tokens: {value: Int(local.currentTokens), cfsqltype: "cf_sql_integer"}, - now: {value: Now(), cfsqltype: "cf_sql_timestamp"}, - clientKey: {value: arguments.clientKey, cfsqltype: "cf_sql_varchar"} + if (local.currentTokens < 1) { + local.outcome.allowed = false; + local.outcome.remaining = 0; + } else { + local.currentTokens -= 1; + local.outcome.remaining = Int(local.currentTokens); + QueryExecute( + "UPDATE wheels_rate_limits SET counter = :tokens, expires_at = :now WHERE store_key = :storeKey", + { + tokens: {value: Int(local.currentTokens), cfsqltype: "cf_sql_integer"}, + now: {value: Now(), cfsqltype: "cf_sql_timestamp"}, + storeKey: {value: local.bucketKey, cfsqltype: "cf_sql_varchar"} + }, + $queryOptions() + ); } - ); + transaction action="commit"; + } catch (any dbError) { + transaction action="rollback"; + local.err = $handleError("DB error", arguments.clientKey); + local.outcome.allowed = local.err.allowed; + local.outcome.remaining = local.err.remaining; + } } + } + } catch (any lockError) { + local.err = $handleError("lock timeout", arguments.clientKey); + local.outcome.allowed = local.err.allowed; + local.outcome.remaining = local.err.remaining; + } + + return {allowed: local.outcome.allowed, remaining: local.outcome.remaining, resetAt: arguments.resetAt}; + } + + /** + * Seconds elapsed since a stored timestamp value. SQLite has no real DATETIME + * type — depending on the engine + JDBC driver combination, a cf_sql_timestamp + * binding round-trips as a date object, a datetime string, or a raw + * epoch-milliseconds number (observed on Adobe CF with sqlite-jdbc, where + * IsDate() is false and DateDiff() against the raw number silently produces a + * huge bogus elapsed value that reads every token bucket as fully refilled). + * Normalize: dates/datetime strings go through DateDiff, raw numbers are + * treated as epoch milliseconds against GetTickCount() (epoch ms on both + * Lucee and Adobe). + */ + private numeric function $secondsSince(required any storedTime) { + if (IsDate(arguments.storedTime)) { + return DateDiff("s", arguments.storedTime, Now()); + } + return Int((GetTickCount() - arguments.storedTime) / 1000); + } + + /** + * Resolve query options for database storage. The Wheels datasource is resolved + * lazily (not in init()) because middleware is constructed in config/settings.cfm + * before application.wheels.dataSourceName may be set. Apps relying on a default + * datasource (this.datasource in Application.cfc) keep working: when nothing + * resolves, an empty options struct preserves the previous behavior. + */ + private struct function $queryOptions() { + if (!variables.datasourceResolved) { + try { + if (StructKeyExists(application, "wheels") && StructKeyExists(application.wheels, "dataSourceName")) { + variables.resolvedDatasource = application.wheels.dataSourceName; + variables.datasourceResolved = true; + } + } catch (any e) { + // No application scope available — fall through to the default datasource. + } + } + if (Len(variables.resolvedDatasource)) { + return {datasource: variables.resolvedDatasource}; + } + return {}; + } + + /** + * Detect the database type from the actual datasource via JDBC metadata. + * Returns: "oracle", "postgresql", "h2", "mysql", "sqlserver", "sqlite", or "default". + * The result is memoized after the first successful cfdbinfo call — this now runs + * on the hot request path for dialect SQL selection, and a metadata round-trip per + * request is unacceptable. A failed cfdbinfo returns "default" WITHOUT caching, + * because the datasource may simply not be resolvable yet (middleware is + * constructed before application.wheels.dataSourceName exists). + */ + private string function $detectDatabaseType() { + if (StructKeyExists(variables, "dbTypeCached")) { + return variables.dbTypeCached; + } + try { + local.options = $queryOptions(); + if (StructKeyExists(local.options, "datasource")) { + cfdbinfo(type = "version", datasource = "#local.options.datasource#", name = "local.info"); } else { - // First request — create bucket with maxRequests - 1 tokens. - local.remaining = variables.maxRequests - 1; - QueryExecute( - "INSERT INTO wheels_rate_limits (store_key, counter, expires_at) VALUES (:clientKey, :tokens, :now)", - { - clientKey: {value: arguments.clientKey, cfsqltype: "cf_sql_varchar"}, - tokens: {value: local.remaining, cfsqltype: "cf_sql_integer"}, - now: {value: Now(), cfsqltype: "cf_sql_timestamp"} - } - ); + cfdbinfo(type = "version", name = "local.info"); + } + local.product = local.info.database_productname; + local.detected = "default"; + if (FindNoCase("oracle", local.product)) { + local.detected = "oracle"; + } else if (FindNoCase("postgre", local.product)) { + local.detected = "postgresql"; + } else if (FindNoCase("h2", local.product)) { + local.detected = "h2"; + } else if (FindNoCase("mysql", local.product) || FindNoCase("mariadb", local.product)) { + local.detected = "mysql"; + } else if (FindNoCase("sql server", local.product)) { + local.detected = "sqlserver"; + } else if (FindNoCase("sqlite", local.product)) { + local.detected = "sqlite"; } + variables.dbTypeCached = local.detected; + return local.detected; } catch (any e) { - local.err = $handleError("DB error", arguments.clientKey); - local.allowed = local.err.allowed; - local.remaining = local.err.remaining; + // cfdbinfo not available — fall through to default without caching. } - - return {allowed: local.allowed, remaining: local.remaining, resetAt: arguments.resetAt}; + return "default"; } /** - * Auto-create the wheels_rate_limits table if it doesn't exist. + * Throttled global purge of expired rows so the table doesn't grow without bound. + * The cutoff trails Now() by windowSeconds because the token bucket strategy stores + * its last-refill time in expires_at: a bucket idle longer than windowSeconds is + * fully refilled, so deleting it is semantically a no-op, while purging at Now() + * would wipe live buckets. For fixed/sliding window rows the extra lag is harmless. */ - private void function $ensureTable() { - if (variables.tableVerified) { + private void function $dbPurgeExpired() { + local.nowSeconds = GetTickCount() / 1000; + if ((local.nowSeconds - variables.lastDbPurge) < variables.cleanupThrottleSeconds) { return; } + variables.lastDbPurge = local.nowSeconds; try { QueryExecute( - "CREATE TABLE IF NOT EXISTS wheels_rate_limits ( - id INT AUTO_INCREMENT PRIMARY KEY, - store_key VARCHAR(255) NOT NULL, - counter INT DEFAULT 1, - expires_at TIMESTAMP, - INDEX idx_store_key (store_key), - INDEX idx_expires_at (expires_at) - )" + "DELETE FROM wheels_rate_limits WHERE expires_at < :cutoff", + {cutoff: {value: DateAdd("s", -variables.windowSeconds, Now()), cfsqltype: "cf_sql_timestamp"}}, + $queryOptions() ); } catch (any e) { - // Table may already exist or DB doesn't support IF NOT EXISTS — that's fine. + // Best-effort purge — never block the rate limit check. + } + } + + /** + * Auto-create the wheels_rate_limits table (schema v2) if it doesn't exist, using + * database-appropriate column types. Returns true only when the v2 table — with + * the row_type discriminator, the client_key lookup column, and a UNIQUE + * store_key index — is verified to exist. Legacy v1 tables (no discriminator) + * are dropped and recreated: a copy-migration is deliberately NOT attempted + * because v1 rows carry no row_type (counter rows and sliding-window event rows + * are indistinguishable) and the unindexed v1 table may already hold duplicate + * store_key rows from the first-insert race. Every row is ephemeral + * (TTL <= windowSeconds, idle buckets refill), so dropping resets in-flight + * counters for at most one window. Failed creation attempts are throttled so a + * permanently broken configuration doesn't run DDL on every request, but the + * limiter can still recover once the database becomes available. + */ + private boolean function $ensureTable() { + if (variables.tableVerified) { + return true; + } + + // Throttle re-attempts after a failure so a broken configuration doesn't + // probe and run DDL on every request. + local.nowSeconds = GetTickCount() / 1000; + if (variables.lastTableAttempt > 0 && (local.nowSeconds - variables.lastTableAttempt) < variables.cleanupThrottleSeconds) { + return false; } - variables.tableVerified = true; + // Probe for the v2 schema (extra columns beyond it are fine). + try { + QueryExecute("SELECT row_type, client_key, counter FROM wheels_rate_limits WHERE 1=0", {}, $queryOptions()); + variables.tableVerified = true; + return true; + } catch (any e) { + // Not a v2 table — missing, unreachable, or a legacy v1 shape. Sort out below. + } + + variables.lastTableAttempt = local.nowSeconds; + + // Legacy v1 table: drop it so the v2 CREATE below can run (see docblock for + // why no rows are copied). + try { + QueryExecute("SELECT counter FROM wheels_rate_limits WHERE 1=0", {}, $queryOptions()); + try { + QueryExecute("DROP TABLE wheels_rate_limits", {}, $queryOptions()); + } catch (any dropError) { + // A concurrent node may have dropped it already — fall through to create. + } + writeLog( + text = "Upgraded wheels_rate_limits to schema v2; in-flight rate-limit counters reset (bounded to one window)", + type = "warning", + file = "wheels_ratelimiter" + ); + } catch (any e) { + // No table at all (or unreachable) — try to create it below. + } + + try { + // Use database-appropriate types (same map as wheels.Job's wheels_jobs table). + // SQL Server must get DATETIME — TIMESTAMP means rowversion there and + // rejects explicit inserts. + local.dbType = $detectDatabaseType(); + if (local.dbType == "oracle") { + local.varcharType = "VARCHAR2"; + local.datetimeType = "TIMESTAMP"; + } else if (local.dbType == "postgresql") { + local.varcharType = "VARCHAR"; + local.datetimeType = "TIMESTAMP"; + } else if (local.dbType == "h2") { + local.varcharType = "VARCHAR"; + local.datetimeType = "TIMESTAMP"; + } else { + local.varcharType = "VARCHAR"; + local.datetimeType = "DATETIME"; + } + + QueryExecute(" + CREATE TABLE wheels_rate_limits ( + store_key #local.varcharType#(255) NOT NULL, + client_key #local.varcharType#(255) NOT NULL, + row_type #local.varcharType#(16) NOT NULL, + counter INT, + expires_at #local.datetimeType# + ) + ", {}, $queryOptions()); + + // The UNIQUE index is load-bearing, not an optimization: MySQL's + // ON DUPLICATE KEY UPDATE silently degrades to always-insert without a + // unique key, capping every counter at 1 and disabling enforcement + // entirely. If it can't be created the table must not survive + // (fail-closed) — a non-unique v2 table would pass the probe but break + // the upsert contract. + try { + QueryExecute("CREATE UNIQUE INDEX uq_wrl_store_key ON wheels_rate_limits (store_key)", {}, $queryOptions()); + } catch (any uniqueIndexError) { + try { + QueryExecute("DROP TABLE wheels_rate_limits", {}, $queryOptions()); + } catch (any dropError) { + } + writeLog( + text = "Failed to create the UNIQUE store_key index on wheels_rate_limits — table dropped (fail-closed): #uniqueIndexError.message#", + type = "error", + file = "wheels_ratelimiter" + ); + return false; + } + + // Plain lookup indexes are optional — don't fail table creation if they + // can't be created. + try { + QueryExecute("CREATE INDEX idx_wrl_client_key ON wheels_rate_limits (client_key)", {}, $queryOptions()); + QueryExecute("CREATE INDEX idx_wrl_expires_at ON wheels_rate_limits (expires_at)", {}, $queryOptions()); + } catch (any indexError) { + } + + writeLog(text = "Auto-created wheels_rate_limits table (schema v2)", type = "information", file = "wheels_ratelimiter"); + variables.tableVerified = true; + return true; + } catch (any createError) { + // A concurrent node or thread may have created the table between our probe + // and the CREATE — re-probe once (against the v2 column set) before + // reporting failure. + try { + QueryExecute("SELECT row_type, client_key, counter FROM wheels_rate_limits WHERE 1=0", {}, $queryOptions()); + variables.tableVerified = true; + return true; + } catch (any reprobeError) { + } + writeLog( + text = "Failed to auto-create wheels_rate_limits table: #createError.message#", + type = "error", + file = "wheels_ratelimiter" + ); + return false; + } } } diff --git a/vendor/wheels/middleware/SecurityHeaders.cfc b/vendor/wheels/middleware/SecurityHeaders.cfc index 62d3baee1d..db5a4d3806 100644 --- a/vendor/wheels/middleware/SecurityHeaders.cfc +++ b/vendor/wheels/middleware/SecurityHeaders.cfc @@ -19,7 +19,7 @@ component implements="wheels.middleware.MiddlewareInterface" output="false" { * @strictTransportSecurity Strict-Transport-Security value. Auto-defaults to `max-age=31536000; includeSubDomains` in production when not explicitly set. * @hsts Set to false to suppress the Strict-Transport-Security header entirely, regardless of environment or strictTransportSecurity value. Useful when a TLS-terminating proxy already emits HSTS. * @permissionsPolicy Permissions-Policy value. Empty by default (opt-in) because it is app-specific. - * @environment Application environment (e.g. "production", "development"). When empty, falls back to application.$wheels.environment if available. + * @environment Application environment (e.g. "production", "development"). When empty, falls back to application.$wheels.environment (during application start) and then application.wheels.environment (after start) if available. */ public SecurityHeaders function init( string frameOptions = "SAMEORIGIN", @@ -35,11 +35,18 @@ component implements="wheels.middleware.MiddlewareInterface" output="false" { variables.headers = {}; // Resolve environment: explicit parameter > application.$wheels.environment + // (during application start) > application.wheels.environment (after start). + // onapplicationstart renames application.$wheels to application.wheels when it + // finishes, and route-scoped string middleware is instantiated per request — + // long after the rename — so checking only $wheels would silently disable + // the production HSTS auto-default for those instantiations. local.env = arguments.environment; if (!Len(local.env)) { try { if (StructKeyExists(application, "$wheels") && StructKeyExists(application.$wheels, "environment")) { local.env = application.$wheels.environment; + } else if (StructKeyExists(application, "wheels") && StructKeyExists(application.wheels, "environment")) { + local.env = application.wheels.environment; } } catch (any e) { // application scope may not be available during testing diff --git a/vendor/wheels/middleware/TenantResolver.cfc b/vendor/wheels/middleware/TenantResolver.cfc index 3314f8fd00..03a00fb218 100644 --- a/vendor/wheels/middleware/TenantResolver.cfc +++ b/vendor/wheels/middleware/TenantResolver.cfc @@ -124,13 +124,9 @@ component implements="wheels.middleware.MiddlewareInterface" output="false" { // Expose the extracted subdomain so the resolver can use it arguments.request.$tenantSubdomain = local.subdomain; - // If a custom resolver is provided, pass the request to it - if (!IsSimpleValue(variables.resolver)) { - return variables.resolver(arguments.request); - } - - // Without a resolver, return just the subdomain as the ID (user must provide dataSource via resolver) - return {}; + // Delegate to the resolver (if configured). Without a resolver there is + // no way to map the subdomain to a dataSource, so the tenant stays unresolved. + return $invokeResolver(arguments.request); } /** @@ -159,18 +155,23 @@ component implements="wheels.middleware.MiddlewareInterface" output="false" { // Expose the extracted header value so the resolver can use it arguments.request.$tenantHeaderValue = local.headerValue; - // If a custom resolver is provided, pass the request to it - if (!IsSimpleValue(variables.resolver)) { - return variables.resolver(arguments.request); - } - - return {}; + return $invokeResolver(arguments.request); } /** * Delegate entirely to the user-provided resolver closure. */ private struct function $resolveFromCustom(required struct request) { + return $invokeResolver(arguments.request); + } + + /** + * Invoke the user-provided resolver closure with consistent result handling + * shared by all strategies. Returns an empty struct when no resolver is + * configured or when the resolver returns a non-struct value (such as + * `false`, the not-resolved sentinel). + */ + private struct function $invokeResolver(required struct request) { if (!IsSimpleValue(variables.resolver)) { local.result = variables.resolver(arguments.request); if (IsStruct(local.result)) { diff --git a/vendor/wheels/migrator/AutoMigrator.cfc b/vendor/wheels/migrator/AutoMigrator.cfc index 97d6dca03b..07dd6b9b95 100644 --- a/vendor/wheels/migrator/AutoMigrator.cfc +++ b/vendor/wheels/migrator/AutoMigrator.cfc @@ -179,6 +179,16 @@ component extends="wheels.migrator.Base" { ? arguments.options.heuristicThreshold : 0.7; + // Validate the threshold up front: when it's out of range every per-model + // diff() throws, and the catch below would swallow all of them — silently + // reporting "no drift" instead of surfacing the configuration error. + if (local.threshold < 0 || local.threshold > 1) { + Throw( + type = "Wheels.InvalidThreshold", + message = "heuristicThreshold must be between 0 and 1, got " & local.threshold + ); + } + if (StructKeyExists(application[local.appKey], "models")) { for (local.modelName in application[local.appKey].models) { try { @@ -208,7 +218,18 @@ component extends="wheels.migrator.Base" { local.results[local.modelName] = local.diffResult; } } catch (any e) { - // Skip models that fail to load (e.g. missing tables) + // Skip models that fail to load (e.g. missing tables) — but + // deliberate validation throws (bad rename hints, type-mismatch + // hints, out-of-range thresholds) must surface to the caller + // instead of silently dropping the model from the results. + if ( + ListFindNoCase( + "Wheels.InvalidThreshold,Wheels.InvalidRenameHint,Wheels.DuplicateRenameHint,Wheels.RenameHintTypeMismatch", + e.type + ) + ) { + rethrow; + } continue; } } diff --git a/vendor/wheels/migrator/Base.cfc b/vendor/wheels/migrator/Base.cfc index a175683eb1..128b2611cf 100644 --- a/vendor/wheels/migrator/Base.cfc +++ b/vendor/wheels/migrator/Base.cfc @@ -15,6 +15,19 @@ component extends="wheels.Global"{ public string function $getDBType(string dataSource = "") { local.appKey = $appKey(); local.dsName = Len(arguments.dataSource) ? arguments.dataSource : application[local.appKey].dataSourceName; + + // Memoize the resolved adapter name per datasource: engine identity is + // stable for the life of the application, and discovery paths (e.g. + // Migrator.getAvailableMigrations()) instantiate every migration CFC — + // without this cache each init() triggers a fresh $dbinfo(version) + // round-trip (plus a SELECT version() on PostgreSQL). + if ( + StructKeyExists(application[local.appKey], "$migratorAdapterNames") + && StructKeyExists(application[local.appKey].$migratorAdapterNames, local.dsName) + ) { + return application[local.appKey].$migratorAdapterNames[local.dsName]; + } + local.info = $dbinfo( type = "version", datasource = local.dsName, @@ -76,19 +89,45 @@ component extends="wheels.Global"{ } else if (local.info.driver_name Contains "SQLite") { local.adapterName = "SQLite"; } + // Only cache successful detection — an empty string means the engine + // could not be identified and callers surface their own errors. + if (Len(local.adapterName)) { + if (!StructKeyExists(application[local.appKey], "$migratorAdapterNames")) { + application[local.appKey].$migratorAdapterNames = {}; + } + application[local.appKey].$migratorAdapterNames[local.dsName] = local.adapterName; + } return local.adapterName; } private string function $getForeignKeys(required string table) { local.appKey = $appKey(); local.foreignKeyList = ""; - local.tables = $dbinfo( - type = "tables", - datasource = application[local.appKey].dataSourceName, - username = application[local.appKey].dataSourceUserName, - password = application[local.appKey].dataSourcePassword - ); - if (ListFindNoCase(ValueList(local.tables.table_name), arguments.table)) { + // Probe the single table for existence instead of listing every table + // in the schema — a failed zero-row SELECT means the table doesn't + // exist, so there are no foreign keys to report. (Mutate a struct field + // rather than assigning a bare local inside catch: BoxLang discards + // `local.X = ...` assignments made in a catch body.) + local.state = {tableExists = true}; + // Migration.init() always sets this.adapter, so a missing adapter is a + // broken instantiation — fail loudly rather than silently interpolating + // an UNQUOTED table name into SQL (#2937 review, #2977). + if (!StructKeyExists(this, "adapter")) { + Throw( + type = "Wheels.Migrator.MissingAdapter", + message = "$getForeignKeys() requires an initialized database adapter. Instantiate migrations through Migration.init()." + ); + } + local.quotedTable = this.adapter.quoteTableName(arguments.table); + try { + $query( + datasource = application[local.appKey].dataSourceName, + sql = "SELECT 1 FROM #local.quotedTable# WHERE 1=0" + ); + } catch (any e) { + local.state.tableExists = false; + } + if (local.state.tableExists) { local.foreignKeys = $dbinfo( type = "foreignkeys", table = arguments.table, @@ -114,49 +153,41 @@ component extends="wheels.Global"{ } local.appKey = $appKey(); local.dsName = Len(arguments.dataSource) ? arguments.dataSource : application[local.appKey].dataSourceName; - local.sql = Trim(arguments.sql); - local.info = $dbinfo( - type = "version", - datasource = local.dsName, - username = application[local.appKey].dataSourceUserName, - password = application[local.appKey].dataSourcePassword - ); - if (Right(local.sql, 1) neq ";" && !FindNoCase("Oracle", local.info.database_productname)) { - local.sql = local.sql &= ";"; - } - if (StructKeyExists(request, "$wheelsMigrationSQLFile") && application[local.appKey].writeMigratorSQLFiles) { - $file( - action = "append", - file = request.$wheelsMigrationSQLFile, - output = "#local.sql#", - addNewLine = "yes", - fixNewLine = "yes" - ); - } - if (StructKeyExists(request, "$wheelsDebugSQL") && request.$wheelsDebugSQL) { - if (!StructKeyExists(request, "$wheelsDebugSQLResult")) { - request.$wheelsDebugSQLResult = []; - } - ArrayAppend(request.$wheelsDebugSQLResult, local.sql); - } else { - $query(datasource = local.dsName, sql = local.sql); + // Executed statements may change the schema — drop the request-scoped + // column cache so the next $getColumns() re-probes. + StructDelete(request, "$wheelsMigratorColumns"); + local.prepared = $prepareMigrationSql(sql = arguments.sql, dsName = local.dsName); + if (!local.prepared.captured) { + $query(datasource = local.dsName, sql = local.prepared.sql); } } /** * Executes a parameterized SQL statement for safe data operations. */ - private void function $executeWithParams(required string sql, required array params) { + private void function $executeWithParams(required string sql, required array params, string dataSource = "") { + local.appKey = $appKey(); + local.dsName = Len(arguments.dataSource) ? arguments.dataSource : application[local.appKey].dataSourceName; + local.prepared = $prepareMigrationSql(sql = arguments.sql, dsName = local.dsName); + if (!local.prepared.captured) { + queryExecute(local.prepared.sql, arguments.params, {datasource: local.dsName}); + } + } + + /** + * Shared pre-execution pipeline for $execute()/$executeWithParams(): trims + * the statement, appends the ";" terminator (except on Oracle — resolved + * via the memoized $getDBType() rather than a per-statement $dbinfo + * round-trip), appends to the migration SQL file when enabled, and captures + * the statement on request.$wheelsDebugSQLResult in dry-run mode. + * Returns {sql, captured}; captured=true means the caller must not execute + * the statement. + */ + private struct function $prepareMigrationSql(required string sql, required string dsName) { local.appKey = $appKey(); local.sql = Trim(arguments.sql); - local.info = $dbinfo( - type = "version", - datasource = application[local.appKey].dataSourceName, - username = application[local.appKey].dataSourceUserName, - password = application[local.appKey].dataSourcePassword - ); - if (Right(local.sql, 1) neq ";" && !FindNoCase("Oracle", local.info.database_productname)) { - local.sql = local.sql & ";"; + if (Right(local.sql, 1) neq ";" && $getDBType(arguments.dsName) != "Oracle") { + local.sql &= ";"; } if (StructKeyExists(request, "$wheelsMigrationSQLFile") && application[local.appKey].writeMigratorSQLFiles) { $file( @@ -167,18 +198,33 @@ component extends="wheels.Global"{ fixNewLine = "yes" ); } - if (StructKeyExists(request, "$wheelsDebugSQL") && request.$wheelsDebugSQL) { + local.captured = StructKeyExists(request, "$wheelsDebugSQL") && request.$wheelsDebugSQL; + if (local.captured) { if (!StructKeyExists(request, "$wheelsDebugSQLResult")) { request.$wheelsDebugSQLResult = []; } ArrayAppend(request.$wheelsDebugSQLResult, local.sql); - } else { - queryExecute(local.sql, arguments.params, {datasource: application[local.appKey].dataSourceName}); } + return {sql: local.sql, captured: local.captured}; } public string function $getColumns(required string tableName) { local.appKey = $appKey(); + // Request-scoped cache: addRecord()/updateRecord() consult the column + // list for every inserted/updated row, so a multi-row seed migration + // would otherwise issue a full table-metadata round-trip per row. + // $execute() drops the cache whenever a statement runs, so DDL in the + // same request (addColumn() etc.) is reflected on the next read. + // Key on the VERBATIM table name: the $dbinfo probe below uses original + // case, so case-folding the key would let `Authors` and `authors` share + // one slot on case-sensitive databases (#2937 review, #2977). + local.cacheKey = application[local.appKey].dataSourceName & "|" & arguments.tableName; + if ( + StructKeyExists(request, "$wheelsMigratorColumns") + && StructKeyExists(request.$wheelsMigratorColumns, local.cacheKey) + ) { + return request.$wheelsMigratorColumns[local.cacheKey]; + } local.columns = $dbinfo( datasource = application[local.appKey].dataSourceName, username = application[local.appKey].dataSourceUserName, @@ -186,7 +232,12 @@ component extends="wheels.Global"{ type = "columns", table = arguments.tableName ); - return ValueList(local.columns.COLUMN_NAME); + local.columnList = ValueList(local.columns.COLUMN_NAME); + if (!StructKeyExists(request, "$wheelsMigratorColumns")) { + request.$wheelsMigratorColumns = {}; + } + request.$wheelsMigratorColumns[local.cacheKey] = local.columnList; + return local.columnList; } /** diff --git a/vendor/wheels/migrator/CLAUDE.md b/vendor/wheels/migrator/CLAUDE.md index 538f28b68a..becbcb60dd 100644 --- a/vendor/wheels/migrator/CLAUDE.md +++ b/vendor/wheels/migrator/CLAUDE.md @@ -58,6 +58,13 @@ The flag is read via `$get("useUnderscoreReferenceColumns")` inside `references( 2. **Hard-coding `& "id"` or `& "type"` concatenations.** All four sites in this directory resolve the reference-column suffix through `$get("useUnderscoreReferenceColumns")` — `TableDefinition.cfc::references()` (id + polymorphic type), `Migration.cfc::removeColumn` (referenceName branch), and `Migration.cfc::addReference`. If you add new code that builds a reference column name, route it through `$get` too rather than hard-coding `& "id"`. 3. **`required` on column-name parameters.** Use `$combineArguments(... required=true)` instead. Declaring CFML-level `required` blocks the alias path because validation runs before the function body. +## Internal caches + +Two caches introduced in #2937 — know their scopes before adding probes: + +- `application[appKey].$migratorAdapterNames` — application-scoped, keyed by datasource name. Memoized migrator adapter name, written by `Base.cfc::$getDBType()`. Survives requests; rebuilt on reload (a datasource's driver can't change without one). +- `request.$wheelsMigratorColumns` — request-scoped, keyed by `dsName|tableName` (table name VERBATIM — no case folding, since the `$dbinfo` probe uses original case and case-sensitive databases can host `Authors` and `authors` separately). Column list per table, written by `Base.cfc::$getColumns()`, dropped wholesale by `$execute()` so DDL in the same request is reflected on the next read. + ## Tests Specs live in `vendor/wheels/tests/specs/migrator/`. `referencesSpec.cfc` exercises `TableDefinition::references()` (the `columnNames` alias plus the suffix flag) at the unit layer — inspecting `t.columns` / `t.foreignKeys` directly without `t.create()` so the assertions are adapter-independent. `primaryKeySpec.cfc` mirrors that shape for `TableDefinition::primaryKey()` — the `columnName` / `columnNames` aliases plus precedence semantics (#2803). `migrationSpec.cfc` covers Migration.cfc command-version helpers via real DDL roundtrips — its "Tests addReference" describe block guards the `useUnderscoreReferenceColumns` path on `Migration.cfc::addReference()`. Most FK-related tests in `migrationSpec.cfc` skip on SQLite (which doesn't support altering CONSTRAINTS) but run on every other engine in CI. diff --git a/vendor/wheels/migrator/Migration.cfc b/vendor/wheels/migrator/Migration.cfc index 4bc8743060..6121f0ab85 100755 --- a/vendor/wheels/migrator/Migration.cfc +++ b/vendor/wheels/migrator/Migration.cfc @@ -115,7 +115,8 @@ component extends="Base" { */ public void function dropTable(required string name) { local.appKey = $appKey(); - local.adapterName = $getDBType(); + // init() already resolved the engine — no need to re-sniff via $getDBType(). + local.adapterName = this.adapter.adapterName(); if (application[local.appKey].serverName != "lucee" && local.adapterName != "SQLite") { local.foreignKeys = $getForeignKeys(arguments.name); local.foreignKeysArray = ListToArray(local.foreignKeys); @@ -410,6 +411,7 @@ component extends="Base" { * * @table The table name to perform the index operation on * @columnNames One or more column names to index, comma separated + * @columnName Singular alias for `columnNames` (matches the convention every other migrator helper follows). Pass one or the other — not both. * @unique If true will create a unique index constraint * @indexName The name of the index to add: Defaults to table name + underscore + first column name */ @@ -417,9 +419,16 @@ component extends="Base" { required string table, string columnNames, boolean unique = "false", - string indexName = objectCase("#arguments.table#_#ListFirst(arguments.columnNames)#") + string indexName = "" ) { $combineArguments(args = arguments, combine = "columnNames,columnName", required = true); + // Compute the default index name here, AFTER $combineArguments has + // resolved the columnName alias — a parameter-default expression would + // dereference arguments.columnNames before the alias resolves and throw + // an undefined-key error on the documented columnName path. + if (!Len(arguments.indexName)) { + arguments.indexName = objectCase("#arguments.table#_#ListFirst(arguments.columnNames)#"); + } $execute(this.adapter.addIndex(argumentCollection = arguments)); announce("Added index to column(s) #arguments.columnNames# in table #arguments.table#"); } @@ -467,16 +476,20 @@ component extends="Base" { local.columnNames = ""; local.placeholders = ""; local.params = []; + // One metadata probe per record (cached per request inside $getColumns) + // — previously each timestamp check below issued its own full + // table-column round-trip. + local.tableColumns = $getColumns(arguments.table); if ( !StructKeyExists(arguments, application[local.appKey].timeStampOnCreateProperty) - && ListFindNoCase($getColumns(arguments.table), application[local.appKey].timeStampOnCreateProperty) + && ListFindNoCase(local.tableColumns, application[local.appKey].timeStampOnCreateProperty) ) { arguments[application[local.appKey].timeStampOnCreateProperty] = $timestamp(); } if ( application[local.appKey].setUpdatedAtOnCreate && !StructKeyExists(arguments, application[local.appKey].timeStampOnUpdateProperty) - && ListFindNoCase($getColumns(arguments.table), application[local.appKey].timeStampOnUpdateProperty) + && ListFindNoCase(local.tableColumns, application[local.appKey].timeStampOnUpdateProperty) ) { arguments[application[local.appKey].timeStampOnUpdateProperty] = $timestamp(); } diff --git a/vendor/wheels/migrator/TableDefinition.cfc b/vendor/wheels/migrator/TableDefinition.cfc index 03a4fab125..b58f3fb56a 100644 --- a/vendor/wheels/migrator/TableDefinition.cfc +++ b/vendor/wheels/migrator/TableDefinition.cfc @@ -123,6 +123,25 @@ component extends="Base" { return this; } + /** + * Shared implementation for the typed column helpers below: resolves the + * columnNames/columnName alias, stamps the column type, and adds one column + * per (comma-delimited) name. `args` is the caller's arguments scope, so + * type-specific options (limit, default, allowNull, precision, scale, size) + * pass straight through to column(). + */ + private any function $addTypedColumns(required string columnType, required struct args) { + $combineArguments(args = arguments.args, combine = "columnNames,columnName", required = true); + arguments.args.columnType = arguments.columnType; + local.columnNamesArray = ListToArray(arguments.args.columnNames); + local.iEnd = ArrayLen(local.columnNamesArray); + for (local.i = 1; local.i <= local.iEnd; local.i++) { + arguments.args.columnName = Trim(local.columnNamesArray[local.i]); + column(argumentCollection = arguments.args); + } + return this; + } + /** * Adds integer columns to table definition. * @@ -130,15 +149,7 @@ component extends="Base" { * [category: Table Definition Functions] */ public any function bigInteger(string columnNames, numeric limit, string default, boolean allowNull) { - $combineArguments(args = arguments, combine = "columnNames,columnName", required = true); - arguments.columnType = "biginteger"; - local.columnNamesArray = ListToArray(arguments.columnNames); - local.iEnd = ArrayLen(local.columnNamesArray); - for (local.i = 1; local.i <= local.iEnd; local.i++) { - arguments.columnName = Trim(local.columnNamesArray[local.i]); - column(argumentCollection = arguments); - } - return this; + return $addTypedColumns(columnType = "biginteger", args = arguments); } /** @@ -148,15 +159,7 @@ component extends="Base" { * [category: Table Definition Functions] */ public any function binary(string columnNames, string default, boolean allowNull) { - $combineArguments(args = arguments, combine = "columnNames,columnName", required = true); - arguments.columnType = "binary"; - local.columnNamesArray = ListToArray(arguments.columnNames); - local.iEnd = ArrayLen(local.columnNamesArray); - for (local.i = 1; local.i <= local.iEnd; local.i++) { - arguments.columnName = Trim(local.columnNamesArray[local.i]); - column(argumentCollection = arguments); - } - return this; + return $addTypedColumns(columnType = "binary", args = arguments); } /** @@ -166,15 +169,7 @@ component extends="Base" { * [category: Table Definition Functions] */ public any function boolean(string columnNames, string default, boolean allowNull) { - $combineArguments(args = arguments, combine = "columnNames,columnName", required = true); - arguments.columnType = "boolean"; - local.columnNamesArray = ListToArray(arguments.columnNames); - local.iEnd = ArrayLen(local.columnNamesArray); - for (local.i = 1; local.i <= local.iEnd; local.i++) { - arguments.columnName = Trim(local.columnNamesArray[local.i]); - column(argumentCollection = arguments); - } - return this; + return $addTypedColumns(columnType = "boolean", args = arguments); } /** @@ -184,15 +179,7 @@ component extends="Base" { * [category: Table Definition Functions] */ public any function date(string columnNames, string default, boolean allowNull) { - $combineArguments(args = arguments, combine = "columnNames,columnName", required = true); - arguments.columnType = "date"; - local.columnNamesArray = ListToArray(arguments.columnNames); - local.iEnd = ArrayLen(local.columnNamesArray); - for (local.i = 1; local.i <= local.iEnd; local.i++) { - arguments.columnName = Trim(local.columnNamesArray[local.i]); - column(argumentCollection = arguments); - } - return this; + return $addTypedColumns(columnType = "date", args = arguments); } /** @@ -202,15 +189,7 @@ component extends="Base" { * [category: Table Definition Functions] */ public any function datetime(string columnNames, string default, boolean allowNull) { - $combineArguments(args = arguments, combine = "columnNames,columnName", required = true); - arguments.columnType = "datetime"; - local.columnNamesArray = ListToArray(arguments.columnNames); - local.iEnd = ArrayLen(local.columnNamesArray); - for (local.i = 1; local.i <= local.iEnd; local.i++) { - arguments.columnName = Trim(local.columnNamesArray[local.i]); - column(argumentCollection = arguments); - } - return this; + return $addTypedColumns(columnType = "datetime", args = arguments); } /** @@ -220,15 +199,7 @@ component extends="Base" { * [category: Table Definition Functions] */ public any function decimal(string columnNames, string default, boolean allowNull, numeric precision, numeric scale) { - $combineArguments(args = arguments, combine = "columnNames,columnName", required = true); - arguments.columnType = "decimal"; - local.columnNamesArray = ListToArray(arguments.columnNames); - local.iEnd = ArrayLen(local.columnNamesArray); - for (local.i = 1; local.i <= local.iEnd; local.i++) { - arguments.columnName = Trim(local.columnNamesArray[local.i]); - column(argumentCollection = arguments); - } - return this; + return $addTypedColumns(columnType = "decimal", args = arguments); } /** @@ -238,15 +209,11 @@ component extends="Base" { * [category: Table Definition Functions] */ public any function float(string columnNames, string default = "", boolean allowNull = "true") { - $combineArguments(args = arguments, combine = "columnNames,columnName", required = true); - arguments.columnType = "float"; - local.columnNamesArray = ListToArray(arguments.columnNames); - local.iEnd = ArrayLen(local.columnNamesArray); - for (local.i = 1; local.i <= local.iEnd; local.i++) { - arguments.columnName = Trim(local.columnNamesArray[local.i]); - column(argumentCollection = arguments); - } - return this; + // NOTE: the default=""/allowNull="true" parameter defaults are a + // long-standing outlier among these helpers — preserved as-is for + // backward compatibility (addColumnOptions renders default="" as + // DEFAULT NULL). + return $addTypedColumns(columnType = "float", args = arguments); } /** @@ -256,15 +223,7 @@ component extends="Base" { * [category: Table Definition Functions] */ public any function integer(string columnNames, numeric limit, string default, boolean allowNull) { - $combineArguments(args = arguments, combine = "columnNames,columnName", required = true); - arguments.columnType = "integer"; - local.columnNamesArray = ListToArray(arguments.columnNames); - local.iEnd = ArrayLen(local.columnNamesArray); - for (local.i = 1; local.i <= local.iEnd; local.i++) { - arguments.columnName = Trim(local.columnNamesArray[local.i]); - column(argumentCollection = arguments); - } - return this; + return $addTypedColumns(columnType = "integer", args = arguments); } /** @@ -274,15 +233,7 @@ component extends="Base" { * [category: Table Definition Functions] */ public any function string(string columnNames, any limit, string default, boolean allowNull) { - $combineArguments(args = arguments, combine = "columnNames,columnName", required = true); - arguments.columnType = "string"; - local.columnNamesArray = ListToArray(arguments.columnNames); - local.iEnd = ArrayLen(local.columnNamesArray); - for (local.i = 1; local.i <= local.iEnd; local.i++) { - arguments.columnName = Trim(local.columnNamesArray[local.i]); - column(argumentCollection = arguments); - } - return this; + return $addTypedColumns(columnType = "string", args = arguments); } /** @@ -292,15 +243,7 @@ component extends="Base" { * [category: Table Definition Functions] */ public any function char(string columnNames, any limit, string default, boolean allowNull) { - $combineArguments(args = arguments, combine = "columnNames,columnName", required = true); - arguments.columnType = "char"; - local.columnNamesArray = ListToArray(arguments.columnNames); - local.iEnd = ArrayLen(local.columnNamesArray); - for (local.i = 1; local.i <= local.iEnd; local.i++) { - arguments.columnName = Trim(local.columnNamesArray[local.i]); - column(argumentCollection = arguments); - } - return this; + return $addTypedColumns(columnType = "char", args = arguments); } /** @@ -317,15 +260,7 @@ component extends="Base" { * [category: Table Definition Functions] */ public any function text(string columnNames, string default, boolean allowNull, string size) { - $combineArguments(args = arguments, combine = "columnNames,columnName", required = true); - arguments.columnType = "text"; - local.columnNamesArray = ListToArray(arguments.columnNames); - local.iEnd = ArrayLen(local.columnNamesArray); - for (local.i = 1; local.i <= local.iEnd; local.i++) { - arguments.columnName = Trim(local.columnNamesArray[local.i]); - column(argumentCollection = arguments); - } - return this; + return $addTypedColumns(columnType = "text", args = arguments); } /** @@ -335,15 +270,10 @@ component extends="Base" { * [category: Table Definition Functions] */ public any function uniqueidentifier(string columnNames, string default = "newid()", boolean allowNull) { - $combineArguments(args = arguments, combine = "columnNames,columnName", required = true); - arguments.columnType = "uniqueidentifier"; - local.columnNamesArray = ListToArray(arguments.columnNames); - local.iEnd = ArrayLen(local.columnNamesArray); - for (local.i = 1; local.i <= local.iEnd; local.i++) { - arguments.columnName = Trim(local.columnNamesArray[local.i]); - column(argumentCollection = arguments); - } - return this; + // NOTE: the default="newid()" parameter default is MSSQL syntax — this + // helper is only registered by the MicrosoftSQLServer adapter, so the + // outlier default is preserved as-is. + return $addTypedColumns(columnType = "uniqueidentifier", args = arguments); } /** @@ -353,15 +283,7 @@ component extends="Base" { * [category: Table Definition Functions] */ public any function time(string columnNames, string default, boolean allowNull) { - $combineArguments(args = arguments, combine = "columnNames,columnName", required = true); - arguments.columnType = "time"; - local.columnNamesArray = ListToArray(arguments.columnNames); - local.iEnd = ArrayLen(local.columnNamesArray); - for (local.i = 1; local.i <= local.iEnd; local.i++) { - arguments.columnName = Trim(local.columnNamesArray[local.i]); - column(argumentCollection = arguments); - } - return this; + return $addTypedColumns(columnType = "time", args = arguments); } /** @@ -371,14 +293,9 @@ component extends="Base" { * [category: Table Definition Functions] */ public any function timestamp(string columnNames, string default, boolean allowNull, string columnType = "datetime") { - $combineArguments(args = arguments, combine = "columnNames,columnName", required = true); - local.columnNamesArray = ListToArray(arguments.columnNames); - local.iEnd = ArrayLen(local.columnNamesArray); - for (local.i = 1; local.i <= local.iEnd; local.i++) { - arguments.columnName = Trim(local.columnNamesArray[local.i]); - column(argumentCollection = arguments); - } - return this; + // columnType is caller-overridable here (defaults to "datetime") — + // unlike the sibling helpers, which stamp a fixed type. + return $addTypedColumns(columnType = arguments.columnType, args = arguments); } /** diff --git a/vendor/wheels/migrator/TenantMigrator.cfc b/vendor/wheels/migrator/TenantMigrator.cfc index 325fe3db2a..e65bc638a4 100644 --- a/vendor/wheels/migrator/TenantMigrator.cfc +++ b/vendor/wheels/migrator/TenantMigrator.cfc @@ -39,13 +39,24 @@ component { * @tenants Array of tenant structs, each with at minimum a `dataSource` key. Optional: `id`. * @tenantProvider Closure that returns an array of tenant structs. Used when tenants is empty. * @stopOnError If true (default), stops on the first tenant that fails. If false, collects errors and continues. + * @migratePath Path to the migration files. Defaults to the standard app location. + * @sqlPath Path the migrator writes generated SQL files to (when `writeMigratorSQLFiles` is enabled). */ public struct function migrateAll( string action = "latest", array tenants = [], any tenantProvider, - boolean stopOnError = true + boolean stopOnError = true, + string migratePath = "/app/migrator/migrations/", + string sqlPath = "/app/migrator/sql/" ) { + if (!ListFindNoCase("latest,up,down,info", arguments.action)) { + Throw( + type = "Wheels.TenantMigrator.InvalidAction", + message = "Invalid migration action `#arguments.action#`. Valid actions are `latest`, `up`, `down` and `info`." + ); + } + local.results = { success = [], failed = [], @@ -64,47 +75,64 @@ component { local.results.total = ArrayLen(local.tenantList); - for (local.tenant in local.tenantList) { - if (!IsStruct(local.tenant) || !StructKeyExists(local.tenant, "dataSource") || !Len(local.tenant.dataSource)) { - ArrayAppend(local.results.failed, { - tenant = local.tenant, - error = "Tenant struct missing required 'dataSource' key" - }); - if (arguments.stopOnError) break; - continue; - } + // Snapshot any pre-existing tenant context (e.g. set by TenantResolver + // middleware) so it can be restored after the run instead of deleted. + if (!StructKeyExists(request, "wheels")) { + request.wheels = {}; + } + local.hadRequestTenant = StructKeyExists(request.wheels, "tenant"); + if (local.hadRequestTenant) { + local.originalRequestTenant = request.wheels.tenant; + } - local.tenantId = StructKeyExists(local.tenant, "id") ? local.tenant.id : local.tenant.dataSource; + try { + for (local.tenant in local.tenantList) { + if (!IsStruct(local.tenant) || !StructKeyExists(local.tenant, "dataSource") || !Len(local.tenant.dataSource)) { + ArrayAppend(local.results.failed, { + tenant = local.tenant, + error = "Tenant struct missing required 'dataSource' key" + }); + if (arguments.stopOnError) break; + continue; + } - try { - // Set the tenant context so migrations use the correct datasource - if (!StructKeyExists(request, "wheels")) { - request.wheels = {}; + local.tenantId = StructKeyExists(local.tenant, "id") ? local.tenant.id : local.tenant.dataSource; + + try { + // Set the tenant context so migrations use the correct datasource + request.wheels.tenant = { + id = local.tenantId, + dataSource = local.tenant.dataSource, + config = StructKeyExists(local.tenant, "config") ? local.tenant.config : {} + }; + + // Run the standard migrator against the tenant's datasource + local.output = $runForTenant( + action = arguments.action, + dataSource = local.tenant.dataSource, + migratePath = arguments.migratePath, + sqlPath = arguments.sqlPath + ); + + ArrayAppend(local.results.success, { + tenant = local.tenantId, + dataSource = local.tenant.dataSource, + output = local.output + }); + } catch (any e) { + ArrayAppend(local.results.failed, { + tenant = local.tenantId, + dataSource = local.tenant.dataSource, + error = e.message + }); + if (arguments.stopOnError) break; } - request.wheels.tenant = { - id = local.tenantId, - dataSource = local.tenant.dataSource, - config = StructKeyExists(local.tenant, "config") ? local.tenant.config : {} - }; - - // Run the standard migrator with the tenant's datasource - local.migrator = $createMigrator(local.tenant.dataSource); - local.output = local.migrator.migrate(arguments.action); - - ArrayAppend(local.results.success, { - tenant = local.tenantId, - dataSource = local.tenant.dataSource, - output = local.output - }); - } catch (any e) { - ArrayAppend(local.results.failed, { - tenant = local.tenantId, - dataSource = local.tenant.dataSource, - error = e.message - }); - if (arguments.stopOnError) break; - } finally { - // Clean up tenant context + } + } finally { + // Restore the pre-existing tenant context (or remove the one we set) + if (local.hadRequestTenant) { + request.wheels.tenant = local.originalRequestTenant; + } else { StructDelete(request.wheels, "tenant"); } } @@ -113,28 +141,89 @@ component { } /** - * Create a migrator instance configured for a specific datasource. - * Temporarily overrides the application datasource for the migration run. - * Uses cflock to prevent race conditions when multiple threads migrate concurrently. + * Runs a single migration action against one tenant datasource. + * Holds an exclusive named lock for the FULL run: the migrator reads + * `application.wheels.dataSourceName` lazily at query time, so the + * application-wide datasource is swapped to the tenant's for the whole + * action and only restored once it completes. The lock prevents + * concurrent tenant migrations from interleaving datasource swaps. */ - private any function $createMigrator(required string dataSource) { + public any function $runForTenant( + required string action, + required string dataSource, + required string migratePath, + required string sqlPath + ) { local.appKey = "wheels"; - lock name="wheels_tenant_migrator" type="exclusive" timeout="30" { - local.originalDS = application[local.appKey].dataSourceName; - - // Temporarily set the application datasource to the tenant's + lock name="wheels_tenant_migrator" type="exclusive" timeout="300" { + local.originalDataSourceName = application[local.appKey].dataSourceName; application[local.appKey].dataSourceName = arguments.dataSource; - try { - local.migrator = new wheels.migrator.Migrator(); + local.migrator = $newMigrator(migratePath = arguments.migratePath, sqlPath = arguments.sqlPath); + return $executeAction(migrator = local.migrator, action = arguments.action); } finally { - // Restore original datasource - application[local.appKey].dataSourceName = local.originalDS; + application[local.appKey].dataSourceName = local.originalDataSourceName; } } + } + + /** + * Creates a `wheels.Migrator` instance configured for the given paths. + */ + public any function $newMigrator(required string migratePath, required string sqlPath) { + return CreateObject("component", "wheels.Migrator").init( + migratePath = arguments.migratePath, + sqlPath = arguments.sqlPath + ); + } - return local.migrator; + /** + * Executes one migration action on a migrator instance. Mirrors the + * command handling in `vendor/wheels/public/views/cli.cfm`. + */ + public any function $executeAction(required any migrator, required string action) { + switch (arguments.action) { + case "latest": + return arguments.migrator.migrateToLatest(); + case "up": + // Walk the migration list (sorted ascending by version) and + // migrate to the first pending version after the current one. + local.currentVersion = arguments.migrator.getCurrentMigrationVersion(); + local.targetVersion = ""; + for (local.migration in arguments.migrator.getAvailableMigrations()) { + if (local.migration.status != "migrated" && local.migration.version > local.currentVersion) { + local.targetVersion = local.migration.version; + break; + } + } + if (Len(local.targetVersion)) { + return arguments.migrator.migrateTo(local.targetVersion); + } + return "No pending migrations. Database is at version #local.currentVersion#."; + case "down": + // Walk the list in reverse to find the migration immediately + // below the current version, then migrate down to it. + local.currentVersion = arguments.migrator.getCurrentMigrationVersion(); + if (local.currentVersion == "0") { + return "Database is at version 0; nothing to roll back."; + } + local.migrations = arguments.migrator.getAvailableMigrations(); + local.targetVersion = "0"; + for (local.i = ArrayLen(local.migrations); local.i >= 1; local.i--) { + if (local.migrations[local.i].version < local.currentVersion && local.migrations[local.i].status == "migrated") { + local.targetVersion = local.migrations[local.i].version; + break; + } + } + return arguments.migrator.migrateTo(local.targetVersion); + case "info": + return ArrayToList(arguments.migrator.$buildInfoOutput(), Chr(10)); + } + Throw( + type = "Wheels.TenantMigrator.InvalidAction", + message = "Invalid migration action `#arguments.action#`. Valid actions are `latest`, `up`, `down` and `info`." + ); } } diff --git a/vendor/wheels/migrator/templates/blank.cfc b/vendor/wheels/migrator/templates/blank.cfc index 6ecb8ca2e9..5a19af73e7 100644 --- a/vendor/wheels/migrator/templates/blank.cfc +++ b/vendor/wheels/migrator/templates/blank.cfc @@ -1,16 +1,17 @@ component extends="[extends]" hint="[description]" { function up() { + var state = {}; transaction { try { // your code goes here } catch (any e) { - local.exception = e; + state.exception = e; } - if (StructKeyExists(local, "exception")) { + if (StructKeyExists(state, "exception")) { transaction action="rollback"; - Throw(errorCode = "1", detail = local.exception.detail, message = local.exception.message, type = "any"); + Throw(errorCode = "1", detail = state.exception.detail, message = state.exception.message, type = "any"); } else { transaction action="commit"; } @@ -18,16 +19,17 @@ component extends="[extends]" hint="[description]" { } function down() { + var state = {}; transaction { try { // your code goes here } catch (any e) { - local.exception = e; + state.exception = e; } - if (StructKeyExists(local, "exception")) { + if (StructKeyExists(state, "exception")) { transaction action="rollback"; - Throw(errorCode = "1", detail = local.exception.detail, message = local.exception.message, type = "any"); + Throw(errorCode = "1", detail = state.exception.detail, message = state.exception.message, type = "any"); } else { transaction action="commit"; } diff --git a/vendor/wheels/migrator/templates/change-column.cfc b/vendor/wheels/migrator/templates/change-column.cfc index 8a4aa49180..a848012749 100644 --- a/vendor/wheels/migrator/templates/change-column.cfc +++ b/vendor/wheels/migrator/templates/change-column.cfc @@ -20,16 +20,17 @@ component extends="[extends]" hint="[description]" { function up() { + var state = {}; transaction { try { changeColumn(table = 'tableName', columnType = '', columnName = 'columnName', default = '', allowNull = true); } catch (any e) { - local.exception = e; + state.exception = e; } - if (StructKeyExists(local, "exception")) { + if (StructKeyExists(state, "exception")) { transaction action="rollback"; - Throw(errorCode = "1", detail = local.exception.detail, message = local.exception.message, type = "any"); + Throw(errorCode = "1", detail = state.exception.detail, message = state.exception.message, type = "any"); } else { transaction action="commit"; } @@ -37,16 +38,17 @@ component extends="[extends]" hint="[description]" { } function down() { + var state = {}; transaction { try { changeColumn(table = 'tableName', columnName = 'columnName'); } catch (any e) { - local.exception = e; + state.exception = e; } - if (StructKeyExists(local, "exception")) { + if (StructKeyExists(state, "exception")) { transaction action="rollback"; - Throw(errorCode = "1", detail = local.exception.detail, message = local.exception.message, type = "any"); + Throw(errorCode = "1", detail = state.exception.detail, message = state.exception.message, type = "any"); } else { transaction action="commit"; } diff --git a/vendor/wheels/migrator/templates/change-table.cfc b/vendor/wheels/migrator/templates/change-table.cfc index 8176112979..958bd25401 100644 --- a/vendor/wheels/migrator/templates/change-table.cfc +++ b/vendor/wheels/migrator/templates/change-table.cfc @@ -13,17 +13,18 @@ component extends="[extends]" hint="[description]" { function up() { + var state = {}; transaction { try { t = changeTable('tableName'); t.change(); } catch (any e) { - local.exception = e; + state.exception = e; } - if (StructKeyExists(local, "exception")) { + if (StructKeyExists(state, "exception")) { transaction action="rollback"; - Throw(errorCode = "1", detail = local.exception.detail, message = local.exception.message, type = "any"); + Throw(errorCode = "1", detail = state.exception.detail, message = state.exception.message, type = "any"); } else { transaction action="commit"; } @@ -31,16 +32,17 @@ component extends="[extends]" hint="[description]" { } function down() { + var state = {}; transaction { try { removeColumn(table = 'tableName', columnName = 'columnName'); } catch (any e) { - local.exception = e; + state.exception = e; } - if (StructKeyExists(local, "exception")) { + if (StructKeyExists(state, "exception")) { transaction action="rollback"; - Throw(errorCode = "1", detail = local.exception.detail, message = local.exception.message, type = "any"); + Throw(errorCode = "1", detail = state.exception.detail, message = state.exception.message, type = "any"); } else { transaction action="commit"; } diff --git a/vendor/wheels/migrator/templates/create-column.cfc b/vendor/wheels/migrator/templates/create-column.cfc index 7df17ee131..b61789d149 100644 --- a/vendor/wheels/migrator/templates/create-column.cfc +++ b/vendor/wheels/migrator/templates/create-column.cfc @@ -19,16 +19,17 @@ component extends="[extends]" hint="[description]" { function up() { + var state = {}; transaction { try { addColumn(table = 'tableName', columnType = '', columnName = 'columnName', default = '', allowNull = true); } catch (any e) { - local.exception = e; + state.exception = e; } - if (StructKeyExists(local, "exception")) { + if (StructKeyExists(state, "exception")) { transaction action="rollback"; - Throw(errorCode = "1", detail = local.exception.detail, message = local.exception.message, type = "any"); + Throw(errorCode = "1", detail = state.exception.detail, message = state.exception.message, type = "any"); } else { transaction action="commit"; } @@ -36,16 +37,17 @@ component extends="[extends]" hint="[description]" { } function down() { + var state = {}; transaction { try { removeColumn(table = 'tableName', columnName = 'columnName'); } catch (any e) { - local.exception = e; + state.exception = e; } - if (StructKeyExists(local, "exception")) { + if (StructKeyExists(state, "exception")) { transaction action="rollback"; - Throw(errorCode = "1", detail = local.exception.detail, message = local.exception.message, type = "any"); + Throw(errorCode = "1", detail = state.exception.detail, message = state.exception.message, type = "any"); } else { transaction action="commit"; } diff --git a/vendor/wheels/migrator/templates/create-index.cfc b/vendor/wheels/migrator/templates/create-index.cfc index 3c01c4e3d1..6678053964 100644 --- a/vendor/wheels/migrator/templates/create-index.cfc +++ b/vendor/wheels/migrator/templates/create-index.cfc @@ -14,16 +14,17 @@ component extends="[extends]" hint="[description]" { function up() { + var state = {}; transaction { try { addIndex(table = 'tableName', columnNames = 'columnName', unique = true); } catch (any e) { - local.exception = e; + state.exception = e; } - if (StructKeyExists(local, "exception")) { + if (StructKeyExists(state, "exception")) { transaction action="rollback"; - Throw(errorCode = "1", detail = local.exception.detail, message = local.exception.message, type = "any"); + Throw(errorCode = "1", detail = state.exception.detail, message = state.exception.message, type = "any"); } else { transaction action="commit"; } @@ -31,16 +32,17 @@ component extends="[extends]" hint="[description]" { } function down() { + var state = {}; transaction { try { removeIndex(table = 'tableName', indexName = ''); } catch (any e) { - local.exception = e; + state.exception = e; } - if (StructKeyExists(local, "exception")) { + if (StructKeyExists(state, "exception")) { transaction action="rollback"; - Throw(errorCode = "1", detail = local.exception.detail, message = local.exception.message, type = "any"); + Throw(errorCode = "1", detail = state.exception.detail, message = state.exception.message, type = "any"); } else { transaction action="commit"; } diff --git a/vendor/wheels/migrator/templates/create-record.cfc b/vendor/wheels/migrator/templates/create-record.cfc index 2e0c93dd42..bea339f179 100644 --- a/vendor/wheels/migrator/templates/create-record.cfc +++ b/vendor/wheels/migrator/templates/create-record.cfc @@ -12,16 +12,17 @@ component extends="[extends]" hint="[description]" { function up() { + var state = {}; transaction { try { addRecord(table = 'tableName', field = ''); } catch (any e) { - local.exception = e; + state.exception = e; } - if (StructKeyExists(local, "exception")) { + if (StructKeyExists(state, "exception")) { transaction action="rollback"; - Throw(errorCode = "1", detail = local.exception.detail, message = local.exception.message, type = "any"); + Throw(errorCode = "1", detail = state.exception.detail, message = state.exception.message, type = "any"); } else { transaction action="commit"; } @@ -29,16 +30,17 @@ component extends="[extends]" hint="[description]" { } function down() { + var state = {}; transaction { try { removeRecord(table = 'tableName', where = ''); } catch (any e) { - local.exception = e; + state.exception = e; } - if (StructKeyExists(local, "exception")) { + if (StructKeyExists(state, "exception")) { transaction action="rollback"; - Throw(errorCode = "1", detail = local.exception.detail, message = local.exception.message, type = "any"); + Throw(errorCode = "1", detail = state.exception.detail, message = state.exception.message, type = "any"); } else { transaction action="commit"; } diff --git a/vendor/wheels/migrator/templates/create-table.cfc b/vendor/wheels/migrator/templates/create-table.cfc index a0eaf29638..18a648f9e5 100644 --- a/vendor/wheels/migrator/templates/create-table.cfc +++ b/vendor/wheels/migrator/templates/create-table.cfc @@ -29,18 +29,19 @@ component extends="[extends]" hint="[description]" { function up() { + var state = {}; transaction { try { t = createTable(name = 'tableName'); t.timestamps(); t.create(); } catch (any e) { - local.exception = e; + state.exception = e; } - if (StructKeyExists(local, "exception")) { + if (StructKeyExists(state, "exception")) { transaction action="rollback"; - Throw(errorCode = "1", detail = local.exception.detail, message = local.exception.message, type = "any"); + Throw(errorCode = "1", detail = state.exception.detail, message = state.exception.message, type = "any"); } else { transaction action="commit"; } @@ -48,16 +49,17 @@ component extends="[extends]" hint="[description]" { } function down() { + var state = {}; transaction { try { dropTable('tableName'); } catch (any e) { - local.exception = e; + state.exception = e; } - if (StructKeyExists(local, "exception")) { + if (StructKeyExists(state, "exception")) { transaction action="rollback"; - Throw(errorCode = "1", detail = local.exception.detail, message = local.exception.message, type = "any"); + Throw(errorCode = "1", detail = state.exception.detail, message = state.exception.message, type = "any"); } else { transaction action="commit"; } diff --git a/vendor/wheels/migrator/templates/execute.cfc b/vendor/wheels/migrator/templates/execute.cfc index ea2ddaf4ba..d11a2473ab 100644 --- a/vendor/wheels/migrator/templates/execute.cfc +++ b/vendor/wheels/migrator/templates/execute.cfc @@ -1,16 +1,17 @@ component extends="[extends]" hint="[description]" { function up() { + var state = {}; transaction { try { execute(''); } catch (any e) { - local.exception = e; + state.exception = e; } - if (StructKeyExists(local, "exception")) { + if (StructKeyExists(state, "exception")) { transaction action="rollback"; - Throw(errorCode = "1", detail = local.exception.detail, message = local.exception.message, type = "any"); + Throw(errorCode = "1", detail = state.exception.detail, message = state.exception.message, type = "any"); } else { transaction action="commit"; } @@ -18,16 +19,17 @@ component extends="[extends]" hint="[description]" { } function down() { + var state = {}; transaction { try { execute(''); } catch (any e) { - local.exception = e; + state.exception = e; } - if (StructKeyExists(local, "exception")) { + if (StructKeyExists(state, "exception")) { transaction action="rollback"; - Throw(errorCode = "1", detail = local.exception.detail, message = local.exception.message, type = "any"); + Throw(errorCode = "1", detail = state.exception.detail, message = state.exception.message, type = "any"); } else { transaction action="commit"; } diff --git a/vendor/wheels/migrator/templates/remove-column.cfc b/vendor/wheels/migrator/templates/remove-column.cfc index a40826c0a3..d22dfa455e 100644 --- a/vendor/wheels/migrator/templates/remove-column.cfc +++ b/vendor/wheels/migrator/templates/remove-column.cfc @@ -13,16 +13,17 @@ component extends="[extends]" hint="[description]" { function up() { + var state = {}; transaction { try { removeColumn(table = 'tableName', columnName = 'columnName'); } catch (any e) { - local.exception = e; + state.exception = e; } - if (StructKeyExists(local, "exception")) { + if (StructKeyExists(state, "exception")) { transaction action="rollback"; - Throw(errorCode = "1", detail = local.exception.detail, message = local.exception.message, type = "any"); + Throw(errorCode = "1", detail = state.exception.detail, message = state.exception.message, type = "any"); } else { transaction action="commit"; } @@ -30,16 +31,17 @@ component extends="[extends]" hint="[description]" { } function down() { + var state = {}; transaction { try { addColumn(table = 'tableName', columnType = '', columnName = 'columnName', default = '', allowNull = true); } catch (any e) { - local.exception = e; + state.exception = e; } - if (StructKeyExists(local, "exception")) { + if (StructKeyExists(state, "exception")) { transaction action="rollback"; - Throw(errorCode = "1", detail = local.exception.detail, message = local.exception.message, type = "any"); + Throw(errorCode = "1", detail = state.exception.detail, message = state.exception.message, type = "any"); } else { transaction action="commit"; } diff --git a/vendor/wheels/migrator/templates/remove-index.cfc b/vendor/wheels/migrator/templates/remove-index.cfc index b7a1e3abbe..fe78166867 100644 --- a/vendor/wheels/migrator/templates/remove-index.cfc +++ b/vendor/wheels/migrator/templates/remove-index.cfc @@ -12,16 +12,17 @@ component extends="[extends]" hint="[description]" { function up() { + var state = {}; transaction { try { removeIndex(table = 'tableName', indexName = ''); } catch (any e) { - local.exception = e; + state.exception = e; } - if (StructKeyExists(local, "exception")) { + if (StructKeyExists(state, "exception")) { transaction action="rollback"; - Throw(errorCode = "1", detail = local.exception.detail, message = local.exception.message, type = "any"); + Throw(errorCode = "1", detail = state.exception.detail, message = state.exception.message, type = "any"); } else { transaction action="commit"; } @@ -29,16 +30,17 @@ component extends="[extends]" hint="[description]" { } function down() { + var state = {}; transaction { try { addIndex(table = 'tableName', columnNames = 'columnName', unique = true); } catch (any e) { - local.exception = e; + state.exception = e; } - if (StructKeyExists(local, "exception")) { + if (StructKeyExists(state, "exception")) { transaction action="rollback"; - Throw(errorCode = "1", detail = local.exception.detail, message = local.exception.message, type = "any"); + Throw(errorCode = "1", detail = state.exception.detail, message = state.exception.message, type = "any"); } else { transaction action="commit"; } diff --git a/vendor/wheels/migrator/templates/remove-record.cfc b/vendor/wheels/migrator/templates/remove-record.cfc index 806c55ecf7..ac58421be2 100644 --- a/vendor/wheels/migrator/templates/remove-record.cfc +++ b/vendor/wheels/migrator/templates/remove-record.cfc @@ -12,16 +12,17 @@ component extends="[extends]" hint="[description]" { function up() { + var state = {}; transaction { try { removeRecord(table = 'tableName', where = ''); } catch (any e) { - local.exception = e; + state.exception = e; } - if (StructKeyExists(local, "exception")) { + if (StructKeyExists(state, "exception")) { transaction action="rollback"; - Throw(errorCode = "1", detail = local.exception.detail, message = local.exception.message, type = "any"); + Throw(errorCode = "1", detail = state.exception.detail, message = state.exception.message, type = "any"); } else { transaction action="commit"; } @@ -29,16 +30,17 @@ component extends="[extends]" hint="[description]" { } function down() { + var state = {}; transaction { try { addRecord(table = 'tableName', field = ''); } catch (any e) { - local.exception = e; + state.exception = e; } - if (StructKeyExists(local, "exception")) { + if (StructKeyExists(state, "exception")) { transaction action="rollback"; - Throw(errorCode = "1", detail = local.exception.detail, message = local.exception.message, type = "any"); + Throw(errorCode = "1", detail = state.exception.detail, message = state.exception.message, type = "any"); } else { transaction action="commit"; } diff --git a/vendor/wheels/migrator/templates/remove-table.cfc b/vendor/wheels/migrator/templates/remove-table.cfc index 6445d0bf74..902788b409 100644 --- a/vendor/wheels/migrator/templates/remove-table.cfc +++ b/vendor/wheels/migrator/templates/remove-table.cfc @@ -11,16 +11,17 @@ component extends="[extends]" hint="[description]" { function up() { + var state = {}; transaction { try { dropTable(name = 'tableName'); } catch (any e) { - local.exception = e; + state.exception = e; } - if (StructKeyExists(local, "exception")) { + if (StructKeyExists(state, "exception")) { transaction action="rollback"; - Throw(errorCode = "1", detail = local.exception.detail, message = local.exception.message, type = "any"); + Throw(errorCode = "1", detail = state.exception.detail, message = state.exception.message, type = "any"); } else { transaction action="commit"; } @@ -28,18 +29,19 @@ component extends="[extends]" hint="[description]" { } function down() { + var state = {}; transaction { try { t = createTable(name = 'tableName'); t.timestamps(); t.create(); } catch (any e) { - local.exception = e; + state.exception = e; } - if (StructKeyExists(local, "exception")) { + if (StructKeyExists(state, "exception")) { transaction action="rollback"; - Throw(errorCode = "1", detail = local.exception.detail, message = local.exception.message, type = "any"); + Throw(errorCode = "1", detail = state.exception.detail, message = state.exception.message, type = "any"); } else { transaction action="commit"; } diff --git a/vendor/wheels/migrator/templates/rename-column.cfc b/vendor/wheels/migrator/templates/rename-column.cfc index 43d5be2051..a03e95dcdc 100644 --- a/vendor/wheels/migrator/templates/rename-column.cfc +++ b/vendor/wheels/migrator/templates/rename-column.cfc @@ -13,16 +13,17 @@ component extends="[extends]" hint="[description]" { function up() { + var state = {}; transaction { try { renameColumn(table = 'tableName', columnName = 'columnName', newColumnName = 'newColumnName'); } catch (any e) { - local.exception = e; + state.exception = e; } - if (StructKeyExists(local, "exception")) { + if (StructKeyExists(state, "exception")) { transaction action="rollback"; - Throw(errorCode = "1", detail = local.exception.detail, message = local.exception.message, type = "any"); + Throw(errorCode = "1", detail = state.exception.detail, message = state.exception.message, type = "any"); } else { transaction action="commit"; } @@ -30,16 +31,17 @@ component extends="[extends]" hint="[description]" { } function down() { + var state = {}; transaction { try { renameColumn(table = 'tableName', columnName = 'columnName', newColumnName = 'newColumnName'); } catch (any e) { - local.exception = e; + state.exception = e; } - if (StructKeyExists(local, "exception")) { + if (StructKeyExists(state, "exception")) { transaction action="rollback"; - Throw(errorCode = "1", detail = local.exception.detail, message = local.exception.message, type = "any"); + Throw(errorCode = "1", detail = state.exception.detail, message = state.exception.message, type = "any"); } else { transaction action="commit"; } diff --git a/vendor/wheels/migrator/templates/rename-table.cfc b/vendor/wheels/migrator/templates/rename-table.cfc index 7ade6fd272..a690408f42 100644 --- a/vendor/wheels/migrator/templates/rename-table.cfc +++ b/vendor/wheels/migrator/templates/rename-table.cfc @@ -12,16 +12,17 @@ component extends="[extends]" hint="[description]" { function up() { + var state = {}; transaction { try { renameTable(oldName = '', newName = ''); } catch (any e) { - local.exception = e; + state.exception = e; } - if (StructKeyExists(local, "exception")) { + if (StructKeyExists(state, "exception")) { transaction action="rollback"; - Throw(errorCode = "1", detail = local.exception.detail, message = local.exception.message, type = "any"); + Throw(errorCode = "1", detail = state.exception.detail, message = state.exception.message, type = "any"); } else { transaction action="commit"; } @@ -29,16 +30,17 @@ component extends="[extends]" hint="[description]" { } function down() { + var state = {}; transaction { try { renameTable(oldName = '', newName = ''); } catch (any e) { - local.exception = e; + state.exception = e; } - if (StructKeyExists(local, "exception")) { + if (StructKeyExists(state, "exception")) { transaction action="rollback"; - Throw(errorCode = "1", detail = local.exception.detail, message = local.exception.message, type = "any"); + Throw(errorCode = "1", detail = state.exception.detail, message = state.exception.message, type = "any"); } else { transaction action="commit"; } diff --git a/vendor/wheels/migrator/templates/update-record.cfc b/vendor/wheels/migrator/templates/update-record.cfc index ae44f5b53e..1a785350a4 100644 --- a/vendor/wheels/migrator/templates/update-record.cfc +++ b/vendor/wheels/migrator/templates/update-record.cfc @@ -13,16 +13,17 @@ component extends="[extends]" hint="[description]" { function up() { + var state = {}; transaction { try { updateRecord(table = 'tableName', where = ''); } catch (any e) { - local.exception = e; + state.exception = e; } - if (StructKeyExists(local, "exception")) { + if (StructKeyExists(state, "exception")) { transaction action="rollback"; - Throw(errorCode = "1", detail = local.exception.detail, message = local.exception.message, type = "any"); + Throw(errorCode = "1", detail = state.exception.detail, message = state.exception.message, type = "any"); } else { transaction action="commit"; } @@ -30,16 +31,17 @@ component extends="[extends]" hint="[description]" { } function down() { + var state = {}; transaction { try { updateRecord(table = 'tableName', where = ''); } catch (any e) { - local.exception = e; + state.exception = e; } - if (StructKeyExists(local, "exception")) { + if (StructKeyExists(state, "exception")) { transaction action="rollback"; - Throw(errorCode = "1", detail = local.exception.detail, message = local.exception.message, type = "any"); + Throw(errorCode = "1", detail = state.exception.detail, message = state.exception.message, type = "any"); } else { transaction action="commit"; } diff --git a/vendor/wheels/model/create.cfc b/vendor/wheels/model/create.cfc index 36280aca00..898b640ff2 100644 --- a/vendor/wheels/model/create.cfc +++ b/vendor/wheels/model/create.cfc @@ -215,27 +215,13 @@ component { * Create an INSERT statement and run to create the record. */ public boolean function $create(required any parameterize, required boolean reload) { - // Allow explicit assignment of the createdAt/updatedAt properties if allowExplicitTimestamps is true - local.allowExplicitTimestamps = StructKeyExists(this, "allowExplicitTimestamps") && this.allowExplicitTimestamps; - - if ( - local.allowExplicitTimestamps - && StructKeyExists(this, $get("timeStampOnCreateProperty")) - && Len(this[$get("timeStampOnCreateProperty")]) - ) { - // leave createdat unmolested - } else if (variables.wheels.class.timeStampingOnCreate) { - $timestampProperty(property = variables.wheels.class.timeStampOnCreateProperty); - } - if ( - local.allowExplicitTimestamps - && StructKeyExists(this, $get("timeStampOnUpdateProperty")) - && Len(this[$get("timeStampOnUpdateProperty")]) - ) { - // leave updatedat unmolested - } else if ($get("setUpdatedAtOnCreate") && variables.wheels.class.timeStampingOnUpdate) { - $timestampProperty(property = variables.wheels.class.timeStampOnUpdateProperty); - } + // Stamp createdAt/updatedAt (updatedAt only when `setUpdatedAtOnCreate` allows it). + // The shared helper also honors explicitly assigned values when allowExplicitTimestamps is true. + $stampTimestampProperty(event = "create", enabled = variables.wheels.class.timeStampingOnCreate); + $stampTimestampProperty( + event = "update", + enabled = $get("setUpdatedAtOnCreate") && variables.wheels.class.timeStampingOnUpdate + ); local.pks = listToArray(primaryKeys()); local.uuidBasedKeys = []; @@ -247,33 +233,25 @@ component { local.key = trim(local.key); local.columnMeta = this.columnDataForProperty(local.key); + // Use a single definition of "has a value" for both the explicitly-set check and the + // UUID generation guard below (an empty string previously skipped generation entirely). + local.keyHasValue = StructKeyExists(this, local.key) && !IsNull(this[local.key]) && Len(this[local.key]); + // Check if the primary key was explicitly set by the user - if (structKeyExists(this, local.key) && len(this[local.key])) { + if (local.keyHasValue) { local.primaryKeyExplicitlySet = true; } - if ($isUUIDColumn(local.columnMeta)) { + if (!local.keyHasValue && $isUUIDColumn(local.columnMeta)) { arrayAppend(local.uuidBasedKeys, local.key); } } + // Generate values for blank UUID-based keys. Uniqueness is left to the primary key + // constraint: with random UUIDs a collision is practically impossible (~2^-122), so + // probing the table with a SELECT before every insert was redundant. for (local.key in local.uuidBasedKeys) { - if (!structKeyExists(this, local.key) || isNull(this[local.key])) { - local.newUUID = generateUUID(); - - while (true) { - local.exists = this.findOne( - where = "#local.key# = '#local.newUUID#'" - ); - - if (!local.exists) { - this[local.key] = local.newUUID; - break; - } else { - local.newUUID = generateUUID(); - } - } - } + this[local.key] = generateUUID(); } // Start by adding column names and values for the properties that exist on the object to two arrays. @@ -344,12 +322,16 @@ component { } /** - * Determines if a column is likely intended to store UUID value + * Determines if a column is likely intended to store a UUID value. + * Requires an explicitly reported 36-character size: a missing size previously made every + * unsized char/varchar/text column a UUID candidate. */ public boolean function $isUUIDColumn(required struct columnMeta) { return ( listFindNoCase("uniqueidentifier,char,varchar,uuid,raw,text", arguments.columnMeta.dataType) - && (arguments.columnMeta.size == 36 || isNull(arguments.columnMeta.size)) + && StructKeyExists(arguments.columnMeta, "size") + && !IsNull(arguments.columnMeta.size) + && arguments.columnMeta.size == 36 ); } diff --git a/vendor/wheels/model/miscellaneous.cfc b/vendor/wheels/model/miscellaneous.cfc index 86fdf8a52d..5e3af0f377 100644 --- a/vendor/wheels/model/miscellaneous.cfc +++ b/vendor/wheels/model/miscellaneous.cfc @@ -171,10 +171,25 @@ component { /** * Returns the name of the database table that this model is mapped to. * + * This is a getter and takes no arguments — the table setter is `table()`. + * Calling `tableName()` with an argument has always been a silent no-op (CFML + * accepts the extra argument and the model keeps its convention table), a trap + * some 4.0-era docs taught as a setter. When error information is shown + * (development / testing — the same gate `exists()` uses above) it now fails + * loud; in production it stays a no-op so an upgrade never breaks a running + * app. See issue #3079. + * * [section: Model Class] * [category: Miscellaneous Functions] */ public string function tableName() { + if (StructCount(arguments) && $get("showErrorInformation")) { + Throw( + type = "Wheels.InvalidArgument", + message = "`tableName()` is a getter and takes no arguments. To set the database table for this model, call `table()` in `config()` instead, e.g. `table(""my_table"")`.", + detail = "Passing a name to `tableName()` has always been a silent no-op (the model keeps its convention table), so it now fails loud in development. The table setter is `table()`. See issue ##3079." + ); + } if ($get("lowerCaseTableNames")) { return LCase(variables.wheels.class.tableName); } else { @@ -310,7 +325,7 @@ component { local.parts = ListToArray(local.rv.value, "/-"); if (ArrayLen(local.parts) == 3 && IsNumeric(local.parts[1]) && IsNumeric(local.parts[2]) && IsNumeric(local.parts[3])) { try { - local.rv.value = $engineAdapter().parseAmbiguousSlashDate(local.parts[1], local.parts[2], local.parts[3]); + local.rv.value = $parseSlashDate(d1 = local.parts[1], d2 = local.parts[2], year = local.parts[3]); } catch (any e) { local.rv.value = CreateDate(local.parts[3], local.parts[1], local.parts[2]); } @@ -374,4 +389,40 @@ component { public void function $timestampProperty(required string property) { this[arguments.property] = $timestamp(variables.wheels.class.timeStampMode); } + + /** + * Internal function. Single shared implementation of the timestamp stamping rules used by + * both `$create` and `$update` so the two write paths can't drift apart (the update path + * once copy-pasted the create-only `setUpdatedAtOnCreate` gate from the create path). + * Stamps the configured create or update timestamp property unless stamping is gated off + * via `enabled` or the property was explicitly assigned while `allowExplicitTimestamps` is + * enabled on the object. The explicit-assignment check uses the global setting name while + * stamping targets the class-level property, mirroring the original inline logic (the two + * can differ when the class-level property is overridden at runtime). + * + * @event Which timestamp to stamp: "create" or "update". + * @enabled Whether stamping is enabled for this write path (class-level timestamping + * config combined with any path-specific gates). + */ + public void function $stampTimestampProperty(required string event, required boolean enabled) { + if (!arguments.enabled) { + return; + } + if (arguments.event == "create") { + local.settingName = "timeStampOnCreateProperty"; + } else { + local.settingName = "timeStampOnUpdateProperty"; + } + // Allow explicit assignment of the timestamp property if allowExplicitTimestamps is true. + if ( + StructKeyExists(this, "allowExplicitTimestamps") + && this.allowExplicitTimestamps + && StructKeyExists(this, $get(local.settingName)) + && Len(this[$get(local.settingName)]) + ) { + // Leave the explicitly assigned value unmolested. + return; + } + $timestampProperty(property = variables.wheels.class[local.settingName]); + } } diff --git a/vendor/wheels/model/onmissingmethod.cfc b/vendor/wheels/model/onmissingmethod.cfc index 66f71a644d..3249d02dfe 100644 --- a/vendor/wheels/model/onmissingmethod.cfc +++ b/vendor/wheels/model/onmissingmethod.cfc @@ -393,35 +393,16 @@ component { local.method = "deleteOne"; arguments.missingMethodArguments.where = local.where; } else if (local.name == "setObject") { - // single argument, must be either the key or the object - if (StructCount(arguments.missingMethodArguments) == 1) { - if (IsObject(arguments.missingMethodArguments[1])) { - local.componentReference = arguments.missingMethodArguments[1]; - local.method = "update"; - } else { - arguments.missingMethodArguments.key = arguments.missingMethodArguments[1]; - local.method = "updateByKey"; - } - StructClear(arguments.missingMethodArguments); - } else { - // multiple arguments so ensure that either 'key' or the association name exists (local.key) - if ( - StructKeyExists(arguments.missingMethodArguments, local.key) - && IsObject(arguments.missingMethodArguments[local.key]) - ) { - local.componentReference = arguments.missingMethodArguments[local.key]; - local.method = "update"; - StructDelete(arguments.missingMethodArguments, local.key); - } else if (StructKeyExists(arguments.missingMethodArguments, "key")) { - local.method = "updateByKey"; - } else { - Throw( - type = "Wheels.IncorrectArguments", - message = "The `#local.key#` or `key` named argument is required.", - extendedInfo = "When using multiple arguments for #local.name#() you must supply an object using the argument `#local.key#` or a key using the argument `key`, e.g. #local.name#(#local.key#=post) or #local.name#(key=post.id)." - ); - } - } + local.resolved = $resolveAssociationTarget( + missingMethodArguments = arguments.missingMethodArguments, + componentReference = local.componentReference, + argumentName = local.key, + methodName = local.name, + objectMethod = "update", + keyMethod = "updateByKey" + ); + local.method = local.resolved.method; + local.componentReference = local.resolved.componentReference; $setForeignKeyValues(missingMethodArguments = arguments.missingMethodArguments, keys = local.info.foreignKey); } } else if (local.info.type == "hasMany") { @@ -449,101 +430,44 @@ component { local.method = "findAll"; arguments.missingMethodArguments.where = local.where; } else if (local.name == "addObject") { - // single argument, must be either the key or the object - if (StructCount(arguments.missingMethodArguments) == 1) { - if (IsObject(arguments.missingMethodArguments[1])) { - local.componentReference = arguments.missingMethodArguments[1]; - local.method = "update"; - } else { - arguments.missingMethodArguments.key = arguments.missingMethodArguments[1]; - local.method = "updateByKey"; - } - StructClear(arguments.missingMethodArguments); - } else { - // multiple arguments so ensure that either 'key' or the singularized association name exists (local.singularKey) - if ( - StructKeyExists(arguments.missingMethodArguments, local.singularKey) - && IsObject(arguments.missingMethodArguments[local.singularKey]) - ) { - local.componentReference = arguments.missingMethodArguments[local.singularKey]; - local.method = "update"; - StructDelete(arguments.missingMethodArguments, local.singularKey); - } else if (StructKeyExists(arguments.missingMethodArguments, "key")) { - local.method = "updateByKey"; - } else { - Throw( - type = "Wheels.IncorrectArguments", - message = "The `#local.singularKey#` or `key` named argument is required.", - extendedInfo = "When using multiple arguments for #local.name#() you must supply an object using the argument `#local.singularKey#` or a key using the argument `key`, e.g. #local.name#(#local.singularKey#=post) or #local.name#(key=post.id)." - ); - } - } + local.resolved = $resolveAssociationTarget( + missingMethodArguments = arguments.missingMethodArguments, + componentReference = local.componentReference, + argumentName = local.singularKey, + methodName = local.name, + objectMethod = "update", + keyMethod = "updateByKey" + ); + local.method = local.resolved.method; + local.componentReference = local.resolved.componentReference; $setForeignKeyValues(missingMethodArguments = arguments.missingMethodArguments, keys = local.info.foreignKey); } else if (local.name == "removeObject") { - // single argument, must be either the key or the object - if (StructCount(arguments.missingMethodArguments) == 1) { - if (IsObject(arguments.missingMethodArguments[1])) { - local.componentReference = arguments.missingMethodArguments[1]; - local.method = "update"; - } else { - arguments.missingMethodArguments.key = arguments.missingMethodArguments[1]; - local.method = "updateByKey"; - } - StructClear(arguments.missingMethodArguments); - } else { - // multiple arguments so ensure that either 'key' or the singularized object name exists (local.singularKey) - if ( - StructKeyExists(arguments.missingMethodArguments, local.singularKey) - && IsObject(arguments.missingMethodArguments[local.singularKey]) - ) { - local.componentReference = arguments.missingMethodArguments[local.singularKey]; - local.method = "update"; - StructDelete(arguments.missingMethodArguments, local.singularKey); - } else if (StructKeyExists(arguments.missingMethodArguments, "key")) { - local.method = "updateByKey"; - } else { - Throw( - type = "Wheels.IncorrectArguments", - message = "The `#local.singularKey#` or `key` named argument is required.", - extendedInfo = "When using multiple arguments for #local.name#() you must supply an object using the argument `#local.singularKey#` or a key using the argument `key`, e.g. #local.name#(#local.singularKey#=post) or #local.name#(key=post.id)." - ); - } - } + local.resolved = $resolveAssociationTarget( + missingMethodArguments = arguments.missingMethodArguments, + componentReference = local.componentReference, + argumentName = local.singularKey, + methodName = local.name, + objectMethod = "update", + keyMethod = "updateByKey" + ); + local.method = local.resolved.method; + local.componentReference = local.resolved.componentReference; $setForeignKeyValues( missingMethodArguments = arguments.missingMethodArguments, keys = local.info.foreignKey, setToNull = true ); } else if (local.name == "deleteObject") { - // single argument, must be either the key or the object - if (StructCount(arguments.missingMethodArguments) == 1) { - if (IsObject(arguments.missingMethodArguments[1])) { - local.componentReference = arguments.missingMethodArguments[1]; - local.method = "delete"; - } else { - arguments.missingMethodArguments.key = arguments.missingMethodArguments[1]; - local.method = "deleteByKey"; - } - StructClear(arguments.missingMethodArguments); - } else { - // multiple arguments so ensure that either 'key' or the singularized object name exists (local.singularKey) - if ( - StructKeyExists(arguments.missingMethodArguments, local.singularKey) - && IsObject(arguments.missingMethodArguments[local.singularKey]) - ) { - local.componentReference = arguments.missingMethodArguments[local.singularKey]; - local.method = "delete"; - StructDelete(arguments.missingMethodArguments, local.singularKey); - } else if (StructKeyExists(arguments.missingMethodArguments, "key")) { - local.method = "deleteByKey"; - } else { - Throw( - type = "Wheels.IncorrectArguments", - message = "The `#local.singularKey#` or `key` named argument is required.", - extendedInfo = "When using multiple arguments for #local.name#() you must supply an object using the argument `#local.singularKey#` or a key using the argument `key`, e.g. #local.name#(#local.singularKey#=post) or #local.name#(key=post.id)." - ); - } - } + local.resolved = $resolveAssociationTarget( + missingMethodArguments = arguments.missingMethodArguments, + componentReference = local.componentReference, + argumentName = local.singularKey, + methodName = local.name, + objectMethod = "delete", + keyMethod = "deleteByKey" + ); + local.method = local.resolved.method; + local.componentReference = local.resolved.componentReference; $setForeignKeyValues(missingMethodArguments = arguments.missingMethodArguments, keys = local.info.foreignKey); } else if (local.name == "hasObjects") { local.method = "exists"; @@ -641,4 +565,55 @@ component { } } } + + /** + * Internal function. Resolves the "key or object" argument convention shared by the dynamic + * association methods (setObject, addObject, removeObject and deleteObject). Mutates + * `missingMethodArguments` in place and returns a struct with the method to invoke plus the + * component reference to invoke it on (the supplied object when one was passed, otherwise + * the `componentReference` given in the arguments). + */ + public struct function $resolveAssociationTarget( + required struct missingMethodArguments, + required any componentReference, + required string argumentName, + required string methodName, + required string objectMethod, + required string keyMethod + ) { + local.rv = {}; + local.rv.method = ""; + local.rv.componentReference = arguments.componentReference; + + if (StructCount(arguments.missingMethodArguments) == 1) { + // Single argument, must be either the key or the object. + if (IsObject(arguments.missingMethodArguments[1])) { + local.rv.componentReference = arguments.missingMethodArguments[1]; + local.rv.method = arguments.objectMethod; + } else { + arguments.missingMethodArguments.key = arguments.missingMethodArguments[1]; + local.rv.method = arguments.keyMethod; + } + StructClear(arguments.missingMethodArguments); + } else { + // Multiple arguments so ensure that either `key` or the association argument exists. + if ( + StructKeyExists(arguments.missingMethodArguments, arguments.argumentName) + && IsObject(arguments.missingMethodArguments[arguments.argumentName]) + ) { + local.rv.componentReference = arguments.missingMethodArguments[arguments.argumentName]; + local.rv.method = arguments.objectMethod; + StructDelete(arguments.missingMethodArguments, arguments.argumentName); + } else if (StructKeyExists(arguments.missingMethodArguments, "key")) { + local.rv.method = arguments.keyMethod; + } else { + Throw( + type = "Wheels.IncorrectArguments", + message = "The `#arguments.argumentName#` or `key` named argument is required.", + extendedInfo = "When using multiple arguments for #arguments.methodName#() you must supply an object using the argument `#arguments.argumentName#` or a key using the argument `key`, e.g. #arguments.methodName#(#arguments.argumentName#=post) or #arguments.methodName#(key=post.id)." + ); + } + } + return local.rv; + } } diff --git a/vendor/wheels/model/properties.cfc b/vendor/wheels/model/properties.cfc index 31b40dea3d..ce850c4697 100644 --- a/vendor/wheels/model/properties.cfc +++ b/vendor/wheels/model/properties.cfc @@ -751,34 +751,25 @@ component { } /** - * Sanitizes arguments passed to dynamic scope handler functions so that - * string interpolation in WHERE clauses is safe against SQL injection. + * Escapes simple-value arguments passed to dynamic scope handler functions so that + * QUOTED string interpolation in WHERE clauses (`where = "col = '##args.x##'"`) is safe: + * the quoted literal is subsequently re-extracted by `$whereClause`'s RESQLWhere pass + * and bound via cfqueryparam, so escaping the quotes is sufficient to keep the value + * inside the literal. Values are never keyword-filtered or rewritten beyond escaping — + * "Union Pacific" round-trips unchanged. * - * WARNING: For best security, scope handlers should use parameterized queries - * rather than string interpolation. This sanitization is a safety net, not a - * replacement for proper parameterization. + * WARNING: escaping does NOT make UNQUOTED interpolation (`where = "age > ##args.age##"`) + * safe. Handlers that interpolate unquoted must validate/cast the value themselves, or + * better, return `whereParams` in the scope spec for full parameterization (supported by + * `ScopeChain.$mergeSpecs()` — see the enum scope implementation in this file). * - * @args The struct of arguments to sanitize (typically missingMethodArguments). + * @args The struct of arguments to escape (typically missingMethodArguments). */ public struct function $sanitizeScopeHandlerArgs(required struct args) { local.sanitized = {}; for (local.key in arguments.args) { local.val = arguments.args[local.key]; if (IsSimpleValue(local.val)) { - // Strip null bytes - local.val = Replace(local.val, Chr(0), "", "all"); - // Strip SQL comment/statement markers before escaping - local.val = Replace(local.val, "--", "", "all"); - local.val = Replace(local.val, "/*", "", "all"); - local.val = Replace(local.val, "*/", "", "all"); - local.val = Replace(local.val, ";", "", "all"); - // Strip dangerous SQL keywords that could be used for injection. - // Word-boundary matching prevents false positives in normal values. - local.val = REReplaceNoCase(local.val, "\b(UNION|EXEC|EXECUTE|BENCHMARK|SLEEP|WAITFOR|DELAY)\b", "", "all"); - local.val = REReplaceNoCase(local.val, "\bxp_\w*", "", "all"); - local.val = REReplaceNoCase(local.val, "\bINTO\s+OUTFILE\b", "", "all"); - local.val = REReplaceNoCase(local.val, "\bLOAD_FILE\s*\(", "(", "all"); - local.val = REReplaceNoCase(local.val, "\bCHAR\s*\(", "(", "all"); local.sanitized[local.key] = $escapeSqlValue(local.val); } else { local.sanitized[local.key] = local.val; @@ -916,10 +907,14 @@ component { } variables.wheels.class.enums[arguments.property] = local.enumDef; - // Auto-register inclusion validation for this property + // Build inclusion from stored values (not name keys) so valid() agrees with scopes and is*() — ##3014. + local.inclusionList = ""; + for (local.enumName in ListToArray(local.enumDef.names)) { + local.inclusionList = ListAppend(local.inclusionList, local.enumDef.values[local.enumName]); + } validatesInclusionOf( properties = arguments.property, - list = StructKeyList(local.enumDef.values), + list = local.inclusionList, allowBlank = true ); diff --git a/vendor/wheels/model/query/QueryBuilder.cfc b/vendor/wheels/model/query/QueryBuilder.cfc index 066b233627..1c406420c6 100644 --- a/vendor/wheels/model/query/QueryBuilder.cfc +++ b/vendor/wheels/model/query/QueryBuilder.cfc @@ -435,9 +435,10 @@ component output="false" { local.scopeDef = variables.modelReference.$classData().scopes[arguments.missingMethodName]; if (StructKeyExists(local.scopeDef, "handler") && Len(local.scopeDef.handler)) { + local.sanitizedArgs = variables.modelReference.$sanitizeScopeHandlerArgs(arguments.missingMethodArguments); local.spec = variables.modelReference.$invoke( method = local.scopeDef.handler, - invokeArgs = arguments.missingMethodArguments + invokeArgs = local.sanitizedArgs ); } else { local.spec = Duplicate(local.scopeDef); diff --git a/vendor/wheels/model/query/ScopeChain.cfc b/vendor/wheels/model/query/ScopeChain.cfc index 885c06cf0f..37169e9303 100644 --- a/vendor/wheels/model/query/ScopeChain.cfc +++ b/vendor/wheels/model/query/ScopeChain.cfc @@ -34,10 +34,25 @@ component output="false" { // The downstream SQL builder ($whereClause) will re-extract these via RESQLWhere // regex and convert them into cfqueryparam parameters for true parameterized execution. if (StructKeyExists(local.spec, "whereParams") && IsArray(local.spec.whereParams)) { - for (local.p in local.spec.whereParams) { - local.quotedVal = "'" & variables.modelReference.$escapeSqlValue(ToString(local.p.value)) & "'"; - local.resolvedWhere = Replace(local.resolvedWhere, "?", local.quotedVal, "one"); + // Split on the original placeholders once and rejoin with the quoted values so a + // substituted value that itself contains a literal "?" can't absorb the next + // placeholder and shift the remaining parameters. + local.parts = ListToArray(local.resolvedWhere, "?", true); + local.paramCount = ArrayLen(local.spec.whereParams); + local.rebuilt = local.parts[1]; + local.iEnd = ArrayLen(local.parts); + for (local.i = 2; local.i <= local.iEnd; local.i++) { + if (local.i - 1 <= local.paramCount) { + local.quotedVal = "'" & variables.modelReference.$escapeSqlValue(ToString(local.spec.whereParams[local.i - 1].value)) & "'"; + local.rebuilt &= local.quotedVal; + } else { + // More placeholders than parameters: leave the extra "?" in place (matches + // the previous behavior of only resolving as many "?" as there are params). + local.rebuilt &= "?"; + } + local.rebuilt &= local.parts[local.i]; } + local.resolvedWhere = local.rebuilt; } if (StructKeyExists(local.merged, "where") && Len(local.merged.where)) { local.merged.where = "(#local.merged.where#) AND (#local.resolvedWhere#)"; diff --git a/vendor/wheels/model/read.cfc b/vendor/wheels/model/read.cfc index 8b0b1d53c6..6264f25346 100644 --- a/vendor/wheels/model/read.cfc +++ b/vendor/wheels/model/read.cfc @@ -50,7 +50,8 @@ component { string dataSource = variables.wheels.class.dataSource, numeric $limit = "0", numeric $offset = "0", - boolean $forUpdate = "false" + boolean $forUpdate = "false", + boolean $useRequestCache = "true" ) { $args(name = "findAll", args = arguments); $setDebugName(name = "findAll", args = arguments); @@ -155,7 +156,8 @@ component { $debugName = arguments.$debugName, includeSoftDeletes = arguments.includeSoftDeletes, dataSource = arguments.dataSource, - callbacks = false + callbacks = false, + $useRequestCache = arguments.$useRequestCache ); if (local.values.RecordCount) { local.paginationWhere = ""; @@ -230,8 +232,12 @@ component { local.originalWhere = arguments.where; arguments.where = ReReplace(arguments.where, variables.wheels.class.RESQLWhere, "\1?\8", "all"); - // get info from cache when available, otherwise create the generic select, from, where and order by clause - local.queryShellKey = $hashedKey(variables.wheels.class.modelName, arguments); + // get info from cache when available, otherwise create the generic select, from, where and order by clause. + // $useRequestCache governs request-level caching only — it doesn't change the generated SQL — so strip it from the shell-key args; otherwise the application-scoped SQL cache fragments into two entries (batch vs non-batch) for every model that uses both. + // A shallow StructCopy is sufficient (and cheaper than Duplicate) since we only remove a top-level key and $hashedKey doesn't mutate the struct. + local.shellKeyArgs = StructCopy(arguments); + StructDelete(local.shellKeyArgs, "$useRequestCache"); + local.queryShellKey = $hashedKey(variables.wheels.class.modelName, local.shellKeyArgs); local.sql = $getFromCache(local.queryShellKey, "sql"); if (!IsArray(local.sql)) { local.sql = []; @@ -296,15 +302,22 @@ component { parameterize = arguments.parameterize ); - // Create a struct in the request scope to store cached queries. - if (!StructKeyExists(request.wheels, variables.wheels.class.modelName)) { - request.wheels[variables.wheels.class.modelName] = {}; + // Determine whether the request-level query cache should be used for this call. + // Batch finders (findEach / findInBatches) opt out via $useRequestCache so their per-page results don't accumulate in the request scope for the remainder of the request. + local.useRequestCache = application.wheels.cacheQueriesDuringRequest && arguments.$useRequestCache; + if (local.useRequestCache) { + // Create a struct in the request scope to store cached queries. + if (!StructKeyExists(request.wheels, variables.wheels.class.modelName)) { + request.wheels[variables.wheels.class.modelName] = {}; + } + + // Derive the request cache key from the SQL shell key computed above (it already encodes the model name and the full arguments struct) so we don't have to serialize all arguments a second time. + local.queryKey = $hashedKey(local.queryShellKey, local.originalWhere); } // return existing query result if it has been run already in current request, otherwise pass off the sql array to the query - local.queryKey = $hashedKey(variables.wheels.class.modelName, arguments, local.originalWhere); if ( - application.wheels.cacheQueriesDuringRequest + local.useRequestCache && !arguments.reload && StructKeyExists(request.wheels[variables.wheels.class.modelName], local.queryKey) ) { @@ -338,7 +351,10 @@ component { } } local.findAll = variables.wheels.class.adapter.$querySetup(argumentCollection = local.finderArgs); - request.wheels[variables.wheels.class.modelName][local.queryKey] = local.findAll; // <- store in request cache so we never run the exact same query twice in the same request + if (local.useRequestCache) { + // Store in request cache so we never run the exact same query twice in the same request. + request.wheels[variables.wheels.class.modelName][local.queryKey] = local.findAll; + } } // return using the native cfml query returntype if the argument is present @@ -648,6 +664,15 @@ component { arguments.order = primaryKey() & " ASC"; } + // Run the COUNT query once up front and pass the total to every page request below so each batch doesn't re-run it. + local.totalRecords = $countForBatching(argumentCollection = arguments); + + // Nothing matches so there is nothing to iterate. + // Returning here also avoids a redundant second COUNT (findAll only honors its `count` argument when it's greater than zero, so passing 0 would make the first page query re-run the COUNT itself). + if (local.totalRecords == 0) { + return; + } + local.page = 1; local.keepGoing = true; @@ -660,6 +685,9 @@ component { local.finderArgs.page = local.page; local.finderArgs.perPage = arguments.batchSize; local.finderArgs.includeSoftDeletes = arguments.includeSoftDeletes; + local.finderArgs.count = local.totalRecords; + // Opt out of the request-level query cache so the per-batch results don't accumulate in memory for the remainder of the request (which would defeat the purpose of batched processing). + local.finderArgs.$useRequestCache = false; if (arguments.returnAs == "struct") { local.finderArgs.returnAs = "structs"; } else { @@ -719,6 +747,15 @@ component { arguments.order = primaryKey() & " ASC"; } + // Run the COUNT query once up front and pass the total to every page request below so each batch doesn't re-run it. + local.totalRecords = $countForBatching(argumentCollection = arguments); + + // Nothing matches so there is nothing to iterate. + // Returning here also avoids a redundant second COUNT (findAll only honors its `count` argument when it's greater than zero, so passing 0 would make the first page query re-run the COUNT itself). + if (local.totalRecords == 0) { + return; + } + local.page = 1; local.keepGoing = true; @@ -732,6 +769,9 @@ component { local.finderArgs.perPage = arguments.batchSize; local.finderArgs.returnAs = arguments.returnAs; local.finderArgs.includeSoftDeletes = arguments.includeSoftDeletes; + local.finderArgs.count = local.totalRecords; + // Opt out of the request-level query cache so the per-batch results don't accumulate in memory for the remainder of the request (which would defeat the purpose of batched processing). + local.finderArgs.$useRequestCache = false; if (StructKeyExists(arguments, "parameterize")) { local.finderArgs.parameterize = arguments.parameterize; } @@ -765,4 +805,24 @@ component { } } } + + /** + * Runs the COUNT query used by the batch finders (findEach / findInBatches) once up front. + * The result is passed to findAll's `count` argument for every page so the COUNT isn't re-run per batch. + */ + public numeric function $countForBatching( + string where = "", + string include = "", + boolean includeSoftDeletes = false, + any parameterize + ) { + local.countArgs = {}; + local.countArgs.where = arguments.where; + local.countArgs.include = arguments.include; + local.countArgs.includeSoftDeletes = arguments.includeSoftDeletes; + if (StructKeyExists(arguments, "parameterize")) { + local.countArgs.parameterize = arguments.parameterize; + } + return this.count(argumentCollection = local.countArgs); + } } diff --git a/vendor/wheels/model/serialize.cfc b/vendor/wheels/model/serialize.cfc index 1a1649d9df..4d834cbd3b 100644 --- a/vendor/wheels/model/serialize.cfc +++ b/vendor/wheels/model/serialize.cfc @@ -93,15 +93,22 @@ component { required string returnIncluded ) { local.rv = []; - local.doneStructs = ""; + + // Duplicate root rows can only occur when associated tables are joined into the query via the `include` argument, so for plain queries we skip duplicate detection entirely to avoid the per-row serialization/hashing cost. + local.detectDuplicates = Len(arguments.include) > 0; + local.doneStructs = {}; // loop through all of our records and create an object for each row in the query local.iEnd = arguments.query.recordCount; for (local.i = 1; local.i <= local.iEnd; local.i++) { // create a new struct local.struct = $queryRowToStruct(properties = arguments.query, row = local.i); - local.structHash = $hashedKey(local.struct); - if (!ListFind(local.doneStructs, local.structHash, Chr(7))) { + local.isDuplicate = false; + if (local.detectDuplicates) { + local.structHash = $hashedKey(local.struct); + local.isDuplicate = StructKeyExists(local.doneStructs, local.structHash); + } + if (!local.isDuplicate) { if (Len(arguments.include) && arguments.returnIncluded) { // loop through our associations to build nested objects attached to the main object local.jEnd = ListLen(arguments.include); @@ -110,7 +117,7 @@ component { if (variables.wheels.class.associations[local.include].type == "hasMany") { // we have a hasMany association, so loop through all of the records again to find the ones that belong to our root object local.struct[local.include] = []; - local.hasManyDoneStructs = ""; + local.hasManyDoneStructs = {}; // only get a reference to our model once per association local._model = model(variables.wheels.class.associations[local.include].modelName); @@ -125,7 +132,7 @@ component { base = false ); local.hasManyStructHash = $hashedKey(local.hasManyStruct); - if (!ListFind(local.hasManyDoneStructs, local.hasManyStructHash, Chr(7))) { + if (!StructKeyExists(local.hasManyDoneStructs, local.hasManyStructHash)) { // create object instance from values in current query row if it belongs to the current object local.primaryKeyColumnValues = ""; local.lEnd = ListLen(primaryKeys()); @@ -141,7 +148,7 @@ component { ) { ArrayAppend(local.struct[local.include], local.hasManyStruct); } - local.hasManyDoneStructs = ListAppend(local.hasManyDoneStructs, local.hasManyStructHash, Chr(7)); + local.hasManyDoneStructs[local.hasManyStructHash] = true; } } } else { @@ -173,7 +180,9 @@ component { } ArrayAppend(local.rv, local.struct); - local.doneStructs = ListAppend(local.doneStructs, local.structHash, Chr(7)); + if (local.detectDuplicates) { + local.doneStructs[local.structHash] = true; + } } } return local.rv; diff --git a/vendor/wheels/model/sql.cfc b/vendor/wheels/model/sql.cfc index f62ec78958..773068beb9 100644 --- a/vendor/wheels/model/sql.cfc +++ b/vendor/wheels/model/sql.cfc @@ -431,6 +431,37 @@ component { return local.rv; } + /** + * Internal function. + * Returns true when a select-list item contains SQL control characters or a + * parenthesized subquery — the patterns $orderByClause and $groupByClause already + * reject but the select clause currently passes through verbatim (SEC-21). + */ + public boolean function $isSuspiciousSelectItem(required string item) { + return Find(";", arguments.item) > 0 + || Find("--", arguments.item) > 0 + || Find("/*", arguments.item) > 0 + || ReFindNoCase("\(\s*SELECT(\s|\()", arguments.item) > 0; + } + + /** + * Internal function. + * SEC-21 deprecation window: logs a development-mode warning for suspicious + * select= items instead of rejecting them, so existing apps keep working while + * being nudged off raw SQL in select=. Returns true when a warning was logged. + */ + public boolean function $warnOnUnvalidatedSelectItem(required string item) { + if (get("environment") != "development" || !$isSuspiciousSelectItem(arguments.item)) { + return false; + } + WriteLog( + type = "warning", + file = "wheels", + text = "[Wheels] The select= item `#arguments.item#` contains SQL control characters or a subquery. Dotted/aliased select items are currently passed through unvalidated; a future Wheels release will reject items containing `;`, `--`, `/*`, or subqueries (use a calculated property instead, and never pass request input to select=)." + ); + return true; + } + /** * Internal function. */ @@ -499,6 +530,14 @@ component { In case "." or " AS " is passed in the column name item, append that as it is in the select query and then move onto the next iteration. */ if (Find(".", local.iItem) || Find(" AS ", local.iItem)) { + // SEC-21 deprecation window: dotted/aliased select items pass through + // unvalidated (unlike ORDER BY / GROUP BY). Warn in development mode when + // an item looks like raw SQL so apps can migrate before a future release + // rejects these. GROUP BY items are already rejected in $groupByClause + // before reaching this function, so gate on the select clause only. + if (arguments.clause == "select") { + $warnOnUnvalidatedSelectItem(local.iItem); + } local.rv = ListAppend(local.rv, local.iItem); continue; } @@ -697,6 +736,29 @@ component { return local.rv; } + /** + * Internal function. + * Returns the SQL dialect name for THIS model's datasource (e.g. "MySQL", + * "PostgreSQL", "SQLite") by stripping the "Model" suffix from the adapter + * name persisted on the model class at $assignAdapter() time. Replaces the + * former Migration.adapter.adapterName() probe, which instantiated + * wheels.migrator.Migration on every WHERE build and — worse — probed the + * app DEFAULT datasource's dialect even for models on a custom datasource. + * Deliberately NOT get("adapterName"): $assignAdapter() rewrites that + * GLOBAL setting on every model class init (including adapter-cache hits), + * so in a multi-datasource app it holds the adapter of whichever model + * class initialized most recently — order-dependent and wrong for any + * model whose datasource differs from the last-initialized one. The + * global remains only as a fallback for table-less models, which never + * run $assignAdapter(). + */ + public string function $dialectName() { + if (StructKeyExists(variables.wheels.class, "adapterName")) { + return ReReplace(variables.wheels.class.adapterName, "Model$", ""); + } + return ReReplace(get("adapterName"), "Model$", ""); + } + /** * Internal function. */ @@ -709,9 +771,9 @@ component { struct useIndex = {} ) { // Issue#1273: Added this section to allow included tables to be referenced in the query - local.migration = CreateObject("component", "wheels.migrator.Migration").init(); + local.dialect = $dialectName(); local.tempSql = ""; - if(arguments.include != "" && ListFind('PostgreSQL,CockroachDB,H2,MicrosoftSQLServer,Oracle,SQLite', local.migration.adapter.adapterName()) && structKeyExists(arguments, "sql")){ + if(arguments.include != "" && ListFind('PostgreSQL,CockroachDB,H2,MicrosoftSQLServer,Oracle,SQLite', local.dialect) && structKeyExists(arguments, "sql")){ local.tempSql = arguments.sql; } local.whereClause = $whereClause( @@ -726,15 +788,22 @@ component { // Resolve include via $expandedAssociations to get safe table names (prevents SQL injection) local.expandedAssociations = $expandedAssociations(include=arguments.include); if(ArrayLen(local.expandedAssociations)){ - local.resolvedTableName = variables.wheels.class.adapter.$quoteIdentifier(local.expandedAssociations[1].tableName); - if(ListFind('PostgreSQL,CockroachDB', local.migration.adapter.adapterName())){ - ArrayAppend(arguments.sql, "FROM #local.resolvedTableName#"); + // list EVERY included table — hard-indexing [1] dropped all includes after the first + local.resolvedTableNames = ""; + for (local.i = 1; local.i <= ArrayLen(local.expandedAssociations); local.i++) { + local.resolvedTableNames = ListAppend( + local.resolvedTableNames, + variables.wheels.class.adapter.$quoteIdentifier(local.expandedAssociations[local.i].tableName) + ); } - else if(ListFind('MicrosoftSQLServer', local.migration.adapter.adapterName())){ + if(ListFind('PostgreSQL,CockroachDB', local.dialect)){ + ArrayAppend(arguments.sql, "FROM #local.resolvedTableNames#"); + } + else if(ListFind('MicrosoftSQLServer', local.dialect)){ ArrayAppend(arguments.sql, "FROM #$quotedTableName()#"); } - else if(ListFind('H2,Oracle,SQLite', local.migration.adapter.adapterName())){ - ArrayAppend(arguments.sql, "WHERE EXISTS (SELECT 1 FROM #local.resolvedTableName#"); + else if(ListFind('H2,Oracle,SQLite', local.dialect)){ + ArrayAppend(arguments.sql, "WHERE EXISTS (SELECT 1 FROM #local.resolvedTableNames#"); } } } @@ -750,6 +819,8 @@ component { */ public array function $whereClause(required string where, string include = "", boolean includeSoftDeletes = "false", sql = "", boolean softDelete = "true", useIndex = {}) { local.rv = []; + // hoisted: the soft-delete section at the bottom of this function also reads the dialect + local.dialect = $dialectName(); if (Len(arguments.where)) { // setup an array containing class info for current class and all the ones that should be included local.classes = []; @@ -762,27 +833,50 @@ component { // constructed internally by $expandedAssociations() using $quoteIdentifier() for all // table and column names (see the join-building loop in $expandedAssociations). The // include parameter is validated against registered associations before reaching here. + // for UPDATE-with-include the joined tables' ON conditions move into the WHERE + // clause; use the joinOnConditions exposed by $expandedAssociations (position + // arithmetic on " ON ") and join multiple includes with AND — the former + // Split("ON") truncated joins containing the ON substring and the classes[2] + // hard-index dropped every include after the first local.joinclause = ""; - local.migration = CreateObject("component", "wheels.migrator.Migration").init(); - if(arguments.include != "" && ListFind('PostgreSQL,CockroachDB,H2', local.migration.adapter.adapterName()) && left(arguments.sql[1], 6) == 'UPDATE'){ - for(local.i = 1; local.i<= arrayLen(local.classes); i++){ - if(structKeyExists(local.classes[local.i], "JOIN")){ - local.joinclause &= local.classes[local.i].JOIN.Split("ON")[2]; + if(arguments.include != "" && ListFind('PostgreSQL,CockroachDB,H2', local.dialect) && left(arguments.sql[1], 6) == 'UPDATE'){ + for(local.i = 1; local.i <= ArrayLen(local.classes); local.i++){ + if(StructKeyExists(local.classes[local.i], "joinOnConditions") && Len(local.classes[local.i].joinOnConditions)){ + if(Len(local.joinclause)){ + local.joinclause &= " AND "; + } + local.joinclause &= local.classes[local.i].joinOnConditions; } } + if(!Len(local.joinclause)){ + Throw(type="Wheels.UpdateAll.EmptyJoinConditions", + message="updateAll(include=) produced no join conditions for dialect #local.dialect#"); + } ArrayAppend(local.rv, "WHERE #local.joinclause# AND"); } - else if(arguments.include != "" && ListFind('MicrosoftSQLServer', local.migration.adapter.adapterName()) && left(arguments.sql[1], 6) == 'UPDATE'){ - for(local.i = 1; local.i<= arrayLen(local.classes); i++){ + else if(arguments.include != "" && ListFind('MicrosoftSQLServer', local.dialect) && left(arguments.sql[1], 6) == 'UPDATE'){ + for(local.i = 1; local.i <= ArrayLen(local.classes); local.i++){ if(structKeyExists(local.classes[local.i], "JOIN")){ local.joinclause &= local.classes[local.i].JOIN; } } ArrayAppend(local.rv, "#local.joinclause# WHERE "); } - else if(arguments.include != "" && ListFind('Oracle,SQLite', local.migration.adapter.adapterName()) && left(arguments.sql[1], 6) == 'UPDATE'){ + else if(arguments.include != "" && ListFind('Oracle,SQLite', local.dialect) && left(arguments.sql[1], 6) == 'UPDATE'){ + for(local.i = 1; local.i <= ArrayLen(local.classes); local.i++){ + if(StructKeyExists(local.classes[local.i], "joinOnConditions") && Len(local.classes[local.i].joinOnConditions)){ + if(Len(local.joinclause)){ + local.joinclause &= " AND "; + } + local.joinclause &= local.classes[local.i].joinOnConditions; + } + } + if(!Len(local.joinclause)){ + Throw(type="Wheels.UpdateAll.EmptyJoinConditions", + message="updateAll(include=) produced no join conditions for dialect #local.dialect#"); + } ArrayAppend(local.rv, "WHERE"); - ArrayAppend(local.rv, local.classes[2].JOIN.Split("ON")[2] & " AND"); + ArrayAppend(local.rv, local.joinclause & " AND"); } else { ArrayAppend(local.rv, "WHERE"); @@ -922,7 +1016,7 @@ component { local.addToWhere = Replace(local.addToWhere, ",", " AND ", "all"); if (Len(local.addToWhere)) { if (Len(arguments.where)) { - if(!(ListFind('Oracle,SQLite', local.migration.adapter.adapterName()) && (isArray(arguments.sql) && left(arguments.sql[1], 6) == 'UPDATE'))){ + if(!(ListFind('Oracle,SQLite', local.dialect) && (isArray(arguments.sql) && left(arguments.sql[1], 6) == 'UPDATE'))){ ArrayInsertAt(local.rv, local.wherePos, " ("); } ArrayAppend(local.rv, ") AND ("); @@ -1106,16 +1200,65 @@ component { } else { local.firstAssociation = ListFirst(local.throughPath); local.targetAssociation = ListLast(local.throughPath); - - local.expandedInclude = local.firstAssociation & "(" & local.targetAssociation & ")"; - local.rv = ListAppend(local.rv, local.expandedInclude); + + // Only rewrite a 2-element `through` into a nested this-model include + // when its first segment is actually an association on the current + // model (mirroring the 1-element branch's existence check above). The + // `hasMany` `shortcut` argument stores an opposite-side chain in + // `through` ("#singularize(shortcut)#,#name#") that is consumed by the + // shortcut dispatcher in $associationMethod, not by include expansion. + // Rewriting it here turned the plain include (e.g. "userRoles") into a + // lookup for an association the current model does not have (e.g. + // "role(userRoles)"), throwing Wheels.AssociationNotFound (issue #3109). + if (StructKeyExists(local.associations, local.firstAssociation)) { + local.expandedInclude = local.firstAssociation & "(" & local.targetAssociation & ")"; + local.rv = ListAppend(local.rv, local.expandedInclude); + } else { + // Not a this-model through chain (e.g. a shortcut's default through), use as-is. + local.rv = ListAppend(local.rv, local.currentInclude); + } } } else { - // No through association, use as-is - local.rv = ListAppend(local.rv, local.currentInclude); + // `currentInclude` is not a this-model `through` association. It may, + // however, be the `shortcut` name of a many-to-many `hasMany` — a + // convenience accessor registered as a dynamic method (consumed by the + // shortcut dispatcher in $associationMethod), NOT as a first-class + // includable association. When such a name reaches `include`, resolve it + // to the nested this-model bridge include so the join still happens + // instead of throwing Wheels.AssociationNotFound (issue #3208). + // + // Only the shortcut path is rewritten here: a plain association without + // a `through` is left untouched, and a real association whose name was + // passed (even one carrying a shortcut's own through-chain) never enters + // this branch — preserving the issue #3109 contract. + local.shortcutExpanded = ""; + if (!StructKeyExists(local.associations, local.currentInclude)) { + for (local.assocName in local.associations) { + local.assoc = local.associations[local.assocName]; + if ( + StructKeyExists(local.assoc, "shortcut") + && Len(local.assoc.shortcut) + && local.assoc.shortcut == local.currentInclude + && StructKeyExists(local.assoc, "through") + && ListLen(local.assoc.through) == 2 + ) { + // through = ","; + // the first segment is the bridge model's association to the far + // side, so the shortcut joins as "()". + local.shortcutExpanded = local.assocName & "(" & ListFirst(local.assoc.through) & ")"; + break; + } + } + } + if (Len(local.shortcutExpanded)) { + local.rv = ListAppend(local.rv, local.shortcutExpanded); + } else { + // No through / shortcut match, use as-is + local.rv = ListAppend(local.rv, local.currentInclude); + } } } - + return local.rv; } @@ -1179,47 +1322,71 @@ component { // create a reference to the associated class local.associatedClass = model(local.classAssociations[local.name].modelName); - if (!Len(local.classAssociations[local.name].foreignKey)) { - // cfformat-ignore-start - if (local.classAssociations[local.name].type == "belongsTo") { - local.classAssociations[local.name].foreignKey = local.associatedClass.$classData().modelName & Replace(local.associatedClass.$classData().keys, ",", ",#local.associatedClass.$classData().modelName#", "all"); - } else { - local.classAssociations[local.name].foreignKey = local.class.$classData().modelName & Replace(local.class.$classData().keys, ",", ",#local.class.$classData().modelName#", "all"); - } - // cfformat-ignore-end - } - if (!Len(local.classAssociations[local.name].joinKey)) { - if (local.classAssociations[local.name].type == "belongsTo") { - local.classAssociations[local.name].joinKey = local.associatedClass.$classData().keys; - } else { - local.classAssociations[local.name].joinKey = local.class.$classData().keys; + // fill in the context-independent association metadata under a double-checked named + // lock so a concurrent first hit cannot interleave partial writes into the shared + // application-scoped association struct (same pattern as the JOIN-variant memo + // below); the lock is only taken before the marker exists so the hot path stays + // lock-free, and the values are derived solely from class data so filling them + // once per application lifetime is equivalent to the previous per-call rewrite + if (!StructKeyExists(local.classAssociations[local.name], "expandedMetadataFilled")) { + lock name="wheelsJoinMemo#application.applicationName#" type="exclusive" timeout="10" { + if (!StructKeyExists(local.classAssociations[local.name], "expandedMetadataFilled")) { + if (!Len(local.classAssociations[local.name].foreignKey)) { + // cfformat-ignore-start + if (local.classAssociations[local.name].type == "belongsTo") { + local.classAssociations[local.name].foreignKey = local.associatedClass.$classData().modelName & Replace(local.associatedClass.$classData().keys, ",", ",#local.associatedClass.$classData().modelName#", "all"); + } else { + local.classAssociations[local.name].foreignKey = local.class.$classData().modelName & Replace(local.class.$classData().keys, ",", ",#local.class.$classData().modelName#", "all"); + } + // cfformat-ignore-end + } + if (!Len(local.classAssociations[local.name].joinKey)) { + if (local.classAssociations[local.name].type == "belongsTo") { + local.classAssociations[local.name].joinKey = local.associatedClass.$classData().keys; + } else { + local.classAssociations[local.name].joinKey = local.class.$classData().keys; + } + } + local.classAssociations[local.name].tableName = local.associatedClass.$classData().tableName; + local.classAssociations[local.name].columnList = local.associatedClass.$classData().columnList; + local.classAssociations[local.name].properties = local.associatedClass.$classData().properties; + local.classAssociations[local.name].propertyList = local.associatedClass.$classData().propertyList; + + /* + To fix the issue below: + https://github.com/wheels-dev/wheels/issues/580 + + Add aliasedPropertyList in the associated class that will be used to check the duplicate column + */ + local.classAssociations[local.name].aliasedPropertyList = local.associatedClass.$classData().aliasedPropertyList; + + local.classAssociations[local.name].calculatedProperties = local.associatedClass.$classData().calculatedProperties; + local.classAssociations[local.name].calculatedPropertyList = local.associatedClass.$classData().calculatedPropertyList; + // TODO: deprecate the lists above in favour of these structs to avoid listFind + local.classAssociations[local.name].columnStruct = local.associatedClass.$classData().columnStruct; + local.classAssociations[local.name].propertyStruct = local.associatedClass.$classData().propertyStruct; + + // the marker is written last so readers that skip the lock only ever + // observe a fully-populated metadata set + local.classAssociations[local.name].expandedMetadataFilled = true; + } } } - local.classAssociations[local.name].tableName = local.associatedClass.$classData().tableName; - local.classAssociations[local.name].columnList = local.associatedClass.$classData().columnList; - local.classAssociations[local.name].properties = local.associatedClass.$classData().properties; - local.classAssociations[local.name].propertyList = local.associatedClass.$classData().propertyList; - - /* - To fix the issue below: - https://github.com/wheels-dev/wheels/issues/580 - - Add aliasedPropertyList in the associated class that will be used to check the duplicate column - */ - local.classAssociations[local.name].aliasedPropertyList = local.associatedClass.$classData().aliasedPropertyList; - - local.classAssociations[local.name].calculatedProperties = local.associatedClass.$classData().calculatedProperties; - local.classAssociations[local.name].calculatedPropertyList = local.associatedClass.$classData().calculatedPropertyList; - // TODO: deprecate the lists above in favour of these structs to avoid listFind - local.classAssociations[local.name].columnStruct = local.associatedClass.$classData().columnStruct; - local.classAssociations[local.name].propertyStruct = local.associatedClass.$classData().propertyStruct; - - // create the join string if it hasn't already been done - if (!StructKeyExists(local.classAssociations[local.name], "join")) { + + // the JOIN string depends on the calling context (soft-delete handling and whether the + // table needs to be aliased), so memoize it per context variant instead of globally + local.aliasJoin = ListFindNoCase(local.tables, local.classAssociations[local.name].tableName) > 0; + local.joinVariantKey = "sd" & (arguments.includeSoftDeletes ? 1 : 0) & "_alias" & (local.aliasJoin ? 1 : 0); + + // create the join string if it hasn't already been done for this context variant + if ( + !StructKeyExists(local.classAssociations[local.name], "joinVariants") + || !StructKeyExists(local.classAssociations[local.name].joinVariants, local.joinVariantKey) + ) { local.joinType = UCase(ReplaceNoCase(local.classAssociations[local.name].joinType, "outer", "left outer", "one")); local.join = local.joinType & " JOIN " & variables.wheels.class.adapter.$quoteIdentifier(local.classAssociations[local.name].tableName); // alias the table as the association name when joining to itself - if (ListFindNoCase(local.tables, local.classAssociations[local.name].tableName)) { + if (local.aliasJoin) { local.join = variables.wheels.class.adapter.$tableAlias( local.join, local.classAssociations[local.name].pluralizedName @@ -1253,9 +1420,8 @@ component { // alias the table as the association name when joining to itself local.tableName = local.classAssociations[local.name].tableName; - if (ListFindNoCase(local.tables, local.classAssociations[local.name].tableName)) { + if (local.aliasJoin) { local.tableName = local.classAssociations[local.name].pluralizedName; - ; } local.toAppend = ListAppend( local.toAppend, @@ -1283,7 +1449,20 @@ component { ); } - local.classAssociations[local.name].join = local.join & Replace(local.toAppend, ",", " AND ", "all"); + // store the built string under a double-checked named lock so a concurrent first hit + // for another context cannot poison the shared application-scoped association struct; + // the lock is only taken on memo miss so the hot path stays lock-free + lock name="wheelsJoinMemo#application.applicationName#" type="exclusive" timeout="10" { + if (!StructKeyExists(local.classAssociations[local.name], "joinVariants")) { + local.classAssociations[local.name].joinVariants = {}; + } + local.classAssociations[local.name].joinVariants[local.joinVariantKey] = local.join & Replace( + local.toAppend, + ",", + " AND ", + "all" + ); + } } // loop over each character in the delimiter sequence and move up / down the levels as appropriate @@ -1300,8 +1479,18 @@ component { // add table name to the list of used ones so we know to alias it when used a second time local.tables = ListAppend(local.tables, local.classAssociations[local.name].tableName); - // add info to the array that we will return - ArrayAppend(local.rv, local.classAssociations[local.name]); + // add info to the array that we will return; use a per-call shallow copy carrying the + // context-correct join so callers are immune to concurrent re-memoization of other variants + local.entry = StructCopy(local.classAssociations[local.name]); + local.entry.join = local.classAssociations[local.name].joinVariants[local.joinVariantKey]; + StructDelete(local.entry, "joinVariants"); + // expose the ON conditions separately for UPDATE-with-include WHERE building: + // position arithmetic on the " ON " the builder writes verbatim above — replaces + // the former case-sensitive Split("ON") that corrupted joins whose quoted + // identifiers contain the ON substring (e.g. uppercase H2 schemas) + local.onPos = Find(" ON ", local.entry.join); + local.entry.joinOnConditions = local.onPos GT 0 ? Mid(local.entry.join, local.onPos + 4, Len(local.entry.join)) : ""; + ArrayAppend(local.rv, local.entry); } return local.rv; } diff --git a/vendor/wheels/model/update.cfc b/vendor/wheels/model/update.cfc index a0feb7f183..4dd3a74e34 100644 --- a/vendor/wheels/model/update.cfc +++ b/vendor/wheels/model/update.cfc @@ -73,14 +73,14 @@ component { } else { arguments.sql = []; // Issue#1273: Added this section to allow included tables to be referenced in the query - local.migration = CreateObject("component", "wheels.migrator.Migration").init(); + local.dialect = $dialectName(); local.indexHint = this.$indexHint( useIndex = arguments.useIndex, modelName = variables.wheels.class.modelName, adapterName = get("adapterName") ); - - if (ListFind('MySQL', local.migration.adapter.adapterName())){ + + if (ListFind('MySQL', local.dialect)){ local.list = ""; local.associations = []; local.associations = $expandedAssociations( @@ -98,14 +98,14 @@ component { } } - else if (ListFind('MicrosoftSQLServer', local.migration.adapter.adapterName())){ + else if (ListFind('MicrosoftSQLServer', local.dialect)){ if (Len(local.indexHint)) { ArrayAppend(arguments.sql, "UPDATE #$quotedTableName()# #local.indexHint# SET"); } else { ArrayAppend(arguments.sql, "UPDATE #$quotedTableName()# SET"); } } - else if (ListFind('PostgreSQL,CockroachDB,H2,Oracle,SQLite', local.migration.adapter.adapterName())){ + else if (ListFind('PostgreSQL,CockroachDB,H2,Oracle,SQLite', local.dialect)){ ArrayAppend(arguments.sql, "UPDATE #$quotedTableName()# SET"); } local.pos = 0; @@ -131,7 +131,7 @@ component { includeSoftDeletes = arguments.includeSoftDeletes ); arguments.sql = $addWhereClauseParameters(sql = arguments.sql, where = arguments.where); - if (ListFind('H2', local.migration.adapter.adapterName()) && arguments.include != ""){ + if (ListFind('H2', local.dialect) && arguments.include != ""){ arrayAppend(arguments.sql, ")"); } local.rv = invokeWithTransaction(method = "$updateAll", argumentCollection = arguments); @@ -302,17 +302,9 @@ component { public boolean function $update(required any parameterize, required boolean reload) { // Perform update if changes have been made. if (hasChanged()) { - // Allow explicit assignment of the createdAt/updatedAt properties if allowExplicitTimestamps is true - local.allowExplicitTimestamps = StructKeyExists(this, "allowExplicitTimestamps") && this.allowExplicitTimestamps; - if ( - local.allowExplicitTimestamps - && StructKeyExists(this, $get("timeStampOnUpdateProperty")) - && Len(this[$get("timeStampOnUpdateProperty")]) - ) { - // leave updatedAt unmolested - } else if ($get("setUpdatedAtOnCreate") && variables.wheels.class.timeStampingOnUpdate) { - $timestampProperty(property = variables.wheels.class.timeStampOnUpdateProperty); - } + // Stamp updatedAt on every update (the `setUpdatedAtOnCreate` setting only gates the + // create path; it must not stop updates from bumping the timestamp). + $stampTimestampProperty(event = "update", enabled = variables.wheels.class.timeStampingOnUpdate); local.sql = []; ArrayAppend(local.sql, "UPDATE #$quotedTableName()# SET "); diff --git a/vendor/wheels/model/validations.cfc b/vendor/wheels/model/validations.cfc index ddbfe4936b..53aa704755 100644 --- a/vendor/wheels/model/validations.cfc +++ b/vendor/wheels/model/validations.cfc @@ -578,8 +578,20 @@ component { if (StructKeyExists(arguments, local.item) && Len(arguments[local.item])) { local.key = local.item & "Evaluated"; try { - local[local.key] = $evaluateConditionString(arguments[local.item]); + local[local.key] = $evaluateConditionString(arguments[local.item]); } catch (any e) { + if ($get("showErrorInformation")) { + Throw( + type = "Wheels.InvalidValidationCondition", + message = "The `#local.item#` expression `#arguments[local.item]#` could not be evaluated: #e.message#", + extendedInfo = "Supported forms: `this.property`, `this.method()`, bare `method()` (optionally negated with `!`), and binary comparisons using eq/neq/lt/lte/gt/gte or ==/!=//>=." + ); + } + cflog( + text = "Wheels: validation `#local.item#` expression `#arguments[local.item]#` could not be evaluated (#e.message#); validation skipped.", + type = "error", + file = "wheels-errors" + ); return false; } } @@ -838,23 +850,30 @@ component { * Normalizes symbolic comparison operators to their CFML string equivalents. */ public string function $normalizeConditionOperators(required string condition) { - local.rv = ReplaceList(arguments.condition, "==,!=,<,<=,>,>=", "eq,neq,lt,lte,gt,gte"); - return Replace(local.rv, " ", " ", "all"); + // Replace two-character operators first so the single-character "<" and ">" + // replacements cannot mangle "<=" and ">=" into "lt=" and "gt=". + local.rv = Replace(arguments.condition, "==", " eq ", "all"); + local.rv = Replace(local.rv, "!=", " neq ", "all"); + local.rv = Replace(local.rv, "<=", " lte ", "all"); + local.rv = Replace(local.rv, ">=", " gte ", "all"); + local.rv = Replace(local.rv, "<", " lt ", "all"); + local.rv = Replace(local.rv, ">", " gt ", "all"); + return Trim(REReplace(local.rv, "\s+", " ", "all")); } /** - * Splits a normalized condition on the last matching comparison operator. + * Splits a normalized condition on the first whitespace-delimited comparison operator. * Returns {expression} always, plus {operator, rightOperand} when an operator is found. */ public struct function $splitConditionOnOperator(required string condition) { local.rv = {expression: arguments.condition}; - for (local.op in ListToArray("eq,neq,lt,lte,gt,gte")) { - local.position = FindNoCase(local.op, arguments.condition); - if (local.position) { - local.rv.expression = Trim(Mid(arguments.condition, 1, local.position - 1)); - local.rv.operator = local.op; - local.rv.rightOperand = Trim(Mid(arguments.condition, local.position + Len(local.op), Len(arguments.condition))); - } + local.padded = " " & arguments.condition & " "; + local.match = REFindNoCase("\s(neq|lte|gte|eq|lt|gt)\s", local.padded, 1, true); + if (local.match.pos[1] > 0) { + local.rv.expression = Trim(Mid(local.padded, 1, local.match.pos[2] - 1)); + // LCase keeps the operator compatible with the case-sensitive switch in $resolveOperator on Adobe CF. + local.rv.operator = LCase(Mid(local.padded, local.match.pos[2], local.match.len[2])); + local.rv.rightOperand = Trim(Mid(local.padded, local.match.pos[2] + local.match.len[2], Len(local.padded))); } return local.rv; } @@ -943,7 +962,11 @@ component { } local.leftOperand = IsNumeric(local.tokens[1]) ? JavaCast("double", local.tokens[1]) : local.tokens[1]; local.rightOperand = IsNumeric(local.tokens[3]) ? JavaCast("double", local.tokens[3]) : local.tokens[3]; - return $resolveOperator(local.leftOperand, local.rightOperand, local.tokens[2]); + // LCase keeps word-form operators ("1 EQ 0") compatible with the + // case-sensitive switch in $resolveOperator on Adobe CF — symbolic + // operators are already lowercased by $normalizeConditionOperators, + // but word-form ones arrive raw (#2977). + return $resolveOperator(local.leftOperand, local.rightOperand, LCase(local.tokens[2])); } /** diff --git a/vendor/wheels/public/assets/css/debugbar.css b/vendor/wheels/public/assets/css/debugbar.css new file mode 100644 index 0000000000..0c34410bb6 --- /dev/null +++ b/vendor/wheels/public/assets/css/debugbar.css @@ -0,0 +1,41 @@ +#wheels-debugbar *{box-sizing:border-box;margin:0;padding:0;} +#wheels-debugbar{position:fixed;bottom:0;left:0;right:0;z-index:99999;font-size:13px;line-height:1.4;} +#wheels-debugbar .wdb-bar{display:flex;align-items:center;background:#1e1e2e;color:#cdd6f4;height:36px;padding:0 8px;gap:2px;border-top:1px solid #45475a;user-select:none;} +#wheels-debugbar .wdb-bar a,#wheels-debugbar .wdb-bar span{color:#cdd6f4;text-decoration:none;} +#wheels-debugbar .wdb-tab{display:flex;align-items:center;gap:5px;padding:0 10px;height:36px;cursor:pointer;border:none;background:none;color:#cdd6f4;font-size:12px;font-family:inherit;white-space:nowrap;transition:background .15s;} +#wheels-debugbar .wdb-tab:hover{background:#313244;} +#wheels-debugbar .wdb-tab.active{background:#313244;color:#89b4fa;border-top:2px solid #89b4fa;padding-top:2px;} +#wheels-debugbar .wdb-tab svg{width:14px;height:14px;fill:currentColor;flex-shrink:0;} +#wheels-debugbar .wdb-badge{display:inline-flex;align-items:center;justify-content:center;min-width:18px;height:18px;padding:0 5px;border-radius:9px;font-size:10px;font-weight:700;line-height:1;} +#wheels-debugbar .wdb-badge-green{background:#28a74533;color:#a6e3a1;} +#wheels-debugbar .wdb-badge-yellow{background:#ffc10733;color:#f9e2af;} +#wheels-debugbar .wdb-badge-red{background:#dc354533;color:#f38ba8;} +#wheels-debugbar .wdb-badge-blue{background:#89b4fa33;color:#89b4fa;} +#wheels-debugbar .wdb-sep{width:1px;height:20px;background:#45475a;margin:0 4px;} +#wheels-debugbar .wdb-spacer{flex:1;} +#wheels-debugbar .wdb-panel{display:none;position:fixed;bottom:36px;left:0;right:0;max-height:50vh;background:#1e1e2e;border-top:1px solid #45475a;overflow-y:auto;color:#cdd6f4;padding:0;} +#wheels-debugbar .wdb-panel.open{display:block;} +#wheels-debugbar .wdb-panel-header{display:flex;align-items:center;justify-content:space-between;padding:10px 16px;border-bottom:1px solid #313244;position:sticky;top:0;background:#1e1e2e;z-index:1;} +#wheels-debugbar .wdb-panel-header h3{font-size:14px;font-weight:600;color:#cdd6f4;} +#wheels-debugbar .wdb-panel-body{padding:12px 16px;} +#wheels-debugbar .wdb-table{width:100%;border-collapse:collapse;} +#wheels-debugbar .wdb-table th{text-align:left;padding:6px 12px;font-size:11px;font-weight:600;color:#a6adc8;text-transform:uppercase;letter-spacing:.5px;background:#181825;border-bottom:1px solid #313244;} +#wheels-debugbar .wdb-table td{padding:6px 12px;border-bottom:1px solid #313244;font-size:12px;vertical-align:top;} +#wheels-debugbar .wdb-table tr:hover td{background:#31324433;} +#wheels-debugbar .wdb-kv{display:grid;grid-template-columns:180px 1fr;gap:0;} +#wheels-debugbar .wdb-kv dt{padding:6px 12px;font-weight:600;color:#a6adc8;font-size:12px;border-bottom:1px solid #313244;} +#wheels-debugbar .wdb-kv dd{padding:6px 12px;font-size:12px;border-bottom:1px solid #313244;word-break:break-word;} +#wheels-debugbar .wdb-kv dd code{font-family:'SF Mono',SFMono-Regular,Menlo,Consolas,monospace;background:#313244;padding:1px 5px;border-radius:3px;font-size:11px;} +#wheels-debugbar .wdb-timing-row{display:flex;align-items:center;gap:8px;margin-bottom:6px;} +#wheels-debugbar .wdb-timing-label{width:100px;font-size:12px;color:#a6adc8;text-align:right;} +#wheels-debugbar .wdb-timing-bar-bg{flex:1;height:20px;background:#313244;border-radius:3px;overflow:hidden;position:relative;} +#wheels-debugbar .wdb-timing-bar{height:100%;border-radius:3px;display:flex;align-items:center;padding-left:6px;font-size:10px;font-weight:600;color:#1e1e2e;min-width:30px;} +#wheels-debugbar .wdb-close-btn{background:none;border:none;color:#a6adc8;cursor:pointer;font-size:18px;padding:4px 8px;border-radius:4px;line-height:1;} +#wheels-debugbar .wdb-close-btn:hover{background:#45475a;color:#cdd6f4;} +#wheels-debugbar .wdb-link-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:8px;padding:4px 0;} +#wheels-debugbar .wdb-link-card{display:flex;align-items:center;gap:8px;padding:10px 12px;background:#313244;border-radius:6px;color:#cdd6f4;text-decoration:none;font-size:12px;font-weight:500;transition:background .15s;} +#wheels-debugbar .wdb-link-card:hover{background:#45475a;} +#wheels-debugbar .wdb-link-card svg{width:16px;height:16px;fill:#89b4fa;flex-shrink:0;} +#wheels-debugbar .wdb-env-dot{width:8px;height:8px;border-radius:50%;display:inline-block;} +#wheels-debugbar .wdb-section{margin-bottom:16px;} +#wheels-debugbar .wdb-section-title{font-size:11px;font-weight:700;color:#89b4fa;text-transform:uppercase;letter-spacing:.5px;margin-bottom:8px;padding-bottom:4px;border-bottom:1px solid #313244;} diff --git a/vendor/wheels/public/assets/js/debugbar.js b/vendor/wheels/public/assets/js/debugbar.js new file mode 100644 index 0000000000..382881954d --- /dev/null +++ b/vendor/wheels/public/assets/js/debugbar.js @@ -0,0 +1,45 @@ +(function () { + var activePanel = null; + window.wdbToggle = function (name) { + var panels = document.querySelectorAll('#wheels-debugbar .wdb-panel'); + var tabs = document.querySelectorAll('#wheels-debugbar .wdb-tab'); + if (activePanel === name) { + wdbClosePanel(); + return; + } + for (var i = 0; i < panels.length; i++) panels[i].classList.remove('open'); + var p = document.getElementById('wdb-panel-' + name); + if (p) p.classList.add('open'); + for (var j = 0; j < tabs.length; j++) tabs[j].classList.remove('active'); + var t = document.getElementById('wdb-tab-' + name); + if (t) t.classList.add('active'); + activePanel = name; + }; + window.wdbClosePanel = function () { + var panels = document.querySelectorAll('#wheels-debugbar .wdb-panel'); + var tabs = document.querySelectorAll('#wheels-debugbar .wdb-tab'); + for (var i = 0; i < panels.length; i++) panels[i].classList.remove('open'); + for (var j = 0; j < tabs.length; j++) tabs[j].classList.remove('active'); + activePanel = null; + }; + window.wdbMinimize = function () { + wdbClosePanel(); + document.getElementById('wheels-debugbar').style.display = 'none'; + document.getElementById('wdb-minimized').style.display = 'block'; + try { sessionStorage.setItem('wdb-hidden', '1'); } catch (e) {} + }; + window.wdbRestore = function () { + document.getElementById('wheels-debugbar').style.display = ''; + document.getElementById('wdb-minimized').style.display = 'none'; + try { sessionStorage.removeItem('wdb-hidden'); } catch (e) {} + }; + window.wdbEnvSwitch = function (el) { + var target = el.getAttribute('data-wdb-reload'); + if (!target) return false; + var pw = window.prompt('Enter the reload password to switch environments:'); + if (pw === null || pw === '') return false; + window.location.href = target + '&password=' + encodeURIComponent(pw); + return false; + }; + try { if (sessionStorage.getItem('wdb-hidden') === '1') wdbMinimize(); } catch (e) {} +})(); diff --git a/vendor/wheels/public/docs/core.cfm b/vendor/wheels/public/docs/core.cfm index b73d0379b9..66358163f4 100644 --- a/vendor/wheels/public/docs/core.cfm +++ b/vendor/wheels/public/docs/core.cfm @@ -40,7 +40,7 @@ if (StructKeyExists(application.wheels, "docs")) { ArrayAppend(documentScope, {"name" = "model", "scope" = modelInstance}); ArrayAppend(documentScope, {"name" = "mapper", "scope" = application.wheels.mapper}); - if (application.wheels.enablePluginsComponent) { + if (application.wheels.enableMigratorComponent) { ArrayAppend(documentScope, {"name" = "migrator", "scope" = application.wheels.migrator}); ArrayAppend( documentScope, @@ -74,5 +74,11 @@ if (StructKeyExists(application.wheels, "docs")) { application.wheels.docs = docs; } -include "layouts/#request.wheels.params.format#.cfm"; +// Validate `format` against an alphanumeric allowlist before interpolating +// it into the include path. Without this, `format=../views/info` would +// climb out of layouts/ — same LFI traversal class $getRequestFormat was +// hardened against (issue #2974). Unscoped on purpose: this template runs +// both at template level (views/docs.cfm) and inside a UDF (views/ai.cfm). +docFormat = $resolveDocFormat(request.wheels.params.format); +include "layouts/#docFormat#.cfm"; diff --git a/vendor/wheels/public/docs/reference/migration/announce.txt b/vendor/wheels/public/docs/reference/migration/announce.txt index 75cfa32532..4636081a20 100644 --- a/vendor/wheels/public/docs/reference/migration/announce.txt +++ b/vendor/wheels/public/docs/reference/migration/announce.txt @@ -1,20 +1,21 @@ // 1. Log a custom message during a migration's up() step function up() { + var state = {}; transaction { try { - createTable(name="articles", force=true) { - t.string(columnNames="title", null=false); - t.text("body"); - t.timestamps(); - }; + t = createTable(name="articles", force=true); + t.string(columnNames="title", allowNull=false); + t.text(columnNames="body"); + t.timestamps(); + t.create(); announce("Created articles table"); } catch (any e) { - local.exception = e; + state.exception = e; } - if (StructKeyExists(local, "exception")) { + if (StructKeyExists(state, "exception")) { transaction action="rollback"; - throw(errorCode="1", detail=local.exception.detail, message=local.exception.message, type="any"); + throw(errorCode="1", detail=state.exception.detail, message=state.exception.message, type="any"); } else { transaction action="commit"; } @@ -23,20 +24,21 @@ function up() { // 2. Announce multiple steps to provide granular feedback function up() { + var state = {}; transaction { try { - addColumn(table="users", columnType="string", columnName="apiKey", limit=64, null=true); + addColumn(table="users", columnType="string", columnName="apiKey", limit=64, allowNull=true); announce("Added apiKey column to users"); addIndex(table="users", columnNames="apiKey", unique=true); announce("Added unique index on users.apiKey"); } catch (any e) { - local.exception = e; + state.exception = e; } - if (StructKeyExists(local, "exception")) { + if (StructKeyExists(state, "exception")) { transaction action="rollback"; - throw(errorCode="1", detail=local.exception.detail, message=local.exception.message, type="any"); + throw(errorCode="1", detail=state.exception.detail, message=state.exception.message, type="any"); } else { transaction action="commit"; } diff --git a/vendor/wheels/public/docs/reference/migration/down.txt b/vendor/wheels/public/docs/reference/migration/down.txt index 3ae7334ac3..d0da035b4d 100644 --- a/vendor/wheels/public/docs/reference/migration/down.txt +++ b/vendor/wheels/public/docs/reference/migration/down.txt @@ -1,19 +1,20 @@ // 1. Reverse a table creation by dropping the table // Called automatically when rolling back this migration function down() { + var state = {}; transaction { try { dropTable("employees"); } catch (any e) { - local.exception = e; + state.exception = e; } - if (structKeyExists(local, "exception")) { + if (structKeyExists(state, "exception")) { transaction action="rollback"; throw( errorCode = "1", - detail = local.exception.detail, - message = local.exception.message, + detail = state.exception.detail, + message = state.exception.message, type = "any" ); } else { @@ -24,19 +25,20 @@ function down() { // 2. Reverse an addColumn() call by removing the column function down() { + var state = {}; transaction { try { removeColumn(table="users", columnName="biography"); } catch (any e) { - local.exception = e; + state.exception = e; } - if (structKeyExists(local, "exception")) { + if (structKeyExists(state, "exception")) { transaction action="rollback"; throw( errorCode = "1", - detail = local.exception.detail, - message = local.exception.message, + detail = state.exception.detail, + message = state.exception.message, type = "any" ); } else { @@ -49,19 +51,20 @@ function down() { component extends="[extends]" hint="Add status column to orders" { function up() { + var state = {}; transaction { try { addColumn(table="orders", columnType="string", columnName="status", limit=50, default="pending"); } catch (any e) { - local.exception = e; + state.exception = e; } - if (structKeyExists(local, "exception")) { + if (structKeyExists(state, "exception")) { transaction action="rollback"; throw( errorCode = "1", - detail = local.exception.detail, - message = local.exception.message, + detail = state.exception.detail, + message = state.exception.message, type = "any" ); } else { @@ -71,19 +74,20 @@ component extends="[extends]" hint="Add status column to orders" { } function down() { + var state = {}; transaction { try { removeColumn(table="orders", columnName="status"); } catch (any e) { - local.exception = e; + state.exception = e; } - if (structKeyExists(local, "exception")) { + if (structKeyExists(state, "exception")) { transaction action="rollback"; throw( errorCode = "1", - detail = local.exception.detail, - message = local.exception.message, + detail = state.exception.detail, + message = state.exception.message, type = "any" ); } else { diff --git a/vendor/wheels/public/docs/reference/migration/up.txt b/vendor/wheels/public/docs/reference/migration/up.txt index 9337357b2c..a565fabb88 100644 --- a/vendor/wheels/public/docs/reference/migration/up.txt +++ b/vendor/wheels/public/docs/reference/migration/up.txt @@ -1,5 +1,6 @@ // 1. Create a new table (typical migration forward) function up() { + var state = {}; transaction { try { t = createTable(name="posts"); @@ -9,15 +10,15 @@ function up() { t.timestamps(); t.create(); } catch (any e) { - local.exception = e; + state.exception = e; } - if (structKeyExists(local, "exception")) { + if (structKeyExists(state, "exception")) { transaction action="rollback"; throw( errorCode = "1", - detail = local.exception.detail, - message = local.exception.message, + detail = state.exception.detail, + message = state.exception.message, type = "any" ); } else { @@ -28,19 +29,20 @@ function up() { // 2. Add a column to an existing table function up() { + var state = {}; transaction { try { addColumn(table="users", columnType="string", columnName="avatarUrl", limit=500, allowNull=true); } catch (any e) { - local.exception = e; + state.exception = e; } - if (structKeyExists(local, "exception")) { + if (structKeyExists(state, "exception")) { transaction action="rollback"; throw( errorCode = "1", - detail = local.exception.detail, - message = local.exception.message, + detail = state.exception.detail, + message = state.exception.message, type = "any" ); } else { @@ -51,20 +53,21 @@ function up() { // 3. Run raw SQL and seed initial data in the same migration function up() { + var state = {}; transaction { try { execute("ALTER TABLE products ADD COLUMN sku VARCHAR(50)"); addRecord(table="settings", key="maintenance_mode", value="false"); } catch (any e) { - local.exception = e; + state.exception = e; } - if (structKeyExists(local, "exception")) { + if (structKeyExists(state, "exception")) { transaction action="rollback"; throw( errorCode = "1", - detail = local.exception.detail, - message = local.exception.message, + detail = state.exception.detail, + message = state.exception.message, type = "any" ); } else { diff --git a/vendor/wheels/public/docs/reference/tabledefinition/change.txt b/vendor/wheels/public/docs/reference/tabledefinition/change.txt index a835bee753..d2e2f6bc3f 100644 --- a/vendor/wheels/public/docs/reference/tabledefinition/change.txt +++ b/vendor/wheels/public/docs/reference/tabledefinition/change.txt @@ -12,5 +12,5 @@ t.change(addColumns=true); // 3. Add a foreign key reference column to an existing table t = changeTable(name='orders'); - t.references(referenceNames='customer', allowNull=false); + t.references(columnNames='customer', allowNull=false); t.change(addColumns=true); diff --git a/vendor/wheels/public/docs/reference/tabledefinition/float.txt b/vendor/wheels/public/docs/reference/tabledefinition/float.txt index 528d1a0b8e..6e0f4e3fce 100644 --- a/vendor/wheels/public/docs/reference/tabledefinition/float.txt +++ b/vendor/wheels/public/docs/reference/tabledefinition/float.txt @@ -11,8 +11,8 @@ t.float("latitude,longitude"); t.float(columnNames="score", allowNull=false); // 5. Use float() within a createTable migration -createTable("measurements") { - t.float("temperature"); - t.float(columnNames="humidity,pressure", default="0.0"); - t.timestamps(); -}; +t = createTable("measurements"); +t.float("temperature"); +t.float(columnNames="humidity,pressure", default="0.0"); +t.timestamps(); +t.create(); diff --git a/vendor/wheels/public/docs/reference/tabledefinition/references.txt b/vendor/wheels/public/docs/reference/tabledefinition/references.txt index 85abb0bdc1..e85cfed543 100644 --- a/vendor/wheels/public/docs/reference/tabledefinition/references.txt +++ b/vendor/wheels/public/docs/reference/tabledefinition/references.txt @@ -1,30 +1,38 @@ +// The generated column suffix depends on the `useUnderscoreReferenceColumns` setting: +// `true` (the default for apps generated by `wheels new`) produces `_id` / `_type`, +// matching Wheels model `belongsTo` defaults; `false` (the framework default for existing apps) +// produces `id` / `type`. The examples below show both outcomes. + // 1. Add a single reference column with a foreign key constraint -// Creates a `userId` integer column and a foreign key pointing to the `users` table. +// Creates a `user_id` (or `userid`) integer column and a foreign key pointing to the `users` table. t = createTable(name='posts'); t.string(columnNames='title', limit=255, allowNull=false); - t.references(referenceNames='user'); + t.references(columnNames='user'); t.timestamps(); t.create(); // 2. Add multiple reference columns at once -// Creates `authorId` and `categoryId` integer columns, each with a foreign key. +// Creates `author_id` and `category_id` (or `authorid` and `categoryid`) integer columns, +// each with a foreign key. t = createTable(name='articles'); t.string(columnNames='title', limit=255, allowNull=false); - t.references(referenceNames='author,category'); + t.references(columnNames='author,category'); t.timestamps(); t.create(); -// 3. Add a polymorphic reference (no foreign key, adds a `type` string column) -// Creates `commentableId` (integer) and `commentableType` (string) columns. +// 3. Add a polymorphic reference (no foreign key, adds a `_type` / `type` string column) +// Creates `commentable_id` (integer) and `commentable_type` (string) columns +// (or `commentableid` / `commentabletype` when `useUnderscoreReferenceColumns` is `false`). t = createTable(name='comments'); t.text(columnNames='body', allowNull=false); - t.references(referenceNames='commentable', polymorphic=true); + t.references(columnNames='commentable', polymorphic=true); t.timestamps(); t.create(); // 4. Add a reference with cascade delete and allow null +// The legacy `referenceNames=` argument is still accepted as an alias for `columnNames=`. t = createTable(name='attachments'); - t.references(referenceNames='post', allowNull=true, onDelete='cascade'); + t.references(columnNames='post', allowNull=true, onDelete='cascade'); t.string(columnNames='fileName', limit=255); t.timestamps(); t.create(); diff --git a/vendor/wheels/public/docs/reference/tabledefinition/text.txt b/vendor/wheels/public/docs/reference/tabledefinition/text.txt index 0d569e12ac..cd9f05c60f 100644 --- a/vendor/wheels/public/docs/reference/tabledefinition/text.txt +++ b/vendor/wheels/public/docs/reference/tabledefinition/text.txt @@ -14,9 +14,9 @@ t.text(columnNames="content", size="mediumtext"); t.text(columnNames="rawHtml", size="longtext"); // 6. Full migration example using text() inside createTable -createTable("articles") { - t.string("title"); - t.text("body"); - t.text(columnNames="excerpt", allowNull=true); - t.timestamps(); -}; +t = createTable("articles"); +t.string("title"); +t.text("body"); +t.text(columnNames="excerpt", allowNull=true); +t.timestamps(); +t.create(); diff --git a/vendor/wheels/public/docs/reference/tabledefinition/timestamps.txt b/vendor/wheels/public/docs/reference/tabledefinition/timestamps.txt index 8289ee38a5..264b66a8ac 100644 --- a/vendor/wheels/public/docs/reference/tabledefinition/timestamps.txt +++ b/vendor/wheels/public/docs/reference/tabledefinition/timestamps.txt @@ -12,6 +12,6 @@ t = createTable(name='posts'); t.string(columnNames='slug', limit=255, allowNull=false); t.text(columnNames='body'); t.boolean(columnNames='published', default=false, allowNull=false); - t.references(referenceNames='author'); + t.references(columnNames='author'); t.timestamps(); t.create(); diff --git a/vendor/wheels/public/helpers.cfm b/vendor/wheels/public/helpers.cfm index 836686c557..fddf109586 100644 --- a/vendor/wheels/public/helpers.cfm +++ b/vendor/wheels/public/helpers.cfm @@ -91,7 +91,9 @@ function outputSetting(array setting) { local.rv &= ''; local.rv &= ReReplace(ReReplace(arguments.setting[i], "(^[a-z])", "\u\1"), "([A-Z])", " \1", "all"); local.rv &= ''; - local.rv &= formatSettingOutput(get(arguments.setting[i])); + // Resolves on wheels.Public (outputSetting is only invoked from info.cfm + // inside Public.cfc::info()) and redacts secret-shaped settings. + local.rv &= $settingDisplayValue(arguments.setting[i]); local.rv &= ''; } return local.rv; @@ -206,24 +208,32 @@ public struct function $$findMatchingRoutes( ArrayAppend(local.matches, local.route); } - local.alternativeMatchingMethodsForURL = ""; - - for (local.route in application.wheels.routes) { - if (!StructKeyExists(local.route, "regex")) - local.route.regex = application.wheels.mapper.$patternToRegex(local.route.pattern); - - if (ReFindNoCase(local.route.regex, arguments.path) || (!Len(arguments.path) && local.route.pattern == "/")) - local.alternativeMatchingMethodsForURL = ListAppend(local.alternativeMatchingMethodsForURL, local.route.methods); - } - if (!ArrayLen(local.matches)) { + // The alternative-verbs scan is only consumed on this no-match path, + // so it runs here instead of on every invocation — the unconditional + // scan also performed lazy .regex writes onto application-scope route + // structs from the request thread (#2961 P14). + local.alternativeMatchingMethodsForURL = ""; + + for (local.route in application.wheels.routes) { + if (!StructKeyExists(local.route, "regex")) + local.route.regex = application.wheels.mapper.$patternToRegex(local.route.pattern); + + if (ReFindNoCase(local.route.regex, arguments.path) || (!Len(arguments.path) && local.route.pattern == "/")) + local.alternativeMatchingMethodsForURL = ListAppend(local.alternativeMatchingMethodsForURL, local.route.methods); + } + if (Len(local.alternativeMatchingMethodsForURL)) { ArrayAppend( local.errors, { type = "Wheels.RouteNotFound", message = "Incorrect HTTP Verb for route", - extendedInfo = "The `#arguments.path#` path does not allow `#EncodeForHTML(arguments.requestMethod)#` requests, only `#UCase(local.alternativeMatchingMethodsForURL)#` requests. Ensure you are using the correct HTTP Verb and that your `config/routes.cfm` file is configured correctly." + // EncodeForHTML on the path: this message is rendered by + // routetester.cfm / routetesterprocess.cfm and the raw + // user-supplied path was a reflected-XSS sink — the 404 + // branch below already encodes it (#2961 SEC-8). + extendedInfo = "The `#EncodeForHTML(arguments.path)#` path does not allow `#EncodeForHTML(arguments.requestMethod)#` requests, only `#UCase(local.alternativeMatchingMethodsForURL)#` requests. Ensure you are using the correct HTTP Verb and that your `config/routes.cfm` file is configured correctly." } ); } else { diff --git a/vendor/wheels/public/layout/_header.cfm b/vendor/wheels/public/layout/_header.cfm index e60a89f6ff..263b8e63f8 100644 --- a/vendor/wheels/public/layout/_header.cfm +++ b/vendor/wheels/public/layout/_header.cfm @@ -4,40 +4,6 @@ if (!IsDefined("pageHeader")) { include "../helpers.cfm"; } -// Css Path -request.wheelsInternalAssetPath = application.wheels.webpath & "wheels/public/assets"; - -// Inline the Semantic UI icon font as a data URI. The framework's dev pages -// inline semantic.min.css into a