ホワイトヘルスケア - テックブログ

ホワイトヘルスケアは、日本のヘルスケア領域における社会課題に正面から向き合い、現実世界を生きる人々の不安や痛みといった見過ごされるべきでない問題に対して本質的な解決に取り組むことで、持続的な医療システム・社会の実現を目指します。

Railsでお手軽全文検索 - search_copとMySQLの全文検索機能を活用

概要

RailsActiveRecordを拡張するsearch_copという検索用のgemと、MySQL全文検索機能を組み合わせることで、シンプルな全文検索を実装できます。

動作環境

はじめに

こんにちは、開発チームの丸尾です。今回は、Railsで簡単に全文検索を実装する方法について紹介します。

search_copは、シンプルな記述で複数のキーワードを使った検索を可能にするgemですが、実はMySQL全文検索機能にも対応しています。 今回は、実際にsearch_copとMySQL全文検索を組み合わせて使ってみた結果をお伝えしつつ、その設定方法と使い方についても解説していきます。

MySQL全文検索のモードについて

MySQL全文検索のモードは以下の三つあります。

  • IN NATURAL LANGUAGE MODE
  • IN BOOLEAN MODE
  • WITH QUERY EXPANSION

search_copを使った全文検索機能はIN BOOLEAN MODEを使った検索になります。それぞれのモードについての説明はここでは割愛します。
詳細は以下をご確認下さい。
MySQL :: MySQL 8.0 リファレンスマニュアル :: 12.10 全文検索関数

インストールと設定

search_copのインストール

Gemfileにsearch_copを追加し、bundle installを実行します。

gem 'search_cop'

モデルの作成

bin/rails generate model Book title:string content:text
  • db/migrate/xxxxx_create_books.rb
class CreateBooks < ActiveRecord::Migration[7.0]
  def change
    create_table :books do |t|
      t.string :title
      t.text :content

      t.timestamps
    end
  end
end

FULLTEXT INDEXを作成

bin/rails generate migration AddFulltextIndexToBooks
  • db/migrate/xxxxx_add_fulltext_index_to_books.rb
class AddFulltextIndexToBooks < ActiveRecord::Migration[7.0]
  def up
    execute("ALTER TABLE books ADD FULLTEXT INDEX `index_books_on_fulltext` (`title`, `content`) WITH PARSER ngram")
  end

  def down
    execute("ALTER TABLE books DROP INDEX `index_books_on_fulltext`")
  end
end

WITH PARSER ngram オプションを使用することで、文字列をn文字単位で分割してインデックスを作成します(デフォルトは2文字です)。 日本語のようにスペースで単語が区切られていない言語に対して全文検索を効果的に行うためには、ngram全文パーサーが必須です。

参考: MySQL :: MySQL 8.0 リファレンスマニュアル :: 12.10.8 ngram 全文パーサー

migrate実行

bin/rails db:migrate

FULLTEXT INDEXの作成を確認

mysql> SHOW INDEX FROM books \G
*************************** 1. row ***************************
        Table: books
   Non_unique: 0
     Key_name: PRIMARY
 Seq_in_index: 1
  Column_name: id
    Collation: A
  Cardinality: 0
     Sub_part: NULL
       Packed: NULL
         Null:
   Index_type: BTREE
      Comment:
Index_comment:
      Visible: YES
   Expression: NULL
*************************** 2. row ***************************
        Table: books
   Non_unique: 1
     Key_name: index_books_on_fulltext
 Seq_in_index: 1
  Column_name: title
    Collation: NULL
  Cardinality: 0
     Sub_part: NULL
       Packed: NULL
         Null: YES
   Index_type: FULLTEXT
      Comment:
Index_comment:
      Visible: YES
   Expression: NULL
*************************** 3. row ***************************
        Table: books
   Non_unique: 1
     Key_name: index_books_on_fulltext
 Seq_in_index: 2
  Column_name: content
    Collation: NULL
  Cardinality: 0
     Sub_part: NULL
       Packed: NULL
         Null: YES
   Index_type: FULLTEXT
      Comment:
Index_comment:
      Visible: YES
   Expression: NULL
3 rows in set (0.01 sec)

正しくFULLTEXT INDEXが作成されました。

検索対象モデルにsearch_copの設定を記載

  • app/models/book.rb
class Book < ApplicationRecord
  include SearchCop

  search_scope :search do
    attributes :all => [:title, :content]

    options :all, :type => :fulltext, :default => true
  end
end

このようにsearch_scopeのオプションを使って、検索対象のカラムを指定します。

search_copを使ってみる

それでは実際に検索してみましょう。今回は以下のようなテストデータ(架空の本)を用意しました。

Book.all.select(:id, :title, :content)
  Book Load (1.7ms)  SELECT `books`.`id`, `books`.`title`, `books`.`content` FROM `books` /* loading for pp */ LIMIT 11
