STI and multi attributes models in Rails

Once I had a task to collect data from different landing pages to a database. The task is challenging because each web form from a landing page has different inputs and data. I figured out how to write an elegant solution in a Rails application.

Preparation

First, we have to create a migration to store data in DB.

class CreateLandingForms < ActiveRecord::Migration
  def change
    create_table :landing_forms do |t|
      t.string :type
      t.jsonb :data
      t.timestamps
    end
  end
end

The “type” is an important attribute for STI (single table inheritance) because we will have a few models (one model for each landing page) which use the same table in DB. Rails will automatically keep class name to the attribute.

The “data” is an attribute with JSONB data type. Each model has a unique amount of fields. We will store the information in the attribute as JSON.

Models

The main model looks like that. There is nothing special. Other models will be inherited from the main model.

# app/models/landing_form.rb
# == Schema Information
#
# Table name: landing_forms
#
#  id         :bigint           not null, primary key
#  data       :jsonb
#  type       :string
#  created_at :datetime         not null
#  updated_at :datetime         not null
#
class LandingForm < ApplicationRecord
end

Let’s create models to collect data from different web forms. For instance, from 2 landing pages: Christmas’s landing page and Black Friday’s landing page.

Here is a model for Christmas’s landing page. Attributes are full name, phone number, city, state, address and a gift you would like to receive from Santa. :-) Method store_accessor helps us to collect data to the “data” field and work with it such typical ActiveRecord attributes.

# app/models/landing_forms/christmas.rb
module LandingForms
  class Christmas < LandingForm
    store_accessor :data, :full_name, :phone_number, :city, :state, :address, :gift

    validates :full_name, presence: true
    validates :phone_number, presence: true
    validates :city, presence: true
    validates :state, presence: true
    validates :address, presence: true
    validates :gift, presence: true

    def self.permitted_params
      [:full_name, :phone_number, :city, :state, :address, :gift]
    end
  end
end

The next model - is a model for Black Friday’s landing page. Attributes are first name, second name and email. If there is a requirement that email must be unique, we can add custom validation for email.

# app/models/landing_forms/black_friday.rb
module LandingForms
  class BlackFriday < LandingForm
    store_accessor :data, :first_name, :last_name, :email

    scope :by_email, ->(email) { where(["data->>'email' IN (?)", email]) }

    validates :first_name, presence: true
    validates :last_name, presence: true
    validates :email, presence: true

    validate :unique_email

    def self.permitted_params
      [:first_name, :last_name, :email]
    end

    private

    def unique_email
      return if errors.size.positive?
      return if self.class.where(type: type).by_email(email).where.not(id: id).count.zero?

      errors.add(:email, I18n.t('landing_forms.email_already_taken'))
    end
  end
end

You see there is nothing difficult to describe behaviour. In order to prove that the validation works we will write test cases. Fortunately store_accessor works best with FactoryBot.

# spec/factories/landing_form_factory.rb
FactoryBot.define do
  # factory for one model
  factory :black_friday_form, class: LandingForms::BlackFriday do
    first_name { Faker::Name.first_name }
    last_name { Faker::Name.last_name }
    email { Faker::Internet.email }
  end

  # factory for another model
  factory :christmas_form, class: LandingForms::Christmas do
    full_name { Faker::Name.name_with_middle }
    phone_number { Faker::PhoneNumber.phone_number }
    city { Faker::Address.city }
    state { Faker::Address.state }
    address { Faker::Address.full_address }
    gift { Faker::Hipster.sentence }
  end
end

And here are unit tests.

# spec/models/landing_form_spec.rb
require 'rails_helper'

RSpec.describe LandingForms::Christmas, type: :model do
  let(:item) { build(:christmas_form) }

  it 'works' do
    expect(item.save).to eq(true)
  end
end

RSpec.describe LandingForms::BlackFriday, type: :model do
  let(:item) { build(:black_friday_form) }

  it 'works' do
    expect(item.save).to eq(true)
  end

  describe 'unique email validation' do
    let!(:item1) { create(:black_friday_form) }

    context 'email is not the same' do
      let(:item2) { build(:black_friday_form) }

      it 'is valid' do
        expect(item2.valid?).to eq(true)
        expect(item2.save).to eq(true)
      end
    end

    context 'email is the same' do
      let(:item2) { build(:black_friday_form, email: item1.email) }

      it 'is not valid' do
        expect(item2.valid?).to eq(false)
        expect(item2.errors.attribute_names).to include(:email)
        expect(item2.errors[:email]).to include(I18n.t('landing_forms.email_already_taken'))
      end
    end
  end
end

Controllers

In order to receive data from a client, we write endpoints in rails-router, one endpoint for each controller.

Rails.application.routes.draw do
namespace :api do
    namespace :v1 do
      namespace :landing_forms do
        post :black_friday, to: 'black_friday#create'
        post :christmas, to: 'christmas#create'
      end
    end
  end
end

A controller looks like that. Just one public method create. We don’t need anything more.

# app/controllers/api/v1/landing_forms/black_friday_controller.rb
module Api
  module V1
    module LandingForms
      class BlackFridayController < ApplicationController
        def create
          object = model_class.new(model_params)
          if object.valid? && object.save
            render json: { success: true }
          else
            render json: object.errors, status: :unprocessable_entity
          end
        end

        private

        def model_params
          params.permit(*model_class.permitted_params)
        end

        def model_class
          ::LandingForms::BlackFriday
        end
      end
    end
  end
end

Let's write request test cases for the controller to prove that it works.

# spec/requests/api/v1/landing_forms/black_friday_spec.rb
require 'rails_helper'

RSpec.describe Api::V1::SubscriptionsController, type: :request do
  describe '#create' do
    subject { post '/api/v1/landing_forms/black_friday', params: params, as: :json }

    before { subject }

    context 'email validation' do
      let!(:item1) { create(:black_friday_form) }

      context 'email is unique' do
        let(:params) do
          { first_name: Faker::Name.first_name, last_name: Faker::Name.last_name, email: Faker::Internet.email }
        end

        it 'renders success' do
          expect(response).to have_http_status(:ok)
          expect(JSON.parse(response.body)['success']).to eq(true)
        end
      end

      context 'email is the same' do
        let(:params) do
          { first_name: Faker::Name.first_name, last_name: Faker::Name.last_name, email: item1.email }
        end

        it 'returns errors' do
          expect(response).to have_http_status(:unprocessable_entity)
          json = JSON.parse(response.body)
          expect(json.keys).to eq(['email'])
          expect(json['email']).to include(I18n.t('landing_forms.email_already_taken'))
        end
      end
    end
  end
end

Voilà! It works. Now it’s super easy to collect data from one more web form. We need:

  • Create a new model and describe all attributes and validations
  • Create a new endpoint in the route and a controller

36