Needle/Rails, Injected - Interceptors - Attn: Jamis Buck

Jamis (or anyone else who cares to give it a go),

I'm having a bit of trouble wrapping my head around the usage of
interceptors with Needle. I've implemented the changes you outlined in
you code examples from you proposal on "Rails, Injected"
(http://ruby.jamisbuck.org/rails-injected.html) and would like to use
the method interception within a few of my ActiveRecord classes.
Specifically, I have the following (abbreviated) classes:

-- snip --

class Project < ActiveRecord::Base
  has_many :story_cards

  def status
    ProjectStatus::find(self.status_name)
  end

  def status=(new_status)
    if ProjectStatus.include?(new_status)
      self.status_name = new_status.name
    else
      raise ArgumentError, "Only predefined instances of ProjectStatus
can be assigned."
    end
  end
end

class StoryCard < ActiveRecord::Base
  belongs_to :project
end

-- end snip --

And I have RankedValue/ProjectStatus defined as:

-- snip --

module RankedValue
  include Comparable

  attr_reader :name, :rank
  
  def initialize(name, rank)
    @name = name
    @rank = rank
  end

  def self.included(mod)
    mod.module_eval <<-EOF,__FILE__,__LINE__
    def self.new(*args, &block)
      name = args[0]
      name = name.upcase.gsub(' ', '_')
      const_set(name, super(*args, &block))
    end
    private_class_method :new
    
    def self.to_a
      constants.collect! { |cnst| const_get(cnst) }
    end

    def self.include?(obj)
      self.to_a.include?(obj)
    end

    def self.find(name)
      begin
        name_trans = name.upcase.gsub(' ', '_')
        const_get(name_trans)
      rescue NameError
        raise "No predefined instance of #{mod} matches #name == '\#{name}'."
      end
    end
    EOF
  end

  def <=>(other)
    self.rank <=> other.rank
  end
end

class ProjectStatus
  include RankedValue

  def initialize(name, rank, closed)
    super(name, rank)
    @closed = closed
  end

  def is_closed?
    @closed
  end

  new('Pending', 1, false)
  new('In Progress', 2, false)
  new('On Hold', 3, true)
  new('Completed', 4, true)
  new('Cancelled', 5, true)
end

-- end snip --

So, basically, I have the following instances of ProjectStatus available:

* ProjectStatus::PENDING
* ProjectStatus::IN_PROGRESS
* ProjectStatus::ON_HOLD
* ProjectStatus::COMPLETED
* ProjectStatus::CANCELLED

each of which responds to #is_closed? with true or false depending on
how the instance was initialized.

OK, now that we're through all the background (whew!), here's what I
want to be able to do. I want to make sure that a StoryCard can only
be assigned to a Project when Project#status.is_closed? == false.

I know that I could implement this by overriding the
StoryCard#project_id= method, but this seems to break encapsulation.
It should ultimately be the Project class's responsibility to decide
if a StoryCard can be assigned to it. I'm thinking I could create an
interceptor within the Project class definition to intercept the calls
to StoryCard#project_id= and add the necessary advice before the
assignment takes place (and raise a RuntimeError if the Project is
closed), but I'm not sure where to start.

Any advice?

···

--
Regards,
John Wilger

-----------
Alice came to a fork in the road. "Which road do I take?" she asked.
"Where do you want to go?" responded the Cheshire cat.
"I don't know," Alice answered.
"Then," said the cat, "it doesn't matter."
- Lewis Carrol, Alice in Wonderland

John Wilger wrote:

I know that I could implement this by overriding the
StoryCard#project_id= method, but this seems to break encapsulation.
It should ultimately be the Project class's responsibility to decide
if a StoryCard can be assigned to it. I'm thinking I could create an
interceptor within the Project class definition to intercept the calls
to StoryCard#project_id= and add the necessary advice before the
assignment takes place (and raise a RuntimeError if the Project is
closed), but I'm not sure where to start.

Well, I'm not exactly an expert in AR, so I'll probably be making some assumptions that are false.

First of all, I'm assuming StoryCard#project_id= takes an integer and not a Project instance.

This means that the interceptor would not have access to the project instance, unless you did a look up in the interceptor itself. So, let's attempt that.

In your 'project.rb' file, do something like this:

   # do this first so that we can guarantee that the story_card service
   # will have been registered.
   require 'storycard'

   class Project < ActiveRecord::Base
     registry.intercept( :story_card ).doing do |chain,context|
       # only intercept the #project_id= method
       if context.sym == :project_id=
         # use the registry, not the Project constant, so that we
         # if interceptors are added to the project service, they
         # will be invoked.
         project = registry[ :project ].find( context.args.first )
         if project.status.is_closed?
           raise "cannot add storycard to a closed service!"
         else
           chain.process_next( context )
         end
       else
         chain.process_next( context )
       end
     end

     ...
   end

That said, I'm not confident that this approach will work, and here's why: I believe that when you assign a storycard to a project, the #project_id= call occurs internally, within the StoryCard class itself. That means that the interceptors are bypassed (interceptors are only invoked when a client invokes a method on the service--methods invoked internally by the service do not go through the interceptors).

Also, the AR objects use the constant names internally to reference other AR classes, which means that they aren't going through the registry anyway to get the dependencies. (Which means interceptors won't help you at all, in that case.)

This all goes back to why I don't, in general, like giving classes themselves prominant acting roles. Class methods are just globals, and like all globals should be used sparingly. DI canhelp reduce the necessity of class methods, but only if the framework was designed with DI in mind.

···

--
Jamis Buck
jgb3@email.byu.edu
http://www.jamisbuck.org/jamis