Claude Code Plugins

Community-maintained marketplace

Feedback

Wheels Model Generator

@wheels-dev/wheels
202
0

Generate Wheels ORM models with proper validations, associations, and methods. Use when the user wants to create or modify a Wheels model, add validations, define associations (hasMany, belongsTo, hasManyThrough), or implement custom model methods. Prevents common Wheels-specific errors like mixed argument styles and ensures proper CFML syntax.

Install Skill

1Download skill
2Enable skills in Claude

Open claude.ai/settings/capabilities and find the "Skills" section

3Upload to Claude

Click "Upload skill" and select the downloaded ZIP file

Note: Please verify skill by going through its instructions before using it.

SKILL.md

name Wheels Model Generator
description Generate Wheels ORM models with proper validations, associations, and methods. Use when the user wants to create or modify a Wheels model, add validations, define associations (hasMany, belongsTo, hasManyThrough), or implement custom model methods. Prevents common Wheels-specific errors like mixed argument styles and ensures proper CFML syntax.

Wheels Model Generator

When to Use This Skill

Activate this skill automatically when:

  • User requests to create a new model (e.g., "create a User model")
  • User wants to add associations (e.g., "Post hasMany Comments")
  • User needs to add validations (e.g., "validate email format")
  • User wants to implement custom model methods
  • User is modifying existing model configuration
  • User mentions: model, validation, association, hasMany, belongsTo, ORM

Critical Anti-Patterns to Prevent

❌ ANTI-PATTERN 1: Mixed Argument Styles

NEVER mix positional and named arguments in Wheels functions.

WRONG:

hasMany("comments", dependent="delete")  // ❌ Mixed
belongsTo("user", foreignKey="userId")   // ❌ Mixed
validatesPresenceOf("title", message="Required")  // ❌ Mixed

CORRECT:

// Option 1: All named parameters (RECOMMENDED)
hasMany(name="comments", dependent="delete")
belongsTo(name="user", foreignKey="userId")
validatesPresenceOf(property="title", message="Required")

// Option 2: All positional parameters (only when no additional options)
hasMany("comments")
belongsTo("user")
validatesPresenceOf("title")

❌ ANTI-PATTERN 2: Inconsistent Parameter Styles

Use the SAME style throughout the entire config() function.

WRONG:

function config() {
    hasMany("comments");  // Positional
    belongsTo(name="user");  // Named
}

CORRECT:

function config() {
    hasMany(name="comments");  // All named
    belongsTo(name="user");    // All named
}

❌ ANTI-PATTERN 3: Wrong Parameter Names (CRITICAL)

🚨 PRODUCTION FINDING: Wheels validation functions use "properties" (PLURAL), not "property"!

WRONG:

validatesPresenceOf(property="username,email")  // ❌ "property" parameter doesn't exist!
validatesUniquenessOf(property="email")         // ❌ Wrong parameter name
validatesFormatOf(property="email", regEx="...")  // ❌ Won't work
validatesLengthOf(property="username", minimum=3)  // ❌ Parameter not recognized

CORRECT:

validatesPresenceOf(properties="username,email")  // ✅ Use "properties" (plural)
validatesUniquenessOf(properties="email")         // ✅ Correct
validatesFormatOf(properties="email", regEx="...")  // ✅ Works
validatesLengthOf(properties="username", minimum=3)  // ✅ Recognized

Similarly for custom validation:

validate(methods="customValidation")  // ✅ "methods" (plural)
validate(method="customValidation")   // ❌ "method" doesn't exist

🚨 Production-Tested Critical Fixes

1. setPrimaryKey() Requirement (CRITICAL)

🔴 CRITICAL DISCOVERY: Even when migrations correctly create primary keys, models MUST explicitly declare them using setPrimaryKey() in the config() method.

Problem Symptom:

Error: "Wheels.NoPrimaryKey: No primary key exists on the users table"

Even when migration succeeded:

// Migration appeared successful
t = createTable(name="users");  // Creates id column as primary key
t.create();  // ✅ Reports success

Required Fix in Model:

component extends="Model" {
    function config() {
        table("users");
        setPrimaryKey("id");  // 🚨 MANDATORY - Always add this line!

        // Rest of configuration...
        hasMany(name="tweets", dependent="delete");
        validatesPresenceOf(properties="username,email");
    }
}

Why This Happens:

  • CLI generators may not add setPrimaryKey() to generated models
  • Wheels ORM requires explicit primary key declaration in model
  • Missing this causes "NoPrimaryKey" error even with correct database schema
  • ALWAYS add setPrimaryKey("id") to EVERY model's config() method

Rule:

