Rails 8.2プレビュー: has_jsonで型安全なJSON属性

Note: この機能はRails 8.2で登場します。main にはマージ済みですが、まだリリースされていません。GitHubでソースを見るか、Gemfileをmainブランチに向けて試せます。Rails 8.2のもう一つの注目すべき追加機能として、Rails.app.revision による組み込みのデプロイ追跡もチェックしてみてください。


Railsの開発者なら誰もが一度はこのコードを書いたことがあるはずです:

class Account < ApplicationRecord
  def settings
    super || {}
  end

  def max_invites
    settings["max_invites"]&.to_i
  end

  def max_invites=(value)
    settings["max_invites"] = value.to_i
    save_settings!
  end

  def restrict_admins_only?
    ActiveModel::Type::Boolean.new.cast(settings["restrict_admins_only"])
  end

  def restrict_admins_only=(value)
    settings["restrict_admins_only"] = ActiveModel::Type::Boolean.new.cast(value)
    save_settings!
  end

  private

  def save_settings!
    self.settings = settings # trigger the dirty tracking
  end
end

そして設定を追加するたびに、これはどんどんひどくなっていきます。

Rails 8.2が導入する has_json は、これを次のように縮めます:

class Account < ApplicationRecord
  has_json :settings, restrict_admins_only: false, max_invites: 10
end

これだけです。型変換、デフォルト値、アクセサ。すべて処理されます。

問題: フォームパラメータは常に文字列

ユーザーがフォームを送信すると、すべてが文字列として届きます:

params[:account][:settings]
# => { "max_invites" => "50", "restrict_admins_only" => "true" }

これをJSONカラムにそのまま格納すると、データベースには文字列の値が入ります:

{"max_invites": "50", "restrict_admins_only": "true"}

こうなると、コードは .to_i やboolean変換だらけになります。比較は静かに壊れます:

account.settings["max_invites"] > 25  # => false (string comparison!)
account.settings["restrict_admins_only"] == true  # => false (it's "true", not true)

has_jsonはこれをどう解決するか

has_json は、型を自動的に変換するスキーマ対応のアクセサを作ります:

class Account < ApplicationRecord
  has_json :settings,
    restrict_admins_only: true,   # boolean, defaults to true
    max_invites: 10,              # integer, defaults to 10
    welcome_message: "Hello!"     # string, defaults to "Hello!"
end

スキーマはデフォルト値によって定義されます。Railsは各値から型を推論します:

  • true / false はbooleanを意味する
  • Integerはintegerを意味する
  • Stringはstringを意味する

これでフォームパラメータがそのまま動きます:

account = Account.new
account.settings = {
  "max_invites" => "50",
  "restrict_admins_only" => "false"
}

account.settings.max_invites           # => 50 (integer!)
account.settings.restrict_admins_only? # => false (boolean!)

実例: ユーザー設定パネル

ユーザーが通知設定を構成する設定パネルを作ってみましょう。

マイグレーション

class AddPreferencesToUsers < ActiveRecord::Migration[8.2]
  def change
    add_column :users, :preferences, :jsonb, default: {}, null: false
  end
end

モデル

class User < ApplicationRecord
  has_json :preferences,
    email_notifications: true,
    sms_notifications: false,
    digest_frequency: 7,        # days between digest emails
    timezone: "UTC",
    items_per_page: 25
end

フォーム

<%# app/views/users/preferences.html.erb %>
<%= form_with model: current_user, url: preferences_path, method: :patch do |f| %>
  <fieldset>
    <legend>Notifications</legend>

    <%= f.label :email_notifications do %>
      <%= f.check_box :email_notifications,
          checked: current_user.preferences.email_notifications? %>
      Email notifications
    <% end %>

    <%= f.label :sms_notifications do %>
      <%= f.check_box :sms_notifications,
          checked: current_user.preferences.sms_notifications? %>
      SMS notifications
    <% end %>

    <%= f.label :digest_frequency %>
    <%= f.select :digest_frequency,
        [["Daily", 1], ["Weekly", 7], ["Monthly", 30]],
        selected: current_user.preferences.digest_frequency %>
  </fieldset>

  <fieldset>
    <legend>Display</legend>

    <%= f.label :items_per_page %>
    <%= f.select :items_per_page,
        [10, 25, 50, 100],
        selected: current_user.preferences.items_per_page %>

    <%= f.label :timezone %>
    <%= f.time_zone_select :timezone,
        nil,
        default: current_user.preferences.timezone %>
  </fieldset>

  <%= f.submit "Save Preferences" %>
<% end %>

コントローラ

class PreferencesController < ApplicationController
  def update
    # All the type coercion happens automatically
    current_user.preferences = preferences_params
    current_user.save!

    redirect_to preferences_path, notice: "Preferences saved!"
  end

  private

  def preferences_params
    params.require(:user).permit(
      :email_notifications,
      :sms_notifications,
      :digest_frequency,
      :items_per_page,
      :timezone
    )
  end
end

手動の型変換は不要です。フォームは文字列を送り、has_json がスキーマに基づいてそれらを正しい型に変換します。

トップレベルアクセスのためのhas_delegated_json

.preferences アクセサを介さず、モデル上で直接設定にアクセスしたい場合は、has_delegated_json を使います:

class User < ApplicationRecord
  has_delegated_json :preferences,
    email_notifications: true,
    sms_notifications: false,
    digest_frequency: 7
end

user = User.new
user.email_notifications?  # => true (delegated to preferences.email_notifications?)
user.digest_frequency = 30
user.digest_frequency      # => 30

