Skip to content

Commit 8cfd176

Browse files
author
Peter Amiri
committed
docs(web/blog): Beyond findAll: Scopes, Enums, and the Chainable Query Builder
1 parent a748630 commit 8cfd176

1 file changed

Lines changed: 266 additions & 0 deletions

File tree

Lines changed: 266 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,266 @@
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

Comments
 (0)