Rails CamelCase controller name to snake_case file mapping demystified

| Comments

Have you ever wondered how does your UsersController just work fine when when you place it in the app/controllers/users_controller.rb file? It’s one of the first things in the Rails world that we say it works “automagically”. This magic is not too hard to understand if you know some Ruby. As Rails is an open-source framework, we all can be magicians!

We have a lot of helpers we use with Strings. I won’t be describing them all here one by one, you can find it nicely described here - it’s Rails' Inflector, a part ActiveSupport API. And that’s it! Thanks for reading! :-)

Just kidding! I know you’re more curious - let’s look deeper.

Inflector

“The Inflector transforms words from singular to plural, class names to table names, modularized class names to ones without, and class names to foreign keys. The default inflections for pluralization, singularization, and uncountable words are kept in inflections.rb.”

Your ActiveSupport gem contains inflector.rb in the lib/active_support directory, but wait… look at this file:

/home/ozim/.rvm/gems/ruby-2.1.1/gems/activesupport-4.1.1
1
2
3
4
5
6
require 'active_support/inflector/inflections'
require 'active_support/inflector/transliterate'
require 'active_support/inflector/methods'

require 'active_support/inflections'
require 'active_support/core_ext/string/inflections'

It looks strange, no methods, no classes, no modules, nothing. The reason this file exists starts to make sense when we want to require active_support/inflector without the rest of active_support. But we’re using it in Rails and still are looking for something else - our CamelCase to snake_case transformer. Let’s open the active_support/inflector/methods file (which is required by our inflector.rb) and you’ll see a part of the “magic”.

inflector/methods.rb

/home/ozim/.rvm/gems/ruby-2.1.1/gems/activesupport-4.1.1/lib/inflector/methods.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
    def camelize(term, uppercase_first_letter = true)
      string = term.to_s
      if uppercase_first_letter
        string = string.sub(/^[a-z\d]*/) { inflections.acronyms[$&] || $&.capitalize }
      else
        string = string.sub(/^(?:#{inflections.acronym_regex}(?=\b|[A-Z_])|\w)/) { $&.downcase }
      end
      string.gsub!(/(?:_|(\/))([a-z\d]*)/i) { "#{$1}#{inflections.acronyms[$2] || $2.capitalize}" }
      string.gsub!('/', '::')
      string
    end

    [...]

    def underscore(camel_cased_word)
      word = camel_cased_word.to_s.gsub('::', '/')
      word.gsub!(/(?:([A-Za-z\d])|^)(#{inflections.acronym_regex})(?=\b|[^a-z])/) { "#{$1}#{$1 && '_'}#{$2.downcase}" }
      word.gsub!(/([A-Z\d]+)([A-Z][a-z])/,'\1_\2')
      word.gsub!(/([a-z\d])([A-Z])/,'\1_\2')
      word.tr!("-", "_")
      word.downcase!
      word
    end

This two methods above make the job done. Just load the inflector into your IRB and check it out!

IRB
1
2
3
4
5
6
2.2.3 :001 > require 'active_support/inflector'
 => true
2.2.3 :002 > "UsersController".underscore
 => "users_controller"
2.2.3 :003 > _.camelize
 => "UsersController"

Great! We know how it works, but to be honest it’s not a big surprise that Rails uses gsub to change CamelCase to snake_case and vice versa. The question is: how the heck it figures out that I’m looking for a users_controller.rb file when typing “example.com/users” in the browser?! That’s much more interesting.

Class names are constants in Ruby

Launch your IRB and check that:

IRB
1
2
3
4
5
6
7
8
9
2.2.3 :001 > Testnotexists
NameError: uninitialized constant Testnotexists
  from (IRB):1
  from /home/ozim/.rvm/rubies/ruby-2.2.3/bin/IRB:11:in `<main>'
2.2.3 :002 > class Testnotexists
2.2.3 :003?>   end
 => nil
2.2.3 :004 > Testnotexists
 => Testnotexists

You see? Uninitialized constant. What Ruby performs here is a const_missing method call. This method is called every time Ruby fails to find a constant among all the classes and modules in the current scope. Try that:

IRB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
2.2.3 :001 > StrangeClassName
NameError: uninitialized constant StrangeClassName
  from (IRB):1
  from /home/ozim/.rvm/rubies/ruby-2.2.3/bin/IRB:11:in `<main>'
2.2.3 :002 > class Object
2.2.3 :003?>   def self.const_missing(name)
2.2.3 :004?>     puts "Oh no! The " + name.to_s + " is missing!"
2.2.3 :005?>     end
2.2.3 :006?>   end
 => :const_missing
2.2.3 :007 > StrangeClassName
Oh no! The StrangeClassName is missing!
 => nil
2.2.3 :008 >

We’re much closer to the answer now. The only thing that is missing here is:

const_get

“Checks for a constant with the given name in mod If inherit is set, the lookup will also search the ancestors (and Object if mod is a Module.) The value of the constant is returned if a definition is found, otherwise a NameError is raised.” [source].

What is going on here? Ruby const_get method ends up the explanation of Rails magic with all the CamelControllers to snake_files mapping. Just take a look on the active_support/inflector/methods constantize:

active_support/inflector/methods.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
def constantize(camel_cased_word)
      names = camel_cased_word.split('::')

      # Trigger a builtin NameError exception including the ill-formed constant in the message.
      Object.const_get(camel_cased_word) if names.empty?

      # Remove the first blank element in case of '::ClassName' notation.
      names.shift if names.size > 1 && names.first.empty?

      names.inject(Object) do |constant, name|
        if constant == Object
          constant.const_get(name)
        else
          candidate = constant.const_get(name)
          next candidate if constant.const_defined?(name, false)
          next candidate unless Object.const_defined?(name)

          # Go down the ancestors to check it it's owned
          # directly before we reach Object or the end of ancestors.
          constant = constant.ancestors.inject do |const, ancestor|
            break const    if ancestor == Object
            break ancestor if ancestor.const_defined?(name, false)
            const
          end

          # owner is in Object, so raise
          constant.const_get(name, false)
        end
      end
    end

You see const_get here? You can try this method by yourself in IRB:

IRB
1
2
3
4
5
2.2.3 :001 > Math::PI
 => 3.141592653589793
2.2.3 :002 > Math.const_get("PI")
 => 3.141592653589793
2.2.3 :003 >

For further reading you can try to find out how does Ruby resolve the constant names.

Comments