✅ MANDATORY: Add setPrimaryKey("id") to EVERY model config() - no exceptions!

2. Property Access in beforeCreate() Callbacks (CRITICAL)

🔴 CRITICAL DISCOVERY: Accessing properties in beforeCreate() callbacks without checking existence causes "no accessible Member" errors.

Problem Symptom:

Error: "Component [app.models.User] has no accessible Member with name [FOLLOWERSCOUNT]"

❌ WRONG - Causes Error:

component extends="Model" {
    function config() {
        beforeCreate("setDefaults");
    }

    function setDefaults() {
        // ❌ Error if property doesn't exist yet!
        if (!len(this.followersCount)) {
            this.followersCount = 0;
        }
    }
}

✅ CORRECT - Always Check Existence First:

component extends="Model" {
    function config() {
        beforeCreate("setDefaults");
    }

    function setDefaults() {
        // ✅ Check existence first!
        if (!structKeyExists(this, "followersCount") || !len(this.followersCount)) {
            this.followersCount = 0;
        }

        if (!structKeyExists(this, "followingCount") || !len(this.followingCount)) {
            this.followingCount = 0;
        }

        if (!structKeyExists(this, "tweetsCount") || !len(this.tweetsCount)) {
            this.tweetsCount = 0;
        }
    }
}

Why This Happens:

  • In beforeCreate(), properties may not exist yet in the this scope
  • Direct access like this.propertyName throws error if property doesn't exist
  • Must use structKeyExists(this, "propertyName") before accessing
  • This applies to ANY property access in beforeCreate, beforeValidation callbacks

Rule:

✅ MANDATORY: Use structKeyExists(this, "property") before accessing properties in beforeCreate()

3. Complete Production-Ready Model Template

Use this template for ALL model generation to avoid common issues:

component extends="Model" {

    function config() {
        // 🚨 MANDATORY: Always set primary key
        table("users");
        setPrimaryKey("id");  // CRITICAL - Never omit this!

        // Associations - ALWAYS use named parameters
        hasMany(name="tweets", dependent="delete");
        hasMany(name="likes", dependent="delete");
        hasMany(name="followings", foreignKey="followerId", dependent="delete");
        hasMany(name="followers", foreignKey="followingId", dependent="delete");

        // Validations - Use "properties" (plural)
        validatesPresenceOf(properties="username,email,passwordHash");
        validatesUniquenessOf(properties="username,email", message="[property] already taken");
        validatesFormatOf(properties="email", regEx="^[\w\.-]+@[\w\.-]+\.\w+$", message="Invalid email format");
        validatesLengthOf(properties="username", minimum=3, maximum=50);
        validatesLengthOf(properties="bio", maximum=160, allowBlank=true);

        // Callbacks
        beforeCreate("setDefaults");
    }

    // 🚨 CRITICAL: Always use structKeyExists() in beforeCreate
    function setDefaults() {
        if (!structKeyExists(this, "followersCount") || !len(this.followersCount)) {
            this.followersCount = 0;
        }
        if (!structKeyExists(this, "followingCount") || !len(this.followingCount)) {
            this.followingCount = 0;
        }
        if (!structKeyExists(this, "tweetsCount") || !len(this.tweetsCount)) {
            this.tweetsCount = 0;
        }
    }

    // Custom methods
    function fullName() {
        return "@" & this.username;
    }

    function isFollowing(required numeric userId) {
        var follow = model("Follow").findOne(where="followerId = #this.id# AND followingId = #arguments.userId#");
        return isObject(follow);
    }
}

4. CLI Generator Post-Generation Checklist

After using CLI wheels g model command, ALWAYS review and fix:

  • Add setPrimaryKey("id") to config() method
  • Change all validation parameters from property= to properties=
  • Change custom validation from method= to methods=
  • Add structKeyExists() checks in all beforeCreate/beforeValidation callbacks
  • Ensure all association parameters use named style (name=, dependent=)
  • Verify all callback methods are marked private
  • Test model instantiation: model("ModelName").new() should not error

Model Generation Template

Basic Model Structure

component extends="Model" {

    function config() {
        // Table configuration (optional - only if table name differs from convention)
        // table(name="custom_table_name");

        // Associations - ALWAYS use named parameters for consistency
        hasMany(name="association_name", dependent="delete");
        belongsTo(name="parent_model");

        // Validations - ALWAYS use named parameters
        validatesPresenceOf(property="field1,field2");
        validatesUniquenessOf(property="field_name");

        // Callbacks (optional)
        beforeValidationOnCreate("methodName");
        afterCreate("methodName");
    }

    // Custom public methods
    public string function customMethod(required string param) {
        // Implementation
        return result;
    }

    // Private helper methods
    private void function helperMethod() {
        // Implementation
    }
}

