This changeset is larger than I prefer, and may be unstable with databases besides mysql.

Also, for developers: two new gems are required for running tests: zentest and flexmock.

I applied James Kebinger's patch to add starred actions. These are implemented behind the scenes as a tag, so you can see all starred actions the way you would look at actions for any tag. Closes #387. Thanks, James!  
Tests now rely the ZenTest gem. Thanks Ryan Davis & Eric Hodel.
I improved test coverage of a few models and created a test for the new helper methods to support the stars. (Helper method tests are made possible by ZenTest. The helper tests use mock objects to isolate them, courtesy of flexmock. Thanks, Jim Weirich!)
Modified a few selenium tests to work properly with mysql.
Upgraded the has_many_polymorphs plugin.
Add rails_rcov plugin to get test coverage numbers more easily.
Convert toggle_check action to correspond to a PUT instead of a POST (follows CRUD<->HTTP mapping better).

I'm having some issues running tests with sqlite3 that I haven't been able to figure out. I'll work on it, but wanted to check in so I can check out and work from the beach this weekend.

Happy holiday weekend to those of you in the U.S.!




git-svn-id: http://www.rousette.org.uk/svn/tracks-repos/trunk@544 a4c988fc-2ded-0310-b66e-134b36920a42
This commit is contained in:
lukemelia 2007-05-25 19:01:08 +00:00
parent 2ac8cd3324
commit 1cce7f076c
83 changed files with 2249 additions and 720 deletions

View file

@ -8,3 +8,9 @@ require 'rake/testtask'
require 'rake/rdoctask'
require 'tasks/rails'
begin
require 'test/rails/rake_tasks'
rescue LoadError => e
#It's ok if you don't have ZenTest installed if you're not a developer
end

View file

@ -7,7 +7,7 @@ require "redcloth"
require 'date'
require 'time'
Tag # We need this in development mode, or you get 'method missing' errors
#Tag # We need this in development mode, or you get 'method missing' errors
class ApplicationController < ActionController::Base

View file

@ -5,7 +5,7 @@ class TodosController < ApplicationController
skip_before_filter :login_required, :only => [:index]
prepend_before_filter :login_or_feed_token_required, :only => [:index]
append_before_filter :init, :except => [ :destroy, :completed, :completed_archive, :check_deferred ]
append_before_filter :get_todo_from_params, :only => [ :edit, :toggle_check, :show, :update, :destroy ]
append_before_filter :get_todo_from_params, :only => [ :edit, :toggle_check, :toggle_star, :show, :update, :destroy ]
session :off, :only => :index, :if => Proc.new { |req| is_feed_request(req) }
@ -138,6 +138,11 @@ class TodosController < ApplicationController
end
end
end
def toggle_star
@todo.toggle_star!
@saved = @todo.save!
end
def update
@todo.tag_with(params[:tag_list],@user) if params[:tag_list]

View file

@ -24,6 +24,17 @@ module TodosHelper
str
end
def remote_star_icon
str = link_to( image_tag_for_star(@todo),
toggle_star_todo_path(@todo),
:class => "icon star_item", :title => "star the action '#{@todo.description}'")
apply_behavior '.item-container a.star_item:click',
remote_function(:url => javascript_variable('this.href'), :method => 'put',
:with => "{ _source_view : '#{@source_view}' }"),
:prevent_default => true
str
end
def remote_edit_icon
if !@todo.completed?
str = link_to( image_tag_for_edit,
@ -41,8 +52,8 @@ module TodosHelper
def remote_toggle_checkbox
str = check_box_tag('item_id', toggle_check_todo_path(@todo), @todo.completed?, :class => 'item-checkbox')
apply_behavior '.item-container input.item-checkbox:click',
remote_function(:url => javascript_variable('this.value'),
:with => "{ method : 'post', _source_view : '#{@source_view}' }")
remote_function(:url => javascript_variable('this.value'), :method => 'put',
:with => "{ _source_view : '#{@source_view}' }")
str
end
@ -57,7 +68,8 @@ module TodosHelper
end
def tag_list
@todo.tags.collect{|t| "<span class=\"tag\">" + link_to(t.name, :action => "tag", :id => t.name) + "</span>"}.join('')
tag_list = @todo.tags.reject{|t| t.name == Todo::STARRED_TAG_NAME}.collect{|t| "<span class=\"tag\">" + link_to(t.name, :action => "tag", :id => t.name) + "</span>"}.join('')
"<span class='tags'>#{tag_list}</span>"
end
def deferred_due_date
@ -204,4 +216,9 @@ module TodosHelper
image_tag("blank.png", :title =>"Edit action", :class=>"edit_item", :id=> dom_id(@todo, 'edit_icon'))
end
def image_tag_for_star(todo)
class_str = todo.starred? ? "starred_todo" : "unstarred_todo"
image_tag("blank.png", :title =>"Star action", :class => class_str)
end
end

View file

@ -4,6 +4,8 @@ class Todo < ActiveRecord::Base
belongs_to :project
belongs_to :user
STARRED_TAG_NAME = "starred"
acts_as_state_machine :initial => :active, :column => 'state'
state :active, :enter => Proc.new { |t| t[:show_from] = nil }
@ -89,5 +91,21 @@ class Todo < ActiveRecord::Base
:description => "Actions for #{user.display_name}"
}
end
def starred?
tags.any? {|tag| tag.name == STARRED_TAG_NAME}
end
def toggle_star!
if starred?
delete_tags STARRED_TAG_NAME
tags.reload
else
add_tag STARRED_TAG_NAME
tags.reload
end
starred?
end
end

View file

@ -1,11 +1,11 @@
<%
@todo = todo
Tag
%>
<div id="<%= dom_id(todo) %>" class="item-container">
<div id="<%= dom_id(todo, 'line') %>">
<%= remote_delete_icon %>
<%= remote_edit_icon %>
<%= remote_star_icon %>
<%= remote_toggle_checkbox unless source_view_is :deferred %>
<div class="description<%= staleness_class( todo ) %>">
<%= date_span %>

View file

@ -0,0 +1,3 @@
if @saved
page[@todo].down('a.star_item').down('img').toggleClassName('starred_todo').toggleClassName('unstarred_todo')
end

View file

@ -33,7 +33,7 @@ ActionController::Routing::Routes.draw do |map|
# ToDo Routes
map.resources :todos,
:member => {:toggle_check => :post},
:member => {:toggle_check => :put, :toggle_star => :put},
:collection => {:check_deferred => :post, :filter_to_context => :post, :filter_to_project => :post}
map.with_options :controller => "todos" do |todos|
todos.home '', :action => "index"

View file

@ -22,5 +22,9 @@ class ActiveRecord::Base
split = tag_string.downcase.split(", ")
tags.delete tags.select{|t| split.include? t.name}
end
def add_tag tag_name
Tag.find_or_create_by_name(tag_name).on(self,user)
end
end

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

View file

