I recently came accross something approximating the following in a
client's code base.
# 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
It's a fairly standard requirement for an application to have some named record that may need an automatically generated name that must be unique. This is a pretty standard (if not pretty) approach for a Rails application to take. I've taken a similar tack in the past myself.
This time1 it occurred to me that this could be a good opportunity to apply a little bit of Object-Oriented Design and make use of a Value Object. As a starting place I envisioned something like this:
# app/model/user/unique_name.rb
class User::UniqueName
delgegate :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
Although User::UniqueName
still isn't terribly pretty, it already has some useful properties. For one, in any testing I do, whether it's in the form of unit tests or experimenting in the REPL, it has the very desirable property that it can be tested on basis of simple input and output. Additionally, this can be done in relative isolation from the other properties I'd like to test about User
. This wouldn't stop me from testing the integration with User
either, but those tests can be few.
User::UniqueName.new(User.new).to_s # that's it!
All the essential behavior is encapsulated here. As a nice ergonomic bonus ActiveRecord will take care of calling #to_s
for us as it coerces our Value Object into a string.
If this is the best we can do this is not a terrible place to be, but as it stands this code can be cleaned up, and it could be made more general. For example, on another project I'm working on there are many models that require unique names to be generated. Some models also need to be unique within a certain scope. In that case the Value Object might take this form instead. Here we've also added logic that will return the name of the record if it's present removing the need for a condition in our callback.
# 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 = record_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? ? root_name : "#{root_name} (#{n})"
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
Now we have a Value Object that is general enough to be used widely throughout a large project, and perhaps is on it's way to being a useful library.
-
Having long favored functional programming I've been exploring the complementary nature of object-oriented and functional programming (more on that later). ↩
Top comments (0)