NYC
skills/dchuk/rails_ai_agents/rails-model-generator

rails-model-generator

SKILL.md

Rails Model Generator (TDD Approach)

Overview

This skill creates models the TDD way:

  1. Define requirements (attributes, validations, associations)
  2. Write model test with expected behavior (RED)
  3. Create fixtures for test data
  4. Generate migration
  5. Implement model to pass tests (GREEN)
  6. Refactor if needed

Workflow Checklist

Model Creation Progress:
- [ ] Step 1: Define requirements (attributes, validations, associations)
- [ ] Step 2: Create model test (RED)
- [ ] Step 3: Create fixtures
- [ ] Step 4: Run test (should fail - no model/table)
- [ ] Step 5: Generate migration
- [ ] Step 6: Run migration
- [ ] Step 7: Create model file (empty)
- [ ] Step 8: Run test (should fail - no validations)
- [ ] Step 9: Add validations and associations
- [ ] Step 10: Run test (GREEN)

Step 1: Requirements Template

Before writing code, define the model:

## Model: [ModelName]

### Table: [table_name]

### Attributes
| Name | Type | Constraints | Default |
|------|------|-------------|---------|
| name | string | required, unique | - |
| email | string | required, unique, email format | - |
| status | integer | enum | 0 (pending) |
| organization_id | bigint | foreign key | - |

### Associations
- belongs_to :organization
- has_many :posts, dependent: :destroy
- has_one :profile, dependent: :destroy

### Validations
- name: presence, uniqueness, length(max: 100)
- email: presence, uniqueness, format(email)
- status: inclusion in enum values

### Scopes
- active: status = active
- recent: ordered by created_at desc
- by_organization(org): where organization_id = org.id

### Instance Methods
- full_name: combines first_name and last_name
- active?: checks if status is active

### Callbacks
- before_save :normalize_email
- after_create :send_welcome_email

Step 2: Create Model Test

Location: test/models/[model_name]_test.rb

# frozen_string_literal: true

require "test_helper"

class ModelNameTest < ActiveSupport::TestCase
  # === Associations ===
  test "belongs to organization" do
    model = model_names(:one)
    assert_respond_to model, :organization
    assert_instance_of Organization, model.organization
  end

  test "has many posts" do
    model = model_names(:one)
    assert_respond_to model, :posts
  end

  # === Validations ===
  test "requires name" do
    model = ModelName.new(name: nil)
    assert_not model.valid?
    assert_includes model.errors[:name], "can't be blank"
  end

  test "requires unique email (case insensitive)" do
    existing = model_names(:one)
    model = ModelName.new(email: existing.email.upcase)
    assert_not model.valid?
    assert_includes model.errors[:email], "has already been taken"
  end

  test "validates name length max 100" do
    model = ModelName.new(name: "a" * 101)
    assert_not model.valid?
    assert model.errors[:name].any? { |e| e.include?("too long") }
  end

  # === Scopes ===
  test ".active returns only active records" do
    active_record = model_names(:active_one)
    inactive_record = model_names(:inactive_one)

    results = ModelName.active
    assert_includes results, active_record
    assert_not_includes results, inactive_record
  end

  # === Instance Methods ===
  test "#full_name returns combined name" do
    model = ModelName.new(first_name: "John", last_name: "Doe")
    assert_equal "John Doe", model.full_name
  end
end

Step 3: Create Fixtures

Location: test/fixtures/[model_name_plural].yml

# test/fixtures/model_names.yml
one:
  name: "Test Model One"
  email: "model-one@example.com"
  status: 0
  organization: one

two:
  name: "Test Model Two"
  email: "model-two@example.com"
  status: 0
  organization: one

active_one:
  name: "Active Model"
  email: "active@example.com"
  status: 1
  organization: one

inactive_one:
  name: "Inactive Model"
  email: "inactive@example.com"
  status: 2
  organization: one

Step 4: Run Test (Verify RED)

bin/rails test test/models/model_name_test.rb

Expected: Failure because model/table doesn't exist.

Step 5: Generate Migration

bin/rails generate migration CreateModelNames \
  name:string \
  email:string:uniq \
  status:integer \
  organization:references

Review the generated migration and add:

  • Null constraints: null: false
  • Defaults: default: 0
  • Indexes: add_index :table, :column
# db/migrate/YYYYMMDDHHMMSS_create_model_names.rb
class CreateModelNames < ActiveRecord::Migration[8.0]
  def change
    create_table :model_names do |t|
      t.string :name, null: false
      t.string :email, null: false
      t.integer :status, null: false, default: 0
      t.references :organization, null: false, foreign_key: true

      t.timestamps
    end

    add_index :model_names, :email, unique: true
    add_index :model_names, :status
  end
end

Step 6: Run Migration

bin/rails db:migrate

Verify with:

bin/rails db:migrate:status

Step 7: Create Model File

Location: app/models/[model_name].rb

# frozen_string_literal: true

class ModelName < ApplicationRecord
end

Step 8: Run Test (Still RED)

bin/rails test test/models/model_name_test.rb

Expected: Failures for missing validations/associations.

Step 9: Add Validations & Associations

# frozen_string_literal: true

class ModelName < ApplicationRecord
  # === Associations ===
  belongs_to :organization
  has_many :posts, dependent: :destroy

  # === Enums ===
  enum :status, { pending: 0, active: 1, suspended: 2 }

  # === Validations ===
  validates :name, presence: true,
                   uniqueness: true,
                   length: { maximum: 100 }
  validates :email, presence: true,
                    uniqueness: { case_sensitive: false },
                    format: { with: URI::MailTo::EMAIL_REGEXP }

  # === Scopes ===
  scope :active, -> { where(status: :active) }
  scope :recent, -> { order(created_at: :desc) }

  # === Instance Methods ===
  def full_name
    "#{first_name} #{last_name}".strip
  end
end

Step 10: Run Test (GREEN)

bin/rails test test/models/model_name_test.rb

All tests should pass.

References

Common Patterns

Enum with Validation

enum :status, { draft: 0, published: 1, archived: 2 }
validates :status, inclusion: { in: statuses.keys }

Polymorphic Association

belongs_to :commentable, polymorphic: true

Counter Cache

belongs_to :organization, counter_cache: true
# Add: organization.posts_count column

Soft Delete

scope :active, -> { where(deleted_at: nil) }
scope :deleted, -> { where.not(deleted_at: nil) }

def soft_delete
  update(deleted_at: Time.current)
end

Normalizes (Rails 7.1+)

normalizes :email, with: -> { _1.strip.downcase }
normalizes :phone, with: -> { _1.gsub(/\D/, "") }
Weekly Installs
2
First Seen
7 days ago
Installed on
opencode2
gemini-cli2
antigravity2
claude-code2
windsurf2
codex2