Rails 有許多組織程式碼的 pattern, 一個經常被提到的例子是 form object.

Form object 主要使用在你需要創造表單, 但該表單不直接與某個特定的 model 有關, 這類的情境有很多, 例如搜尋表單, 你可能不會想把當下搜尋的參數儲存到資料庫, 但你依然想對輸入的資料進行驗證, 如必須輸入關鍵字, 日期必須在一個月內, … 這時候就可以自行創造一個 SearchForm model 專門負責處理搜尋表單的資料驗證.

以前經常使用 Virtus 實作, 但它已經不再更新了, 而它的接班人 dry-rb 又有點… 雖然強大, 太過於複雜了, 而且許多地方的設計與 Rails 格格不入, 想要開心使用的話必須要做很多工, 而且還要說服其他同事接受, 真的有點困難.

其實 Rails 的 ActiveModel 提供了非常多的的小工具, 可以幫助我們設計 form object, 而且可以輕易的與 form helper 接軌, 網路上也有非常多相關的文章. 那我為什麼要寫這篇呢? 因為最近有人正好問到我, 我發現有許多資料都有點舊了 (但依然是值得去閱讀的, 我不打算重複寫太多原理), Rails 6 有著更多內建方便的好東西去把這件事情做更好, 因此我想分享的是一個較新, 並且可簡單驗證與測試的 pattern.

通用 form object module, 可定義欄位並強制轉型, 以及經常使用的 callbacks:

module ApplicationForm
  extend ActiveSupport::Concern
  include ActiveModel::Model
  include ActiveModel::Attributes
  include ActiveModel::Validations::Callbacks

  included do
    # adds before_call, after_call
    define_model_callbacks :call
  end

  def save
    validate.tap do |result|
      run_callbacks(:call) { call } if result
    end
  end

  def inspect
    "#<#{self.class.name} #{attributes}>"
  end
end

直接 include 它, 定義需要的東西即可. 以下是搜尋表單範例, 限制關鍵字最短長度, 去除頭尾空白:

class SearchForm
  include ApplicationForm
  
  attribute :keyword, :string
  attr_reader :result
  
  validates :keyword, length: { minimal: 2 }

  before_validation :strip_keyword

  def strip_keyword
    self.keyword = keyword.try(:strip)
  end

  def call
    @result = Model.search(keyword)
  end
end

使用方法:

@form = SearchForm.new(params[:form])
if @form.save
  @models = @form.result
else
  render :search
end
<%= simple_form_for @form do |f| %>
  <%= f.input :keyword %>
  <%= f.button :submit %>
<% end %>

單元測試:

require 'rails_helper'

RSpec.describe SearchForm, type: :model do
  context 'keyword is too short' do
    subject(:form) { SearchForm.new(keyword: 'x') }
    # not good if you have many attributes to check
    it { is_expected.to be_invalid }

    # just check errors for passed attribute
    it 'should have error on keyword' do
      form.validate
      expect(form.errors[:keyword]).to include('is too short (minimum is 2 characters)')
    end

    # or use rspec-collection_matchers, the recommended way
    it { is_expected.to have(1).error_on(:keyword) }
  end

  context 'keyword is valid' do
    subject(:form) { SearchForm.new(keyword: 'ayaya') }
    it 'finds models' do
      form.save
      expect(form.result).to be_present
    end
  end
end

對於較複雜的搜尋, 我則建議 form object 只進行參數檢查, 檢查完後可將資料交由專門產生查詢的 query object 處理.