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 / false signifie 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 :

  1. Elle définit un getter qui enveloppe le hash JSON dans un DataAccessor
  2. Le DataAccessor utilise method_missing pour fournir un accès typé à chaque clé
  3. La conversion de type utilise ActiveModel::Type.lookup, le même système que Rails utilise pour les colonnes de base de données
  4. Les valeurs par défaut sont appliquées via reverse_merge! lors de l’instanciation de l’accesseur
  5. Un callback before_save garantit 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 :

  1. Seulement trois types. Boolean, integer, string. Pas de dates, de tableaux ni d’objets imbriqués.

  2. 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.

  3. Pas de validation. Il n’y a aucun moyen intégré de valider les valeurs (comme max_invites doit être positif). Ajoutez vos propres validations.

  4. 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.