Ruby on Railsで「二ヶ月に一度以上行われているか」集計して判定する


3行まとめ

  • 二ヶ月に一度以上行われているか、という集計を行う
  • 月毎の集計をActiveRecordで行い、二ヶ月に一度か?という条件を配列の処理で行う
  • each_consという関数が便利

はじめに

こんにちは。最近行った開発で、例えば面談のような定期的なイベントが二ヶ月に一度以上開催されているかどうか、というのを判定するという仕様がありました。
毎月ではなく二ヶ月に一度以上で良い、というのがトリッキーなところで、
色々考えてみて、そこまで複雑ではないがパズルみたいで面白かったので記事としてまとめたいと思いました。

やったこと

前提として、

  • 定期的なイベントはEventモデル(eventsテーブル)のレコードとして記録されている
  • Eventにはstart_atカラム(DateTime型)があり、そこで開催日付が記録されている
    とします。
    「定期的なイベントが二ヶ月に一度以上開催されているか」を判定するために、主に2つのステップを踏む必要があると考えました。
  1. レコードを集計して、毎月のイベントの開催回数を集計する
  2. 集計した開催回数を元に、それが二ヶ月に一度以上行われているかを判定する

以下、それぞれについてどのように実装したかを解説していきます。

1. レコードを集計して、毎月のイベントの開催回数を集計する

dateカラムはDateTime型で記録されているため、月のレベルで集計する必要があります。
そのために以下のような集計クエリを作成しました。

event_monthly_count_hash = Event.where(
  start_at: period_begin..period_end
).group("to_char(start_at,'YYYYMM')").count

ここで、period_begin, period_endは集計したい期間の最初と最後の日付で、こちらから与えるものです。月毎の集計をしたいという関係で、必ずperiod_beginは月初に、period_endは月末になるように前処理を行なっています。

このクエリによって、groupを噛ませた上でのcountなので、以下のような"YYYYMM"をキーに持ち、月毎に何回Eventが開催されているかのハッシュを得ることができます。
今回は2022年の3月から6月までを集計して、以下のようなハッシュを得たとしましょう。

 {"202203"=>2, "202204" => 3, "202206" => 1}

次に、月毎に何回開催されているか・・・という情報を期間の最初から最後まで順に並べた配列を得るため、以下のような処理を行ってハッシュを配列に加工します。

monthly_count_array =
  (period_begin..period_end).each_with_object([]) do |date, arr|
    next unless date.day == 1

    arr << ( event_monthly_count_hash[date.strftime('%Y%m')] || 0 ) # 一度も行われていない場合nilになるので0で補完する
  end

期間のうち日付のループを回して、そのうち1日をカウントすることで、「毎月」という条件を抽出しています。
period_beginは必ず月初になるようにしているので、このような方法で対象期間の月ごとのループを行うことができます。
この処理によって、期間のうち早い順に並べて配列にすることができるのと、ハッシュでは一度も行われていない場合はnilになってしまうので、一度も行われていない場合に0を補完することができます。

この結果、以下のような集計結果の配列(monthly_count_array)を得ることができました。

[2,3,0,1]

2. 集計した開催回数を元に、それが二ヶ月に一度以上行われているかを判定する

ここまででActiveRecordの処理などを用いて月毎に何度イベントが行われたか?を集計して配列を得ることができたので、ここからは純粋に配列の処理で、「二ヶ月に一度以上行われているか」という判定をするフェーズに移ります。

「二ヶ月に一度以上行われているか」という条件は、「二月連続で0回であるようなことがないか」という風に言い換えることができます。つまり0が二つ並んでいるようなことがないかを判定すれば良いわけです。

[2,3,0,1] # => 0が一つしか連続して並んでいない(2つ連続で並んでいることがない)のでOK
[2,0,1,0] # => 0が一つしか連続して並んでいない(2つ連続で並んでいることがない)のでOK
[2,0,0,1] # => 0が2つ連続で並んでいる部分があるのでNG

このような判定を行うために以下のような処理を書きました。

success_flag = true
 if monthly_count_array.length >= 2
  # 配列を先頭から2つずつ取り出して (例:[1,2,3] -> [1,2], [2,3])、
  # 両方とも0である(二ヶ月間実施されていない)ことを判定する
  monthly_count_array.each_cons(2) { |x| success_flag = false if x.all?(0) }
else
  success_flag = monthly_count_array.none?(0)
end

each_consとは(https://docs.ruby-lang.org/ja/latest/method/Enumerable/i/each_cons.html)

要素を重複ありで n 要素ずつに区切り、ブロックに渡して繰り返します。

という関数です。コメントにもありますが、[1,2,3,4]という配列に対して、[1,2], [2,3], [3,4]という部分配列を順に返します。
そのような部分配列が、全て0か(x.all?(0))どうかを判定しています。
また、else句のところはコーナーケースで、配列の長さが1であるような場合を想定しています。この場合は、仕様として決めたところで、長さ1の配列の要素が0であればNG, 0でなければOKとすることにしました。

このようにして、「二ヶ月に一度以上イベントが行われているか」をDBから集計し判定することができました。

まとめ

「二ヶ月に一度以上イベントが行われているか」を判定するために、月毎の開催回数をgroup byよりハッシュで得て、それを月毎の開催回数の配列に変換し、0が二つ続かないかどうかという判定をeach_cons関数を用いて行いました。
この方法は二ヶ月に一度だけではなく、半年に一度などnヶ月に一度と拡張できるはずです。

決められた要件からそれをコードレベルに落として、どうしたらそのような判定を実現するかを考えていくのは興味深かったです。
どこまでSQLというかActiveRecordにやらせて、どこからRubyの配列などの処理にやらせるかという判断が地味に難しいところでした。考えた順番としては、少なくとも配列で0が二つ続かないという判定をさせれば欲しい結果が得られるよね→そのような配列を作るためにどのようなクエリや前処理が必要だろう?という逆算をして処理を組み立てていきました。

あとから振り返ってみるとそこまで複雑ではないですが、興味深い実装でした!先輩方とペアプロなどしながら色々議論もできて楽しかったです。

みなさんの何か参考になれば幸いです。