Wednesday, February 10, 2016

Dynamic Rails Model

When writing a rails application do you find yourself writing model after model; and then controller after controller; and then views after, well you get the idea.  I ran into one scenario where I was able to have the models created dynamically and then leverage the same controller and views.

The Scenario
An existing database driven solution needed an admin interface to make maintenance easier and more efficient.

Within the current database the admin team would create the same set of tables for each of their customers.  Part of why they did this was that customers would only have access to their tables.  For this post we will pretend their customer table's simply consisted of two tables called problems and solutions.  Therefore Customer ABC would get tables abc_problems and abc_solutions and Customer XYZ would get tables xyz_problems and xyz solutions.

It was decided that a Ruby on Rails application could be spun up to point to the database tables and provide the new admin interface.  The proposed UI would allow the admins to select a customer and then be able to manage the data for that customer.  If the admin selected Customer ABC he would be able to create new entries in abc_problems and abc_solutions, and edit or delete entries in those tables as well.

First Pass
The project team realized that to show a list of all customers they needed a new table for all the customers (customers).

They started by also creating models for a single customer (AbcProblem and AbcSolution) and for customers as a whole (Customer).

They created a generic controller for each of their customer models (problems_controller and solutions_controller) and built views to support the standard actions. They just wired the controllers and views to use the AbcProblem and AbcSolution models regardless of what was selected in the customer dropdown.

The Call for Help
But at this point they realized they were going to need to create new models for every single customer (and for future customers).  And, somehow modify the controllers and views to handle each one or have to create controllers and views for each customer as well.

With the limited amount of time before the code freeze we needed to get it working.

From the Rails perspective everything would have become greatly simplified if the database structure could have been changed from tables for each customer to common tables that had a customer column.  But that was not an option here since 1) the tables were already supporting live customers in there current format and 2) access to the data was user account and table driven.

Our first step was to make the models dynamic so the Rails application would not need to be updated every time a new customer was added.  To do this we took the Abc models they had created and moved their code into abstract models and within an eval statement.  Then we created a factory that would load/create the model based on prefix.

For example, the initial AbcProblem may have looked like this:

class AbcProblem < ActiveRecord::Base
  validates :title, presence: true
  validates :description, presence: true
  validates :severity, numericality: { greater_than: 0, less_than: 11}
  has_many :abc_solutions
end


From that we created a ProblemFactory that would create a new ActiveRecord class if it didn't already exist.
class ProblemFactory
  @@problem_classes = {}

  def self.problem_class(prefix)
    if @@problem_classes[prefix].nil?
      @@problem_classes[prefix] = build_problem_class(prefix)
    end
    @@problem_classes[prefix]
  end

  def self.build_problem_class(prefix)    problem_def = "class #{prefix.capitalize}Problem < ActiveRecord::Base;"+
      " self.table_name = '#{prefix}_problems';"+
      "   validates :title, presence: true;"+
      "   validates :description, presence: true;"+
      " validates :severity, numericality: { greater_than: 0, less_than: 11};"+
      " has_many :#{prefix}_solutions;"+
      "end"
    eval(problem_def)
    eval("#{prefix.capitalize}Problem")
  end

end

That isn't quite enough since there isn't yet a model yet for AbcSolutions.  So we added that as well within build_problem_class (refactored to be build_problem_classes)

  def self.build_problem_classes(prefix)
    #create the solution class as well
    solution_def = "class #{prefix.capitalize}Solution < ActiveRecord::Base;"+
      " self.table_name = '#{prefix}_solutions';"+
      " validates :explanation, presence: true;"+
      " include AbstractSolution;"+
      "end"
    eval(solution_def)

#etc...

The initial references in the controllers to the models were something like:
  def index
    @problems = AbcProblem.all
  end
 def new
    @problem = AbcProblem.new
  end
  def create
    @problem = AbcProblem.new(problem_params)
    render (@problem.save ? :show : :new)
  end

Those were changed to dynamically retrieve the class based on which customer was selected:
  def index
    @problems = problem_class.all
  end
 
  def new
    @problem = problem_class.new
  end
 
  def create
    @problem = problem_class.new(problem_params)
    render (@problem.save ? :show : :new)
  end
  def problem_class
    ProblemFactory.problem_class(@customer.prefix)
  end

  def load_customer
    @customer = Customer.find(params[:customer_id])
  end


The Stuff Works
The positives from this approach were
  1 - It works
  2 - It was completed easily within the deadline for the demo
  3- It supports additional customers.  When the admin database is updated with new customer tables, the rails application doesn't need to be rebuilt.  Just add a customer from within the UI.

The main negative from this approach is that future developers on the project will have a learning curve to figure out this solution.  Not only are models created inside a factory using the eval statement; but, a few places with rails generated code had to be changed.

Routes needed some massaging since the three controllers (customers, problems, and solutions) where not all using a specific rails model.

Since the views were displaying AbcProduct or DefSolution the form helpers on the views needed to be changed from using form_for(@model) and f.text_field :attr because there is was no route for an AbcProduct .  Instead we used the 'classic' helpers form_tag and and text_field_tag.

You can check out all the gory details from git hub: https://github.com/gwinklosky/dynamic_model