rails-ai:models

Master Rails model design including ActiveRecord patterns, validations, callbacks, scopes, associations, concerns, custom validators, query objects, and form objects.

Safety Notice

This listing is imported from skills.sh public index metadata. Review upstream SKILL.md and repository scripts before running.

Copy this and send it to your AI assistant to learn

Install skill "rails-ai:models" with this command: npx skills add zerobearing2/rails-ai/zerobearing2-rails-ai-rails-ai-models

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] &#x26;&#x26; word_count &#x3C; options[:minimum_words]
  record.errors.add(attribute, "must have at least #{options[:minimum_words]} words (currently #{word_count})")
end

if options[:maximum_words] &#x26;&#x26; 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:

  • Rails Guides - Active Record Basics

  • Rails Guides - Active Record Associations

  • Rails Guides - Active Record Validations

  • Rails Guides - Active Record Query Interface

  • Rails API - ActiveSupport::Concern

  • Rails API - ActiveModel::API

Source Transparency

This detail page is rendered from real SKILL.md content. Trust labels are metadata-based hints, not a safety guarantee.

Related Skills

Related by shared tags or category signals.

General

rails-ai:hotwire

No summary provided by upstream source.

Repository SourceNeeds Review
General

rails-ai:views

No summary provided by upstream source.

Repository SourceNeeds Review
General

rails-ai:mailers

No summary provided by upstream source.

Repository SourceNeeds Review
General

rails-ai:debugging-rails

No summary provided by upstream source.

Repository SourceNeeds Review