Association Patterns

One-to-Many (Parent → Children)

Parent Model (Post):

component extends="Model" {
    function config() {
        hasMany(name="comments", dependent="delete");
        // dependent="delete" removes associated records when parent is deleted
    }
}

Child Model (Comment):

component extends="Model" {
    function config() {
        belongsTo(name="post");
    }
}

Many-to-Many (Through Join Table)

Post Model:

component extends="Model" {
    function config() {
        hasMany(name="postTags");
        hasManyThrough(name="tags", through="postTags");
    }
}

Tag Model:

component extends="Model" {
    function config() {
        hasMany(name="postTags");
        hasManyThrough(name="posts", through="postTags");
    }
}

PostTag Join Model:

component extends="Model" {
    function config() {
        belongsTo(name="post");
        belongsTo(name="tag");
    }
}

Self-Referential Association

User Model (for followers/following):

component extends="Model" {
    function config() {
        hasMany(name="followings", modelName="Follow", foreignKey="followerId");
        hasMany(name="followers", modelName="Follow", foreignKey="followingId");
    }
}

Validation Patterns

Presence Validation

// Single property
validatesPresenceOf(property="email");

// Multiple properties
validatesPresenceOf(property="name,email,password");

// With custom message
validatesPresenceOf(property="email", message="Email is required");

// Conditional validation
validatesPresenceOf(property="password", condition="isNew()");

Uniqueness Validation

// Basic uniqueness
validatesUniquenessOf(property="email");

// Case-insensitive uniqueness
validatesUniquenessOf(property="username", message="Username already taken");

// Scoped uniqueness
validatesUniquenessOf(property="slug", scope="categoryId");

Format Validation (Regular Expressions)

// Email format
validatesFormatOf(
    property="email",
    regEx="^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$",
    message="Please enter a valid email address"
);

// URL format
validatesFormatOf(
    property="website",
    regEx="^https?://[^\s/$.?#].[^\s]*$",
    message="Please enter a valid URL"
);

// Phone number format (US)
validatesFormatOf(
    property="phone",
    regEx="^\d{3}-?\d{3}-?\d{4}$",
    message="Phone must be in format XXX-XXX-XXXX"
);

Length Validation

// Minimum length
validatesLengthOf(property="password", minimum=8);

// Maximum length
validatesLengthOf(property="title", maximum=200);

// Exact length
validatesLengthOf(property="zipCode", is=5);

// Within range
validatesLengthOf(
    property="username",
    minimum=3,
    maximum=20,
    message="Username must be between 3 and 20 characters"
);

Numericality Validation

// Must be numeric
validatesNumericalityOf(property="age");

// Integer only
validatesNumericalityOf(property="quantity", onlyInteger=true);

// Greater than
validatesNumericalityOf(
    property="price",
    greaterThan=0,
    message="Price must be positive"
);

// Less than or equal to
validatesNumericalityOf(property="discount", lessThanOrEqualTo=100);

// Within range
validatesNumericalityOf(
    property="rating",
    greaterThanOrEqualTo=1,
    lessThanOrEqualTo=5
);

Confirmation Validation

// Password confirmation
validatesConfirmationOf(property="password");

// Requires passwordConfirmation property in form
// <input name="user[password]">
// <input name="user[passwordConfirmation]">

Inclusion/Exclusion Validation

// Must be in list
validatesInclusionOf(
    property="status",
    list="draft,published,archived",
    message="Invalid status"
);

// Cannot be in list
validatesExclusionOf(
    property="username",
    list="admin,root,system",
    message="Username is reserved"
);

Custom Validation

component extends="Model" {
    function config() {
        // Register custom validation method
        validate(method="customValidation");
    }

    private void function customValidation() {
        // Add error if validation fails
        if (len(this.email) && !isValid("email", this.email)) {
            addError(property="email", message="Invalid email format");
        }

        // Complex business logic
        if (structKeyExists(this, "startDate") && structKeyExists(this, "endDate")) {
            if (this.endDate < this.startDate) {
                addError(property="endDate", message="End date must be after start date");
            }
        }
    }
}

Callback Patterns

Available Callbacks

// Before callbacks
beforeValidation("methodName")
beforeValidationOnCreate("methodName")
beforeValidationOnUpdate("methodName")

beforeSave("methodName")
beforeCreate("methodName")
beforeUpdate("methodName")
beforeDelete("methodName")

// After callbacks
afterValidation("methodName")
afterValidationOnCreate("methodName")
afterValidationOnUpdate("methodName")

afterSave("methodName")
afterCreate("methodName")
afterUpdate("methodName")
afterDelete("methodName")

