Inheriting From Built-In Ruby Types «
»


Code: , , ,
No comments

2015-05-09: While I still want to break down ActiveRecord models, I now disagree with the idea of inheriting from Ruby’s stdlib types and think they should always be wrapped. My RailsConf 2015 talk expands on the thinking below (especially about immutability!) and touches on reasons why not to inherit from stdlib.

This post originally appeared on the DevMynd blog.

The string is a stark data structure and everywhere it is passed there is much duplication of process. It is a perfect vehicle for hiding information.
— Alan Perlis

I was teaching a class on refactoring and wanted a real-world example to demonstrate finding a class to extract. My students were Rails developers, so I immediately knew I wanted to show an example of an ActiveRecord model.

After skinny controllers and fat models became a Rails best practice ActiveRecord models have tended to grow without bounds. It’s easy to push code down into them, but developers seem to have some kind of mental block causing them to think they can’t create classes that aren’t more ActiveRecord models.

Though I’ve seen this often enough I couldn’t use any client code for my example. I scratched my head a moment and thought of Discourse, a new forum project. I went directly to the User model because it’s so commonly a god object. (Just to be clear, I’m not trying to pick on Discourse. This is a fairly minor improvement to address a very common problem.)

I scrolled down past the usual mass of associations, validations, and hooks to the method definitions. Here’s the first eight:

  def self.username_length
  def self.suggest_username(name)
  def self.create_for_email(email, options=())
  def self.username_available?(username)
  def self.username_valid?(username)
  def enqueue_welcome_message(message_type)
  def self.suggest_name(email)
  def change_username(new_username)

From this quick glance there’s a Username class waiting to be extracted. It’s in the name of several methods, it’s the sole argument to many methods, and most of the methods are class methods, implying they don’t maintain any state (class-level variables being rare in Ruby).

It’s discouraging to think about extracting a Username. It’s stored as a varchar in the database and ActiveRecord will choke on validations if it can’t treat it as a string.

The solution is straightforward: Username should inherit from String. Username is-a String, and keeping it in the built-in type is why this code sprawls.

I extracted the below class, changing as little as possible, mostly just removing ‘username’ from the start of method names and using self where appropriate. I left behind the unrelated create_for_email, enqueue_welcome_message, suggest_name, and the temptingchange_username, which was about editing aUser.

  class Username < String
    def username_length
      3..15
    end
 
    def suggest
      name = self.dup
      if name =~ /([^@]+)@([^\.]+)/
        name = Regexp.last_match[1]
 
        # Special case, if it's me @ something, take the something.
        name = Regexp.last_match[2] if name == 'me'
      end
 
      name.gsub!(/^[^A-Za-z0-9]+/, "")
      name.gsub!(/[^A-Za-z0-9_]+$/, "")
      name.gsub!(/[^A-Za-z0-9_]+/, "_")
 
      # Pad the length with 1s
      missing_chars = username_length.begin - name.length
      name << ('1' * missing_chars) if missing_chars > 0
 
      # Trim extra length
      name = name[0..username_length.end-1]
 
      i = 1
      attempt = name
      while !attempt.available?
        suffix = i.to_s
        max_length = username_length.end - 1 - suffix.length
        attempt = "#{name[0..max_length]}#{suffix}"
        i+=1
      end
      attempt
    end
 
    def available?
      !User.where(username_lower: lower).exists?
    end
 
    # export a business name for this operation
    alias :lower :downcase
  end

And it’s used as:

  u = Username.new 'eric_blair'
  u.available?
  new = u.suggest
  User.new username: Username.new('Samuel Clemens').suggest

The User class got a lot simpler now that it doesn’t know all the business rules about usernames. I left the validation in User because it’s the thing being persisted to the database, though if it wasn’t for the Active Record pattern I’d want to move that over as well.

Now Username is a simple immutable value object that’s easier to reason about and test. It adheres nicely to the SOLID principles, and it’s an uncommon nice example of inheritance working well in Ruby.

One caveat is that User objects Rails instantiantes will return Strings on calls to User#username. We’ll need to write a getter to instantiate and return a Username object, though Rails before 3.2 included composed_of for this:

  class User < ActiveRecord::Base
    def username
      Username.new(read_attribute(:username))
    end
 
    # or, pre Rails 3.2:
    composed_of :username, converter: :new
  end

(And to the curious: no, I haven’t contributed a patch back to Discourse. They have a Contributor License Agreement to backdoor their ostensible open source licensing.)

Leave a Reply

Your email address will not be published.