diff --git a/CHANGELOG.md b/CHANGELOG.md index c9d9e8632..9f114e749 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index 2ca220777..24479155d 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -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) diff --git a/docs/src/database-interaction-through-models/enums.md b/docs/src/database-interaction-through-models/enums.md new file mode 100644 index 000000000..b638f97a0 --- /dev/null +++ b/docs/src/database-interaction-through-models/enums.md @@ -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()` 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 + + Published + + Draft + + Archived + +``` + +### Combining Scopes + +```cfm +// Enum scopes compose with other scopes +model("Post").published().recent().findAll(); +``` diff --git a/tests/_assets/models/PostWithEnum.cfc b/tests/_assets/models/PostWithEnum.cfc new file mode 100644 index 000000000..781f3ddfe --- /dev/null +++ b/tests/_assets/models/PostWithEnum.cfc @@ -0,0 +1,9 @@ +component extends="Model" { + + function config() { + table("c_o_r_e_posts"); + belongsTo("author"); + enum(property = "status", values = "draft,published,archived"); + } + +} diff --git a/tests/specs/models/EnumSpec.cfc b/tests/specs/models/EnumSpec.cfc new file mode 100644 index 000000000..614d7986f --- /dev/null +++ b/tests/specs/models/EnumSpec.cfc @@ -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); + }) + + }) + + }) + + } +}