Skip to content

Commit db0fcd9

Browse files
authored
Merge pull request #1920 from wheels-dev/peter/query-scopes-1907
Query scopes: tests and documentation
2 parents 9c5a60e + 66b1650 commit db0fcd9

File tree

5 files changed

+227
-0
lines changed

5 files changed

+227
-0
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ All historical references to "CFWheels" in this changelog have been preserved fo
2121
## [Unreleased]
2222

2323
### Added
24+
- Query scopes with `scope()` for reusable, composable query fragments in models
2425
- Batch processing with `findEach()` and `findInBatches()` for memory-efficient record iteration
2526

2627
----

docs/src/SUMMARY.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,7 @@
177177
* [Transactions](database-interaction-through-models/transactions.md)
178178
* [Dirty Records](database-interaction-through-models/dirty-records.md)
179179
* [Soft Delete](database-interaction-through-models/soft-delete.md)
180+
* [Query Scopes](database-interaction-through-models/query-scopes.md)
180181
* [Batch Processing](database-interaction-through-models/batch-processing.md)
181182
* [Automatic Time Stamps](database-interaction-through-models/automatic-time-stamps.md)
182183
* [Database Migrations](database-interaction-through-models/database-migrations/README.md)
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
# Query Scopes
2+
3+
Query scopes let you define reusable, composable query fragments on your models. Instead of repeating the same `where` or `order` clauses across your application, define them once in the model's `config()` and chain them together.
4+
5+
## Defining Scopes
6+
7+
Add scopes in your model's `config()` function using `scope()`:
8+
9+
```cfm
10+
component extends="Model" {
11+
function config() {
12+
// Static scopes — fixed query fragments
13+
scope(name="active", where="status = 'active'");
14+
scope(name="recent", order="createdAt DESC");
15+
scope(name="limited", maxRows=10);
16+
17+
// Dynamic scope — accepts arguments at call time
18+
scope(name="byRole", handler="scopeByRole");
19+
}
20+
21+
private struct function scopeByRole(required string role) {
22+
return {where: "role = '#arguments.role#'"};
23+
}
24+
}
25+
```
26+
27+
### Static Scopes
28+
29+
Static scopes define a fixed set of query parameters. The `scope()` function accepts these keys:
30+
31+
| Parameter | Description |
32+
|-----------|-------------|
33+
| `name` | The scope name (becomes the chainable method) |
34+
| `where` | SQL WHERE clause fragment |
35+
| `order` | SQL ORDER BY clause |
36+
| `select` | Columns to select |
37+
| `include` | Associated models to include |
38+
| `maxRows` | Limit the number of rows returned |
39+
40+
### Dynamic Scopes
41+
42+
Dynamic scopes use a `handler` function that returns a struct of query parameters. The handler receives whatever arguments the caller passes:
43+
44+
```cfm
45+
scope(name="olderThan", handler="scopeOlderThan");
46+
47+
private struct function scopeOlderThan(required numeric age) {
48+
return {where: "age > #arguments.age#"};
49+
}
50+
```
51+
52+
## Using Scopes
53+
54+
Call scopes as methods on the model, then finish with a terminal method like `findAll()`, `findOne()`, `count()`, or `exists()`:
55+
56+
```cfm
57+
// Single scope
58+
users = model("User").active().findAll();
59+
60+
// Chained scopes — conditions are AND'd together
61+
users = model("User").active().recent().findAll();
62+
63+
// Dynamic scope with argument
64+
admins = model("User").byRole("admin").findAll();
65+
66+
// With additional finder arguments
67+
users = model("User").active().findAll(page=1, perPage=25);
68+
```
69+
70+
## How Scopes Combine
71+
72+
When you chain multiple scopes:
73+
74+
- **WHERE** clauses are combined with `AND`
75+
- **ORDER BY** clauses are appended (first scope's order comes first)
76+
- **INCLUDE** clauses are appended
77+
- **SELECT** uses the last scope's value (last wins)
78+
- **maxRows** uses the smallest value
79+
80+
```cfm
81+
// These two WHERE clauses become: (status = 'active') AND (role = 'admin')
82+
model("User").active().byRole("admin").findAll();
83+
```
84+
85+
## Terminal Methods
86+
87+
After building a scope chain, call one of these to execute the query:
88+
89+
| Method | Returns |
90+
|--------|---------|
91+
| `findAll()` | Query result set |
92+
| `findOne()` | Single model object |
93+
| `findByKey(key)` | Model object by primary key |
94+
| `count()` | Number of matching records |
95+
| `exists()` | Boolean |
96+
| `average(property)` | Average value |
97+
| `sum(property)` | Sum of values |
98+
| `maximum(property)` | Maximum value |
99+
| `minimum(property)` | Minimum value |
100+
| `updateAll(...)` | Updates matching records |
101+
| `deleteAll()` | Deletes matching records |
102+
| `findEach(callback)` | Batch iteration (one at a time) |
103+
| `findInBatches(callback)` | Batch iteration (groups) |
104+
105+
## Transitioning to the Query Builder
106+
107+
You can move from a scope chain into the chainable query builder at any point:
108+
109+
```cfm
110+
model("User")
111+
.active()
112+
.where("age", ">", 18)
113+
.orderBy("name", "ASC")
114+
.get();
115+
```
116+
117+
See [Query Builder](query-builder.md) for the full fluent API.
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
component extends="Model" {
2+
3+
function config() {
4+
table("c_o_r_e_authors");
5+
6+
scope(name = "withLastNameDjurner", where = "lastname = 'Djurner'");
7+
scope(name = "orderedByFirstName", order = "firstname ASC");
8+
scope(name = "firstThree", maxRows = 3);
9+
scope(name = "byLastName", handler = "scopeByLastName");
10+
}
11+
12+
private struct function scopeByLastName(required string lastName) {
13+
return {where: "lastname = '#arguments.lastName#'"};
14+
}
15+
16+
}
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
component extends="wheels.WheelsTest" {
2+
3+
function run() {
4+
5+
describe("Query Scopes", () => {
6+
7+
describe("static scopes", () => {
8+
9+
it("filters with a where scope", () => {
10+
var result = model("authorScoped").withLastNameDjurner().findAll();
11+
expect(result.recordcount).toBe(1);
12+
expect(result.lastname).toBe("Djurner");
13+
})
14+
15+
it("orders with an order scope", () => {
16+
var result = model("authorScoped").orderedByFirstName().findAll();
17+
expect(result.firstname[1]).toBe("Adam");
18+
})
19+
20+
it("limits with a maxRows scope", () => {
21+
var result = model("authorScoped").firstThree().findAll(order = "id");
22+
expect(result.recordcount).toBe(3);
23+
})
24+
25+
})
26+
27+
describe("chaining", () => {
28+
29+
it("chains multiple scopes together", () => {
30+
var result = model("authorScoped").orderedByFirstName().firstThree().findAll();
31+
expect(result.recordcount).toBe(3);
32+
expect(result.firstname[1]).toBe("Adam");
33+
})
34+
35+
it("returns a chainable object, not a query", () => {
36+
var chain = model("authorScoped").withLastNameDjurner();
37+
expect(IsQuery(chain)).toBeFalse();
38+
expect(IsSimpleValue(chain)).toBeFalse();
39+
})
40+
41+
it("merges scope WHERE with finder WHERE using AND", () => {
42+
var result = model("authorScoped").withLastNameDjurner().findAll(where = "firstname = 'Per'");
43+
expect(result.recordcount).toBe(1);
44+
expect(result.firstname).toBe("Per");
45+
})
46+
47+
})
48+
49+
describe("dynamic scopes", () => {
50+
51+
it("accepts arguments via a handler function", () => {
52+
var result = model("authorScoped").byLastName("Petruzzi").findAll();
53+
expect(result.recordcount).toBe(1);
54+
expect(result.lastname).toBe("Petruzzi");
55+
})
56+
57+
})
58+
59+
describe("terminal methods", () => {
60+
61+
it("works with count()", () => {
62+
var result = model("authorScoped").withLastNameDjurner().count();
63+
expect(result).toBe(1);
64+
})
65+
66+
it("works with findOne()", () => {
67+
var result = model("authorScoped").withLastNameDjurner().findOne();
68+
expect(IsObject(result)).toBeTrue();
69+
expect(result.lastName).toBe("Djurner");
70+
})
71+
72+
it("works with exists()", () => {
73+
var result = model("authorScoped").withLastNameDjurner().exists();
74+
expect(result).toBeTrue();
75+
})
76+
77+
it("accepts additional finder args", () => {
78+
var result = model("authorScoped").orderedByFirstName().findAll(select = "firstname");
79+
expect(result.recordcount).toBeGT(0);
80+
})
81+
82+
})
83+
84+
it("returns empty results for non-matching scope", () => {
85+
var result = model("authorScoped").byLastName("NonExistent").findAll();
86+
expect(result.recordcount).toBe(0);
87+
})
88+
89+
})
90+
91+
}
92+
}

0 commit comments

Comments
 (0)