|
| 1 | +--- |
| 2 | +title: 'Beyond findAll: Scopes, Enums, and the Chainable Query Builder' |
| 3 | +slug: beyond-findall-scopes-enums-query-builder |
| 4 | +publishedAt: '2026-06-22T14:00:00.000Z' |
| 5 | +updatedAt: '2026-06-19T14:35:00.000Z' |
| 6 | +author: Peter Amiri |
| 7 | +tags: |
| 8 | + - wheels-4 |
| 9 | + - models |
| 10 | + - query-builder |
| 11 | + - scopes |
| 12 | + - enums |
| 13 | +categories: [] |
| 14 | +excerpt: >- |
| 15 | + Wheels 4.0 ships three model-side features that compose into one design: |
| 16 | + scopes name reusable query fragments, enums turn property values into |
| 17 | + auto-generated checkers and scopes, and the chainable query builder swaps |
| 18 | + raw WHERE strings for fluent, type-aware composition. This post walks all |
| 19 | + three through one worked example and surfaces the bug I hit while writing |
| 20 | + it. |
| 21 | +coverImage: null |
| 22 | +--- |
| 23 | + |
| 24 | +The first thing you learn about Wheels' ORM is `findAll(where="status = 'published'")`. The first thing you stop learning, six months later, is *anything else*. Raw WHERE strings work for the four-line query you wrote on Monday. By Thursday you're concatenating user input into them, by next month you're copy-pasting the same `status = 'published' AND publishedAt <= NOW()` fragment into eight different controllers, and by the time you go to add a filter for "published OR scheduled-but-the-author-is-an-admin" you've forgotten which file the last similar query lives in. |
| 25 | + |
| 26 | +Wheels 4.0 ships three features on the model side that, taken together, are the answer to that drift. *Scopes* name reusable query fragments and compose by chaining. *Enums* take a property whose values are a closed set ("draft, published, archived") and auto-generate the checkers, validators, and scopes for it. The *chainable query builder* replaces raw WHERE strings with `where("column", "value")` calls that auto-quote and type-check on the way to the SQL. |
| 27 | + |
| 28 | +They're three features, but they're one design — every one of them returns the same kind of deferred-query object, and they all flow into the same materialise-on-terminal-method path. You can chain a scope onto an enum onto a builder onto a `.get()` without thinking about which is which. This post walks all three through one worked model and shows where they touch. |
| 29 | + |
| 30 | +## A reusable query fragment is a scope |
| 31 | + |
| 32 | +Let's start with a `Post` model (`app/models/Post.cfc`) that has the kind of query state most blogs end up with: a status (draft / published / archived), an author, a publish date, view count. |
| 33 | + |
| 34 | +```cfm |
| 35 | +component extends="Model" { |
| 36 | + function config() { |
| 37 | + belongsTo("author"); |
| 38 | + scope(name="published", where="status = 'published'", order="publishedAt DESC"); |
| 39 | + scope(name="recent", order="publishedAt DESC", maxRows=10); |
| 40 | + } |
| 41 | +} |
| 42 | +``` |
| 43 | + |
| 44 | +The two `scope()` calls register named query fragments. Each is a struct of finder arguments — `where`, `order`, `select`, `include`, `maxRows` — that you'd otherwise hand to `findAll()` directly. From your controller: |
| 45 | + |
| 46 | +```cfm |
| 47 | +posts = model("Post").published().findAll(); |
| 48 | +``` |
| 49 | + |
| 50 | +`published()` isn't a real method on `Post.cfc`. The model's `onMissingMethod()` hook recognises it as a scope name, looks up the registered struct, and returns a `ScopeChain` proxy carrying that scope's specs. The proxy has its own `onMissingMethod()` for the next link in the chain. Nothing executes yet — `published()` returned a chainable, not a query. |
| 51 | + |
| 52 | +`.findAll()` is the terminal call. The proxy materialises its accumulated specs into a single finder-argument struct and hands it to the real `findAll()`. The same finder Wheels has had since 1.0; the same SQL it would generate from `findAll(where="status = 'published'", order="publishedAt DESC")`. Scopes are a layer over the finder, not a replacement for it. |
| 53 | + |
| 54 | +The interesting property is composition. Stack two scopes and they merge: |
| 55 | + |
| 56 | +```cfm |
| 57 | +posts = model("Post").published().recent().findAll(); |
| 58 | +``` |
| 59 | + |
| 60 | +`recent` only declared `order` and `maxRows`. `published` only declared `where` and `order`. The chain merges WHERE clauses with `AND`, *appends* `order` fragments in declaration order (so `published`'s fragment sorts first — both happen to be `publishedAt DESC` here, but the rule is "earlier fragments take precedence"), takes the last `select` declared, and takes the most restrictive `maxRows`. There's no separate "scope merger" object — `ScopeChain.$mergeSpecs()` walks the accumulated specs in order and rolls them up before handing to the finder. You can read the implementation in a few minutes; there's no magic. |
| 61 | + |
| 62 | +### Dynamic scopes take parameters |
| 63 | + |
| 64 | +Static scopes — fixed WHERE strings — are fine for `published` and `recent`. When the filter is parameterised, use the handler form — back in `app/models/Post.cfc`: |
| 65 | + |
| 66 | +```cfm |
| 67 | +function config() { |
| 68 | + belongsTo("author"); |
| 69 | + scope(name="published", where="status = 'published'", order="publishedAt DESC"); |
| 70 | + scope(name="byAuthor", handler="scopeByAuthor"); |
| 71 | +} |
| 72 | +
|
| 73 | +private struct function scopeByAuthor(required numeric authorId) { |
| 74 | + return { |
| 75 | + where: "authorId = ?", |
| 76 | + whereParams: [{value: arguments.authorId, type: "CF_SQL_INTEGER"}] |
| 77 | + }; |
| 78 | +} |
| 79 | +``` |
| 80 | + |
| 81 | +`byAuthor(42)` invokes the handler with the argument, the handler returns a spec struct, and the chain absorbs it the same way it absorbs a static scope. The `whereParams` array is the safe path for user input: the ScopeChain splices a quoted, escaped value into the `?` placeholder, which the finder then re-parameterizes into `cfqueryparam` downstream. Wheels also runs a sanitisation pass on every handler argument — it strips null bytes and escapes quotes and backslashes so a value can't break out of its quoted literal — but that's escaping, not keyword filtering (legitimate values like `Union Pacific` pass straight through), and it's defence in depth, not a substitute for parameterisation. Prefer `whereParams` over hand-rolling `where: "role = '#arguments.role#'"` so the quoting isn't your job in the first place. |
| 82 | + |
| 83 | +The defaults are reasonable: `whereParams` is treated as positional, and the type strings are the standard `CF_SQL_*` constants. Inside the handler, you can run any logic you want — branch on the argument, look up another model — as long as you return a struct with the finder-argument shape. |
| 84 | + |
| 85 | +## An enum is a property-shaped scope generator |
| 86 | + |
| 87 | +A `status` field with three valid values — draft, published, archived — has the same shape as a thousand other Wheels models. The pattern in plain Wheels is: |
| 88 | + |
| 89 | +```cfm |
| 90 | +function config() { |
| 91 | + validatesInclusionOf(properties="status", list="draft,published,archived"); |
| 92 | + scope(name="draft", where="status = 'draft'"); |
| 93 | + scope(name="published", where="status = 'published'"); |
| 94 | + scope(name="archived", where="status = 'archived'"); |
| 95 | +} |
| 96 | +``` |
| 97 | + |
| 98 | +Four lines of bookkeeping per enum-shaped property, repeated wherever you have one. Wheels 4.0 collapses it to: |
| 99 | + |
| 100 | +```cfm |
| 101 | +function config() { |
| 102 | + enum(property="status", values="draft,published,archived"); |
| 103 | +} |
| 104 | +``` |
| 105 | + |
| 106 | +That single call registers: |
| 107 | + |
| 108 | +- A `validatesInclusionOf` check on the property, so `status = "wat"` fails validation cleanly. |
| 109 | +- One scope per value (`draft()`, `published()`, `archived()`) so `model("Post").published().findAll()` works without a separate `scope()` call. |
| 110 | +- One boolean checker per value on the instance: `post.isDraft()`, `post.isPublished()`, `post.isArchived()`. |
| 111 | + |
| 112 | +The scopes are parameterised — `where: "status = ?"` with `whereParams = [{value: "published", type: "CF_SQL_VARCHAR"}]` — not string-interpolated. Auto-generated code is the place the framework can afford to do the safe thing without you remembering. |
| 113 | + |
| 114 | +Two value forms work: |
| 115 | + |
| 116 | +```cfm |
| 117 | +// Names map to themselves — stored value matches the name. |
| 118 | +enum(property="status", values="draft,published,archived"); |
| 119 | +
|
| 120 | +// Names map to explicit stored values — useful when the DB column is |
| 121 | +// an integer or you need the names to differ from what's persisted. |
| 122 | +enum(property="priority", values={low: 0, medium: 1, high: 2}); |
| 123 | +``` |
| 124 | + |
| 125 | +The struct form coerces every stored value to a string before the underlying scope is built (so `0` becomes `"0"`). That's usually fine, but if you have a numeric column and a literal `0` is meaningful, double-check the comparison in your SQL log. |
| 126 | + |
| 127 | +A few sharp edges worth knowing: |
| 128 | + |
| 129 | +- **Value-name collisions.** An enum value named `name` or `update` will register a scope called `name()` or `update()` on the model. There's no guard against collisions with method names you've defined. If you write `enum(property="action", values="create,update,delete")`, the collision is silent and cuts both ways: at the model root the real `update()` and `delete()` win and your scopes are unreachable; on a chain the scopes shadow the methods. No guard fires in either direction. Pick value names that don't double as verbs the framework uses. |
| 130 | +- **Invalid characters in stored values.** The framework rejects single quotes, semicolons, comment markers, and other SQL-injection-shaped characters in enum stored values at registration time, throwing `Wheels.InvalidEnumValue`. The values you provide are baked into auto-generated scope SQL, so this is a registration-time check, not a runtime one. It only fires if you write something like `values={oops: "it's fine"}`. |
| 131 | +- **Validation fires on save, not on assignment.** `post.status = "wat"` doesn't throw — it sets the property. `post.valid()` is what surfaces the inclusion failure (`errorsOn("status")` returns a validation error). If you want to fail on assignment, you'd add your own setter; the enum machinery doesn't intercept the write. |
| 132 | + |
| 133 | +## The chainable query builder |
| 134 | + |
| 135 | +Scopes handle the named, reusable case. The query builder handles the ad-hoc, runtime-composed case — the place where you used to reach for a raw WHERE string and concatenate variables into it. |
| 136 | + |
| 137 | +```cfm |
| 138 | +posts = model("Post") |
| 139 | + .where("authorId", session.authorId) |
| 140 | + .where("views", ">", 100) |
| 141 | + .whereNotNull("publishedAt") |
| 142 | + .orderBy("publishedAt", "DESC") |
| 143 | + .limit(25) |
| 144 | + .get(); |
| 145 | +``` |
| 146 | + |
| 147 | +Three calling conventions for `where`: |
| 148 | + |
| 149 | +- **`.where("clause")`** — passes the string through verbatim. You're back to manual quoting territory; don't put user input here. |
| 150 | +- **`.where("column", value)`** — equality, auto-quoted: `column = '<quoted>'`. |
| 151 | +- **`.where("column", operator, value)`** — operator in the middle: `column > <quoted>`, `column LIKE <quoted>`, and so on. |
| 152 | + |
| 153 | +Auto-quoting goes through the database adapter's `$quoteValue()` and is preceded by a *type check* against the property's declared type. If the column is declared `integer` and the value isn't a valid integer literal, the builder throws before any SQL is built. That closes the classic injection vector where `"0 OR 1=1"` slipped through the unquoted numeric path. The same goes for `float` and `boolean` — those payloads get rejected at the type check; everything else, dates included, is escaped and quoted on its way into the SQL. |
| 154 | + |
| 155 | +The full method surface, in addition to `where`: |
| 156 | + |
| 157 | +| Method | SQL | |
| 158 | +|---|---| |
| 159 | +| `orWhere(...)` | Same conventions as `where`, OR-combined | |
| 160 | +| `whereNull(column)` | `column IS NULL` | |
| 161 | +| `whereNotNull(column)` | `column IS NOT NULL` | |
| 162 | +| `whereBetween(column, low, high)` | `column BETWEEN low AND high` | |
| 163 | +| `whereIn(column, list)` | `column IN (...)` | |
| 164 | +| `whereNotIn(column, list)` | `column NOT IN (...)` | |
| 165 | +| `orderBy(column, direction)` | `ORDER BY column direction` | |
| 166 | +| `limit(n)`, `offset(n)` | `LIMIT n` / `OFFSET n` | |
| 167 | +| `select(columns)` | column projection | |
| 168 | +| `include(associations)` | eager-load associations | |
| 169 | +| `group(columns)`, `distinct()` | `GROUP BY` / `SELECT DISTINCT` | |
| 170 | +| `forUpdate()` | pessimistic row lock (DB-dependent) | |
| 171 | + |
| 172 | +Terminal methods materialise the chain: |
| 173 | + |
| 174 | +- **`.get()` / `.findAll()`** — returns a query of all matching rows. |
| 175 | +- **`.first()` / `.findOne()`** — returns the first row as a model instance, or `false`. |
| 176 | +- **`.count()`** — returns the integer count. |
| 177 | +- **`.exists()`** — returns true/false. |
| 178 | +- **`.updateAll(...)`** — bulk update, returns rows affected. |
| 179 | +- **`.deleteAll()`** — bulk delete, returns rows deleted. |
| 180 | +- **`.findEach(callback)`** / **`.findInBatches(callback)`** — stream rows for large result sets without holding them all in memory. |
| 181 | + |
| 182 | +Like scopes, nothing happens until a terminal method fires. You can build a query object, branch on it, layer more conditions, and only `.get()` it once at the end: |
| 183 | + |
| 184 | +```cfm |
| 185 | +query = model("Post").where("status", "published"); |
| 186 | +
|
| 187 | +if (params.authorId != "") { |
| 188 | + query = query.where("authorId", params.authorId); |
| 189 | +} |
| 190 | +if (params.sort == "popular") { |
| 191 | + query = query.orderBy("views", "DESC"); |
| 192 | +} else { |
| 193 | + query = query.orderBy("publishedAt", "DESC"); |
| 194 | +} |
| 195 | +
|
| 196 | +posts = query.limit(25).get(); |
| 197 | +``` |
| 198 | + |
| 199 | +That kind of conditional composition is what raw WHERE strings make ugly. The builder makes it the natural shape. |
| 200 | + |
| 201 | +## Composing all three |
| 202 | + |
| 203 | +The three features compose with no special integration code — they all return objects that implement `onMissingMethod()` and accumulate state into the same finder-argument struct on the way to the terminal call. Here's a query that uses all three: |
| 204 | + |
| 205 | +```cfm |
| 206 | +// Post.cfc |
| 207 | +function config() { |
| 208 | + belongsTo("author"); |
| 209 | + enum(property="status", values="draft,published,archived"); |
| 210 | + scope(name="recent", order="publishedAt DESC", maxRows=10); |
| 211 | + scope(name="byAuthor", handler="scopeByAuthor"); |
| 212 | +} |
| 213 | +
|
| 214 | +private struct function scopeByAuthor(required numeric authorId) { |
| 215 | + return { |
| 216 | + where: "authorId = ?", |
| 217 | + whereParams: [{value: arguments.authorId, type: "CF_SQL_INTEGER"}] |
| 218 | + }; |
| 219 | +} |
| 220 | +``` |
| 221 | + |
| 222 | +```cfm |
| 223 | +// PostsController.cfc |
| 224 | +posts = model("Post") |
| 225 | + .published() // enum-generated scope |
| 226 | + .byAuthor(session.authorId) // dynamic scope |
| 227 | + .where("views", ">", 100) // builder where |
| 228 | + .whereNotNull("featuredImage") // builder whereNotNull |
| 229 | + .orderBy("publishedAt", "DESC") // builder orderBy |
| 230 | + .limit(20) |
| 231 | + .get(); |
| 232 | +``` |
| 233 | + |
| 234 | +The chain reads top-to-bottom in the order you'd describe the query out loud: "published posts by this author with more than 100 views and a featured image, newest first, top 20." The SQL Wheels generates looks roughly like: |
| 235 | + |
| 236 | +```sql |
| 237 | +SELECT * FROM posts |
| 238 | +WHERE (status = 'published') |
| 239 | + AND (authorId = 42) |
| 240 | + AND (views > 100) |
| 241 | + AND (featuredImage IS NOT NULL) |
| 242 | +ORDER BY publishedAt DESC |
| 243 | +LIMIT 20 |
| 244 | +``` |
| 245 | + |
| 246 | +Each clause comes from a different layer, but the layers don't know about each other. The scope returns a spec; the dynamic scope returns a spec with whereParams; the builder appends WHERE conditions to an array. `$buildFinderArgs()` walks both lists in order, joins the WHERE fragments with each clause's own connector (`AND` by default, `OR` for `orWhere()`), appends the `order` fragments in chain order, and returns a finder-argument struct that the terminal method hands to `findAll()`. It's the same finder you'd call directly — the chainable surface is sugar around the existing implementation, not a parallel one. |
| 247 | + |
| 248 | +## What changed while writing this post |
| 249 | + |
| 250 | +While testing edge cases for the builder section, I tried `model("Post").whereIn("id", [])` to see what the framework did with an empty array. The answer was: nothing good. It produced literal SQL `id IN ()`, which is malformed in every engine Wheels supports and surfaces as a generic syntax error from the JDBC driver. No framework-shaped error, no pointer to the line in your code that built the empty array. |
| 251 | + |
| 252 | +Empty inputs to `WHERE IN` aren't an exotic edge case. They're what you get whenever the values come from another query, a form filter, or any computation that might return zero results. The Rails community converged on this pattern in 2016, Sequel matches it, Django matches it, Laravel Eloquent matches it: an empty `IN` matches no rows, and an empty `NOT IN` matches every row. It's what the SQL spec implies and what every other framework's users expect. |
| 253 | + |
| 254 | +Wheels now does the same. `whereIn("id", [])` sets an `$alwaysEmpty` flag on the builder; every terminal — `.count()`, `.first()`, `.findAll()`, `.exists()`, `.updateAll()`, `.deleteAll()`, `.findEach()`, `.findInBatches()` — short-circuits before going through the finder, returning the appropriate zero-row sentinel (`0`, `false`, an empty query, or no callback invocation). `whereNotIn("id", [])` is a no-op: it appends no clause, so the chain proceeds normally and every other row matches. The chains compose cleanly — `.where("status", "active").whereIn("id", []).count()` still returns `0` because the terminal sees the flag first; `.where("status", "active").whereNotIn("id", []).count()` returns the count of active rows because `whereNotIn` of nothing excludes nothing. Fourteen new specs in `queryBuilderSpec.cfc` lock the behaviour in: every patched terminal, the empty-array and empty-list inputs to both `whereIn` and `whereNotIn`, composition with `where`, and the documented `select()` silent-ignore on the short-circuit path. The reference table in the query-builder guide notes the short-circuit so you don't have to read the source to confirm it. |
| 255 | + |
| 256 | +The first cut of this fix tried the more obvious approach: append `1 = 0` and `1 = 1` as raw SQL clauses, the way Rails docs describe it. That broke immediately. Wheels' WHERE-clause parser in `vendor/wheels/model/sql.cfc` runs a property-extraction regex over every clause it sees — even ones like `1 = 0` that don't have a property — and threw `Wheels.ColumnNotFound` on the literal `1`. The fix that shipped is the one that works alongside the parser instead of around it: short-circuit at the terminal so the parser never sees a column-less clause. Either approach is correct on the SQL side; only one composes with the rest of the framework. |
| 257 | + |
| 258 | +This is the same shape as the rate-limiter `windowSeconds=0` fix from the first post in this series, and the OpenCode template drift from the third: the article ships alongside the framework change because finding the rough edge is most of the work, and the cost of fixing it once you've found it is almost nothing. |
| 259 | + |
| 260 | +There are a few related rough edges I didn't fix in this PR but that are worth naming so the next person who hits them knows they exist: |
| 261 | + |
| 262 | +- **No `.toSql()` method.** If you want to see the SQL the chain is about to generate, you have to enable the debug panel or step through `$buildFinderArgs()` yourself. A `.toSql()` that returns the would-be query string without executing it would be useful for debugging complex chains. Filed for follow-up. |
| 263 | +- **No `defaultScope()` / `unscoped()`.** Rails lets you declare a model-wide default scope (e.g. "always filter out soft-deleted rows") with an escape hatch (`unscoped`). Wheels doesn't have either. Soft-delete is the obvious motivating case; if your model has a `deletedAt` and you want every query to filter on `WHERE deletedAt IS NULL`, you currently scatter `.whereNull("deletedAt")` through every call site or write a wrapper. Not a bug — just a missing affordance. |
| 264 | +- **Enum value-name collisions are unchecked.** `enum(property="action", values="create,update,delete")` will silently shadow the model's own `update()` and `delete()` chain methods. The framework could refuse to register an enum value whose name matches an existing method; today it doesn't. Pick value names that aren't verbs. |
| 265 | + |
| 266 | +The next post in the series is the last one: *From Empty Directory to Deployed SaaS — end-to-end with generators, multi-tenancy, jobs, browser tests, and `wheels deploy`*. That one's a longer post. Coming soon. |
0 commit comments