Matthewの備忘録

忘れたときはここを見ろ。何か書いてある。

RoRでDBテーブルのカラムと一致しない項目がある入力フォームを実現する その3

 前回まではフォーム専用の代理クラスを作っていたが、特異メソッドを加えることでモデルにない属性をフォーム入力項目にしてしまえるもう一つの方法があるので記しておく。

特異メソッド

 特異メソッドについては次を参考にすればよい:

 特異メソッドとは、簡単にいえば、インスタンスに追加されたメソッドである。任意のインスタンスに、そしてそれ以外のクラスや他インスタンス等に何の影響もなく、メソッドが動的に加えられるのである。この特異メソッドでフォームだけで使う属性のアクセサーメソッドを登録してしまうというのうが今回の方法である。

実現方法

 例えば、住所録のAdressBookなるモデルがあって、その属性値がaddress、フォームでは都道府県のstate、市区町村のcity、そして町名字番地等のstreetを入力させ、半角空白文字でつないでadddressに一旦格納したのちにDBで永続化されるとしよう。

 この例では、付け加えた属性しかフォームで利用していないが、元々モデルにある属性もフォームのフィールド(入力項目)として並列利用できることを明確にしておく。

モデル

 インスタンス(オブジェクト)自身にインスタンス変数、そのアクセサー、そして必要があればバリデーションを実行させるメソッドを加える。また、今回は簡便のために、モデルに三つの入力値(state, city, street)から半角空白文字で繋げた文字列を生成するクラスメソッド(get_address_from)も加えている。

class AddressBook < ApplicationRecord
  def add_form_attributes
    self.singleton_class.class_eval{
      attr_accessor :state, :city, :street
      validates :state,  presence: true
      validates :city,   presence: true
      validates :street, presence: true
    }
  end

  def self.get_address_from(params)
    params["state"].to_s + ' ' + 
      params["city"].to_s + ' ' + 
      params["street"].to_s
  end
end

 self.singleton_class.class_evalが肝心なところでる。class_evalではなくinstance_evalを使いたいところだが、singleton_class(特異クラス)を態々呼び出して特異メソッドを定義させている。このあたりは、もっと詳しく理解して記しておくべきだが、後日にする。今はこのやり方で多くの場合に対応できるはずだ。また、このメソッドを何回実行しても登録されるのは最初の一回だけであり、2回目以降はエラーは出ない。

参考:

インスタンス変数とそのアクセサーが要らなくなったら

 因みに、必要が無くなったらremove_methodとremove_instance_variableを使って加えたメソッドやインスタンス変数を削除することができる。フォーム用に使う場合は、殆どの場合、そうする必要はないだろう。

 バリデーションを削除したい場合は・・・後日にする。

参考:

ビュー

 最初にscaffoldで生成していたとすると、手を加えるのは_form.html.erbだけで済み、前回までの方法より手間が少ない。フォームの為に加えた属性を、モデル元来の属性と同じように利用できる:

<%= form_for(address_book) do |f| %>
  <% if address_book.errors.any? %>
    <div id="error_explanation">
      <h2><%= pluralize(address_book.errors.count, "error") %>
        prohibited this address_book from being saved:</h2>

      <ul>
      <% address_book.errors.full_messages.each do |message| %>
        <li><%= message %></li>
      <% end %>
      </ul>
    </div>
  <% end %>

  <div class="field">
    <%#= f.label :address %>
    <%#= f.text_field :address %>
  </div>

  <div class="field">
    <%= f.label :state %>
    <%= f.text_field :state %>
  </div>

  <div class="field">
    <%= f.label :city %>
    <%= f.text_field :city %>
  </div>

  <div class="field">
    <%= f.label :street %>
    <%= f.text_field :street %>
  </div>

  <div class="actions">
    <%= f.submit %>
  </div>
<% end %>

尚、上記のコードにはscaffoldで生成したaddressはコメントアウトして残してある。

コントローラー

 フォーム専用の属性を加えた属性間の情報受け渡し時のフィルタリング/コンバート処理や、ストロングパラメーターの処理をそれ用に変更するだけで、ロジックは変わりがなく、この点も前回までの専用のクラスを用意するよりは簡便である。

 コンストラクターをオーバーライドして自作のコンストラクターを加えたり、before_actionなどフックを使ってフィルター/コンバーターの役目をするメソッドを作って、もっと簡潔に出来るとは思うが、new/createそしてedit/updateの処理の差異を明示するために敢えて残した:

class AddressBooksController < ApplicationController
  # before_action :set_address_book, only: [:show, :edit, :update, :destroy]
  before_action :set_address_book, only: [:show, :destroy]
  # GET /address_books
  # GET /address_books.json
  def index
    @address_books = AddressBook.all
  end

  # GET /address_books/1
  # GET /address_books/1.json
  def show
  end

  # GET /address_books/new
  def new
    @address_book = AddressBook.new
    @address_book.add_form_attributes
  end

  # GET /address_books/1/edit
  def edit
    @address_book = AddressBook.find(params[:id])
    @address_book.add_form_attributes
    @address_book.state, @address_book.city, @address_book.street =
      @address_book.address.to_s.split(' ')
  end

  # POST /address_books
  # POST /address_books.json
  def create
    @address_book = AddressBook.new(address_book_params)
    @address_book.add_form_attributes
    @address_book.state  = params[:address_book][:state]
    @address_book.city   = params[:address_book][:city]
    @address_book.street = params[:address_book][:street]
    respond_to do |format|
      if @address_book.save
        format.html { redirect_to @address_book, 
          notice: 'Address book was successfully created.' }
        format.json { render :show, status: :created, 
          location: @address_book }
      else
        format.html { render :new }
        format.json { render json: @address_book.errors, 
          status: :unprocessable_entity }
      end
    end
  end

  # PATCH/PUT /address_books/1
  # PATCH/PUT /address_books/1.json
  def update
    @address_book = AddressBook.find(params[:id])
    @address_book.add_form_attributes
    @address_book.state  = params[:address_book][:state]
    @address_book.city   = params[:address_book][:city]
    @address_book.street = params[:address_book][:street]
    respond_to do |format|
      if @address_book.update(address_book_params)
        format.html { redirect_to @address_book, 
          notice: 'Address book was successfully updated.' }
        format.json { render :show, status: :ok, 
          location: @address_book }
      else
        format.html { render :edit }
        format.json { render json: @address_book.errors, 
          status: :unprocessable_entity }
      end
    end
  end

  # DELETE /address_books/1
  # DELETE /address_books/1.json
  def destroy
    @address_book.destroy
    respond_to do |format|
      format.html { redirect_to address_books_url, 
        notice: 'Address book was successfully destroyed.' }
      format.json { head :no_content }
    end
  end

  private
    # Use callbacks to share common setup or constraints between actions.
    def set_address_book
      @address_book = AddressBook.find(params[:id])
    end

    # Never trust parameters from the scary internet, 
    # only allow the white list through.
    def address_book_params
      # params.require(:address_book).permit(:address)
      {"address" => AddressBook.get_address_from(
        params.require(:address_book).permit(:state, :city, :street))}
    end
end

i18n

 
 前回までのモデル専用クラスのようにActiveModel用に書くのではなく、ActiveRecordのモデルに継ぎ足せる:

ja:
  activerecord:
    models:
      address_book: "住所録"
    attributes:
      address_book:
        address: "住所"
        state: "都道府県"
        city: "市区町村"
        street: "町名字番地等"
  errors:
    format: "%{attribute}%{message}"
    messages:
      blank: を入力してください