Aperçu de Rails 8.2 : des attributs JSON typés et sûrs avec has_json
Note: Cette fonctionnalité arrive dans Rails 8.2. Elle est mergée dans main mais pas encore publiée. Vous pouvez voir le code source sur GitHub ou l’essayer en pointant votre Gemfile sur la branche main. Un autre ajout de Rails 8.2 qui vaut le coup d’œil : le suivi de déploiement intégré avec Rails.app.revision.
Chaque développeur Rails a déjà écrit ce code :
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
Et ça empire à chaque réglage que vous ajoutez.
Rails 8.2 introduit has_json, qui réduit tout ça à :
class Account < ApplicationRecord
has_json :settings, restrict_admins_only: false, max_invites: 10
end
C’est tout. Conversion de types, valeurs par défaut et accesseurs. Tout est géré.
Le problème : les params de formulaire sont toujours des chaînes
Quand un utilisateur soumet un formulaire, tout arrive sous forme de chaînes :
params[:account][:settings]
# => { "max_invites" => "50", "restrict_admins_only" => "true" }
Si vous stockez ça tel quel dans une colonne JSON, vous obtenez des valeurs chaînes dans votre base de données :
{"max_invites": "50", "restrict_admins_only": "true"}
Désormais votre code est truffé de .to_i et de conversions booléennes. Les comparaisons cassent silencieusement :
account.settings["max_invites"] > 25 # => false (string comparison!)
account.settings["restrict_admins_only"] == true # => false (it's "true", not true)
Comment has_json résout ça
has_json crée un accesseur conscient du schéma qui convertit automatiquement les types :
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
Le schéma est défini par les valeurs par défaut. Rails infère le type à partir de chaque valeur :
true/falsesignifie boolean- Un Integer signifie integer
- Une String signifie string
Maintenant les params de formulaire fonctionnent simplement :
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!)
Exemple concret : un panneau de préférences utilisateur
Construisons un panneau de préférences où les utilisateurs configurent leurs réglages de notification.
La migration
class AddPreferencesToUsers < ActiveRecord::Migration[8.2]
def change
add_column :users, :preferences, :jsonb, default: {}, null: false
end
end
Le modèle
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
Le formulaire
<%# 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 %>
Le contrôleur
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
Aucune conversion de type manuelle. Le formulaire envoie des chaînes, has_json les convertit dans les bons types d’après le schéma.
Utiliser has_delegated_json pour un accès au niveau supérieur
Si vous voulez vous passer de l’accesseur .preferences et accéder aux réglages directement sur le modèle, utilisez 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
C’est utile quand votre colonne JSON agit comme une extension du modèle plutôt que comme un objet imbriqué.
Déclarer des types sans valeurs par défaut
Parfois vous voulez spécifier un type sans valeur par défaut. Utilisez des symboles :
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)
C’est pratique pour des réglages optionnels qui ne devraient pas avoir de valeur par défaut.
Exemple concret : feature flags multi-tenant
Voici un cas d’usage concret : des feature flags par tenant stockés en 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
Dans votre application :
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
Dans vos vues :
<% 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 %>
Un panneau d’administration pour basculer les flags :
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
Les cases à cocher du formulaire envoient “1”/“0”, les champs numériques envoient des chaînes. has_json gère tout ça.
Comment ça marche sous le capot
L’implémentation est élégante. Quand vous appelez has_json :
- Elle définit un getter qui enveloppe le hash JSON dans un
DataAccessor - Le
DataAccessorutilisemethod_missingpour fournir un accès typé à chaque clé - La conversion de type utilise
ActiveModel::Type.lookup, le même système que Rails utilise pour les colonnes de base de données - Les valeurs par défaut sont appliquées via
reverse_merge!lors de l’instanciation de l’accesseur - Un callback
before_savegarantit que les valeurs par défaut sont écrites avant la persistance
La conversion réelle se produit ici :
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
Cela signifie que vous obtenez le même comportement de conversion que les attributs ActiveRecord classiques. "true", "1", "yes" deviennent tous true. "42" devient 42.
Les limites à connaître
Avant de tout miser dessus, connaissez les contraintes :
-
Seulement trois types. Boolean, integer, string. Pas de dates, de tableaux ni d’objets imbriqués.
-
Pas d’imbrication. Vous ne pouvez pas définir un schéma à l’intérieur d’un schéma. Chaque clé doit être une primitive.
-
Pas de validation. Il n’y a aucun moyen intégré de valider les valeurs (comme
max_invitesdoit être positif). Ajoutez vos propres validations. -
Surcoût de method_missing. Chaque accès à un attribut passe par
method_missing. Pour les chemins critiques, envisagez la mise en cache.
Pour des structures JSON complexes, vous voudrez quand même store_accessor ou un modèle dédié.
Migrer depuis des accesseurs manuels
Si vous avez des colonnes JSON existantes avec des accesseurs manuels, la migration est directe :
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
La structure JSON reste la même. Vos données existantes fonctionnent sans migration.
Essayez-le aujourd’hui
En attendant Rails 8.2, vous pouvez essayer has_json en pointant sur main :
# Gemfile
gem "rails", github: "rails/rails", branch: "main"
Ou extrayez juste le module. Il fait environ 100 lignes et n’a aucune dépendance.
Pour conclure
Si vous en avez assez d’écrire .to_i et ActiveModel::Type::Boolean.new.cast pour chaque champ JSON, has_json est fait pour vous. Il gère les préférences utilisateur, les feature flags et la configuration, pour que vous arrêtiez d’écrire encore et encore le même code de conversion.
Si vous montez de version Rails pour profiter de fonctionnalités comme celle-ci, soyez attentif aux changements de performance du connection pool dans Rails 7.2 qui peuvent affecter votre application.
Voir la PR #56258 pour l’implémentation complète et la discussion.