これは、JSONカラムがネストしたオブジェクトというより、モデルの拡張として機能する場合に便利です。

デフォルト値なしで型を宣言する

デフォルト値なしで型だけを指定したい場合もあります。シンボルを使います:

class Account < ApplicationRecord
  has_json :settings,
    max_invites: :integer,     # integer, defaults to nil
    beta_enabled: :boolean,    # boolean, defaults to nil
    custom_domain: :string     # string, defaults to nil
end

account = Account.new
account.settings.max_invites    # => nil
account.settings.beta_enabled?  # => false (nil is falsey)

これは、デフォルト値を持つべきでないオプションの設定に便利です。

実例: マルチテナントの機能フラグ

実践的なユースケースを挙げます: テナントごとの機能フラグをJSONに格納します。

class Tenant < ApplicationRecord
  has_json :feature_flags,
    new_dashboard: false,
    api_v2: false,
    ai_assistant: false,
    max_users: 5,
    max_storage_gb: 10
end

アプリケーション内で:

class ApplicationController < ActionController::Base
  before_action :check_feature_access

  private

  def feature_enabled?(flag)
    current_tenant.feature_flags.public_send("#{flag}?")
  end
  helper_method :feature_enabled?

  def check_feature_access
    # Automatically enforce limits
    if current_tenant.users.count >= current_tenant.feature_flags.max_users
      redirect_to upgrade_path if action_name == "create" && controller_name == "users"
    end
  end
end

ビュー内で:

<% if feature_enabled?(:new_dashboard) %>
  <%= render "dashboards/new_layout" %>
<% else %>
  <%= render "dashboards/legacy_layout" %>
<% end %>

<% if feature_enabled?(:ai_assistant) %>
  <%= render "shared/ai_chat_widget" %>
<% end %>

フラグを切り替える管理パネル:

class Admin::TenantsController < AdminController
  def update_flags
    tenant = Tenant.find(params[:id])
    tenant.feature_flags = feature_flag_params
    tenant.save!

    redirect_to admin_tenant_path(tenant), notice: "Flags updated"
  end

  private

  def feature_flag_params
    params.require(:tenant).permit(
      :new_dashboard, :api_v2, :ai_assistant, :max_users, :max_storage_gb
    )
  end
end

フォームのチェックボックスは “1”/“0” を送り、数値入力は文字列を送ります。has_json がそのすべてを処理します。

内部の仕組み

その実装はエレガントです。has_json を呼ぶと:

  1. JSONハッシュを DataAccessor でラップするゲッターを定義する
  2. DataAccessormethod_missing を使って各キーへの型付きアクセスを提供する
  3. 型変換には ActiveModel::Type.lookup を使う。これはRailsがデータベースカラムに使っているのと同じ仕組みです
  4. デフォルト値は、アクセサがインスタンス化されるときに reverse_merge! で適用される
  5. before_save コールバックが、永続化の前にデフォルト値が書き込まれることを保証する

実際の変換はここで起こります:

def lookup_schema_type_for(key)
  type_or_default_value = @schema[key.to_sym]

  case type_or_default_value
  when :boolean, :integer, :string
    ActiveModel::Type.lookup type_or_default_value
  when TrueClass, FalseClass
    ActiveModel::Type.lookup :boolean
  when Integer
    ActiveModel::Type.lookup :integer
  when String
    ActiveModel::Type.lookup :string
  end
end

つまり、通常のActiveRecord属性とまったく同じ変換挙動が得られます。"true""1""yes" はすべて true になります。"42"42 になります。

知っておくべき制約

全面的に採用する前に、制約を知っておきましょう:

  1. 型は3つだけ。 boolean、integer、string。date、array、ネストしたオブジェクトはありません。

  2. ネスト不可。 スキーマの中にスキーマを定義することはできません。各キーはプリミティブでなければなりません。

  3. バリデーションなし。 値を検証する組み込みの方法はありません(max_invites は正の数でなければならない、など)。自分でバリデーションを追加してください。

  4. method_missingのオーバーヘッド。 各属性アクセスは method_missing を経由します。ホットパスではキャッシュを検討してください。

複雑なJSON構造には、やはり store_accessor や専用のモデルが欲しくなるでしょう。

手動アクセサからの移行

手動アクセサ付きの既存のJSONカラムがあっても、移行は簡単です:

Before:

class Account < ApplicationRecord
  def settings
    super || {}
  end

  def max_invites
    settings["max_invites"]&.to_i || 10
  end

  def max_invites=(value)
    self.settings = settings.merge("max_invites" => value.to_i)
  end

  # ... 20 more methods like this
end

After:

class Account < ApplicationRecord
  has_json :settings, max_invites: 10 # ... add all your other fields
end

JSONの構造は同じままです。既存のデータはマイグレーションなしで動作します。

今日試してみる

Rails 8.2を待つ間、mainを指すことで has_json を試せます:

# Gemfile
gem "rails", github: "rails/rails", branch: "main"

または、このモジュールだけを抜き出してもよいです。約100行で、依存関係はありません。

まとめ

JSONフィールドごとに .to_iActiveModel::Type::Boolean.new.cast を書くのにうんざりしているなら、has_json はあなたのためのものです。ユーザー設定、機能フラグ、構成を処理してくれるので、同じ変換コードを何度も書くのをやめられます。

こうした機能を活用するためにRailsのバージョンをアップグレードしているなら、アプリに影響しうる Rails 7.2のコネクションプールのパフォーマンス変更にも注意してください。

完全な実装と議論については PR #56258 を参照してください。