Overloading ActiveRecord methods

I’m currently working on a project management tool. The way the company works is with a project being either a group of jobs or a single job. So the first job could be X0001. To this you can then add sub-jobs as X0001A, X0001B and so on. The project X0001 is then a container holding the original job and information about all its sub-jobs.

It seemed to make sense to me to use the same database for both projects and jobs, and to have the Project class inherit from the Job class. This would let me use the same tools to analyse project figures at either the job or project level. So project.profit would give me the sum of the profit for the core job and all its sub-jobs. Whereas job.profit would give me the profit for just the one job.

There are probably better ways of doing this but this is the solution I came up with.

I first created the Job class as a standard Rails model. The required custom stuff was then put into the Project class. Here is core of the Project class definition:

class Project < Job

  #Used to check that a job number is of the correct pattern
  PROJECT_NUMBER_PATTERN = /^[A-Za-z]{1,2}[0-9]{4,}$/  

  # Replaced standard find method with one that will exclude sub-jobs from and find operation.
  def self.find(*args)
    options = modify_args_conditions_options(args.extract_options!)
    do_normal_find_things(args, options)
  end

  # Replace standard calculate method with one that will exclude results from sub-jobs.
  # For example if there are three jobs: 'J0001', 'J0002', and 'J0002A'
  # Job.count         ---> 3
  # Project.count     ---> 2
  # as 'J0002A' is a sub-job.
  def self.calculate(operation, column_name, options = {})
    options = modify_args_conditions_options(options)
    do_normal_calculate_things(operation, column_name, options)
  end


  private
  def self.do_normal_find_things(args, options)
    validate_find_options(options)
    set_readonly_option!(options)
    case args.first
      when :first then find_initial(options)
      when :last  then find_last(options)
      when :all   then find_every(options)
    else
      find_from_ids(args, options)
    end
  end

  def self.do_normal_calculate_things(operation, column_name, options)
    validate_calculation_options(operation, options)
    column_name     = options[:select] if options[:select]
    column_name     = '*' if column_name == :all
    column          = column_for column_name
    catch :invalid_query do
      if options[:group]
        return execute_grouped_calculation(operation, column_name, column, options)
      else
        return execute_simple_calculation(operation, column_name, column, options)
      end
    end
    0
  end

  def self.modify_args_conditions_options(options)
    projects_only_condition = "number REGEXP '#{PROJECT_NUMBER_PATTERN.source}'"
    if options[:conditions].kind_of? Array
      options[:conditions][0] << " AND #{projects_only_condition}"
    elsif options[:conditions].kind_of? String
      options[:conditions] << " AND #{projects_only_condition}"
    else
      options[:conditions] = projects_only_condition
    end
    return options
  end

end

So what is going on here?

First I dug into the ActiveRecord find method and found that there was a place within its initial definition where I could put in a bit of code that would modify the options passed into the underlying methods. The modification was to add a clause to conditions so that only jobs with numbers that match the pattern prescribed for a Project, would be returned. A project number started with one or two letters and was then followed by numbers. A sub-job added trailing letters to the project number.

I first created a find class method for Project that exactly matched the standard ActiveRecord::Base.find code, and then added the additional code.

Once I'd done that I realised that I could separate out the bits of code I'd added (modify_args_conditions_options) from the unaltered original code (do_normal_find_things). This had two advantages. First it made maintenance easier as I knew which code was original and should be left alone, and which was custom code I'd added. It also meant that I could easily use my custom code elsewhere.....

This seemed to work fine at first for both find and paginate tasks (as will_paginate (mislav_will_paginate) relies on find - so modify how find works, and you also modify how will_paginate works). However, then I noticed that the number of pages was wrong - there were too many. When I investigated this problem, I realised I also needed to modify how the ActiveRecord::Calculations.count method behaved.

Drilling into ActiveRecord's count method, I realised that the place to modify the behaviour was to alter the way ActiveRecord::Calculations.calculate works. This would have the added benefit of also altering other calculation methods like average. It was simple for me to insert the modify_args_conditions_options method into calculate and then call the original methods to carry on the rest of the task.

Hey presto, I've got what I want. I can now modify Project methods as I need - adding new ones, leaving the Job defined ones and overloading Job methods with Project specific version as needed.

So my next job is to decide how to deal with common tasks. For example lets return to profit. Do I just define a new Project profit method, or redefine the Job profit method so it will handle figures coming from either a single job or a group of jobs. Of the two the latter will probably be the better.

To get this to work I probably need to redefine again how find and calculate work so that instead of returning the results for the primary job, they return the summarised figures for the primary job and all its sub-jobs. The key thing being that, now I know where I can hack into find and calculate to modify their behaviours, making this modification should be fairly straight forward.

This entry was posted in Ruby. Bookmark the permalink.