Ruby on Rails Complete Guide
Table of Contents
- Introduction
- Installation and Setup
- Rails Architecture (MVC)
- Getting Started
- Models and ActiveRecord
- Views and ERB Templates
- Controllers
- Routing
- Database Migrations
- Associations
- Validations
- Forms and Form Helpers
- Authentication and Authorization
- Testing
- Deployment
- Best Practices
- Advanced Topics
Introduction
Ruby on Rails (often just called "Rails") is a web application framework written in Ruby. It follows the Model-View-Controller (MVC) architectural pattern and emphasizes convention over configuration (CoC) and don't repeat yourself (DRY) principles.
Key Features
- Convention over Configuration: Rails makes assumptions about what you want to do and how you're going to do it
- DRY (Don't Repeat Yourself): Every piece of knowledge must have a single, unambiguous representation
- RESTful Design: Rails encourages RESTful application design
- Active Record Pattern: Object-relational mapping (ORM) framework
- Built-in Testing: Comprehensive testing framework included
Installation and Setup
Prerequisites
- Ruby (version 2.7.0 or higher)
- Node.js (for JavaScript runtime)
- Yarn or npm (for managing JavaScript packages)
- SQLite3, PostgreSQL, or MySQL (database)
Installing Rails
# Install Rails gem
gem install rails
# Verify installation
rails --version
# Create a new Rails application
rails new myapp
# Navigate to the application directory
cd myapp
# Start the Rails server
rails server
# or
rails s
Project Structure
myapp/
├── app/
│ ├── controllers/
│ ├── models/
│ ├── views/
│ ├── helpers/
│ ├── mailers/
│ └── assets/
├── config/
├── db/
├── lib/
├── public/
├── test/
├── vendor/
├── Gemfile
└── Rakefile
Rails Architecture (MVC)
Rails follows the Model-View-Controller pattern:
Model
- Represents data and business logic
- Handles database interactions through ActiveRecord
- Contains validations and associations
View
- Presentation layer
- ERB templates that generate HTML
- Handles user interface
Controller
- Handles user input and coordinates between Model and View
- Processes HTTP requests
- Renders appropriate responses
Getting Started
Creating Your First Controller
# Generate a controller
rails generate controller Welcome index
This creates:
app/controllers/welcome_controller.rb
app/views/welcome/index.html.erb
- Routes entry
Basic Controller Example
# app/controllers/welcome_controller.rb
class WelcomeController < ApplicationController
def index
@message = "Hello, Rails!"
end
end
Basic View Example
<!-- app/views/welcome/index.html.erb -->
<h1>Welcome to Rails</h1>
<p><%= @message %></p>
Setting Root Route
# config/routes.rb
Rails.application.routes.draw do
root 'welcome#index'
end
Models and ActiveRecord
Generating a Model
# Generate a model with attributes
rails generate model Article title:string body:text published:boolean
# Run the migration
rails db:migrate
Basic Model Example
# app/models/article.rb
class Article < ApplicationRecord
validates :title, presence: true, length: { minimum: 5 }
validates :body, presence: true
scope :published, -> { where(published: true) }
def summary
body.truncate(100)
end
end
ActiveRecord Basics
# Creating records
article = Article.new(title: "My Title", body: "Content")
article.save
# or
article = Article.create(title: "My Title", body: "Content")
# Finding records
Article.all
Article.find(1)
Article.find_by(title: "My Title")
Article.where(published: true)
# Updating records
article.update(title: "New Title")
# Deleting records
article.destroy
Views and ERB Templates
ERB Syntax
<!-- Output Ruby code -->
<%= ruby_code %>
<!-- Execute Ruby code without output -->
<% ruby_code %>
<!-- Comments -->
<%# This is a comment %>
Layout Example
<!-- app/views/layouts/application.html.erb -->
<!DOCTYPE html>
<html>
<head>
<title>My App</title>
<%= csrf_meta_tags %>
<%= csp_meta_tag %>
<%= stylesheet_link_tag 'application', 'data-turbolinks-track': 'reload' %>
<%= javascript_pack_tag 'application', 'data-turbolinks-track': 'reload' %>
</head>
<body>
<header>
<nav>
<%= link_to "Home", root_path %>
<%= link_to "Articles", articles_path %>
</nav>
</header>
<main>
<%= yield %>
</main>
</body>
</html>
Partials
<!-- app/views/shared/_header.html.erb -->
<header>
<h1>My Application</h1>
</header>
<!-- Using the partial -->
<%= render 'shared/header' %>
<!-- Partial with local variables -->
<%= render 'article', article: @article %>
Controllers
RESTful Controller Example
# app/controllers/articles_controller.rb
class ArticlesController < ApplicationController
before_action :set_article, only: [:show, :edit, :update, :destroy]
def index
@articles = Article.all
end
def show
end
def new
@article = Article.new
end
def create
@article = Article.new(article_params)
if @article.save
redirect_to @article, notice: 'Article was successfully created.'
else
render :new
end
end
def edit
end
def update
if @article.update(article_params)
redirect_to @article, notice: 'Article was successfully updated.'
else
render :edit
end
end
def destroy
@article.destroy
redirect_to articles_url, notice: 'Article was successfully deleted.'
end
private
def set_article
@article = Article.find(params[:id])
end
def article_params
params.require(:article).permit(:title, :body, :published)
end
end
Controller Filters
class ApplicationController < ActionController::Base
before_action :authenticate_user!
before_action :set_current_user
after_action :log_action
around_action :catch_exceptions
private
def set_current_user
@current_user = User.find(session[:user_id]) if session[:user_id]
end
def log_action
Rails.logger.info "Action completed: #{action_name}"
end
def catch_exceptions
begin
yield
rescue => e
Rails.logger.error "Error: #{e.message}"
redirect_to root_path, alert: "Something went wrong"
end
end
end
Routing
Basic Routes
# config/routes.rb
Rails.application.routes.draw do
root 'welcome#index'
# RESTful routes
resources :articles
# Custom routes
get 'about', to: 'pages#about'
post 'contact', to: 'pages#contact'
# Nested routes
resources :articles do
resources :comments
end
# Namespace
namespace :admin do
resources :users
end
# Route constraints
get 'articles/:id', to: 'articles#show', constraints: { id: /\d+/ }
end
Route Helpers
# In controllers and views
articles_path # /articles
article_path(@article) # /articles/1
new_article_path # /articles/new
edit_article_path(@article) # /articles/1/edit
# With parameters
article_path(@article, format: :json)
articles_path(page: 2)
Database Migrations
Creating Migrations
# Generate migration
rails generate migration CreateArticles title:string body:text published:boolean
# Add column
rails generate migration AddAuthorToArticles author:string
# Remove column
rails generate migration RemoveAuthorFromArticles author:string
# Create join table
rails generate migration CreateJoinTableArticlesTags articles tags
Migration Example
# db/migrate/20231201000000_create_articles.rb
class CreateArticles < ActiveRecord::Migration[7.0]
def change
create_table :articles do |t|
t.string :title, null: false
t.text :body
t.boolean :published, default: false
t.references :user, null: false, foreign_key: true
t.timestamps
end
add_index :articles, :published
add_index :articles, [:user_id, :created_at]
end
end
Running Migrations
# Run pending migrations
rails db:migrate
# Rollback last migration
rails db:rollback
# Rollback specific number of migrations
rails db:rollback STEP=3
# Reset database
rails db:reset
# Drop and recreate database
rails db:drop db:create db:migrate
Associations
Types of Associations
# One-to-many
class User < ApplicationRecord
has_many :articles, dependent: :destroy
end
class Article < ApplicationRecord
belongs_to :user
end
# Many-to-many
class Article < ApplicationRecord
has_many :article_tags
has_many :tags, through: :article_tags
end
class Tag < ApplicationRecord
has_many :article_tags
has_many :articles, through: :article_tags
end
class ArticleTag < ApplicationRecord
belongs_to :article
belongs_to :tag
end
# One-to-one
class User < ApplicationRecord
has_one :profile, dependent: :destroy
end
class Profile < ApplicationRecord
belongs_to :user
end
Association Options
class User < ApplicationRecord
has_many :articles,
dependent: :destroy,
foreign_key: 'author_id',
class_name: 'Article',
inverse_of: :author
has_many :published_articles,
-> { where(published: true) },
class_name: 'Article'
end
Validations
Common Validations
class Article < ApplicationRecord
validates :title, presence: true,
length: { minimum: 5, maximum: 100 },
uniqueness: true
validates :body, presence: true,
length: { minimum: 10 }
validates :email, format: { with: URI::MailTo::EMAIL_REGEXP }
validates :age, numericality: { greater_than: 0, less_than: 150 }
validates :terms_of_service, acceptance: true
validates :password, confirmation: true
validate :custom_validation
private
def custom_validation
if title.present? && title.include?('spam')
errors.add(:title, 'cannot contain spam')
end
end
end
Conditional Validations
class Article < ApplicationRecord
validates :body, presence: true, if: :published?
validates :summary, presence: true, unless: :draft?
validates :title, presence: true, if: -> { published && created_at > 1.week.ago }
end
Forms and Form Helpers
Form Helpers
<!-- app/views/articles/_form.html.erb -->
<%= form_with model: @article, local: true do |form| %>
<% if @article.errors.any? %>
<div id="error_explanation">
<h2><%= pluralize(@article.errors.count, "error") %> prohibited this article from being saved:</h2>
<ul>
<% @article.errors.full_messages.each do |message| %>
<li><%= message %></li>
<% end %>
</ul>
</div>
<% end %>
<div class="field">
<%= form.label :title %>
<%= form.text_field :title %>
</div>
<div class="field">
<%= form.label :body %>
<%= form.text_area :body %>
</div>
<div class="field">
<%= form.check_box :published %>
<%= form.label :published %>
</div>
<div class="actions">
<%= form.submit %>
</div>
<% end %>
Form Types
<!-- Text inputs -->
<%= form.text_field :title %>
<%= form.text_area :body %>
<%= form.password_field :password %>
<%= form.email_field :email %>
<%= form.number_field :age %>
<!-- Select inputs -->
<%= form.select :category, ['Tech', 'Sports', 'News'] %>
<%= form.collection_select :user_id, User.all, :id, :name %>
<!-- Checkboxes and radio buttons -->
<%= form.check_box :published %>
<%= form.radio_button :status, 'active' %>
<!-- File uploads -->
<%= form.file_field :image %>
<!-- Hidden fields -->
<%= form.hidden_field :user_id %>
Authentication and Authorization
Using Devise (Popular Authentication Gem)
# Gemfile
gem 'devise'
# Install Devise
rails generate devise:install
rails generate devise User
rails db:migrate
Basic Authentication
# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
before_action :authenticate_user!
before_action :configure_permitted_parameters, if: :devise_controller?
protected
def configure_permitted_parameters
devise_parameter_sanitizer.permit(:sign_up, keys: [:first_name, :last_name])
devise_parameter_sanitizer.permit(:account_update, keys: [:first_name, :last_name])
end
end
Authorization with CanCanCan
# Gemfile
gem 'cancancan'
# app/models/ability.rb
class Ability
include CanCan::Ability
def initialize(user)
user ||= User.new # guest user (not logged in)
if user.admin?
can :manage, :all
else
can :read, Article, published: true
can :manage, Article, user_id: user.id
end
end
end
# In controllers
class ArticlesController < ApplicationController
load_and_authorize_resource
def index
# @articles is automatically loaded and authorized
end
end
Testing
Test Types in Rails
Unit Tests (Models)
# test/models/article_test.rb
require 'test_helper'
class ArticleTest < ActiveSupport::TestCase
test "should not save article without title" do
article = Article.new
assert_not article.save
end
test "should save article with valid attributes" do
article = Article.new(title: "Test Title", body: "Test body")
assert article.save
end
test "title should be at least 5 characters" do
article = Article.new(title: "Hi", body: "Test body")
assert_not article.valid?
assert_includes article.errors[:title], "is too short (minimum is 5 characters)"
end
end
Integration Tests (Controllers)
# test/controllers/articles_controller_test.rb
require 'test_helper'
class ArticlesControllerTest < ActionDispatch::IntegrationTest
setup do
@article = articles(:one)
end
test "should get index" do
get articles_url
assert_response :success
end
test "should create article" do
assert_difference('Article.count') do
post articles_url, params: { article: { title: "New Article", body: "Article body" } }
end
assert_redirected_to article_url(Article.last)
end
end
System Tests (Full Stack)
# test/system/articles_test.rb
require "application_system_test_case"
class ArticlesTest < ApplicationSystemTestCase
test "creating an article" do
visit articles_path
click_on "New Article"
fill_in "Title", with: "Test Article"
fill_in "Body", with: "This is a test article"
click_on "Create Article"
assert_text "Article was successfully created"
end
end
Test Fixtures
# test/fixtures/articles.yml
one:
title: "First Article"
body: "This is the first article"
published: true
two:
title: "Second Article"
body: "This is the second article"
published: false
Running Tests
# Run all tests
rails test
# Run specific test file
rails test test/models/article_test.rb
# Run specific test
rails test test/models/article_test.rb:test_should_not_save_article_without_title
# Run tests with coverage
rails test --coverage
Deployment
Preparing for Production
# config/environments/production.rb
Rails.application.configure do
config.cache_classes = true
config.eager_load = true
config.consider_all_requests_local = false
config.action_controller.perform_caching = true
config.public_file_server.enabled = ENV['RAILS_SERVE_STATIC_FILES'].present?
config.assets.compile = false
config.active_storage.variant_processor = :mini_magick
config.log_level = :info
config.force_ssl = true
end
Environment Variables
# config/database.yml
production:
<<: *default
database: <%= ENV['DATABASE_URL'] %>
# In your app
class ApplicationController < ActionController::Base
before_action :set_api_key
private
def set_api_key
@api_key = ENV['API_KEY']
end
end
Deployment to Heroku
# Install Heroku CLI and login
heroku login
# Create Heroku app
heroku create myapp
# Add PostgreSQL addon
heroku addons:create heroku-postgresql:hobby-dev
# Deploy
git push heroku main
# Run migrations
heroku run rails db:migrate
# Open app
heroku open
Deployment with Docker
# Dockerfile
FROM ruby:3.0
WORKDIR /app
COPY Gemfile Gemfile.lock ./
RUN bundle install
COPY . .
EXPOSE 3000
CMD ["rails", "server", "-b", "0.0.0.0"]
Best Practices
Code Organization
- Fat Models, Skinny Controllers: Keep business logic in models
- Use Service Objects: For complex business logic
- Follow RESTful conventions: Use standard CRUD operations
- Use Concerns: For shared functionality
# app/models/concerns/publishable.rb
module Publishable
extend ActiveSupport::Concern
included do
scope :published, -> { where(published: true) }
scope :draft, -> { where(published: false) }
end
def publish!
update!(published: true, published_at: Time.current)
end
end
# app/models/article.rb
class Article < ApplicationRecord
include Publishable
end
Security Best Practices
- Use Strong Parameters: Always filter parameters
- Protect from CSRF: Use
protect_from_forgery
- Validate User Input: Use model validations
- Use HTTPS: Force SSL in production
- Keep Dependencies Updated: Regular
bundle update
Performance Optimization
# Use includes to avoid N+1 queries
@articles = Article.includes(:user, :comments).all
# Use select to limit columns
@articles = Article.select(:id, :title, :created_at).all
# Use counter caches
class Article < ApplicationRecord
belongs_to :user, counter_cache: true
end
# Add database indexes
add_index :articles, :user_id
add_index :articles, [:published, :created_at]
Advanced Topics
Background Jobs with Active Job
# app/jobs/article_notification_job.rb
class ArticleNotificationJob < ApplicationJob
queue_as :default
def perform(article)
ArticleMailer.notification(article).deliver_now
end
end
# Usage
ArticleNotificationJob.perform_later(@article)
Caching
# Fragment caching
<% cache @article do %>
<%= render @article %>
<% end %>
# Low-level caching
Rails.cache.fetch("expensive_operation", expires_in: 1.hour) do
expensive_operation
end
# Russian Doll caching
<% cache [@article, @article.comments.maximum(:updated_at)] do %>
<%= render @article %>
<%= render @article.comments %>
<% end %>
API Development
# app/controllers/api/v1/articles_controller.rb
class Api::V1::ArticlesController < ApplicationController
respond_to :json
def index
@articles = Article.all
render json: @articles
end
def show
@article = Article.find(params[:id])
render json: @article, include: [:user, :comments]
end
def create
@article = Article.new(article_params)
if @article.save
render json: @article, status: :created
else
render json: { errors: @article.errors }, status: :unprocessable_entity
end
end
end
WebSockets with Action Cable
# app/channels/comments_channel.rb
class CommentsChannel < ApplicationCable::Channel
def subscribed
stream_from "comments_#{params[:article_id]}"
end
def unsubscribed
# Cleanup
end
end
# Broadcasting
ActionCable.server.broadcast("comments_#{@article.id}", {
comment: render_comment(@comment)
})