MVC, ActiveRecord, routing, controllers, views, ActiveJob, ActionCable, and testing with RSpec.
# ── Rails Routing ──
Rails.application.routes.draw do
# Root route
root 'articles#index'
# Resource routing (RESTful)
resources :articles do
member do
post :publish # POST /articles/:id/publish
delete :archive # DELETE /articles/:id/archive
end
collection do
get :drafts # GET /articles/drafts
get :search # GET /articles/search
end
resources :comments, only: [:create, :destroy]
resources :likes, shallow: true
end
# Nested resources
resources :users do
resources :posts, shallow: true
end
# Singular resource
resource :profile, only: [:show, :edit, :update]
# Custom routes
get 'about', to: 'pages#about', as: :about
get 'login', to: 'sessions#new', as: :login
post 'login', to: 'sessions#create'
delete 'logout', to: 'sessions#destroy', as: :logout
# Namespace
namespace :admin do
resources :users, :articles, :settings
end
# Scope with module
scope '/api', module: :api do
resources :articles, defaults: { format: :json }
end
end# ── Controller (MVC) ──
class ArticlesController < ApplicationController
before_action :set_article, only: [:show, :edit, :update, :destroy]
before_action :authenticate_user!, except: [:index, :show]
# GET /articles
def index
@articles = Article.includes(:author, :tags)
.published
.order(created_at: :desc)
.page(params[:page])
.per(10)
end
# GET /articles/:id
def show
@comment = @article.comments.build
increment_view_count
end
# GET /articles/new
def new
@article = current_user.articles.build
end
# POST /articles
def create
@article = current_user.articles.build(article_params)
if @article.save
redirect_to @article, notice: 'Article was successfully created.'
else
render :new, status: :unprocessable_entity
end
end
# PATCH/PUT /articles/:id
def update
if @article.update(article_params)
redirect_to @article, notice: 'Article was successfully updated.'
else
render :edit, status: :unprocessable_entity
end
end
# DELETE /articles/:id
def destroy
@article.destroy
redirect_to articles_url, notice: 'Article was successfully deleted.'
end
private
def set_article
@article = Article.friendly.find(params[:id])
end
def article_params
params.require(:article).permit(:title, :body, :status, :category_id, tag_ids: [])
end
def increment_view_count
@article.increment!(:view_count)
end
end| HTTP Verb | Path | Controller#Action |
|---|---|---|
| GET | /articles | articles#index |
| GET | /articles/new | articles#new |
| POST | /articles | articles#create |
| GET | /articles/:id | articles#show |
| GET | /articles/:id/edit | articles#edit |
| PATCH/PUT | /articles/:id | articles#update |
| DELETE | /articles/:id | articles#destroy |
| Helper | Returns | Path Helper |
|---|---|---|
| articles_path | /articles | resources path |
| new_article_path | /articles/new | new resource form |
| article_path(@article) | /articles/1 | singular resource |
| edit_article_path(@a) | /articles/1/edit | edit form |
| articles_url | https://.../articles | absolute URL |
# ── Model Definition ──
class Article < ApplicationRecord
belongs_to :author, class_name: 'User', inverse_of: :articles
belongs_to :category, optional: true
has_many :comments, dependent: :destroy
has_many :likes, as: :likeable, dependent: :destroy
has_and_belongs_to_many :tags
has_one_attached :cover_image
has_many_attached :gallery_images
enum status: { draft: 0, published: 1, archived: 2 }
# Validations
validates :title, presence: true, length: { minimum: 5, maximum: 200 }
validates :body, presence: true, length: { minimum: 20 }
validates :status, inclusion: { in: statuses.keys }
validates :title, uniqueness: { scope: :author_id, case_sensitive: false }
validates :cover_image, content_type: ['image/png', 'image/jpeg'],
size: { less_than: 5.megabytes }
# Scopes
scope :published, -> { where(status: :published) }
scope :recent, -> { order(created_at: :desc) }
scope :by_category, ->(slug) { joins(:category).where(categories: { slug: slug }) }
scope :search, ->(query) {
where('title ILIKE ? OR body ILIKE ?', "%query%", "%query%")
}
# Callbacks
before_validation :generate_slug, on: :create
after_create :send_notification
after_update :clear_cache, if: :saved_change_to_status?
# Instance methods
def reading_time
(body.split.size / 200.0).ceil
end
def truncated_body(length = 150)
body.truncate(length)
end
private
def generate_slug
self.slug = title.parameterize if slug.blank?
end
def send_notification
NotificationMailer.new_article(self).deliver_later
end
def clear_cache
Rails.cache.delete("article-#{id}")
end
end# ── ActiveRecord Queries ──
# Basic queries
Article.all
Article.first
Article.last
Article.find(1)
Article.find_by(slug: 'hello-world')
Article.find_or_create_by(title: 'Default', body: '...')
Article.find_or_initialize_by(title: 'Default')
# Where clauses
Article.where(status: :published)
Article.where('view_count > ?', 100)
Article.where(created_at: 1.week.ago..Time.current)
Article.where.not(status: :draft)
Article.where(status: [:published, :archived])
# Ordering, limiting, offset
Article.order(:created_at)
Article.order(created_at: :desc, title: :asc)
Article.limit(10).offset(20)
# Joins & includes
Article.joins(:comments).group('articles.id').having('COUNT(comments.id) > 5')
Article.includes(:author, :comments).first # Eager loading
Article.eager_load(:author) # Always uses LEFT OUTER JOIN
Article.preload(:tags) # Separate queries
# Pluck & select
Article.pluck(:title, :status)
Article.select(:id, :title).map(&:title)
# Calculations
Article.count
Article.average(:view_count)
Article.minimum(:created_at)
Article.maximum(:view_count)
Article.sum(:view_count)
Article.group(:status).count
Article.group(:category_id).average(:view_count)
# Updating & deleting
article.update(title: 'New Title')
Article.where(status: :draft).update_all(status: :published)
Article.where('created_at < ?', 1.year.ago).delete_all| Macro | Relationship | Options |
|---|---|---|
| belongs_to | Many-to-one | optional, inverse_of |
| has_one | One-to-one | dependent, as |
| has_many | One-to-many | dependent, foreign_key |
| has_and_belongs_to_many | Many-to-many | join_table |
| has_many :through | Many via join model | source, source_type |
| Validator | Options | Purpose |
|---|---|---|
| validates :field, presence: true | - | Cannot be blank |
| uniqueness: true | scope, case_sensitive | Unique value |
| length: { in: 6..20 } | min, max, is | String length |
| numericality: true | only_integer, greater_than | Number check |
| inclusion: { in: [...] } | - | Value in set |
| exclusion: { in: [...] } | - | Value not in set |
| format: { with: /regex/ } | - | Regex match |
| confirmation: true | - | Must match _confirmation field |
includes(:association) when accessing related records in a loop. Use Bullet gem in development to detect N+1 queries automatically.# ── Migration Commands ──
rails generate model Article title:string body:text status:integer user:references
rails generate migration AddSlugToArticles slug:string:uniq
rails generate migration CreateJoinTableUserRole user role
rails db:create # Create database
rails db:migrate # Run pending migrations
rails db:rollback # Undo last migration
rails db:rollback STEP=3 # Undo last 3 migrations
rails db:migrate:status # Show migration status
rails db:seed # Run db/seeds.rb
rails db:drop # Drop the database
rails db:reset # Drop + create + migrate + seed
rails db:prepare # Create, migrate, and seed if needed# ── Migration Examples ──
class CreateArticles < ActiveRecord::Migration[7.1]
def change
create_table :articles do |t|
t.string :title, null: false, limit: 200
t.text :body, null: false
t.string :slug
t.integer :status, null: false, default: 0
t.references :author, foreign_key: { to_table: :users }, null: false
t.references :category, foreign_key: true
t.integer :view_count, null: false, default: 0
t.timestamps
end
add_index :articles, :slug, unique: true
add_index :articles, :status
add_index :articles, [:author_id, :status]
add_index :articles, :created_at
end
end
class AddPublishedAtToArticles < ActiveRecord::Migration[7.1]
def change
add_column :articles, :published_at, :datetime
add_column :articles, :excerpt, :text
reversible do |dir|
dir.up do
Article.where(status: :published).find_each do |article|
article.update_column(:published_at, article.created_at)
end
end
end
end
end| Type | Description | Example |
|---|---|---|
| string | Short text | t.string :name |
| text | Long text | t.text :body |
| integer | Integer | t.integer :count |
| bigint | Big integer | t.bigint :views |
| float | Float | t.float :price |
| decimal | Fixed precision | t.decimal :price, precision: 10, scale: 2 |
| boolean | True/False | t.boolean :active |
| datetime | Date + Time | t.datetime :published_at |
| date | Date only | t.date :birthday |
| json | JSON data | t.json :metadata |
| jsonb | JSON (PostgreSQL) | t.jsonb :settings |
| references | Foreign key | t.references :user |
| Method | Purpose |
|---|---|
| add_column | Add a column to existing table |
| remove_column | Remove a column |
| change_column | Change column type/options |
| rename_column | Rename a column |
| add_index | Add an index |
| remove_index | Remove an index |
| add_reference | Add foreign key + index |
| add_check_constraint | Add check constraint |
| add_foreign_key | Add foreign key constraint |
| change_table (block) | Multiple column changes |
<!-- ── View Template ── -->
<% content_for :title, 'Articles' %>
<div class="container">
<% if notice %>
<div class="alert alert-info"><%= notice %></div>
<% end %>
<div class="header">
<h1>Articles</h1>
<%= link_to 'New Article', new_article_path, class: 'btn btn-primary' %>
</div>
<!-- Pagination -->
<%= paginate @articles %>
<% @articles.each do |article| %>
<article class="card">
<h2><%= link_to article.title, article %></h2>
<div class="meta">
<span>By <%= article.author.name %></span>
<span><%= time_ago_in_words(article.created_at) %> ago</span>
<span><%= pluralize(article.view_count, 'view') %></span>
<span class="badge"><%= article.status %></span>
</div>
<p><%= truncate(article.body, length: 200) %></p>
<div class="tags">
<% article.tags.each do |tag| %>
<span class="tag"><%= tag.name %></span>
<% end %>
</div>
</article>
<% end %>
<%= paginate @articles %>
</div><!-- ── Partial with locals ── -->
<%# Render: render 'shared/article_card', article: @article %>
<div class="article-card <%= article.status %>">
<% if article.cover_image.attached? %>
<%= image_tag article.cover_image, class: 'cover' %>
<% end %>
<div class="content">
<h3><%= link_to article.title, article_path(article) %></h3>
<p class="meta">
by <%= article.author.name %> ·
<%= l(article.created_at, format: :short) %>
</p>
<p><%= article.truncated_body %></p>
<div class="actions">
<%= link_to 'Read more', article, class: 'btn btn-sm' %>
<% if policy(article).edit? %>
<%= link_to 'Edit', edit_article_path(article), class: 'btn btn-sm btn-outline' %>
<% end %>
</div>
</div>
</div>| Helper | Purpose |
|---|---|
| link_to | Generate anchor tag |
| button_to | Generate form button |
| image_tag | Generate img tag |
| form_with | Generate form (model-backed) |
| render partial | Render a partial template |
| content_tag | Generate HTML tag programmatically |
| time_ago_in_words | Human-readable time distance |
| number_to_currency | Format as currency |
| truncate | Truncate text |
| pluralize | Pluralize with count |
| File | Purpose |
|---|---|
| app/views/layouts/application.html.erb | Main layout |
| app/views/articles/index.html.erb | List view |
| app/views/articles/show.html.erb | Detail view |
| app/views/articles/_form.html.erb | Form partial |
| app/views/shared/_navbar.html.erb | Shared navigation |
| app/views/shared/_footer.html.erb | Shared footer |
| app/views/shared/_error_messages.html.erb | Form errors |
| app/helpers/application_helper.rb | Global view helpers |
# ── Essential Gems ──
source 'https://rubygems.org'
ruby '3.3.0'
gem 'rails', '~> 7.1.0'
gem 'pg', '~> 1.5' # PostgreSQL adapter
gem 'puma', '~> 6.0' # App server
gem 'devise', '~> 4.9' # Authentication
gem 'pundit', '~> 2.3' # Authorization
gem 'friendly_id', '~> 5.5' # Slugs
gem 'kaminari', '~> 1.2' # Pagination
gem 'active_storage_validations' # File validations
gem 'mini_magick', '~> 4.12' # Image processing
gem 'image_processing', '~> 1.2' # Variant support
gem 'sidekiq', '~> 7.0' # Background jobs
gem 'redis', '~> 5.0' # Redis client
gem 'bootsnap', require: false # Faster boot
gem 'turbo-rails' # Hotwire Turbo
gem 'stimulus-rails' # Hotwire Stimulus
gem 'tailwindcss-rails' # Tailwind CSS
group :development, :test do
gem 'rspec-rails', '~> 6.0'
gem 'factory_bot_rails'
gem 'faker'
gem 'pry-rails'
gem 'rubocop-rails', require: false
gem 'bullet' # N+1 detection
end
group :development do
gem 'web-console'
gem 'spring'
end
group :test do
gem 'shoulda-matchers', '~> 5.0'
gem 'capybara', '~> 3.39'
gem 'selenium-webdriver'
gem 'database_cleaner-active_record'
end# ── RSpec Tests ──
require 'rails_helper'
RSpec.describe Article, type: :model do
describe 'Validations' do
it { should validate_presence_of(:title) }
it { should validate_length_of(:title).is_at_least(5) }
it { should validate_presence_of(:body) }
it { should validate_length_of(:body).is_at_least(20) }
it { should validate_inclusion_of(:status).in_array(%w[draft published archived]) }
it { should belong_to(:author).class_name('User') }
it { should have_many(:comments).dependent(:destroy) }
it { should have_and_belong_to_many(:tags) }
end
describe 'Scopes' do
let!(:published) { create_list(:article, 3, status: :published) }
let!(:draft) { create(:article, status: :draft) }
it 'returns only published articles' do
expect(Article.published).to eq(published)
end
end
describe '#reading_time' do
it 'calculates reading time based on word count' do
article = create(:article, body: 'Word ' * 400)
expect(article.reading_time).to eq(2)
end
end
describe 'Callbacks' do
it 'generates slug before validation on create' do
article = create(:article, title: 'My First Article')
expect(article.slug).to eq('my-first-article')
end
end
end~> operator for minor updates. Run bundle update carefully and review changelogs. Use bundle exec to ensure correct gem versions. Keep development/test gems separated from production gems.Rails follows MVC (Model-View-Controller): Model (ActiveRecord) handles data and business logic through ORM.View (ERB templates) renders HTML with embedded Ruby. Controller receives HTTP requests, processes them through models, and renders views. The router maps URLs to controller actions. Rails also includes strong conventions: naming, file structure, and RESTful patterns reduce boilerplate.
includes uses eager loading (preload by default, joins when WHERE clause references included table).joins performs an INNER JOIN but does not load associated records (useful for filtering).eager_load always uses a LEFT OUTER JOIN and loads all records in one query.preload always loads in separate queries. Use Bullet gem to detect N+1 queries.
Callbacks are hooks into the object lifecycle: before_validation, after_create,before_save, etc. They are useful for auto-generating slugs, sending notifications, or updating caches. Avoid them for complex business logic because they create hidden dependencies, make testing harder, and can cause unexpected side effects. Prefer explicit service objects for complex operations.
Strong parameters prevent mass assignment vulnerabilities by requiring explicit whitelisting of permitted attributes. Without them, attackers could submit extra form fields (like admin: true) and gain elevated privileges. Use params.require(:article).permit(:title, :body, :status) in controllers to whitelist allowed parameters.