Аккуратный код - уникальные имена

Аккуратный код - уникальные имена

Недавно я столкнулся с чем-то примерно следующим в кодовой базе клиента.

# app/model/user.rb
class User < ActiveRecord::Base
  # ...more code

  before_validation :set_username

  # ...more code

  private

  def set_username
    return unless username.blank?

    i = 0
    begin
      if i < 10 && (email.present? || first_name.present?)
        prefix = [first_name, last_name].compact.join.presence || email.split('@')[0]
        self.username = prefix + (i.positive? ? i.to_s : '')
      else
        self.username = "user_#{SecureRandom.hex(3)}"
      end
      i += 1
    end while User.exists?(username: self.username)
  end
end

Это довольно стандартное требование к приложению - иметь некоторую именованную запись, которая может нуждаться в автоматически генерируемом имени, которое должно быть уникальным. Это довольно стандартный (если не сказать красивый) подход для Rails-приложения. Я и сам в прошлом использовал подобный подход.

В этот раз1 мне пришло в голову, что это может быть хорошей возможностью применить немного объектно-ориентированного дизайна и использовать объект Value. В качестве отправной точки я представлял себе нечто подобное:

# app/model/user/unique_name.rb
class User::UniqueName
  delegate :email, :first_name, :last_name, to: :@user

  def initialize(user)
    @user = user
  end

  def to_s
    username = nil
    i = 0
    begin
      if i < 10 && (email.present? || first_name.present?)
        prefix = [first_name, last_name].compact.join.presence || email.split('@')[0]
        username = prefix + (i.positive? ? i.to_s : '')
      else
        username = "user_#{SecureRandom.hex(3)}"
      end
      i += 1
    end while User.exists?(username: self.username)
    username
  end
end

# app/model/user.rb
class User < ActiveRecord::Base
  # ...more code

  before_validation :set_username

  # ...more code

  private
  def set_username
    return unless username.blank?
    self.username = UniqueName.new(self)
  end
end

Хотя User::UniqueName все еще не очень красив, у него уже есть несколько полезных свойств. Например, при любом тестировании, которое я провожу, будь то модульные тесты или эксперименты в REPL, он обладает очень желательным свойством - его можно тестировать на основе простых входных и выходных данных. Кроме того, это можно сделать в относительной изоляции от других свойств User, которые я хотел бы протестировать. Это не помешает мне тестировать и интеграцию с User, но таких тестов может быть немного.

User::UniqueName.new(User.new).to_s # вот и все!

Все основные действия инкапсулированы здесь. В качестве приятного эргономичного бонуса ActiveRecord позаботится о вызове #to_s для нас, поскольку он преобразует наш объект Value в строку.

Если это лучшее, что мы можем сделать, то это не так уж и плохо, но в нынешнем виде этот код можно почистить и сделать его более общим. Например, в другом проекте, над которым я работаю, есть много моделей, для которых требуется генерировать уникальные имена. Некоторые модели также должны быть уникальными в определенной области. В этом случае объект Value Object может иметь такую форму. Здесь мы также добавили логику, которая будет возвращать имя записи, если оно присутствует, устраняя необходимость в условии в нашем обратном вызове.

# app/models/unique_name.rb
class UniqueName
  def initialize(record, attribute: :name, scope: nil, root_name: nil)
    @record = record
    @attribute = attribute
    @root_name = root_name || "New #{model.model_name.human}"
    @scope = scope
  end

  def to_s
    name = имя_записи
    return name if name.present?
    unique_name
  end

  def record_name
    record.public_send(attribute)
  end

  def record_scope_value
    record.public_send(scope)
  end

  def unique_name
    n = auto_named_count
    n.zero? ? name : numbered_name(n)
  end

  def numbered_name(number)
    "#{default_name} (#{number})"
  end

  def auto_named_count
    query = model.where(attribute => root_name).or(model.where(attribute => "#{root_name} (%)"))
    return query.count unless scope
    query.or(model.where(scope => record_scope_value)).count
  end

  def model
    record.class
  end

  private

  attr_reader :record, :attribute, :root_name, :scope
end

# app/model/user.rb
class User < ActiveRecord::Base
  # ...more code

  before_validation { self.username = UniqueName.new(self) }

  # ...more code
end

# app/model/survey.rb
class Survey < ActiveRecord::Base
  # ...more code

  validates :name, uniqueness: { scope: :author }
  before_validation { self.name = UniqueName.new(self, scope: :author_id) }

  # ...more code
end

# app/model/saved_report.rb
class SavedReport < ActiveRecord::Base
  # ...more code

  validates :name, uniqueness: { scope: :author }
  before_validation { self.name = UniqueName.new(self, scope: :author_id) }

  # ...more code
end

Теперь у нас есть объект Value Object, достаточно общий, чтобы широко использоваться в большом проекте, и, возможно, уже на пути к тому, чтобы стать полезной библиотекой.

Первоначально опубликовано на delonnewman.name.

Давно отдавая предпочтение функциональному программированию, я изучаю взаимодополняемость объектно-ориентированного и функционального программирования (подробнее об этом позже).