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は、裏側で mail gem を使用してメールをパースしているようだ
  • どうもパースに失敗した場合は文字列で返ってきてしまうということらしい

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 はくぐり抜けてしまう
  • mail gem のパース成否をチェック内容としたカスタムバリデーターを用意することにする
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 ConnectivityTerminalプラグインを確認してみたが、どちらもインストール済で有効化されていた。

解決策

~/Library/Application Support/JetBrains/RubyMine2025.3/disabled_plugins.txt の中身を空にして再起動したら解決した。

使ってないプラグインや更新が止まったようなプラグインはマメに Uninstall しておいたほうがよさそう。

参考

「エンジニアのためのドキュメントライティング」を読んだ

ドキュメントをもっとうまく書けるようになりたいなと思って手に取った本。

仮想のサービスを題材にしているものの、具体への踏み込みというよりは、考え方の方に比重が置かれているような気がする。

この本が伝えたかったのは、ドキュメントもプロダクトのように扱うとうまくいくということなのかな、と思った。 ユーザーにとっての価値が大事だったり、フィードバックをちゃんと受け取れるようにしたり。。

ドキュメントに対して考えるべきことの全体感みたいなものは見えてきた気がするが、じゃあ実際に自分たちの現場に当てはめて考えようと思うと、まだまだ難しいものが残されていそう。

「Tidy First?」 を読んだ

Kent Beck が2023年に16年ぶりに発表した書籍らしい。

シリーズとして執筆される予定らしいが、一冊目ということもあるのかボリュームはそれほどなく、サッと読める。

第Ⅰ部と第Ⅱ部では、乱雑なコードを整頓するための実践的なテクニックとプロセスの話に触れられている。

知っている話が多かったけれども、改めて意識していきたいと思った。

読んでいて面白かったのは第Ⅲ部で、本のタイトルである「先に整頓するか?」に対する答えの解説は(特に仕事で行う)ソフトウェア開発の構造が言語化されたもののように思う。

続巻も楽しみにして待ってる。

Rails で年月のみの値を管理する #rails

目的

月次の集計結果や、職歴の開始年月など、日付が意味を持たず年月のみだけを管理したいケースがある。

そういった場合にどう実装するか。

方針

  • DB上は、DATE型で値を保持する
  • 日付は必ず月初日(1日)にして意味を持たせない

文字列等他の形式で保持する方法もあるが、DATE型にするとデータの整合性を守りやすく、ソートや検索時の利便性も高い。

問題点

年月を画面から入力する際、html の input 要素には month 型がある。

<input type="month"> - HTML | MDN

Rails の form にも month_field が用意されている。

month_field | Railsドキュメント

しかし、 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 でホスティングしようとしていた(XMLRSS フィードのようなもの)。

ファイルはアップロードされ、CloudFront 経由でアクセスもできたのだが、ブラウザだとダウンロードされてしまい表示ができなかった。

原因

S3 で当該 XML のプロパティを確認したところ、メタデータがおかしくなっていた。

Content-Dispositionattachment が設定されてしまっており、そのためブラウザでダウンロードになってしまっている。

また Content-Typeapplication/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/xmlapplication/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)

まとめ

設定を変更することで、問題なく表示されるようになった。

一律でなくファイルによって個別に対応する必要があるような場合は、都度設定を切り替えるようにすればできそう(調べていない)。

参考