Marketplace
rails-ai:models
Use when designing Rails models - ActiveRecord patterns, validations, callbacks, scopes, associations, concerns, query objects, form objects
$ Instalar
git clone https://github.com/zerobearing2/rails-ai /tmp/rails-ai && cp -r /tmp/rails-ai/skills/models ~/.claude/skills/rails-ai// tip: Run this command in your terminal to install the skill
SKILL.md
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:
Repository

zerobearing2
Author
zerobearing2/rails-ai/skills/models
15
Stars
1
Forks
Updated5d ago
Added1w ago