Skip to content

Commit ba56daf

Browse files
bpamiriclaude
andcommitted
feat: add TestBox tests and docs for query builder (#1908)
Replace RocketUnit tests with TestBox BDD specs for QueryBuilder including where, orWhere, whereNull, whereBetween, whereIn, orderBy, limit, and terminal methods. Add shared test infrastructure. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 4dc18eb commit ba56daf

File tree

7 files changed

+417
-0
lines changed

7 files changed

+417
-0
lines changed

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,13 @@ All historical references to "CFWheels" in this changelog have been preserved fo
1818

1919
----
2020

21+
## [Unreleased]
22+
23+
### Added
24+
- Chainable query builder with `where()`, `orWhere()`, `whereNull()`, `whereBetween()`, `whereIn()`, `orderBy()`, `limit()`, and more for injection-safe fluent queries
25+
26+
----
27+
2128

2229
# [3.0.0](https://github.com/wheels-dev/wheels/releases/tag/v3.0.0) => 2026-01-10
2330

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
* [Automatic Time Stamps](database-interaction-through-models/automatic-time-stamps.md)
181182
* [Database Migrations](database-interaction-through-models/database-migrations/README.md)
182183
* [Migrations in Production](database-interaction-through-models/database-migrations/migrations-in-production.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.

tests/_assets/models/Author.cfc

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
component extends="Model" {
2+
3+
function config() {
4+
table("c_o_r_e_authors");
5+
hasMany("posts");
6+
hasOne("profile");
7+
}
8+
9+
}

tests/_assets/models/Post.cfc

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
component extends="Model" {
2+
3+
function config() {
4+
table("c_o_r_e_posts");
5+
belongsTo("author");
6+
}
7+
8+
}

tests/populate.cfm

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
<cfsetting requestTimeOut="300">
2+
<cfscript>
3+
// Get database info
4+
local.db_info = $dbinfo(datasource = application.wheels.dataSourceName, type = "version");
5+
local.db = LCase(Replace(local.db_info.database_productname, " ", "", "all"));
6+
7+
// Set DB-specific types
8+
local.identityColumnType = "int NOT NULL IDENTITY";
9+
if (local.db IS "mysql" or local.db IS "mariadb") {
10+
local.identityColumnType = "int NOT NULL AUTO_INCREMENT";
11+
} else if (local.db IS "postgresql") {
12+
local.identityColumnType = "SERIAL NOT NULL";
13+
}
14+
local.storageEngine = (local.db IS "mysql" or local.db IS "mariadb") ? "ENGINE=InnoDB" : "";
15+
</cfscript>
16+
17+
<!--- Drop existing tables --->
18+
<cftry>
19+
<cfquery datasource="#application.wheels.dataSourceName#">DROP TABLE IF EXISTS c_o_r_e_posts</cfquery>
20+
<cfcatch></cfcatch>
21+
</cftry>
22+
<cftry>
23+
<cfquery datasource="#application.wheels.dataSourceName#">DROP TABLE IF EXISTS c_o_r_e_authors</cfquery>
24+
<cfcatch></cfcatch>
25+
</cftry>
26+
27+
<!--- Create tables --->
28+
<cfquery datasource="#application.wheels.dataSourceName#">
29+
CREATE TABLE c_o_r_e_authors (
30+
id #local.identityColumnType#,
31+
firstname varchar(100) NOT NULL,
32+
lastname varchar(100) NOT NULL,
33+
PRIMARY KEY(id)
34+
) #local.storageEngine#
35+
</cfquery>
36+
37+
<cfquery datasource="#application.wheels.dataSourceName#">
38+
CREATE TABLE c_o_r_e_posts (
39+
id #local.identityColumnType#,
40+
authorid int NULL,
41+
title varchar(250) NOT NULL,
42+
body text NOT NULL,
43+
createdat datetime NOT NULL,
44+
updatedat datetime NOT NULL,
45+
deletedat datetime NULL,
46+
views int DEFAULT 0 NOT NULL,
47+
averagerating float NULL,
48+
status varchar(20) DEFAULT 'draft' NOT NULL,
49+
PRIMARY KEY(id)
50+
) #local.storageEngine#
51+
</cfquery>
52+
53+
<!--- Populate data --->
54+
<cfscript>
55+
model("author").create(firstName = "Per", lastName = "Djurner");
56+
model("author").create(firstName = "Tony", lastName = "Petruzzi");
57+
model("author").create(firstName = "Chris", lastName = "Peters");
58+
model("author").create(firstName = "Peter", lastName = "Amiri");
59+
model("author").create(firstName = "James", lastName = "Gibson");
60+
model("author").create(firstName = "Raul", lastName = "Riera");
61+
model("author").create(firstName = "Andy", lastName = "Bellenie");
62+
model("author").create(firstName = "Adam", lastName = "Chapman");
63+
model("author").create(firstName = "Tom", lastName = "King");
64+
model("author").create(firstName = "David", lastName = "Belanger");
65+
66+
// Create posts with various statuses
67+
local.per = model("author").findOne(where = "firstName = 'Per'");
68+
local.per.createPost(title = "First post", body = "Body 1", views = 5, status = "published");
69+
local.per.createPost(title = "Second post", body = "Body 2", views = 5, status = "published");
70+
local.per.createPost(title = "Third post", body = "Body 3", views = 0, averageRating = "3.2", status = "archived");
71+
72+
local.tony = model("author").findOne(where = "firstName = 'Tony'");
73+
local.tony.createPost(title = "Fourth post", body = "Body 4", views = 3, averageRating = "3.6", status = "draft");
74+
local.tony.createPost(title = "Fifth post", body = "Body 5", views = 2, averageRating = "3.6", status = "draft");
75+
</cfscript>

0 commit comments

Comments
 (0)