はじめに
データベースやマイグレなしで実行される単純なテストクラスを作成したいとき、APIを使わずに変更されないデータをモデルファイル内に直接記述したいとき、どうやって実装するか困ることあるかと思います
今回はそれを解決するためのactive_hashのgemを用いた実装方法と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: ルートを設定
今回はトップページのルート側にリソースを追加します
root "static_pages#home"
# 以下省略
resources :quizzes, only: %i[index show new create destroy]
2: ActiveYamlのモデルをセットアップする
Gemfileに以下を追加して、ライブラリをインストールします
gem 'active_hash'
bash環境でインストールする場合
bundle install
Dockerの仮想環境でインストールする場合
docker compose run web bundle install
3: モデルを生成
今回はquizモデルとquestionモデルを生成します
active_hashを使って実装するので、マイグレは生成しないようコマンドに
--skip-migration
を付け加えるのを忘れないでください
Quizモデル
rails generate model Quiz --skip-migration
app/models/quiz.rb
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
Questionモデル
rails generate model Question --skip-migration
app/models/question.rb
yamlで定義したデータを使用したいモデルに読み込みます
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
- id: 1 # 問題数
content: パンはパンでも食べられないパンは? # 問題のタイトル
choices: # 選択肢
- カトパン
- フライパン
- ナカパン
answers: # 回答
- フライパン
explain: "つまりはおやじギャグ" # 解説
- id: 2
// 以下省略
5: コントローラー側の生成
modelとviewをつなぎ動かすためのコントローラを生成します
app/controllers/quizzes_controller.rb
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
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は追加していないので、デザインについては各個人で任せることにします
問題画面フォーム
(コントローラー側の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 %>