=>
[#<Book:0x0000ffffab805560 id: 1, title: "月光のソナタ", content: "作家ほげさんによる、月明かりの下で繰り広げられるピアニストの挑戦と恋の物語。">,
 #<Book:0x0000ffffaac73e90 id: 2, title: "夢見る機械", content: "作家ふがさんによる、未来都市を舞台にした革新的な技術と人間の感情が交錯するサイエンスフィクション。">,
 #<Book:0x0000ffffaac73df0 id: 3, title: "静かなる波", content: "作家ぴよさんによる、海辺の小さな村を舞台にした老船乗りの過去と現在を織り交ぜた物語。">,
 #<Book:0x0000ffffaac73c10 id: 4, title: "風に乗る言葉", content: "作家ほげさんによる、旅人の詩人が各地を巡りながら出会う人々との交流を描いた物語。">,
 #<Book:0x0000ffffaac73990 id: 5, title: "時を超える橋", content: "作家ほげさんによる、時代を超えた恋愛を描いたロマンス小説。">]

モデルのsearchメソッドを使って簡単に全文検索できます。

Book.search("ほげ").select(:id, :title, :content)
  Book Load (1.1ms)  SELECT `books`.`id`, `books`.`title`, `books`.`content` FROM `books` WHERE (MATCH(`books`.`title`, `books`.`content`) AGAINST('ほげ' IN BOOLEAN MODE)) /* loading for pp */ LIMIT 11
=>
[#<Book:0x0000ffffab8e6ec0 id: 1, title: "月光のソナタ", content: "作家ほげさんによる、月明かりの下で繰り広げられるピアニストの挑戦と恋の物語。">,
 #<Book:0x0000ffffab8e6e20 id: 4, title: "風に乗る言葉", content: "作家ほげさんによる、旅人の詩人が各地を巡りながら出会う人々との交流を描いた物語。">,
 #<Book:0x0000ffffab8e6d80 id: 5, title: "時を超える橋", content: "作家ほげさんによる、時代を超えた恋愛を描いたロマンス小説。">]
Book.search("ほげ 風に乗る").select(:id, :title, :content)
  Book Load (2.2ms)  SELECT `books`.`id`, `books`.`title`, `books`.`content` FROM `books` WHERE ((MATCH(`books`.`title`, `books`.`content`) AGAINST('+ほげ +風に乗る' IN BOOLEAN MODE))) /* loading for pp */ LIMIT 11
=> [#<Book:0x0000ffffab8e6240 id: 4, title: "風に乗る言葉", content: "作家ほげさんによる、旅人の詩人が各地を巡りながら出会う人々との交流を描いた物語。">]
Book.search("ロマンス小説").select(:id, :title, :content)
  Book Load (1.5ms)  SELECT `books`.`id`, `books`.`title`, `books`.`content` FROM `books` WHERE (MATCH(`books`.`title`, `books`.`content`) AGAINST('ロマンス小説' IN BOOLEAN MODE)) /* loading for pp */ LIMIT 11
=> [#<Book:0x0000ffffaca5e248 id: 5, title: "時を超える橋", content: "作家ほげさんによる、時代を超えた恋愛を描いたロマンス小説。">]

sqlを見ると全文検索が正しく動いてそうですね。

このように、search_copを使えば、簡単な記述でMySQL全文検索機能が利用できて、高速な検索も可能になります。

また、ついでに全文検索を使用しない通常のlike検索との速度比較をしてみましょう。
データが少ないと差が分かりにくいので追加で10万件入れてみます。

Book.count
  Book Count (4.2ms)  SELECT COUNT(*) FROM `books`
=> 100005

以下は全文検索を使用した検索結果です。クエリ実行時間: 0.9ms
先の5件のデータの時と速度はあまり変わらないですね。(むしろ少し早くなってる!?

Book.search("ほげ 風に乗る").select(:id, :title, :content)
  Book Load (0.9ms)  SELECT `books`.`id`, `books`.`title`, `books`.`content` FROM `books` WHERE ((MATCH(`books`.`title`, `books`.`content`) AGAINST('+ほげ +風に乗る' IN BOOLEAN MODE))) /* loading for pp */ LIMIT 11
=> [#<Book:0x0000ffffab81de30 id: 4, title: "風に乗る言葉", content: "作家ほげさんによる、旅人の詩人が各地を巡りながら出会う人々との交流を描いた物語。詩と自然の美しさを通じて人生の哲学を問う作品です。">]

以下は全文検索を使用しない場合の検索結果です。クエリ実行時間: 57.5ms
全文検索を使わない通常の検索では、TEXT型のカラムに対してインデックスを使った高速な検索ができません。そのため、大量のデータを検索する際には速度が大幅に低下します。

Book.search("ほげ 風に乗る").select(:id, :title, :content)
  Book Load (57.5ms)  SELECT `books`.`id`, `books`.`title`, `books`.`content` FROM `books` WHERE ((((`books`.`title` IS NOT NULL AND `books`.`title` LIKE '%ほげ%' ESCAPE '\\') OR (`books`.`content` IS NOT NULL AND `books`.`content` LIKE '%ほげ%' ESCAPE '\\')) AND ((`books`.`title` IS NOT NULL AND `books`.`title` LIKE '%風に乗る%' ESCAPE '\\') OR (`books`.`content` IS NOT NULL AND `books`.`content` LIKE '%風に乗る%' ESCAPE '\\')))) /* loading for pp */ LIMIT 11
=> [#<Book:0x0000ffffab934a30 id: 4, title: "風に乗る言葉", content: "作家ほげさんによる、旅人の詩人が各地を巡りながら出会う人々との交流を描いた物語。詩と自然の美しさを通じて人生の哲学を問う作品です。">]

その差50倍以上!

まとめ

いかがでしたでしょうか。search_copと、MySQL全文検索機能を活用することで、elasticsearch等の専用の検索エンジンを導入せず開発・運用コストを抑え、シンプルなアーキテクチャを維持できます。

また、複雑なSQLクエリを直接記述する必要がなく、DSLを使って直感的に検索条件を指定できるのも大きなメリットです。

参考リンク

採用情報

Ruby on Rails を使った Whitehealthcare に興味のある方はこちらから

whitehealthcare.co.jp

【入社エントリー】地方在住でフルリモート勤務

はじめに

はじめまして、開発チームの横田です。

2024年4月にホワイトヘルスケアに入社し、1ヶ月が経ちました。
私は関西在住で、フルリモートで勤務させていただいております。

今日は、私が入社前に不安に感じていた、

  • ホワイトヘルスケアの開発チームってどんな人がいるの?
  • フルリモートでコミュニケーションはどのように取っているの?

といった部分を入社エントリーとして記載いたします。

開発チームのメンバー

2024年5月時点で、開発チームは総勢9名在籍しており、フロントエンド・バックエンド・インフラ・データ分析と様々な専門分野のエンジニアで構成されています。
かくいう私も、エンジニアというよりはデータサイエンティストとして、医療系のデータ分析などを経験してきました。
経歴としても、大手・ベンチャーフリーランス経験者など様々です。ヘルスケア業界がはじめての方もいらっしゃいます。
少数精鋭で、エンジニア歴10年以上の30代〜40代のベテランメンバーで構成されていることが特徴です。
半数はここ1年以内の採用のため、組織として急拡大しているフェーズになります。

また、実は9名中地方在住者が3名います(九州地方・中国地方・関西地方)。
関東圏のメンバーもリモートであるためそこまで困ることはありませんが、開発チームや会社全体としてのコミュニケーションの取り方について、私が良いなと感じたことを中心に、続いて記載します。

コミュニケーションの取り方

弊社では、基本的にSlackを使ったコミュニケーションを取っています。
その中でも良いなと感じたことは以下です。

原則パブリックチャンネル

人事情報のような個人情報等以外、原則パブリックチャンネルしか作成できないため、基本的にどのチャンネルも覗くことができます。他部門や同僚の状況も把握しやすく、リモートワークにおいて助かります。

リアクションがよくもらえる

通常の投稿や会議などで、スタンプやレスなどリアクションがよくもらえる文化です。リアクションがもらえることが後述の心理的安全性に繋がり、コミュニケーション活性化に大きく寄与していると考えております。いつも瞬く間にスタンプを入れてくれる職人に感謝です。

心理的安全性が確保されている

テキストコミュニケーション能力が高い方が多く、!や絵文字・スタンプなど適度に入れたコミュニケーションを取ってくれるため、リモートにおける心理的安全性の確保に繋がっていると感じています。もちろん、強要ルールがあるわけではなく、自主的なものになります。

社内会議はハドルミーティング

社内会議は基本的に、Slackのいずれかのチャンネルのハドルミーティングを使用しています。ハドルミーティングの良いところは、スレッドができて会議においてレスしたことが後から見返すことができる点です。議事録を書くほどでもないような社内会議でも決定事項とTo doだけスレッドに記載しておけば、後から見返すことができるのは便利だなと感じました。もちろん、スレッドに残るので会議非参加者でも情報のキャッチアップができることも利点です。
また、どのチャンネルで誰と誰が会議をしているかSlack上で表示されるため、出社時に近いような雰囲気になります。

timesチャンネル

非エンジニアの方含めて、何人か個人のtimesチャンネルを作成し好きにつぶやいています。改まって雑談したいわけではないけど、出社しているときのように自然と雑談に繋がるのは良いなと思います。ここでも意外とリアクションがもらえるので、皆さんよく見ているなと感じます。

大部屋・小部屋チャンネル

大部屋と呼ばれる全社的なチャンネル、小部屋と呼ばれる部門レベルのチャンネルがあり、誰かと話したいときは該当チャンネルのハドルミーティングに参加しておけば、見つけた誰かが入ってきてお話できるようになっています。最初のうちはできるだけ入って人間関係を作っていくなど活用できます。

役員クラスとの1on1

1ヶ月に1度役員クラスの方との1on1が設定されており、経営層と会話させていただく機会が設けられています。(※組織拡大に伴い変更される可能性はありますが現状です。)

対面での全社集会

3ヶ月に1度全社集会があり、地方在住者は出張扱いとして参加できるため、対面でお会いすることもできます。宿泊もOKなので、その後の懇親会も参加できます。出張・懇親会ともに強制ではないです。

全社的なコミュニケーションで私が良いなと感じたことは以上です。
コミュニケーションの枠組みから外れるため詳細は省きますが、他にもドキュメントやオンボーディング動画がNotionに綺麗にまとめられているため、フルリモートでも入社後のキャッチアップがスムーズに行えました。

開発チームとしては、週1回の進捗報告会・モブプロ・雑談会が設定されています。
雑談会は、週に1度決められた時間にSlackの大部屋チャンネルのハドルミーティングに開発チーム全員で入り、他部署の人と交流する時間です。リモートだからこそ、コミュニケーションを取るための工夫がなされています。

まとめ

いかがでしょうか。少しでもホワイトヘルスケア、開発チームの雰囲気を掴んでいただけたら幸いです。
まだまだ入社して1ヶ月ですが、ルールや文化・皆さんのコミュニケーション能力の高さに、地方からのフルリモートでも特に困らずに働くことができています。

採用情報

ホワイトヘルスケアに興味を持たれた方はこちらへ! hrmos.co

sidekiqをバージョンアップしたらsidekiq-statisticがエラーになったが解消した話

概要

  • ruby 3.1 系 から 3.2 系 にバージョンアップしたら、sidekiq-statisticでエラーが発生するようになった。
  • sidekiqのバージョンアップに伴い、sidekiq-statisticが期待する I/F が変わったため、エラーが発生した。

動作環境

  • ruby 3.1 系から 3.2 系にバージョンアップ
    • sidekiq6 系から 7 系にバージョンアップ
  • sidekiq-statistic導入済み

はじめに

ホワイトヘルスケア エンジニアの林です。

本記事は、 ruby 3.1 系 から 3.2 系 にバージョンアップした際、sidekiq-statisticがエラーが発生した時の話になります。

発生した現象と調査について

事の始まり

弊社は Ruby on Rails を使ったアプリを開発しており、sidekiqを使用した job で処理を行なうロジックがあります。また、開発補助のためsidekiqの統計情報をグラフィカルに確認できるsidekiq-statisticという gem も導入しています。

アプリは ruby 3.1 系を使用して開発していましたが、脆弱性対応もあり、ruby 3.2 系にバージョンアップすることになりました。

それに伴い、使用していた gem のバージョンアップも同時に実施しました。

発生した現象

sidekiqの job 実行時にエラーが発生し job が失敗するようになりました。sidekiqの UI 上から確認できた エラーログは以下でした。

NoMethodError: undefined method `value` for 300:Integer Processor (以下略

調査

エラーの原因はバージョンアップではあるのは確かでしたが、どこで何が起こりエラーになったのかわからなかったため、順番にエラーの調査を行っていきました。

1. バージョンアップ前後での rspec の実行状況

弊社は rspec でテストを書くことになっており、今回エラーとなったsidekiqの job が実行している class に対してもテストが存在していました。

そこで、まず rspec がバージョンアップ前後で 失敗しないか確認しました。 バージョンアップ直後にも全体を通して実行し、エラーが発生していないことは確認はしていましたが、ランダム値による突発的な発生なども考えられるため、エラーが起こった job を何度か実行してみました。しかし、rspec 上では再現しませんでした。

2. job の内容の確認

次に、job の内容について調査しました。

job の挙動をざっくりと説明すると、DB のテーブルコピーを行う、というものです。エラー発生時の環境と rspec 実行時の環境とでは、DB 内のデータの作り方が異なる(rspec では FactoryBot を使ってダミーデータを投入する)ため、そこで差が出たのかと考えました。前述の観点でコードを確認しましたが、特にデータの作り方で何か変わるようなロジックはありませんでした。

また、rails のコンソール(rails c)上で job を直接実行したところ、エラーが発生しなかったため、 job ではなく別のところに原因があると予測しました。

エラーの原因の特定

前述の調査の他にも、google で検索をかけたり等行っていましたが、原因の特定に至らず手詰まり状態になっていました。

そこで、エラーメッセージ前後のログの内容を確認することにしました。前述したエラーログの前後に、以下のログも出力されていました。これは、「hmsetが deprecated である」旨のメッセージであり、エラーになっていたわけではありませんが、このログを元にsidekiq-statistic-1.4.0/lib/sidekiq/statistic/middleware.rbを確認してみることにしました。

worker  | [sidekiq#5788] Redis has deprecated the `hmset`command, called at ["/usr/local/bundle/gems/sidekiq-statistic-1.4.0/lib/sidekiq/statistic/middleware.rb:42:in `block (2 levels) in save_entry_for_worker'"]

sidekiq-statistic-1.4.0/lib/sidekiq/statistic/middleware.rbを調べたところ、deprecated に該当しているコードが確認できました。それとは別に、問題の発端となったエラーに出てきたvalue method を使っているコードも発見しました。

参考リンク:https://github.com/davydovanton/sidekiq-statistic/blob/v1.4.0/lib/sidekiq/statistic/middleware.rb

コード抜粋

if times_list_length.value > max_timelist_length
  redis.ltrim(timeslist_key, 0, (max_timelist_length * 0.75).to_i)
end

このコードに対しログを仕込んでみたところ、times_list_length.valueでエラーになっていることがわかり、エラーの発生源を突き止めることができました。

根本原因

根本的な原因は、sidekiqのバージョンアップにより、内部で使用している redis のクライアントがredisからredis-clientに変わり、I/F も変わってしまったためでした。そのため、sidekiq-statisticがその変更に対応できていなかったため、エラーが発生してしまったということです。

本体コードを簡素化した以下のコードを実行しても クラスや I/F が変わっていることが確認できます。

Sidekiq.redis do |redis|
  r = nil
  redis.pipelined do
    r = redis.lpush "timeslist_key", 0.12132123
  end
  p r.class
  p r
end

# sidekiq v6 の場合
# => Redis::Future
# => <Redis::Future [:lpush, "timeslist_key", 0.12132123]>

# sidekiq v7 の場合
# => Integer
# => 1

対応策

対応方法としてはいくつかあると思いますが、他 gem との兼ね合いなども考慮した結果、該当 method をclass_evalを使って上書きすることで対応することとしました。こうすることで、他の gem への影響を最小限に抑えて対応することができました。

まとめ / 感想

今回調査した中で、色々と学ぶ点が多くあったと感じました。具体的には以下の通りです。

  • 原因となったエラーメッセージ前後のメッセージも、エラー特定に役立つ
    • エラーの原因が特定できたのは偶然性が高いと感じている
  • 想定していないところでアップデートの影響が出ることもある
    • アプリの挙動と直接関係ない gem で発生した
    • rspecAPI サーバとして確認する方法では捕捉できなかった
  • gem 管理リポジトリhttps://rubygems.org/だが、そこに最新バージョンが登録されているわけではない

採用情報

Ruby on Rails を使った Whitehealthcare に興味のある方はこちらから https://whitehealthcare.co.jp/recruit/

ActiveRecordのerrorsにaddしてもsaveに成功するとaddした内容がなくなる

概要

  • ActiveRecordを継承したmodelには、errorsで任意にエラーを追加できる
  • 追加したエラーは、savevalidateに成功すると、errorsから削除される

はじめに

ホワイトヘルスケア エンジニアの林です。

本記事は、ActiveRecord を使っていた際に、errorsに追加したエラーが意図せず消えてしまった時の挙動を調べた際の話になります。

発生した現象と調査について

事の始まり

1 対多の関係のテーブルに対し、ある機能を作成していた時の話です。

テーブルをざっくり例えると以下のようなイメージのものでした。

  • userが複数のarticlesを所有している
  • articlesは、公開/非公開のstatus、記事のtext、記事の文字数text_countをフィールドとして持つ
  • userは、公開記事の全文字列の合計数であるpublished_text_countをフィールドとして持つ

図として表現するとこのような感じ。

erDiagram
users ||--|{ articles: ""

users {
  integer published_text_count
}

articles {
  enum status
  string text
  integer text_count
}

コードで model を表現するとこのような感じ。

class User < ApplicationRecord
  has_many :articles
end

class Article < ApplicationRecord
  belongs_to :store_invoice
  enum :status, {
        DRAFT: 'draft',             # 下書き
        PUBLISH: 'publish',         # 公開
  }
end

発生した問題

作成していた機能は、以下のようなものでした。

  1. userを指定し、articlesを更新する
    • articleDRAFTだった場合、PUBLISHに変更する
    • articlePUBLISHだった場合、変更できなかったArticle.idのリストをエラーとして表示する
  2. articlePUBLISHであるtextの合計値であるpublished_text_countを更新する

上記をdraft_to_publishとして以下のように実装しました。

class User < ApplicationRecord
  has_many :articles

  def draft_to_publish
    # 元々PUBLISHだったものは、idをエラーとして保持
    errors.add(:base, articles.PUBLISH.ids)

    # DRAFTはPUBLISHに変更
    articles.DRAFT.update(status: :PUBLISH)

    # `published_text_count`の更新
    published_text_count = articles.PUBLISH.sum(:text_count)

    # フィールド値の更新
    save
  end
end


# 想定していた使い方

# 対象のユーザでmethodをcall
user = User.first
user.draft_to_publish

# PUBLISHのarticlesがあれば、このraiseでcall側に通知することを期待してた
raise StandardError(user.errors.full_messages) if user.errors.empty?

しかし、PUBLISHarticlesが存在するuserであっても、エラーがraiseされることがなかったため、調査することにしました。

原因の調査

結論から言うと、save(正確にはvalid?)でerrorsがクリアされている、ということがわかりました。

該当箇所:https://github.com/rails/rails/blob/v7.1.2/activemodel/lib/active_model/validations.rb#L363-L369

明示された文章を探し出せなかったのですが、rails の思想としては、「あるタイミングでエラーが発生しても、その後の処理で保存できる(検証 OK になった)なら、値の修正などでエラーが解消されたとみなす」ということなのだろうと理解しました。

まとめ / 感想

一般的な(?)使い方を考えると、「新規に validation に成功したら errors がクリアされる、は確かにそうだな〜」と思いました。が、反面、自分の認識していないところでデータが書き換わるのは想定しておらずだいぶ困惑しました。

Framework を使う場合、どういった使い方を想定されて作られているのか、という点を意識して使う必要があることを再認識させられる出来事でした。

採用情報

Ruby on Railsアプリ開発をしているホワイトヘルスケアが気になった方は、ぜひお気軽にお問い合わせください!

採用情報詳細

hrmos.co

ActiveRecordのenumにvalidateオプションが追加されてて嬉しい

こんにちは、エンジニアの菅野です。

今回はRailsの最近の変更で地味に嬉しかった enumのvalidateオプションについて書いていきます。

概要

Rails7.1からActiveRecordenumvalidate オプションが指定できるようになりました。

PR

github.com

これにより validate: true が指定された場合、 個別にvalidationを書かずとも対象外の値が入力された際にバリデーションエラーとして扱ってくれるようになりました 🎉

実践

例えばこんなテーブルがあったとします。

# migration
class CreateCountries < ActiveRecord::Migration[7.1]
    def change
        create_table :countries do |t|
            t.string :name, null: false
            t.string :code, limit: 2, null: false
            t.integer :continent, limit: 1, null: false
            t.datetime :created_at, null: false
        end
    end
end

モデルクラスは以下で、まだ validate オプションはつけていません。

class Country < ApplicationRecord
    enum :continent, {
        asia: 'Asia',
        africa: 'Africa',
        europe: 'Europe',
        north_america: 'NorthAmerica',
        south_america: 'SouthAmerica',
        australia_oceania: 'Australia/Oceania'
    }

    validates :name, presence: true
    validates :code, presence: true
end

まずはサンプルとして日本のレコードをつくります。

[1] pry(main)> japan = Country.create(name: 'Japan', code: 'JP', continent: :asia)
  TRANSACTION (0.4ms)  BEGIN
  Country Create (2.4ms)  INSERT INTO `countries` (`name`, `code`, `continent`, `created_at`) VALUES ('Japan', 'JP', 'Asia', '2023-11-02 02:22:45.486031')
  TRANSACTION (3.1ms)  COMMIT
=> #<Country:0x0000ffff954d74d0
 id: 1,
 name: "Japan",
 code: "JP",
 continent: "asia",
 created_at: Thu, 02 Nov 2023 02:22:45.486031000 UTC +00:00>
[2] pry(main)> japan.continent
=> "asia"

続いて continent に範囲外の値を指定します。 試しに日本を南極大陸に移動させてみましょう。

[3] pry(main)> japan.continent = :antarctica
ArgumentError: 'antarctica' is not a valid continent

値を代入した維持点で ArgumentError が発生してしまいました。

今度はモデルクラスのenum定義で validate オプションを有効にしてみます。

enum :continent, {
    asia: 'Asia',
    africa: 'Africa',
    europe: 'Europe',
    north_america: 'NorthAmerica',
    south_america: 'SouthAmerica',
    australia_oceania: 'Australia/Oceania'
}, validate: true

改めて範囲外の値を代入します。しかし今回ArgumentErrorは発生しません。

[2] pry(main)> japan = Country.last
  Country Load (3.0ms)  SELECT `countries`.* FROM `countries` ORDER BY `countries`.`id` DESC LIMIT 1
=> #<Country:0x0000ffff8e9ba788
 id: 1,
 name: "Japan",
 code: "JP",
 continent: "asia",
 created_at: Thu, 02 Nov 2023 02:22:45.486031000 UTC +00:00>
[3] pry(main)> japan.continent = :antarctica
=> :antarctica

続いてモデルのバリデーションをかけたところで、初めてバリデーションエラーが発生するようになりました。

[3] pry(main)> japan.continent = :antarctica
=> :antarctica
[4] pry(main)> japan.valid?
=> false
[5] pry(main)> japan.errors.messages
=> {:continent=>["is not included in the list"]}
[6] pry(main)> 

代入時点で無条件に例外がスローされることもなくなり、他フィールドと同じようにActiveRecordのエラーとして扱えるようになりましたので、地味に嬉しい変更ではないかと思います。

利用バージョン

ruby '3.2.2'
rails '7.1.1'

まとめ

これまで個別に validates :xxx, in: { inclusion: %w[bla bla bla] } などと記述していたenum値のバリデーションが、7.1からoption一発で宣言できるようになりました。嬉しいですね。

BigQueryでGROUPING SETSが実装されたので早速試してみた

概要

  • BigQueryがGROUPING SETSをサポートし始めました
  • GROUPING SETSの基本的な使用方法と、それがデータ分析にどのように役立つかを実際のクエリ例とともに紹介します

目次

  • はじめに
  • GROUPING SETSとは何か?
  • GROUPING SETSの使い方
  • ROLLUPとCUBEとの関係
  • まとめ

はじめに

こんにちは、データエンジニアの石田です。今月の初め頃にBigQueryにGROUPING SETSの機能がリリースされました。一般的なSQL環境では既にお馴染みの機能ですが、BigQueryでは長らくサポートされていませんでした。いままでもUNIONやUNNESTを駆使すれば同様の集計は可能でしたが、GROUPING SETSを使うことで簡素に書けるようになりましたので、実際の使用例を通じて使い方を紹介します。 cloud.google.com

GROUPING SETSとは何か?

GROUPING SETSは、GROUP BY句の一部で、複数のグループ化基準を一度のクエリで指定できる機能です。簡単に言えば、一つのクエリで複数のGROUP BYを実行する機能だと思ってください。

異なるレベルの集計を同時に行いたい場合に役立ちます。とてもシンプルに書けるので冗長性が減少し、実行効率が向上します。

GROUPING SETSの使い方

それではGROUPING SETSの使用例を見てみましょう。

我々はホワイトヘルスケアでは、医療データの分析をメインで行っています。ここでは、hospital(病院)、patient(患者)、point(点数、患者の医療費のこと)の3つのフィールドを持つ架空の医療データセットを使用します。

hospital patient point
ヘルスケア病院 石田さん 200
ヘルスケア病院 山田さん 250
ヘルスケア病院 鈴木さん 550
ホワイトクリニック 石田さん 100
ホワイトクリニック 山田さん 200
ホワイトクリニック 鈴木さん 450

この点数を「病院ごと」、「患者ごと」、そして「病院と患者の組み合わせごと」に集計したいとします。普通に集計するなら、集計したいレベルごと3つの別々のクエリでGROUP BYを実行します。しかし、GROUPING SETSを使用すると、これを一度のクエリで実行できます。

SELECT 
  hospital, 
  patient, 
  SUM(point) as total_points
FROM 
  medical_data
GROUP BY 
  GROUPING SETS (
    (hospital),
    (patient),
    (hospital, patient)
  );

このクエリは、次の3つの集計を一度に行います:

  1. 病院ごとの点数合計
  2. 患者ごとの点数合計
  3. 病院と患者の組み合わせごとの点数合計

結果セットは以下のようになります。

hospital patient total_points
ヘルスケア病院 NULL 1000
ホワイトクリニック NULL 750
NULL 石田さん 300
NULL 山田さん 450
NULL 鈴木さん 1000
ヘルスケア病院 石田さん 200
ヘルスケア病院 山田さん 250
ヘルスケア病院 鈴木さん 550
ホワイトクリニック 石田さん 100
ホワイトクリニック 山田さん 200
ホワイトクリニック 鈴木さん 450

いくつかの行にhospitalまたはpatientがNULLとして表示されていますが、これはその行が特定のレベルの集計(病院全体または患者全体)を表しているためです。例えば、patientがNULLの場合、その行は病院全体の点数を示しています。

GROUPING SETSを使わずにこれを集計しようとするなら、hospitalGROUP BYしたレコード、patientGROUP BYしたレコード、hospital, patientGROUP BYしたレコードを個別に集計した後、それらをUNIONしなくてはなりません。

ROLLUPとCUBEとの関係

GROUPING SETSROLLUPCUBEにも対応しており、これらと組み合わせるとさらに複雑な集計を1つのクエリで実現できます。それぞれ使い方を見てみましょう。

ROLLUP

ROLLUPは特定の階層での集約データ(サブトータル)と、すべてのレベルの集計(グランドトータル)を簡単に取得できる機能です。GROUPING SETSと組み合わせると、さまざまなレベルの集計を一度のクエリで取得できます。

以下は、hospital(病院)とdepartment(診療科)の架空のデータセットです。

hospital patient point
ヘルスケア病院 循環器科 350
ヘルスケア病院 神経科 650
ホワイトクリニック 循環器科 400
ホワイトクリニック 神経科 350

例えば、病院内の各診療科で行われた診療の点数の合計を計算し、さらにその病院全体での合計も計算したい場合、ROLLUPが役に立ちます。

SELECT 
  hospital, 
  department, 
  SUM(point) as total_points
FROM 
  medical_data
GROUP BY 
  GROUPING SETS (
    ROLLUP (hospital, department)
  );

このクエリは、以下の集計を行います。

  1. 各病院と診療科部門の組み合わせごとの点数合計(最も詳細なレベル)
  2. 各病院全体の点数合計(診療科をロールアップした結果)
  3. 全病院の点数総合計(全てをロールアップした結果)

結果セットは以下のようになります。

hospital department total_points
ヘルスケア病院 循環器科 350
ヘルスケア病院 神経科 650
ヘルスケア病院 null 1000
ホワイトクリニック 循環器科 400
ホワイトクリニック 神経科 350
ホワイトクリニック null 750
null null 1750

NULLはロールアップされた集計を示しています。たとえば、departmentがNULLの場合、その行は病院全体の小計を示しています。表の一番下、hospitaldepartmentの両方がNULLのレコードは、全病院の総合計です。

各レベルは、前のレベルのデータを「ロールアップ」して、より集約された値を集計しています。表の下に行けば行くほど、全体に近くなるイメージでしょうか。上の例はカラムが少ないのでピンとこないかもしれませんが、これに患者、地域、疾患、担当医、etc...と様々な階層が加わったときも、このクエリで一撃で全ての粒度の集計が行えるため、詳細からより広い視点の洞察を一度に得ることができます。

CUBE

CUBEROLLUPと似ていますが、CUBEの方が提供される集計の種類がより多いです。CUBEは、指定された列のすべての可能な組み合わせにわたって集計を行います。

実行すると、データを複数の次元で集計し、各階層のサブトータルと、全階層のグランドトータルを含む、クロス集計(ピボットテーブルのような)結果を得ることができます。

具体的な例を使いながら説明します。以下は、hospital(病院)、department(診療科)、doctor(医者)という3つの階層があるセットです。

hospital department doctor point
ヘルスケア病院 循環器科 山本 150
ヘルスケア病院 循環器科 佐藤 200
ヘルスケア病院 神経科 山本 300
ヘルスケア病院 神経科 佐藤 350
ホワイトクリニック 循環器科 山本 250
ホワイトクリニック 循環器科 佐藤 150
ホワイトクリニック 神経科 山本 200
ホワイトクリニック 神経科 佐藤 150

CUBEを使用すると、これらのすべての可能な組み合わせ(病院、診療科、医者、病院+診療科、病院+医者、診療科+医者、病院+診療科+医者、など)にわたってデータを集計できます。

以下が、GROUPING SETSCUBEを使用したクエリです。

SELECT 
  hospital, 
  department, 
  doctor,
  SUM(point) as total_points
FROM 
  medical_data
GROUP BY 
  GROUPING SETS (
    CUBE (hospital, department, doctor)
  );

以下がクエリの実行結果になります。

hospital department doctor total_points
ヘルスケア病院 循環器科 山本先生 150
ヘルスケア病院 循環器科 佐藤先生 200
ヘルスケア病院 循環器科 NULL 350
ヘルスケア病院 神経科 山本先生 300
ヘルスケア病院 神経科 佐藤先生 350
ヘルスケア病院 神経科 NULL 650
ヘルスケア病院 NULL 山本先生 450
ヘルスケア病院 NULL 佐藤先生 550
ヘルスケア病院 NULL NULL 1000
ホワイトクリニック 循環器科 山本先生 250
ホワイトクリニック 循環器科 佐藤先生 150
ホワイトクリニック 循環器科 NULL 400
ホワイトクリニック 神経科 山本先生 200
ホワイトクリニック 神経科 佐藤先生 150
ホワイトクリニック 神経科 NULL 350
ホワイトクリニック NULL 山本先生 450
ホワイトクリニック NULL 佐藤先生 300
ホワイトクリニック NULL NULL 750
NULL 循環器科 山本先生 400
NULL 循環器科 佐藤先生 350
NULL 循環器科 NULL 750
NULL 神経科 山本先生 500
NULL 神経科 佐藤先生 500
NULL 神経科 NULL 1000
NULL NULL 山本先生 900
NULL NULL 佐藤先生 850
NULL NULL NULL 1750

NULLを多数含むので一見とっつき難い印象があるかもしれませんが、今までの一連の出力例と同様だと思えば、それほど違和感はないでしょう。

簡潔なクエリで様々な角度からのインサイトを得ることができる一面、すべての可能な組み合わせを含むため計算量が多く、中には分析に必要ではない組み合わせも含まれるため、少し冗長かもしれませんね。

まとめ

GROUPING SETSをうまく使いこなせば、グループ集計のダルさを一発で解消できます。レポートにするまでもないけど、なんとなくデータ全体のイメージをカジュアルに把握したいなんて時に最適です。 ただし、やはり予想外に計算量が多くなる可能性があるので、必要なデータだけをしっかりと指定して、スマートにクエリを実行しましょう!

採用情報

ホワイトヘルスケアと医療データの分析に興味がある方は以下をクリック。

hrmos.co

気軽にヘルプを出せるSlack チャンネルを運用してコミュニケーションを促進している話

概要

技術的なことで困った時に、雑にヘルプを出せる場を作るのはいいぞ!

はじめに

ホワイトヘルスケア エンジニアの林です。

本記事は、弊社が導入している Slack の運用についての紹介になります。

弊社勤務形態について

弊社では、エンジニア、非エンジニア問わず従業員の多くがリモート勤務しています。例えばエンジニアの話であれば、関東圏に在住している方が多数ですが、九州在住の方もいらっしゃいます。

またエンジニアはほぼフルリモートで勤務しており、勤務の開始時間や終了時間もまちまちです(私事ですが、子育て真っ最中なので大変ありがたい制度です)。

勤務を行う上で感じる課題

上記のような勤務形態なので、個人的には働きやすい環境ではありますが、課題も感じています。その 1 つがコミュニケーションのハードルです。

弊社ではコミュニケーションツールとして Slack を導入しており、エンジニア間(従業員間)でのコミュニケーションの心理的ハードルは低い環境であると思っています。

しかしながら、技術的に行き詰まりヘルプを出したい時などで、以下の様な心理的ハードルもあると感じていました。

  • 汎用的な問題を、どこで話題にすれば良いかわからない
    • DM でするのも違うし、プロジェクト用のチャンネルでするのも違うし...
  • 専門的な問題を、誰に聞けばわからない
    • この分野の話、誰が一番知っているのか(知ってそうなのか)わからない...
  • そもそもポストしにくい
    • フルリモートで業務開始時間がまちまちだし、各人色々 MTG 入ってるみたいだし、見られているのかわからない...

解決策

上記のような心理的ハードルを下げる施策として、弊社では「困った時に雑にヘルプを出せるチャンネル」を作成しました(余談ですが、エンジニアだけではなく、非エンジニアも同じ様な使い方をするチャンネルを作成しています)。 このチャンネルでは、エンジニアが全員参加しており、困りごとや質問など気軽に投げられる様になっています。

このチャンネルの導入は、前述した課題の解決の一助となっていると感じています。

  • 汎用的な問題を、どこで話題にすれば良いかわからない
    → とりあえず困ったらこのチャンネルに投げれば OK 。なのでどこで話題にするのかを悩む必要なし
  • 専門的な問題を、誰に聞けばわからない
    → エンジニア全員がチャンネルに参加しているため、得意分野関係なくエンジニア同士で議論が進み解決に繋がる可能性が高まる
  • そもそもポストしにくい
    → 良くも悪くもレスを期待するチャンネルではない位置付けのため、タイミングや内容に関わらずポストできる

またそれだけでなく、そのほかにも以下の様な良いところもあると感じています。

  • 知識の幅が増える
    → 問題の当事者でなくても、話題に上がった問題を知ることで、関連する情報として認識や知識の幅が増える
  • エンジニア同士のコミュニケーションの促進
    →「技術」という共通の話題から、プロジェクト関わらずエンジニア間でコミュニケーションが発生する

まとめ

弊社では Slack の運用として、技術的なことを気軽にポストできるチャンネルを作成しています。フルリモートという働きであっても、エンジニア間でのコミュニケーションのハードルを下げることができていると感じています。

採用情報

弊社はまだまだ Slack の運用や開発環境がリッチとはいえないため、今後もより良くなるように改善や続けて行きます。この様な開発環境である Whitehealthcare に興味が出た方はこちらからご確認下さい。

https://hrmos.co/pages/whitehealthcare