
Rails Model Generator
- 2 installs
- Updated February 9, 2026
- dchuk/rails_ai_agents
Creates Rails models the TDD way: test first, then migration, then model, including validations and associations.
About
Generates Rails models with a TDD flow of test, migration, then model. A developer uses it when creating models, adding validations, or defining associations and tables.
- Test-first, then migration, then model
- Validations and associations setup
Rails Model Generator by the numbers
- 2 all-time installs (skills.sh)
- Ranked #743 of 911 Databases skills by installs in the Skillselion catalog
- Data as of Jul 29, 2026 (Skillselion catalog sync)
npx skills add https://github.com/dchuk/rails_ai_agents --skill rails-model-generatorAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 2 |
|---|---|
| Last updated | February 9, 2026 |
| Repository | dchuk/rails_ai_agents ↗ |
What it does
Creates Rails models the TDD way: test first, then migration, then model, including validations and associations.
Files
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_emailStep 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
endStep 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: oneStep 4: Run Test (Verify RED)
bin/rails test test/models/model_name_test.rbExpected: Failure because model/table doesn't exist.
Step 5: Generate Migration
bin/rails generate migration CreateModelNames \
name:string \
email:string:uniq \
status:integer \
organization:referencesReview 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
endStep 6: Run Migration
bin/rails db:migrateVerify with:
bin/rails db:migrate:statusStep 7: Create Model File
Location: app/models/[model_name].rb
# frozen_string_literal: true
class ModelName < ApplicationRecord
endStep 8: Run Test (Still RED)
bin/rails test test/models/model_name_test.rbExpected: 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
endStep 10: Run Test (GREEN)
bin/rails test test/models/model_name_test.rbAll tests should pass.
References
- See reference/validations.md for validation patterns
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: trueCounter Cache
belongs_to :organization, counter_cache: true
# Add: organization.posts_count columnSoft Delete
scope :active, -> { where(deleted_at: nil) }
scope :deleted, -> { where.not(deleted_at: nil) }
def soft_delete
update(deleted_at: Time.current)
endNormalizes (Rails 7.1+)
normalizes :email, with: -> { _1.strip.downcase }
normalizes :phone, with: -> { _1.gsub(/\D/, "") }Rails Validation Patterns Reference
Standard Validations
Presence
validates :name, presence: true
validates :email, presence: { message: "is required" }Test:
test "requires name" do
record = Model.new(valid_attributes.except(:name))
assert_not record.valid?
assert record.errors[:name].any?
endUniqueness
validates :email, uniqueness: true
validates :email, uniqueness: { case_sensitive: false }
validates :slug, uniqueness: { scope: :organization_id }
validates :email, uniqueness: { conditions: -> { where(deleted_at: nil) } }Test:
test "requires unique email" do
existing = users(:one)
record = User.new(email: existing.email, password: "password123", account: accounts(:one))
assert_not record.valid?
assert record.errors[:email].any?
end
test "requires unique slug scoped to organization" do
existing = records(:one)
record = Record.new(slug: existing.slug, organization: existing.organization)
assert_not record.valid?
assert record.errors[:slug].any?
endLength
validates :name, length: { maximum: 100 }
validates :bio, length: { minimum: 10, maximum: 500 }
validates :pin, length: { is: 4 }
validates :tags, length: { in: 1..5 }Test:
test "rejects name longer than 100 characters" do
record = Model.new(valid_attributes.merge(name: "a" * 101))
assert_not record.valid?
assert record.errors[:name].any?
end
test "accepts name within 100 characters" do
record = Model.new(valid_attributes.merge(name: "a" * 100))
assert record.valid?
endFormat
validates :email, format: { with: URI::MailTo::EMAIL_REGEXP }
validates :phone, format: { with: /\A\+?[\d\s-]+\z/ }
validates :slug, format: { with: /\A[a-z0-9-]+\z/, message: "only allows lowercase letters, numbers, and hyphens" }Test:
test "accepts valid email format" do
record = Model.new(valid_attributes.merge(email: "test@example.com"))
assert record.valid?
end
test "rejects invalid email format" do
record = Model.new(valid_attributes.merge(email: "invalid-email"))
assert_not record.valid?
assert record.errors[:email].any?
endNumericality
validates :age, numericality: { only_integer: true, greater_than: 0 }
validates :price, numericality: { greater_than_or_equal_to: 0 }
validates :quantity, numericality: { only_integer: true, in: 1..100 }Test:
test "requires positive integer for age" do
record = Model.new(valid_attributes.merge(age: -1))
assert_not record.valid?
assert record.errors[:age].any?
end
test "rejects non-integer age" do
record = Model.new(valid_attributes.merge(age: 1.5))
assert_not record.valid?
endInclusion/Exclusion
validates :status, inclusion: { in: %w[draft published archived] }
validates :role, inclusion: { in: :allowed_roles }
validates :username, exclusion: { in: %w[admin root system] }Test:
test "accepts valid status values" do
%w[draft published archived].each do |status|
record = Model.new(valid_attributes.merge(status: status))
assert record.valid?, "Expected #{status} to be valid"
end
end
test "rejects invalid status values" do
record = Model.new(valid_attributes.merge(status: "invalid"))
assert_not record.valid?
assert record.errors[:status].any?
end
test "rejects reserved usernames" do
%w[admin root system].each do |username|
record = Model.new(valid_attributes.merge(username: username))
assert_not record.valid?, "Expected #{username} to be invalid"
end
endAcceptance
validates :terms, acceptance: true
validates :terms, acceptance: { accept: ['yes', 'true', '1'] }Confirmation
validates :password, confirmation: true
# Requires :password_confirmation attribute in formConditional Validations
With If/Unless
validates :phone, presence: true, if: :requires_phone?
validates :company, presence: true, unless: :individual?
validates :bio, length: { minimum: 50 }, if: -> { featured? }Test:
test "requires phone when requires_phone? is true" do
record = Model.new(valid_attributes.except(:phone))
record.stub(:requires_phone?, true) do
assert_not record.valid?
assert record.errors[:phone].any?
end
end
test "does not require phone when requires_phone? is false" do
record = Model.new(valid_attributes.except(:phone))
record.stub(:requires_phone?, false) do
assert record.valid?
end
endWith On (Context)
validates :password, presence: true, on: :create
validates :reason, presence: true, on: :archiveTest:
test "requires password on create" do
record = Model.new(valid_attributes.except(:password))
assert_not record.valid?
assert record.errors[:password].any?
endCustom Validations
Custom Method
class User < ApplicationRecord
validate :email_domain_allowed
private
def email_domain_allowed
return if email.blank?
domain = email.split('@').last
unless allowed_domains.include?(domain)
errors.add(:email, "domain is not allowed")
end
end
endTest:
test "accepts allowed email domain" do
user = User.new(valid_attributes.merge(email: "test@allowed.com"))
assert user.valid?
end
test "rejects disallowed email domain" do
user = User.new(valid_attributes.merge(email: "test@blocked.com"))
assert_not user.valid?
assert_includes user.errors[:email], "domain is not allowed"
endCustom Validator Class
# app/validators/email_domain_validator.rb
class EmailDomainValidator < ActiveModel::EachValidator
def validate_each(record, attribute, value)
return if value.blank?
domain = value.split('@').last
unless options[:allowed].include?(domain)
record.errors.add(attribute, options[:message] || "domain not allowed")
end
end
end
# Usage in model:
validates :email, email_domain: { allowed: %w[company.com], message: "must be company email" }Association Validations
validates :organization, presence: true
validates_associated :profile # Validates the associated record too
# With nested attributes
accepts_nested_attributes_for :addresses, allow_destroy: true
validates :addresses, length: { minimum: 1, message: "must have at least one address" }Database-Level Constraints
Always pair validations with database constraints:
# Migration
add_column :users, :email, :string, null: false
add_index :users, :email, unique: true
add_check_constraint :users, 'age >= 0', name: 'age_non_negative'
# Model
validates :email, presence: true, uniqueness: true
validates :age, numericality: { greater_than_or_equal_to: 0 }Common Email Regex Patterns
# Simple (recommended for most cases)
URI::MailTo::EMAIL_REGEXP
# More permissive
/\A[^@\s]+@[^@\s]+\z/Performance Tips
1. Order validations by cost: Put cheap validations first 2. Use `on:` to skip validations: Don't validate password on every save 3. Avoid N+1 in custom validations: Cache lookups 4. Use database constraints: They're faster than Rails validations