Permalink
Browse files

add method call tracking and rails/action_controller adapter

  • Loading branch information...
Sven Fuchs
Sven Fuchs committed Sep 14, 2009
1 parent bf23b6b commit 5dc2657c9a0e76f868eb6735e1d5a82025d5508c
@@ -0,0 +1,88 @@
+# Simple mechanism to track calls on methods.
+#
+# Include the module and call track_methods(an_array, :foo, :bar) on any object.
+# This will set up the methods :foo and :bar on the object's metaclass.
+#
+# When the method :foo is called for the first time this is recorded to the
+# given array and the method is removed from the metaclass (so it only records
+# the) first call. The given array will then equal [[the_object, :foo]].
+module MethodCallTracking
+ def track_method_calls(tracker, *methods)
+ if methods.empty?
+ # FIXME this assumes ActiveRecord
+ define_track_method(tracker, @attributes, :[], [self, nil])
+ else
+ methods.each do |method|
+ define_track_method(tracker, self, method, [self, method])
+ end
+ end
+ end
+
+ # Sets up a method in the meta class of the target object which will save
+ # a reference when the method is called first, then removes itself and
+ # delegates to the regular method in the class. (Cheap method proxy pattern
+ # that leverages Ruby's way of looking up a method in the meta class first
+ # and then in the regular class second.)
+ def define_track_method(tracker, target, method, reference)
+ meta_class = class << target; self; end
+ meta_class.send :define_method, method do |*args|
+ tracker << reference
+ meta_class.send(:remove_method, method)
+ super
+ end
+ end
+
+ # Tracks method access on trackable objects. Trackables can be given as
+ #
+ # * instance variable names (when starting with an @)
+ # * method names (otherwise)
+ # * Hashes that use ivar or method names as keys and method names as values:
+ #
+ # So both of these:
+ #
+ # Tracker.new controller, :'@foo', :bar, { :'@baz' => :buz }
+ # Tracker.new controller, :'@foo', :bar, { :'@baz' => [:buz] }
+ #
+ # would set up access tracking for the controller's ivar @foo, the method :bar
+ # and the method :buz on the ivar @baz.
+ class Tracker
+ attr_reader :references
+
+ def initialize
+ @references = []
+ end
+
+ def track(owner, *trackables)
+ trackables.each do |trackable|
+ trackable = { trackable => nil } unless trackable.is_a?(Hash)
+ trackable.each do |trackable, methods|
+ trackable = resolve_trackable(owner, trackable)
+ track_methods(trackable, methods) unless trackable.nil? # FIXME issue warning
+ end
+ end
+ end
+
+ protected
+
+ # Resolves the trackable by looking it up on the owner. Trackables will be
+ # interpreted as instance variables when they start with an @ and as method
+ # names otherwise.
+ def resolve_trackable(owner, trackable)
+ case trackable.to_s
+ when /^@/; owner.instance_variable_get(trackable.to_sym)
+ else owner.send(trackable.to_sym)
+ end
+ end
+
+ # Wraps the trackable into a MethodReadObserver and registers itself as an observer.
+ # Sets up tracking for the read_attribute method when the methods argument is nil.
+ # Sets up tracking for any other given methods otherwise.
+ def track_methods(trackable, methods)
+ if trackable.is_a?(Array)
+ trackable.each { |trackable| track_methods(trackable, methods) }
+ else
+ trackable.track_method_calls(references, *Array(methods))
+ end
+ end
+ end
+end
@@ -0,0 +1,110 @@
+require 'method_call_tracking'
+
+module Rack::Cache::Tags
+ module Rails
+ module ActionController
+ module ActMacro
+ # ...
+ #
+ # cache_tags :index, :show, :track => ['@article', '@articles', {'@site' => :tag_counts}]
+ #
+ def cache_tags(*actions)
+ tracks_references(*actions)
+
+ unless caches_page_with_references?
+ alias_method_chain :caching_allowed, :skipping
+ end
+
+ # options = actions.extract_options!
+ after_filter(:only => actions) { |c| c.cache_control }
+ end
+
+ # Sets up reference tracking for given actions and objects
+ #
+ # tracks_references :index, :show, :track => ['@article', '@articles', {'@site' => :tag_counts}]
+ #
+ def tracks_references(*actions)
+ unless tracks_references?
+ include Rack::Cache::Tags::Rails::ActionController
+
+ # helper_method :cached_references
+ # attr_writer :cached_references
+ alias_method_chain :render, :reference_tracking
+
+ class_inheritable_accessor :track_references_to
+ self.track_references_to = []
+
+ class_inheritable_accessor :track_references_on
+ self.track_references_on = []
+ end
+
+ options = actions.extract_options!
+ track = options[:track]
+
+ self.track_references_to += track.is_a?(Array) ? track : [track]
+ self.track_references_to.uniq!
+ self.track_references_on = actions
+ end
+
+ def caches_page_with_references?
+ method_defined? :caching_allowed_without_skipping
+ end
+
+ def tracks_references?
+ method_defined? :render_without_reference_tracking
+ end
+ end
+
+ attr_reader :reference_tracker
+
+ def cache_control
+ if perform_caching && caching_allowed
+ expires_in(10.years.from_now, :public => true)
+ set_cache_tags
+ end
+ end
+
+ def skip_caching!
+ @skip_caching = true
+ end
+
+ def skip_caching?
+ @skip_caching == true
+ end
+
+ protected
+
+ def render_with_reference_tracking(*args, &block)
+ args << options = args.extract_options!
+ skip_caching! if options.delete(:skip_caching) || !cacheable_action?
+
+ setup_reference_tracking if perform_caching && caching_allowed
+ render_without_reference_tracking(*args, &block)
+ end
+
+ def cacheable_action?
+ action = params[:action] || ''
+ self.class.track_references_on.include?(action.to_sym)
+ end
+
+ def setup_reference_tracking
+ trackables = self.class.track_references_to || {}
+ @reference_tracker ||= MethodCallTracking::Tracker.new
+ @reference_tracker.track(self, *trackables.clone)
+ end
+
+ def set_cache_tags
+ cache_tags = @reference_tracker.references.map do |reference|
+ reference.first.cache_tag
+ end
+ response.headers[Rack::Cache::TAGS_HEADER] = cache_tags.join(',') unless cache_tags.empty?
+ end
+
+ def caching_allowed_with_skipping
+ caching_allowed_without_skipping && !skip_caching?
+ end
+
+ ::ActionController::Base.send(:extend, ActMacro)
+ end
+ end
+end
@@ -0,0 +1,44 @@
+require File.expand_path("#{File.dirname(__FILE__)}/test_setup")
+require 'method_call_tracking'
+
+class Template
+ def initialize(locals)
+ locals.each { |name, value| instance_variable_set(:"@#{name}", value) }
+ end
+end
+
+class Foo
+ include MethodCallTracking
+ def self.bar; end
+ attr_reader :attributes
+ def initialize; @attributes = {}; end
+ def bar; end
+end
+
+describe 'MethodCallTracking' do
+ describe 'setup' do
+ before(:each) do
+ @foo = Foo.new
+ @template = Template.new(:foo => @foo)
+ @tracker = MethodCallTracking::Tracker.new
+ end
+
+ it "with an instance and method definition" do
+ @tracker.track(@template, :@foo => :bar)
+ @foo.bar
+ assert_referenced @foo, :bar
+ end
+
+ it "with an instance and no method" do
+ @tracker.track(@template, :@foo => nil)
+ @foo.attributes[:bar]
+ assert_referenced @foo
+ end
+
+ def assert_referenced(object, method = nil)
+ assert @tracker.references.any? { |reference|
+ reference[0] == object && reference[1] == method
+ }, "should reference #{object.inspect}, #{method ? method.inspect : ''} but doesn't"
+ end
+ end
+end
@@ -0,0 +1,56 @@
+require File.expand_path("#{File.dirname(__FILE__)}/../test_setup")
+
+require 'rubygems'
+require 'action_controller'
+require 'rack/cache/tags/rails/action_controller'
+
+class Foo
+ include MethodCallTracking
+ def bar
+ 'YAY'
+ end
+ def cache_tag
+ "foo-1"
+ end
+end
+
+class FooController < ActionController::Base
+ cache_tags :index, :track => { :@foo => :bar }
+ def index
+ @foo = Foo.new
+ render :file => File.expand_path(File.dirname(__FILE__) + '/templates/index.html.erb')
+ end
+end
+
+describe 'Rack::Cache::Tags::Rails::ActionController' do
+ it "tracks references (method access) to objects assigned to the view" do
+ get('/')
+ references = @controller.reference_tracker.references
+
+ assert_equal "200 OK", @response.status
+ assert_equal 1, references.size
+ assert_equal Foo, references.first[0].class
+ assert_equal :bar, references.first[1]
+ end
+
+ it "adds an after_filter that adds cache-control, max-age and x-cache-tags headers" do
+ get('/')
+ assert_equal "foo-1", @response.headers[Rack::Cache::TAGS_HEADER]
+ end
+
+ it "sends cache-control, max-age and x-cache-tags headers" do
+ assert FooController.filter_chain.select { |f| f.type == :after }
+ end
+
+ def get(path)
+ @request = ActionController::Request.new(
+ "REQUEST_METHOD" => "GET",
+ "REQUEST_URI" => path,
+ "rack.input" => "",
+ "action_controller.request.path_parameters" => { :action => 'index' }
+ )
+ @response = ActionController::Response.new
+ @controller = FooController.new
+ @controller.process(@request, @response)
+ end
+end
@@ -0,0 +1,2 @@
+INDEX
+<%= @foo.bar %>

0 comments on commit 5dc2657

Please sign in to comment.