RoRでDBテーブルのカラムと一致しない項目がある入力フォームを実現する その3
前回まではフォーム専用の代理クラスを作っていたが、特異メソッドを加えることでモデルにない属性をフォーム入力項目にしてしまえるもう一つの方法があるので記しておく。
特異メソッド
特異メソッドについては次を参考にすればよい:
- Rubyist Magazine - Ruby 初級者のための class << self の話 (または特異クラスとメタクラス)
- 特異クラス・特異メソッド・メソッドの種類 Ruby - Qiita
- Ruby、Pythonでインスタンス変数を動的に取得/設定する - Misc Notes
特異メソッドとは、簡単にいえば、インスタンスに追加されたメソッドである。任意のインスタンスに、そしてそれ以外のクラスや他インスタンス等に何の影響もなく、メソッドが動的に加えられるのである。この特異メソッドでフォームだけで使う属性のアクセサーメソッドを登録してしまうというのうが今回の方法である。
実現方法
例えば、住所録の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回目以降はエラーは出ない。
参考:
ビュー
最初に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: を入力してください