Apresentando has_subtypes: uma nova associação no Ruby on Rails

systems development

Esta publicação apresenta uma nova associação avançada do Rails: has_subtypes

O Rails tem 6 tipos básicos de associações: belongs_to, has_one, has_many, has_many :through, has_one :through e has_and_belongs_to_many.

Também existem associações avançadas. As principais são as polimórficas e os tipos delegados, que permitem que um modelo pertença a mais de um outro modelo por meio de um tipo e de um ID. Essencialmente, são variações especiais da associação belongs_to. Por exemplo, um tipo delegado ainda é um belongs_to, mas com uma camada adicional de semântica e convenções.

Meu objetivo aqui é apresentar uma alternativa aos tipos delegados usando uma nova associação avançada: has_subtypes. Em vez de se concentrar em belongs_to, esse novo método especializa a associação has_one.

Essa nova abordagem para resolver o problema da herança em múltiplas tabelas também permite maior controle do banco de dados. Diferentemente dos tipos delegados, agora é possível garantir a integridade referencial entre cada tabela filha (subtipo) e a tabela pai.

Implementação

rails new app

cd app

rails generate model Entry

rails generate model Message entry:references content:text

rails generate model Comment entry:references body:text

Agora, abra o projeto e insira o método a seguir dentro da classe ApplicationRecord:

class ApplicationRecord < ActiveRecord::Base
  self.abstract_class = true

  def self.has_subtypes(*types, dependent: :destroy)
    types.each { has_one(_1, dependent:) }

    scope :with_subtype, -> { includes(*types) }

    class_eval <<~RUBY, __FILE__, __LINE__ + 1
      def subtype = #{types.join(" || ")} # mensagem || comentário

      before_save do
        subtypes = [#{types.join(", ")}].compact # mensagem, comentário
        raise "must have exactly one subtype" unless subtypes.one?
      end
    RUBY
  end
end

Seria ótimo se, no futuro, esse método passasse a fazer parte do próprio Rails. Por enquanto, você já pode começar a usar essa especialização da associação has_one.

Exemplos de modelos

class Entry < ApplicationRecord
  has_subtypes :message, :comment
end

class Message < ApplicationRecord
  belongs_to :entry
end

class Comment < ApplicationRecord
  belongs_to :entry
end

Cada Entry pode ser agora um Message ou um Comment.


Passo a passo no console do Rails

# Inicie o console do Rails
rails console
# 1. Crie um Entry com um Message
entry1 = Entry.create!(message: Message.new(content: "Hello world"))

# 2. Crie um Entry com um Comment
entry2 = Entry.create!(comment: Comment.new(body: "First comment"))

# 3. Verifique o subtipo de cada registro
entry1.subtype
# => #<Message id:1, content:"Hello world">

entry2.subtype
# => #<Comment id:1, body:"First comment">

# 4. Consulte todos os registros com seus subtipos
Entry.with_subtype.each do |entry|
  puts "Entry ##{entry.id} subtype: #{entry.subtype.class.name}"
end
# Saída:
# Entry #1 subtype: Message
# Entry #2 subtype: Comment

# 5. Tente criar um Entry com os dois subtipos (isso gerará um erro)
Entry.create!(message: Message.new(content: "Test"), comment: Comment.new(body: "Invalid"))
# => RuntimeError: must have exactly one subtype

Conclusão

Com essa nova associação has_subtypes, você pode:

  • Trabalhar com um conceito claro de subtipos, estendendo associações has_one.
  • Garantir a integridade referencial entre as tabelas pai e filha.
  • Consultar e paginar registros com seus subtipos em um único comando, de forma simples, semelhante aos tipos delegados, mas com garantias mais fortes no banco de dados.

Agradecimentos

Obrigado, Matz, por criar uma linguagem tão divertida: Ruby.

Obrigado, David Heinemeier Hansson, por esclarecer a doutrina do Rails: ampliar a tenda — “Precisamos de discordâncias. Precisamos de dialetos. Precisamos de diversidade de pensamento e de pessoas.” Esta publicação só foi possível graças a essa filosofia de “ampliar a tenda”.

Por fim, este resultado não teria sido possível sem a refatoração e as sugestões de Kasper Timm Hansen. Muito obrigado, Kasper!