Skip to content

Commit 726eebf

Browse files
bpamiriclaude
andcommitted
feat: add tests and docs for enum support (#1910)
- PostWithEnum test model with status enum (draft/published/archived) - 11 unit tests covering is*() checkers, auto-scopes, validation - Added status column to c_o_r_e_posts test table with seed data - Framework documentation page with usage examples - SUMMARY.md and CHANGELOG.md entries Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 4dc18eb commit 726eebf

File tree

6 files changed

+221
-0
lines changed

6 files changed

+221
-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+
- Enum support with `enum()` for named property values, auto-generated `is*()` checkers, auto-scopes, and inclusion validation
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+
* [Enums](database-interaction-through-models/enums.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: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
# Enums
2+
3+
Enums let you define a fixed set of allowed values for a model property. Wheels automatically generates boolean checker methods, query scopes, and inclusion validation for each enum.
4+
5+
## Defining Enums
6+
7+
Add enums in your model's `config()` function:
8+
9+
```cfm
10+
component extends="Model" {
11+
function config() {
12+
// String list — each value is both the name and stored value
13+
enum(property="status", values="draft,published,archived");
14+
15+
// Struct mapping — names map to different stored values
16+
enum(property="priority", values={low: 0, medium: 1, high: 2});
17+
}
18+
}
19+
```
20+
21+
### String List
22+
23+
When you pass a comma-delimited string, each value serves as both the display name and the stored database value:
24+
25+
```cfm
26+
enum(property="status", values="draft,published,archived");
27+
// Stores "draft", "published", or "archived" in the database
28+
```
29+
30+
### Struct Mapping
31+
32+
When you pass a struct, the keys are the names and the values are what gets stored in the database:
33+
34+
```cfm
35+
enum(property="priority", values={low: 0, medium: 1, high: 2});
36+
// Stores 0, 1, or 2 in the database
37+
```
38+
39+
## What Enums Generate
40+
41+
Defining an enum automatically creates three things:
42+
43+
### 1. Boolean Checker Methods
44+
45+
For each enum value, an `is<Value>()` method is generated on model instances:
46+
47+
```cfm
48+
post = model("Post").findByKey(1);
49+
50+
post.isDraft(); // true or false
51+
post.isPublished(); // true or false
52+
post.isArchived(); // true or false
53+
```
54+
55+
### 2. Query Scopes
56+
57+
Each enum value becomes a named scope you can chain:
58+
59+
```cfm
60+
// Find all draft posts
61+
drafts = model("Post").draft().findAll();
62+
63+
// Find all published posts, ordered by date
64+
published = model("Post").published().findAll(order="createdAt DESC");
65+
66+
// Count archived posts
67+
archivedCount = model("Post").archived().count();
68+
```
69+
70+
### 3. Inclusion Validation
71+
72+
A `validatesInclusionOf` validation is automatically registered, preventing invalid values:
73+
74+
```cfm
75+
post = model("Post").new();
76+
post.status = "invalid_value";
77+
post.valid(); // false — "invalid_value" is not in the enum
78+
79+
post.status = "published";
80+
post.valid(); // true (assuming other validations pass)
81+
post.errorsOn("status"); // empty array
82+
```
83+
84+
The validation uses `allowBlank=true`, so blank/empty values are permitted unless you add a separate `validatesPresenceOf`.
85+
86+
## Examples
87+
88+
### Filtering by Enum Value
89+
90+
```cfm
91+
// Using auto-generated scopes
92+
model("Post").published().findAll(page=1, perPage=25);
93+
94+
// Using standard WHERE clause
95+
model("Post").findAll(where="status = 'published'");
96+
97+
// Using query builder
98+
model("Post").where("status", "published").get();
99+
```
100+
101+
### Checking State in Views
102+
103+
```cfm
104+
<cfif post.isPublished()>
105+
<span class="badge badge-success">Published</span>
106+
<cfelseif post.isDraft()>
107+
<span class="badge badge-warning">Draft</span>
108+
<cfelse>
109+
<span class="badge badge-secondary">Archived</span>
110+
</cfif>
111+
```
112+
113+
### Combining Scopes
114+
115+
```cfm
116+
// Enum scopes compose with other scopes
117+
model("Post").published().recent().findAll();
118+
```
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_posts");
5+
belongsTo("author");
6+
enum(property = "status", values = "draft,published,archived");
7+
}
8+
9+
}
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
component extends="wheels.tests.Test" {
2+
3+
/* --- isDraft() / isPublished() / isArchived() boolean checkers --- */
4+
5+
function test_isDraft_returns_true_for_draft_post() {
6+
post = model("postWithEnum").findOne(where = "status = 'draft'", order = "id");
7+
assert("IsObject(post)");
8+
assert("post.isDraft() IS true");
9+
}
10+
11+
function test_isDraft_returns_false_for_published_post() {
12+
post = model("postWithEnum").findOne(where = "status = 'published'", order = "id");
13+
assert("IsObject(post)");
14+
assert("post.isDraft() IS false");
15+
}
16+
17+
function test_isPublished_returns_true_for_published_post() {
18+
post = model("postWithEnum").findOne(where = "status = 'published'", order = "id");
19+
assert("post.isPublished() IS true");
20+
}
21+
22+
function test_isArchived_returns_true_for_archived_post() {
23+
post = model("postWithEnum").findOne(where = "status = 'archived'", order = "id");
24+
assert("post.isArchived() IS true");
25+
}
26+
27+
function test_isArchived_returns_false_for_draft_post() {
28+
post = model("postWithEnum").findOne(where = "status = 'draft'", order = "id");
29+
assert("post.isArchived() IS false");
30+
}
31+
32+
/* --- Enum validation --- */
33+
34+
function test_validates_inclusion_of_enum_values() {
35+
post = model("postWithEnum").findOne(order = "id");
36+
post.status = "invalid_status";
37+
assert("post.valid() IS false");
38+
}
39+
40+
function test_valid_enum_value_passes_validation() {
41+
post = model("postWithEnum").findOne(order = "id");
42+
post.status = "published";
43+
// Should pass the enum inclusion validation (other validations may fail)
44+
post.valid();
45+
errors = post.errorsOn("status");
46+
assert("ArrayLen(errors) IS 0");
47+
}
48+
49+
/* --- Auto-generated scopes per enum value --- */
50+
51+
function test_draft_scope_returns_draft_posts() {
52+
result = model("postWithEnum").draft().findAll();
53+
assert("result.recordcount GT 0");
54+
assert("result.status IS 'draft'");
55+
}
56+
57+
function test_published_scope_returns_published_posts() {
58+
result = model("postWithEnum").published().findAll();
59+
assert("result.recordcount GT 0");
60+
assert("result.status IS 'published'");
61+
}
62+
63+
function test_archived_scope_returns_archived_posts() {
64+
result = model("postWithEnum").archived().findAll();
65+
assert("result.recordcount GT 0");
66+
assert("result.status IS 'archived'");
67+
}
68+
69+
function test_enum_scope_with_count() {
70+
draftCount = model("postWithEnum").draft().count();
71+
publishedCount = model("postWithEnum").published().count();
72+
archivedCount = model("postWithEnum").archived().count();
73+
totalCount = model("postWithEnum").count();
74+
assert("draftCount + publishedCount + archivedCount IS totalCount");
75+
}
76+
77+
}

