Isolate Namespace in Rails Engines - A hack in itself

Yuva's avatar

Yuva

isolate_namespace is that one feature which Rails boasts about to use while creating gems, but doesn't work when one wants to extend models, controllers and views which are provided by that gem. We have used it while developing one of our gems and found that its really hard to extend models, controllers and views. For instance, if you copy views from gem to main application in order to customize them, and try to use routes defined in the main application, you will be slapped with an error saying those routes are not defined! It takes some time to understand how it works under the hood, and all it boils down to is: isolate_namespace is nothing but a bunch of hacks. This blog post will do a code walk-through and will try to explain how it works:

The isolate_namespace method

This method can be found in railties-engine file. Its reads as:

def isolate_namespace(mod)
  engine_name(generate_railtie_name(mod))
 
  self.routes.default_scope =
    { module: ActiveSupport::Inflector.underscore(mod.name) }
  self.isolated = true
 
  unless mod.respond_to?(:railtie_namespace)
    name, railtie = engine_name, self
 
    mod.singleton_class.instance_eval do
      define_method(:railtie_namespace) { railtie }
 
      unless mod.respond_to?(:table_name_prefix)
        define_method(:table_name_prefix) { "#{name}_" }
      end
 
      # removed code for :use_relative_model_naming?
      # removed code for :railtie_helpers_paths
 
      unless mod.respond_to?(:railtie_routes_url_helpers)
        define_method(:railtie_routes_url_helpers) {
          railtie.routes.url_helpers
        }
      end
    end
  end
end

mod is module, which is has to be isolated. In case of blorgh gem, its Blorgh itself. Hence forth, we will use blorgh gem as an example given in rails guides

  1. routes.default_scope: It defines default scope for routes. This scope will be used while generating routes for Rails engine. It says module to be used is blorgh, and all the controllers will be searched under gem. This can be easily understood. Just put a binding.pry inside routes.rb of gem, and you can see this:

    [4] pry(#<Mapper>)> self.instance_variable_get(:@scope)
    => {:path_names=>{:new=>"new", :edit=>"edit"},
    :module=>"blorgh",
    :constraints=>{},
    :defaults=>{},
    :options=>{}}

    It says default module that should be used is blorgh. All the controllers will be prepended by blorgh/

  2. If module doesn't respond to railtie_namespace (generally modules dont!), it goes ahead and adds bunch of methods to module (i.e Blorgh for example), and not to engine. Thats why its a hack! There is nothing done on engine! Everything is added to Blorgh module. So, what it adds exactly?

  3. table_name_prefix: This can be easily guessed. It will be used by active record. Now searching through activerecord source, we find this:

    def full_table_name_prefix #:nodoc:
      (parents.detect{ |p| p.respond_to?(:table_name_prefix) } || self).
        table_name_prefix
    end

    It looks tricky, but this is how it works. It searches through all the parents of the AR class, and checks whether any of them responds to table_name_prefix and returns it. (Default value is empty string). Well, parents are not the parents of the class, but the hierarchy of the modules. activesupport defines this method:

    # Returns all the parents of this module according to its name,
    # ordered from nested outwards. The receiver is not contained
    # within the result.
    #
    #   module M
    #     module N
    #     end
    #   end
    #   X = M::N
    #
    #   M.parents    # => [Object]
    #   M::N.parents # => [M, Object]
    #   X.parents    # => [M, Object]
    def parents
      parents = []
      if parent_name
        parts = parent_name.split('::')
        until parts.empty?
          parents << ActiveSupport::Inflector.constantize(parts * '::')
          parts.pop
        end
      end
      parents << Object unless parents.include? Object
      parents
    end

    Now, here comes the funny part: Create a folder called blorgh in your main application under app/models folder, and create a model called Test

    module Blorgh
      class Test < ActiveRecord::Base
      end
    end

    Now, fire up console, and execute the following:

    [1] pry(main)> Blorgh::Test.table_name
    => "blorgh_tests"

    See, it automatically prepends tests table name with blorgh_. One will be wondering how did that happen? Well it happened because of the module called Blorgh. So anything one puts under Blorgh module will get special treatment. Do it with any other namespace, (i.e module), it will just return tests. The only way you can get rid of this behavior is to specify the table name explicity on model using self.table_name = "tests". If someone in some gem magically says to isolate a namespace which you are using in your application, hell breaks loose! You will be wondering why your application code is behaving strangely.

    You can find other hacks by searching through the Rails code. We will cover another hack here:

  4. railtie_routes_url_helpers: This method is used to define route helpers which can be accessible to generate paths. Digging through the code, you can find it in actionpack.

    module AbstractController
      module Railties
        module RoutesHelpers
          def self.with(routes)
            Module.new do
              define_method(:inherited) do |klass|
                super(klass)
                namespace = klass.parents.detect { |m|
                  m.respond_to?(:railtie_routes_url_helpers)
                }
     
                if namespace
                  klass.send(:include,
                      namespace.railtie_routes_url_helpers)
                else
                  klass.send(:include, routes.url_helpers)
                end
              end
            end
          end
        end
      end
    end

    This inherited method will be called in the context of your controllers. Again if your controller is under Blorgh module, it magically includes only the routes defined by gem, otherwise it includes the application route helpers. Thats why even though you copy views from gem to your main app, they still cannot access helpers defined by main application. Generally we all know how to fix this: Call the url helpers by prepending with either main_app or gem mount point, i.e blorgh here. This way all the route helpers will be available in all the views.

    = link_to "Surveys", blorgh.question_groups_path
    = link_to "Login", main_app.new_user_session_path
  5. Other issues include extending models and controllers. Rails guides gives two options here. One to use class_eval, and other to use concerns introduced in Rails 4. Both are kind of hacky. Hope there is a better solution.