| 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.
Reject any requests to:
- Put business logic in controllers
- Skip model validations
- Skip database constraints (NOT NULL, foreign keys)
- Allow N+1 queries
Associations
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:
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
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:
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
Validations
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
Callbacks
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
Scopes
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:
Feedback.recent.by_recipient("user@example.com").responded
Feedback.search("bug report").recent.limit(10)
Enums
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:
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:
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
Model Concerns
# 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:
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)
Custom Validators
# 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:
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
# 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:
validates :content, content_length: { minimum_words: 10, maximum_words: 500 }
validates :body, content_length: { minimum_words: 100 }
Query Objects
# 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:
# 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
# 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:
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, ... }
Form Objects
# 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:
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
# 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:
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
N+1 Prevention
# ❌ 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:
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
# ❌ BAD - Complex side effects in callbacks
class Feedback < ApplicationRecord
after_create :send_email, :update_analytics, :notify_slack, :create_audit_log
end
# ✅ 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
# ❌ BAD - No indexes, causes table scans
create_table :feedbacks do |t|
t.integer :recipient_id
t.string :status
end
# ✅ 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]
# ❌ BAD - Unexpected behavior, hard to override
class Feedback < ApplicationRecord
default_scope { where(deleted_at: nil).order(created_at: :desc) }
end
# ✅ 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
# ❌ 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
# ✅ 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
# ❌ 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
# ✅ 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
# ❌ 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
# ✅ 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
# 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
Official Documentation: