active_hashを使って何が作れるか知りたいあなたに

# はじめに
データベースやマイグレなしで実行される単純なテストクラスを作成したいとき、APIを使わずに変更されないデータをモデルファイル内に直接記述したいとき、どうやって実装するか困ることあるかと思います
今回はそれを解決するためのactive_hashのgemを用いた実装方法とactive_hashで何ができるのかについて説明いたします。

# active_hashとは
https://github.com/active-hash/active_hash

ActiveRecord のようなモデルの読み取り専用データソースとして Ruby ハッシュを使用できるようにするシンプルな基本クラスです。

すべてのハッシュに:idキーがあることを前提としていますが、データベースに保存されるであろうキーです。これにより、アプリのコードやデータベースの外部キーを変更することなく、ActiveHashオブジェクトから完全なActiveRecordオブジェクトにシームレスでアップグレードできます。

また、AR オブジェクトで #has_many と #belongs_to (belongs_to_active_hash 経由) を使用することも可能です。

ActiveHash には以下のものも同梱されています:
* 1:ActiveFile: ファイルデータソースを作成するために使用できる基本クラス
* 2:ActiveYaml: YAMLをハッシュに変換し、そのデータをActiveHashオブジェクトにロードする基本クラス

## どういうときに便利なのか
都道府県名やカテゴリー選択などの変更されないデータをモデルファイル内に直接記述することで、データベースへ保存せずにデータを取り扱うことができます。

商品を登録するフォームなどでcollection_selectを使うときの実装に便利だと考えます


# 対応方法
今回はトップページにクイズ機能をactive_hashを用いて実装していきます

Railsの実装なので、MVCモデルの形で進めていきます

下記の技術を用いて、環境構築しています
rails newの環境構築は各自で進めていきましょう
###### 使用技術
|ツール| 内容 |
| -------- | -------- |
| フロントエンド|HTML,CSS,Node.js 20.17.0,esbuild|
| バックエンド| Rails 7.2.1, (ruby 3.2.3 (2024-01-18 revision 52bb2ac0a6) [x86_64-linux])|
| データベース| PostgreSQL|
| 開発環境|Docker|

## 1: ルートを設定
今回はトップページのルート側にリソースを追加します
````ruby
root "static_pages#home"
# 以下省略
resources :quizzes, only: %i[index show new create destroy]
````

## 2: ActiveYamlのモデルをセットアップする
Gemfileに以下を追加して、ライブラリをインストールします
````ruby
gem 'active_hash'
````
bash環境でインストールする場合
````bash
bundle install
````
Dockerの仮想環境でインストールする場合
````bash
docker compose run web bundle install
````

## 3: モデルを生成
今回はquizモデルとquestionモデルを生成します
active_hashを使って実装するので、マイグレは生成しないようコマンドに
````
--skip-migration
````
を付け加えるのを忘れないでください
<br>
Quizモデル
````
rails generate model Quiz --skip-migration
````
app/models/quiz.rb
````ruby
class Quiz
  include ActiveModel::Model
  # ActiveModel::Modelモジュールには、
  # Action PackやAction ViewとやりとりするためのActiveModel::APIが
  #デフォルトで含まれており、モデル的なRubyクラスを実装する場合はこの方法が推奨されています。
  include ActiveModel::Attributes
  # ActiveModel::Attributesモジュールを利用することで、
  # データ型の定義、デフォルト値の設定、PORO（プレーンなRubyオブジェクト）のキャストや
  # シリアライズの処理が可能になります。
  # これは、フォームデータで通常のオブジェクトの日付やブーリアン値などに対して
  # Active Recordと同じような変換を行うときに便利です。
  
  attr_accessor :question_id, :selected_answers

  validates :selected_answers, presence: true
end
````
<br>
Questionモデル

````
rails generate model Question --skip-migration
````

app/models/question.rb
yamlで定義したデータを使用したいモデルに読み込みます
````ruby
class Question < ActiveYaml::Base
    set_root_path "app/models/active_hash"
    # app/models/active_hash/questions.ymlのにつなげて、問題を表示できるようにしています
    set_filename "questions"
end
````
## 4: YAMLファイルの作成: ファイルを作成し、データを記述
ここで問題の素材を作ります
app/models/active_hash/questions.yml
````yml
- id: 1 # 問題数
  content: パンはパンでも食べられないパンは？ # 問題のタイトル
  choices: # 選択肢
    - カトパン
    - フライパン
    - ナカパン
  answers: # 回答
    - フライパン
  explain: "つまりはおやじギャグ" # 解説
- id: 2
// 以下省略
````
## 5: コントローラー側の生成
modelとviewをつなぎ動かすためのコントローラを生成します
app/controllers/quizzes_controller.rb
````ruby
class QuizzesController < ApplicationController
    skip_before_action :require_login, only: [:new, :create]
    # クイズの結果を表示するアクション
    def index
      # 正解した問題数をすべて表示する
      @correct_count = session[:correct_count]
      @questions = Question.all
    end
    # 問題画面フォームの表示サクション
    def new
      if !params[:count]
      # 最初に、まだ「正解数（score）」が設定されていなければ
        session[:correct_count] = 0
        #「正解数（score）」は0として設定します
      end
      
      quiz_count = params[:count] || 1
      @quiz = Quiz.new
      @question = Question.find(quiz_count)
      # 問題を取り出して、画面に表示する準備します
    end
    
    # クイズの答えを送信してくれた後に、その答えが合っているかどうかをチェックするアクション
    def create
      @question = Question.find(quiz_params[:question_id])
      @select_answers = quiz_params[:selected_answers]
      @is_correct = @question.answers.sort == @select_answers.sort
      @questions_count = Question.all.count
      session[:correct_count] = session[:correct_count].to_i + 1 if @is_correct
      # 例えば、選んだ答えが問題の答えと合っていたら「正解！」と言って正解数を1増やします。
      # 不正解なら「不正解！」と言って、そのまま次の問題を準備します。

      # コードの中では、quiz_paramsを使って送られてきた答え（選択肢）を取り出し、
      # それが正しい答えと一致しているかどうかを調べています。
      # そして、一致していれば正解数を更新する仕組みです。

    end
  
    private
    # ストロングパラメーターで問題数(:question_id)と選択肢(selected_answers:)の答えが
    # 格納されています
    def quiz_params
      params.require(:quiz).permit(:question_id, selected_answers: [])
    end
end
````
クイズで答えを選ぶときに「送信ボタン」をうまく動かす仕組みを作るため、jsを作成します
app/javascript/controllers/quiz_controller.js
````js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = ["checkbox", "submitButton"];
  
  /*クイズの画面が開いたときに「まずボタンの状態を確認する」作業をしてくれます。*/
  connect() {
    this.toggleButton();
  }

  /*「答えを選んだかどうかを調べる魔法」です。
  全部の答え（チェックボックス）を見て、「どれか1つでも選ばれているか？」を確認します。*/
  toggleButton() {
     /*もし選んでいれば、「ボタンを押せるようにする」*/
    const anyChecked = this.checkboxTargets.some((checkbox) => checkbox.checked);
     /*何も選んでいなければ、「ボタンを押せないようにする」*/
    this.submitButtonTarget.disabled = !anyChecked;
  }

  /*答えをクリック（選択）したときに、もう一度ボタンの状態を確認してくれます。*/
  check() {
    this.toggleButton();
  }
}
````
## 6: view画面を生成
問題画面の表示フォーム、全問回答結果を表示させるためのフォーム、クイズの答えを確認して正解か不正解かを判定しているフォームを作成します
今回CSSは追加していないので、デザインについては各個人で任せることにします
<br>
### 問題画面フォーム
(コントローラー側のnewアクションに当たる部分で、問題が表示され、回答ができます。問題、設問番号はyamlファイルから紐づいています)
app/views/quizzes/new.html.erb
````
<div>
  <div>
    <div>
      <div>
        <div>
          <h2><%= @question.content %></h2>

          <div data-controller="quiz">
            <%= form_with model: @quiz, url: quizzes_path do |f| %>
              <%= f.hidden_field :question_id, value: @question.id %>

              <div>
                <% @question.choices.shuffle.each do |choice| %>
                  <div>
                    <label id="answers_<%= choice %>_label">
                      <%= f.check_box :selected_answers, { multiple: true, name: 'quiz[selected_answers][]', id: "answers_#{choice}", data: { action: "quiz#check", quiz_target: "checkbox" } }, choice, nil %>
                      <%= simple_format(choice) %>
                    </label>
                  </div>
                <% end %>
              </div>

              <div id="answer_submit">
                <%= f.submit '回答する', data: { quiz_target: "submitButton" } %>
              </div>
              <div id="answer"></div>
            <% end %>
          </div>
        </div>
      </div>
    </div>
  </div>
</div>
````

### 全問回答結果フォーム
(コントローラー側のindexアクションに当たる部分で、何問正解したかと、問題の振り返りができます。問題、設問番号、解説はyamlファイルから紐づいています)
app/views/quizzes/index.html.erb
````
<div>
  <div>
    <div>
      <div>
        <div>
          <h2>結果</h2>
          <p>正解数: <%= @correct_count %> / <%= @questions.count %></p>
        </div>
      </div>

      <% @questions.each_with_index do |question, index| %>
        <div>
          <div>
            <h3>問題 <%= index + 1 %>: <%= question.content %></h3>

            <div>
              <p>正解:</p>
              <ul>
                <% question.answers.each do |correct_answer| %>
                  <li><%= correct_answer %></li>
                <% end %>
              </ul>
            </div>

            <div>
              <p>解説:</p>
              <p><%= question.explain %></p>
            </div>
          </div>
        </div>
      <% end %>
    </div>
  </div>
</div>
````
### クイズの答えを確認して正解か不正解かを判定しているフォーム
(コントローラー側のcreateアクションに当たる部分で、判定と一緒に解説が付属されます。
解説はyamlファイルから紐づいています)
app/views/quizzes/create.tarbo_stream.erb
````
<% @question.choices.each do |choice| %>
  <%= turbo_stream.replace "answers_#{choice}_label" do %>
    <div>
      <% if @select_answers.include?(choice) %>
        <span><%= choice %></span>
      <% else %>
        <span><%= choice %></span>
      <% end %>
    </div>
  <% end %>
<% end %>

<%= turbo_stream.remove "answer_submit" %>

<%= turbo_stream.replace 'answer' do %>
  <div>
    <% if @is_correct %>
      <h2>✔ 正解！</h2>
    <% else %>
      <h2>✖ 不正解</h2>
      <p>正解は：</p>
      <ul>
        <% @question.answers.each do |answer| %>
          <li><%= answer %></li>
        <% end %>
      </ul>
    <% end %>

    <div>
      <%= simple_format(@question.explain) %>
    </div>

    <div>
      <% if @question.id < @questions_count %>
        <%= link_to '次の問題', new_quiz_path(count: @question.id + 1) %>
      <% else %>
        <%= link_to '結果を見る', quizzes_path %>
      <% end %>
    </div>
  </div>
<% end %>
````
# 番外編:active_hashにi18nの機能を追加実装するには

active_hashにi18nの機能を追加して、翻訳させることもできます
最初に、i18nに関しては下記の記事をご確認ください

https://qiita.com/jiantaiyiteng1/items/b017ca87ab11f43679f1

Railsのi18nのデフォルト設定を記述までできたら、ymlファイルにi18nを追加していきます
ja.ymlやen.ymlなどを別途作成するのではなく、active_hashのymlファイルに追加します
app/models/active_hash/questions.yml
````yml
- id: 1 # 問題数
  content: 
   ja: パンはパンでも食べられないパンは？ # 問題のタイトル
   en: "Bread is bread, but what bread can't you eat?"
  choices: # 選択肢
   ja:
    - カトパン
    - フライパン
    - ナカパン
   en:
   - Katopan
   - Frypan
   - Nakapan
  answers: # 回答
    
    - フライパン
  explain:  # 解説
   ja: "つまりはおやじギャグ"
   en: "gonyogonyogonyo"
- id: 2
// 以下省略
````

app/models/question.rb
yamlで定義したデータを使用したいモデルにi18n機能が実装できるようアクションを追加します
````ruby
class Question < ActiveYaml::Base
    set_root_path "app/models/active_hash"
    # app/models/active_hash/questions.ymlのにつなげて、問題を表示できるようにしています
    set_filename "questions"

    # 
    def localized_content
        content[I18n.locale.to_s]
    end
      
    def localized_choices
        choices[I18n.locale.to_s]
    end
      
    def localized_answers
        answers[I18n.locale.to_s]
    end
      
    def localized_explain
        explain[I18n.locale.to_s]
    end
end
````
最後にクイズの答えを確認して正解か不正解かを判定しているフォーム(app/views/quizzes/create.turbo_stream.erb)
全問回答結果フォーム(app/views/quizzes/index.html.erb)
問題画面フォーム(app/views/quizzes/new.html.erb)

をi18n機能が実装できるアクションにそって、問題と答えと解説にi18n機能を追加できたら実装完了です