Files
gh-zerobearing2-rails-ai/skills/models/SKILL.md
2025-11-30 09:08:30 +08:00

1158 lines
32 KiB
Markdown

---
name: rails-ai:models
description: Use when designing Rails models - ActiveRecord patterns, validations, callbacks, scopes, associations, concerns, query objects, form objects
---
# Models
Master Rails model design including ActiveRecord patterns, validations, callbacks, scopes, associations, concerns, custom validators, query objects, and form objects.
<when-to-use>
- Designing database models and associations
- Writing validations and callbacks
- Implementing business logic in models
- Creating scopes and query methods
- Extracting complex queries to query objects
- Building form objects for multi-model operations
- Organizing shared behavior with concerns
- Creating custom validators
- Preventing N+1 queries
</when-to-use>
<benefits>
- **Convention Over Configuration** - Minimal setup for maximum functionality
- **Single Responsibility** - Each pattern handles one concern
- **Reusability** - Share behavior across models with concerns
- **Testability** - Test models, concerns, validators in isolation
- **Query Optimization** - Built-in N+1 prevention and eager loading
- **Type Safety** - ActiveModel::Attributes provides type casting
- **Database Agnostic** - Works with PostgreSQL, MySQL, SQLite
</benefits>
<team-rules-enforcement>
**This skill enforces:**
-**Rule #7:** Fat models, thin controllers (business logic in models)
-**Rule #12:** Database constraints for data integrity
**Reject any requests to:**
- Put business logic in controllers
- Skip model validations
- Skip database constraints (NOT NULL, foreign keys)
- Allow N+1 queries
</team-rules-enforcement>
<verification-checklist>
Before completing model work:
- ✅ All validations tested
- ✅ All associations tested
- ✅ Database constraints added (NOT NULL, foreign keys, unique indexes)
- ✅ No N+1 queries (verified with bullet or manual check)
- ✅ Business logic in model (not controller)
- ✅ Strong parameters in controller for mass assignment
- ✅ All tests passing
</verification-checklist>
<standards>
- Define associations at the top of the model
- Use validations to enforce data integrity
- Minimize callback usage - prefer service objects
- Use scopes for reusable queries, not class methods
- Always eager load associations to prevent N+1 queries
- Use enums for status/state fields
- Extract concerns when models exceed 200 lines
- Place custom validators in `app/validators/`
- Place query objects in `app/queries/`
- Place form objects in `app/forms/`
- Use transactions for multi-model operations
- Prefer database constraints with validations for critical data
</standards>
## Associations
<pattern name="basic-associations">
<description>Standard ActiveRecord associations for model relationships</description>
<implementation>
```ruby
class Feedback < ApplicationRecord
belongs_to :recipient, class_name: "User", optional: true
belongs_to :category, counter_cache: true
has_one :response, class_name: "FeedbackResponse", dependent: :destroy
has_many :abuse_reports, dependent: :destroy
has_many :taggings, dependent: :destroy
has_many :tags, through: :taggings
# Scoped associations
has_many :recent_reports, -> { where(created_at: 7.days.ago..) },
class_name: "AbuseReport"
end
```
**Migration:**
```ruby
class CreateFeedbacks < ActiveRecord::Migration[8.1]
def change
create_table :feedbacks do |t|
t.references :recipient, foreign_key: { to_table: :users }, null: true
t.references :category, foreign_key: true, null: false
t.text :content, null: false
t.string :status, default: "pending", null: false
t.timestamps
end
add_index :feedbacks, :status
end
end
```
</implementation>
<why>
Associations express relationships between models with minimal code. Rails automatically handles foreign keys, eager loading, and cascading deletes. Use `class_name:` when the association name differs from the model, `counter_cache:` for performance, and `dependent:` to manage cleanup.
</why>
</pattern>
<pattern name="polymorphic-associations">
<description>Flexible associations where a model belongs to multiple types</description>
<implementation>
```ruby
class Comment < ApplicationRecord
belongs_to :commentable, polymorphic: true
belongs_to :author, class_name: "User"
validates :content, presence: true
end
class Feedback < ApplicationRecord
has_many :comments, as: :commentable, dependent: :destroy
end
class Article < ApplicationRecord
has_many :comments, as: :commentable, dependent: :destroy
end
```
**Migration:**
```ruby
class CreateComments < ActiveRecord::Migration[8.1]
def change
create_table :comments do |t|
t.references :commentable, polymorphic: true, null: false
t.references :author, foreign_key: { to_table: :users }, null: false
t.text :content, null: false
t.timestamps
end
add_index :comments, [:commentable_type, :commentable_id]
end
end
```
</implementation>
<why>
Polymorphic associations allow a model to belong to multiple parent types through a single association. Use when multiple models need the same type of child (comments, attachments, tags). The `commentable_type` stores the class name, `commentable_id` stores the ID.
</why>
</pattern>
## Validations
<pattern name="comprehensive-validations">
<description>Built-in Rails validations for data integrity</description>
<implementation>
```ruby
class Feedback < ApplicationRecord
validates :content, presence: true, length: { minimum: 50, maximum: 5000 }
validates :recipient_email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
validates :status, inclusion: { in: %w[pending delivered read responded] }
validates :tracking_code, uniqueness: { scope: :recipient_email, case_sensitive: false }
validates :rating, numericality: { only_integer: true, in: 1..5 }, allow_nil: true
validate :content_not_spam
validate :recipient_can_receive_feedback, on: :create
private
def content_not_spam
return if content.blank?
spam_keywords = %w[viagra cialis lottery]
errors.add(:content, "appears to contain spam") if spam_keywords.any? { |k| content.downcase.include?(k) }
end
def recipient_can_receive_feedback
return if recipient_email.blank?
user = User.find_by(email: recipient_email)
errors.add(:recipient_email, "has disabled feedback") if user&.feedback_disabled?
end
end
```
</implementation>
<why>
Validations enforce data integrity before persisting to the database. Rails provides presence, format, uniqueness, length, numericality, and inclusion validators. Custom `validate` methods handle complex business logic. Use `on: :create` or `on: :update` for lifecycle-specific validations.
</why>
</pattern>
## Callbacks
<pattern name="minimal-callbacks">
<description>Use callbacks sparingly - prefer service objects for complex logic</description>
<implementation>
```ruby
class Feedback < ApplicationRecord
before_validation :normalize_email, :strip_whitespace
before_create :generate_tracking_code
after_create_commit :enqueue_delivery_job
after_update_commit :notify_recipient_of_response, if: :response_added?
private
def normalize_email
self.recipient_email = recipient_email&.downcase&.strip
end
def strip_whitespace
self.content = content&.strip
end
def generate_tracking_code
self.tracking_code = SecureRandom.alphanumeric(10).upcase
end
def enqueue_delivery_job
SendFeedbackJob.perform_later(id)
end
def response_added?
saved_change_to_response? && response.present?
end
def notify_recipient_of_response
FeedbackMailer.notify_of_response(self).deliver_later
end
end
```
</implementation>
<why>
Callbacks hook into the model lifecycle for simple data normalization and side effects. Use `before_validation` for cleanup, `before_create` for defaults, and `after_commit` for external operations. Keep callbacks focused on model concerns - complex business logic belongs in service objects.
</why>
</pattern>
## Scopes
<pattern name="effective-scopes">
<description>Reusable query scopes for common filtering</description>
<implementation>
```ruby
class Feedback < ApplicationRecord
scope :recent, -> { where(created_at: 30.days.ago..) }
scope :unread, -> { where(status: "delivered") }
scope :responded, -> { where.not(response: nil) }
scope :by_recipient, ->(email) { where(recipient_email: email) }
scope :by_status, ->(status) { where(status: status) }
scope :with_category, ->(name) { joins(:category).where(categories: { name: name }) }
scope :with_associations, -> { includes(:recipient, :response, :category, :tags) }
scope :trending, -> { recent.where("views_count > ?", 100).order(views_count: :desc).limit(10) }
def self.search(query)
return none if query.blank?
where("content ILIKE ? OR response ILIKE ?", "%#{sanitize_sql_like(query)}%", "%#{sanitize_sql_like(query)}%")
end
end
```
**Usage:**
```ruby
Feedback.recent.by_recipient("user@example.com").responded
Feedback.search("bug report").recent.limit(10)
```
</implementation>
<why>
Scopes provide chainable query methods that keep controllers clean. Use scopes for simple filters, class methods for complex queries. Scopes are lazy-evaluated and composable. Use `includes()` in scopes to prevent N+1 queries.
</why>
</pattern>
## Enums
<pattern name="enum-usage">
<description>Enums for status and state fields with automatic predicates</description>
<implementation>
```ruby
class Feedback < ApplicationRecord
enum :status, {
pending: "pending",
delivered: "delivered",
read: "read",
responded: "responded"
}, prefix: true, scopes: true
enum :priority, { low: 0, medium: 1, high: 2, urgent: 3 }, prefix: :priority
end
```
**Usage:**
```ruby
feedback.status = "pending"
feedback.status_pending! # Updates and saves
feedback.status_pending? # true/false
Feedback.status_pending # Scope
Feedback.statuses.keys # ["pending", "delivered", ...]
feedback.status_before_last_save # Track changes
```
**Migration:**
```ruby
class CreateFeedbacks < ActiveRecord::Migration[8.1]
def change
create_table :feedbacks do |t|
t.string :status, default: "pending", null: false
t.integer :priority, default: 0, null: false
t.timestamps
end
add_index :feedbacks, :status
end
end
```
</implementation>
<why>
Enums map human-readable states to database values with automatic predicates, scopes, and bang methods. Use string-backed enums for clarity in the database. The `prefix:` option prevents method name conflicts. Scopes make querying easy.
</why>
</pattern>
## Model Concerns
<pattern name="concern-anatomy">
<description>Extract shared behavior into reusable concerns</description>
<implementation>
```ruby
# app/models/concerns/taggable.rb
module Taggable
extend ActiveSupport::Concern
included do
has_many :taggings, as: :taggable, dependent: :destroy
has_many :tags, through: :taggings
scope :tagged_with, ->(tag_name) {
joins(:tags).where(tags: { name: tag_name }).distinct
}
end
def tag_list
tags.pluck(:name).join(", ")
end
def tag_list=(names)
self.tags = names.to_s.split(",").map do |name|
Tag.find_or_create_by(name: name.strip.downcase)
end
end
def add_tag(tag_name)
return if tagged_with?(tag_name)
tags << Tag.find_or_create_by(name: tag_name.strip.downcase)
end
def tagged_with?(tag_name)
tags.exists?(name: tag_name.strip.downcase)
end
class_methods do
def popular_tags(limit = 10)
Tag.joins(:taggings)
.where(taggings: { taggable_type: name })
.group("tags.id")
.select("tags.*, COUNT(taggings.id) as usage_count")
.order("usage_count DESC")
.limit(limit)
end
end
end
```
**Usage:**
```ruby
class Feedback < ApplicationRecord
include Taggable
end
class Article < ApplicationRecord
include Taggable
end
feedback.tag_list = "bug, urgent, ui"
feedback.add_tag("needs-review")
Feedback.tagged_with("bug")
Feedback.popular_tags(5)
```
</implementation>
<why>
Concerns extract shared behavior into reusable modules. Use `included do` for associations, validations, callbacks. Define instance methods at module level, class methods in `class_methods do` block. Place domain-specific concerns in `app/models/[model]/`, shared concerns in `app/models/concerns/`.
</why>
</pattern>
## Custom Validators
<pattern name="email-validator">
<description>Reusable validation logic using ActiveModel::EachValidator</description>
<implementation>
```ruby
# app/validators/email_validator.rb
class EmailValidator < ActiveModel::EachValidator
EMAIL_REGEX = /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i
def validate_each(record, attribute, value)
return if value.blank? && options[:allow_blank]
unless value =~ EMAIL_REGEX
record.errors.add(attribute, options[:message] || "is not a valid email address")
end
end
end
```
**Usage:**
```ruby
class Feedback < ApplicationRecord
validates :email, email: true
validates :backup_email, email: { allow_blank: true }
validates :email, email: { message: "must be a valid company email" }
end
```
</implementation>
<why>
Custom validators encapsulate reusable validation logic. Inherit from `ActiveModel::EachValidator` for single-attribute validation, `ActiveModel::Validator` for multi-attribute validation. Support `:allow_blank` and `:message` options. Place in `app/validators/` for discoverability.
</why>
</pattern>
<pattern name="content-length-validator">
<description>Validate content by word count instead of character count</description>
<implementation>
```ruby
# app/validators/content_length_validator.rb
class ContentLengthValidator < ActiveModel::EachValidator
def validate_each(record, attribute, value)
return if value.blank? && options[:allow_blank]
word_count = value.to_s.split.size
if options[:minimum_words] && word_count < options[:minimum_words]
record.errors.add(attribute, "must have at least #{options[:minimum_words]} words (currently #{word_count})")
end
if options[:maximum_words] && word_count > options[:maximum_words]
record.errors.add(attribute, "must have at most #{options[:maximum_words]} words (currently #{word_count})")
end
end
end
```
**Usage:**
```ruby
validates :content, content_length: { minimum_words: 10, maximum_words: 500 }
validates :body, content_length: { minimum_words: 100 }
```
</implementation>
<why>
Word count validation is more meaningful than character count for content fields. Custom validators make this reusable across models. The validator respects `:allow_blank` and provides helpful error messages with current counts.
</why>
</pattern>
## Query Objects
<pattern name="basic-chainable-query">
<description>Encapsulate complex queries in reusable, testable objects</description>
<implementation>
```ruby
# app/queries/feedback_query.rb
class FeedbackQuery
def initialize(relation = Feedback.all)
@relation = relation
end
def by_recipient(email)
@relation = @relation.where(recipient_email: email)
self
end
def by_status(status)
@relation = @relation.where(status: status)
self
end
def recent(limit = 10)
@relation = @relation.order(created_at: :desc).limit(limit)
self
end
def with_responses
@relation = @relation.where.not(response: nil)
self
end
def created_since(date)
@relation = @relation.where("created_at >= ?", date)
self
end
def results
@relation
end
end
```
**Usage:**
```ruby
# Controller
@feedbacks = FeedbackQuery.new
.by_recipient(params[:email])
.by_status(params[:status])
.recent(20)
.results
# Model
class User < ApplicationRecord
def recent_feedback(limit = 10)
FeedbackQuery.new.by_recipient(email).recent(limit).results
end
end
```
</implementation>
<why>
Query objects encapsulate complex filtering, search, and aggregation logic. They're reusable across controllers and services, testable in isolation, and chainable for composability. Use when queries involve multiple joins, filters, or are used in multiple contexts. Return `self` for chaining, `results` to execute.
</why>
</pattern>
<pattern name="aggregation-query">
<description>Query object for aggregations and statistical calculations</description>
<implementation>
```ruby
# app/queries/feedback_stats_query.rb
class FeedbackStatsQuery
def initialize(relation = Feedback.all)
@relation = relation
end
def by_recipient(email)
@relation = @relation.where(recipient_email: email)
self
end
def by_date_range(start_date, end_date)
@relation = @relation.where(created_at: start_date..end_date)
self
end
def stats
{
total_count: @relation.count,
responded_count: @relation.where.not(response: nil).count,
pending_count: @relation.where(response: nil).count,
by_status: @relation.group(:status).count,
by_category: @relation.group(:category).count
}
end
end
```
**Usage:**
```ruby
stats = FeedbackStatsQuery.new
.by_recipient(current_user.email)
.by_date_range(30.days.ago, Time.current)
.stats
# Returns: { total_count: 42, responded_count: 28, pending_count: 14, ... }
```
</implementation>
<why>
Query objects for aggregations centralize statistical calculations and reporting logic. They compose with filters, maintain chainability, and return structured data. Use for dashboards, reports, and analytics. Keep aggregation logic out of controllers and models.
</why>
</pattern>
## Form Objects
<pattern name="contact-form">
<description>Form object for non-database forms using ActiveModel::API</description>
<implementation>
```ruby
# app/forms/contact_form.rb
class ContactForm
include ActiveModel::API
include ActiveModel::Attributes
attribute :name, :string
attribute :email, :string
attribute :message, :string
attribute :subject, :string
validates :name, presence: true, length: { minimum: 2 }
validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
validates :message, presence: true, length: { minimum: 10, maximum: 1000 }
validates :subject, presence: true
def deliver
return false unless valid?
ContactMailer.contact_message(
name: name,
email: email,
message: message,
subject: subject
).deliver_later
true
end
end
```
**Controller:**
```ruby
class ContactsController < ApplicationController
def create
@contact_form = ContactForm.new(contact_params)
if @contact_form.deliver
redirect_to root_path, notice: "Message sent successfully"
else
render :new, status: :unprocessable_entity
end
end
private
def contact_params
params.expect(contact_form: [:name, :email, :message, :subject])
end
end
```
</implementation>
<why>
Form objects handle non-database forms (contact, search) and complex multi-model operations. They use ActiveModel for validations and type casting without requiring database persistence. Return boolean from action methods, validate before executing logic.
</why>
</pattern>
<pattern name="multi-model-form">
<description>Form object that creates multiple related models in a transaction</description>
<implementation>
```ruby
# app/forms/user_registration_form.rb
class UserRegistrationForm
include ActiveModel::API
include ActiveModel::Attributes
attribute :email, :string
attribute :password, :string
attribute :password_confirmation, :string
attribute :name, :string
attribute :company_name, :string
attribute :role, :string
validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
validates :password, presence: true, length: { minimum: 8 }
validates :password_confirmation, presence: true
validates :name, presence: true
validates :company_name, presence: true
validate :passwords_match
def save
return false unless valid?
ActiveRecord::Base.transaction do
@user = User.create!(email: email, password: password, name: name)
@company = Company.create!(name: company_name, owner: @user)
@membership = Membership.create!(user: @user, company: @company, role: role || "admin")
UserMailer.welcome(@user).deliver_later
true
end
rescue ActiveRecord::RecordInvalid => e
errors.add(:base, e.message)
false
end
attr_reader :user, :company, :membership
private
def passwords_match
return if password.blank?
errors.add(:password_confirmation, "doesn't match password") unless password == password_confirmation
end
end
```
**Controller:**
```ruby
class RegistrationsController < ApplicationController
def create
@registration = UserRegistrationForm.new(registration_params)
if @registration.save
session[:user_id] = @registration.user.id
redirect_to dashboard_path(@registration.company), notice: "Welcome!"
else
render :new, status: :unprocessable_entity
end
end
end
```
</implementation>
<why>
Form objects simplify multi-model operations by wrapping them in a transaction. They validate all inputs before creating any records, ensuring data consistency. Expose created records via attr_reader for controller access. Use for registration, checkout, wizards.
</why>
</pattern>
## N+1 Prevention
<pattern name="n-plus-one-prevention">
<description>Eager load associations to prevent N+1 queries</description>
<implementation>
```ruby
# ❌ BAD - N+1 queries (1 + 20 + 20 + 20 = 61 queries)
@feedbacks = Feedback.limit(20)
@feedbacks.each do |f|
puts f.recipient.name, f.category.name, f.tags.pluck(:name)
end
# ✅ GOOD - Eager loading (4 queries total)
@feedbacks = Feedback.includes(:recipient, :category, :tags).limit(20)
@feedbacks.each do |f|
puts f.recipient.name, f.category.name, f.tags.pluck(:name)
end
```
**Eager Loading Methods:**
```ruby
Feedback.includes(:recipient, :tags) # Separate queries (default)
Feedback.preload(:recipient, :tags) # Forces separate queries
Feedback.eager_load(:recipient, :tags) # LEFT OUTER JOIN
Feedback.includes(recipient: :profile) # Nested associations
```
</implementation>
<why>
N+1 queries occur when loading a collection triggers additional queries for each item's associations. Use `includes()` to eager load associations in advance. Rails loads data in 2-3 queries instead of N+1. Always check for N+1 in views and use includes in scopes.
</why>
</pattern>
<antipatterns>
<antipattern>
<description>Using callbacks for complex business logic</description>
<bad-example>
```ruby
# ❌ BAD - Complex side effects in callbacks
class Feedback < ApplicationRecord
after_create :send_email, :update_analytics, :notify_slack, :create_audit_log
end
```
</bad-example>
<good-example>
```ruby
# ✅ GOOD - Use service object
class Feedback < ApplicationRecord
after_create_commit :enqueue_creation_job
private
def enqueue_creation_job
ProcessFeedbackCreationJob.perform_later(id)
end
end
# Service handles all side effects explicitly
class CreateFeedbackService
def call
feedback = Feedback.create!(@params)
FeedbackMailer.notify_recipient(feedback).deliver_later
Analytics.track("feedback_created", feedback_id: feedback.id)
feedback
end
end
```
</good-example>
<why-bad>
Callbacks with complex side effects make models hard to test, introduce hidden dependencies, and create unpredictable behavior. Service objects make side effects explicit and testable. Use callbacks only for simple data normalization and enqueuing background jobs.
</why-bad>
</antipattern>
<antipattern>
<description>Missing database indexes on foreign keys and query columns</description>
<bad-example>
```ruby
# ❌ BAD - No indexes, causes table scans
create_table :feedbacks do |t|
t.integer :recipient_id
t.string :status
end
```
</bad-example>
<good-example>
```ruby
# ✅ GOOD - Indexes on foreign keys and query columns
create_table :feedbacks do |t|
t.references :recipient, foreign_key: { to_table: :users }, index: true
t.string :status, null: false
end
add_index :feedbacks, :status
add_index :feedbacks, [:status, :created_at]
```
</good-example>
<why-bad>
Missing indexes cause slow queries at scale. Index all foreign keys, status columns, and frequently queried fields. Composite indexes speed up queries filtering on multiple columns. Use `t.references` to create indexed foreign keys automatically.
</why-bad>
</antipattern>
<antipattern>
<description>Using default_scope</description>
<bad-example>
```ruby
# ❌ BAD - Unexpected behavior, hard to override
class Feedback < ApplicationRecord
default_scope { where(deleted_at: nil).order(created_at: :desc) }
end
```
</bad-example>
<good-example>
```ruby
# ✅ GOOD - Explicit scopes
class Feedback < ApplicationRecord
scope :active, -> { where(deleted_at: nil) }
scope :recent_first, -> { order(created_at: :desc) }
end
# Usage
Feedback.active.recent_first
```
</good-example>
<why-bad>
default_scope applies to all queries, causing unexpected results and making it hard to query all records. It affects associations, counts, and exists? checks. Use explicit scopes that developers can choose to apply.
</why-bad>
</antipattern>
<antipattern>
<description>Duplicating validation logic across models</description>
<bad-example>
```ruby
# ❌ BAD - Duplicated email validation
class User < ApplicationRecord
validates :email, format: { with: /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i }
end
class Feedback < ApplicationRecord
validates :recipient_email, format: { with: /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i }
end
```
</bad-example>
<good-example>
```ruby
# ✅ GOOD - Reusable email validator
class EmailValidator < ActiveModel::EachValidator
EMAIL_REGEX = /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i
def validate_each(record, attribute, value)
return if value.blank? && options[:allow_blank]
record.errors.add(attribute, options[:message] || "is not a valid email") unless value =~ EMAIL_REGEX
end
end
class User < ApplicationRecord
validates :email, email: true
end
class Feedback < ApplicationRecord
validates :recipient_email, email: true
end
```
</good-example>
<why-bad>
Duplicated validations are hard to maintain and lead to inconsistencies. Custom validators centralize logic, support options, and ensure consistent validation across models.
</why-bad>
</antipattern>
<antipattern>
<description>Putting complex query logic in controllers</description>
<bad-example>
```ruby
# ❌ BAD - Fat controller
class FeedbacksController < ApplicationController
def index
@feedbacks = Feedback.all
@feedbacks = @feedbacks.where("recipient_email ILIKE ?", "%#{params[:recipient_email]}%") if params[:recipient_email].present?
@feedbacks = @feedbacks.where(status: params[:status]) if params[:status].present?
@feedbacks = @feedbacks.where("content ILIKE ? OR response ILIKE ?", "%#{params[:q]}%", "%#{params[:q]}%") if params[:q].present?
@feedbacks = @feedbacks.order(created_at: :desc).page(params[:page])
end
end
```
</bad-example>
<good-example>
```ruby
# ✅ GOOD - Thin controller with query object
class FeedbacksController < ApplicationController
def index
@feedbacks = FeedbackQuery.new
.filter_by_params(params.slice(:recipient_email, :status))
.search(params[:q])
.order_by(:created_at, :desc)
.paginate(page: params[:page])
.results
end
end
```
</good-example>
<why-bad>
Complex queries in controllers violate Single Responsibility Principle and are hard to test. Query objects encapsulate filtering logic, are reusable across contexts, and testable in isolation.
</why-bad>
</antipattern>
<antipattern>
<description>Fat controllers with complex form logic</description>
<bad-example>
```ruby
# ❌ BAD - All logic in controller
class RegistrationsController < ApplicationController
def create
@user = User.new(user_params)
@company = Company.new(company_params)
ActiveRecord::Base.transaction do
if @user.save
@company.owner = @user
if @company.save
@membership = Membership.create(user: @user, company: @company, role: "admin")
UserMailer.welcome(@user).deliver_later
redirect_to dashboard_path(@company)
end
end
end
end
end
```
</bad-example>
<good-example>
```ruby
# ✅ GOOD - Use form object
class RegistrationsController < ApplicationController
def create
@registration = UserRegistrationForm.new(registration_params)
@registration.save ? redirect_to(dashboard_path(@registration.company)) : render(:new, status: :unprocessable_entity)
end
end
```
</good-example>
<why-bad>
Multi-model operations in controllers are hard to test and reuse. Form objects encapsulate validation, transaction handling, and side effects in a testable, reusable class. Use for registration, checkout, wizards.
</why-bad>
</antipattern>
</antipatterns>
<testing>
Test models, concerns, validators, query objects, and form objects in isolation:
```ruby
# Model tests
class FeedbackTest < ActiveSupport::TestCase
test "validates presence of content" do
feedback = Feedback.new(recipient_email: "user@example.com")
assert_not feedback.valid?
assert_includes feedback.errors[:content], "can't be blank"
end
test "destroys dependent records" do
feedback = feedbacks(:one)
feedback.abuse_reports.create!(reason: "spam", reporter_email: "test@example.com")
assert_difference("AbuseReport.count", -1) { feedback.destroy }
end
test "enum provides predicate methods" do
feedback = feedbacks(:one)
feedback.update(status: "pending")
assert feedback.status_pending?
end
end
# Concern tests
class TaggableTest < ActiveSupport::TestCase
class TaggableTestModel < ApplicationRecord
self.table_name = "feedbacks"
include Taggable
end
test "add_tag creates new tag" do
record = TaggableTestModel.first
record.add_tag("urgent")
assert record.tagged_with?("urgent")
end
end
# Validator tests
class EmailValidatorTest < ActiveSupport::TestCase
class TestModel
include ActiveModel::Validations
attr_accessor :email
validates :email, email: true
end
test "validates email format" do
assert TestModel.new(email: "user@example.com").valid?
assert_not TestModel.new(email: "invalid").valid?
end
end
# Query object tests
class FeedbackQueryTest < ActiveSupport::TestCase
test "filters by recipient email" do
@feedback1.update(recipient_email: "test@example.com")
@feedback2.update(recipient_email: "other@example.com")
results = FeedbackQuery.new.by_recipient("test@example.com").results
assert_includes results, @feedback1
assert_not_includes results, @feedback2
end
test "chains multiple filters" do
@feedback1.update(recipient_email: "test@example.com", status: "pending")
results = FeedbackQuery.new.by_recipient("test@example.com").by_status("pending").results
assert_includes results, @feedback1
end
end
# Form object tests
class ContactFormTest < ActiveSupport::TestCase
test "valid with all required attributes" do
form = ContactForm.new(name: "John", email: "john@example.com", subject: "Question", message: "This is my message")
assert form.valid?
end
test "delivers email when valid" do
form = ContactForm.new(name: "John", email: "john@example.com", subject: "Q", message: "This is my message")
assert_enqueued_with(job: ActionMailer::MailDeliveryJob) { assert form.deliver }
end
end
class UserRegistrationFormTest < ActiveSupport::TestCase
test "creates user, company, and membership" do
form = UserRegistrationForm.new(email: "user@example.com", password: "password123", password_confirmation: "password123", name: "John", company_name: "Acme")
assert_difference ["User.count", "Company.count", "Membership.count"] { assert form.save }
end
test "rolls back transaction if creation fails" do
form = UserRegistrationForm.new(email: "user@example.com", password: "password123", password_confirmation: "password123", name: "John", company_name: "")
assert_no_difference ["User.count", "Company.count"] { assert_not form.save }
end
end
```
</testing>
<related-skills>
- rails-ai:controllers - RESTful controllers for models
- rails-ai:testing - Testing models thoroughly
- rails-ai:security - SQL injection prevention, strong parameters
- rails-ai:jobs - Background job processing for models
</related-skills>
<resources>
**Official Documentation:**
- [Rails Guides - Active Record Basics](https://guides.rubyonrails.org/active_record_basics.html)
- [Rails Guides - Active Record Associations](https://guides.rubyonrails.org/association_basics.html)
- [Rails Guides - Active Record Validations](https://guides.rubyonrails.org/active_record_validations.html)
- [Rails Guides - Active Record Query Interface](https://guides.rubyonrails.org/active_record_querying.html)
- [Rails API - ActiveSupport::Concern](https://api.rubyonrails.org/classes/ActiveSupport/Concern.html)
- [Rails API - ActiveModel::API](https://api.rubyonrails.org/classes/ActiveModel/API.html)
</resources>