skills/zerobearing2/rails-ai/rails-ai:models

rails-ai:models

SKILL.md

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:

Weekly Installs
3
GitHub Stars
29
First Seen
Feb 22, 2026
Installed on
opencode3
gemini-cli3
github-copilot3
codex3
kimi-cli3
amp3