Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ All historical references to "CFWheels" in this changelog have been preserved fo
## [Unreleased]

### Added
- Enum support with `enum()` for named property values, auto-generated `is*()` checkers, auto-scopes, and inclusion validation
- Query scopes with `scope()` for reusable, composable query fragments in models
- Batch processing with `findEach()` and `findInBatches()` for memory-efficient record iteration

Expand Down
1 change: 1 addition & 0 deletions docs/src/SUMMARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,7 @@
* [Transactions](database-interaction-through-models/transactions.md)
* [Dirty Records](database-interaction-through-models/dirty-records.md)
* [Soft Delete](database-interaction-through-models/soft-delete.md)
* [Enums](database-interaction-through-models/enums.md)
* [Query Scopes](database-interaction-through-models/query-scopes.md)
* [Batch Processing](database-interaction-through-models/batch-processing.md)
* [Automatic Time Stamps](database-interaction-through-models/automatic-time-stamps.md)
Expand Down
118 changes: 118 additions & 0 deletions docs/src/database-interaction-through-models/enums.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
# Enums

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.

## Defining Enums

Add enums in your model's `config()` function:

```cfm
component extends="Model" {
function config() {
// String list — each value is both the name and stored value
enum(property="status", values="draft,published,archived");

// Struct mapping — names map to different stored values
enum(property="priority", values={low: 0, medium: 1, high: 2});
}
}
```

### String List

When you pass a comma-delimited string, each value serves as both the display name and the stored database value:

```cfm
enum(property="status", values="draft,published,archived");
// Stores "draft", "published", or "archived" in the database
```

### Struct Mapping

When you pass a struct, the keys are the names and the values are what gets stored in the database:

```cfm
enum(property="priority", values={low: 0, medium: 1, high: 2});
// Stores 0, 1, or 2 in the database
```

## What Enums Generate

Defining an enum automatically creates three things:

### 1. Boolean Checker Methods

For each enum value, an `is<Value>()` method is generated on model instances:

```cfm
post = model("Post").findByKey(1);

post.isDraft(); // true or false
post.isPublished(); // true or false
post.isArchived(); // true or false
```

### 2. Query Scopes

Each enum value becomes a named scope you can chain:

```cfm
// Find all draft posts
drafts = model("Post").draft().findAll();

// Find all published posts, ordered by date
published = model("Post").published().findAll(order="createdAt DESC");

// Count archived posts
archivedCount = model("Post").archived().count();
```

### 3. Inclusion Validation

A `validatesInclusionOf` validation is automatically registered, preventing invalid values:

```cfm
post = model("Post").new();
post.status = "invalid_value";
post.valid(); // false — "invalid_value" is not in the enum

post.status = "published";
post.valid(); // true (assuming other validations pass)
post.errorsOn("status"); // empty array
```

The validation uses `allowBlank=true`, so blank/empty values are permitted unless you add a separate `validatesPresenceOf`.

## Examples

### Filtering by Enum Value

```cfm
// Using auto-generated scopes
model("Post").published().findAll(page=1, perPage=25);

// Using standard WHERE clause
model("Post").findAll(where="status = 'published'");

// Using query builder
model("Post").where("status", "published").get();
```

### Checking State in Views

```cfm
<cfif post.isPublished()>
<span class="badge badge-success">Published</span>
<cfelseif post.isDraft()>
<span class="badge badge-warning">Draft</span>
<cfelse>
<span class="badge badge-secondary">Archived</span>
</cfif>
```

### Combining Scopes

```cfm
// Enum scopes compose with other scopes
model("Post").published().recent().findAll();
```
9 changes: 9 additions & 0 deletions tests/_assets/models/PostWithEnum.cfc
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
component extends="Model" {

function config() {
table("c_o_r_e_posts");
belongsTo("author");
enum(property = "status", values = "draft,published,archived");
}

}
88 changes: 88 additions & 0 deletions tests/specs/models/EnumSpec.cfc
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
component extends="wheels.WheelsTest" {

function run() {

describe("Enum Support", () => {

describe("is*() boolean checkers", () => {

it("isDraft() returns true for a draft post", () => {
var post = model("postWithEnum").findOne(where = "status = 'draft'", order = "id");
expect(IsObject(post)).toBeTrue();
expect(post.isDraft()).toBeTrue();
})

it("isDraft() returns false for a published post", () => {
var post = model("postWithEnum").findOne(where = "status = 'published'", order = "id");
expect(post.isDraft()).toBeFalse();
})

it("isPublished() returns true for a published post", () => {
var post = model("postWithEnum").findOne(where = "status = 'published'", order = "id");
expect(post.isPublished()).toBeTrue();
})

it("isArchived() returns true for an archived post", () => {
var post = model("postWithEnum").findOne(where = "status = 'archived'", order = "id");
expect(post.isArchived()).toBeTrue();
})

it("isArchived() returns false for a draft post", () => {
var post = model("postWithEnum").findOne(where = "status = 'draft'", order = "id");
expect(post.isArchived()).toBeFalse();
})

})

describe("validation", () => {

it("rejects invalid enum values", () => {
var post = model("postWithEnum").findOne(order = "id");
post.status = "invalid_status";
expect(post.valid()).toBeFalse();
})

it("passes validation for valid enum values", () => {
var post = model("postWithEnum").findOne(order = "id");
post.status = "published";
post.valid();
var errors = post.errorsOn("status");
expect(ArrayLen(errors)).toBe(0);
})

})

describe("auto-generated scopes", () => {

it("draft() returns only draft posts", () => {
var result = model("postWithEnum").draft().findAll();
expect(result.recordcount).toBeGT(0);
expect(result.status).toBe("draft");
})

it("published() returns only published posts", () => {
var result = model("postWithEnum").published().findAll();
expect(result.recordcount).toBeGT(0);
expect(result.status).toBe("published");
})

it("archived() returns only archived posts", () => {
var result = model("postWithEnum").archived().findAll();
expect(result.recordcount).toBeGT(0);
expect(result.status).toBe("archived");
})

it("scope counts sum to total count", () => {
var draftCount = model("postWithEnum").draft().count();
var publishedCount = model("postWithEnum").published().count();
var archivedCount = model("postWithEnum").archived().count();
var totalCount = model("postWithEnum").count();
expect(draftCount + publishedCount + archivedCount).toBe(totalCount);
})

})

})

}
}
Loading