ruby on rails

Use Pundit Policies to manage permitted parameters, defaults and per action

To manage permitted params you can add permitted_attributes and permitted_attributes_for_#{action} in your policies e.g.

class CommentPolicy < ApplicationPolicy
  def permitted_attributes
    %i[body]
  end

  def permitted_attributes_for_create
    %i[body application_id]
  end
end

Then in your controller

class CommentController
  def create
    @comment = Comment.create(comment_attributes) # { body: 'body', application_id: 1 }
  end
 
  def update
    @comment = Comment.find(params[:id])
    @comment.update(comment_attributes) # { body: 'new body' }
  end
 
  private def comment_attributes
    permitted_attributes(Comment)
  end
end
6
Joe

Use `requestSubmit` for form submits with Turbo

- `HTMLFormElement.submit()`: Does NOT trigger `submit` event
- `HTMLFormElement.requestSubmit()`: Triggers `submit` event

`Turbo` listens to submit events on form. 
Hence, always use `requestSubmit` to submit  a form using JS.
6
Abhi

Ignore bundled gems for Rubocop and ESLint in GitHub Actions

Rails projects will often install gems into the vendor directory in GitHub actions so we can cache them for the consecutive runs.

If you use code linters in the action you need to exclude the `vendor/bundle/*` to prevent false positives

For ESLint this can be configured through ignorePatterns
// ./.eslintrc.json
{
  "ignorePatterns": [
    "vendor/bundle/*"
  ]
}

For Rubocop you can exclude through the AllCops config
# ./.rubocop.yml
AllCops:
  Exclude:
    - 'vendor/**/*'
6
Joe

Rails 6, Ruby 3, Bootstrap 5 and Hotwire

