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 を呼ぶと:
- JSONハッシュを
DataAccessorでラップするゲッターを定義する DataAccessorはmethod_missingを使って各キーへの型付きアクセスを提供する- 型変換には
ActiveModel::Type.lookupを使う。これはRailsがデータベースカラムに使っているのと同じ仕組みです - デフォルト値は、アクセサがインスタンス化されるときに
reverse_merge!で適用される 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 になります。
知っておくべき制約
全面的に採用する前に、制約を知っておきましょう:
-
型は3つだけ。 boolean、integer、string。date、array、ネストしたオブジェクトはありません。
-
ネスト不可。 スキーマの中にスキーマを定義することはできません。各キーはプリミティブでなければなりません。
-
バリデーションなし。 値を検証する組み込みの方法はありません(
max_invitesは正の数でなければならない、など)。自分でバリデーションを追加してください。 -
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_i や ActiveModel::Type::Boolean.new.cast を書くのにうんざりしているなら、has_json はあなたのためのものです。ユーザー設定、機能フラグ、構成を処理してくれるので、同じ変換コードを何度も書くのをやめられます。
こうした機能を活用するためにRailsのバージョンをアップグレードしているなら、アプリに影響しうる Rails 7.2のコネクションプールのパフォーマンス変更にも注意してください。
完全な実装と議論については PR #56258 を参照してください。