From 3ea037b39fd157508931463eb23cf8cf28e85276 Mon Sep 17 00:00:00 2001 From: Peter Amiri Date: Sat, 7 Mar 2026 12:17:45 -0800 Subject: [PATCH] feat: add TestBox tests and docs for enum support (#1910) Replace RocketUnit tests with TestBox BDD specs for enum(), is*() checkers, validation, and auto-generated scopes. Add shared test infrastructure. Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 7 ++ docs/src/SUMMARY.md | 1 + .../enums.md | 118 ++++++++++++++++++ tests/_assets/models/Author.cfc | 9 ++ tests/_assets/models/Post.cfc | 8 ++ tests/_assets/models/PostWithEnum.cfc | 9 ++ tests/populate.cfm | 75 +++++++++++ tests/specs/models/EnumSpec.cfc | 88 +++++++++++++ 8 files changed, 315 insertions(+) create mode 100644 docs/src/database-interaction-through-models/enums.md create mode 100644 tests/_assets/models/Author.cfc create mode 100644 tests/_assets/models/Post.cfc create mode 100644 tests/_assets/models/PostWithEnum.cfc create mode 100644 tests/specs/models/EnumSpec.cfc diff --git a/CHANGELOG.md b/CHANGELOG.md index fb96224ebd..f8e637605d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,13 @@ 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 + +---- + # [3.0.0](https://github.com/wheels-dev/wheels/releases/tag/v3.0.0) => 2026-01-10 diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index 2496690e4c..6122bc6b60 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) * [Automatic Time Stamps](database-interaction-through-models/automatic-time-stamps.md) * [Database Migrations](database-interaction-through-models/database-migrations/README.md) * [Migrations in Production](database-interaction-through-models/database-migrations/migrations-in-production.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 0000000000..b638f97a02 --- /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/Author.cfc b/tests/_assets/models/Author.cfc new file mode 100644 index 0000000000..ba7bf6a27d --- /dev/null +++ b/tests/_assets/models/Author.cfc @@ -0,0 +1,9 @@ +component extends="Model" { + + function config() { + table("c_o_r_e_authors"); + hasMany("posts"); + hasOne("profile"); + } + +} diff --git a/tests/_assets/models/Post.cfc b/tests/_assets/models/Post.cfc new file mode 100644 index 0000000000..4ec82a4682 --- /dev/null +++ b/tests/_assets/models/Post.cfc @@ -0,0 +1,8 @@ +component extends="Model" { + + function config() { + table("c_o_r_e_posts"); + belongsTo("author"); + } + +} diff --git a/tests/_assets/models/PostWithEnum.cfc b/tests/_assets/models/PostWithEnum.cfc new file mode 100644 index 0000000000..781f3ddfe5 --- /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/populate.cfm b/tests/populate.cfm index e69de29bb2..d6c8df6f19 100644 --- a/tests/populate.cfm +++ b/tests/populate.cfm @@ -0,0 +1,75 @@ + + +// Get database info +local.db_info = $dbinfo(datasource = application.wheels.dataSourceName, type = "version"); +local.db = LCase(Replace(local.db_info.database_productname, " ", "", "all")); + +// Set DB-specific types +local.identityColumnType = "int NOT NULL IDENTITY"; +if (local.db IS "mysql" or local.db IS "mariadb") { + local.identityColumnType = "int NOT NULL AUTO_INCREMENT"; +} else if (local.db IS "postgresql") { + local.identityColumnType = "SERIAL NOT NULL"; +} +local.storageEngine = (local.db IS "mysql" or local.db IS "mariadb") ? "ENGINE=InnoDB" : ""; + + + + + DROP TABLE IF EXISTS c_o_r_e_posts + + + + DROP TABLE IF EXISTS c_o_r_e_authors + + + + + +CREATE TABLE c_o_r_e_authors ( + id #local.identityColumnType#, + firstname varchar(100) NOT NULL, + lastname varchar(100) NOT NULL, + PRIMARY KEY(id) +) #local.storageEngine# + + + +CREATE TABLE c_o_r_e_posts ( + id #local.identityColumnType#, + authorid int NULL, + title varchar(250) NOT NULL, + body text NOT NULL, + createdat datetime NOT NULL, + updatedat datetime NOT NULL, + deletedat datetime NULL, + views int DEFAULT 0 NOT NULL, + averagerating float NULL, + status varchar(20) DEFAULT 'draft' NOT NULL, + PRIMARY KEY(id) +) #local.storageEngine# + + + + +model("author").create(firstName = "Per", lastName = "Djurner"); +model("author").create(firstName = "Tony", lastName = "Petruzzi"); +model("author").create(firstName = "Chris", lastName = "Peters"); +model("author").create(firstName = "Peter", lastName = "Amiri"); +model("author").create(firstName = "James", lastName = "Gibson"); +model("author").create(firstName = "Raul", lastName = "Riera"); +model("author").create(firstName = "Andy", lastName = "Bellenie"); +model("author").create(firstName = "Adam", lastName = "Chapman"); +model("author").create(firstName = "Tom", lastName = "King"); +model("author").create(firstName = "David", lastName = "Belanger"); + +// Create posts with various statuses +local.per = model("author").findOne(where = "firstName = 'Per'"); +local.per.createPost(title = "First post", body = "Body 1", views = 5, status = "published"); +local.per.createPost(title = "Second post", body = "Body 2", views = 5, status = "published"); +local.per.createPost(title = "Third post", body = "Body 3", views = 0, averageRating = "3.2", status = "archived"); + +local.tony = model("author").findOne(where = "firstName = 'Tony'"); +local.tony.createPost(title = "Fourth post", body = "Body 4", views = 3, averageRating = "3.6", status = "draft"); +local.tony.createPost(title = "Fifth post", body = "Body 5", views = 2, averageRating = "3.6", status = "draft"); + diff --git a/tests/specs/models/EnumSpec.cfc b/tests/specs/models/EnumSpec.cfc new file mode 100644 index 0000000000..614d7986f0 --- /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); + }) + + }) + + }) + + } +}