@ -78,6 +78,11 @@ a:hover img.edit_item {background-image: url(../images/edit_on.png); background-
img.delete_item {background-image: url(../images/delete_off.png); background-repeat: no-repeat; border: none;}
a:hover img.delete_item {background-image: url(../images/delete_on.png);background-color: transparent;background-repeat: no-repeat; border: none;}
img.starred_todo {background-image: url(../images/staricons.png); background-repeat: no-repeat; border:none; background-position: 0px 0px;}
a:hover img.starred_todo {background-image: url(../images/staricons.png); background-repeat: no-repeat; border:none; background-position: -16px 0px;}
img.unstarred_todo {background-image: url(../images/staricons.png); background-repeat: no-repeat; border:none; background-position: -32px 0px;}
a:hover img.unstarred_todo {background-image: url(../images/staricons.png); background-repeat: no-repeat; border:none; background-position: -48px 0px;}
a.to_top {background: transparent url(../images/top_off.png) no-repeat;}
a.to_top:hover {background: transparent url(../images/top_on.png) no-repeat;}
@ -285,12 +290,12 @@ input.item-checkbox {
}
.description {
margin-left: 70px;
margin-left: 85px;
}
.stale_l1, .stale_l2, .stale_l3 {
margin-left: 67px;
padding-left: 3px;
margin-left: 82px;
padding-left: 3px;
}
.stale_l1 {

View file

@ -4,7 +4,7 @@ require 'backend_controller'
# Re-raise errors caught by the controller.
class BackendController; def rescue_action(e) raise e end; end
class BackendControllerTest < Test::Unit::TestCase
class BackendControllerTest < Test::Rails::TestCase
fixtures :users, :projects, :contexts, :todos, :notes
def setup

View file

@ -150,4 +150,36 @@ class ContextsControllerTest < TodoContainerControllerTestBase
assert_response :ok
end
def test_show_sets_title
@request.session['user_id'] = users(:admin_user).id
get :show, { :id => "1" }
assert_equal 'TRACKS::Context: agenda', assigns['page_title']
end
def test_show_renders_show_template
@request.session['user_id'] = users(:admin_user).id
get :show, { :id => "1" }
assert_template "contexts/show"
end
def test_show_xml_renders_context_to_xml
@request.session['user_id'] = users(:admin_user).id
get :show, { :id => "1", :format => 'xml' }
assert_equal contexts(:agenda).to_xml( :except => :user_id ), @response.body
end
def test_show_with_nil_context_returns_404
@request.session['user_id'] = users(:admin_user).id
get :show, { :id => "0" }
assert_equal 'Context not found', @response.body
assert_response 404
end
def test_show_xml_with_nil_context_returns_404
@request.session['user_id'] = users(:admin_user).id
get :show, { :id => "0", :format => 'xml' }
assert_response 404
assert_xml_select 'error', 'Context not found'
end
end

View file

@ -4,7 +4,7 @@ require 'data_controller'
# Re-raise errors caught by the controller.
class DataController; def rescue_action(e) raise e end; end
class DataControllerTest < Test::Unit::TestCase
class DataControllerTest < Test::Rails::TestCase
fixtures :users, :preferences, :projects, :notes
def setup

View file

@ -4,7 +4,7 @@ require 'feedlist_controller'
# Re-raise errors caught by the controller.
class FeedlistController; def rescue_action(e) raise e end; end
class FeedlistControllerTest < Test::Unit::TestCase
class FeedlistControllerTest < Test::Rails::TestCase
fixtures :users, :preferences, :projects, :contexts, :todos, :notes
def setup

View file

@ -5,7 +5,7 @@ require_dependency "login_system"
# Re-raise errors caught by the controller.
class LoginController; def rescue_action(e) raise e end; end
class LoginControllerTest < Test::Unit::TestCase
class LoginControllerTest < Test::Rails::TestCase
fixtures :preferences, :users
def setup

View file

@ -4,7 +4,7 @@ require 'notes_controller'
# Re-raise errors caught by the controller.
class NotesController; def rescue_action(e) raise e end; end
class NotesControllerTest < Test::Unit::TestCase
class NotesControllerTest < Test::Rails::TestCase
def setup
@controller = NotesController.new
request = ActionController::TestRequest.new

View file

@ -5,7 +5,7 @@ require 'preference'
# Re-raise errors caught by the controller.
class PreferencesController; def rescue_action(e) raise e end; end
class PreferencesControllerTest < Test::Unit::TestCase
class PreferencesControllerTest < Test::Rails::TestCase
fixtures :users
def setup

View file

@ -57,7 +57,7 @@ class ProjectsControllerTest < TodoContainerControllerTestBase
def test_create_project_and_go_to_project_page
num_projects = Project.count
xhr :post, :create, { :project => {:name => 'Immediate Project Planning Required'}, :go_to_project => 1}
assert_js_redirected_to '/projects/5'
assert_js_redirected_to %r{/?projects/\d+}
assert_equal num_projects + 1, Project.count
end

View file

@ -1,4 +1,4 @@
class TodoContainerControllerTestBase < Test::Unit::TestCase
class TodoContainerControllerTestBase < Test::Rails::TestCase
def perform_setup(container_class, controller_class)
@controller = controller_class.new

View file

@ -4,7 +4,7 @@ require 'todos_controller'
# Re-raise errors caught by the controller.
class TodosController; def rescue_action(e) raise e end; end
class TodosControllerTest < Test::Unit::TestCase
class TodosControllerTest < Test::Rails::TestCase
fixtures :users, :preferences, :projects, :contexts, :todos, :tags, :taggings
def setup
@ -25,6 +25,15 @@ class TodosControllerTest < Test::Unit::TestCase
assert_equal 1, assigns['context_not_done_counts'][contexts(:lab).id]
end
def test_tag_is_retrieved_properly
@request.session['user_id'] = users(:admin_user).id
get :index
t = assigns['not_done_todos'].find{|t| t.id == 2}
assert_equal 1, t.tags.count
assert_equal 'foo', t.tags[0].name
assert !t.starred?
end
def test_not_done_counts_after_hiding_project
p = Project.find(1)
p.hide!

View file

@ -4,7 +4,7 @@ require 'users_controller'
# Re-raise errors caught by the controller.
class UsersController; def rescue_action(e) raise e end; end
class UsersControllerTest < Test::Unit::TestCase
class UsersControllerTest < Test::Rails::TestCase
fixtures :preferences, :users
def setup

View file

@ -0,0 +1,5 @@
setup :fixtures => :all
login :as => 'admin'
open '/'
click "css=#c1 #todo_9 a.star_item"
wait_for_element_present "css=#c1 #todo_9 img.starred_todo"

View file

@ -0,0 +1,4 @@
setup :fixtures => :all
login :as => 'admin'
open '/'
assert_element_present "css=#c2 #todo_2 img.unstarred_todo"

View file

@ -1,13 +1,20 @@
setup :fixtures => :all
next_available_todo_id = 18
login :as => 'admin'
#first, defer a todo
open "/projects/1"
include_partial 'project_detail/add_deferred_todo'
open "/projects/1"
click "edit_icon_todo_#{next_available_todo_id}"
wait_for_element_present "show_from_todo_#{next_available_todo_id}"
type "show_from_todo_#{next_available_todo_id}", ""
click "edit_icon_todo_5"
wait_for_element_present "show_from_todo_5"
type "show_from_todo_5", "1/1/2030"
click "//input[@value='Update']"
wait_for_element_present "xpath=//div[@id='p1'] //div[@id='todo_#{next_available_todo_id}']"
wait_for_element_present "xpath=//div[@id='tickler'] //div[@id='todo_5']"
#now activate the other deferred one
open "/projects/1"
click "edit_icon_todo_15"
wait_for_element_present "show_from_todo_15"
type "show_from_todo_15", ""
click "//input[@value='Update']"
wait_for_element_present "xpath=//div[@id='p1'] //div[@id='todo_15']"
assert_not_visible "tickler-empty-nd"
assert_text 'badge_count', '3'
assert_text 'badge_count', '2'

View file

@ -1,7 +1,10 @@
ENV["RAILS_ENV"] = "test"
require File.expand_path(File.dirname(__FILE__) + "/../config/environment")
require File.expand_path(File.dirname(__FILE__) + "/../app/controllers/application")
require 'test/rails' #you need the zentest gem isntalled
require 'test_help'
require 'flexmock/test_unit' #and the flexmock gem, too!
module Tracks
class Config
def self.salt
@ -11,6 +14,20 @@ module Tracks
end
class Test::Unit::TestCase
def xml_document
@xml_document ||= HTML::Document.new(@response.body, false, true)
end
def assert_xml_select(*args, &block)
@html_document = xml_document
assert_select(*args, &block)
end
end
class Test::Rails::TestCase < Test::Unit::TestCase
# Turn off transactional fixtures if you're working with MyISAM tables in MySQL
self.use_transactional_fixtures = true
@ -30,16 +47,7 @@ class Test::Unit::TestCase
end
return string
end
def xml_document
@xml_document ||= HTML::Document.new(@response.body, false, true)
end
def assert_xml_select(*args, &block)
@html_document = xml_document
assert_select(*args, &block)
end
def next_week
1.week.from_now.utc.to_date
end
@ -58,6 +66,8 @@ class Test::Unit::TestCase
if options.is_a?(String)
assert_equal(options.gsub(/^\//, ''), redirected_to, message)
elsif options.is_a?(Regexp)
assert(options =~ redirected_to, "#{message} #{options} #{redirected_to}")
else
msg = build_message(message, "response is not a redirection to all of the options supplied (redirection is <?>)", redirected_to)
assert_equal(@controller.url_for(options).match(js_regexp)[3], redirected_to, msg)
@ -69,7 +79,7 @@ class Test::Unit::TestCase
end
class ActionController::IntegrationTest
def assert_test_environment_ok
assert_equal "test", ENV['RAILS_ENV']
assert_equal "change-me", Tracks::Config.salt

View file

@ -1,6 +1,6 @@
require File.dirname(__FILE__) + '/../test_helper'
class ContextTest < Test::Unit::TestCase
class ContextTest < Test::Rails::TestCase
fixtures :contexts, :todos, :users, :preferences
def setup
@ -107,4 +107,11 @@ class ContextTest < Test::Unit::TestCase
assert_equal "<p>#{undone_todo_count}. Context is Hidden.</p>", @agenda.summary(undone_todo_count)
end
def test_null_object
c = Context.null_object
assert c.nil?
assert_nil c.id
assert_equal '', c.name
end
end

View file

@ -1,6 +1,6 @@
require File.dirname(__FILE__) + '/../test_helper'
class NotesTest < Test::Unit::TestCase
class NotesTest < Test::Rails::TestCase
fixtures :notes
def setup

View file

@ -1,6 +1,6 @@
require File.dirname(__FILE__) + '/../test_helper'
class PreferenceTest < Test::Unit::TestCase
class PreferenceTest < Test::Rails::TestCase
fixtures :users, :preferences
def setup

View file

@ -1,6 +1,6 @@
require File.dirname(__FILE__) + '/../test_helper'
class ProjectTest < Test::Unit::TestCase
class ProjectTest < Test::Rails::TestCase
fixtures :projects, :contexts, :todos, :users, :preferences
def setup

View file

@ -1,6 +1,6 @@
require File.dirname(__FILE__) + '/../test_helper'
class TagTest < Test::Unit::TestCase
class TagTest < Test::Rails::TestCase
fixtures :tags
# Replace this with your real tests.

View file

@ -1,6 +1,6 @@
require File.dirname(__FILE__) + '/../test_helper'
class TaggingTest < Test::Unit::TestCase
class TaggingTest < Test::Rails::TestCase
fixtures :taggings
# Replace this with your real tests.

View file

@ -1,8 +1,8 @@
require File.dirname(__FILE__) + '/../test_helper'
require 'date'
class TodoTest < Test::Unit::TestCase
fixtures :todos, :users, :contexts, :preferences
class TodoTest < Test::Rails::TestCase
fixtures :todos, :users, :contexts, :preferences, :tags, :taggings
def setup
@not_completed1 = Todo.find(1).reload
@ -139,4 +139,28 @@ class TodoTest < Test::Unit::TestCase
assert_equal :deferred, t.current_state
end
def test_todo_is_not_starred
assert !@not_completed1.starred?
end
def test_todo_2_is_not_starred
assert !Todo.find(2).starred?
end
def test_todo_is_starred_after_starred_tag_is_added
@not_completed1.add_tag('starred')
assert @not_completed1.starred?
end
def test_todo_is_starred_after_toggle_starred
@not_completed1.toggle_star!
assert @not_completed1.starred?
end
def test_todo_is_not_starred_after_toggle_starred_twice
@not_completed1.toggle_star!
@not_completed1.toggle_star!
assert !@not_completed1.starred?
end
end

View file

@ -16,7 +16,7 @@ class SimpleLdapAuthenticator
end
end
class UserTest < Test::Unit::TestCase
class UserTest < Test::Rails::TestCase
fixtures :users, :preferences, :projects, :contexts, :todos
def setup

View file

@ -0,0 +1,40 @@
require File.dirname(__FILE__) + '/../test_helper'
class TodosHelperTest < Test::Rails::HelperTestCase
def setup
super
end
include TodosHelper
def test_remote_star_icon_unstarred
@todo = flexmock(:id => 1, :to_param => 1, :description => 'Get gas', :starred? => false)
assert_remote_star_icon_helper_matches %r{<a href="/todos/1;toggle_star" class="icon star_item" title="star the action 'Get gas'"><img alt="Blank" class="unstarred_todo" src="/images/blank.png[?0-9]*" title="Star action" /></a>}
assert_behavior_registered
end
def test_remote_star_icon_starred
@todo = flexmock(:id => 1, :to_param => 1, :description => 'Get gas', :starred? => true)
assert_remote_star_icon_helper_matches %r{<a href="/todos/1;toggle_star" class="icon star_item" title="star the action 'Get gas'"><img alt="Blank" class="starred_todo" src="/images/blank.png[?0-9]*" title="Star action" /></a>}
assert_behavior_registered
end
def assert_remote_star_icon_helper_matches(regex)
@controller.send :initialise_js_behaviours #simulate before filter
output = remote_star_icon
#puts output
assert output =~ regex
@controller.send :store_js_behaviours #simulate after filter
end
def assert_behavior_registered
behaviors = @controller.session[:js_behaviours]
assert behaviors[:options][:reapply_after_ajax]
assert_equal 1, behaviors[:rules].length
rule = behaviors[:rules][0]
assert_equal ".item-container a.star_item:click", rule[0]
assert_equal "new Ajax.Request(this.href, {asynchronous:true, evalScripts:true, method:'put', parameters:{ _source_view : '' }}); return false;",
rule[1]
end
end

View file

@ -0,0 +1,43 @@
LICENSE
Manifest
README
Rakefile
init.rb
lib/has_many_polymorphs/association.rb
lib/has_many_polymorphs/autoload.rb
lib/has_many_polymorphs/base.rb
lib/has_many_polymorphs/class_methods.rb
lib/has_many_polymorphs/configuration.rb
lib/has_many_polymorphs/debugging_tools.rb
lib/has_many_polymorphs/dependencies.rb
lib/has_many_polymorphs/rake_task_redefine_task.rb
lib/has_many_polymorphs/reflection.rb
lib/has_many_polymorphs/support_methods.rb
lib/has_many_polymorphs.rb
test/fixtures/aquatic/fish.yml
test/fixtures/aquatic/little_whale_pupils.yml
test/fixtures/aquatic/whales.yml
test/fixtures/bow_wows.yml
test/fixtures/cats.yml
test/fixtures/eaters_foodstuffs.yml
test/fixtures/frogs.yml
test/fixtures/keep_your_enemies_close.yml
test/fixtures/petfoods.yml
test/fixtures/wild_boars.yml
test/models/aquatic/fish.rb
test/models/aquatic/pupils_whale.rb
test/models/aquatic/whale.rb
test/models/beautiful_fight_relationship.rb
test/models/cat.rb
test/models/dog.rb
test/models/eaters_foodstuff.rb
test/models/frog.rb
test/models/kitten.rb
test/models/petfood.rb
test/models/tabby.rb
test/models/wild_boar.rb
test/modules/extension_module.rb
test/modules/other_extension_module.rb
test/schema.rb
test/test_helper.rb
test/unit/polymorph_test.rb

View file

@ -1,17 +1,24 @@
Self-referential, polymorphic has_many :through helper
Self-referential, polymorphic has_many :through helper for ActiveRecord.
Copyright 2006 Evan Weaver (see the LICENSE file)
Copyright 2007 Cloudburst, LLC (see the LICENSE file)
"model :parent_class" may be required in some controllers or perhaps models in order for reloading to work properly, since the parent setup must be executed on the child every time the child class is reloaded.
Usage and help:
http://blog.evanweaver.com/articles/2006/06/02/has_many_polymorphs
Also see the source code, although it's probably not going to be super helpful to you.
Documentation:
http://blog.evanweaver.com/pages/has_many_polymorphs
Changelog:
27.3. use new :source and :source_type options in 1.2.3 (David Lemstra); fix pluralization bug; add some tests; experimental tagging generator
27.2. deprecate has_many_polymorphs_cache_classes= option, because it doesn't really work. use config.cache_classes= instead to cache all reloadable items
27.1. dispatcher.to_prepare didn't fire in the console; now using a config.after_initialize wrapper instead
27. dependency injection framework elimates having to care about load order
26. make the logger act sane for the gem version
25.2. allow :skip_duplicates on double relationships
25.1. renamed :ignore_duplicates to :skip_duplicates to better express its non-passive behavior, made sure not to load target set on push unless necessary
25. activerecord compatibility branch becomes trunk. extra options now supported for double polymorphism; conditions nulled-out and propogated to child relationships; more tests; new :ignore_duplicates option on macro can be set to false if you want << to try to push duplicate associations
24.1. code split into multiple files. tests added for pluralization check. rails 1.1.6 no longer officially supported.
24. unlimited mixed class association extensions for both single and double targets and joins
23. gem version
22. api change; prefix on methods is now singular when using :rename_individual_collections
21. add configuration option to cache polymorphic classes in development mode
20. collection methods (push, delete, clear) now on individual collections
@ -40,7 +47,3 @@ Changelog:
3. added :dependent support on the join table
1-2. no changelog
Known problems:
1. Plugin's test fixtures do not load properly for non-edge postgres, invalidating the tests.
2. quote_value() hack is stupid.

View file

@ -0,0 +1,59 @@
require 'rubygems'
require 'rake'
require 'lib/has_many_polymorphs/rake_task_redefine_task'
NAME = "has_many_polymorphs"
begin
require 'rake/clean'
gem 'echoe', '>= 1.1'
require 'echoe'
require 'fileutils'
AUTHOR = "Evan Weaver"
EMAIL = "evan at cloudbur dot st"
DESCRIPTION = "Self-referential, polymorphic has_many :through helper for ActiveRecord."
CHANGES = `cat README`[/^([\d\.]+\. .*)/, 1]
RUBYFORGE_NAME = "polymorphs"
GEM_NAME = "has_many_polymorphs"
HOMEPATH = "http://blog.evanweaver.com"
RELEASE_TYPES = ["gem"]
REV = nil
VERS = `cat README`[/^([\d\.]+)\. /, 1]
CLEAN.include ['**/.*.sw?', '*.gem', '.config']
RDOC_OPTS = ['--quiet', '--title', "has_many_polymorphs documentation",
"--opname", "index.html",
"--line-numbers",
"--main", "README",
"--inline-source"]
include FileUtils
echoe = Echoe.new(GEM_NAME, VERS) do |p|
p.author = AUTHOR
p.rubyforge_name = RUBYFORGE_NAME
p.name = NAME
p.description = DESCRIPTION
p.changes = CHANGES
p.email = EMAIL
p.summary = DESCRIPTION
p.url = HOMEPATH
p.need_tar = false
p.need_tar_gz = true
p.test_globs = ["*_test.rb"]
p.clean_globs = CLEAN
end
rescue LoadError => boom
puts "You are missing a dependency required for meta-operations on this gem."
puts "#{boom.to_s.capitalize}."
desc 'Run the default tasks'
task :default => :test
end
desc 'Run the test suite.'
Rake::Task.redefine_task("test") do
puts "Notice; tests must be run from within a functioning Rails environment."
system "ruby -Ibin:lib:test test/unit/polymorph_test.rb #{ENV['METHOD'] ? "--name=#{ENV['METHOD']}" : ""}"
end

View file

@ -0,0 +1,12 @@
Thank you for installing has_many_polymorphs!
There is an experimental tagging system generator in place.
./script/generator tagging TaggableModel1 TaggableModel2 [..]
You can use the flags --skip-migration and/or --self-referential.
Tests will be generated, but will not work unless you have at
least 2 fixture entries for each taggable model. Their ids must
be 1 and 2.

View file

@ -0,0 +1,78 @@
require 'ruby-debug'
Debugger.start
class TaggingGenerator < Rails::Generator::NamedBase
default_options :skip_migration => false
default_options :self_referential => false
attr_reader :parent_association_name
attr_reader :taggable_models
def initialize(runtime_args, runtime_options = {})
@parent_association_name = (runtime_args.include?("--self-referential") ? "tagger" : "tag")
@taggable_models = runtime_args.reject{|opt| opt =~ /^--/}.map do |taggable|
":" + taggable.underscore.pluralize
end
@taggable_models += [":tags"] if runtime_args.include?("--self-referential")
@taggable_models.uniq!
hacks
runtime_args.unshift("placeholder")
super
end
def hacks
# add the extension require in environment.rb
phrase = "require 'tagging_extensions'"
filename = "#{RAILS_ROOT}/config/environment.rb"
unless (open(filename) do |file|
file.grep(/#{Regexp.escape phrase}/).any?
end)
open(filename, 'a+') do |file|
file.puts "\n" + phrase + "\n"
end
end
end
def manifest
record do |m|
m.class_collisions class_path, class_name, "#{class_name}Test"
m.directory File.join('app/models', class_path)
m.directory File.join('test/unit', class_path)
m.directory File.join('test/fixtures', class_path)
m.directory File.join('test/fixtures', class_path)
m.directory File.join('lib')
m.template 'tag.rb', File.join('app/models', class_path, "tag.rb")
m.template 'tag_test.rb', File.join('test/unit', class_path, "tag_test.rb")
m.template 'tags.yml', File.join('test/fixtures', class_path, "tags.yml")
m.template 'tagging.rb', File.join('app/models', class_path, "tagging.rb")
m.template 'tagging_test.rb', File.join('test/unit', class_path, "tagging_test.rb")
m.template 'taggings.yml', File.join('test/fixtures', class_path, "taggings.yml")
m.template 'tagging_extensions.rb', File.join('lib', 'tagging_extensions.rb')
unless options[:skip_migration]
m.migration_template 'migration.rb', 'db/migrate',
:migration_file_name => "create_tags_and_taggings"
end
end
end
protected
def banner
"Usage: #{$0} generate tagging [TaggableModelA TaggableModelB ...]"
end
def add_options!(opt)
opt.separator ''
opt.separator 'Options:'
opt.on("--skip-migration",
"Don't generate a migration file for this model") { |v| options[:skip_migration] = v }
opt.on("--self-referential",
"Allow tags to tag themselves.") { |v| options[:self_referential] = v }
end
end

View file

@ -0,0 +1,23 @@
class CreateTagsAndTaggings < ActiveRecord::Migration
def self.up
create_table :tags do |t|
t.column :name, :string, :null => false
end
add_index :tags, :name, :unique => true
create_table :taggings do |t|
t.column :<%= parent_association_name -%>_id, :integer, :null => false
t.column :taggable_id, :integer, :null => false
t.column :taggable_type, :string, :null => false
# t.column :position, :integer
end
add_index :taggings, [:<%= parent_association_name -%>_id, :taggable_id, :taggable_type], :unique => true
end
def self.down
drop_table :tags
drop_table :taggings
end
end

View file

@ -0,0 +1,35 @@
class Tag < ActiveRecord::Base
DELIMITER = " " # how to separate tags in strings (you may
# also need to change the validates_format_of parameters
# if you update this)
# if speed becomes an issue, you could remove these validations
# and rescue the AR index errors instead
validates_presence_of :name
validates_uniqueness_of :name, :case_sensitive => false
validates_format_of :name, :with => /^[a-zA-Z0-9\_\-]+$/,
:message => "can not contain special characters"
has_many_polymorphs :taggables,
:from => [<%= taggable_models.join(", ") %>],
:through => :taggings,
:dependent => :destroy,
<% if options[:self_referential] -%> :as => :<%= parent_association_name -%>,
<% end -%>
:skip_duplicates => false,
:parent_extend => proc { # XXX this isn't right
def to_s
self.map(&:name).sort.join(Tag::DELIMITER)
end
}
def before_create
# if you allow editable tag names, you might want before_save instead
self.name = name.downcase.strip.squeeze(" ")
end
class Error < StandardError
end
end

View file

@ -0,0 +1,10 @@
require File.dirname(__FILE__) + '/../test_helper'
class TagTest < Test::Unit::TestCase
fixtures :tags, :taggings, :recipes, :posts
def test_to_s
assert_equal "delicious sexy", Recipe.find(2).tags.to_s
end
end

View file

@ -0,0 +1,15 @@
class Tagging < ActiveRecord::Base
belongs_to :<%= parent_association_name -%><%= ", :foreign_key => \"#{parent_association_name}_id\", :class_name => \"Tag\"" if options[:self_referential] %>
belongs_to :taggable, :polymorphic => true
# if you want acts_as_list, you will have to manage the tagging positions
# manually, by created decorated join records
# acts_as_list :scope => :taggable
def before_destroy
# if all the taggings for a particular <%= parent_association_name -%> are deleted, we want to
# delete the <%= parent_association_name -%> too
<%= parent_association_name -%>.destroy_without_callbacks if <%= parent_association_name -%>.taggings.count == 1
end
end

View file

@ -0,0 +1,88 @@
class ActiveRecord::Base
# the alternative to these taggable?() checks is to explicitly include a
# TaggingMethods module (which you would create) in each taggable model
def tag_with list
# completely replace the existing tag set
taggable?(true)
list = tag_cast_to_string(list)
Tag.transaction do # transactions may not be ideal for you here
current = <%= parent_association_name -%>s.map(&:name)
_add_tags(list - current)
_remove_tags(current - list)
end
self
end
alias :<%= parent_association_name -%>s= :tag_with
# need to avoid name conflicts with the built-in ActiveRecord association
# methods, thus the underscores
def _add_tags incoming
taggable?(true)
tag_cast_to_string(incoming).each do |tag_name|
begin
tag = Tag.find_or_create_by_name(tag_name)
raise Tag::Error, "tag could not be saved: #{tag_name}" if tag.new_record?
tag.taggables << self
rescue ActiveRecord::StatementInvalid => e
raise unless e.to_s =~ /duplicate/i
end
end
end
def _remove_tags outgoing
taggable?(true)
outgoing = tag_cast_to_string(outgoing)
<% if options[:self_referential] %>
# because of http://dev.rubyonrails.org/ticket/6466
taggings.destroy(taggings.find(:all, :include => :<%= parent_association_name -%>).select do |tagging|
outgoing.include? tagging.<%= parent_association_name -%>.name
end)
<% else -%>
<%= parent_association_name -%>s.delete(<%= parent_association_name -%>s.select do |tag|
outgoing.include? tag.name
end)
<% end -%>
end
def tag_list
taggable?(true)
<%= parent_association_name -%>s.reload
<%= parent_association_name -%>s.to_s
end
private
def tag_cast_to_string obj
case obj
when Array
obj.map! do |item|
case item
when /^\d+$/, Fixnum then Tag.find(item).name # this will be slow if you use ids a lot
when Tag then item.name
when String then item
else
raise "Invalid type"
end
end
when String
obj = obj.split(Tag::DELIMITER).map do |tag_name|
tag_name.strip.squeeze(" ")
end
else
raise "Invalid object of class #{obj.class} as tagging method parameter"
end.flatten.compact.map(&:downcase).uniq
end
def taggable?(should_raise = false)
unless flag = respond_to?(:<%= parent_association_name -%>s)
raise "#{self.class} is not a taggable model" if should_raise
end
flag
end
end

View file

@ -0,0 +1,58 @@
require File.dirname(__FILE__) + '/../test_helper'
class TaggingTest < Test::Unit::TestCase
fixtures :taggings, :tags, :posts, :recipes
def setup
@obj1 = Recipe.find(1)
@obj2 = Recipe.find(2)
@obj3 = Post.find(1)
@tag1 = Tag.find(1)
@tag2 = Tag.find(2)
@tagging1 = Tagging.find(1)
end
def test_tag_with
@obj2.tag_with "dark columbian"
assert_equal "columbian dark", @obj2.tag_list
end
<% if options[:self_referential] -%>
def test_self_referential_tag_with
@tag1.tag_with [1, 2]
assert @tag1.tags.include?(@tag1)
assert !@tag2.tags.include?(@tag1)
end
<% end -%>
def test__add_tags
@obj1._add_tags "porter longneck"
assert Tag.find_by_name("porter").taggables.include?(@obj1)
assert Tag.find_by_name("longneck").taggables.include?(@obj1)
assert_equal "delicious longneck porter", @obj1.tag_list
@obj1._add_tags [2]
assert_equal "delicious longneck porter sexy", @obj1.tag_list
end
def test__remove_tags
@obj2._remove_tags ["2", @tag1]
assert @obj2.tags.empty?
end
def test_tag_list
assert_equal "delicious sexy", @obj2.tag_list
end
def test_taggable
assert_raises(RuntimeError) do
@tagging1.send(:taggable?, true)
end
assert !@tagging1.send(:taggable?)
assert @obj3.send(:taggable?)
<% if options[:self_referential] -%>
assert @tag1.send(:taggable?)
<% end -%>
end
end

View file

@ -0,0 +1,21 @@
---
taggings_003:
<%= parent_association_name -%>_id: "2"
id: "3"
taggable_type: <%= taggable_models[0][1..-1].classify %>
taggable_id: "1"
taggings_004:
<%= parent_association_name -%>_id: "2"
id: "4"
taggable_type: <%= taggable_models[1][1..-1].classify %>
taggable_id: "2"
taggings_001:
<%= parent_association_name -%>_id: "1"
id: "1"
taggable_type: <%= taggable_models[1][1..-1].classify %>
taggable_id: "1"
taggings_002:
<%= parent_association_name -%>_id: "1"
id: "2"
taggable_type: <%= taggable_models[1][1..-1].classify %>
taggable_id: "2"

View file

@ -0,0 +1,7 @@
---
tags_001:
name: delicious
id: "1"
tags_002:
name: sexy
id: "2"

View file

@ -1 +1,2 @@
require 'has_many_polymorphs'

View file

@ -0,0 +1 @@
puts open("#{File.dirname(__FILE__)}/TAGGING_INSTALL").read

View file

@ -1,581 +1,31 @@
# self-referential, polymorphic has_many :through plugin
# http://blog.evanweaver.com/articles/2006/06/02/has_many_polymorphs
# http://blog.evanweaver.com/pages/has_many_polymorphs
# operates via magic dust, and courage
if defined? Rails::Configuration
class Rails::Configuration
def has_many_polymorphs_cache_classes= *args
::ActiveRecord::Associations::ClassMethods.has_many_polymorphs_cache_classes = *args
end
end
require 'active_record'
require 'has_many_polymorphs/reflection'
require 'has_many_polymorphs/association'
require 'has_many_polymorphs/class_methods'
require 'has_many_polymorphs/support_methods'
require 'has_many_polymorphs/configuration'
require 'has_many_polymorphs/base'
class ActiveRecord::Base
extend ActiveRecord::Associations::PolymorphicClassMethods
end
module ActiveRecord
if ENV['RAILS_ENV'] =~ /development|test/ and ENV['USER'] == 'eweaver'
# enable this condition to get awesome association debugging
# you will get a folder "generated_models" in the current dir containing valid Ruby files
# explaining all ActiveRecord relationships set up by the plugin, as well as listing the
# line in the plugin that made each particular macro call
class << Base
COLLECTION_METHODS = [:belongs_to, :has_many, :has_and_belongs_to_many, :has_one].each do |method_name|
alias_method "original_#{method_name}".to_sym, method_name
undef_method method_name
end
unless defined? GENERATED_CODE_DIR
# automatic code generation for debugging... bitches
GENERATED_CODE_DIR = "generated_models"
system "rm -rf #{GENERATED_CODE_DIR}"
Dir.mkdir GENERATED_CODE_DIR
alias :original_method_missing :method_missing
def method_missing(method_name, *args, &block)
if COLLECTION_METHODS.include? method_name.to_sym
Dir.chdir GENERATED_CODE_DIR do
filename = "#{ActiveRecord::Associations::ClassMethods.demodulate(self.name.underscore)}.rb"
contents = File.open(filename).read rescue "\nclass #{self.name}\n\nend\n"
line = caller[1][/\:(\d+)\:/, 1]
contents[-5..-5] = "\n #{method_name} #{args[0..-2].inspect[1..-2]},\n #{args[-1].inspect[1..-2].gsub(" :", "\n :").gsub("=>", " => ")}\n#{ block ? " #{block.inspect.sub(/\@.*\//, '@')}\n" : ""} # called from line #{line}\n\n"
File.open(filename, "w") do |file|
file.puts contents
end
end
# doesn't handle blocks
self.send("original_#{method_name}", *args, &block)
else
self.send(:original_method_missing, method_name, *args, &block)
end
end
end
end
# and we want to track the reloader's shenanigans
(::Dependencies.log_activity = true) rescue nil
end
module Associations
module ClassMethods
mattr_accessor :has_many_polymorphs_cache_classes
def acts_as_double_polymorphic_join opts
raise RuntimeError, "Couldn't understand #{opts.inspect} options in acts_as_double_polymorphic_join. Please only specify the two relationships and their member classes; there are no options to set. " unless opts.length == 2
join_name = self.name.tableize.to_sym
opts.each do |polymorphs, children|
parent_hash_key = (opts.keys - [polymorphs]).first # parents are the entries in the _other_ children array
begin
parent_foreign_key = self.reflect_on_association(parent_hash_key.to_s.singularize.to_sym).primary_key_name
rescue NoMethodError
raise RuntimeError, "Couldn't find 'belongs_to' association for :#{parent_hash_key.to_s.singularize} in #{self.name}." unless parent_foreign_key
end
parents = opts[parent_hash_key]
conflicts = (children & parents) # set intersection
parents.each do |parent_name|
parent_class = parent_name.to_s.classify.constantize
reverse_polymorph = parent_hash_key.to_s.singularize
polymorph = polymorphs.to_s.singularize
parent_class.send(:has_many_polymorphs,
polymorphs, {:double => true,
:from => children,
:as => parent_hash_key.to_s.singularize.to_sym,
:through => join_name,
:dependent => :destroy,
:foreign_key => parent_foreign_key,
:foreign_type_key => parent_foreign_key.to_s.sub(/_id$/, '_type'),
:reverse_polymorph => reverse_polymorph,
:conflicts => conflicts,
:rename_individual_collections => false})
if conflicts.include? parent_name
# unify the alternate sides of the conflicting children
(conflicts).each do |method_name|
unless parent_class.instance_methods.include?(method_name)
parent_class.send(:define_method, method_name) do
(self.send("#{reverse_polymorph}_#{method_name}") +
self.send("#{polymorph}_#{method_name}")).freeze
end
end
end
# unify the join model
unless parent_class.instance_methods.include?(join_name)
parent_class.send(:define_method, join_name) do
(self.send("#{join_name}_as_#{reverse_polymorph}") +
self.send("#{join_name}_as_#{polymorph}")).freeze
end
end
end
end
end
end
def has_many_polymorphs(polymorphs, options, &block)
options.assert_valid_keys(:from, :acts_as, :as, :through, :foreign_key, :dependent, :double,
:rename_individual_collections, :foreign_type_key, :reverse_polymorph, :conflicts)
# the way this deals with extra parameters to the associations could use some work
options[:as] ||= options[:acts_as] ||= self.table_name.singularize.to_sym
# foreign keys follow the table name, not the class name in Rails 2.0
options[:foreign_key] ||= "#{options[:as].to_s}_id"
# no conflicts by default
options[:conflicts] ||= []
# construct the join table name
options[:through] ||= join_table((options[:as].to_s.pluralize or self.table_name), polymorphs)
if options[:reverse_polymorph]
options[:through_with_reverse_polymorph] = "#{options[:through]}_as_#{options[:reverse_polymorph]}".to_sym
else
options[:through_with_reverse_polymorph] = options[:through]
end
options[:join_class_name] ||= options[:through].to_s.classify
# the class must have_many on the join_table
opts = {:foreign_key => options[:foreign_key], :dependent => options[:dependent],
:class_name => options[:join_class_name]}
if options[:foreign_type_key]
opts[:conditions] = "#{options[:foreign_type_key]} = #{quote_value self.base_class.name}"
end
has_many demodulate(options[:through_with_reverse_polymorph]), opts
polymorph = polymorphs.to_s.singularize.to_sym
# add the base_class method to the join_table so that STI will work transparently
inject_before_save_into_join_table(options[:join_class_name], polymorph)
# get some reusable info
children, child_associations = {}, {}
options[:from].each do |child_plural|
children[child_plural] = child_plural.to_s.singularize.to_sym
child_associations[child_plural] = (options[:rename_individual_collections] ? "#{polymorph}_#{child_plural}".to_sym : child_plural)
end
# get our models out of the reloadable lists, if requested
if self.has_many_polymorphs_cache_classes
klasses = [self.name, options[:join_class_name], *children.values.map{|x| x.to_s.classify}]
klasses += basify_sti_classnames(klasses).keys.to_a.compact.uniq.map{|x| x.to_s.classify}
klasses.uniq!
klasses.each {|s| logger.debug "Ejecting #{s.inspect} from the autoload lists"}
begin
Dependencies.autoloaded_constants -= klasses
Dependencies.explicitly_unloadable_constants -= klasses
rescue NoMethodError
raise "Rails 1.2.0 or later is required to set config.has_many_polymorphs_cache_classes = true"
end
end
# auto-inject individually named associations for the children into the join model
create_virtual_associations_for_join_to_individual_children(children, polymorph, options)
# iterate through the polymorphic children, running the parent class's :has_many on each one
create_has_many_through_associations_for_parent_to_children(children, child_associations, polymorphs, polymorph, options)
# auto-inject the regular polymorphic associations into the child classes
create_has_many_through_associations_for_children_to_parent(children, polymorph, options)
create_general_collection_association_for_parent(polymorphs, polymorph, basify_sti_classnames(children), options, &block)
end
def self.demodulate(s)
s.to_s.gsub('/', '_').to_sym
end
protected
def demodulate(s)
ActiveRecord::Associations::ClassMethods.demodulate(s)
end
def basify_sti_classnames(hash)
# this blows
result = {}
hash.each do |plural, singular|
klass = plural.to_s.classify.constantize
if klass != klass.base_class
result[klass.base_class.table_name.to_sym] = klass.base_class.table_name.singularize.to_sym
else
result[plural] = singular
end
end
result
end
def inject_before_save_into_join_table(join_class_name, polymorph)
sti_hook = "sti_class_rewrite"
rewrite_procedure = %[
self.send(:#{polymorph}_type=, self.#{polymorph}_type.constantize.base_class.name)
]
# this also blows, and should be abstracted. alias_method_chain is not enough.
join_class_name.constantize.class_eval %[
unless instance_methods.include? "before_save_with_#{sti_hook}"
if instance_methods.include? "before_save"
alias_method :before_save_without_#{sti_hook}, :before_save
def before_save_with_#{sti_hook}
before_save_without_#{sti_hook}
#{rewrite_procedure}
end
else
def before_save_with_#{sti_hook}
#{rewrite_procedure}
end
end
alias_method :before_save, :before_save_with_#{sti_hook}
end
]
end
def create_virtual_associations_for_join_to_individual_children(children, polymorph, options)
children.each do |child_plural, child|
options[:join_class_name].constantize.instance_eval do
association_name = child.to_s
association_name += "_as_#{polymorph}" if options[:conflicts].include?(child_plural)
association = demodulate(association_name)
opts = {:class_name => child.to_s.classify,
:foreign_key => "#{polymorph}_id" }
unless self.reflect_on_all_associations.map(&:name).include? association
belongs_to association, opts
end
end
end
end
def create_has_many_through_associations_for_children_to_parent(children, polymorph, options)
children.each do |child_plural, child|
if child == options[:as]
raise RuntimeError, "You can't have a self-referential polymorphic has_many :through without renaming the non-polymorphic foreign key in the join model."
end
parent = self
child.to_s.classify.constantize.instance_eval do
# this shouldn't be called at all during doubles; there is no way to traverse to a
# double polymorphic parent (XXX is that right?)
unless options[:double] or options[:conflicts].include? self.name.tableize.to_sym
begin
require_dependency parent.name.underscore # XXX why is this here?
rescue MissingSourceFile
end
# the join table
through = demodulate(options[:through_with_reverse_polymorph]).to_s
through += "_as_child" if parent == self
through = through.to_sym
has_many through, :as => polymorph,
:class_name => options[:through].to_s.classify,
:dependent => options[:dependent]
association = options[:as].to_s.pluralize
association += "_of_#{polymorph.to_s.pluralize}" if options[:rename_individual_collections] # XXX check this
# the polymorphic parent association
has_many association.to_sym, :through => through,
:class_name => parent.name,
:source => options[:as],
:foreign_key => options[:foreign_key]
end
end
end
end
def create_has_many_through_associations_for_parent_to_children(children, child_associations, polymorphs, polymorph, options)
children.each do |child_plural, child|
#puts ":source => #{child}"
association = demodulate(child_associations[child_plural]).to_s
source = demodulate(child).to_s
if options[:conflicts].include? child_plural
# XXX what?
association = "#{polymorph}_#{association}" if options[:conflicts].include? self.name.tableize.to_sym
source += "_as_#{polymorph}"
end
# activerecord is broken when you try to anonymously extend an association in a namespaced model,
extension = self.class_eval %[
module #{association.classify + "AssociationExtension"}
def push *args
proxy_owner.send(:#{polymorphs}).send(:push, *args).select{|x| x.is_a? #{child.to_s.classify}}
end
alias :<< :push
def delete *args
proxy_owner.send(:#{polymorphs}).send(:delete, *args)
end
def clear
proxy_owner.send(:#{polymorphs}).send(:clear, #{child.to_s.classify})
end
self # required
end]
has_many association.to_sym, :through => demodulate(options[:through_with_reverse_polymorph]),
:source => source.to_sym,
:conditions => ["#{options[:join_class_name].constantize.table_name}.#{polymorph}_type = ?", child.to_s.classify.constantize.base_class.name],
:extend => extension
end
end
def create_general_collection_association_for_parent(collection_name, polymorph, children, options, &block)
# we need to explicitly rename all the columns because we are fetching all the children objects at once.
# if multiple objects have a 'title' column, for instance, there will be a collision and we will potentially
# lose data. if we alias the fields and then break them up later, there are no collisions.
join_model = options[:through].to_s.classify.constantize
# figure out what fields we wanna grab
select_fields = []
children.each do |plural, singular|
klass = plural.to_s.classify.constantize
klass.columns.map(&:name).each do |name|
select_fields << "#{klass.table_name}.#{name} as #{demodulate plural}_#{name}"
end
end
# now get the join model fields
join_model.columns.map(&:name).each do |name|
select_fields << "#{join_model.table_name}.#{name} as #{join_model.table_name}_#{name}"
end
from_table = self.table_name
left_joins = children.keys.map do |n|
klass = n.to_s.classify.constantize
"LEFT JOIN #{klass.table_name} ON #{join_model.table_name}.#{polymorph}_id = #{klass.table_name}.#{klass.primary_key} AND #{join_model.table_name}.#{polymorph}_type = '#{n.to_s.classify}'"
end
sql_query = 'SELECT ' + select_fields.join(', ') + " FROM #{join_model.table_name}" +
"\nJOIN #{from_table} as polymorphic_parent ON #{join_model.table_name}.#{options[:foreign_key]} = polymorphic_parent.#{self.primary_key}\n" +
left_joins.join("\n") + "\nWHERE "
if options[:foreign_type_key]
sql_query +="#{join_model.table_name}.#{options[:foreign_type_key]} = #{quote_value self.base_class.name} AND "
end
# for sqlite3 you have to reference the left-most table in WHERE clauses or rows with NULL
# join results sometimes get silently dropped. it's stupid.
sql_query += "#{join_model.table_name}.#{options[:foreign_key]} "
#puts("Built collection property query:\n #{sql_query}")
class_eval do
attr_accessor "#{collection_name}_cache"
cattr_accessor "#{collection_name}_options"
define_method(collection_name) do
if collection_name_cache = instance_variable_get("@#{collection_name}_cache")
#puts("Cache hit on #{collection_name}")
collection_name_cache
else
#puts("Cache miss on #{collection_name}")
rows = connection.select_all("#{sql_query}" + (new_record? ? "IS NULL" : "= #{self.id}"))
# this gives us a hash with keys for each object type
objectified = objectify_polymorphic_array(rows, "#{join_model}", "#{polymorph}_type")
# locally cache the different object types found
# this doesn't work... yet.
objectified.each do |key, array|
instance_variable_set("@#{ActiveRecord::Associations::ClassMethods.demodulate(key)}", array)
end
proxy_object = HasManyPolymorphsProxyCollection.new(objectified[:all], self, send("#{collection_name}_options"))
(class << proxy_object; self end).send(:class_eval, &block) if block_given?
instance_variable_set("@#{collection_name}_cache", proxy_object)
end
end
# in order not to break tests, see if we have been defined already
unless instance_methods.include? "reload_with_#{collection_name}"
define_method("reload_with_#{collection_name}") do
send("reload_without_#{collection_name}")
instance_variable_set("@#{collection_name}_cache", nil)
self
end
alias_method "reload_without_#{collection_name}", :reload
alias_method :reload, "reload_with_#{collection_name}"
end
end
send("#{collection_name}_options=",
options.merge(:collection_name => collection_name,
:type_key => "#{polymorph}_type",
:id_key => "#{polymorph}_id"))
# puts("Defined the collection proxy.\n#{collection_name}\n")
end
def join_table(a, b)
[a.to_s, b.to_s].sort.join("_").to_sym
end
unless self.respond_to? :quote_value
# hack it in (very badly) for Rails 1.1.6 people
def quote_value s
"'#{s.inspect[1..-2]}'"
end
end
end
################################################
# decided to leave this alone unless it becomes clear that there is some benefit
# in deriving from AssociationProxy
#
# the benefit would be custom finders on the collection, perhaps...
class HasManyPolymorphsProxyCollection < Array
alias :array_delete :delete
alias :array_push :push
alias :count :length
def initialize(contents, parent, options)
@parent = parent
@options = options
@join_class = options[:join_class_name].constantize
return if contents.blank?
super(contents)
end
def push(objs, args={})
objs = [objs] unless objs.is_a? Array
objs.each do |obj|
data = {@options[:foreign_key] => @parent.id,
@options[:type_key] => obj.class.base_class.to_s, @options[:id_key] => obj.id}
data.merge!({@options[:foreign_type_key] => @parent.class.base_class.to_s}) if @options[:foreign_type_key] # for double polymorphs
conditions_string = data.keys.map(&:to_s).push("").join(" = ? AND ")[0..-6]
if @join_class.find(:first, :conditions => [conditions_string] + data.values).blank?
@join_class.new(data).save!
end
end
if args[:reload]
reload
else
# we have to do this funky stuff instead of just array difference because +/.uniq returns a regular array,
# which doesn't have our special methods and configuration anymore
unless (difference = objs - collection).blank?
@parent.send("#{@options[:collection_name]}_cache=".to_sym, collection.array_push(*difference))
end
end
@parent.send(@options[:collection_name])
end
alias :<< :push
def delete(objs, args={})
if objs
objs = [objs] unless objs.is_a? Array
elsif args[:clear]
objs = collection
objs = objs.select{|obj| obj.is_a? args[:klass]} if args[:klass]
else
raise RuntimeError, "Invalid delete parameters (has_many_polymorphs)."
end
records = []
objs.each do |obj|
records += join_records.select do |record|
record.send(@options[:type_key]) == obj.class.base_class.to_s and
record.send(@options[:id_key]) == obj.id
end
end
reload if args[:reload]
unless records.blank?
records.map(&:destroy)
# XXX could be faster if we reversed the loops
deleted_items = collection.select do |item|
records.select {|join_record|
join_record.send(@options[:type_key]) == item.class.base_class.name and
join_record.send(@options[:id_key]) == item.id
}.length > 0
end
# keep the cache fresh, while we're at it. see comment in .push
deleted_items.each { |item| collection.array_delete(item) }
@parent.send("#{@options[:collection_name]}_cache=", collection)
return deleted_items unless deleted_items.empty?
end
nil
end
def clear(klass = nil)
result = delete(nil, :clear => true, :klass => klass)
return result if result
collection
end
def reload
# reset the cache, postponing reloading from the db until we really need it
@parent.reload
end
private
def join_records
@parent.send(ActiveRecord::Associations::ClassMethods.demodulate(@options[:through]))
end
def collection
@parent.send(@options[:collection_name])
end
end
end
class Base
# turns an array of hashes (db rows) into a hash consisting of :all (array of everything) and
# a hash key for each class type it finds, e.g. :posts and :comments
private
def objectify_polymorphic_array(array, join_model, type_field)
join_model = join_model.constantize
arrays_hash = {}
array.each do |element|
klass = element["#{join_model.table_name}_#{type_field}"].constantize
association = ActiveRecord::Associations::ClassMethods.demodulate(klass.name.pluralize.underscore.downcase)
hash = {}
# puts "Class #{klass.inspect}"
# puts "Association name: #{association.inspect}"
element.each do |key, value|
# puts "key #{key} - value #{value.inspect}"
if key =~ /^#{association}_(.+)/
hash[$1] = value
# puts "#{$1.inspect} assigned #{value.inspect}"
end
end
object = klass.instantiate(hash)
arrays_hash[:all] ||= []
arrays_hash[association] ||= []
arrays_hash[:all] << object
arrays_hash[association] << object
end
arrays_hash
end
end
if ENV['RAILS_ENV'] =~ /development|test/ and ENV['USER'] == 'eweaver'
_logger_warn "has_many_polymorphs: debug mode enabled"
require 'has_many_polymorphs/debugging_tools'
end
#require 'ruby-debug'
#Debugger.start
if defined? Rails and RAILS_ENV and RAILS_ROOT
_logger_warn "has_many_polymorphs: Rails environment detected"
require 'has_many_polymorphs/dependencies'
require 'has_many_polymorphs/autoload'
end
_logger_debug "has_many_polymorphs: loaded ok"

View file

@ -0,0 +1,153 @@
module ActiveRecord
module Associations
class PolymorphicError < ActiveRecordError; end
class PolymorphicMethodNotSupportedError < ActiveRecordError; end
class PolymorphicAssociation < HasManyThroughAssociation
def <<(*records)
return if records.empty?
if @reflection.options[:skip_duplicates]
_logger_debug "Loading instances for polymorphic duplicate push check; use :skip_duplicates => false and perhaps a database constraint to avoid this possible performance issue"
load_target
end
@reflection.klass.transaction do
flatten_deeper(records).each do |record|
if @owner.new_record? or not record.respond_to?(:new_record?) or record.new_record?
raise PolymorphicError, "You can't associate unsaved records."
end
next if @reflection.options[:skip_duplicates] and @target.include? record
@owner.send(@reflection.through_reflection.name).proxy_target << @reflection.klass.create!(construct_join_attributes(record))
@target << record if loaded?
end
end
self
end
alias :push :<<
alias :concat :<<
def find(*args)
# super(*(args << returning(Base.send(:extract_options_from_args!, args)) {|opts| opts.delete :include})) # returning is slow
opts = Base.send(:extract_options_from_args!, args)
opts.delete :include
super(*(args + [opts]))
end
def construct_scope
_logger_warn "Warning; not all usage scenarios for polymorphic scopes are supported yet."
super
end
def delete(*records)
records = flatten_deeper(records)
records.reject! {|record| @target.delete(record) if record.new_record?}
return if records.empty?
@reflection.klass.transaction do
records.each do |record|
joins = @reflection.through_reflection.name
@owner.send(joins).delete(@owner.send(joins).select do |join|
join.send(@reflection.options[:polymorphic_key]) == record.id and
join.send(@reflection.options[:polymorphic_type_key]) == "#{record.class.base_class}"
end)
@target.delete(record)
end
end
# records
end
def clear(klass = nil)
load_target
return if @target.empty?
if klass
delete(@target.select {|r| r.is_a? klass })
else
@owner.send(@reflection.through_reflection.name).clear
@target.clear
end
[]
end
# undef :sum
# undef :create!
protected
def construct_quoted_owner_attributes(*args)
# no access to returning() here? why not?
type_key = @reflection.options[:foreign_type_key]
{@reflection.primary_key_name => @owner.id,
type_key=> (@owner.class.base_class.name if type_key)}
end
def construct_from
# build the FROM part of the query, in this case, the polymorphic join table
@reflection.klass.table_name
end
def construct_owner
# the table name for the owner object's class
@owner.class.table_name
end
def construct_owner_key
# the primary key field for the owner object
@owner.class.primary_key
end
def construct_select(custom_select = nil)
# build the select query
selected = custom_select || @reflection.options[:select]
end
def construct_joins(custom_joins = nil)
# build the string of default joins
"JOIN #{construct_owner} AS polymorphic_parent ON #{construct_from}.#{@reflection.options[:foreign_key]} = polymorphic_parent.#{construct_owner_key} " +
@reflection.options[:from].map do |plural|
klass = plural._as_class
"LEFT JOIN #{klass.table_name} ON #{construct_from}.#{@reflection.options[:polymorphic_key]} = #{klass.table_name}.#{klass.primary_key} AND #{construct_from}.#{@reflection.options[:polymorphic_type_key]} = #{@reflection.klass.quote_value(klass.base_class.name)}"
end.uniq.join(" ") + " #{custom_joins}"
end
def construct_conditions
# build the fully realized condition string
conditions = construct_quoted_owner_attributes.map do |field, value|
"#{construct_from}.#{field} = #{@reflection.klass.quote_value(value)}" if value
end
conditions << custom_conditions if custom_conditions
"(" + conditions.compact.join(') AND (') + ")"
end
def custom_conditions
# custom conditions... not as messy as has_many :through because our joins are a little smarter
if @reflection.options[:conditions]
"(" + interpolate_sql(@reflection.klass.send(:sanitize_sql, @reflection.options[:conditions])) + ")"
end
end
alias :construct_owner_attributes :construct_quoted_owner_attributes
alias :conditions :custom_conditions # XXX possibly not necessary
alias :sql_conditions :custom_conditions # XXX ditto
# construct attributes for join for a particular record
def construct_join_attributes(record)
{@reflection.options[:polymorphic_key] => record.id,
@reflection.options[:polymorphic_type_key] => "#{record.class.base_class}",
@reflection.options[:foreign_key] => @owner.id}.merge(@reflection.options[:foreign_type_key] ?
{@reflection.options[:foreign_type_key] => "#{@owner.class.base_class}"} : {}) # for double-sided relationships
end
def build(attrs = nil)
raise PolymorphicMethodNotSupportedError, "You can't associate new records."
end
end
end
end

View file

@ -0,0 +1,26 @@
require 'initializer'
class Rails::Initializer
def after_initialize_with_autoload
after_initialize_without_autoload
_logger_debug "has_many_polymorphs: autoload hook invoked"
Dir["#{RAILS_ROOT}/app/models/**/*.rb"].each do |filename|
next if filename =~ /svn|CVS|bzr/
open filename do |file|
if file.grep(/has_many_polymorphs|acts_as_double_polymorphic_join/).any?
begin
model = File.basename(filename)[0..-4].classify
model.constantize
_logger_warn "has_many_polymorphs: preloaded parent model #{model}"
rescue Object => e
_logger_warn "error preloading #{model}: #{e.inspect}"
end
end
end
end
end
alias_method_chain :after_initialize, :autoload
end

View file

@ -0,0 +1,47 @@
module ActiveRecord
class Base
class << self
def instantiate_without_callbacks_with_polymorphic_checks(record)
if record['polymorphic_parent_class']
reflection = record['polymorphic_parent_class'].constantize.reflect_on_association(record['polymorphic_association_id'].to_sym)
# _logger_debug "Instantiating a polymorphic row for #{record['polymorphic_parent_class']}.reflect_on_association(:#{record['polymorphic_association_id']})"
# rewrite 'record' with the right column names
table_aliases = reflection.options[:table_aliases].dup
record = Hash[*table_aliases.keys.map {|key| [key, record[table_aliases[key]]] }.flatten]
# find the real child class
klass = record["#{self.table_name}.#{reflection.options[:polymorphic_type_key]}"].constantize
if sti_klass = record["#{klass.table_name}.#{klass.inheritance_column}"]
klass = klass.class_eval do compute_type(sti_klass) end # in case of namespaced STI models
end
# check that the join actually joined to something
unless (child_id = record["#{self.table_name}.#{reflection.options[:polymorphic_key]}"]) == record["#{klass.table_name}.#{klass.primary_key}"]
raise ActiveRecord::Associations::PolymorphicError,
"Referential integrity violation; child <#{klass.name}:#{child_id}> was not found for #{reflection.name.inspect}"
end
# eject the join keys
record = Hash[*record._select do |column, value|
column[/^#{klass.table_name}/]
end.map do |column, value|
[column[/\.(.*)/, 1], value]
end.flatten]
# allocate and assign values
returning(klass.allocate) do |obj|
obj.instance_variable_set("@attributes", record)
end
else
instantiate_without_callbacks_without_polymorphic_checks(record)
end
end
alias_method_chain :instantiate_without_callbacks, :polymorphic_checks # oh yeah
end
end
end

View file

@ -0,0 +1,452 @@
module ActiveRecord::Associations
module PolymorphicClassMethods
#################
# AR::Base association macros
RESERVED_KEYS = [:conditions, :order, :limit, :offset, :extend, :skip_duplicates,
:join_extend, :dependent, :rename_individual_collections]
def acts_as_double_polymorphic_join options={}, &extension
collections = options._select {|k,v| v.is_a? Array and k.to_s !~ /(#{RESERVED_KEYS.map(&:to_s).join('|')})$/}
raise PolymorphicError, "Couldn't understand options in acts_as_double_polymorphic_join. Valid parameters are your two class collections, and then #{RESERVED_KEYS.inspect[1..-2]}, with optionally your collection names prepended and joined with an underscore." unless collections.size == 2
options = options._select {|k,v| !collections[k]}
options[:extend] = (options[:extend] ? Array(options[:extend]) + [extension] : extension) if extension # inline the block
collection_option_keys = Hash[*collections.keys.map do |key|
[key, RESERVED_KEYS.map{|option| "#{key}_#{option}".to_sym}]
end._flatten_once]
collections.keys.each do |collection|
options.each do |key, value|
next if collection_option_keys.values.flatten.include? key
# shift the general options to the individual sides
collection_value = options[collection_key = "#{collection}_#{key}".to_sym]
case key
when :conditions
collection_value, value = sanitize_sql(collection_value), sanitize_sql(value)
options[collection_key] = (collection_value ? "(#{collection_value}) AND (#{value})" : value)
when :order
options[collection_key] = (collection_value ? "#{collection_value}, #{value}" : value)
when :extend, :join_extend
options[collection_key] = Array(collection_value) + Array(value)
when :limit, :offset, :dependent, :rename_individual_collections
options[collection_key] ||= value
else
raise PolymorphicError, "Unknown option key #{key.inspect}."
end
end
end
join_name = self.name.tableize.to_sym
collections.each do |association_id, children|
parent_hash_key = (collections.keys - [association_id]).first # parents are the entries in the _other_ children array
begin
parent_foreign_key = self.reflect_on_association(parent_hash_key._singularize).primary_key_name
rescue NoMethodError
raise PolymorphicError, "Couldn't find 'belongs_to' association for :#{parent_hash_key._singularize} in #{self.name}." unless parent_foreign_key
end
parents = collections[parent_hash_key]
conflicts = (children & parents) # set intersection
parents.each do |plural_parent_name|
parent_class = plural_parent_name._as_class
singular_reverse_association_id = parent_hash_key._singularize
parent_class.send(:has_many_polymorphs,
association_id, {:is_double => true,
:from => children,
:as => singular_reverse_association_id,
:through => join_name.to_sym,
:foreign_key => parent_foreign_key,
:foreign_type_key => parent_foreign_key.to_s.sub(/_id$/, '_type'),
:singular_reverse_association_id => singular_reverse_association_id,
:conflicts => conflicts}.merge(Hash[*options._select do |key, value|
collection_option_keys[association_id].include? key and !value.nil?
end.map do |key, value|
[key.to_s[association_id.to_s.length+1..-1].to_sym, value]
end._flatten_once])) # rename side-specific options to general names
if conflicts.include? plural_parent_name
# unify the alternate sides of the conflicting children
(conflicts).each do |method_name|
unless parent_class.instance_methods.include?(method_name)
parent_class.send(:define_method, method_name) do
(self.send("#{singular_reverse_association_id}_#{method_name}") +
self.send("#{association_id._singularize}_#{method_name}")).freeze
end
end
end
# unify the join model... join model is always renamed for doubles, unlike child associations
unless parent_class.instance_methods.include?(join_name)
parent_class.send(:define_method, join_name) do
(self.send("#{join_name}_as_#{singular_reverse_association_id}") +
self.send("#{join_name}_as_#{association_id._singularize}")).freeze
end
end
else
unless parent_class.instance_methods.include?(join_name)
parent_class.send(:alias_method, join_name, "#{join_name}_as_#{singular_reverse_association_id}")
end
end
end
end
end
def has_many_polymorphs (association_id, options = {}, &extension)
_logger_debug "has_many_polymorphs: INIT"
reflection = create_has_many_polymorphs_reflection(association_id, options, &extension)
# puts "Created reflection #{reflection.inspect}"
# configure_dependency_for_has_many(reflection)
collection_reader_method(reflection, PolymorphicAssociation)
end
def create_has_many_polymorphs_reflection(association_id, options, &extension)
options.assert_valid_keys(
:from,
:as,
:through,
:foreign_key,
:foreign_type_key,
:polymorphic_key, # same as :association_foreign_key
:polymorphic_type_key,
:dependent, # default :destroy, only affects the join table
:skip_duplicates, # default true, only affects the polymorphic collection
:ignore_duplicates, # deprecated
:is_double,
:rename_individual_collections,
:reverse_association_id, # not used
:singular_reverse_association_id,
:conflicts,
:extend,
:join_class_name,
:join_extend,
:parent_extend,
:table_aliases,
:select, # applies to the polymorphic relationship
:conditions, # applies to the polymorphic relationship, the children, and the join
# :include,
:order, # applies to the polymorphic relationship, the children, and the join
:group, # only applies to the polymorphic relationship and the children
:limit, # only applies to the polymorphic relationship and the children
:offset, # only applies to the polymorphic relationship
:parent_order,
:parent_group,
:parent_limit,
:parent_offset,
# :source,
:uniq, # XXX untested, only applies to the polymorphic relationship
# :finder_sql,
# :counter_sql,
# :before_add,
# :after_add,
# :before_remove,
# :after_remove
:dummy)
# validate against the most frequent configuration mistakes
verify_pluralization_of(association_id)
raise PolymorphicError, ":from option must be an array" unless options[:from].is_a? Array
options[:from].each{|plural| verify_pluralization_of(plural)}
options[:as] ||= self.table_name.singularize.to_sym
options[:conflicts] = Array(options[:conflicts])
options[:foreign_key] ||= "#{options[:as]}_id"
options[:association_foreign_key] =
options[:polymorphic_key] ||= "#{association_id._singularize}_id"
options[:polymorphic_type_key] ||= "#{association_id._singularize}_type"
if options.has_key? :ignore_duplicates
_logger_warn "DEPRECATION WARNING: please use :skip_duplicates instead of :ignore_duplicates"
options[:skip_duplicates] = options[:ignore_duplicates]
end
options[:skip_duplicates] = true unless options.has_key? :skip_duplicates
options[:dependent] = :destroy unless options.has_key? :dependent
options[:conditions] = sanitize_sql(options[:conditions])
# options[:finder_sql] ||= "(options[:polymorphic_key]
options[:through] ||= build_join_table_symbol((options[:as]._pluralize or self.table_name), association_id)
options[:join_class_name] ||= options[:through]._classify
options[:table_aliases] ||= build_table_aliases([options[:through]] + options[:from])
options[:select] ||= build_select(association_id, options[:table_aliases])
options[:through] = "#{options[:through]}_as_#{options[:singular_reverse_association_id]}" if options[:singular_reverse_association_id]
options[:through] = demodulate(options[:through]).to_sym
options[:extend] = spiked_create_extension_module(association_id, Array(options[:extend]) + Array(extension))
options[:join_extend] = spiked_create_extension_module(association_id, Array(options[:join_extend]), "Join")
options[:parent_extend] = spiked_create_extension_module(association_id, Array(options[:parent_extend]), "Parent")
# create the reflection object
returning(create_reflection(:has_many_polymorphs, association_id, options, self)) do |reflection|
if defined? Dependencies and RAILS_ENV == "development"
_logger_warn "DEPRECATION WARNING: \"has_many_polymorphs_cache_classes =\" no longer has any effect. Please use \"config.cache_classes = true\" in the regular environment config (not in the \"after_initialize\" block)." if ActiveRecord::Associations::ClassMethods.has_many_polymorphs_cache_classes
inject_dependencies(association_id, reflection) if Dependencies.mechanism == :load
end
# set up the other related associations
create_join_association(association_id, reflection)
create_has_many_through_associations_for_parent_to_children(association_id, reflection)
create_has_many_through_associations_for_children_to_parent(association_id, reflection)
end
end
private
##############
# table mapping for use at instantiation point
def build_table_aliases(from)
# for the targets
returning({}) do |aliases|
from.map(&:to_s).sort.map(&:to_sym).each_with_index do |plural, t_index|
table = plural._as_class.table_name
plural._as_class.columns.map(&:name).each_with_index do |field, f_index|
aliases["#{table}.#{field}"] = "t#{t_index}_r#{f_index}"
end
end
end
end
def build_select(association_id, aliases)
# cause instantiate has to know which reflection the results are coming from
(["\'#{self.name}\' AS polymorphic_parent_class",
"\'#{association_id}\' AS polymorphic_association_id"] +
aliases.map do |table, _alias|
"#{table} AS #{_alias}"
end.sort).join(", ")
end
##############
# model caching
def inject_dependencies(association_id, reflection)
_logger_debug "has_many_polymorphs: injecting dependencies"
requirements = [self, reflection.klass].map{|klass| [klass, klass.base_class]}.flatten.uniq
# below, a contributed fix for a bug in doubles that I can't reproduce
# parents = all_classes_for(association_id, reflection)
# if (parents - requirements).empty?
# requirements = (requirements - [parents[0]])
# parents = [parents[0]]
# else
# parents = (parents - requirements)
# end
#
# parents.each do |target_klass|
# Dependencies.inject_dependency(target_klass, *requirements)
# end
(all_classes_for(association_id, reflection) - requirements).each do |target_klass|
Dependencies.inject_dependency(target_klass, *requirements)
end
end
#################
# macro sub-builders
def create_join_association(association_id, reflection)
options = {:foreign_key => reflection.options[:foreign_key],
:dependent => reflection.options[:dependent],
:class_name => reflection.klass.name,
:extend => reflection.options[:join_extend],
# :limit => reflection.options[:limit],
# :offset => reflection.options[:offset],
:order => devolve(association_id, reflection, reflection.options[:order], reflection.klass),
:conditions => devolve(association_id, reflection, reflection.options[:conditions], reflection.klass)
}
if reflection.options[:foreign_type_key]
type_check = "#{reflection.options[:foreign_type_key]} = #{quote_value(self.base_class.name)}"
conjunction = options[:conditions] ? " AND " : nil
options[:conditions] = "#{options[:conditions]}#{conjunction}#{type_check}"
end
has_many(reflection.options[:through], options)
inject_before_save_into_join_table(association_id, reflection)
end
def inject_before_save_into_join_table(association_id, reflection)
sti_hook = "sti_class_rewrite"
rewrite_procedure = %[self.send(:#{association_id._singularize}_type=, self.#{association_id._singularize}_type.constantize.base_class.name)]
# XXX should be abstracted?
reflection.klass.class_eval %[
unless instance_methods.include? "before_save_with_#{sti_hook}"
if instance_methods.include? "before_save"
alias_method :before_save_without_#{sti_hook}, :before_save
def before_save_with_#{sti_hook}
before_save_without_#{sti_hook}
#{rewrite_procedure}
end
else
def before_save_with_#{sti_hook}
#{rewrite_procedure}
end
end
alias_method :before_save, :before_save_with_#{sti_hook}
end
]
end
def create_has_many_through_associations_for_children_to_parent(association_id, reflection)
child_pluralization_map(association_id, reflection).each do |plural, singular|
if singular == reflection.options[:as]
raise PolymorphicError, if reflection.options[:is_double]
"You can't give either of the sides in a double-polymorphic join the same name as any of the individual target classes."
else
"You can't have a self-referential polymorphic has_many :through without renaming the non-polymorphic foreign key in the join model."
end
end
parent = self
plural._as_class.instance_eval do
# this shouldn't be called at all during doubles; there is no way to traverse to a double polymorphic parent (XXX is that right?)
unless reflection.options[:is_double] or reflection.options[:conflicts].include? self.name.tableize.to_sym
# the join table
through = "#{reflection.options[:through]}#{'_as_child' if parent == self}".to_sym
has_many(through,
:as => association_id._singularize,
:class_name => reflection.klass.name,
:dependent => reflection.options[:dependent],
:extend => reflection.options[:join_extend],
# :limit => reflection.options[:limit],
# :offset => reflection.options[:offset],
:order => devolve(association_id, reflection, reflection.options[:order], reflection.klass),
:conditions => devolve(association_id, reflection, reflection.options[:conditions], reflection.klass)
)
# the association to the collection parents
association = "#{reflection.options[:as]._pluralize}#{"_of_#{association_id}" if reflection.options[:rename_individual_collections]}".to_sym
has_many(association,
:through => through,
:class_name => parent.name,
:source => reflection.options[:as],
:foreign_key => reflection.options[:foreign_key] ,
:extend => reflection.options[:parent_extend],
:order => reflection.options[:parent_order],
:offset => reflection.options[:parent_offset],
:limit => reflection.options[:parent_limit],
:group => reflection.options[:parent_group])
end
end
end
end
def create_has_many_through_associations_for_parent_to_children(association_id, reflection)
child_pluralization_map(association_id, reflection).each do |plural, singular|
#puts ":source => #{child}"
current_association = demodulate(child_association_map(association_id, reflection)[plural])
source = demodulate(singular)
if reflection.options[:conflicts].include? plural
# XXX check this
current_association = "#{association_id._singularize}_#{current_association}" if reflection.options[:conflicts].include? self.name.tableize.to_sym
source = "#{source}_as_#{association_id._singularize}".to_sym
end
# make push/delete accessible from the individual collections but still operate via the general collection
extension_module = self.class_eval %[
module #{self.name + current_association._classify + "PolymorphicChildAssociationExtension"}
def push *args; proxy_owner.send(:#{association_id}).send(:push, *args).select{|x| x.is_a? #{singular._classify}}; end
alias :<< :push
def delete *args; proxy_owner.send(:#{association_id}).send(:delete, *args); end
def clear; proxy_owner.send(:#{association_id}).send(:clear, #{singular._classify}); end
self
end]
has_many(current_association.to_sym,
:through => reflection.options[:through],
:source => association_id._singularize,
:source_type => plural._as_class.base_class.name,
:extend => (Array(extension_module) + reflection.options[:extend]),
:limit => reflection.options[:limit],
# :offset => reflection.options[:offset],
:order => devolve(association_id, reflection, reflection.options[:order], plural._as_class),
:conditions => devolve(association_id, reflection, reflection.options[:conditions], plural._as_class),
:group => devolve(association_id, reflection, reflection.options[:group], plural._as_class)
)
end
end
##############
# some support methods
def child_pluralization_map(association_id, reflection)
Hash[*reflection.options[:from].map do |plural|
[plural, plural._singularize]
end.flatten]
end
def child_association_map(association_id, reflection)
Hash[*reflection.options[:from].map do |plural|
[plural, "#{association_id._singularize.to_s + "_" if reflection.options[:rename_individual_collections]}#{plural}".to_sym]
end.flatten]
end
def demodulate(s)
s.to_s.gsub('/', '_').to_sym
end
def build_join_table_symbol(a, b)
[a.to_s, b.to_s].sort.join("_").to_sym
end
def all_classes_for(association_id, reflection)
klasses = [self, reflection.klass, *child_pluralization_map(association_id, reflection).keys.map(&:_as_class)]
klasses += klasses.map(&:base_class)
klasses.uniq
end
def devolve(association_id, reflection, string, klass)
return unless string
(all_classes_for(association_id, reflection) - # the join class must always be preserved
[klass, klass.base_class, reflection.klass, reflection.klass.base_class]).map do |klass|
klass.columns.map do |column|
[klass.table_name, column.name]
end.map do |table, column|
["#{table}.#{column}", "`#{table}`.#{column}", "#{table}.`#{column}`", "`#{table}`.`#{column}`"]
end
end.flatten.sort_by(&:size).reverse.each do |quoted_reference|
string.gsub!(quoted_reference, "NULL")
end
string
end
def verify_pluralization_of(sym)
sym = sym.to_s
singular = sym.singularize
plural = singular.pluralize
raise PolymorphicError, "Pluralization rules not set up correctly. You passed :#{sym}, which singularizes to :#{singular}, but that pluralizes to :#{plural}, which is different. Maybe you meant :#{plural} to begin with?" unless sym == plural
end
def spiked_create_extension_module(association_id, extensions, identifier = nil)
module_extensions = extensions.select{|e| e.is_a? Module}
proc_extensions = extensions.select{|e| e.is_a? Proc }
# support namespaced anonymous blocks as well as multiple procs
proc_extensions.each_with_index do |proc_extension, index|
module_name = "#{self.to_s}#{association_id._classify}Polymorphic#{identifier}AssociationExtension#{index}"
the_module = self.class_eval "module #{module_name}; self; end" # haha
the_module.class_eval &proc_extension
module_extensions << the_module
end
module_extensions
end
end
end

View file

@ -0,0 +1,18 @@
### deprecated
if defined? Rails::Configuration
class Rails::Configuration
def has_many_polymorphs_cache_classes= *args
::ActiveRecord::Associations::ClassMethods.has_many_polymorphs_cache_classes = *args
end
end
end
module ActiveRecord
module Associations
module ClassMethods
mattr_accessor :has_many_polymorphs_cache_classes
end
end
end

View file

@ -0,0 +1,72 @@
class << ActiveRecord::Base
COLLECTION_METHODS = [:belongs_to, :has_many, :has_and_belongs_to_many, :has_one,
:has_many_polymorphs, :acts_as_double_polymorphic_join].each do |method_name|
alias_method "original_#{method_name}".to_sym, method_name
undef_method method_name
end
unless defined? GENERATED_CODE_DIR
# automatic code generation for debugging
# you will get a folder "generated_models" in RAILS_ROOT containing valid Ruby files
# explaining all ActiveRecord relationships set up by the plugin, as well as listing the
# line in the plugin that made each particular macro call
GENERATED_CODE_DIR = "#{RAILS_ROOT}/generated_models"
begin
system "rm -rf #{GENERATED_CODE_DIR}"
Dir.mkdir GENERATED_CODE_DIR
rescue Errno::EACCES
_logger_warn "no permissions for generated code dir: #{GENERATED_CODE_DIR}"
end
if File.exist? GENERATED_CODE_DIR
alias :original_method_missing :method_missing
def method_missing(method_name, *args, &block)
if COLLECTION_METHODS.include? method_name.to_sym
Dir.chdir GENERATED_CODE_DIR do
filename = "#{demodulate(self.name.underscore)}.rb"
contents = File.open(filename).read rescue "\nclass #{self.name}\n\nend\n"
line = caller[1][/\:(\d+)\:/, 1]
contents[-5..-5] = "\n #{method_name} #{args[0..-2].inspect[1..-2]},\n #{args[-1].inspect[1..-2].gsub(" :", "\n :").gsub("=>", " => ")}\n#{ block ? " #{block.inspect.sub(/\@.*\//, '@')}\n" : ""} # called from line #{line}\n\n"
File.open(filename, "w") do |file|
file.puts contents
end
end
# doesn't handle blocks cause we can't introspect on code like that in Ruby without hackery and dependencies
self.send("original_#{method_name}", *args, &block)
else
self.send(:original_method_missing, method_name, *args, &block)
end
end
end
end
end
# and have a debugger enabled
case ENV['DEBUG']
when "ruby-debug"
require 'rubygems'
require 'ruby-debug'
Debugger.start
puts "Notice; ruby-debug enabled."
when "trace"
puts "Notice; method tracing enabled"
$debug_trace_indent = 0
set_trace_func (proc do |event, file, line, id, binding, classname|
if id.to_s =~ /instantiate/ #/IRB|Wirble|RubyLex|RubyToken|Logger|ConnectionAdapters|SQLite3|MonitorMixin|Benchmark|Inflector|Inflections/
if event == 'call'
puts (" " * $debug_trace_indent) + "#{event}ed #{classname}\##{id} from #{file.split('/').last}::#{line}"
$debug_trace_indent += 1
elsif event == 'return'
$debug_trace_indent -= 1 unless $debug_trace_indent == 0
puts (" " * $debug_trace_indent) + "#{event}ed #{classname}\##{id}"
end
end
end)
when "dependencies"
puts "Notice; dependency activity being logged"
(::Dependencies.log_activity = true) rescue nil
end

View file

@ -0,0 +1,30 @@
module Dependencies
#### dependency injection
mattr_accessor :injection_graph
self.injection_graph = Hash.new([])
def inject_dependency(target, *requirements)
target, requirements = target.to_s, requirements.map(&:to_s)
injection_graph[target] = ((injection_graph[target] + requirements).uniq - [target])
requirements.each {|requirement| mark_for_unload requirement }
# _logger_debug "has_many_polymorphs: injection graph: #{injection_graph.inspect}"
end
def new_constants_in_with_injection(*descs, &block) # chain
# _logger_debug "has_many_polymorphs: NEW: autoloaded constants: #{autoloaded_constants.inspect}; #{explicitly_unloadable_constants.inspect}" if (autoloaded_constants + explicitly_unloadable_constants).any?
returning(new_constants_in_without_injection(*descs, &block)) do |found|
# _logger_debug "has_many_polymorphs: new constants: #{found.inspect}" if found.any?
found.each do |constant|
injection_graph[constant].each do |requirement|
requirement.constantize
# _logger_debug "has_many_polymorphs: constantized #{requirement}"
end
end
end
end
alias_method_chain :new_constants_in, :injection
end

View file

@ -0,0 +1,25 @@
# http://www.bigbold.com/snippets/posts/show/2032
module Rake
module TaskManager
def redefine_task(task_class, args, &block)
task_name, deps = resolve_args(args)
task_name = task_class.scope_name(@scope, task_name)
deps = [deps] unless deps.respond_to?(:to_ary)
deps = deps.collect {|d| d.to_s }
task = @tasks[task_name.to_s] = task_class.new(task_name, self)
task.application = self
task.add_comment(@last_comment)
@last_comment = nil
task.enhance(deps, &block)
task
end
end
class Task
class << self
def redefine_task(args, &block)
Rake.application.redefine_task(self, args, &block)
end
end
end
end

View file

@ -0,0 +1,41 @@
module ActiveRecord
module Reflection
module ClassMethods
def create_reflection(macro, name, options, active_record)
case macro
when :has_many, :belongs_to, :has_one, :has_and_belongs_to_many
reflection = AssociationReflection.new(macro, name, options, active_record)
when :composed_of
reflection = AggregateReflection.new(macro, name, options, active_record)
######
when :has_many_polymorphs
reflection = PolymorphicReflection.new(macro, name, options, active_record)
######
end
write_inheritable_hash :reflections, name => reflection
reflection
end
end
class PolymorphicError < ActiveRecordError
end
class PolymorphicReflection < AssociationReflection
def check_validity!
# nothing
end
# these are kind of shady but it lets us inherit more directly
def source_reflection
self
end
def class_name
@class_name ||= options[:join_class_name]
end
end
end
end

View file

@ -0,0 +1,57 @@
# hope these don't mess anyone up
class String
def _as_class
# classify expects self to be plural
self.classify.constantize
end
# def _as_base_class; _as_class.base_class; end
alias :_singularize :singularize
alias :_pluralize :pluralize
alias :_classify :classify
end
class Symbol
def _as_class; self.to_s._as_class; end
# def _as_base_class; self.to_s._as_base_class; end
def _singularize; self.to_s.singularize.to_sym; end
def _pluralize; self.to_s.pluralize.to_sym; end
def _classify; self.to_s.classify; end
end
class Array
def _flatten_once
self.inject([]){|r, el| r + Array(el)}
end
end
class Hash
def _select
Hash[*self.select do |key, value|
yield key, value
end._flatten_once]
end
end
class Object
def _metaclass; (class << self; self; end); end
def _logger_debug s
::ActiveRecord::Base.logger.debug(s) if ::ActiveRecord::Base.logger
end
def _logger_warn s
if ::ActiveRecord::Base.logger
::ActiveRecord::Base.logger.warn(s)
else
$stderr.puts("has_many_polymorphs: #{s}")
end
end
end
class ActiveRecord::Base
def _base_class_name
self.class.base_class.name.to_s
end
end

View file

@ -2,7 +2,11 @@ swimmy:
id: 1
name: Swimmy
speed: 10
created_at: "2007-02-01 12:00:00"
updated_at: "2007-02-04 10:00:00"
jaws:
id: 2
name: Jaws
speed: 20
speed: 20
created_at: "2007-02-02 12:00:00"
updated_at: "2007-02-03 10:00:00"

View file

@ -1,3 +1,5 @@
shamu:
id: 1
name: Shamu
created_at: "2007-03-01 12:00:00"
updated_at: "2007-03-04 10:00:00"

View file

@ -1,6 +1,10 @@
rover:
id: 1
name: Rover
created_at: "2007-01-01 12:00:00"
updated_at: "2007-01-04 10:00:00"
spot:
id: 2
name: Spot
name: Spot
created_at: "2007-01-02 12:00:00"
updated_at: "2007-01-03 10:00:00"

View file

@ -2,7 +2,11 @@ chloe:
id: 1
cat_type: Kitten
name: Chloe
created_at: "2007-04-01 12:00:00"
updated_at: "2007-04-04 10:00:00"
alice:
id: 2
cat_type: Kitten
name: Alice
name: Alice
created_at: "2007-04-02 12:00:00"
updated_at: "2007-04-03 10:00:00"

View file

@ -1,3 +1,5 @@
froggy:
id: 1
name: Froggy
created_at: "2007-05-01 12:00:00"
updated_at: "2007-05-04 10:00:00"

View file

@ -1,6 +1,11 @@
kibbles:
the_petfood_primary_key: 1
name: Kibbles
created_at: "2007-06-01 12:00:00"
updated_at: "2007-06-04 10:00:00"
bits:
the_petfood_primary_key: 2
name: Bits
name: Bits
created_at: "2007-06-02 12:00:00"
updated_at: "2007-06-03 10:00:00"

View file

@ -1,6 +1,10 @@
puma:
id: 1
name: Puma
created_at: "2007-07-01 12:00:00"
updated_at: "2007-07-04 10:00:00"
jacrazy:
id: 2
name: Jacrazy
created_at: "2007-07-02 12:00:00"
updated_at: "2007-07-03 10:00:00"

View file

@ -5,7 +5,9 @@ require 'aquatic/pupils_whale'
class Aquatic::Whale < ActiveRecord::Base
has_many_polymorphs(:aquatic_pupils, :from => [:dogs, :"aquatic/fish"],
:through => "aquatic/pupils_whales") do
def blow; "result"; end
end
:through => "aquatic/pupils_whales") do
def a_method
:correct_block_result
end
end
end

View file

@ -1,4 +1,6 @@
require 'extension_module'
class BeautifulFightRelationship < ActiveRecord::Base
set_table_name 'keep_your_enemies_close'
@ -8,6 +10,17 @@ class BeautifulFightRelationship < ActiveRecord::Base
# are not supported by Rails
acts_as_double_polymorphic_join :enemies => [:dogs, :kittens, :frogs],
:protectors => [:wild_boars, :kittens, :"aquatic/fish", :dogs]
:protectors => [:wild_boars, :kittens, :"aquatic/fish", :dogs],
:enemies_extend => [ExtensionModule, proc {}],
:protectors_extend => proc {
def a_method
:correct_proc_result
end
},
:join_extend => proc {
def a_method
:correct_join_result
end
}
end

View file

@ -0,0 +1,9 @@
class Canine < ActiveRecord::Base
self.abstract_class = true
def an_abstract_method
:correct_abstract_method_response
end
end

View file

@ -1,6 +1,8 @@
class Dog < ActiveRecord::Base
attr_accessor :after_find_test, :after_initialize_test
require 'canine'
class Dog < Canine
attr_accessor :after_find_test, :after_initialize_test
set_table_name "bow_wows"
def after_find

View file

@ -8,14 +8,31 @@ require 'dog'
require 'wild_boar'
require 'kitten'
require 'tabby'
require 'extension_module'
require 'other_extension_module'
class Petfood < ActiveRecord::Base
set_primary_key 'the_petfood_primary_key'
has_many_polymorphs :eaters,
:from => [:dogs, :petfoods, :wild_boars, :kittens,
:tabbies, :"aquatic/fish"],
:dependent => :destroy,
:rename_individual_collections => true,
:acts_as => :foodstuff,
:foreign_key => "foodstuff_id"
end
:from => [:dogs, :petfoods, :wild_boars, :kittens,
:tabbies, :"aquatic/fish"],
# :dependent => :destroy, :destroy is now the default
:rename_individual_collections => true,
:as => :foodstuff,
:foreign_key => "foodstuff_id",
:ignore_duplicates => false,
:conditions => "NULL IS NULL",
:order => "eaters_foodstuffs.updated_at ASC",
:parent_order => "the_petfood_primary_key DESC",
:extend => [ExtensionModule, OtherExtensionModule, proc {}],
:join_extend => proc {
def a_method
:correct_join_result
end
},
:parent_extend => proc {
def a_method
:correct_parent_proc_result
end
}
end

View file

@ -0,0 +1,9 @@
module ExtensionModule
def a_method
:correct_module_result
end
def self.a_method
:incorrect_module_result
end
end

View file

@ -0,0 +1,9 @@
module OtherExtensionModule
def another_method
:correct_other_module_result
end
def self.another_method
:incorrect_other_module_result
end
end

View file

@ -1,23 +1,33 @@
ActiveRecord::Schema.define(:version => 0) do
create_table :petfoods, :force => true, :primary_key => :the_petfood_primary_key do |t|
t.column :name, :string
t.column :created_at, :datetime, :null => false
t.column :updated_at, :datetime, :null => false
end
create_table :bow_wows, :force => true do |t|
t.column :name, :string
t.column :created_at, :datetime, :null => false
t.column :updated_at, :datetime, :null => false
end
create_table :cats, :force => true do |t|
t.column :name, :string
t.column :cat_type, :string
t.column :created_at, :datetime, :null => false
t.column :updated_at, :datetime, :null => false
end
create_table :frogs, :force => true do |t|
t.column :name, :string
t.column :created_at, :datetime, :null => false
t.column :updated_at, :datetime, :null => false
end
create_table :wild_boars, :force => true do |t|
t.column :name, :string
t.column :created_at, :datetime, :null => false
t.column :updated_at, :datetime, :null => false
end
create_table :eaters_foodstuffs, :force => true do |t|
@ -25,21 +35,29 @@ ActiveRecord::Schema.define(:version => 0) do
t.column :eater_id, :integer
t.column :some_attribute, :integer, :default => 0
t.column :eater_type, :string
t.column :created_at, :datetime, :null => false
t.column :updated_at, :datetime, :null => false
end
create_table :fish, :force => true do |t|
t.column :name, :string
t.column :speed, :integer
t.column :created_at, :datetime, :null => false
t.column :updated_at, :datetime, :null => false
end
create_table :whales, :force => true do |t|
t.column :name, :string
t.column :created_at, :datetime, :null => false
t.column :updated_at, :datetime, :null => false
end
create_table :little_whale_pupils, :force => true do |t|
t.column :whale_id, :integer
t.column :aquatic_pupil_id, :integer
t.column :aquatic_pupil_type, :string
t.column :created_at, :datetime, :null => false
t.column :updated_at, :datetime, :null => false
end
create_table :keep_your_enemies_close, :force => true do |t|
@ -47,6 +65,8 @@ ActiveRecord::Schema.define(:version => 0) do
t.column :enemy_type, :string
t.column :protector_id, :integer
t.column :protector_type, :string
t.column :created_at, :datetime, :null => false
t.column :updated_at, :datetime, :null => false
end
end

View file

@ -1,20 +1,31 @@
require 'pathname'
# default test helper
begin
require 'rubygems'
require 'ruby-debug'
Debugger.start
rescue Object
end
# load the applicaiton's test helper
begin
require File.dirname(__FILE__) + '/../../../../test/test_helper'
rescue LoadError
require '~/projects/miscellaneous/cookbook/test/test_helper'
end
WORKING_DIR = File.dirname(__FILE__)
Inflector.inflections {|i| i.irregular 'fish', 'fish' }
# fixtures
$LOAD_PATH.unshift(Test::Unit::TestCase.fixture_path = File.dirname(__FILE__) + "/fixtures/")
$LOAD_PATH.unshift(Test::Unit::TestCase.fixture_path = WORKING_DIR + "/fixtures")
# models
$LOAD_PATH.unshift("#{Pathname.new(__FILE__).dirname.to_s}/models")
$LOAD_PATH.unshift(WORKING_DIR + "/models")
# extension modules
$LOAD_PATH.unshift(WORKING_DIR + "/modules")
class Test::Unit::TestCase
self.use_transactional_fixtures = true # must stay true for tests to run on postgres or sqlite3
self.use_transactional_fixtures = (not ActiveRecord::Base.connection.is_a? ActiveRecord::ConnectionAdapters::MysqlAdapter)
self.use_instantiated_fixtures = false
end

View file

@ -6,13 +6,9 @@ class PolymorphTest < Test::Unit::TestCase
:"aquatic/fish", :"aquatic/whales", :"aquatic/little_whale_pupils",
:keep_your_enemies_close
require 'beautiful_fight_relationship'
# to-do: finder queries on the collection
# order-mask column on the join table for polymorphic order
# rework load order so you could push and pop without ever loading the whole collection
# so that limit works in a sane way
def setup
@association_error = ActiveRecord::Associations::PolymorphicError
@kibbles = Petfood.find(1)
@bits = Petfood.find(2)
@shamu = Aquatic::Whale.find(1)
@ -25,8 +21,8 @@ class PolymorphTest < Test::Unit::TestCase
@froggy = Frog.find(1)
@join_count = EatersFoodstuff.count
@l = @kibbles.eaters.length
@m = @bits.eaters.count
@l = @kibbles.eaters.size
@m = @bits.eaters.size
end
def test_all_relationship_validities
@ -53,21 +49,16 @@ class PolymorphTest < Test::Unit::TestCase
assert_equal @l += 1, @kibbles.eaters.count
@kibbles.reload
assert_equal @l, @kibbles.eaters.count
assert_equal @l, @kibbles.eaters.count
end
def test_duplicate_assignment
# try to add a duplicate item
# try to add a duplicate item when :ignore_duplicates is false
@kibbles.eaters.push(@alice)
assert @kibbles.eaters.include?(@alice)
@kibbles.eaters.push(@alice)
assert_equal @l + 1, @kibbles.eaters.count
assert_equal @join_count + 1, EatersFoodstuff.count
@kibbles.reload
assert_equal @l + 1, @kibbles.eaters.count
assert_equal @join_count + 1, EatersFoodstuff.count
assert_equal @l + 2, @kibbles.eaters.count
assert_equal @join_count + 2, EatersFoodstuff.count
end
def test_create_and_push
@ -97,31 +88,27 @@ class PolymorphTest < Test::Unit::TestCase
assert @join_record.id
assert_equal @join_count + 1, EatersFoodstuff.count
# has the parent changed if we don't reload?
assert_equal @m, @bits.eaters.count
# if we do reload, is the new association there?
# XXX no, because TestCase breaks reload. it works fine in the app.
# not reloaded
assert_equal @m, @bits.eaters.size
assert_equal @m + 1, @bits.eaters.count # SQL :)
assert_equal Petfood, @bits.eaters.reload.class
assert_equal @m + 1, @bits.eaters.count
# is the new association there?
assert @bits.eaters.reload
assert @bits.eaters.include?(@chloe)
# puts "XXX #{EatersFoodstuff.count}"
end
def test_add_unsaved
# add an unsaved item
assert @bits.eaters << Kitten.new(:name => "Bridget")
assert_nil Kitten.find_by_name("Bridget")
assert_equal @m + 1, @bits.eaters.count
assert @bits.save
@bits.reload
assert_equal @m + 1, @bits.eaters.count
end
# not supporting this, since has_many :through doesn't support it either
# def test_add_unsaved
# # add an unsaved item
# assert @bits.eaters << Kitten.new(:name => "Bridget")
# assert_nil Kitten.find_by_name("Bridget")
# assert_equal @m + 1, @bits.eaters.count
#
# assert @bits.save
# @bits.reload
# assert_equal @m + 1, @bits.eaters.count
#
# end
def test_self_reference
assert @kibbles.eaters << @bits
@ -154,11 +141,10 @@ class PolymorphTest < Test::Unit::TestCase
def test_clear
@kibbles.eaters << [@chloe, @spot, @rover]
@kibbles.reload
assert_equal 3, @kibbles.eaters.clear.size
assert @kibbles.eaters.clear.blank?
assert @kibbles.eaters.blank?
@kibbles.reload
assert @kibbles.eaters.blank?
assert_equal 0, @kibbles.eaters.clear.size
end
def test_individual_collections
@ -169,7 +155,7 @@ class PolymorphTest < Test::Unit::TestCase
assert 1, @rover.eaters_foodstuffs.count
end
def test_invididual_collections_push
def test_individual_collections_push
assert_equal [@chloe], (@kibbles.eater_kittens << @chloe)
@kibbles.reload
assert @kibbles.eaters.include?(@chloe)
@ -177,24 +163,31 @@ class PolymorphTest < Test::Unit::TestCase
assert !@kibbles.eater_dogs.include?(@chloe)
end
def test_invididual_collections_delete
def test_individual_collections_delete
@kibbles.eaters << [@chloe, @spot, @rover]
@kibbles.reload
assert_equal [@chloe], @kibbles.eater_kittens.delete(@chloe)
assert @kibbles.eater_kittens.empty?
assert !@kibbles.eater_kittens.delete(@chloe)
@kibbles.eater_kittens.delete(@chloe) # what should this return?
@kibbles.reload
assert @kibbles.eater_kittens.empty?
assert @kibbles.eater_dogs.include?(@spot)
end
def test_invididual_collections_clear
def test_individual_collections_clear
@kibbles.eaters << [@chloe, @spot, @rover]
@kibbles.reload
assert_equal [@chloe], @kibbles.eater_kittens.clear
assert_equal [], @kibbles.eater_kittens.clear
assert @kibbles.eater_kittens.empty?
assert_equal 2, @kibbles.eaters.size
assert @kibbles.eater_kittens.empty?
assert_equal 2, @kibbles.eaters.size
assert !@kibbles.eater_kittens.include?(@chloe)
assert !@kibbles.eaters.include?(@chloe)
@kibbles.reload
assert @kibbles.eater_kittens.empty?
assert_equal 2, @kibbles.eaters.size
@ -229,26 +222,20 @@ class PolymorphTest < Test::Unit::TestCase
def test_normal_callbacks
assert @rover.respond_to?(:after_initialize)
assert @rover.respond_to?(:after_find)
assert @rover.respond_to?(:after_find)
assert @rover.after_initialize_test
assert @rover.after_find_test
end
def test_our_callbacks
def test_model_callbacks_not_overridden_by_plugin_callbacks
assert 0, @bits.eaters.count
assert @bits.eaters.push(@rover)
@bits.save
# puts "Testing callbacks."
@bits2 = Petfood.find_by_name("Bits")
@bits.reload
assert rover = @bits2.eaters.select { |x| x.name == "Rover" }[0]
assert rover.after_initialize_test
assert rover.after_find_test
# puts "Done."
end
def test_number_of_join_records
@ -268,8 +255,8 @@ class PolymorphTest < Test::Unit::TestCase
@join_record.save!
@bits.eaters.reload
assert_equal 'Puma', @puma.name
assert_equal 'Puma', @bits.eaters.first.name
assert_equal "Puma", @puma.name
assert_equal "Puma", @bits.eaters.first.name
end
def test_before_save_on_join_table_is_not_clobbered_by_sti_base_class_fix
@ -347,6 +334,10 @@ class PolymorphTest < Test::Unit::TestCase
assert_equal 3, @alice.beautiful_fight_relationships.size
end
def test_double_dependency_injection
# breakpoint
end
def test_double_collection_deletion
@alice.enemies << @spot
@alice.reload
@ -432,17 +423,12 @@ class PolymorphTest < Test::Unit::TestCase
assert !@spot.protectors.include?(@alice)
end
def test_hmp_passed_block_manipulates_proxy_class
assert_equal "result", @shamu.aquatic_pupils.blow
assert_raises(NoMethodError) { @kibbles.eaters.blow }
end
def test_collection_query_on_unsaved_record
assert Dog.new.enemies.empty?
assert Dog.new.foodstuffs_of_eaters.empty?
end
def test_double_invididual_collections_push
def test_double_individual_collections_push
assert_equal [@chloe], (@spot.protector_kittens << @chloe)
@spot.reload
assert @spot.protectors.include?(@chloe)
@ -456,22 +442,22 @@ class PolymorphTest < Test::Unit::TestCase
assert !@spot.enemy_dogs.include?(@froggy)
end
def test_double_invididual_collections_delete
def test_double_individual_collections_delete
@spot.protectors << [@chloe, @puma]
@spot.reload
assert_equal [@chloe], @spot.protector_kittens.delete(@chloe)
assert @spot.protector_kittens.empty?
assert !@spot.protector_kittens.delete(@chloe)
@spot.protector_kittens.delete(@chloe) # again, unclear what .delete should return
@spot.reload
assert @spot.protector_kittens.empty?
assert @spot.wild_boars.include?(@puma)
end
def test_double_invididual_collections_clear
def test_double_individual_collections_clear
@spot.protectors << [@chloe, @puma, @alice]
@spot.reload
assert_equal [@chloe, @alice], @spot.protector_kittens.clear.sort_by(&:id)
assert_equal [], @spot.protector_kittens.clear
assert @spot.protector_kittens.empty?
assert_equal 1, @spot.protectors.size
@spot.reload
@ -481,7 +467,156 @@ class PolymorphTest < Test::Unit::TestCase
assert !@spot.protectors.include?(@chloe)
assert !@spot.protector_kittens.include?(@alice)
assert !@spot.protectors.include?(@alice)
assert @spot.protectors.include?(@puma)
assert @spot.wild_boars.include?(@puma)
end
def test_single_extensions
assert_equal :correct_block_result, @shamu.aquatic_pupils.a_method
@kibbles.eaters.push(@alice)
@kibbles.eaters.push(@spot)
assert_equal :correct_join_result, @kibbles.eaters_foodstuffs.a_method
assert_equal :correct_module_result, @kibbles.eaters.a_method
assert_equal :correct_other_module_result, @kibbles.eaters.another_method
@kibbles.eaters.each do |eater|
assert_equal :correct_join_result, eater.eaters_foodstuffs.a_method
end
assert_equal :correct_parent_proc_result, @kibbles.foodstuffs_of_eaters.a_method
assert_equal :correct_parent_proc_result, @kibbles.eaters.first.foodstuffs_of_eaters.a_method
end
def test_double_extensions
assert_equal :correct_proc_result, @spot.protectors.a_method
assert_equal :correct_module_result, @spot.enemies.a_method
assert_equal :correct_join_result, @spot.beautiful_fight_relationships_as_enemy.a_method
assert_equal :correct_join_result, @spot.beautiful_fight_relationships_as_protector.a_method
assert_equal :correct_join_result, @froggy.beautiful_fight_relationships.a_method
assert_equal :correct_join_result, @froggy.beautiful_fight_relationships_as_enemy.a_method
assert_raises(NoMethodError) {@froggy.beautiful_fight_relationships_as_protector.a_method}
end
def test_pluralization_checks
assert_raises(@association_error) {
eval "class SomeModel < ActiveRecord::Base
has_many_polymorphs :polymorphs, :from => [:dog, :cats]
end" }
assert_raises(@association_error) {
eval "class SomeModel < ActiveRecord::Base
has_many_polymorphs :polymorph, :from => [:dogs, :cats]
end" }
assert_raises(@association_error) {
eval "class SomeModel < ActiveRecord::Base
acts_as_double_polymorphic_join :polymorph => [:dogs, :cats], :unimorphs => [:dogs, :cats]
end" }
end
def test_single_custom_finders
[@kibbles, @alice, @puma, @spot, @bits].each {|record| @kibbles.eaters << record; sleep 1} # XXX yeah i know
assert_equal @kibbles.eaters, @kibbles.eaters.find(:all, :order => "eaters_foodstuffs.created_at ASC")
assert_equal @kibbles.eaters.reverse, @kibbles.eaters.find(:all, :order => "eaters_foodstuffs.created_at DESC")
if ActiveRecord::Base.connection.is_a? ActiveRecord::ConnectionAdapters::MysqlAdapter
assert_equal @kibbles.eaters.sort_by(&:created_at), @kibbles.eaters.find(:all, :order => "IFNULL(bow_wows.created_at,(IFNULL(petfoods.created_at,(IFNULL(wild_boars.created_at,(IFNULL(cats.created_at,fish.created_at))))))) ASC")
end
assert_equal @kibbles.eaters.select{|x| x.is_a? Petfood}, @kibbles.eater_petfoods.find(:all, :order => "eaters_foodstuffs.created_at ASC")
end
def test_double_custom_finders
@spot.protectors << [@chloe, @puma, @alice]
assert_equal [@chloe], @spot.protectors.find(:all, :conditions => ["cats.name = ?", @chloe.name], :limit => 1)
assert_equal [], @spot.protectors.find(:all, :conditions => ["cats.name = ?", @chloe.name], :limit => 1, :offset => 1)
assert_equal 2, @spot.protectors.find(:all, :limit => 100, :offset => 1).size
end
def test_single_custom_finder_parameters_carry_to_individual_relationships
# XXX test nullout here
end
def test_double_custom_finder_parameters_carry_to_individual_relationships
# XXX test nullout here
end
def test_include_doesnt_fail
assert_nothing_raised do
@spot.protectors.find(:all, :include => :wild_boars)
end
end
def test_abstract_method
assert_equal :correct_abstract_method_response, @spot.an_abstract_method
end
def test_missing_target_should_raise
@kibbles.eaters << [@kibbles, @alice, @puma, @spot, @bits]
@spot.destroy_without_callbacks
assert_raises(@association_error) { @kibbles.eaters.reload }
# assert_raises(@association_error) { @kibbles.eater_dogs.reload } # bah AR
end
def test_lazy_loading_is_lazy
# XXX
end
def test_push_with_skip_duplicates_false_doesnt_load_target
# XXX
end
def test_association_foreign_key_is_sane
assert_equal "eater_id", Petfood.reflect_on_association(:eaters).association_foreign_key
end
def test_reflection_instance_methods_are_sane
assert_equal EatersFoodstuff, Petfood.reflect_on_association(:eaters).klass
assert_equal EatersFoodstuff.name, Petfood.reflect_on_association(:eaters).class_name
end
def test_parent_order_orders_parents
@alice.foodstuffs_of_eaters << Petfood.find(:all, :order => "the_petfood_primary_key ASC")
@alice.reload #not necessary
assert_equal [2,1], @alice.foodstuffs_of_eaters.map(&:id)
end
# def test_polymorphic_include
# @kibbles.eaters << [@kibbles, @alice, @puma, @spot, @bits]
# assert @kibbles.eaters.include?(@kibbles.eaters_foodstuffs.find(:all, :include => :eater).first.eater)
# end
#
# def test_double_polymorphic_include
# end
#
# def test_single_child_include
# end
#
# def test_double_child_include
# end
#
# def test_single_include_from_parent
# end
#
# def test_double_include_from_parent
# end
#
# def test_meta_referential_single_include
# end
#
# def test_meta_referential_double_include
# end
#
# def test_meta_referential_single_include
# end
#
# def test_meta_referential_single_double_multi_include
# end
#
# def test_dont_ignore_duplicates
# end
#
# def test_ignore_duplicates
# end
#
# def test_tagging_system_generator
# end
#
# def test_tagging_system_library
# end
end

View file

@ -0,0 +1,20 @@
Copyright (c) 2006 Coda Hale
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

86
tracks/vendor/plugins/rails_rcov/README vendored Normal file
View file

@ -0,0 +1,86 @@
= rails_rcov plugin for Rails
rails_rcov provides easy-to-use Rake tasks to determine the code coverage of
your unit, functional, and integration tests using Mauricio Fernandez's rcov
tool.
== Installation
First, install rcov from Mauricio's web site
[http://eigenclass.org/hiki.rb?rcov]. Make sure it's on your system path, so
that typing +rcov+ on the command line actually runs it. THIS PLUGIN DOESN'T DO
ANYTHING BESIDES GENERATE ERRORS UNLESS YOU INSTALL RCOV FIRST. RCOV CONTAINS
ALL THE MAGIC, THIS PLUGIN JUST RUNS IT.
Second, install this plugin. If your project is source-controlled by Subversion
(which it should be, really), the easiest way to install this is via Rails'
plugin script:
./script/plugin install -x http://svn.codahale.com/rails_rcov
If you're not using Subversion, or if you don't want it adding
<tt>svn:externals</tt> in your project, remove the <tt>-x</tt> switch:
./script/plugin install http://svn.codahale.com/rails_rcov
== Usage
For each <tt>test:blah</tt> task you have for your Rails project, rails_rcov
adds two more: <tt>test:blah:rcov</tt> and <tt>test:blah:clobber_rcov</tt>.
Running <tt>rake test:units:rcov</tt>, for example, will run your unit tests
through rcov and write the code coverage reports to
<tt>your_rails_app/coverage/units</tt>.
Running <tt>test:units:clobber_rcov</tt> will erase the generated report for the
unit tests.
Each rcov task takes two optional parameters: RCOV_PARAMS, whose argument is
passed along to rcov, and SHOW_ONLY, which limits the files displayed in the
report.
RCOV_PARAMS:
# sort by coverage
rake test:units:rcov RCOV_PARAMS="--sort=coverage"
# show callsites and hide fully covered files
rake test:units:rcov RCOV_PARAMS="--callsites --only-uncovered"
Check the rcov documentation for more details.
SHOW_ONLY is a comma-separated list of the files you'd like to see. Right now
there are four types of files rake_rcov recognizes: models, helpers,
controllers, and lib. These can be abbreviated to their first letters:
# only show files from app/models
rake test:units:rcov SHOW_ONLY=models
# only show files from app/helpers and app/controllers
rake test:units:rcov SHOW_ONLY=helpers,controllers
# only show files from app/helpers and app/controllers, with less typing
rake test:units:rcov SHOW_ONLY=h,c
Please note that rails_rcov has only been tested with a Bash shell, and any
other environment could well explode in your face. If you're having trouble
getting this to work on Windows, please take the time to figure out what's not
working. Most of the time it boils down to the different ways the Window shell
and the Bash shell escape metacharacters. Play around with the way rcov_rake
escapes data (like on line 73, or 78) and send me a fix. I don't have a working
Windows environment anymore, so leaving it up to me won't solve anything. ;-)
== Resources
=== Subversion
* http://svn.codahale.com/rails_rcov
=== Blog
* http://blog.codahale.com
== Credits
Written by Coda Hale <coda.hale@gmail.com>. Thanks to Nils Franzen for a Win32
escaping patch. Thanks to Alex Wayne for suggesting how to make SHOW_ONLY not be
useless.

View file

@ -0,0 +1,150 @@
# This File Uses Magic
# ====================
# Here's an example of how this file works. As an example, let's say you typed
# this into your terminal:
#
# $ rake --tasks
#
# The rake executable goes through all the various places .rake files can be,
# accumulates them all, and then runs them. When this file is loaded by Rake,
# it iterates through all the tasks, and for each task named 'test:blah' adds
# test:blah:rcov and test:blah:rcov_clobber.
#
# So you've seen all the tasks, and you type this into your terminal:
#
# $ rake test:units:rcov
#
# Rake does the same thing as above, but it runs the test:units:rcov task, which
# pretty much just does this:
#
# $ ruby [this file] [the test you want to run] [some options]
#
# Now this file is run via the Ruby interpreter, and after glomming up the
# options, it acts just like the Rake executable, with a slight difference: it
# passes all the arguments to rcov, not ruby, so all your unit tests get some
# rcov sweet loving.
if ARGV.grep(/--run-rake-task=/).empty?
# Define all our Rake tasks
require 'rake/clean'
require 'rcov/rcovtask'
def to_rcov_task_sym(s)
s = s.gsub(/(test:)/,'')
s.empty? ? nil : s.intern
end
def to_rcov_task_name(s)
s = s.gsub(/(test:)/,'')
s =~ /s$/i ? s[0..-2] : s
end
def new_rcov_task(test_name)
output_dir = "./coverage/#{test_name.gsub('test:','')}"
CLOBBER.include(output_dir)
# Add a task to run the rcov process
desc "Run all #{to_rcov_task_name(test_name)} tests with Rcov to measure coverage"
task :rcov => [:clobber_rcov] do |t|
run_code = '"' << File.expand_path(__FILE__) << '"'
run_code << " --run-rake-task=#{test_name}"
params = String.new
if ENV['RCOV_PARAMS']
params << ENV['RCOV_PARAMS']
end
# rake test:units:rcov SHOW_ONLY=models,controllers,lib,helpers
# rake test:units:rcov SHOW_ONLY=m,c,l,h
if ENV['SHOW_ONLY']
show_only = ENV['SHOW_ONLY'].to_s.split(',').map{|x|x.strip}
if show_only.any?
reg_exp = []
for show_type in show_only
reg_exp << case show_type
when 'm', 'models' : 'app\/models'
when 'c', 'controllers' : 'app\/controllers'
when 'h', 'helpers' : 'app\/helpers'
when 'l', 'lib' : 'lib'
else
show_type
end
end
reg_exp.map!{ |m| "(#{m})" }
params << " -x \\\"^(?!#{reg_exp.join('|')})\\\""
end
end
unless params.empty?
run_code << " --rcov-params=\"#{params}\""
end
ruby run_code
end
# Add a task to clean up after ourselves
desc "Remove Rcov reports for #{to_rcov_task_name(test_name)} tests"
task :clobber_rcov do |t|
rm_r output_dir, :force => true
end
# Link our clobber task to the main one
task :clobber => [:clobber_rcov]
end
test_tasks = Rake::Task.tasks.select{ |t| t.comment && t.name =~ /^test/ }
for test_task in test_tasks
namespace :test do
if sym = to_rcov_task_sym(test_task.name)
namespace sym do
new_rcov_task(test_task.name)
end
end
end
end
else
# Load rake tasks, hijack ruby, and redirect the task through rcov
require 'rubygems'
require 'rake'
module RcovTestSettings
class << self
attr_accessor :output_dir, :options
def to_params
"-o \"#{@output_dir}\" -T -x \"rubygems/*,rcov*\" --rails #{@options}"
end
end
# load options and arguments from command line
unless (cmd_line = ARGV.grep(/--rcov-params=/)).empty?
@options = cmd_line.first.gsub(/--rcov-params=/, '')
end
end
def is_windows?
processor, platform, *rest = RUBY_PLATFORM.split("-")
platform == 'mswin32'
end
# intercept what Rake *would* be doing with Ruby, and send it through Rcov instead
module RakeFileUtils
alias :ruby_without_rcov :ruby
def ruby(*args, &block)
cmd = (is_windows? ? 'rcov.cmd' : 'rcov') << " #{RcovTestSettings.to_params} #{args}"
status = sh(cmd, {}, &block)
puts "View the full results at <file://#{RcovTestSettings.output_dir}/index.html>"
return status
end
end
# read the test name and execute it (through Rcov)
unless (cmd_line = ARGV.grep(/--run-rake-task=/)).empty?
test_name = cmd_line.first.gsub(/--run-rake-task=/,'')
ARGV.clear; ARGV << test_name
RcovTestSettings.output_dir = File.expand_path("./coverage/#{test_name.gsub('test:','')}")
Rake.application.run
else
raise "No test to execute!"
end
end