// New callbacks
afterNew("methodName")
afterFind("methodName")

Common Callback Use Cases

component extends="Model" {
    function config() {
        // Auto-generate slug before validation
        beforeValidationOnCreate("generateSlug");

        // Set timestamps manually if needed
        beforeCreate("setCreatedTimestamp");
        beforeUpdate("setUpdatedTimestamp");

        // Hash password before saving
        beforeSave("hashPassword");

        // Send welcome email after user creation
        afterCreate("sendWelcomeEmail");
    }

    private void function generateSlug() {
        if (!len(this.slug) && len(this.title)) {
            this.slug = lCase(reReplace(this.title, "[^a-zA-Z0-9]", "-", "ALL"));
            this.slug = reReplace(this.slug, "-+", "-", "ALL");
            this.slug = reReplace(this.slug, "^-|-$", "", "ALL");
        }
    }

    private void function hashPassword() {
        if (structKeyExists(this, "password") && !isHashed(this.password)) {
            this.password = hash(this.password, "SHA-512");
        }
    }

    private void function sendWelcomeEmail() {
        // Email sending logic
        sendMail(
            to=this.email,
            subject="Welcome!",
            body="Thanks for signing up."
        );
    }
}

Custom Method Patterns

Common Custom Methods

component extends="Model" {

    // Full name accessor
    public string function fullName() {
        return this.firstName & " " & this.lastName;
    }

    // Excerpt generator
    public string function excerpt(numeric length=200) {
        if (!structKeyExists(this, "content")) return "";
        var plain = reReplace(this.content, "<[^>]*>", "", "ALL");
        return len(plain) > arguments.length
            ? left(plain, arguments.length) & "..."
            : plain;
    }

    // Status checker
    public boolean function isPublished() {
        return structKeyExists(this, "published") && this.published;
    }

    // Date formatter
    public string function formattedDate(string format="yyyy-mm-dd") {
        return dateFormat(this.createdAt, arguments.format);
    }

    // URL generator
    public string function url() {
        return "/posts/" & this.slug;
    }

    // Safe deletion check
    public boolean function canDelete() {
        // Don't allow deletion if has associated records
        return this.comments().recordCount == 0;
    }
}

Implementation Checklist

When generating a model, ensure:

  • Component extends="Model"
  • config() function defined
  • All association parameters use NAMED style (name=, dependent=)
  • All validation parameters use NAMED style (property=, message=)
  • Consistent parameter style throughout entire config()
  • Association direction matches database relationships
  • Validations match business requirements
  • Custom methods have return type hints (public/private, string/boolean/numeric)
  • Callback methods are private
  • No mixed argument styles anywhere

Testing Generated Models

After generating a model, validate it works:

// Test instantiation
user = model("User").new();
// Should not throw error

// Test associations are defined
posts = user.posts();
// Should return query object

// Test validations work
user.email = "invalid";
result = user.valid();
// Should return false

errors = user.allErrors();
// Should contain email validation error

// Test custom methods
name = user.fullName();
// Should return concatenated name

Common Model Patterns

User Authentication Model

See templates/user-authentication-model.cfc for complete example.

Soft Delete Model

component extends="Model" {
    function config() {
        // Mark as deleted instead of actually deleting
        beforeDelete("softDelete");
    }

    private void function softDelete() {
        this.deletedAt = now();
        this.save();
        abort();  // Prevent actual deletion
    }

    public query function findActive() {
        return this.findAll(where="deletedAt IS NULL");
    }
}

Timestamped Model

component extends="Model" {
    function config() {
        // Wheels automatically handles createdAt and updatedAt
        // if columns exist in database
        // No configuration needed!
    }
}

Related Skills

  • wheels-anti-pattern-detector: Validates generated model code
  • wheels-migration-generator: Creates database schema for model
  • wheels-test-generator: Creates TestBox specs for model
  • wheels-controller-generator: Creates controller for model

Quick Reference

Association Options

  • name - Association name (required when using named params)
  • dependent - What to do with associated records: "delete", "deleteAll", "remove", "removeAll"
  • foreignKey - Custom foreign key column name
  • joinKey - Custom join key for hasManyThrough
  • modelName - Override associated model name
  • through - Join model for hasManyThrough

Validation Options

  • property - Property name(s) to validate (required)
  • message - Custom error message
  • when - When to run validation: "onCreate", "onUpdate"
  • condition - Method name that returns boolean
  • allowBlank - Allow empty string (default false)

Callback Options

  • method - Method name to call (or array of method names)

Generated by: Wheels Model Generator Skill v1.0 Framework: CFWheels 3.0+ Last Updated: 2025-10-20