Percorrendo listas agrupadas com Rails

Facilitando a iteração por registros agrupados

Saber percorrer uma lista de dados, de forma eficiente, é de fundamental importância na construção de um algoritmo. Não é raro encontrar exemplos de códigos que priorizam a performance na manipulação dessas listas. Contudo, a legibilidade muitas vezes é sacrificada em prol da máxima eficiência.

Sob essa óptica, o Rails oferece alguns métodos de manipulação de Arrays que economizam tempo de desenvolvimento de uma eventual implementação própria.

O in_groups_of, por exemplo, mostra-se bastante útil quando desejamos percorrer registros agrupados por um determinado limite. Combinado com alguns métodos do Active Record como o group_by e scope, a consulta pode se tornar ainda mais poderosa.

Para ilustrar o uso, podemos pensar em um contexto de Lobby em algum jogo eletrônico. 

Nossa regra de negócio é descrita como:

  • Cada jogador possui um nível númerico.
  • Cada partida permite um número máximo de 10 jogadores (sem times; FFA).
  • As partidas são niveladas pelo nível dos jogadores, de forma que jogadores de níveis idênticos fiquem numa mesma partida.

Comecemos pela classe de jogador:

class Player < ApplicationRecord
  validates :level, presence: true
  validates :level, numericality: { only_integer: true }

  belongs_to :lobby, optional: true

  scope :ordered_by_level, lambda {
    order(level: :desc)
  }

  def self.group_by_level
    ordered_by_level.group_by(&:level)
  end
end

Na classe, validamos a presença e a numericidade inteira, além de adicionar uma associação opcional a um Lobby. O scope ordered_by_level ordena os registros de pelo nível do jogador, de forma descendente, enquanto o método de classe group_by_level agrupa os resultados em uma Hash, onde cada key é o nível do jogador, e os valores, os própria instância dos jogadores:

# Player.group_by_level

{
  50 => 
    [#<Player:0x0000000108733120
      id: 20,
      name: 'Paul Atreides',
      level: 50,
      lobby_id: nil>,
     #<Player:0x0000000108733142
      id: 104,
      name: 'Stilgar',
      level: 50,
      lobby_id: nil>
    ],
  48 =>
    [#<Player:0x0000000108733119
      id: 2,
      name: 'Chani',
      level: 48,
      lobby_id: nil>,
    ]
  # ...
}

Também precisamos de um modelo de Lobby:

class Lobby < ApplicationRecord
  has_many :players, dependent: :nullify
end

Agora, vamos ao coração da lógica. Necessitamos de um algoritmo que capture a lista já ordenada por níveis de jogadores, separe essa lista em uma outra lista agrupada pelo limite máximo em cada partida, e associe um novo lobby a esses jogadores. A leitura da documentação do método in_groups_of é recomendada para entender plenamente o código a seguir.

class LobbyService
  PLAYER_LIMIT_BY_ROOM = 10

  def self.call
    new.call
  end

  def call
    distribute_players_in_rooms
  end

  private

  def distribute_players_in_rooms
    grouped_players.each do |_level, players_list|
      InsertPlayersInLobbyJob.perform_later(players_list: players_list.in_groups_of(PLAYER_LIMIT_BY_ROOM, false),
                                            lobby:)
    end
  end

  def grouped_players
    Player.group_by_level
  end

  def lobby
    Lobby.create!
  end
end

Por fim, basta um Job que associe o lobby aos jogadores:

class InsertPlayersInLobbyJob < ApplicationJob
  queue_as :default

  def perform(players_list:, lobby:)
    players_list.each { |player| player.lobby = lobby }
  end
end

Com isso, teremos lobbies nivelados pelo nível dos jogadores, utilizando-se principalmente de métodos que percorrem listas, preservando a legibilidade e manutenibilidade do código!

Até a próxima!

4 Comentários

Eloá

Excelente!!

12 jul, 2023 Responder

Thiago Portella

Obrigado!!! ❤️

14 jul, 2023 Responder

Eduardo

Desafio agora a fazer um algoritmo pra sortear os times da pelada de fim de ano, de forma a manter os times equilibrados de acordo com o nível dos jogadores. 😀

12 jul, 2023 Responder

Thiago Portella

O maior desafio vai ser descobrir qual o nível de cada um 😆

14 jul, 2023 Responder