Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ Wheels reads `dataSourceName` from `config/settings.cfm` at application start an
set(dataSourceName = "myapp_dev");
```

The name you set here must match a datasource registered with your CFML engine. In Lucee that's configured in `lucee.json` (or `/lucee/admin/` for server-scope datasources); in Adobe it's in the admin UI. Check your engine's documentation for the registration shape — Wheels only cares that the name resolves to a working connection.
The name you set here must match a datasource registered with your CFML engine. In Lucee that's configured in `lucee.json` (or `/lucee/admin/` for server-scope datasources); in Adobe it's in the admin UI. Check your engine's documentation for the registration shape — Wheels only cares that the name resolves to a working connection. In development, a name that doesn't resolve surfaces as a `Wheels.DataSourceNotFound` error page naming the datasource (served with HTTP 404).

`config/settings.cfm` is the shared file loaded in every environment. The next section shows how to override it per environment.

Expand All @@ -54,14 +54,18 @@ Any model can point at a different datasource by calling `dataSource()` in `conf
component extends="Model" {
function config() {
dataSource("legacy_reporting");
tableName("vw_legacy_users");
table("vw_legacy_users");
setPrimaryKey("user_id");
}
}
```

The typical legacy case is all three overrides at once: a non-default datasource, a non-convention table name (often a view), and a non-`id` primary key. The rest of the model still behaves like any other — associations, validations, finders, and the query builder all use the datasource you've declared.

<Aside type="caution">
The table-name **setter** is `table()`. `tableName()` is a zero-argument getter — calling `tableName("vw_legacy_users")` silently ignores the argument, the model resolves the conventional table name instead, and the first query throws `Wheels.TableNotFound` ([#3079](https://github.com/wheels-dev/wheels/issues/3079)).
</Aside>

`dataSource()` also accepts `username` and `password` arguments for databases that need per-model credentials:

```cfm {test:compile}
Expand All @@ -88,7 +92,7 @@ set(dataSourceName = "myapp_primary");
component extends="Model" {
function config() {
dataSource("myapp_replica");
tableName("users");
table("users");
setPrimaryKey("id");
}
}
Expand All @@ -102,7 +106,7 @@ A framework-level read/write split is under consideration for a future release.

Wheels gives you three ways to wrap work in a transaction. Which one you pick depends on how many statements you're coordinating.

**Single model method.** Every persistence method — `save`, `create`, `update`, `delete` — accepts a `transaction` argument. Pass `"rollback"` to run the statement inside a transaction and always roll it back (useful for dry-runs and tests) or `"commit"` (the default when `transactionMode` is set) to commit normally.
**Single model method.** Every persistence method — `save`, `create`, `update`, `delete` — accepts a `transaction` argument. Pass `"rollback"` to run the statement inside a transaction and always roll it back (useful for dry-runs and tests) or `"commit"` (the default — persistence methods default their `transaction` argument to the `transactionMode` setting, whose framework default is `"commit"`) to commit normally.

```cfm {test:compile}
component extends="Controller" {
Expand All @@ -125,6 +129,8 @@ component extends="Controller" {
}
```

The invoked method must return a boolean — `invokeWithTransaction()` throws `Methods invoked using invokeWithTransaction must return a boolean value` otherwise. Returning `true` commits; returning `false` rolls the transaction back, even with `transaction="commit"`. Make sure `settleOutstandingCharges()` ends with `return true;` on its success path.

**Multiple statements, multiple models.** Use a native CFML `transaction{}` block. Everything inside runs against the same connection and commits or rolls back as a unit. An uncaught exception inside rolls back automatically; an explicit rollback uses `transaction action="rollback"`.

```cfm {test:compile}
Expand All @@ -141,7 +147,7 @@ component extends="Controller" {
}
```

