🇺🇸 English | 🇪🇸 Español

Atributos anidados en una relación has_many :through polimórfica con validaciones en Ruby on Rails

Una funcionalidad muy común en las aplicaciones web es añadir la posibilidad de etiquetar o categorizar. En este caso voy a implementar esta funcionalidad de manera que se puedan añadir categorías a diferentes modelos. En el ejemplo usaré solo un modelo simulando posts de un blog pero se puede repetir el mismo proceso con cualquier otro modelo.

Para ir rápido vamos a usar los scaffolds de Rails para generar a la vez el modelo y el controlador con todos los métodos.

Empieza creando un scaffold de un modelo al que queramos añadir categorías. Por ejemplo posts de un blog.

$ bin/rails g scaffold posts title:string

Modifica la migración para que el title sea obligatorio en la base de datos t.string :title, null: false. Más tarde añadiremos la validación en el modelo.

Crea también un scaffold de categorías.

$ bin/rails g scaffold categories name:string

Al igual que con los posts, modifica la migración para que el name sea obligatorio en la base de datos.

Para la relación entre posts y categories creamos un modelo que vamos a llamar categorization. Generamos la migración haciendo referencia explícita a las categorías. Para permitir que cualquier modelo que queramos pueda tener categorías no vamos a hacer referencia al modelo post que hemos creado anteriormente, sino que vamos a crear una relación polimórfica y al modelo referenciado lo llamaremos categorizable.

$ bin/rails g model categorization category:references categorizable:references{polymorphic}

Para evitar que un modelo categorizable tenga asociada la misma categoría más de una vez, añade un índice en la base de datos. Más adelante añadiremos también la validación en el modelo para que la aplicación muestre un error amigable al usuario.

class CreateCategorizations < ActiveRecord::Migration[7.0]
  def change
    create_table :categorizations do |t|
      t.references :category, null: false, foreign_key: true
      t.references :categorizable, polymorphic: true, null: false
      t.index [:category_id, :categorizable_type, :categorizable_id], unique: true,  name: "unique_category_categorizable"

      t.timestamps
    end
  end
end

Corre las migraciones para hacer efectivos los cambios en la base de datos.

$ bin/rails db:migrate

Ahora toca añadir las relaciones y validaciones a nivel de modelo.

Rails ha generado Categorization con las relaciones necesarias. Le añadimos la validación para que una categoría pueda asociarse únicamente una vez a un mismo categorizable.

class Categorization < ApplicationRecord
  belongs_to :category
  belongs_to :categorizable, polymorphic: true

  validates :category, uniqueness: { scope: [:categorizable], message: "can't be assigned more than once." }
end

Category tiene varias categorizations y le añadimos la dependencia al eliminar una categoría. Si existe una categorization asociada no podremos borrar esta categoría. Así nos aseguramos que no borramos categorías que están en uso. También le añadimos validación para que el name exista y sea único.

class Category < ApplicationRecord
  has_many :categorizations, dependent: :restrict_with_exception

  validates :name, presence: true, uniqueness: true
end

Post tiene también varias categorizations pero en éste último modelo hemos quedado que no se hace referencia explícita a Post. Por lo tanto hemos de indicar el as: :categorizable para que Rails almacene tanto el id como el tipo de modelo. Post también tiene categories y especificando la relación aquí nos permitirá acceder a ellas desde ActiveRecord de manera muy sencilla.

La única validación que añadimos en este caso es que tenga un título.

class Post < ApplicationRecord
  has_many :categorizations, as: :categorizable, dependent: :destroy
  has_many :categories, through: :categorizations

  validates_presence_of :title
end

Con las relaciones establecidas tanto a nivel de base de datos como de modelo podemos empezar a añadir datos desde la consola.

Hay diferentes maneras de añadir categorías a un post, por ejemplo:

# Crea una categoría.
Category.create(name: "test")

# Crea un post y asígnale una categoría directamente. Gracias a las relaciones que hemos especificado en el modelo, Rails sabe hacerlo automáticamente.
p = Post.new(title: "Hola mundo")
p.categories << Category.last
p.save!


# Puedes crear la relación pasando también por categorizations.
p = Post.new(title: "Hola mundo")
p.categorizations.build(category: Category.last)
p.save!

Intenta crear categorías sin nombre o posts sin títulos. Debería darte error. Intenta también añadir la misma categoría dos veces al mismo post. No deberías poder.

Formulario y atributos anidados

Hasta ahora hemos añadido posts y categorías desde la consola pero en una aplicación real los usuarios interactuarán con los datos a través de formularios.

Al usar el scaffold de Rails ya hemos generado los controladores con las acciones necesarias para crear, ver, editar, y borrar tanto categorías como posts de manera independiente. ¿Cómo podemos hacer para añadir categorías a un post a la hora de crearlo?

Rails incorpora la funcionalidad de atributos anidados para permitir crear objetos asociados a la vez que se crea el objeto padre.

Si queremos crear categorías a la hora de crear un post, también tenemos que poder crear categorizations. Empieza modificando el modelo Post para que quede así:

class Post < ApplicationRecord
  has_many :categorizations, as: :categorizable, dependent: :destroy
  has_many :categories, through: :categorizations

  accepts_nested_attributes_for :categorizations

  validates_presence_of :title
end

Deberíamos hacer lo mismo en el modelo Categorization pero hay un pequeño problema. Si hiciésemos esto, Rails intentaría crear nuevas categorías todo el rato y no queremos eso. Principalmente porque tenemos una validación que nos impide crear dos categorías con el mismo nombre.

