Rails
Rails is a very cool framework for web-applications and IMO the best for mvp-prototyping and beyond.
Curriculum
Learning from some youtube guys:
- Video Source: Projekt
- Video Source: simple-pm
- Best practises Semicolon&Sons - Best practises I Passing ruby data to javaScript
Articles
- Great note collection from corsego
- devise, hotwire, turbo, darkmode, basics, views
- Some great articles about coding johnnunemaker
- rails, ruby, …
- Great summary about encryption in rails7 corsego
- ActiveRecord Mistakes that slow down your app
- Modularisation (Monolith):
Pattern matching
def extract(**data)
case data
in name: {first:}
puts first
in tags: [first_tag, *_]
puts first_tag
end
end
> extract(name: { first: "Brad", last: "Gessler" })
"Brad"
> extract(tags: ["person", "earthling"] })
"person"
def matcher(**data)
if first = data.fetch(:first)
puts first
elsif data.key?(:tags)
tags = data.fetch(:tags)
if tags.is_a? Array
puts tags.first
end
end
end
Source: fly.io
Colorize logger
module ColorizedLogger
COLOR_CODES = {
debug: "\e[36m", # Cyan
info: "\e[32m", # Green
warn: "\e[33m", # Yellow
error: "\e[31m", # Red
fatal: "\e[35m", # Magenta
unknown: "\e[37m" # White (or terminal default)
}.freeze
RESET = "\e[0m"
def debug(progname = nil, &block)
super(colorize(:debug, progname, &block))
end
def info(progname = nil, &block)
super(colorize(:info, progname, &block))
end
def warn(progname = nil, &block)
super(colorize(:warn, progname, &block))
end
def error(progname = nil, &block)
super(colorize(:error, progname, &block))
end
def fatal(progname = nil, &block)
super(colorize(:fatal, progname, &block))
end
def unknown(progname = nil, &block)
super(colorize(:unknown, progname, &block))
end
private
def colorize(level, message, &block)
"#{COLOR_CODES[level]}#{message || (block && block.call)}#{RESET}"
end
end
Rails.logger.extend(ColorizedLogger)
Test coverage pre-commit hook
To get an positive exit code for pre-commit hook integration you have to add this to your spec config:
# spec/spec_helper.rb
RSpec.configure do |config|
if ENV["TEST_COVERAGE"]
SimpleCov.start "rails" do
spec_paths = ARGV.grep %r{(spec)/\w+}
if spec_paths.any?
file_paths = spec_paths.map { |spec_path| spec_path.gsub(%r{spec/|_spec}, "") }
add_filter do |file|
file_paths.none? do |file_path|
if file.filename.include? "/app/"
file.filename.match?(%r{/app/#{file_path}})
else
file.filename.include?(file_path)
end
end
end
end
minimum_coverage 98.9
minimum_coverage_by_file 81.4
end
else
SimpleCov.start "rails"
end
[...]
Pre-commit hook:
files=$(git --no-pager diff --name-only --cached --diff-filter=AM)
erbfiles=$(echo "$files" | grep -e '\.html.erb$')
[[ -n "$specfiles" ]] && (TEST_COVERAGE=true bundle exec rspec "$specfiles" || exit 1)
before_action wrapper
class User::LikesController < ApplicationController
abort_without_feature :like
must_have_feature :like
requires_feature :like
end
# app/controllers/concerns/requires_feature.rb
module RequiresFeature
def requires_feature(name, from: :user, **)
before_action(-> { head :bad_request unless Flipper.enabled?(name, Current.public_send(from)) }, **)
end
end
Source: buttondown
Rails ERD
For creating an erd diagram of your db schema, you could create a pdf with: rails-erd with this command:
bundle exec rails erd attributes=foreign_keys,primary_keys,timestamps,content notation=bachman
Race conditions
Avoid race conditions with ActiveRecord::Base.transaction do
and Model.lock.find(model_id)
or my_model_object.lock!
Source: fastruby.io
Migration from sidekiq to solid_queue
Step 1: Update Gemfile
# Remove Sidekiq
# gem 'sidekiq'
# Add Solid Queue
gem 'solid_ queue'
Step 2: Run bundle install
# $ bundle install
Step 3: Generate Solid Queue installation files
# $ rails generate solid_queue:install
Step 4: Run migrations
# $ rails db:migrate
Step 5: Update config/application.rb
# Rails.application.configure do
# config.active_job.queue_adapter = :solid_queue
# end
Step 6: Remove Sidekiq initializer
# Delete or comment out config/initializers/sidekiq.rb
Step 7: Update worker process command in Profile or deployment scripts
# Old: worker: bundle exec sidekiq
# New: worker: bundle exec rails solid_queue: start
Step 8: Remove Redis configuration related to Sidekiq
# Check config/redis.yml or any Redis initializers
Step 9: Update any Sidekiq-specific code in your jobs
Before:
# class MyJob
# include Sidekiq: :Worker
# def perform(args)
# job logic
# end
# end
After:
# class MyJob < ApplicationJob
# queue_as: default def perform(args)
# job logic
# end
Step 10: Update any Sidekiq-specific API calls
# Before: Sidekiq:: Client.push( 'class' => MyJob,
# After: MyJob. perform_later (1, 2, 3)
Step 11: Set up Mission Control (optional)
# In Gemfile:
gem 'mission_control-jobs'
# In config/routes.rb:
Rails.application.routes.draw do
mount MissionControl::Jobs::Engine, at: "/jobs"
end
Step 12: Remove any Sidekiq web UI routes
# Delete or comment out in config/routes.rb:
# require 'sidekiq/web'
# mount Sidekiq:: Web => '/sidekiq'
Responsible monkeypatch
Here’s the list of rules I try to follow:
- Wrap the patch in a module with an obvious name and use
Module#prepend
to apply it - Make sure you’re patching the right thing
- Limit the patch’s surface area
- Give yourself escape hatches
- Over-communicate
# ActionView's date_select helper provides the option to "discard" certain
# fields. Discarded fields are (confusingly) still rendered to the page
# using hidden inputs, i.e. <input type="hidden" />. This patch adds an
# additional option to the date_select helper that allows the caller to
# skip rendering the chosen fields altogether. For example, to render all
# but the year field, you might have this in one of your views:
#
# date_select(:date_of_birth, order: [:month, :day])
#
# or, equivalently:
#
# date_select(:date_of_birth, discard_year: true)
#
# To avoid rendering the year field altogether, set :render_discarded to
# false:
#
# date_select(:date_of_birth, discard_year: true, render_discarded: false)
#
# This patch assumes the #build_hidden method exists on
# ActionView::Helpers::DateTimeSelector and accepts two arguments.
#
module RenderDiscardedMonkeypatch
class << self
EXPIRATION_DATE = Date.new(2021, 8, 15)
def apply_patch
if Date.today > EXPIRATION_DATE
puts "WARNING: Please re-evaluate whether or not the ActionView "\
"date_select patch present in #{__FILE__} is still necessary."
end
const = find_const
mtd = find_method(const)
# make sure the class we want to patch exists;
# make sure the #build_hidden method exists and accepts exactly
# two arguments
unless const && mtd && mtd.arity == 2
raise "Could not find class or method when patching "\
"ActionView's date_select helper. Please investigate."
end
# if rails has been upgraded, make sure this patch is still
# necessary
unless rails_version_ok?
puts "WARNING: It looks like Rails has been upgraded since "\
"ActionView's date_select helper was monkeypatched in "\
"#{__FILE__}. Please re-evaluate the patch."
end
# actually apply the patch
const.prepend(InstanceMethods)
end
private
def find_const
Kernel.const_get('ActionView::Helpers::DateTimeSelector')
rescue NameError
# return nil if the constant doesn't exist
end
def find_method(const)
return unless const
const.instance_method(:build_hidden)
rescue NameError
# return nil if the method doesn't exist
end
def rails_version_ok?
Rails::VERSION::MAJOR == 6 && Rails::VERSION::MINOR == 1
end
end
module InstanceMethods
# :render_discarded is an additional option you can pass to the
# date_select helper in your views. Use it to avoid rendering
# "discarded" fields, i.e. fields marked as discarded or simply
# not included in date_select's :order array. For example,
# specifying order: [:day, :month] will cause the helper to
# "discard" the :year field. Discarding a field renders it as a
# hidden input. Set :render_discarded to false to avoid rendering
# it altogether.
def build_hidden(type, value)
if @options.fetch(:render_discarded, true)
super
else
''
end
end
end
end
RenderDiscardedMonkeypatch.apply_patch
Source: appsignal/blog
RSpec
Bisect flaky tests
with rspec --bisect <file>
you could find flaky test setting to re-run it.
hint
If rspec
exits with 1
if suite passes, try untilpass() { until "$@"; do :; done }
and run it with untilpass rspec ...
Testing an array with attributes
expect(items[0].id).to eql(1)
expect(items[0].name).to eql('One')
expect(items[1].id).to eql(2)
expect(items[1].name).to eql('Two')
expect(items[0]).to have_attributes(id: 1, name: 'One')
expect(items[1]).to have_attributes(id: 2, name: 'Two')
expect(items).to match_array([
have_attributes(id: 1, name: 'One'),
have_attributes(id: 2, name: 'Two'),
])
Source: benpickles
Custom matcher
RSpec::Matchers.define :have_errors_on do |attribute|
chain :with_message do |message|
@message = message
end
match do |model|
model.valid?
@has_errors = model.errors.key?(attribute)
if @message
@has_errors && model.errors[attribute].include?(@message)
else
@has_errors
end
end
failure_message_for_should do |model|
if @message
"Validation errors #{model.errors[attribute].inspect} should include #{@message.inspect}"
else
"#{model.class} should have errors on attribute #{attribute.inspect}"
end
end
failure_message_for_should_not do |model|
"#{model.class} should not have an error on attribute #{attribute.inspect}"
end
end
# usage
describe User do
before { subject.email = "foobar" }
it { should have_errors_on(:email).with_message("Email has an invalid format") }
end
Data class
Similiar to the value-alike objects in ruby(3.2), here an example for rails:
class DataClass
ATTRIBUTES = %i[first_name last_name city zipcode phone_number].freeze
include ActiveModel::Model
attr_accessor *ATTRIBUTES
def ==(object)
ATTRIBUTES.all? { |attribute| public_send(attribute) == object.public_send(attribute) }
end
end
ConsumerClass.new(DataClass({first_name: "Bill", last_name: "...", ...}))
Links: Polyfill - Data gem
RSpec factory trait & transient
FactoryBot.define do
factory :user, class: User do
trait :with_book do
transient do
# 🦄1. default value when you use :with_book trait
# 🦄2. Dont't assign just 'Agile'. see also: https://thoughtbot.com/blog/deprecating-static-attributes-in-factory_bot-4-11
title { 'Agile' }
end
after(:build) do |user, evaluator|
user.book = FactoryBot.create(:book, title: evaluator.title)
end
end
end
factory :book, class Book do
sequence(:title) { |n| "book no.#{n}" } # 🦄 default value
end
end
# usage
let!(:user) { create(:user, :with_book, title: 'Ruby') }
Source: dev.to/n350071
RailsWorld2023
Some notes on talks:
- Turbo morphing
- new feature for keep scrolling position on whole dom body change
- no-build / bun
- key goal to eliminate build time for frontend stuff, ship as code
- if assignment block
def test 42 end if a = test puts "Assign #{a}" else puts "Nil is return" end
- Class.method(:name).source_location
- get code location of a method
- Model attribute strict_loading prohibit loading relations
- Migration add_column :virtual
create_table :users do |t|
t.numeric :height_cm
t.virtual :height_in, type: :numeric, as: 'height_cm / 2.54', stored: true
end
- Model with_options relations
class User
with_options dependent: :destroy do |options|
options.has_many :tasks, class_name: "UserTask"
options.has_many :addresses
end
end
- try(:method_name) || default_method
- Routing constraints
- subdomain
- authenticated
- Routes draw split into files
- rails generate generator ApiClient
- String truncate_words with omission
- DateTime before? past? future?
- Time.current.all_day .all_week …
- Abbreviation number_to_human() round_mode significant format units
Professional Ruby on Rails Developer with Rails 5
Some notes while doing the udamy course and some ruby notes:
Basics
Render plain text:
def hello
render plain: "hello world!"
end
Create a controller with a method and use a object in view:
# controller
class TodosController < ApplicationController
def new
@todo = Todo.new
end
end
# view
<%= form_for @todo do |f| %>
<% end %>
Use flash
for notifications:
#controller
@todo.save
flash[:notice] = "Todo was created successfully"
# view
<% flash.each do |name, msg| %>
<ul>
<li><%= msg %></li>
</ul>
<% end %>
A bootstrap styled flash messages:
<div class="row">
<div class="col-md-10 col-md-offset-1">
<% flash.each do |name, msg| %>
<div class="alert alert-<%= name %>">
<a href="#" class="close" data-dismiss="alert">×</a>
<%= msg %>
</div>
<% end %>
</div>
</div>
Render partials in view ie a file views/layouts/_messages.html.erb
:
<%= render 'layouts/messages' %>
Before run a method do action:
# controller
before_action :set_todo, only: [:edit, :update, :show, :destroy]
private
def set_todo
@todo = Todo.find(params[:id])
end
def todo_params
params.require(:todo).permit(:name, :description)
end
# permit has_many list
def recipe_params
params.require(:recipe).permit(:name, :description, ingredient_ids: [])
end
end
Add pg to production for ie. heroku deployment:
group :production do
gem 'pg'
end
bundle install --without production
Some simple validations:
validates :name, presence: true
validates :description, presence: true, length: { minimum: 5, maximum: 500 }
Application helpers:
helper_method :current_chef, :logged_in?
def current_chef
@current_chef ||= Chef.find(session[:chef_id]) if session[:chef_id]
end
def logged_in?
!!current_chef
end
def require_user
if !logged_in?
flash[:danger] = "You must be logged in to perform that action"
redirect_to root_path
# also possible
redirect_to :back
end
end
Render partial for model:
# View
<% if recipe.ingredients.any? %>
<p>Ingredients: <%= render recipe.ingredients %></p>
<% end %>
# Now create a new partial _ingredient.html.erb under the app/views/ingredients folder for this to work
<span class="badge"><%= link_to ingredient.name,
ingredient_path(ingredient) %> </span>
Model
Order models by column:
# order by updated_at -> top of model
default_scope -> { order(updated_at: :desc) }
Get just the last 20 entries of a model
def self.most_recent
order(:created_at).last(20)
end
Routes
Set root route:
# router.rb
root "pages#home"
Specific route to controller#method:
get '/about', to: 'pages#about'
Nested routes:
resources :recipes do
resources :comments, only: [:create]
end
# Form in view
<%= form\_for(\[@recipe, @comment\], :html => {class: "form-horizontal",
role: "form"}) do |f| %>
Links
Create links in views:
<%= link_to "Edit this todo", edit_todo_path(@todo) %>
<%= link_to "Back to todos listing", todos_path %>
<td><%= link_to 'delete', todo_path(todo), method: :delete, data: { confirm: "Are you sure?"} %></td>
<%= link_to "MyRecipes", root_path, class: "navbar-brand", id: "logo" %>
<%= link_to "Sign up or log in", "#" class: "btn btn-danger btn-lg" %>
Tests
Create integration test for model:
rails generate integration_test recipes
Simple test root path:
test "should get home" do
get pages_home_url
assert_response :success
end
test "should get root" do
get root_url
assert_response :success
end
to fix this tests, do:
root "pages#home"
get 'pages/home', to: 'pages#home'
# controller
class PagesController < ApplicationController
def home
end
end
Test a simple validation:
require 'test_helper'
class RecipeTest < ActiveSupport::TestCase
def setup
@recipe = Recipe.new(name: "vegetable", description: "great vegetable recipe")
end
test "recipe should be valid" do
assert @recipe.valid?
end
test "name should be present" do
@recipe.name = " "
assert_not @recipe.valid?
end
test "description should be present" do
@recipe.description = " "
assert_not @recipe.valid?
end
test "description shouldn't be less than 5 characters" do
@recipe.description = "a" * 3
assert_not @recipe.valid?
end
test "description shouldn't be more than 500 characters" do
@recipe.description = "a" * 501
assert_not @recipe.valid?
end
test "should get recipes show" do
get recipe_path(@recipe)
assert_template 'recipes/show'
assert_match @recipe.name, response.body
assert_match @recipe.description, response.body
assert_match @chef.chefname, response.body
end
test "should get recipes listing" do
get recipes_path
assert_template 'recipes/index'
assert_select "a[href=?]", recipe_path(@recipe), text: @recipe.name
assert_select "a[href=?]", recipe_path(@recipe2), text: @recipe2.name
end
end
Test a email validation:
require 'test_helper'
class ChefTest < ActiveSupport::TestCase
def setup
@chef = Chef.new(chefname: "mashrur", email: "mashrur@example.com")
end
test "should be valid" do
assert @chef.valid?
end
test "name should be present" do
@chef.chefname = " "
assert_not @chef.valid?
end
test "name should be less than 30 characters" do
@chef.chefname = "a" * 31
assert_not @chef.valid?
end
test "email should be present" do
@chef.email = " "
assert_not @chef.valid?
end
test "email should not be too long" do
@chef.email = "a" * 245 + "@example.com"
assert_not @chef.valid?
end
test "email should accept correct format" do
valid_emails = %w[user@example.com MASHRUR@gmail.com M.first@yahoo.ca john+smith@co.uk.org]
valid_emails.each do |valids|
@chef.email = valids
assert @chef.valid?, "#{valids.inspect} should be valid"
end
end
test "should reject invalid addresses" do
invalid_emails = %w[mashrur@example mashrur@example,com mashrur.name@gmail. joe@bar+foo.com]
invalid_emails.each do |invalids|
@chef.email = invalids
assert_not @chef.valid?, "#{invalids.inspect} should be invalid"
end
end
test "email should be unique and case insensitive" do
duplicate_chef = @chef.dup
duplicate_chef.email = @chef.email.upcase
@chef.save
assert_not duplicate_chef.valid?
end
test "reject an invalid signup" do
get signup_path
assert_no_difference "Chef.count" do
post chefs_path, params: { chef: { chefname: " ",
email: " ", password: "password",
password_confirmation: " " } }
end
assert_template 'chefs/new'
assert_select 'h2.panel-title'
assert_select 'div.panel-body'
end
test "accept valid signup" do
get signup_path
assert_difference "Chef.count", 1 do
post chefs_path, params: { chef: { chefname: "mashrur",
email: "mashrur@example.com", password: "password",
password_confirmation: "password" } }
end
follow_redirect!
assert_template 'chefs/show'
assert_not flash.empty?
end
end
# controller
class Chef < ApplicationRecord
validates :chefname, presence: true, length: { maximum: 30 }
VALID_EMAIL_REGEX = /\A[\w+\-.]+@[a-z\d\-.]+\.[a-z]+\z/i
validates :email, presence: true, length: { maximum: 255 },
format: { with: VALID_EMAIL_REGEX },
uniqueness: { case_sensitive: false }
end
Downcase before save with test:
before_save { self.email = email.downcase }
# And then test it with:
test "email should be lower case before hitting db" do
mixed_email = "JohN@ExampLe.com"
@chef.email = mixed_email
@chef.save
assert_equal mixed_email.downcase, @chef.reload.email
end
Create a association and test it:
Association
# chef model
validates :chef_id, presence: true
has_many :recipes
# recipe model
belongs_to :chef
# destroy with dependent
has_many :recipes, dependent: :destroy
Test
def setup
@chef = Chef.create!(chefname: "mashrur", email: "mashrur@example.com")
@recipe = @chef.recipes.build(name: "vegetable", description: "great vegetable recipe")
end
test "recipe without chef should be invalid" do
@recipe.chef_id = nil
assert_not @recipe.valid?
end
Styling
Add bootstrap
to project (good html/css tutorial link):
gem 'bootstrap-sass', '~> 3.3.7'
gem 'jquery-rails'
# app/assets/javascripts/application.js
//= require bootstrap-sprockets
# app/assets/stylesheets/custom.css.scss
@import "bootstrap-sprockets";
@import "bootstrap";
Views
Render html for each data:
<%= f.collection_check_boxes :ingredient_ids,
Ingredient.all, :id, :name do |cb| %>
<% cb.label(class: "checkbox-inline input_checkbox") {cb.check_box(class: "checkbox") + cb.text} %>
<% end %>
Database
Create migration:
rails generate migration create_recipes
# modify the migration file
rails db:migrate
Rename column:
rename_column :recipes, :email, :description
rails db:migrate
Many to many association:
# Model
has_many :recipe_ingredients
has_many :recipes, through: :recipe_ingredients