Transactions commit when the block exits cleanly and roll back on any thrown exception. All statements in a transaction must run against the same datasource — cross-database transactions aren't a thing the engine can give you.
Transactions commit when the block exits cleanly and roll back on any thrown exception. All statements in a transaction must run against the same datasource — cross-database transactions aren't a thing the engine can give you. Adobe ColdFusion enforces this with an error (`Datasource names for all the database tags within the cftransaction tag must be the same.`); Lucee 7 does not — it runs the statements against both datasources but provides no cross-database atomicity, so the failure mode is silent.

## Raw queries

Expand All @@ -159,9 +165,9 @@ component extends="Model" {
}
```

The `?` placeholder plus the `parameters` array gets you parameter binding through `cfqueryparam`. Never concatenate user input into the SQL string — that's the canonical SQL-injection gap. A hardening walkthrough lands in Phase 2b; until then the rule is: if a value comes from `params`, `session`, or any request-scoped source, it goes through a placeholder, not through string interpolation.
The `?` placeholder plus the `parameters` array gets you parameter binding through `cfqueryparam`. Never concatenate user input into the SQL string — that's the canonical SQL-injection gap. The rule is: if a value comes from `params`, `session`, or any request-scoped source, it goes through a placeholder, not through string interpolation.

For queries that return model instances rather than a raw query result, stay with `findAll(where="...")` or the [query builder](/v4-0-0/basics/query-builder-and-scopes/). Raw `queryExecute()` is for the cases where you genuinely want a query object, not an array of objects.
For model instances, use `findOne()`/`findByKey()` or `findAll(returnAs="objects")` — plain `findAll()` already returns a query result by default, so reach for raw `queryExecute()` only when neither the ORM nor the [query builder](/v4-0-0/basics/query-builder-and-scopes/) can express the SQL you need.

## Connection pooling

Expand All @@ -175,7 +181,9 @@ Wheels 4.0 has database adapters for:
- **MySQL / MariaDB** — fully supported, broad production use.
- **Microsoft SQL Server** — fully supported, common in enterprise shops.
- **SQLite** — great for development and testing, production-fine for small apps.
- **H2** — embedded, great for tests that need a fresh database per run.
- **H2** — embedded, great for tests that need a fresh database per run (Lucee-only in the CI matrix).
- **CockroachDB** — distributed SQL, PostgreSQL wire-compatible, with its own adapter.
- **Oracle** — supported with its own adapter (runs as a soft-fail leg in the CI matrix).

The Wheels 4.0 core test suite runs on Lucee 7 + SQLite for day-to-day development; the full CI matrix covers additional engines and databases. SQLite is the reference — if something works there but not on your target database, that's a bug worth filing.

Expand Down
21 changes: 11 additions & 10 deletions web/sites/guides/src/content/docs/v4-0-0/digging-deeper/cors.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ This page shows you how to let a browser on `https://client.example.com` call yo

CORS — Cross-Origin Resource Sharing — is a browser-enforced rule. A script running on origin A (scheme + host + port, e.g. `https://client.example.com`) cannot read responses from a different origin B unless B explicitly opts in with `Access-Control-*` response headers. Server-to-server HTTP calls don't have CORS; it's purely a browser-side policy enforced for JavaScript `fetch`, `XMLHttpRequest`, and `EventSource`.

The `wheels.middleware.Cors` middleware writes those opt-in headers onto every matching response and short-circuits preflight `OPTIONS` requests with a 204-shaped empty body.
The `wheels.middleware.Cors` middleware writes those opt-in headers onto every matching response and short-circuits preflight `OPTIONS` requests with an empty-body response (HTTP 200).

## Enable the middleware

Expand Down Expand Up @@ -62,7 +62,7 @@ These are the constructor arguments on `wheels.middleware.Cors`, verified agains
| `allowCredentials` | `false` | When `true`, browsers may include cookies and `Authorization` headers. Forbids `allowOrigins="*"`. |
| `maxAge` | `86400` (24 hours) | `Access-Control-Max-Age` — how long the browser may cache the preflight response. |

