Vista previa de Rails 8.2: atributos JSON con tipos seguros gracias a has_json

Note: Esta funcionalidad llega en Rails 8.2. Está mergeada en main pero aún no se ha publicado. Puedes ver el código fuente en GitHub o probarla apuntando tu Gemfile a la rama main. Otra incorporación de Rails 8.2 que vale la pena revisar es el seguimiento de despliegues integrado con Rails.app.revision.


Todo desarrollador de Rails ha escrito este código:

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

Y empeora con cada ajuste que añades.

Rails 8.2 introduce has_json, que reduce todo esto a:

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

Eso es todo. Conversión de tipos, valores por defecto y accesores. Todo gestionado.

El problema: los params de formulario siempre son cadenas

Cuando un usuario envía un formulario, todo llega como cadenas:

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

Si guardas esto directamente en una columna JSON, obtienes valores de cadena en tu base de datos:

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

Ahora tu código está plagado de .to_i y conversiones a boolean. Las comparaciones se rompen silenciosamente:

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

Cómo lo resuelve has_json

has_json crea un accesor consciente del schema que convierte los tipos automáticamente:

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

El schema queda definido por los valores por defecto. Rails infiere el tipo a partir de cada valor:

  • true / false significa boolean
  • Un Integer significa integer
  • Un String significa string

Ahora los params de formulario simplemente funcionan:

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!)

Ejemplo real: un panel de preferencias de usuario

Construyamos un panel de preferencias donde los usuarios configuran sus ajustes de notificación.

La migration

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

El modelo

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

El formulario

<%# 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 %>

El controlador

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

Sin conversión de tipos manual. El formulario envía cadenas, has_json las convierte a los tipos correctos según el schema.

Usar has_delegated_json para acceso en el nivel superior

Si quieres saltarte el accesor .preferences y acceder a los ajustes directamente en el modelo, usa 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

Esto es útil cuando tu columna JSON actúa como una extensión del modelo en lugar de como un objeto anidado.

Declarar tipos sin valores por defecto

A veces quieres especificar un tipo sin un valor por defecto. Usa símbolos:

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)

Esto resulta práctico para ajustes opcionales que no deberían tener un valor por defecto.

Ejemplo real: feature flags multi-tenant

Aquí tienes un caso de uso práctico: feature flags por tenant almacenados 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

En tu aplicación:

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

En tus vistas:

<% 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 panel de administración para alternar los 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

Las casillas de verificación del formulario envían “1”/“0”, los inputs numéricos envían cadenas. has_json se encarga de todo ello.

Cómo funciona por dentro

La implementación es elegante. Cuando llamas a has_json:

  1. Define un getter que envuelve el hash JSON en un DataAccessor
  2. El DataAccessor usa method_missing para proporcionar acceso tipado a cada clave
  3. La conversión de tipo usa ActiveModel::Type.lookup, el mismo sistema que Rails usa para las columnas de base de datos
  4. Los valores por defecto se aplican mediante reverse_merge! cuando se instancia el accesor
  5. Un callback before_save asegura que los valores por defecto se escriban antes de la persistencia

La conversión real ocurre aquí:

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

Esto significa que obtienes el mismo comportamiento de conversión que los atributos normales de ActiveRecord. "true", "1", "yes" se convierten todos en true. "42" se convierte en 42.

Limitaciones que conviene conocer

Antes de apostarlo todo, conoce las restricciones:

  1. Solo tres tipos. Boolean, integer, string. Ni fechas, ni arrays, ni objetos anidados.

  2. Sin anidamiento. No puedes definir un schema dentro de un schema. Cada clave debe ser una primitiva.

  3. Sin validación. No hay una forma integrada de validar valores (como que max_invites deba ser positivo). Añade tus propias validaciones.

  4. Sobrecoste de method_missing. Cada acceso a un atributo pasa por method_missing. Para rutas críticas, considera usar caché.

Para estructuras JSON complejas, seguirás queriendo store_accessor o un modelo dedicado.

Migrar desde accesores manuales

Si tienes columnas JSON existentes con accesores manuales, la migración es directa:

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 estructura JSON se mantiene igual. Tus datos existentes funcionan sin migración.

Pruébalo hoy

Mientras esperas a Rails 8.2, puedes probar has_json apuntando a main:

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

O extrae solo el módulo. Son unas 100 líneas y no tiene dependencias.

Para terminar

Si estás cansado de escribir .to_i y ActiveModel::Type::Boolean.new.cast para cada campo JSON, has_json es para ti. Gestiona preferencias de usuario, feature flags y configuración, para que dejes de escribir una y otra vez el mismo código de conversión.

Si estás subiendo de versión de Rails para aprovechar funcionalidades como esta, ten en cuenta los cambios de rendimiento del connection pool en Rails 7.2 que pueden afectar a tu aplicación.

Consulta la PR #56258 para la implementación completa y la discusión.