Lo que hace accepts_nested_attributes_for es crear un método de escritura. Por ejemplo, para accepts_nested_attributes_for :category crearía un método category_attributes=(attributes).

La solución en este caso es implementar nosotros mismos este método dentro del modelo Categorization:

def category_attributes=(attributes)
  self.category = Category.find_or_create_by(attributes)
end

De este modo le estamos diciendo que primero busque una categoría con el nombre que le pasamos, y si no la encuentra, que la cree.

Ahora estamos listos para actualizar el formulario. Hay todavía unas cosas que mejorar en el modelo Post pero lo veremos al final.

Siguiendo la guía de los formularios anidados, en la vista del formulario de los posts (app/views/posts/_form.html.erb) añadimos lo siguiente:

<%= form.fields_for :categorizations do |categorizations_form| %>
  <%= categorizations_form.fields_for :category do |category_form| %>
    <div>
      <%= category_form.label :name, "Category", style: "display: block" %>
      <%= category_form.text_field :name, disabled: category_form.object.name.present? %>
    </div>
  <% end %>
<% end %>

fields_for nos permite renderizar los campos del formulario para una relación que acepte atributos anidados. En este caso tenemos que usarla dos veces, una dentro de otra, para categorizations y category.

Si recargas la vista ahora mismo verás que no se muestra ningún campo anidado. Esto es porque hay que inicializarlos. Un patrón común es hacerlo en el controlador. Por ejemplo, la acción new del controlador PostsController puede quedar así:

def new
  @post = Post.new
  @post.categorizations.build.build_category
end

Ahora sí que debería mostrar un campo vacío. Se puede añadir esta inicialización también en el método edit y en los métodos create y update si las acciones de guardar o actualizar han fallado.

Todavía no funciona, porque tenemos que permitir los nuevos parámetros. Para ello modificamos el post_params del controlador PostsController:

def post_params
  params.require(:post).permit(:title, categorizations_attributes: [ :id, { category_attributes: [:id, :name] } ])
end

Ahora ya podemos crear un post y sus categorías asociadas a la vez. Al crear una categoría que ya existe, no la crea de nuevo, sino que asocia la categoría ya existente.

Sin embargo tenemos un par de problemas:

Estamos mostrando siempre un input en blanco para poder añadir categorías nuevas pero tenemos una validación que nos impide crear categorías sin título. Tenemos que evitar de alguna manera intentar crear categorías sin título.

Si añadimos dos veces la misma categoría por despiste, nos saltará la validación y se mostrará un error en el formulario.

Esta es la parte que más me ha traído de cabeza. Al final lo he solucionado de esta manera. No sé si es la mejor, pero funciona.

accepts_nested_attributes_for acepta una opción llamada reject_if a la que le puedes pasar una función que recibe los atributos como parámetro.

Actualiza el modelo Post:

accepts_nested_attributes_for :categorizations, reject_if: :empty_or_assigned_category

La función puede ser privada.

private

def empty_or_assigned_category(attributes)
  category_name = attributes.dig(:category_attributes, :name)

  # reject if category name is empty:
  return true if !category_name.present?

  # reject if category is already assigned:
  if !attributes.dig(:id).present?
    assigned = self.categories.include? Category.find_by(name: category_name)
    return true if assigned
  end

  false
end

Esta función se ejecuta por cada categorizations_attributes que llega en params.

Si la categoría que tratamos de crear no tiene un name asignado, nos llegará: {"category_attributes"=>{"name"=>""}}. Por lo tanto hacemos un reject de esta categoría devolviendo true en la función y la ignoramos.

Al crear una categoría nueva pizza, el valor de la variable attributes es {"category_attributes"=>{"name"=>"pizza"}}. Aquí tenemos que comprobar por nuestra cuenta si el post actual tiene la categoría asociada. Como estamos dentro del modelo Post podemos acceder a self.categories y comprobar si ya está asociada. De ser así devolvemos true para rechazar este parámetro.

Si no estuviese asociada pasaría esta validación y llegaría al category_attributes=(attributes) del modelo Categorization, donde buscará a ver si ya existe y si no la creará.

Si la categoría existe y está asignada, nos llegará en los params el id de la categorization {"id"=>"10", "category_attributes"=>{"id"=>"1"}}. Rails se encarga de gestionar este caso y no nos tenemos que preocupar de nada.

Categorizar cualquier modelo usando concerns

Una vez desarrollada la funcionalidad en el modelo Post podemos extraerla en un concern para que sea más fácil de reutilizar. Para ello crea un fichero app/models/concerns/categorizable.rb con el siguiente contenido:

module Categorizable
  extend ActiveSupport::Concern

  included do
    has_many :categorizations, as: :categorizable, dependent: :destroy
    has_many :categories, through: :categorizations
  
    accepts_nested_attributes_for :categorizations, reject_if: :empty_or_assigned_category
  end

  private

    def empty_or_assigned_category(attributes)
      category_name = attributes.dig(:category_attributes, :name)

      # reject if category name is empty:
      return true if !category_name.present?

      # reject if category is already assigned:
      if !attributes.dig(:id).present?
        assigned = self.categories.include? Category.find_by(name: category_name)
        return true if assigned
      end

      false
    end
end

Para usarlo solo tienes que incluir el concern en el modelo que quieras que se pueda categorizar. Nuestro modelo Post quedaría así:

class Post < ApplicationRecord
  include Categorizable

  validates_presence_of :title
end

Últimos escritos

Proyectos

Recibe actualizaciones de mis proyectos

    Nombre
    Email

    Dónde encontrarme