内部結合を利用したあいまい検索時にsyntax errorで詰まった話
概要
whereメソッドとjoinメソッドを使用して、内部結合を利用したあいまい検索をした時、syntax error
で詰まった話。
失敗時のコード
掲示板に関して、作成ユーザー、掲示板タグ、掲示板タイトル、掲示板コンテンツ(記事内容)で検索を行おうとして以下のような実装をした所、掲示板コンテンツの部分でsyntax errorが表示された。作成ユーザー、掲示板タグはセレクトボックスによる選択、掲示板タイトル、掲示板コンテンツはフリーワード検索で行えるようにしてある。
# models/board.rb class Board < ApplicationRecord belongs_to :user has_many :board_tags has_many :contents has_many :tags, through: :board_tags scope :by_user, ->(user_id) { where(user_id: user_id) } scope :by_board_tag, ->(tag_id) { joins(:board_tags).where(board_tags: { tag_id: tag_id }) } scope :title_contain, ->(word) { where('title LIKE ?', "%#{word}%") } scope :body_contain, ->(word) { joins(:contents).where(contents: { 'contents.body LIKE ?', "%#{word}%" }) } end
# controllers/boards_controller.rb class BoardsController < ApplicationController def index @search_boards = SearchBoards.new(search_params) @boards = @search_boards.search.order(id: :desc) end private def search_params params[:q]&.permit(:title, :body, :tag_id, :user_id) end
# forms/search_boards.rb class SearchBoards include ActiveModel::Model include ActiveModel::Attributes attribute :user_id, :integer attribute :tag_id, :integer attribute :title, :string attribute :body, :string def search relation = Board.distinct relation = relation.by_user(user_id) if user_id.present? relation = relation.by_board_tag(tag_id) if tag_id.present? title_words.each do |word| relation = relation.title_contain(word) end body_words.each do |word| relation = relation.body_contain(word) end relation end private def title_words title.present? ? title.split(nil) : [] end def body_words body.present? ? body.split(nil) : [] end end
# views/boards/index.html.slim .ul.list-inline li = form_with model: @search_boards, scope: :q, url: boards_path, method: :get, html: { class: 'form-inline' } do |f| => f.select :user_id, User.pluck(:name, :id) , { include_blank: true }, class: 'form-control' => f.select :tag_id, Tag.pluck(:name, :id) , { include_blank: true }, class: 'form-control' .input-group = f.search_field :title, placeholder: "タイトル", class: 'form-control' .input-group = f.search_field :body, placeholder: "本文", class: 'form-control' span.input-group-btn = f.submit '検索', class: %w[btn btn-default btn-flat]
エラーを見ると、Boardモデルに書いたscope :body_contain
の部分で構文エラーが発生していたみたい。
SyntaxError - syntax error, unexpected ',', expecting => ...ents: {'contents.body LIKE ?', "%#{word}%"}) } ... ^ /Users/yoshitaka/Desktop/app/models/article.rb:74: syntax error, unexpected ')', expecting end ...nts.body LIKE ?', "%#{word}%"}) } ... ^: app/models/board.rb:74:in `' app/controllers/boards_controller.rb:7:in `index'
原因
何故こうなったかを調べると、内部結合を利用して結合先のテーブルで文字列検索を実行する時は一致検索時とは異なり、.where(結合先のテーブル名: { }
の結合先テーブル部分を書く必要がないことが原因だった。
セレクトボックスで一致検索をする場合、内部結合を利用した一致検索を行うコードは以下のように、whereメソッドの引数に「結合先のテーブル名: { カラム名: 値 }」を指定する事で検索できます。
モデル名.joins(:関連名).where(結合先のテーブル名: { カラム名: 値 }) # article_tagsテーブルのtag_idカラムが1のレコードを全て取得 Board.joins(:board_tags).where(board_tags: { tag_id: 1 }) }
しかしフリーワードであいまい検索をする場合、whereの条件部分に.where(結合先のテーブル名.カラム名 LIKE 〜)
といったように、結合先のテーブル名を書くことで結合先のテーブルに対して検索を行なってくれます。
# sentencesテーブルのbodyカラムに「hoge」という文字が入っているレコードを全て取得 Board.joins(:contents).where('contents.body LIKE ?', "%hoge%") }
解決方法
なので、whereの後ろに結合先のテーブル名を書かずに文字列検索用のコードを直後に書いたらエラーが解消した。
scope :body_contain, ->(word) { joins(:contents).where(contents: { 'contents.body LIKE ?', "%#{word}%" }) } => scope :body_contain, ->(word) { joins(:contents).where('contents.body LIKE ?', "%#{word}%") }
Rakeタスクのテストをする方法
実装したいこと
前回の記事で実装したrakeタスクの内容をRSpecでテストしたい。
備忘録として記事に残してます。
前提
RSpec
、FactoryBot
は既に設定されているものとして記述してます。
実装
1. specディレクトリ配下にrake_helper.rb
を作成し、設定を記述する。
require 'rails_helper' require 'rake' RSpec.configure do |config| config.before(:suite) do Rails.application.load_tasks # Load all the tasks just as Rails does (`load 'Rakefile'` is another simple way) end config.before(:each) do Rake.application.tasks.each(&:reenable) # Remove persistency between examples end end
before(:suite)
:テスト実行時、ただ一度だけブロック内の処理を実行します。
Rails.application.load_tasks
:lib/tasks配下のすべての.rakeファイルを読み込んでいる。
before(:each)
:各テストケースの前にブロック内の処理を都度実行します。
Rake.application
:Rake::Application
のインスタンスを生成しており、読み込むファイルの拡張子やファイル名を定義している。 また、読み込む際のクラスも指定している。
reenable
:インスタンスを再度実行できるメソッド。Rakeタスクのテストを実行する場合、各テストケース間の結合性は邪魔になるので、各Rakeタスクを実行した履歴を消去した上で、再度利用できるようにしている。
2. specディレクトリ配下にrake_helper.rb
を作成し、設定を記述する。
テストの内容は前回の記事にも記載したように、「「公開待ち」の記事に対して、公開日時が過去になっているものがあれば、状態を「公開」に変更する」タスクを想定してある。
FactoryBot.define do factory :article do sequence(:title) { |n| "title-#{n}" } sequence(:slug) { |n| "slug-#{n}" } state { :draft } end end
require 'rake_helper' describe 'article_state:update_article_state' do subject(:task) { Rake.application['article_state:update_article_state'] } before do create(:article, state: :publish_wait, published_at: Time.current - 1.day) create(:article, state: :publish_wait, published_at: Time.current + 1.day) create(:article, state: :draft) end it 'update_article_state' do expect { task.invoke }.to change { Article.published.size }.from(0).to(1) end end
invoke
メソッドでRakeタスクを実行してある。他にはexecute
メソッドでも実行できる。
参考記事
Rakeタスクとwheneverを使用して、定期実行したい処理を回す方法
そもそもの定義
rakeタスクとは
Rake
とは:rubyで処理内容を定義できるビルドツールのこと。
Rakeタスク
とは:Rakeが実行する処理内容を「Rakeタスク」と呼ぶ。Rakeタスクを定義する場所を「Rakefile」と呼ぶ。
wheneverとは
whenever
とは:cronと呼ばれる、UNIX系のOSでは標準で備わってある仕組みを、rubyの簡単な文法で設定できるようにしたライブラリのこと。
cron
とは:「○時になったら○○のコマンドを実行」などといった具合に、定期的にコマンドを実行するためにメモリ場で常に命令を待機しているプロセス(=デーモンプロセス)のこと。
Rakeタスクの実装方法
1. Rakeタスクのファイルを作成
以下のコマンドを実行する
% rails g task <タスクの名前>
コマンドを実行することでlib/tasks/<タスクの名前>.rake
という新しいファイルが生成される。このファイルにRakeタスクを記入していく。
2. タスクに処理を記入する
今回は「「公開待ち」の記事に対して、公開日時が過去になっているものがあれば、状態を「公開」に変更する」といったRakeタスクを作成する。以下が実装したコードになる。
namespace :publish_article do desc "記事の公開日が過去の日時になったら、ステータスが「公開待ち」の記事を「公開」にする" task change_published: :environment do Article.where(status: :publish_wait).find_each do |article| if article.published_at <= Time.current article.update( state: :published ) end end end end
task change_published:
で区切られたブロック内にRakeタスクの内容を記述する。chenge_published:
の部分の名前は適切な名前に変更可能です。
:environment
の記述はデータベースに接続する場合必要になる。今回はArticleクラスのモデルに接続する必要があるので記載してある。
Articleモデルのスキーマ情報は以下の通り
# models/article.rb # == Schema Information # # id :bigint not null, primary key # author_id :bigint # uuid :string(255) # title :string(255) # description :text(65535) # body :text(65535) # state :integer default("draft"), not null # published_at :datetime # created_at :datetime not null # updated_at :datetime not null # deleted_at :datetime # # Indexes # # index_articles_on_author_id (author_id) # index_articles_on_deleted_at (deleted_at) # index_articles_on_published_at (published_at) # index_articles_on_uuid (uuid) # class Article < ApplicationRecord belongs_to :author enum state: { draft: 0, published: 1, publish_wait: 2 } end
3. 作成したRakeタスクが存在するか確認する
以下のコマンドを実行する
% bundle exec rake -T # 出力結果の中に2.で作成したRakeタスクの名前(今回だとchange_publishedの部分)が出力されているばOK ・・・ rake publish_article:change_published ・・・
4. Rakeタスクを実行する
% bundle exec rake publish_article:change_published
ログを確認してエラーが発生しておらず、Rakeタスクが実行されていればOK。
wheneverの使用方法
1. wheneverをインストールした後、以下のコマンドを実行する
# Gemfile gem 'whenever' => bundle installコマンドを実行
以下のコマンドを実行することで、config配下にschedule.rbというファイルが生成される。
% bundle exec wheneverize .
2. schedule.rbに設定と実行する処理内容を記載する
今回は「日付が変わる毎に先ほど記述したRakeタスクを実行する」ことにした。以下が実装したコードになる。
# Rails.rootを使用するために必要 require File.expand_path(File.dirname(__FILE__) + '/environment') # cronを実行する環境変数 rails_env = ENV['RAILS_ENV'] || :development # cronを実行する環境変数をセット set :environment, rails_env # cronのログの吐き出し場所 set :output, "#{Rails.root}/log/cron.log" every 1.day, at: '0:00' do rake 'publish_article:change_published' end
require
の部分はRails.rootを使用するためのコードです。
wheneverはRailsとは切り離されたもので単なるrubyのファイルとなっており、Railsとは関係のない単なるrubyのファイルの中でRailsのメソッドを使いたい場合、今回のような記述が必要となってきます。
rails_env
の部分はENV['RAILS_ENV']で環境を判断し、何も入っていなければ:developmentをrails_envに代入するようにしています。
処理内容の書き方については、今回は「Rakeタスクを毎日実行する」ようにしてあるのでrake
を使用しました。他の書き方については
runner "Rails内のメソッドを実行" command "bashコマンドを実行"
といったものがある。
3. crontabコマンドでcronを更新する
schedule.rbを変更しただけでは変更は反映されないので、以下のコマンドを実行することでcronにデータを反映させる。
% bundle exec whenever --update-crontab
次にcrontab -l
コマンドを実行して、先ほど反映させた定期実行タスクが存在するかを確認できる。
% crontab -l # Begin Whenever generated tasks for: (Rails_ROOT)/config/schedule.rb at: 2021-05-07 20:06:17 +0900 0 * * * * /bin/bash -l -c 'cd (Rails_ROOT) && RAILS_ENV=development bundle exec rake publish_article:change_published --silent >> (Rails_ROOT)/log/cron.log 2>&1' # End Whenever generated tasks for: (Rails_ROOT)/config/schedule.rb at: 2021-05-07 20:06:17 +0900
gretelを使用したパンくずリストの作成
1. gretelをインストールした後、以下のコマンドを実行する
# Gemfile gem 'gretel' => bundle installコマンドを実行
以下のコマンドを実行することで、config配下にbreadcrumb.rbというファイルが生成される。
% bin/rails generate gretel:install
2. 設定ファイルを編集する
生成されたファイルを確認すると、以下のようになっている。
# config/breadcrumb.rb crumb :issues do link "All issues", issues_path end crumb :issue do |issue| link issue.title, issue_path(issue) parent :issues end
:issue
の部分はパンくずの名前になる。
他のパンくず設定で親(parentの部分)を定義するときや、以降で設定するviewファイルでパンくずを呼び出す際の名前を設定する。
link
の部分はパンくずリストに表示されるテキストとリンク先を設定する(書き方はRailsのヘルパーメソッドのlink_toと同様)。
URI Patternが/issues/:id
のような、issueのidをパスに含める必要がある場合には|issue|
のようにブロック変数を渡す必要がある。
parent
の部分は現在ここで作ったページの親ページが何かを設定する。今回の場合、:issue
だと:issues
が親に相当する。
3. viewファイルを編集する
最初に、パンくずリストを表示させたい箇所に以下のコードを記述する。
<%= breadcrumbs separator: " › " %>
separator: " › "
はパンくずで表示される文字を区切る部分に入る文字を指定する。>
だとHTMLタグと認識され、表示されない可能性があるので、特殊文字を使用している。
今回は全画面にパンくずリストを表示させたいのでapplication.html.erb
に記述する。
# app/views/layouts/application.html.erb <!DOCTYPE html> <html> <head> ・・・ </head> <body> <%= breadcrumbs separator: " › " %> <%= yield %> </body> </html> end
次に設定ファイルで設定したcrumb名を各viewファイルに指定する。
# app/views/issues/index.html.erb <%= breadcrumbs :issues %>
# app/views/issues/show.html.erb <%= breadcrumbs :issue, @issue %>
詳細ページでは:issue
のcrumbを使用している。
ここで、設定ファイルで設定したcrumb :issue
ではブロック変数を渡しており、ブロック変数には元となるインスタンスを渡さなければならない。
よって、詳細ページのviewファイルでは@issue
のインスタンスを渡してある。
公式リファレンス
erbからslimへのテンプレートエンジンの変更方法&slimの記法まとめ
Slimの導入方法
1. Gemfileにrails-slimを加え、bundle installコマンドを実行
# Gemfile gem 'slim-rails' =>bundle installコマンドを実行
2. デフォルトで生成されるviewファイルのテンプレートエンジンをslimに変更することも可能
# config/application.rb class Application < Rails::Application config.generators.template_engine = :slim end
基本的な書き方一覧
・HTMLタグの<>や閉じタグが不要
# index.html.erb <h1>Users</h1>
# index.html.slim h1 Users
・classはh1.name
, idはp#name
のように、.
と#
で書く。複数のクラスを記載したい場合は.
で繋げる必要がある。
# index.html.erb <h1 class="name">Users</h1> <h1 class="name email">Users</h1> <p id="name">Users</p>
# index.html.slim h1.name Users h1.name.email Users p#name Users
・erbの<% %>というruby記法の使用宣言が「-」に変更する
<% if @blogs.present? %> <p>在庫あり</p> <% end %>
- @blogs.present? p 在庫あり
・<%= %> というruby記法の使用宣言と出力が、「=」に変更する
<% @blogs.each do |blog| %> <p><%= blog.title %></p> <% end %>
- @blogs.each do |blog| p = blog.title
・render
やyield
は、=
ではなく==
を使う
==
を使用することで、HTMLエスケープを無効にして出力することができる。
一例として、render_to_stringによって返された「画像を表示する文字列」から画像を表示する場合、以下のようなコードなる。
# admin/articles_controller.rb class Admin::ArticlesController < ApplicationController def show @article = Article.find_by!(id: params[:article_id]) @article.body = @article.controller.render_to_string("shared/_media_image", locals: { medium: medium }) end end
irb(#<Admin::Articles::PreviewsController:0x00007fe4cd0b4b40>):001:0> @article.body => "<div class=\"media-image\"><img src=\"http://localhost:3000/rails/active_storage/representations/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBCdz09IiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--4420d225322ef11058618687b5a47ec403e7c6c8/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaDdCam9MY21WemFYcGxTU0lOTVRBeU5IZzNOamdHT2daRlZBPT0iLCJleHAiOm51bGwsInB1ciI6InZhcmlhdGlvbiJ9fQ==--6ec66a681693b203f182cf67cd6ee51b8eb65eca/sample01.jpg\" /></div>"
# articles/show.html.slim article.article == @article.body
viewファイルの== @article.body
の部分を= @article.body
とすると、コンソールで表示した<div class= 〜 </div>
までのコードを表示することになるが、== @article.body
とすることでHTMLエスケープを無効にし、画像を表示することができる。
公式リファレンス
詳しい日本語のリファレンスはこちら。
テスト環境でsorceryを使用したユーザーログイン機能を使用したとき、Userクラスのインスタンスメソッドでハマった話
概要
テスト環境でsorceryを使用してログイン機能を追加した時、user.password
で詰まった話。
失敗時のコード
以下のような、ユーザーが正常にログインできるかのテストコードを作成した時、テストに失敗した。
# spec/factories/users.rb FactoryBot.define do factory :user do sequence(:email) { |n| "user_#{n}@example.com" } password { 'password' } password_confirmation { 'password' } end end
# spec/system/usersessions_spec.rb require 'rails_helper' RSpec.describe "UserSessions", type: :system do let(:user) { create(:user) } describe 'ログイン前' do context 'フォームの入力値が正常' do before do visit root_path click_link "Login" fill_in "Email", with: user.email fill_in "Password", with: user.password click_button "Login" end it 'ログイン処理が成功する' do expect(current_path).to eq root_path expect(page).to have_content "Tasks" expect(page).to have_content "Login successful" end end end end
テストに失敗した時のスクリーンショットを見ると、ログインに成功してなかったみたいなのでclick_button
の上にbinding.irb
を挟んでuser.email
とuser.password
を確認してみると、以下のようにuser.password
でnilが返っていた。
irb(#<RSpec::ExampleGroups::UserSessions::Nested::Nested:0x00007f85a4104d08>):001:0> user.email => "user_1@example.com" irb(#<RSpec::ExampleGroups::UserSessions::Nested::Nested:0x00007f85a4104d08>):002:0> user.password => nil
原因
何故こうなったかを調べると、usersテーブルにpassword
カラムが存在してないことが原因みたい。
sorceryを使用してログイン機能を追加する場合、usersテーブルにはcrypted_password
カラムが存在して、仮想的な属性であるpassword
とpassword_confirmation
を元にcrypted_password
カラムを作成している。なのでuser.password
とuser.password_confirmation
を指定してuser.create
を実行すると、user.crypted_password
が作成されてデータベースに保存されるが、仮想的な属性であるpassword
とpassword_confirmation
は無くなるみたいである。
今回の場合だとFactoryBot.create(:user)
を使用すると、FactoryBot
で指定したpassword
とpassword_confirmation
を値を元にuser.crypted_password
を作成するのでuser.password
とuser.password_confirmation
は存在しなくなる(nilになる)。よって、ログインに失敗したようである。
解決方法
なので、今回のようにあらかじめ作成しておいたユーザーデータをもとにログインを検証するテストを作成する場合、fill_in "Password"の箇所をFactoryBotでpasswordに指定した"password"を直に書くことでうまくテストが通るようになった。
fill_in "Password", with: user.password => fill_in "Password", with: "password"
RSpec、Capybara、FactoryBotを使用したテスト環境の構築・設定方法、及びテストの書き方一例
テスト環境の構築構築方法
1. RSpec、Capybara、FactpryBot、Webdriversのgemをインストール
# Gemfile group :development, :test do gem 'rspec-rails' gem 'factory_bot_rails' gem 'capybara' gem 'webdrivers' end => bundle installコマンドを実行
2. RSpecに必要なディレクトリや設定ファイルを作成する
% bin/rails g rspec:install => Running via Spring preloader in process 10358 create .rspec create spec create spec/spec_helper.rb create spec/rails_helper.rb
3. 自動で作成されてあるtestディレクトリを削除する
% rm -r ./test
FactoryBotを使用したテストデータの作成方法
# spec/factories/tasks.rb FactoryBot.define do factory :task do # 重複したデータの作成を阻止したい場合、シーケンスを使用する。 sequence(:title) { |n| "title_#{n}" } # シーケンスの第二引数で定義した文字の末尾にはnextメソッドが実行されるので、文字列の末尾を変更することで独立したデータを作成する上のようなコードの場合、以下のようにも書ける。 => sequence(:title, "title_1") content { "content" } status { :todo } deadline { 1.week.from_now } # Userモデルのファクトリとアソシエーションを作成する場合、以下のコードを記載 association :user # traitを使用することで元となるデータを継承・オーバーライドして、属性地をグループ化することができる。 trait :finished_task do status { :done } completion_date { Time.current.yesterday } end end end
以下、SystemSpecの書き方一例
# spec/system/user_spec.rb require 'rails_helper' RSpec.describe 'User', type: :system do # letは遅延読み込みを実現するメソッドで、変数に値を代入するように使用できる。 # 以下のコードでは、テストファイル内でuserを呼び出すことでFactoryBot内で作成したuserデータを作成し、データベースに保存できる。 let(:user) { FactoryBot.create(:user) } describe 'ログイン後' do before do visit root_path click_link "ログイン" fill_in "メールアドレス", with: user.email fill_in "パスワード", with: "password" click_button "ログイン" end describe 'ユーザー編集' do before do visit users_path click_link "ユーザー編集" end context 'フォームの入力値が正常' do before do fill_in "メールアドレス", with: user.email fill_in "パスワード", with: "update_password" fill_in "再確認", with: "update_password" click_button "ユーザー更新" end it 'ユーザーの編集が成功する' do expect(current_path).to eq user_path(user) expect(page).to have_content user.email expect(page).to have_content "User was successfully updated." end end end end end
テスト環境の各種設定
・ヘッドレスドライバを使用して、ブラウザのウィンドウを開くことなくテストを実行する
# spec/spec_helper.rb require 'capybara/rspec' RSpec.configure do |config| config.before(:each, type: :system) do driven_by(:selenium_chrome_headless) end end
・RSpec の出力をデフォルトの形式からドキュメント形式に変更する
# .rspec # 以下のコードを追加 --format documentation
・テストで使用する共通の処理(login処理など)を記述したサポートモジュールのファイルをspec/support配下に作成し、その配下のファイルを読み込む
# spec/support/login_support.rb module LoginSupport def sign_in_as(user) visit root_path click_link "Sign in" fill_in "Email", with: user.email fill_in "Password", with: user.password click_button "Log in" end end
# spec/rails_helper.rb # 20行目付近にある以下のコードのコメントアウトをはずすことで、spec/support配下のファイルを読み込む # Dir[Rails.root.join('spec', 'support', '**', '*.rb')].each { |f| require f } => Dir[Rails.root.join('spec', 'support', '**', '*.rb')].each { |f| require f } # 以下のコードを追加してLoginSupportモジュールをテストで使用できるようにする RSpec.configure do |config| config.include LoginSupport end
・実行するテストケースを限定する設定
# spec/spec_helper.rb # 50行付近にある以下のコードのコメントアウトをはずす RSpec.configure do |config| # config.filter_run_when_matching :focus => config.filter_run_when_matching :focus end
・FactoryBot.create(:user)
やFactoryBot.build(:user)
をする時、以下の記載をすることでFactoryBot.
の部分を省略できる
RSpec.configure do |config| config.include FactoryBot::Syntax::Methods end