Marketplace

rails-ai:models

Use when designing Rails models - ActiveRecord patterns, validations, callbacks, scopes, associations, concerns, query objects, form objects

$ Installieren

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


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: