Moved Rails plugins required by the core to lib/plugins.

git-svn-id: svn+ssh://rubyforge.org/var/svn/redmine/trunk@9533 e93f8b46-1217-0410-a6f0-8f06a7374b81
This commit is contained in:
Jean-Philippe Lang
2012-04-26 17:23:24 +00:00
parent 26868d8b14
commit 8d73ddf73f
237 changed files with 15 additions and 0 deletions

View File

@@ -0,0 +1,2 @@
require File.dirname(__FILE__) + '/lib/acts_as_activity_provider'
ActiveRecord::Base.send(:include, Redmine::Acts::ActivityProvider)

View File

@@ -0,0 +1,88 @@
# Redmine - project management software
# Copyright (C) 2006-2012 Jean-Philippe Lang
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
module Redmine
module Acts
module ActivityProvider
def self.included(base)
base.extend ClassMethods
end
module ClassMethods
def acts_as_activity_provider(options = {})
unless self.included_modules.include?(Redmine::Acts::ActivityProvider::InstanceMethods)
cattr_accessor :activity_provider_options
send :include, Redmine::Acts::ActivityProvider::InstanceMethods
end
options.assert_valid_keys(:type, :permission, :timestamp, :author_key, :find_options)
self.activity_provider_options ||= {}
# One model can provide different event types
# We store these options in activity_provider_options hash
event_type = options.delete(:type) || self.name.underscore.pluralize
options[:timestamp] ||= "#{table_name}.created_on"
options[:find_options] ||= {}
options[:author_key] = "#{table_name}.#{options[:author_key]}" if options[:author_key].is_a?(Symbol)
self.activity_provider_options[event_type] = options
end
end
module InstanceMethods
def self.included(base)
base.extend ClassMethods
end
module ClassMethods
# Returns events of type event_type visible by user that occured between from and to
def find_events(event_type, user, from, to, options)
provider_options = activity_provider_options[event_type]
raise "#{self.name} can not provide #{event_type} events." if provider_options.nil?
scope = self
if from && to
scope = scope.scoped(:conditions => ["#{provider_options[:timestamp]} BETWEEN ? AND ?", from, to])
end
if options[:author]
return [] if provider_options[:author_key].nil?
scope = scope.scoped(:conditions => ["#{provider_options[:author_key]} = ?", options[:author].id])
end
if options[:limit]
# id and creation time should be in same order in most cases
scope = scope.scoped(:order => "#{table_name}.id DESC", :limit => options[:limit])
end
if provider_options.has_key?(:permission)
scope = scope.scoped(:conditions => Project.allowed_to_condition(user, provider_options[:permission] || :view_project, options))
elsif respond_to?(:visible)
scope = scope.visible(user, options)
else
ActiveSupport::Deprecation.warn "acts_as_activity_provider with implicit :permission option is deprecated. Add a visible scope to the #{self.name} model or use explicit :permission option."
scope = scope.scoped(:conditions => Project.allowed_to_condition(user, "view_#{self.name.underscore.pluralize}".to_sym, options))
end
scope.all(provider_options[:find_options].dup)
end
end
end
end
end
end

View File

@@ -0,0 +1,2 @@
require File.dirname(__FILE__) + '/lib/acts_as_attachable'
ActiveRecord::Base.send(:include, Redmine::Acts::Attachable)

View File

@@ -0,0 +1,102 @@
# Redmine - project management software
# Copyright (C) 2006-2012 Jean-Philippe Lang
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
module Redmine
module Acts
module Attachable
def self.included(base)
base.extend ClassMethods
end
module ClassMethods
def acts_as_attachable(options = {})
cattr_accessor :attachable_options
self.attachable_options = {}
attachable_options[:view_permission] = options.delete(:view_permission) || "view_#{self.name.pluralize.underscore}".to_sym
attachable_options[:delete_permission] = options.delete(:delete_permission) || "edit_#{self.name.pluralize.underscore}".to_sym
has_many :attachments, options.merge(:as => :container,
:order => "#{Attachment.table_name}.created_on",
:dependent => :destroy)
send :include, Redmine::Acts::Attachable::InstanceMethods
before_save :attach_saved_attachments
end
end
module InstanceMethods
def self.included(base)
base.extend ClassMethods
end
def attachments_visible?(user=User.current)
(respond_to?(:visible?) ? visible?(user) : true) &&
user.allowed_to?(self.class.attachable_options[:view_permission], self.project)
end
def attachments_deletable?(user=User.current)
(respond_to?(:visible?) ? visible?(user) : true) &&
user.allowed_to?(self.class.attachable_options[:delete_permission], self.project)
end
def saved_attachments
@saved_attachments ||= []
end
def unsaved_attachments
@unsaved_attachments ||= []
end
def save_attachments(attachments, author=User.current)
if attachments.is_a?(Hash)
attachments = attachments.values
end
if attachments.is_a?(Array)
attachments.each do |attachment|
a = nil
if file = attachment['file']
next unless file.size > 0
a = Attachment.create(:file => file, :author => author)
elsif token = attachment['token']
a = Attachment.find_by_token(token)
next unless a
a.filename = attachment['filename'] unless attachment['filename'].blank?
a.content_type = attachment['content_type']
end
next unless a
a.description = attachment['description'].to_s.strip
if a.new_record?
unsaved_attachments << a
else
saved_attachments << a
end
end
end
{:files => saved_attachments, :unsaved => unsaved_attachments}
end
def attach_saved_attachments
saved_attachments.each do |attachment|
self.attachments << attachment
end
end
module ClassMethods
end
end
end
end
end

View File

@@ -0,0 +1,2 @@
require File.dirname(__FILE__) + '/lib/acts_as_customizable'
ActiveRecord::Base.send(:include, Redmine::Acts::Customizable)

View File

@@ -0,0 +1,161 @@
# Redmine - project management software
# Copyright (C) 2006-2012 Jean-Philippe Lang
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
module Redmine
module Acts
module Customizable
def self.included(base)
base.extend ClassMethods
end
module ClassMethods
def acts_as_customizable(options = {})
return if self.included_modules.include?(Redmine::Acts::Customizable::InstanceMethods)
cattr_accessor :customizable_options
self.customizable_options = options
has_many :custom_values, :as => :customized,
:include => :custom_field,
:order => "#{CustomField.table_name}.position",
:dependent => :delete_all,
:validate => false
send :include, Redmine::Acts::Customizable::InstanceMethods
validate :validate_custom_field_values
after_save :save_custom_field_values
end
end
module InstanceMethods
def self.included(base)
base.extend ClassMethods
end
def available_custom_fields
CustomField.find(:all, :conditions => "type = '#{self.class.name}CustomField'",
:order => 'position')
end
# Sets the values of the object's custom fields
# values is an array like [{'id' => 1, 'value' => 'foo'}, {'id' => 2, 'value' => 'bar'}]
def custom_fields=(values)
values_to_hash = values.inject({}) do |hash, v|
v = v.stringify_keys
if v['id'] && v.has_key?('value')
hash[v['id']] = v['value']
end
hash
end
self.custom_field_values = values_to_hash
end
# Sets the values of the object's custom fields
# values is a hash like {'1' => 'foo', 2 => 'bar'}
def custom_field_values=(values)
values = values.stringify_keys
custom_field_values.each do |custom_field_value|
key = custom_field_value.custom_field_id.to_s
if values.has_key?(key)
value = values[key]
if value.is_a?(Array)
value = value.reject(&:blank?).uniq
if value.empty?
value << ''
end
end
custom_field_value.value = value
end
end
@custom_field_values_changed = true
end
def custom_field_values
@custom_field_values ||= available_custom_fields.collect do |field|
x = CustomFieldValue.new
x.custom_field = field
x.customized = self
if field.multiple?
values = custom_values.select { |v| v.custom_field == field }
if values.empty?
values << custom_values.build(:customized => self, :custom_field => field, :value => nil)
end
x.value = values.map(&:value)
else
cv = custom_values.detect { |v| v.custom_field == field }
cv ||= custom_values.build(:customized => self, :custom_field => field, :value => nil)
x.value = cv.value
end
x
end
end
def visible_custom_field_values
custom_field_values.select(&:visible?)
end
def custom_field_values_changed?
@custom_field_values_changed == true
end
def custom_value_for(c)
field_id = (c.is_a?(CustomField) ? c.id : c.to_i)
custom_values.detect {|v| v.custom_field_id == field_id }
end
def custom_field_value(c)
field_id = (c.is_a?(CustomField) ? c.id : c.to_i)
custom_field_values.detect {|v| v.custom_field_id == field_id }.try(:value)
end
def validate_custom_field_values
if new_record? || custom_field_values_changed?
custom_field_values.each(&:validate_value)
end
end
def save_custom_field_values
target_custom_values = []
custom_field_values.each do |custom_field_value|
if custom_field_value.value.is_a?(Array)
custom_field_value.value.each do |v|
target = custom_values.detect {|cv| cv.custom_field == custom_field_value.custom_field && cv.value == v}
target ||= custom_values.build(:customized => self, :custom_field => custom_field_value.custom_field, :value => v)
target_custom_values << target
end
else
target = custom_values.detect {|cv| cv.custom_field == custom_field_value.custom_field}
target ||= custom_values.build(:customized => self, :custom_field => custom_field_value.custom_field)
target.value = custom_field_value.value
target_custom_values << target
end
end
self.custom_values = target_custom_values
custom_values.each(&:save)
@custom_field_values_changed = false
true
end
def reset_custom_values!
@custom_field_values = nil
@custom_field_values_changed = true
end
module ClassMethods
end
end
end
end
end

View File

@@ -0,0 +1,2 @@
require File.dirname(__FILE__) + '/lib/acts_as_event'
ActiveRecord::Base.send(:include, Redmine::Acts::Event)

View File

@@ -0,0 +1,91 @@
# Redmine - project management software
# Copyright (C) 2006-2012 Jean-Philippe Lang
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
module Redmine
module Acts
module Event
def self.included(base)
base.extend ClassMethods
end
module ClassMethods
def acts_as_event(options = {})
return if self.included_modules.include?(Redmine::Acts::Event::InstanceMethods)
default_options = { :datetime => :created_on,
:title => :title,
:description => :description,
:author => :author,
:url => {:controller => 'welcome'},
:type => self.name.underscore.dasherize }
cattr_accessor :event_options
self.event_options = default_options.merge(options)
send :include, Redmine::Acts::Event::InstanceMethods
end
end
module InstanceMethods
def self.included(base)
base.extend ClassMethods
end
%w(datetime title description author type).each do |attr|
src = <<-END_SRC
def event_#{attr}
option = event_options[:#{attr}]
if option.is_a?(Proc)
option.call(self)
elsif option.is_a?(Symbol)
send(option)
else
option
end
end
END_SRC
class_eval src, __FILE__, __LINE__
end
def event_date
event_datetime.to_date
end
def event_url(options = {})
option = event_options[:url]
if option.is_a?(Proc)
option.call(self).merge(options)
elsif option.is_a?(Hash)
option.merge(options)
elsif option.is_a?(Symbol)
send(option).merge(options)
else
option
end
end
# Returns the mail adresses of users that should be notified
def recipients
notified = project.notified_users
notified.reject! {|user| !visible?(user)}
notified.collect(&:mail)
end
module ClassMethods
end
end
end
end
end

View File

@@ -0,0 +1,23 @@
ActsAsList
==========
This acts_as extension provides the capabilities for sorting and reordering a number of objects in a list. The class that has this specified needs to have a +position+ column defined as an integer on the mapped database table.
Example
=======
class TodoList < ActiveRecord::Base
has_many :todo_items, :order => "position"
end
class TodoItem < ActiveRecord::Base
belongs_to :todo_list
acts_as_list :scope => :todo_list
end
todo_list.first.move_to_bottom
todo_list.last.move_higher
Copyright (c) 2007 David Heinemeier Hansson, released under the MIT license

View File

@@ -0,0 +1,3 @@
$:.unshift "#{File.dirname(__FILE__)}/lib"
require 'active_record/acts/list'
ActiveRecord::Base.class_eval { include ActiveRecord::Acts::List }

View File

@@ -0,0 +1,270 @@
module ActiveRecord
module Acts #:nodoc:
module List #:nodoc:
def self.included(base)
base.extend(ClassMethods)
end
# This +acts_as+ extension provides the capabilities for sorting and reordering a number of objects in a list.
# The class that has this specified needs to have a +position+ column defined as an integer on
# the mapped database table.
#
# Todo list example:
#
# class TodoList < ActiveRecord::Base
# has_many :todo_items, :order => "position"
# end
#
# class TodoItem < ActiveRecord::Base
# belongs_to :todo_list
# acts_as_list :scope => :todo_list
# end
#
# todo_list.first.move_to_bottom
# todo_list.last.move_higher
module ClassMethods
# Configuration options are:
#
# * +column+ - specifies the column name to use for keeping the position integer (default: +position+)
# * +scope+ - restricts what is to be considered a list. Given a symbol, it'll attach <tt>_id</tt>
# (if it hasn't already been added) and use that as the foreign key restriction. It's also possible
# to give it an entire string that is interpolated if you need a tighter scope than just a foreign key.
# Example: <tt>acts_as_list :scope => 'todo_list_id = #{todo_list_id} AND completed = 0'</tt>
def acts_as_list(options = {})
configuration = { :column => "position", :scope => "1 = 1" }
configuration.update(options) if options.is_a?(Hash)
configuration[:scope] = "#{configuration[:scope]}_id".intern if configuration[:scope].is_a?(Symbol) && configuration[:scope].to_s !~ /_id$/
if configuration[:scope].is_a?(Symbol)
scope_condition_method = %(
def scope_condition
if #{configuration[:scope].to_s}.nil?
"#{configuration[:scope].to_s} IS NULL"
else
"#{configuration[:scope].to_s} = \#{#{configuration[:scope].to_s}}"
end
end
)
else
scope_condition_method = "def scope_condition() \"#{configuration[:scope]}\" end"
end
class_eval <<-EOV
include ActiveRecord::Acts::List::InstanceMethods
def acts_as_list_class
::#{self.name}
end
def position_column
'#{configuration[:column]}'
end
#{scope_condition_method}
before_destroy :remove_from_list
before_create :add_to_list_bottom
EOV
end
end
# All the methods available to a record that has had <tt>acts_as_list</tt> specified. Each method works
# by assuming the object to be the item in the list, so <tt>chapter.move_lower</tt> would move that chapter
# lower in the list of all chapters. Likewise, <tt>chapter.first?</tt> would return +true+ if that chapter is
# the first in the list of all chapters.
module InstanceMethods
# Insert the item at the given position (defaults to the top position of 1).
def insert_at(position = 1)
insert_at_position(position)
end
# Swap positions with the next lower item, if one exists.
def move_lower
return unless lower_item
acts_as_list_class.transaction do
lower_item.decrement_position
increment_position
end
end
# Swap positions with the next higher item, if one exists.
def move_higher
return unless higher_item
acts_as_list_class.transaction do
higher_item.increment_position
decrement_position
end
end
# Move to the bottom of the list. If the item is already in the list, the items below it have their
# position adjusted accordingly.
def move_to_bottom
return unless in_list?
acts_as_list_class.transaction do
decrement_positions_on_lower_items
assume_bottom_position
end
end
# Move to the top of the list. If the item is already in the list, the items above it have their
# position adjusted accordingly.
def move_to_top
return unless in_list?
acts_as_list_class.transaction do
increment_positions_on_higher_items
assume_top_position
end
end
# Move to the given position
def move_to=(pos)
case pos.to_s
when 'highest'
move_to_top
when 'higher'
move_higher
when 'lower'
move_lower
when 'lowest'
move_to_bottom
end
end
# Removes the item from the list.
def remove_from_list
if in_list?
decrement_positions_on_lower_items
update_attribute position_column, nil
end
end
# Increase the position of this item without adjusting the rest of the list.
def increment_position
return unless in_list?
update_attribute position_column, self.send(position_column).to_i + 1
end
# Decrease the position of this item without adjusting the rest of the list.
def decrement_position
return unless in_list?
update_attribute position_column, self.send(position_column).to_i - 1
end
# Return +true+ if this object is the first in the list.
def first?
return false unless in_list?
self.send(position_column) == 1
end
# Return +true+ if this object is the last in the list.
def last?
return false unless in_list?
self.send(position_column) == bottom_position_in_list
end
# Return the next higher item in the list.
def higher_item
return nil unless in_list?
acts_as_list_class.find(:first, :conditions =>
"#{scope_condition} AND #{position_column} = #{(send(position_column).to_i - 1).to_s}"
)
end
# Return the next lower item in the list.
def lower_item
return nil unless in_list?
acts_as_list_class.find(:first, :conditions =>
"#{scope_condition} AND #{position_column} = #{(send(position_column).to_i + 1).to_s}"
)
end
# Test if this record is in a list
def in_list?
!send(position_column).nil?
end
private
def add_to_list_top
increment_positions_on_all_items
end
def add_to_list_bottom
self[position_column] = bottom_position_in_list.to_i + 1
end
# Overwrite this method to define the scope of the list changes
def scope_condition() "1" end
# Returns the bottom position number in the list.
# bottom_position_in_list # => 2
def bottom_position_in_list(except = nil)
item = bottom_item(except)
item ? item.send(position_column) : 0
end
# Returns the bottom item
def bottom_item(except = nil)
conditions = scope_condition
conditions = "#{conditions} AND #{self.class.primary_key} != #{except.id}" if except
acts_as_list_class.find(:first, :conditions => conditions, :order => "#{position_column} DESC")
end
# Forces item to assume the bottom position in the list.
def assume_bottom_position
update_attribute(position_column, bottom_position_in_list(self).to_i + 1)
end
# Forces item to assume the top position in the list.
def assume_top_position
update_attribute(position_column, 1)
end
# This has the effect of moving all the higher items up one.
def decrement_positions_on_higher_items(position)
acts_as_list_class.update_all(
"#{position_column} = (#{position_column} - 1)", "#{scope_condition} AND #{position_column} <= #{position}"
)
end
# This has the effect of moving all the lower items up one.
def decrement_positions_on_lower_items
return unless in_list?
acts_as_list_class.update_all(
"#{position_column} = (#{position_column} - 1)", "#{scope_condition} AND #{position_column} > #{send(position_column).to_i}"
)
end
# This has the effect of moving all the higher items down one.
def increment_positions_on_higher_items
return unless in_list?
acts_as_list_class.update_all(
"#{position_column} = (#{position_column} + 1)", "#{scope_condition} AND #{position_column} < #{send(position_column).to_i}"
)
end
# This has the effect of moving all the lower items down one.
def increment_positions_on_lower_items(position)
acts_as_list_class.update_all(
"#{position_column} = (#{position_column} + 1)", "#{scope_condition} AND #{position_column} >= #{position}"
)
end
# Increments position (<tt>position_column</tt>) of all items in the list.
def increment_positions_on_all_items
acts_as_list_class.update_all(
"#{position_column} = (#{position_column} + 1)", "#{scope_condition}"
)
end
def insert_at_position(position)
remove_from_list
increment_positions_on_lower_items(position)
self.update_attribute(position_column, position)
end
end
end
end
end

View File

@@ -0,0 +1,332 @@
require 'test/unit'
require 'rubygems'
gem 'activerecord', '>= 1.15.4.7794'
require 'active_record'
require "#{File.dirname(__FILE__)}/../init"
ActiveRecord::Base.establish_connection(:adapter => "sqlite3", :dbfile => ":memory:")
def setup_db
ActiveRecord::Schema.define(:version => 1) do
create_table :mixins do |t|
t.column :pos, :integer
t.column :parent_id, :integer
t.column :created_at, :datetime
t.column :updated_at, :datetime
end
end
end
def teardown_db
ActiveRecord::Base.connection.tables.each do |table|
ActiveRecord::Base.connection.drop_table(table)
end
end
class Mixin < ActiveRecord::Base
end
class ListMixin < Mixin
acts_as_list :column => "pos", :scope => :parent
def self.table_name() "mixins" end
end
class ListMixinSub1 < ListMixin
end
class ListMixinSub2 < ListMixin
end
class ListWithStringScopeMixin < ActiveRecord::Base
acts_as_list :column => "pos", :scope => 'parent_id = #{parent_id}'
def self.table_name() "mixins" end
end
class ListTest < Test::Unit::TestCase
def setup
setup_db
(1..4).each { |counter| ListMixin.create! :pos => counter, :parent_id => 5 }
end
def teardown
teardown_db
end
def test_reordering
assert_equal [1, 2, 3, 4], ListMixin.find(:all, :conditions => 'parent_id = 5', :order => 'pos').map(&:id)
ListMixin.find(2).move_lower
assert_equal [1, 3, 2, 4], ListMixin.find(:all, :conditions => 'parent_id = 5', :order => 'pos').map(&:id)
ListMixin.find(2).move_higher
assert_equal [1, 2, 3, 4], ListMixin.find(:all, :conditions => 'parent_id = 5', :order => 'pos').map(&:id)
ListMixin.find(1).move_to_bottom
assert_equal [2, 3, 4, 1], ListMixin.find(:all, :conditions => 'parent_id = 5', :order => 'pos').map(&:id)
ListMixin.find(1).move_to_top
assert_equal [1, 2, 3, 4], ListMixin.find(:all, :conditions => 'parent_id = 5', :order => 'pos').map(&:id)
ListMixin.find(2).move_to_bottom
assert_equal [1, 3, 4, 2], ListMixin.find(:all, :conditions => 'parent_id = 5', :order => 'pos').map(&:id)
ListMixin.find(4).move_to_top
assert_equal [4, 1, 3, 2], ListMixin.find(:all, :conditions => 'parent_id = 5', :order => 'pos').map(&:id)
end
def test_move_to_bottom_with_next_to_last_item
assert_equal [1, 2, 3, 4], ListMixin.find(:all, :conditions => 'parent_id = 5', :order => 'pos').map(&:id)
ListMixin.find(3).move_to_bottom
assert_equal [1, 2, 4, 3], ListMixin.find(:all, :conditions => 'parent_id = 5', :order => 'pos').map(&:id)
end
def test_next_prev
assert_equal ListMixin.find(2), ListMixin.find(1).lower_item
assert_nil ListMixin.find(1).higher_item
assert_equal ListMixin.find(3), ListMixin.find(4).higher_item
assert_nil ListMixin.find(4).lower_item
end
def test_injection
item = ListMixin.new(:parent_id => 1)
assert_equal "parent_id = 1", item.scope_condition
assert_equal "pos", item.position_column
end
def test_insert
new = ListMixin.create(:parent_id => 20)
assert_equal 1, new.pos
assert new.first?
assert new.last?
new = ListMixin.create(:parent_id => 20)
assert_equal 2, new.pos
assert !new.first?
assert new.last?
new = ListMixin.create(:parent_id => 20)
assert_equal 3, new.pos
assert !new.first?
assert new.last?
new = ListMixin.create(:parent_id => 0)
assert_equal 1, new.pos
assert new.first?
assert new.last?
end
def test_insert_at
new = ListMixin.create(:parent_id => 20)
assert_equal 1, new.pos
new = ListMixin.create(:parent_id => 20)
assert_equal 2, new.pos
new = ListMixin.create(:parent_id => 20)
assert_equal 3, new.pos
new4 = ListMixin.create(:parent_id => 20)
assert_equal 4, new4.pos
new4.insert_at(3)
assert_equal 3, new4.pos
new.reload
assert_equal 4, new.pos
new.insert_at(2)
assert_equal 2, new.pos
new4.reload
assert_equal 4, new4.pos
new5 = ListMixin.create(:parent_id => 20)
assert_equal 5, new5.pos
new5.insert_at(1)
assert_equal 1, new5.pos
new4.reload
assert_equal 5, new4.pos
end
def test_delete_middle
assert_equal [1, 2, 3, 4], ListMixin.find(:all, :conditions => 'parent_id = 5', :order => 'pos').map(&:id)
ListMixin.find(2).destroy
assert_equal [1, 3, 4], ListMixin.find(:all, :conditions => 'parent_id = 5', :order => 'pos').map(&:id)
assert_equal 1, ListMixin.find(1).pos
assert_equal 2, ListMixin.find(3).pos
assert_equal 3, ListMixin.find(4).pos
ListMixin.find(1).destroy
assert_equal [3, 4], ListMixin.find(:all, :conditions => 'parent_id = 5', :order => 'pos').map(&:id)
assert_equal 1, ListMixin.find(3).pos
assert_equal 2, ListMixin.find(4).pos
end
def test_with_string_based_scope
new = ListWithStringScopeMixin.create(:parent_id => 500)
assert_equal 1, new.pos
assert new.first?
assert new.last?
end
def test_nil_scope
new1, new2, new3 = ListMixin.create, ListMixin.create, ListMixin.create
new2.move_higher
assert_equal [new2, new1, new3], ListMixin.find(:all, :conditions => 'parent_id IS NULL', :order => 'pos')
end
def test_remove_from_list_should_then_fail_in_list?
assert_equal true, ListMixin.find(1).in_list?
ListMixin.find(1).remove_from_list
assert_equal false, ListMixin.find(1).in_list?
end
def test_remove_from_list_should_set_position_to_nil
assert_equal [1, 2, 3, 4], ListMixin.find(:all, :conditions => 'parent_id = 5', :order => 'pos').map(&:id)
ListMixin.find(2).remove_from_list
assert_equal [2, 1, 3, 4], ListMixin.find(:all, :conditions => 'parent_id = 5', :order => 'pos').map(&:id)
assert_equal 1, ListMixin.find(1).pos
assert_equal nil, ListMixin.find(2).pos
assert_equal 2, ListMixin.find(3).pos
assert_equal 3, ListMixin.find(4).pos
end
def test_remove_before_destroy_does_not_shift_lower_items_twice
assert_equal [1, 2, 3, 4], ListMixin.find(:all, :conditions => 'parent_id = 5', :order => 'pos').map(&:id)
ListMixin.find(2).remove_from_list
ListMixin.find(2).destroy
assert_equal [1, 3, 4], ListMixin.find(:all, :conditions => 'parent_id = 5', :order => 'pos').map(&:id)
assert_equal 1, ListMixin.find(1).pos
assert_equal 2, ListMixin.find(3).pos
assert_equal 3, ListMixin.find(4).pos
end
end
class ListSubTest < Test::Unit::TestCase
def setup
setup_db
(1..4).each { |i| ((i % 2 == 1) ? ListMixinSub1 : ListMixinSub2).create! :pos => i, :parent_id => 5000 }
end
def teardown
teardown_db
end
def test_reordering
assert_equal [1, 2, 3, 4], ListMixin.find(:all, :conditions => 'parent_id = 5000', :order => 'pos').map(&:id)
ListMixin.find(2).move_lower
assert_equal [1, 3, 2, 4], ListMixin.find(:all, :conditions => 'parent_id = 5000', :order => 'pos').map(&:id)
ListMixin.find(2).move_higher
assert_equal [1, 2, 3, 4], ListMixin.find(:all, :conditions => 'parent_id = 5000', :order => 'pos').map(&:id)
ListMixin.find(1).move_to_bottom
assert_equal [2, 3, 4, 1], ListMixin.find(:all, :conditions => 'parent_id = 5000', :order => 'pos').map(&:id)
ListMixin.find(1).move_to_top
assert_equal [1, 2, 3, 4], ListMixin.find(:all, :conditions => 'parent_id = 5000', :order => 'pos').map(&:id)
ListMixin.find(2).move_to_bottom
assert_equal [1, 3, 4, 2], ListMixin.find(:all, :conditions => 'parent_id = 5000', :order => 'pos').map(&:id)
ListMixin.find(4).move_to_top
assert_equal [4, 1, 3, 2], ListMixin.find(:all, :conditions => 'parent_id = 5000', :order => 'pos').map(&:id)
end
def test_move_to_bottom_with_next_to_last_item
assert_equal [1, 2, 3, 4], ListMixin.find(:all, :conditions => 'parent_id = 5000', :order => 'pos').map(&:id)
ListMixin.find(3).move_to_bottom
assert_equal [1, 2, 4, 3], ListMixin.find(:all, :conditions => 'parent_id = 5000', :order => 'pos').map(&:id)
end
def test_next_prev
assert_equal ListMixin.find(2), ListMixin.find(1).lower_item
assert_nil ListMixin.find(1).higher_item
assert_equal ListMixin.find(3), ListMixin.find(4).higher_item
assert_nil ListMixin.find(4).lower_item
end
def test_injection
item = ListMixin.new("parent_id"=>1)
assert_equal "parent_id = 1", item.scope_condition
assert_equal "pos", item.position_column
end
def test_insert_at
new = ListMixin.create("parent_id" => 20)
assert_equal 1, new.pos
new = ListMixinSub1.create("parent_id" => 20)
assert_equal 2, new.pos
new = ListMixinSub2.create("parent_id" => 20)
assert_equal 3, new.pos
new4 = ListMixin.create("parent_id" => 20)
assert_equal 4, new4.pos
new4.insert_at(3)
assert_equal 3, new4.pos
new.reload
assert_equal 4, new.pos
new.insert_at(2)
assert_equal 2, new.pos
new4.reload
assert_equal 4, new4.pos
new5 = ListMixinSub1.create("parent_id" => 20)
assert_equal 5, new5.pos
new5.insert_at(1)
assert_equal 1, new5.pos
new4.reload
assert_equal 5, new4.pos
end
def test_delete_middle
assert_equal [1, 2, 3, 4], ListMixin.find(:all, :conditions => 'parent_id = 5000', :order => 'pos').map(&:id)
ListMixin.find(2).destroy
assert_equal [1, 3, 4], ListMixin.find(:all, :conditions => 'parent_id = 5000', :order => 'pos').map(&:id)
assert_equal 1, ListMixin.find(1).pos
assert_equal 2, ListMixin.find(3).pos
assert_equal 3, ListMixin.find(4).pos
ListMixin.find(1).destroy
assert_equal [3, 4], ListMixin.find(:all, :conditions => 'parent_id = 5000', :order => 'pos').map(&:id)
assert_equal 1, ListMixin.find(3).pos
assert_equal 2, ListMixin.find(4).pos
end
end

View File

@@ -0,0 +1,2 @@
require File.dirname(__FILE__) + '/lib/acts_as_searchable'
ActiveRecord::Base.send(:include, Redmine::Acts::Searchable)

View File

@@ -0,0 +1,133 @@
# Redmine - project management software
# Copyright (C) 2006-2012 Jean-Philippe Lang
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
module Redmine
module Acts
module Searchable
def self.included(base)
base.extend ClassMethods
end
module ClassMethods
# Options:
# * :columns - a column or an array of columns to search
# * :project_key - project foreign key (default to project_id)
# * :date_column - name of the datetime column (default to created_on)
# * :sort_order - name of the column used to sort results (default to :date_column or created_on)
# * :permission - permission required to search the model (default to :view_"objects")
def acts_as_searchable(options = {})
return if self.included_modules.include?(Redmine::Acts::Searchable::InstanceMethods)
cattr_accessor :searchable_options
self.searchable_options = options
if searchable_options[:columns].nil?
raise 'No searchable column defined.'
elsif !searchable_options[:columns].is_a?(Array)
searchable_options[:columns] = [] << searchable_options[:columns]
end
searchable_options[:project_key] ||= "#{table_name}.project_id"
searchable_options[:date_column] ||= "#{table_name}.created_on"
searchable_options[:order_column] ||= searchable_options[:date_column]
# Should we search custom fields on this model ?
searchable_options[:search_custom_fields] = !reflect_on_association(:custom_values).nil?
send :include, Redmine::Acts::Searchable::InstanceMethods
end
end
module InstanceMethods
def self.included(base)
base.extend ClassMethods
end
module ClassMethods
# Searches the model for the given tokens
# projects argument can be either nil (will search all projects), a project or an array of projects
# Returns the results and the results count
def search(tokens, projects=nil, options={})
if projects.is_a?(Array) && projects.empty?
# no results
return [[], 0]
end
# TODO: make user an argument
user = User.current
tokens = [] << tokens unless tokens.is_a?(Array)
projects = [] << projects unless projects.nil? || projects.is_a?(Array)
find_options = {:include => searchable_options[:include]}
find_options[:order] = "#{searchable_options[:order_column]} " + (options[:before] ? 'DESC' : 'ASC')
limit_options = {}
limit_options[:limit] = options[:limit] if options[:limit]
if options[:offset]
limit_options[:conditions] = "(#{searchable_options[:date_column]} " + (options[:before] ? '<' : '>') + "'#{connection.quoted_date(options[:offset])}')"
end
columns = searchable_options[:columns]
columns = columns[0..0] if options[:titles_only]
token_clauses = columns.collect {|column| "(LOWER(#{column}) LIKE ?)"}
if !options[:titles_only] && searchable_options[:search_custom_fields]
searchable_custom_field_ids = CustomField.find(:all,
:select => 'id',
:conditions => { :type => "#{self.name}CustomField",
:searchable => true }).collect(&:id)
if searchable_custom_field_ids.any?
custom_field_sql = "#{table_name}.id IN (SELECT customized_id FROM #{CustomValue.table_name}" +
" WHERE customized_type='#{self.name}' AND customized_id=#{table_name}.id AND LOWER(value) LIKE ?" +
" AND #{CustomValue.table_name}.custom_field_id IN (#{searchable_custom_field_ids.join(',')}))"
token_clauses << custom_field_sql
end
end
sql = (['(' + token_clauses.join(' OR ') + ')'] * tokens.size).join(options[:all_words] ? ' AND ' : ' OR ')
find_options[:conditions] = [sql, * (tokens.collect {|w| "%#{w.downcase}%"} * token_clauses.size).sort]
scope = self
project_conditions = []
if searchable_options.has_key?(:permission)
project_conditions << Project.allowed_to_condition(user, searchable_options[:permission] || :view_project)
elsif respond_to?(:visible)
scope = scope.visible(user)
else
ActiveSupport::Deprecation.warn "acts_as_searchable with implicit :permission option is deprecated. Add a visible scope to the #{self.name} model or use explicit :permission option."
project_conditions << Project.allowed_to_condition(user, "view_#{self.name.underscore.pluralize}".to_sym)
end
# TODO: use visible scope options instead
project_conditions << "#{searchable_options[:project_key]} IN (#{projects.collect(&:id).join(',')})" unless projects.nil?
project_conditions = project_conditions.empty? ? nil : project_conditions.join(' AND ')
results = []
results_count = 0
scope = scope.scoped({:conditions => project_conditions}).scoped(find_options)
results_count = scope.count(:all)
results = scope.find(:all, limit_options)
[results, results_count]
end
end
end
end
end
end

View File

@@ -0,0 +1,26 @@
acts_as_tree
============
Specify this +acts_as+ extension if you want to model a tree structure by providing a parent association and a children
association. This requires that you have a foreign key column, which by default is called +parent_id+.
class Category < ActiveRecord::Base
acts_as_tree :order => "name"
end
Example:
root
\_ child1
\_ subchild1
\_ subchild2
root = Category.create("name" => "root")
child1 = root.children.create("name" => "child1")
subchild1 = child1.children.create("name" => "subchild1")
root.parent # => nil
child1.parent # => root
root.children # => [child1]
root.children.first.children.first # => subchild1
Copyright (c) 2007 David Heinemeier Hansson, released under the MIT license

View File

@@ -0,0 +1,22 @@
require 'rake'
require 'rake/testtask'
require 'rake/rdoctask'
desc 'Default: run unit tests.'
task :default => :test
desc 'Test acts_as_tree plugin.'
Rake::TestTask.new(:test) do |t|
t.libs << 'lib'
t.pattern = 'test/**/*_test.rb'
t.verbose = true
end
desc 'Generate documentation for acts_as_tree plugin.'
Rake::RDocTask.new(:rdoc) do |rdoc|
rdoc.rdoc_dir = 'rdoc'
rdoc.title = 'acts_as_tree'
rdoc.options << '--line-numbers' << '--inline-source'
rdoc.rdoc_files.include('README')
rdoc.rdoc_files.include('lib/**/*.rb')
end

View File

@@ -0,0 +1 @@
ActiveRecord::Base.send :include, ActiveRecord::Acts::Tree

View File

@@ -0,0 +1,110 @@
module ActiveRecord
module Acts
module Tree
def self.included(base)
base.extend(ClassMethods)
end
# Specify this +acts_as+ extension if you want to model a tree structure by providing a parent association and a children
# association. This requires that you have a foreign key column, which by default is called +parent_id+.
#
# class Category < ActiveRecord::Base
# acts_as_tree :order => "name"
# end
#
# Example:
# root
# \_ child1
# \_ subchild1
# \_ subchild2
#
# root = Category.create("name" => "root")
# child1 = root.children.create("name" => "child1")
# subchild1 = child1.children.create("name" => "subchild1")
#
# root.parent # => nil
# child1.parent # => root
# root.children # => [child1]
# root.children.first.children.first # => subchild1
#
# In addition to the parent and children associations, the following instance methods are added to the class
# after calling <tt>acts_as_tree</tt>:
# * <tt>siblings</tt> - Returns all the children of the parent, excluding the current node (<tt>[subchild2]</tt> when called on <tt>subchild1</tt>)
# * <tt>self_and_siblings</tt> - Returns all the children of the parent, including the current node (<tt>[subchild1, subchild2]</tt> when called on <tt>subchild1</tt>)
# * <tt>ancestors</tt> - Returns all the ancestors of the current node (<tt>[child1, root]</tt> when called on <tt>subchild2</tt>)
# * <tt>root</tt> - Returns the root of the current node (<tt>root</tt> when called on <tt>subchild2</tt>)
module ClassMethods
# Configuration options are:
#
# * <tt>foreign_key</tt> - specifies the column name to use for tracking of the tree (default: +parent_id+)
# * <tt>order</tt> - makes it possible to sort the children according to this SQL snippet.
# * <tt>counter_cache</tt> - keeps a count in a +children_count+ column if set to +true+ (default: +false+).
def acts_as_tree(options = {})
configuration = { :foreign_key => "parent_id", :dependent => :destroy, :order => nil, :counter_cache => nil }
configuration.update(options) if options.is_a?(Hash)
belongs_to :parent, :class_name => name, :foreign_key => configuration[:foreign_key], :counter_cache => configuration[:counter_cache]
has_many :children, :class_name => name, :foreign_key => configuration[:foreign_key], :order => configuration[:order], :dependent => configuration[:dependent]
class_eval <<-EOV
include ActiveRecord::Acts::Tree::InstanceMethods
def self.roots
find(:all, :conditions => "#{configuration[:foreign_key]} IS NULL", :order => #{configuration[:order].nil? ? "nil" : %Q{"#{configuration[:order]}"}})
end
def self.root
find(:first, :conditions => "#{configuration[:foreign_key]} IS NULL", :order => #{configuration[:order].nil? ? "nil" : %Q{"#{configuration[:order]}"}})
end
EOV
end
end
module InstanceMethods
# Returns list of ancestors, starting from parent until root.
#
# subchild1.ancestors # => [child1, root]
def ancestors
node, nodes = self, []
nodes << node = node.parent while node.parent
nodes
end
# Returns list of descendants.
#
# root.descendants # => [child1, subchild1, subchild2]
def descendants
children + children.collect(&:children).flatten
end
# Returns list of descendants and a reference to the current node.
#
# root.self_and_descendants # => [root, child1, subchild1, subchild2]
def self_and_descendants
[self] + descendants
end
# Returns the root node of the tree.
def root
node = self
node = node.parent while node.parent
node
end
# Returns all siblings of the current node.
#
# subchild1.siblings # => [subchild2]
def siblings
self_and_siblings - [self]
end
# Returns all siblings and a reference to the current node.
#
# subchild1.self_and_siblings # => [subchild1, subchild2]
def self_and_siblings
parent ? parent.children : self.class.roots
end
end
end
end
end

View File

@@ -0,0 +1,219 @@
require 'test/unit'
require 'rubygems'
require 'active_record'
$:.unshift File.dirname(__FILE__) + '/../lib'
require File.dirname(__FILE__) + '/../init'
class Test::Unit::TestCase
def assert_queries(num = 1)
$query_count = 0
yield
ensure
assert_equal num, $query_count, "#{$query_count} instead of #{num} queries were executed."
end
def assert_no_queries(&block)
assert_queries(0, &block)
end
end
ActiveRecord::Base.establish_connection(:adapter => "sqlite3", :dbfile => ":memory:")
# AR keeps printing annoying schema statements
$stdout = StringIO.new
def setup_db
ActiveRecord::Base.logger
ActiveRecord::Schema.define(:version => 1) do
create_table :mixins do |t|
t.column :type, :string
t.column :parent_id, :integer
end
end
end
def teardown_db
ActiveRecord::Base.connection.tables.each do |table|
ActiveRecord::Base.connection.drop_table(table)
end
end
class Mixin < ActiveRecord::Base
end
class TreeMixin < Mixin
acts_as_tree :foreign_key => "parent_id", :order => "id"
end
class TreeMixinWithoutOrder < Mixin
acts_as_tree :foreign_key => "parent_id"
end
class RecursivelyCascadedTreeMixin < Mixin
acts_as_tree :foreign_key => "parent_id"
has_one :first_child, :class_name => 'RecursivelyCascadedTreeMixin', :foreign_key => :parent_id
end
class TreeTest < Test::Unit::TestCase
def setup
setup_db
@root1 = TreeMixin.create!
@root_child1 = TreeMixin.create! :parent_id => @root1.id
@child1_child = TreeMixin.create! :parent_id => @root_child1.id
@root_child2 = TreeMixin.create! :parent_id => @root1.id
@root2 = TreeMixin.create!
@root3 = TreeMixin.create!
end
def teardown
teardown_db
end
def test_children
assert_equal @root1.children, [@root_child1, @root_child2]
assert_equal @root_child1.children, [@child1_child]
assert_equal @child1_child.children, []
assert_equal @root_child2.children, []
end
def test_parent
assert_equal @root_child1.parent, @root1
assert_equal @root_child1.parent, @root_child2.parent
assert_nil @root1.parent
end
def test_delete
assert_equal 6, TreeMixin.count
@root1.destroy
assert_equal 2, TreeMixin.count
@root2.destroy
@root3.destroy
assert_equal 0, TreeMixin.count
end
def test_insert
@extra = @root1.children.create
assert @extra
assert_equal @extra.parent, @root1
assert_equal 3, @root1.children.size
assert @root1.children.include?(@extra)
assert @root1.children.include?(@root_child1)
assert @root1.children.include?(@root_child2)
end
def test_ancestors
assert_equal [], @root1.ancestors
assert_equal [@root1], @root_child1.ancestors
assert_equal [@root_child1, @root1], @child1_child.ancestors
assert_equal [@root1], @root_child2.ancestors
assert_equal [], @root2.ancestors
assert_equal [], @root3.ancestors
end
def test_root
assert_equal @root1, TreeMixin.root
assert_equal @root1, @root1.root
assert_equal @root1, @root_child1.root
assert_equal @root1, @child1_child.root
assert_equal @root1, @root_child2.root
assert_equal @root2, @root2.root
assert_equal @root3, @root3.root
end
def test_roots
assert_equal [@root1, @root2, @root3], TreeMixin.roots
end
def test_siblings
assert_equal [@root2, @root3], @root1.siblings
assert_equal [@root_child2], @root_child1.siblings
assert_equal [], @child1_child.siblings
assert_equal [@root_child1], @root_child2.siblings
assert_equal [@root1, @root3], @root2.siblings
assert_equal [@root1, @root2], @root3.siblings
end
def test_self_and_siblings
assert_equal [@root1, @root2, @root3], @root1.self_and_siblings
assert_equal [@root_child1, @root_child2], @root_child1.self_and_siblings
assert_equal [@child1_child], @child1_child.self_and_siblings
assert_equal [@root_child1, @root_child2], @root_child2.self_and_siblings
assert_equal [@root1, @root2, @root3], @root2.self_and_siblings
assert_equal [@root1, @root2, @root3], @root3.self_and_siblings
end
end
class TreeTestWithEagerLoading < Test::Unit::TestCase
def setup
teardown_db
setup_db
@root1 = TreeMixin.create!
@root_child1 = TreeMixin.create! :parent_id => @root1.id
@child1_child = TreeMixin.create! :parent_id => @root_child1.id
@root_child2 = TreeMixin.create! :parent_id => @root1.id
@root2 = TreeMixin.create!
@root3 = TreeMixin.create!
@rc1 = RecursivelyCascadedTreeMixin.create!
@rc2 = RecursivelyCascadedTreeMixin.create! :parent_id => @rc1.id
@rc3 = RecursivelyCascadedTreeMixin.create! :parent_id => @rc2.id
@rc4 = RecursivelyCascadedTreeMixin.create! :parent_id => @rc3.id
end
def teardown
teardown_db
end
def test_eager_association_loading
roots = TreeMixin.find(:all, :include => :children, :conditions => "mixins.parent_id IS NULL", :order => "mixins.id")
assert_equal [@root1, @root2, @root3], roots
assert_no_queries do
assert_equal 2, roots[0].children.size
assert_equal 0, roots[1].children.size
assert_equal 0, roots[2].children.size
end
end
def test_eager_association_loading_with_recursive_cascading_three_levels_has_many
root_node = RecursivelyCascadedTreeMixin.find(:first, :include => { :children => { :children => :children } }, :order => 'mixins.id')
assert_equal @rc4, assert_no_queries { root_node.children.first.children.first.children.first }
end
def test_eager_association_loading_with_recursive_cascading_three_levels_has_one
root_node = RecursivelyCascadedTreeMixin.find(:first, :include => { :first_child => { :first_child => :first_child } }, :order => 'mixins.id')
assert_equal @rc4, assert_no_queries { root_node.first_child.first_child.first_child }
end
def test_eager_association_loading_with_recursive_cascading_three_levels_belongs_to
leaf_node = RecursivelyCascadedTreeMixin.find(:first, :include => { :parent => { :parent => :parent } }, :order => 'mixins.id DESC')
assert_equal @rc1, assert_no_queries { leaf_node.parent.parent.parent }
end
end
class TreeTestWithoutOrder < Test::Unit::TestCase
def setup
setup_db
@root1 = TreeMixinWithoutOrder.create!
@root2 = TreeMixinWithoutOrder.create!
end
def teardown
teardown_db
end
def test_root
assert [@root1, @root2].include?(TreeMixinWithoutOrder.root)
end
def test_roots
assert_equal [], [@root1, @root2] - TreeMixinWithoutOrder.roots
end
end

View File

View File

View File

View File

@@ -0,0 +1,74 @@
*SVN* (version numbers are overrated)
* (5 Oct 2006) Allow customization of #versions association options [Dan Peterson]
*0.5.1*
* (8 Aug 2006) Versioned models now belong to the unversioned model. @article_version.article.class => Article [Aslak Hellesoy]
*0.5* # do versions even matter for plugins?
* (21 Apr 2006) Added without_locking and without_revision methods.
Foo.without_revision do
@foo.update_attributes ...
end
*0.4*
* (28 March 2006) Rename non_versioned_fields to non_versioned_columns (old one is kept for compatibility).
* (28 March 2006) Made explicit documentation note that string column names are required for non_versioned_columns.
*0.3.1*
* (7 Jan 2006) explicitly set :foreign_key option for the versioned model's belongs_to assocation for STI [Caged]
* (7 Jan 2006) added tests to prove has_many :through joins work
*0.3*
* (2 Jan 2006) added ability to share a mixin with versioned class
* (2 Jan 2006) changed the dynamic version model to MyModel::Version
*0.2.4*
* (27 Nov 2005) added note about possible destructive behavior of if_changed? [Michael Schuerig]
*0.2.3*
* (12 Nov 2005) fixed bug with old behavior of #blank? [Michael Schuerig]
* (12 Nov 2005) updated tests to use ActiveRecord Schema
*0.2.2*
* (3 Nov 2005) added documentation note to #acts_as_versioned [Martin Jul]
*0.2.1*
* (6 Oct 2005) renamed dirty? to changed? to keep it uniform. it was aliased to keep it backwards compatible.
*0.2*
* (6 Oct 2005) added find_versions and find_version class methods.
* (6 Oct 2005) removed transaction from create_versioned_table().
this way you can specify your own transaction around a group of operations.
* (30 Sep 2005) fixed bug where find_versions() would order by 'version' twice. (found by Joe Clark)
* (26 Sep 2005) added :sequence_name option to acts_as_versioned to set the sequence name on the versioned model
*0.1.3* (18 Sep 2005)
* First RubyForge release
*0.1.2*
* check if module is already included when acts_as_versioned is called
*0.1.1*
* Adding tests and rdocs
*0.1*
* Initial transfer from Rails ticket: http://dev.rubyonrails.com/ticket/1974

View File

@@ -0,0 +1,20 @@
Copyright (c) 2005 Rick Olson
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.

View File

@@ -0,0 +1,28 @@
= acts_as_versioned
This library adds simple versioning to an ActiveRecord module. ActiveRecord is required.
== Resources
Install
* gem install acts_as_versioned
Rubyforge project
* http://rubyforge.org/projects/ar-versioned
RDocs
* http://ar-versioned.rubyforge.org
Subversion
* http://techno-weenie.net/svn/projects/acts_as_versioned
Collaboa
* http://collaboa.techno-weenie.net/repository/browse/acts_as_versioned
Special thanks to Dreamer on ##rubyonrails for help in early testing. His ServerSideWiki (http://serversidewiki.com)
was the first project to use acts_as_versioned <em>in the wild</em>.

View File

@@ -0,0 +1,41 @@
== Creating the test database
The default name for the test databases is "activerecord_versioned". If you
want to use another database name then be sure to update the connection
adapter setups you want to test with in test/connections/<your database>/connection.rb.
When you have the database online, you can import the fixture tables with
the test/fixtures/db_definitions/*.sql files.
Make sure that you create database objects with the same user that you specified in i
connection.rb otherwise (on Postgres, at least) tests for default values will fail.
== Running with Rake
The easiest way to run the unit tests is through Rake. The default task runs
the entire test suite for all the adapters. You can also run the suite on just
one adapter by using the tasks test_mysql_ruby, test_ruby_mysql, test_sqlite,
or test_postresql. For more information, checkout the full array of rake tasks with "rake -T"
Rake can be found at http://rake.rubyforge.org
== Running by hand
Unit tests are located in test directory. If you only want to run a single test suite,
or don't want to bother with Rake, you can do so with something like:
cd test; ruby -I "connections/native_mysql" base_test.rb
That'll run the base suite using the MySQL-Ruby adapter. Change the adapter
and test suite name as needed.
== Faster tests
If you are using a database that supports transactions, you can set the
"AR_TX_FIXTURES" environment variable to "yes" to use transactional fixtures.
This gives a very large speed boost. With rake:
rake AR_TX_FIXTURES=yes
Or, by hand:
AR_TX_FIXTURES=yes ruby -I connections/native_sqlite3 base_test.rb

View File

@@ -0,0 +1,182 @@
require 'rubygems'
Gem::manage_gems
require 'rake/rdoctask'
require 'rake/packagetask'
require 'rake/gempackagetask'
require 'rake/testtask'
require 'rake/contrib/rubyforgepublisher'
PKG_NAME = 'acts_as_versioned'
PKG_VERSION = '0.3.1'
PKG_FILE_NAME = "#{PKG_NAME}-#{PKG_VERSION}"
PROD_HOST = "technoweenie@bidwell.textdrive.com"
RUBY_FORGE_PROJECT = 'ar-versioned'
RUBY_FORGE_USER = 'technoweenie'
desc 'Default: run unit tests.'
task :default => :test
desc 'Test the calculations plugin.'
Rake::TestTask.new(:test) do |t|
t.libs << 'lib'
t.pattern = 'test/**/*_test.rb'
t.verbose = true
end
desc 'Generate documentation for the calculations plugin.'
Rake::RDocTask.new(:rdoc) do |rdoc|
rdoc.rdoc_dir = 'rdoc'
rdoc.title = "#{PKG_NAME} -- Simple versioning with active record models"
rdoc.options << '--line-numbers --inline-source'
rdoc.rdoc_files.include('README', 'CHANGELOG', 'RUNNING_UNIT_TESTS')
rdoc.rdoc_files.include('lib/**/*.rb')
end
spec = Gem::Specification.new do |s|
s.name = PKG_NAME
s.version = PKG_VERSION
s.platform = Gem::Platform::RUBY
s.summary = "Simple versioning with active record models"
s.files = FileList["{lib,test}/**/*"].to_a + %w(README MIT-LICENSE CHANGELOG RUNNING_UNIT_TESTS)
s.files.delete "acts_as_versioned_plugin.sqlite.db"
s.files.delete "acts_as_versioned_plugin.sqlite3.db"
s.files.delete "test/debug.log"
s.require_path = 'lib'
s.autorequire = 'acts_as_versioned'
s.has_rdoc = true
s.test_files = Dir['test/**/*_test.rb']
s.add_dependency 'activerecord', '>= 1.10.1'
s.add_dependency 'activesupport', '>= 1.1.1'
s.author = "Rick Olson"
s.email = "technoweenie@gmail.com"
s.homepage = "http://techno-weenie.net"
end
Rake::GemPackageTask.new(spec) do |pkg|
pkg.need_tar = true
end
desc "Publish the API documentation"
task :pdoc => [:rdoc] do
Rake::RubyForgePublisher.new(RUBY_FORGE_PROJECT, RUBY_FORGE_USER).upload
end
desc 'Publish the gem and API docs'
task :publish => [:pdoc, :rubyforge_upload]
desc "Publish the release files to RubyForge."
task :rubyforge_upload => :package do
files = %w(gem tgz).map { |ext| "pkg/#{PKG_FILE_NAME}.#{ext}" }
if RUBY_FORGE_PROJECT then
require 'net/http'
require 'open-uri'
project_uri = "http://rubyforge.org/projects/#{RUBY_FORGE_PROJECT}/"
project_data = open(project_uri) { |data| data.read }
group_id = project_data[/[?&]group_id=(\d+)/, 1]
raise "Couldn't get group id" unless group_id
# This echos password to shell which is a bit sucky
if ENV["RUBY_FORGE_PASSWORD"]
password = ENV["RUBY_FORGE_PASSWORD"]
else
print "#{RUBY_FORGE_USER}@rubyforge.org's password: "
password = STDIN.gets.chomp
end
login_response = Net::HTTP.start("rubyforge.org", 80) do |http|
data = [
"login=1",
"form_loginname=#{RUBY_FORGE_USER}",
"form_pw=#{password}"
].join("&")
http.post("/account/login.php", data)
end
cookie = login_response["set-cookie"]
raise "Login failed" unless cookie
headers = { "Cookie" => cookie }
release_uri = "http://rubyforge.org/frs/admin/?group_id=#{group_id}"
release_data = open(release_uri, headers) { |data| data.read }
package_id = release_data[/[?&]package_id=(\d+)/, 1]
raise "Couldn't get package id" unless package_id
first_file = true
release_id = ""
files.each do |filename|
basename = File.basename(filename)
file_ext = File.extname(filename)
file_data = File.open(filename, "rb") { |file| file.read }
puts "Releasing #{basename}..."
release_response = Net::HTTP.start("rubyforge.org", 80) do |http|
release_date = Time.now.strftime("%Y-%m-%d %H:%M")
type_map = {
".zip" => "3000",
".tgz" => "3110",
".gz" => "3110",
".gem" => "1400"
}; type_map.default = "9999"
type = type_map[file_ext]
boundary = "rubyqMY6QN9bp6e4kS21H4y0zxcvoor"
query_hash = if first_file then
{
"group_id" => group_id,
"package_id" => package_id,
"release_name" => PKG_FILE_NAME,
"release_date" => release_date,
"type_id" => type,
"processor_id" => "8000", # Any
"release_notes" => "",
"release_changes" => "",
"preformatted" => "1",
"submit" => "1"
}
else
{
"group_id" => group_id,
"release_id" => release_id,
"package_id" => package_id,
"step2" => "1",
"type_id" => type,
"processor_id" => "8000", # Any
"submit" => "Add This File"
}
end
query = "?" + query_hash.map do |(name, value)|
[name, URI.encode(value)].join("=")
end.join("&")
data = [
"--" + boundary,
"Content-Disposition: form-data; name=\"userfile\"; filename=\"#{basename}\"",
"Content-Type: application/octet-stream",
"Content-Transfer-Encoding: binary",
"", file_data, ""
].join("\x0D\x0A")
release_headers = headers.merge(
"Content-Type" => "multipart/form-data; boundary=#{boundary}"
)
target = first_file ? "/frs/admin/qrs.php" : "/frs/admin/editrelease.php"
http.post(target + query, data, release_headers)
end
if first_file then
release_id = release_response.body[/release_id=(\d+)/, 1]
raise("Couldn't get release id") unless release_id
end
first_file = false
end
end
end

View File

@@ -0,0 +1 @@
require 'acts_as_versioned'

View File

@@ -0,0 +1,566 @@
# Copyright (c) 2005 Rick Olson
#
# 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.
module ActiveRecord #:nodoc:
module Acts #:nodoc:
# Specify this act if you want to save a copy of the row in a versioned table. This assumes there is a
# versioned table ready and that your model has a version field. This works with optimistic locking if the lock_version
# column is present as well.
#
# The class for the versioned model is derived the first time it is seen. Therefore, if you change your database schema you have to restart
# your container for the changes to be reflected. In development mode this usually means restarting WEBrick.
#
# class Page < ActiveRecord::Base
# # assumes pages_versions table
# acts_as_versioned
# end
#
# Example:
#
# page = Page.create(:title => 'hello world!')
# page.version # => 1
#
# page.title = 'hello world'
# page.save
# page.version # => 2
# page.versions.size # => 2
#
# page.revert_to(1) # using version number
# page.title # => 'hello world!'
#
# page.revert_to(page.versions.last) # using versioned instance
# page.title # => 'hello world'
#
# page.versions.earliest # efficient query to find the first version
# page.versions.latest # efficient query to find the most recently created version
#
#
# Simple Queries to page between versions
#
# page.versions.before(version)
# page.versions.after(version)
#
# Access the previous/next versions from the versioned model itself
#
# version = page.versions.latest
# version.previous # go back one version
# version.next # go forward one version
#
# See ActiveRecord::Acts::Versioned::ClassMethods#acts_as_versioned for configuration options
module Versioned
CALLBACKS = [:set_new_version, :save_version_on_create, :save_version?, :clear_altered_attributes]
def self.included(base) # :nodoc:
base.extend ClassMethods
end
module ClassMethods
# == Configuration options
#
# * <tt>class_name</tt> - versioned model class name (default: PageVersion in the above example)
# * <tt>table_name</tt> - versioned model table name (default: page_versions in the above example)
# * <tt>foreign_key</tt> - foreign key used to relate the versioned model to the original model (default: page_id in the above example)
# * <tt>inheritance_column</tt> - name of the column to save the model's inheritance_column value for STI. (default: versioned_type)
# * <tt>version_column</tt> - name of the column in the model that keeps the version number (default: version)
# * <tt>sequence_name</tt> - name of the custom sequence to be used by the versioned model.
# * <tt>limit</tt> - number of revisions to keep, defaults to unlimited
# * <tt>if</tt> - symbol of method to check before saving a new version. If this method returns false, a new version is not saved.
# For finer control, pass either a Proc or modify Model#version_condition_met?
#
# acts_as_versioned :if => Proc.new { |auction| !auction.expired? }
#
# or...
#
# class Auction
# def version_condition_met? # totally bypasses the <tt>:if</tt> option
# !expired?
# end
# end
#
# * <tt>if_changed</tt> - Simple way of specifying attributes that are required to be changed before saving a model. This takes
# either a symbol or array of symbols. WARNING - This will attempt to overwrite any attribute setters you may have.
# Use this instead if you want to write your own attribute setters (and ignore if_changed):
#
# def name=(new_name)
# write_changed_attribute :name, new_name
# end
#
# * <tt>extend</tt> - Lets you specify a module to be mixed in both the original and versioned models. You can also just pass a block
# to create an anonymous mixin:
#
# class Auction
# acts_as_versioned do
# def started?
# !started_at.nil?
# end
# end
# end
#
# or...
#
# module AuctionExtension
# def started?
# !started_at.nil?
# end
# end
# class Auction
# acts_as_versioned :extend => AuctionExtension
# end
#
# Example code:
#
# @auction = Auction.find(1)
# @auction.started?
# @auction.versions.first.started?
#
# == Database Schema
#
# The model that you're versioning needs to have a 'version' attribute. The model is versioned
# into a table called #{model}_versions where the model name is singlular. The _versions table should
# contain all the fields you want versioned, the same version column, and a #{model}_id foreign key field.
#
# A lock_version field is also accepted if your model uses Optimistic Locking. If your table uses Single Table inheritance,
# then that field is reflected in the versioned model as 'versioned_type' by default.
#
# Acts_as_versioned comes prepared with the ActiveRecord::Acts::Versioned::ActMethods::ClassMethods#create_versioned_table
# method, perfect for a migration. It will also create the version column if the main model does not already have it.
#
# class AddVersions < ActiveRecord::Migration
# def self.up
# # create_versioned_table takes the same options hash
# # that create_table does
# Post.create_versioned_table
# end
#
# def self.down
# Post.drop_versioned_table
# end
# end
#
# == Changing What Fields Are Versioned
#
# By default, acts_as_versioned will version all but these fields:
#
# [self.primary_key, inheritance_column, 'version', 'lock_version', versioned_inheritance_column]
#
# You can add or change those by modifying #non_versioned_columns. Note that this takes strings and not symbols.
#
# class Post < ActiveRecord::Base
# acts_as_versioned
# self.non_versioned_columns << 'comments_count'
# end
#
def acts_as_versioned(options = {}, &extension)
# don't allow multiple calls
return if self.included_modules.include?(ActiveRecord::Acts::Versioned::ActMethods)
send :include, ActiveRecord::Acts::Versioned::ActMethods
cattr_accessor :versioned_class_name, :versioned_foreign_key, :versioned_table_name, :versioned_inheritance_column,
:version_column, :max_version_limit, :track_altered_attributes, :version_condition, :version_sequence_name, :non_versioned_columns,
:version_association_options
# legacy
alias_method :non_versioned_fields, :non_versioned_columns
alias_method :non_versioned_fields=, :non_versioned_columns=
class << self
alias_method :non_versioned_fields, :non_versioned_columns
alias_method :non_versioned_fields=, :non_versioned_columns=
end
send :attr_accessor, :altered_attributes
self.versioned_class_name = options[:class_name] || "Version"
self.versioned_foreign_key = options[:foreign_key] || self.to_s.foreign_key
self.versioned_table_name = options[:table_name] || "#{table_name_prefix}#{base_class.name.demodulize.underscore}_versions#{table_name_suffix}"
self.versioned_inheritance_column = options[:inheritance_column] || "versioned_#{inheritance_column}"
self.version_column = options[:version_column] || 'version'
self.version_sequence_name = options[:sequence_name]
self.max_version_limit = options[:limit].to_i
self.version_condition = options[:if] || true
self.non_versioned_columns = [self.primary_key, inheritance_column, 'version', 'lock_version', versioned_inheritance_column]
self.version_association_options = {
:class_name => "#{self.to_s}::#{versioned_class_name}",
:foreign_key => versioned_foreign_key,
:dependent => :delete_all
}.merge(options[:association_options] || {})
if block_given?
extension_module_name = "#{versioned_class_name}Extension"
silence_warnings do
self.const_set(extension_module_name, Module.new(&extension))
end
options[:extend] = self.const_get(extension_module_name)
end
class_eval do
has_many :versions, version_association_options do
# finds earliest version of this record
def earliest
@earliest ||= find(:first, :order => 'version')
end
# find latest version of this record
def latest
@latest ||= find(:first, :order => 'version desc')
end
end
before_save :set_new_version
after_create :save_version_on_create
after_update :save_version
after_save :clear_old_versions
after_save :clear_altered_attributes
unless options[:if_changed].nil?
self.track_altered_attributes = true
options[:if_changed] = [options[:if_changed]] unless options[:if_changed].is_a?(Array)
options[:if_changed].each do |attr_name|
define_method("#{attr_name}=") do |value|
write_changed_attribute attr_name, value
end
end
end
include options[:extend] if options[:extend].is_a?(Module)
end
# create the dynamic versioned model
const_set(versioned_class_name, Class.new(ActiveRecord::Base)).class_eval do
def self.reloadable? ; false ; end
# find first version before the given version
def self.before(version)
find :first, :order => 'version desc',
:conditions => ["#{original_class.versioned_foreign_key} = ? and version < ?", version.send(original_class.versioned_foreign_key), version.version]
end
# find first version after the given version.
def self.after(version)
find :first, :order => 'version',
:conditions => ["#{original_class.versioned_foreign_key} = ? and version > ?", version.send(original_class.versioned_foreign_key), version.version]
end
def previous
self.class.before(self)
end
def next
self.class.after(self)
end
def versions_count
page.version
end
end
versioned_class.cattr_accessor :original_class
versioned_class.original_class = self
versioned_class.set_table_name versioned_table_name
versioned_class.belongs_to self.to_s.demodulize.underscore.to_sym,
:class_name => "::#{self.to_s}",
:foreign_key => versioned_foreign_key
versioned_class.send :include, options[:extend] if options[:extend].is_a?(Module)
versioned_class.set_sequence_name version_sequence_name if version_sequence_name
end
end
module ActMethods
def self.included(base) # :nodoc:
base.extend ClassMethods
end
# Finds a specific version of this record
def find_version(version = nil)
self.class.find_version(id, version)
end
# Saves a version of the model if applicable
def save_version
save_version_on_create if save_version?
end
# Saves a version of the model in the versioned table. This is called in the after_save callback by default
def save_version_on_create
rev = self.class.versioned_class.new
self.clone_versioned_model(self, rev)
rev.version = send(self.class.version_column)
rev.send("#{self.class.versioned_foreign_key}=", self.id)
rev.save
end
# Clears old revisions if a limit is set with the :limit option in <tt>acts_as_versioned</tt>.
# Override this method to set your own criteria for clearing old versions.
def clear_old_versions
return if self.class.max_version_limit == 0
excess_baggage = send(self.class.version_column).to_i - self.class.max_version_limit
if excess_baggage > 0
sql = "DELETE FROM #{self.class.versioned_table_name} WHERE version <= #{excess_baggage} AND #{self.class.versioned_foreign_key} = #{self.id}"
self.class.versioned_class.connection.execute sql
end
end
def versions_count
version
end
# Reverts a model to a given version. Takes either a version number or an instance of the versioned model
def revert_to(version)
if version.is_a?(self.class.versioned_class)
return false unless version.send(self.class.versioned_foreign_key) == self.id and !version.new_record?
else
return false unless version = versions.find_by_version(version)
end
self.clone_versioned_model(version, self)
self.send("#{self.class.version_column}=", version.version)
true
end
# Reverts a model to a given version and saves the model.
# Takes either a version number or an instance of the versioned model
def revert_to!(version)
revert_to(version) ? save_without_revision : false
end
# Temporarily turns off Optimistic Locking while saving. Used when reverting so that a new version is not created.
def save_without_revision
save_without_revision!
true
rescue
false
end
def save_without_revision!
without_locking do
without_revision do
save!
end
end
end
# Returns an array of attribute keys that are versioned. See non_versioned_columns
def versioned_attributes
self.attributes.keys.select { |k| !self.class.non_versioned_columns.include?(k) }
end
# If called with no parameters, gets whether the current model has changed and needs to be versioned.
# If called with a single parameter, gets whether the parameter has changed.
def changed?(attr_name = nil)
attr_name.nil? ?
(!self.class.track_altered_attributes || (altered_attributes && altered_attributes.length > 0)) :
(altered_attributes && altered_attributes.include?(attr_name.to_s))
end
# keep old dirty? method
alias_method :dirty?, :changed?
# Clones a model. Used when saving a new version or reverting a model's version.
def clone_versioned_model(orig_model, new_model)
self.versioned_attributes.each do |key|
new_model.send("#{key}=", orig_model.send(key)) if orig_model.has_attribute?(key)
end
if self.class.columns_hash.include?(self.class.inheritance_column)
if orig_model.is_a?(self.class.versioned_class)
new_model[new_model.class.inheritance_column] = orig_model[self.class.versioned_inheritance_column]
elsif new_model.is_a?(self.class.versioned_class)
new_model[self.class.versioned_inheritance_column] = orig_model[orig_model.class.inheritance_column]
end
end
end
# Checks whether a new version shall be saved or not. Calls <tt>version_condition_met?</tt> and <tt>changed?</tt>.
def save_version?
version_condition_met? && changed?
end
# Checks condition set in the :if option to check whether a revision should be created or not. Override this for
# custom version condition checking.
def version_condition_met?
case
when version_condition.is_a?(Symbol)
send(version_condition)
when version_condition.respond_to?(:call) && (version_condition.arity == 1 || version_condition.arity == -1)
version_condition.call(self)
else
version_condition
end
end
# Executes the block with the versioning callbacks disabled.
#
# @foo.without_revision do
# @foo.save
# end
#
def without_revision(&block)
self.class.without_revision(&block)
end
# Turns off optimistic locking for the duration of the block
#
# @foo.without_locking do
# @foo.save
# end
#
def without_locking(&block)
self.class.without_locking(&block)
end
def empty_callback() end #:nodoc:
protected
# sets the new version before saving, unless you're using optimistic locking. In that case, let it take care of the version.
def set_new_version
self.send("#{self.class.version_column}=", self.next_version) if new_record? || (!locking_enabled? && save_version?)
end
# Gets the next available version for the current record, or 1 for a new record
def next_version
return 1 if new_record?
(versions.calculate(:max, :version) || 0) + 1
end
# clears current changed attributes. Called after save.
def clear_altered_attributes
self.altered_attributes = []
end
def write_changed_attribute(attr_name, attr_value)
# Convert to db type for comparison. Avoids failing Float<=>String comparisons.
attr_value_for_db = self.class.columns_hash[attr_name.to_s].type_cast(attr_value)
(self.altered_attributes ||= []) << attr_name.to_s unless self.changed?(attr_name) || self.send(attr_name) == attr_value_for_db
write_attribute(attr_name, attr_value_for_db)
end
module ClassMethods
# Finds a specific version of a specific row of this model
def find_version(id, version = nil)
return find(id) unless version
conditions = ["#{versioned_foreign_key} = ? AND version = ?", id, version]
options = { :conditions => conditions, :limit => 1 }
if result = find_versions(id, options).first
result
else
raise RecordNotFound, "Couldn't find #{name} with ID=#{id} and VERSION=#{version}"
end
end
# Finds versions of a specific model. Takes an options hash like <tt>find</tt>
def find_versions(id, options = {})
versioned_class.find :all, {
:conditions => ["#{versioned_foreign_key} = ?", id],
:order => 'version' }.merge(options)
end
# Returns an array of columns that are versioned. See non_versioned_columns
def versioned_columns
self.columns.select { |c| !non_versioned_columns.include?(c.name) }
end
# Returns an instance of the dynamic versioned model
def versioned_class
const_get versioned_class_name
end
# Rake migration task to create the versioned table using options passed to acts_as_versioned
def create_versioned_table(create_table_options = {})
# create version column in main table if it does not exist
if !self.content_columns.find { |c| %w(version lock_version).include? c.name }
self.connection.add_column table_name, :version, :integer
end
self.connection.create_table(versioned_table_name, create_table_options) do |t|
t.column versioned_foreign_key, :integer
t.column :version, :integer
end
updated_col = nil
self.versioned_columns.each do |col|
updated_col = col if !updated_col && %(updated_at updated_on).include?(col.name)
self.connection.add_column versioned_table_name, col.name, col.type,
:limit => col.limit,
:default => col.default,
:scale => col.scale,
:precision => col.precision
end
if type_col = self.columns_hash[inheritance_column]
self.connection.add_column versioned_table_name, versioned_inheritance_column, type_col.type,
:limit => type_col.limit,
:default => type_col.default,
:scale => type_col.scale,
:precision => type_col.precision
end
if updated_col.nil?
self.connection.add_column versioned_table_name, :updated_at, :timestamp
end
end
# Rake migration task to drop the versioned table
def drop_versioned_table
self.connection.drop_table versioned_table_name
end
# Executes the block with the versioning callbacks disabled.
#
# Foo.without_revision do
# @foo.save
# end
#
def without_revision(&block)
class_eval do
CALLBACKS.each do |attr_name|
alias_method "orig_#{attr_name}".to_sym, attr_name
alias_method attr_name, :empty_callback
end
end
block.call
ensure
class_eval do
CALLBACKS.each do |attr_name|
alias_method attr_name, "orig_#{attr_name}".to_sym
end
end
end
# Turns off optimistic locking for the duration of the block
#
# Foo.without_locking do
# @foo.save
# end
#
def without_locking(&block)
current = ActiveRecord::Base.lock_optimistically
ActiveRecord::Base.lock_optimistically = false if current
result = block.call
ActiveRecord::Base.lock_optimistically = true if current
result
end
end
end
end
end
end
ActiveRecord::Base.send :include, ActiveRecord::Acts::Versioned

View File

@@ -0,0 +1,41 @@
$:.unshift(File.dirname(__FILE__) + '/../../../rails/activesupport/lib')
$:.unshift(File.dirname(__FILE__) + '/../../../rails/activerecord/lib')
$:.unshift(File.dirname(__FILE__) + '/../lib')
require 'test/unit'
begin
require 'active_support'
require 'active_record'
require 'active_record/fixtures'
rescue LoadError
require 'rubygems'
retry
end
require 'acts_as_versioned'
config = YAML::load(IO.read(File.dirname(__FILE__) + '/database.yml'))
ActiveRecord::Base.logger = Logger.new(File.dirname(__FILE__) + "/debug.log")
ActiveRecord::Base.configurations = {'test' => config[ENV['DB'] || 'sqlite3']}
ActiveRecord::Base.establish_connection(ActiveRecord::Base.configurations['test'])
load(File.dirname(__FILE__) + "/schema.rb")
# set up custom sequence on widget_versions for DBs that support sequences
if ENV['DB'] == 'postgresql'
ActiveRecord::Base.connection.execute "DROP SEQUENCE widgets_seq;" rescue nil
ActiveRecord::Base.connection.remove_column :widget_versions, :id
ActiveRecord::Base.connection.execute "CREATE SEQUENCE widgets_seq START 101;"
ActiveRecord::Base.connection.execute "ALTER TABLE widget_versions ADD COLUMN id INTEGER PRIMARY KEY DEFAULT nextval('widgets_seq');"
end
Test::Unit::TestCase.fixture_path = File.dirname(__FILE__) + "/fixtures/"
$:.unshift(Test::Unit::TestCase.fixture_path)
class Test::Unit::TestCase #:nodoc:
# Turn off transactional fixtures if you're working with MyISAM tables in MySQL
self.use_transactional_fixtures = true
# Instantiated fixtures are slow, but give you @david where you otherwise would need people(:david)
self.use_instantiated_fixtures = false
# Add more helper methods to be used by all tests here...
end

View File

@@ -0,0 +1,18 @@
sqlite:
:adapter: sqlite
:dbfile: acts_as_versioned_plugin.sqlite.db
sqlite3:
:adapter: sqlite3
:dbfile: acts_as_versioned_plugin.sqlite3.db
postgresql:
:adapter: postgresql
:username: postgres
:password: postgres
:database: acts_as_versioned_plugin_test
:min_messages: ERROR
mysql:
:adapter: mysql
:host: localhost
:username: rails
:password:
:database: acts_as_versioned_plugin_test

View File

@@ -0,0 +1,6 @@
caged:
id: 1
name: caged
mly:
id: 2
name: mly

View File

@@ -0,0 +1,3 @@
class Landmark < ActiveRecord::Base
acts_as_versioned :if_changed => [ :name, :longitude, :latitude ]
end

View File

@@ -0,0 +1,7 @@
washington:
id: 1
landmark_id: 1
version: 1
name: Washington, D.C.
latitude: 38.895
longitude: -77.036667

View File

@@ -0,0 +1,6 @@
washington:
id: 1
name: Washington, D.C.
latitude: 38.895
longitude: -77.036667
version: 1

View File

@@ -0,0 +1,10 @@
welcome:
id: 1
title: Welcome to the weblog
lock_version: 24
type: LockedPage
thinking:
id: 2
title: So I was thinking
lock_version: 24
type: SpecialLockedPage

View File

@@ -0,0 +1,27 @@
welcome_1:
id: 1
page_id: 1
title: Welcome to the weblg
version: 23
version_type: LockedPage
welcome_2:
id: 2
page_id: 1
title: Welcome to the weblog
version: 24
version_type: LockedPage
thinking_1:
id: 3
page_id: 2
title: So I was thinking!!!
version: 23
version_type: SpecialLockedPage
thinking_2:
id: 4
page_id: 2
title: So I was thinking
version: 24
version_type: SpecialLockedPage

View File

@@ -0,0 +1,13 @@
class AddVersionedTables < ActiveRecord::Migration
def self.up
create_table("things") do |t|
t.column :title, :text
end
Thing.create_versioned_table
end
def self.down
Thing.drop_versioned_table
drop_table "things" rescue nil
end
end

View File

@@ -0,0 +1,43 @@
class Page < ActiveRecord::Base
belongs_to :author
has_many :authors, :through => :versions, :order => 'name'
belongs_to :revisor, :class_name => 'Author'
has_many :revisors, :class_name => 'Author', :through => :versions, :order => 'name'
acts_as_versioned :if => :feeling_good? do
def self.included(base)
base.cattr_accessor :feeling_good
base.feeling_good = true
base.belongs_to :author
base.belongs_to :revisor, :class_name => 'Author'
end
def feeling_good?
@@feeling_good == true
end
end
end
module LockedPageExtension
def hello_world
'hello_world'
end
end
class LockedPage < ActiveRecord::Base
acts_as_versioned \
:inheritance_column => :version_type,
:foreign_key => :page_id,
:table_name => :locked_pages_revisions,
:class_name => 'LockedPageRevision',
:version_column => :lock_version,
:limit => 2,
:if_changed => :title,
:extend => LockedPageExtension
end
class SpecialLockedPage < LockedPage
end
class Author < ActiveRecord::Base
has_many :pages
end

View File

@@ -0,0 +1,16 @@
welcome_2:
id: 1
page_id: 1
title: Welcome to the weblog
body: Such a lovely day
version: 24
author_id: 1
revisor_id: 1
welcome_1:
id: 2
page_id: 1
title: Welcome to the weblg
body: Such a lovely day
version: 23
author_id: 2
revisor_id: 2

View File

@@ -0,0 +1,7 @@
welcome:
id: 1
title: Welcome to the weblog
body: Such a lovely day
version: 24
author_id: 1
revisor_id: 1

View File

@@ -0,0 +1,6 @@
class Widget < ActiveRecord::Base
acts_as_versioned :sequence_name => 'widgets_seq', :association_options => {
:dependent => :nullify, :order => 'version desc'
}
non_versioned_columns << 'foo'
end

View File

@@ -0,0 +1,46 @@
require File.join(File.dirname(__FILE__), 'abstract_unit')
if ActiveRecord::Base.connection.supports_migrations?
class Thing < ActiveRecord::Base
attr_accessor :version
acts_as_versioned
end
class MigrationTest < Test::Unit::TestCase
self.use_transactional_fixtures = false
def teardown
if ActiveRecord::Base.connection.respond_to?(:initialize_schema_information)
ActiveRecord::Base.connection.initialize_schema_information
ActiveRecord::Base.connection.update "UPDATE schema_info SET version = 0"
else
ActiveRecord::Base.connection.initialize_schema_migrations_table
ActiveRecord::Base.connection.assume_migrated_upto_version(0)
end
Thing.connection.drop_table "things" rescue nil
Thing.connection.drop_table "thing_versions" rescue nil
Thing.reset_column_information
end
def test_versioned_migration
assert_raises(ActiveRecord::StatementInvalid) { Thing.create :title => 'blah blah' }
# take 'er up
ActiveRecord::Migrator.up(File.dirname(__FILE__) + '/fixtures/migrations/')
t = Thing.create :title => 'blah blah', :price => 123.45, :type => 'Thing'
assert_equal 1, t.versions.size
# check that the price column has remembered its value correctly
assert_equal t.price, t.versions.first.price
assert_equal t.title, t.versions.first.title
assert_equal t[:type], t.versions.first[:type]
# make sure that the precision of the price column has been preserved
assert_equal 7, Thing::Version.columns.find{|c| c.name == "price"}.precision
assert_equal 2, Thing::Version.columns.find{|c| c.name == "price"}.scale
# now lets take 'er back down
ActiveRecord::Migrator.down(File.dirname(__FILE__) + '/fixtures/migrations/')
assert_raises(ActiveRecord::StatementInvalid) { Thing.create :title => 'blah blah' }
end
end
end

View File

@@ -0,0 +1,68 @@
ActiveRecord::Schema.define(:version => 0) do
create_table :pages, :force => true do |t|
t.column :version, :integer
t.column :title, :string, :limit => 255
t.column :body, :text
t.column :updated_on, :datetime
t.column :author_id, :integer
t.column :revisor_id, :integer
end
create_table :page_versions, :force => true do |t|
t.column :page_id, :integer
t.column :version, :integer
t.column :title, :string, :limit => 255
t.column :body, :text
t.column :updated_on, :datetime
t.column :author_id, :integer
t.column :revisor_id, :integer
end
create_table :authors, :force => true do |t|
t.column :page_id, :integer
t.column :name, :string
end
create_table :locked_pages, :force => true do |t|
t.column :lock_version, :integer
t.column :title, :string, :limit => 255
t.column :type, :string, :limit => 255
end
create_table :locked_pages_revisions, :force => true do |t|
t.column :page_id, :integer
t.column :version, :integer
t.column :title, :string, :limit => 255
t.column :version_type, :string, :limit => 255
t.column :updated_at, :datetime
end
create_table :widgets, :force => true do |t|
t.column :name, :string, :limit => 50
t.column :foo, :string
t.column :version, :integer
t.column :updated_at, :datetime
end
create_table :widget_versions, :force => true do |t|
t.column :widget_id, :integer
t.column :name, :string, :limit => 50
t.column :version, :integer
t.column :updated_at, :datetime
end
create_table :landmarks, :force => true do |t|
t.column :name, :string
t.column :latitude, :float
t.column :longitude, :float
t.column :version, :integer
end
create_table :landmark_versions, :force => true do |t|
t.column :landmark_id, :integer
t.column :name, :string
t.column :latitude, :float
t.column :longitude, :float
t.column :version, :integer
end
end

View File

@@ -0,0 +1,347 @@
require File.join(File.dirname(__FILE__), 'abstract_unit')
require File.join(File.dirname(__FILE__), 'fixtures/page')
require File.join(File.dirname(__FILE__), 'fixtures/widget')
class VersionedTest < Test::Unit::TestCase
fixtures :pages, :page_versions, :locked_pages, :locked_pages_revisions, :authors, :landmarks, :landmark_versions
set_fixture_class :page_versions => Page::Version
def test_saves_versioned_copy
p = Page.create! :title => 'first title', :body => 'first body'
assert !p.new_record?
assert_equal 1, p.versions.size
assert_equal 1, p.version
assert_instance_of Page.versioned_class, p.versions.first
end
def test_saves_without_revision
p = pages(:welcome)
old_versions = p.versions.count
p.save_without_revision
p.without_revision do
p.update_attributes :title => 'changed'
end
assert_equal old_versions, p.versions.count
end
def test_rollback_with_version_number
p = pages(:welcome)
assert_equal 24, p.version
assert_equal 'Welcome to the weblog', p.title
assert p.revert_to!(p.versions.first.version), "Couldn't revert to 23"
assert_equal 23, p.version
assert_equal 'Welcome to the weblg', p.title
end
def test_versioned_class_name
assert_equal 'Version', Page.versioned_class_name
assert_equal 'LockedPageRevision', LockedPage.versioned_class_name
end
def test_versioned_class
assert_equal Page::Version, Page.versioned_class
assert_equal LockedPage::LockedPageRevision, LockedPage.versioned_class
end
def test_special_methods
assert_nothing_raised { pages(:welcome).feeling_good? }
assert_nothing_raised { pages(:welcome).versions.first.feeling_good? }
assert_nothing_raised { locked_pages(:welcome).hello_world }
assert_nothing_raised { locked_pages(:welcome).versions.first.hello_world }
end
def test_rollback_with_version_class
p = pages(:welcome)
assert_equal 24, p.version
assert_equal 'Welcome to the weblog', p.title
assert p.revert_to!(p.versions.first), "Couldn't revert to 23"
assert_equal 23, p.version
assert_equal 'Welcome to the weblg', p.title
end
def test_rollback_fails_with_invalid_revision
p = locked_pages(:welcome)
assert !p.revert_to!(locked_pages(:thinking))
end
def test_saves_versioned_copy_with_options
p = LockedPage.create! :title => 'first title'
assert !p.new_record?
assert_equal 1, p.versions.size
assert_instance_of LockedPage.versioned_class, p.versions.first
end
def test_rollback_with_version_number_with_options
p = locked_pages(:welcome)
assert_equal 'Welcome to the weblog', p.title
assert_equal 'LockedPage', p.versions.first.version_type
assert p.revert_to!(p.versions.first.version), "Couldn't revert to 23"
assert_equal 'Welcome to the weblg', p.title
assert_equal 'LockedPage', p.versions.first.version_type
end
def test_rollback_with_version_class_with_options
p = locked_pages(:welcome)
assert_equal 'Welcome to the weblog', p.title
assert_equal 'LockedPage', p.versions.first.version_type
assert p.revert_to!(p.versions.first), "Couldn't revert to 1"
assert_equal 'Welcome to the weblg', p.title
assert_equal 'LockedPage', p.versions.first.version_type
end
def test_saves_versioned_copy_with_sti
p = SpecialLockedPage.create! :title => 'first title'
assert !p.new_record?
assert_equal 1, p.versions.size
assert_instance_of LockedPage.versioned_class, p.versions.first
assert_equal 'SpecialLockedPage', p.versions.first.version_type
end
def test_rollback_with_version_number_with_sti
p = locked_pages(:thinking)
assert_equal 'So I was thinking', p.title
assert p.revert_to!(p.versions.first.version), "Couldn't revert to 1"
assert_equal 'So I was thinking!!!', p.title
assert_equal 'SpecialLockedPage', p.versions.first.version_type
end
def test_lock_version_works_with_versioning
p = locked_pages(:thinking)
p2 = LockedPage.find(p.id)
p.title = 'fresh title'
p.save
assert_equal 2, p.versions.size # limit!
assert_raises(ActiveRecord::StaleObjectError) do
p2.title = 'stale title'
p2.save
end
end
def test_version_if_condition
p = Page.create! :title => "title"
assert_equal 1, p.version
Page.feeling_good = false
p.save
assert_equal 1, p.version
Page.feeling_good = true
end
def test_version_if_condition2
# set new if condition
Page.class_eval do
def new_feeling_good() title[0..0] == 'a'; end
alias_method :old_feeling_good, :feeling_good?
alias_method :feeling_good?, :new_feeling_good
end
p = Page.create! :title => "title"
assert_equal 1, p.version # version does not increment
assert_equal 1, p.versions(true).size
p.update_attributes(:title => 'new title')
assert_equal 1, p.version # version does not increment
assert_equal 1, p.versions(true).size
p.update_attributes(:title => 'a title')
assert_equal 2, p.version
assert_equal 2, p.versions(true).size
# reset original if condition
Page.class_eval { alias_method :feeling_good?, :old_feeling_good }
end
def test_version_if_condition_with_block
# set new if condition
old_condition = Page.version_condition
Page.version_condition = Proc.new { |page| page.title[0..0] == 'b' }
p = Page.create! :title => "title"
assert_equal 1, p.version # version does not increment
assert_equal 1, p.versions(true).size
p.update_attributes(:title => 'a title')
assert_equal 1, p.version # version does not increment
assert_equal 1, p.versions(true).size
p.update_attributes(:title => 'b title')
assert_equal 2, p.version
assert_equal 2, p.versions(true).size
# reset original if condition
Page.version_condition = old_condition
end
def test_version_no_limit
p = Page.create! :title => "title", :body => 'first body'
p.save
p.save
5.times do |i|
assert_page_title p, i
end
end
def test_version_max_limit
p = LockedPage.create! :title => "title"
p.update_attributes(:title => "title1")
p.update_attributes(:title => "title2")
5.times do |i|
assert_page_title p, i, :lock_version
assert p.versions(true).size <= 2, "locked version can only store 2 versions"
end
end
def test_track_altered_attributes_default_value
assert !Page.track_altered_attributes
assert LockedPage.track_altered_attributes
assert SpecialLockedPage.track_altered_attributes
end
def test_version_order
assert_equal 23, pages(:welcome).versions.first.version
assert_equal 24, pages(:welcome).versions.last.version
end
def test_track_altered_attributes
p = LockedPage.create! :title => "title"
assert_equal 1, p.lock_version
assert_equal 1, p.versions(true).size
p.title = 'title'
assert !p.save_version?
p.save
assert_equal 2, p.lock_version # still increments version because of optimistic locking
assert_equal 1, p.versions(true).size
p.title = 'updated title'
assert p.save_version?
p.save
assert_equal 3, p.lock_version
assert_equal 1, p.versions(true).size # version 1 deleted
p.title = 'updated title!'
assert p.save_version?
p.save
assert_equal 4, p.lock_version
assert_equal 2, p.versions(true).size # version 1 deleted
end
def assert_page_title(p, i, version_field = :version)
p.title = "title#{i}"
p.save
assert_equal "title#{i}", p.title
assert_equal (i+4), p.send(version_field)
end
def test_find_versions
assert_equal 2, locked_pages(:welcome).versions.size
assert_equal 1, locked_pages(:welcome).versions.find(:all, :conditions => ['title LIKE ?', '%weblog%']).length
assert_equal 2, locked_pages(:welcome).versions.find(:all, :conditions => ['title LIKE ?', '%web%']).length
assert_equal 0, locked_pages(:thinking).versions.find(:all, :conditions => ['title LIKE ?', '%web%']).length
assert_equal 2, locked_pages(:welcome).versions.length
end
def test_find_version
assert_equal page_versions(:welcome_1), Page.find_version(pages(:welcome).id, 23)
assert_equal page_versions(:welcome_2), Page.find_version(pages(:welcome).id, 24)
assert_equal pages(:welcome), Page.find_version(pages(:welcome).id)
assert_equal page_versions(:welcome_1), pages(:welcome).find_version(23)
assert_equal page_versions(:welcome_2), pages(:welcome).find_version(24)
assert_equal pages(:welcome), pages(:welcome).find_version
assert_raise(ActiveRecord::RecordNotFound) { Page.find_version(pages(:welcome).id, 1) }
assert_raise(ActiveRecord::RecordNotFound) { Page.find_version(0, 23) }
end
def test_with_sequence
assert_equal 'widgets_seq', Widget.versioned_class.sequence_name
3.times { Widget.create! :name => 'new widget' }
assert_equal 3, Widget.count
assert_equal 3, Widget.versioned_class.count
end
def test_has_many_through
assert_equal [authors(:caged), authors(:mly)], pages(:welcome).authors
end
def test_has_many_through_with_custom_association
assert_equal [authors(:caged), authors(:mly)], pages(:welcome).revisors
end
def test_referential_integrity
pages(:welcome).destroy
assert_equal 0, Page.count
assert_equal 0, Page::Version.count
end
def test_association_options
association = Page.reflect_on_association(:versions)
options = association.options
assert_equal :delete_all, options[:dependent]
assert_equal 'version', options[:order]
association = Widget.reflect_on_association(:versions)
options = association.options
assert_equal :nullify, options[:dependent]
assert_equal 'version desc', options[:order]
assert_equal 'widget_id', options[:foreign_key]
widget = Widget.create! :name => 'new widget'
assert_equal 1, Widget.count
assert_equal 1, Widget.versioned_class.count
widget.destroy
assert_equal 0, Widget.count
assert_equal 1, Widget.versioned_class.count
end
def test_versioned_records_should_belong_to_parent
page = pages(:welcome)
page_version = page.versions.last
assert_equal page, page_version.page
end
def test_unaltered_attributes
landmarks(:washington).attributes = landmarks(:washington).attributes.except("id")
assert !landmarks(:washington).changed?
end
def test_unchanged_string_attributes
landmarks(:washington).attributes = landmarks(:washington).attributes.except("id").inject({}) { |params, (key, value)| params.update(key => value.to_s) }
assert !landmarks(:washington).changed?
end
def test_should_find_earliest_version
assert_equal page_versions(:welcome_1), pages(:welcome).versions.earliest
end
def test_should_find_latest_version
assert_equal page_versions(:welcome_2), pages(:welcome).versions.latest
end
def test_should_find_previous_version
assert_equal page_versions(:welcome_1), page_versions(:welcome_2).previous
assert_equal page_versions(:welcome_1), pages(:welcome).versions.before(page_versions(:welcome_2))
end
def test_should_find_next_version
assert_equal page_versions(:welcome_2), page_versions(:welcome_1).next
assert_equal page_versions(:welcome_2), pages(:welcome).versions.after(page_versions(:welcome_1))
end
def test_should_find_version_count
assert_equal 24, pages(:welcome).versions_count
assert_equal 24, page_versions(:welcome_1).versions_count
assert_equal 24, page_versions(:welcome_2).versions_count
end
end

View File

@@ -0,0 +1,3 @@
# Include hook code here
require File.dirname(__FILE__) + '/lib/acts_as_watchable'
ActiveRecord::Base.send(:include, Redmine::Acts::Watchable)

View File

@@ -0,0 +1,85 @@
# ActsAsWatchable
module Redmine
module Acts
module Watchable
def self.included(base)
base.extend ClassMethods
end
module ClassMethods
def acts_as_watchable(options = {})
return if self.included_modules.include?(Redmine::Acts::Watchable::InstanceMethods)
class_eval do
has_many :watchers, :as => :watchable, :dependent => :delete_all
has_many :watcher_users, :through => :watchers, :source => :user, :validate => false
named_scope :watched_by, lambda { |user_id|
{ :include => :watchers,
:conditions => ["#{Watcher.table_name}.user_id = ?", user_id] }
}
attr_protected :watcher_ids, :watcher_user_ids
end
send :include, Redmine::Acts::Watchable::InstanceMethods
alias_method_chain :watcher_user_ids=, :uniq_ids
end
end
module InstanceMethods
def self.included(base)
base.extend ClassMethods
end
# Returns an array of users that are proposed as watchers
def addable_watcher_users
users = self.project.users.sort - self.watcher_users
if respond_to?(:visible?)
users.reject! {|user| !visible?(user)}
end
users
end
# Adds user as a watcher
def add_watcher(user)
self.watchers << Watcher.new(:user => user)
end
# Removes user from the watchers list
def remove_watcher(user)
return nil unless user && user.is_a?(User)
Watcher.delete_all "watchable_type = '#{self.class}' AND watchable_id = #{self.id} AND user_id = #{user.id}"
end
# Adds/removes watcher
def set_watcher(user, watching=true)
watching ? add_watcher(user) : remove_watcher(user)
end
# Overrides watcher_user_ids= to make user_ids uniq
def watcher_user_ids_with_uniq_ids=(user_ids)
if user_ids.is_a?(Array)
user_ids = user_ids.uniq
end
send :watcher_user_ids_without_uniq_ids=, user_ids
end
# Returns true if object is watched by +user+
def watched_by?(user)
!!(user && self.watcher_user_ids.detect {|uid| uid == user.id })
end
# Returns an array of watchers' email addresses
def watcher_recipients
notified = watcher_users.active
notified.reject! {|user| user.mail_notification == 'none'}
if respond_to?(:visible?)
notified.reject! {|user| !visible?(user)}
end
notified.collect(&:mail).compact
end
module ClassMethods; end
end
end
end
end

View File

@@ -0,0 +1,13 @@
Autotest.add_hook :initialize do |at|
at.clear_mappings
at.add_mapping %r%^lib/(.*)\.rb$% do |_, m|
at.files_matching %r%^test/#{m[1]}_test.rb$%
end
at.add_mapping(%r%^test/.*\.rb$%) {|filename, _| filename }
at.add_mapping %r%^test/fixtures/(.*)s.yml% do |_, _|
at.files_matching %r%^test/.*\.rb$%
end
end

View File

@@ -0,0 +1,14 @@
notifications:
email:
- parndt@gmail.com
env:
- DB=sqlite3
- DB=sqlite3mem
- DB=postgresql
- DB=mysql
rvm:
- 1.8.7
- 1.9.2
- 1.9.3
- rbx-2.0
- jruby

View File

@@ -0,0 +1,14 @@
2.0.2
* Fixed deprecation warning under Rails 3.1 [Philip Arndt]
* Converted Test::Unit matchers to RSpec. [Uģis Ozols]
* Added inverse_of to associations to improve performance rendering trees. [Sergio Cambra]
* Added row locking and fixed some race conditions. [Markus J. Q. Roberts]
2.0.1
* Fixed a bug with move_to not using nested_set_scope [Andreas Sekine]
2.0.0.pre
* Expect Rails 3
* Changed how callbacks work. Returning false in a before_move action does not block save operations. Use a validation or exception in the callback if you need that.
* Switched to RSpec
* Remove use of Comparable

View File

@@ -0,0 +1,20 @@
Copyright (c) 2007-2011 Collective Idea
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.

View File

@@ -0,0 +1,100 @@
= AwesomeNestedSet
Awesome Nested Set is an implementation of the nested set pattern for ActiveRecord models. It is replacement for acts_as_nested_set and BetterNestedSet, but more awesome.
Version 2 supports Rails 3. Gem versions prior to 2.0 support Rails 2.
== What makes this so awesome?
This is a new implementation of nested set based off of BetterNestedSet that fixes some bugs, removes tons of duplication, adds a few useful methods, and adds STI support.
== Installation
Add to your Gemfile:
gem 'awesome_nested_set'
== Usage
To make use of awesome_nested_set, your model needs to have 3 fields: lft, rgt, and parent_id:
class CreateCategories < ActiveRecord::Migration
def self.up
create_table :categories do |t|
t.string :name
t.integer :parent_id
t.integer :lft
t.integer :rgt
end
end
def self.down
drop_table :categories
end
end
Enable the nested set functionality by declaring acts_as_nested_set on your model
class Category < ActiveRecord::Base
acts_as_nested_set
end
Run `rake rdoc` to generate the API docs and see CollectiveIdea::Acts::NestedSet for more info.
== Protecting attributes from mass assignment
It's generally best to "white list" the attributes that can be used in mass assignment:
class Category < ActiveRecord::Base
acts_as_nested_set
attr_accessible :name, :parent_id
end
If for some reason that is not possible, you will probably want to protect the lft and rgt attributes:
class Category < ActiveRecord::Base
acts_as_nested_set
attr_protected :lft, :rgt
end
== Conversion from other trees
Coming from acts_as_tree or another system where you only have a parent_id? No problem. Simply add the lft & rgt fields as above, and then run
Category.rebuild!
Your tree will be converted to a valid nested set. Awesome!
== View Helper
The view helper is called #nested_set_options.
Example usage:
<%= f.select :parent_id, nested_set_options(Category, @category) {|i| "#{'-' * i.level} #{i.name}" } %>
<%= select_tag 'parent_id', options_for_select(nested_set_options(Category) {|i| "#{'-' * i.level} #{i.name}" } ) %>
See CollectiveIdea::Acts::NestedSet::Helper for more information about the helpers.
== References
You can learn more about nested sets at: http://threebit.net/tutorials/nestedset/tutorial1.html
== How to contribute
If you find what you might think is a bug:
1. Check the GitHub issue tracker to see if anyone else has had the same issue.
http://github.com/collectiveidea/awesome_nested_set/issues/
2. If you don't see anything, create an issue with information on how to reproduce it.
If you want to contribute an enhancement or a fix:
1. Fork the project on github.
http://github.com/collectiveidea/awesome_nested_set/
2. Make your changes with tests.
3. Commit the changes without making changes to the Rakefile, VERSION, or any other files that aren't related to your enhancement or fix
4. Send a pull request.
Copyright ©2008 Collective Idea, released under the MIT license

View File

@@ -0,0 +1,28 @@
# -*- encoding: utf-8 -*-
$LOAD_PATH.unshift File.expand_path("../lib", __FILE__)
require 'rubygems'
require 'bundler/setup'
require 'awesome_nested_set/version'
require "rspec/core/rake_task"
RSpec::Core::RakeTask.new(:spec)
task :default => :spec
task :build do
system "gem build awesome_nested_set.gemspec"
end
task :release => :build do
system "gem push awesome_nested_set-#{ActsAsGeocodable::VERSION}.gem"
end
require 'rdoc/task'
desc 'Generate documentation for the awesome_nested_set plugin.'
Rake::RDocTask.new(:rdoc) do |rdoc|
rdoc.rdoc_dir = 'rdoc'
rdoc.title = 'AwesomeNestedSet'
rdoc.options << '--line-numbers' << '--inline-source'
rdoc.rdoc_files.include('README.rdoc')
rdoc.rdoc_files.include('lib/**/*.rb')
end

View File

@@ -0,0 +1,22 @@
# -*- encoding: utf-8 -*-
lib = File.expand_path('../lib/', __FILE__)
$:.unshift lib unless $:.include?(lib)
require 'awesome_nested_set/version'
Gem::Specification.new do |s|
s.name = %q{awesome_nested_set}
s.version = ::AwesomeNestedSet::VERSION
s.authors = ["Brandon Keepers", "Daniel Morrison", "Philip Arndt"]
s.description = %q{An awesome nested set implementation for Active Record}
s.email = %q{info@collectiveidea.com}
s.extra_rdoc_files = [
"README.rdoc"
]
s.files = Dir.glob("lib/**/*") + %w(MIT-LICENSE README.rdoc CHANGELOG)
s.homepage = %q{http://github.com/collectiveidea/awesome_nested_set}
s.rdoc_options = ["--main", "README.rdoc", "--inline-source", "--line-numbers"]
s.require_paths = ["lib"]
s.rubygems_version = %q{1.3.6}
s.summary = %q{An awesome nested set implementation for Active Record}
s.add_runtime_dependency 'activerecord', '>= 3.0.0'
end

View File

@@ -0,0 +1 @@
require File.dirname(__FILE__) + '/lib/awesome_nested_set'

View File

@@ -0,0 +1,7 @@
require 'awesome_nested_set/awesome_nested_set'
ActiveRecord::Base.send :extend, CollectiveIdea::Acts::NestedSet
if defined?(ActionView)
require 'awesome_nested_set/helper'
ActionView::Base.send :include, CollectiveIdea::Acts::NestedSet::Helper
end

View File

@@ -0,0 +1,603 @@
module CollectiveIdea #:nodoc:
module Acts #:nodoc:
module NestedSet #:nodoc:
# This acts provides Nested Set functionality. Nested Set is a smart way to implement
# an _ordered_ tree, with the added feature that you can select the children and all of their
# descendants with a single query. The drawback is that insertion or move need some complex
# sql queries. But everything is done here by this module!
#
# Nested sets are appropriate each time you want either an orderd tree (menus,
# commercial categories) or an efficient way of querying big trees (threaded posts).
#
# == API
#
# Methods names are aligned with acts_as_tree as much as possible to make replacment from one
# by another easier.
#
# item.children.create(:name => "child1")
#
# Configuration options are:
#
# * +:parent_column+ - specifies the column name to use for keeping the position integer (default: parent_id)
# * +:left_column+ - column name for left boundry data, default "lft"
# * +:right_column+ - column name for right boundry data, default "rgt"
# * +:scope+ - restricts what is to be considered a list. Given a symbol, it'll attach "_id"
# (if it hasn't been already) and use that as the foreign key restriction. You
# can also pass an array to scope by multiple attributes.
# Example: <tt>acts_as_nested_set :scope => [:notable_id, :notable_type]</tt>
# * +:dependent+ - behavior for cascading destroy. If set to :destroy, all the
# child objects are destroyed alongside this object by calling their destroy
# method. If set to :delete_all (default), all the child objects are deleted
# without calling their destroy method.
# * +:counter_cache+ adds a counter cache for the number of children.
# defaults to false.
# Example: <tt>acts_as_nested_set :counter_cache => :children_count</tt>
#
# See CollectiveIdea::Acts::NestedSet::Model::ClassMethods for a list of class methods and
# CollectiveIdea::Acts::NestedSet::Model::InstanceMethods for a list of instance methods added
# to acts_as_nested_set models
def acts_as_nested_set(options = {})
options = {
:parent_column => 'parent_id',
:left_column => 'lft',
:right_column => 'rgt',
:dependent => :delete_all, # or :destroy
:counter_cache => false,
:order => 'id'
}.merge(options)
if options[:scope].is_a?(Symbol) && options[:scope].to_s !~ /_id$/
options[:scope] = "#{options[:scope]}_id".intern
end
class_attribute :acts_as_nested_set_options
self.acts_as_nested_set_options = options
include CollectiveIdea::Acts::NestedSet::Model
include Columns
extend Columns
belongs_to :parent, :class_name => self.base_class.to_s,
:foreign_key => parent_column_name,
:counter_cache => options[:counter_cache],
:inverse_of => :children
has_many :children, :class_name => self.base_class.to_s,
:foreign_key => parent_column_name, :order => left_column_name,
:inverse_of => :parent,
:before_add => options[:before_add],
:after_add => options[:after_add],
:before_remove => options[:before_remove],
:after_remove => options[:after_remove]
attr_accessor :skip_before_destroy
before_create :set_default_left_and_right
before_save :store_new_parent
after_save :move_to_new_parent
before_destroy :destroy_descendants
# no assignment to structure fields
[left_column_name, right_column_name].each do |column|
module_eval <<-"end_eval", __FILE__, __LINE__
def #{column}=(x)
raise ActiveRecord::ActiveRecordError, "Unauthorized assignment to #{column}: it's an internal field handled by acts_as_nested_set code, use move_to_* methods instead."
end
end_eval
end
define_model_callbacks :move
end
module Model
extend ActiveSupport::Concern
module ClassMethods
# Returns the first root
def root
roots.first
end
def roots
where(parent_column_name => nil).order(quoted_left_column_name)
end
def leaves
where("#{quoted_right_column_name} - #{quoted_left_column_name} = 1").order(quoted_left_column_name)
end
def valid?
left_and_rights_valid? && no_duplicates_for_columns? && all_roots_valid?
end
def left_and_rights_valid?
joins("LEFT OUTER JOIN #{quoted_table_name} AS parent ON " +
"#{quoted_table_name}.#{quoted_parent_column_name} = parent.#{primary_key}").
where(
"#{quoted_table_name}.#{quoted_left_column_name} IS NULL OR " +
"#{quoted_table_name}.#{quoted_right_column_name} IS NULL OR " +
"#{quoted_table_name}.#{quoted_left_column_name} >= " +
"#{quoted_table_name}.#{quoted_right_column_name} OR " +
"(#{quoted_table_name}.#{quoted_parent_column_name} IS NOT NULL AND " +
"(#{quoted_table_name}.#{quoted_left_column_name} <= parent.#{quoted_left_column_name} OR " +
"#{quoted_table_name}.#{quoted_right_column_name} >= parent.#{quoted_right_column_name}))"
).count == 0
end
def no_duplicates_for_columns?
scope_string = Array(acts_as_nested_set_options[:scope]).map do |c|
connection.quote_column_name(c)
end.push(nil).join(", ")
[quoted_left_column_name, quoted_right_column_name].all? do |column|
# No duplicates
select("#{scope_string}#{column}, COUNT(#{column})").
group("#{scope_string}#{column}").
having("COUNT(#{column}) > 1").
first.nil?
end
end
# Wrapper for each_root_valid? that can deal with scope.
def all_roots_valid?
if acts_as_nested_set_options[:scope]
roots.group(scope_column_names).group_by{|record| scope_column_names.collect{|col| record.send(col.to_sym)}}.all? do |scope, grouped_roots|
each_root_valid?(grouped_roots)
end
else
each_root_valid?(roots)
end
end
def each_root_valid?(roots_to_validate)
left = right = 0
roots_to_validate.all? do |root|
(root.left > left && root.right > right).tap do
left = root.left
right = root.right
end
end
end
# Rebuilds the left & rights if unset or invalid.
# Also very useful for converting from acts_as_tree.
def rebuild!(validate_nodes = true)
# Don't rebuild a valid tree.
return true if valid?
scope = lambda{|node|}
if acts_as_nested_set_options[:scope]
scope = lambda{|node|
scope_column_names.inject(""){|str, column_name|
str << "AND #{connection.quote_column_name(column_name)} = #{connection.quote(node.send(column_name.to_sym))} "
}
}
end
indices = {}
set_left_and_rights = lambda do |node|
# set left
node[left_column_name] = indices[scope.call(node)] += 1
# find
where(["#{quoted_parent_column_name} = ? #{scope.call(node)}", node]).order(acts_as_nested_set_options[:order]).each{|n| set_left_and_rights.call(n) }
# set right
node[right_column_name] = indices[scope.call(node)] += 1
node.save!(:validate => validate_nodes)
end
# Find root node(s)
root_nodes = where("#{quoted_parent_column_name} IS NULL").order("#{quoted_left_column_name}, #{quoted_right_column_name}, id").each do |root_node|
# setup index for this scope
indices[scope.call(root_node)] ||= 0
set_left_and_rights.call(root_node)
end
end
# Iterates over tree elements and determines the current level in the tree.
# Only accepts default ordering, odering by an other column than lft
# does not work. This method is much more efficent than calling level
# because it doesn't require any additional database queries.
#
# Example:
# Category.each_with_level(Category.root.self_and_descendants) do |o, level|
#
def each_with_level(objects)
path = [nil]
objects.each do |o|
if o.parent_id != path.last
# we are on a new level, did we decent or ascent?
if path.include?(o.parent_id)
# remove wrong wrong tailing paths elements
path.pop while path.last != o.parent_id
else
path << o.parent_id
end
end
yield(o, path.length - 1)
end
end
end
# Any instance method that returns a collection makes use of Rails 2.1's named_scope (which is bundled for Rails 2.0), so it can be treated as a finder.
#
# category.self_and_descendants.count
# category.ancestors.find(:all, :conditions => "name like '%foo%'")
module InstanceMethods
# Value of the parent column
def parent_id
self[parent_column_name]
end
# Value of the left column
def left
self[left_column_name]
end
# Value of the right column
def right
self[right_column_name]
end
# Returns true if this is a root node.
def root?
parent_id.nil?
end
def leaf?
new_record? || (right - left == 1)
end
# Returns true is this is a child node
def child?
!parent_id.nil?
end
# Returns root
def root
self_and_ancestors.where(parent_column_name => nil).first
end
# Returns the array of all parents and self
def self_and_ancestors
nested_set_scope.where([
"#{self.class.quoted_table_name}.#{quoted_left_column_name} <= ? AND #{self.class.quoted_table_name}.#{quoted_right_column_name} >= ?", left, right
])
end
# Returns an array of all parents
def ancestors
without_self self_and_ancestors
end
# Returns the array of all children of the parent, including self
def self_and_siblings
nested_set_scope.where(parent_column_name => parent_id)
end
# Returns the array of all children of the parent, except self
def siblings
without_self self_and_siblings
end
# Returns a set of all of its nested children which do not have children
def leaves
descendants.where("#{self.class.quoted_table_name}.#{quoted_right_column_name} - #{self.class.quoted_table_name}.#{quoted_left_column_name} = 1")
end
# Returns the level of this object in the tree
# root level is 0
def level
parent_id.nil? ? 0 : ancestors.count
end
# Returns a set of itself and all of its nested children
def self_and_descendants
nested_set_scope.where([
"#{self.class.quoted_table_name}.#{quoted_left_column_name} >= ? AND #{self.class.quoted_table_name}.#{quoted_right_column_name} <= ?", left, right
])
end
# Returns a set of all of its children and nested children
def descendants
without_self self_and_descendants
end
def is_descendant_of?(other)
other.left < self.left && self.left < other.right && same_scope?(other)
end
def is_or_is_descendant_of?(other)
other.left <= self.left && self.left < other.right && same_scope?(other)
end
def is_ancestor_of?(other)
self.left < other.left && other.left < self.right && same_scope?(other)
end
def is_or_is_ancestor_of?(other)
self.left <= other.left && other.left < self.right && same_scope?(other)
end
# Check if other model is in the same scope
def same_scope?(other)
Array(acts_as_nested_set_options[:scope]).all? do |attr|
self.send(attr) == other.send(attr)
end
end
# Find the first sibling to the left
def left_sibling
siblings.where(["#{self.class.quoted_table_name}.#{quoted_left_column_name} < ?", left]).
order("#{self.class.quoted_table_name}.#{quoted_left_column_name} DESC").last
end
# Find the first sibling to the right
def right_sibling
siblings.where(["#{self.class.quoted_table_name}.#{quoted_left_column_name} > ?", left]).first
end
# Shorthand method for finding the left sibling and moving to the left of it.
def move_left
move_to_left_of left_sibling
end
# Shorthand method for finding the right sibling and moving to the right of it.
def move_right
move_to_right_of right_sibling
end
# Move the node to the left of another node (you can pass id only)
def move_to_left_of(node)
move_to node, :left
end
# Move the node to the left of another node (you can pass id only)
def move_to_right_of(node)
move_to node, :right
end
# Move the node to the child of another node (you can pass id only)
def move_to_child_of(node)
move_to node, :child
end
# Move the node to root nodes
def move_to_root
move_to nil, :root
end
def move_possible?(target)
self != target && # Can't target self
same_scope?(target) && # can't be in different scopes
# !(left..right).include?(target.left..target.right) # this needs tested more
# detect impossible move
!((left <= target.left && right >= target.left) or (left <= target.right && right >= target.right))
end
def to_text
self_and_descendants.map do |node|
"#{'*'*(node.level+1)} #{node.id} #{node.to_s} (#{node.parent_id}, #{node.left}, #{node.right})"
end.join("\n")
end
protected
def without_self(scope)
scope.where(["#{self.class.quoted_table_name}.#{self.class.primary_key} != ?", self])
end
# All nested set queries should use this nested_set_scope, which performs finds on
# the base ActiveRecord class, using the :scope declared in the acts_as_nested_set
# declaration.
def nested_set_scope(options = {})
options = {:order => "#{self.class.quoted_table_name}.#{quoted_left_column_name}"}.merge(options)
scopes = Array(acts_as_nested_set_options[:scope])
options[:conditions] = scopes.inject({}) do |conditions,attr|
conditions.merge attr => self[attr]
end unless scopes.empty?
self.class.base_class.scoped options
end
def store_new_parent
@move_to_new_parent_id = send("#{parent_column_name}_changed?") ? parent_id : false
true # force callback to return true
end
def move_to_new_parent
if @move_to_new_parent_id.nil?
move_to_root
elsif @move_to_new_parent_id
move_to_child_of(@move_to_new_parent_id)
end
end
# on creation, set automatically lft and rgt to the end of the tree
def set_default_left_and_right
highest_right_row = nested_set_scope(:order => "#{quoted_right_column_name} desc").find(:first, :limit => 1,:lock => true )
maxright = highest_right_row ? (highest_right_row[right_column_name] || 0) : 0
# adds the new node to the right of all existing nodes
self[left_column_name] = maxright + 1
self[right_column_name] = maxright + 2
end
def in_tenacious_transaction(&block)
retry_count = 0
begin
transaction(&block)
rescue ActiveRecord::StatementInvalid => error
raise unless connection.open_transactions.zero?
raise unless error.message =~ /Deadlock found when trying to get lock|Lock wait timeout exceeded/
raise unless retry_count < 10
retry_count += 1
logger.info "Deadlock detected on retry #{retry_count}, restarting transaction"
sleep(rand(retry_count)*0.1) # Aloha protocol
retry
end
end
# Prunes a branch off of the tree, shifting all of the elements on the right
# back to the left so the counts still work.
def destroy_descendants
return if right.nil? || left.nil? || skip_before_destroy
in_tenacious_transaction do
reload_nested_set
# select the rows in the model that extend past the deletion point and apply a lock
self.class.base_class.find(:all,
:select => "id",
:conditions => ["#{quoted_left_column_name} >= ?", left],
:lock => true
)
if acts_as_nested_set_options[:dependent] == :destroy
descendants.each do |model|
model.skip_before_destroy = true
model.destroy
end
else
nested_set_scope.delete_all(
["#{quoted_left_column_name} > ? AND #{quoted_right_column_name} < ?",
left, right]
)
end
# update lefts and rights for remaining nodes
diff = right - left + 1
nested_set_scope.update_all(
["#{quoted_left_column_name} = (#{quoted_left_column_name} - ?)", diff],
["#{quoted_left_column_name} > ?", right]
)
nested_set_scope.update_all(
["#{quoted_right_column_name} = (#{quoted_right_column_name} - ?)", diff],
["#{quoted_right_column_name} > ?", right]
)
reload
# Don't allow multiple calls to destroy to corrupt the set
self.skip_before_destroy = true
end
end
# reload left, right, and parent
def reload_nested_set
reload(
:select => "#{quoted_left_column_name}, #{quoted_right_column_name}, #{quoted_parent_column_name}",
:lock => true
)
end
def move_to(target, position)
raise ActiveRecord::ActiveRecordError, "You cannot move a new node" if self.new_record?
run_callbacks :move do
in_tenacious_transaction do
if target.is_a? self.class.base_class
target.reload_nested_set
elsif position != :root
# load object if node is not an object
target = nested_set_scope.find(target)
end
self.reload_nested_set
unless position == :root || move_possible?(target)
raise ActiveRecord::ActiveRecordError, "Impossible move, target node cannot be inside moved tree."
end
bound = case position
when :child; target[right_column_name]
when :left; target[left_column_name]
when :right; target[right_column_name] + 1
when :root; 1
else raise ActiveRecord::ActiveRecordError, "Position should be :child, :left, :right or :root ('#{position}' received)."
end
if bound > self[right_column_name]
bound = bound - 1
other_bound = self[right_column_name] + 1
else
other_bound = self[left_column_name] - 1
end
# there would be no change
return if bound == self[right_column_name] || bound == self[left_column_name]
# we have defined the boundaries of two non-overlapping intervals,
# so sorting puts both the intervals and their boundaries in order
a, b, c, d = [self[left_column_name], self[right_column_name], bound, other_bound].sort
# select the rows in the model between a and d, and apply a lock
self.class.base_class.select('id').lock(true).where(
["#{quoted_left_column_name} >= :a and #{quoted_right_column_name} <= :d", {:a => a, :d => d}]
)
new_parent = case position
when :child; target.id
when :root; nil
else target[parent_column_name]
end
self.nested_set_scope.update_all([
"#{quoted_left_column_name} = CASE " +
"WHEN #{quoted_left_column_name} BETWEEN :a AND :b " +
"THEN #{quoted_left_column_name} + :d - :b " +
"WHEN #{quoted_left_column_name} BETWEEN :c AND :d " +
"THEN #{quoted_left_column_name} + :a - :c " +
"ELSE #{quoted_left_column_name} END, " +
"#{quoted_right_column_name} = CASE " +
"WHEN #{quoted_right_column_name} BETWEEN :a AND :b " +
"THEN #{quoted_right_column_name} + :d - :b " +
"WHEN #{quoted_right_column_name} BETWEEN :c AND :d " +
"THEN #{quoted_right_column_name} + :a - :c " +
"ELSE #{quoted_right_column_name} END, " +
"#{quoted_parent_column_name} = CASE " +
"WHEN #{self.class.base_class.primary_key} = :id THEN :new_parent " +
"ELSE #{quoted_parent_column_name} END",
{:a => a, :b => b, :c => c, :d => d, :id => self.id, :new_parent => new_parent}
])
end
target.reload_nested_set if target
self.reload_nested_set
end
end
end
end
# Mixed into both classes and instances to provide easy access to the column names
module Columns
def left_column_name
acts_as_nested_set_options[:left_column]
end
def right_column_name
acts_as_nested_set_options[:right_column]
end
def parent_column_name
acts_as_nested_set_options[:parent_column]
end
def scope_column_names
Array(acts_as_nested_set_options[:scope])
end
def quoted_left_column_name
connection.quote_column_name(left_column_name)
end
def quoted_right_column_name
connection.quote_column_name(right_column_name)
end
def quoted_parent_column_name
connection.quote_column_name(parent_column_name)
end
def quoted_scope_column_names
scope_column_names.collect {|column_name| connection.quote_column_name(column_name) }
end
end
end
end
end

View File

@@ -0,0 +1,44 @@
module CollectiveIdea #:nodoc:
module Acts #:nodoc:
module NestedSet #:nodoc:
# This module provides some helpers for the model classes using acts_as_nested_set.
# It is included by default in all views.
#
module Helper
# Returns options for select.
# You can exclude some items from the tree.
# You can pass a block receiving an item and returning the string displayed in the select.
#
# == Params
# * +class_or_item+ - Class name or top level times
# * +mover+ - The item that is being move, used to exlude impossible moves
# * +&block+ - a block that will be used to display: { |item| ... item.name }
#
# == Usage
#
# <%= f.select :parent_id, nested_set_options(Category, @category) {|i|
# "#{'' * i.level} #{i.name}"
# }) %>
#
def nested_set_options(class_or_item, mover = nil)
if class_or_item.is_a? Array
items = class_or_item.reject { |e| !e.root? }
else
class_or_item = class_or_item.roots if class_or_item.is_a?(Class)
items = Array(class_or_item)
end
result = []
items.each do |root|
result += root.self_and_descendants.map do |i|
if mover.nil? || mover.new_record? || mover.move_possible?(i)
[yield(i), i.id]
end
end.compact
end
result
end
end
end
end
end

View File

@@ -0,0 +1,3 @@
module AwesomeNestedSet
VERSION = '2.1.0' unless defined?(::AwesomeNestedSet::VERSION)
end

View File

@@ -0,0 +1,13 @@
require 'awesome_nested_set/compatability'
require 'awesome_nested_set'
ActiveRecord::Base.class_eval do
include CollectiveIdea::Acts::NestedSet
end
if defined?(ActionView)
require 'awesome_nested_set/helper'
ActionView::Base.class_eval do
include CollectiveIdea::Acts::NestedSet::Helper
end
end

View File

@@ -0,0 +1,67 @@
require 'spec_helper'
describe "Helper" do
include CollectiveIdea::Acts::NestedSet::Helper
before(:all) do
self.class.fixtures :categories
end
describe "nested_set_options" do
it "test_nested_set_options" do
expected = [
[" Top Level", 1],
["- Child 1", 2],
['- Child 2', 3],
['-- Child 2.1', 4],
['- Child 3', 5],
[" Top Level 2", 6]
]
actual = nested_set_options(Category) do |c|
"#{'-' * c.level} #{c.name}"
end
actual.should == expected
end
it "test_nested_set_options_with_mover" do
expected = [
[" Top Level", 1],
["- Child 1", 2],
['- Child 3', 5],
[" Top Level 2", 6]
]
actual = nested_set_options(Category, categories(:child_2)) do |c|
"#{'-' * c.level} #{c.name}"
end
actual.should == expected
end
it "test_nested_set_options_with_array_as_argument_without_mover" do
expected = [
[" Top Level", 1],
["- Child 1", 2],
['- Child 2', 3],
['-- Child 2.1', 4],
['- Child 3', 5],
[" Top Level 2", 6]
]
actual = nested_set_options(Category.all) do |c|
"#{'-' * c.level} #{c.name}"
end
actual.should == expected
end
it "test_nested_set_options_with_array_as_argument_with_mover" do
expected = [
[" Top Level", 1],
["- Child 1", 2],
['- Child 3', 5],
[" Top Level 2", 6]
]
actual = nested_set_options(Category.all, categories(:child_2)) do |c|
"#{'-' * c.level} #{c.name}"
end
actual.should == expected
end
end
end

View File

@@ -0,0 +1,841 @@
require 'spec_helper'
describe "AwesomeNestedSet" do
before(:all) do
self.class.fixtures :categories, :departments, :notes, :things, :brokens
end
describe "defaults" do
it "should have left_column_default" do
Default.acts_as_nested_set_options[:left_column].should == 'lft'
end
it "should have right_column_default" do
Default.acts_as_nested_set_options[:right_column].should == 'rgt'
end
it "should have parent_column_default" do
Default.acts_as_nested_set_options[:parent_column].should == 'parent_id'
end
it "should have scope_default" do
Default.acts_as_nested_set_options[:scope].should be_nil
end
it "should have left_column_name" do
Default.left_column_name.should == 'lft'
Default.new.left_column_name.should == 'lft'
RenamedColumns.left_column_name.should == 'red'
RenamedColumns.new.left_column_name.should == 'red'
end
it "should have right_column_name" do
Default.right_column_name.should == 'rgt'
Default.new.right_column_name.should == 'rgt'
RenamedColumns.right_column_name.should == 'black'
RenamedColumns.new.right_column_name.should == 'black'
end
it "should have parent_column_name" do
Default.parent_column_name.should == 'parent_id'
Default.new.parent_column_name.should == 'parent_id'
RenamedColumns.parent_column_name.should == 'mother_id'
RenamedColumns.new.parent_column_name.should == 'mother_id'
end
end
it "creation_with_altered_column_names" do
lambda {
RenamedColumns.create!()
}.should_not raise_exception
end
it "creation when existing record has nil left column" do
assert_nothing_raised do
Broken.create!
end
end
it "quoted_left_column_name" do
quoted = Default.connection.quote_column_name('lft')
Default.quoted_left_column_name.should == quoted
Default.new.quoted_left_column_name.should == quoted
end
it "quoted_right_column_name" do
quoted = Default.connection.quote_column_name('rgt')
Default.quoted_right_column_name.should == quoted
Default.new.quoted_right_column_name.should == quoted
end
it "left_column_protected_from_assignment" do
lambda {
Category.new.lft = 1
}.should raise_exception(ActiveRecord::ActiveRecordError)
end
it "right_column_protected_from_assignment" do
lambda {
Category.new.rgt = 1
}.should raise_exception(ActiveRecord::ActiveRecordError)
end
it "scoped_appends_id" do
ScopedCategory.acts_as_nested_set_options[:scope].should == :organization_id
end
it "roots_class_method" do
Category.roots.should == Category.find_all_by_parent_id(nil)
end
it "root_class_method" do
Category.root.should == categories(:top_level)
end
it "root" do
categories(:child_3).root.should == categories(:top_level)
end
it "root?" do
categories(:top_level).root?.should be_true
categories(:top_level_2).root?.should be_true
end
it "leaves_class_method" do
Category.find(:all, :conditions => "#{Category.right_column_name} - #{Category.left_column_name} = 1").should == Category.leaves
Category.leaves.count.should == 4
Category.leaves.should include(categories(:child_1))
Category.leaves.should include(categories(:child_2_1))
Category.leaves.should include(categories(:child_3))
Category.leaves.should include(categories(:top_level_2))
end
it "leaf" do
categories(:child_1).leaf?.should be_true
categories(:child_2_1).leaf?.should be_true
categories(:child_3).leaf?.should be_true
categories(:top_level_2).leaf?.should be_true
categories(:top_level).leaf?.should be_false
categories(:child_2).leaf?.should be_false
Category.new.leaf?.should be_false
end
it "parent" do
categories(:child_2_1).parent.should == categories(:child_2)
end
it "self_and_ancestors" do
child = categories(:child_2_1)
self_and_ancestors = [categories(:top_level), categories(:child_2), child]
self_and_ancestors.should == child.self_and_ancestors
end
it "ancestors" do
child = categories(:child_2_1)
ancestors = [categories(:top_level), categories(:child_2)]
ancestors.should == child.ancestors
end
it "self_and_siblings" do
child = categories(:child_2)
self_and_siblings = [categories(:child_1), child, categories(:child_3)]
self_and_siblings.should == child.self_and_siblings
lambda do
tops = [categories(:top_level), categories(:top_level_2)]
assert_equal tops, categories(:top_level).self_and_siblings
end.should_not raise_exception
end
it "siblings" do
child = categories(:child_2)
siblings = [categories(:child_1), categories(:child_3)]
siblings.should == child.siblings
end
it "leaves" do
leaves = [categories(:child_1), categories(:child_2_1), categories(:child_3)]
categories(:top_level).leaves.should == leaves
end
it "level" do
categories(:top_level).level.should == 0
categories(:child_1).level.should == 1
categories(:child_2_1).level.should == 2
end
it "has_children?" do
categories(:child_2_1).children.empty?.should be_true
categories(:child_2).children.empty?.should be_false
categories(:top_level).children.empty?.should be_false
end
it "self_and_descendents" do
parent = categories(:top_level)
self_and_descendants = [parent, categories(:child_1), categories(:child_2),
categories(:child_2_1), categories(:child_3)]
self_and_descendants.should == parent.self_and_descendants
self_and_descendants.count.should == parent.self_and_descendants.count
end
it "descendents" do
lawyers = Category.create!(:name => "lawyers")
us = Category.create!(:name => "United States")
us.move_to_child_of(lawyers)
patent = Category.create!(:name => "Patent Law")
patent.move_to_child_of(us)
lawyers.reload
lawyers.children.size.should == 1
us.children.size.should == 1
lawyers.descendants.size.should == 2
end
it "self_and_descendents" do
parent = categories(:top_level)
descendants = [categories(:child_1), categories(:child_2),
categories(:child_2_1), categories(:child_3)]
descendants.should == parent.descendants
end
it "children" do
category = categories(:top_level)
category.children.each {|c| category.id.should == c.parent_id }
end
it "order_of_children" do
categories(:child_2).move_left
categories(:child_2).should == categories(:top_level).children[0]
categories(:child_1).should == categories(:top_level).children[1]
categories(:child_3).should == categories(:top_level).children[2]
end
it "is_or_is_ancestor_of?" do
categories(:top_level).is_or_is_ancestor_of?(categories(:child_1)).should be_true
categories(:top_level).is_or_is_ancestor_of?(categories(:child_2_1)).should be_true
categories(:child_2).is_or_is_ancestor_of?(categories(:child_2_1)).should be_true
categories(:child_2_1).is_or_is_ancestor_of?(categories(:child_2)).should be_false
categories(:child_1).is_or_is_ancestor_of?(categories(:child_2)).should be_false
categories(:child_1).is_or_is_ancestor_of?(categories(:child_1)).should be_true
end
it "is_ancestor_of?" do
categories(:top_level).is_ancestor_of?(categories(:child_1)).should be_true
categories(:top_level).is_ancestor_of?(categories(:child_2_1)).should be_true
categories(:child_2).is_ancestor_of?(categories(:child_2_1)).should be_true
categories(:child_2_1).is_ancestor_of?(categories(:child_2)).should be_false
categories(:child_1).is_ancestor_of?(categories(:child_2)).should be_false
categories(:child_1).is_ancestor_of?(categories(:child_1)).should be_false
end
it "is_or_is_ancestor_of_with_scope" do
root = ScopedCategory.root
child = root.children.first
root.is_or_is_ancestor_of?(child).should be_true
child.update_attribute :organization_id, 'different'
root.is_or_is_ancestor_of?(child).should be_false
end
it "is_or_is_descendant_of?" do
categories(:child_1).is_or_is_descendant_of?(categories(:top_level)).should be_true
categories(:child_2_1).is_or_is_descendant_of?(categories(:top_level)).should be_true
categories(:child_2_1).is_or_is_descendant_of?(categories(:child_2)).should be_true
categories(:child_2).is_or_is_descendant_of?(categories(:child_2_1)).should be_false
categories(:child_2).is_or_is_descendant_of?(categories(:child_1)).should be_false
categories(:child_1).is_or_is_descendant_of?(categories(:child_1)).should be_true
end
it "is_descendant_of?" do
categories(:child_1).is_descendant_of?(categories(:top_level)).should be_true
categories(:child_2_1).is_descendant_of?(categories(:top_level)).should be_true
categories(:child_2_1).is_descendant_of?(categories(:child_2)).should be_true
categories(:child_2).is_descendant_of?(categories(:child_2_1)).should be_false
categories(:child_2).is_descendant_of?(categories(:child_1)).should be_false
categories(:child_1).is_descendant_of?(categories(:child_1)).should be_false
end
it "is_or_is_descendant_of_with_scope" do
root = ScopedCategory.root
child = root.children.first
child.is_or_is_descendant_of?(root).should be_true
child.update_attribute :organization_id, 'different'
child.is_or_is_descendant_of?(root).should be_false
end
it "same_scope?" do
root = ScopedCategory.root
child = root.children.first
child.same_scope?(root).should be_true
child.update_attribute :organization_id, 'different'
child.same_scope?(root).should be_false
end
it "left_sibling" do
categories(:child_1).should == categories(:child_2).left_sibling
categories(:child_2).should == categories(:child_3).left_sibling
end
it "left_sibling_of_root" do
categories(:top_level).left_sibling.should be_nil
end
it "left_sibling_without_siblings" do
categories(:child_2_1).left_sibling.should be_nil
end
it "left_sibling_of_leftmost_node" do
categories(:child_1).left_sibling.should be_nil
end
it "right_sibling" do
categories(:child_3).should == categories(:child_2).right_sibling
categories(:child_2).should == categories(:child_1).right_sibling
end
it "right_sibling_of_root" do
categories(:top_level_2).should == categories(:top_level).right_sibling
categories(:top_level_2).right_sibling.should be_nil
end
it "right_sibling_without_siblings" do
categories(:child_2_1).right_sibling.should be_nil
end
it "right_sibling_of_rightmost_node" do
categories(:child_3).right_sibling.should be_nil
end
it "move_left" do
categories(:child_2).move_left
categories(:child_2).left_sibling.should be_nil
categories(:child_1).should == categories(:child_2).right_sibling
Category.valid?.should be_true
end
it "move_right" do
categories(:child_2).move_right
categories(:child_2).right_sibling.should be_nil
categories(:child_3).should == categories(:child_2).left_sibling
Category.valid?.should be_true
end
it "move_to_left_of" do
categories(:child_3).move_to_left_of(categories(:child_1))
categories(:child_3).left_sibling.should be_nil
categories(:child_1).should == categories(:child_3).right_sibling
Category.valid?.should be_true
end
it "move_to_right_of" do
categories(:child_1).move_to_right_of(categories(:child_3))
categories(:child_1).right_sibling.should be_nil
categories(:child_3).should == categories(:child_1).left_sibling
Category.valid?.should be_true
end
it "move_to_root" do
categories(:child_2).move_to_root
categories(:child_2).parent.should be_nil
categories(:child_2).level.should == 0
categories(:child_2_1).level.should == 1
categories(:child_2).left.should == 1
categories(:child_2).right.should == 4
Category.valid?.should be_true
end
it "move_to_child_of" do
categories(:child_1).move_to_child_of(categories(:child_3))
categories(:child_3).id.should == categories(:child_1).parent_id
Category.valid?.should be_true
end
it "move_to_child_of_appends_to_end" do
child = Category.create! :name => 'New Child'
child.move_to_child_of categories(:top_level)
child.should == categories(:top_level).children.last
end
it "subtree_move_to_child_of" do
categories(:child_2).left.should == 4
categories(:child_2).right.should == 7
categories(:child_1).left.should == 2
categories(:child_1).right.should == 3
categories(:child_2).move_to_child_of(categories(:child_1))
Category.valid?.should be_true
categories(:child_1).id.should == categories(:child_2).parent_id
categories(:child_2).left.should == 3
categories(:child_2).right.should == 6
categories(:child_1).left.should == 2
categories(:child_1).right.should == 7
end
it "slightly_difficult_move_to_child_of" do
categories(:top_level_2).left.should == 11
categories(:top_level_2).right.should == 12
# create a new top-level node and move single-node top-level tree inside it.
new_top = Category.create(:name => 'New Top')
new_top.left.should == 13
new_top.right.should == 14
categories(:top_level_2).move_to_child_of(new_top)
Category.valid?.should be_true
new_top.id.should == categories(:top_level_2).parent_id
categories(:top_level_2).left.should == 12
categories(:top_level_2).right.should == 13
new_top.left.should == 11
new_top.right.should == 14
end
it "difficult_move_to_child_of" do
categories(:top_level).left.should == 1
categories(:top_level).right.should == 10
categories(:child_2_1).left.should == 5
categories(:child_2_1).right.should == 6
# create a new top-level node and move an entire top-level tree inside it.
new_top = Category.create(:name => 'New Top')
categories(:top_level).move_to_child_of(new_top)
categories(:child_2_1).reload
Category.valid?.should be_true
new_top.id.should == categories(:top_level).parent_id
categories(:top_level).left.should == 4
categories(:top_level).right.should == 13
categories(:child_2_1).left.should == 8
categories(:child_2_1).right.should == 9
end
#rebuild swaps the position of the 2 children when added using move_to_child twice onto same parent
it "move_to_child_more_than_once_per_parent_rebuild" do
root1 = Category.create(:name => 'Root1')
root2 = Category.create(:name => 'Root2')
root3 = Category.create(:name => 'Root3')
root2.move_to_child_of root1
root3.move_to_child_of root1
output = Category.roots.last.to_text
Category.update_all('lft = null, rgt = null')
Category.rebuild!
Category.roots.last.to_text.should == output
end
# doing move_to_child twice onto same parent from the furthest right first
it "move_to_child_more_than_once_per_parent_outside_in" do
node1 = Category.create(:name => 'Node-1')
node2 = Category.create(:name => 'Node-2')
node3 = Category.create(:name => 'Node-3')
node2.move_to_child_of node1
node3.move_to_child_of node1
output = Category.roots.last.to_text
Category.update_all('lft = null, rgt = null')
Category.rebuild!
Category.roots.last.to_text.should == output
end
it "should be able to rebuild without validating each record" do
root1 = Category.create(:name => 'Root1')
root2 = Category.create(:name => 'Root2')
root3 = Category.create(:name => 'Root3')
root2.move_to_child_of root1
root3.move_to_child_of root1
root2.name = nil
root2.save!(:validate => false)
output = Category.roots.last.to_text
Category.update_all('lft = null, rgt = null')
Category.rebuild!(false)
Category.roots.last.to_text.should == output
end
it "valid_with_null_lefts" do
Category.valid?.should be_true
Category.update_all('lft = null')
Category.valid?.should be_false
end
it "valid_with_null_rights" do
Category.valid?.should be_true
Category.update_all('rgt = null')
Category.valid?.should be_false
end
it "valid_with_missing_intermediate_node" do
# Even though child_2_1 will still exist, it is a sign of a sloppy delete, not an invalid tree.
Category.valid?.should be_true
Category.delete(categories(:child_2).id)
Category.valid?.should be_true
end
it "valid_with_overlapping_and_rights" do
Category.valid?.should be_true
categories(:top_level_2)['lft'] = 0
categories(:top_level_2).save
Category.valid?.should be_false
end
it "rebuild" do
Category.valid?.should be_true
before_text = Category.root.to_text
Category.update_all('lft = null, rgt = null')
Category.rebuild!
Category.valid?.should be_true
before_text.should == Category.root.to_text
end
it "move_possible_for_sibling" do
categories(:child_2).move_possible?(categories(:child_1)).should be_true
end
it "move_not_possible_to_self" do
categories(:top_level).move_possible?(categories(:top_level)).should be_false
end
it "move_not_possible_to_parent" do
categories(:top_level).descendants.each do |descendant|
categories(:top_level).move_possible?(descendant).should be_false
descendant.move_possible?(categories(:top_level)).should be_true
end
end
it "is_or_is_ancestor_of?" do
[:child_1, :child_2, :child_2_1, :child_3].each do |c|
categories(:top_level).is_or_is_ancestor_of?(categories(c)).should be_true
end
categories(:top_level).is_or_is_ancestor_of?(categories(:top_level_2)).should be_false
end
it "left_and_rights_valid_with_blank_left" do
Category.left_and_rights_valid?.should be_true
categories(:child_2)[:lft] = nil
categories(:child_2).save(:validate => false)
Category.left_and_rights_valid?.should be_false
end
it "left_and_rights_valid_with_blank_right" do
Category.left_and_rights_valid?.should be_true
categories(:child_2)[:rgt] = nil
categories(:child_2).save(:validate => false)
Category.left_and_rights_valid?.should be_false
end
it "left_and_rights_valid_with_equal" do
Category.left_and_rights_valid?.should be_true
categories(:top_level_2)[:lft] = categories(:top_level_2)[:rgt]
categories(:top_level_2).save(:validate => false)
Category.left_and_rights_valid?.should be_false
end
it "left_and_rights_valid_with_left_equal_to_parent" do
Category.left_and_rights_valid?.should be_true
categories(:child_2)[:lft] = categories(:top_level)[:lft]
categories(:child_2).save(:validate => false)
Category.left_and_rights_valid?.should be_false
end
it "left_and_rights_valid_with_right_equal_to_parent" do
Category.left_and_rights_valid?.should be_true
categories(:child_2)[:rgt] = categories(:top_level)[:rgt]
categories(:child_2).save(:validate => false)
Category.left_and_rights_valid?.should be_false
end
it "moving_dirty_objects_doesnt_invalidate_tree" do
r1 = Category.create :name => "Test 1"
r2 = Category.create :name => "Test 2"
r3 = Category.create :name => "Test 3"
r4 = Category.create :name => "Test 4"
nodes = [r1, r2, r3, r4]
r2.move_to_child_of(r1)
Category.valid?.should be_true
r3.move_to_child_of(r1)
Category.valid?.should be_true
r4.move_to_child_of(r2)
Category.valid?.should be_true
end
it "multi_scoped_no_duplicates_for_columns?" do
lambda {
Note.no_duplicates_for_columns?
}.should_not raise_exception
end
it "multi_scoped_all_roots_valid?" do
lambda {
Note.all_roots_valid?
}.should_not raise_exception
end
it "multi_scoped" do
note1 = Note.create!(:body => "A", :notable_id => 2, :notable_type => 'Category')
note2 = Note.create!(:body => "B", :notable_id => 2, :notable_type => 'Category')
note3 = Note.create!(:body => "C", :notable_id => 2, :notable_type => 'Default')
[note1, note2].should == note1.self_and_siblings
[note3].should == note3.self_and_siblings
end
it "multi_scoped_rebuild" do
root = Note.create!(:body => "A", :notable_id => 3, :notable_type => 'Category')
child1 = Note.create!(:body => "B", :notable_id => 3, :notable_type => 'Category')
child2 = Note.create!(:body => "C", :notable_id => 3, :notable_type => 'Category')
child1.move_to_child_of root
child2.move_to_child_of root
Note.update_all('lft = null, rgt = null')
Note.rebuild!
Note.roots.find_by_body('A').should == root
[child1, child2].should == Note.roots.find_by_body('A').children
end
it "same_scope_with_multi_scopes" do
lambda {
notes(:scope1).same_scope?(notes(:child_1))
}.should_not raise_exception
notes(:scope1).same_scope?(notes(:child_1)).should be_true
notes(:child_1).same_scope?(notes(:scope1)).should be_true
notes(:scope1).same_scope?(notes(:scope2)).should be_false
end
it "quoting_of_multi_scope_column_names" do
["\"notable_id\"", "\"notable_type\""].should == Note.quoted_scope_column_names
end
it "equal_in_same_scope" do
notes(:scope1).should == notes(:scope1)
notes(:scope1).should_not == notes(:child_1)
end
it "equal_in_different_scopes" do
notes(:scope1).should_not == notes(:scope2)
end
it "delete_does_not_invalidate" do
Category.acts_as_nested_set_options[:dependent] = :delete
categories(:child_2).destroy
Category.valid?.should be_true
end
it "destroy_does_not_invalidate" do
Category.acts_as_nested_set_options[:dependent] = :destroy
categories(:child_2).destroy
Category.valid?.should be_true
end
it "destroy_multiple_times_does_not_invalidate" do
Category.acts_as_nested_set_options[:dependent] = :destroy
categories(:child_2).destroy
categories(:child_2).destroy
Category.valid?.should be_true
end
it "assigning_parent_id_on_create" do
category = Category.create!(:name => "Child", :parent_id => categories(:child_2).id)
categories(:child_2).should == category.parent
categories(:child_2).id.should == category.parent_id
category.left.should_not be_nil
category.right.should_not be_nil
Category.valid?.should be_true
end
it "assigning_parent_on_create" do
category = Category.create!(:name => "Child", :parent => categories(:child_2))
categories(:child_2).should == category.parent
categories(:child_2).id.should == category.parent_id
category.left.should_not be_nil
category.right.should_not be_nil
Category.valid?.should be_true
end
it "assigning_parent_id_to_nil_on_create" do
category = Category.create!(:name => "New Root", :parent_id => nil)
category.parent.should be_nil
category.parent_id.should be_nil
category.left.should_not be_nil
category.right.should_not be_nil
Category.valid?.should be_true
end
it "assigning_parent_id_on_update" do
category = categories(:child_2_1)
category.parent_id = categories(:child_3).id
category.save
category.reload
categories(:child_3).reload
categories(:child_3).should == category.parent
categories(:child_3).id.should == category.parent_id
Category.valid?.should be_true
end
it "assigning_parent_on_update" do
category = categories(:child_2_1)
category.parent = categories(:child_3)
category.save
category.reload
categories(:child_3).reload
categories(:child_3).should == category.parent
categories(:child_3).id.should == category.parent_id
Category.valid?.should be_true
end
it "assigning_parent_id_to_nil_on_update" do
category = categories(:child_2_1)
category.parent_id = nil
category.save
category.parent.should be_nil
category.parent_id.should be_nil
Category.valid?.should be_true
end
it "creating_child_from_parent" do
category = categories(:child_2).children.create!(:name => "Child")
categories(:child_2).should == category.parent
categories(:child_2).id.should == category.parent_id
category.left.should_not be_nil
category.right.should_not be_nil
Category.valid?.should be_true
end
def check_structure(entries, structure)
structure = structure.dup
Category.each_with_level(entries) do |category, level|
expected_level, expected_name = structure.shift
expected_name.should == category.name
expected_level.should == level
end
end
it "each_with_level" do
levels = [
[0, "Top Level"],
[1, "Child 1"],
[1, "Child 2"],
[2, "Child 2.1"],
[1, "Child 3" ]]
check_structure(Category.root.self_and_descendants, levels)
# test some deeper structures
category = Category.find_by_name("Child 1")
c1 = Category.new(:name => "Child 1.1")
c2 = Category.new(:name => "Child 1.1.1")
c3 = Category.new(:name => "Child 1.1.1.1")
c4 = Category.new(:name => "Child 1.2")
[c1, c2, c3, c4].each(&:save!)
c1.move_to_child_of(category)
c2.move_to_child_of(c1)
c3.move_to_child_of(c2)
c4.move_to_child_of(category)
levels = [
[0, "Top Level"],
[1, "Child 1"],
[2, "Child 1.1"],
[3, "Child 1.1.1"],
[4, "Child 1.1.1.1"],
[2, "Child 1.2"],
[1, "Child 2"],
[2, "Child 2.1"],
[1, "Child 3" ]]
check_structure(Category.root.self_and_descendants, levels)
end
it "should not error on a model with attr_accessible" do
model = Class.new(ActiveRecord::Base)
model.set_table_name 'categories'
model.attr_accessible :name
lambda {
model.acts_as_nested_set
model.new(:name => 'foo')
}.should_not raise_exception
end
describe "before_move_callback" do
it "should fire the callback" do
categories(:child_2).should_receive(:custom_before_move)
categories(:child_2).move_to_root
end
it "should stop move when callback returns false" do
Category.test_allows_move = false
categories(:child_3).move_to_root.should be_false
categories(:child_3).root?.should be_false
end
it "should not halt save actions" do
Category.test_allows_move = false
categories(:child_3).parent_id = nil
categories(:child_3).save.should be_true
end
end
describe "counter_cache" do
it "should allow use of a counter cache for children" do
note1 = things(:parent1)
note1.children.count.should == 2
end
it "should increment the counter cache on create" do
note1 = things(:parent1)
note1.children.count.should == 2
note1[:children_count].should == 2
note1.children.create :body => 'Child 3'
note1.children.count.should == 3
note1.reload
note1[:children_count].should == 3
end
it "should decrement the counter cache on destroy" do
note1 = things(:parent1)
note1.children.count.should == 2
note1[:children_count].should == 2
note1.children.last.destroy
note1.children.count.should == 1
note1.reload
note1[:children_count].should == 1
end
end
describe "association callbacks on children" do
it "should call the appropriate callbacks on the children :has_many association " do
root = DefaultWithCallbacks.create
root.should_not be_new_record
child = root.children.build
root.before_add.should == child
root.after_add.should == child
root.before_remove.should_not == child
root.after_remove.should_not == child
child.save.should be_true
root.children.delete(child).should be_true
root.before_remove.should == child
root.after_remove.should == child
end
end
end

View File

@@ -0,0 +1,18 @@
sqlite3:
adapter: <%= "jdbc" if defined? JRUBY_VERSION %>sqlite3
database: awesome_nested_set.sqlite3.db
sqlite3mem:
adapter: <%= "jdbc" if defined? JRUBY_VERSION %>sqlite3
database: ":memory:"
postgresql:
adapter: postgresql
username: postgres
password: postgres
database: awesome_nested_set_plugin_test
min_messages: ERROR
mysql:
adapter: mysql2
host: localhost
username: root
password:
database: awesome_nested_set_plugin_test

View File

@@ -0,0 +1,45 @@
ActiveRecord::Schema.define(:version => 0) do
create_table :categories, :force => true do |t|
t.column :name, :string
t.column :parent_id, :integer
t.column :lft, :integer
t.column :rgt, :integer
t.column :organization_id, :integer
end
create_table :departments, :force => true do |t|
t.column :name, :string
end
create_table :notes, :force => true do |t|
t.column :body, :text
t.column :parent_id, :integer
t.column :lft, :integer
t.column :rgt, :integer
t.column :notable_id, :integer
t.column :notable_type, :string
end
create_table :renamed_columns, :force => true do |t|
t.column :name, :string
t.column :mother_id, :integer
t.column :red, :integer
t.column :black, :integer
end
create_table :things, :force => true do |t|
t.column :body, :text
t.column :parent_id, :integer
t.column :lft, :integer
t.column :rgt, :integer
t.column :children_count, :integer
end
create_table :brokens, :force => true do |t|
t.column :name, :string
t.column :parent_id, :integer
t.column :lft, :integer
t.column :rgt, :integer
end
end

View File

@@ -0,0 +1,3 @@
one:
id: 1
name: One

View File

@@ -0,0 +1,34 @@
top_level:
id: 1
name: Top Level
lft: 1
rgt: 10
child_1:
id: 2
name: Child 1
parent_id: 1
lft: 2
rgt: 3
child_2:
id: 3
name: Child 2
parent_id: 1
lft: 4
rgt: 7
child_2_1:
id: 4
name: Child 2.1
parent_id: 3
lft: 5
rgt: 6
child_3:
id: 5
name: Child 3
parent_id: 1
lft: 8
rgt: 9
top_level_2:
id: 6
name: Top Level 2
lft: 11
rgt: 12

View File

@@ -0,0 +1,3 @@
top:
id: 1
name: Top

View File

@@ -0,0 +1,38 @@
scope1:
id: 1
body: Top Level
lft: 1
rgt: 10
notable_id: 1
notable_type: Category
child_1:
id: 2
body: Child 1
parent_id: 1
lft: 2
rgt: 3
notable_id: 1
notable_type: Category
child_2:
id: 3
body: Child 2
parent_id: 1
lft: 4
rgt: 7
notable_id: 1
notable_type: Category
child_3:
id: 4
body: Child 3
parent_id: 1
lft: 8
rgt: 9
notable_id: 1
notable_type: Category
scope2:
id: 5
body: Top Level 2
lft: 1
rgt: 2
notable_id: 1
notable_type: Departments

View File

@@ -0,0 +1,27 @@
parent1:
id: 1
body: Top Level
lft: 1
rgt: 10
children_count: 2
child_1:
id: 2
body: Child 1
parent_id: 1
lft: 2
rgt: 3
children_count: 0
child_2:
id: 3
body: Child 2
parent_id: 1
lft: 4
rgt: 7
children_count: 0
child_2_1:
id: 4
body: Child 2.1
parent_id: 3
lft: 8
rgt: 9
children_count: 0

View File

@@ -0,0 +1,32 @@
$:.unshift(File.dirname(__FILE__) + '/../lib')
plugin_test_dir = File.dirname(__FILE__)
require 'rubygems'
require 'bundler/setup'
require 'rspec'
require 'logger'
require 'active_support'
require 'active_model'
require 'active_record'
require 'action_controller'
require 'awesome_nested_set'
ActiveRecord::Base.logger = Logger.new(plugin_test_dir + "/debug.log")
require 'yaml'
require 'erb'
ActiveRecord::Base.configurations = YAML::load(ERB.new(IO.read(plugin_test_dir + "/db/database.yml")).result)
ActiveRecord::Base.establish_connection(ENV["DB"] || "sqlite3mem")
ActiveRecord::Migration.verbose = false
load(File.join(plugin_test_dir, "db", "schema.rb"))
require 'support/models'
require 'rspec/rails'
RSpec.configure do |config|
config.fixture_path = "#{plugin_test_dir}/fixtures"
config.use_transactional_fixtures = true
end

View File

@@ -0,0 +1,72 @@
class Note < ActiveRecord::Base
acts_as_nested_set :scope => [:notable_id, :notable_type]
end
class Default < ActiveRecord::Base
set_table_name 'categories'
acts_as_nested_set
end
class ScopedCategory < ActiveRecord::Base
set_table_name 'categories'
acts_as_nested_set :scope => :organization
end
class RenamedColumns < ActiveRecord::Base
acts_as_nested_set :parent_column => 'mother_id', :left_column => 'red', :right_column => 'black'
end
class Category < ActiveRecord::Base
acts_as_nested_set
validates_presence_of :name
# Setup a callback that we can switch to true or false per-test
set_callback :move, :before, :custom_before_move
cattr_accessor :test_allows_move
@@test_allows_move = true
def custom_before_move
@@test_allows_move
end
def to_s
name
end
def recurse &block
block.call self, lambda{
self.children.each do |child|
child.recurse &block
end
}
end
end
class Thing < ActiveRecord::Base
acts_as_nested_set :counter_cache => 'children_count'
end
class DefaultWithCallbacks < ActiveRecord::Base
set_table_name 'categories'
attr_accessor :before_add, :after_add, :before_remove, :after_remove
acts_as_nested_set :before_add => :do_before_add_stuff,
:after_add => :do_after_add_stuff,
:before_remove => :do_before_remove_stuff,
:after_remove => :do_after_remove_stuff
private
[ :before_add, :after_add, :before_remove, :after_remove ].each do |hook_name|
define_method "do_#{hook_name}_stuff" do |child_node|
self.send("#{hook_name}=", child_node)
end
end
end
class Broken < ActiveRecord::Base
acts_as_nested_set
end

View File

@@ -0,0 +1,41 @@
require File.dirname(__FILE__) + '/../test_helper'
module CollectiveIdea
module Acts #:nodoc:
module NestedSet #:nodoc:
class AwesomeNestedSetTest < Test::Unit::TestCase
include Helper
fixtures :categories
def test_nested_set_options
expected = [
[" Top Level", 1],
["- Child 1", 2],
['- Child 2', 3],
['-- Child 2.1', 4],
['- Child 3', 5],
[" Top Level 2", 6]
]
actual = nested_set_options(Category) do |c|
"#{'-' * c.level} #{c.name}"
end
assert_equal expected, actual
end
def test_nested_set_options_with_mover
expected = [
[" Top Level", 1],
["- Child 1", 2],
['- Child 3', 5],
[" Top Level 2", 6]
]
actual = nested_set_options(Category, categories(:child_2)) do |c|
"#{'-' * c.level} #{c.name}"
end
assert_equal expected, actual
end
end
end
end
end

View File

@@ -0,0 +1,603 @@
require File.dirname(__FILE__) + '/test_helper'
class Note < ActiveRecord::Base
acts_as_nested_set :scope => [:notable_id, :notable_type]
end
class AwesomeNestedSetTest < Test::Unit::TestCase
class Default < ActiveRecord::Base
acts_as_nested_set
set_table_name 'categories'
end
class Scoped < ActiveRecord::Base
acts_as_nested_set :scope => :organization
set_table_name 'categories'
end
def test_left_column_default
assert_equal 'lft', Default.acts_as_nested_set_options[:left_column]
end
def test_right_column_default
assert_equal 'rgt', Default.acts_as_nested_set_options[:right_column]
end
def test_parent_column_default
assert_equal 'parent_id', Default.acts_as_nested_set_options[:parent_column]
end
def test_scope_default
assert_nil Default.acts_as_nested_set_options[:scope]
end
def test_left_column_name
assert_equal 'lft', Default.left_column_name
assert_equal 'lft', Default.new.left_column_name
end
def test_right_column_name
assert_equal 'rgt', Default.right_column_name
assert_equal 'rgt', Default.new.right_column_name
end
def test_parent_column_name
assert_equal 'parent_id', Default.parent_column_name
assert_equal 'parent_id', Default.new.parent_column_name
end
def test_quoted_left_column_name
quoted = Default.connection.quote_column_name('lft')
assert_equal quoted, Default.quoted_left_column_name
assert_equal quoted, Default.new.quoted_left_column_name
end
def test_quoted_right_column_name
quoted = Default.connection.quote_column_name('rgt')
assert_equal quoted, Default.quoted_right_column_name
assert_equal quoted, Default.new.quoted_right_column_name
end
def test_left_column_protected_from_assignment
assert_raises(ActiveRecord::ActiveRecordError) { Category.new.lft = 1 }
end
def test_right_column_protected_from_assignment
assert_raises(ActiveRecord::ActiveRecordError) { Category.new.rgt = 1 }
end
def test_parent_column_protected_from_assignment
assert_raises(ActiveRecord::ActiveRecordError) { Category.new.parent_id = 1 }
end
def test_colums_protected_on_initialize
c = Category.new(:lft => 1, :rgt => 2, :parent_id => 3)
assert_nil c.lft
assert_nil c.rgt
assert_nil c.parent_id
end
def test_scoped_appends_id
assert_equal :organization_id, Scoped.acts_as_nested_set_options[:scope]
end
def test_roots_class_method
assert_equal Category.find_all_by_parent_id(nil), Category.roots
end
def test_root_class_method
assert_equal categories(:top_level), Category.root
end
def test_root
assert_equal categories(:top_level), categories(:child_3).root
end
def test_root?
assert categories(:top_level).root?
assert categories(:top_level_2).root?
end
def test_leaves_class_method
assert_equal Category.find(:all, :conditions => "#{Category.right_column_name} - #{Category.left_column_name} = 1"), Category.leaves
assert_equal Category.leaves.count, 4
assert (Category.leaves.include? categories(:child_1))
assert (Category.leaves.include? categories(:child_2_1))
assert (Category.leaves.include? categories(:child_3))
assert (Category.leaves.include? categories(:top_level_2))
end
def test_leaf
assert categories(:child_1).leaf?
assert categories(:child_2_1).leaf?
assert categories(:child_3).leaf?
assert categories(:top_level_2).leaf?
assert !categories(:top_level).leaf?
assert !categories(:child_2).leaf?
end
def test_parent
assert_equal categories(:child_2), categories(:child_2_1).parent
end
def test_self_and_ancestors
child = categories(:child_2_1)
self_and_ancestors = [categories(:top_level), categories(:child_2), child]
assert_equal self_and_ancestors, child.self_and_ancestors
end
def test_ancestors
child = categories(:child_2_1)
ancestors = [categories(:top_level), categories(:child_2)]
assert_equal ancestors, child.ancestors
end
def test_self_and_siblings
child = categories(:child_2)
self_and_siblings = [categories(:child_1), child, categories(:child_3)]
assert_equal self_and_siblings, child.self_and_siblings
assert_nothing_raised do
tops = [categories(:top_level), categories(:top_level_2)]
assert_equal tops, categories(:top_level).self_and_siblings
end
end
def test_siblings
child = categories(:child_2)
siblings = [categories(:child_1), categories(:child_3)]
assert_equal siblings, child.siblings
end
def test_leaves
leaves = [categories(:child_1), categories(:child_2_1), categories(:child_3), categories(:top_level_2)]
assert categories(:top_level).leaves, leaves
end
def test_level
assert_equal 0, categories(:top_level).level
assert_equal 1, categories(:child_1).level
assert_equal 2, categories(:child_2_1).level
end
def test_has_children?
assert categories(:child_2_1).children.empty?
assert !categories(:child_2).children.empty?
assert !categories(:top_level).children.empty?
end
def test_self_and_descendents
parent = categories(:top_level)
self_and_descendants = [parent, categories(:child_1), categories(:child_2),
categories(:child_2_1), categories(:child_3)]
assert_equal self_and_descendants, parent.self_and_descendants
assert_equal self_and_descendants, parent.self_and_descendants.count
end
def test_descendents
lawyers = Category.create!(:name => "lawyers")
us = Category.create!(:name => "United States")
us.move_to_child_of(lawyers)
patent = Category.create!(:name => "Patent Law")
patent.move_to_child_of(us)
lawyers.reload
assert_equal 1, lawyers.children.size
assert_equal 1, us.children.size
assert_equal 2, lawyers.descendants.size
end
def test_self_and_descendents
parent = categories(:top_level)
descendants = [categories(:child_1), categories(:child_2),
categories(:child_2_1), categories(:child_3)]
assert_equal descendants, parent.descendants
end
def test_children
category = categories(:top_level)
category.children.each {|c| assert_equal category.id, c.parent_id }
end
def test_is_or_is_ancestor_of?
assert categories(:top_level).is_or_is_ancestor_of?(categories(:child_1))
assert categories(:top_level).is_or_is_ancestor_of?(categories(:child_2_1))
assert categories(:child_2).is_or_is_ancestor_of?(categories(:child_2_1))
assert !categories(:child_2_1).is_or_is_ancestor_of?(categories(:child_2))
assert !categories(:child_1).is_or_is_ancestor_of?(categories(:child_2))
assert categories(:child_1).is_or_is_ancestor_of?(categories(:child_1))
end
def test_is_ancestor_of?
assert categories(:top_level).is_ancestor_of?(categories(:child_1))
assert categories(:top_level).is_ancestor_of?(categories(:child_2_1))
assert categories(:child_2).is_ancestor_of?(categories(:child_2_1))
assert !categories(:child_2_1).is_ancestor_of?(categories(:child_2))
assert !categories(:child_1).is_ancestor_of?(categories(:child_2))
assert !categories(:child_1).is_ancestor_of?(categories(:child_1))
end
def test_is_or_is_ancestor_of_with_scope
root = Scoped.root
child = root.children.first
assert root.is_or_is_ancestor_of?(child)
child.update_attribute :organization_id, 'different'
assert !root.is_or_is_ancestor_of?(child)
end
def test_is_or_is_descendant_of?
assert categories(:child_1).is_or_is_descendant_of?(categories(:top_level))
assert categories(:child_2_1).is_or_is_descendant_of?(categories(:top_level))
assert categories(:child_2_1).is_or_is_descendant_of?(categories(:child_2))
assert !categories(:child_2).is_or_is_descendant_of?(categories(:child_2_1))
assert !categories(:child_2).is_or_is_descendant_of?(categories(:child_1))
assert categories(:child_1).is_or_is_descendant_of?(categories(:child_1))
end
def test_is_descendant_of?
assert categories(:child_1).is_descendant_of?(categories(:top_level))
assert categories(:child_2_1).is_descendant_of?(categories(:top_level))
assert categories(:child_2_1).is_descendant_of?(categories(:child_2))
assert !categories(:child_2).is_descendant_of?(categories(:child_2_1))
assert !categories(:child_2).is_descendant_of?(categories(:child_1))
assert !categories(:child_1).is_descendant_of?(categories(:child_1))
end
def test_is_or_is_descendant_of_with_scope
root = Scoped.root
child = root.children.first
assert child.is_or_is_descendant_of?(root)
child.update_attribute :organization_id, 'different'
assert !child.is_or_is_descendant_of?(root)
end
def test_same_scope?
root = Scoped.root
child = root.children.first
assert child.same_scope?(root)
child.update_attribute :organization_id, 'different'
assert !child.same_scope?(root)
end
def test_left_sibling
assert_equal categories(:child_1), categories(:child_2).left_sibling
assert_equal categories(:child_2), categories(:child_3).left_sibling
end
def test_left_sibling_of_root
assert_nil categories(:top_level).left_sibling
end
def test_left_sibling_without_siblings
assert_nil categories(:child_2_1).left_sibling
end
def test_left_sibling_of_leftmost_node
assert_nil categories(:child_1).left_sibling
end
def test_right_sibling
assert_equal categories(:child_3), categories(:child_2).right_sibling
assert_equal categories(:child_2), categories(:child_1).right_sibling
end
def test_right_sibling_of_root
assert_equal categories(:top_level_2), categories(:top_level).right_sibling
assert_nil categories(:top_level_2).right_sibling
end
def test_right_sibling_without_siblings
assert_nil categories(:child_2_1).right_sibling
end
def test_right_sibling_of_rightmost_node
assert_nil categories(:child_3).right_sibling
end
def test_move_left
categories(:child_2).move_left
assert_nil categories(:child_2).left_sibling
assert_equal categories(:child_1), categories(:child_2).right_sibling
assert Category.valid?
end
def test_move_right
categories(:child_2).move_right
assert_nil categories(:child_2).right_sibling
assert_equal categories(:child_3), categories(:child_2).left_sibling
assert Category.valid?
end
def test_move_to_left_of
categories(:child_3).move_to_left_of(categories(:child_1))
assert_nil categories(:child_3).left_sibling
assert_equal categories(:child_1), categories(:child_3).right_sibling
assert Category.valid?
end
def test_move_to_right_of
categories(:child_1).move_to_right_of(categories(:child_3))
assert_nil categories(:child_1).right_sibling
assert_equal categories(:child_3), categories(:child_1).left_sibling
assert Category.valid?
end
def test_move_to_root
categories(:child_2).move_to_root
assert_nil categories(:child_2).parent
assert_equal 0, categories(:child_2).level
assert_equal 1, categories(:child_2_1).level
assert_equal 1, categories(:child_2).left
assert_equal 4, categories(:child_2).right
assert Category.valid?
end
def test_move_to_child_of
categories(:child_1).move_to_child_of(categories(:child_3))
assert_equal categories(:child_3).id, categories(:child_1).parent_id
assert Category.valid?
end
def test_move_to_child_of_appends_to_end
child = Category.create! :name => 'New Child'
child.move_to_child_of categories(:top_level)
assert_equal child, categories(:top_level).children.last
end
def test_subtree_move_to_child_of
assert_equal 4, categories(:child_2).left
assert_equal 7, categories(:child_2).right
assert_equal 2, categories(:child_1).left
assert_equal 3, categories(:child_1).right
categories(:child_2).move_to_child_of(categories(:child_1))
assert Category.valid?
assert_equal categories(:child_1).id, categories(:child_2).parent_id
assert_equal 3, categories(:child_2).left
assert_equal 6, categories(:child_2).right
assert_equal 2, categories(:child_1).left
assert_equal 7, categories(:child_1).right
end
def test_slightly_difficult_move_to_child_of
assert_equal 11, categories(:top_level_2).left
assert_equal 12, categories(:top_level_2).right
# create a new top-level node and move single-node top-level tree inside it.
new_top = Category.create(:name => 'New Top')
assert_equal 13, new_top.left
assert_equal 14, new_top.right
categories(:top_level_2).move_to_child_of(new_top)
assert Category.valid?
assert_equal new_top.id, categories(:top_level_2).parent_id
assert_equal 12, categories(:top_level_2).left
assert_equal 13, categories(:top_level_2).right
assert_equal 11, new_top.left
assert_equal 14, new_top.right
end
def test_difficult_move_to_child_of
assert_equal 1, categories(:top_level).left
assert_equal 10, categories(:top_level).right
assert_equal 5, categories(:child_2_1).left
assert_equal 6, categories(:child_2_1).right
# create a new top-level node and move an entire top-level tree inside it.
new_top = Category.create(:name => 'New Top')
categories(:top_level).move_to_child_of(new_top)
categories(:child_2_1).reload
assert Category.valid?
assert_equal new_top.id, categories(:top_level).parent_id
assert_equal 4, categories(:top_level).left
assert_equal 13, categories(:top_level).right
assert_equal 8, categories(:child_2_1).left
assert_equal 9, categories(:child_2_1).right
end
#rebuild swaps the position of the 2 children when added using move_to_child twice onto same parent
def test_move_to_child_more_than_once_per_parent_rebuild
root1 = Category.create(:name => 'Root1')
root2 = Category.create(:name => 'Root2')
root3 = Category.create(:name => 'Root3')
root2.move_to_child_of root1
root3.move_to_child_of root1
output = Category.roots.last.to_text
Category.update_all('lft = null, rgt = null')
Category.rebuild!
assert_equal Category.roots.last.to_text, output
end
# doing move_to_child twice onto same parent from the furthest right first
def test_move_to_child_more_than_once_per_parent_outside_in
node1 = Category.create(:name => 'Node-1')
node2 = Category.create(:name => 'Node-2')
node3 = Category.create(:name => 'Node-3')
node2.move_to_child_of node1
node3.move_to_child_of node1
output = Category.roots.last.to_text
Category.update_all('lft = null, rgt = null')
Category.rebuild!
assert_equal Category.roots.last.to_text, output
end
def test_valid_with_null_lefts
assert Category.valid?
Category.update_all('lft = null')
assert !Category.valid?
end
def test_valid_with_null_rights
assert Category.valid?
Category.update_all('rgt = null')
assert !Category.valid?
end
def test_valid_with_missing_intermediate_node
# Even though child_2_1 will still exist, it is a sign of a sloppy delete, not an invalid tree.
assert Category.valid?
Category.delete(categories(:child_2).id)
assert Category.valid?
end
def test_valid_with_overlapping_and_rights
assert Category.valid?
categories(:top_level_2)['lft'] = 0
categories(:top_level_2).save
assert !Category.valid?
end
def test_rebuild
assert Category.valid?
before_text = Category.root.to_text
Category.update_all('lft = null, rgt = null')
Category.rebuild!
assert Category.valid?
assert_equal before_text, Category.root.to_text
end
def test_move_possible_for_sibling
assert categories(:child_2).move_possible?(categories(:child_1))
end
def test_move_not_possible_to_self
assert !categories(:top_level).move_possible?(categories(:top_level))
end
def test_move_not_possible_to_parent
categories(:top_level).descendants.each do |descendant|
assert !categories(:top_level).move_possible?(descendant)
assert descendant.move_possible?(categories(:top_level))
end
end
def test_is_or_is_ancestor_of?
[:child_1, :child_2, :child_2_1, :child_3].each do |c|
assert categories(:top_level).is_or_is_ancestor_of?(categories(c))
end
assert !categories(:top_level).is_or_is_ancestor_of?(categories(:top_level_2))
end
def test_left_and_rights_valid_with_blank_left
assert Category.left_and_rights_valid?
categories(:child_2)[:lft] = nil
categories(:child_2).save(false)
assert !Category.left_and_rights_valid?
end
def test_left_and_rights_valid_with_blank_right
assert Category.left_and_rights_valid?
categories(:child_2)[:rgt] = nil
categories(:child_2).save(false)
assert !Category.left_and_rights_valid?
end
def test_left_and_rights_valid_with_equal
assert Category.left_and_rights_valid?
categories(:top_level_2)[:lft] = categories(:top_level_2)[:rgt]
categories(:top_level_2).save(false)
assert !Category.left_and_rights_valid?
end
def test_left_and_rights_valid_with_left_equal_to_parent
assert Category.left_and_rights_valid?
categories(:child_2)[:lft] = categories(:top_level)[:lft]
categories(:child_2).save(false)
assert !Category.left_and_rights_valid?
end
def test_left_and_rights_valid_with_right_equal_to_parent
assert Category.left_and_rights_valid?
categories(:child_2)[:rgt] = categories(:top_level)[:rgt]
categories(:child_2).save(false)
assert !Category.left_and_rights_valid?
end
def test_moving_dirty_objects_doesnt_invalidate_tree
r1 = Category.create
r2 = Category.create
r3 = Category.create
r4 = Category.create
nodes = [r1, r2, r3, r4]
r2.move_to_child_of(r1)
assert Category.valid?
r3.move_to_child_of(r1)
assert Category.valid?
r4.move_to_child_of(r2)
assert Category.valid?
end
def test_multi_scoped_no_duplicates_for_columns?
assert_nothing_raised do
Note.no_duplicates_for_columns?
end
end
def test_multi_scoped_all_roots_valid?
assert_nothing_raised do
Note.all_roots_valid?
end
end
def test_multi_scoped
note1 = Note.create!(:body => "A", :notable_id => 2, :notable_type => 'Category')
note2 = Note.create!(:body => "B", :notable_id => 2, :notable_type => 'Category')
note3 = Note.create!(:body => "C", :notable_id => 2, :notable_type => 'Default')
assert_equal [note1, note2], note1.self_and_siblings
assert_equal [note3], note3.self_and_siblings
end
def test_multi_scoped_rebuild
root = Note.create!(:body => "A", :notable_id => 3, :notable_type => 'Category')
child1 = Note.create!(:body => "B", :notable_id => 3, :notable_type => 'Category')
child2 = Note.create!(:body => "C", :notable_id => 3, :notable_type => 'Category')
child1.move_to_child_of root
child2.move_to_child_of root
Note.update_all('lft = null, rgt = null')
Note.rebuild!
assert_equal Note.roots.find_by_body('A'), root
assert_equal [child1, child2], Note.roots.find_by_body('A').children
end
def test_same_scope_with_multi_scopes
assert_nothing_raised do
notes(:scope1).same_scope?(notes(:child_1))
end
assert notes(:scope1).same_scope?(notes(:child_1))
assert notes(:child_1).same_scope?(notes(:scope1))
assert !notes(:scope1).same_scope?(notes(:scope2))
end
def test_quoting_of_multi_scope_column_names
assert_equal ["\"notable_id\"", "\"notable_type\""], Note.quoted_scope_column_names
end
def test_equal_in_same_scope
assert_equal notes(:scope1), notes(:scope1)
assert_not_equal notes(:scope1), notes(:child_1)
end
def test_equal_in_different_scopes
assert_not_equal notes(:scope1), notes(:scope2)
end
end

View File

@@ -0,0 +1,18 @@
sqlite3:
adapter: sqlite3
dbfile: awesome_nested_set.sqlite3.db
sqlite3mem:
:adapter: sqlite3
:dbfile: ":memory:"
postgresql:
:adapter: postgresql
:username: postgres
:password: postgres
:database: awesome_nested_set_plugin_test
:min_messages: ERROR
mysql:
:adapter: mysql
:host: localhost
:username: root
:password:
:database: awesome_nested_set_plugin_test

View File

@@ -0,0 +1,23 @@
ActiveRecord::Schema.define(:version => 0) do
create_table :categories, :force => true do |t|
t.column :name, :string
t.column :parent_id, :integer
t.column :lft, :integer
t.column :rgt, :integer
t.column :organization_id, :integer
end
create_table :departments, :force => true do |t|
t.column :name, :string
end
create_table :notes, :force => true do |t|
t.column :body, :text
t.column :parent_id, :integer
t.column :lft, :integer
t.column :rgt, :integer
t.column :notable_id, :integer
t.column :notable_type, :string
end
end

View File

@@ -0,0 +1,34 @@
top_level:
id: 1
name: Top Level
lft: 1
rgt: 10
child_1:
id: 2
name: Child 1
parent_id: 1
lft: 2
rgt: 3
child_2:
id: 3
name: Child 2
parent_id: 1
lft: 4
rgt: 7
child_2_1:
id: 4
name: Child 2.1
parent_id: 3
lft: 5
rgt: 6
child_3:
id: 5
name: Child 3
parent_id: 1
lft: 8
rgt: 9
top_level_2:
id: 6
name: Top Level 2
lft: 11
rgt: 12

View File

@@ -0,0 +1,15 @@
class Category < ActiveRecord::Base
acts_as_nested_set
def to_s
name
end
def recurse &block
block.call self, lambda{
self.children.each do |child|
child.recurse &block
end
}
end
end

View File

@@ -0,0 +1,3 @@
top:
id: 1
name: Top

View File

@@ -0,0 +1,38 @@
scope1:
id: 1
body: Top Level
lft: 1
rgt: 10
notable_id: 1
notable_type: Category
child_1:
id: 2
body: Child 1
parent_id: 1
lft: 2
rgt: 3
notable_id: 1
notable_type: Category
child_2:
id: 3
body: Child 2
parent_id: 1
lft: 4
rgt: 7
notable_id: 1
notable_type: Category
child_3:
id: 4
body: Child 3
parent_id: 1
lft: 8
rgt: 9
notable_id: 1
notable_type: Category
scope2:
id: 5
body: Top Level 2
lft: 1
rgt: 2
notable_id: 1
notable_type: Departments

View File

@@ -0,0 +1,31 @@
$:.unshift(File.dirname(__FILE__) + '/../lib')
plugin_test_dir = File.dirname(__FILE__)
require 'rubygems'
require 'test/unit'
require 'multi_rails_init'
# gem 'activerecord', '>= 2.0'
require 'active_record'
require 'action_controller'
require 'action_view'
require 'active_record/fixtures'
require plugin_test_dir + '/../init.rb'
ActiveRecord::Base.logger = Logger.new(plugin_test_dir + "/debug.log")
ActiveRecord::Base.configurations = YAML::load(IO.read(plugin_test_dir + "/db/database.yml"))
ActiveRecord::Base.establish_connection(ENV["DB"] || "sqlite3mem")
ActiveRecord::Migration.verbose = false
load(File.join(plugin_test_dir, "db", "schema.rb"))
Dir["#{plugin_test_dir}/fixtures/*.rb"].each {|file| require file }
class Test::Unit::TestCase #:nodoc:
self.fixture_path = File.dirname(__FILE__) + "/fixtures/"
self.use_transactional_fixtures = true
self.use_instantiated_fixtures = false
fixtures :categories, :notes, :departments
end

View File

@@ -0,0 +1,152 @@
* Exported the changelog of Pagination code for historical reference.
* Imported some patches from Rails Trac (others closed as "wontfix"):
#8176, #7325, #7028, #4113. Documentation is much cleaner now and there
are some new unobtrusive features!
* Extracted Pagination from Rails trunk (r6795)
#
# ChangeLog for /trunk/actionpack/lib/action_controller/pagination.rb
#
# Generated by Trac 0.10.3
# 05/20/07 23:48:02
#
09/03/06 23:28:54 david [4953]
* trunk/actionpack/lib/action_controller/pagination.rb (modified)
Docs and deprecation
08/07/06 12:40:14 bitsweat [4715]
* trunk/actionpack/lib/action_controller/pagination.rb (modified)
Deprecate direct usage of @params. Update ActionView::Base for
instance var deprecation.
06/21/06 02:16:11 rick [4476]
* trunk/actionpack/lib/action_controller/pagination.rb (modified)
Fix indent in pagination documentation. Closes #4990. [Kevin Clark]
04/25/06 17:42:48 marcel [4268]
* trunk/actionpack/lib/action_controller/pagination.rb (modified)
Remove all remaining references to @params in the documentation.
03/16/06 06:38:08 rick [3899]
* trunk/actionpack/lib/action_view/helpers/pagination_helper.rb (modified)
trivial documentation patch for #pagination_links [Francois
Beausoleil] closes #4258
02/20/06 03:15:22 david [3620]
* trunk/actionpack/lib/action_controller/pagination.rb (modified)
* trunk/actionpack/test/activerecord/pagination_test.rb (modified)
* trunk/activerecord/CHANGELOG (modified)
* trunk/activerecord/lib/active_record/base.rb (modified)
* trunk/activerecord/test/base_test.rb (modified)
Added :count option to pagination that'll make it possible for the
ActiveRecord::Base.count call to using something else than * for the
count. Especially important for count queries using DISTINCT #3839
[skaes]. Added :select option to Base.count that'll allow you to
select something else than * to be counted on. Especially important
for count queries using DISTINCT (closes #3839) [skaes].
02/09/06 09:17:40 nzkoz [3553]
* trunk/actionpack/lib/action_controller/pagination.rb (modified)
* trunk/actionpack/test/active_record_unit.rb (added)
* trunk/actionpack/test/activerecord (added)
* trunk/actionpack/test/activerecord/active_record_assertions_test.rb (added)
* trunk/actionpack/test/activerecord/pagination_test.rb (added)
* trunk/actionpack/test/controller/active_record_assertions_test.rb (deleted)
* trunk/actionpack/test/fixtures/companies.yml (added)
* trunk/actionpack/test/fixtures/company.rb (added)
* trunk/actionpack/test/fixtures/db_definitions (added)
* trunk/actionpack/test/fixtures/db_definitions/sqlite.sql (added)
* trunk/actionpack/test/fixtures/developer.rb (added)
* trunk/actionpack/test/fixtures/developers_projects.yml (added)
* trunk/actionpack/test/fixtures/developers.yml (added)
* trunk/actionpack/test/fixtures/project.rb (added)
* trunk/actionpack/test/fixtures/projects.yml (added)
* trunk/actionpack/test/fixtures/replies.yml (added)
* trunk/actionpack/test/fixtures/reply.rb (added)
* trunk/actionpack/test/fixtures/topic.rb (added)
* trunk/actionpack/test/fixtures/topics.yml (added)
* Fix pagination problems when using include
* Introduce Unit Tests for pagination
* Allow count to work with :include by using count distinct.
[Kevin Clark &amp; Jeremy Hopple]
11/05/05 02:10:29 bitsweat [2878]
* trunk/actionpack/lib/action_controller/pagination.rb (modified)
Update paginator docs. Closes #2744.
10/16/05 15:42:03 minam [2649]
* trunk/actionpack/lib/action_controller/pagination.rb (modified)
Update/clean up AP documentation (rdoc)
08/31/05 00:13:10 ulysses [2078]
* trunk/actionpack/CHANGELOG (modified)
* trunk/actionpack/lib/action_controller/pagination.rb (modified)
Add option to specify the singular name used by pagination. Closes
#1960
08/23/05 14:24:15 minam [2041]
* trunk/actionpack/CHANGELOG (modified)
* trunk/actionpack/lib/action_controller/pagination.rb (modified)
Add support for :include with pagination (subject to existing
constraints for :include with :limit and :offset) #1478
[michael@schubert.cx]
07/15/05 20:27:38 david [1839]
* trunk/actionpack/lib/action_controller/pagination.rb (modified)
* trunk/actionpack/lib/action_view/helpers/pagination_helper.rb (modified)
More pagination speed #1334 [Stefan Kaes]
07/14/05 08:02:01 david [1832]
* trunk/actionpack/lib/action_controller/pagination.rb (modified)
* trunk/actionpack/lib/action_view/helpers/pagination_helper.rb (modified)
* trunk/actionpack/test/controller/addresses_render_test.rb (modified)
Made pagination faster #1334 [Stefan Kaes]
04/13/05 05:40:22 david [1159]
* trunk/actionpack/CHANGELOG (modified)
* trunk/actionpack/lib/action_controller/pagination.rb (modified)
* trunk/activerecord/lib/active_record/base.rb (modified)
Fixed pagination to work with joins #1034 [scott@sigkill.org]
04/02/05 09:11:17 david [1067]
* trunk/actionpack/CHANGELOG (modified)
* trunk/actionpack/lib/action_controller/pagination.rb (modified)
* trunk/actionpack/lib/action_controller/scaffolding.rb (modified)
* trunk/actionpack/lib/action_controller/templates/scaffolds/list.rhtml (modified)
* trunk/railties/lib/rails_generator/generators/components/scaffold/templates/controller.rb (modified)
* trunk/railties/lib/rails_generator/generators/components/scaffold/templates/view_list.rhtml (modified)
Added pagination for scaffolding (10 items per page) #964
[mortonda@dgrmm.net]
03/31/05 14:46:11 david [1048]
* trunk/actionpack/lib/action_view/helpers/pagination_helper.rb (modified)
Improved the message display on the exception handler pages #963
[Johan Sorensen]
03/27/05 00:04:07 david [1017]
* trunk/actionpack/CHANGELOG (modified)
* trunk/actionpack/lib/action_view/helpers/pagination_helper.rb (modified)
Fixed that pagination_helper would ignore :params #947 [Sebastian
Kanthak]
03/22/05 13:09:44 david [976]
* trunk/actionpack/lib/action_view/helpers/pagination_helper.rb (modified)
Fixed documentation and prepared for 0.11.0 release
03/21/05 14:35:36 david [967]
* trunk/actionpack/lib/action_controller/pagination.rb (modified)
* trunk/actionpack/lib/action_view/helpers/pagination_helper.rb (modified)
Tweaked the documentation
03/20/05 23:12:05 david [949]
* trunk/actionpack/CHANGELOG (modified)
* trunk/actionpack/lib/action_controller.rb (modified)
* trunk/actionpack/lib/action_controller/pagination.rb (added)
* trunk/actionpack/lib/action_view/helpers/pagination_helper.rb (added)
* trunk/activesupport/lib/active_support/core_ext/kernel.rb (added)
Added pagination support through both a controller and helper add-on
#817 [Sam Stephenson]

View File

@@ -0,0 +1,18 @@
Pagination
==========
To install:
script/plugin install svn://errtheblog.com/svn/plugins/classic_pagination
This code was extracted from Rails trunk after the release 1.2.3.
WARNING: this code is dead. It is unmaintained, untested and full of cruft.
There is a much better pagination plugin called will_paginate.
Install it like this and glance through the README:
script/plugin install svn://errtheblog.com/svn/plugins/will_paginate
It doesn't have the same API, but is in fact much nicer. You can
have both plugins installed until you change your controller/view code that
handles pagination. Then, simply uninstall classic_pagination.

View File

@@ -0,0 +1,22 @@
require 'rake'
require 'rake/testtask'
require 'rake/rdoctask'
desc 'Default: run unit tests.'
task :default => :test
desc 'Test the classic_pagination plugin.'
Rake::TestTask.new(:test) do |t|
t.libs << 'lib'
t.pattern = 'test/**/*_test.rb'
t.verbose = true
end
desc 'Generate documentation for the classic_pagination plugin.'
Rake::RDocTask.new(:rdoc) do |rdoc|
rdoc.rdoc_dir = 'rdoc'
rdoc.title = 'Pagination'
rdoc.options << '--line-numbers' << '--inline-source'
rdoc.rdoc_files.include('README')
rdoc.rdoc_files.include('lib/**/*.rb')
end

View File

@@ -0,0 +1,33 @@
#--
# Copyright (c) 2004-2006 David Heinemeier Hansson
#
# 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.
#++
require 'pagination'
require 'pagination_helper'
ActionController::Base.class_eval do
include ActionController::Pagination
end
ActionView::Base.class_eval do
include ActionView::Helpers::PaginationHelper
end

View File

@@ -0,0 +1 @@
puts "\n\n" + File.read(File.dirname(__FILE__) + '/README')

View File

@@ -0,0 +1,405 @@
module ActionController
# === Action Pack pagination for Active Record collections
#
# The Pagination module aids in the process of paging large collections of
# Active Record objects. It offers macro-style automatic fetching of your
# model for multiple views, or explicit fetching for single actions. And if
# the magic isn't flexible enough for your needs, you can create your own
# paginators with a minimal amount of code.
#
# The Pagination module can handle as much or as little as you wish. In the
# controller, have it automatically query your model for pagination; or,
# if you prefer, create Paginator objects yourself.
#
# Pagination is included automatically for all controllers.
#
# For help rendering pagination links, see
# ActionView::Helpers::PaginationHelper.
#
# ==== Automatic pagination for every action in a controller
#
# class PersonController < ApplicationController
# model :person
#
# paginate :people, :order => 'last_name, first_name',
# :per_page => 20
#
# # ...
# end
#
# Each action in this controller now has access to a <tt>@people</tt>
# instance variable, which is an ordered collection of model objects for the
# current page (at most 20, sorted by last name and first name), and a
# <tt>@person_pages</tt> Paginator instance. The current page is determined
# by the <tt>params[:page]</tt> variable.
#
# ==== Pagination for a single action
#
# def list
# @person_pages, @people =
# paginate :people, :order => 'last_name, first_name'
# end
#
# Like the previous example, but explicitly creates <tt>@person_pages</tt>
# and <tt>@people</tt> for a single action, and uses the default of 10 items
# per page.
#
# ==== Custom/"classic" pagination
#
# def list
# @person_pages = Paginator.new self, Person.count, 10, params[:page]
# @people = Person.find :all, :order => 'last_name, first_name',
# :limit => @person_pages.items_per_page,
# :offset => @person_pages.current.offset
# end
#
# Explicitly creates the paginator from the previous example and uses
# Paginator#to_sql to retrieve <tt>@people</tt> from the model.
#
module Pagination
unless const_defined?(:OPTIONS)
# A hash holding options for controllers using macro-style pagination
OPTIONS = Hash.new
# The default options for pagination
DEFAULT_OPTIONS = {
:class_name => nil,
:singular_name => nil,
:per_page => 10,
:conditions => nil,
:order_by => nil,
:order => nil,
:join => nil,
:joins => nil,
:count => nil,
:include => nil,
:select => nil,
:group => nil,
:parameter => 'page'
}
else
DEFAULT_OPTIONS[:group] = nil
end
def self.included(base) #:nodoc:
super
base.extend(ClassMethods)
end
def self.validate_options!(collection_id, options, in_action) #:nodoc:
options.merge!(DEFAULT_OPTIONS) {|key, old, new| old}
valid_options = DEFAULT_OPTIONS.keys
valid_options << :actions unless in_action
unknown_option_keys = options.keys - valid_options
raise ActionController::ActionControllerError,
"Unknown options: #{unknown_option_keys.join(', ')}" unless
unknown_option_keys.empty?
options[:singular_name] ||= ActiveSupport::Inflector.singularize(collection_id.to_s)
options[:class_name] ||= ActiveSupport::Inflector.camelize(options[:singular_name])
end
# Returns a paginator and a collection of Active Record model instances
# for the paginator's current page. This is designed to be used in a
# single action; to automatically paginate multiple actions, consider
# ClassMethods#paginate.
#
# +options+ are:
# <tt>:singular_name</tt>:: the singular name to use, if it can't be inferred by singularizing the collection name
# <tt>:class_name</tt>:: the class name to use, if it can't be inferred by
# camelizing the singular name
# <tt>:per_page</tt>:: the maximum number of items to include in a
# single page. Defaults to 10
# <tt>:conditions</tt>:: optional conditions passed to Model.find(:all, *params) and
# Model.count
# <tt>:order</tt>:: optional order parameter passed to Model.find(:all, *params)
# <tt>:order_by</tt>:: (deprecated, used :order) optional order parameter passed to Model.find(:all, *params)
# <tt>:joins</tt>:: optional joins parameter passed to Model.find(:all, *params)
# and Model.count
# <tt>:join</tt>:: (deprecated, used :joins or :include) optional join parameter passed to Model.find(:all, *params)
# and Model.count
# <tt>:include</tt>:: optional eager loading parameter passed to Model.find(:all, *params)
# and Model.count
# <tt>:select</tt>:: :select parameter passed to Model.find(:all, *params)
#
# <tt>:count</tt>:: parameter passed as :select option to Model.count(*params)
#
# <tt>:group</tt>:: :group parameter passed to Model.find(:all, *params). It forces the use of DISTINCT instead of plain COUNT to come up with the total number of records
#
def paginate(collection_id, options={})
Pagination.validate_options!(collection_id, options, true)
paginator_and_collection_for(collection_id, options)
end
# These methods become class methods on any controller
module ClassMethods
# Creates a +before_filter+ which automatically paginates an Active
# Record model for all actions in a controller (or certain actions if
# specified with the <tt>:actions</tt> option).
#
# +options+ are the same as PaginationHelper#paginate, with the addition
# of:
# <tt>:actions</tt>:: an array of actions for which the pagination is
# active. Defaults to +nil+ (i.e., every action)
def paginate(collection_id, options={})
Pagination.validate_options!(collection_id, options, false)
module_eval do
before_filter :create_paginators_and_retrieve_collections
OPTIONS[self] ||= Hash.new
OPTIONS[self][collection_id] = options
end
end
end
def create_paginators_and_retrieve_collections #:nodoc:
Pagination::OPTIONS[self.class].each do |collection_id, options|
next unless options[:actions].include? action_name if
options[:actions]
paginator, collection =
paginator_and_collection_for(collection_id, options)
paginator_name = "@#{options[:singular_name]}_pages"
self.instance_variable_set(paginator_name, paginator)
collection_name = "@#{collection_id.to_s}"
self.instance_variable_set(collection_name, collection)
end
end
# Returns the total number of items in the collection to be paginated for
# the +model+ and given +conditions+. Override this method to implement a
# custom counter.
def count_collection_for_pagination(model, options)
model.count(:conditions => options[:conditions],
:joins => options[:join] || options[:joins],
:include => options[:include],
:select => (options[:group] ? "DISTINCT #{options[:group]}" : options[:count]))
end
# Returns a collection of items for the given +model+ and +options[conditions]+,
# ordered by +options[order]+, for the current page in the given +paginator+.
# Override this method to implement a custom finder.
def find_collection_for_pagination(model, options, paginator)
model.find(:all, :conditions => options[:conditions],
:order => options[:order_by] || options[:order],
:joins => options[:join] || options[:joins], :include => options[:include],
:select => options[:select], :limit => options[:per_page],
:group => options[:group], :offset => paginator.current.offset)
end
protected :create_paginators_and_retrieve_collections,
:count_collection_for_pagination,
:find_collection_for_pagination
def paginator_and_collection_for(collection_id, options) #:nodoc:
klass = options[:class_name].constantize
page = params[options[:parameter]]
count = count_collection_for_pagination(klass, options)
paginator = Paginator.new(self, count, options[:per_page], page)
collection = find_collection_for_pagination(klass, options, paginator)
return paginator, collection
end
private :paginator_and_collection_for
# A class representing a paginator for an Active Record collection.
class Paginator
include Enumerable
# Creates a new Paginator on the given +controller+ for a set of items
# of size +item_count+ and having +items_per_page+ items per page.
# Raises ArgumentError if items_per_page is out of bounds (i.e., less
# than or equal to zero). The page CGI parameter for links defaults to
# "page" and can be overridden with +page_parameter+.
def initialize(controller, item_count, items_per_page, current_page=1)
raise ArgumentError, 'must have at least one item per page' if
items_per_page <= 0
@controller = controller
@item_count = item_count || 0
@items_per_page = items_per_page
@pages = {}
self.current_page = current_page
end
attr_reader :controller, :item_count, :items_per_page
# Sets the current page number of this paginator. If +page+ is a Page
# object, its +number+ attribute is used as the value; if the page does
# not belong to this Paginator, an ArgumentError is raised.
def current_page=(page)
if page.is_a? Page
raise ArgumentError, 'Page/Paginator mismatch' unless
page.paginator == self
end
page = page.to_i
@current_page_number = has_page_number?(page) ? page : 1
end
# Returns a Page object representing this paginator's current page.
def current_page
@current_page ||= self[@current_page_number]
end
alias current :current_page
# Returns a new Page representing the first page in this paginator.
def first_page
@first_page ||= self[1]
end
alias first :first_page
# Returns a new Page representing the last page in this paginator.
def last_page
@last_page ||= self[page_count]
end
alias last :last_page
# Returns the number of pages in this paginator.
def page_count
@page_count ||= @item_count.zero? ? 1 :
(q,r=@item_count.divmod(@items_per_page); r==0? q : q+1)
end
alias length :page_count
# Returns true if this paginator contains the page of index +number+.
def has_page_number?(number)
number >= 1 and number <= page_count
end
# Returns a new Page representing the page with the given index
# +number+.
def [](number)
@pages[number] ||= Page.new(self, number)
end
# Successively yields all the paginator's pages to the given block.
def each(&block)
page_count.times do |n|
yield self[n+1]
end
end
# A class representing a single page in a paginator.
class Page
include Comparable
# Creates a new Page for the given +paginator+ with the index
# +number+. If +number+ is not in the range of valid page numbers or
# is not a number at all, it defaults to 1.
def initialize(paginator, number)
@paginator = paginator
@number = number.to_i
@number = 1 unless @paginator.has_page_number? @number
end
attr_reader :paginator, :number
alias to_i :number
# Compares two Page objects and returns true when they represent the
# same page (i.e., their paginators are the same and they have the
# same page number).
def ==(page)
return false if page.nil?
@paginator == page.paginator and
@number == page.number
end
# Compares two Page objects and returns -1 if the left-hand page comes
# before the right-hand page, 0 if the pages are equal, and 1 if the
# left-hand page comes after the right-hand page. Raises ArgumentError
# if the pages do not belong to the same Paginator object.
def <=>(page)
raise ArgumentError unless @paginator == page.paginator
@number <=> page.number
end
# Returns the item offset for the first item in this page.
def offset
@paginator.items_per_page * (@number - 1)
end
# Returns the number of the first item displayed.
def first_item
offset + 1
end
# Returns the number of the last item displayed.
def last_item
[@paginator.items_per_page * @number, @paginator.item_count].min
end
# Returns true if this page is the first page in the paginator.
def first?
self == @paginator.first
end
# Returns true if this page is the last page in the paginator.
def last?
self == @paginator.last
end
# Returns a new Page object representing the page just before this
# page, or nil if this is the first page.
def previous
if first? then nil else @paginator[@number - 1] end
end
# Returns a new Page object representing the page just after this
# page, or nil if this is the last page.
def next
if last? then nil else @paginator[@number + 1] end
end
# Returns a new Window object for this page with the specified
# +padding+.
def window(padding=2)
Window.new(self, padding)
end
# Returns the limit/offset array for this page.
def to_sql
[@paginator.items_per_page, offset]
end
def to_param #:nodoc:
@number.to_s
end
end
# A class for representing ranges around a given page.
class Window
# Creates a new Window object for the given +page+ with the specified
# +padding+.
def initialize(page, padding=2)
@paginator = page.paginator
@page = page
self.padding = padding
end
attr_reader :paginator, :page
# Sets the window's padding (the number of pages on either side of the
# window page).
def padding=(padding)
@padding = padding < 0 ? 0 : padding
# Find the beginning and end pages of the window
@first = @paginator.has_page_number?(@page.number - @padding) ?
@paginator[@page.number - @padding] : @paginator.first
@last = @paginator.has_page_number?(@page.number + @padding) ?
@paginator[@page.number + @padding] : @paginator.last
end
attr_reader :padding, :first, :last
# Returns an array of Page objects in the current window.
def pages
(@first.number..@last.number).to_a.collect! {|n| @paginator[n]}
end
alias to_a :pages
end
end
end
end

View File

@@ -0,0 +1,135 @@
module ActionView
module Helpers
# Provides methods for linking to ActionController::Pagination objects using a simple generator API. You can optionally
# also build your links manually using ActionView::Helpers::AssetHelper#link_to like so:
#
# <%= link_to "Previous page", { :page => paginator.current.previous } if paginator.current.previous %>
# <%= link_to "Next page", { :page => paginator.current.next } if paginator.current.next %>
module PaginationHelper
unless const_defined?(:DEFAULT_OPTIONS)
DEFAULT_OPTIONS = {
:name => :page,
:window_size => 2,
:always_show_anchors => true,
:link_to_current_page => false,
:params => {}
}
end
# Creates a basic HTML link bar for the given +paginator+. Links will be created
# for the next and/or previous page and for a number of other pages around the current
# pages position. The +html_options+ hash is passed to +link_to+ when the links are created.
#
# ==== Options
# <tt>:name</tt>:: the routing name for this paginator
# (defaults to +page+)
# <tt>:prefix</tt>:: prefix for pagination links
# (i.e. Older Pages: 1 2 3 4)
# <tt>:suffix</tt>:: suffix for pagination links
# (i.e. 1 2 3 4 <- Older Pages)
# <tt>:window_size</tt>:: the number of pages to show around
# the current page (defaults to <tt>2</tt>)
# <tt>:always_show_anchors</tt>:: whether or not the first and last
# pages should always be shown
# (defaults to +true+)
# <tt>:link_to_current_page</tt>:: whether or not the current page
# should be linked to (defaults to
# +false+)
# <tt>:params</tt>:: any additional routing parameters
# for page URLs
#
# ==== Examples
# # We'll assume we have a paginator setup in @person_pages...
#
# pagination_links(@person_pages)
# # => 1 <a href="/?page=2/">2</a> <a href="/?page=3/">3</a> ... <a href="/?page=10/">10</a>
#
# pagination_links(@person_pages, :link_to_current_page => true)
# # => <a href="/?page=1/">1</a> <a href="/?page=2/">2</a> <a href="/?page=3/">3</a> ... <a href="/?page=10/">10</a>
#
# pagination_links(@person_pages, :always_show_anchors => false)
# # => 1 <a href="/?page=2/">2</a> <a href="/?page=3/">3</a>
#
# pagination_links(@person_pages, :window_size => 1)
# # => 1 <a href="/?page=2/">2</a> ... <a href="/?page=10/">10</a>
#
# pagination_links(@person_pages, :params => { :viewer => "flash" })
# # => 1 <a href="/?page=2&amp;viewer=flash/">2</a> <a href="/?page=3&amp;viewer=flash/">3</a> ...
# # <a href="/?page=10&amp;viewer=flash/">10</a>
def pagination_links(paginator, options={}, html_options={})
name = options[:name] || DEFAULT_OPTIONS[:name]
params = (options[:params] || DEFAULT_OPTIONS[:params]).clone
prefix = options[:prefix] || ''
suffix = options[:suffix] || ''
pagination_links_each(paginator, options, prefix, suffix) do |n|
params[name] = n
link_to(n.to_s, params, html_options)
end
end
# Iterate through the pages of a given +paginator+, invoking a
# block for each page number that needs to be rendered as a link.
#
# ==== Options
# <tt>:window_size</tt>:: the number of pages to show around
# the current page (defaults to +2+)
# <tt>:always_show_anchors</tt>:: whether or not the first and last
# pages should always be shown
# (defaults to +true+)
# <tt>:link_to_current_page</tt>:: whether or not the current page
# should be linked to (defaults to
# +false+)
#
# ==== Example
# # Turn paginated links into an Ajax call
# pagination_links_each(paginator, page_options) do |link|
# options = { :url => {:action => 'list'}, :update => 'results' }
# html_options = { :href => url_for(:action => 'list') }
#
# link_to_remote(link.to_s, options, html_options)
# end
def pagination_links_each(paginator, options, prefix = nil, suffix = nil)
options = DEFAULT_OPTIONS.merge(options)
link_to_current_page = options[:link_to_current_page]
always_show_anchors = options[:always_show_anchors]
current_page = paginator.current_page
window_pages = current_page.window(options[:window_size]).pages
return if window_pages.length <= 1 unless link_to_current_page
first, last = paginator.first, paginator.last
html = ''
html << prefix if prefix
if always_show_anchors and not (wp_first = window_pages[0]).first?
html << yield(first.number)
html << ' ... ' if wp_first.number - first.number > 1
html << ' '
end
window_pages.each do |page|
if current_page == page && !link_to_current_page
html << page.number.to_s
else
html << yield(page.number)
end
html << ' '
end
if always_show_anchors and not (wp_last = window_pages[-1]).last?
html << ' ... ' if last.number - wp_last.number > 1
html << yield(last.number)
end
html << suffix if suffix
html
end
end # PaginationHelper
end # Helpers
end # ActionView

View File

@@ -0,0 +1,24 @@
thirty_seven_signals:
id: 1
name: 37Signals
rating: 4
TextDrive:
id: 2
name: TextDrive
rating: 4
PlanetArgon:
id: 3
name: Planet Argon
rating: 4
Google:
id: 4
name: Google
rating: 4
Ionist:
id: 5
name: Ioni.st
rating: 4

View File

@@ -0,0 +1,9 @@
class Company < ActiveRecord::Base
attr_protected :rating
set_sequence_name :companies_nonstd_seq
validates_presence_of :name
def validate
errors.add('rating', 'rating should not be 2') if rating == 2
end
end

View File

@@ -0,0 +1,7 @@
class Developer < ActiveRecord::Base
has_and_belongs_to_many :projects
end
class DeVeLoPeR < ActiveRecord::Base
set_table_name "developers"
end

View File

@@ -0,0 +1,21 @@
david:
id: 1
name: David
salary: 80000
jamis:
id: 2
name: Jamis
salary: 150000
<% for digit in 3..10 %>
dev_<%= digit %>:
id: <%= digit %>
name: fixture_<%= digit %>
salary: 100000
<% end %>
poor_jamis:
id: 11
name: Jamis
salary: 9000

View File

@@ -0,0 +1,13 @@
david_action_controller:
developer_id: 1
project_id: 2
joined_on: 2004-10-10
david_active_record:
developer_id: 1
project_id: 1
joined_on: 2004-10-10
jamis_active_record:
developer_id: 2
project_id: 1

View File

@@ -0,0 +1,3 @@
class Project < ActiveRecord::Base
has_and_belongs_to_many :developers, :uniq => true
end

View File

@@ -0,0 +1,7 @@
action_controller:
id: 2
name: Active Controller
active_record:
id: 1
name: Active Record

View File

@@ -0,0 +1,13 @@
witty_retort:
id: 1
topic_id: 1
content: Birdman is better!
created_at: <%= 6.hours.ago.to_s(:db) %>
updated_at: nil
another:
id: 2
topic_id: 2
content: Nuh uh!
created_at: <%= 1.hour.ago.to_s(:db) %>
updated_at: nil

View File

@@ -0,0 +1,5 @@
class Reply < ActiveRecord::Base
belongs_to :topic, :include => [:replies]
validates_presence_of :content
end

View File

@@ -0,0 +1,42 @@
CREATE TABLE 'companies' (
'id' INTEGER PRIMARY KEY NOT NULL,
'name' TEXT DEFAULT NULL,
'rating' INTEGER DEFAULT 1
);
CREATE TABLE 'replies' (
'id' INTEGER PRIMARY KEY NOT NULL,
'content' text,
'created_at' datetime,
'updated_at' datetime,
'topic_id' integer
);
CREATE TABLE 'topics' (
'id' INTEGER PRIMARY KEY NOT NULL,
'title' varchar(255),
'subtitle' varchar(255),
'content' text,
'created_at' datetime,
'updated_at' datetime
);
CREATE TABLE 'developers' (
'id' INTEGER PRIMARY KEY NOT NULL,
'name' TEXT DEFAULT NULL,
'salary' INTEGER DEFAULT 70000,
'created_at' DATETIME DEFAULT NULL,
'updated_at' DATETIME DEFAULT NULL
);
CREATE TABLE 'projects' (
'id' INTEGER PRIMARY KEY NOT NULL,
'name' TEXT DEFAULT NULL
);
CREATE TABLE 'developers_projects' (
'developer_id' INTEGER NOT NULL,
'project_id' INTEGER NOT NULL,
'joined_on' DATE DEFAULT NULL,
'access_level' INTEGER DEFAULT 1
);

View File

@@ -0,0 +1,3 @@
class Topic < ActiveRecord::Base
has_many :replies, :include => [:user], :dependent => :destroy
end

Some files were not shown because too many files have changed in this diff Show More