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/falsesignifica 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:
- Define un getter que envuelve el hash JSON en un
DataAccessor - El
DataAccessorusamethod_missingpara proporcionar acceso tipado a cada clave - La conversión de tipo usa
ActiveModel::Type.lookup, el mismo sistema que Rails usa para las columnas de base de datos - Los valores por defecto se aplican mediante
reverse_merge!cuando se instancia el accesor - Un callback
before_saveasegura 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:
-
Solo tres tipos. Boolean, integer, string. Ni fechas, ni arrays, ni objetos anidados.
-
Sin anidamiento. No puedes definir un schema dentro de un schema. Cada clave debe ser una primitiva.
-
Sin validación. No hay una forma integrada de validar valores (como que
max_invitesdeba ser positivo). Añade tus propias validaciones. -
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.