vendor/wheels/tests/populate.cfm

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,7 @@ CREATE TABLE c_o_r_e_posts
174174
,deletedat #local.datetimeColumnType# NULL
175175
,views #local.intColumnType# DEFAULT 0 NOT NULL
176176
,averagerating #local.floatColumnType# NULL
177+
,status varchar(20) DEFAULT 'draft' NOT NULL
177178
,PRIMARY KEY(id)
178179
) #local.storageEngine#
179180
</cfquery>
@@ -416,6 +417,14 @@ FROM c_o_r_e_users u INNER JOIN c_o_r_e_galleries g ON u.id = g.userid
416417
views = 2,
417418
averageRating = "3.6"
418419
)>
420+
<!--- set some post statuses for enum testing --->
421+
<cfquery datasource="#application.wheels.dataSourceName#">
422+
UPDATE c_o_r_e_posts SET status = 'published' WHERE id IN (1, 2)
423+
</cfquery>
424+
<cfquery datasource="#application.wheels.dataSourceName#">
425+
UPDATE c_o_r_e_posts SET status = 'archived' WHERE id = 3
426+
</cfquery>
427+
419428
<cfset local.chris = model("author").create(firstName = "Chris", lastName = "Peters")>
420429
<cfset local.peter = model("author").create(firstName = "Peter", lastName = "Amiri")>
421430
<cfset local.james = model("author").create(firstName = "James", lastName = "Gibson")>

0 commit comments

Comments
 (0)