Extending the blogpost from Bootrails with Hotwire:
All the fancy new things (as of when I'm writing this TIL):

$ ruby --version
3.0.1
$ rails --version
Rails 6.1.3.1
$ rails new MyApp --skip-bundle --skip-coffee --database=postgresql
$ cd MyApp
# Add Hotwire-rails to Gemfile
$ echo "\ngem 'hotwire-rails'" >> Gemfile
$ bundle
$ ./bin/rails webpacker:install
# lets keep javascript and stylesheets in a app/frontend folder
$ mv app/javascript app/frontend
# add bootstrap dependencies
$ yarn add bootstrap@next
# when prompted, choose the latest version here:
$ yarn add @popperjs/core@next

Edit `./webpacker.yml` and modify source_path:
# Inside webpacker.yml
default: &default
  source_path: app/frontend # Change here

Add files to load bootstrap:
$ mkdir app/frontend/js && touch app/frontend/js/bootstrap_js_files.js
$ touch app/frontend/packs/application.scss
# Warning, this removes application.css (which we replace with application.scss)
$ touch app/assets/stylesheets/.keep && rm app/assets/stylesheets/application.css

// inside app/frontend/js/bootstrap_js_files.js
// enable plugins as you require them  

// import 'bootstrap/js/src/alert'  
// import 'bootstrap/js/src/button'  
// import 'bootstrap/js/src/carousel'  
import 'bootstrap/js/src/collapse'  
import 'bootstrap/js/src/dropdown'  
// import 'bootstrap/js/src/modal'  
// import 'bootstrap/js/src/popover'  
import 'bootstrap/js/src/scrollspy'  
// import 'bootstrap/js/src/tab'  
// import 'bootstrap/js/src/toast'  
// import 'bootstrap/js/src/tooltip'

// inside app/frontend/packs/application.js  

// Add this line before import Rails …
import '../js/bootstrap_js_files.js'  

// inside app/frontend/packs/application.scss  

// Import Bootstrap v5  
@import "~bootstrap/scss/bootstrap";

Setup Hotwire
# create ./bin/bundle; currently required for the Remove Turbolinks task by Stimulus
# see: https://github.com/hotwired/stimulus-rails/issues/55
$ bundle binstubs bundler
$ ./bin/rails hotwire:install

Modify `app/views/layouts/application.html.erb`:
<!DOCTYPE html>
<html>
  <head>
    <title>MyApp</title>
    <meta name="viewport" content="width=device-width,initial-scale=1">
    <%= csrf_meta_tags %>
    <%= csp_meta_tag %>

    <!-- Warning !! ensure that "stylesheet_pack_tag" is used, line below; also change turbolinks to turbo for hotwire -->
    <%= stylesheet_pack_tag 'application', media: 'all', 'data-turbo-track': 'reload' %>
    <%= javascript_pack_tag 'application', 'data-turbo-track': 'reload' %>
  </head>

  <body>
    <%= yield %>
  </body>
</html>

Check if everything compiles:
$ ./bin/rails assets:clobber
# You can probably ignore the warnings :)
$ ./bin/rails webpacker:compile
10
Const

Rails Cache-Control

For enabling setting cache-control headers of static assets and using a CDN on Heroku:

config.public_file_server.enabled = ENV['RAILS_SERVE_STATIC_FILES'].present?
config.public_file_server.headers = {
  'Cache-Control' => 'public, max-age=31536000',
  # You need to enable this (or set it to your domain) for fonts to work
  'Access-Control-Allow-Origin' => '*'
}


if ENV['ASSET_HOST'].present?
  config.action_controller.asset_host = ENV['ASSET_HOST'] # 'https://cdn.example.com'
end

5
Const

Unique ID for DOM in Rails

Rails has many great view helpers, one of which is dom_id:
dom_id(Post.find(12))       # => "post_23"
dom_id(Post.new)            # => "new_post"

You can also provide a prefix:
dom_id(Post.find(23), :edit) # => "edit_post_23"
dom_id(Post.new, :custom)    # => "custom_post"
4
Const

Fix input on Heroku's rails console

If you end up pasting lots of lines into a rails console on Heroku (or else where), you might get issues with the lines "bleeding" into each other.

Next time this happens, try disabling multiline:

$ heroku run 'bundle exec rails console -- --nomultiline'
5
Const

PostgreSQL UPDATE Join

This query shows how to use the PostgreSQL UPDATE join syntax to update data in a table based on values in another table. for example;

updating subscription_missions.giftbox_type with rewards.gift_box_type through mission_rewards table (tested on 300k records, crazy level fast)

  UPDATE
        subscription_missions sm
  SET
        giftbox_type = r.gift_box_type
  FROM
        mission_rewards mr,
        rewards r
  WHERE
        sm.giftbox_type IS NULL
        AND sm.id = mr.subscription_mission_id
        AND mr.reward_id = r.id;



1
Ali

List your model names

$ RUBYOPT='-W0' rails runner 'Rails.application.eager_load!; puts ApplicationRecord.descendants.collect { |type| type.name }'


NOTE: RUBYOPT=-W0 avoids verbosity when running rails command which suppresses warnnings.

1
Ali

Postgres Functions & Non-sargable Queries

Using postgres functions inside a where clause can make a query non-sargable.

Database Structure

tracks has_many artists

Non-Sargable Query

Using a LOWER function prevents DBMS engine from using indexes.

Track.joins(:artists).where('LOWER(tracks.display_name) LIKE ?', "eric clapton%").explain

Gather  (cost=1000.85..56714.32 width=4061)
   Workers Planned: 2
   ->  Nested Loop  (cost=0.85..55713.62 rows=3 width=4061)
         ->  Nested Loop  (cost=0.42..55708.37 rows=3 width=4069)
               ->  Parallel Seq Scan on tracks  (cost=0.00..55365.17 rows=41 width=4061)
                     Filter: (lower((display_name)::text) ~~* 'eric clapton%'::text)
               ->  Index Scan using index_artist_relations_on_artist_item_type_and_artist_item_id on artist_relations  (cost=0.42..8.36 rows=1 width=16)
                     Index Cond: (((artist_item_type)::text = 'Track'::text) AND (artist_item_id = tracks.id))
         ->  Index Only Scan using idx_35952_primary on artists  (cost=0.43..1.75 rows=1 width=8)
               Index Cond: (id = artist_relations.artist_id)


Sargable Query

Removing LOWER function allows DBMS engine to use indexes, resulting in faster execution.

Track.joins(:artists).where('tracks.display_name ILIKE ?', "eric clapton%").explain

Nested Loop  (cost=1497.60..2695.43 width=4061)
   ->  Nested Loop  (cost=1497.17..2684.94 rows=6 width=4069)
         ->  Bitmap Heap Scan on tracks  (cost=1496.75..1873.05 rows=97 width=4061)
               Recheck Cond: ((display_name)::text ~~* 'eric clapton%'::text)
               ->  Bitmap Index Scan on index_tracks_on_display_name  (cost=0.00..1496.73 rows=97 width=0)
                     Index Cond: ((display_name)::text ~~* 'eric clapton%'::text)
         ->  Index Scan using index_artist_relations_on_artist_item_type_and_artist_item_id on artist_relations  (cost=0.42..8.36 rows=1 width=16)
               Index Cond: (((artist_item_type)::text = 'Track'::text) AND (artist_item_id = tracks.id))
   ->  Index Only Scan using idx_35952_primary on artists  (cost=0.43..1.75 rows=1 width=8)
         Index Cond: (id = artist_relations.artist_id)


1
Abhi

How to find god objects in your project (Rails)

Go to your project root and cd into app/models (using CLI obviously) then run wc -lw * | sort -u

$ wc -lw * | sort -u
103     186 deal_broker.rb
116     313 physical_sim_report.rb
126     229 coupon_code.rb
126     260 subscription_mission.rb
129     284 payment.rb
145     290 deal_value.rb
147     255 single_use_coupon.rb
166     344 sim_card.rb
166     406 coupon_campaign.rb
225     451 order_summary.rb
260     534 deal.rb
308     783 package.rb
443    1031 user.rb
728    1744 subscription.rb
7262   15841 total


So now you can see Rocky is all about User ,Subscription and Package.

Note: First column number of lines and the second column number of words.

827
Ali

Tag rails logs with useful information

Just learned about tagged logging in rails. Did you know you can tag a log by using

logger.tagged('Your Tag') { logger.info('Message') }


You can also do it globally with

# config/application.rb
config.log_tags = [:method_on_request_object, lambda { |request| request.method.modifier_method }]


Add client Device OS and OS Version information to logs (requires clients to send X-OS and X-OS-Version headers)

# config/application.rb
config.log_tags = [ :request_id, lambda { |request| "#{request.headers['HTTP_X_OS']} #{request.headers['HTTP_X_OS_VERSION']}" } ]


828
Joe

Limited has_many associations

# app/models/user.rb
has_one :four_car_garage
has_many :cars
validates :cars, length: { maximum: 4 }


Joe

Create idempotent migrations

When you want to write destructive migrations please use if_exists: true when you’re trying to remove a table and check for a table existence when you want to add or remove a column ActiveRecord::Base.connection.table_exists?

Case 1:

def up
  drop_table :kittens, if_exists: true
end


Case 2:

def up
  return unless ActiveRecord::Base.connection.table_exists?(:kittens)
  add_column :kittens, :kind, :integer
end


Ali

If you want to override previously set order

If you want to override previously set order (even through default_scope), use reorder() instead.

E.g.

User.order('id ASC').reorder('name DESC')


would ignore ordering by id completely

Ali

Attaching file to ActiveStorage without HTTP

If you want to attach a file you generated on disk or downloaded from a user-submitted URL, you can do it like this.

@model.image.attach(
    io: File.open('/path/to/file'),
  filename: 'file.pdf',
  # content type is optional
  content_type: 'application/pdf'
)


2
Tino

Add new types to Rails 5 attributes API

The Rails 5 attributes API allows us to build form objects very easily.

I had previously been using an after_initialize callback to downcase emails, with the following code I can define an :email date type and values are automatically cast when set.

This has the advantage of casting subsequent calls to set the email value.

class EmailType < ActiveModel::Type::String
  def cast(value)
    return super unless value.is_a?(String)
    super(value.downcase)
  end
end

ActiveModel::Type.register(:email, EmailType)


class Foo
  include ActiveModel::Model
  include ActiveModel::Attributes

  attribute :email, :email
end


> Foo.new(email: 'EMAIL@MAIL.com').email
=> 'email@mail.com'


1
Joe

Scope or scope in Rails

# app/models/user.rb
...
enum role: { user: 0, author: 1, admin: 2, robot: 3 }

scope :human, -> { user.or(author) }
scope :terminator, -> { admin.or(robot) }


Produces

User.human.to_sql
#=> "SELECT \"users\".* FROM \"users\" WHERE (\"users\".\"role\" = 0 OR \"users\".\"role\" = 1)"


827
Joe

HTML forms that submit to another action

I've known for a long time that you can submit values with submit buttons so you can track which button was used to submit the form e.g.

language-html

However, today I needed to actually submit the form to a completely differnt action. Turns out you can do this with formaction attributes

language-html

828
Joe

Show TODO and other notes in your Rails app

Did you know you can show all TODOs/OPTIMIZE/FIXME in your rails app with rails notes?

$ rails notes
app/helpers/users_helper.rb:
  * [10] [TODO] Use ActiveSupport extension methods for Date/Time

app/services/slack.rb:
  * [20] [OPTIMIZE] Replace library with core Net::HTTP



You can even focus with notes:todo etc.:

$ rails notes:todo
app/helpers/users_helper.rb:
  * [10] [TODO] Use ActiveSupport extension methods for Date/Time


827
Const

Create a Hash from an Enumerable (Rails 6.0)

New method allowing you to create a Hash from an Enumerable:

%w(driver owner drivy).index_with(nil)
# => { 'driver' => nil, 'owner' => nil, 'drivy' => nil }

%w(driver owner drivy).index_with { |role| delta_amount_for(role) }
# => { 'driver' => '...', 'owner' => '...', 'drivy' => '...' }


828
Ali

Use minItems/maxItems in collection JSON schemas

To make your collection JSON schemas more reilable use minItems and maxItems so that you can trust your API.

{
  "type": "array",
  "minItems": 1,
  "items": {
    "$ref": "subscription_notification.json"
  }
}


instead of

{
  "type": "array",
  "items": {
    "$ref": "subscription_notification.json"
  }
}


If your API returns [] empty array that last one would pass if you make an assertion in your specs.

expect(response).to match_response_schema(:subscription_notifications)


But not the first one

The property '#/' did not contain a minimum number of items 1 in schema


https://json-schema.org/understanding-json-schema/reference/array.html

Ali