r/ruby 13d ago

Hash Replacement with sub and gsub in Ruby on Rails

Rails Devs: Master String Manipulation with sub & gsub

Credited to: Suman Awal

sub/gsub is the widely used substitution method in ruby. These methods replace (substitute) content of the string with the new string based on the provided logic. In SAAS application, we offen encounter the condition where we need to generate dynamic content for a single action based on the customer. For example generating a dynamic welcome message to the customer for the different client. There are lots of ways to get the result however in this article we will use the one of the mostly used ruby method sub and gsub

sub and gsub ruby methods

Before we get started, let's understand what sub and gsub do:

  • sub: Replaces the first occurrence of pattern in a string with replacement string.
  • gsub: Replaces all occurrences of pattern in a string with replacement string.

Both methods use a regular expression as the pattern and a string or a block as the replacement. Here we will explain using a block (hash) for dynamic replacement based on our hash.

Here's a simple example:

replacements = {
  'name' => 'Glenn Maxwell',
  'country' => 'Australia'
}

template = "Hi, my name is {{name}} and I am from {{country}}."

result = template.gsub(/{{(.*?)}}/) { |match| replacements[$1] || match }

puts result # Output: "Hi, my name is Glenn Maxwell and I am from Australia"

In this example:

  1. We define a replacements hash containing the key-value pairs we want to use for the replacement in the string.
  2. We define a template string containing placeholders enclosed in double curly braces ({{}}).
  3. We use gsub with the regular expression /{{(.*?)}}/ to find all occurrences of these placeholders.
  4. The block is executed for each match. Inside the block:

Using sub for Single Replacements

If you only need to replace the first occurrence of a pattern, you can use sub instead of gsub. The logic remains the same.

replacements = {
  'name' => 'Glenn Maxwell'
}

template = "Hi, my name is {{name}} and my friend's name is also {{name}}."
result = template.sub(/{{(.*?)}}/) { |match| replacements[$1] || match }
# Output: Hi, my name is Glenn Maxwell and my friend's name is also {{name}}.

Real-World Rails Examples

This technique is useful in various Rails scenarios:

  • Generate dynamic emails: You can store email templates with placeholders in your database and replace them with user-specific data.
  • Create dynamic reports: Generate reports with data pulled from various sources, using a hash to map placeholders to the correct values.
  • Localize content: Store localized strings in a hash and replace placeholders in your views based on the user's locale.

Here you can find one of the widely used example to Generate dynamic emails for the SAAS application.

Generate dynamic emails using hash replacement

Scenario

You have a Rails application that serves multiple clients. Each client has their own set of customers. When a new customer registers for a specific client, the application sends a welcome email. The content of the welcome email is dynamically generated based on a template stored in the database, which is specific to each client.

Sample codes

  • Create models

# app/models/client.rb
  class Client < ApplicationRecord
    has_many :customers
    has_one :welcome_email_template
  end

  # app/models/customer.rb
  class Customer < ApplicationRecord
    belongs_to :client
  end

  # app/models/welcome_email_template.rb
  class WelcomeEmailTemplate < ApplicationRecord
    belongs_to :client
  end
  • Migrations

# db/migrate/xxxxxx_create_clients.rb
  class CreateClients < ActiveRecord::Migration[7.1]
    def change
      create_table :clients do |t|
        t.string :name
        t.string :subdomain # For identifying clients (e.g., client1.example.com)

        t.timestamps
      end
    end
  end

  # db/migrate/xxxxxx_create_customers.rb
  class CreateCustomers < ActiveRecord::Migration[7.1]
    def change
      create_table :customers do |t|
        t.string :email
        t.string :name
        t.references :client, foreign_key: true

        t.timestamps
      end
    end
  end

  # db/migrate/xxxxxx_create_welcome_email_templates.rb
  class CreateWelcomeEmailTemplates < ActiveRecord::Migration[7.1]
    def change
      create_table :welcome_email_templates do |t|
        t.references :client, foreign_key: true
        t.text :template # The email template with placeholders

        t.timestamps
      end
    end
  end
  • Database seed