Matching is **exact string comparison** on the incoming `Origin` header. There is no wildcard-subdomain support: `https://*.myapp.com` is not a valid entry. Enumerate every origin you need — scheme + host + port, nothing more, nothing less.
Matching is an **exact match** on the incoming `Origin` header, compared case-insensitively. There is no wildcard-subdomain support: `https://*.myapp.com` is not a valid entry. Enumerate every origin you need — scheme + host + port, nothing more, nothing less.

<Aside type="caution">
**Migrating from 3.x global settings?** The `allowHeaders` default above (`"Content-Type,Authorization,X-Requested-With"`) is narrower than the legacy `accessControlAllowHeaders` global-setting default, which also included `X-Auth-Token`, `X-Requested-By`, and `Origin`. A like-for-like swap silently drops those headers. See [Upgrading from 3.x — CORS allow-list defaults drift](/v4-0-0/upgrading/3x-to-4x/#migrating-from-global-settings-to-the-middleware) for the side-by-side comparison and the explicit-constructor-args fix.
Expand All @@ -73,7 +73,7 @@ Matching is **exact string comparison** on the incoming `Origin` header. There i
Before any non-simple cross-origin request (anything that uses a custom header, a non-GET/POST/HEAD method, or a non-simple content type), the browser sends a preflight `OPTIONS` request asking "may I?" The Cors middleware handles this for you:

- If the `Origin` is in `allowOrigins` (or `allowOrigins` is `"*"`), the middleware writes `Access-Control-Allow-Origin`, `Access-Control-Allow-Methods`, `Access-Control-Allow-Headers`, optionally `Access-Control-Allow-Credentials`, plus `Access-Control-Max-Age`, and returns an empty response without running your controller.
- If the `Origin` is not in the allowlist, the middleware still short-circuits the `OPTIONS` request with an empty body but **omits the `Access-Control-Allow-*` headers**. The browser sees no opt-in and blocks the subsequent real request.
- If the `Origin` is not in the allowlist, the middleware still short-circuits the `OPTIONS` request with an empty body but **omits the `Access-Control-Allow-*` headers**. The browser sees no opt-in and blocks the subsequent real request. (`Access-Control-Max-Age` is emitted on every `OPTIONS` response, including disallowed origins.)

You do not write an `options` action on your controllers. The middleware owns `OPTIONS` entirely.

Expand All @@ -89,7 +89,7 @@ Three rules come with it:

## Per-route Cors

Most real apps want strict CORS only on `/api` and not on the HTML pages. Pass middleware to a `scope()` block and the rules apply only to routes declared inside the callback:
Most real apps want strict CORS only on `/api` and not on the HTML pages. Pass middleware to a `scope()` block and the rules apply only to the routes declared before the scope's closing `.end()`:

```cfm {test:compile} title="config/routes.cfm"
<cfscript>
Expand All @@ -101,12 +101,11 @@ mapper()
allowOrigins="https://client.myapp.com",
allowCredentials=true
)
],
callback=function(map) {
map.resources("posts");
map.resources("comments");
}
]
)
.resources("posts")
.resources("comments")
.end()
.resources("pages")
.wildcard()
.end();
Expand All @@ -115,6 +114,8 @@ mapper()

Routes under `/api` get the Cors middleware. `resources("pages")` and everything outside the scope do not. Scope-level middleware composes with global middleware — both run.

Close the scope with `.end()` before declaring routes outside it — `scope()` does not take a `callback` argument. A callback passed to it is silently ignored (the routes inside it never register), and the unclosed scope leaks its path prefix and middleware onto every subsequent route ([#3072](https://github.com/wheels-dev/wheels/issues/3072)).

<Aside type="caution">
The preflight short-circuit that prevents unmatched `OPTIONS` requests from reaching the route table applies only when `Cors` is registered in the **global** pipeline via `config/settings.cfm`. Route-scoped `Cors` declared inside `.scope()` in `config/routes.cfm` does not benefit: route matching runs before route-scoped middleware executes, so a browser preflight to a path that only declares `POST` will 404 with `Wheels.RouteNotFound` unless `Cors` is also in the global pipeline.

Expand All @@ -139,7 +140,7 @@ When a cross-origin request fails, work through this checklist:
-H "Access-Control-Request-Headers: Content-Type" \
-v https://api.myapp.com/posts
```
- **Match the origin exactly.** Origins are strings, compared character-for-character. `https://myapp.com` is not `http://myapp.com`, not `https://www.myapp.com`, not `https://myapp.com:8080`. All four are distinct origins.
- **Match the origin exactly.** Origins are strings, compared exactly (case-insensitively). `https://myapp.com` is not `http://myapp.com`, not `https://www.myapp.com`, not `https://myapp.com:8080`. All four are distinct origins.

## Common pitfalls

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,10 @@ set(middleware = [

With no other arguments you get: `strategy="fixedWindow"`, `storage="memory"`, and keying by client IP. Every client gets 60 requests per 60 seconds.

<Aside type="note">
Requests handled by the internal Wheels controller — the `/wheels` GUI routes, or a root route left pointing at the built-in welcome page instead of one of your controllers — bypass the middleware pipeline and never count against a budget. Test rate limiting against one of your own controller routes.
</Aside>

## Sliding window

Swap the strategy for smoother enforcement:
Expand Down Expand Up @@ -135,14 +139,15 @@ set(middleware = [
maxRequests=1000,
windowSeconds=60,
keyFunction=function(req) {
return req.cgi.http_x_api_key ?: "anonymous";
var apiKey = cgi.http_x_api_key;
return Len(apiKey) ? apiKey : "anonymous";
}
)
]);
</cfscript>
```

The closure receives the request struct and returns a unique-per-client string. Fall back to a constant like `"anonymous"` so unauthenticated traffic still hits some limit — otherwise an attacker who omits the header accumulates counter entries under whatever empty-string key your code returns.
The closure receives the middleware request context — a struct of `params`, `route`, `pathInfo`, and `method` — and returns a unique-per-client string. The context does not carry a `cgi` key ([#3074](https://github.com/wheels-dev/wheels/issues/3074)), so read headers from the engine's `cgi` scope directly, as above. Note the `Len()` guard: a missing header reads as an **empty string** in the `cgi` scope, not undefined, so `cgi.http_x_api_key ?: "anonymous"` would never fall back. Fall back to a constant like `"anonymous"` so unauthenticated traffic still hits some limit — otherwise every header-less request shares whatever empty-string key your code returns: one merged budget you never intended.

<Aside type="tip">
Keys longer than `maxKeyLength` (default 128) are auto-replaced with a SHA-256 hash to bound memory. You don't need to truncate keys yourself.
Expand Down Expand Up @@ -190,17 +195,18 @@ mapper()
maxRequests=5,
windowSeconds=60
)
],
callback=function(map) {
map.post(name="authenticate", pattern="/", to="sessions##create");
}
]
)
.post(name="authenticate", pattern="/", to="sessions##create")
.end()
.resources("users")
.wildcard()
.end();
</cfscript>
```

Declare the scoped routes between `.scope()` and its matching `.end()`, then close the scope before any routes that shouldn't share the limit. `scope()` does not take a `callback` argument — a callback passed to it is silently ignored, and without the `.end()` every subsequent route (including `.wildcard()`) nests under `/login`, breaking the rest of the app's routing ([#3072](https://github.com/wheels-dev/wheels/issues/3072)).

Global middleware from `config/settings.cfm` still runs — it composes with the scope-level one. The pattern is: a permissive global limit (say 60/min) plus a tight limit on sensitive routes (5/min on login) so a brute-force attempt trips the narrow limit long before the broad one.

## Debugging unexpected rejections
Expand Down
Loading