normalizes を使ってフリーテキストのテンプレート機能を作る #rails
目的
テキスト型のカラムを持つモデルにおいて、入力フォーム(テキストエリア)に特定のテンプレートを表示したい。
例えば以下のようなもの。
【本日の業務内容】 ・ ・ 【所感・課題】 【明日の予定】 ・
- placeholder とは異なり、テンプレートに対してユーザーが修正するような形で使いたい
- 厳密なデータの構造化(項目を分ける)が必要ない、あるいは頻繁に項目が変わるようなもの
実現に当たっての課題
- ユーザーがテンプレートから値を書き換えていない場合は、DBに値を保存させたくない(nil として扱いたい)
- 複数のモデルで似たようなことをやりたいので、汎用的な仕組みにしたい
方針
- normalizes を使って、テンプレートの処理を正規化として任せる
- ActiveSupport::Concern でモジュール化し、各モデルで使い回せるようにする
実装例
# app/models/concerns/template_normalizable.rb module TemplateNormalizable extend ActiveSupport::Concern class_methods do def normalizes_template_for(attribute, template:, allow_template_value: false) normalizes attribute, with: lambda { |value| # 比較用に改行コードの統一 normalized_template = template.to_s.gsub("\r\n", "\n").strip normalized_value = value.to_s.gsub("\r\n", "\n").strip return nil if normalized_value.blank? return nil if !allow_template_value && normalized_value == normalized_template value } # ViewやAPIレスポンスなどで使うためのメソッドを動的生成 define_method("#{attribute}_or_template") do public_send(attribute).presence || template end end end end
ブラウザのテキストエリアから送信される改行コードは \r\n (CRLF) になることが多いため、Ruby側で定義したテンプレートの改行コード \n (LF) と単純に文字列比較すると一致しないケースがある。
そのため、比較前に改行コードを統一し、差異を吸収するようにしてある。
また、テンプレート文字列を必要に応じてそのまま保存できるような allow_template_value オプションも追加した。
使用例
モデル側
# app/models/daily_report.rb class DailyReport < ApplicationRecord # モジュールを include include TemplateNormalizable # 複数行にわたるテンプレートはヒアドキュメントで定義したほうが可読性がいいかも REPORT_TEMPLATE = <<~TEXT.strip 【本日の業務内容】 ・ ・ 【所感・課題】 【明日の予定】 ・ TEXT # 対象のカラムとテンプレートを渡して宣言する normalizes_template_for :content, template: REPORT_TEMPLATE # テンプレートの値そのまま保存を許可する場合は allow_template_value を使う normalizes_template_for :blocker, template: '特になし', allow_template_value: true end
View側
<%= #{カラム名}_or_template形式でメソッドが用意される %>
<%= f.text_area :content, value: daily_report.content_or_template %>
感想
テンプレート定義が多いと、モデルが結構汚れるので悩ましいところ。
もしテンプレート自体をデータベースで管理したいとなった場合は、方針自体を見直したほうがよさそう。
ActionMailerの `.to` が配列ではなく文字列になるケースがある #rails
問題
- ActionMailerを使っていて、不可解な
NoMethodErrorに遭遇した - 通常、
message.toは宛先の配列を返すが、特定のメールアドレスの場合だけ「文字列」が返ってきてしまっていた - そのため、配列操作系メソッドなどを利用したい場合、エラーになってしまう
期待挙動:
# 通常のアドレス mail = UserMailer.welcome('test@example.com') mail.message.to # => ["test@example.com"] (Array)
問題になるケース:
# 特定の不正なアドレス mail = UserMailer.welcome('.@gmail.com') mail.message.to # => ".@gmail.com" (String)
原因
- RailsのActionMailerは、裏側で
mailgem を使用してメールをパースしているようだ - どうもパースに失敗した場合は文字列で返ってきてしまうということらしい
RFC違反について
- エラーになった
.@gmail.comはローカルパートがドットで始まっているため、そもそも RFC 違反である - ただし、RFC 違反であっても許容されているケースもある
- この辺は、過去のキャリアメールとかでRFC違反のアドレスが存在していたことなどが要因なのかもしれない(想像)
- そのため、RFC違反しているから期待挙動にならないというわけではない
# RFC違反のアドレスでも許容されるものがある(連続ドットなど) mail = UserMailer.welcome('te..st@example.com') mail.message.to # => ["te..st@example.com"] (Array)
対応
- 事前にバリデーションを行う
- メールアドレスのバリデーションには
URI::MailTo::EMAIL_REGEXPなどを使った正規表現の Format バリデーションが主流と思うが、.@gmail.comはくぐり抜けてしまう mailgem のパース成否をチェック内容としたカスタムバリデーターを用意することにする- 正規表現の自前メンテは避けたい
class EmailFormatValidator < ActiveModel::EachValidator def validate_each(record, attribute, value) return if value.blank? # 基本的な形式をチェック unless value.match?(URI::MailTo::EMAIL_REGEXP) record.errors.add(attribute, options[:message] || :invalid) return end # 正規表現をすり抜けた場合、mail gem を使ってチェック begin # インスタンス生成時に、パース成否が判別できる Mail::Address.new(value) rescue Mail::Field::ParseError # パース失敗時は、エラーにする record.errors.add(attribute, options[:message] || :invalid) end end end
まとめ
- メールアドレスのバリデーションは正規表現だけでは不十分な場合がある
- 今回の
.@のケースの場合、思わぬ落とし穴になる可能性がある - 特に不特定多数に公開しているようなWebサイトの場合、カスタムバリデーターを用意して対応できるようにしておくといいかも
RubyMineをバージョンアップしたら、リモートインタープリターが認識できなくなった #rubymine
問題
RubyMine 2025.3.1.1 にバージョンアップしたところ、起動時にエラーが起きて Remote Interpreter が認識されなくなった。
Home path (docker-compose://[/path/to/your/repo/compose.yaml]:rails//usr/local/bin/ruby) for Ruby interpreter 'Remote: ruby-3.3.10-p183' does not exist
設定が何かおかしいのかな、と思って Remote Interpreter を作り直そうとしてみると、Plugin 絡みのエラーが出る。
One or both of the 'FTP/SFTP/WebDAV Connectivity' and 'Terminal' plugins are missing. Please enable the plugins in 'Settings' | Plugins.
メッセージの通り FTP/SFTP/WebDAV Connectivity と Terminal のプラグインを確認してみたが、どちらもインストール済で有効化されていた。
解決策
~/Library/Application Support/JetBrains/RubyMine2025.3/disabled_plugins.txt の中身を空にして再起動したら解決した。
使ってないプラグインや更新が止まったようなプラグインはマメに Uninstall しておいたほうがよさそう。
参考
「エンジニアのためのドキュメントライティング」を読んだ
ドキュメントをもっとうまく書けるようになりたいなと思って手に取った本。
仮想のサービスを題材にしているものの、具体への踏み込みというよりは、考え方の方に比重が置かれているような気がする。
この本が伝えたかったのは、ドキュメントもプロダクトのように扱うとうまくいくということなのかな、と思った。 ユーザーにとっての価値が大事だったり、フィードバックをちゃんと受け取れるようにしたり。。
ドキュメントに対して考えるべきことの全体感みたいなものは見えてきた気がするが、じゃあ実際に自分たちの現場に当てはめて考えようと思うと、まだまだ難しいものが残されていそう。
「Tidy First?」 を読んだ
Rails で年月のみの値を管理する #rails
目的
月次の集計結果や、職歴の開始年月など、日付が意味を持たず年月のみだけを管理したいケースがある。
そういった場合にどう実装するか。
方針
- DB上は、DATE型で値を保持する
- 日付は必ず月初日(1日)にして意味を持たせない
文字列等他の形式で保持する方法もあるが、DATE型にするとデータの整合性を守りやすく、ソートや検索時の利便性も高い。
問題点
年月を画面から入力する際、html の input 要素には month 型がある。
<input type="month"> - HTML | MDN
Rails の form にも month_field が用意されている。
しかし、 month 型はパラメータの値が YYYY-MM 形式の文字列であるため、どこかで日付型に変換する必要がある。
解決策
ActiveRecord::Type::Date を拡張して年月用の Type を用意し、 YYYY-MM の文字列を解釈できるようにする。
実装例
年月型。
配置場所は app/models/types 配下にしたが、もっと良さげな場所があるかもしれない。
module Types class YearMonth < ActiveRecord::Type::Date def cast_value(value) date = if value.is_a?(String) && value.match?(%r{^\d{4}-(?:0[1-9]|1[0-2])$}) # YYYY-MM 形式の文字列の場合は日付を追加したうえで日付に変換する Time.zone.parse("#{value}-01").to_date else # それ以外の変換は親クラスに任せる super end # 日付型に変換できた場合は月初日にする date&.beginning_of_month end def changed_in_place?(raw_old_value, new_value) # 年月が同じであれば値が変わっていないとみなすよう、cast してから比較するようにしておく cast(raw_old_value) != cast(new_value) end end end
initializer で作成した年月型を読み込む。
# app/config/initializers/types.rb Rails.application.reloader.to_prepare do ActiveRecord::Type.register(:year_month, Types::YearMonth) end
モデル側では attribute で年月型であることを指定する。
class SampleModel < ApplicationRecord attribute :sample_month, :year_month end
Active Storage で XML の Content-Type が application/octet-stream になるのを防ぐ #rails
問題
Rails から Active Storage で S3 に XML をアップロードし、CloudFront でホスティングしようとしていた(XML は RSS フィードのようなもの)。
ファイルはアップロードされ、CloudFront 経由でアクセスもできたのだが、ブラウザだとダウンロードされてしまい表示ができなかった。
原因
S3 で当該 XML のプロパティを確認したところ、メタデータがおかしくなっていた。
Content-Disposition に attachment が設定されてしまっており、そのためブラウザでダウンロードになってしまっている。
また Content-Type も application/xml を指定しているにも関わらず、application/octet-stream になっていた。
データ( active_storage_blobs )の content_type や S3 でのオブジェクトタイプは問題なかった。
my_model.attach( io: f, filename: 'feed.xml', content_type: 'application/xml', key: 'feed.xml' )
対応
Rails の設定を変更することで解決した。
config.active_storage.content_types_to_serve_as_binary
content_types_to_serve_as_binary に設定されている Content-Type は、常に attachement として扱われてしまう。
デフォルトで text/xml や application/xml が含まれているので、外してやる必要がある。
config.active_storage.content_types_allowed_inline
content_types_allowed_inline は Active Storageでインライン配信を許可する Content-Type を設定するものだが、application/xml を含めておくようにしないと S3 上でのメタデータも意図したようにならない。
デフォルト値に追加しておくとよい。
# application/xml を削除 config.active_storage.content_types_to_serve_as_binary = %w(text/html image/svg+xml application/postscript application/x-shockwave-flash text/xml application/xhtml+xml application/mathml+xml text/cache-manifest) # application/xml を追加 config.active_storage.content_types_allowed_inline = %w(image/webp image/avif image/png image/gif image/jpeg image/tiff image/vnd.adobe.photoshop image/vnd.microsoft.icon application/pdf application/xml)
まとめ
設定を変更することで、問題なく表示されるようになった。
一律でなくファイルによって個別に対応する必要があるような場合は、都度設定を切り替えるようにすればできそう(調べていない)。