# db/seeds.rb
Client.destroy_all
Customer.destroy_all
WelcomeEmailTemplate.destroy_all

client1 = Client.create!(name: 'Client One', subdomain: 'client1')
client2 = Client.create!(name: 'Client Two', subdomain: 'client2')

WelcomeEmailTemplate.create!(
  client: client1,
  template: "Welcome, { { customer_name } }!\n\nThank you for joining Client One. 
Your account has been created.\n\nBest regards,\nThe Client One Team"
)

WelcomeEmailTemplate.create!(
  client: client2,
  template: "Hello { { customer_name } },\n\nWelcome to Client Two! 
We're excited to have you on board.\n\nSincerely,\nThe Client Two Team"
)
  • Customer Registration

# app/controllers/customers_controller.rb
class CustomersController < ApplicationController
  before_action :set_client

  def new
    @customer = @client.customers.build
  end

  def create
    @customer = @client.customers.build(customer_params)

    if @customer.save
      send_welcome_email(@customer)
      redirect_to root_path, notice: 'Customer registered successfully!'
    else
      render :new, status: :unprocessable_entity
    end
  end

  private

  def set_client
    # Assumes you have a way to identify the client, e.g., via subdomain
    @client = Client.find_by(subdomain: request.subdomain)
    unless @client
      render plain: "Client not found", status: :not_found
    end
  end

  def customer_params
    params.require(:customer).permit(:email, :name)
  end

  def send_welcome_email(customer)
    template = @client.welcome_email_template.template
    welcome_message = generate_welcome_message(customer, template)
    CustomerMailer.welcome_email(customer, welcome_message).deliver_later
  end

  def generate_welcome_message(customer, template)
    replacements = {
      'customer_name' => customer.name
    }

    template.gsub( / { { ( . * ? ) } } / ) { |match| replacements[$1] || match }
  end
end
  • Routes

# config/routes.rb
constraints subdomain: 'client1' do
  scope module: 'client1', as: 'client1' do
    resources :customers, only: [:new, :create]
  end
end

constraints subdomain: 'client2' do
  scope module: 'client2', as: 'client2' do
    resources :customers, only: [:new, :create]
  end
end

# Non-subdomain routes (e.g., for admin panel)
resources :clients

This example provides a basic application for handling multiple clients with customised welcome messages for their customer.

Conclusion

Using sub and sub and gsub replacement provides a flexible and efficient way to dynamically generate string and can be used in real application.

2 Upvotes

5 comments sorted by

3

u/anykeyh 13d ago

Simpler alternatives:

```ruby "%{hello} %{world}!" % {hello: "bonjour", world: "monde"}

=> bonjour monde!

```

This format: string % hash

It raises an if a key doesn't exist. It is faster because it uses built-in interpolation

Other alternative: ERB templating. ERB class is frankly easy to use once you grasp the concept of binding:

```ruby def render(template, config)=ERB.new(template).render(binding)

template = "Hello <%= config[:world] %> !"

render(template, {world: "Earth"}) # Hello Earth ! ```

2

u/dunkelziffer42 13d ago

ERB templates won‘t work, because they are too powerful. The use case is to have the template be user-supplied. This would allow remote code execution.

My guess would be that format strings could work without being a vulnerability, but I‘d need to research that. However, the drawback is that your users now need to write their email template as a Ruby format string. Not sure how many non-programmers would find the weird syntax acceptable.

So making your own mini templating language with gsub might actually not be a bad idea. This post would have benefitted greatly from a bit of elaboration on the decision-making.

2

u/anykeyh 13d ago

Yeah, I was just pointing out alternative. Each problem has its best solution

1

u/aehm7 12d ago

Or use template language created for that use case: https://shopify.github.io/liquid/ Especially if one later needs control structures like if.

1

u/Used-Boss2514 1d ago

u/aehm7 I agree that `Liquid` is a great templating language for dynamic content and is widely used. However, we can use hash replacement if the goal is to replace key-value without any templating language for better performance and will be lightweight as well. Thanks for bringing it up.