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:

Articles

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:

  1. Wrap the patch in a module with an obvious name and use Module#prepend to apply it
  2. Make sure you’re patching the right thing
  3. Limit the patch’s surface area
  4. Give yourself escape hatches
  5. 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| %>

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
Last change: 2024-11-26, commit: 604ba3c