Skip to content

Commit aec989d

Browse files
authored
Merge pull request #1922 from wheels-dev/peter/query-builder-1908
Query builder: tests and documentation
2 parents 85a5dea + 846f57b commit aec989d

File tree

4 files changed

+319
-0
lines changed

4 files changed

+319
-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+
- Chainable query builder with `where()`, `orWhere()`, `whereNull()`, `whereBetween()`, `whereIn()`, `orderBy()`, `limit()`, and more for injection-safe fluent queries
2425
- Enum support with `enum()` for named property values, auto-generated `is*()` checkers, auto-scopes, and inclusion validation
2526
- Query scopes with `scope()` for reusable, composable query fragments in models
2627
- Batch processing with `findEach()` and `findInBatches()` for memory-efficient record iteration

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 Builder](database-interaction-through-models/query-builder.md)
180181
* [Enums](database-interaction-through-models/enums.md)
181182
* [Query Scopes](database-interaction-through-models/query-scopes.md)
182183
* [Batch Processing](database-interaction-through-models/batch-processing.md)
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
# Query Builder
2+
3+
The query builder provides a fluent, chainable API for constructing database queries. It is an alternative to passing raw SQL strings to `findAll(where="...")`, with the added benefit of automatic value quoting for SQL injection safety.
4+
5+
## Basic Usage
6+
7+
Start a query chain by calling `.where()` on any model, then finish with a terminal method like `.get()`:
8+
9+
```cfm
10+
users = model("User")
11+
.where("status", "active")
12+
.where("age", ">", 18)
13+
.orderBy("name", "ASC")
14+
.limit(25)
15+
.get();
16+
```
17+
18+
## WHERE Conditions
19+
20+
### Equality
21+
22+
```cfm
23+
// Two arguments: property, value
24+
model("User").where("status", "active").get();
25+
// Generates: status = 'active'
26+
```
27+
28+
### Operators
29+
30+
```cfm
31+
// Three arguments: property, operator, value
32+
model("User").where("age", ">", 18).get();
33+
model("User").where("views", ">=", 100).get();
34+
model("User").where("name", "LIKE", "%smith%").get();
35+
```
36+
37+
### Raw Strings
38+
39+
```cfm
40+
// One argument: raw WHERE clause (no auto-quoting)
41+
model("User").where("status = 'active' AND role = 'admin'").get();
42+
```
43+
44+
### OR Conditions
45+
46+
```cfm
47+
model("User")
48+
.where("role", "admin")
49+
.orWhere("role", "superadmin")
50+
.get();
51+
// Generates: role = 'admin' OR role = 'superadmin'
52+
```
53+
54+
### NULL Checks
55+
56+
```cfm
57+
model("User").whereNull("deletedAt").get();
58+
model("User").whereNotNull("emailVerifiedAt").get();
59+
```
60+
61+
### BETWEEN
62+
63+
```cfm
64+
model("Product").whereBetween("price", 10, 50).get();
65+
// Generates: price BETWEEN 10 AND 50
66+
```
67+
68+
### IN / NOT IN
69+
70+
```cfm
71+
// With a comma-delimited list
72+
model("User").whereIn("role", "admin,editor,author").get();
73+
74+
// With an array
75+
model("User").whereIn("role", ["admin", "editor"]).get();
76+
77+
// NOT IN
78+
model("User").whereNotIn("status", "banned,suspended").get();
79+
```
80+
81+
## Ordering
82+
83+
```cfm
84+
model("User").orderBy("name", "ASC").get();
85+
model("User").orderBy("createdAt", "DESC").get();
86+
87+
// Multiple order clauses
88+
model("User")
89+
.orderBy("lastName", "ASC")
90+
.orderBy("firstName", "ASC")
91+
.get();
92+
```
93+
94+
## Limiting Results
95+
96+
```cfm
97+
model("User").limit(10).get();
98+
model("User").offset(20).limit(10).get();
99+
```
100+
101+
## Other Builder Methods
102+
103+
```cfm
104+
// Select specific columns
105+
model("User").select("id,name,email").get();
106+
107+
// Include associations
108+
model("User").include("profile,orders").get();
109+
110+
// Group by
111+
model("Order").select("status,COUNT(id) as orderCount").group("status").get();
112+
113+
// Distinct
114+
model("User").select("city").distinct().get();
115+
```
116+
117+
## Terminal Methods
118+
119+
Call one of these to execute the built query:
120+
121+
| Method | Returns | Description |
122+
|--------|---------|-------------|
123+
| `get()` | query | Alias for `findAll()` |
124+
| `findAll()` | query | All matching records |
125+
| `first()` | object | Alias for `findOne()` |
126+
| `findOne()` | object | First matching record |
127+
| `count()` | numeric | Number of matching records |
128+
| `exists()` | boolean | Whether any records match |
129+
| `updateAll(...)` | numeric | Update matching records |
130+
| `deleteAll()` | numeric | Delete matching records |
131+
| `findEach(callback)` | void | Batch iterate one at a time |
132+
| `findInBatches(callback)` | void | Batch iterate in groups |
133+
134+
## Combining with Scopes
135+
136+
You can transition from scopes to the query builder at any point in the chain:
137+
138+
```cfm
139+
model("User")
140+
.active() // scope
141+
.where("role", "admin") // query builder
142+
.orderBy("name", "ASC")
143+
.get();
144+
```
145+
146+
See [Query Scopes](query-scopes.md) for defining reusable scope fragments.
147+
148+
## SQL Injection Safety
149+
150+
All values passed to `where()`, `whereBetween()`, `whereIn()`, and `whereNotIn()` are automatically quoted using the model's database adapter. You do not need to manually escape values:
151+
152+
```cfm
153+
// Safe — value is auto-quoted
154+
model("User").where("name", userInput).get();
155+
156+
// Also safe
157+
model("User").whereIn("id", userSuppliedIds).get();
158+
```
159+
160+
Raw string WHERE clauses (single-argument `where()`) are passed through as-is. Avoid interpolating user input into raw strings.
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
component extends="wheels.WheelsTest" {
2+
3+
function run() {
4+
5+
describe("Query Builder", () => {
6+
7+
describe("where()", () => {
8+
9+
it("filters with equality (2-arg form)", () => {
10+
var result = model("author").where("lastName", "Djurner").get();
11+
expect(result.recordcount).toBe(1);
12+
expect(result.lastname).toBe("Djurner");
13+
})
14+
15+
it("filters with an operator (3-arg form)", () => {
16+
var result = model("post").where("views", ">", 0).get();
17+
expect(result.recordcount).toBeGT(0);
18+
})
19+
20+
it("passes through raw SQL strings (1-arg form)", () => {
21+
var result = model("author").where("lastName = 'Djurner'").get();
22+
expect(result.recordcount).toBe(1);
23+
})
24+
25+
it("chains multiple where conditions with AND", () => {
26+
var result = model("author").where("firstName", "Per").where("lastName", "Djurner").get();
27+
expect(result.recordcount).toBe(1);
28+
})
29+
30+
})
31+
32+
describe("orWhere()", () => {
33+
34+
it("combines conditions with OR", () => {
35+
var result = model("author").where("lastName", "Djurner").orWhere("lastName", "Petruzzi").get();
36+
expect(result.recordcount).toBe(2);
37+
})
38+
39+
})
40+
41+
describe("NULL checks", () => {
42+
43+
it("filters with whereNull()", () => {
44+
var result = model("post").whereNull("deletedat").get();
45+
expect(result.recordcount).toBeGT(0);
46+
})
47+
48+
it("filters with whereNotNull()", () => {
49+
var result = model("post").whereNotNull("averagerating").get();
50+
expect(result.recordcount).toBeGT(0);
51+
})
52+
53+
})
54+
55+
describe("whereBetween()", () => {
56+
57+
it("filters values in a range", () => {
58+
var result = model("post").whereBetween("views", 1, 5).get();
59+
expect(result.recordcount).toBeGT(0);
60+
})
61+
62+
})
63+
64+
describe("whereIn() / whereNotIn()", () => {
65+
66+
it("matches values in a list", () => {
67+
var result = model("author").whereIn("lastName", "Djurner,Petruzzi").get();
68+
expect(result.recordcount).toBe(2);
69+
})
70+
71+
it("matches values in an array", () => {
72+
var result = model("author").whereIn("lastName", ["Djurner", "Petruzzi"]).get();
73+
expect(result.recordcount).toBe(2);
74+
})
75+
76+
it("excludes values with whereNotIn()", () => {
77+
var totalCount = model("author").count();
78+
var result = model("author").whereNotIn("lastName", "Djurner,Petruzzi").get();
79+
expect(result.recordcount).toBe(totalCount - 2);
80+
})
81+
82+
})
83+
84+
describe("orderBy()", () => {
85+
86+
it("orders ascending", () => {
87+
var result = model("author").orderBy("firstName", "ASC").get();
88+
expect(result.firstname[1]).toBe("Adam");
89+
})
90+
91+
it("orders descending", () => {
92+
var result = model("author").orderBy("firstName", "DESC").get();
93+
expect(result.firstname[1]).toBe("Tony");
94+
})
95+
96+
})
97+
98+
describe("limit()", () => {
99+
100+
it("limits the number of results", () => {
101+
var result = model("author").limit(3).orderBy("id", "ASC").get();
102+
expect(result.recordcount).toBe(3);
103+
})
104+
105+
})
106+
107+
describe("terminal methods", () => {
108+
109+
it("get() is an alias for findAll()", () => {
110+
var r1 = model("author").where("lastName", "Djurner").get();
111+
var r2 = model("author").where("lastName", "Djurner").findAll();
112+
expect(r1.recordcount).toBe(r2.recordcount);
113+
})
114+
115+
it("first() returns a model object", () => {
116+
var result = model("author").where("lastName", "Djurner").first();
117+
expect(IsObject(result)).toBeTrue();
118+
expect(result.lastName).toBe("Djurner");
119+
})
120+
121+
it("findOne() returns a model object", () => {
122+
var result = model("author").where("lastName", "Djurner").findOne();
123+
expect(IsObject(result)).toBeTrue();
124+
})
125+
126+
it("count() returns the matching count", () => {
127+
var result = model("author").where("lastName", "Djurner").count();
128+
expect(result).toBe(1);
129+
})
130+
131+
it("exists() returns true for matching records", () => {
132+
var result = model("author").where("lastName", "Djurner").exists();
133+
expect(result).toBeTrue();
134+
})
135+
136+
it("exists() returns false for no matches", () => {
137+
var result = model("author").where("lastName", "NonExistent").exists();
138+
expect(result).toBeFalse();
139+
})
140+
141+
})
142+
143+
it("handles complex chains", () => {
144+
var result = model("author")
145+
.where("firstName", "Per")
146+
.whereNotNull("lastName")
147+
.orderBy("id", "ASC")
148+
.limit(10)
149+
.get();
150+
expect(result.recordcount).toBe(1);
151+
expect(result.firstname).toBe("Per");
152+
})
153+
154+
})
155+
156+
}
157+
}

0 commit comments

Comments
 (0)